diff --git a/hub/src/overseer/brainClient.test.ts b/hub/src/overseer/brainClient.test.ts index d3364ea7f6..9317d717fd 100644 --- a/hub/src/overseer/brainClient.test.ts +++ b/hub/src/overseer/brainClient.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { filterChatModels, listBrainProfiles, resolveBrainConfig } from './brainClient' +import { filterChatModels, isKnownBrainProfile, listBrainProfiles, resolveBrainConfig, resolveBrainSelection } from './brainClient' const baseEnv = { OVERSEER_BRAIN_URL: 'http://local.test/v1/', @@ -32,8 +32,13 @@ describe('resolveBrainConfig', () => { expect(cfg).toMatchObject({ baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o', apiKey: 'sk-test' }) }) - it('falls back to default for an unknown profile', () => { - expect(resolveBrainConfig(multiEnv, { profile: 'nope' })?.baseUrl).toBe('http://local.test/v1') + it('returns null for an unknown named profile (no silent fallback to env default)', () => { + expect(resolveBrainConfig(multiEnv, { profile: 'nope' })).toBeNull() + }) + + it('uses only the env default when profile is explicitly default', () => { + const cfg = resolveBrainConfig(multiEnv, { profile: 'default' }) + expect(cfg).toMatchObject({ baseUrl: 'http://local.test/v1', model: 'main' }) }) it('model override wins over the selected profile model', () => { @@ -41,6 +46,35 @@ describe('resolveBrainConfig', () => { }) }) +describe('resolveBrainSelection', () => { + it('falls through to env default when nothing is set', () => { + expect(resolveBrainSelection(null)).toEqual({ model: undefined }) + }) + + it('uses the persisted active brain when no per-request override', () => { + expect(resolveBrainSelection({ profile: 'openai', model: 'gpt-4o' })).toEqual({ profile: 'openai', model: 'gpt-4o' }) + }) + + it('per-request profile overrides the active profile wholesale', () => { + expect(resolveBrainSelection({ profile: 'openai', model: 'gpt-4o' }, { profile: 'default' })) + .toEqual({ profile: 'default', model: undefined }) + }) + + it('per-request model alone re-skins the active profile', () => { + expect(resolveBrainSelection({ profile: 'openai', model: 'gpt-4o' }, { model: 'gpt-4o-mini' })) + .toEqual({ profile: 'openai', model: 'gpt-4o-mini' }) + }) + + it('active profile with null model resolves to profile default', () => { + expect(resolveBrainSelection({ profile: 'local', model: null })).toEqual({ profile: 'local', model: undefined }) + }) + + it('composes with resolveBrainConfig so the active brain becomes the effective config', () => { + const cfg = resolveBrainConfig(multiEnv, resolveBrainSelection({ profile: 'openai', model: 'gpt-4o-mini' })) + expect(cfg).toMatchObject({ baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini', apiKey: 'sk-test' }) + }) +}) + describe('listBrainProfiles', () => { it('lists the default plus named profiles (no url/key exposed)', () => { const list = listBrainProfiles(multiEnv) @@ -55,6 +89,14 @@ describe('listBrainProfiles', () => { }) }) +describe('isKnownBrainProfile', () => { + it('returns true for configured profile ids and false otherwise', () => { + expect(isKnownBrainProfile('default', multiEnv)).toBe(true) + expect(isKnownBrainProfile('openai', multiEnv)).toBe(true) + expect(isKnownBrainProfile('ghost', multiEnv)).toBe(false) + }) +}) + describe('filterChatModels', () => { it('drops non-chat models and sorts the rest', () => { const filtered = filterChatModels([ diff --git a/hub/src/overseer/brainClient.ts b/hub/src/overseer/brainClient.ts index 325b77f132..9b9af7c206 100644 --- a/hub/src/overseer/brainClient.ts +++ b/hub/src/overseer/brainClient.ts @@ -96,17 +96,45 @@ export function resolveBrainConfig( env: NodeJS.ProcessEnv = process.env, opts: { profile?: string; model?: string } = {} ): BrainConfig | null { - let cfg: BrainConfig | null = null const profile = opts.profile?.trim() + let cfg: BrainConfig | null = null if (profile && profile.toLowerCase() !== 'default') { cfg = readProfile(env, `OVERSEER_BRAIN_PROFILE_${profile.toUpperCase()}_`) + // Named profile requested but not configured — do not silently fall back to env default. + if (!cfg) return null + } else { + cfg = readProfile(env, 'OVERSEER_BRAIN_') } - if (!cfg) cfg = readProfile(env, 'OVERSEER_BRAIN_') if (!cfg) return null const model = opts.model?.trim() return model ? { ...cfg, model } : cfg } +/** + * Collapse the three brain-selection layers into a single `{ profile, model }` to hand + * to `resolveBrainConfig`. Precedence, highest first: + * 1. per-request override (converse body `profile`/`model`) — testing "at whim" + * 2. persisted active brain (operator's console choice, survives restart) + * 3. env default (falls through as no profile/model) + * + * An explicit per-request `profile` overrides the active profile wholesale (its own optional + * model, not the active profile's model). A per-request `model` alone re-skins the active profile. + */ +export function resolveBrainSelection( + active: { profile: string; model: string | null } | null, + opts: { profile?: string; model?: string } = {} +): { profile?: string; model?: string } { + const reqProfile = opts.profile?.trim() + const reqModel = opts.model?.trim() + if (reqProfile) { + return { profile: reqProfile, model: reqModel || undefined } + } + if (active) { + return { profile: active.profile, model: reqModel || active.model || undefined } + } + return { model: reqModel || undefined } +} + const NON_CHAT_MODEL = /embedding|whisper|tts|dall-?e|moderation|audio|realtime|image|transcribe|-search|babbage|davinci-002|instruct/i /** Keep the chat-usable model ids (drop embeddings/audio/image/etc.), sorted. */ @@ -137,6 +165,11 @@ export async function listBrainModels(config: BrainConfig, timeoutMs = 12_000): .filter((id): id is string => typeof id === 'string' && id.length > 0) } +/** True when `profile` is a brain the hub currently has configured in env. */ +export function isKnownBrainProfile(profile: string, env: NodeJS.ProcessEnv = process.env): boolean { + return listBrainProfiles(env).some((p) => p.id === profile) +} + /** List configured brain profiles for the UI (id/label/model only — no url/key). */ export function listBrainProfiles(env: NodeJS.ProcessEnv = process.env): OverseerBrainProfileInfo[] { const out: OverseerBrainProfileInfo[] = [] diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index ae8db3a426..79dda16f18 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -9,6 +9,7 @@ import { SessionStore } from './sessionStore' import { UserStore } from './userStore' import { EventStore } from './eventStore' import { InboxStore } from './inboxStore' +import { SettingsStore, ensureOverseerSettingsSchema } from './settingsStore' import { ensureOverseerEventsSchema, ensureDeletedSessionsSchema } from './events' import { ensureOverseerInboxSchema } from './inboxItems' @@ -28,6 +29,8 @@ export { SessionStore } from './sessionStore' export { UserStore } from './userStore' export { EventStore } from './eventStore' export { InboxStore } from './inboxStore' +export { SettingsStore } from './settingsStore' +export type { ActiveBrainSetting } from './settingsStore' export type { InsertSystemEventInput, ListSystemEventsOptions, StoredSystemEvent } from './eventStore' export type { ListInboxItemsOptions, StoredInboxItem } from './inboxStore' @@ -44,6 +47,7 @@ const REQUIRED_TABLES = [ 'inbox_items', 'inbox_item_source_events', 'inbox_operator_actions', + 'overseer_settings', ] as const export class Store { @@ -58,6 +62,7 @@ export class Store { readonly push: PushStore readonly events: EventStore readonly inbox: InboxStore + readonly settings: SettingsStore /** * Filesystem path of the underlying SQLite database, or ':memory:' for @@ -110,6 +115,7 @@ export class Store { this.push = new PushStore(this.db) this.events = new EventStore(this.db) this.inbox = new InboxStore(this.db) + this.settings = new SettingsStore(this.db) } close(): void { @@ -195,6 +201,7 @@ export class Store { ensureOverseerEventsSchema(this.db) ensureDeletedSessionsSchema(this.db) ensureOverseerInboxSchema(this.db) + ensureOverseerSettingsSchema(this.db) this.assertRequiredTablesPresent() } diff --git a/hub/src/store/settingsStore.test.ts b/hub/src/store/settingsStore.test.ts new file mode 100644 index 0000000000..42b45f1c70 --- /dev/null +++ b/hub/src/store/settingsStore.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { Database } from 'bun:sqlite' +import { SettingsStore, ensureOverseerSettingsSchema } from './settingsStore' + +function freshStore(): SettingsStore { + const db = new Database(':memory:', { strict: true }) + ensureOverseerSettingsSchema(db) + return new SettingsStore(db) +} + +describe('SettingsStore', () => { + it('round-trips a raw key/value', () => { + const s = freshStore() + expect(s.get('missing')).toBeNull() + s.set('k', 'v') + expect(s.get('k')).toBe('v') + s.set('k', 'v2') + expect(s.get('k')).toBe('v2') + s.delete('k') + expect(s.get('k')).toBeNull() + }) + + it('round-trips the active brain (profile + model)', () => { + const s = freshStore() + expect(s.getActiveBrain()).toBeNull() + s.setActiveBrain({ profile: 'openai', model: 'gpt-4o' }) + expect(s.getActiveBrain()).toEqual({ profile: 'openai', model: 'gpt-4o' }) + }) + + it('normalizes a null model and clears', () => { + const s = freshStore() + s.setActiveBrain({ profile: 'local', model: null }) + expect(s.getActiveBrain()).toEqual({ profile: 'local', model: null }) + s.clearActiveBrain() + expect(s.getActiveBrain()).toBeNull() + }) + + it('returns null on malformed persisted json rather than throwing', () => { + const s = freshStore() + s.set('active_brain', 'not json{') + expect(s.getActiveBrain()).toBeNull() + s.set('active_brain', JSON.stringify({ model: 'x' })) + expect(s.getActiveBrain()).toBeNull() + }) + + it('DDL is idempotent', () => { + const db = new Database(':memory:', { strict: true }) + ensureOverseerSettingsSchema(db) + ensureOverseerSettingsSchema(db) + const store = new SettingsStore(db) + store.set('a', 'b') + expect(store.get('a')).toBe('b') + }) +}) diff --git a/hub/src/store/settingsStore.ts b/hub/src/store/settingsStore.ts new file mode 100644 index 0000000000..0da3f17cd1 --- /dev/null +++ b/hub/src/store/settingsStore.ts @@ -0,0 +1,70 @@ +import type { Database } from 'bun:sqlite' + +/** + * Tiny key/value settings table for hub-side runtime config that must survive a restart and be + * switchable at whim without editing env + bouncing the hub. First consumer: the Overseer's + * active brain (which profile/model the converse + voice surfaces default to). Idempotent DDL, + * run on every boot alongside the other Overseer self-heal schemas (not on the SCHEMA_VERSION ladder). + */ +export function ensureOverseerSettingsSchema(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS overseer_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + `) +} + +/** The persisted active-brain selection — a profile id plus an optional model override. */ +export type ActiveBrainSetting = { + profile: string + model: string | null +} + +const ACTIVE_BRAIN_KEY = 'active_brain' + +export class SettingsStore { + constructor(private readonly db: Database) {} + + get(key: string): string | null { + const row = this.db.prepare('SELECT value FROM overseer_settings WHERE key = ?').get(key) as + | { value: string } + | undefined + return row?.value ?? null + } + + set(key: string, value: string): void { + this.db + .prepare( + `INSERT INTO overseer_settings (key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at` + ) + .run(key, value, Date.now()) + } + + delete(key: string): void { + this.db.prepare('DELETE FROM overseer_settings WHERE key = ?').run(key) + } + + /** Read the persisted active brain, or null when the operator has never chosen one (use env default). */ + getActiveBrain(): ActiveBrainSetting | null { + const raw = this.get(ACTIVE_BRAIN_KEY) + if (!raw) return null + try { + const parsed = JSON.parse(raw) as Partial + if (typeof parsed.profile !== 'string' || parsed.profile.length === 0) return null + return { profile: parsed.profile, model: typeof parsed.model === 'string' ? parsed.model : null } + } catch { + return null + } + } + + setActiveBrain(value: ActiveBrainSetting): void { + this.set(ACTIVE_BRAIN_KEY, JSON.stringify({ profile: value.profile, model: value.model ?? null })) + } + + clearActiveBrain(): void { + this.delete(ACTIVE_BRAIN_KEY) + } +} diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index f4bde7e656..8a4d75e920 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -47,6 +47,7 @@ import { OverseerEntity } from './overseerEntity' import { extractAssistantPlainText } from '@hapi/protocol/messages' import type { InboxOperatorAction } from '@hapi/protocol' import type { ListSystemEventsOptions, StoredSystemEvent } from '../store' +import type { SettingsStore } from '../store' import type { ListInboxItemsOptions, StoredInboxItem } from '../store/inboxItems' export type { Session, SyncEvent } from '@hapi/protocol/types' @@ -340,6 +341,11 @@ export class SyncEngine { return this.overseer } + /** Hub settings KV (persisted active brain, etc.) — see SettingsStore. */ + getSettings(): SettingsStore { + return this.store.settings + } + getSystemEvents(options: ListSystemEventsOptions = {}): StoredSystemEvent[] { return this.overseerEvents.list(options) } diff --git a/hub/src/web/routes/overseer.test.ts b/hub/src/web/routes/overseer.test.ts index f59db646d4..6b953b5228 100644 --- a/hub/src/web/routes/overseer.test.ts +++ b/hub/src/web/routes/overseer.test.ts @@ -111,4 +111,92 @@ describe('overseer routes', () => { }) expect(res.status).toBe(400) }) + + it('GET /overseer/brains reports profiles + a null active until one is set', async () => { + const prev = process.env.OVERSEER_BRAIN_URL + process.env.OVERSEER_BRAIN_URL = 'http://brain.test/v1' + try { + const app = buildApp(new Store(':memory:')) + const res = await app.request('/api/overseer/brains') + expect(res.status).toBe(200) + const body = await res.json() as { profiles: Array<{ id: string }>; active: unknown } + expect(body.profiles.some((p) => p.id === 'default')).toBe(true) + expect(body.active).toBeNull() + } finally { + if (prev === undefined) delete process.env.OVERSEER_BRAIN_URL + else process.env.OVERSEER_BRAIN_URL = prev + } + }) + + it('PUT /overseer/brain/active rejects an unconfigured profile (400) and persists a known one', async () => { + const prev = process.env.OVERSEER_BRAIN_URL + process.env.OVERSEER_BRAIN_URL = 'http://brain.test/v1' + try { + const app = buildApp(new Store(':memory:')) + + const bad = await app.request('/api/overseer/brain/active', { + method: 'PUT', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ profile: 'ghost' }) + }) + expect(bad.status).toBe(400) + + const ok = await app.request('/api/overseer/brain/active', { + method: 'PUT', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ profile: 'default', model: 'main' }) + }) + expect(ok.status).toBe(200) + const okBody = await ok.json() as { active: { profile: string; model: string | null } } + expect(okBody.active).toEqual({ profile: 'default', model: 'main' }) + + const get = await app.request('/api/overseer/brain/active') + const getBody = await get.json() as { active: { profile: string; model: string | null } } + expect(getBody.active).toEqual({ profile: 'default', model: 'main' }) + } finally { + if (prev === undefined) delete process.env.OVERSEER_BRAIN_URL + else process.env.OVERSEER_BRAIN_URL = prev + } + }) + + it('PUT /overseer/brain/active persists across engine rebuilds on the same store', async () => { + const prev = process.env.OVERSEER_BRAIN_URL + process.env.OVERSEER_BRAIN_URL = 'http://brain.test/v1' + try { + const store = new Store(':memory:') + const app = buildApp(store) + await app.request('/api/overseer/brain/active', { + method: 'PUT', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ profile: 'default', model: null }) + }) + // A fresh engine over the same store must observe the persisted choice. + const app2 = buildApp(store) + const get = await app2.request('/api/overseer/brain/active') + const body = await get.json() as { active: { profile: string; model: string | null } } + expect(body.active).toEqual({ profile: 'default', model: null }) + } finally { + if (prev === undefined) delete process.env.OVERSEER_BRAIN_URL + else process.env.OVERSEER_BRAIN_URL = prev + } + }) + + it('GET /overseer/brains clears a stale persisted profile that is no longer configured', async () => { + const prevUrl = process.env.OVERSEER_BRAIN_URL + const prevOpenAi = process.env.OVERSEER_BRAIN_PROFILE_OPENAI_URL + process.env.OVERSEER_BRAIN_URL = 'http://brain.test/v1' + delete process.env.OVERSEER_BRAIN_PROFILE_OPENAI_URL + try { + const store = new Store(':memory:') + store.settings.setActiveBrain({ profile: 'openai', model: 'gpt-4o' }) + const app = buildApp(store) + const res = await app.request('/api/overseer/brains') + expect(res.status).toBe(200) + const body = await res.json() as { active: unknown } + expect(body.active).toBeNull() + expect(store.settings.getActiveBrain()).toBeNull() + } finally { + if (prevUrl === undefined) delete process.env.OVERSEER_BRAIN_URL + else process.env.OVERSEER_BRAIN_URL = prevUrl + if (prevOpenAi === undefined) delete process.env.OVERSEER_BRAIN_PROFILE_OPENAI_URL + else process.env.OVERSEER_BRAIN_PROFILE_OPENAI_URL = prevOpenAi + } + }) }) diff --git a/hub/src/web/routes/overseer.ts b/hub/src/web/routes/overseer.ts index 7d629d9347..7fc498320e 100644 --- a/hub/src/web/routes/overseer.ts +++ b/hub/src/web/routes/overseer.ts @@ -12,7 +12,8 @@ import type { WebAppEnv } from '../middleware/auth' import { requireSyncEngine } from './guards' import { isOverseerToolName, OverseerWriteNotAllowedError, runOverseerTool } from '../../overseer/runOverseerTool' import { runOverseerConverse } from '../../overseer/converse' -import { BrainUnavailableError, filterChatModels, listBrainModels, listBrainProfiles, resolveBrainConfig } from '../../overseer/brainClient' +import { BrainUnavailableError, filterChatModels, isKnownBrainProfile, listBrainModels, listBrainProfiles, resolveBrainConfig, resolveBrainSelection } from '../../overseer/brainClient' +import type { ActiveBrainSetting } from '../../store/settingsStore' const convoTurnBodySchema = z.object({ operatorText: z.string().max(8000).default(''), @@ -36,6 +37,21 @@ const converseBodySchema = z.object({ profile: z.string().max(64).optional() }) +const activeBrainBodySchema = z.object({ + profile: z.string().min(1).max(64), + model: z.string().max(100).nullish() +}) + +/** Drop a persisted active brain when its profile was removed from env after restart. */ +function getSanitizedActiveBrain(engine: SyncEngine): ActiveBrainSetting | null { + const settings = engine.getSettings() + const active = settings.getActiveBrain() + if (!active) return null + if (isKnownBrainProfile(active.profile)) return active + settings.clearActiveBrain() + return null +} + export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Hono { const app = new Hono() @@ -62,12 +78,57 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho }) }) - // Configured brain profiles for the converse UI (id/label/model only — no - // url or api key is exposed to the client). + // Configured brain profiles for the console UI (id/label/model only — no url + // or api key is exposed to the client) plus the persisted active selection. app.get('/overseer/brains', (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) return engine - return c.json({ profiles: listBrainProfiles(process.env) }) + return c.json({ + profiles: listBrainProfiles(process.env), + active: getSanitizedActiveBrain(engine) + }) + }) + + // The persisted active brain — the profile/model the converse + voice surfaces + // default to when a request does not override. Switchable at whim, survives a + // restart, no env edit / hub bounce required. + app.get('/overseer/brain/active', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + const active = getSanitizedActiveBrain(engine) + const selection = resolveBrainSelection(active) + const config = resolveBrainConfig(process.env, selection) + return c.json({ + active, + effective: config ? { profile: selection.profile ?? 'default', model: config.model } : null + }) + }) + + app.put('/overseer/brain/active', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: 'Invalid JSON body' }, 400) + } + const parsed = activeBrainBodySchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.flatten() }, 400) + } + + // Only allow selecting a profile the hub actually has configured, so the + // console can never persist a dead brain that would silently fall back to env. + const known = listBrainProfiles(process.env).some((p) => p.id === parsed.data.profile) + if (!known) { + return c.json({ error: `Unknown brain profile: ${parsed.data.profile}` }, 400) + } + + const active = { profile: parsed.data.profile, model: parsed.data.model ?? null } + engine.getSettings().setActiveBrain(active) + return c.json({ active }) }) // Live model list for a brain profile (proxies the endpoint's GET /models so @@ -85,7 +146,8 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ profile: id, defaultModel: config.model, models }) } catch (error) { const message = error instanceof Error ? error.message : 'failed to list models' - return c.json({ profile: id, defaultModel: config.model, models: [], error: message }) + const reachable = error instanceof BrainUnavailableError ? error.reachable : false + return c.json({ profile: id, defaultModel: config.model, models: [], error: message, reachable }) } }) @@ -146,10 +208,11 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ error: 'Last message must be from the operator' }, 400) } - const config = resolveBrainConfig(process.env, { + const active = getSanitizedActiveBrain(engine) + const config = resolveBrainConfig(process.env, resolveBrainSelection(active, { profile: parsed.data.profile, model: parsed.data.model - }) + })) if (!config) { return c.json({ reply: 'The Overseer brain is not configured on this hub (set OVERSEER_BRAIN_URL). I can still show raw events and inbox items, but I cannot answer in conversation yet.', diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index c037da8567..86611739d2 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -233,7 +233,7 @@ function createWebApp(options: { const corsOriginOption = corsOrigins.includes('*') ? '*' : corsOrigins const corsMiddleware = cors({ origin: corsOriginOption, - allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], + allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowHeaders: ['authorization', 'content-type'] }) app.use('/api/*', corsMiddleware) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 750d4c84ca..f594ad1218 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -834,14 +834,35 @@ export class ApiClient { }) } - async fetchOverseerBrains(): Promise<{ profiles: import('@hapi/protocol').OverseerBrainProfileInfo[] }> { + async fetchOverseerBrains(): Promise<{ + profiles: import('@hapi/protocol').OverseerBrainProfileInfo[] + active: { profile: string; model: string | null } | null + }> { return await this.request('/api/overseer/brains') } async fetchOverseerBrainModels( profileId: string - ): Promise<{ profile: string; defaultModel: string | null; models: string[]; error?: string }> { + ): Promise<{ profile: string; defaultModel: string | null; models: string[]; error?: string; reachable?: boolean }> { return await this.request(`/api/overseer/brains/${encodeURIComponent(profileId)}/models`) } + /** Persist the active brain (profile + optional model) — survives restart, no env bounce. */ + async setOverseerActiveBrain( + profile: string, + model: string | null + ): Promise<{ active: { profile: string; model: string | null } }> { + return await this.request('/api/overseer/brain/active', { + method: 'PUT', + body: JSON.stringify({ profile, model }) + }) + } + + async fetchOverseerIdentity(): Promise<{ + identity: import('@hapi/protocol').OverseerIdentity + systemPrompt: string + }> { + return await this.request('/api/overseer/identity') + } + } diff --git a/web/src/components/overseer/OverseerBrainPanel.tsx b/web/src/components/overseer/OverseerBrainPanel.tsx new file mode 100644 index 0000000000..6f6a689ec7 --- /dev/null +++ b/web/src/components/overseer/OverseerBrainPanel.tsx @@ -0,0 +1,159 @@ +import { useCallback, useEffect, useState } from 'react' +import { useAppContext } from '@/lib/app-context' +import type { OverseerBrainProfileInfo } from '@hapi/protocol' + +type ActiveBrain = { profile: string; model: string | null } + +/** + * Runtime brain switcher. Unlike the per-request selectors in the talk-to panel (which only + * affect the next converse call), this persists the hub's *active* brain — the profile/model + * that voice + converse default to — via PUT /overseer/brain/active. Switchable at whim, no env + * edit, no hub restart. The api key never reaches the browser (model list is proxied server-side). + */ +export function OverseerBrainPanel() { + const { api } = useAppContext() + const [profiles, setProfiles] = useState([]) + const [active, setActive] = useState(null) + const [selectedProfile, setSelectedProfile] = useState('default') + const [selectedModel, setSelectedModel] = useState('') + const [models, setModels] = useState([]) + const [modelsLoading, setModelsLoading] = useState(false) + const [modelsError, setModelsError] = useState(null) + const [modelsReachable, setModelsReachable] = useState(null) + const [saving, setSaving] = useState(false) + const [saveError, setSaveError] = useState(null) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + if (!api) return + void api.fetchOverseerBrains() + .then((res) => { + setProfiles(res.profiles) + setActive(res.active) + setSelectedProfile(res.active?.profile ?? res.profiles.find((p) => p.isDefault)?.id ?? res.profiles[0]?.id ?? 'default') + setSelectedModel(res.active?.model ?? '') + }) + .catch((err) => setSaveError(err instanceof Error ? err.message : 'failed to load brains')) + .finally(() => setLoaded(true)) + }, [api]) + + // Live model list for the chosen profile (server proxies GET /models). Also acts as a + // reachability probe — an error here means the endpoint is offline (GPU pulled for VR, etc.). + useEffect(() => { + if (!api || !selectedProfile) return + let cancelled = false + setModelsLoading(true) + setModelsError(null) + setModelsReachable(null) + void api.fetchOverseerBrainModels(selectedProfile) + .then((res) => { + if (cancelled) return + setModels(res.models) + if (res.error) { + setModelsError(res.error) + setModelsReachable(res.reachable ?? false) + } else { + setModelsReachable(true) + } + }) + .catch((err) => { if (!cancelled) setModelsError(err instanceof Error ? err.message : 'model list failed') }) + .finally(() => { if (!cancelled) setModelsLoading(false) }) + return () => { cancelled = true } + }, [api, selectedProfile]) + + const profileDefaultModel = profiles.find((p) => p.id === selectedProfile)?.model ?? null + + const activeLabel = active + ? `${active.profile}${active.model ? ` · ${active.model}` : ' · (profile default)'}` + : 'env default' + + const isDirty = selectedProfile !== (active?.profile ?? '') || (selectedModel || null) !== (active?.model ?? null) + + const save = useCallback(async () => { + if (!api || saving) return + setSaving(true) + setSaveError(null) + try { + const res = await api.setOverseerActiveBrain(selectedProfile, selectedModel || null) + setActive(res.active) + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'failed to set active brain') + } finally { + setSaving(false) + } + }, [api, saving, selectedProfile, selectedModel]) + + return ( +
+
+

Brain

+ + active: {activeLabel} + +
+

+ The active brain is what voice and every converse default to. Switch it at whim — persisted, no hub restart. + A per-request override in the talk-to panel below still wins for that one call. +

+ +
+ + + + + +
+ +
+ {modelsError ? ( + modelsReachable ? ( + endpoint reachable, model list failed: {modelsError} + ) : ( + offline / unreachable: {modelsError} + ) + ) : modelsLoading ? ( + probing endpoint… + ) : ( + endpoint reachable · {models.length} chat model{models.length === 1 ? '' : 's'} + )} + {saveError ? {saveError} : null} +
+
+ ) +} diff --git a/web/src/components/settings/OverseerChatDebugControls.tsx b/web/src/components/settings/OverseerChatDebugControls.tsx index 9d6e4f5997..be19d13a78 100644 --- a/web/src/components/settings/OverseerChatDebugControls.tsx +++ b/web/src/components/settings/OverseerChatDebugControls.tsx @@ -76,7 +76,7 @@ export function OverseerChatDebugControls() { setLoading(true) try { const res = await api.overseerConverse(nextHistory, { - profile: selectedProfile !== 'default' ? selectedProfile : undefined, + profile: selectedProfile, model: selectedModel || undefined }) as OverseerConverseResponse setModel(res.model) diff --git a/web/src/hooks/useAppGoBack.test.ts b/web/src/hooks/useAppGoBack.test.ts index 91101ff330..2b3cd6cc50 100644 --- a/web/src/hooks/useAppGoBack.test.ts +++ b/web/src/hooks/useAppGoBack.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest' -import { getSettingsBackTarget } from './useAppGoBack' +import { getOverseerBackTarget, getSettingsBackTarget } from './useAppGoBack' + +describe('getOverseerBackTarget', () => { + it('maps /overseer to /sessions for bookmark/PWA entry with no history', () => { + expect(getOverseerBackTarget('/overseer')).toBe('/sessions') + expect(getOverseerBackTarget('/sessions')).toBeNull() + }) +}) describe('getSettingsBackTarget', () => { it.each([ diff --git a/web/src/hooks/useAppGoBack.ts b/web/src/hooks/useAppGoBack.ts index 470e95d754..d6ec902040 100644 --- a/web/src/hooks/useAppGoBack.ts +++ b/web/src/hooks/useAppGoBack.ts @@ -1,6 +1,11 @@ import { useCallback } from 'react' import { useLocation, useNavigate, useRouter } from '@tanstack/react-router' +export function getOverseerBackTarget(pathname: string): string | null { + if (pathname === '/overseer') return '/sessions' + return null +} + export function getSettingsBackTarget(pathname: string): string | null { if (pathname === '/settings') return '/sessions' if (pathname === '/settings/voice/advanced' || pathname === '/settings/voice/voices') return '/settings/voice' @@ -28,6 +33,13 @@ export function useAppGoBack(): () => void { return } + // Overseer console opened as first history entry (bookmark/PWA) has nowhere to go back. + const overseerBackTarget = getOverseerBackTarget(pathname) + if (overseerBackTarget) { + navigate({ to: overseerBackTarget }) + return + } + // For single file view, go back to files list if (pathname.match(/^\/sessions\/[^/]+\/file$/)) { const filesPath = pathname.replace(/\/file$/, '/files') diff --git a/web/src/router.tsx b/web/src/router.tsx index 0fd265dbd6..c04116eedb 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -55,6 +55,7 @@ import SettingsVoicePage from '@/routes/settings/voice' import SettingsVoiceVoicesPage from '@/routes/settings/voice-voices' import SettingsVoiceAdvancedPage from '@/routes/settings/voice-advanced' import SettingsAboutPage from '@/routes/settings/about' +import OverseerConsolePage from '@/routes/overseer' import SharePage from '@/routes/share' import { setSharePendingTransfer } from '@/lib/sharePendingState' import { deleteShareTransfer } from '@/lib/shareTransfer' @@ -138,6 +139,26 @@ function FolderOpenIcon(props: { className?: string }) { ) } +function OverseerIcon(props: { className?: string }) { + return ( + + + + + ) +} + function SettingsIcon(props: { className?: string }) { return ( +
+ + {open ? ( +
+
    + {identity.tools.map((tool) => ( +
  • + + {tool.readonly ? 'read ' : 'write'} + {' '} + {tool.name} + {' — '}{tool.description} +
  • + ))} +
+
+ ) : null} +
+ ) +} + +export default function OverseerConsolePage() { + const goBack = useAppGoBack() + + return ( +
+
+ {!isTelegramApp() && ( + + )} +
Overseer
+
+ +
+
+ + + +
+ + + +
+
+
+
+ ) +} diff --git a/web/src/routes/settings/about.tsx b/web/src/routes/settings/about.tsx index 882a09ff3d..2d3d0732ec 100644 --- a/web/src/routes/settings/about.tsx +++ b/web/src/routes/settings/about.tsx @@ -1,12 +1,11 @@ import { PROTOCOL_VERSION } from '@hapi/protocol' +import { useNavigate } from '@tanstack/react-router' import { useTranslation } from '@/lib/use-translation' -import { EventsDebugControls } from '@/components/settings/EventsDebugControls' -import { InboxDebugControls } from '@/components/settings/InboxDebugControls' -import { OverseerChatDebugControls } from '@/components/settings/OverseerChatDebugControls' import { SettingsPageContent, SettingsRow, SettingsSection } from '@/components/settings/SettingsPrimitives' export default function SettingsAboutPage() { const { t } = useTranslation() + const navigate = useNavigate() return ( @@ -16,10 +15,16 @@ export default function SettingsAboutPage() { {__APP_VERSION__}} /> {PROTOCOL_VERSION}} /> + {/* Overseer debug panels (brain switch, talk-to, events, inbox) now live in the dedicated console. */} - - - + ) diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index fcb3c1ed60..0e16ac02e3 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -196,6 +196,9 @@ describe('responsive settings pages', () => { expect(screen.getByText(String(__APP_VERSION__))).toBeInTheDocument() expect(screen.getByText('Protocol Version')).toBeInTheDocument() expect(screen.getByRole('link', { name: 'hapi.run' })).toHaveAttribute('rel', 'noopener noreferrer') + // Debug panels moved to /overseer; About keeps a console deep-link. + fireEvent.click(screen.getByRole('button', { name: /Overseer console/ })) + expect(navigate).toHaveBeenCalledWith({ to: '/overseer' }) }) it('links common voice settings to full-page voices and advanced pages', () => {