@tduncan / Cortana / commits / 50bd38e

feat: add per-conversation MCP toggle and implement chunked processing for KittenTTS stability

abdulmominsakib committed Apr 28, 2026 at 08:48 UTC 50bd38ec438c542838a5ae6c665a8d6bf23627c9
15 files changed +832 -253
lib/core/providers/app_providers.dart
+2
@@ -77,6 +77,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
77 void setHasCompletedOnboarding(bool value) =>
78 _update(state.copyWith(hasCompletedOnboarding: value));
79 void setMcpEnabled(bool value) => _update(state.copyWith(mcpEnabled: value));
80 + void setNewChatMcpEnabled(bool value) =>
81 + _update(state.copyWith(newChatMcpEnabled: value));
82 void setCodeThemeDark(SyntaxThemeName value) =>
83 _update(state.copyWith(codeThemeDark: value));
84 void setCodeThemeLight(SyntaxThemeName value) =>
lib/core/storage/entities.dart
+5
@@ -179,6 +179,7 @@ class ConversationEntity {
179 double? topP;
180 int? maxTokens;
181 int? contextLength;
182 + bool? mcpEnabled;
183
184 @Backlink()
185 final messages = ToMany<MessageEntity>();
@@ -200,6 +201,7 @@ class ConversationEntity {
201 this.topP,
202 this.maxTokens,
203 this.contextLength,
204 + this.mcpEnabled,
205 });
206
207 factory ConversationEntity.fromDomain(Conversation conversation) {
@@ -219,6 +221,7 @@ class ConversationEntity {
221 topP: conversation.topP,
222 maxTokens: conversation.maxTokens,
223 contextLength: conversation.contextLength,
224 + mcpEnabled: conversation.mcpEnabled,
225 );
226 }
227
@@ -239,8 +242,10 @@ class ConversationEntity {
242 topP: topP,
243 maxTokens: maxTokens,
244 contextLength: contextLength,
245 + mcpEnabled: mcpEnabled,
246 );
247 }
248 +
249 }
250
251 @Entity()
lib/features/chat/data/kitten_tts_service.dart
+119 -27
@@ -1,3 +1,4 @@
1 +import 'dart:async';
2 import 'dart:io';
3
4 import 'package:audioplayers/audioplayers.dart';
@@ -64,9 +65,17 @@ class KittenTtsService {
65 }
66
67 // Copy assets to filesystem if not already present
67 - final configPath = await _ensureAssetFile(ttsDir, _fileConfig, _assetConfig);
68 + final configPath = await _ensureAssetFile(
69 + ttsDir,
70 + _fileConfig,
71 + _assetConfig,
72 + );
73 final modelPath = await _ensureAssetFile(ttsDir, _fileModel, _assetModel);
69 - final voicesPath = await _ensureAssetFile(ttsDir, _fileVoices, _assetVoices);
74 + final voicesPath = await _ensureAssetFile(
75 + ttsDir,
76 + _fileVoices,
77 + _assetVoices,
78 + );
79
80 Log.info(
81 'KittenTTS assets ready: config=$configPath, model=$modelPath, voices=$voicesPath',
@@ -106,46 +115,81 @@ class KittenTtsService {
115 double speed = 1.0,
116 }) async {
117 await initialize();
109 - if (text.trim().isEmpty) return;
118 + final trimmedText = text.trim();
119 + if (trimmedText.isEmpty) return;
120 +
121 + // If already speaking, stop first
122 + if (_isSpeaking) {
123 + await stop();
124 + }
125
126 _isSpeaking = true;
127
128 try {
114 - final phonemized = "${_phonemizer.phonemize(text)} "; // Add trailing silence
115 - Log.debug('Phonemized: "$text" → "$phonemized" (speed: $speed)');
116 -
117 - Uint8List wavBytes;
118 - try {
119 - wavBytes = await _tts!.generateWavBytes(
120 - phonemizedText: phonemized,
121 - language: 'en',
122 - voice: voice.displayName,
123 - speed: speed,
124 - );
125 - } catch (e) {
126 - // If session is not initialized, try one re-initialization
127 - if (e.toString().contains('Session not initialized')) {
128 - Log.warning('KittenTTS session not initialized, attempting recovery...');
129 - _isInitialized = false;
130 - await initialize();
129 + // Split text into smaller chunks (sentences) to avoid ONNX sequence length limits
130 + // The "Nano" model used by KittenTTS often crashes on inputs > ~200-300 chars.
131 + final chunks = _splitIntoChunks(trimmedText);
132 + Log.debug('Split text into ${chunks.length} chunks for TTS');
133 +
134 + for (final chunk in chunks) {
135 + if (!_isSpeaking) break;
136 +
137 + final phonemized =
138 + "${_phonemizer.phonemize(chunk)} "; // Add trailing silence
139 + Log.debug('Speaking chunk: "$chunk"');
140 + Log.debug('Phonemized: "$phonemized" (speed: $speed)');
141 +
142 + Uint8List wavBytes;
143 + try {
144 wavBytes = await _tts!.generateWavBytes(
145 phonemizedText: phonemized,
146 language: 'en',
147 voice: voice.displayName,
148 speed: speed,
149 );
137 - } else {
138 - rethrow;
150 + } catch (e) {
151 + // If session is not initialized, try one re-initialization
152 + if (e.toString().contains('Session not initialized')) {
153 + Log.warning(
154 + 'KittenTTS session not initialized, attempting recovery...',
155 + );
156 + _isInitialized = false;
157 + await initialize();
158 + wavBytes = await _tts!.generateWavBytes(
159 + phonemizedText: phonemized,
160 + language: 'en',
161 + voice: voice.displayName,
162 + speed: speed,
163 + );
164 + } else {
165 + rethrow;
166 + }
167 }
140 - }
168
142 - // Play the WAV bytes
143 - await _audioPlayer!.stop();
144 - await _audioPlayer!.play(BytesSource(wavBytes));
169 + if (!_isSpeaking) break;
170 +
171 + // Play the WAV bytes and wait for completion
172 + final completer = Completer<void>();
173 + StreamSubscription? completeSub;
174 +
175 + completeSub = _audioPlayer!.onPlayerComplete.listen((_) {
176 + if (!completer.isCompleted) completer.complete();
177 + });
178 +
179 + await _audioPlayer!.play(BytesSource(wavBytes));
180 +
181 + // Wait until the chunk finishes playing or _isSpeaking becomes false (stopped manually)
182 + while (_isSpeaking && !completer.isCompleted) {
183 + await Future.delayed(const Duration(milliseconds: 50));
184 + }
185 +
186 + await completeSub.cancel();
187 + }
188 } catch (e, st) {
189 Log.error('KittenTTS speak error: $e\n$st');
147 - _isSpeaking = false;
190 rethrow;
191 + } finally {
192 + _isSpeaking = false;
193 }
194 }
195
@@ -192,4 +236,52 @@ class KittenTtsService {
236 await file.writeAsBytes(byteData.buffer.asUint8List());
237 return file.path;
238 }
239 +
240 + /// Splits text into smaller chunks (ideally sentences) to avoid TTS engine limits.
241 + List<String> _splitIntoChunks(String text, {int maxChars = 200}) {
242 + final sentences = _splitIntoSentences(text);
243 + final chunks = <String>[];
244 + String currentChunk = "";
245 +
246 + for (final sentence in sentences) {
247 + // If adding this sentence exceeds maxChars, push currentChunk and start new
248 + if (currentChunk.isNotEmpty &&
249 + (currentChunk.length + sentence.length + 1) > maxChars) {
250 + chunks.add(currentChunk.trim());
251 + currentChunk = "";
252 + }
253 +
254 + // If the sentence itself is longer than maxChars, split it by words
255 + if (sentence.length > maxChars) {
256 + if (currentChunk.isNotEmpty) {
257 + chunks.add(currentChunk.trim());
258 + currentChunk = "";
259 + }
260 +
261 + final words = sentence.split(' ');
262 + String subChunk = "";
263 + for (final word in words) {
264 + if (subChunk.isNotEmpty &&
265 + (subChunk.length + word.length + 1) > maxChars) {
266 + chunks.add(subChunk.trim());
267 + subChunk = "";
268 + }
269 + subChunk += (subChunk.isEmpty ? "" : " ") + word;
270 + }
271 + if (subChunk.isNotEmpty) currentChunk = subChunk;
272 + } else {
273 + currentChunk += (currentChunk.isEmpty ? "" : " ") + sentence;
274 + }
275 + }
276 +
277 + if (currentChunk.isNotEmpty) chunks.add(currentChunk.trim());
278 + return chunks;
279 + }
280 +
281 + /// Splits text into sentences based on punctuation.
282 + List<String> _splitIntoSentences(String text) {
283 + // Split by . ! ? followed by space or end of string
284 + final regex = RegExp(r'(?<=[.!?])\s+');
285 + return text.split(regex).where((s) => s.trim().isNotEmpty).toList();
286 + }
287 }
lib/features/chat/data/phonemizer_service.dart
+506 -173
@@ -1,218 +1,551 @@
1 -/// A basic English grapheme-to-phoneme (G2P) converter.
1 +/// A rule-based English G2P converter tuned for KittenTTS.
2 ///
3 -/// Converts English text into IPA-style phonemes suitable for KittenTTS.
4 -/// This is a rule-based approach and is not perfect — it handles common
5 -/// English pronunciation patterns. For production-grade accuracy, eSpeak
6 -/// or a neural G2P model should be used instead.
3 +/// KittenTTS internally uses:
4 +/// phonemizer.backend.EspeakBackend(language="en-us", preserve_punctuation=True, with_stress=True)
5 +///
6 +/// This service approximates that pipeline on the Flutter/Dart side so that
7 +/// pre-phonemized IPA can be sent directly to a backend using generate_from_ipa().
8 +///
9 +/// ── TextCleaner vocabulary (from kittentts/onnx_model.py) ────────────────
10 +/// Only characters in this set will be accepted by the server-side tokenizer.
11 +/// Any character outside it is silently dropped (matching the KeyError handler
12 +/// in TextCleaner.__call__). Emitting unknown chars wastes tokens and breaks
13 +/// word shapes, so this service validates its own output via [sanitize].
14 +///
15 +/// Pad: $
16 +/// Punctuation: ; : , . ! ? ¡ ¿ — … " « » " " (space)
17 +/// Letters: A-Z a-z
18 +/// IPA: ɑɐɒæɓʙβɔɕçɗɖðʤəɘɚɛɜɝɞɟʄɡɠɢʛɦɧħɥʜɨɪʝɭɬɫɮʟɱɯɰŋɳɲɴøɵɸθœɶʘ
19 +/// ɹɺɾɻʀʁɽʂʃʈʧʉʊʋⱱʌɣɤʍχʎʏʑʐʒʔʡʕʢǀǁǂǃˈˌːˑʼʴʰʱʲʷˠˤ˞
20 +/// ↓↑→↗↘ ᵻ (and stress/diacritic combining marks)
21 +///
22 +/// ── AmE vs BrE ───────────────────────────────────────────────────────────
23 +/// The model is trained on en-us eSpeak output. Differences from BrE that
24 +/// matter most:
25 +/// • Short 'o' (hot, lot) → /ɑ/ not /ɒ/
26 +/// • Stressed er/ir/ur → /ɝ/ (r-coloured) not /ɜː/
27 +/// • Unstressed final er → /ɚ/ (r-coloured schwa) not /ə/
28 +/// • 'r' is always rhotic → include /ɹ/ after vowels
29 +///
30 +/// ── Limitations ──────────────────────────────────────────────────────────
31 +/// • No stress-mark prediction (ˈ ˌ). The model still works without them.
32 +/// • Numbers, currencies, and abbreviations are NOT expanded. Use the server's
33 +/// clean_text=True flag (TextPreprocessor) or expand them before calling.
34 +/// • This is a heuristic approximation — eSpeak or a neural G2P will always
35 +/// be more accurate for production use.
36 class PhonemizerService {
8 - static final Map<String, String> _consonants = {
9 - 'b': 'b', 'c': 'k', 'd': 'd', 'f': 'f', 'g': 'g',
10 - 'h': 'h', 'j': 'dʒ', 'k': 'k', 'l': 'l', 'm': 'm',
11 - 'n': 'n', 'p': 'p', 'q': 'k', 'r': 'ɹ', 's': 's',
12 - 't': 't', 'v': 'v', 'w': 'w', 'x': 'ks', 'y': 'j',
37 + // ─────────────────────────── Vocabulary guard ────────────────────────────
38 +
39 + /// Every IPA character emitted by this service must be in this set.
40 + /// Matches the _letters_ipa + _punctuation + _letters strings in TextCleaner.
41 + static final Set<String> _validChars = {
42 + // Punctuation kept by preserve_punctuation=True
43 + ';', ':', ',', '.', '!', '?', '¡', '¿', '—', '…',
44 + '"', '«', '»', '\u201C', '\u201D', ' ',
45 + // ASCII letters (eSpeak sometimes passes through proper nouns etc.)
46 + for (int c = 65; c <= 90; c++) String.fromCharCode(c),
47 + for (int c = 97; c <= 122; c++) String.fromCharCode(c),
48 + // IPA characters from TextCleaner._letters_ipa
49 + 'ɑ',
50 + 'ɐ',
51 + 'ɒ',
52 + 'æ',
53 + 'ɓ',
54 + 'ʙ',
55 + 'β',
56 + 'ɔ',
57 + 'ɕ',
58 + 'ç',
59 + 'ɗ',
60 + 'ɖ',
61 + 'ð',
62 + 'ʤ',
63 + 'ə',
64 + 'ɘ',
65 + 'ɚ',
66 + 'ɛ',
67 + 'ɜ',
68 + 'ɝ',
69 + 'ɞ',
70 + 'ɟ',
71 + 'ʄ',
72 + 'ɡ',
73 + 'ɠ',
74 + 'ɢ',
75 + 'ʛ',
76 + 'ɦ',
77 + 'ɧ',
78 + 'ħ',
79 + 'ɥ',
80 + 'ʜ',
81 + 'ɨ',
82 + 'ɪ',
83 + 'ʝ',
84 + 'ɭ',
85 + 'ɬ',
86 + 'ɫ',
87 + 'ɮ',
88 + 'ʟ',
89 + 'ɱ',
90 + 'ɯ',
91 + 'ɰ',
92 + 'ŋ',
93 + 'ɳ',
94 + 'ɲ',
95 + 'ɴ',
96 + 'ø',
97 + 'ɵ',
98 + 'ɸ',
99 + 'θ',
100 + 'œ',
101 + 'ɶ',
102 + 'ʘ',
103 + 'ɹ',
104 + 'ɺ',
105 + 'ɾ',
106 + 'ɻ',
107 + 'ʀ',
108 + 'ʁ',
109 + 'ɽ',
110 + 'ʂ',
111 + 'ʃ',
112 + 'ʈ',
113 + 'ʧ',
114 + 'ʉ',
115 + 'ʊ',
116 + 'ʋ',
117 + 'ⱱ', 'ʌ', 'ɣ', 'ɤ', 'ʍ', 'χ', 'ʎ', 'ʏ', 'ʑ', 'ʐ', 'ʒ', 'ʔ', 'ʡ', 'ʕ', 'ʢ',
118 + 'ǀ', 'ǁ', 'ǂ', 'ǃ',
119 + 'ˈ', 'ˌ', 'ː', 'ˑ', 'ʼ', 'ʴ', 'ʰ', 'ʱ', 'ʲ', 'ʷ', 'ˠ', 'ˤ', '˞',
120 + '↓', '↑', '→', '↗', '↘', 'ᵻ',
121 + // Combining characters used by eSpeak
122 + '\u0329', // combining vertical line below (̩)
123 + };
124 +
125 + // ────────────────────── Silent initial clusters ───────────────────────────
126 +
127 + static const Map<String, String> _silentInitialClusters = {
128 + 'kn': 'n', // knife, know
129 + 'wr': 'ɹ', // write, wrong
130 + 'gn': 'n', // gnome, gnat
131 + 'ps': 's', // psychology
132 + 'pn': 'n', // pneumonia
133 + 'mn': 'm', // mnemonic
134 + };
135 +
136 + // ──────────────────── Consonant multigraphs ───────────────────────────────
137 + // Longer entries are always checked before shorter ones at the call site.
138 +
139 + static const Map<String, String> _consonantMultigraphs = {
140 + 'tch': 'tʃ', // catch, watch
141 + 'dge': 'dʒ', // bridge, lodge
142 + 'ch': 'tʃ', // chin, church
143 + 'sh': 'ʃ', // ship, fish
144 + 'zh': 'ʒ', // measure (rare spelling)
145 + 'th': 'θ', // default unvoiced; voiced ð covered in lexicon
146 + 'gh': '', // night, though (silent)
147 + 'ph': 'f', // phone, graph
148 + 'wh': 'w', // where, what
149 + 'ng': 'ŋ', // sing, ring
150 + 'nk': 'ŋk', // think, bank
151 + 'qu': 'kw', // queen, quick
152 + 'ck': 'k', // back, rock
153 + };
154 +
155 + static const Map<String, String> _singleConsonants = {
156 + 'b': 'b',
157 + 'c': 'k',
158 + 'd': 'd',
159 + 'f': 'f',
160 + 'g': 'ɡ',
161 + 'h': 'h',
162 + 'j': 'dʒ',
163 + 'k': 'k',
164 + 'l': 'l',
165 + 'm': 'm',
166 + 'n': 'n',
167 + 'p': 'p',
168 + 'q': 'k',
169 + 'r': 'ɹ',
170 + 's': 's',
171 + 't': 't',
172 + 'v': 'v',
173 + 'w': 'w',
174 + 'x': 'ks',
175 + 'y': 'j',
176 'z': 'z',
14 - // Common digraphs
15 - 'ch': 'tʃ', 'sh': 'ʃ', 'th': 'θ', 'gh': '', 'ph': 'f',
16 - 'wh': 'w', 'ng': 'ŋ', 'qu': 'kw', 'ck': 'k',
177 };
178
19 - static final Map<String, String> _vowelRules = {
20 - 'a': 'æ', // cat
21 - 'e': 'ɛ', // bed
22 - 'i': 'ɪ', // sit
23 - 'o': 'ɒ', // hot (BrE)
24 - 'u': 'ʌ', // cup
25 - // Long vowels / diphthongs
26 - 'ay': 'eɪ', 'ai': 'eɪ',
27 - 'ee': 'iː', 'ea': 'iː',
28 - 'ie': 'iː',
29 - 'oa': 'oʊ', 'ow': 'oʊ',
30 - 'oo': 'uː',
31 - 'ue': 'uː',
32 - 'ou': 'aʊ',
33 - 'oi': 'ɔɪ', 'oy': 'ɔɪ',
34 - 'au': 'ɔː',
35 - 'aw': 'ɔː',
36 - 'ar': 'ɑː',
37 - 'er': 'ɜː',
38 - 'ir': 'ɜː',
39 - 'or': 'ɔː',
40 - 'ur': 'ɜː',
41 - 'igh': 'aɪ',
179 + // ─────────────────────── Short vowels (en-us) ─────────────────────────────
180 +
181 + static const Map<String, String> _shortVowels = {
182 + 'a': 'æ', // cat, bad
183 + 'e': 'ɛ', // bed, red
184 + 'i': 'ɪ', // sit, big
185 + 'o': 'ɑ', // hot, lot ← AmE /ɑ/ not BrE /ɒ/
186 + 'u': 'ʌ', // cup, but
187 + };
188 +
189 + // ──────────────────── Long vowels (magic-e targets) ───────────────────────
190 +
191 + static const Map<String, String> _longVowels = {
192 + 'a': 'eɪ', // make, cake
193 + 'e': 'iː', // here (rare)
194 + 'i': 'aɪ', // bike, time
195 + 'o': 'oʊ', // hope, home
196 + 'u': 'juː', // cube, mute
197 };
198
44 - /// Convert English text to phonemized string for KittenTTS.
199 + // ──────────────────── Vowel multigraphs ───────────────────────────────────
200 + // Checked longest-first: trigraphs before digraphs before singles.
201 +
202 + static const Map<String, String> _vowelMultigraphs = {
203 + // Trigraphs
204 + 'igh': 'aɪ', // night, light
205 + 'ure': 'jʊɹ', // pure, cure — rhotic AmE
206 + 'ear': 'ɪɹ', // ear, fear — rhotic AmE
207 + 'air': 'ɛɹ', // air, care — rhotic AmE
208 + 'oor': 'ʊɹ', // poor, floor — rhotic AmE
209 + // r-coloured vowel digraphs (AmE rhotic) — checked before plain digraphs
210 + 'ar': 'ɑːɹ', // car, far
211 + 'er': 'ɝ', // her, fern — stressed; see _phonemizeCore for unstressed
212 + 'ir': 'ɝ', // bird, girl
213 + 'or': 'ɔːɹ', // for, born
214 + 'ur': 'ɝ', // burn, hurt
215 + // Pure vowel digraphs
216 + 'ay': 'eɪ', // day, say
217 + 'ai': 'eɪ', // rain, wait
218 + 'ei': 'eɪ', // eight, vein
219 + 'ee': 'iː', // see, feet
220 + 'ea': 'iː', // eat, read (most common allophone)
221 + 'ie': 'iː', // field, brief
222 + 'oa': 'oʊ', // boat, road
223 + 'ow': 'oʊ', // low, snow (the /aʊ/ sense is covered in lexicon)
224 + 'oo': 'uː', // food, moon
225 + 'ue': 'uː', // blue, true
226 + 'ew': 'juː', // new, few
227 + 'eu': 'juː', // feud
228 + 'ou': 'aʊ', // out, found
229 + 'oi': 'ɔɪ', // oil, coin
230 + 'oy': 'ɔɪ', // boy, toy
231 + 'au': 'ɔː', // cause, pause
232 + 'aw': 'ɔː', // saw, law
233 + };
234 +
235 + // ──────────────── Suffixes — longest-first for greedy stripping ───────────
236 +
237 + static const List<MapEntry<String, String>> _suffixes = [
238 + MapEntry('tion', 'ʃən'), // nation, station
239 + MapEntry('sion', 'ʒən'), // vision, decision
240 + MapEntry('ture', 'tʃɚ'), // nature, picture — AmE rhotic schwa
241 + MapEntry('ness', 'nɪs'), // kindness
242 + MapEntry('ment', 'mənt'), // moment
243 + MapEntry('able', 'əbəl'), // capable
244 + MapEntry('ible', 'ɪbəl'), // possible
245 + MapEntry('ical', 'ɪkəl'), // logical
246 + MapEntry('ious', 'iəs'), // previous
247 + MapEntry('ance', 'əns'), //rance → balance
248 + MapEntry('ence', 'əns'), //idence → evidence
249 + MapEntry('ism', 'ɪzəm'), // criticism
250 + MapEntry('ity', 'ɪti'), // clarity
251 + MapEntry('ise', 'aɪz'), // realise
252 + MapEntry('ize', 'aɪz'), // realize
253 + MapEntry('ive', 'ɪv'), // active
254 + MapEntry('ous', 'əs'), // famous
255 + MapEntry('ful', 'fəl'), // careful
256 + MapEntry('ing', 'ɪŋ'), // running
257 + MapEntry('est', 'ɪst'), // fastest
258 + MapEntry('ist', 'ɪst'), // artist
259 + MapEntry('ant', 'ənt'), // important
260 + MapEntry('ent', 'ənt'), // student
261 + MapEntry('age', 'ɪdʒ'), // village
262 + MapEntry('less', 'lɪs'), // careless
263 + MapEntry('ify', 'ɪfaɪ'), // clarify
264 + MapEntry('fy', 'faɪ'), // simplify
265 + MapEntry('ly', 'liː'), // quickly
266 + MapEntry('er', 'ɚ'), // runner — unstressed, AmE rhotic schwa
267 + MapEntry('ed', 'd'), // walked (simplified; ignores /t/ and /ɪd/)
268 + MapEntry('s', 'z'), // cats (simplified; ignores /s/)
269 + ];
270 +
271 + // ─────────────────────────── Public API ──────────────────────────────────
272 +
273 + /// Convert English [text] to IPA compatible with KittenTTS's TextCleaner.
274 ///
46 - /// Handles basic cleaning, punctuation removal, and rule-based phonemization.
47 - /// Words are separated by spaces.
275 + /// Punctuation (`.`, `,`, `!`, `?`, `;`, `:`) is preserved in-place,
276 + /// matching the behaviour of EspeakBackend with preserve_punctuation=True.
277 + /// The output is space-separated IPA words, ready for the server's
278 + /// basic_english_tokenize → TextCleaner pipeline (or generate_from_ipa).
279 String phonemize(String text) {
280 + // Preserve sentence-level punctuation but strip everything else.
281 final cleaned = text
50 - .replaceAll(RegExp(r"[^\w\s']"), ' ')
282 + .replaceAll(RegExp(r"[^\w\s'.,!?;:]"), ' ')
283 .replaceAll(RegExp(r'\s+'), ' ')
284 .trim()
285 .toLowerCase();
286
55 - final words = cleaned.split(' ');
56 - final phonemizedWords = <String>[];
57 -
58 - for (final word in words) {
59 - if (word.isEmpty) continue;
60 - final phonemes = _phonemizeWord(word);
61 - phonemizedWords.add(phonemes);
287 + final output = StringBuffer();
288 + for (int i = 0; i < cleaned.length; i++) {
289 + final char = cleaned[i];
290 + if (_isPreservablePunctuation(char)) {
291 + // Keep punctuation with no surrounding space so it attaches to the
292 + // preceding word token, mirroring eSpeak preserve_punctuation output.
293 + output.write(char);
294 + } else if (char == ' ') {
295 + output.write(' ');
296 + } else {
297 + // Collect the whole word then phonemize it.
298 + final start = i;
299 + while (i < cleaned.length &&
300 + cleaned[i] != ' ' &&
301 + !_isPreservablePunctuation(cleaned[i])) {
302 + i++;
303 + }
304 + i--; // loop will increment
305 + final word = cleaned.substring(start, i + 1);
306 + if (word.isNotEmpty) output.write(_phonemizeWord(word));
307 + }
308 }
309
64 - return phonemizedWords.join(' ');
310 + return sanitize(output.toString());
311 }
312
313 + /// Remove any characters that KittenTTS's TextCleaner would silently drop.
314 + ///
315 + /// Call this on any IPA string before sending to the backend to ensure
316 + /// every character maps to a valid token ID.
317 + String sanitize(String ipa) =>
318 + ipa.split('').where((c) => _validChars.contains(c)).join();
319 +
320 + // ────────────────────────── Word-level logic ──────────────────────────────
321 +
322 String _phonemizeWord(String word) {
68 - // Handle very common words first
69 - if (_commonWords.containsKey(word)) {
70 - return _commonWords[word]!;
323 + if (_commonWords.containsKey(word)) return _commonWords[word]!;
324 +
325 + // Greedy longest-suffix stripping.
326 + for (final entry in _suffixes) {
327 + final suffix = entry.key;
328 + if (word.length > suffix.length + 1 && word.endsWith(suffix)) {
329 + final stem = word.substring(0, word.length - suffix.length);
330 + return _phonemizeCore(stem) + entry.value;
331 + }
332 }
333
334 + return _phonemizeCore(word);
335 + }
336 +
337 + // ─────────────────────────── Core engine ─────────────────────────────────
338 +
339 + String _phonemizeCore(String word) {
340 + if (word.isEmpty) return '';
341 +
342 final buffer = StringBuffer();
343 int i = 0;
344
345 + // ── Silent initial cluster (kn-, wr-, gn-, ps-, pn-) ──────────────────
346 + if (word.length >= 2) {
347 + final pair = word.substring(0, 2);
348 + if (_silentInitialClusters.containsKey(pair)) {
349 + buffer.write(_silentInitialClusters[pair]);
350 + i = 2;
351 + }
352 + }
353 +
354 while (i < word.length) {
77 - // Try digraphs first
78 - if (i < word.length - 1) {
79 - final two = word.substring(i, i + 2);
80 - final three = i < word.length - 2 ? word.substring(i, i + 3) : null;
355 + final char = word[i];
356 + final isLast = i == word.length - 1;
357 + final next = isLast ? null : word[i + 1];
358
82 - if (three != null && _vowelRules.containsKey(three)) {
83 - buffer.write(_vowelRules[three]);
84 - i += 3;
359 + // ── Silent final -e ────────────────────────────────────────────────
360 + if (char == 'e' && isLast && word.length > 2) {
361 + i++;
362 + continue;
363 + }
364 +
365 + // ── Silent final -mb (lamb, comb, thumb) ──────────────────────────
366 + if (char == 'm' && next == 'b' && i == word.length - 2) {
367 + buffer.write('m');
368 + i += 2;
369 + continue;
370 + }
371 +
372 + // ── Vowels ──────────────────────────────────────────────────────────
373 + if (_isVowel(char)) {
374 + // Trigraph vowel
375 + if (i + 2 < word.length) {
376 + final tri = word.substring(i, i + 3);
377 + if (_vowelMultigraphs.containsKey(tri)) {
378 + buffer.write(_vowelMultigraphs[tri]);
379 + i += 3;
380 + continue;
381 + }
382 + }
383 + // Digraph vowel — special case: unstressed final 'er' → /ɚ/ (schwa-r)
384 + if (next != null) {
385 + final di = word.substring(i, i + 2);
386 + if (di == 'er' && i == word.length - 2) {
387 + // Word-final 'er': unstressed rhotic schwa (butter, water)
388 + buffer.write('ɚ');
389 + i += 2;
390 + continue;
391 + }
392 + if (_vowelMultigraphs.containsKey(di)) {
393 + buffer.write(_vowelMultigraphs[di]);
394 + i += 2;
395 + continue;
396 + }
397 + }
398 + // Magic-e: V + C(s) + final-e → long vowel
399 + if (_hasMagicE(word, i)) {
400 + buffer.write(_longVowels[char] ?? char);
401 + i++;
402 continue;
403 }
404 + // Default short vowel
405 + buffer.write(_shortVowels[char] ?? char);
406 + i++;
407 + continue;
408 + }
409
88 - if (_vowelRules.containsKey(two)) {
89 - buffer.write(_vowelRules[two]);
90 - i += 2;
410 + // ── Consonants ───────────────────────────────────────────────────────
411 +
412 + // Soft-c before e / i / y → /s/
413 + if (char == 'c' && next != null && 'eiy'.contains(next)) {
414 + buffer.write('s');
415 + i++;
416 + continue;
417 + }
418 +
419 + // Soft-g before e / i / y → /dʒ/
420 + if (char == 'g' && next != null && 'eiy'.contains(next)) {
421 + buffer.write('dʒ');
422 + i++;
423 + continue;
424 + }
425 +
426 + // y as vowel at end of word
427 + if (char == 'y' && isLast) {
428 + buffer.write(word.length <= 3 ? 'aɪ' : 'iː');
429 + i++;
430 + continue;
431 + }
432 +
433 + // Silent final -mb
434 + // (already handled above, but guard for mid-word mb before final e)
435 +
436 + // Consonant trigraph (tch, dge) — checked before digraph
437 + if (i + 2 < word.length) {
438 + final tri = word.substring(i, i + 3);
439 + if (_consonantMultigraphs.containsKey(tri)) {
440 + buffer.write(_consonantMultigraphs[tri]!);
441 + i += 3;
442 continue;
443 }
444 + }
445
94 - if (_consonants.containsKey(two)) {
95 - buffer.write(_consonants[two]);
446 + // Consonant digraph
447 + if (next != null) {
448 + final di = word.substring(i, i + 2);
449 + if (_consonantMultigraphs.containsKey(di)) {
450 + buffer.write(_consonantMultigraphs[di]!);
451 i += 2;
452 continue;
453 }
454 }
455
101 - // Single letter
102 - final char = word[i];
103 - if (_vowelRules.containsKey(char)) {
104 - buffer.write(_vowelRules[char]);
105 - } else if (_consonants.containsKey(char)) {
106 - buffer.write(_consonants[char]);
107 - }
456 + // Single consonant
457 + buffer.write(_singleConsonants[char] ?? '');
458 i++;
459 }
460
461 return buffer.toString();
462 }
463
114 - static final Map<String, String> _commonWords = {
115 - 'the': 'ðə',
116 - 'a': 'ə',
117 - 'an': 'æn',
118 - 'and': 'ænd',
119 - 'of': 'ɒv',
120 - 'to': 'tuː',
121 - 'in': 'ɪn',
122 - 'is': 'ɪz',
123 - 'you': 'juː',
124 - 'that': 'ðæt',
125 - 'it': 'ɪt',
126 - 'he': 'hiː',
127 - 'was': 'wɒz',
128 - 'for': 'fɔː',
129 - 'on': 'ɒn',
130 - 'are': 'ɑː',
131 - 'as': 'æz',
132 - 'with': 'wɪð',
133 - 'his': 'hɪz',
134 - 'they': 'ðeɪ',
135 - 'at': 'æt',
136 - 'be': 'biː',
137 - 'this': 'ðɪs',
138 - 'have': 'hæv',
139 - 'from': 'frɒm',
140 - 'or': 'ɔː',
141 - 'one': 'wʌn',
142 - 'had': 'hæd',
143 - 'by': 'baɪ',
144 - 'word': 'wɜːd',
145 - 'but': 'bʌt',
146 - 'not': 'nɒt',
147 - 'what': 'wɒt',
148 - 'all': 'ɔːl',
149 - 'were': 'wɜː',
150 - 'we': 'wiː',
151 - 'when': 'wen',
152 - 'your': 'jɔː',
153 - 'can': 'kæn',
154 - 'said': 'sed',
155 - 'there': 'ðeə',
156 - 'use': 'juːz',
157 - 'each': 'iːtʃ',
158 - 'which': 'wɪtʃ',
159 - 'she': 'ʃiː',
160 - 'do': 'duː',
161 - 'how': 'haʊ',
162 - 'their': 'ðeə',
163 - 'if': 'ɪf',
164 - 'will': 'wɪl',
165 - 'up': 'ʌp',
166 - 'other': 'ʌðə',
167 - 'about': 'əbaʊt',
168 - 'out': 'aʊt',
169 - 'many': 'meni',
170 - 'then': 'ðen',
171 - 'them': 'ðem',
172 - 'these': 'ðiːz',
173 - 'so': 'səʊ',
174 - 'some': 'sʌm',
175 - 'her': 'hɜː',
176 - 'would': 'wʊd',
177 - 'make': 'meɪk',
178 - 'like': 'laɪk',
179 - 'into': 'ɪntuː',
180 - 'him': 'hɪm',
181 - 'has': 'hæz',
182 - 'two': 'tuː',
183 - 'more': 'mɔː',
184 - 'go': 'gəʊ',
185 - 'way': 'weɪ',
186 - 'could': 'kʊd',
187 - 'my': 'maɪ',
188 - 'than': 'ðæn',
189 - 'first': 'fɜːst',
190 - 'water': 'wɔːtə',
191 - 'been': 'biːn',
192 - 'call': 'kɔːl',
193 - 'who': 'huː',
194 - 'now': 'naʊ',
195 - 'find': 'faɪnd',
196 - 'long': 'lɒŋ',
197 - 'down': 'daʊn',
198 - 'day': 'deɪ',
199 - 'did': 'dɪd',
200 - 'get': 'get',
201 - 'come': 'kʌm',
202 - 'made': 'meɪd',
203 - 'may': 'meɪ',
204 - 'i': 'aɪ',
205 - 'am': 'æm',
206 - 'yes': 'jes',
207 - 'no': 'nəʊ',
208 - 'hello': 'hɛloʊ',
209 - 'hi': 'haɪ',
210 - 'goodbye': 'gʊdbaɪ',
211 - 'thanks': 'θæŋks',
212 - 'thank': 'θæŋk',
213 - 'please': 'pliːz',
214 - 'sorry': 'sɒri',
215 - 'okay': 'əʊkeɪ',
216 - 'ok': 'əʊkeɪ',
464 + // ───────────────────────────── Helpers ───────────────────────────────────
465 +
466 + bool _isVowel(String char) => 'aeiou'.contains(char);
467 +
468 + bool _isPreservablePunctuation(String char) =>
469 + ','.contains(char) || '.!?;:'.contains(char);
470 +
471 + /// True when the vowel at [index] is lengthened by a trailing silent-e.
472 + /// Pattern: vowel + one-or-more consonants + final-e (VCe).
473 + bool _hasMagicE(String word, int index) {
474 + if (word.isEmpty || word[word.length - 1] != 'e' || word.length < 3) {
475 + return false;
476 + }
477 + int j = index + 1;
478 + int consonantCount = 0;
479 + while (j < word.length - 1) {
480 + if (_isVowel(word[j])) return false;
481 + consonantCount++;
482 + j++;
483 + }
484 + return consonantCount >= 1;
485 + }
486 +
487 + // ───────────────────────────── Lexicon ───────────────────────────────────
488 + // Common words are listed here verbatim as eSpeak en-us would produce them.
489 + // Voiced-th (ð) words must live here — rule-based cannot predict th voicing.
490 +
491 + static const Map<String, String> _commonWords = {
492 + // Articles / determiners
493 + 'the': 'ðə', 'a': 'ə', 'an': 'æn',
494 + // Conjunctions / prepositions
495 + 'and': 'ænd', 'or': 'ɔːɹ', 'but': 'bʌt', 'if': 'ɪf',
496 + 'of': 'ʌv', 'to': 'tuː', 'in': 'ɪn', 'on': 'ɑn',
497 + 'at': 'æt', 'by': 'baɪ', 'as': 'æz',
498 + 'for': 'fɔːɹ', 'with': 'wɪð', 'from': 'fɹʌm', 'into': 'ɪntuː',
499 + 'up': 'ʌp', 'out': 'aʊt', 'over': 'oʊvɚ', 'than': 'ðæn',
500 + 'then': 'ðɛn', 'so': 'soʊ', 'yet': 'jɛt',
501 + // Pronouns
502 + 'i': 'aɪ', 'you': 'juː', 'he': 'hiː', 'she': 'ʃiː',
503 + 'it': 'ɪt', 'we': 'wiː', 'they': 'ðeɪ',
504 + 'me': 'miː', 'him': 'hɪm', 'her': 'hɜːɹ', 'us': 'ʌs',
505 + 'them': 'ðɛm', 'my': 'maɪ', 'your': 'jɔːɹ', 'his': 'hɪz',
506 + 'its': 'ɪts', 'our': 'aʊɚ', 'their': 'ðɛɹ',
507 + 'this': 'ðɪs', 'that': 'ðæt', 'these': 'ðiːz', 'those': 'ðoʊz',
508 + 'who': 'huː', 'which': 'wɪtʃ', 'what': 'wʌt', 'where': 'wɛɹ',
509 + 'when': 'wɛn', 'how': 'haʊ', 'why': 'waɪ',
510 + // Auxiliary / modal verbs
511 + 'is': 'ɪz', 'are': 'ɑːɹ', 'was': 'wɑz', 'were': 'wɜːɹ',
512 + 'be': 'biː', 'been': 'biːn', 'being': 'biːɪŋ',
513 + 'have': 'hæv', 'has': 'hæz', 'had': 'hæd',
514 + 'do': 'duː', 'does': 'dʌz', 'did': 'dɪd', 'done': 'dʌn',
515 + 'will': 'wɪl', 'would': 'wʊd', 'can': 'kæn', 'could': 'kʊd',
516 + 'shall': 'ʃæl', 'should': 'ʃʊd', 'may': 'meɪ', 'might': 'maɪt',
517 + 'must': 'mʌst', 'need': 'niːd',
518 + // Common verbs
519 + 'get': 'ɡɛt', 'got': 'ɡɑt', 'go': 'ɡoʊ', 'went': 'wɛnt',
520 + 'come': 'kʌm', 'came': 'keɪm',
521 + 'make': 'meɪk', 'made': 'meɪd',
522 + 'say': 'seɪ', 'said': 'sɛd',
523 + 'know': 'noʊ', 'think': 'θɪŋk', 'see': 'siː',
524 + 'look': 'lʊk', 'find': 'faɪnd', 'give': 'ɡɪv', 'use': 'juːz',
525 + 'tell': 'tɛl', 'call': 'kɔːl', 'keep': 'kiːp', 'let': 'lɛt',
526 + 'seem': 'siːm', 'feel': 'fiːl', 'try': 'tɹaɪ', 'leave': 'liːv',
527 + 'put': 'pʊt', 'mean': 'miːn', 'show': 'ʃoʊ',
528 + // Common nouns
529 + 'time': 'taɪm', 'year': 'jɪɹ', 'day': 'deɪ', 'way': 'weɪ',
530 + 'man': 'mæn', 'men': 'mɛn', 'word': 'wɜːɹd',
531 + 'world': 'wɜːɹld', 'life': 'laɪf', 'hand': 'hænd',
532 + 'place': 'pleɪs', 'case': 'keɪs', 'thing': 'θɪŋ', 'home': 'hoʊm',
533 + 'water': 'wɔːtɚ', 'room': 'ɹuːm', 'book': 'bʊk',
534 + 'eye': 'aɪ', 'door': 'dɔːɹ', 'face': 'feɪs', 'name': 'neɪm',
535 + 'people': 'piːpəl', 'child': 'tʃaɪld', 'children': 'tʃɪldɹən',
536 + // Numbers (eSpeak expands these — caller should use clean_text=True)
537 + 'one': 'wʌn', 'two': 'tuː', 'three': 'θɹiː',
538 + // Adjectives / adverbs
539 + 'not': 'nɑt', 'all': 'ɔːl', 'some': 'sʌm', 'more': 'mɔːɹ',
540 + 'very': 'vɛɹiː', 'just': 'dʒʌst', 'also': 'ɔːlsoʊ',
541 + 'even': 'iːvən', 'well': 'wɛl', 'such': 'sʌtʃ', 'only': 'oʊnliː',
542 + 'any': 'ɛniː', 'many': 'mɛniː', 'each': 'iːtʃ', 'long': 'lɔːŋ',
543 + 'down': 'daʊn', 'first': 'fɜːɹst', 'other': 'ʌðɚ', 'about': 'əbaʊt',
544 + // Greetings / discourse
545 + 'hello': 'hɛloʊ', 'hi': 'haɪ', 'hey': 'heɪ',
546 + 'bye': 'baɪ', 'goodbye': 'ɡʊdbaɪ',
547 + 'yes': 'jɛs', 'no': 'noʊ', 'okay': 'oʊkeɪ', 'ok': 'oʊkeɪ',
548 + 'please': 'pliːz', 'thanks': 'θæŋks', 'thank': 'θæŋk',
549 + 'sorry': 'sɑɹiː', 'am': 'æm',
550 };
551 }
lib/features/chat/providers/chat_mcp_providers.dart
+16 -3
@@ -1,20 +1,33 @@
1 import 'package:flutter_riverpod/flutter_riverpod.dart';
2 import '../../../core/providers/app_providers.dart';
3 import '../data/models/mcp_integration.dart';
4 +import '../../conversations/providers/conversation_providers.dart';
5
6 export '../data/models/mcp_integration.dart';
7
8 class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
9 @override
10 ChatMcpConfig build() {
10 - final settings = ref.watch(settingsProvider);
11 - return ChatMcpConfig(enabled: settings.mcpEnabled);
11 + // We no longer watch settingsProvider here to avoid resetting per-chat state
12 + // when global settings change. Initial state is set via ChatNotifier.
13 + return const ChatMcpConfig(enabled: true);
14 }
15
16 void setConfig(ChatMcpConfig config) {
17 state = config;
18 }
19
20 + void setEnabled(bool enabled) {
21 + state = state.copyWith(enabled: enabled);
22 + }
23 +
24 + void updateEnabled(WidgetRef ref, String conversationId, bool enabled) {
25 + state = state.copyWith(enabled: enabled);
26 + ref
27 + .read(conversationsProvider.notifier)
28 + .updateMcpEnabled(conversationId, enabled);
29 + }
30 +
31 void addIntegration(McpIntegration integration) {
32 state = state.copyWith(
33 integrations: [...state.integrations, integration],
@@ -52,7 +65,7 @@ class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
65
66 void clearAll() {
67 final settings = ref.read(settingsProvider);
55 - state = ChatMcpConfig(enabled: settings.mcpEnabled);
68 + state = ChatMcpConfig(enabled: settings.newChatMcpEnabled);
69 }
70 }
71
lib/features/chat/providers/chat_providers.dart
+19
@@ -11,6 +11,7 @@ import 'package:localmind/features/conversations/providers/conversation_provider
11 import 'package:localmind/features/models/data/models/model_info.dart';
12 import 'package:localmind/features/personas/providers/personas_providers.dart';
13 import 'package:localmind/features/servers/providers/server_providers.dart';
14 +import './chat_mcp_providers.dart';
15
16 import '../../../core/models/enums.dart';
17 import '../../../core/providers/service_providers.dart';
@@ -418,6 +419,11 @@ class ChatNotifier extends Notifier<ChatState> {
419 ref
420 .read(conv.activeConversationProvider.notifier)
421 .setActiveConversation(conversation);
422 +
423 + // Sync MCP config for this chat
424 + ref
425 + .read(chatMcpConfigProvider.notifier)
426 + .setEnabled(conversation.mcpEnabled ?? true);
427 } catch (e, stackTrace) {
428 Log.fatal(error: e, stackTrace: stackTrace);
429 state = state.copyWith(
@@ -434,6 +440,12 @@ class ChatNotifier extends Notifier<ChatState> {
440 ref
441 .read(conv.activeConversationProvider.notifier)
442 .setActiveConversation(null);
443 +
444 + // Sync MCP config for new chat
445 + final settings = ref.read(settingsProvider);
446 + ref
447 + .read(chatMcpConfigProvider.notifier)
448 + .setEnabled(settings.newChatMcpEnabled);
449 }
450
451 String generateUuid() {
@@ -487,6 +499,7 @@ class ChatNotifier extends Notifier<ChatState> {
499 final selectedModel = ref.read(selectedModelProvider);
500 final chatParams = ref.read(chatParamsProvider);
501 final chatService = ref.read(chatServiceProvider);
502 + final settings = ref.read(settingsProvider);
503
504 if (server == null) {
505 state = state.copyWith(errorMessage: 'No server connected');
@@ -509,12 +522,18 @@ class ChatNotifier extends Notifier<ChatState> {
522 modelId: selectedModel?.id,
523 personaId: selectedPersona?.id,
524 systemPrompt: selectedPersona?.systemPrompt,
525 + mcpEnabled: settings.newChatMcpEnabled,
526 );
527 _currentConversationId = conversation.id;
528 ref
529 .read(conv.activeConversationProvider.notifier)
530 .setActiveConversation(conversation);
531
532 + // Sync MCP config for the newly created chat
533 + ref
534 + .read(chatMcpConfigProvider.notifier)
535 + .setEnabled(settings.newChatMcpEnabled);
536 +
537 // Clear preselected persona after it's applied to the new conversation
538 ref.read(selectedPersonaProvider.notifier).clear();
539 }
lib/features/chat/views/components/chat_bubble.dart
+68 -36
@@ -16,6 +16,7 @@ class ChatBubble extends StatelessWidget {
16 this.onCopy,
17 this.onRetry,
18 this.onDelete,
19 + this.onEdit,
20 this.isStreaming = false,
21 });
22
@@ -23,6 +24,7 @@ class ChatBubble extends StatelessWidget {
24 final VoidCallback? onCopy;
25 final VoidCallback? onRetry;
26 final VoidCallback? onDelete;
27 + final VoidCallback? onEdit;
28 final bool isStreaming;
29
30 @override
@@ -35,7 +37,12 @@ class ChatBubble extends StatelessWidget {
37 case MessageRole.user:
38 return _AnimatedBubble(
39 alignment: Alignment.centerRight,
38 - child: _UserBubble(message: message),
40 + child: _UserBubble(
41 + message: message,
42 + onCopy: onCopy,
43 + onDelete: onDelete,
44 + onEdit: onEdit,
45 + ),
46 );
47 case MessageRole.assistant:
48 return _AnimatedBubble(
@@ -98,9 +105,17 @@ class _AnimatedBubble extends StatelessWidget {
105 }
106
107 class _UserBubble extends StatelessWidget {
101 - const _UserBubble({required this.message});
108 + const _UserBubble({
109 + required this.message,
110 + this.onCopy,
111 + this.onDelete,
112 + this.onEdit,
113 + });
114
115 final Message message;
116 + final VoidCallback? onCopy;
117 + final VoidCallback? onDelete;
118 + final VoidCallback? onEdit;
119
120 @override
121 Widget build(BuildContext context) {
@@ -109,56 +124,73 @@ class _UserBubble extends StatelessWidget {
124
125 return Align(
126 alignment: Alignment.centerRight,
112 - child: Container(
113 - constraints: BoxConstraints(
114 - maxWidth: MediaQuery.of(context).size.width * 0.75,
115 - ),
116 - margin: const EdgeInsets.only(left: 48, right: 8, top: 4, bottom: 4),
117 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
118 - decoration: BoxDecoration(
119 - color: isDark ? const Color(0xFF3B82F6) : const Color(0xFF2563EB),
120 - borderRadius: BorderRadius.circular(
121 - 18,
122 - ).copyWith(bottomRight: const Radius.circular(4)),
123 - ),
124 - child: Column(
125 - crossAxisAlignment: CrossAxisAlignment.end,
126 - children: [
127 - if (message.attachmentPaths != null &&
128 - message.attachmentPaths!.isNotEmpty)
129 - Padding(
130 - padding: const EdgeInsets.only(bottom: 8),
131 - child: _AttachmentList(
132 - paths: message.attachmentPaths!,
133 - isUser: true,
127 + child: Column(
128 + crossAxisAlignment: CrossAxisAlignment.end,
129 + children: [
130 + Container(
131 + constraints: BoxConstraints(
132 + maxWidth: MediaQuery.of(context).size.width * 0.75,
133 + ),
134 + margin: const EdgeInsets.only(left: 48, right: 8, top: 4, bottom: 2),
135 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
136 + decoration: BoxDecoration(
137 + color: isDark ? const Color(0xFF3B82F6) : const Color(0xFF2563EB),
138 + borderRadius: BorderRadius.circular(
139 + 18,
140 + ).copyWith(bottomRight: const Radius.circular(4)),
141 + ),
142 + child: Column(
143 + crossAxisAlignment: CrossAxisAlignment.end,
144 + children: [
145 + if (message.attachmentPaths != null &&
146 + message.attachmentPaths!.isNotEmpty)
147 + Padding(
148 + padding: const EdgeInsets.only(bottom: 8),
149 + child: _AttachmentList(
150 + paths: message.attachmentPaths!,
151 + isUser: true,
152 + ),
153 + ),
154 + MarkdownBody(
155 + data: message.content,
156 + selectable: true,
157 + styleSheet: MarkdownStyleSheet(
158 + p: TextStyle(color: Colors.white, fontSize: 15, height: 1.4),
159 + ),
160 + shrinkWrap: true,
161 ),
135 - ),
136 - MarkdownBody(
137 - data: message.content,
138 - styleSheet: MarkdownStyleSheet(
139 - p: TextStyle(color: Colors.white, fontSize: 15, height: 1.4),
140 - ),
141 - shrinkWrap: true,
162 + ],
163 ),
143 - const SizedBox(height: 4),
144 - Row(
164 + ),
165 + Padding(
166 + padding: const EdgeInsets.only(right: 12, bottom: 4),
167 + child: Row(
168 mainAxisSize: MainAxisSize.min,
169 children: [
170 Text(
171 _formatTime(message.createdAt),
172 style: TextStyle(
173 fontSize: 11,
151 - color: Colors.white.withValues(alpha: 0.7),
174 + color: isDark
175 + ? const Color(0xFF666666)
176 + : const Color(0xFF999999),
177 ),
178 ),
179 if (message.status == MessageStatus.error) ...[
180 const SizedBox(width: 4),
181 Icon(Icons.error_outline, size: 14, color: Colors.red[200]),
182 ],
183 + const SizedBox(width: 8),
184 + MessageActionBar(
185 + content: message.content,
186 + onCopy: onCopy,
187 + onDelete: onDelete,
188 + onEdit: onEdit,
189 + ),
190 ],
191 ),
160 - ],
161 - ),
192 + ),
193 + ],
194 ),
195 );
196 }
lib/features/chat/views/components/chat_settings_sheet.dart
+10 -1
@@ -244,7 +244,16 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
244 ShadSwitch(
245 value: mcpConfig.enabled,
246 onChanged: isGloballyEnabled
247 - ? (v) => ref.read(chatMcpConfigProvider.notifier).toggleEnabled()
247 + ? (v) {
248 + final convId = ref.read(conv.activeConversationProvider)?.id;
249 + if (convId != null) {
250 + ref
251 + .read(chatMcpConfigProvider.notifier)
252 + .updateEnabled(ref, convId, v);
253 + } else {
254 + ref.read(chatMcpConfigProvider.notifier).setEnabled(v);
255 + }
256 + }
257 : null,
258 label: const Text('Enable MCP for this chat'),
259 ),
lib/features/conversations/data/models/conversation.dart
+10 -2
@@ -14,6 +14,7 @@ class Conversation {
14 final double? topP;
15 final int? maxTokens;
16 final int? contextLength;
17 + final bool? mcpEnabled;
18
19 Conversation({
20 required this.id,
@@ -31,6 +32,7 @@ class Conversation {
32 this.topP,
33 this.maxTokens,
34 this.contextLength,
35 + this.mcpEnabled,
36 });
37
38 Conversation copyWith({
@@ -55,6 +57,8 @@ class Conversation {
57 bool clearMaxTokens = false,
58 int? contextLength,
59 bool clearContextLength = false,
60 + bool? mcpEnabled,
61 + bool clearMcpEnabled = false,
62 }) {
63 return Conversation(
64 id: id ?? this.id,
@@ -67,11 +71,15 @@ class Conversation {
71 modelId: modelId ?? this.modelId,
72 messageCount: messageCount ?? this.messageCount,
73 lastMessagePreview: lastMessagePreview ?? this.lastMessagePreview,
70 - systemPrompt: clearSystemPrompt ? null : (systemPrompt ?? this.systemPrompt),
74 + systemPrompt:
75 + clearSystemPrompt ? null : (systemPrompt ?? this.systemPrompt),
76 temperature: clearTemperature ? null : (temperature ?? this.temperature),
77 topP: clearTopP ? null : (topP ?? this.topP),
78 maxTokens: clearMaxTokens ? null : (maxTokens ?? this.maxTokens),
74 - contextLength: clearContextLength ? null : (contextLength ?? this.contextLength),
79 + contextLength:
80 + clearContextLength ? null : (contextLength ?? this.contextLength),
81 + mcpEnabled: clearMcpEnabled ? null : (mcpEnabled ?? this.mcpEnabled),
82 );
83 }
84 +
85 }
lib/features/conversations/providers/conversation_providers.dart
+29
@@ -39,6 +39,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
39 String? systemPrompt,
40 String? serverId,
41 String? modelId,
42 + bool? mcpEnabled,
43 }) async {
44 final db = ref.read(databaseProvider);
45 final now = DateTime.now();
@@ -56,6 +57,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
57 messageCount: 0,
58 lastMessagePreview: null,
59 systemPrompt: systemPrompt,
60 + mcpEnabled: mcpEnabled,
61 );
62
63 db.conversationBox.put(ConversationEntity.fromDomain(conversation));
@@ -63,6 +65,33 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
65 return conversation;
66 }
67
68 + Future<void> updateMcpEnabled(String id, bool enabled) async {
69 + final db = ref.read(databaseProvider);
70 + final conversations = state.value ?? [];
71 + final existingIndex = conversations.indexWhere((c) => c.id == id);
72 + if (existingIndex != -1) {
73 + final existing = conversations[existingIndex];
74 + final updated = existing.copyWith(
75 + mcpEnabled: enabled,
76 + updatedAt: DateTime.now(),
77 + );
78 +
79 + final query = db.conversationBox
80 + .query(ConversationEntity_.id.equals(id))
81 + .build();
82 + final existingEntity = query.findFirst();
83 + query.close();
84 +
85 + final entity = ConversationEntity.fromDomain(updated);
86 + if (existingEntity != null) {
87 + entity.internalId = existingEntity.internalId;
88 + }
89 + db.conversationBox.put(entity);
90 +
91 + state = AsyncData(await _loadAll());
92 + }
93 + }
94 +
95 Future<void> renameConversation(String id, String newTitle) async {
96 final db = ref.read(databaseProvider);
97 final conversations = state.value ?? [];
lib/features/settings/data/models/app_settings.dart
+6
@@ -27,6 +27,7 @@ class AppSettings {
27 final bool hasCompletedOnboarding;
28 final bool hasAskedForNotifications;
29 final bool mcpEnabled;
30 + final bool newChatMcpEnabled;
31 final SyntaxThemeName codeThemeDark;
32 final SyntaxThemeName codeThemeLight;
33 final LiteLmBackendType preferredBackend;
@@ -52,6 +53,7 @@ class AppSettings {
53 this.hasCompletedOnboarding = false,
54 this.hasAskedForNotifications = false,
55 this.mcpEnabled = true,
56 + this.newChatMcpEnabled = true,
57 this.codeThemeDark = SyntaxThemeName.dark,
58 this.codeThemeLight = SyntaxThemeName.light,
59 this.preferredBackend = LiteLmBackendType.cpu,
@@ -78,6 +80,7 @@ class AppSettings {
80 bool? hasCompletedOnboarding,
81 bool? hasAskedForNotifications,
82 bool? mcpEnabled,
83 + bool? newChatMcpEnabled,
84 SyntaxThemeName? codeThemeDark,
85 SyntaxThemeName? codeThemeLight,
86 LiteLmBackendType? preferredBackend,
@@ -106,6 +109,7 @@ class AppSettings {
109 hasAskedForNotifications:
110 hasAskedForNotifications ?? this.hasAskedForNotifications,
111 mcpEnabled: mcpEnabled ?? this.mcpEnabled,
112 + newChatMcpEnabled: newChatMcpEnabled ?? this.newChatMcpEnabled,
113 codeThemeDark: codeThemeDark ?? this.codeThemeDark,
114 codeThemeLight: codeThemeLight ?? this.codeThemeLight,
115 preferredBackend: preferredBackend ?? this.preferredBackend,
@@ -134,6 +138,7 @@ class AppSettings {
138 'hasCompletedOnboarding': hasCompletedOnboarding,
139 'hasAskedForNotifications': hasAskedForNotifications,
140 'mcpEnabled': mcpEnabled,
141 + 'newChatMcpEnabled': newChatMcpEnabled,
142 'codeThemeDark': codeThemeDark.index,
143 'codeThemeLight': codeThemeLight.index,
144 'preferredBackend': preferredBackend.index,
@@ -162,6 +167,7 @@ class AppSettings {
167 hasCompletedOnboarding: map['hasCompletedOnboarding'] ?? false,
168 hasAskedForNotifications: map['hasAskedForNotifications'] ?? false,
169 mcpEnabled: map['mcpEnabled'] ?? true,
170 + newChatMcpEnabled: map['newChatMcpEnabled'] ?? true,
171 codeThemeDark: SyntaxThemeName.values[map['codeThemeDark'] ?? 0],
172 codeThemeLight: SyntaxThemeName.values[map['codeThemeLight'] ?? 1],
173 preferredBackend: LiteLmBackendType.values[map['preferredBackend'] ?? 0],
lib/features/settings/screens/settings_screen.dart
+8
@@ -128,6 +128,14 @@ class SettingsScreen extends ConsumerWidget {
128 ref.read(settingsProvider.notifier).setMcpEnabled(v),
129 isDark: isDark,
130 ),
131 + if (settings.mcpEnabled)
132 + _ToggleSetting(
133 + label: 'New Chat MCP Default',
134 + value: settings.newChatMcpEnabled,
135 + onChanged: (v) =>
136 + ref.read(settingsProvider.notifier).setNewChatMcpEnabled(v),
137 + isDark: isDark,
138 + ),
139 const Divider(height: 32),
140 _SectionHeader(title: 'On-Device Inference'),
141 Padding(
lib/objectbox-model.json
+6 -1
@@ -5,7 +5,7 @@
5 "entities": [
6 {
7 "id": "1:6060443518060866028",
8 - "lastPropertyId": "16:9154328066006661329",
8 + "lastPropertyId": "17:8783340423572117942",
9 "name": "ConversationEntity",
10 "properties": [
11 {
@@ -90,6 +90,11 @@
90 "id": "16:9154328066006661329",
91 "name": "contextLength",
92 "type": 6
93 + },
94 + {
95 + "id": "17:8783340423572117942",
96 + "name": "mcpEnabled",
97 + "type": 1
98 }
99 ],
100 "relations": []
lib/objectbox.g.dart
+20 -2
@@ -22,7 +22,7 @@ final _entities = <obx_int.ModelEntity>[
22 obx_int.ModelEntity(
23 id: const obx_int.IdUid(1, 6060443518060866028),
24 name: 'ConversationEntity',
25 - lastPropertyId: const obx_int.IdUid(16, 9154328066006661329),
25 + lastPropertyId: const obx_int.IdUid(17, 8783340423572117942),
26 flags: 0,
27 properties: <obx_int.ModelProperty>[
28 obx_int.ModelProperty(
@@ -122,6 +122,12 @@ final _entities = <obx_int.ModelEntity>[
122 type: 6,
123 flags: 0,
124 ),
125 + obx_int.ModelProperty(
126 + id: const obx_int.IdUid(17, 8783340423572117942),
127 + name: 'mcpEnabled',
128 + type: 1,
129 + flags: 0,
130 + ),
131 ],
132 relations: <obx_int.ModelRelation>[],
133 backlinks: <obx_int.ModelBacklink>[
@@ -500,7 +506,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
506 final systemPromptOffset = object.systemPrompt == null
507 ? null
508 : fbb.writeString(object.systemPrompt!);
503 - fbb.startTable(17);
509 + fbb.startTable(18);
510 fbb.addInt64(0, object.internalId);
511 fbb.addOffset(1, idOffset);
512 fbb.addOffset(2, titleOffset);
@@ -517,6 +523,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
523 fbb.addFloat64(13, object.topP);
524 fbb.addInt64(14, object.maxTokens);
525 fbb.addInt64(15, object.contextLength);
526 + fbb.addBool(16, object.mcpEnabled);
527 fbb.finish(fbb.endTable());
528 return object.internalId;
529 },
@@ -588,6 +595,11 @@ obx_int.ModelDefinition getObjectBoxModel() {
595 rootOffset,
596 34,
597 );
598 + final mcpEnabledParam = const fb.BoolReader().vTableGetNullable(
599 + buffer,
600 + rootOffset,
601 + 36,
602 + );
603 final object = ConversationEntity(
604 internalId: internalIdParam,
605 id: idParam,
@@ -605,6 +617,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
617 topP: topPParam,
618 maxTokens: maxTokensParam,
619 contextLength: contextLengthParam,
620 + mcpEnabled: mcpEnabledParam,
621 );
622 obx_int.InternalToManyAccess.setRelInfo<ConversationEntity>(
623 object.messages,
@@ -1053,6 +1066,11 @@ class ConversationEntity_ {
1066 _entities[0].properties[15],
1067 );
1068
1069 + /// See [ConversationEntity.mcpEnabled].
1070 + static final mcpEnabled = obx.QueryBooleanProperty<ConversationEntity>(
1071 + _entities[0].properties[16],
1072 + );
1073 +
1074 /// see [ConversationEntity.messages]
1075 static final messages =
1076 obx.QueryBacklinkToMany<MessageEntity, ConversationEntity>(
pubspec.lock
+8 -8
@@ -769,10 +769,10 @@ packages:
769 dependency: transitive
770 description:
771 name: matcher
772 - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
772 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
773 url: "https://pub.dev"
774 source: hosted
775 - version: "0.12.18"
775 + version: "0.12.19"
776 material_color_utilities:
777 dependency: transitive
778 description:
@@ -1231,26 +1231,26 @@ packages:
1231 dependency: transitive
1232 description:
1233 name: test
1234 - sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a"
1234 + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
1235 url: "https://pub.dev"
1236 source: hosted
1237 - version: "1.29.0"
1237 + version: "1.30.0"
1238 test_api:
1239 dependency: transitive
1240 description:
1241 name: test_api
1242 - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
1242 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
1243 url: "https://pub.dev"
1244 source: hosted
1245 - version: "0.7.9"
1245 + version: "0.7.10"
1246 test_core:
1247 dependency: transitive
1248 description:
1249 name: test_core
1250 - sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943"
1250 + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
1251 url: "https://pub.dev"
1252 source: hosted
1253 - version: "0.6.15"
1253 + version: "0.6.16"
1254 theme_extensions_builder_annotation:
1255 dependency: transitive
1256 description: