feat(admin): build the AI Policy console (§6) — approved, spec now implemented
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.
This commit is contained in:
@@ -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<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.' },
|
||||||
|
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-3 gap-3 p-4">
|
||||||
|
{(['local', 'server', '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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ScrollText,
|
ScrollText,
|
||||||
LogOut,
|
LogOut,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
Bot,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
Activity,
|
Activity,
|
||||||
@@ -55,6 +56,7 @@ const NAV_GROUPS: ReadonlyArray<{
|
|||||||
{ tab: 'branding', label: 'Branding', icon: Palette },
|
{ tab: 'branding', label: 'Branding', icon: Palette },
|
||||||
{ tab: 'auth', label: 'Authentication', icon: Shield },
|
{ tab: 'auth', label: 'Authentication', icon: Shield },
|
||||||
{ tab: 'policy', label: 'Policy', icon: Scale },
|
{ tab: 'policy', label: 'Policy', icon: Scale },
|
||||||
|
{ tab: 'ai-policy', label: 'AI', icon: Bot },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { SettingsTab } from './_tabs/settings';
|
|||||||
import { BrandingTab } from './_tabs/branding';
|
import { BrandingTab } from './_tabs/branding';
|
||||||
import { AuthTab } from './_tabs/auth';
|
import { AuthTab } from './_tabs/auth';
|
||||||
import { PolicyTab } from './_tabs/policy';
|
import { PolicyTab } from './_tabs/policy';
|
||||||
|
import { AiPolicyTab } from './_tabs/ai-policy';
|
||||||
import { PluginsTab } from './_tabs/plugins';
|
import { PluginsTab } from './_tabs/plugins';
|
||||||
import { ThemesTab } from './_tabs/themes';
|
import { ThemesTab } from './_tabs/themes';
|
||||||
import { MarketplaceTab } from './_tabs/marketplace';
|
import { MarketplaceTab } from './_tabs/marketplace';
|
||||||
@@ -39,6 +40,7 @@ export default function AdminPage() {
|
|||||||
case 'branding': return <BrandingTab />;
|
case 'branding': return <BrandingTab />;
|
||||||
case 'auth': return <AuthTab />;
|
case 'auth': return <AuthTab />;
|
||||||
case 'policy': return <PolicyTab />;
|
case 'policy': return <PolicyTab />;
|
||||||
|
case 'ai-policy': return <AiPolicyTab />;
|
||||||
case 'plugins': return <PluginsTab />;
|
case 'plugins': return <PluginsTab />;
|
||||||
case 'themes': return <ThemesTab />;
|
case 'themes': return <ThemesTab />;
|
||||||
case 'marketplace': return <MarketplaceTab />;
|
case 'marketplace': return <MarketplaceTab />;
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import type { AiConsoleConfig, AiClass } from '@/lib/ai/types';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
const VALID_CLASSES: AiClass[] = ['local', 'server', 'public'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET/PUT /api/admin/ai/policy - the admin console's writable config
|
||||||
|
* (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6): per-class enable, model/
|
||||||
|
* provider allow-lists, retrieval on/off, BYOK consent text. Separate from
|
||||||
|
* /api/admin/ai/entitlement (seats/ledger - runtime state) and from the
|
||||||
|
* generic /api/admin/policy (FeatureGates - the master aiAssistantEnabled
|
||||||
|
* toggle stays there, this console only links to it, per spec §6 open
|
||||||
|
* question 3).
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const result = await requireAdminAuth(request);
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
return NextResponse.json(configManager.getAiConsoleConfig(), { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ai console policy read error', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(body: Partial<AiConsoleConfig>): string | null {
|
||||||
|
if (body.classesEnabled !== undefined) {
|
||||||
|
if (typeof body.classesEnabled !== 'object' || body.classesEnabled === null) return 'classesEnabled must be an object';
|
||||||
|
for (const key of Object.keys(body.classesEnabled)) {
|
||||||
|
if (!VALID_CLASSES.includes(key as AiClass)) return `classesEnabled has an unknown class "${key}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.serverModelAllowlist !== undefined && body.serverModelAllowlist !== null) {
|
||||||
|
if (!Array.isArray(body.serverModelAllowlist) || !body.serverModelAllowlist.every((m) => typeof m === 'string')) {
|
||||||
|
return 'serverModelAllowlist must be an array of strings or null';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.publicProviderAllowlist !== undefined && body.publicProviderAllowlist !== null) {
|
||||||
|
if (!Array.isArray(body.publicProviderAllowlist) || !body.publicProviderAllowlist.every((m) => typeof m === 'string')) {
|
||||||
|
return 'publicProviderAllowlist must be an array of strings or null';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.retrievalEnabled !== undefined && typeof body.retrievalEnabled !== 'boolean') {
|
||||||
|
return 'retrievalEnabled must be a boolean';
|
||||||
|
}
|
||||||
|
if (body.consent !== undefined && body.consent !== null) {
|
||||||
|
if (typeof body.consent !== 'object' || typeof body.consent.version !== 'string' || typeof body.consent.text !== 'string') {
|
||||||
|
return 'consent must be { version: string, text: string } or null';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
const result = await requireAdminAuth(request);
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
|
||||||
|
let body: Partial<AiConsoleConfig>;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = validate(body);
|
||||||
|
if (validationError) return NextResponse.json({ error: validationError }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const next = await configManager.setAiConsoleConfig(body);
|
||||||
|
await auditLog('ai.console_policy.update', {
|
||||||
|
classesEnabled: next.classesEnabled,
|
||||||
|
retrievalEnabled: next.retrievalEnabled,
|
||||||
|
consentVersion: next.consent?.version ?? null,
|
||||||
|
serverModelAllowlistCount: next.serverModelAllowlist?.length ?? null,
|
||||||
|
publicProviderAllowlistCount: next.publicProviderAllowlist?.length ?? null,
|
||||||
|
}, ip);
|
||||||
|
return NextResponse.json(next);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ai console policy update error', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,14 +17,25 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const policy = configManager.getPolicy();
|
const policy = configManager.getPolicy();
|
||||||
|
const consoleConfig = configManager.getAiConsoleConfig();
|
||||||
|
|
||||||
const classes = [...DEFAULT_AI_ENTITLEMENT.classes];
|
// A class must be BOTH infra-available AND not explicitly disabled by
|
||||||
if (process.env.AI_SERVER_BASE_URL) classes.push('server');
|
// the admin console (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6) to reach
|
||||||
|
// users. Missing classesEnabled entries default to allowed, so this
|
||||||
|
// changes nothing until an admin actually touches the console.
|
||||||
|
const classAllowed = (cls: (typeof DEFAULT_AI_ENTITLEMENT.classes)[number]) => consoleConfig.classesEnabled[cls] !== false;
|
||||||
|
const classes: typeof DEFAULT_AI_ENTITLEMENT.classes = [];
|
||||||
|
if (classAllowed('local')) classes.push('local');
|
||||||
|
if (classAllowed('public')) classes.push('public');
|
||||||
|
if (process.env.AI_SERVER_BASE_URL && classAllowed('server')) classes.push('server');
|
||||||
|
|
||||||
const aiPolicy: AiPolicy = {
|
const aiPolicy: AiPolicy = {
|
||||||
enabled: policy.features.aiAssistantEnabled,
|
enabled: policy.features.aiAssistantEnabled,
|
||||||
entitlement: { ...DEFAULT_AI_ENTITLEMENT, classes },
|
entitlement: { ...DEFAULT_AI_ENTITLEMENT, classes },
|
||||||
publicConsentVersion: null,
|
publicConsentVersion: consoleConfig.consent?.version ?? null,
|
||||||
|
retrievalEnabled: consoleConfig.retrievalEnabled,
|
||||||
|
consent: consoleConfig.consent,
|
||||||
|
publicProviderAllowlist: consoleConfig.publicProviderAllowlist,
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(aiPolicy, {
|
return NextResponse.json(aiPolicy, {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
import { serverSearchMail, hydrateMailRefs } from '@/lib/ai/retrieval/mail-embeddings';
|
import { serverSearchMail, hydrateMailRefs } from '@/lib/ai/retrieval/mail-embeddings';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -31,6 +32,14 @@ export async function POST(request: NextRequest) {
|
|||||||
return new NextResponse(null, { status: 404 });
|
return new NextResponse(null, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Real enforcement, not cosmetic client hiding (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md
|
||||||
|
// §6): an admin can disable mail-content-to-embeddings augmentation
|
||||||
|
// independent of disabling the `server` chat class outright.
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
if (!configManager.getAiConsoleConfig().retrievalEnabled) {
|
||||||
|
return NextResponse.json({ error: 'retrieval is disabled by admin policy' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
let body: { query?: unknown; limit?: unknown };
|
let body: { query?: unknown; limit?: unknown };
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
import { checkAndAssignSeat, recordUsage } from '@/lib/ai/entitlement';
|
import { checkAndAssignSeat, recordUsage } from '@/lib/ai/entitlement';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -35,6 +36,15 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Real enforcement, not cosmetic client hiding (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md
|
||||||
|
// §6): the admin console can disable the whole `server` class even when
|
||||||
|
// AI_SERVER_BASE_URL stays configured (e.g. keeping infra up for staging
|
||||||
|
// while turning it off for users).
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
if (configManager.getAiConsoleConfig().classesEnabled.server === false) {
|
||||||
|
return NextResponse.json({ error: 'the server-hosted AI class is disabled by admin policy' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const seat = await checkAndAssignSeat(auth.username);
|
const seat = await checkAndAssignSeat(auth.username);
|
||||||
if (!seat.allowed) {
|
if (!seat.allowed) {
|
||||||
return NextResponse.json({ error: seat.reason ?? 'not entitled' }, { status: 402 });
|
return NextResponse.json({ error: seat.reason ?? 'not entitled' }, { status: 402 });
|
||||||
@@ -58,6 +68,11 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'model and messages are required' }, { status: 400 });
|
return NextResponse.json({ error: 'model and messages are required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowlist = configManager.getAiConsoleConfig().serverModelAllowlist;
|
||||||
|
if (allowlist && !allowlist.includes(model)) {
|
||||||
|
return NextResponse.json({ error: `model "${model}" is not on the admin allow-list` }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const baseUrl = process.env.AI_SERVER_BASE_URL;
|
const baseUrl = process.env.AI_SERVER_BASE_URL;
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 });
|
return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -37,7 +38,17 @@ export async function GET(request: NextRequest) {
|
|||||||
// lists them in the same /api/tags response, but calling /api/chat with
|
// lists them in the same /api/tags response, but calling /api/chat with
|
||||||
// one fails outright. `capabilities` absent (older Ollama) fails open
|
// one fails outright. `capabilities` absent (older Ollama) fails open
|
||||||
// rather than hiding every model on an upgrade.
|
// rather than hiding every model on an upgrade.
|
||||||
const chatModels = (body.models ?? []).filter((m) => !m.capabilities || m.capabilities.includes('completion'));
|
let chatModels = (body.models ?? []).filter((m) => !m.capabilities || m.capabilities.includes('completion'));
|
||||||
|
|
||||||
|
// Admin allow-list (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6). null = every
|
||||||
|
// completion-capable model (today's behavior, unchanged).
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const allowlist = configManager.getAiConsoleConfig().serverModelAllowlist;
|
||||||
|
if (allowlist) {
|
||||||
|
const allowed = new Set(allowlist);
|
||||||
|
chatModels = chatModels.filter((m) => allowed.has(m.name));
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ models: chatModels.map((m) => m.name).filter(Boolean) });
|
return NextResponse.json({ models: chatModels.map((m) => m.name).filter(Boolean) });
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -117,9 +117,18 @@ export function AiAssistantSettings() {
|
|||||||
const [newProfileBaseUrl, setNewProfileBaseUrl] = useState('https://openrouter.ai/api/v1');
|
const [newProfileBaseUrl, setNewProfileBaseUrl] = useState('https://openrouter.ai/api/v1');
|
||||||
const [newProfileModel, setNewProfileModel] = useState('');
|
const [newProfileModel, setNewProfileModel] = useState('');
|
||||||
const [newProfileKey, setNewProfileKey] = useState('');
|
const [newProfileKey, setNewProfileKey] = useState('');
|
||||||
|
const [profileError, setProfileError] = useState<string | null>(null);
|
||||||
|
|
||||||
const addProfile = useCallback(() => {
|
const addProfile = useCallback(() => {
|
||||||
if (!newProfileName || !newProfileBaseUrl || !newProfileModel || !newProfileKey) return;
|
if (!newProfileName || !newProfileBaseUrl || !newProfileModel || !newProfileKey) return;
|
||||||
|
setProfileError(null);
|
||||||
|
// Admin allow-list (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6.1) — advisory,
|
||||||
|
// client-side only, checked here at save time.
|
||||||
|
const allowlist = policy.publicProviderAllowlist;
|
||||||
|
if (allowlist && !allowlist.some((prefix) => newProfileBaseUrl.startsWith(prefix))) {
|
||||||
|
setProfileError(`This base URL isn't on the admin-approved list (${allowlist.join(', ')}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const profile = createProfile(newProfileName, newProfileBaseUrl, newProfileModel);
|
const profile = createProfile(newProfileName, newProfileBaseUrl, newProfileModel);
|
||||||
setAiApiKey(profile.id, newProfileKey);
|
setAiApiKey(profile.id, newProfileKey);
|
||||||
update('publicProfiles', [...settings.publicProfiles, profile]);
|
update('publicProfiles', [...settings.publicProfiles, profile]);
|
||||||
@@ -128,7 +137,7 @@ export function AiAssistantSettings() {
|
|||||||
setNewProfileBaseUrl('https://openrouter.ai/api/v1');
|
setNewProfileBaseUrl('https://openrouter.ai/api/v1');
|
||||||
setNewProfileModel('');
|
setNewProfileModel('');
|
||||||
setNewProfileKey('');
|
setNewProfileKey('');
|
||||||
}, [newProfileName, newProfileBaseUrl, newProfileModel, newProfileKey, settings.publicProfiles, settings.activeProfileId, update]);
|
}, [newProfileName, newProfileBaseUrl, newProfileModel, newProfileKey, settings.publicProfiles, settings.activeProfileId, update, policy.publicProviderAllowlist]);
|
||||||
|
|
||||||
const removeProfile = useCallback(
|
const removeProfile = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
@@ -376,6 +385,7 @@ export function AiAssistantSettings() {
|
|||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{profileError && <p className="text-xs text-destructive">{profileError}</p>}
|
||||||
</div>
|
</div>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { logger } from '@/lib/logger';
|
|||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
||||||
import { ensureConfigDir, getConfigPath, assertWritable } from './paths';
|
import { ensureConfigDir, getConfigPath, assertWritable } from './paths';
|
||||||
|
import { DEFAULT_AI_CONSOLE_CONFIG, type AiConsoleConfig } from '@/lib/ai/types';
|
||||||
|
|
||||||
function parseEnvValue(value: string, type: string): unknown {
|
function parseEnvValue(value: string, type: string): unknown {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -26,6 +27,7 @@ function parseEnvValue(value: string, type: string): unknown {
|
|||||||
class ConfigManager {
|
class ConfigManager {
|
||||||
private adminConfig: Record<string, unknown> = {};
|
private adminConfig: Record<string, unknown> = {};
|
||||||
private policyCache: SettingsPolicy = { ...DEFAULT_POLICY };
|
private policyCache: SettingsPolicy = { ...DEFAULT_POLICY };
|
||||||
|
private aiConsoleConfigCache: AiConsoleConfig = { ...DEFAULT_AI_CONSOLE_CONFIG };
|
||||||
private loaded = false;
|
private loaded = false;
|
||||||
|
|
||||||
/** Load admin config and policy from disk. Called once at startup and on reload. */
|
/** Load admin config and policy from disk. Called once at startup and on reload. */
|
||||||
@@ -42,6 +44,12 @@ class ConfigManager {
|
|||||||
} else {
|
} else {
|
||||||
this.policyCache = { ...DEFAULT_POLICY };
|
this.policyCache = { ...DEFAULT_POLICY };
|
||||||
}
|
}
|
||||||
|
const aiConsoleConfig = await this.readJsonFile('ai-policy.json');
|
||||||
|
this.aiConsoleConfigCache = {
|
||||||
|
...DEFAULT_AI_CONSOLE_CONFIG,
|
||||||
|
...aiConsoleConfig,
|
||||||
|
classesEnabled: { ...DEFAULT_AI_CONSOLE_CONFIG.classesEnabled, ...(aiConsoleConfig?.classesEnabled as object | undefined) },
|
||||||
|
} as AiConsoleConfig;
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
logger.debug('ConfigManager loaded', { configKeys: Object.keys(this.adminConfig).length });
|
logger.debug('ConfigManager loaded', { configKeys: Object.keys(this.adminConfig).length });
|
||||||
}
|
}
|
||||||
@@ -175,6 +183,27 @@ class ConfigManager {
|
|||||||
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
|
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current AI console config (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6).
|
||||||
|
*/
|
||||||
|
getAiConsoleConfig(): AiConsoleConfig {
|
||||||
|
return this.aiConsoleConfigCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the AI console config. Writes to disk.
|
||||||
|
*/
|
||||||
|
async setAiConsoleConfig(config: Partial<AiConsoleConfig>): Promise<AiConsoleConfig> {
|
||||||
|
assertWritable('update AI console config');
|
||||||
|
this.aiConsoleConfigCache = {
|
||||||
|
...this.aiConsoleConfigCache,
|
||||||
|
...config,
|
||||||
|
classesEnabled: { ...this.aiConsoleConfigCache.classesEnabled, ...(config.classesEnabled || {}) },
|
||||||
|
};
|
||||||
|
await this.writeJsonFile('ai-policy.json', this.aiConsoleConfigCache as unknown as Record<string, unknown>);
|
||||||
|
return this.aiConsoleConfigCache;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrates deprecated feature gates forward. The standalone "All Mail" view
|
* Migrates deprecated feature gates forward. The standalone "All Mail" view
|
||||||
* (`allMailViewEnabled`) was folded into the unified "All mail" entry, so an
|
* (`allMailViewEnabled`) was folded into the unified "All mail" entry, so an
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ export interface AiPolicy {
|
|||||||
entitlement: AiEntitlement;
|
entitlement: AiEntitlement;
|
||||||
/** Public-model consent text version currently in force (§7.3). Unset until P2. */
|
/** Public-model consent text version currently in force (§7.3). Unset until P2. */
|
||||||
publicConsentVersion: string | null;
|
publicConsentVersion: string | null;
|
||||||
|
/** Mirrors AiConsoleConfig.retrievalEnabled (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6). */
|
||||||
|
retrievalEnabled: boolean;
|
||||||
|
/** Admin-authored consent shown once per user before first BYOK/public use.
|
||||||
|
* Bumping the version re-prompts everyone (client tracks acceptance per version). */
|
||||||
|
consent: { version: string; text: string } | null;
|
||||||
|
/** Base-URL prefixes a BYOK profile's baseUrl must match. null = unrestricted
|
||||||
|
* (today's behavior). Advisory/client-side only — see spec §6.1. */
|
||||||
|
publicProviderAllowlist: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_AI_ENTITLEMENT: AiEntitlement = {
|
export const DEFAULT_AI_ENTITLEMENT: AiEntitlement = {
|
||||||
@@ -52,4 +60,39 @@ export const DEFAULT_AI_POLICY: AiPolicy = {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
entitlement: { ...DEFAULT_AI_ENTITLEMENT },
|
entitlement: { ...DEFAULT_AI_ENTITLEMENT },
|
||||||
publicConsentVersion: null,
|
publicConsentVersion: null,
|
||||||
|
retrievalEnabled: true,
|
||||||
|
consent: null,
|
||||||
|
publicProviderAllowlist: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin-authored console config (docs/AI-ASSISTANT-CONCEPT.md §6 /
|
||||||
|
// docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md). Persisted via config-manager
|
||||||
|
// (CONFIG dir - operator-authored, not runtime state like entitlement.ts's
|
||||||
|
// seats/ledger). Read by /api/ai/policy (public) and written by
|
||||||
|
// /api/admin/ai/policy (admin-protected).
|
||||||
|
export interface AiConsoleConfig {
|
||||||
|
/** Per-class admin override. A class must be BOTH infra-available
|
||||||
|
* (server: AI_SERVER_BASE_URL set) AND not explicitly disabled here to
|
||||||
|
* reach users. Missing entries default to true - turning this feature
|
||||||
|
* on changes nothing until an admin touches it. */
|
||||||
|
classesEnabled: Partial<Record<AiClass, boolean>>;
|
||||||
|
/** null = every completion-capable model Ollama reports (today's
|
||||||
|
* behavior, unchanged). Non-null = only these model names selectable
|
||||||
|
* for the `server` class. */
|
||||||
|
serverModelAllowlist: string[] | null;
|
||||||
|
/** null = unrestricted BYOK base URLs (today's behavior, unchanged).
|
||||||
|
* Non-null = base URL must start with one of these prefixes. */
|
||||||
|
publicProviderAllowlist: string[] | null;
|
||||||
|
/** Master switch for the retrieval leg (mail-content → embeddings).
|
||||||
|
* Independent of classesEnabled.server. Defaults true. */
|
||||||
|
retrievalEnabled: boolean;
|
||||||
|
consent: { version: string; text: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_AI_CONSOLE_CONFIG: AiConsoleConfig = {
|
||||||
|
classesEnabled: {},
|
||||||
|
serverModelAllowlist: null,
|
||||||
|
publicProviderAllowlist: null,
|
||||||
|
retrievalEnabled: true,
|
||||||
|
consent: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const ADMIN_TABS = [
|
|||||||
'branding',
|
'branding',
|
||||||
'auth',
|
'auth',
|
||||||
'policy',
|
'policy',
|
||||||
|
'ai-policy',
|
||||||
'plugins',
|
'plugins',
|
||||||
'themes',
|
'themes',
|
||||||
'marketplace',
|
'marketplace',
|
||||||
|
|||||||
Reference in New Issue
Block a user