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: