Admin-gated Settings: runtime model selection, is_admin enforced (#90)
Build image / build-and-push (push) Successful in 11s

Closes #79. Agent model + on/off move into an admin-only Settings section, and
is_admin is enforced for the first time. The live Runner is hot-swapped behind an
atomic.Pointer with routes always registered, so a settings change takes effect
with no restart; /capabilities reads the pointer. Secrets stay in the env, never
the DB. Precedence: Settings → env → default. Verified live: swap on/off/model,
bad spec → 400, race-clean. Gadfly blocking round addressed (dead code removed,
error-swallowing fixed, frontend 503 handling, holder dedup).
This commit was merged in pull request #90.
This commit is contained in:
2026-07-22 02:53:55 +00:00
24 changed files with 1316 additions and 103 deletions
+20 -7
View File
@@ -13,13 +13,21 @@ import { historyKey } from './history'
const capabilitiesSchema = z.object({ agent: z.boolean() })
/** Whether this instance has the assistant configured. Without it the panel
* isn't rendered at all — a dead button is worse than no button. */
export const capabilitiesKey = ['capabilities'] as const
/** Whether the assistant is live RIGHT NOW. Without it the panel isn't rendered
* at all — a dead button is worse than no button.
*
* Not `staleTime: Infinity` any more: an admin can turn the assistant on or off
* in Settings (#79), so this must be able to change under a running page. The
* settings save invalidates this key directly; the finite staleTime just means
* another admin's change is picked up on the next focus/remount rather than
* never. */
export function useCapabilities() {
return useQuery({
queryKey: ['capabilities'] as const,
queryKey: capabilitiesKey,
queryFn: async () => capabilitiesSchema.parse(await api.get('/capabilities')),
staleTime: Infinity, // server config; it doesn't change under a running page
staleTime: 60_000,
})
}
@@ -153,10 +161,15 @@ export async function streamChat(
return
}
if (!res.ok || !res.body) {
// 503 is the assistant being turned off at runtime (#79) — the route exists,
// there's just no model behind it. Distinct from a 404, which would mean the
// whole endpoint is absent.
handlers.onError(
res.status === 404
? "This instance doesn't have the assistant configured."
: 'The assistant is not available right now.',
res.status === 503
? "The assistant isn't enabled on this instance."
: res.status === 404
? "This instance doesn't have the assistant configured."
: 'The assistant is not available right now.',
)
return
}
+79
View File
@@ -0,0 +1,79 @@
// Instance settings data layer (#79): admin-only, instance-wide configuration.
//
// The GET/PATCH return both the stored settings and a read-only "effective" view
// — what's actually in force after layering the DB over the environment — so the
// form can say "inheriting ollama-cloud/glm-5.2:cloud from the environment" and
// whether the API key is present, without the key ever crossing the wire.
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { ApiError, api } from './api'
import { capabilitiesKey } from './agent'
export const instanceSettingsSchema = z.object({
// '' means "inherit the PANSY_AGENT_MODEL env var".
agentModel: z.string(),
// null means "inherit PANSY_AGENT_ENABLED"; true/false is an explicit override.
agentEnabled: z.boolean().nullable(),
version: z.number(),
updatedAt: z.string(),
})
export type InstanceSettings = z.infer<typeof instanceSettingsSchema>
export const effectiveAgentSchema = z.object({
model: z.string(),
enabled: z.boolean(),
hasApiKey: z.boolean(),
agentLive: z.boolean(),
})
export type EffectiveAgent = z.infer<typeof effectiveAgentSchema>
export const settingsResponseSchema = z.object({
settings: instanceSettingsSchema,
effective: effectiveAgentSchema,
})
export type SettingsResponse = z.infer<typeof settingsResponseSchema>
export const settingsKey = ['settings'] as const
export const settingsQueryOptions = queryOptions({
queryKey: settingsKey,
queryFn: async (): Promise<SettingsResponse> =>
settingsResponseSchema.parse(await api.get('/settings')),
})
export function useSettings() {
return useQuery(settingsQueryOptions)
}
export interface SettingsUpdate {
agentModel: string
agentEnabled: boolean | null
version: number
}
export function useUpdateSettings() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (input: SettingsUpdate): Promise<SettingsResponse> =>
settingsResponseSchema.parse(await api.patch('/settings', input)),
onSuccess: (res) => {
qc.setQueryData(settingsKey, res)
// The save may have turned the assistant on or off; the editor keys its
// chat tab off /capabilities, so make it re-read rather than trust its
// cached answer.
qc.invalidateQueries({ queryKey: capabilitiesKey })
},
})
}
/** If err is a 409 version conflict, return the fresh settings it carries so a
* form can rebase; otherwise null. */
export function conflictSettings(err: unknown): InstanceSettings | null {
if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
const current = (err.body as { current?: unknown }).current
const parsed = instanceSettingsSchema.safeParse(current)
if (parsed.success) return parsed.data
}
return null
}