feat: token streaming for orchestrator final response
Switches the orchestrator's final response from a fire-and-wait model to a
live SSE stream so text appears token-by-token as the model generates it.
- llm_client: complete() gains token_sink param; anthropic_api backend uses
client.messages.stream(); local backend uses httpx SSE streaming; non-streaming
backends (claude_cli, gemini_cli) emit the full text as one chunk
- orchestrator_engine + openai_orchestrator: token_sink threaded through run(),
_run_from_contents(), _claude_handoff(), and _run_from_messages()
- routers/orchestrator: each job gets an asyncio.Queue; _on_progress and
_token_sink write progress/token events to it; _finalize_job emits done,
error handler emits error, confirmation gate emits confirm; new GET
/orchestrate/{job_id}/stream SSE endpoint with 20s keepalive
- app.js: _doOrchestrate switches from 2s poll loop to EventSource; thinking
bubble converts to a streaming message on first token; auto-scroll while
streaming; confirm/error/done events handled; finalization unchanged
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1475,68 +1475,79 @@
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { job_id } = await res.json();
|
||||
|
||||
// Poll until complete or stopped
|
||||
let job;
|
||||
while (true) {
|
||||
if (activeController.signal.aborted) throw new DOMException('Aborted', 'AbortError');
|
||||
// Stream events from the job via SSE
|
||||
const job = await new Promise((resolve, reject) => {
|
||||
const es = new EventSource(`/orchestrate/${job_id}/stream`);
|
||||
let streamingStarted = false;
|
||||
let accumulatedText = '';
|
||||
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
const abort = activeController.signal;
|
||||
abort.addEventListener('abort', () => { es.close(); reject(new DOMException('Aborted', 'AbortError')); });
|
||||
|
||||
if (activeController.signal.aborted) throw new DOMException('Aborted', 'AbortError');
|
||||
es.onmessage = async (e) => {
|
||||
let event;
|
||||
try { event = JSON.parse(e.data); } catch { return; }
|
||||
|
||||
const pollRes = await fetch(`/orchestrate/${job_id}`, {
|
||||
signal: activeController.signal,
|
||||
});
|
||||
if (!pollRes.ok) throw new Error(`Poll failed: HTTP ${pollRes.status}`);
|
||||
job = await pollRes.json();
|
||||
if (event.type === 'connected' || event.type === 'keepalive') return;
|
||||
|
||||
if (job.status === 'queued' || job.status === 'running') {
|
||||
const prog = job.progress;
|
||||
const n = job.tool_calls?.length || 0;
|
||||
if (prog) {
|
||||
thinkingDiv.textContent = `⚡ ${prog}`;
|
||||
} else {
|
||||
thinkingDiv.textContent = n
|
||||
? `⚡ working… (${n} tool${n !== 1 ? 's' : ''} used)`
|
||||
: '⚡ working…';
|
||||
if (event.type === 'progress') {
|
||||
if (!streamingStarted) thinkingDiv.textContent = `⚡ ${event.text}`;
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (job.status === 'awaiting_confirmation') {
|
||||
const pc = job.pending_confirmation || {};
|
||||
const toolNames = (pc.tools || []).map(t => t.name).join(', ');
|
||||
thinkingDiv.className = 'message assistant';
|
||||
thinkingDiv.innerHTML = `<div class="confirm-gate">
|
||||
<p>${escapeHtml(pc.message || 'Confirm this action?')}</p>
|
||||
<p class="confirm-tools">Tool${(pc.tools||[]).length !== 1 ? 's' : ''}: <code>${escapeHtml(toolNames)}</code></p>
|
||||
<div class="confirm-actions">
|
||||
<button class="confirm-btn">Confirm</button>
|
||||
<button class="deny-btn">Deny</button>
|
||||
</div>
|
||||
</div>`;
|
||||
if (event.type === 'token') {
|
||||
if (!streamingStarted) {
|
||||
streamingStarted = true;
|
||||
thinkingDiv.className = 'message assistant';
|
||||
thinkingDiv.innerHTML = '';
|
||||
}
|
||||
accumulatedText += event.text;
|
||||
setMessageText(thinkingDiv, 'assistant', accumulatedText);
|
||||
thinkingDiv.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await new Promise(resolve => {
|
||||
thinkingDiv.querySelector('.confirm-btn').onclick = () => resolve(true);
|
||||
thinkingDiv.querySelector('.deny-btn').onclick = () => resolve(false);
|
||||
});
|
||||
if (event.type === 'confirm') {
|
||||
const pc = event;
|
||||
const toolNames = (pc.tools || []).map(t => t.name).join(', ');
|
||||
thinkingDiv.className = 'message assistant';
|
||||
thinkingDiv.innerHTML = `<div class="confirm-gate">
|
||||
<p>${escapeHtml(pc.message || 'Confirm this action?')}</p>
|
||||
<p class="confirm-tools">Tool${(pc.tools||[]).length !== 1 ? 's' : ''}: <code>${escapeHtml(toolNames)}</code></p>
|
||||
<div class="confirm-actions">
|
||||
<button class="confirm-btn">Confirm</button>
|
||||
<button class="deny-btn">Deny</button>
|
||||
</div>
|
||||
</div>`;
|
||||
const confirmed = await new Promise(r => {
|
||||
thinkingDiv.querySelector('.confirm-btn').onclick = () => r(true);
|
||||
thinkingDiv.querySelector('.deny-btn').onclick = () => r(false);
|
||||
});
|
||||
thinkingDiv.className = 'message assistant thinking';
|
||||
thinkingDiv.textContent = confirmed ? '⚡ confirmed — continuing…' : '⚡ denied — finishing…';
|
||||
streamingStarted = false;
|
||||
accumulatedText = '';
|
||||
const action = confirmed ? 'confirm' : 'deny';
|
||||
await fetch(`/orchestrate/${job_id}/${action}`, { method: 'POST' });
|
||||
return;
|
||||
}
|
||||
|
||||
thinkingDiv.className = 'message assistant thinking';
|
||||
thinkingDiv.textContent = confirmed ? '⚡ confirmed — continuing…' : '⚡ denied — finishing…';
|
||||
if (event.type === 'error') {
|
||||
es.close();
|
||||
reject(new Error(event.message || 'Orchestrator failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const action = confirmed ? 'confirm' : 'deny';
|
||||
const resumeRes = await fetch(`/orchestrate/${job_id}/${action}`, {
|
||||
method: 'POST',
|
||||
signal: activeController.signal,
|
||||
});
|
||||
if (!resumeRes.ok) throw new Error(`Resume failed: HTTP ${resumeRes.status}`);
|
||||
continue;
|
||||
}
|
||||
if (event.type === 'done') {
|
||||
es.close();
|
||||
// If we received tokens, the response is already rendered —
|
||||
// use accumulatedText; otherwise fall back to event.response.
|
||||
resolve({ ...event, response: accumulatedText || event.response });
|
||||
}
|
||||
};
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (job.status === 'error') throw new Error(job.error || 'Orchestrator failed');
|
||||
es.onerror = () => { es.close(); reject(new Error('Stream connection lost')); };
|
||||
});
|
||||
|
||||
// Update session so this turn is part of the resumable history
|
||||
if (job.session_id) {
|
||||
@@ -1548,6 +1559,7 @@
|
||||
const userHistIdx = currentHistory.length - 1; // pushed before fetch
|
||||
attachHistoryControls(userMsgDiv, userHistIdx);
|
||||
|
||||
// If tokens streamed, the div is already a message; if not, set text now.
|
||||
thinkingDiv.className = 'message assistant';
|
||||
setMessageText(thinkingDiv, 'assistant', job.response || '(no response)');
|
||||
const assistHistIdx = currentHistory.length;
|
||||
|
||||
Reference in New Issue
Block a user