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>
163 lines
6.5 KiB
TypeScript
163 lines
6.5 KiB
TypeScript
/**
|
|
* Main-process model bridge — Reginald's brain runs here. Model traffic (like
|
|
* gitea's) stays in main: the renderer is CSP-locked and never talks to the
|
|
* LLM directly. On `model:chat` it drives the agent loop against the configured
|
|
* OpenAI-compatible endpoint, executing `query_project` by reconciling the repo
|
|
* and building the requested view. v0 is read-only — writes still go through the
|
|
* propose-approve controls.
|
|
*/
|
|
|
|
import {
|
|
buildProjectView,
|
|
captureWork,
|
|
type ChangeProposal,
|
|
type ChatMessage,
|
|
createChatClient,
|
|
describeChange,
|
|
type ModelRouter,
|
|
type ProjectView,
|
|
proposalsFor,
|
|
type ProposeChangeArgs,
|
|
type QueryFilters,
|
|
REGINALD_SYSTEM,
|
|
REGINALD_TOOLS,
|
|
runAgentTurn,
|
|
toDirectiveInput,
|
|
} from '@commitea/core'
|
|
import { ipcMain } from 'electron'
|
|
|
|
import {
|
|
AGENT_SNAPSHOT_TTL_MS,
|
|
appendDirectiveEntry,
|
|
getGiteaClient,
|
|
getPmStateClient,
|
|
getSnapshot,
|
|
} from './gitea.js'
|
|
|
|
/** Small local model for prose + the read tool; big model reserved for later decomposition. */
|
|
function resolveModelRouter(): ModelRouter | null {
|
|
if (process.env.COMMITEA_E2E === '1') return null // e2e uses the scripted fixture Reginald
|
|
const baseUrl = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1'
|
|
if (!baseUrl) return null
|
|
return {
|
|
small: { baseUrl, model: process.env.COMMITEA_MODEL_SMALL ?? '' },
|
|
big: { baseUrl, model: process.env.COMMITEA_MODEL_BIG ?? '' },
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve which model to actually ask for. An explicit env override wins;
|
|
* otherwise ask the server which model is *loaded* (LM Studio's native
|
|
* `/api/v0/models`) so Reginald follows whatever you load — no config churn on a
|
|
* model switch. Falls back to the first non-embedding model, then a sane default.
|
|
*/
|
|
async function resolveLoadedModel(baseUrl: string, override: string): Promise<string> {
|
|
if (override) return override
|
|
const root = baseUrl.replace(/\/v1\/?$/, '')
|
|
try {
|
|
const res = await fetch(`${root}/api/v0/models`)
|
|
if (res.ok) {
|
|
const data = (await res.json()) as { data?: { id: string; state?: string; type?: string }[] }
|
|
const loaded = (data.data ?? []).find((m) => m.state === 'loaded' && m.type !== 'embeddings')
|
|
if (loaded) return loaded.id
|
|
}
|
|
} catch {
|
|
// native API unavailable — fall through to the OpenAI listing
|
|
}
|
|
try {
|
|
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/models`)
|
|
if (res.ok) {
|
|
const data = (await res.json()) as { data?: { id: string }[] }
|
|
const first = (data.data ?? []).find((m) => !/embed/i.test(m.id))
|
|
if (first) return first.id
|
|
}
|
|
} catch {
|
|
// ignore — use the default
|
|
}
|
|
return 'google/gemma-4-e4b'
|
|
}
|
|
|
|
export function registerModelIpc(): void {
|
|
const router = resolveModelRouter()
|
|
|
|
ipcMain.handle('model:status', async () => {
|
|
if (!router) return { configured: false, model: null }
|
|
const model = await resolveLoadedModel(router.small.baseUrl, router.small.model)
|
|
return { configured: true, model }
|
|
})
|
|
|
|
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).
|
|
const proposals: ChangeProposal[] = []
|
|
|
|
const execute = async (name: string, args: unknown) => {
|
|
if (!client) return { error: 'gitea is not configured' }
|
|
if (name === 'query_project') {
|
|
// reuse a recent reconcile — a multi-tool turn shouldn't refetch the repo each call
|
|
const snap = await getSnapshot(client, { maxAgeMs: AGENT_SNAPSHOT_TTL_MS })
|
|
const a = (args ?? {}) as { view: ProjectView; filters?: QueryFilters }
|
|
return buildProjectView(a.view, a.filters, snap, new Date())
|
|
}
|
|
if (name === 'propose_change') {
|
|
const a = (args ?? {}) as ProposeChangeArgs
|
|
const issue = await client.getIssue(a.issue).catch(() => null)
|
|
if (!issue) return { error: `issue #${a.issue} not found` }
|
|
const built = proposalsFor(a, issue.labels, issue.title)
|
|
proposals.push(...built)
|
|
return built.length
|
|
? { proposed: built.map((p) => ({ issue: a.issue, diff: describeChange(p.plan) })) }
|
|
: { proposed: [], note: 'no change — already at that value' }
|
|
}
|
|
if (name === 'record_directive') {
|
|
const pm = getPmStateClient()
|
|
if (!pm) return { error: 'pm-state is not configured' }
|
|
try {
|
|
const entry = await appendDirectiveEntry(pm, toDirectiveInput(args))
|
|
return { recorded: { kind: entry.kind, quote: entry.quote } }
|
|
} catch (e) {
|
|
return { error: `could not record — is the pm-state repo created? (${e instanceof Error ? e.message : e})` }
|
|
}
|
|
}
|
|
return { error: `unknown tool: ${name}` }
|
|
}
|
|
|
|
try {
|
|
const turn = await runAgentTurn({
|
|
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) {
|
|
return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) }
|
|
}
|
|
})
|
|
|
|
// capture_work — braindump → proposed issue set. The big model does the
|
|
// decomposition (with one loaded local model, that's the loaded one). Returns
|
|
// a proposal; nothing is filed until the Capture tray approves it.
|
|
ipcMain.handle('model:capture', async (_event, braindump: string) => {
|
|
if (!router) return { ok: false as const, reason: 'unconfigured' as const }
|
|
const model = await resolveLoadedModel(router.big.baseUrl, router.big.model)
|
|
const chat = createChatClient({ ...router.big, model }, fetch)
|
|
try {
|
|
const proposal = await captureWork((m, t) => chat.complete(m, t), braindump)
|
|
return { ok: true as const, ...proposal }
|
|
} catch (e) {
|
|
return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) }
|
|
}
|
|
})
|
|
}
|