token streaming & reasoning display: live SSE tokens frontend to back
Nightly Build / build (push) Successful in 6m59s

- Add StreamDelta(SseDecoder) framing shared by OpenAI/Anthropic
- OpenAiClient: stream=true + reasoning_content deltas, index-based
  tool_calls accumulation, usage from final chunk
- AnthropicClient: message_start/content_block_*/message_delta events,
  thinking_delta->reasoning, input_json_delta->tool input
- TokenDelta ServerEvent variant wired through ChatHub + WS broadcast
- Frontend throttled flush (~15 Hz), pending bubble mutate-in-place,
  reasoning as collapsed-by-default <details>
- Drop streaming bubble on error/llm_failed/model_fallback
- i18n: chat.reasoning key added to en/fr/it
This commit is contained in:
2026-07-22 12:59:13 +01:00
parent e1d285e7db
commit 3343260bb0
23 changed files with 950 additions and 113 deletions
+19 -1
View File
@@ -507,6 +507,21 @@ export function renderAttachmentChips(host, attachments, { removable = false } =
</div>`;
}
/**
* Collapsible chain-of-thought block: small, muted, collapsed by default so it
* never weighs on the UI. A native <details> — Lit keeps the element stable
* across re-renders, so a user-expanded block stays open while tokens stream
* into it (live) and in past history items alike.
*/
function renderReasoning(msg) {
if (!msg.reasoning) return nothing;
return html`
<details class="reasoning-block ${msg.streaming ? 'reasoning-block--live' : ''}">
<summary>${t('chat.reasoning')}</summary>
<div class="reasoning-content">${msg.reasoning}</div>
</details>`;
}
export function renderMsg(host, msg) {
try {
switch (msg.kind) {
@@ -516,6 +531,7 @@ export function renderMsg(host, msg) {
return html`
<div class="copilot-msg assistant copilot-markdown ${msg.failed ? 'copilot-msg--failed' : ''}">
${msg.failed ? failedBadge() : nothing}
${renderReasoning(msg)}
${unsafeHTML(renderMarkdown(msg.content))}
${msg.input_tokens != null ? html`<div class="copilot-token-count">↑${msg.input_tokens.toLocaleString()} tok &nbsp;↓${msg.output_tokens?.toLocaleString()} tok</div>` : nothing}
</div>`;
@@ -523,8 +539,10 @@ export function renderMsg(host, msg) {
return html`
<div class="copilot-msg assistant copilot-markdown ${msg.failed ? 'copilot-msg--failed' : ''}">
${msg.failed ? failedBadge() : nothing}
${renderReasoning(msg)}
${unsafeHTML(renderMarkdown(msg.content))}
${msg.input_tokens != null ? html`<div class="copilot-token-count">↑${msg.input_tokens.toLocaleString()} tok &nbsp;↓${msg.output_tokens?.toLocaleString()} tok</div>` : nothing}
${msg.streaming ? html`<span class="stream-caret"></span>` : nothing}
${msg.input_tokens != null && !msg.streaming ? html`<div class="copilot-token-count">↑${msg.input_tokens.toLocaleString()} tok &nbsp;↓${msg.output_tokens?.toLocaleString()} tok</div>` : nothing}
</div>`;
case 'error':
return html`
+69
View File
@@ -88,6 +88,75 @@
letter-spacing: 0.02em;
}
/* ── Reasoning (chain-of-thought) block ────────────────────────────────────── */
/* Small, low-contrast, collapsed by default: visible but never heavy. */
.reasoning-block {
margin: 0 0 0.45rem;
font-size: 0.75rem;
color: var(--placeholder-color);
opacity: 0.75;
}
.reasoning-block > summary {
cursor: pointer;
user-select: none;
font-style: italic;
letter-spacing: 0.02em;
list-style-position: inside;
padding: 0.1rem 0;
}
.reasoning-block > summary:hover {
color: var(--msg-assistant-text);
}
.reasoning-block--live > summary {
animation: reasoning-pulse 1.6s ease-in-out infinite;
}
.reasoning-content {
margin-top: 0.35rem;
padding-left: 0.6rem;
border-left: 2px solid var(--toolbar-border);
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono);
font-size: 0.72rem;
line-height: 1.5;
max-height: 16rem;
overflow-y: auto;
}
@keyframes reasoning-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
@media (prefers-reduced-motion: reduce) {
.reasoning-block--live > summary { animation: none; }
}
/* Blinking caret at the end of a streaming assistant bubble. */
.stream-caret {
display: inline-block;
width: 0.5em;
height: 1em;
margin-left: 0.1em;
vertical-align: text-bottom;
background: currentColor;
opacity: 0.6;
animation: stream-caret-blink 1s steps(2, start) infinite;
}
@keyframes stream-caret-blink {
to { visibility: hidden; }
}
@media (prefers-reduced-motion: reduce) {
.stream-caret { animation: none; }
}
/* ── Tool call blocks ──────────────────────────────────────────────────────── */
.copilot-tool {
+1
View File
@@ -65,6 +65,7 @@ export default {
'chat.send': 'Send',
'chat.stop': 'Stop',
'chat.thinking': 'Thinking…',
'chat.reasoning': 'Reasoning…',
'chat.attach': 'Attach files',
'chat.new_session': 'New conversation',
'chat.security_group': 'Security group',
+1
View File
@@ -65,6 +65,7 @@ export default {
'chat.send': 'Envoyer',
'chat.stop': 'Arrêter',
'chat.thinking': 'Réflexion…',
'chat.reasoning': 'Raisonnement…',
'chat.attach': 'Joindre des fichiers',
'chat.new_session': 'Nouvelle conversation',
'chat.security_group': 'Groupe de sécurité',
+1
View File
@@ -65,6 +65,7 @@ export default {
'chat.send': 'Invia',
'chat.stop': 'Ferma',
'chat.thinking': 'Sto pensando…',
'chat.reasoning': 'Ragionamento…',
'chat.attach': 'Allega file',
'chat.new_session': 'Nuova conversazione',
'chat.security_group': 'Gruppo di sicurezza',
+103 -7
View File
@@ -51,6 +51,7 @@ export class ChatSession extends LightElement {
// STOP button when reconnecting mid-turn).
static _STREAMING_EVENTS = new Set([
'thinking', 'tool_start', 'agent_start', 'pending_write', 'approval_required',
'token_delta',
]);
constructor() {
@@ -251,6 +252,7 @@ export class ChatSession extends LightElement {
this._ws.close();
this._ws = null;
}
this._cancelStreamFlush();
this._messages = [];
this._waiting = false;
try {
@@ -290,18 +292,62 @@ export class ChatSession extends LightElement {
});
break;
case 'thinking':
this._push({ kind: 'thinking', message_id: msg.message_id, content: msg.content,
input_tokens: msg.input_tokens, output_tokens: msg.output_tokens });
case 'thinking': {
// A tool-call round's text. When the round streamed, its pending bubble
// becomes the thinking item in place. Reasoning comes from the event
// (buffered providers) or the streamed accumulation.
const last = this._messages[this._messages.length - 1];
const streaming = (last?.kind === 'assistant' && last.streaming) ? last : null;
const item = { kind: 'thinking', message_id: msg.message_id, content: msg.content,
reasoning: msg.reasoning_content ?? streaming?.reasoning ?? null,
input_tokens: msg.input_tokens, output_tokens: msg.output_tokens };
if (streaming) this._replaceLast(item); else this._push(item);
break;
}
case 'done':
this._waiting = false;
this._push({ kind: 'assistant', content: msg.content,
input_tokens: msg.input_tokens, output_tokens: msg.output_tokens });
case 'token_delta': {
// Best-effort live tokens. Accumulate into a pending assistant bubble;
// the final `done` (or `thinking`) event replaces it with authoritative
// content. Mutate in place + throttled flush: deltas can arrive at a
// high rate and a full Lit update per token would be wasteful.
let last = this._messages[this._messages.length - 1];
if (last?.kind !== 'assistant' || !last.streaming) {
last = { kind: 'assistant', content: '', reasoning: '', streaming: true };
this._messages = [...this._messages, last];
this._onMessagePushed(last);
}
if (msg.kind === 'reasoning') last.reasoning += msg.delta;
else last.content += msg.delta;
this._scheduleStreamFlush();
break;
}
case 'done': {
this._waiting = false;
const last = this._messages[this._messages.length - 1];
if (last?.kind === 'assistant' && last.streaming) {
// Finalize the streamed bubble with the authoritative content.
this._replaceLast({ kind: 'assistant', content: msg.content,
reasoning: msg.reasoning_content ?? last.reasoning ?? null,
input_tokens: msg.input_tokens, output_tokens: msg.output_tokens });
} else {
this._push({ kind: 'assistant', content: msg.content,
reasoning: msg.reasoning_content ?? null,
input_tokens: msg.input_tokens, output_tokens: msg.output_tokens });
}
break;
}
case 'tool_start': {
// A round with tool calls but no Thinking event (no usage/text) leaves a
// reasoning-only streaming bubble behind: finalize it in place so its
// content isn't swallowed by the next round's deltas.
const last = this._messages[this._messages.length - 1];
if (last?.kind === 'assistant' && last.streaming) {
this._replaceLast({ kind: 'thinking', content: last.content,
reasoning: last.reasoning || null,
input_tokens: null, output_tokens: null });
}
// On resume, the server re-emits ToolStart for tools already in history.
// Update in place rather than pushing a duplicate card.
const existingIdx = this._messages.findIndex(
@@ -399,6 +445,14 @@ export class ChatSession extends LightElement {
break;
case 'agent_done': {
// A sub-agent's final round emits no Done: its streamed bubble would
// stay pending forever — finalize it with the accumulated content.
const last = this._messages[this._messages.length - 1];
if (last?.kind === 'assistant' && last.streaming) {
this._replaceLast({ kind: 'assistant', content: last.content,
reasoning: last.reasoning || null,
input_tokens: null, output_tokens: null });
}
this._updateAgent(msg.stack_id, { done: true });
const agentMsg = this._messages.find(m => m.kind === 'agent' && m.stack_id === msg.stack_id);
if (agentMsg) {
@@ -419,6 +473,7 @@ export class ChatSession extends LightElement {
case 'error':
this._waiting = false;
this._dropStreaming();
this._pushError(msg.message);
break;
@@ -435,6 +490,9 @@ export class ChatSession extends LightElement {
}
case 'model_fallback':
// A fallback mid-stream means the previous attempt's deltas are orphaned:
// drop the pending bubble — the replacement model streams a fresh one.
this._dropStreaming();
this._push({ kind: 'info', content: `⚡ Model fallback: ${msg.from}${msg.to}` });
break;
@@ -453,6 +511,7 @@ export class ChatSession extends LightElement {
break;
case 'new_session':
this._cancelStreamFlush();
this._messages = [];
this._waiting = false;
break;
@@ -476,6 +535,7 @@ export class ChatSession extends LightElement {
case 'llm_failed':
this._waiting = false;
this._dropStreaming();
this._pushError(`LLM unavailable. Tried: ${msg.tried.join(', ')}. ${msg.last_error}`);
break;
}
@@ -487,6 +547,42 @@ export class ChatSession extends LightElement {
this._onMessagePushed(item);
}
// ── Live token streaming ────────────────────────────────────────────────────
// A pending assistant bubble (`streaming: true`) is mutated in place by
// `token_delta` events and flushed to Lit at most ~15×/s; turn-ending events
// (`done`/`thinking`) finalize it via `_replaceLast`, failures drop it.
_scheduleStreamFlush() {
if (this._streamFlushTimer) return;
this._streamFlushTimer = setTimeout(() => {
this._streamFlushTimer = null;
this._messages = [...this._messages];
this._scrollToBottom();
}, 66);
}
_cancelStreamFlush() {
if (!this._streamFlushTimer) return;
clearTimeout(this._streamFlushTimer);
this._streamFlushTimer = null;
}
_replaceLast(item) {
this._cancelStreamFlush();
const updated = [...this._messages];
updated[updated.length - 1] = item;
this._messages = updated;
this._scrollToBottom();
}
_dropStreaming() {
this._cancelStreamFlush();
const last = this._messages[this._messages.length - 1];
if (last?.kind === 'assistant' && last.streaming) {
this._messages = this._messages.slice(0, -1);
}
}
_pushError(text) {
this._push({ kind: 'error', content: text });
}