token streaming & reasoning display: live SSE tokens frontend to back
Nightly Build / build (push) Successful in 6m59s
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:
@@ -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 ↓${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 ↓${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 ↓${msg.output_tokens?.toLocaleString()} tok</div>` : nothing}
|
||||
</div>`;
|
||||
case 'error':
|
||||
return html`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user