Admin-gated Settings: runtime model selection, is_admin enforced (#90)
Build image / build-and-push (push) Successful in 11s
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:
+20
-7
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user