feat: Reginald follows the model you load (auto-detect) + shows it in the header
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) <noreply@anthropic.com>
This commit is contained in:
@@ -11,13 +11,13 @@ const MAIN = join(here, '..', 'out', 'main', 'index.js')
|
|||||||
test.describe('live Reginald', () => {
|
test.describe('live Reginald', () => {
|
||||||
test('answers a question by consulting the real project', async () => {
|
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.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 app = await electron.launch({ args: [MAIN], env: { ...process.env } })
|
||||||
const win = await app.firstWindow()
|
const win = await app.firstWindow()
|
||||||
await win.waitForLoadState('domcontentloaded')
|
await win.waitForLoadState('domcontentloaded')
|
||||||
|
|
||||||
// model configured → the live greeting + header, not the scripted demo
|
// model configured → the live greeting + header (the loaded model, not the scripted demo)
|
||||||
await expect(win.getByText('gemma-4 · local')).toBeVisible({ timeout: 20000 })
|
await expect(win.getByText(/· local$/)).toBeVisible({ timeout: 20000 })
|
||||||
await expect(win.getByText(/I check the real board before I answer/)).toBeVisible()
|
await expect(win.getByText(/I check the real board before I answer/)).toBeVisible()
|
||||||
|
|
||||||
const composer = win.getByPlaceholder(/Tell me what to do/)
|
const composer = win.getByPlaceholder(/Tell me what to do/)
|
||||||
@@ -25,7 +25,7 @@ test.describe('live Reginald', () => {
|
|||||||
await composer.press('Enter')
|
await composer.press('Enter')
|
||||||
|
|
||||||
// the agent loop ran end-to-end: it consulted the project, then answered
|
// 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 win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-reginald.png'), fullPage: true, animations: 'disabled' })
|
||||||
|
|
||||||
await app.close()
|
await app.close()
|
||||||
|
|||||||
@@ -28,23 +28,57 @@ function resolveModelRouter(): ModelRouter | null {
|
|||||||
const baseUrl = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1'
|
const baseUrl = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1'
|
||||||
if (!baseUrl) return null
|
if (!baseUrl) return null
|
||||||
return {
|
return {
|
||||||
small: { baseUrl, model: process.env.COMMITEA_MODEL_SMALL ?? 'google/gemma-4-e4b' },
|
small: { baseUrl, model: process.env.COMMITEA_MODEL_SMALL ?? '' },
|
||||||
big: { baseUrl, model: process.env.COMMITEA_MODEL_BIG ?? 'qwen/qwen3.6-35b-a3b' },
|
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 {
|
export function registerModelIpc(): void {
|
||||||
const router = resolveModelRouter()
|
const router = resolveModelRouter()
|
||||||
|
|
||||||
ipcMain.handle('model:status', () => ({
|
ipcMain.handle('model:status', async () => {
|
||||||
configured: !!router,
|
if (!router) return { configured: false, model: null }
|
||||||
model: router?.small.model ?? null,
|
const model = await resolveLoadedModel(router.small.baseUrl, router.small.model)
|
||||||
}))
|
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 }
|
if (!router) return { ok: false as const, reason: 'unconfigured' as const }
|
||||||
const client = getGiteaClient()
|
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) => {
|
const execute = async (name: string, args: unknown) => {
|
||||||
if (name !== 'query_project') return { error: `unknown tool: ${name}` }
|
if (name !== 'query_project') return { error: `unknown tool: ${name}` }
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ export interface ChatPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPanel({ onOpenDirectives, offline }: 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 [text, setText] = useState('')
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
|
|||||||
<Icon name="sparkles" size={16} style={{ color: offline ? 'var(--ink-3)' : 'var(--jade)' }} />
|
<Icon name="sparkles" size={16} style={{ color: offline ? 'var(--ink-3)' : 'var(--jade)' }} />
|
||||||
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>Reginald</span>
|
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>Reginald</span>
|
||||||
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}>
|
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}>
|
||||||
{offline ? 'offline · queueing' : live ? 'gemma-4 · local' : 'demo · scripted'}
|
{offline ? 'offline · queueing' : live ? `${modelLabel} · local` : 'demo · scripted'}
|
||||||
</span>
|
</span>
|
||||||
<IconButton icon="history" label="Directive log" size="sm" onClick={onOpenDirectives} />
|
<IconButton icon="history" label="Directive log" size="sm" onClick={onOpenDirectives} />
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface ChatState {
|
|||||||
thinking: boolean
|
thinking: boolean
|
||||||
/** true once a model endpoint is confirmed; otherwise the panel echoes the demo reply. */
|
/** true once a model endpoint is confirmed; otherwise the panel echoes the demo reply. */
|
||||||
live: boolean
|
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). */
|
/** Tools Reginald consulted on the last turn (for a subtle activity line). */
|
||||||
steps: string[]
|
steps: string[]
|
||||||
send: (text: string) => void
|
send: (text: string) => void
|
||||||
@@ -31,6 +33,7 @@ export function useChat(): ChatState {
|
|||||||
const [convo, setConvo] = useState<ChatMessage[]>([])
|
const [convo, setConvo] = useState<ChatMessage[]>([])
|
||||||
const [thinking, setThinking] = useState(false)
|
const [thinking, setThinking] = useState(false)
|
||||||
const [live, setLive] = useState(false)
|
const [live, setLive] = useState(false)
|
||||||
|
const [model, setModel] = useState<string | null>(null)
|
||||||
const [steps, setSteps] = useState<string[]>([])
|
const [steps, setSteps] = useState<string[]>([])
|
||||||
const convoRef = useRef(convo)
|
const convoRef = useRef(convo)
|
||||||
convoRef.current = convo
|
convoRef.current = convo
|
||||||
@@ -42,6 +45,7 @@ export function useChat(): ChatState {
|
|||||||
.then((s) => {
|
.then((s) => {
|
||||||
if (alive && s.configured) {
|
if (alive && s.configured) {
|
||||||
setLive(true)
|
setLive(true)
|
||||||
|
setModel(s.model)
|
||||||
setSeed([LIVE_GREETING])
|
setSeed([LIVE_GREETING])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -97,5 +101,5 @@ export function useChat(): ChatState {
|
|||||||
[live],
|
[live],
|
||||||
)
|
)
|
||||||
|
|
||||||
return { msgs: [...seed, ...convo], thinking, live, steps, send }
|
return { msgs: [...seed, ...convo], thinking, live, model, steps, send }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user