Assistant streams its answer token by token

ai/client.py streams the completion and puts the chunks back together
(Assembler), covering OpenAI's piecewise tool-call arguments, Ollama's whole
calls, OpenRouter's comment lines, unnumbered calls and in-stream errors.
The turn emits a delta event per piece and a message per round as before,
and the chat grows the last answer as deltas arrive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 17:37:56 +02:00
co-authored by Claude Opus 5.5
parent 429100a0c7
commit 58b92da8b0
8 changed files with 243 additions and 32 deletions
+3 -2
View File
@@ -115,8 +115,9 @@ export class AiService {
* Chat
* One turn, streamed as server-sent events.
*
* ``message``, ``tool_call`` and ``tool_result`` events as the model works,
* ``error`` when something stops it, and ``done`` last either way.
* ``delta`` as the model writes, ``message`` with a round's whole text,
* ``tool_call`` and ``tool_result`` as it works, ``error`` when something
* stops it, and ``done`` last either way.
* @param data The data for the request.
* @param data.requestBody
* @returns unknown Successful Response
+15 -6
View File
@@ -175,10 +175,12 @@ export function ChatPanel() {
.finally(() => setLoaded(true))
}, [open, loaded, available])
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the list grows, not on every render.
const tail = shown[shown.length - 1]
const tailLength = tail && "text" in tail ? tail.text.length : 0
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the list or a streamed answer grows, not on every render.
useEffect(() => {
bottom.current?.scrollIntoView({ block: "end" })
}, [shown.length, waiting])
}, [shown.length, tailLength, waiting])
const send = async () => {
const text = draft.trim()
@@ -190,11 +192,18 @@ export function ChatPanel() {
controller.current = abort
const onEvent = (event: ChatEvent) => {
switch (event.type) {
// A round's text grows in the last item and is settled by `message`;
// a tool call or the next turn always comes between two rounds.
case "delta":
case "message":
setItems((prev) => [
...prev,
{ kind: "assistant", text: event.content },
])
setItems((prev) => {
const last = prev[prev.length - 1]
if (last?.kind !== "assistant")
return [...prev, { kind: "assistant", text: event.content }]
const text =
event.type === "delta" ? last.text + event.content : event.content
return [...prev.slice(0, -1), { kind: "assistant", text }]
})
break
case "tool_call":
setItems((prev) => [
+1
View File
@@ -3,6 +3,7 @@ import { apiToken } from "@/lib/portal"
export type ChatEvent =
| { type: "start" }
| { type: "delta"; content: string }
| { type: "message"; content: string }
| { type: "tool_call"; name: string; arguments: Record<string, unknown> }
| { type: "tool_result"; name: string; ok: boolean; error?: string }