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:
@@ -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
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user