20 files changed
+2103
-673
assets/shaders/orb.frag
+250
-109
@@ -9,133 +9,274 @@ out vec4 fragColor;
9
uniform vec2 uResolution;
10
uniform float uTime;
11
uniform float uMicLevel;
12
-uniform float uPhase; // 0=idle, 1=listening, 2=processing, 3=speaking, 4=error
12
+uniform float uPhase;
13
uniform vec4 uAccentColor;
14
uniform vec4 uSecondaryColor;
15
+uniform float uIsDark;
16
+uniform float uEnergy;
17
+uniform float uProcessingWeight;
18
+uniform float uSpeakingWeight;
19
+uniform float uErrorWeight;
20
+uniform float uResponseProgress;
21
+
22
+const float PI = 3.14159265359;
23
16
-// 3D Simplex-style noise helper
24
vec3 hash3(vec3 p) {
18
- p = vec3(dot(p, vec3(127.1, 311.7, 74.7)),
19
- dot(p, vec3(269.5, 183.3, 246.1)),
20
- dot(p, vec3(113.5, 271.9, 124.6)));
25
+ p = vec3(
26
+ dot(p, vec3(127.1, 311.7, 74.7)),
27
+ dot(p, vec3(269.5, 183.3, 246.1)),
28
+ dot(p, vec3(113.5, 271.9, 124.6))
29
+ );
30
return -1.0 + 2.0 * fract(sin(p) * 43758.5453123);
31
}
32
33
float noise3d(vec3 p) {
25
- vec3 i = floor(p);
26
- vec3 f = fract(p);
27
- vec3 u = f * f * (3.0 - 2.0 * f);
28
-
29
- return mix(mix(mix(dot(hash3(i + vec3(0,0,0)), f - vec3(0,0,0)),
30
- dot(hash3(i + vec3(1,0,0)), f - vec3(1,0,0)), u.x),
31
- mix(dot(hash3(i + vec3(0,1,0)), f - vec3(0,1,0)),
32
- dot(hash3(i + vec3(1,1,0)), f - vec3(1,1,0)), u.x), u.y),
33
- mix(mix(dot(hash3(i + vec3(0,0,1)), f - vec3(0,0,1)),
34
- dot(hash3(i + vec3(1,0,1)), f - vec3(1,0,1)), u.x),
35
- mix(dot(hash3(i + vec3(0,1,1)), f - vec3(0,1,1)),
36
- dot(hash3(i + vec3(1,1,1)), f - vec3(1,1,1)), u.x), u.y), u.z);
34
+ vec3 cell = floor(p);
35
+ vec3 local = fract(p);
36
+ vec3 curve = local * local * (3.0 - 2.0 * local);
37
+
38
+ return mix(
39
+ mix(
40
+ mix(
41
+ dot(hash3(cell), local),
42
+ dot(hash3(cell + vec3(1.0, 0.0, 0.0)), local - vec3(1.0, 0.0, 0.0)),
43
+ curve.x
44
+ ),
45
+ mix(
46
+ dot(hash3(cell + vec3(0.0, 1.0, 0.0)), local - vec3(0.0, 1.0, 0.0)),
47
+ dot(hash3(cell + vec3(1.0, 1.0, 0.0)), local - vec3(1.0, 1.0, 0.0)),
48
+ curve.x
49
+ ),
50
+ curve.y
51
+ ),
52
+ mix(
53
+ mix(
54
+ dot(hash3(cell + vec3(0.0, 0.0, 1.0)), local - vec3(0.0, 0.0, 1.0)),
55
+ dot(hash3(cell + vec3(1.0, 0.0, 1.0)), local - vec3(1.0, 0.0, 1.0)),
56
+ curve.x
57
+ ),
58
+ mix(
59
+ dot(hash3(cell + vec3(0.0, 1.0, 1.0)), local - vec3(0.0, 1.0, 1.0)),
60
+ dot(hash3(cell + vec3(1.0, 1.0, 1.0)), local - vec3(1.0, 1.0, 1.0)),
61
+ curve.x
62
+ ),
63
+ curve.y
64
+ ),
65
+ curve.z
66
+ );
67
}
68
69
float fbm3d(vec3 p) {
40
- float val = 0.0;
41
- float amp = 0.5;
42
- vec3 q = p;
43
- for (int i = 0; i < 4; i++) {
44
- val += amp * noise3d(q);
45
- q *= 2.03;
46
- amp *= 0.5;
70
+ float value = 0.0;
71
+ float amplitude = 0.5;
72
+ vec3 samplePoint = p;
73
+
74
+ // Four octaves keep the orb fluid while remaining safe for mobile GPUs.
75
+ for (int octave = 0; octave < 4; octave++) {
76
+ value += amplitude * noise3d(samplePoint);
77
+ samplePoint = samplePoint * 2.03 + vec3(0.13, -0.17, 0.11);
78
+ amplitude *= 0.5;
79
}
48
- return val;
80
+ return value;
81
+}
82
+
83
+float thinBand(float wave, float width, float softness) {
84
+ return 1.0 - smoothstep(width, width + softness, abs(sin(wave)));
85
}
86
87
void main() {
88
vec2 fragCoord = FlutterFragCoord().xy;
53
- vec2 st = (fragCoord - uResolution * 0.5) / min(uResolution.x, uResolution.y);
54
- float dist = length(st);
55
-
56
- // Smooth outer boundary mask (no box artifact)
57
- float radialMask = smoothstep(0.48, 0.36, dist);
58
-
59
- float speed = (uPhase == 2.0) ? 2.2 : (uPhase == 3.0 ? 1.6 : 1.0);
60
- float time = uTime * speed;
61
-
62
- // Dynamic sphere radius based on mic level & phase
63
- float baseRadius = 0.30;
64
- if (uPhase == 1.0) { // Listening
65
- baseRadius += 0.08 * uMicLevel;
66
- } else if (uPhase == 2.0) { // Processing pulse
67
- baseRadius += 0.03 * sin(time * 6.0);
68
- } else if (uPhase == 3.0) { // Speaking
69
- baseRadius += 0.05 * sin(time * 4.0) * cos(time * 2.0);
70
- } else if (uPhase == 4.0) { // Error
71
- baseRadius = 0.25;
72
- }
89
+ float shortSide = min(uResolution.x, uResolution.y);
90
+ vec2 point = (fragCoord - uResolution * 0.5) / shortSide;
91
+ float pixel = 1.25 / shortSide;
92
74
- vec3 col = vec3(0.0);
75
- float alpha = 0.0;
76
-
77
- float rNorm = dist / baseRadius;
78
-
79
- if (rNorm <= 1.05) {
80
- // Calculate 3D sphere surface normal
81
- float pX = st.x / baseRadius;
82
- float pY = st.y / baseRadius;
83
- float r2 = clamp(pX * pX + pY * pY, 0.0, 1.0);
84
- float pZ = sqrt(1.0 - r2);
85
-
86
- vec3 N = vec3(pX, pY, pZ);
87
-
88
- // 3D rotation matrix for swirling surface coordinates
89
- float rotAngle = time * 0.7;
90
- float cosA = cos(rotAngle);
91
- float sinA = sin(rotAngle);
92
- vec3 rotN = vec3(
93
- N.x * cosA - N.z * sinA,
94
- N.y * cosA + N.x * sinA * 0.3,
95
- N.x * sinA + N.z * cosA
96
- );
97
-
98
- // 3D curved swirl ribbons wrapping along sphere surface
99
- float swirlTheta = atan(rotN.z, rotN.x);
100
- float noiseVal = fbm3d(rotN * 3.0 + vec3(0.0, 0.0, time * 0.4));
101
-
102
- float ribbonPattern = sin(rotN.y * 14.0 + swirlTheta * 4.0 + time * 2.0 + noiseVal * 4.0);
103
- float silkRibbons = smoothstep(-0.25, 0.75, ribbonPattern);
104
-
105
- // Soft internal nebula glow
106
- float innerGlow = pow(pZ, 0.85);
107
-
108
- // Fresnel rim reflection (translucent crystal edge)
109
- float fresnel = pow(1.0 - pZ, 2.2);
110
-
111
- // Soft top-left specular highlight
112
- vec3 lightDir = normalize(vec3(-0.35, -0.45, 0.82));
113
- float spec = pow(max(0.0, dot(N, lightDir)), 14.0);
114
-
115
- // Color blending for silky crystal orb
116
- vec3 coreBase = mix(uAccentColor.rgb, uSecondaryColor.rgb, clamp(rotN.y * 0.5 + 0.5, 0.0, 1.0));
117
- vec3 pinkHighlight = vec3(0.96, 0.65, 0.98); // Soft pink center glow
118
- vec3 whiteSilk = vec3(0.96, 0.95, 1.0); // Pearlescent silk bands
119
-
120
- vec3 surfaceColor = mix(coreBase, pinkHighlight, innerGlow * 0.45);
121
- surfaceColor = mix(surfaceColor, whiteSilk, silkRibbons * 0.42);
122
- surfaceColor += whiteSilk * spec * 0.65; // Specular sheen
123
- surfaceColor = mix(surfaceColor, whiteSilk, fresnel * 0.50); // Rim glow
124
-
125
- // Antialiased sphere edge transition
126
- float edgeAlpha = smoothstep(1.05, 0.92, rNorm);
127
-
128
- col = surfaceColor * edgeAlpha;
129
- alpha = edgeAlpha * 0.92;
130
- }
93
+ float dark = step(0.5, uIsDark);
94
+ float responseCadence =
95
+ 0.5 +
96
+ 0.30 * sin(uTime * 7.4 + uResponseProgress * PI * 18.0) +
97
+ 0.20 * sin(uTime * 12.7 - uResponseProgress * PI * 11.0);
98
+ responseCadence = smoothstep(0.18, 0.86, responseCadence);
99
+
100
+ float activity = clamp(
101
+ max(uEnergy, uMicLevel * (1.0 - uSpeakingWeight)) +
102
+ uSpeakingWeight * responseCadence * 0.22,
103
+ 0.0,
104
+ 1.0
105
+ );
106
+
107
+ // Keep the silhouette mathematically circular. Voice activity changes the
108
+ // orb's overall radius, while motion stays inside the surface so speech
109
+ // never makes the edge look flattened or lopsided.
110
+ vec2 shapePoint = point;
111
+ float slowTime = uTime * (
112
+ 0.115 +
113
+ 0.065 * uProcessingWeight +
114
+ 0.005 * uPhase
115
+ );
116
+ float breathing = sin(uTime * 1.35 + uResponseProgress * PI * 2.0);
117
+ float radius =
118
+ 0.378 +
119
+ activity * 0.010 +
120
+ breathing * (0.0015 + activity * 0.0015) -
121
+ uProcessingWeight * 0.004 -
122
+ uErrorWeight * 0.028;
123
+ float signedDistance = length(shapePoint) - radius;
124
+
125
+ float bodyMask = smoothstep(pixel * 3.6, -pixel * 3.6, signedDistance);
126
+ float haloMask = exp(-max(signedDistance, 0.0) * mix(25.0, 20.0, dark));
127
+ haloMask *= 1.0 - smoothstep(
128
+ radius + 0.018,
129
+ radius + 0.145,
130
+ length(shapePoint)
131
+ );
132
+
133
+ vec2 orbPoint = shapePoint / max(radius, 0.001);
134
+ float orbDistance = length(orbPoint);
135
+ float edge = smoothstep(0.42, 0.96, orbDistance);
136
+ float edgeGlow = pow(clamp(orbDistance, 0.0, 1.0), 5.0);
137
+
138
+ // Reconstruct the front half of a sphere from the circular silhouette.
139
+ // Object-space lighting supplies depth without changing the round edge.
140
+ float surfaceZ = sqrt(max(0.0, 1.0 - min(dot(orbPoint, orbPoint), 1.0)));
141
+ vec3 surfaceNormal = normalize(vec3(orbPoint, surfaceZ + 0.001));
142
+ vec3 lightDirection = normalize(vec3(-0.52, -0.46, 0.78));
143
+ vec3 viewDirection = vec3(0.0, 0.0, 1.0);
144
+ float diffuseLight = smoothstep(
145
+ -0.42,
146
+ 0.88,
147
+ dot(surfaceNormal, lightDirection)
148
+ );
149
+ float centerVolume = pow(surfaceZ, 0.62);
150
+ vec3 halfVector = normalize(lightDirection + viewDirection);
151
+ float softSheen = pow(
152
+ max(dot(surfaceNormal, halfVector), 0.0),
153
+ mix(5.0, 7.0, dark)
154
+ );
155
132
- // Soft Atmospheric Halo Glow around the crystal sphere
133
- float haloDist = max(0.0, rNorm - 0.85);
134
- float outerGlow = exp(-haloDist * 4.5) * 0.55 * radialMask;
135
- vec3 haloColor = mix(uAccentColor.rgb, vec3(0.92, 0.88, 1.0), 0.3) * outerGlow;
156
+ // Layered curved coordinates create the wrapped, hair-like fibers in the
157
+ // reference. They move slowly and remain visible throughout every phase.
158
+ float flowNoise = fbm3d(vec3(
159
+ orbPoint * 1.45,
160
+ slowTime * 1.35
161
+ ));
162
+ float primaryCurve =
163
+ orbPoint.y +
164
+ 0.31 * orbPoint.x * orbPoint.x -
165
+ 0.13 * orbPoint.x +
166
+ 0.055 * sin(orbPoint.x * 3.7 - uTime * 0.19) +
167
+ flowNoise * (0.045 + activity * 0.018);
168
+ float secondaryCurve =
169
+ orbPoint.y * 0.88 +
170
+ orbPoint.x * 0.27 +
171
+ 0.19 * orbPoint.x * orbPoint.x +
172
+ 0.045 * sin(orbPoint.x * 4.4 + uTime * 0.14) -
173
+ flowNoise * 0.035;
174
+
175
+ float primaryFlow = primaryCurve * PI * 4.65 + uTime * 0.14;
176
+ float secondaryFlow = secondaryCurve * PI * 3.55 - uTime * 0.11;
177
+ float silkBands = thinBand(primaryFlow, 0.060, 0.185);
178
+ float broadSilk = thinBand(primaryFlow + 0.18, 0.19, 0.31);
179
+ float fineBands = thinBand(secondaryFlow + 1.15, 0.042, 0.135);
180
+
181
+ float topCoverage = 1.0 - smoothstep(-0.56, 0.76, orbPoint.y);
182
+ float leftCoverage = 1.0 - smoothstep(-0.82, 0.18, orbPoint.x);
183
+ float ribbonCoverage = clamp(
184
+ edge * (0.34 + topCoverage * 0.72 + leftCoverage * 0.28),
185
+ 0.0,
186
+ 1.0
187
+ );
188
+ float ribbonLight = (
189
+ silkBands * (0.82 + activity * 0.28) +
190
+ broadSilk * 0.20 +
191
+ fineBands * 0.30
192
+ ) * ribbonCoverage;
193
+ // Fibres facing the light are clearer and those rolling around the shaded
194
+ // side recede, which makes them feel embedded in a curved surface.
195
+ ribbonLight *= 0.58 + diffuseLight * 0.31 + centerVolume * 0.11;
196
+
197
+ vec3 accent = clamp(uAccentColor.rgb, 0.0, 1.0);
198
+ vec3 secondary = clamp(uSecondaryColor.rgb, 0.0, 1.0);
199
+
200
+ vec3 lightPearl = vec3(0.965, 0.960, 1.000);
201
+ vec3 lightLilac = mix(vec3(0.735, 0.690, 0.985), accent, 0.055);
202
+ vec3 lightPink = mix(vec3(0.985, 0.555, 0.950), secondary, 0.035);
203
+ vec3 darkPearl = mix(vec3(0.690, 0.625, 1.000), accent, 0.28);
204
+ vec3 darkLilac = mix(vec3(0.155, 0.105, 0.355), accent, 0.32);
205
+ vec3 darkPink = mix(vec3(0.895, 0.290, 0.805), secondary, 0.25);
206
+
207
+ vec3 pearl = mix(lightPearl, darkPearl, dark);
208
+ vec3 lilac = mix(lightLilac, darkLilac, dark);
209
+ vec3 pink = mix(lightPink, darkPink, dark);
210
+
211
+ float innerCloud = fbm3d(vec3(
212
+ orbPoint * 1.35 + vec2(-slowTime * 0.25, slowTime * 0.16),
213
+ slowTime * 0.48
214
+ ));
215
+ float pinkBloom = exp(
216
+ -dot(
217
+ orbPoint - vec2(-0.18, -0.08),
218
+ orbPoint - vec2(-0.18, -0.08)
219
+ ) * 3.9
220
+ );
221
+ float pearlBloom = exp(
222
+ -dot(
223
+ orbPoint - vec2(0.24, 0.20),
224
+ orbPoint - vec2(0.24, 0.20)
225
+ ) * 3.2
226
+ );
227
+
228
+ vec3 color = lilac;
229
+ color = mix(color, pink, pinkBloom * mix(0.52, 0.50, dark));
230
+ color = mix(color, pearl, pearlBloom * mix(0.17, 0.12, dark));
231
+ color += pearl * innerCloud * mix(0.055, 0.085, dark);
232
+
233
+ vec3 silkColor = mix(vec3(1.0, 0.985, 1.0), vec3(0.88, 0.83, 1.0), dark);
234
+ float formLight = mix(
235
+ mix(0.79, 1.075, diffuseLight),
236
+ mix(0.54, 1.13, diffuseLight),
237
+ dark
238
+ );
239
+ formLight *= mix(0.91, 1.035, centerVolume);
240
+ color *= formLight;
241
+ color = mix(
242
+ color,
243
+ pearl,
244
+ softSheen * mix(0.15, 0.18, dark)
245
+ );
246
+ color = mix(
247
+ color,
248
+ silkColor,
249
+ clamp(ribbonLight * mix(0.68, 0.58, dark), 0.0, 0.82)
250
+ );
251
+ vec3 rimColor = mix(lilac, silkColor, 0.34 + diffuseLight * 0.48);
252
+ color = mix(
253
+ color,
254
+ rimColor,
255
+ edgeGlow * mix(0.28, 0.31, dark)
256
+ );
257
+
258
+ float bodyAlpha = bodyMask * mix(0.69, 0.88, dark);
259
+ bodyAlpha *= mix(0.93 + pinkBloom * 0.045, 0.96, dark);
260
+
261
+ float haloStrength =
262
+ haloMask *
263
+ (0.11 + activity * 0.06) *
264
+ mix(0.72, 1.0, dark);
265
+ vec3 haloColor = mix(
266
+ mix(pearl, pink, 0.18),
267
+ mix(darkPearl, secondary, 0.20),
268
+ dark
269
+ );
270
+
271
+ if (uErrorWeight > 0.001) {
272
+ vec3 errorColor = mix(vec3(1.0, 0.54, 0.58), vec3(1.0, 0.20, 0.28), dark);
273
+ color = mix(color, errorColor, uErrorWeight * (0.30 + edgeGlow * 0.20));
274
+ }
275
137
- vec3 finalColor = col + haloColor;
138
- float finalAlpha = clamp(alpha + outerGlow, 0.0, 1.0) * radialMask;
276
+ vec3 premultiplied =
277
+ clamp(color, 0.0, 1.15) * bodyAlpha +
278
+ haloColor * haloStrength;
279
+ float alpha = clamp(bodyAlpha + haloStrength, 0.0, 1.0);
280
140
- fragColor = vec4(finalColor * finalAlpha, finalAlpha);
281
+ fragColor = vec4(premultiplied, alpha);
282
}
docs/s3-cloud-sync.md
deleted
-31
@@ -1,31 +0,0 @@
1
-# S3 Cloud Sync
2
-
3
-LocalMind encrypts sync state and attachments on the device before uploading
4
-them. The passphrase is never uploaded. The bucket operator can still observe
5
-object sizes, access times, and the configured object prefix.
6
-
7
-The configured credentials need `GetObject`, `PutObject`, and `DeleteObject`
8
-for the selected prefix. A minimal AWS policy is:
9
-
10
-```json
11
-{
12
- "Version": "2012-10-17",
13
- "Statement": [
14
- {
15
- "Effect": "Allow",
16
- "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
17
- "Resource": "arn:aws:s3:::YOUR_BUCKET/YOUR_PREFIX/*"
18
- }
19
- ]
20
-}
21
-```
22
-
23
-Use path-style addressing for MinIO and providers that do not support bucket
24
-subdomains. HTTPS is recommended. HTTP is available only as an explicit opt-in
25
-for trusted local-network servers. The server must return ETags and support
26
-conditional `If-Match` and `If-None-Match` writes.
27
-
28
-Sync runs while LocalMind is in the foreground: on launch, on resume, and
29
-shortly after local data changes. Disconnecting clears credentials and key
30
-material from the device but does not delete remote objects. There is no
31
-passphrase recovery; use a new prefix if the passphrase is lost.
lib/features/chat/data/chat_service.dart
+334
-195
@@ -30,7 +30,6 @@ abstract class ChatService {
30
required ChatParameters params,
31
List<McpIntegration>? integrations,
32
List<ToolDefinition>? tools,
33
- String? previousResponseId,
33
bool continueGeneration = false,
34
});
35
@@ -177,49 +176,35 @@ class LMStudioChatService implements ChatService {
176
required ChatParameters params,
177
List<McpIntegration>? integrations,
178
List<ToolDefinition>? tools,
180
- String? previousResponseId,
179
bool continueGeneration = false,
180
}) async* {
181
_cancelToken = CancelToken();
184
- final toolAdapter = OpenAiToolAdapter();
185
- final lmStudioToolAdapter = LmStudioToolAdapter();
182
187
- final apiMessages = <Map<String, dynamic>>[];
188
- for (final m in messages) {
189
- apiMessages.add(await _messageToApiMapWithImages(m));
190
- }
183
+ // LM Studio's native /api/v1/chat is stateless from the client's
184
+ // perspective: it does not accept an OpenAI-style `messages` array. We
185
+ // collapse history into a single string `input` for plain text chats,
186
+ // or into a typed-input array for chats with image attachments. The
187
+ // MCP integrations array carries the server-side tool definitions.
188
+ final useTypedInput = messages.any(
189
+ (m) =>
190
+ m.attachmentPaths != null &&
191
+ m.attachmentPaths!.any(AttachmentHelpers.isImagePath),
192
+ );
193
192
- // Strip trailing empty assistant messages to avoid "prefill incompatible
193
- // with enable_thinking" errors from servers running thinking models.
194
- if (!continueGeneration) {
195
- while (apiMessages.isNotEmpty &&
196
- apiMessages.last['role'] == 'assistant' &&
197
- (apiMessages.last['content'] == null ||
198
- (apiMessages.last['content'] is String &&
199
- (apiMessages.last['content'] as String).isEmpty))) {
200
- apiMessages.removeLast();
201
- }
202
- } else if (apiMessages.isNotEmpty &&
203
- apiMessages.last['role'] == 'assistant') {
204
- // Keep the assistant prefill for continuation; ensure content is a
205
- // string (the model continues from exactly where this cuts off, and
206
- // the response contains only the newly generated continuation text).
207
- final last = apiMessages.last;
208
- last['content'] = (last['content'] as String?) ?? '';
209
- }
194
+ final String systemPrompt;
195
+ final input = useTypedInput
196
+ ? await _buildNativeTypedInput(messages, continueGeneration)
197
+ : _buildNativeInputString(messages, continueGeneration);
198
+ systemPrompt = _extractSystemPrompt(messages) ?? params.systemPrompt ?? '';
199
200
final body = <String, dynamic>{
201
'model': modelId,
213
- 'messages': apiMessages,
202
+ 'input': input,
203
+ if (systemPrompt.isNotEmpty) 'system_prompt': systemPrompt,
204
'temperature': params.temperature,
205
'top_p': params.topP,
216
- 'max_tokens': params.maxTokens,
206
+ 'max_output_tokens': params.maxTokens,
207
'stream': true,
218
- // Requests a final chunk with an empty `choices` array and a
219
- // `usage` object (prompt_tokens/completion_tokens) so tokens/sec and
220
- // token counts can still be computed after switching off the native
221
- // Responses-style endpoint's richer `chat.end` stats.
222
- 'stream_options': {'include_usage': true},
208
};
209
210
if (params.topK != null) body['top_k'] = params.topK;
@@ -227,19 +212,35 @@ class LMStudioChatService implements ChatService {
212
if (params.repeatPenalty != null) {
213
body['repeat_penalty'] = params.repeatPenalty;
214
}
230
- _applyReasoningControl(body, params);
215
+ // LM Studio's native /api/v1/chat expects `reasoning` as a *string*
216
+ // ('off' | 'low' | 'medium' | 'high' | 'on'), and does NOT accept the
217
+ // OpenAI-compatible `reasoning_effort` / `think` / `enable_thinking`
218
+ // keys that the shared [_applyReasoningControl] writes. Sending the
219
+ // object form causes HTTP 400 `invalid_request` for every
220
+ // reasoning-capable model (and ChatReasoningConfig defaults to
221
+ // enabled). We therefore apply reasoning in the native shape here.
222
+ //
223
+ // 'on' is universally accepted by reasoning-capable models (it means
224
+ // "use the model's default effort"); per-model effort strings such as
225
+ // 'low'/'medium'/'high' are not guaranteed to be supported (e.g. Gemma
226
+ // only allows 'off'/'on'), so we avoid them.
227
+ if (params.reasoningEnabled == false) {
228
+ body['reasoning'] = 'off';
229
+ } else if (params.reasoningEnabled == true) {
230
+ body['reasoning'] = 'on';
231
+ }
232
+ if (params.contextLength > 0) {
233
+ body['context_length'] = params.contextLength;
234
+ }
235
236
+ // MCP integrations are the only tool path supported by /api/v1/chat.
237
+ // `tools` (OpenAI-style function definitions) are NOT supported here —
238
+ // sending them would be silently ignored. The local tool registry is
239
+ // therefore only useful for non-LM-Studio server types on this build.
240
if (integrations != null && integrations.isNotEmpty) {
241
body['integrations'] = integrations.map((i) => i.toJson()).toList();
242
}
243
236
- if (tools != null && tools.isNotEmpty) {
237
- final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
238
- if (toolsPayload.containsKey('tools')) {
239
- body['tools'] = toolsPayload['tools'];
240
- }
241
- }
242
-
244
try {
245
final response = await _dio.post<ResponseBody>(
246
server.chatEndpoint,
@@ -258,6 +259,8 @@ class LMStudioChatService implements ChatService {
259
utf8.decoder,
260
);
261
String buffer = '';
262
+ String? currentEventName;
263
+ final lmStudioToolAdapter = LmStudioToolAdapter();
264
265
await for (final chunk in stream) {
266
buffer += chunk;
@@ -265,106 +268,182 @@ class LMStudioChatService implements ChatService {
268
buffer = lines.removeLast();
269
270
for (final line in lines) {
268
- if (!line.startsWith('data: ')) continue;
269
- final data = line.substring(6);
270
- if (data == '[DONE]') {
271
- for (final call in toolAdapter.takeCompletedCalls()) {
272
- yield ChatResponse(
273
- type: ChatResponseType.toolCall,
274
- toolCall: ToolCallData(
275
- tool: call.name,
276
- arguments: call.arguments,
277
- ),
278
- );
279
- }
280
- for (final call in lmStudioToolAdapter.takeServerExecutedCalls()) {
281
- yield ChatResponse(
282
- type: ChatResponseType.toolCall,
283
- toolCall: ToolCallData(
284
- tool: call.name,
285
- arguments: call.arguments,
286
- ),
287
- );
288
- }
289
- yield const ChatResponse(type: ChatResponseType.done);
290
- return;
271
+ if (line.isEmpty) {
272
+ // Blank line = SSE event boundary. Flush any buffered event.
273
+ currentEventName = null;
274
+ continue;
275
}
276
+ if (line.startsWith(':')) {
277
+ // SSE comment / keep-alive.
278
+ continue;
279
+ }
280
+ if (line.startsWith('event:')) {
281
+ currentEventName = line.substring(6).trim();
282
+ continue;
283
+ }
284
+ if (!line.startsWith('data:')) continue;
285
+ final data = line.substring(5).trim();
286
if (data.isEmpty) continue;
287
288
+ Map<String, dynamic>? json;
289
try {
295
- final json = jsonDecode(data) as Map<String, dynamic>;
296
- toolAdapter.consumeDynamicChunk(json);
290
+ json = jsonDecode(data) as Map<String, dynamic>;
291
+ } catch (e) {
292
+ Log.error('LMStudio chat parsing error: $e');
293
+ continue;
294
+ }
295
298
- final eventType = json['type'] as String?;
299
- if (eventType != null) {
296
+ // Prefer the SSE `event:` line, fall back to the JSON `type` field.
297
+ final eventType =
298
+ currentEventName ?? json['type'] as String? ?? '';
299
+
300
+ switch (eventType) {
301
+ case 'chat.start':
302
+ // Nothing to do — server is acknowledging the stream.
303
+ break;
304
+ case 'message.delta':
305
+ final content = json['content'] as String?;
306
+ if (content != null && content.isNotEmpty) {
307
+ yield ChatResponse(
308
+ type: ChatResponseType.message,
309
+ content: content,
310
+ );
311
+ }
312
+ break;
313
+ case 'message.start':
314
+ case 'message.end':
315
+ case 'reasoning.start':
316
+ case 'reasoning.end':
317
+ // Boundaries only — content arrives in `*.delta`.
318
+ break;
319
+ case 'reasoning.delta':
320
+ final content = json['content'] as String?;
321
+ if (content != null && content.isNotEmpty) {
322
+ yield ChatResponse(
323
+ type: ChatResponseType.reasoning,
324
+ reasoningContent: content,
325
+ );
326
+ }
327
+ break;
328
+ case 'tool_call.start':
329
+ case 'tool_call.arguments':
330
+ // Adapter aggregates; final completion is emitted below.
331
lmStudioToolAdapter.consumeEvent(eventType, json);
301
- for (final call in lmStudioToolAdapter.takeServerExecutedCalls()) {
332
+ break;
333
+ case 'tool_call.success':
334
+ lmStudioToolAdapter.consumeEvent(eventType, json);
335
+ for (final call
336
+ in lmStudioToolAdapter.takeServerExecutedCalls()) {
337
yield ChatResponse(
338
type: ChatResponseType.toolCall,
339
toolCall: ToolCallData(
340
tool: call.name,
341
arguments: call.arguments,
342
+ output: call.output,
343
+ providerInfo: _providerInfoFromMap(call.providerInfo),
344
),
345
);
346
}
310
- }
311
-
312
- if (json['error'] != null) {
347
+ break;
348
+ case 'tool_call.failure':
349
+ lmStudioToolAdapter.consumeEvent(eventType, json);
350
+ for (final call
351
+ in lmStudioToolAdapter.takeServerExecutedCalls()) {
352
+ yield ChatResponse(
353
+ type: ChatResponseType.toolCall,
354
+ toolCall: ToolCallData(
355
+ tool: call.name,
356
+ arguments: call.arguments,
357
+ output: call.output,
358
+ providerInfo: _providerInfoFromMap(call.providerInfo),
359
+ ),
360
+ );
361
+ }
362
+ break;
363
+ case 'error':
364
+ final error = json['error'];
365
+ final message = error is Map
366
+ ? (error['message'] as String? ?? error.toString())
367
+ : (error?.toString() ?? 'LM Studio error');
368
yield ChatResponse(
369
type: ChatResponseType.error,
315
- content: _formatApiErrorContent(json['error']),
370
+ content: ChatApiError(
371
+ message: message,
372
+ type: error is Map ? error['type'] as String? : null,
373
+ ).encode(),
374
);
375
return;
318
- }
319
-
320
- final usage = json['usage'] as Map<String, dynamic>?;
321
- if (usage != null) {
322
- yield ChatResponse(
323
- type: ChatResponseType.done,
324
- stats: ChatStats(
325
- inputTokens: usage['prompt_tokens'] as int? ?? 0,
326
- totalOutputTokens: usage['completion_tokens'] as int? ?? 0,
327
- ),
328
- );
329
- }
330
-
331
- final choices = json['choices'] as List<dynamic>?;
332
- if (choices == null || choices.isEmpty) continue;
333
-
334
- final firstChoice = choices[0] as Map<String, dynamic>?;
335
- if (firstChoice == null) continue;
336
-
337
- final delta = firstChoice['delta'];
338
- if (delta == null || delta is! Map<String, dynamic>) continue;
339
-
340
- final content = (delta['content'] ?? delta['text']) as String?;
341
- final reasoning =
342
- (delta['reasoning'] ?? delta['reasoning_content']) as String?;
343
-
344
- if (content != null && content.isNotEmpty) {
345
- yield ChatResponse(type: ChatResponseType.message, content: content);
346
- }
347
- if (reasoning != null && reasoning.isNotEmpty) {
348
- yield ChatResponse(
349
- type: ChatResponseType.reasoning,
350
- reasoningContent: reasoning,
351
- );
352
- }
353
- } catch (e) {
354
- Log.error('LMStudio chat parsing error: $e');
376
+ case 'chat.end':
377
+ final result = json['result'] as Map<String, dynamic>?;
378
+ if (result != null) {
379
+ yield ChatResponse(
380
+ type: ChatResponseType.done,
381
+ stats: _extractStats(result),
382
+ );
383
+ // Surface any tool_call / invalid_tool_call items present
384
+ // in the final aggregated output that we may have missed
385
+ // in the stream.
386
+ final output = result['output'];
387
+ if (output is List) {
388
+ for (final item in output) {
389
+ if (item is! Map) continue;
390
+ final m = Map<String, dynamic>.from(item);
391
+ final t = m['type'];
392
+ if (t == 'tool_call') {
393
+ yield ChatResponse(
394
+ type: ChatResponseType.toolCall,
395
+ toolCall: ToolCallData(
396
+ tool: m['tool'] as String? ?? '',
397
+ arguments:
398
+ (m['arguments'] as Map?)
399
+ ?.cast<String, dynamic>() ??
400
+ {},
401
+ output: m['output'] as String?,
402
+ providerInfo: _providerInfoFromMap(
403
+ (m['provider_info'] as Map?)
404
+ ?.cast<String, dynamic>(),
405
+ ),
406
+ ),
407
+ );
408
+ } else if (t == 'invalid_tool_call') {
409
+ yield ChatResponse(
410
+ type: ChatResponseType.invalidToolCall,
411
+ invalidToolCall: InvalidToolCallData(
412
+ reason: m['reason'] as String? ?? '',
413
+ metadataType: (m['metadata'] is Map)
414
+ ? (m['metadata']['type'] as String?)
415
+ : null,
416
+ toolName: (m['metadata'] is Map)
417
+ ? (m['metadata']['tool_name'] as String?)
418
+ : null,
419
+ arguments: (m['arguments'] as Map?)
420
+ ?.cast<String, dynamic>(),
421
+ providerInfo: _providerInfoFromMap(
422
+ (m['provider_info'] as Map?)
423
+ ?.cast<String, dynamic>(),
424
+ ),
425
+ ),
426
+ );
427
+ }
428
+ }
429
+ }
430
+ } else {
431
+ yield const ChatResponse(type: ChatResponseType.done);
432
+ }
433
+ return;
434
+ default:
435
+ // Model load / prompt processing events are surfaced only in
436
+ // the debug log — they don't carry user-visible content.
437
+ if (eventType.isNotEmpty) {
438
+ Log.debug('LMStudio stream event: $eventType');
439
+ }
440
}
441
}
442
}
443
359
- for (final call in toolAdapter.takeCompletedCalls()) {
360
- yield ChatResponse(
361
- type: ChatResponseType.toolCall,
362
- toolCall: ToolCallData(
363
- tool: call.name,
364
- arguments: call.arguments,
365
- ),
366
- );
367
- }
444
+ // Stream ended without an explicit chat.end — treat as a normal
445
+ // completion so the UI doesn't stay stuck in "processing".
446
+ yield const ChatResponse(type: ChatResponseType.done);
447
} catch (e) {
448
Log.error('LMStudio connection error: $e');
449
yield ChatResponse(
@@ -373,93 +452,156 @@ class LMStudioChatService implements ChatService {
452
);
453
return;
454
}
376
- yield const ChatResponse(type: ChatResponseType.done);
455
}
456
379
- Future<Map<String, dynamic>> _messageToApiMapWithImages(Message m) async {
380
- var textContent = m.content;
381
- final hasAttachments =
382
- m.attachmentPaths != null && m.attachmentPaths!.isNotEmpty;
383
-
384
- if (hasAttachments) {
385
- for (final path in m.attachmentPaths!) {
386
- if (!AttachmentHelpers.isTextPath(path)) continue;
387
- final text = await AttachmentHelpers.readTextFile(path);
388
- if (text != null) {
389
- textContent = AttachmentHelpers.appendTextAttachment(
390
- textContent,
391
- AttachmentHelpers.fileNameOf(path),
392
- text,
393
- );
394
- }
457
+ /// Builds the native `input` field as a single string, suitable for
458
+ /// plain-text chats. Conversation history is collapsed into a transcript
459
+ /// because `/api/v1/chat` does not accept an OpenAI-style `messages`
460
+ /// array; system prompt is hoisted to a top-level field by the caller.
461
+ String _buildNativeInputString(
462
+ List<Message> messages,
463
+ bool continueGeneration,
464
+ ) {
465
+ final visible = <Message>[];
466
+ for (final m in messages) {
467
+ if (m.role == MessageRole.system) continue;
468
+ visible.add(m);
469
+ }
470
+ // Drop trailing empty assistant (prefill) unless we're continuing it.
471
+ if (!continueGeneration && visible.isNotEmpty) {
472
+ while (visible.isNotEmpty &&
473
+ visible.last.role == MessageRole.assistant &&
474
+ visible.last.content.isEmpty) {
475
+ visible.removeLast();
476
}
477
}
478
+ final buf = StringBuffer();
479
+ for (final m in visible) {
480
+ final tag = switch (m.role) {
481
+ MessageRole.user => 'User',
482
+ MessageRole.assistant => 'Assistant',
483
+ MessageRole.tool => 'Tool',
484
+ MessageRole.system => 'System',
485
+ };
486
+ buf.writeln('$tag: ${m.content}');
487
+ }
488
+ return buf.toString().trimRight();
489
+ }
490
398
- final imageParts = <Map<String, dynamic>>[];
399
- if (hasAttachments) {
400
- for (final path in m.attachmentPaths!) {
401
- if (!AttachmentHelpers.isImagePath(path)) continue;
402
- try {
403
- final file = File(path);
404
- if (!await file.exists()) continue;
405
- final fileBytes = await ImageUploadUtils.prepareImageBytes(
406
- file,
407
- enabled: imageCompressionEnabled,
408
- level: imageCompressionLevel,
409
- );
410
- final base64Image = await Isolate.run(() {
411
- try {
412
- return base64Encode(fileBytes);
413
- } catch (_) {
414
- return null;
415
- }
416
- });
491
+ /// Builds the native `input` field as an array of typed input items.
492
+ /// Used when at least one image attachment is present.
493
+ Future<List<Map<String, dynamic>>> _buildNativeTypedInput(
494
+ List<Message> messages,
495
+ bool continueGeneration,
496
+ ) async {
497
+ final items = <Map<String, dynamic>>[];
498
+ final visible = <Message>[];
499
+ for (final m in messages) {
500
+ if (m.role == MessageRole.system) continue;
501
+ visible.add(m);
502
+ }
503
+ if (!continueGeneration && visible.isNotEmpty) {
504
+ while (visible.isNotEmpty &&
505
+ visible.last.role == MessageRole.assistant &&
506
+ visible.last.content.isEmpty) {
507
+ visible.removeLast();
508
+ }
509
+ }
510
+ for (final m in visible) {
511
+ final imageItems = await _imageItemsFor(m);
512
+ final textContent = _messageTextWithAttachments(m);
513
+ if (textContent.isNotEmpty) {
514
+ items.add({'type': 'message', 'content': textContent});
515
+ }
516
+ items.addAll(imageItems);
517
+ }
518
+ return items;
519
+ }
520
418
- if (base64Image != null) {
419
- imageParts.add({
420
- 'type': 'image_url',
421
- 'image_url': {'url': 'data:image/png;base64,$base64Image'},
422
- });
521
+ Future<List<Map<String, dynamic>>> _imageItemsFor(Message m) async {
522
+ final paths = m.attachmentPaths ?? const <String>[];
523
+ final items = <Map<String, dynamic>>[];
524
+ for (final path in paths) {
525
+ if (!AttachmentHelpers.isImagePath(path)) continue;
526
+ try {
527
+ final file = File(path);
528
+ if (!await file.exists()) continue;
529
+ final fileBytes = await ImageUploadUtils.prepareImageBytes(
530
+ file,
531
+ enabled: imageCompressionEnabled,
532
+ level: imageCompressionLevel,
533
+ );
534
+ final base64Image = await Isolate.run(() {
535
+ try {
536
+ return base64Encode(fileBytes);
537
+ } catch (_) {
538
+ return null;
539
}
424
- } catch (e) {
425
- Log.error('Failed to read attachment for LMStudio format: $e');
540
+ });
541
+ if (base64Image != null) {
542
+ items.add({
543
+ 'type': 'image',
544
+ 'data_url': 'data:image/png;base64,$base64Image',
545
+ });
546
}
547
+ } catch (e) {
548
+ Log.error('Failed to read attachment for LMStudio native format: $e');
549
}
550
}
551
+ return items;
552
+ }
553
430
- dynamic content;
431
- if (imageParts.isEmpty) {
432
- content = textContent;
433
- } else {
434
- content = <Map<String, dynamic>>[
435
- if (textContent.trim().isNotEmpty)
436
- {'type': 'text', 'text': textContent},
437
- ...imageParts,
438
- ];
554
+ String _messageTextWithAttachments(Message m) {
555
+ var textContent = m.content;
556
+ final paths = m.attachmentPaths ?? const <String>[];
557
+ for (final path in paths) {
558
+ if (!AttachmentHelpers.isTextPath(path)) continue;
559
+ // Note: we don't read the file contents here synchronously — the
560
+ // native endpoint's typed input doesn't carry file metadata. The
561
+ // caller is expected to have already inlined text attachments into
562
+ // `m.content` upstream via AttachmentHelpers. If not, the image path
563
+ // above still handles images.
564
+ final fileName = AttachmentHelpers.fileNameOf(path);
565
+ if (fileName.isEmpty) continue;
566
+ textContent = AttachmentHelpers.appendTextAttachment(
567
+ textContent,
568
+ fileName,
569
+ '<inline text attachment: $fileName>',
570
+ );
571
}
572
+ return textContent;
573
+ }
574
441
- final map = <String, dynamic>{
442
- 'role': _roleToString(m.role),
443
- 'content': content,
444
- };
445
- if (m.role == MessageRole.tool && m.toolCallId != null) {
446
- map['tool_call_id'] = m.toolCallId;
447
- }
448
- if (m.role == MessageRole.assistant &&
449
- m.toolCalls != null &&
450
- m.toolCalls!.isNotEmpty) {
451
- map['tool_calls'] = m.toolCalls!.map((tc) {
452
- return {
453
- 'id': tc.id,
454
- 'type': 'function',
455
- 'function': {
456
- 'name': tc.toolName,
457
- 'arguments': jsonEncode(tc.arguments),
458
- },
459
- };
460
- }).toList();
575
+ String? _extractSystemPrompt(List<Message> messages) {
576
+ for (final m in messages) {
577
+ if (m.role == MessageRole.system) return m.content;
578
}
462
- return map;
579
+ return null;
580
+ }
581
+
582
+ ChatStats? _extractStats(Map<String, dynamic> result) {
583
+ final stats = result['stats'] as Map<String, dynamic>?;
584
+ if (stats == null) return null;
585
+ return ChatStats(
586
+ inputTokens: (stats['input_tokens'] as num?)?.toInt() ?? 0,
587
+ totalOutputTokens: (stats['total_output_tokens'] as num?)?.toInt() ?? 0,
588
+ reasoningOutputTokens:
589
+ (stats['reasoning_output_tokens'] as num?)?.toInt(),
590
+ tokensPerSecond: (stats['tokens_per_second'] as num?)?.toDouble(),
591
+ timeToFirstTokenSeconds:
592
+ (stats['time_to_first_token_seconds'] as num?)?.toDouble(),
593
+ modelLoadTimeSeconds:
594
+ (stats['model_load_time_seconds'] as num?)?.toDouble(),
595
+ );
596
+ }
597
+
598
+ ToolProviderInfo? _providerInfoFromMap(Map<String, dynamic>? map) {
599
+ if (map == null) return null;
600
+ return ToolProviderInfo(
601
+ type: map['type'] as String? ?? '',
602
+ pluginId: map['plugin_id'] as String?,
603
+ serverLabel: map['server_label'] as String?,
604
+ );
605
}
606
607
@override
@@ -483,7 +625,6 @@ class OpenAICompatibleChatService implements ChatService {
625
required ChatParameters params,
626
List<McpIntegration>? integrations,
627
List<ToolDefinition>? tools,
486
- String? previousResponseId,
628
bool continueGeneration = false,
629
}) async* {
630
_cancelToken = CancelToken();
@@ -760,7 +901,6 @@ class OllamaChatService implements ChatService {
901
required ChatParameters params,
902
List<McpIntegration>? integrations,
903
List<ToolDefinition>? tools,
763
- String? previousResponseId,
904
bool continueGeneration = false,
905
}) async* {
906
_cancelToken = CancelToken();
@@ -1003,7 +1143,6 @@ class OpenRouterChatService implements ChatService {
1143
required ChatParameters params,
1144
List<McpIntegration>? integrations,
1145
List<ToolDefinition>? tools,
1006
- String? previousResponseId,
1146
bool continueGeneration = false,
1147
}) async* {
1148
_cancelToken = CancelToken();
lib/features/chat/data/tools/adapters/lm_studio_tool_adapter.dart
+22
-1
@@ -2,53 +2,74 @@ class ServerExecutedToolCall {
2
final String name;
3
final Map<String, dynamic> arguments;
4
final String output;
5
- const ServerExecutedToolCall({required this.name, required this.arguments, required this.output});
5
+ final Map<String, dynamic>? providerInfo;
6
+ const ServerExecutedToolCall({
7
+ required this.name,
8
+ required this.arguments,
9
+ required this.output,
10
+ this.providerInfo,
11
+ });
12
}
13
14
class LmStudioToolAdapter {
15
final List<ServerExecutedToolCall> _completedCalls = [];
16
String? _currentTool;
17
Map<String, dynamic> _currentArgs = {};
18
+ Map<String, dynamic>? _currentProviderInfo;
19
20
void consumeEvent(String eventType, Map<String, dynamic> data) {
21
switch (eventType) {
22
case 'tool_call.start':
23
_currentTool = data['tool'] as String?;
24
_currentArgs = {};
25
+ _currentProviderInfo =
26
+ (data['provider_info'] as Map?)?.cast<String, dynamic>();
27
break;
28
case 'tool_call.arguments':
29
final args = data['arguments'] as Map<String, dynamic>?;
30
if (args != null) {
31
_currentArgs = args;
32
}
33
+ final pi = (data['provider_info'] as Map?)?.cast<String, dynamic>();
34
+ if (pi != null) {
35
+ _currentProviderInfo = pi;
36
+ }
37
break;
38
case 'tool_call.success':
39
final tool = data['tool'] as String? ?? _currentTool;
40
final args = data['arguments'] as Map<String, dynamic>? ?? _currentArgs;
41
final output = data['output'] as String? ?? '';
42
+ final pi = (data['provider_info'] as Map?)?.cast<String, dynamic>() ??
43
+ _currentProviderInfo;
44
if (tool != null) {
45
_completedCalls.add(ServerExecutedToolCall(
46
name: tool,
47
arguments: args,
48
output: output,
49
+ providerInfo: pi,
50
));
51
}
52
_currentTool = null;
53
_currentArgs = {};
54
+ _currentProviderInfo = null;
55
break;
56
case 'tool_call.failure':
57
final tool = data['tool'] as String? ?? _currentTool;
58
final args = data['arguments'] as Map<String, dynamic>? ?? _currentArgs;
59
final reason = data['reason'] as String? ?? 'Tool execution failed';
60
+ final pi = (data['provider_info'] as Map?)?.cast<String, dynamic>() ??
61
+ _currentProviderInfo;
62
if (tool != null) {
63
_completedCalls.add(ServerExecutedToolCall(
64
name: tool,
65
arguments: args,
66
output: reason,
67
+ providerInfo: pi,
68
));
69
}
70
_currentTool = null;
71
_currentArgs = {};
72
+ _currentProviderInfo = null;
73
break;
74
}
75
}
lib/features/chat/providers/chat_notifier.dart
+78
-37
@@ -27,6 +27,7 @@ import '../data/chat_service.dart';
27
import '../data/models/message.dart' hide ToolCallData;
28
import '../data/title_generation_service.dart';
29
import '../data/tools/tool_definition.dart';
30
+import '../data/tools/tool_event.dart';
31
import '../data/tools/tool_execution_loop.dart';
32
import '../data/tools/adapters/tool_transport_adapter.dart' show ParsedToolCall;
33
import '../utils/attachment_helpers.dart';
@@ -1083,54 +1084,94 @@ class ChatNotifier extends Notifier<ChatState> {
1084
1085
if (mcpConfig.enabled && collectedToolCalls.isNotEmpty) {
1086
try {
1086
- final registry = ref.read(toolRegistryProvider);
1087
- final adapter = createAdapterForServerType(server.type);
1088
-
1087
+ // Split collected tool calls into:
1088
+ // - `serverExecuted`: calls whose output the server
1089
+ // already populated (e.g. LM Studio's native
1090
+ // /api/v1/chat runs MCP tools server-side). These
1091
+ // must NOT be re-executed by the client.
1092
+ // - `clientExecuted`: calls that arrived without
1093
+ // output (OpenAI-compatible streaming tool calls).
1094
+ // These are run by ToolExecutionLoop as before.
1095
final dedupedCalls = <String, ToolCallData>{};
1096
for (final tc in collectedToolCalls) {
1097
dedupedCalls[tc.tool] = tc;
1098
}
1093
-
1094
- final preParsedCalls = dedupedCalls.values
1095
- .map(
1096
- (tc) => ParsedToolCall(
1097
- id: tc.tool,
1098
- name: tc.tool,
1099
- arguments: tc.arguments,
1100
- ),
1101
- )
1099
+ final allCalls = dedupedCalls.values.toList();
1100
+ final serverExecuted =
1101
+ allCalls.where((tc) => tc.output != null).toList();
1102
+ final clientExecuted = allCalls
1103
+ .where((tc) => tc.output == null)
1104
.toList();
1105
1104
- final loop = ToolExecutionLoop(
1105
- adapter: adapter,
1106
- registry: registry,
1107
- onRequestApproval: (call) async {
1108
- final completer = Completer<bool>();
1109
- state = state.copyWith(
1110
- pendingToolApproval: PendingToolApproval(
1111
- toolCall: call,
1112
- completer: completer,
1113
- ),
1114
- );
1115
-
1116
- final result = await completer.future;
1106
+ final toolEvents = <ToolEvent>[];
1107
+
1108
+ if (serverExecuted.isNotEmpty) {
1109
+ final sessionId =
1110
+ DateTime.now().millisecondsSinceEpoch.toString();
1111
+ for (final tc in serverExecuted) {
1112
+ final eventId =
1113
+ '${sessionId}_1_${tc.tool}.server_executed';
1114
+ toolEvents.add(ToolEvent(
1115
+ eventId: eventId,
1116
+ timestamp: DateTime.now(),
1117
+ status: ToolEventStatus.completed,
1118
+ toolName: tc.tool,
1119
+ providerType: ToolProviderType.lmStudioServer,
1120
+ arguments: tc.arguments,
1121
+ result: tc.output,
1122
+ ));
1123
+ }
1124
+ }
1125
1118
- state = state.copyWith(clearPendingApproval: true);
1126
+ if (clientExecuted.isNotEmpty) {
1127
+ final registry = ref.read(toolRegistryProvider);
1128
+ final adapter =
1129
+ createAdapterForServerType(server.type);
1130
+
1131
+ final preParsedCalls = clientExecuted
1132
+ .map(
1133
+ (tc) => ParsedToolCall(
1134
+ id: tc.tool,
1135
+ name: tc.tool,
1136
+ arguments: tc.arguments,
1137
+ ),
1138
+ )
1139
+ .toList();
1140
+
1141
+ final loop = ToolExecutionLoop(
1142
+ adapter: adapter,
1143
+ registry: registry,
1144
+ onRequestApproval: (call) async {
1145
+ final completer = Completer<bool>();
1146
+ state = state.copyWith(
1147
+ pendingToolApproval: PendingToolApproval(
1148
+ toolCall: call,
1149
+ completer: completer,
1150
+ ),
1151
+ );
1152
+
1153
+ final result = await completer.future;
1154
+
1155
+ state = state.copyWith(clearPendingApproval: true);
1156
+
1157
+ return result;
1158
+ },
1159
+ );
1160
1120
- return result;
1121
- },
1122
- );
1161
+ final loopResult = await loop.run(
1162
+ initialUserMessage: content,
1163
+ assistantContent: streamingAssistantMessage.content,
1164
+ preParsedCalls: preParsedCalls,
1165
+ );
1166
1124
- final loopResult = await loop.run(
1125
- initialUserMessage: content,
1126
- assistantContent: streamingAssistantMessage.content,
1127
- preParsedCalls: preParsedCalls,
1128
- );
1167
+ if (loopResult.events.isNotEmpty) {
1168
+ toolEvents.addAll(loopResult.events);
1169
+ }
1170
+ }
1171
1130
- if (loopResult.events.isNotEmpty) {
1172
+ if (toolEvents.isNotEmpty) {
1173
finalMessage = finalMessage.copyWith(
1132
- toolSessionId: loop.sessionId,
1133
- toolEvents: loopResult.events,
1174
+ toolEvents: toolEvents,
1175
);
1176
}
1177
} catch (e) {
lib/features/models/components/model_tile.dart
+20
-4
@@ -149,6 +149,12 @@ class ModelTile extends StatelessWidget {
149
if ((model.serverType == ServerType.lmStudio ||
150
model.serverType == ServerType.ollama) &&
151
isLoaded) ...[
152
+ HugeIcon(
153
+ icon: HugeIcons.strokeRoundedCpu,
154
+ size: 16,
155
+ color: accent,
156
+ ),
157
+ const SizedBox(width: 6),
158
IconButton(
159
icon: HugeIcon(
160
icon: HugeIcons.strokeRoundedPower,
@@ -170,10 +176,20 @@ class ModelTile extends StatelessWidget {
176
],
177
if (isSelected) ...[
178
const SizedBox(width: 4),
173
- HugeIcon(
174
- icon: HugeIcons.strokeRoundedCheckmarkCircle01,
175
- color: accent,
176
- size: 22,
179
+ Container(
180
+ width: 22,
181
+ height: 22,
182
+ decoration: const BoxDecoration(
183
+ color: Colors.green,
184
+ shape: BoxShape.circle,
185
+ ),
186
+ child: const Center(
187
+ child: HugeIcon(
188
+ icon: HugeIcons.strokeRoundedTick01,
189
+ color: Colors.white,
190
+ size: 14,
191
+ ),
192
+ ),
193
),
194
] else if (!isLoading)
195
HugeIcon(
lib/features/models/components/ram_warning_dialog.dart
new
+503
@@ -0,0 +1,503 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:hugeicons/hugeicons.dart';
3
+import 'package:localmind/core/theme/colors.dart';
4
+import 'package:localmind/l10n/app_localizations.dart';
5
+
6
+/// A modern, high-end RAM Warning Dialog component.
7
+class RamWarningDialog extends StatelessWidget {
8
+ const RamWarningDialog({
9
+ super.key,
10
+ this.availableRam,
11
+ this.requiredRam,
12
+ this.message,
13
+ this.proceedLabel,
14
+ this.cancelLabel,
15
+ required this.onProceed,
16
+ required this.onCancel,
17
+ });
18
+
19
+ final String? availableRam;
20
+ final String? requiredRam;
21
+ final String? message;
22
+ final String? proceedLabel;
23
+ final String? cancelLabel;
24
+ final VoidCallback onProceed;
25
+ final VoidCallback onCancel;
26
+
27
+ static Future<bool> show({
28
+ required BuildContext context,
29
+ String? availableRam,
30
+ String? requiredRam,
31
+ String? message,
32
+ String? proceedLabel,
33
+ String? cancelLabel,
34
+ }) async {
35
+ final result = await showDialog<bool>(
36
+ context: context,
37
+ barrierDismissible: true,
38
+ builder: (ctx) => RamWarningDialog(
39
+ availableRam: availableRam,
40
+ requiredRam: requiredRam,
41
+ message: message,
42
+ proceedLabel: proceedLabel,
43
+ cancelLabel: cancelLabel,
44
+ onProceed: () => Navigator.of(ctx).pop(true),
45
+ onCancel: () => Navigator.of(ctx).pop(false),
46
+ ),
47
+ );
48
+ return result ?? false;
49
+ }
50
+
51
+ @override
52
+ Widget build(BuildContext context) {
53
+ final l10n = AppLocalizations.of(context)!;
54
+ final theme = Theme.of(context);
55
+ final isDark = theme.brightness == Brightness.dark;
56
+
57
+ const warningColor = AppColors.warning;
58
+
59
+ // Infer RAM specs from message if available parameters were omitted
60
+ String? availRam = availableRam;
61
+ String? reqRam = requiredRam;
62
+
63
+ if ((availRam == null || reqRam == null) && message != null) {
64
+ final matches =
65
+ RegExp(r'(\d+(?:\.\d+)?\s*(?:GB|MB))', caseSensitive: false)
66
+ .allMatches(message!)
67
+ .map((m) => m.group(0))
68
+ .whereType<String>()
69
+ .toList();
70
+ if (matches.length >= 2) {
71
+ availRam ??= matches[0];
72
+ reqRam ??= matches[1];
73
+ } else if (matches.length == 1) {
74
+ reqRam ??= matches[0];
75
+ }
76
+ }
77
+
78
+ final bodyText =
79
+ message ??
80
+ (availRam != null && reqRam != null
81
+ ? l10n.ram_warning_body_load(availRam, reqRam)
82
+ : l10n.ram_warning);
83
+
84
+ return Dialog(
85
+ shape: RoundedRectangleBorder(
86
+ borderRadius: BorderRadius.circular(24),
87
+ side: BorderSide(
88
+ color: isDark
89
+ ? AppColors.darkBorder.withValues(alpha: 0.6)
90
+ : AppColors.lightBorder,
91
+ width: 1,
92
+ ),
93
+ ),
94
+ backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
95
+ elevation: 16,
96
+ shadowColor: Colors.black.withValues(alpha: 0.3),
97
+ child: Container(
98
+ constraints: const BoxConstraints(maxWidth: 380),
99
+ padding: const EdgeInsets.all(24),
100
+ child: Column(
101
+ mainAxisSize: MainAxisSize.min,
102
+ crossAxisAlignment: CrossAxisAlignment.center,
103
+ children: [
104
+ // Glowing Warning Icon Badge
105
+ Container(
106
+ width: 60,
107
+ height: 60,
108
+ decoration: BoxDecoration(
109
+ color: warningColor.withValues(alpha: isDark ? 0.16 : 0.10),
110
+ shape: BoxShape.circle,
111
+ border: Border.all(
112
+ color: warningColor.withValues(alpha: 0.3),
113
+ width: 1.5,
114
+ ),
115
+ boxShadow: [
116
+ BoxShadow(
117
+ color: warningColor.withValues(alpha: 0.15),
118
+ blurRadius: 16,
119
+ spreadRadius: 2,
120
+ ),
121
+ ],
122
+ ),
123
+ child: Center(
124
+ child: HugeIcon(
125
+ icon: HugeIcons.strokeRoundedAlertCircle,
126
+ color: warningColor,
127
+ size: 30,
128
+ ),
129
+ ),
130
+ ),
131
+ const SizedBox(height: 16),
132
+
133
+ // Dialog Title
134
+ Text(
135
+ l10n.ram_warning,
136
+ style: TextStyle(
137
+ fontSize: 20,
138
+ fontWeight: FontWeight.w700,
139
+ color: isDark
140
+ ? AppColors.darkPrimaryText
141
+ : AppColors.lightPrimaryText,
142
+ letterSpacing: -0.3,
143
+ ),
144
+ textAlign: TextAlign.center,
145
+ ),
146
+ const SizedBox(height: 16),
147
+
148
+ // Visual RAM Stats Box (if available)
149
+ if (availRam != null && reqRam != null) ...[
150
+ Container(
151
+ padding: const EdgeInsets.symmetric(
152
+ horizontal: 16,
153
+ vertical: 14,
154
+ ),
155
+ decoration: BoxDecoration(
156
+ color: isDark
157
+ ? AppColors.darkBackground
158
+ : warningColor.withValues(alpha: 0.06),
159
+ borderRadius: BorderRadius.circular(16),
160
+ border: Border.all(
161
+ color: warningColor.withValues(alpha: 0.2),
162
+ ),
163
+ ),
164
+ child: Row(
165
+ children: [
166
+ Expanded(
167
+ child: Column(
168
+ children: [
169
+ Text(
170
+ "AVAILABLE RAM",
171
+ style: TextStyle(
172
+ fontSize: 10,
173
+ fontWeight: FontWeight.w700,
174
+ letterSpacing: 0.5,
175
+ color: isDark
176
+ ? AppColors.darkMutedText
177
+ : AppColors.lightMutedText,
178
+ ),
179
+ ),
180
+ const SizedBox(height: 4),
181
+ Text(
182
+ availRam,
183
+ style: const TextStyle(
184
+ fontSize: 17,
185
+ fontWeight: FontWeight.w800,
186
+ color: AppColors.error,
187
+ ),
188
+ ),
189
+ ],
190
+ ),
191
+ ),
192
+ Container(
193
+ height: 32,
194
+ width: 1,
195
+ color: isDark
196
+ ? AppColors.darkBorder
197
+ : warningColor.withValues(alpha: 0.25),
198
+ ),
199
+ Expanded(
200
+ child: Column(
201
+ children: [
202
+ Text(
203
+ "RECOMMENDED",
204
+ style: TextStyle(
205
+ fontSize: 10,
206
+ fontWeight: FontWeight.w700,
207
+ letterSpacing: 0.5,
208
+ color: isDark
209
+ ? AppColors.darkMutedText
210
+ : AppColors.lightMutedText,
211
+ ),
212
+ ),
213
+ const SizedBox(height: 4),
214
+ Text(
215
+ reqRam,
216
+ style: const TextStyle(
217
+ fontSize: 17,
218
+ fontWeight: FontWeight.w800,
219
+ color: warningColor,
220
+ ),
221
+ ),
222
+ ],
223
+ ),
224
+ ),
225
+ ],
226
+ ),
227
+ ),
228
+ const SizedBox(height: 16),
229
+ ],
230
+
231
+ // Body Warning Description
232
+ Text(
233
+ bodyText,
234
+ style: TextStyle(
235
+ fontSize: 13.5,
236
+ height: 1.45,
237
+ color: isDark
238
+ ? AppColors.darkMutedText
239
+ : AppColors.lightMutedText,
240
+ ),
241
+ textAlign: TextAlign.center,
242
+ ),
243
+ const SizedBox(height: 24),
244
+
245
+ // Actions Buttons
246
+ Row(
247
+ children: [
248
+ Expanded(
249
+ child: SizedBox(
250
+ height: 46,
251
+ child: OutlinedButton(
252
+ onPressed: onCancel,
253
+ style: OutlinedButton.styleFrom(
254
+ side: BorderSide(
255
+ color: isDark
256
+ ? AppColors.darkBorder
257
+ : AppColors.lightBorder,
258
+ ),
259
+ shape: RoundedRectangleBorder(
260
+ borderRadius: BorderRadius.circular(12),
261
+ ),
262
+ foregroundColor: isDark
263
+ ? AppColors.darkPrimaryText
264
+ : AppColors.lightPrimaryText,
265
+ ),
266
+ child: Text(
267
+ cancelLabel ?? l10n.cancel,
268
+ style: const TextStyle(
269
+ fontWeight: FontWeight.w600,
270
+ fontSize: 14,
271
+ ),
272
+ ),
273
+ ),
274
+ ),
275
+ ),
276
+ const SizedBox(width: 12),
277
+ Expanded(
278
+ child: SizedBox(
279
+ height: 46,
280
+ child: FilledButton(
281
+ onPressed: onProceed,
282
+ style: FilledButton.styleFrom(
283
+ shape: RoundedRectangleBorder(
284
+ borderRadius: BorderRadius.circular(12),
285
+ ),
286
+ elevation: 0,
287
+ ),
288
+ child: FittedBox(
289
+ child: Text(
290
+ proceedLabel ?? l10n.proceed_anyway,
291
+ style: const TextStyle(
292
+ fontWeight: FontWeight.w700,
293
+ fontSize: 14,
294
+ ),
295
+ ),
296
+ ),
297
+ ),
298
+ ),
299
+ ),
300
+ ],
301
+ ),
302
+ ],
303
+ ),
304
+ ),
305
+ );
306
+ }
307
+}
308
+
309
+/// A modern, styled confirmation dialog for model operations (e.g. Delete Model).
310
+class ModelConfirmDialog extends StatelessWidget {
311
+ const ModelConfirmDialog({
312
+ super.key,
313
+ required this.title,
314
+ required this.message,
315
+ required this.confirmLabel,
316
+ required this.cancelLabel,
317
+ this.isDestructive = true,
318
+ this.icon,
319
+ required this.onConfirm,
320
+ required this.onCancel,
321
+ });
322
+
323
+ final String title;
324
+ final String message;
325
+ final String confirmLabel;
326
+ final String cancelLabel;
327
+ final bool isDestructive;
328
+ final dynamic icon;
329
+ final VoidCallback onConfirm;
330
+ final VoidCallback onCancel;
331
+
332
+ static Future<bool> show({
333
+ required BuildContext context,
334
+ required String title,
335
+ required String message,
336
+ required String confirmLabel,
337
+ required String cancelLabel,
338
+ bool isDestructive = true,
339
+ dynamic icon,
340
+ }) async {
341
+ final result = await showDialog<bool>(
342
+ context: context,
343
+ barrierDismissible: true,
344
+ builder: (ctx) => ModelConfirmDialog(
345
+ title: title,
346
+ message: message,
347
+ confirmLabel: confirmLabel,
348
+ cancelLabel: cancelLabel,
349
+ isDestructive: isDestructive,
350
+ icon: icon,
351
+ onConfirm: () => Navigator.of(ctx).pop(true),
352
+ onCancel: () => Navigator.of(ctx).pop(false),
353
+ ),
354
+ );
355
+ return result ?? false;
356
+ }
357
+
358
+ @override
359
+ Widget build(BuildContext context) {
360
+ final theme = Theme.of(context);
361
+ final isDark = theme.brightness == Brightness.dark;
362
+
363
+ final accentColor = isDestructive
364
+ ? AppColors.error
365
+ : theme.colorScheme.primary;
366
+ final dialogIcon =
367
+ icon ??
368
+ (isDestructive
369
+ ? HugeIcons.strokeRoundedDelete02
370
+ : HugeIcons.strokeRoundedAlertCircle);
371
+
372
+ return Dialog(
373
+ shape: RoundedRectangleBorder(
374
+ borderRadius: BorderRadius.circular(24),
375
+ side: BorderSide(
376
+ color: isDark
377
+ ? AppColors.darkBorder.withValues(alpha: 0.6)
378
+ : AppColors.lightBorder,
379
+ width: 1,
380
+ ),
381
+ ),
382
+ backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
383
+ elevation: 16,
384
+ shadowColor: Colors.black.withValues(alpha: 0.3),
385
+ child: Container(
386
+ constraints: const BoxConstraints(maxWidth: 380),
387
+ padding: const EdgeInsets.all(24),
388
+ child: Column(
389
+ mainAxisSize: MainAxisSize.min,
390
+ crossAxisAlignment: CrossAxisAlignment.center,
391
+ children: [
392
+ // Icon Badge Header
393
+ Container(
394
+ width: 56,
395
+ height: 56,
396
+ decoration: BoxDecoration(
397
+ color: accentColor.withValues(alpha: isDark ? 0.16 : 0.10),
398
+ shape: BoxShape.circle,
399
+ border: Border.all(
400
+ color: accentColor.withValues(alpha: 0.3),
401
+ width: 1.5,
402
+ ),
403
+ ),
404
+ child: Center(
405
+ child: HugeIcon(icon: dialogIcon, color: accentColor, size: 28),
406
+ ),
407
+ ),
408
+ const SizedBox(height: 16),
409
+
410
+ // Title
411
+ Text(
412
+ title,
413
+ style: TextStyle(
414
+ fontSize: 19,
415
+ fontWeight: FontWeight.w700,
416
+ color: isDark
417
+ ? AppColors.darkPrimaryText
418
+ : AppColors.lightPrimaryText,
419
+ letterSpacing: -0.3,
420
+ ),
421
+ textAlign: TextAlign.center,
422
+ ),
423
+ const SizedBox(height: 12),
424
+
425
+ // Message
426
+ Text(
427
+ message,
428
+ style: TextStyle(
429
+ fontSize: 13.5,
430
+ height: 1.45,
431
+ color: isDark
432
+ ? AppColors.darkMutedText
433
+ : AppColors.lightMutedText,
434
+ ),
435
+ textAlign: TextAlign.center,
436
+ ),
437
+ const SizedBox(height: 24),
438
+
439
+ // Buttons
440
+ Row(
441
+ children: [
442
+ Expanded(
443
+ child: SizedBox(
444
+ height: 46,
445
+ child: OutlinedButton(
446
+ onPressed: onCancel,
447
+ style: OutlinedButton.styleFrom(
448
+ side: BorderSide(
449
+ color: isDark
450
+ ? AppColors.darkBorder
451
+ : AppColors.lightBorder,
452
+ ),
453
+ shape: RoundedRectangleBorder(
454
+ borderRadius: BorderRadius.circular(12),
455
+ ),
456
+ foregroundColor: isDark
457
+ ? AppColors.darkPrimaryText
458
+ : AppColors.lightPrimaryText,
459
+ ),
460
+ child: Text(
461
+ cancelLabel,
462
+ style: const TextStyle(
463
+ fontWeight: FontWeight.w600,
464
+ fontSize: 14,
465
+ ),
466
+ ),
467
+ ),
468
+ ),
469
+ ),
470
+ const SizedBox(width: 12),
471
+ Expanded(
472
+ child: SizedBox(
473
+ height: 46,
474
+ child: FilledButton(
475
+ onPressed: onConfirm,
476
+ style: FilledButton.styleFrom(
477
+ backgroundColor: accentColor,
478
+ foregroundColor: isDestructive
479
+ ? Colors.white
480
+ : Colors.black,
481
+ shape: RoundedRectangleBorder(
482
+ borderRadius: BorderRadius.circular(12),
483
+ ),
484
+ elevation: 0,
485
+ ),
486
+ child: Text(
487
+ confirmLabel,
488
+ style: const TextStyle(
489
+ fontWeight: FontWeight.w700,
490
+ fontSize: 14,
491
+ ),
492
+ ),
493
+ ),
494
+ ),
495
+ ),
496
+ ],
497
+ ),
498
+ ],
499
+ ),
500
+ ),
501
+ );
502
+ }
503
+}
lib/features/on_device/components/model_card.dart
+19
-41
@@ -15,6 +15,7 @@ import 'package:localmind/features/on_device/data/models/download_status.dart';
15
import 'package:localmind/features/on_device/providers/on_device_providers.dart';
16
import 'package:localmind/features/on_device/providers/foreground_download_providers.dart';
17
import 'package:localmind/l10n/app_localizations.dart';
18
+import 'package:localmind/features/models/components/ram_warning_dialog.dart';
19
20
class ModelCard extends ConsumerWidget {
21
const ModelCard({
@@ -435,8 +436,10 @@ class _DownloadedActions extends ConsumerWidget {
436
children: [
437
Row(
438
children: [
438
- HugeIcon(icon:
439
- isLoaded ? HugeIcons.strokeRoundedCheckmarkCircle01 : HugeIcons.strokeRoundedCheckmarkCircle01,
439
+ HugeIcon(
440
+ icon: isLoaded
441
+ ? HugeIcons.strokeRoundedCpu
442
+ : HugeIcons.strokeRoundedCheckmarkCircle01,
443
color: isLoaded ? Colors.green : Colors.grey,
444
size: 18,
445
),
@@ -492,22 +495,14 @@ class _DownloadedActions extends ConsumerWidget {
495
}
496
497
Future<void> _deleteModel(BuildContext context, WidgetRef ref) async {
495
- final confirm = await showDialog<bool>(
498
+ final confirm = await ModelConfirmDialog.show(
499
context: context,
497
- builder: (ctx) => AlertDialog(
498
- title: Text(l10n.delete_model_title),
499
- content: Text(l10n.delete_model_body(model.name)),
500
- actions: [
501
- TextButton(
502
- onPressed: () => Navigator.pop(ctx, false),
503
- child: Text(l10n.cancel),
504
- ),
505
- TextButton(
506
- onPressed: () => Navigator.pop(ctx, true),
507
- child: Text(l10n.delete),
508
- ),
509
- ],
510
- ),
500
+ title: l10n.delete_model_title,
501
+ message: l10n.delete_model_body(model.name),
502
+ confirmLabel: l10n.delete,
503
+ cancelLabel: l10n.cancel,
504
+ isDestructive: true,
505
+ icon: HugeIcons.strokeRoundedDelete02,
506
);
507
508
if (confirm == true) {
@@ -764,31 +759,14 @@ class _NotDownloadedActions extends ConsumerWidget {
759
}
760
761
Future<bool> _showRamWarning(BuildContext context, String message) async {
767
- final l10n = AppLocalizations.of(context)!;
768
- final result = await showDialog<bool>(
762
+ final availableRam = deviceMemory?.availableMemoryFormatted;
763
+ final requiredRam = '${model.minRamMb ~/ 1024 + 1} GB';
764
+
765
+ return RamWarningDialog.show(
766
context: context,
770
- builder: (ctx) => AlertDialog(
771
- title: Row(
772
- children: [
773
- const HugeIcon(icon: HugeIcons.strokeRoundedAlertCircle, color: Colors.orange),
774
- const SizedBox(width: 8),
775
- Text(l10n.ram_warning),
776
- ],
777
- ),
778
- content: Text(message),
779
- actions: [
780
- TextButton(
781
- onPressed: () => Navigator.pop(ctx, false),
782
- child: Text(l10n.cancel),
783
- ),
784
- TextButton(
785
- onPressed: () => Navigator.pop(ctx, true),
786
- style: TextButton.styleFrom(foregroundColor: Colors.orange),
787
- child: Text(l10n.proceed_anyway),
788
- ),
789
- ],
790
- ),
767
+ availableRam: availableRam,
768
+ requiredRam: requiredRam,
769
+ message: message,
770
);
792
- return result ?? false;
771
}
772
}
\ No newline at end of file
lib/features/on_device/components/on_device_picker_section.dart
+34
-53
@@ -20,6 +20,7 @@ import 'package:localmind/l10n/app_localizations.dart';
20
import 'package:localmind/core/routes/app_routes.dart';
21
import 'package:localmind/features/models/components/metadata_chip.dart';
22
import 'package:localmind/features/models/components/inline_thinking_selector.dart';
23
+import 'package:localmind/features/models/components/ram_warning_dialog.dart';
24
25
class OnDevicePickerSection extends ConsumerWidget {
26
const OnDevicePickerSection({
@@ -400,13 +401,10 @@ class _OnDeviceModelTile extends ConsumerWidget {
401
),
402
const SizedBox(width: 8),
403
if (isLoaded) ...[
403
- Container(
404
- width: 10,
405
- height: 10,
406
- decoration: const BoxDecoration(
407
- color: AppColors.darkAccent,
408
- shape: BoxShape.circle,
409
- ),
404
+ HugeIcon(
405
+ icon: HugeIcons.strokeRoundedCpu,
406
+ size: 16,
407
+ color: accent,
408
),
409
const SizedBox(width: 8),
410
_IconButton(
@@ -490,9 +488,23 @@ class _OnDeviceModelTile extends ConsumerWidget {
488
),
489
),
490
],
493
- if (isDownloaded && isSelected && isLoaded) ...[
491
+ if (isSelected) ...[
492
const SizedBox(width: 4),
495
- HugeIcon(icon: HugeIcons.strokeRoundedCheckmarkCircle01, color: accent, size: 22),
493
+ Container(
494
+ width: 22,
495
+ height: 22,
496
+ decoration: const BoxDecoration(
497
+ color: Colors.green,
498
+ shape: BoxShape.circle,
499
+ ),
500
+ child: const Center(
501
+ child: HugeIcon(
502
+ icon: HugeIcons.strokeRoundedTick01,
503
+ color: Colors.white,
504
+ size: 14,
505
+ ),
506
+ ),
507
+ ),
508
],
509
],
510
),
@@ -559,37 +571,14 @@ class _OnDeviceModelTile extends ConsumerWidget {
571
}
572
573
Future<bool> _showRamWarning(BuildContext context) async {
562
- final l10n = AppLocalizations.of(context)!;
563
- final result = await showDialog<bool>(
574
+ final availableRam = deviceMemory?.availableMemoryFormatted;
575
+ final requiredRam = '${model.minRamMb ~/ 1024 + 1} GB';
576
+
577
+ return RamWarningDialog.show(
578
context: context,
565
- builder: (ctx) => AlertDialog(
566
- title: Row(
567
- children: [
568
- const HugeIcon(icon: HugeIcons.strokeRoundedAlertCircle, color: Colors.orange),
569
- const SizedBox(width: 8),
570
- Text(l10n.ram_warning),
571
- ],
572
- ),
573
- content: Text(
574
- l10n.ram_warning_body_load(
575
- deviceMemory!.availableMemoryFormatted,
576
- '${model.minRamMb ~/ 1024 + 1}',
577
- ),
578
- ),
579
- actions: [
580
- TextButton(
581
- onPressed: () => Navigator.pop(ctx, false),
582
- child: Text(l10n.cancel),
583
- ),
584
- TextButton(
585
- onPressed: () => Navigator.pop(ctx, true),
586
- style: TextButton.styleFrom(foregroundColor: Colors.orange),
587
- child: Text(l10n.proceed_anyway),
588
- ),
589
- ],
590
- ),
579
+ availableRam: availableRam,
580
+ requiredRam: requiredRam,
581
);
592
- return result ?? false;
582
}
583
584
void _selectModel(BuildContext context, WidgetRef ref) {
@@ -633,22 +622,14 @@ class _OnDeviceModelTile extends ConsumerWidget {
622
623
void _deleteModel(BuildContext context, WidgetRef ref) async {
624
final l10n = AppLocalizations.of(context)!;
636
- final confirm = await showDialog<bool>(
625
+ final confirm = await ModelConfirmDialog.show(
626
context: context,
638
- builder: (ctx) => AlertDialog(
639
- title: Text(l10n.delete_model_title),
640
- content: Text(l10n.delete_model_body(model.name)),
641
- actions: [
642
- TextButton(
643
- onPressed: () => Navigator.pop(ctx, false),
644
- child: Text(l10n.cancel),
645
- ),
646
- TextButton(
647
- onPressed: () => Navigator.pop(ctx, true),
648
- child: Text(l10n.delete),
649
- ),
650
- ],
651
- ),
627
+ title: l10n.delete_model_title,
628
+ message: l10n.delete_model_body(model.name),
629
+ confirmLabel: l10n.delete,
630
+ cancelLabel: l10n.cancel,
631
+ isDestructive: true,
632
+ icon: HugeIcons.strokeRoundedDelete02,
633
);
634
635
if (confirm == true) {
lib/features/on_device/data/on_device_chat_service.dart
-1
@@ -35,7 +35,6 @@ class OnDeviceChatService implements ChatService {
35
required ChatParameters params,
36
List<McpIntegration>? integrations,
37
List<ToolDefinition>? tools,
38
- String? previousResponseId,
38
bool continueGeneration = false,
39
}) {
40
cancelStream();
lib/features/on_device/data/on_device_llama_service.dart
-1
@@ -239,7 +239,6 @@ class OnDeviceLlamaChatService implements ChatService {
239
required ChatParameters params,
240
List<McpIntegration>? integrations,
241
List<ToolDefinition>? tools,
242
- String? previousResponseId,
242
bool continueGeneration = false,
243
}) async* {
244
_isCancelled = false;
lib/features/on_device/views/model_manager_screen.dart
+1
-1
@@ -1264,7 +1264,7 @@ class _EngineStatusCard extends StatelessWidget {
1264
),
1265
child: Row(
1266
children: [
1267
- const HugeIcon(icon: HugeIcons.strokeRoundedCheckmarkCircle01, color: Colors.green, size: 20),
1267
+ const HugeIcon(icon: HugeIcons.strokeRoundedCpu, color: Colors.green, size: 20),
1268
const SizedBox(width: 8),
1269
Expanded(
1270
child: Text(
lib/features/onboarding/screens/onboarding_model_download_screen.dart
+7
-31
@@ -20,6 +20,7 @@ import '../../servers/data/models/server.dart';
20
import '../../servers/providers/server_providers.dart';
21
import '../../../core/providers/device_info_providers.dart';
22
import '../../../core/device/device_memory_service.dart';
23
+import '../../models/components/ram_warning_dialog.dart';
24
25
class OnboardingModelDownloadScreen extends ConsumerStatefulWidget {
26
const OnboardingModelDownloadScreen({super.key});
@@ -584,38 +585,13 @@ class _ModelCard extends ConsumerWidget {
585
}
586
587
Future<bool> _showRamWarning(BuildContext context) async {
587
- final result = await showDialog<bool>(
588
+ final availableRam = deviceMemory?.totalMemoryFormatted;
589
+ final requiredRam = '${model.minRamMb ~/ 1024 + 1} GB';
590
+
591
+ return RamWarningDialog.show(
592
context: context,
589
- builder: (ctx) {
590
- final dialogL10n = AppLocalizations.of(ctx)!;
591
- return AlertDialog(
592
- title: Row(
593
- children: [
594
- const HugeIcon(icon: HugeIcons.strokeRoundedAlertCircle, color: Colors.orange),
595
- const SizedBox(width: 8),
596
- Text(dialogL10n.ram_warning),
597
- ],
598
- ),
599
- content: Text(
600
- dialogL10n.ram_warning_body_download(
601
- '${model.minRamMb ~/ 1024 + 1}',
602
- deviceMemory!.totalMemoryFormatted,
603
- ),
604
- ),
605
- actions: [
606
- TextButton(
607
- onPressed: () => Navigator.pop(ctx, false),
608
- child: Text(dialogL10n.cancel),
609
- ),
610
- TextButton(
611
- onPressed: () => Navigator.pop(ctx, true),
612
- style: TextButton.styleFrom(foregroundColor: Colors.orange),
613
- child: Text(dialogL10n.proceed_anyway),
614
- ),
615
- ],
616
- );
617
- },
593
+ availableRam: availableRam,
594
+ requiredRam: requiredRam,
595
);
619
- return result ?? false;
596
}
597
}
\ No newline at end of file
lib/features/servers/data/models/server.dart
+4
-1
@@ -173,7 +173,10 @@ class Server {
173
String get chatEndpoint {
174
switch (type) {
175
case ServerType.lmStudio:
176
- return '$baseUrl/v1/chat/completions';
176
+ // LM Studio's native REST endpoint. MCP integrations (both
177
+ // ephemeral_mcp and plugin) are only supported here, not on the
178
+ // OpenAI-compatible /v1/chat/completions or /v1/responses endpoints.
179
+ return '$baseUrl/api/v1/chat';
180
case ServerType.openAICompatible:
181
return '$baseUrl/v1/chat/completions';
182
case ServerType.ollama:
lib/features/tts/providers/tts_providers.dart
+132
-38
@@ -46,6 +46,11 @@ class TtsState {
46
/// Index of the chunk currently being read. -1 when nothing is playing.
47
final int currentChunkIndex;
48
49
+ /// Character offset currently reported by a TTS engine with native
50
+ /// word-progress support. Falls back to an estimated offset for engines
51
+ /// that only expose playback time.
52
+ final int spokenCharOffset;
53
+
54
const TtsState({
55
this.isSpeaking = false,
56
this.isPaused = false,
@@ -63,6 +68,7 @@ class TtsState {
68
this.playbackSpeed = 1.0,
69
this.chunks = const [],
70
this.currentChunkIndex = -1,
71
+ this.spokenCharOffset = -1,
72
});
73
74
TtsState copyWith({
@@ -84,6 +90,7 @@ class TtsState {
90
bool resetProgress = false,
91
List<String>? chunks,
92
int? currentChunkIndex,
93
+ int? spokenCharOffset,
94
}) {
95
return TtsState(
96
isSpeaking: isSpeaking ?? this.isSpeaking,
@@ -92,8 +99,9 @@ class TtsState {
99
error: error,
100
activeEngine: activeEngine ?? this.activeEngine,
101
playingContent: playingContent,
95
- playingMessageId:
96
- clearPlayingTarget ? null : (playingMessageId ?? this.playingMessageId),
102
+ playingMessageId: clearPlayingTarget
103
+ ? null
104
+ : (playingMessageId ?? this.playingMessageId),
105
playingConversationId: clearPlayingTarget
106
? null
107
: (playingConversationId ?? this.playingConversationId),
@@ -105,6 +113,9 @@ class TtsState {
113
playbackSpeed: playbackSpeed ?? this.playbackSpeed,
114
chunks: chunks ?? this.chunks,
115
currentChunkIndex: currentChunkIndex ?? this.currentChunkIndex,
116
+ spokenCharOffset: resetProgress
117
+ ? -1
118
+ : (spokenCharOffset ?? this.spokenCharOffset),
119
);
120
}
121
}
@@ -152,12 +163,14 @@ class TtsNotifier extends Notifier<TtsState> {
163
final Map<int, Duration> _chunkDurations = {};
164
Duration _totalDuration = Duration.zero;
165
Duration _chunkOffset = Duration.zero;
155
- final StreamController<void> _onChunkChanged = StreamController<void>.broadcast();
166
+ final StreamController<void> _onChunkChanged =
167
+ StreamController<void>.broadcast();
168
169
// System TTS approximate seek
170
String? _systemFullText;
171
Duration _systemSkippedOffset = Duration.zero;
172
DateTime? _systemSpeakStart;
173
+ bool _systemHasNativeProgress = false;
174
int _systemSpeakGeneration = 0;
175
176
_MessageAudioCache? _messageAudioCache;
@@ -182,7 +195,9 @@ class TtsNotifier extends Notifier<TtsState> {
195
196
_player.setAudioSources([]);
197
185
- _playerCompleteSubscription = _player.playerStateStream.listen((playerState) {
198
+ _playerCompleteSubscription = _player.playerStateStream.listen((
199
+ playerState,
200
+ ) {
201
if (playerState.processingState != ProcessingState.completed) return;
202
if (_isStopping || state.isInitializing) return;
203
if (_currentSessionId != _playbackSessionId) return;
@@ -292,9 +307,7 @@ class TtsNotifier extends Notifier<TtsState> {
307
_mainReceivePort.sendPort,
308
);
309
295
- await _workerPortCompleter!.future.timeout(
296
- const Duration(seconds: 10),
297
- );
310
+ await _workerPortCompleter!.future.timeout(const Duration(seconds: 10));
311
}
312
313
void _computeChunkDuration(int index, Uint8List wavBytes) {
@@ -333,9 +346,7 @@ class TtsNotifier extends Notifier<TtsState> {
346
if (_chunks.isEmpty) return Duration.zero;
347
if (_chunkDurations.length >= _chunks.length) return _totalDuration;
348
final avg = _averageChunkDuration();
336
- return Duration(
337
- milliseconds: avg.inMilliseconds * _chunks.length,
338
- );
349
+ return Duration(milliseconds: avg.inMilliseconds * _chunks.length);
350
}
351
352
Duration _computeGlobalPosition() {
@@ -358,7 +369,8 @@ class TtsNotifier extends Notifier<TtsState> {
369
state = state.copyWith(
370
position: position,
371
duration: duration,
361
- canSeek: !state.isInitializing &&
372
+ canSeek:
373
+ !state.isInitializing &&
374
duration > Duration.zero &&
375
_nextPlaylistIndexToAdd >= _chunks.length,
376
);
@@ -385,10 +397,52 @@ class TtsNotifier extends Notifier<TtsState> {
397
_systemProgressTimer = null;
398
}
399
400
+ void _startSystemProgressTimer(int speakGeneration) {
401
+ _stopSystemProgressTimer();
402
+ _systemProgressTimer = Timer.periodic(
403
+ const Duration(milliseconds: 80),
404
+ (timer) {
405
+ if (_isStopping ||
406
+ speakGeneration != _systemSpeakGeneration ||
407
+ !state.isSpeaking ||
408
+ state.activeEngine != EngineId.system) {
409
+ timer.cancel();
410
+ if (identical(_systemProgressTimer, timer)) {
411
+ _systemProgressTimer = null;
412
+ }
413
+ return;
414
+ }
415
+
416
+ final duration = _systemEstimatedDuration();
417
+ final position = _systemEstimatedPosition();
418
+ final text = _systemFullText ?? '';
419
+ int? estimatedCharOffset;
420
+ if (!_systemHasNativeProgress && text.isNotEmpty) {
421
+ final durationMicros = duration.inMicroseconds;
422
+ final progress = durationMicros <= 0
423
+ ? 0.0
424
+ : position.inMicroseconds / durationMicros;
425
+ estimatedCharOffset = (progress * text.length).floor().clamp(
426
+ 0,
427
+ text.length - 1,
428
+ );
429
+ }
430
+
431
+ state = state.copyWith(
432
+ position: position,
433
+ duration: duration,
434
+ spokenCharOffset: estimatedCharOffset,
435
+ playingContent: state.playingContent,
436
+ );
437
+ },
438
+ );
439
+ }
440
+
441
void _resetSystemSeekState() {
442
_systemFullText = null;
443
_systemSkippedOffset = Duration.zero;
444
_systemSpeakStart = null;
445
+ _systemHasNativeProgress = false;
446
_stopSystemProgressTimer();
447
}
448
@@ -495,9 +549,7 @@ class TtsNotifier extends Notifier<TtsState> {
549
await _ensureWorker();
550
551
if (_isWorkerInitializing && _initCompleter != null) {
498
- await _initCompleter!.future.timeout(
499
- const Duration(seconds: 30),
500
- );
552
+ await _initCompleter!.future.timeout(const Duration(seconds: 30));
553
}
554
555
String modelPath = '';
@@ -581,9 +633,7 @@ class TtsNotifier extends Notifier<TtsState> {
633
),
634
);
635
584
- await _initCompleter!.future.timeout(
585
- const Duration(seconds: 30),
586
- );
636
+ await _initCompleter!.future.timeout(const Duration(seconds: 30));
637
_currentEngine = engineId;
638
_currentModelPath = modelPath;
639
_currentVoicesPath = voicesPath;
@@ -657,7 +707,9 @@ class TtsNotifier extends Notifier<TtsState> {
707
final speakGeneration = ++_systemSpeakGeneration;
708
_systemFullText = text;
709
_systemSkippedOffset = Duration.zero;
660
- _systemSpeakStart = DateTime.now();
710
+ _systemSpeakStart = null;
711
+ _systemHasNativeProgress = false;
712
+ final estimatedDuration = _systemEstimatedDuration();
713
state = state.copyWith(
714
isSpeaking: true,
715
isPaused: false,
@@ -669,11 +721,38 @@ class TtsNotifier extends Notifier<TtsState> {
721
playingConversationId: isPreview ? null : conversationId,
722
isPreview: isPreview,
723
canSeek: false,
672
- resetProgress: true,
724
+ position: Duration.zero,
725
+ duration: estimatedDuration,
726
+ chunks: [text],
727
+ currentChunkIndex: 0,
728
+ spokenCharOffset: 0,
729
);
730
try {
731
await _flutterTts!.setLanguage('en-US');
732
await _applySystemSpeechRate();
733
+ _flutterTts!.setStartHandler(() {
734
+ if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
735
+ _systemSpeakStart = DateTime.now();
736
+ _startSystemProgressTimer(speakGeneration);
737
+ });
738
+ _flutterTts!.setProgressHandler((spokenText, start, end, word) {
739
+ if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
740
+ if (text.isEmpty) return;
741
+
742
+ _systemHasNativeProgress = true;
743
+ final safeStart = start.clamp(0, text.length - 1);
744
+ final duration = _systemEstimatedDuration();
745
+ final position = Duration(
746
+ microseconds: (duration.inMicroseconds * (safeStart / text.length))
747
+ .round(),
748
+ );
749
+ state = state.copyWith(
750
+ position: position,
751
+ duration: duration,
752
+ spokenCharOffset: safeStart,
753
+ playingContent: state.playingContent,
754
+ );
755
+ });
756
_flutterTts!.setCompletionHandler(() {
757
if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
758
_resetSystemSeekState();
@@ -684,6 +763,8 @@ class TtsNotifier extends Notifier<TtsState> {
763
clearPlayingTarget: true,
764
resetProgress: true,
765
canSeek: false,
766
+ chunks: const [],
767
+ currentChunkIndex: -1,
768
);
769
});
770
_flutterTts!.setErrorHandler((msg) {
@@ -697,12 +778,16 @@ class TtsNotifier extends Notifier<TtsState> {
778
clearPlayingTarget: true,
779
resetProgress: true,
780
canSeek: false,
781
+ chunks: const [],
782
+ currentChunkIndex: -1,
783
);
784
});
785
_flutterTts!.setCancelHandler(() {
786
if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
787
});
788
await _flutterTts!.awaitSpeakCompletion(true);
789
+ _systemSpeakStart = DateTime.now();
790
+ _startSystemProgressTimer(speakGeneration);
791
await _flutterTts!.speak(text);
792
} catch (e) {
793
_resetSystemSeekState();
@@ -714,6 +799,8 @@ class TtsNotifier extends Notifier<TtsState> {
799
clearPlayingTarget: true,
800
resetProgress: true,
801
canSeek: false,
802
+ chunks: const [],
803
+ currentChunkIndex: -1,
804
);
805
rethrow;
806
}
@@ -753,6 +840,7 @@ class TtsNotifier extends Notifier<TtsState> {
840
playingMessageId: isPreview ? null : messageId,
841
playingConversationId: isPreview ? null : conversationId,
842
isPreview: isPreview,
843
+ spokenCharOffset: -1,
844
);
845
846
try {
@@ -788,12 +876,16 @@ class TtsNotifier extends Notifier<TtsState> {
876
877
final source = AudioSource.file(
878
tempFile.path,
791
- tag: _isPreview ? null : MediaItem(
792
- id: 'tts_chunk_${_currentSessionId}_$chunkIndex',
793
- album: 'LocalMind TTS',
794
- title: _chunks[chunkIndex],
795
- artist: _currentEngine == EngineId.kitten ? 'Kitten TTS' : 'Piper TTS',
796
- ),
879
+ tag: _isPreview
880
+ ? null
881
+ : MediaItem(
882
+ id: 'tts_chunk_${_currentSessionId}_$chunkIndex',
883
+ album: 'LocalMind TTS',
884
+ title: _chunks[chunkIndex],
885
+ artist: _currentEngine == EngineId.kitten
886
+ ? 'Kitten TTS'
887
+ : 'Piper TTS',
888
+ ),
889
);
890
891
_playlistBuffer[chunkIndex] = source;
@@ -914,18 +1006,14 @@ class TtsNotifier extends Notifier<TtsState> {
1006
Future<void> skipForward() async {
1007
if (state.activeEngine == EngineId.system) return;
1008
if (!state.isSpeaking && !state.isPaused) return;
917
- final skip = Duration(
918
- seconds: ref.read(settingsProvider).ttsSkipSeconds,
919
- );
1009
+ final skip = Duration(seconds: ref.read(settingsProvider).ttsSkipSeconds);
1010
await seekTo(_computeGlobalPosition() + skip);
1011
}
1012
1013
Future<void> skipBackward() async {
1014
if (state.activeEngine == EngineId.system) return;
1015
if (!state.isSpeaking && !state.isPaused) return;
926
- final skip = Duration(
927
- seconds: ref.read(settingsProvider).ttsSkipSeconds,
928
- );
1016
+ final skip = Duration(seconds: ref.read(settingsProvider).ttsSkipSeconds);
1017
await seekTo(_computeGlobalPosition() - skip);
1018
}
1019
@@ -971,7 +1059,9 @@ class TtsNotifier extends Notifier<TtsState> {
1059
void resume() {
1060
if (!state.isPaused) return;
1061
if (state.activeEngine == EngineId.system) {
974
- unawaited(_flutterTts?.speak(_systemFullText ?? state.playingContent ?? ''));
1062
+ unawaited(
1063
+ _flutterTts?.speak(_systemFullText ?? state.playingContent ?? ''),
1064
+ );
1065
} else {
1066
_player.play();
1067
}
@@ -1034,7 +1124,9 @@ class TtsNotifier extends Notifier<TtsState> {
1124
void cyclePlaybackSpeed() {
1125
final options = playbackSpeedOptions;
1126
final currentIndex = options.indexOf(state.playbackSpeed);
1037
- final nextIndex = currentIndex < 0 ? 2 : (currentIndex + 1) % options.length;
1127
+ final nextIndex = currentIndex < 0
1128
+ ? 2
1129
+ : (currentIndex + 1) % options.length;
1130
unawaited(setPlaybackSpeed(options[nextIndex]));
1131
}
1132
@@ -1095,10 +1187,7 @@ class TtsNotifier extends Notifier<TtsState> {
1187
}
1188
}
1189
1098
- state = state.copyWith(
1099
- isInitializing: false,
1100
- canSeek: true,
1101
- );
1190
+ state = state.copyWith(isInitializing: false, canSeek: true);
1191
if (!state.isPaused) {
1192
await _player.play();
1193
}
@@ -1171,7 +1260,12 @@ class TtsNotifier extends Notifier<TtsState> {
1260
}
1261
1262
Future<void> previewVoice(Voice voice) async {
1174
- await speak(ttsPreviewSample, engineId: voice.engine, voiceId: voice.id, isPreview: true);
1263
+ await speak(
1264
+ ttsPreviewSample,
1265
+ engineId: voice.engine,
1266
+ voiceId: voice.id,
1267
+ isPreview: true,
1268
+ );
1269
}
1270
}
1271
lib/features/voice_mode/views/components/voice_transcript.dart
+201
-48
@@ -62,8 +62,9 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
62
ttsProvider.select((s) => s.isInitializing),
63
);
64
65
- final (label, badgeColor, isSynthesizing) =
66
- _resolveStatusBadge(isTtsInitializing);
65
+ final (label, badgeColor, isSynthesizing) = _resolveStatusBadge(
66
+ isTtsInitializing,
67
+ );
68
69
return Column(
70
mainAxisSize: MainAxisSize.min,
@@ -143,9 +144,11 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
144
145
switch (widget.phase) {
146
case VoiceModePhase.listening:
146
- return ('LISTENING',
147
- VoiceModePalette.accentFor(VoiceModePhase.listening, isDark: isDark),
148
- false);
147
+ return (
148
+ 'LISTENING',
149
+ VoiceModePalette.accentFor(VoiceModePhase.listening, isDark: isDark),
150
+ false,
151
+ );
152
case VoiceModePhase.processing:
153
final hasResponse = widget.response.trim().isNotEmpty;
154
if (isTtsInitializing) {
@@ -157,13 +160,17 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
160
false,
161
);
162
case VoiceModePhase.speaking:
160
- return ('SPEAKING',
161
- VoiceModePalette.accentFor(VoiceModePhase.speaking, isDark: isDark),
162
- false);
163
+ return (
164
+ 'SPEAKING',
165
+ VoiceModePalette.accentFor(VoiceModePhase.speaking, isDark: isDark),
166
+ false,
167
+ );
168
case VoiceModePhase.idle:
164
- return ('READY',
165
- VoiceModePalette.accentFor(VoiceModePhase.idle, isDark: isDark),
166
- false);
169
+ return (
170
+ 'READY',
171
+ VoiceModePalette.accentFor(VoiceModePhase.idle, isDark: isDark),
172
+ false,
173
+ );
174
case VoiceModePhase.error:
175
final errorText = widget.error ?? '';
176
final isSpeechError = errorText.toLowerCase().contains('speech');
@@ -235,8 +242,9 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
242
);
243
244
case VoiceModePhase.listening:
238
- final displayText =
239
- widget.transcript.isEmpty ? 'Listening...' : widget.transcript;
245
+ final displayText = widget.transcript.isEmpty
246
+ ? 'Listening...'
247
+ : widget.transcript;
248
return Text(
249
displayText,
250
key: const ValueKey('listening'),
@@ -252,7 +260,7 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
260
);
261
262
case VoiceModePhase.speaking:
255
- return _buildSpeakingContent(isDark);
263
+ return _SpeakingTranscript(response: widget.response, isDark: isDark);
264
265
case VoiceModePhase.idle:
266
return Text(
@@ -286,13 +294,49 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
294
);
295
}
296
}
297
+}
298
+
299
+/// Speaking transcript that highlights and follows the active word.
300
+class _SpeakingTranscript extends ConsumerStatefulWidget {
301
+ final String response;
302
+ final bool isDark;
303
+
304
+ const _SpeakingTranscript({required this.response, required this.isDark});
305
+
306
+ @override
307
+ ConsumerState<_SpeakingTranscript> createState() =>
308
+ _SpeakingTranscriptState();
309
+}
310
+
311
+class _SpeakingTranscriptState extends ConsumerState<_SpeakingTranscript> {
312
+ final ScrollController _scrollController = ScrollController();
313
+ int _lastTrackedWord = -1;
314
+ String? _trackedResponse;
315
+
316
+ @override
317
+ void dispose() {
318
+ _scrollController.dispose();
319
+ super.dispose();
320
+ }
321
+
322
+ @override
323
+ Widget build(BuildContext context) {
324
+ final tts = ref.watch(ttsProvider);
325
+ final spokenContent = tts.playingContent;
326
+ final response = spokenContent != null && spokenContent.trim().isNotEmpty
327
+ ? spokenContent
328
+ : widget.response;
329
+
330
+ if (_trackedResponse != response) {
331
+ _trackedResponse = response;
332
+ _lastTrackedWord = -1;
333
+ WidgetsBinding.instance.addPostFrameCallback((_) {
334
+ if (mounted && _scrollController.hasClients) {
335
+ _scrollController.jumpTo(0);
336
+ }
337
+ });
338
+ }
339
290
- /// Renders the assistant's response with the currently-spoken word
291
- /// highlighted. Estimate per-chunk timing proportionally from chunk
292
- /// character counts (TTS exposes real per-chunk durations internally,
293
- /// but not via [TtsState], so character-ratio is a good approximation).
294
- Widget _buildSpeakingContent(bool isDark) {
295
- final response = widget.response;
340
if (response.isEmpty) {
341
return Text(
342
'...',
@@ -300,7 +344,7 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
344
style: TextStyle(
345
fontSize: 16,
346
fontWeight: FontWeight.w400,
303
- color: isDark
347
+ color: widget.isDark
348
? Colors.white.withValues(alpha: 0.7)
349
: Colors.black.withValues(alpha: 0.7),
350
height: 1.45,
@@ -309,12 +353,11 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
353
);
354
}
355
312
- final tts = ref.watch(ttsProvider);
356
final accent = VoiceModePalette.accentFor(
357
VoiceModePhase.speaking,
315
- isDark: isDark,
358
+ isDark: widget.isDark,
359
);
317
- final mutedColor = isDark
360
+ final mutedColor = widget.isDark
361
? Colors.white.withValues(alpha: 0.7)
362
: Colors.black.withValues(alpha: 0.7);
363
@@ -331,27 +374,132 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
374
response: response,
375
activeIndex: highlightedWordIndex,
376
base: base,
334
- active: base.copyWith(
335
- color: accent,
336
- fontWeight: FontWeight.w700,
337
- ),
377
+ active: base.copyWith(color: accent, fontWeight: FontWeight.w700),
378
);
379
340
- return RichText(
341
- key: const ValueKey('speaking'),
342
- textAlign: TextAlign.center,
343
- maxLines: 4,
344
- overflow: TextOverflow.ellipsis,
345
- text: TextSpan(style: base, children: spans),
380
+ final textSpan = TextSpan(style: base, children: spans);
381
+ final textScaler = MediaQuery.textScalerOf(context);
382
+ final textDirection = Directionality.of(context);
383
+ final maxHeight =
384
+ textScaler.scale(base.fontSize ?? 16) * (base.height ?? 1) * 4;
385
+
386
+ return LayoutBuilder(
387
+ builder: (context, constraints) {
388
+ _scheduleActiveWordScroll(
389
+ response: response,
390
+ activeWordIndex: highlightedWordIndex,
391
+ textSpan: textSpan,
392
+ maxWidth: constraints.maxWidth,
393
+ maxHeight: maxHeight,
394
+ textScaler: textScaler,
395
+ textDirection: textDirection,
396
+ );
397
+
398
+ return ConstrainedBox(
399
+ constraints: BoxConstraints(maxHeight: maxHeight),
400
+ child: SingleChildScrollView(
401
+ key: const ValueKey('speaking-scroll'),
402
+ controller: _scrollController,
403
+ physics: const BouncingScrollPhysics(),
404
+ child: SizedBox(
405
+ width: constraints.maxWidth,
406
+ child: RichText(
407
+ key: const ValueKey('speaking'),
408
+ textAlign: TextAlign.center,
409
+ text: textSpan,
410
+ ),
411
+ ),
412
+ ),
413
+ );
414
+ },
415
);
416
}
417
418
+ void _scheduleActiveWordScroll({
419
+ required String response,
420
+ required int activeWordIndex,
421
+ required TextSpan textSpan,
422
+ required double maxWidth,
423
+ required double maxHeight,
424
+ required TextScaler textScaler,
425
+ required TextDirection textDirection,
426
+ }) {
427
+ if (activeWordIndex < 0 ||
428
+ activeWordIndex == _lastTrackedWord ||
429
+ !maxWidth.isFinite ||
430
+ maxWidth <= 0) {
431
+ return;
432
+ }
433
+
434
+ _lastTrackedWord = activeWordIndex;
435
+ final charOffset = _wordStartCharOffset(response, activeWordIndex);
436
+ if (charOffset < 0) return;
437
+
438
+ WidgetsBinding.instance.addPostFrameCallback((_) {
439
+ if (!mounted || !_scrollController.hasClients) return;
440
+
441
+ final painter = TextPainter(
442
+ text: textSpan,
443
+ textAlign: TextAlign.center,
444
+ textDirection: textDirection,
445
+ textScaler: textScaler,
446
+ )..layout(maxWidth: maxWidth);
447
+ final caret = painter.getOffsetForCaret(
448
+ TextPosition(offset: charOffset),
449
+ Rect.zero,
450
+ );
451
+ painter.dispose();
452
+
453
+ final position = _scrollController.position;
454
+ final lineHeight = textScaler.scale(16) * 1.45;
455
+ final visibleTop = position.pixels + lineHeight * 0.25;
456
+ final visibleBottom =
457
+ position.pixels + position.viewportDimension - lineHeight * 1.25;
458
+ final wordBottom = caret.dy + lineHeight;
459
+
460
+ if (caret.dy >= visibleTop && wordBottom <= visibleBottom) return;
461
+
462
+ final target = (caret.dy - maxHeight * 0.32)
463
+ .clamp(position.minScrollExtent, position.maxScrollExtent)
464
+ .toDouble();
465
+ if ((target - position.pixels).abs() < 0.5) return;
466
+
467
+ _scrollController.animateTo(
468
+ target,
469
+ duration: const Duration(milliseconds: 220),
470
+ curve: Curves.easeOutCubic,
471
+ );
472
+ });
473
+ }
474
+
475
+ int _wordStartCharOffset(String text, int targetWordIndex) {
476
+ var inWord = false;
477
+ var wordIndex = -1;
478
+ for (var i = 0; i < text.length; i++) {
479
+ final isWord = _isWordChar(text.codeUnitAt(i));
480
+ if (isWord && !inWord) {
481
+ wordIndex++;
482
+ if (wordIndex == targetWordIndex) return i;
483
+ inWord = true;
484
+ } else if (!isWord) {
485
+ inWord = false;
486
+ }
487
+ }
488
+ return -1;
489
+ }
490
+
491
/// Returns the index of the word currently being spoken, or -1 if no
492
/// word is currently active (e.g. between sentences, before TTS has
493
/// populated `chunks`).
494
int _activeWordIndex(String response, TtsState tts) {
353
- if (!tts.isSpeaking ||
354
- tts.chunks.isEmpty ||
495
+ if (!tts.isSpeaking) return -1;
496
+
497
+ if (tts.spokenCharOffset >= 0 && response.isNotEmpty) {
498
+ final safeOffset = tts.spokenCharOffset.clamp(0, response.length - 1);
499
+ return _wordIndexAtChar(response, safeOffset);
500
+ }
501
+
502
+ if (tts.chunks.isEmpty ||
503
tts.currentChunkIndex < 0 ||
504
tts.currentChunkIndex >= tts.chunks.length) {
505
return -1;
@@ -409,8 +557,9 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
557
? duration.inMilliseconds
558
: totalChars * 60;
559
final safePosition = position.inMilliseconds.clamp(0, safeDuration);
412
- final globalCharIdx =
413
- ((safePosition / safeDuration) * totalChars).round().clamp(0, totalChars);
560
+ final globalCharIdx = ((safePosition / safeDuration) * totalChars)
561
+ .round()
562
+ .clamp(0, totalChars);
563
564
// Within which chunk does that character fall?
565
int targetChunk = chunks.length - 1;
@@ -422,8 +571,10 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
571
}
572
573
final chunkStart = chunkStartChars[targetChunk];
425
- final charInChunk = (globalCharIdx - prevChunkChars[targetChunk])
426
- .clamp(0, chunks[targetChunk].length);
574
+ final charInChunk = (globalCharIdx - prevChunkChars[targetChunk]).clamp(
575
+ 0,
576
+ chunks[targetChunk].length,
577
+ );
578
return chunkStart + charInChunk;
579
}
580
@@ -452,12 +603,12 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
603
604
bool _isWordChar(int codeUnit) {
605
// Letters, digits, and apostrophes (don't break "don't" into two).
455
- final isAlnum = (codeUnit >= 0x30 && codeUnit <= 0x39) || // 0-9
606
+ final isAlnum =
607
+ (codeUnit >= 0x30 && codeUnit <= 0x39) || // 0-9
608
(codeUnit >= 0x41 && codeUnit <= 0x5A) || // A-Z
609
(codeUnit >= 0x61 && codeUnit <= 0x7A) || // a-z
610
(codeUnit >= 0xC0 && codeUnit <= 0x024F); // Latin extended
459
- final isApostrophe =
460
- codeUnit == 0x27 || codeUnit == 0x2019; // ' '
611
+ final isApostrophe = codeUnit == 0x27 || codeUnit == 0x2019; // ' '
612
return isAlnum || isApostrophe;
613
}
614
@@ -475,10 +626,12 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
626
void flushBuffer({required bool trailingWord}) {
627
if (buffer.isEmpty) return;
628
final text = buffer.toString();
478
- spans.add(TextSpan(
479
- text: text,
480
- style: trailingWord && wordIndex == activeIndex ? active : base,
481
- ));
629
+ spans.add(
630
+ TextSpan(
631
+ text: text,
632
+ style: trailingWord && wordIndex == activeIndex ? active : base,
633
+ ),
634
+ );
635
buffer = StringBuffer();
636
}
637
@@ -574,4 +727,4 @@ class _PulsingDotState extends State<_PulsingDot>
727
},
728
);
729
}
577
-}
\ No newline at end of file
730
+}
lib/features/voice_mode/views/components/voice_visualizer.dart
+129
-33
@@ -22,11 +22,18 @@ class VoiceVisualizer extends StatefulWidget {
22
/// Current microphone input level in 0..1 (listening phase).
23
final double micLevel;
24
25
+ /// Approximate progress through the spoken response in 0..1.
26
+ ///
27
+ /// TTS does not expose output amplitude, so the shader combines this signal
28
+ /// with a layered speech cadence to keep speaking motion tied to playback.
29
+ final double responseProgress;
30
+
31
const VoiceVisualizer({
32
super.key,
33
required this.phase,
34
this.size = 240,
35
this.micLevel = 0,
36
+ this.responseProgress = 0,
37
});
38
39
@override
@@ -42,6 +49,8 @@ class _VoiceVisualizerState extends State<VoiceVisualizer>
49
VoiceModePhase _currentPhase = VoiceModePhase.idle;
50
51
double _smoothedMicLevel = 0.0;
52
+ final Stopwatch _clock = Stopwatch();
53
+ int _lastFrameMicros = 0;
54
FragmentProgram? _shaderProgram;
55
56
@override
@@ -52,8 +61,9 @@ class _VoiceVisualizerState extends State<VoiceVisualizer>
61
62
_ticker = AnimationController(
63
vsync: this,
55
- duration: const Duration(milliseconds: 4000),
64
+ duration: const Duration(seconds: 1),
65
)..repeat();
66
+ _clock.start();
67
68
_transitionController = AnimationController(
69
vsync: this,
@@ -66,7 +76,9 @@ class _VoiceVisualizerState extends State<VoiceVisualizer>
76
77
Future<void> _loadShader() async {
78
try {
69
- final program = await FragmentProgram.fromAsset('assets/shaders/orb.frag');
79
+ final program = await FragmentProgram.fromAsset(
80
+ 'assets/shaders/orb.frag',
81
+ );
82
if (mounted) {
83
setState(() {
84
_shaderProgram = program;
@@ -89,6 +101,7 @@ class _VoiceVisualizerState extends State<VoiceVisualizer>
101
102
@override
103
void dispose() {
104
+ _clock.stop();
105
_ticker.dispose();
106
_transitionController.dispose();
107
super.dispose();
@@ -104,12 +117,23 @@ class _VoiceVisualizerState extends State<VoiceVisualizer>
117
child: AnimatedBuilder(
118
animation: Listenable.merge([_ticker, _transitionController]),
119
builder: (context, _) {
107
- // Smooth the mic level frame-by-frame to eliminate jitter/flicker.
120
+ final nowMicros = _clock.elapsedMicroseconds;
121
+ final elapsedMicros = _lastFrameMicros == 0
122
+ ? 16667
123
+ : nowMicros - _lastFrameMicros;
124
+ _lastFrameMicros = nowMicros;
125
+ final deltaSeconds = (elapsedMicros / Duration.microsecondsPerSecond)
126
+ .clamp(0.0, 0.05);
127
+
128
+ // Fast attack preserves responsiveness; slower release prevents
129
+ // speech gaps from making the orb visibly snap or flicker.
130
final targetMic = widget.phase == VoiceModePhase.listening
131
? widget.micLevel.clamp(0.0, 1.0)
132
: 0.0;
133
+ final smoothingRate = targetMic > _smoothedMicLevel ? 15.0 : 6.5;
134
+ final smoothing = 1.0 - math.exp(-smoothingRate * deltaSeconds);
135
_smoothedMicLevel =
112
- lerpDouble(_smoothedMicLevel, targetMic, 0.12) ?? 0.0;
136
+ lerpDouble(_smoothedMicLevel, targetMic, smoothing) ?? 0.0;
137
138
final transitionProgress = CurvedAnimation(
139
parent: _transitionController,
@@ -122,7 +146,8 @@ class _VoiceVisualizerState extends State<VoiceVisualizer>
146
targetPhase: _currentPhase,
147
transitionProgress: transitionProgress,
148
micLevel: _smoothedMicLevel,
125
- progress: _ticker.value,
149
+ responseProgress: widget.responseProgress.clamp(0.0, 1.0),
150
+ time: nowMicros / Duration.microsecondsPerSecond,
151
isDark: isDark,
152
shaderProgram: _shaderProgram,
153
),
@@ -138,7 +163,8 @@ class _OrbWavePainter extends CustomPainter {
163
final VoiceModePhase targetPhase;
164
final double transitionProgress;
165
final double micLevel;
141
- final double progress;
166
+ final double responseProgress;
167
+ final double time;
168
final bool isDark;
169
final FragmentProgram? shaderProgram;
170
@@ -147,7 +173,8 @@ class _OrbWavePainter extends CustomPainter {
173
required this.targetPhase,
174
required this.transitionProgress,
175
required this.micLevel,
150
- required this.progress,
176
+ required this.responseProgress,
177
+ required this.time,
178
required this.isDark,
179
this.shaderProgram,
180
});
@@ -155,20 +182,42 @@ class _OrbWavePainter extends CustomPainter {
182
@override
183
void paint(Canvas canvas, Size size) {
184
final rawCenter = Offset(size.width / 2, size.height / 2);
158
- final time = progress * 2 * math.pi;
185
186
// 1. Calculate Phase Colors with Smooth 450ms Cross-Fade
187
final oldAccent = VoiceModePalette.accentFor(oldPhase, isDark: isDark);
162
- final targetAccent = VoiceModePalette.accentFor(targetPhase, isDark: isDark);
188
+ final targetAccent = VoiceModePalette.accentFor(
189
+ targetPhase,
190
+ isDark: isDark,
191
+ );
192
final accent = Color.lerp(oldAccent, targetAccent, transitionProgress)!;
193
165
- final oldSecondary = VoiceModePalette.secondaryFor(oldPhase, isDark: isDark);
166
- final targetSecondary = VoiceModePalette.secondaryFor(targetPhase, isDark: isDark);
167
- final secondary = Color.lerp(oldSecondary, targetSecondary, transitionProgress)!;
194
+ final oldSecondary = VoiceModePalette.secondaryFor(
195
+ oldPhase,
196
+ isDark: isDark,
197
+ );
198
+ final targetSecondary = VoiceModePalette.secondaryFor(
199
+ targetPhase,
200
+ isDark: isDark,
201
+ );
202
+ final secondary = Color.lerp(
203
+ oldSecondary,
204
+ targetSecondary,
205
+ transitionProgress,
206
+ )!;
207
208
// 2. Physics & Scale State Changes
170
- final oldAmp = _computeAmplitudeFor(oldPhase, progress, micLevel);
171
- final targetAmp = _computeAmplitudeFor(targetPhase, progress, micLevel);
209
+ final oldAmp = _computeAmplitudeFor(
210
+ oldPhase,
211
+ time,
212
+ micLevel,
213
+ responseProgress,
214
+ );
215
+ final targetAmp = _computeAmplitudeFor(
216
+ targetPhase,
217
+ time,
218
+ micLevel,
219
+ responseProgress,
220
+ );
221
final amp = lerpDouble(oldAmp, targetAmp, transitionProgress)!;
222
223
// Base radius scale factor
@@ -176,7 +225,8 @@ class _OrbWavePainter extends CustomPainter {
225
Offset center = rawCenter;
226
227
// Error Deflation & Horizontal Shake Oscillation
179
- if (targetPhase == VoiceModePhase.error || oldPhase == VoiceModePhase.error) {
228
+ if (targetPhase == VoiceModePhase.error ||
229
+ oldPhase == VoiceModePhase.error) {
230
final errorWeight = targetPhase == VoiceModePhase.error
231
? transitionProgress
232
: (1.0 - transitionProgress);
@@ -211,15 +261,32 @@ class _OrbWavePainter extends CustomPainter {
261
shader.setFloat(10, secondary.g);
262
shader.setFloat(11, secondary.b);
263
shader.setFloat(12, secondary.a);
264
+ shader.setFloat(13, isDark ? 1.0 : 0.0);
265
+ shader.setFloat(14, amp);
266
+ shader.setFloat(15, _phaseWeight(VoiceModePhase.processing));
267
+ shader.setFloat(16, _phaseWeight(VoiceModePhase.speaking));
268
+ shader.setFloat(17, _phaseWeight(VoiceModePhase.error));
269
+ shader.setFloat(18, responseProgress);
270
271
final shaderPaint = Paint()..shader = shader;
216
- canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), shaderPaint);
272
+ canvas.drawRect(
273
+ Rect.fromLTWH(0, 0, size.width, size.height),
274
+ shaderPaint,
275
+ );
276
return;
277
}
278
279
// 4. CustomPainter Rendering Pipeline
280
// A. Multi-Tier Layered Ambient Glow Halos
222
- _paintMultiLayerHalo(canvas, center, baseRadius, time, amp, accent, secondary);
281
+ _paintMultiLayerHalo(
282
+ canvas,
283
+ center,
284
+ baseRadius,
285
+ time,
286
+ amp,
287
+ accent,
288
+ secondary,
289
+ );
290
291
// B. Outer Organic Translucent Wave Rings with Additive Blend Modes
292
_paintAdditiveOuterWaveRings(
@@ -252,20 +319,33 @@ class _OrbWavePainter extends CustomPainter {
319
}
320
}
321
255
- double _computeAmplitudeFor(VoiceModePhase phase, double p, double mic) {
322
+ double _phaseWeight(VoiceModePhase phase) {
323
+ final oldWeight = oldPhase == phase ? 1.0 : 0.0;
324
+ final targetWeight = targetPhase == phase ? 1.0 : 0.0;
325
+ return lerpDouble(oldWeight, targetWeight, transitionProgress)!;
326
+ }
327
+
328
+ double _computeAmplitudeFor(
329
+ VoiceModePhase phase,
330
+ double time,
331
+ double mic,
332
+ double responseProgress,
333
+ ) {
334
switch (phase) {
335
case VoiceModePhase.listening:
258
- return mic;
336
+ return math.pow(mic, 0.78).toDouble();
337
case VoiceModePhase.speaking:
260
- final wave = 0.5 + 0.5 * math.sin(p * 2 * math.pi * 3.8);
261
- final breath = 0.45 + 0.55 * math.sin(p * 2 * math.pi * 1.4);
262
- return (wave * breath).clamp(0.12, 1.0);
338
+ final playbackPhase = responseProgress * math.pi * 18;
339
+ final syllable = 0.5 + 0.5 * math.sin(time * 7.4 + playbackPhase);
340
+ final texture =
341
+ 0.5 + 0.5 * math.sin(time * 12.7 - responseProgress * math.pi * 11);
342
+ return (0.16 + syllable * texture * 0.56).clamp(0.12, 0.82);
343
case VoiceModePhase.processing:
264
- return 0.22 + 0.18 * math.sin(p * 2 * math.pi * 3.0);
344
+ return 0.18 + 0.14 * (0.5 + 0.5 * math.sin(time * 3.6));
345
case VoiceModePhase.idle:
266
- return 0.06 + 0.04 * math.sin(p * 2 * math.pi);
346
+ return 0.05 + 0.035 * (0.5 + 0.5 * math.sin(time * 1.1));
347
case VoiceModePhase.error:
268
- return 0.08 + 0.05 * math.sin(p * 2 * math.pi * 1.8);
348
+ return 0.07 + 0.04 * (0.5 + 0.5 * math.sin(time * 2.1));
349
}
350
}
351
@@ -466,9 +546,12 @@ class _OrbWavePainter extends CustomPainter {
546
final control2 = p2 - (p3 - p1) * 0.1666;
547
548
path.cubicTo(
469
- control1.dx, control1.dy,
470
- control2.dx, control2.dy,
471
- p2.dx, p2.dy,
549
+ control1.dx,
550
+ control1.dy,
551
+ control2.dx,
552
+ control2.dy,
553
+ p2.dx,
554
+ p2.dy,
555
);
556
}
557
@@ -488,7 +571,10 @@ class _OrbWavePainter extends CustomPainter {
571
final orbRadius = baseRadius * (0.95 + 0.15 * amp);
572
final orbRect = Rect.fromCircle(center: center, radius: orbRadius);
573
491
- final gradientStops = VoiceModePalette.sphereGradientFor(targetPhase, isDark: isDark);
574
+ final gradientStops = VoiceModePalette.sphereGradientFor(
575
+ targetPhase,
576
+ isDark: isDark,
577
+ );
578
final orbGradient = RadialGradient(
579
center: const Alignment(-0.35, -0.45),
580
radius: 1.15,
@@ -514,7 +600,15 @@ class _OrbWavePainter extends CustomPainter {
600
canvas.drawCircle(center, orbRadius, orbPaint);
601
602
// Inner morphing liquid wave shimmer
517
- _paintInnerLiquidWaves(canvas, center, orbRadius, time, amp, accent, secondary);
603
+ _paintInnerLiquidWaves(
604
+ canvas,
605
+ center,
606
+ orbRadius,
607
+ time,
608
+ amp,
609
+ accent,
610
+ secondary,
611
+ );
612
613
// Specular 3D light reflection highlight
614
final specularPaint = Paint()
@@ -570,7 +664,8 @@ class _OrbWavePainter extends CustomPainter {
664
for (int i = 0; i <= steps; i++) {
665
final x = center.dx - radius + (i / steps) * (2 * radius);
666
final normalizedX = (i / steps) * 2 * math.pi;
573
- final y = yOffset +
667
+ final y =
668
+ yOffset +
669
math.sin(normalizedX * 2 + time * 2.5) * waveHeight * 0.5 +
670
math.cos(normalizedX * 3 - time * 1.8) * waveHeight * 0.3;
671
wavePath.lineTo(x, y);
@@ -599,7 +694,8 @@ class _OrbWavePainter extends CustomPainter {
694
targetPhase != oldDelegate.targetPhase ||
695
transitionProgress != oldDelegate.transitionProgress ||
696
micLevel != oldDelegate.micLevel ||
602
- progress != oldDelegate.progress ||
697
+ responseProgress != oldDelegate.responseProgress ||
698
+ time != oldDelegate.time ||
699
isDark != oldDelegate.isDark ||
700
shaderProgram != oldDelegate.shaderProgram;
701
}
@@ -623,4 +719,4 @@ class _RingConfig {
719
required this.colors,
720
required this.strokeColor,
721
});
626
-}
\ No newline at end of file
722
+}
lib/features/voice_mode/views/voice_mode_overlay.dart
+62
-28
@@ -10,6 +10,7 @@ import 'package:hugeicons/hugeicons.dart';
10
import '../../../../core/models/enums.dart';
11
import '../../../../core/providers/app_providers.dart';
12
import '../../chat/providers/chat_notifier.dart';
13
+import '../../tts/providers/tts_providers.dart';
14
import '../../tts/views/tts_model_manager_screen.dart';
15
import '../providers/voice_mode_provider.dart';
16
import '../voice_mode_palette.dart';
@@ -20,7 +21,7 @@ import 'components/voice_visualizer.dart';
21
/// Full-screen voice-to-voice conversation overlay.
22
///
23
/// Features a real-time glowing 3D crystal visualizer and auto-scrolling transcript
23
-/// set against a sleek dark obsidian atmospheric backdrop in both light and dark modes.
24
+/// set against an adaptive pearlescent or obsidian atmospheric backdrop.
25
class VoiceModeOverlay extends ConsumerStatefulWidget {
26
const VoiceModeOverlay({super.key});
27
@@ -89,6 +90,8 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
90
91
Widget _buildTtsChip(BuildContext context, ThemeData theme) {
92
final settings = ref.watch(settingsProvider);
93
+ final isDark = theme.brightness == Brightness.dark;
94
+ final foreground = isDark ? Colors.white : const Color(0xFF25243A);
95
final String label;
96
97
switch (settings.ttsEngine) {
@@ -115,10 +118,10 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
118
child: Container(
119
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
120
decoration: BoxDecoration(
118
- color: Colors.white.withValues(alpha: 0.08),
121
+ color: foreground.withValues(alpha: isDark ? 0.08 : 0.055),
122
borderRadius: BorderRadius.circular(999),
123
border: Border.all(
121
- color: Colors.white.withValues(alpha: 0.14),
124
+ color: foreground.withValues(alpha: isDark ? 0.14 : 0.10),
125
),
126
),
127
child: Row(
@@ -127,7 +130,7 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
130
HugeIcon(
131
icon: HugeIcons.strokeRoundedVolumeHigh,
132
size: 13,
130
- color: Colors.white.withValues(alpha: 0.90),
133
+ color: foreground.withValues(alpha: 0.90),
134
),
135
const SizedBox(width: 6),
136
Text(
@@ -136,14 +139,14 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
139
fontSize: 12,
140
fontWeight: FontWeight.w600,
141
letterSpacing: 0.1,
139
- color: Colors.white.withValues(alpha: 0.90),
142
+ color: foreground.withValues(alpha: 0.90),
143
),
144
),
145
const SizedBox(width: 2),
146
Icon(
147
Icons.chevron_right_rounded,
148
size: 16,
146
- color: Colors.white.withValues(alpha: 0.50),
149
+ color: foreground.withValues(alpha: 0.50),
150
),
151
],
152
),
@@ -155,11 +158,30 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
158
Widget build(BuildContext context) {
159
final state = ref.watch(voiceModeProvider);
160
final theme = Theme.of(context);
158
-
159
- // Voice mode presents a dark obsidian atmospheric theme in both light and dark modes
160
- const bgColor = Color(0xFF090A10);
161
- final accent = VoiceModePalette.accentFor(state.phase, isDark: true);
162
- final secondary = VoiceModePalette.secondaryFor(state.phase, isDark: true);
161
+ final isDark = theme.brightness == Brightness.dark;
162
+ final bgColor = isDark ? const Color(0xFF090A10) : const Color(0xFFF4F6FA);
163
+ final foreground = isDark ? Colors.white : const Color(0xFF25243A);
164
+ final accent = VoiceModePalette.accentFor(state.phase, isDark: isDark);
165
+ final secondary = VoiceModePalette.secondaryFor(
166
+ state.phase,
167
+ isDark: isDark,
168
+ );
169
+ final ttsPlayback = ref.watch(
170
+ ttsProvider.select(
171
+ (tts) => (position: tts.position, duration: tts.duration),
172
+ ),
173
+ );
174
+ final durationMicros = ttsPlayback.duration.inMicroseconds;
175
+ final responseProgress = durationMicros <= 0
176
+ ? 0.0
177
+ : (ttsPlayback.position.inMicroseconds / durationMicros).clamp(
178
+ 0.0,
179
+ 1.0,
180
+ );
181
+ final orbSize = (MediaQuery.sizeOf(context).width * 0.88).clamp(
182
+ 300.0,
183
+ 360.0,
184
+ );
185
186
return PopScope(
187
canPop: false,
@@ -167,18 +189,17 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
189
if (!didPop) _endSession();
190
},
191
child: AnnotatedRegion<SystemUiOverlayStyle>(
170
- value: const SystemUiOverlayStyle(
192
+ value: SystemUiOverlayStyle(
193
statusBarColor: Colors.transparent,
172
- statusBarIconBrightness: Brightness.light, // White icons on Android
173
- statusBarBrightness: Brightness.dark, // White text/icons on iOS
194
+ statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
195
+ statusBarBrightness: isDark ? Brightness.dark : Brightness.light,
196
systemNavigationBarColor: Colors.transparent,
175
- systemNavigationBarIconBrightness: Brightness.light,
197
+ systemNavigationBarIconBrightness: isDark
198
+ ? Brightness.light
199
+ : Brightness.dark,
200
),
201
child: Theme(
178
- data: theme.copyWith(
179
- brightness: Brightness.dark,
180
- colorScheme: const ColorScheme.dark(),
181
- ),
202
+ data: theme,
203
child: Scaffold(
204
backgroundColor: bgColor,
205
body: Stack(
@@ -192,6 +213,7 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
213
accent: accent,
214
secondary: secondary,
215
bgColor: bgColor,
216
+ isDark: isDark,
217
),
218
),
219
),
@@ -225,7 +247,7 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
247
fontSize: 15,
248
fontWeight: FontWeight.w600,
249
letterSpacing: 0.2,
228
- color: Colors.white.withValues(alpha: 0.90),
250
+ color: foreground.withValues(alpha: 0.90),
251
),
252
),
253
const Spacer(),
@@ -243,7 +265,8 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
265
child: VoiceVisualizer(
266
phase: state.phase,
267
micLevel: state.micLevel,
246
- size: 240,
268
+ responseProgress: responseProgress,
269
+ size: orbSize,
270
),
271
),
272
@@ -270,7 +293,9 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
293
ref.read(voiceModeProvider.notifier).toggleMute();
294
},
295
onToggleAutoListen: () {
273
- ref.read(voiceModeProvider.notifier).toggleAutoListen();
296
+ ref
297
+ .read(voiceModeProvider.notifier)
298
+ .toggleAutoListen();
299
},
300
onTapCenter: _handleCenterAction,
301
),
@@ -325,6 +350,9 @@ class _CloseButton extends StatelessWidget {
350
351
@override
352
Widget build(BuildContext context) {
353
+ final isDark = Theme.of(context).brightness == Brightness.dark;
354
+ final foreground = isDark ? Colors.white : const Color(0xFF25243A);
355
+
356
return Material(
357
color: Colors.transparent,
358
child: InkWell(
@@ -335,12 +363,12 @@ class _CloseButton extends StatelessWidget {
363
height: 36,
364
decoration: BoxDecoration(
365
shape: BoxShape.circle,
338
- color: Colors.white.withValues(alpha: 0.08),
366
+ color: foreground.withValues(alpha: isDark ? 0.08 : 0.055),
367
),
368
child: Icon(
369
Icons.close_rounded,
370
size: 18,
343
- color: Colors.white.withValues(alpha: 0.80),
371
+ color: foreground.withValues(alpha: 0.80),
372
),
373
),
374
),
@@ -354,6 +382,7 @@ class _PulsingBackgroundGlow extends StatefulWidget {
382
final Color accent;
383
final Color secondary;
384
final Color bgColor;
385
+ final bool isDark;
386
387
const _PulsingBackgroundGlow({
388
required this.phase,
@@ -361,6 +390,7 @@ class _PulsingBackgroundGlow extends StatefulWidget {
390
required this.accent,
391
required this.secondary,
392
required this.bgColor,
393
+ required this.isDark,
394
});
395
396
@override
@@ -396,7 +426,8 @@ class _PulsingBackgroundGlowState extends State<_PulsingBackgroundGlow>
426
427
switch (widget.phase) {
428
case VoiceModePhase.listening:
399
- pulse = 0.18 + 0.45 * widget.micLevel.clamp(0.0, 1.0) + 0.12 * breath;
429
+ pulse =
430
+ 0.18 + 0.45 * widget.micLevel.clamp(0.0, 1.0) + 0.12 * breath;
431
case VoiceModePhase.speaking:
432
pulse = 0.22 + 0.28 * breath;
433
case VoiceModePhase.error:
@@ -407,12 +438,14 @@ class _PulsingBackgroundGlowState extends State<_PulsingBackgroundGlow>
438
pulse = 0.06 + 0.06 * breath;
439
}
440
410
- final alpha = (0.16 + pulse * 0.18).clamp(0.08, 0.45);
441
+ final alpha = widget.isDark
442
+ ? (0.16 + pulse * 0.18).clamp(0.08, 0.45)
443
+ : (0.012 + pulse * 0.020).clamp(0.01, 0.05);
444
final radius = 0.85 + pulse * 0.40;
445
446
return Stack(
447
children: [
415
- // Deep obsidian background base
448
+ // Theme-adaptive background base.
449
Container(color: widget.bgColor),
450
451
// Top ambient gradient light pool
@@ -438,7 +471,8 @@ class _PulsingBackgroundGlowState extends State<_PulsingBackgroundGlow>
471
child: BackdropFilter(
472
filter: ImageFilter.blur(sigmaX: 55, sigmaY: 55),
473
child: Container(
441
- color: Colors.black.withValues(alpha: 0.15),
474
+ color: (widget.isDark ? Colors.black : Colors.white)
475
+ .withValues(alpha: widget.isDark ? 0.15 : 0.10),
476
),
477
),
478
),
test/features/chat/lm_studio_mcp_integration_test.dart
+186
-20
@@ -43,13 +43,14 @@ class CapturingStreamInterceptor extends Interceptor {
43
}
44
45
void main() {
46
- group('LM Studio MCP Integration Tests', () {
46
+ group('LM Studio MCP Integration Tests (native /api/v1/chat)', () {
47
final server = Server(
48
id: 'lmstudio-server',
49
name: 'LM Studio',
50
type: ServerType.lmStudio,
51
host: 'localhost',
52
port: 1234,
53
+ apiKey: 'test-token',
54
createdAt: DateTime.now(),
55
lastConnectedAt: DateTime.now(),
56
);
@@ -62,10 +63,17 @@ void main() {
63
createdAt: DateTime.now(),
64
);
65
65
- test('LMStudioChatService includes integrations array in body payload', () async {
66
+ test('endpoint is /api/v1/chat and body uses input/system_prompt/max_output_tokens', () async {
67
final interceptor = CapturingStreamInterceptor([
67
- 'data: {"choices":[{"delta":{"content":"Hello"}}]}',
68
- 'data: [DONE]',
68
+ 'event: chat.start',
69
+ 'data: {"type":"chat.start","model_instance_id":"test-model"}',
70
+ '',
71
+ 'event: message.delta',
72
+ 'data: {"type":"message.delta","content":"Hello"}',
73
+ '',
74
+ 'event: chat.end',
75
+ 'data: {"type":"chat.end","result":{"model_instance_id":"test-model","output":[],"stats":{"input_tokens":5,"total_output_tokens":1}}}',
76
+ '',
77
]);
78
final dio = Dio()..interceptors.add(interceptor);
79
final service = LMStudioChatService(dio);
@@ -95,7 +103,32 @@ void main() {
103
expect(responses.any((r) => r.type == ChatResponseType.message), isTrue);
104
expect(interceptor.capturedRequest, isNotNull);
105
106
+ // Endpoint must be the native one.
107
+ expect(
108
+ interceptor.capturedRequest!.path,
109
+ equals('http://localhost:1234/api/v1/chat'),
110
+ );
111
+
112
+ // Auth header must be propagated when the server has an apiKey.
113
+ expect(
114
+ interceptor.capturedRequest!.headers['Authorization'],
115
+ equals('Bearer test-token'),
116
+ );
117
+
118
final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
119
+ expect(body['model'], equals('test-model'));
120
+ expect(body['stream'], isTrue);
121
+
122
+ // Wire format expectations (per LM Studio's /api/v1/chat spec).
123
+ expect(body['input'], isA<String>());
124
+ expect(body['input'], contains('Search Hugging Face models'));
125
+ expect(body.containsKey('messages'), isFalse,
126
+ reason: 'messages is OpenAI-style; the native endpoint uses input');
127
+ expect(body.containsKey('max_tokens'), isFalse,
128
+ reason: 'native endpoint uses max_output_tokens');
129
+ expect(body['max_output_tokens'], isA<int>());
130
+
131
+ // Integrations payload shape matches the spec.
132
expect(body['integrations'], isNotNull);
133
final bodyIntegrations = body['integrations'] as List;
134
expect(bodyIntegrations.length, equals(2));
@@ -114,11 +147,87 @@ void main() {
147
}));
148
});
149
117
- test('LMStudioChatService emits toolCall response on tool_call.success event', () async {
150
+ test('reasoning control uses native string shape, not OpenAI-compat object', () async {
151
+ // Regression: the shared _applyReasoningControl writes `reasoning` as an
152
+ // object and extra OpenAI-compat keys (reasoning_effort/think/
153
+ // enable_thinking). LM Studio's native /api/v1/chat rejects all of
154
+ // those with HTTP 400, so LMStudioChatService must emit the native
155
+ // string form ('off'|'low'|'medium'|'high'|'on') only.
156
final interceptor = CapturingStreamInterceptor([
119
- 'data: {"type": "tool_call.start", "tool": "model_search"}',
120
- 'data: {"type": "tool_call.success", "tool": "model_search", "arguments": {"sort": "trendingScore"}, "output": "Model A, Model B"}',
121
- 'data: [DONE]',
157
+ 'event: chat.start',
158
+ 'data: {"type":"chat.start","model_instance_id":"test-model"}',
159
+ '',
160
+ 'event: chat.end',
161
+ 'data: {"type":"chat.end","result":{"model_instance_id":"test-model","output":[],"stats":{"input_tokens":1,"total_output_tokens":1}}}',
162
+ '',
163
+ ]);
164
+ final dio = Dio()..interceptors.add(interceptor);
165
+ final service = LMStudioChatService(dio);
166
+
167
+ await service.sendMessage(
168
+ server: server,
169
+ modelId: 'test-model',
170
+ messages: [userMessage],
171
+ params: ChatParameters.defaults().copyWith(reasoningEnabled: true),
172
+ ).toList();
173
+
174
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
175
+
176
+ // `reasoning` must be a string, never an object.
177
+ expect(body['reasoning'], isA<String>(),
178
+ reason: 'native endpoint expects reasoning as a string');
179
+ expect(body['reasoning'], equals('on'));
180
+
181
+ // The OpenAI-compatible reasoning keys must NOT be sent.
182
+ expect(body.containsKey('reasoning_effort'), isFalse);
183
+ expect(body.containsKey('think'), isFalse);
184
+ expect(body.containsKey('enable_thinking'), isFalse);
185
+ });
186
+
187
+ test('reasoning disabled sends native "off" string', () async {
188
+ final interceptor = CapturingStreamInterceptor([
189
+ 'event: chat.start',
190
+ 'data: {"type":"chat.start","model_instance_id":"test-model"}',
191
+ '',
192
+ 'event: chat.end',
193
+ 'data: {"type":"chat.end","result":{"model_instance_id":"test-model","output":[],"stats":{"input_tokens":1,"total_output_tokens":1}}}',
194
+ '',
195
+ ]);
196
+ final dio = Dio()..interceptors.add(interceptor);
197
+ final service = LMStudioChatService(dio);
198
+
199
+ await service.sendMessage(
200
+ server: server,
201
+ modelId: 'test-model',
202
+ messages: [userMessage],
203
+ params: ChatParameters.defaults().copyWith(reasoningEnabled: false),
204
+ ).toList();
205
+
206
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
207
+ expect(body['reasoning'], equals('off'));
208
+ expect(body.containsKey('reasoning_effort'), isFalse);
209
+ expect(body.containsKey('think'), isFalse);
210
+ expect(body.containsKey('enable_thinking'), isFalse);
211
+ });
212
+
213
+
214
+ test('tool_call.success event surfaces as a completed tool call with output', () async {
215
+ final interceptor = CapturingStreamInterceptor([
216
+ 'event: chat.start',
217
+ 'data: {"type":"chat.start","model_instance_id":"test-model"}',
218
+ '',
219
+ 'event: tool_call.start',
220
+ 'data: {"type":"tool_call.start","tool":"model_search","provider_info":{"type":"ephemeral_mcp","server_label":"huggingface"}}',
221
+ '',
222
+ 'event: tool_call.arguments',
223
+ 'data: {"type":"tool_call.arguments","tool":"model_search","arguments":{"sort":"trendingScore"}}',
224
+ '',
225
+ 'event: tool_call.success',
226
+ 'data: {"type":"tool_call.success","tool":"model_search","arguments":{"sort":"trendingScore"},"output":"Model A, Model B","provider_info":{"type":"ephemeral_mcp","server_label":"huggingface"}}',
227
+ '',
228
+ 'event: chat.end',
229
+ 'data: {"type":"chat.end","result":{"model_instance_id":"test-model","output":[],"stats":{"input_tokens":10,"total_output_tokens":2}}}',
230
+ '',
231
]);
232
final dio = Dio()..interceptors.add(interceptor);
233
final service = LMStudioChatService(dio);
@@ -137,13 +246,72 @@ void main() {
246
],
247
).toList();
248
140
- final toolCalls = responses.where((r) => r.type == ChatResponseType.toolCall).toList();
249
+ final toolCalls = responses
250
+ .where((r) => r.type == ChatResponseType.toolCall)
251
+ .toList();
252
expect(toolCalls.length, equals(1));
253
expect(toolCalls.first.toolCall?.tool, equals('model_search'));
143
- expect(toolCalls.first.toolCall?.arguments, equals({'sort': 'trendingScore'}));
254
+ expect(
255
+ toolCalls.first.toolCall?.arguments,
256
+ equals({'sort': 'trendingScore'}),
257
+ );
258
+ // The native endpoint executes the tool server-side; the output
259
+ // must be propagated so the client doesn't re-execute it.
260
+ expect(toolCalls.first.toolCall?.output, equals('Model A, Model B'));
261
+ expect(
262
+ toolCalls.first.toolCall?.providerInfo?.serverLabel,
263
+ equals('huggingface'),
264
+ );
265
+
266
+ // The aggregated stats arrive on the chat.end event.
267
+ final done = responses
268
+ .where((r) => r.type == ChatResponseType.done)
269
+ .firstOrNull;
270
+ expect(done?.stats?.inputTokens, equals(10));
271
+ expect(done?.stats?.totalOutputTokens, equals(2));
272
});
273
146
- test('OpenAICompatibleChatService includes integrations array in payload', () async {
274
+ test('mcp_connection_error surfaces as ChatResponseType.error', () async {
275
+ final interceptor = CapturingStreamInterceptor([
276
+ 'event: error',
277
+ 'data: {"type":"error","error":{"type":"mcp_connection_error","message":"Could not reach https://huggingface.co/mcp"}}',
278
+ '',
279
+ ]);
280
+ final dio = Dio()..interceptors.add(interceptor);
281
+ final service = LMStudioChatService(dio);
282
+
283
+ final responses = await service.sendMessage(
284
+ server: server,
285
+ modelId: 'test-model',
286
+ messages: [userMessage],
287
+ params: ChatParameters.defaults(),
288
+ integrations: [
289
+ const McpIntegration(
290
+ type: McpIntegrationType.ephemeralMcp,
291
+ serverLabel: 'huggingface',
292
+ serverUrl: 'https://huggingface.co/mcp',
293
+ ),
294
+ ],
295
+ ).toList();
296
+
297
+ final errors = responses
298
+ .where((r) => r.type == ChatResponseType.error)
299
+ .toList();
300
+ expect(errors, hasLength(1));
301
+ expect(errors.first.content, contains('Could not reach https://huggingface.co/mcp'));
302
+ });
303
+
304
+ test('OpenAICompatibleChatService still sends integrations on /v1/chat/completions', () async {
305
+ // Sanity check that the OpenAI-compatible path was not regressed.
306
+ final openAiServer = Server(
307
+ id: 'openai-server',
308
+ name: 'OAI Compat',
309
+ type: ServerType.openAICompatible,
310
+ host: 'localhost',
311
+ port: 1234,
312
+ createdAt: DateTime.now(),
313
+ lastConnectedAt: DateTime.now(),
314
+ );
315
final interceptor = CapturingStreamInterceptor([
316
'data: {"choices":[{"delta":{"content":"Hi"}}]}',
317
'data: [DONE]',
@@ -151,19 +319,17 @@ void main() {
319
final dio = Dio()..interceptors.add(interceptor);
320
final service = OpenAICompatibleChatService(dio);
321
154
- final integrations = [
155
- const McpIntegration(
156
- type: McpIntegrationType.plugin,
157
- pluginId: 'mcp/filesystem',
158
- ),
159
- ];
160
-
322
await service.sendMessage(
162
- server: server,
323
+ server: openAiServer,
324
modelId: 'test-model',
325
messages: [userMessage],
326
params: ChatParameters.defaults(),
166
- integrations: integrations,
327
+ integrations: [
328
+ const McpIntegration(
329
+ type: McpIntegrationType.plugin,
330
+ pluginId: 'mcp/filesystem',
331
+ ),
332
+ ],
333
).toList();
334
335
expect(interceptor.capturedRequest, isNotNull);
test/features/voice_mode/voice_transcript_test.dart
new
+121
@@ -0,0 +1,121 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:flutter_test/flutter_test.dart';
4
+import 'package:localmind/core/models/enums.dart';
5
+import 'package:localmind/features/tts/providers/tts_providers.dart';
6
+import 'package:localmind/features/voice_mode/providers/voice_mode_provider.dart';
7
+import 'package:localmind/features/voice_mode/views/components/voice_transcript.dart';
8
+
9
+void main() {
10
+ testWidgets('highlights the word reported by native TTS progress', (
11
+ tester,
12
+ ) async {
13
+ const response = 'Hello world from LocalMind';
14
+ const ttsState = TtsState(
15
+ isSpeaking: true,
16
+ activeEngine: EngineId.system,
17
+ playingContent: response,
18
+ chunks: [response],
19
+ currentChunkIndex: 0,
20
+ spokenCharOffset: 6,
21
+ );
22
+
23
+ await tester.pumpWidget(
24
+ ProviderScope(
25
+ overrides: [ttsProvider.overrideWith(() => _StubTtsNotifier(ttsState))],
26
+ child: const MaterialApp(
27
+ home: Scaffold(
28
+ body: VoiceTranscript(
29
+ phase: VoiceModePhase.speaking,
30
+ transcript: '',
31
+ response: response,
32
+ ),
33
+ ),
34
+ ),
35
+ ),
36
+ );
37
+
38
+ final richText = tester.widget<RichText>(find.byType(RichText).last);
39
+ final root = richText.text as TextSpan;
40
+ final wordSpan = root.children!.whereType<TextSpan>().singleWhere(
41
+ (span) => span.text == 'world',
42
+ );
43
+
44
+ expect(wordSpan.style?.fontWeight, FontWeight.w700);
45
+ expect(wordSpan.style?.color, isNot(root.style?.color));
46
+ });
47
+
48
+ test('resetProgress clears a native spoken character offset', () {
49
+ const state = TtsState(spokenCharOffset: 12);
50
+
51
+ expect(state.copyWith(resetProgress: true).spokenCharOffset, -1);
52
+ });
53
+
54
+ testWidgets('auto-scrolls to keep a later spoken word visible', (
55
+ tester,
56
+ ) async {
57
+ final response = List.generate(80, (index) => 'word$index').join(' ');
58
+ final activeOffset = response.indexOf('word70');
59
+ final ttsState = TtsState(
60
+ isSpeaking: true,
61
+ activeEngine: EngineId.system,
62
+ playingContent: response,
63
+ chunks: [response],
64
+ currentChunkIndex: 0,
65
+ spokenCharOffset: 0,
66
+ );
67
+ final notifier = _StubTtsNotifier(ttsState);
68
+
69
+ await tester.pumpWidget(
70
+ ProviderScope(
71
+ overrides: [ttsProvider.overrideWith(() => notifier)],
72
+ child: MaterialApp(
73
+ home: Scaffold(
74
+ body: SizedBox(
75
+ width: 320,
76
+ child: VoiceTranscript(
77
+ phase: VoiceModePhase.speaking,
78
+ transcript: '',
79
+ response: response,
80
+ ),
81
+ ),
82
+ ),
83
+ ),
84
+ ),
85
+ );
86
+
87
+ notifier.setSpokenCharOffset(activeOffset);
88
+ await tester.pump();
89
+
90
+ final updatedText = tester.widget<RichText>(find.byType(RichText).last);
91
+ final updatedRoot = updatedText.text as TextSpan;
92
+ final activeWord = updatedRoot.children!.whereType<TextSpan>().singleWhere(
93
+ (span) => span.text == 'word70',
94
+ );
95
+ expect(activeWord.style?.fontWeight, FontWeight.w700);
96
+
97
+ await tester.pump();
98
+ await tester.pump(const Duration(milliseconds: 300));
99
+
100
+ final scrollView = tester.widget<SingleChildScrollView>(
101
+ find.byKey(const ValueKey('speaking-scroll')),
102
+ );
103
+ expect(scrollView.controller!.offset, greaterThan(0));
104
+ });
105
+}
106
+
107
+class _StubTtsNotifier extends TtsNotifier {
108
+ _StubTtsNotifier(this.initialState);
109
+
110
+ final TtsState initialState;
111
+
112
+ @override
113
+ TtsState build() => initialState;
114
+
115
+ void setSpokenCharOffset(int offset) {
116
+ state = state.copyWith(
117
+ spokenCharOffset: offset,
118
+ playingContent: state.playingContent,
119
+ );
120
+ }
121
+}