Files
commitea/packages/core/src/agent/chat-client.ts
Croissant Le Doux dbcdcda5e7 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>
2026-07-09 00:22:12 -04:00

185 lines
6.0 KiB
TypeScript

/**
* Reginald's model client — a thin OpenAI-wire chat-completions client over an
* injected fetch (the same seam the gitea client uses). `@commitea/core` stays
* pure: no network, no globals. The desktop main process passes the real
* `fetch`; tests pass a stub. Points at any OpenAI-compatible endpoint (LM
* Studio, Ollama, the OpenAI API) — the model router picks which.
*/
import type { FetchLike } from '../gitea/types.js'
/** Where a model lives + which model to ask for. */
export interface ModelConfig {
/** OpenAI-compatible base, including the version segment, e.g. `http://localhost:1234/v1`. */
baseUrl: string
model: string
/** Bearer key; omitted for local servers that don't check it. */
apiKey?: string
}
/** A model's request to run a tool. `arguments` is a raw JSON string (OpenAI shape). */
export interface ToolCall {
id: string
name: string
arguments: string
}
/** One turn in the conversation, in our normalized shape. */
export interface ChatMessage {
role: 'system' | 'user' | 'assistant' | 'tool'
content: string
/** assistant turns that requested tools. */
toolCalls?: ToolCall[]
/** tool turns: which call they answer. */
toolCallId?: string
/** tool turns: the function name. */
name?: string
}
/** A tool the model may call — name, description, and a JSON-Schema parameter spec. */
export interface ToolDecl {
name: string
description: string
parameters: object
}
export interface CompletionResult {
content: string
toolCalls: ToolCall[]
}
/** Called with each streamed content delta (final-prose streaming). */
export type OnToken = (delta: string) => void
export interface ChatClient {
/** When `onToken` is given, the response streams (SSE) and each content delta is emitted. */
complete(messages: ChatMessage[], tools?: ToolDecl[], onToken?: OnToken): Promise<CompletionResult>
}
/** Map our message shape to the OpenAI wire shape. */
function toWireMessage(m: ChatMessage): Record<string, unknown> {
if (m.role === 'assistant' && m.toolCalls?.length) {
return {
role: 'assistant',
content: m.content || null,
tool_calls: m.toolCalls.map((tc) => ({
id: tc.id,
type: 'function',
function: { name: tc.name, arguments: tc.arguments },
})),
}
}
if (m.role === 'tool') {
return { role: 'tool', tool_call_id: m.toolCallId, content: m.content }
}
return { role: m.role, content: m.content }
}
function toWireTool(t: ToolDecl): Record<string, unknown> {
return { type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters } }
}
/** Shape of the one choice we read back. */
interface RawChoiceMessage {
content: string | null
tool_calls?: { id: string; function: { name: string; arguments: string } }[]
}
export function createChatClient(config: ModelConfig, fetchImpl: FetchLike): ChatClient {
const url = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`
return {
async complete(messages, tools, onToken) {
const body: Record<string, unknown> = {
model: config.model,
messages: messages.map(toWireMessage),
temperature: 0,
}
if (tools?.length) {
body.tools = tools.map(toWireTool)
body.tool_choice = 'auto'
}
const stream = !!onToken
if (stream) body.stream = true
const res = await fetchImpl(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: stream ? 'text/event-stream' : 'application/json',
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
},
body: JSON.stringify(body),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`model completion failed (${res.status}): ${text.slice(0, 200)}`)
}
if (stream && res.body) return readStream(res.body, onToken!)
const json = (await res.json()) as { choices?: { message: RawChoiceMessage }[] }
const msg = json.choices?.[0]?.message
return {
content: msg?.content ?? '',
toolCalls: (msg?.tool_calls ?? []).map((tc) => ({
id: tc.id,
name: tc.function.name,
arguments: tc.function.arguments,
})),
}
},
}
}
/** Streamed tool-call delta: name arrives first, arguments accumulate across chunks. */
interface RawToolDelta {
index: number
id?: string
function?: { name?: string; arguments?: string }
}
/** Parse an OpenAI SSE stream: emit content deltas via onToken, accumulate the final result. */
async function readStream(body: ReadableStream<Uint8Array>, onToken: OnToken): Promise<CompletionResult> {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let content = ''
const toolAcc: { id: string; name: string; arguments: string }[] = []
const handle = (data: string) => {
if (data === '[DONE]') return
let chunk: { choices?: { delta?: { content?: string; tool_calls?: RawToolDelta[] } }[] }
try {
chunk = JSON.parse(data)
} catch {
return
}
const delta = chunk.choices?.[0]?.delta
if (!delta) return
if (delta.content) {
content += delta.content
onToken(delta.content)
}
for (const tc of delta.tool_calls ?? []) {
const slot = (toolAcc[tc.index] ??= { id: '', name: '', arguments: '' })
if (tc.id) slot.id = tc.id
if (tc.function?.name) slot.name = tc.function.name
if (tc.function?.arguments) slot.arguments += tc.function.arguments
}
}
for (;;) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const t = line.trim()
if (t.startsWith('data:')) handle(t.slice(5).trim())
}
}
if (buffer.trim().startsWith('data:')) handle(buffer.trim().slice(5).trim())
return { content, toolCalls: toolAcc.filter((t) => t.name).map((t) => ({ id: t.id, name: t.name, arguments: t.arguments })) }
}