Files
SRCmail/app/(main)/admin/_tabs/ai-policy.tsx
T
Bernd RodlerandClaude Sonnet 5 f121678e2a feat(ai): Paperclip-style env-var provider presets + zero-config local default
Two product decisions from tonight:

1. Public AI providers can now be published by an admin as named presets
   (lib/ai/types.ts's PublicAiPreset: name/baseUrl/model/apiKeyEnvVar).
   The admin names an env var, never a secret value - the actual key is
   whatever ops has set in the server's real environment, same custody
   model as the existing AI_SERVER_BASE_URL var. A new server route
   (app/api/ai/public/chat) resolves it and makes the call itself, which
   also sidesteps the CORS/wrong-base-URL failure class chatPublic hit
   earlier tonight. Users pick a preset from a dropdown in Settings -
   Answer with - no key field at all; personal BYOK (paste your own key)
   stays available as a secondary "Add your own key" option, not removed.
   Admin UI: new "Public - org-managed presets" card in the AI policy tab.

2. AI now defaults ON instead of requiring setup (lib/ai/auto-provision.ts):
   on first load, if no provider is chosen yet, probe OpenCode (this app
   auto-spawns `opencode serve` itself, so it's the one local option with
   zero external install step) then Ollama via the existing auto-discovery,
   and adopt whichever answers. Never overrides an explicit choice - only
   fires while provider is still null. Wired into both AI entry points
   (the Ask button and the Settings pane) so it resolves before either
   renders its "not configured" state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 14:01:00 +02:00

453 lines
23 KiB
TypeScript

'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<AiClass, { name: string; desc: string }> = {
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 (
<>
<div className="flex gap-3.5 px-4 pt-2.5 pb-0.5 text-xs">
<label className="flex items-center gap-1.5 cursor-pointer text-muted-foreground">
<input type="radio" checked={!restricted} onChange={() => onChange(null)} />
Unrestricted (current)
</label>
<label className={`flex items-center gap-1.5 cursor-pointer ${restricted ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>
<input type="radio" checked={restricted} onChange={() => onChange(values ?? [])} />
Restrict to selected
</label>
</div>
{restricted && (
<>
<div className="flex flex-wrap gap-1.5 px-4 pt-2.5">
{(values ?? []).map((v) => (
<span key={v} className="inline-flex items-center gap-1.5 bg-muted border border-border rounded-full py-1 pl-3 pr-1.5 text-xs">
{v}
<button onClick={() => onChange((values ?? []).filter((x) => x !== v))} className="text-muted-foreground hover:text-foreground">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<div className="flex gap-2 px-4 py-3">
<input
value={draft}
onChange={(e) => 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('');
}
}}
/>
<button
onClick={() => { if (draft.trim()) { onChange([...(values ?? []), draft.trim()]); setDraft(''); } }}
className="h-8 px-3 rounded border border-border bg-muted text-xs font-medium hover:bg-muted/70"
>
Add
</button>
</div>
</>
)}
</>
);
}
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 && (
<div className="divide-y divide-border">
{presets.map((p) => (
<div key={p.id} className="px-4 py-2.5 flex items-center justify-between gap-3">
<div className="min-w-0">
<span className="text-sm font-medium">{p.name}</span>
<p className="text-xs text-muted-foreground truncate">
{p.model} · {p.baseUrl} · reads <code className="text-[11px]">{p.apiKeyEnvVar}</code>
</p>
</div>
<button
onClick={() => onChange(presets.filter((x) => x.id !== p.id))}
className="shrink-0 text-muted-foreground hover:text-destructive"
aria-label={`Remove ${p.name}`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
<div className="px-4 py-3 flex flex-col gap-2 border-t border-border">
<div className="flex gap-2 flex-wrap">
<input value={name} onChange={(e) => 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" />
<input value={model} onChange={(e) => 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" />
</div>
<div className="flex gap-2 flex-wrap">
<input value={baseUrl} onChange={(e) => 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" />
<input value={envVar} onChange={(e) => 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" />
<button onClick={addPreset} disabled={!canAdd}
className="h-8 px-3 rounded border border-border bg-muted text-xs font-medium hover:bg-muted/70 disabled:opacity-50 inline-flex items-center gap-1.5">
<Plus className="w-3 h-3" /> Add
</button>
</div>
<p className="text-xs text-muted-foreground">
Only the env var <em>name</em> 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.
</p>
</div>
</>
);
}
export function AiPolicyTab() {
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
const [config, setConfig] = useState<AiConsoleConfig>({ ...DEFAULT_AI_CONSOLE_CONFIG });
const [entitlement, setEntitlement] = useState<EntitlementResponse | null>(null);
const [serverModels, setServerModels] = useState<string[]>([]);
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<AiConsoleConfig>) {
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 <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
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 (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">AI</h1>
<p className="text-sm text-muted-foreground mt-1">Provider classes, allow-lists, seats, usage, and BYOK consent for the AI Assistant.</p>
</div>
{dirty && (
<button onClick={handleSave} disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm">
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save changes
</button>
)}
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
<button onClick={() => setActiveTab('policy')}
className="w-full flex items-center gap-2 text-xs text-muted-foreground bg-muted border border-border rounded-md px-3.5 py-2.5 hover:bg-muted/70 transition-colors text-left">
<span>The master AI Assistant on/off switch lives in</span>
<span className="text-primary font-medium inline-flex items-center gap-1">Policy Feature Gates <ArrowRight className="w-3 h-3" /></span>
</button>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Provider classes</h2>
<p className="text-xs text-muted-foreground mt-0.5">Which of the three AI classes users can reach at all.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 p-4">
{(['local', 'server', 'opencode', 'public'] as AiClass[]).map((cls) => {
const enabled = config.classesEnabled[cls] !== false;
const disabledByInfra = cls === 'server' && !serverInfraAvailable;
return (
<div key={cls} className={`border border-border rounded-md p-3.5 ${disabledByInfra ? 'opacity-55' : ''}`}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm font-semibold">{CLASS_INFO[cls].name}</span>
<button
onClick={() => !disabledByInfra && toggleClass(cls)}
disabled={disabledByInfra}
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled && !disabledByInfra ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'} ${disabledByInfra ? 'cursor-not-allowed' : ''}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled && !disabledByInfra ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
<p className="text-xs text-muted-foreground">{CLASS_INFO[cls].desc}</p>
{disabledByInfra && <p className="text-xs text-amber-600 dark:text-amber-400 mt-1.5">Not configured (AI_SERVER_BASE_URL unset)</p>}
</div>
);
})}
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Server model allow-list</h2>
<p className="text-xs text-muted-foreground mt-0.5">Restrict which Ollama models users may select for the Server class. Also enforced on every chat call, not just the picker.</p>
</div>
<AllowlistEditor
values={config.serverModelAllowlist}
onChange={(v) => update({ serverModelAllowlist: v })}
placeholder={serverModels.length ? `e.g. ${serverModels[0]}` : 'e.g. qwen2.5:32b'}
/>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Public (BYOK) provider allow-list</h2>
<p className="text-xs text-muted-foreground mt-0.5">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.</p>
</div>
<AllowlistEditor
values={config.publicProviderAllowlist}
onChange={(v) => update({ publicProviderAllowlist: v })}
placeholder="e.g. https://api.openai.com"
/>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Public org-managed presets</h2>
<p className="text-xs text-muted-foreground mt-0.5">
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.
</p>
</div>
<PublicPresetsEditor presets={config.publicPresets} onChange={(v) => update({ publicPresets: v })} />
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Entitlement &amp; seats</h2>
<p className="text-xs text-muted-foreground mt-0.5">Server class only. First successful use auto-assigns a seat.</p>
</div>
<div className="px-4 py-3 flex items-center gap-3 border-b border-border">
<span className="text-sm flex-1">Seats licensed</span>
<input
type="number" min={0}
value={entitlement?.seatsTotal ?? 0}
onChange={(e) => 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"
/>
<span className="text-xs text-muted-foreground">{entitlement?.assignedTo.length ?? 0} of {entitlement?.seatsTotal ?? 0} assigned</span>
</div>
<div className="divide-y divide-border">
{(entitlement?.assignedTo ?? []).length === 0 && (
<div className="px-4 py-3 text-xs text-muted-foreground">No seats assigned yet.</div>
)}
{(entitlement?.assignedTo ?? []).map((username) => (
<div key={username} className="px-4 py-2.5 flex items-center justify-between gap-3">
<span className="text-sm">{username}</span>
<button onClick={() => revokeSeat(username)}
className="text-xs font-medium text-destructive border border-border rounded px-2.5 py-1 hover:bg-destructive/10">
Revoke
</button>
</div>
))}
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Usage</h2>
<p className="text-xs text-muted-foreground mt-0.5">Last 200 metered calls. Read-only.</p>
</div>
<div className="flex gap-6 px-4 py-3 border-b border-border flex-wrap">
<div><span className="text-lg font-semibold tabular-nums block">{usageToday.length}</span><span className="text-[11px] uppercase tracking-wide text-muted-foreground">Calls today</span></div>
<div><span className="text-lg font-semibold tabular-nums block">{tokensToday.toLocaleString()}</span><span className="text-[11px] uppercase tracking-wide text-muted-foreground">Tokens today</span></div>
<div><span className="text-lg font-semibold tabular-nums block">{avgLatency}ms</span><span className="text-[11px] uppercase tracking-wide text-muted-foreground">Avg latency</span></div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground uppercase text-[10px] tracking-wide">
<th className="text-left px-4 py-2 font-medium">Time</th>
<th className="text-left px-4 py-2 font-medium">User</th>
<th className="text-left px-4 py-2 font-medium">Model</th>
<th className="text-left px-4 py-2 font-medium">Prompt tok</th>
<th className="text-left px-4 py-2 font-medium">Compl. tok</th>
<th className="text-left px-4 py-2 font-medium">Latency</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{(entitlement?.recentUsage ?? []).length === 0 && (
<tr><td colSpan={6} className="px-4 py-3 text-muted-foreground">No usage recorded yet.</td></tr>
)}
{[...(entitlement?.recentUsage ?? [])].reverse().slice(0, 50).map((u, i) => (
<tr key={i} className="tabular-nums">
<td className="px-4 py-2">{new Date(u.timestamp).toLocaleTimeString()}</td>
<td className="px-4 py-2">{u.username}</td>
<td className="px-4 py-2">{u.model}</td>
<td className="px-4 py-2">{u.promptTokens}</td>
<td className="px-4 py-2">{u.completionTokens}</td>
<td className="px-4 py-2">{u.latencyMs}ms</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Retrieval &amp; consent</h2>
<p className="text-xs text-muted-foreground mt-0.5">Mail-content augmentation and the BYOK consent prompt.</p>
</div>
<div className="px-4 py-3 flex items-center justify-between gap-4 border-b border-border">
<div>
<div className="text-sm">Retrieval leg</div>
<p className="text-xs text-muted-foreground mt-0.5">Send recent mail content to the Server class's embedding model to answer questions grounded in the user's own mail.</p>
</div>
<button onClick={() => update({ retrievalEnabled: !config.retrievalEnabled })}
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${config.retrievalEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${config.retrievalEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
<div className="px-4 py-3.5 space-y-2">
<label className="text-sm block">Consent text (shown once per version, before first BYOK/Public use)</label>
<textarea
value={config.consent?.text ?? ''}
onChange={(e) => update({ consent: { version: config.consent?.version ?? '1', text: e.target.value } })}
className="w-full min-h-20 rounded border border-input bg-background px-2.5 py-2 text-xs"
placeholder="Using a bring-your-own-key provider sends your question — and, if retrieval is on, related excerpts from your mail — to that provider's servers, outside this organisation. Continue?"
/>
</div>
<div className="px-4 py-3 flex items-center gap-2.5 flex-wrap">
<span className="text-sm">Version</span>
<input
value={config.consent?.version ?? ''}
onChange={(e) => update({ consent: { version: e.target.value, text: config.consent?.text ?? '' } })}
className="w-20 h-8 rounded border border-input bg-background px-2 text-xs text-center"
/>
<button
onClick={() => update({ consent: { version: String(Number.parseInt(config.consent?.version || '0', 10) + 1), text: config.consent?.text ?? '' } })}
className="h-8 px-3 rounded border border-border bg-muted text-xs font-medium hover:bg-muted/70">
Bump version (re-prompt everyone)
</button>
</div>
</div>
</div>
);
}