Skip to content

Commit d27511d

Browse files
committed
fix: detect incomplete Anthropic SSE stream and deduplicate system prompt
stream_anthropic_sse previously returned partial output silently when the connection dropped before message_stop arrived. Now tracks message_stop_received and returns an explicit error so the user knows to retry rather than seeing a silently truncated response. Also propagates JSON parse errors in parse_anthropic_sse_line via ? instead of silently returning Ok(false), consistent with the OpenAI SSE parser. Extracts the duplicated CHAT_SYSTEM_INSTRUCTIONS into a single module- level constant — previously maintained as two identical inline concat! blocks in send_openai_compatible and send_anthropic.
1 parent aa982f3 commit d27511d

1 file changed

Lines changed: 24 additions & 27 deletions

File tree

src-tauri/src/services/chat_service.rs

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ use crate::{
1414

1515
use super::{now_rfc3339, provider_service};
1616

17+
const CHAT_SYSTEM_INSTRUCTIONS: &str = concat!(
18+
"IMPORTANT: Reply using the same language as the user's latest message. If user writes Indonesian, answer in Indonesian. Never switch to another language unless the user explicitly asks you to.\n\n",
19+
"INTERACTIVE PREVIEW: When the user asks for a visualization, diagram, chart, interactive demo, or any visual HTML content, output it as a fenced code block with tag `html:preview`. The app renders it as a live iframe preview with a full design system pre-loaded (CSS variables, SVG color ramp classes, pre-styled form elements, light/dark mode).\n\n",
20+
"Design rules: flat (no gradients/shadows/glow), use CSS vars for colors (var(--color-text-primary), var(--color-background-secondary), etc). system-ui font, 2 weights (400/500), sentence case. Structure: style → content → script last.\n\n",
21+
"SVG diagrams: use pre-loaded classes — `.t` (14px text), `.ts` (12px), `.th` (14px bold), `.box` (neutral), `.node` (clickable), `.arr` (arrow), `.leader` (dashed). Color ramps: `class=\"c-blue\"` on `<g>` wrapping shape+text — auto light/dark. Available: c-purple, c-teal, c-coral, c-blue, c-amber, c-green, c-red, c-gray, c-pink. Max 2-3 ramps per diagram.\n\n",
22+
"Chart.js: wrap canvas in div with position:relative + explicit height. Load UMD from cdnjs.cloudflare.com with onload callback. Disable default legend, build custom HTML legend with 10px colored squares.\n\n",
23+
"Interactive: form elements pre-styled. Use sendPrompt(text) for drill-down. CDN: cdnjs.cloudflare.com, cdn.jsdelivr.net, unpkg.com, esm.sh only.\n\n",
24+
"Always output COMPLETE standalone HTML (DOCTYPE, html, head, body). No titles/prose inside widget — explanations go in your response text."
25+
);
26+
1727
pub async fn get_messages(db: &SqlitePool, session_id: &str) -> AppResult<Vec<Message>> {
1828
let messages = sqlx::query_as::<_, Message>(
1929
"SELECT id, session_id, role, content, created_at FROM messages \
@@ -180,22 +190,13 @@ async fn send_openai_compatible(
180190
"stream": true,
181191
});
182192

183-
let system_instructions = concat!(
184-
"IMPORTANT: Reply using the same language as the user's latest message. If user writes Indonesian, answer in Indonesian. Never switch to another language unless the user explicitly asks you to.\n\n",
185-
"INTERACTIVE PREVIEW: When the user asks for a visualization, diagram, chart, interactive demo, or any visual HTML content, output it as a fenced code block with tag `html:preview`. The app renders it as a live iframe preview with a full design system pre-loaded (CSS variables, SVG color ramp classes, pre-styled form elements, light/dark mode).\n\n",
186-
"Design rules: flat (no gradients/shadows/glow), use CSS vars for colors (var(--color-text-primary), var(--color-background-secondary), etc). system-ui font, 2 weights (400/500), sentence case. Structure: style → content → script last.\n\n",
187-
"SVG diagrams: use pre-loaded classes — `.t` (14px text), `.ts` (12px), `.th` (14px bold), `.box` (neutral), `.node` (clickable), `.arr` (arrow), `.leader` (dashed). Color ramps: `class=\"c-blue\"` on `<g>` wrapping shape+text — auto light/dark. Available: c-purple, c-teal, c-coral, c-blue, c-amber, c-green, c-red, c-gray, c-pink. Max 2-3 ramps per diagram.\n\n",
188-
"Chart.js: wrap canvas in div with position:relative + explicit height. Load UMD from cdnjs.cloudflare.com with onload callback. Disable default legend, build custom HTML legend with 10px colored squares.\n\n",
189-
"Interactive: form elements pre-styled. Use sendPrompt(text) for drill-down. CDN: cdnjs.cloudflare.com, cdn.jsdelivr.net, unpkg.com, esm.sh only.\n\n",
190-
"Always output COMPLETE standalone HTML (DOCTYPE, html, head, body). No titles/prose inside widget — explanations go in your response text."
191-
);
192193
let payload_with_system = if let Some(arr) = payload.get("messages").and_then(Value::as_array) {
193194
let mut updated = arr.clone();
194195
updated.insert(
195196
0,
196197
serde_json::json!({
197198
"role": "system",
198-
"content": system_instructions,
199+
"content": CHAT_SYSTEM_INSTRUCTIONS,
199200
}),
200201
);
201202
let mut p = payload.clone();
@@ -250,19 +251,10 @@ async fn send_anthropic(
250251
"stream": true,
251252
});
252253

253-
let system_instructions_anthropic = concat!(
254-
"IMPORTANT: Reply using the same language as the user's latest message. If user writes Indonesian, answer in Indonesian. Never switch to another language unless the user explicitly asks you to.\n\n",
255-
"INTERACTIVE PREVIEW: When the user asks for a visualization, diagram, chart, interactive demo, or any visual HTML content, output it as a fenced code block with tag `html:preview`. The app renders it as a live iframe preview with a full design system pre-loaded (CSS variables, SVG color ramp classes, pre-styled form elements, light/dark mode).\n\n",
256-
"Design rules: flat (no gradients/shadows/glow), use CSS vars for colors (var(--color-text-primary), var(--color-background-secondary), etc). system-ui font, 2 weights (400/500), sentence case. Structure: style → content → script last.\n\n",
257-
"SVG diagrams: use pre-loaded classes — `.t` (14px text), `.ts` (12px), `.th` (14px bold), `.box` (neutral), `.node` (clickable), `.arr` (arrow), `.leader` (dashed). Color ramps: `class=\"c-blue\"` on `<g>` wrapping shape+text — auto light/dark. Available: c-purple, c-teal, c-coral, c-blue, c-amber, c-green, c-red, c-gray, c-pink. Max 2-3 ramps per diagram.\n\n",
258-
"Chart.js: wrap canvas in div with position:relative + explicit height. Load UMD from cdnjs.cloudflare.com with onload callback. Disable default legend, build custom HTML legend with 10px colored squares.\n\n",
259-
"Interactive: form elements pre-styled. Use sendPrompt(text) for drill-down. CDN: cdnjs.cloudflare.com, cdn.jsdelivr.net, unpkg.com, esm.sh only.\n\n",
260-
"Always output COMPLETE standalone HTML (DOCTYPE, html, head, body). No titles/prose inside widget — explanations go in your response text."
261-
);
262254
if let Some(sys) = system_msgs.first() {
263-
payload["system"] = serde_json::json!(format!("{}\n\n{}", sys.content, system_instructions_anthropic));
255+
payload["system"] = serde_json::json!(format!("{}\n\n{}", sys.content, CHAT_SYSTEM_INSTRUCTIONS));
264256
} else {
265-
payload["system"] = serde_json::json!(system_instructions_anthropic);
257+
payload["system"] = serde_json::json!(CHAT_SYSTEM_INSTRUCTIONS);
266258
}
267259

268260
let mut request = client
@@ -372,8 +364,9 @@ async fn stream_anthropic_sse(
372364
let mut stream = response.bytes_stream();
373365
let mut line_buffer = String::new();
374366
let mut output = String::new();
367+
let mut message_stop_received = false;
375368

376-
loop {
369+
'outer: loop {
377370
tokio::select! {
378371
_ = cancel_token.cancelled() => {
379372
return Err(AppError::Cancelled);
@@ -391,7 +384,8 @@ async fn stream_anthropic_sse(
391384
}
392385

393386
if parse_anthropic_sse_line(&line, on_token, &mut output)? {
394-
return Ok(output);
387+
message_stop_received = true;
388+
break 'outer;
395389
}
396390
}
397391
}
@@ -402,6 +396,12 @@ async fn stream_anthropic_sse(
402396
}
403397
}
404398

399+
if !message_stop_received {
400+
return Err(AppError::Http(
401+
"Stream ended without completion signal — connection may have been interrupted. Please retry.".to_string(),
402+
));
403+
}
404+
405405
Ok(output)
406406
}
407407

@@ -428,10 +428,7 @@ fn parse_anthropic_sse_line(
428428
};
429429
let payload = payload.trim();
430430

431-
let value: Value = match serde_json::from_str(payload) {
432-
Ok(v) => v,
433-
Err(_) => return Ok(false),
434-
};
431+
let value: Value = serde_json::from_str(payload)?;
435432

436433
let event_type = value.get("type").and_then(Value::as_str).unwrap_or("");
437434

0 commit comments

Comments
 (0)