From 30e5059b94301ad9f450d86a2823547a67a0aab7 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Thu, 6 Aug 2026 08:48:30 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(admin):=20build=20the=20AI=20Policy=20?= =?UTF-8?q?console=20(=C2=A76)=20=E2=80=94=20approved,=20spec=20now=20impl?= =?UTF-8?q?emented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New admin tab "AI" (app/(main)/admin/_tabs/ai-policy.tsx): provider-class toggles, server model allow-list, BYOK provider allow-list, seats/usage (front-end for the already-real lib/ai/entitlement.ts), retrieval on/off, consent text + version bump. Real backend, not cosmetic: AiConsoleConfig persisted via config-manager (lib/ai/types.ts, ai-policy.json in the CONFIG dir). New GET/PUT /api/admin/ai/policy. Enforcement wired at every real chokepoint, not just the picker: /api/ai/server/chat checks classesEnabled.server and the model allow-list, /api/ai/retrieve checks retrievalEnabled, /api/ai/server/models filters by allow-list. GET /api/ai/policy folds classesEnabled into the classes list clients see. Resolved the spec's 3 open questions as recommended: BYOK allow-list stays client-side/advisory (wired into ai-assistant-settings.tsx's addProfile), tier picker stays cosmetic, master aiAssistantEnabled toggle stays in the existing Policy tab (this tab links to it instead of duplicating it). Defaults preserve today's behavior exactly (classesEnabled/allowlists all start empty/null) — turning this on changes nothing until an admin touches it. --- app/(main)/admin/_tabs/ai-policy.tsx | 361 ++++++++++++++++++ app/(main)/admin/layout.tsx | 2 + app/(main)/admin/page.tsx | 2 + app/api/admin/ai/policy/route.ts | 92 +++++ app/api/ai/policy/route.ts | 17 +- app/api/ai/retrieve/route.ts | 9 + app/api/ai/server/chat/route.ts | 15 + app/api/ai/server/models/route.ts | 13 +- components/settings/ai-assistant-settings.tsx | 12 +- lib/admin/config-manager.ts | 29 ++ lib/ai/types.ts | 43 +++ stores/admin-tab-store.ts | 1 + 12 files changed, 591 insertions(+), 5 deletions(-) create mode 100644 app/(main)/admin/_tabs/ai-policy.tsx create mode 100644 app/api/admin/ai/policy/route.ts diff --git a/app/(main)/admin/_tabs/ai-policy.tsx b/app/(main)/admin/_tabs/ai-policy.tsx new file mode 100644 index 00000000..0dc4e7a2 --- /dev/null +++ b/app/(main)/admin/_tabs/ai-policy.tsx @@ -0,0 +1,361 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Save, Loader2, X, ArrowRight } from 'lucide-react'; +import type { AiConsoleConfig, AiClass } from '@/lib/ai/types'; +import { DEFAULT_AI_CONSOLE_CONFIG } from '@/lib/ai/types'; +import type { AiEntitlementState, MeteringEntry } from '@/lib/ai/entitlement'; +import { apiFetch } from '@/lib/browser-navigation'; +import { useAdminTabStore } from '@/stores/admin-tab-store'; + +type EntitlementResponse = AiEntitlementState & { recentUsage: MeteringEntry[] }; + +const CLASS_INFO: Record = { + local: { name: 'Local', desc: "Ollama on the user's own machine. Free, unmetered, never reaches this server." }, + server: { name: 'Server', desc: 'VNC-hosted. Entitlement-enforced, seat + usage tracked below.' }, + public: { name: 'Public (BYOK)', desc: "User's own API key, direct from their browser to the provider." }, +}; + +function AllowlistEditor({ + values, onChange, placeholder, +}: { values: string[] | null; onChange: (next: string[] | null) => void; placeholder: string }) { + const [draft, setDraft] = useState(''); + const restricted = values !== null; + + return ( + <> +
+ + +
+ {restricted && ( + <> +
+ {(values ?? []).map((v) => ( + + {v} + + + ))} +
+
+ setDraft(e.target.value)} + placeholder={placeholder} + className="flex-1 h-8 rounded border border-input bg-background px-2.5 text-xs" + onKeyDown={(e) => { + if (e.key === 'Enter' && draft.trim()) { + onChange([...(values ?? []), draft.trim()]); + setDraft(''); + } + }} + /> + +
+ + )} + + ); +} + +export function AiPolicyTab() { + const setActiveTab = useAdminTabStore((s) => s.setActiveTab); + const [config, setConfig] = useState({ ...DEFAULT_AI_CONSOLE_CONFIG }); + const [entitlement, setEntitlement] = useState(null); + const [serverModels, setServerModels] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [dirty, setDirty] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + useEffect(() => { void load(); }, []); + + async function load() { + setLoading(true); + try { + const [policyRes, entitlementRes, modelsRes] = await Promise.all([ + apiFetch('/api/admin/ai/policy'), + apiFetch('/api/admin/ai/entitlement'), + apiFetch('/api/ai/server/models').catch(() => null), + ]); + if (policyRes.ok) setConfig(await policyRes.json()); + if (entitlementRes.ok) setEntitlement(await entitlementRes.json()); + if (modelsRes?.ok) { + const data = await modelsRes.json(); + setServerModels(data.models ?? []); + } + } finally { + setLoading(false); + } + } + + function update(patch: Partial) { + setConfig((prev) => ({ ...prev, ...patch })); + setDirty(true); + setMessage(null); + } + + function toggleClass(cls: AiClass) { + const current = config.classesEnabled[cls] !== false; + update({ classesEnabled: { ...config.classesEnabled, [cls]: !current } }); + } + + async function handleSave() { + setSaving(true); + setMessage(null); + const res = await apiFetch('/api/admin/ai/policy', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + if (res.ok) { + setConfig(await res.json()); + setDirty(false); + setMessage({ type: 'success', text: 'Saved.' }); + } else { + const data = await res.json().catch(() => ({})); + setMessage({ type: 'error', text: data.error || 'Failed to save' }); + } + setSaving(false); + } + + async function setSeatTotal(total: number) { + const res = await apiFetch('/api/admin/ai/entitlement', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ seatsTotal: total }), + }); + if (res.ok) { + const data = await res.json(); + setEntitlement((prev) => (prev ? { ...prev, ...data } : prev)); + } + } + + async function revokeSeat(username: string) { + const res = await apiFetch('/api/admin/ai/entitlement', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ revokeUsername: username }), + }); + if (res.ok) { + const data = await res.json(); + setEntitlement((prev) => (prev ? { ...prev, ...data } : prev)); + } + } + + if (loading) { + return
Loading...
; + } + + const serverInfraAvailable = serverModels.length > 0 || entitlement !== null; + const usageToday = (entitlement?.recentUsage ?? []).filter((u) => u.timestamp.slice(0, 10) === new Date().toISOString().slice(0, 10)); + const tokensToday = usageToday.reduce((sum, u) => sum + u.promptTokens + u.completionTokens, 0); + const avgLatency = usageToday.length ? Math.round(usageToday.reduce((sum, u) => sum + u.latencyMs, 0) / usageToday.length) : 0; + + return ( +
+
+
+

AI

+

Provider classes, allow-lists, seats, usage, and BYOK consent for the AI Assistant.

+
+ {dirty && ( + + )} +
+ + {message && ( +
+ {message.text} +
+ )} + + + +
+
+

Provider classes

+

Which of the three AI classes users can reach at all.

+
+
+ {(['local', 'server', 'public'] as AiClass[]).map((cls) => { + const enabled = config.classesEnabled[cls] !== false; + const disabledByInfra = cls === 'server' && !serverInfraAvailable; + return ( +
+
+ {CLASS_INFO[cls].name} + +
+

{CLASS_INFO[cls].desc}

+ {disabledByInfra &&

Not configured (AI_SERVER_BASE_URL unset)

} +
+ ); + })} +
+
+ +
+
+

Server — model allow-list

+

Restrict which Ollama models users may select for the Server class. Also enforced on every chat call, not just the picker.

+
+ update({ serverModelAllowlist: v })} + placeholder={serverModels.length ? `e.g. ${serverModels[0]}` : 'e.g. qwen2.5:32b'} + /> +
+ +
+
+

Public (BYOK) — provider allow-list

+

Restrict which base URLs users may point a bring-your-own-key profile at. Checked client-side at save time — advisory, not a network boundary.

+
+ update({ publicProviderAllowlist: v })} + placeholder="e.g. https://api.openai.com" + /> +
+ +
+
+

Entitlement & seats

+

Server class only. First successful use auto-assigns a seat.

+
+
+ Seats licensed + setSeatTotal(Math.max(0, Number.parseInt(e.target.value, 10) || 0))} + className="w-20 h-8 rounded border border-input bg-background px-2 text-sm text-center" + /> + {entitlement?.assignedTo.length ?? 0} of {entitlement?.seatsTotal ?? 0} assigned +
+
+ {(entitlement?.assignedTo ?? []).length === 0 && ( +
No seats assigned yet.
+ )} + {(entitlement?.assignedTo ?? []).map((username) => ( +
+ {username} + +
+ ))} +
+
+ +
+
+

Usage

+

Last 200 metered calls. Read-only.

+
+
+
{usageToday.length}Calls today
+
{tokensToday.toLocaleString()}Tokens today
+
{avgLatency}msAvg latency
+
+
+ + + + + + + + + + + + + {(entitlement?.recentUsage ?? []).length === 0 && ( + + )} + {[...(entitlement?.recentUsage ?? [])].reverse().slice(0, 50).map((u, i) => ( + + + + + + + + + ))} + +
TimeUserModelPrompt tokCompl. tokLatency
No usage recorded yet.
{new Date(u.timestamp).toLocaleTimeString()}{u.username}{u.model}{u.promptTokens}{u.completionTokens}{u.latencyMs}ms
+
+
+ +
+
+

Retrieval & consent

+

Mail-content augmentation and the BYOK consent prompt.

+
+
+
+
Retrieval leg
+

Send recent mail content to the Server class's embedding model to answer questions grounded in the user's own mail.

+
+ +
+
+ +