Moves the agent model out of env-only config into an admin-editable Settings
section, and enforces is_admin for the first time — it has been in the schema
since migration 0001, plumbed to the client, and checked nowhere.
Backend:
- Migration 0010: instance_settings, a single-row (CHECK id=1) table — pansy's
first instance-level state. Holds agent_model ('' = inherit env) and
agent_enabled (NULL = inherit env), version-guarded like every mutable row.
SECRETS STAY IN ENV: OLLAMA_CLOUD_API_KEY is never stored here.
- requireAdmin at the service seam (authoritative) plus a cheap middleware
early-403. Non-admin gets 403, not 404 — settings existence isn't masked.
- EffectiveAgent resolves DB-over-env (model, enabled); key always from env.
- The live Runner is hot-swapped, not built once. agentHolder holds it behind
an atomic.Pointer; the chat routes are now registered UNCONDITIONALLY and
nil-check agent.get(), so a settings change turns the assistant on/off/onto a
new model with no restart and no race against in-flight readers. /capabilities
reads the pointer, so it reports what's live, not what booted.
- internal/agentmodel is a new leaf package holding the one place that knows how
to turn a spec into a model. Both agent (to run) and service (to validate a
spec before storing it) import it; it can't live in agent, which imports
service. Settings PATCH validates the spec via Parse, so a typo is a 400 now
rather than a broken assistant on the next turn.
Frontend:
- /settings route (admin guard), a Settings page (model field, tri-state
enabled, live status), nav link shown only to admins.
- useCapabilities drops staleTime:Infinity — the assistant can now change under
a running page — and the settings save invalidates it.
Contract change: chat routes always exist, so "assistant off" is a runtime 503
+ capabilities:false, not a missing route. Updated the test that asserted the
old shape.
Verified live against the built binary: disable flips capabilities to false and
logs it; re-enable with a new model swaps it back; a bad spec is rejected 400;
the setting persists across a restart. Swap is race-clean under `go test -race`.
Docs: README (precedence + key-stays-in-env), DESIGN (decision + routes),
CLAUDE (don't re-add conditional route registration; key never in the DB).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
165 lines
5.8 KiB
TypeScript
165 lines
5.8 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Alert } from '@/components/ui/Alert'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { Select } from '@/components/ui/Select'
|
|
import { TextField } from '@/components/ui/TextField'
|
|
import { toast } from '@/components/ui/toast'
|
|
import { errorMessage } from '@/lib/api'
|
|
import {
|
|
conflictSettings,
|
|
useSettings,
|
|
useUpdateSettings,
|
|
type EffectiveAgent,
|
|
type InstanceSettings,
|
|
} from '@/lib/settings'
|
|
import { usePageTitle } from '@/lib/usePageTitle'
|
|
|
|
// agentEnabled is a tri-state on the wire (null = inherit env, true, false); the
|
|
// form models it as three named choices so "inherit" is a deliberate pick, not
|
|
// an empty control.
|
|
type EnabledChoice = 'inherit' | 'on' | 'off'
|
|
const toChoice = (v: boolean | null): EnabledChoice => (v === null ? 'inherit' : v ? 'on' : 'off')
|
|
const fromChoice = (c: EnabledChoice): boolean | null => (c === 'inherit' ? null : c === 'on')
|
|
|
|
/** Admin-only instance settings (#79). The one that matters today is the agent
|
|
* model; the API key stays in the environment and is only ever reported as
|
|
* present/absent, never shown or edited. */
|
|
export function SettingsPage() {
|
|
usePageTitle('Settings')
|
|
const settings = useSettings()
|
|
const update = useUpdateSettings()
|
|
|
|
if (settings.isPending) {
|
|
return <p className="text-sm text-muted">Loading settings…</p>
|
|
}
|
|
if (settings.isError) {
|
|
return <Alert>{errorMessage(settings.error, "Couldn't load settings.")}</Alert>
|
|
}
|
|
return <SettingsForm data={settings.data} update={update} />
|
|
}
|
|
|
|
function SettingsForm({
|
|
data,
|
|
update,
|
|
}: {
|
|
data: { settings: InstanceSettings; effective: EffectiveAgent }
|
|
update: ReturnType<typeof useUpdateSettings>
|
|
}) {
|
|
const [model, setModel] = useState(data.settings.agentModel)
|
|
const [enabled, setEnabled] = useState<EnabledChoice>(toChoice(data.settings.agentEnabled))
|
|
const [version, setVersion] = useState(data.settings.version)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Rebase the form when the query cache updates (e.g. a successful save writes
|
|
// the new row back), so the version we submit is never stale.
|
|
useEffect(() => {
|
|
setVersion(data.settings.version)
|
|
}, [data.settings.version])
|
|
|
|
const eff = data.effective
|
|
|
|
const save = () => {
|
|
setError(null)
|
|
update.mutate(
|
|
{ agentModel: model.trim(), agentEnabled: fromChoice(enabled), version },
|
|
{
|
|
onSuccess: () => toast.info('Settings saved.'),
|
|
onError: (err) => {
|
|
const current = conflictSettings(err)
|
|
if (current) {
|
|
// Someone else saved first. Adopt their row so the next attempt is
|
|
// clean, and say so rather than silently discarding this edit.
|
|
setModel(current.agentModel)
|
|
setEnabled(toChoice(current.agentEnabled))
|
|
setVersion(current.version)
|
|
setError('Someone else changed these settings just now — reloaded their version. Re-apply your change if you still want it.')
|
|
return
|
|
}
|
|
setError(errorMessage(err, "Couldn't save settings."))
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex max-w-2xl flex-col gap-6">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-fg">Settings</h1>
|
|
<p className="mt-1 text-sm text-muted">
|
|
Instance-wide, admin-only. Changes take effect immediately — no restart.
|
|
</p>
|
|
</div>
|
|
|
|
<section className="flex flex-col gap-4 rounded-lg border border-border p-4">
|
|
<div>
|
|
<h2 className="text-sm font-semibold text-fg">Garden assistant</h2>
|
|
<p className="mt-1 text-xs text-muted">
|
|
The model runs against your Ollama Cloud key, which is set in the environment and never
|
|
shown here.
|
|
</p>
|
|
</div>
|
|
|
|
<AgentStatus eff={eff} />
|
|
|
|
<TextField
|
|
label="Model"
|
|
name="agentModel"
|
|
placeholder={eff.model || 'ollama-cloud/glm-5.2:cloud'}
|
|
value={model}
|
|
onChange={(e) => setModel(e.target.value)}
|
|
hint={
|
|
model.trim() === ''
|
|
? `Empty — inheriting ${eff.model || 'the built-in default'} from the environment.`
|
|
: 'A majordomo model spec. A comma-separated list is a failover chain.'
|
|
}
|
|
/>
|
|
|
|
<Select
|
|
label="Enabled"
|
|
name="agentEnabled"
|
|
value={enabled}
|
|
onChange={(e) => setEnabled(e.target.value as EnabledChoice)}
|
|
options={[
|
|
{ value: 'inherit', label: 'Inherit from environment' },
|
|
{ value: 'on', label: 'On' },
|
|
{ value: 'off', label: 'Off' },
|
|
]}
|
|
/>
|
|
</section>
|
|
|
|
{error && <Alert>{error}</Alert>}
|
|
|
|
<div className="flex justify-end">
|
|
<Button onClick={save} disabled={update.isPending}>
|
|
{update.isPending ? 'Saving…' : 'Save changes'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// AgentStatus surfaces the gap the capabilities endpoint exists for: enabled +
|
|
// a key does not guarantee the assistant is actually running (an unresolvable
|
|
// model leaves it down), and that's precisely what an admin needs to see.
|
|
function AgentStatus({ eff }: { eff: EffectiveAgent }) {
|
|
const [tone, text] = eff.agentLive
|
|
? (['ok', `Live on ${eff.model}`] as const)
|
|
: !eff.hasApiKey
|
|
? (['warn', 'No API key set in the environment — the assistant is off.'] as const)
|
|
: !eff.enabled
|
|
? (['warn', 'Turned off.'] as const)
|
|
: (['warn', `Configured but not running — check the model (${eff.model}).`] as const)
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span
|
|
className={
|
|
'inline-block h-2 w-2 rounded-full ' + (tone === 'ok' ? 'bg-emerald-500' : 'bg-amber-500')
|
|
}
|
|
aria-hidden
|
|
/>
|
|
<span className={tone === 'ok' ? 'text-fg' : 'text-muted'}>{text}</span>
|
|
</div>
|
|
)
|
|
}
|