'use client'; import { useEffect, useState } from 'react'; import { Save, Loader2, X, ArrowRight, Plus, Trash2 } from 'lucide-react'; import type { AiConsoleConfig, AiClass, PublicAiPreset } 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.' }, opencode: { name: 'OpenCode', desc: 'A locally-running OpenCode agent server. Holds its own provider credentials; nothing metered here.' }, 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(''); } }} />
)} ); } function newPresetId(): string { return `preset-${Math.random().toString(36).slice(2, 10)}`; } /** * The Paperclip-style env-var-key picker (decision 2026-08-07): an admin * names a preset and an env var; the actual secret value is never entered * here — it's whatever ops has set in the server's real environment. This is * what lets a user in Settings pick a provider from a dropdown instead of * pasting a key. */ function PublicPresetsEditor({ presets, onChange, }: { presets: PublicAiPreset[]; onChange: (next: PublicAiPreset[]) => void }) { const [name, setName] = useState(''); const [baseUrl, setBaseUrl] = useState('https://api.deepseek.com'); const [model, setModel] = useState(''); const [envVar, setEnvVar] = useState(''); const canAdd = name.trim() && baseUrl.trim() && model.trim() && envVar.trim(); function addPreset() { if (!canAdd) return; onChange([...presets, { id: newPresetId(), name: name.trim(), baseUrl: baseUrl.trim(), model: model.trim(), apiKeyEnvVar: envVar.trim() }]); setName(''); setBaseUrl('https://api.deepseek.com'); setModel(''); setEnvVar(''); } return ( <> {presets.length > 0 && (
{presets.map((p) => (
{p.name}

{p.model} · {p.baseUrl} · reads {p.apiKeyEnvVar}

))}
)}
setName(e.target.value)} placeholder="Name, e.g. DeepSeek (org)" className="flex-1 min-w-[160px] h-8 rounded border border-input bg-background px-2.5 text-xs" /> setModel(e.target.value)} placeholder="Model, e.g. deepseek-chat" className="flex-1 min-w-[160px] h-8 rounded border border-input bg-background px-2.5 text-xs" />
setBaseUrl(e.target.value)} placeholder="API base URL" className="flex-1 min-w-[200px] h-8 rounded border border-input bg-background px-2.5 text-xs" /> setEnvVar(e.target.value)} placeholder="Env var, e.g. DEEPSEEK_API_KEY" className="flex-1 min-w-[200px] h-8 rounded border border-input bg-background px-2.5 text-xs" />

Only the env var name is stored here — provision the actual key as a real environment variable on the server (k8s secret, .env, Electron packaging). This app never sees or stores the value.

); } 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', 'opencode', '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" />

Public — org-managed presets

Paperclip-style: publish a provider by name instead of making every user paste their own key. Users pick one of these in Settings with no key field at all — the server resolves the named env var at request time.

update({ publicPresets: v })} />

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) => ( ))}
Time User Model Prompt tok Compl. tok Latency
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.