From d70c6e2542c601b7e89e82d0280baf03c9ec05b0 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 21:03:23 -0400 Subject: [PATCH] feat: Reginald follows the model you load (auto-detect) + shows it in the header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of a hardcoded model name (which forces LM Studio to JIT-swap your loaded model out — and fails when a big model already fills memory), resolve the model at request time: an explicit env override wins, else ask the server which model is *loaded* (LM Studio's native /api/v0/models), else the first non-embedding model, else a default. Reginald now uses whatever you load, no config churn. - main/model.ts: resolveLoadedModel() drives both model:status and model:chat; COMMITEA_MODEL_SMALL still overrides. - useChat exposes the resolved model id; the panel header shows it (google/gemma-4-26b-a4b-qat → "gemma-4-26b-a4b · local"). - live-reginald e2e: header assertion relaxed to the loaded model; timeouts raised for a slow big local model (~2 calls/turn + a reconcile). Verified: 14 fixture e2e green; live e2e drives the app against the loaded gemma-4-26b — "What now?" → "You should work on #2 … on the critical path, unblocks #33 and #4" (the real scheduler pick), header shows the live model. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/e2e/live-reginald.spec.ts | 8 ++-- apps/desktop/src/main/model.ts | 48 ++++++++++++++++--- .../src/components/shell/chat-panel.tsx | 6 ++- apps/desktop/src/renderer/src/lib/use-chat.ts | 6 ++- 4 files changed, 54 insertions(+), 14 deletions(-) diff --git a/apps/desktop/e2e/live-reginald.spec.ts b/apps/desktop/e2e/live-reginald.spec.ts index c15469c..a26b757 100644 --- a/apps/desktop/e2e/live-reginald.spec.ts +++ b/apps/desktop/e2e/live-reginald.spec.ts @@ -11,13 +11,13 @@ const MAIN = join(here, '..', 'out', 'main', 'index.js') test.describe('live Reginald', () => { test('answers a question by consulting the real project', async () => { test.skip(!process.env.GITEA_LIVE || !process.env.COMMITEA_MODEL_LIVE, 'live model test — opt-in') - test.setTimeout(120_000) + test.setTimeout(300_000) // a big local model is slow: ~2 calls/turn + a reconcile const app = await electron.launch({ args: [MAIN], env: { ...process.env } }) const win = await app.firstWindow() await win.waitForLoadState('domcontentloaded') - // model configured → the live greeting + header, not the scripted demo - await expect(win.getByText('gemma-4 · local')).toBeVisible({ timeout: 20000 }) + // model configured → the live greeting + header (the loaded model, not the scripted demo) + await expect(win.getByText(/· local$/)).toBeVisible({ timeout: 20000 }) await expect(win.getByText(/I check the real board before I answer/)).toBeVisible() const composer = win.getByPlaceholder(/Tell me what to do/) @@ -25,7 +25,7 @@ test.describe('live Reginald', () => { await composer.press('Enter') // the agent loop ran end-to-end: it consulted the project, then answered - await expect(win.getByText(/consulted the project/)).toBeVisible({ timeout: 90_000 }) + await expect(win.getByText(/consulted the project/)).toBeVisible({ timeout: 240_000 }) await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-reginald.png'), fullPage: true, animations: 'disabled' }) await app.close() diff --git a/apps/desktop/src/main/model.ts b/apps/desktop/src/main/model.ts index b79afd9..18f8208 100644 --- a/apps/desktop/src/main/model.ts +++ b/apps/desktop/src/main/model.ts @@ -28,23 +28,57 @@ function resolveModelRouter(): ModelRouter | null { const baseUrl = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1' if (!baseUrl) return null return { - small: { baseUrl, model: process.env.COMMITEA_MODEL_SMALL ?? 'google/gemma-4-e4b' }, - big: { baseUrl, model: process.env.COMMITEA_MODEL_BIG ?? 'qwen/qwen3.6-35b-a3b' }, + 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 { + 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', () => ({ - configured: !!router, - model: router?.small.model ?? null, - })) + 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 chat = createChatClient(router.small, fetch) + const model = await resolveLoadedModel(router.small.baseUrl, router.small.model) + const chat = createChatClient({ ...router.small, model }, fetch) const execute = async (name: string, args: unknown) => { if (name !== 'query_project') return { error: `unknown tool: ${name}` } diff --git a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx index 23ed380..081dbf7 100644 --- a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx +++ b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx @@ -15,7 +15,9 @@ export interface ChatPanelProps { } export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) { - const { msgs, thinking, live, steps, send: sendChat } = useChat() + const { msgs, thinking, live, model, steps, send: sendChat } = useChat() + // shorten "google/gemma-4-26b-a4b-qat" → "gemma-4-26b" for the header chip + const modelLabel = model ? (model.split('/').pop() ?? model).replace(/-(qat|instruct|it|gguf)$/i, '') : 'gemma-4' const [text, setText] = useState('') const scrollRef = useRef(null) @@ -56,7 +58,7 @@ export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) { Reginald - {offline ? 'offline · queueing' : live ? 'gemma-4 · local' : 'demo · scripted'} + {offline ? 'offline · queueing' : live ? `${modelLabel} · local` : 'demo · scripted'} diff --git a/apps/desktop/src/renderer/src/lib/use-chat.ts b/apps/desktop/src/renderer/src/lib/use-chat.ts index ec3b25b..934970f 100644 --- a/apps/desktop/src/renderer/src/lib/use-chat.ts +++ b/apps/desktop/src/renderer/src/lib/use-chat.ts @@ -14,6 +14,8 @@ export interface ChatState { thinking: boolean /** true once a model endpoint is confirmed; otherwise the panel echoes the demo reply. */ live: boolean + /** The loaded model's id when live (for the header). */ + model: string | null /** Tools Reginald consulted on the last turn (for a subtle activity line). */ steps: string[] send: (text: string) => void @@ -31,6 +33,7 @@ export function useChat(): ChatState { const [convo, setConvo] = useState([]) const [thinking, setThinking] = useState(false) const [live, setLive] = useState(false) + const [model, setModel] = useState(null) const [steps, setSteps] = useState([]) const convoRef = useRef(convo) convoRef.current = convo @@ -42,6 +45,7 @@ export function useChat(): ChatState { .then((s) => { if (alive && s.configured) { setLive(true) + setModel(s.model) setSeed([LIVE_GREETING]) } }) @@ -97,5 +101,5 @@ export function useChat(): ChatState { [live], ) - return { msgs: [...seed, ...convo], thinking, live, steps, send } + return { msgs: [...seed, ...convo], thinking, live, model, steps, send } }