Three things, all from running the real thing rather than trusting a status code.
1. OpenCode as a 4th AI class (lib/ai/opencode.ts + app/api/ai/opencode/*).
A locally-running `opencode serve` — the same runtime Paperclip drives as
an adapter. Its appeal over a BYOK profile is precisely what was broken
before: opencode owns provider auth itself, so there is NO api key for
this app to hold, and it reports a REAL model list (25 on this machine)
instead of asking the user to type an exact provider-specific model id
from memory. Typing "Sonnet 5" into a free-text box and getting a bare
"Provider returned 401" is the failure this removes.
IMPORTANT trap, documented in the module header and pinned by a test:
opencode is NOT OpenAI-compatible. `/v1/models` and `/v1/chat/completions`
both answer 200 — because a web-UI catch-all serves index.html for ANY
unknown path. I built the first version against that assumed compatibility
on the strength of two 200s and had to throw it away once I read a body.
Every probe now validates the parsed shape and content-type, never the
status alone. The real API is GET /api/model + POST /session +
POST /session/{id}/message, and the reply's `reasoning` parts are stripped
so a model's private chain of thought can never surface as the answer.
Proxied through our own backend (like the `server` class) because the
desktop renderer's origin is a random port that changes every launch;
same-origin sidesteps opencode's CORS allowlist entirely. Loopback-only by
construction: a non-loopback OPENCODE_BASE_URL is refused, since "local,
no keys, nothing leaves the device" is the whole point of this class.
2. Retrieval read the WRONG ACCOUNT'S index. The indexer writes under the
active account's cookie slot (catchUpIndex passes it) but fetchLocalLeg
omitted `?slot=`, so search resolved to whichever account the multi-slot
resolver found first. Single-account installs never noticed; a real
multi-account/shared-mailbox setup reads an empty store every time. Both
call sites now pass the active slot.
3. "No local mail index available in this session" was shown even when the
index existed and simply matched nothing — actively misleading, and it
masked the missing-SESSION_SECRET bug for hours. AskResult now carries
retrievalState ('augmented' | 'no-match' | 'no-index') and the two cases
get different words: build the index, versus rephrase (with the honest
caveat that keyword search answers content questions better than recency
ones like "the last mail").
Verified live against real opencode 1.18.14: discovery found 25 models and a
real prompt round-tripped the exact expected answer through the real helper
code, not curl. Gate: tsc clean, eslint clean, 2512/2512 unit tests (10 new,
incl. one that fails if the HTML catch-all is ever accepted as an API), build clean.
363 lines
18 KiB
TypeScript
363 lines
18 KiB
TypeScript
'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<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>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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">Entitlement & 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 & 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>
|
|
);
|
|
}
|