@tduncan / Cortana / commits / 278e459

feat: add reasoning mode support for local GGUF models with configurable settings and decoding logic

abdulmominsakib committed Sep 9, 2026 at 07:58 UTC 278e4599c02185ddaa78f446031019a64338faeb
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 +}