49 files changed
+1128
-78
android/app/src/main/AndroidManifest.xml
+3
@@ -117,6 +117,9 @@
117
118
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
119
<queries>
120
+ <intent>
121
+ <action android:name="android.intent.action.TTS_SERVICE" />
122
+ </intent>
123
<intent>
124
<action android:name="android.intent.action.PROCESS_TEXT"/>
125
<data android:mimeType="text/plain"/>
docs/validation/issue-78-reasoning-and-tts.md
new
+43
@@ -0,0 +1,43 @@
1
+# Issue #78 and imported-model/TTS review fixes
2
+
3
+Validated on 2026-09-08 against LocalMind 1.7.9 (84).
4
+
5
+## Changes
6
+
7
+- Preserve the existing Ollama fix that removes trailing empty assistant placeholders and sends native `think` controls.
8
+- Keep native `message.thinking` and `message.content` authoritative and separate, including when the thinking field is empty.
9
+- Handle legacy leading `<think>...</think>` content across stream boundaries. Tags later in an ordinary answer are preserved. Unclosed reasoning remains reasoning rather than being guessed into a final answer.
10
+- Preserve Ollama's final frame without a trailing newline, including UTF-8 text split across network chunks.
11
+- Add saved Model default / On / Off thinking controls to imported GGUF model cards in Local Models. Apply changes at the next generation, including when the model is already loaded. Explicit Off reaches llama.cpp through llamadart's `enableThinking` option. The model's template must support the option.
12
+- Preserve the system TTS engine's voice/language instead of forcing en-US. Follow changes to Android's default TTS engine, await engine initialization, surface failed playback, and avoid starting speech after cancellation during setup.
13
+- Declare Android TTS service discovery. Translate the new model controls into every supported locale.
14
+
15
+Reasoning auto-collapse continues to respect the user's existing setting and manual expand/collapse choices.
16
+
17
+## Automated verification
18
+
19
+- `fvm flutter test`: 382 passed, one environment-dependent test skipped.
20
+- `fvm flutter --suppress-analytics analyze`: no issues.
21
+- `fvm flutter --suppress-analytics build apk --debug --target-platform android-arm64`: succeeded.
22
+- `git diff --check`: clean.
23
+
24
+Regression coverage includes native/legacy Ollama channel separation, split tags, UTF-8 boundaries, final frames without newlines, preference persistence and UI editing, actual forwarding to the inference API, default-engine changes, and TTS initialization failures.
25
+
26
+## Android runtime verification
27
+
28
+Installed the final debug APK on the Pixel 9 Pro Android 16 emulator and verified successful startup. The merged manifest contains `android.intent.action.TTS_SERVICE`.
29
+
30
+Installed the Sherpa 1.13.7 arm64 English Piper lessac-low engine linked from the [official Sherpa APK catalog](https://k2-fsa.github.io/sherpa/onnx/tts/apk-engine.html). Selected it as Android's system TTS engine, then used LocalMind's existing Text To Speech → Speak sample.
31
+
32
+Native logs confirmed:
33
+
34
+- Binding to `com.k2fsa.sherpa.onnx.tts.engine`.
35
+- Sherpa received and synthesized the sample text.
36
+- LocalMind's utterance started at 21:49:23 and completed at 21:49:31.
37
+- Restoring the original system engine without restarting LocalMind bound to `com.google.android.tts`; the next sample started at 21:50:33 and completed at 21:50:41.
38
+
39
+The emulator was launched without host audio; this verifies synthesis and playback completion callbacks, not subjective audio quality. The temporary system-engine override was removed after testing.
40
+
41
+## Remaining environment limits
42
+
43
+The reporter's remote Ollama server and qwen3.5:9b model were not available for a live reproduction. Ollama behavior is verified with captured-request and streaming regression tests. Android 12 hardware was not available; the device smoke test used Android 16. This work does not publish a store release or modify the server's model template.
lib/features/chat/data/chat_service.dart
+78
-53
@@ -1,3 +1,4 @@
1
+import 'ollama_reasoning_decoder.dart';
2
import 'dart:async';
3
import 'dart:convert';
4
import 'dart:io';
@@ -915,6 +916,8 @@ class OllamaChatService implements ChatService {
916
}) async* {
917
_cancelToken = CancelToken();
918
final toolAdapter = OllamaToolAdapter();
919
+ final contentDecoder = OllamaReasoningDecoder();
920
+ var hasNativeThinking = false;
921
922
final apiMessages = <Map<String, dynamic>>[];
923
for (final message in messages) {
@@ -992,70 +995,83 @@ class OllamaChatService implements ChatService {
995
return;
996
}
997
995
- final stream = responseBody.stream.cast<List<int>>().transform(
996
- utf8.decoder,
997
- );
998
- String buffer = '';
998
+ final lines = responseBody.stream
999
+ .cast<List<int>>()
1000
+ .transform(utf8.decoder)
1001
+ .transform(const LineSplitter());
1002
1000
- await for (final chunk in stream) {
1001
- buffer += chunk;
1002
- final lines = buffer.split('\n');
1003
- buffer = lines.removeLast();
1003
+ await for (final line in lines) {
1004
+ if (line.isNotEmpty) {
1005
+ try {
1006
+ final json = jsonDecode(line) as Map<String, dynamic>;
1007
+ toolAdapter.consumeDynamicChunk(json);
1008
+
1009
+ // Ollama surfaces mid-stream errors as a top-level `error`
1010
+ // field, often alongside HTTP 200. Surface them so the UI
1011
+ // shows something more specific than the generic
1012
+ // "unknown error" fallback when — for instance — the
1013
+ // selected model doesn't actually support images.
1014
+ final errorField = json['error'];
1015
+ if (errorField != null) {
1016
+ yield ChatResponse(
1017
+ type: ChatResponseType.error,
1018
+ content: _formatOllamaErrorContent(errorField),
1019
+ );
1020
+ return;
1021
+ }
1022
1005
- for (final line in lines) {
1006
- if (line.isNotEmpty) {
1007
- try {
1008
- final json = jsonDecode(line) as Map<String, dynamic>;
1009
- toolAdapter.consumeDynamicChunk(json);
1010
-
1011
- // Ollama surfaces mid-stream errors as a top-level `error`
1012
- // field, often alongside HTTP 200. Surface them so the UI
1013
- // shows something more specific than the generic
1014
- // "unknown error" fallback when — for instance — the
1015
- // selected model doesn't actually support images.
1016
- final errorField = json['error'];
1017
- if (errorField != null) {
1023
+ final message = json['message'];
1024
+ if (message != null && message is Map<String, dynamic>) {
1025
+ final content = message['content'] as String?;
1026
+ final thinking = message['thinking'] as String?;
1027
+ hasNativeThinking = hasNativeThinking || thinking != null;
1028
+
1029
+ if (thinking != null && thinking.isNotEmpty) {
1030
yield ChatResponse(
1019
- type: ChatResponseType.error,
1020
- content: _formatOllamaErrorContent(errorField),
1031
+ type: ChatResponseType.reasoning,
1032
+ reasoningContent: thinking,
1033
);
1022
- return;
1034
}
1024
-
1025
- final message = json['message'];
1026
- if (message != null && message is Map<String, dynamic>) {
1027
- final content = message['content'] as String?;
1028
- final thinking = message['thinking'] as String?;
1029
-
1030
- if (thinking != null && thinking.isNotEmpty) {
1031
- yield ChatResponse(
1032
- type: ChatResponseType.reasoning,
1033
- reasoningContent: thinking,
1034
- );
1035
- }
1036
- if (content != null && content.isNotEmpty) {
1035
+ if (content != null && content.isNotEmpty) {
1036
+ for (final part in contentDecoder.add(
1037
+ content,
1038
+ nativeThinking: hasNativeThinking,
1039
+ )) {
1040
yield ChatResponse(
1038
- type: ChatResponseType.message,
1039
- content: content,
1041
+ type: part.isReasoning
1042
+ ? ChatResponseType.reasoning
1043
+ : ChatResponseType.message,
1044
+ content: part.isReasoning ? null : part.text,
1045
+ reasoningContent: part.isReasoning ? part.text : null,
1046
);
1047
}
1048
}
1043
- if (json['done'] == true) {
1044
- for (final call in toolAdapter.takeCompletedCalls()) {
1045
- yield ChatResponse(
1046
- type: ChatResponseType.toolCall,
1047
- toolCall: ToolCallData(
1048
- tool: call.name,
1049
- arguments: call.arguments,
1050
- ),
1051
- );
1052
- }
1053
- yield const ChatResponse(type: ChatResponseType.done);
1054
- return;
1049
+ }
1050
+ if (json['done'] == true) {
1051
+ for (final part in contentDecoder.flush()) {
1052
+ yield ChatResponse(
1053
+ type: part.isReasoning
1054
+ ? ChatResponseType.reasoning
1055
+ : ChatResponseType.message,
1056
+ content: part.isReasoning ? null : part.text,
1057
+ reasoningContent: part.isReasoning ? part.text : null,
1058
+ );
1059
}
1056
- } catch (e) {
1057
- Log.error('Ollama chunk parsing error: $e');
1060
+
1061
+ for (final call in toolAdapter.takeCompletedCalls()) {
1062
+ yield ChatResponse(
1063
+ type: ChatResponseType.toolCall,
1064
+ toolCall: ToolCallData(
1065
+ tool: call.name,
1066
+ arguments: call.arguments,
1067
+ ),
1068
+ );
1069
+ }
1070
+ yield const ChatResponse(type: ChatResponseType.done);
1071
+ return;
1072
}
1073
+ } catch (e) {
1074
+ Log.error('Ollama chunk parsing error: $e');
1075
}
1076
}
1077
}
@@ -1067,6 +1083,15 @@ class OllamaChatService implements ChatService {
1083
yield ChatResponse(type: ChatResponseType.error, content: content);
1084
return;
1085
}
1086
+ for (final part in contentDecoder.flush()) {
1087
+ yield ChatResponse(
1088
+ type: part.isReasoning
1089
+ ? ChatResponseType.reasoning
1090
+ : ChatResponseType.message,
1091
+ content: part.isReasoning ? null : part.text,
1092
+ reasoningContent: part.isReasoning ? part.text : null,
1093
+ );
1094
+ }
1095
yield const ChatResponse(type: ChatResponseType.done);
1096
}
1097
lib/features/chat/data/ollama_reasoning_decoder.dart
new
+57
@@ -0,0 +1,57 @@
1
+typedef OllamaTextPart = ({String text, bool isReasoning});
2
+
3
+/// Legacy Ollama models may return a leading `<think>` block in content.
4
+/// Native `message.thinking` takes precedence. Only a leading block is parsed,
5
+/// so tags quoted later in an ordinary answer remain ordinary answer text.
6
+class OllamaReasoningDecoder {
7
+ String _pending = '';
8
+ bool _checkedPrefix = false;
9
+ bool _inReasoning = false;
10
+
11
+ List<OllamaTextPart> add(String text, {required bool nativeThinking}) {
12
+ _pending += text;
13
+ if (nativeThinking) {
14
+ _checkedPrefix = true;
15
+ _inReasoning = false;
16
+ return flush();
17
+ }
18
+ if (!_checkedPrefix) {
19
+ final trimmed = _pending.trimLeft();
20
+ const opening = '<think>';
21
+ if (opening.startsWith(trimmed) && trimmed != opening) return [];
22
+ _checkedPrefix = true;
23
+ if (trimmed.startsWith(opening)) {
24
+ _inReasoning = true;
25
+ _pending = trimmed.substring(opening.length);
26
+ }
27
+ }
28
+ if (!_inReasoning) return flush();
29
+
30
+ const closing = '</think>';
31
+ final end = _pending.indexOf(closing);
32
+ if (end >= 0) {
33
+ final reasoning = _pending.substring(0, end);
34
+ final answer = _pending.substring(end + closing.length);
35
+ _pending = '';
36
+ _inReasoning = false;
37
+ return [
38
+ if (reasoning.isNotEmpty) (text: reasoning, isReasoning: true),
39
+ if (answer.isNotEmpty) (text: answer, isReasoning: false),
40
+ ];
41
+ }
42
+ // Keep only a possible partial closing tag across stream boundaries.
43
+ var keep = 0;
44
+ for (var length = 1; length < closing.length; length++) {
45
+ if (_pending.endsWith(closing.substring(0, length))) keep = length;
46
+ }
47
+ final ready = _pending.substring(0, _pending.length - keep);
48
+ _pending = _pending.substring(_pending.length - keep);
49
+ return [if (ready.isNotEmpty) (text: ready, isReasoning: true)];
50
+ }
51
+
52
+ List<OllamaTextPart> flush() {
53
+ final text = _pending;
54
+ _pending = '';
55
+ return [if (text.isNotEmpty) (text: text, isReasoning: _inReasoning)];
56
+ }
57
+}
lib/features/on_device/components/model_card.dart
+2
@@ -1,3 +1,4 @@
1
+import '../views/components/imported_model_reasoning_setting.dart';
2
import 'package:flutter_gemma_builtin_ai/flutter_gemma_builtin_ai.dart';
3
import 'package:hugeicons/hugeicons.dart';
4
import 'dart:io';
@@ -91,6 +92,7 @@ class ModelCard extends ConsumerWidget {
92
),
93
const SizedBox(height: 8),
94
_CapabilityChips(model: model, theme: theme),
95
+ if (model.isImported) ImportedModelReasoningSetting(model: model),
96
const SizedBox(height: 8),
97
_ModelMetaRow(model: model, theme: theme, l10n: l10n),
98
const SizedBox(height: 8),
lib/features/on_device/data/imported_gguf_model_repository.dart
+19
-1
@@ -17,6 +17,7 @@ class ImportedGgufModelMetadata {
17
final DateTime importedAt;
18
final OnDeviceImportedSource source;
19
final String? sourceUrl;
20
+ final bool? reasoningEnabled;
21
22
const ImportedGgufModelMetadata({
23
required this.id,
@@ -26,6 +27,7 @@ class ImportedGgufModelMetadata {
27
required this.importedAt,
28
required this.source,
29
this.sourceUrl,
30
+ this.reasoningEnabled,
31
});
32
33
String get fileName => p.basename(filePath);
@@ -67,6 +69,7 @@ class ImportedGgufModelMetadata {
69
importedAt: importedAt,
70
isImported: true,
71
importedSource: source,
72
+ reasoningEnabled: reasoningEnabled,
73
);
74
}
75
@@ -79,6 +82,7 @@ class ImportedGgufModelMetadata {
82
'importedAt': importedAt.toIso8601String(),
83
'source': source.name,
84
'sourceUrl': sourceUrl,
85
+ 'reasoningEnabled': reasoningEnabled,
86
};
87
}
88
@@ -91,6 +95,7 @@ class ImportedGgufModelMetadata {
95
importedAt: DateTime.parse(json['importedAt'] as String),
96
source: _sourceFromJson(json['source']),
97
sourceUrl: json['sourceUrl'] as String?,
98
+ reasoningEnabled: json['reasoningEnabled'] as bool?,
99
);
100
}
101
@@ -129,6 +134,17 @@ class ImportedGgufModelRepository {
134
.toList();
135
}
136
137
+ Future<void> updateReasoning(String modelId, bool? enabled) async {
138
+ final models = load();
139
+ final index = models.indexWhere((model) => model.id == modelId);
140
+ if (index < 0) throw StateError('Imported model no longer exists');
141
+ models[index] = ImportedGgufModelMetadata.fromJson({
142
+ ...models[index].toJson(),
143
+ 'reasoningEnabled': enabled,
144
+ });
145
+ await saveAll(models);
146
+ }
147
+
148
Future<List<ImportedGgufModelMetadata>> loadExisting() async {
149
final models = load();
150
final existing = <ImportedGgufModelMetadata>[];
@@ -283,7 +299,9 @@ class ImportedGgufModelRepository {
299
300
Future<void> saveAll(List<ImportedGgufModelMetadata> models) async {
301
final encoded = json.encode(models.map((m) => m.toJson()).toList());
286
- await _prefs.setString(_storageKey, encoded);
302
+ if (!await _prefs.setString(_storageKey, encoded)) {
303
+ throw StateError('Could not save imported models');
304
+ }
305
}
306
307
Future<Directory> _modelsDirectory() async {
lib/features/on_device/data/models/on_device_model.dart
+2
@@ -21,6 +21,7 @@ class OnDeviceModel {
21
final String bestFor;
22
final bool supportsFunctionCalling;
23
final bool supportsThinking;
24
+ final bool? reasoningEnabled;
25
final bool supportsVision;
26
final String languagesLabel;
27
final String? backendNote;
@@ -46,6 +47,7 @@ class OnDeviceModel {
47
required this.bestFor,
48
this.supportsFunctionCalling = false,
49
this.supportsThinking = false,
50
+ this.reasoningEnabled,
51
this.supportsVision = false,
52
required this.languagesLabel,
53
this.backendNote,
lib/features/on_device/data/on_device_llama_service.dart
+13
-1
@@ -13,6 +13,13 @@ import '../../servers/data/models/server.dart';
13
import 'models/on_device_model.dart';
14
15
class OnDeviceLlamaService {
16
+ OnDeviceLlamaService({this.reasoningPreference, this.engineFactory});
17
+
18
+ final llama.LlamaEngine Function()? engineFactory;
19
+
20
+ /// Read at generation time so edits also apply to a model already in memory.
21
+ final bool? Function(String modelId)? reasoningPreference;
22
+
23
llama.LlamaEngine? _engine;
24
llama.ChatSession? _session;
25
String? _currentModelId;
@@ -55,7 +62,8 @@ class OnDeviceLlamaService {
62
'Loading GGUF model ${model.id} from ${model.localPath} '
63
'(context=$contextLength, gpuLayers=$gpuLayers)',
64
);
58
- final engine = llama.LlamaEngine(llama.LlamaBackend());
65
+ final engine =
66
+ engineFactory?.call() ?? llama.LlamaEngine(llama.LlamaBackend());
67
await engine.loadModel(
68
model.localPath!,
69
modelParams: llama.ModelParams(
@@ -174,6 +182,10 @@ class OnDeviceLlamaService {
182
try {
183
await for (final chunk in _session!.create(
184
[llama.LlamaTextContent(text)],
185
+ enableThinking:
186
+ reasoningPreference?.call(modelId) ??
187
+ params.reasoningEnabled ??
188
+ true,
189
params: llama.GenerationParams(
190
maxTokens: params.maxTokens,
191
temp: params.temperature,
lib/features/on_device/providers/on_device_providers.dart
+13
-1
@@ -35,7 +35,13 @@ final onDeviceGemmaServiceProvider = Provider<OnDeviceGemmaService>((ref) {
35
});
36
37
final onDeviceLlamaServiceProvider = Provider<OnDeviceLlamaService>((ref) {
38
- final service = OnDeviceLlamaService();
38
+ final service = OnDeviceLlamaService(
39
+ reasoningPreference: (modelId) => ref
40
+ .read(importedGgufModelsProvider)
41
+ .where((model) => model.id == modelId)
42
+ .firstOrNull
43
+ ?.reasoningEnabled,
44
+ );
45
ref.onDispose(() => service.dispose());
46
return service;
47
});
@@ -247,6 +253,12 @@ class ImportedGgufModelsNotifier extends Notifier<List<OnDeviceModel>> {
253
return model;
254
}
255
256
+ Future<void> updateReasoning(String modelId, bool? enabled) async {
257
+ await _repository.updateReasoning(modelId, enabled);
258
+ if (!ref.mounted) return;
259
+ state = _repository.load().map((model) => model.toOnDeviceModel()).toList();
260
+ }
261
+
262
Future<void> deleteModel(String modelId) async {
263
await _repository.delete(modelId);
264
if (!ref.mounted) return;
lib/features/on_device/views/components/imported_model_reasoning_setting.dart
new
+90
@@ -0,0 +1,90 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+
4
+import '../../../../l10n/app_localizations.dart';
5
+import '../../data/models/on_device_model.dart';
6
+import '../../providers/on_device_providers.dart';
7
+
8
+/// Persisted generation control, available even when GGUF metadata does not
9
+/// advertise thinking support. Changes apply to the next response.
10
+class ImportedModelReasoningSetting extends ConsumerStatefulWidget {
11
+ const ImportedModelReasoningSetting({super.key, required this.model});
12
+
13
+ final OnDeviceModel model;
14
+
15
+ @override
16
+ ConsumerState<ImportedModelReasoningSetting> createState() =>
17
+ _ImportedModelReasoningSettingState();
18
+}
19
+
20
+class _ImportedModelReasoningSettingState
21
+ extends ConsumerState<ImportedModelReasoningSetting> {
22
+ bool _saving = false;
23
+ String? _error;
24
+
25
+ Future<void> _save(String? value) async {
26
+ if (value == null) return;
27
+ setState(() {
28
+ _saving = true;
29
+ _error = null;
30
+ });
31
+ try {
32
+ await ref
33
+ .read(importedGgufModelsProvider.notifier)
34
+ .updateReasoning(
35
+ widget.model.id,
36
+ value == 'default' ? null : value == 'on',
37
+ );
38
+ } catch (_) {
39
+ if (mounted) {
40
+ _error = AppLocalizations.of(context)!.model_reasoning_save_error;
41
+ }
42
+ } finally {
43
+ if (mounted) setState(() => _saving = false);
44
+ }
45
+ }
46
+
47
+ @override
48
+ Widget build(BuildContext context) {
49
+ final l10n = AppLocalizations.of(context)!;
50
+ return Padding(
51
+ padding: const EdgeInsets.only(top: 12),
52
+ child: Column(
53
+ crossAxisAlignment: CrossAxisAlignment.start,
54
+ children: [
55
+ DropdownButtonFormField<String>(
56
+ key: ValueKey('${widget.model.reasoningEnabled}-$_saving-$_error'),
57
+ initialValue: switch (widget.model.reasoningEnabled) {
58
+ true => 'on',
59
+ false => 'off',
60
+ null => 'default',
61
+ },
62
+ isExpanded: true,
63
+ decoration: InputDecoration(
64
+ labelText: l10n.thinking_mode_title,
65
+ helperText: l10n.model_reasoning_help,
66
+ helperMaxLines: 3,
67
+ errorText: _error,
68
+ errorMaxLines: 2,
69
+ ),
70
+ items: [
71
+ DropdownMenuItem(
72
+ value: 'default',
73
+ child: Text(l10n.model_reasoning_default),
74
+ ),
75
+ DropdownMenuItem(
76
+ value: 'on',
77
+ child: Text(l10n.model_reasoning_on),
78
+ ),
79
+ DropdownMenuItem(
80
+ value: 'off',
81
+ child: Text(l10n.reasoning_effort_off),
82
+ ),
83
+ ],
84
+ onChanged: _saving ? null : _save,
85
+ ),
86
+ ],
87
+ ),
88
+ );
89
+ }
90
+}
lib/features/tts/data/system_tts_configuration.dart
new
+17
@@ -0,0 +1,17 @@
1
+import 'package:flutter_tts/flutter_tts.dart';
2
+
3
+/// Keeps the app aligned with Android's chosen TTS engine. Each engine owns
4
+/// its default voice/language; forcing en-US can disable third-party voices.
5
+class SystemTtsConfiguration {
6
+ String? _engine;
7
+
8
+ Future<void> prepare(FlutterTts tts, {required bool isAndroid}) async {
9
+ if (!isAndroid) return;
10
+ final selected = await tts.getDefaultEngine;
11
+ if (selected is! String || selected.trim().isEmpty) return;
12
+ if (selected == _engine) return;
13
+ // setEngine completes after native initialization and reports failures.
14
+ await tts.setEngine(selected);
15
+ _engine = selected;
16
+ }
17
+}
lib/features/tts/providers/tts_providers.dart
+13
-2
@@ -17,6 +17,7 @@ import '../../../core/utils/safe_file_picker.dart';
17
import '../utils/tts_text_processor.dart';
18
import 'tts_model_providers.dart';
19
import '../data/piper_tts_model.dart';
20
+import '../data/system_tts_configuration.dart';
21
import 'tts_worker.dart';
22
import '../utils/wav_merge.dart';
23
@@ -123,6 +124,7 @@ class TtsState {
124
125
class TtsNotifier extends Notifier<TtsState> {
126
FlutterTts? _flutterTts;
127
+ final _systemTtsConfiguration = SystemTtsConfiguration();
128
final AudioPlayer _player = AudioPlayer();
129
bool _isPlayerDisposed = false;
130
bool _isStopping = false;
@@ -728,7 +730,11 @@ class TtsNotifier extends Notifier<TtsState> {
730
spokenCharOffset: 0,
731
);
732
try {
731
- await _flutterTts!.setLanguage('en-US');
733
+ await _systemTtsConfiguration.prepare(
734
+ _flutterTts!,
735
+ isAndroid: Platform.isAndroid,
736
+ );
737
+ if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
738
await _applySystemSpeechRate();
739
_flutterTts!.setStartHandler(() {
740
if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
@@ -786,10 +792,15 @@ class TtsNotifier extends Notifier<TtsState> {
792
if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
793
});
794
await _flutterTts!.awaitSpeakCompletion(true);
795
+ if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
796
_systemSpeakStart = DateTime.now();
797
_startSystemProgressTimer(speakGeneration);
791
- await _flutterTts!.speak(text);
798
+ final result = await _flutterTts!.speak(text);
799
+ if (result == 0 || result == -1) {
800
+ throw StateError('The selected TTS engine could not speak');
801
+ }
802
} catch (e) {
803
+ if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
804
_resetSystemSeekState();
805
state = state.copyWith(
806
isSpeaking: false,
lib/l10n/app_ar.arb
+5
-2
@@ -922,6 +922,9 @@
922
"file_explorer_not_found": "لم يتم العثور على متصفح ملفات. يرجى التأكد من تثبيت وتفعيل تطبيق إدارة الملفات على جهازك.",
923
"export_data_failed": "فشل تصدير النسخة الاحتياطية: {error}",
924
"file_pick_failed": "فشل اختيار الملف: {error}",
925
- "image_pick_failed": "فشل اختيار الصورة: {error}"
925
+ "image_pick_failed": "فشل اختيار الصورة: {error}",
926
+ "model_reasoning_default": "الإعداد الافتراضي للنموذج",
927
+ "model_reasoning_on": "تشغيل",
928
+ "model_reasoning_help": "يُطبّق على الرد التالي. يؤدي الإيقاف إلى تعطيل التفكير إذا كان قالب المحادثة الخاص بالنموذج يدعم ذلك.",
929
+ "model_reasoning_save_error": "تعذّر حفظ وضع التفكير. يُرجى المحاولة مرة أخرى."
930
}
927
-
lib/l10n/app_bn.arb
+5
-1
@@ -918,5 +918,9 @@
918
"settings_concise_voice_responses_desc": "ভয়েস মোডে মডেলের উত্তর সংক্ষেপে (১টি ছোট অনুচ্ছেদ) রাখুন এবং ফলো-আপ প্রশ্ন করুন।",
919
"s3_connection_succeeded": "S3 সংযোগ সফল হয়েছে।",
920
"failed_to_open_url": "ইউআরএল খুলতে ব্যর্থ হয়েছে: {error}",
921
- "failed_to_copy": "কপি করতে ব্যর্থ হয়েছে: {error}"
921
+ "failed_to_copy": "কপি করতে ব্যর্থ হয়েছে: {error}",
922
+ "model_reasoning_default": "মডেলের ডিফল্ট",
923
+ "model_reasoning_on": "চালু",
924
+ "model_reasoning_help": "পরবর্তী উত্তরে প্রযোজ্য হবে। মডেলের চ্যাট টেমপ্লেট সমর্থন করলে বন্ধ করলে চিন্তন বন্ধ হবে।",
925
+ "model_reasoning_save_error": "চিন্তন মোড সংরক্ষণ করা যায়নি। আবার চেষ্টা করুন।"
926
}
lib/l10n/app_de.arb
+5
-1
@@ -915,5 +915,9 @@
915
"settings_concise_voice_responses_desc": "Halten Sie LLM-Antworten im Sprachmodus kurz (1 kurzer Absatz) und stellen Sie Nachfragen.",
916
"s3_connection_succeeded": "S3-Verbindung erfolgreich.",
917
"failed_to_open_url": "URL konnte nicht geöffnet werden: {error}",
918
- "failed_to_copy": "Kopieren fehlgeschlagen: {error}"
918
+ "failed_to_copy": "Kopieren fehlgeschlagen: {error}",
919
+ "model_reasoning_default": "Modellstandard",
920
+ "model_reasoning_on": "Ein",
921
+ "model_reasoning_help": "Gilt für die nächste Antwort. Aus deaktiviert das Denken, sofern die Chatvorlage des Modells dies unterstützt.",
922
+ "model_reasoning_save_error": "Denkmodus konnte nicht gespeichert werden. Bitte erneut versuchen."
923
}
lib/l10n/app_en.arb
+5
-2
@@ -3396,6 +3396,9 @@
3396
"builtin_ai_enable": "Enable",
3397
"@builtin_ai_enable": {
3398
"description": "Button label to enable or initialize built-in on-device AI"
3399
- }
3399
+ },
3400
+ "model_reasoning_default": "Model default",
3401
+ "model_reasoning_on": "On",
3402
+ "model_reasoning_help": "Applies to the next response. Off disables thinking when supported by the model’s chat template.",
3403
+ "model_reasoning_save_error": "Could not save thinking mode. Please try again."
3404
}
3401
-
lib/l10n/app_es.arb
+5
-1
@@ -918,5 +918,9 @@
918
"settings_concise_voice_responses_desc": "Mantén las respuestas del modelo breves (1 párrafo corto) y haz preguntas de seguimiento en modo voz.",
919
"s3_connection_succeeded": "Conexión a S3 realizada con éxito.",
920
"failed_to_open_url": "Error al abrir la URL: {error}",
921
- "failed_to_copy": "Error al copiar: {error}"
921
+ "failed_to_copy": "Error al copiar: {error}",
922
+ "model_reasoning_default": "Predeterminado del modelo",
923
+ "model_reasoning_on": "Activado",
924
+ "model_reasoning_help": "Se aplica a la siguiente respuesta. Desactivado impide el razonamiento si la plantilla de chat del modelo lo permite.",
925
+ "model_reasoning_save_error": "No se pudo guardar el modo de razonamiento. Inténtalo de nuevo."
926
}
lib/l10n/app_fr.arb
+5
-1
@@ -915,5 +915,9 @@
915
"settings_concise_voice_responses_desc": "Conservez les réponses du LLM concises (1 court paragraphe) et posez des questions de suivi en mode vocal.",
916
"s3_connection_succeeded": "Connexion S3 réussie.",
917
"failed_to_open_url": "Échec de l'ouverture de l'URL : {error}",
918
- "failed_to_copy": "Échec de la copie : {error}"
918
+ "failed_to_copy": "Échec de la copie : {error}",
919
+ "model_reasoning_default": "Par défaut du modèle",
920
+ "model_reasoning_on": "Activé",
921
+ "model_reasoning_help": "S’applique à la prochaine réponse. Désactivé coupe le raisonnement si le modèle de conversation le permet.",
922
+ "model_reasoning_save_error": "Impossible d’enregistrer le mode de réflexion. Veuillez réessayer."
923
}
lib/l10n/app_hi.arb
+5
-1
@@ -918,5 +918,9 @@
918
"settings_concise_voice_responses_desc": "वॉयस मोड में LLM उत्तरों को संक्षिप्त (1 छोटा अनुच्छेद) रखें और अनुवर्ती प्रश्न पूछें।",
919
"s3_connection_succeeded": "S3 कनेक्शन सफल रहा।",
920
"failed_to_open_url": "URL खोलने में विफल: {error}",
921
- "failed_to_copy": "कॉपी करने में विफल: {error}"
921
+ "failed_to_copy": "कॉपी करने में विफल: {error}",
922
+ "model_reasoning_default": "मॉडल का डिफ़ॉल्ट",
923
+ "model_reasoning_on": "चालू",
924
+ "model_reasoning_help": "अगले उत्तर पर लागू होगा। बंद करने से चिंतन अक्षम होगा, यदि मॉडल का चैट टेम्पलेट इसका समर्थन करता है।",
925
+ "model_reasoning_save_error": "चिंतन मोड सहेजा नहीं जा सका। फिर से कोशिश करें।"
926
}
lib/l10n/app_it.arb
+5
-1
@@ -918,5 +918,9 @@
918
"settings_concise_voice_responses_desc": "Mantieni le risposte LLM brevi (1 breve paragrafo) e fai domande di approfondimento in modalità vocale.",
919
"s3_connection_succeeded": "Connessione S3 riuscita.",
920
"failed_to_open_url": "Impossibile aprire l'URL: {error}",
921
- "failed_to_copy": "Copia non riuscita: {error}"
921
+ "failed_to_copy": "Copia non riuscita: {error}",
922
+ "model_reasoning_default": "Predefinito del modello",
923
+ "model_reasoning_on": "Attivo",
924
+ "model_reasoning_help": "Si applica alla prossima risposta. Disattivato impedisce il ragionamento se il template di chat del modello lo supporta.",
925
+ "model_reasoning_save_error": "Impossibile salvare la modalità di ragionamento. Riprova."
926
}
lib/l10n/app_ja.arb
+5
-1
@@ -918,5 +918,9 @@
918
"settings_concise_voice_responses_desc": "音声モードでのLLMの応答を短く(短い1段落)保ち、フォローアップの質問を行います。",
919
"s3_connection_succeeded": "S3 接続に成功しました。",
920
"failed_to_open_url": "URL を開くことができませんでした: {error}",
921
- "failed_to_copy": "コピーに失敗しました: {error}"
921
+ "failed_to_copy": "コピーに失敗しました: {error}",
922
+ "model_reasoning_default": "モデルのデフォルト",
923
+ "model_reasoning_on": "オン",
924
+ "model_reasoning_help": "次の応答に適用されます。モデルのチャットテンプレートが対応している場合、オフにすると思考を無効にします。",
925
+ "model_reasoning_save_error": "思考モードを保存できませんでした。もう一度お試しください。"
926
}
lib/l10n/app_ko.arb
+6
-2
@@ -929,5 +929,9 @@
929
"builtin_ai_not_supported": "내장 AI는 이 장치에서 지원되지 않습니다.",
930
"builtin_ai_unsupported_desc": "귀하의 장치 하드웨어 또는 OS는 온디바이스 시스템 AI(예: Gemini Nano / Apple Intelligence)를 지원하지 않습니다. 대신 다운로드 가능한 모델을 선택하세요.",
931
"builtin_ai_unsupported_chip": "지원되지 않음",
932
- "builtin_ai_enable": "활성화"
933
-}
\ No newline at end of file
932
+ "builtin_ai_enable": "활성화",
933
+ "model_reasoning_default": "모델 기본값",
934
+ "model_reasoning_on": "켜짐",
935
+ "model_reasoning_help": "다음 응답에 적용됩니다. 모델의 채팅 템플릿이 지원하는 경우 끄면 사고가 비활성화됩니다.",
936
+ "model_reasoning_save_error": "사고 모드를 저장하지 못했습니다. 다시 시도해 주세요."
937
+}
lib/l10n/app_localizations.dart
+24
@@ -5702,6 +5702,30 @@ abstract class AppLocalizations {
5702
/// In en, this message translates to:
5703
/// **'Enable'**
5704
String get builtin_ai_enable;
5705
+
5706
+ /// No description provided for @model_reasoning_default.
5707
+ ///
5708
+ /// In en, this message translates to:
5709
+ /// **'Model default'**
5710
+ String get model_reasoning_default;
5711
+
5712
+ /// No description provided for @model_reasoning_on.
5713
+ ///
5714
+ /// In en, this message translates to:
5715
+ /// **'On'**
5716
+ String get model_reasoning_on;
5717
+
5718
+ /// No description provided for @model_reasoning_help.
5719
+ ///
5720
+ /// In en, this message translates to:
5721
+ /// **'Applies to the next response. Off disables thinking when supported by the model’s chat template.'**
5722
+ String get model_reasoning_help;
5723
+
5724
+ /// No description provided for @model_reasoning_save_error.
5725
+ ///
5726
+ /// In en, this message translates to:
5727
+ /// **'Could not save thinking mode. Please try again.'**
5728
+ String get model_reasoning_save_error;
5729
}
5730
5731
class _AppLocalizationsDelegate
lib/l10n/app_localizations_ar.dart
+14
@@ -3050,4 +3050,18 @@ class AppLocalizationsAr extends AppLocalizations {
3050
3051
@override
3052
String get builtin_ai_enable => 'Enable';
3053
+
3054
+ @override
3055
+ String get model_reasoning_default => 'الإعداد الافتراضي للنموذج';
3056
+
3057
+ @override
3058
+ String get model_reasoning_on => 'تشغيل';
3059
+
3060
+ @override
3061
+ String get model_reasoning_help =>
3062
+ 'يُطبّق على الرد التالي. يؤدي الإيقاف إلى تعطيل التفكير إذا كان قالب المحادثة الخاص بالنموذج يدعم ذلك.';
3063
+
3064
+ @override
3065
+ String get model_reasoning_save_error =>
3066
+ 'تعذّر حفظ وضع التفكير. يُرجى المحاولة مرة أخرى.';
3067
}
lib/l10n/app_localizations_bn.dart
+14
@@ -3076,4 +3076,18 @@ class AppLocalizationsBn extends AppLocalizations {
3076
3077
@override
3078
String get builtin_ai_enable => 'Enable';
3079
+
3080
+ @override
3081
+ String get model_reasoning_default => 'মডেলের ডিফল্ট';
3082
+
3083
+ @override
3084
+ String get model_reasoning_on => 'চালু';
3085
+
3086
+ @override
3087
+ String get model_reasoning_help =>
3088
+ 'পরবর্তী উত্তরে প্রযোজ্য হবে। মডেলের চ্যাট টেমপ্লেট সমর্থন করলে বন্ধ করলে চিন্তন বন্ধ হবে।';
3089
+
3090
+ @override
3091
+ String get model_reasoning_save_error =>
3092
+ 'চিন্তন মোড সংরক্ষণ করা যায়নি। আবার চেষ্টা করুন।';
3093
}
lib/l10n/app_localizations_de.dart
+14
@@ -3065,4 +3065,18 @@ class AppLocalizationsDe extends AppLocalizations {
3065
3066
@override
3067
String get builtin_ai_enable => 'Enable';
3068
+
3069
+ @override
3070
+ String get model_reasoning_default => 'Modellstandard';
3071
+
3072
+ @override
3073
+ String get model_reasoning_on => 'Ein';
3074
+
3075
+ @override
3076
+ String get model_reasoning_help =>
3077
+ 'Gilt für die nächste Antwort. Aus deaktiviert das Denken, sofern die Chatvorlage des Modells dies unterstützt.';
3078
+
3079
+ @override
3080
+ String get model_reasoning_save_error =>
3081
+ 'Denkmodus konnte nicht gespeichert werden. Bitte erneut versuchen.';
3082
}
lib/l10n/app_localizations_en.dart
+14
@@ -3055,4 +3055,18 @@ class AppLocalizationsEn extends AppLocalizations {
3055
3056
@override
3057
String get builtin_ai_enable => 'Enable';
3058
+
3059
+ @override
3060
+ String get model_reasoning_default => 'Model default';
3061
+
3062
+ @override
3063
+ String get model_reasoning_on => 'On';
3064
+
3065
+ @override
3066
+ String get model_reasoning_help =>
3067
+ 'Applies to the next response. Off disables thinking when supported by the model’s chat template.';
3068
+
3069
+ @override
3070
+ String get model_reasoning_save_error =>
3071
+ 'Could not save thinking mode. Please try again.';
3072
}
lib/l10n/app_localizations_es.dart
+14
@@ -3079,4 +3079,18 @@ class AppLocalizationsEs extends AppLocalizations {
3079
3080
@override
3081
String get builtin_ai_enable => 'Enable';
3082
+
3083
+ @override
3084
+ String get model_reasoning_default => 'Predeterminado del modelo';
3085
+
3086
+ @override
3087
+ String get model_reasoning_on => 'Activado';
3088
+
3089
+ @override
3090
+ String get model_reasoning_help =>
3091
+ 'Se aplica a la siguiente respuesta. Desactivado impide el razonamiento si la plantilla de chat del modelo lo permite.';
3092
+
3093
+ @override
3094
+ String get model_reasoning_save_error =>
3095
+ 'No se pudo guardar el modo de razonamiento. Inténtalo de nuevo.';
3096
}
lib/l10n/app_localizations_fr.dart
+14
@@ -3068,4 +3068,18 @@ class AppLocalizationsFr extends AppLocalizations {
3068
3069
@override
3070
String get builtin_ai_enable => 'Enable';
3071
+
3072
+ @override
3073
+ String get model_reasoning_default => 'Par défaut du modèle';
3074
+
3075
+ @override
3076
+ String get model_reasoning_on => 'Activé';
3077
+
3078
+ @override
3079
+ String get model_reasoning_help =>
3080
+ 'S’applique à la prochaine réponse. Désactivé coupe le raisonnement si le modèle de conversation le permet.';
3081
+
3082
+ @override
3083
+ String get model_reasoning_save_error =>
3084
+ 'Impossible d’enregistrer le mode de réflexion. Veuillez réessayer.';
3085
}
lib/l10n/app_localizations_hi.dart
+14
@@ -3066,4 +3066,18 @@ class AppLocalizationsHi extends AppLocalizations {
3066
3067
@override
3068
String get builtin_ai_enable => 'Enable';
3069
+
3070
+ @override
3071
+ String get model_reasoning_default => 'मॉडल का डिफ़ॉल्ट';
3072
+
3073
+ @override
3074
+ String get model_reasoning_on => 'चालू';
3075
+
3076
+ @override
3077
+ String get model_reasoning_help =>
3078
+ 'अगले उत्तर पर लागू होगा। बंद करने से चिंतन अक्षम होगा, यदि मॉडल का चैट टेम्पलेट इसका समर्थन करता है।';
3079
+
3080
+ @override
3081
+ String get model_reasoning_save_error =>
3082
+ 'चिंतन मोड सहेजा नहीं जा सका। फिर से कोशिश करें।';
3083
}
lib/l10n/app_localizations_it.dart
+14
@@ -3082,4 +3082,18 @@ class AppLocalizationsIt extends AppLocalizations {
3082
3083
@override
3084
String get builtin_ai_enable => 'Enable';
3085
+
3086
+ @override
3087
+ String get model_reasoning_default => 'Predefinito del modello';
3088
+
3089
+ @override
3090
+ String get model_reasoning_on => 'Attivo';
3091
+
3092
+ @override
3093
+ String get model_reasoning_help =>
3094
+ 'Si applica alla prossima risposta. Disattivato impedisce il ragionamento se il template di chat del modello lo supporta.';
3095
+
3096
+ @override
3097
+ String get model_reasoning_save_error =>
3098
+ 'Impossibile salvare la modalità di ragionamento. Riprova.';
3099
}
lib/l10n/app_localizations_ja.dart
+13
@@ -3005,4 +3005,17 @@ class AppLocalizationsJa extends AppLocalizations {
3005
3006
@override
3007
String get builtin_ai_enable => 'Enable';
3008
+
3009
+ @override
3010
+ String get model_reasoning_default => 'モデルのデフォルト';
3011
+
3012
+ @override
3013
+ String get model_reasoning_on => 'オン';
3014
+
3015
+ @override
3016
+ String get model_reasoning_help =>
3017
+ '次の応答に適用されます。モデルのチャットテンプレートが対応している場合、オフにすると思考を無効にします。';
3018
+
3019
+ @override
3020
+ String get model_reasoning_save_error => '思考モードを保存できませんでした。もう一度お試しください。';
3021
}
lib/l10n/app_localizations_ko.dart
+13
@@ -2988,4 +2988,17 @@ class AppLocalizationsKo extends AppLocalizations {
2988
2989
@override
2990
String get builtin_ai_enable => '활성화';
2991
+
2992
+ @override
2993
+ String get model_reasoning_default => '모델 기본값';
2994
+
2995
+ @override
2996
+ String get model_reasoning_on => '켜짐';
2997
+
2998
+ @override
2999
+ String get model_reasoning_help =>
3000
+ '다음 응답에 적용됩니다. 모델의 채팅 템플릿이 지원하는 경우 끄면 사고가 비활성화됩니다.';
3001
+
3002
+ @override
3003
+ String get model_reasoning_save_error => '사고 모드를 저장하지 못했습니다. 다시 시도해 주세요.';
3004
}
lib/l10n/app_localizations_pt.dart
+14
@@ -3095,4 +3095,18 @@ class AppLocalizationsPt extends AppLocalizations {
3095
3096
@override
3097
String get builtin_ai_enable => 'Habilitar';
3098
+
3099
+ @override
3100
+ String get model_reasoning_default => 'Padrão do modelo';
3101
+
3102
+ @override
3103
+ String get model_reasoning_on => 'Ativado';
3104
+
3105
+ @override
3106
+ String get model_reasoning_help =>
3107
+ 'Aplica-se à próxima resposta. Desativado impede o raciocínio se o modelo de chat permitir.';
3108
+
3109
+ @override
3110
+ String get model_reasoning_save_error =>
3111
+ 'Não foi possível salvar o modo de raciocínio. Tente novamente.';
3112
}
lib/l10n/app_localizations_ru.dart
+14
@@ -3080,4 +3080,18 @@ class AppLocalizationsRu extends AppLocalizations {
3080
3081
@override
3082
String get builtin_ai_enable => 'Enable';
3083
+
3084
+ @override
3085
+ String get model_reasoning_default => 'По умолчанию для модели';
3086
+
3087
+ @override
3088
+ String get model_reasoning_on => 'Вкл.';
3089
+
3090
+ @override
3091
+ String get model_reasoning_help =>
3092
+ 'Применяется к следующему ответу. Выключение отключает рассуждения, если это поддерживается шаблоном чата модели.';
3093
+
3094
+ @override
3095
+ String get model_reasoning_save_error =>
3096
+ 'Не удалось сохранить режим рассуждений. Попробуйте ещё раз.';
3097
}
lib/l10n/app_localizations_tr.dart
+14
@@ -3085,4 +3085,18 @@ class AppLocalizationsTr extends AppLocalizations {
3085
3086
@override
3087
String get builtin_ai_enable => 'Enable';
3088
+
3089
+ @override
3090
+ String get model_reasoning_default => 'Model varsayılanı';
3091
+
3092
+ @override
3093
+ String get model_reasoning_on => 'Açık';
3094
+
3095
+ @override
3096
+ String get model_reasoning_help =>
3097
+ 'Sonraki yanıta uygulanır. Modelin sohbet şablonu destekliyorsa Kapalı seçeneği düşünmeyi devre dışı bırakır.';
3098
+
3099
+ @override
3100
+ String get model_reasoning_save_error =>
3101
+ 'Düşünme modu kaydedilemedi. Lütfen tekrar deneyin.';
3102
}
lib/l10n/app_localizations_zh.dart
+24
@@ -2981,6 +2981,18 @@ class AppLocalizationsZh extends AppLocalizations {
2981
2982
@override
2983
String get builtin_ai_enable => 'Enable';
2984
+
2985
+ @override
2986
+ String get model_reasoning_default => '模型默认';
2987
+
2988
+ @override
2989
+ String get model_reasoning_on => '开启';
2990
+
2991
+ @override
2992
+ String get model_reasoning_help => '应用于下一条回复。如果模型的聊天模板支持,关闭后将禁用思考。';
2993
+
2994
+ @override
2995
+ String get model_reasoning_save_error => '无法保存思考模式。请重试。';
2996
}
2997
2998
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
@@ -5906,4 +5918,16 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
5918
String failed_to_copy(String error) {
5919
return '複製失敗: $error';
5920
}
5921
+
5922
+ @override
5923
+ String get model_reasoning_default => '模型預設';
5924
+
5925
+ @override
5926
+ String get model_reasoning_on => '開啟';
5927
+
5928
+ @override
5929
+ String get model_reasoning_help => '套用至下一則回覆。如果模型的聊天範本支援,關閉後將停用思考。';
5930
+
5931
+ @override
5932
+ String get model_reasoning_save_error => '無法儲存思考模式。請再試一次。';
5933
}
lib/l10n/app_pt.arb
+6
-2
@@ -929,5 +929,9 @@
929
"builtin_ai_not_supported": "A IA integrada não é compatível com este dispositivo.",
930
"builtin_ai_unsupported_desc": "O hardware ou sistema operacional do seu dispositivo não oferece suporte à IA do sistema no dispositivo (por exemplo, Gemini Nano/Apple Intelligence). Escolha um modelo para download.",
931
"builtin_ai_unsupported_chip": "Não compatível",
932
- "builtin_ai_enable": "Habilitar"
933
-}
\ No newline at end of file
932
+ "builtin_ai_enable": "Habilitar",
933
+ "model_reasoning_default": "Padrão do modelo",
934
+ "model_reasoning_on": "Ativado",
935
+ "model_reasoning_help": "Aplica-se à próxima resposta. Desativado impede o raciocínio se o modelo de chat permitir.",
936
+ "model_reasoning_save_error": "Não foi possível salvar o modo de raciocínio. Tente novamente."
937
+}
lib/l10n/app_ru.arb
+5
-1
@@ -915,5 +915,9 @@
915
"settings_concise_voice_responses_desc": "Делайте ответы LLM короткими (1 короткий абзац) и задавайте уточняющие вопросы в голосовом режиме.",
916
"s3_connection_succeeded": "Подключение к S3 успешно.",
917
"failed_to_open_url": "Не удалось открыть URL: {error}",
918
- "failed_to_copy": "Не удалось скопировать: {error}"
918
+ "failed_to_copy": "Не удалось скопировать: {error}",
919
+ "model_reasoning_default": "По умолчанию для модели",
920
+ "model_reasoning_on": "Вкл.",
921
+ "model_reasoning_help": "Применяется к следующему ответу. Выключение отключает рассуждения, если это поддерживается шаблоном чата модели.",
922
+ "model_reasoning_save_error": "Не удалось сохранить режим рассуждений. Попробуйте ещё раз."
923
}
lib/l10n/app_tr.arb
+5
-1
@@ -915,5 +915,9 @@
915
"settings_concise_voice_responses_desc": "Sesli modda LLM yanıtlarını kısa tutun (1 kısa paragraf) ve takip soruları sorun.",
916
"s3_connection_succeeded": "S3 bağlantısı başarılı.",
917
"failed_to_open_url": "URL açılamadı: {error}",
918
- "failed_to_copy": "Kopyalanamadı: {error}"
918
+ "failed_to_copy": "Kopyalanamadı: {error}",
919
+ "model_reasoning_default": "Model varsayılanı",
920
+ "model_reasoning_on": "Açık",
921
+ "model_reasoning_help": "Sonraki yanıta uygulanır. Modelin sohbet şablonu destekliyorsa Kapalı seçeneği düşünmeyi devre dışı bırakır.",
922
+ "model_reasoning_save_error": "Düşünme modu kaydedilemedi. Lütfen tekrar deneyin."
923
}
lib/l10n/app_zh.arb
+5
-1
@@ -918,5 +918,9 @@
918
"settings_concise_voice_responses_desc": "在语音模式下保持 LLM 回复简短(1个短段落)并提出后续问题。",
919
"s3_connection_succeeded": "S3 连接成功。",
920
"failed_to_open_url": "无法打开 URL: {error}",
921
- "failed_to_copy": "复制失败: {error}"
921
+ "failed_to_copy": "复制失败: {error}",
922
+ "model_reasoning_default": "模型默认",
923
+ "model_reasoning_on": "开启",
924
+ "model_reasoning_help": "应用于下一条回复。如果模型的聊天模板支持,关闭后将禁用思考。",
925
+ "model_reasoning_save_error": "无法保存思考模式。请重试。"
926
}
lib/l10n/app_zh_TW.arb
+5
-1
@@ -918,5 +918,9 @@
918
"settings_concise_voice_responses_desc": "在語音模式下保持 LLM 回應精簡(1個短段落)並提出後續問題。",
919
"s3_connection_succeeded": "S3 連線成功。",
920
"failed_to_open_url": "無法開啟 URL: {error}",
921
- "failed_to_copy": "複製失敗: {error}"
921
+ "failed_to_copy": "複製失敗: {error}",
922
+ "model_reasoning_default": "模型預設",
923
+ "model_reasoning_on": "開啟",
924
+ "model_reasoning_help": "套用至下一則回覆。如果模型的聊天範本支援,關閉後將停用思考。",
925
+ "model_reasoning_save_error": "無法儲存思考模式。請再試一次。"
926
}
pubspec.yaml
+1
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.7.9+84
19
+version: 1.8.0+85
20
21
environment:
22
sdk: ">=3.12.0 <4.0.0"
test/features/chat/ollama_reasoning_decoder_test.dart
new
+45
@@ -0,0 +1,45 @@
1
+import 'package:flutter_test/flutter_test.dart';
2
+import 'package:localmind/features/chat/data/ollama_reasoning_decoder.dart';
3
+
4
+void main() {
5
+ test('every split point preserves reasoning and answer', () {
6
+ const raw = ' \n<think>Reason 🧠</think>Answer';
7
+ for (var split = 0; split <= raw.length; split++) {
8
+ final decoder = OllamaReasoningDecoder();
9
+ final parts = [
10
+ ...decoder.add(raw.substring(0, split), nativeThinking: false),
11
+ ...decoder.add(raw.substring(split), nativeThinking: false),
12
+ ...decoder.flush(),
13
+ ];
14
+ expect(
15
+ parts.where((p) => p.isReasoning).map((p) => p.text).join(),
16
+ 'Reason 🧠',
17
+ );
18
+ expect(
19
+ parts.where((p) => !p.isReasoning).map((p) => p.text).join(),
20
+ 'Answer',
21
+ );
22
+ }
23
+ });
24
+
25
+ test('leaves tags inside an ordinary answer intact', () {
26
+ final decoder = OllamaReasoningDecoder();
27
+ const answer = 'Use <think>reason</think> as an example.';
28
+ expect(decoder.add(answer, nativeThinking: false), [
29
+ (text: answer, isReasoning: false),
30
+ ]);
31
+ });
32
+
33
+ test(
34
+ 'unclosed reasoning remains reasoning and partial closing text is retained',
35
+ () {
36
+ final decoder = OllamaReasoningDecoder();
37
+ final parts = [
38
+ ...decoder.add('<think>Reason</thi', nativeThinking: false),
39
+ ...decoder.flush(),
40
+ ];
41
+ expect(parts.every((p) => p.isReasoning), isTrue);
42
+ expect(parts.map((p) => p.text).join(), 'Reason</thi');
43
+ },
44
+ );
45
+}
test/features/chat/tools/chat_service_adapter_selection_test.dart
+133
@@ -61,7 +61,140 @@ class CapturingStreamInterceptor extends StreamInterceptor {
61
}
62
}
63
64
+class ByteStreamInterceptor extends Interceptor {
65
+ ByteStreamInterceptor(this.raw);
66
+ final String raw;
67
+
68
+ @override
69
+ void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
70
+ handler.resolve(
71
+ Response(
72
+ requestOptions: options,
73
+ statusCode: 200,
74
+ data: ResponseBody(
75
+ Stream.fromIterable(
76
+ utf8.encode(raw).map((byte) => Uint8List.fromList([byte])),
77
+ ),
78
+ 200,
79
+ ),
80
+ ),
81
+ );
82
+ }
83
+}
84
+
85
void main() {
86
+ group('Ollama stream boundaries', () {
87
+ Future<List<ChatResponse>> run(List<Map<String, dynamic>> chunks) {
88
+ final raw = chunks.map(jsonEncode).join('\n');
89
+ final service = OllamaChatService(
90
+ Dio()..interceptors.add(ByteStreamInterceptor(raw)),
91
+ );
92
+ return service
93
+ .sendMessage(
94
+ server: _ollamaTestServer(),
95
+ modelId: 'qwen3.5:9b',
96
+ messages: [],
97
+ params: ChatParameters.defaults(),
98
+ )
99
+ .toList();
100
+ }
101
+
102
+ test(
103
+ 'retains final native answer without newline and across UTF-8 boundaries',
104
+ () async {
105
+ final responses = await run([
106
+ {
107
+ 'message': {'thinking': 'Reason'},
108
+ 'done': false,
109
+ },
110
+ {
111
+ 'message': {'content': 'উত্তর 🌍'},
112
+ 'done': true,
113
+ },
114
+ ]);
115
+ expect(responses.map((r) => r.type), [
116
+ ChatResponseType.reasoning,
117
+ ChatResponseType.message,
118
+ ChatResponseType.done,
119
+ ]);
120
+ expect(responses[1].content, 'উত্তর 🌍');
121
+ },
122
+ );
123
+
124
+ test('separates legacy think tags split across message chunks', () async {
125
+ final responses = await run([
126
+ for (final text in ['<thi', 'nk>Reason', '</thi', 'nk>Answer'])
127
+ {
128
+ 'message': {'content': text},
129
+ 'done': false,
130
+ },
131
+ {'done': true},
132
+ ]);
133
+ expect(
134
+ responses
135
+ .where((r) => r.type == ChatResponseType.reasoning)
136
+ .map((r) => r.reasoningContent)
137
+ .join(),
138
+ 'Reason',
139
+ );
140
+ expect(
141
+ responses
142
+ .where((r) => r.type == ChatResponseType.message)
143
+ .map((r) => r.content)
144
+ .join(),
145
+ 'Answer',
146
+ );
147
+ });
148
+
149
+ test(
150
+ 'native thinking makes content authoritative even when it contains tags',
151
+ () async {
152
+ final responses = await run([
153
+ {
154
+ 'message': {
155
+ 'thinking': 'Reason',
156
+ 'content': '<think>example</think>',
157
+ },
158
+ 'done': true,
159
+ },
160
+ ]);
161
+ expect(responses[0].reasoningContent, 'Reason');
162
+ expect(responses[1].content, '<think>example</think>');
163
+ },
164
+ );
165
+
166
+ test(
167
+ 'an empty native thinking field still keeps content authoritative',
168
+ () async {
169
+ final responses = await run([
170
+ {
171
+ 'message': {'thinking': '', 'content': '<think>example</think>'},
172
+ 'done': true,
173
+ },
174
+ ]);
175
+ expect(responses.first.type, ChatResponseType.message);
176
+ expect(responses.first.content, '<think>example</think>');
177
+ expect(
178
+ responses.where((r) => r.type == ChatResponseType.reasoning),
179
+ isEmpty,
180
+ );
181
+ },
182
+ );
183
+
184
+ test(
185
+ 'flushes an incomplete tag at clean EOF without a done event',
186
+ () async {
187
+ final responses = await run([
188
+ {
189
+ 'message': {'content': '<thi'},
190
+ },
191
+ ]);
192
+ expect(responses.first.content, '<thi');
193
+ expect(responses.last.type, ChatResponseType.done);
194
+ },
195
+ );
196
+ });
197
+
198
group('createAdapterForServerType', () {
199
test('uses OpenAI adapter for OpenAI-compatible servers', () {
200
final adapter = createAdapterForServerType(ServerType.openAICompatible);
test/features/on_device/imported_gguf_model_repository_test.dart
+31
@@ -18,6 +18,37 @@ void main() {
18
return ImportedGgufModelRepository(prefs, Dio());
19
}
20
21
+ test(
22
+ 'reasoning settings persist without changing model identity or file metadata',
23
+ () async {
24
+ final repo = await createRepository();
25
+ final metadata = ImportedGgufModelMetadata(
26
+ id: 'reasoning',
27
+ name: 'Qwen',
28
+ filePath: '/tmp/qwen.gguf',
29
+ fileSizeBytes: 42,
30
+ importedAt: DateTime.utc(2026),
31
+ source: OnDeviceImportedSource.localFile,
32
+ );
33
+ await repo.saveAll([metadata]);
34
+ expect(repo.load().single.reasoningEnabled, isNull);
35
+ for (final enabled in <bool?>[false, true, null]) {
36
+ await repo.updateReasoning('reasoning', enabled);
37
+ final restored = repo.load().single;
38
+ expect(restored.toOnDeviceModel().reasoningEnabled, enabled);
39
+ expect(restored.toJson(), {
40
+ ...metadata.toJson(),
41
+ 'reasoningEnabled': enabled,
42
+ });
43
+ }
44
+ await expectLater(
45
+ repo.updateReasoning('missing', false),
46
+ throwsStateError,
47
+ );
48
+ expect(repo.load(), hasLength(1));
49
+ },
50
+ );
51
+
52
group('ImportedGgufModelMetadata', () {
53
test('serializes and converts to a llama.cpp on-device model', () {
54
final importedAt = DateTime.utc(2026, 6, 21, 12);
test/features/on_device/imported_model_reasoning_setting_test.dart
new
+84
@@ -0,0 +1,84 @@
1
+import 'dart:io';
2
+
3
+import 'package:dio/dio.dart';
4
+import 'package:flutter/material.dart';
5
+import 'package:flutter_riverpod/flutter_riverpod.dart';
6
+import 'package:flutter_test/flutter_test.dart';
7
+import 'package:localmind/core/providers/storage_providers.dart';
8
+import 'package:localmind/features/on_device/data/imported_gguf_model_repository.dart';
9
+import 'package:localmind/features/on_device/data/models/on_device_model.dart';
10
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
11
+import 'package:localmind/features/on_device/views/components/imported_model_reasoning_setting.dart';
12
+import 'package:localmind/l10n/app_localizations.dart';
13
+import 'package:shared_preferences/shared_preferences.dart';
14
+
15
+void main() {
16
+ testWidgets(
17
+ 'editing thinking persists off/on/default and survives provider recreation',
18
+ (tester) async {
19
+ final file = (await tester.runAsync(() async {
20
+ final dir = await Directory.systemTemp.createTemp('reasoning-editor');
21
+ addTearDown(() => dir.delete(recursive: true));
22
+ return File('${dir.path}/test.gguf').writeAsString('GGUF');
23
+ }))!;
24
+ SharedPreferences.setMockInitialValues({});
25
+ final prefs = await SharedPreferences.getInstance();
26
+ final repository = ImportedGgufModelRepository(prefs, Dio());
27
+ final metadata = ImportedGgufModelMetadata(
28
+ id: 'test',
29
+ name: 'Test',
30
+ filePath: file.path,
31
+ fileSizeBytes: 4,
32
+ importedAt: DateTime.utc(2026),
33
+ source: OnDeviceImportedSource.localFile,
34
+ );
35
+ await repository.saveAll([metadata]);
36
+ final container = ProviderContainer(
37
+ overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
38
+ );
39
+ addTearDown(container.dispose);
40
+ await tester.pumpWidget(
41
+ UncontrolledProviderScope(
42
+ container: container,
43
+ child: MaterialApp(
44
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
45
+ supportedLocales: AppLocalizations.supportedLocales,
46
+ home: Scaffold(
47
+ body: Consumer(
48
+ builder: (context, ref, child) {
49
+ final model = ref.watch(importedGgufModelsProvider).single;
50
+ return ImportedModelReasoningSetting(model: model);
51
+ },
52
+ ),
53
+ ),
54
+ ),
55
+ ),
56
+ );
57
+ await tester.pumpAndSettle();
58
+ for (final entry in {
59
+ 'Off': false,
60
+ 'On': true,
61
+ 'Model default': null,
62
+ }.entries) {
63
+ await tester.tap(find.byType(DropdownButtonFormField<String>));
64
+ await tester.pumpAndSettle();
65
+ await tester.tap(find.text(entry.key).last);
66
+ await tester.pumpAndSettle();
67
+ expect(repository.load().single.reasoningEnabled, entry.value);
68
+ expect(
69
+ container.read(importedGgufModelsProvider).single.reasoningEnabled,
70
+ entry.value,
71
+ );
72
+ final restored = ProviderContainer(
73
+ overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
74
+ );
75
+ expect(
76
+ restored.read(importedGgufModelsProvider).single.reasoningEnabled,
77
+ entry.value,
78
+ );
79
+ restored.dispose();
80
+ }
81
+ expect(tester.takeException(), isNull);
82
+ },
83
+ );
84
+}
test/features/on_device/on_device_llama_reasoning_test.dart
new
+102
@@ -0,0 +1,102 @@
1
+import 'package:flutter_test/flutter_test.dart';
2
+import 'package:llamadart/llamadart.dart' as llama;
3
+import 'package:localmind/core/models/enums.dart';
4
+import 'package:localmind/features/chat/data/chat_service.dart';
5
+import 'package:localmind/features/chat/data/models/chat_parameters.dart';
6
+import 'package:localmind/features/chat/data/models/message.dart';
7
+import 'package:localmind/features/on_device/data/imported_gguf_model_repository.dart';
8
+import 'package:localmind/features/on_device/data/models/on_device_model.dart';
9
+import 'package:localmind/features/on_device/data/on_device_llama_service.dart';
10
+
11
+class RecordingEngine implements llama.LlamaEngine {
12
+ final thinking = <bool>[];
13
+
14
+ @override
15
+ dynamic noSuchMethod(Invocation invocation) {
16
+ switch (invocation.memberName) {
17
+ case #loadModel:
18
+ case #dispose:
19
+ return Future<void>.value();
20
+ case #chatTemplate:
21
+ return Future.value(
22
+ const llama.LlamaChatTemplateResult(prompt: 'test'),
23
+ );
24
+ case #getTokenCount:
25
+ return Future.value(1);
26
+ case #create:
27
+ thinking.add(invocation.namedArguments[#enableThinking] as bool);
28
+ return Stream.value(
29
+ llama.LlamaCompletionChunk.fromJson({
30
+ 'id': 'chunk',
31
+ 'object': 'chat.completion.chunk',
32
+ 'created': 0,
33
+ 'model': 'test',
34
+ 'choices': [
35
+ {
36
+ 'index': 0,
37
+ 'delta': {'content': 'Answer', 'thinking': 'Reasoning'},
38
+ },
39
+ ],
40
+ }),
41
+ );
42
+ default:
43
+ return super.noSuchMethod(invocation);
44
+ }
45
+ }
46
+}
47
+
48
+void main() {
49
+ test(
50
+ 'saved setting reaches generation, including edits while loaded and reset',
51
+ () async {
52
+ bool? preference = false;
53
+ final engine = RecordingEngine();
54
+ final service = OnDeviceLlamaService(
55
+ engineFactory: () => engine,
56
+ reasoningPreference: (_) => preference,
57
+ );
58
+ final model = ImportedGgufModelMetadata(
59
+ id: 'test',
60
+ name: 'Test',
61
+ filePath: '/tmp/test.gguf',
62
+ fileSizeBytes: 4,
63
+ importedAt: DateTime.utc(2026),
64
+ source: OnDeviceImportedSource.localFile,
65
+ ).toOnDeviceModel();
66
+ await service.loadModel(model);
67
+ final messages = [
68
+ Message(
69
+ id: 'u',
70
+ conversationId: 'c',
71
+ role: MessageRole.user,
72
+ content: 'Hi',
73
+ createdAt: DateTime.utc(2026),
74
+ ),
75
+ ];
76
+ Future<List<ChatResponse>> generate({bool? enabled}) => service
77
+ .sendMessage(
78
+ modelId: 'test',
79
+ messages: messages,
80
+ params: ChatParameters.defaults().copyWith(
81
+ reasoningEnabled: enabled,
82
+ ),
83
+ )
84
+ .toList();
85
+ final responses = await generate();
86
+ expect(responses.where((r) => r.type == ChatResponseType.error), isEmpty);
87
+ expect(
88
+ responses
89
+ .where((r) => r.type == ChatResponseType.message)
90
+ .single
91
+ .content,
92
+ 'Answer',
93
+ );
94
+ preference = true;
95
+ await generate();
96
+ preference = null;
97
+ await generate(enabled: false);
98
+ await generate();
99
+ expect(engine.thinking, [false, true, false, true]);
100
+ },
101
+ );
102
+}
test/features/tts/system_tts_configuration_test.dart
new
+77
@@ -0,0 +1,77 @@
1
+import 'package:flutter/services.dart';
2
+import 'package:flutter_test/flutter_test.dart';
3
+import 'package:flutter_tts/flutter_tts.dart';
4
+import 'package:localmind/features/tts/data/system_tts_configuration.dart';
5
+
6
+void main() {
7
+ TestWidgetsFlutterBinding.ensureInitialized();
8
+ const channel = MethodChannel('flutter_tts');
9
+ final calls = <MethodCall>[];
10
+ String? engine;
11
+ bool fail = false;
12
+
13
+ setUp(() {
14
+ calls.clear();
15
+ engine = 'org.example.sherpatts';
16
+ fail = false;
17
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
18
+ .setMockMethodCallHandler(channel, (call) async {
19
+ calls.add(call);
20
+ if (call.method == 'getDefaultEngine') return engine;
21
+ if (call.method == 'setEngine' && fail) {
22
+ throw PlatformException(code: 'TtsError');
23
+ }
24
+ return 1;
25
+ });
26
+ });
27
+ tearDown(() {
28
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
29
+ .setMockMethodCallHandler(channel, null);
30
+ });
31
+
32
+ test(
33
+ 'uses selected third-party engine without forcing language or voice',
34
+ () async {
35
+ final config = SystemTtsConfiguration();
36
+ final tts = FlutterTts();
37
+ await config.prepare(tts, isAndroid: true);
38
+ await config.prepare(tts, isAndroid: true);
39
+ expect(calls.map((c) => c.method), [
40
+ 'getDefaultEngine',
41
+ 'setEngine',
42
+ 'getDefaultEngine',
43
+ ]);
44
+ expect(calls[1].arguments, 'org.example.sherpatts');
45
+ engine = 'org.example.other';
46
+ await config.prepare(tts, isAndroid: true);
47
+ expect(calls.last.method, 'setEngine');
48
+ expect(calls.last.arguments, engine);
49
+ },
50
+ );
51
+
52
+ test('initialization failure is surfaced and retried next time', () async {
53
+ final config = SystemTtsConfiguration();
54
+ final tts = FlutterTts();
55
+ fail = true;
56
+ await expectLater(
57
+ config.prepare(tts, isAndroid: true),
58
+ throwsA(isA<PlatformException>()),
59
+ );
60
+ fail = false;
61
+ await config.prepare(tts, isAndroid: true);
62
+ expect(calls.where((c) => c.method == 'setEngine'), hasLength(2));
63
+ });
64
+
65
+ test(
66
+ 'does not send Android calls on other platforms or select an empty engine',
67
+ () async {
68
+ final config = SystemTtsConfiguration();
69
+ final tts = FlutterTts();
70
+ await config.prepare(tts, isAndroid: false);
71
+ expect(calls, isEmpty);
72
+ engine = null;
73
+ await config.prepare(tts, isAndroid: true);
74
+ expect(calls.map((c) => c.method), ['getDefaultEngine']);
75
+ },
76
+ );
77
+}