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>
185 lines
6.0 KiB
TypeScript
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 })) }
|
|
}
|