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.

+
+ +
+
+ +