feat: stream Reginald's replies token-by-token
The 26b is slow (~30s/call); the chat now shows the answer forming instead of freezing until it's done. The final prose streams over SSE; tool-calling turns stay structured (no partial tokens), so streaming kicks in for the narration. core (@commitea/core): - chat-client.complete gains an optional onToken — when set, it requests stream:true and parses the OpenAI SSE stream, emitting content deltas and assembling streamed tool-call argument fragments into the final result. - GiteaHttpResponse exposes the optional `body` stream (real fetch has it; stubs don't). agent-loop threads onToken to each completion. app: - model:chat forwards each delta to the renderer (event.sender.send); preload exposes model.onToken(cb) → unsubscribe. useChat accumulates the live stream into a growing bubble (with a cursor), replaced by the authoritative final content when the turn resolves. Unconfigured → scripted reply, unchanged. Verified: 118 core tests green (2 streaming: SSE content deltas + tool-call fragment assembly), desktop typecheck clean, 14 fixture e2e green. Live: a real turn against gemma-4-26b assembles the correct answer via the streaming path (live-reginald green) — the reply now renders token-by-token. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -86,11 +86,15 @@ export function registerModelIpc(): void {
|
||||
return { configured: true, model }
|
||||
})
|
||||
|
||||
ipcMain.handle('model:chat', async (_event, messages: ChatMessage[]) => {
|
||||
ipcMain.handle('model:chat', async (event, messages: ChatMessage[]) => {
|
||||
if (!router) return { ok: false as const, reason: 'unconfigured' as const }
|
||||
const client = getGiteaClient()
|
||||
const model = await resolveLoadedModel(router.small.baseUrl, router.small.model)
|
||||
const chat = createChatClient({ ...router.small, model }, fetch)
|
||||
// stream the model's prose to the renderer token-by-token
|
||||
const onToken = (delta: string) => {
|
||||
if (!event.sender.isDestroyed()) event.sender.send('model:chat:token', delta)
|
||||
}
|
||||
|
||||
// Proposals the model formulates this turn; the renderer approves them (the
|
||||
// write happens through gitea:applyChange, never inside the loop).
|
||||
@@ -129,10 +133,11 @@ export function registerModelIpc(): void {
|
||||
|
||||
try {
|
||||
const turn = await runAgentTurn({
|
||||
complete: (m, t) => chat.complete(m, t),
|
||||
complete: (m, t, ot) => chat.complete(m, t, ot),
|
||||
messages: [{ role: 'system', content: REGINALD_SYSTEM }, ...messages],
|
||||
tools: REGINALD_TOOLS,
|
||||
execute,
|
||||
onToken,
|
||||
})
|
||||
return { ok: true as const, content: turn.content, steps: turn.steps, proposals }
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user