@tduncan / Cortana / commits / b197310

refactor: overhaul orb shader rendering logic and update assistant components and services

abdulmominsakib committed Aug 1, 2026 at 11:46 UTC b1973107ba4bc84ddfb8b67b149095a5b7bd1962
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 +}