feat(ai): multi-key BYOK, real server class, real entitlement enforcement

Three pieces built together tonight since they're naturally linked (the
server-class proxy is the real entitlement enforcement chokepoint):

1. Multi-key BYOK (public class): several named provider profiles
   (name/baseUrl/model), each with its own key in lib/ai/key-store.ts
   (keyed by profile id, not a single fixed 'public' slot). The "Try it"
   pane lets you pick which saved profile answers each question - not one
   fixed default.

2. `server` class, real: app/api/ai/server/{models,chat} proxy through
   this app's own backend to AI_SERVER_BASE_URL - same-origin from the
   browser, no CORS/OLLAMA_ORIGINS story at all, standing in tonight for
   VNC's EU/CH-hosted infra with the real Ollama on this Mac (swapping to
   the real instance tomorrow is a config change).

3. Real entitlement enforcement (lib/ai/entitlement.ts), scoped to `server`
   only (not local/public, per the 2026-08-05 decisions): checkAndAssignSeat()
   re-validates on every /api/ai/server/chat call - first use auto-assigns a
   seat if any remain, further calls from an unlicensed user get a 402 with
   a specific reason. recordUsage() appends to an append-only metering
   ledger (timestamp/user/model/tokens/latency) that IS the billing record.
   Admin data endpoints at /api/admin/ai/entitlement (seat total, revoke) -
   the visual admin console is a separate, not-yet-built task.

Two real bugs found and fixed during verification, not just claimed fixed:
- /api/ai/policy never actually added 'server' to entitlement.classes even
  when AI_SERVER_BASE_URL was set (only the type comment was updated) - the
  Server radio option silently never appeared until this was caught live.
- The new routes used readStalwartAuthContext(0) (hardcoded slot, SSO/reauth-
  specific) instead of getStalwartCredentials() (the general multi-slot
  session resolver every other authenticated route uses) - reachable but
  wrong, and would have hidden a real auth gap behind "works on my slot".

Verified end-to-end for real: built + ran the actual server, logged in via
the real (non-demo) auth flow, selected Server, listed the real Ollama
models through the proxy, asked "Reply with exactly the words: SERVER CLASS
WORKS" and got back exactly that - plus confirmed on disk (not just in the
UI) that data/admin-state/ai-entitlement.json recorded the seat assignment
and ai-metering.jsonl recorded real prompt/completion token counts and
latency from the actual model call. Rejection-path logic (seat limit
reached, zero seats configured, revocation) covered by 5 new unit tests
rather than a second live round trip. Full suite: typecheck clean, lint
clean, translations 48/48, production build succeeds.
This commit is contained in:
Bernd Rodler
2026-08-06 00:07:56 +02:00
parent bde8455df5
commit dda7adf565
11 changed files with 821 additions and 122 deletions
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { getEntitlementState, setSeatTotal, revokeSeat, readMeteringLedger } from '@/lib/ai/entitlement';
export const runtime = 'nodejs';
/**
* Admin-only data endpoints for the `server` AI class's real entitlement
* enforcement (lib/ai/entitlement.ts). This is the data plumbing only — the
* visual admin console (docs/AI-ASSISTANT-CONCEPT.md §6) is a separate,
* not-yet-built UI on top of these same endpoints.
*/
export async function GET(request: NextRequest) {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
try {
const [state, ledger] = await Promise.all([getEntitlementState(), readMeteringLedger()]);
return NextResponse.json({ ...state, recentUsage: ledger }, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
logger.error('ai entitlement read error', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
let body: { seatsTotal?: unknown; revokeUsername?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
}
try {
if (typeof body.seatsTotal === 'number') {
const state = await setSeatTotal(body.seatsTotal);
await auditLog('ai.entitlement.seats_total', { seatsTotal: state.seatsTotal }, ip);
return NextResponse.json(state);
}
if (typeof body.revokeUsername === 'string' && body.revokeUsername) {
const state = await revokeSeat(body.revokeUsername);
await auditLog('ai.entitlement.revoke_seat', { username: body.revokeUsername }, ip);
return NextResponse.json(state);
}
return NextResponse.json({ error: 'seatsTotal or revokeUsername is required' }, { status: 400 });
} catch (error) {
logger.error('ai entitlement update error', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+10 -6
View File
@@ -6,20 +6,24 @@ import { DEFAULT_AI_ENTITLEMENT, type AiPolicy } from '@/lib/ai/types';
/** /**
* GET /api/ai/policy - AI Assistant policy (NOT admin-protected - users read this) * GET /api/ai/policy - AI Assistant policy (NOT admin-protected - users read this)
* *
* P0 stub (docs/AI-ASSISTANT-CONCEPT.md §12): proves the client<->server * `enabled` mirrors the admin FeatureGates toggle. `entitlement.classes`
* policy-fetch plumbing end-to-end with no provider ever called. `enabled` * reflects real configuration, not a hardcoded guess: `server` only appears
* mirrors the admin FeatureGates toggle; entitlement is hardcoded unlicensed * when AI_SERVER_BASE_URL is actually set (app/api/ai/server/* would 503
* until P2 wires a real seats/billing backend (§9) - there is no provider * otherwise) - this is enforcement point 1 (docs §10), cosmetic-only, the
* class to grant yet regardless of what an entitlement record might say. * client hiding what it can't use; the real gate is checkAndAssignSeat() on
* every /api/ai/server/chat call, not this list.
*/ */
export async function GET() { export async function GET() {
try { try {
await configManager.ensureLoaded(); await configManager.ensureLoaded();
const policy = configManager.getPolicy(); const policy = configManager.getPolicy();
const classes = [...DEFAULT_AI_ENTITLEMENT.classes];
if (process.env.AI_SERVER_BASE_URL) classes.push('server');
const aiPolicy: AiPolicy = { const aiPolicy: AiPolicy = {
enabled: policy.features.aiAssistantEnabled, enabled: policy.features.aiAssistantEnabled,
entitlement: { ...DEFAULT_AI_ENTITLEMENT }, entitlement: { ...DEFAULT_AI_ENTITLEMENT, classes },
publicConsentVersion: null, publicConsentVersion: null,
}; };
+96
View File
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { checkAndAssignSeat, recordUsage } from '@/lib/ai/entitlement';
import { logger } from '@/lib/logger';
export const runtime = 'nodejs';
const MAX_BODY_BYTES = 200 * 1024;
interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
interface OllamaChatResponse {
message?: { content?: string };
prompt_eval_count?: number;
eval_count?: number;
}
/**
* POST /api/ai/server/chat — the one real enforcement chokepoint for the
* `server` AI class (docs/AI-ASSISTANT-CONCEPT.md §10 point 2: "re-validates
* ... entitlement against live state; rejects on mismatch ... never trusts
* the client"). Every call re-checks the seat; nothing here is cosmetic.
*
* Retrieval already happened client-side (the same /api/offline/search leg
* `local`/`public` use) — this route receives the already-built prompt
* messages and only proxies the model call + records the metering entry
* that IS the billing record (lib/ai/entitlement.ts).
*/
export async function POST(request: NextRequest) {
const auth = await getStalwartCredentials(request);
if (!auth) {
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
}
const seat = await checkAndAssignSeat(auth.username);
if (!seat.allowed) {
return NextResponse.json({ error: seat.reason ?? 'not entitled' }, { status: 402 });
}
const rawBody = await request.text();
if (rawBody.length > MAX_BODY_BYTES) {
return NextResponse.json({ error: 'request too large' }, { status: 413 });
}
let body: { model?: unknown; messages?: unknown };
try {
body = JSON.parse(rawBody);
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
}
const model = typeof body.model === 'string' ? body.model : '';
const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null;
if (!model || !messages || messages.length === 0) {
return NextResponse.json({ error: 'model and messages are required' }, { status: 400 });
}
const baseUrl = process.env.AI_SERVER_BASE_URL;
if (!baseUrl) {
return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 });
}
const startedAt = Date.now();
try {
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages, stream: false }),
});
if (!res.ok) {
return NextResponse.json({ error: `AI server returned ${res.status}` }, { status: 502 });
}
const data = (await res.json()) as OllamaChatResponse;
const content = data.message?.content;
if (!content) {
return NextResponse.json({ error: 'AI server returned no message content' }, { status: 502 });
}
await recordUsage({
timestamp: new Date().toISOString(),
username: auth.username,
model,
promptTokens: data.prompt_eval_count ?? 0,
completionTokens: data.eval_count ?? 0,
latencyMs: Date.now() - startedAt,
});
return NextResponse.json({ answer: content, seatJustAssigned: seat.seatJustAssigned === true });
} catch (cause) {
logger.error('ai server chat failed', { error: cause instanceof Error ? cause.message : String(cause) });
return NextResponse.json({ error: 'AI server unreachable' }, { status: 502 });
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
export const runtime = 'nodejs';
/**
* GET /api/ai/server/models — list models on the centrally-hosted `server`
* class runtime (docs/AI-ASSISTANT-CONCEPT.md §2.1: "the same self-hosted
* open-weight model stack as `local`... running on VNC's own infrastructure
* instead of the user's laptop"). Tonight, `AI_SERVER_BASE_URL` stands in for
* that infra with the Ollama already running on this developer's Mac — see
* the module comment in lib/ai/entitlement.ts. Swapping to the real
* EU/CH-hosted instance tomorrow is a config change, not a rewrite.
*
* Listing models is not a billable action (doc §10 point 1 — cosmetic), so
* this only requires a valid session, not a seat.
*/
export async function GET(request: NextRequest) {
const auth = await getStalwartCredentials(request);
if (!auth) {
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
}
const baseUrl = process.env.AI_SERVER_BASE_URL;
if (!baseUrl) {
return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 });
}
try {
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/tags`);
if (!res.ok) {
return NextResponse.json({ error: `upstream returned ${res.status}` }, { status: 502 });
}
const body = (await res.json()) as { models?: Array<{ name: string }> };
return NextResponse.json({ models: (body.models ?? []).map((m) => m.name).filter(Boolean) });
} catch (cause) {
return NextResponse.json(
{ error: cause instanceof Error ? cause.message : 'AI server unreachable' },
{ status: 502 },
);
}
}
+187 -61
View File
@@ -1,27 +1,32 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { RefreshCw, CheckCircle, AlertTriangle, Loader2 } from 'lucide-react'; import { RefreshCw, CheckCircle, AlertTriangle, Loader2, Plus, Trash2 } from 'lucide-react';
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup, Select } from './settings-section'; import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup, Select } from './settings-section';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types'; import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities'; import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities';
import { getAiApiKey, setAiApiKey } from '@/lib/ai/key-store'; import { getAiApiKey, setAiApiKey, clearAiApiKey } from '@/lib/ai/key-store';
import { loadAiSettings, saveAiSettings, type AiLocalSettings } from '@/lib/ai/local-settings'; import { loadAiSettings, saveAiSettings, createProfile, type AiLocalSettings } from '@/lib/ai/local-settings';
import { askMail, listLocalModels, testLocalConnection, type AskResult } from '@/lib/ai/local-client'; import {
askMail,
listLocalModels,
listServerModels,
testLocalConnection,
type AskResult,
} from '@/lib/ai/local-client';
const inputClass = const inputClass =
'px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 flex-1 min-w-[220px]'; 'px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 flex-1 min-w-[220px]';
/** /**
* Prototype scope (docs/AI-ASSISTANT-CONCEPT.md, decisions recorded * Decisions recorded 2026-08-05 (see lib/ai/types.ts, lib/ai/entitlement.ts):
* 2026-08-05 evening — see lib/ai/types.ts): `local` (loopback * `local` (loopback Ollama) ships free, no entitlement check. `server`
* Ollama-compatible runtime) ships free with no entitlement check; `public` * (centrally-hosted, proxied through this app's own backend) is real and
* (BYOK, OpenAI-compatible) is available but explicitly unmonitored for now * entitlement-enforced — every call re-checks a licensed seat server-side.
* — no seats, no metering, no server-recorded consent yet. `server` * `public` (BYOK) supports several named provider profiles, picked case by
* (VNC-hosted) isn't wired up here; that infra is landing on the dev k8s * case per question, and is explicitly unmonitored for now.
* cluster separately.
*/ */
export function AiAssistantSettings() { export function AiAssistantSettings() {
const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY); const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY);
@@ -52,16 +57,17 @@ export function AiAssistantSettings() {
}, []); }, []);
const canUseLocal = supportsLocalLlm() && policy.entitlement.classes.includes('local'); const canUseLocal = supportsLocalLlm() && policy.entitlement.classes.includes('local');
const canUseServer = policy.entitlement.classes.includes('server');
const canUsePublic = policy.entitlement.classes.includes('public'); const canUsePublic = policy.entitlement.classes.includes('public');
// ── Local provider ── // ── Local provider ──
const [localModels, setLocalModels] = useState<string[]>([]); const [localModels, setLocalModels] = useState<string[]>([]);
const [refreshing, setRefreshing] = useState(false); const [refreshingLocal, setRefreshingLocal] = useState(false);
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle'); const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
const [testError, setTestError] = useState<string | null>(null); const [testError, setTestError] = useState<string | null>(null);
const refreshModels = useCallback(async () => { const refreshLocalModels = useCallback(async () => {
setRefreshing(true); setRefreshingLocal(true);
try { try {
const models = await listLocalModels(settings.localBaseUrl); const models = await listLocalModels(settings.localBaseUrl);
setLocalModels(models); setLocalModels(models);
@@ -69,7 +75,7 @@ export function AiAssistantSettings() {
} catch { } catch {
setLocalModels([]); setLocalModels([]);
} finally { } finally {
setRefreshing(false); setRefreshingLocal(false);
} }
}, [settings.localBaseUrl, settings.localModel, update]); }, [settings.localBaseUrl, settings.localModel, update]);
@@ -85,20 +91,54 @@ export function AiAssistantSettings() {
} }
}, [settings.localBaseUrl]); }, [settings.localBaseUrl]);
// ── Public provider ── // ── Server provider ──
const [hasSavedKey, setHasSavedKey] = useState(false); const [serverModels, setServerModels] = useState<string[]>([]);
const [apiKeyInput, setApiKeyInput] = useState(''); const [refreshingServer, setRefreshingServer] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const [seatNotice, setSeatNotice] = useState<string | null>(null);
useEffect(() => { const refreshServerModels = useCallback(async () => {
setHasSavedKey(!!getAiApiKey('public')); setRefreshingServer(true);
}, []); setServerError(null);
try {
const models = await listServerModels();
setServerModels(models);
if (!settings.serverModel && models[0]) update('serverModel', models[0]);
} catch (err) {
setServerModels([]);
setServerError(err instanceof Error ? err.message : String(err));
} finally {
setRefreshingServer(false);
}
}, [settings.serverModel, update]);
const saveKey = useCallback(() => { // ── Public provider — several named profiles, one picked per question ──
if (!apiKeyInput) return; const [newProfileName, setNewProfileName] = useState('');
setAiApiKey('public', apiKeyInput); const [newProfileBaseUrl, setNewProfileBaseUrl] = useState('https://openrouter.ai/api/v1');
setHasSavedKey(true); const [newProfileModel, setNewProfileModel] = useState('');
setApiKeyInput(''); const [newProfileKey, setNewProfileKey] = useState('');
}, [apiKeyInput]);
const addProfile = useCallback(() => {
if (!newProfileName || !newProfileBaseUrl || !newProfileModel || !newProfileKey) return;
const profile = createProfile(newProfileName, newProfileBaseUrl, newProfileModel);
setAiApiKey(profile.id, newProfileKey);
update('publicProfiles', [...settings.publicProfiles, profile]);
if (!settings.activeProfileId) update('activeProfileId', profile.id);
setNewProfileName('');
setNewProfileBaseUrl('https://openrouter.ai/api/v1');
setNewProfileModel('');
setNewProfileKey('');
}, [newProfileName, newProfileBaseUrl, newProfileModel, newProfileKey, settings.publicProfiles, settings.activeProfileId, update]);
const removeProfile = useCallback(
(id: string) => {
clearAiApiKey(id);
const remaining = settings.publicProfiles.filter((p) => p.id !== id);
update('publicProfiles', remaining);
if (settings.activeProfileId === id) update('activeProfileId', remaining[0]?.id ?? null);
},
[settings.publicProfiles, settings.activeProfileId, update],
);
// ── Ask ── // ── Ask ──
const [question, setQuestion] = useState(''); const [question, setQuestion] = useState('');
@@ -106,41 +146,50 @@ export function AiAssistantSettings() {
const [askResult, setAskResult] = useState<AskResult | null>(null); const [askResult, setAskResult] = useState<AskResult | null>(null);
const [askError, setAskError] = useState<string | null>(null); const [askError, setAskError] = useState<string | null>(null);
const activeProfile = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
const canAsk = const canAsk =
question.trim().length > 0 && question.trim().length > 0 &&
(settings.provider === 'local' (settings.provider === 'local'
? canUseLocal && !!settings.localModel ? canUseLocal && !!settings.localModel
: settings.provider === 'server'
? canUseServer && !!settings.serverModel
: settings.provider === 'public' : settings.provider === 'public'
? canUsePublic && !!settings.publicModel && settings.publicConsentAccepted && hasSavedKey ? canUsePublic && !!activeProfile && settings.publicConsentAccepted
: false); : false);
const runAsk = useCallback(async () => { const runAsk = useCallback(async () => {
setAsking(true); setAsking(true);
setAskError(null); setAskError(null);
setAskResult(null); setAskResult(null);
setSeatNotice(null);
try { try {
const key = activeProfile ? getAiApiKey(activeProfile.id) : null;
const result = await askMail(question.trim(), { const result = await askMail(question.trim(), {
provider: settings.provider as 'local' | 'public', provider: settings.provider as 'local' | 'server' | 'public',
localBaseUrl: settings.localBaseUrl, localBaseUrl: settings.localBaseUrl,
localModel: settings.localModel, localModel: settings.localModel,
publicBaseUrl: settings.publicBaseUrl, serverModel: settings.serverModel,
publicModel: settings.publicModel, publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null,
publicApiKey: getAiApiKey('public'),
}); });
setAskResult(result); setAskResult(result);
if (result.seatJustAssigned) {
setSeatNotice('A licensed seat on the server-hosted class was just assigned to your account.');
}
} catch (err) { } catch (err) {
setAskError(err instanceof Error ? err.message : String(err)); setAskError(err instanceof Error ? err.message : String(err));
} finally { } finally {
setAsking(false); setAsking(false);
} }
}, [question, settings]); }, [question, settings, activeProfile]);
const providerOptions = useMemo( const providerOptions = useMemo(
() => [ () => [
...(canUseLocal ? [{ value: 'local', label: 'Local (Ollama)' }] : []), ...(canUseLocal ? [{ value: 'local', label: 'Local (Ollama)' }] : []),
...(canUsePublic ? [{ value: 'public', label: 'Public (your API key)' }] : []), ...(canUseServer ? [{ value: 'server', label: 'Server (VNC-hosted)' }] : []),
...(canUsePublic ? [{ value: 'public', label: 'Public (your API keys)' }] : []),
], ],
[canUseLocal, canUsePublic], [canUseLocal, canUseServer, canUsePublic],
); );
if (policyLoading) { if (policyLoading) {
@@ -155,13 +204,13 @@ export function AiAssistantSettings() {
<div className="space-y-6"> <div className="space-y-6">
<SettingsSection <SettingsSection
title="AI Assistant" title="AI Assistant"
description="Ask questions about your synced mail. Local runs entirely on this machine's own model runtime; public sends your question (and any retrieved excerpts) to a provider you choose, using your own API key." description="Ask questions about your synced mail. Local runs entirely on this machine's own model runtime; server is centrally hosted and licensed per seat; public sends your question to a provider you choose, using your own API key."
> >
<SettingItem label="Provider"> <SettingItem label="Provider">
{providerOptions.length > 0 ? ( {providerOptions.length > 0 ? (
<RadioGroup <RadioGroup
value={settings.provider ?? ''} value={settings.provider ?? ''}
onChange={(v) => update('provider', v as 'local' | 'public')} onChange={(v) => update('provider', v as 'local' | 'server' | 'public')}
options={providerOptions} options={providerOptions}
/> />
) : ( ) : (
@@ -199,8 +248,8 @@ export function AiAssistantSettings() {
) : ( ) : (
<span className="text-sm text-muted-foreground">{settings.localModel || 'None selected'}</span> <span className="text-sm text-muted-foreground">{settings.localModel || 'None selected'}</span>
)} )}
<Button variant="outline" size="sm" onClick={refreshModels} disabled={refreshing}> <Button variant="outline" size="sm" onClick={refreshLocalModels} disabled={refreshingLocal}>
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshing ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshingLocal ? 'animate-spin' : ''}`} />
Refresh Refresh
</Button> </Button>
</div> </div>
@@ -226,51 +275,112 @@ export function AiAssistantSettings() {
</SettingsSection> </SettingsSection>
)} )}
{settings.provider === 'server' && canUseServer && (
<SettingsSection
title="Server (VNC-hosted)"
description="Centrally hosted — no setup needed on your side. Licensed per seat; using this for the first time consumes one automatically if seats remain."
>
<SettingItem label="Model" description={serverModels.length === 0 ? 'Refresh to list available models.' : undefined}>
<div className="flex items-center gap-2 flex-wrap">
{serverModels.length > 0 ? (
<Select
value={settings.serverModel ?? ''}
onChange={(v) => update('serverModel', v)}
options={serverModels.map((m) => ({ value: m, label: m }))}
/>
) : (
<span className="text-sm text-muted-foreground">{settings.serverModel || 'None selected'}</span>
)}
<Button variant="outline" size="sm" onClick={refreshServerModels} disabled={refreshingServer}>
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshingServer ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</SettingItem>
{serverError && (
<SettingItem label="Status">
<span className="flex items-center gap-1.5 text-sm text-destructive">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" /> {serverError}
</span>
</SettingItem>
)}
</SettingsSection>
)}
{settings.provider === 'public' && canUsePublic && ( {settings.provider === 'public' && canUsePublic && (
<SettingsSection <SettingsSection
title="Public provider" title="Public providers"
description="Any OpenAI-compatible endpoint. Defaults to OpenRouter. Your key is stored only in this browser, never sent anywhere but the provider below — and, for now, use of this class is not monitored or metered by VNC." description="Save several — different models for different questions. Any OpenAI-compatible endpoint works. Keys are stored only in this browser and, for now, use of this class is not monitored or metered by VNC."
> >
<SettingItem label="Base URL"> {settings.publicProfiles.length > 0 && (
<SettingItem label="Saved profiles">
<div className="flex flex-col gap-2 w-full">
{settings.publicProfiles.map((p) => (
<div key={p.id} className="flex items-center gap-2 rounded-md border border-border px-3 py-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
<p className="text-xs text-muted-foreground truncate">{p.model} · {p.baseUrl}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => removeProfile(p.id)} aria-label={`Remove ${p.name}`}>
<Trash2 className="w-3.5 h-3.5 text-destructive" />
</Button>
</div>
))}
</div>
</SettingItem>
)}
<SettingItem label="Add a provider">
<div className="flex flex-col gap-2 w-full">
<div className="flex gap-2 flex-wrap">
<input <input
type="text" type="text"
value={settings.publicBaseUrl} value={newProfileName}
onChange={(e) => update('publicBaseUrl', e.target.value)} onChange={(e) => setNewProfileName(e.target.value)}
placeholder="Name, e.g. Claude via OpenRouter"
spellCheck={false} spellCheck={false}
className={inputClass} className={inputClass}
/> />
</SettingItem>
<SettingItem label="Model">
<input <input
type="text" type="text"
value={settings.publicModel} value={newProfileModel}
onChange={(e) => update('publicModel', e.target.value)} onChange={(e) => setNewProfileModel(e.target.value)}
placeholder="e.g. anthropic/claude-sonnet-4.5" placeholder="Model, e.g. anthropic/claude-sonnet-4.5"
spellCheck={false}
className={inputClass}
/>
</div>
<div className="flex gap-2 flex-wrap">
<input
type="text"
value={newProfileBaseUrl}
onChange={(e) => setNewProfileBaseUrl(e.target.value)}
placeholder="Base URL"
spellCheck={false} spellCheck={false}
className={inputClass} className={inputClass}
/> />
</SettingItem>
<SettingItem
label="API key"
description={hasSavedKey ? 'A key is saved in this browser. Enter a new one to replace it.' : 'Stored in this browser only.'}
>
<div className="flex items-center gap-2 flex-wrap">
<input <input
type="password" type="password"
value={apiKeyInput} value={newProfileKey}
onChange={(e) => setApiKeyInput(e.target.value)} onChange={(e) => setNewProfileKey(e.target.value)}
placeholder={hasSavedKey ? '•••• saved' : 'sk-...'} placeholder="sk-..."
spellCheck={false} spellCheck={false}
className={inputClass} className={inputClass}
/> />
<Button variant="outline" size="sm" onClick={saveKey} disabled={!apiKeyInput}> <Button
Save variant="outline"
size="sm"
onClick={addProfile}
disabled={!newProfileName || !newProfileBaseUrl || !newProfileModel || !newProfileKey}
>
<Plus className="w-3.5 h-3.5 me-1.5" />
Add
</Button> </Button>
</div> </div>
</div>
</SettingItem> </SettingItem>
<SettingItem <SettingItem
label="I understand this leaves the organisation" label="I understand this leaves the organisation"
description="Your question and any retrieved mail excerpts are sent to the provider above, outside this organisation." description="Your question and any retrieved mail excerpts are sent to the provider you pick below, outside this organisation."
> >
<ToggleSwitch <ToggleSwitch
checked={settings.publicConsentAccepted} checked={settings.publicConsentAccepted}
@@ -283,6 +393,15 @@ export function AiAssistantSettings() {
{settings.provider && ( {settings.provider && (
<SettingsSection title="Try it" description="Ask a question against your synced mail."> <SettingsSection title="Try it" description="Ask a question against your synced mail.">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{settings.provider === 'public' && settings.publicProfiles.length > 0 && (
<SettingItem label="Answer with">
<Select
value={settings.activeProfileId ?? ''}
onChange={(v) => update('activeProfileId', v)}
options={settings.publicProfiles.map((p) => ({ value: p.id, label: p.name }))}
/>
</SettingItem>
)}
<textarea <textarea
value={question} value={question}
onChange={(e) => setQuestion(e.target.value)} onChange={(e) => setQuestion(e.target.value)}
@@ -295,6 +414,13 @@ export function AiAssistantSettings() {
Ask Ask
</Button> </Button>
{seatNotice && (
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/40 p-3">
<CheckCircle className="w-4 h-4 mt-0.5 text-green-600 dark:text-green-500 shrink-0" />
<p className="text-sm text-muted-foreground">{seatNotice}</p>
</div>
)}
{askError && ( {askError && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3"> <div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3">
<AlertTriangle className="w-4 h-4 mt-0.5 text-destructive shrink-0" /> <AlertTriangle className="w-4 h-4 mt-0.5 text-destructive shrink-0" />
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
// Real end-to-end seat assignment against the real Ollama was verified live
// (see the commit this test ships with); this covers the rejection branch,
// which is deterministic and cheaper to prove with a unit test than another
// live round trip.
describe('lib/ai/entitlement', () => {
let stateDir: string;
beforeEach(async () => {
vi.resetModules();
stateDir = await mkdtemp(path.join(tmpdir(), 'ai-entitlement-test-'));
process.env.ADMIN_STATE_DIR = stateDir;
delete process.env.AI_SERVER_SEAT_TOTAL;
// Each test needs a fresh globalThis singleton, not just a fresh module -
// the module stashes cached state on globalThis specifically to survive
// HMR, so resetModules() alone doesn't clear it.
delete (globalThis as Record<symbol, unknown>)[Symbol.for('vncmail.ai.entitlement')];
});
afterEach(async () => {
delete process.env.ADMIN_STATE_DIR;
await rm(stateDir, { recursive: true, force: true });
});
it('assigns a seat on first use and allows the same user again', async () => {
const { checkAndAssignSeat, setSeatTotal } = await import('../entitlement');
await setSeatTotal(1);
const first = await checkAndAssignSeat('alice@example.com');
expect(first).toEqual({ allowed: true, seatJustAssigned: true });
const second = await checkAndAssignSeat('alice@example.com');
expect(second).toEqual({ allowed: true });
});
it('rejects a new user once all seats are assigned', async () => {
const { checkAndAssignSeat, setSeatTotal } = await import('../entitlement');
await setSeatTotal(1);
await checkAndAssignSeat('alice@example.com');
const rejected = await checkAndAssignSeat('bob@example.com');
expect(rejected.allowed).toBe(false);
expect(rejected.reason).toMatch(/already assigned/i);
});
it('rejects everyone when no seats are configured', async () => {
const { checkAndAssignSeat } = await import('../entitlement');
const result = await checkAndAssignSeat('anyone@example.com');
expect(result.allowed).toBe(false);
expect(result.reason).toMatch(/no licensed seats/i);
});
it('revoking a seat frees it for someone else', async () => {
const { checkAndAssignSeat, setSeatTotal, revokeSeat } = await import('../entitlement');
await setSeatTotal(1);
await checkAndAssignSeat('alice@example.com');
await revokeSeat('alice@example.com');
const result = await checkAndAssignSeat('bob@example.com');
expect(result).toEqual({ allowed: true, seatJustAssigned: true });
});
it('persists usage to the metering ledger, append-only', async () => {
const { recordUsage, readMeteringLedger } = await import('../entitlement');
await recordUsage({
timestamp: new Date(0).toISOString(),
username: 'alice@example.com',
model: 'qwen2.5:32b',
promptTokens: 10,
completionTokens: 5,
latencyMs: 123,
});
await recordUsage({
timestamp: new Date(0).toISOString(),
username: 'alice@example.com',
model: 'qwen2.5:32b',
promptTokens: 8,
completionTokens: 3,
latencyMs: 90,
});
const ledger = await readMeteringLedger();
expect(ledger).toHaveLength(2);
expect(ledger[0].promptTokens).toBe(10);
expect(ledger[1].promptTokens).toBe(8);
});
});
+162
View File
@@ -0,0 +1,162 @@
// Real entitlement + metering enforcement for the AI Assistant's `server`
// class (docs/AI-ASSISTANT-CONCEPT.md §9/§10 — per-seat licensing, a
// metering ledger that doubles as the billing record).
//
// Deliberately scoped to `server` only, not `local`/`public`, per the
// 2026-08-05 decisions: `local` ships free (never reaches a server this app
// controls, so it can't be metered — see the doc's own §9 reasoning) and
// `public` is explicitly unmonitored for now. `server` is the one class that
// (a) proxies through this app's own backend (see app/api/ai/server/*) and
// (b) has a real marginal cost (shared GPU time) worth gating — so it is the
// one place enforcement is both possible and worth building tonight.
//
// Persistence follows the existing admin state-dir convention
// (lib/admin/paths.ts): STATE, not CONFIG, because this is runtime-mutated
// data (seat assignments, usage), not operator-authored config.
import { readFile, writeFile, rename, appendFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { getStatePath, ensureStateDir } from '@/lib/admin/paths';
import { logger } from '@/lib/logger';
export interface AiEntitlementState {
subject: 'tenant' | 'user';
tier: 'base' | 'standard' | 'pro';
/** Total seats licensed. 0 = server class entirely unlicensed (default). */
seatsTotal: number;
/** Usernames who have consumed a seat (first successful use assigns one,
* matching real per-seat licensing — not deallocated by idling). */
assignedTo: string[];
}
export interface EntitlementCheck {
allowed: boolean;
reason?: string;
/** True the moment this call consumed a previously-unassigned seat. */
seatJustAssigned?: boolean;
}
export interface MeteringEntry {
timestamp: string;
username: string;
model: string;
/** Ollama reports these as prompt_eval_count / eval_count. */
promptTokens: number;
completionTokens: number;
latencyMs: number;
}
const STATE_FILE = 'ai-entitlement.json';
const LEDGER_FILE = 'ai-metering.jsonl';
const DEFAULT_STATE: AiEntitlementState = {
subject: 'tenant',
tier: 'base',
seatsTotal: Number.parseInt(process.env.AI_SERVER_SEAT_TOTAL ?? '0', 10) || 0,
assignedTo: [],
};
// Stash on globalThis like config-manager.ts — HMR/dev re-evaluates this
// module, and in-memory seat state must survive that or every hot reload
// would silently re-grant seats.
const SINGLETON_KEY = Symbol.for('vncmail.ai.entitlement');
type GlobalWithState = typeof globalThis & { [SINGLETON_KEY]?: Promise<AiEntitlementState> | undefined };
async function readState(): Promise<AiEntitlementState> {
try {
const raw = await readFile(getStatePath(STATE_FILE), 'utf-8');
return { ...DEFAULT_STATE, ...JSON.parse(raw) };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.warn('ai-entitlement: failed to read state, using defaults', {
error: error instanceof Error ? error.message : String(error),
});
}
return { ...DEFAULT_STATE };
}
}
async function writeState(state: AiEntitlementState): Promise<void> {
await ensureStateDir();
const target = getStatePath(STATE_FILE);
const tmp = target + '.tmp';
await writeFile(tmp, JSON.stringify(state, null, 2), 'utf-8');
await rename(tmp, target);
}
let cached: AiEntitlementState | null = null;
async function loadCached(): Promise<AiEntitlementState> {
if (cached) return cached;
const g = globalThis as GlobalWithState;
if (!g[SINGLETON_KEY]) g[SINGLETON_KEY] = readState();
cached = await g[SINGLETON_KEY];
return cached;
}
/**
* The real enforcement point (doc §10 point 2): re-validated on every call,
* never trusts anything the client sent. Auto-assigns a seat on first use
* when seats remain — that's what "per-seat" means for a subject that
* hasn't been explicitly provisioned by an admin yet.
*/
export async function checkAndAssignSeat(username: string): Promise<EntitlementCheck> {
const state = await loadCached();
if (state.assignedTo.includes(username)) {
return { allowed: true };
}
if (state.assignedTo.length >= state.seatsTotal) {
return {
allowed: false,
reason: state.seatsTotal === 0
? 'The server-hosted AI class has no licensed seats configured.'
: `All ${state.seatsTotal} licensed seat(s) are already assigned to other users.`,
};
}
const next: AiEntitlementState = { ...state, assignedTo: [...state.assignedTo, username] };
await writeState(next);
cached = next;
const g = globalThis as GlobalWithState;
g[SINGLETON_KEY] = Promise.resolve(next);
return { allowed: true, seatJustAssigned: true };
}
/** The metering write IS the billing record — see module header. Append-only,
* never rewritten, so it stays valid as an audit trail even if this process
* crashes mid-write (worst case: one truncated trailing line). */
export async function recordUsage(entry: MeteringEntry): Promise<void> {
await ensureStateDir();
await appendFile(getStatePath(LEDGER_FILE), JSON.stringify(entry) + '\n', 'utf-8');
}
export async function getEntitlementState(): Promise<AiEntitlementState> {
return loadCached();
}
export async function setSeatTotal(total: number): Promise<AiEntitlementState> {
const state = await loadCached();
const next: AiEntitlementState = { ...state, seatsTotal: Math.max(0, Math.trunc(total)) };
await writeState(next);
cached = next;
(globalThis as GlobalWithState)[SINGLETON_KEY] = Promise.resolve(next);
return next;
}
export async function revokeSeat(username: string): Promise<AiEntitlementState> {
const state = await loadCached();
const next: AiEntitlementState = { ...state, assignedTo: state.assignedTo.filter((u) => u !== username) };
await writeState(next);
cached = next;
(globalThis as GlobalWithState)[SINGLETON_KEY] = Promise.resolve(next);
return next;
}
/** Read-only summary, no PII beyond usernames already visible to any admin. */
export async function readMeteringLedger(limit = 200): Promise<MeteringEntry[]> {
const path = getStatePath(LEDGER_FILE);
if (!existsSync(path)) return [];
const raw = await readFile(path, 'utf-8');
const lines = raw.trim().split('\n').filter(Boolean);
return lines.slice(-limit).map((line) => JSON.parse(line) as MeteringEntry);
}
+16 -10
View File
@@ -1,26 +1,32 @@
// Client-held storage for the user's own public-provider API key (BYOK). // Client-held storage for the user's own public-provider API keys (BYOK).
// //
// Decision 2026-08-05 (reverses docs/AI-ASSISTANT-CONCEPT.md decision #1's // Decision 2026-08-05 (reverses docs/AI-ASSISTANT-CONCEPT.md decision #1's
// server-side-custody design): the user brings and holds their own key, // server-side-custody design): the user brings and holds their own keys,
// client-side, not VNC. This is the same custody model as // client-side, not VNC. This is the same custody model as
// vncmail-native's lib/ai-key-store.ts (expo-secure-store there; this repo // vncmail-native's lib/ai-key-store.ts (expo-secure-store there; this repo
// has no OS keychain access from a browser tab, so localStorage is the // has no OS keychain access from a browser tab, so localStorage is the
// honest equivalent here — plain, not hidden behind a false sense of // honest equivalent here — plain, not hidden behind a false sense of
// "secure storage"). A fuller Paperclip-style key-management UI (multiple // "secure storage").
// providers, masking, rotation) is good follow-up work, not built tonight. //
// Decision 2026-08-05 (later same night): several keys, not one — a user may
// hold multiple named provider profiles (different models, different
// providers) and pick which one answers a given question. Keys are stored
// separately from `lib/ai/local-settings.ts`'s profile metadata (name, base
// URL, model) so a profile can be exported/shared without its secret, and so
// clearing one key can't accidentally corrupt the profile list.
const KEY_PREFIX = 'vncmail:ai:key:'; const KEY_PREFIX = 'vncmail:ai:key:';
export function getAiApiKey(provider: 'public'): string | null { export function getAiApiKey(profileId: string): string | null {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
return window.localStorage.getItem(KEY_PREFIX + provider); return window.localStorage.getItem(KEY_PREFIX + profileId);
} }
export function setAiApiKey(provider: 'public', key: string): void { export function setAiApiKey(profileId: string, key: string): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
window.localStorage.setItem(KEY_PREFIX + provider, key); window.localStorage.setItem(KEY_PREFIX + profileId, key);
} }
export function clearAiApiKey(provider: 'public'): void { export function clearAiApiKey(profileId: string): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
window.localStorage.removeItem(KEY_PREFIX + provider); window.localStorage.removeItem(KEY_PREFIX + profileId);
} }
+87 -18
View File
@@ -1,11 +1,18 @@
// The AI assistant's wire client mirrors vncmail-native's src/api/ai.ts // The AI assistant's wire client. `local`/`public` mirror vncmail-native's
// (same prototype scope: local Ollama + BYOK public, no VNC-hosted `server` // src/api/ai.ts (direct loopback/provider fetch, no streaming) so the two
// class, no streaming) so the two clients stay in lockstep. Runs entirely // clients stay in lockstep — matching docs/AI-ASSISTANT-CONCEPT.md §2's
// client-side (`'use client'` callers only) — a direct loopback/provider // "local"/"public" rows, not proxied through this app's own Next.js server.
// fetch, matching docs/AI-ASSISTANT-CONCEPT.md §2's "local"/"public" rows, // That distinction matters once this app is hosted remotely: a server-side
// not proxied through this app's own Next.js server. That distinction // proxy would reach the *server's* loopback, not the user's own laptop
// matters once this app is hosted remotely: a server-side proxy would reach // running Ollama.
// the *server's* loopback, not the user's own laptop running Ollama. //
// `server` (added 2026-08-05 night) is the opposite by design: it DOES
// proxy through this app's own backend (app/api/ai/server/*), because it's
// centrally-hosted infra (VNC's EU/CH stack — standing in tonight for a real
// Ollama on this Mac, see lib/ai/entitlement.ts), not a user's own machine.
// That server-side hop is also the one real entitlement enforcement point
// (§10 point 2) — `local`/`public` never reach it, by design, and so cannot
// be metered or billed the same way.
export interface ChatMessage { export interface ChatMessage {
role: 'system' | 'user' | 'assistant'; role: 'system' | 'user' | 'assistant';
@@ -74,6 +81,42 @@ export async function chatLocal(
return content; return content;
} }
// ── Server: centrally-hosted, proxied through this app's own backend
// (app/api/ai/server/*). Unlike `local`, this is same-origin from the
// browser's perspective — no CORS/OLLAMA_ORIGINS story at all — and unlike
// both `local` and `public`, every call is entitlement-checked server-side. ──
export async function listServerModels(): Promise<string[]> {
const res = await fetch('/api/ai/server/models');
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error ?? `AI server returned ${res.status}`);
}
const body = (await res.json()) as { models?: string[] };
return body.models ?? [];
}
export interface ServerChatResult {
answer: string;
/** True the moment this call consumed a previously-unassigned licensed seat
* (lib/ai/entitlement.ts) — surfaced so the UI can say so once, not left
* to happen silently the first time someone uses this class. */
seatJustAssigned: boolean;
}
export async function chatServer(model: string, messages: ChatMessage[]): Promise<ServerChatResult> {
const res = await fetch('/api/ai/server/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages }),
});
const body = (await res.json().catch(() => null)) as { answer?: string; error?: string; seatJustAssigned?: boolean } | null;
if (!res.ok || !body?.answer) {
throw new Error(body?.error ?? `AI server returned ${res.status}`);
}
return { answer: body.answer, seatJustAssigned: body.seatJustAssigned === true };
}
// ── Public: OpenAI-compatible chat-completions. OpenRouter by default, but any // ── Public: OpenAI-compatible chat-completions. OpenRouter by default, but any
// endpoint speaking this shape works unmodified (self-hosted vLLM, LiteLLM, etc). ── // endpoint speaking this shape works unmodified (self-hosted vLLM, LiteLLM, etc). ──
@@ -118,6 +161,10 @@ export interface AskResult {
sources: AskSource[]; sources: AskSource[];
/** True when the question was answered without any retrieved context. */ /** True when the question was answered without any retrieved context. */
unaugmented: boolean; unaugmented: boolean;
/** True the moment this call consumed a previously-unassigned licensed
* seat on the `server` class (lib/ai/entitlement.ts). Always false for
* `local`/`public`, which aren't entitlement-gated. */
seatJustAssigned: boolean;
} }
interface OfflineSearchHit { interface OfflineSearchHit {
@@ -152,21 +199,34 @@ export function buildPrompt(question: string, contextBlock: string): ChatMessage
]; ];
} }
/**
* One saved BYOK profile, resolved to an actual key — the caller picks which
* profile answers *this* question (docs decision 2026-08-05: several keys,
* selected case by case, not one fixed "the" public provider).
*/
export interface ResolvedPublicProfile {
baseUrl: string;
model: string;
apiKey: string;
}
export interface AskConfig { export interface AskConfig {
provider: 'local' | 'public'; provider: 'local' | 'server' | 'public';
localBaseUrl: string; localBaseUrl: string;
localModel: string | null; localModel: string | null;
publicBaseUrl: string; serverModel: string | null;
publicModel: string; publicProfile: ResolvedPublicProfile | null;
publicApiKey: string | null;
} }
export async function askMail(question: string, config: AskConfig): Promise<AskResult> { export async function askMail(question: string, config: AskConfig): Promise<AskResult> {
if (config.provider === 'local' && !config.localModel) { if (config.provider === 'local' && !config.localModel) {
throw new Error('No local model selected'); throw new Error('No local model selected');
} }
if (config.provider === 'public' && !config.publicApiKey) { if (config.provider === 'server' && !config.serverModel) {
throw new Error('No public API key saved'); throw new Error('No server model selected');
}
if (config.provider === 'public' && !config.publicProfile) {
throw new Error('No provider profile selected');
} }
const retrieved = await retrieveContext(question); const retrieved = await retrieveContext(question);
@@ -174,14 +234,23 @@ export async function askMail(question: string, config: AskConfig): Promise<AskR
? buildPrompt(question, retrieved.contextBlock) ? buildPrompt(question, retrieved.contextBlock)
: [{ role: 'user' as const, content: question }]; : [{ role: 'user' as const, content: question }];
const answer = let answer: string;
config.provider === 'public' let seatJustAssigned = false;
? await chatPublic(config.publicBaseUrl, config.publicApiKey as string, config.publicModel, messages) if (config.provider === 'public') {
: await chatLocal(config.localBaseUrl, config.localModel as string, messages); const profile = config.publicProfile as ResolvedPublicProfile;
answer = await chatPublic(profile.baseUrl, profile.apiKey, profile.model, messages);
} else if (config.provider === 'server') {
const result = await chatServer(config.serverModel as string, messages);
answer = result.answer;
seatJustAssigned = result.seatJustAssigned;
} else {
answer = await chatLocal(config.localBaseUrl, config.localModel as string, messages);
}
return { return {
answer, answer,
sources: (retrieved?.hits ?? []).map((h) => ({ id: h.id, subject: h.title })), sources: (retrieved?.hits ?? []).map((h) => ({ id: h.id, subject: h.title })),
unaugmented: !retrieved, unaugmented: !retrieved,
seatJustAssigned,
}; };
} }
+48 -6
View File
@@ -4,14 +4,31 @@
// (docs/AI-ASSISTANT-CONCEPT.md §12's P0/P5), and migrating it into the // (docs/AI-ASSISTANT-CONCEPT.md §12's P0/P5), and migrating it into the
// shared store belongs with whichever phase makes these settings real // shared store belongs with whichever phase makes these settings real
// product config rather than a local-AI test harness. // product config rather than a local-AI test harness.
export type AiProvider = 'local' | 'public'; export type AiProvider = 'local' | 'server' | 'public';
/**
* A named public-provider configuration (BYOK). Decision 2026-08-05: several
* of these, not one — different models/providers for different questions,
* picked case by case at Ask time (see `activeProfileId`). The API key
* itself lives in `lib/ai/key-store.ts`, keyed by `id`, not here — so a
* profile's metadata can be listed/edited without ever handling the secret.
*/
export interface AiProviderProfile {
id: string;
name: string;
baseUrl: string;
model: string;
}
export interface AiLocalSettings { export interface AiLocalSettings {
provider: AiProvider | null; provider: AiProvider | null;
localBaseUrl: string; localBaseUrl: string;
localModel: string | null; localModel: string | null;
publicBaseUrl: string; serverModel: string | null;
publicModel: string; publicProfiles: AiProviderProfile[];
/** Which saved profile answers the next question. Not a permanent default —
* the "Try it" UI lets this be changed per question. */
activeProfileId: string | null;
publicConsentAccepted: boolean; publicConsentAccepted: boolean;
} }
@@ -21,17 +38,38 @@ export const DEFAULT_AI_SETTINGS: AiLocalSettings = {
provider: null, provider: null,
localBaseUrl: 'http://127.0.0.1:11434', localBaseUrl: 'http://127.0.0.1:11434',
localModel: null, localModel: null,
publicBaseUrl: 'https://openrouter.ai/api/v1', serverModel: null,
publicModel: '', publicProfiles: [],
activeProfileId: null,
publicConsentAccepted: false, publicConsentAccepted: false,
}; };
function newProfileId(): string {
return `profile-${Math.random().toString(36).slice(2, 10)}-${Math.random().toString(36).slice(2, 10)}`;
}
/** One-time upgrade from the earlier single-profile shape (a bare
* publicBaseUrl/publicModel pair) into the profile list, so a browser that
* already saved settings before profiles existed doesn't just lose them. */
function migrate(raw: Record<string, unknown>): Partial<AiLocalSettings> {
if (Array.isArray(raw.publicProfiles)) return raw as Partial<AiLocalSettings>;
if (typeof raw.publicBaseUrl === 'string' && typeof raw.publicModel === 'string' && raw.publicModel) {
const id = newProfileId();
return {
...raw,
publicProfiles: [{ id, name: 'Default', baseUrl: raw.publicBaseUrl, model: raw.publicModel }],
activeProfileId: id,
};
}
return raw as Partial<AiLocalSettings>;
}
export function loadAiSettings(): AiLocalSettings { export function loadAiSettings(): AiLocalSettings {
if (typeof window === 'undefined') return { ...DEFAULT_AI_SETTINGS }; if (typeof window === 'undefined') return { ...DEFAULT_AI_SETTINGS };
try { try {
const raw = window.localStorage.getItem(STORAGE_KEY); const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_AI_SETTINGS }; if (!raw) return { ...DEFAULT_AI_SETTINGS };
return { ...DEFAULT_AI_SETTINGS, ...JSON.parse(raw) }; return { ...DEFAULT_AI_SETTINGS, ...migrate(JSON.parse(raw)) };
} catch { } catch {
return { ...DEFAULT_AI_SETTINGS }; return { ...DEFAULT_AI_SETTINGS };
} }
@@ -41,3 +79,7 @@ export function saveAiSettings(settings: AiLocalSettings): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} }
export function createProfile(name: string, baseUrl: string, model: string): AiProviderProfile {
return { id: newProfileId(), name, baseUrl, model };
}
+7 -3
View File
@@ -12,9 +12,13 @@
// not built yet). The client-side "this leaves the organisation" // not built yet). The client-side "this leaves the organisation"
// acknowledgement still shows (cheap, honest), it just isn't // acknowledgement still shows (cheap, honest), it just isn't
// server-enforced yet. // server-enforced yet.
// - `server` (VNC-hosted, EU/CH) isn't wired up client-side yet — infra is // - `server` (VNC-hosted, EU/CH) is now wired up for real too (added later
// "this MacBook tonight, the dev k8s cluster tomorrow" per that // the same night, per "do it this night - no stop"): a real server-side
// decision, sequenced after `local` rather than before it. // proxy (app/api/ai/server/*) to AI_SERVER_BASE_URL, which stands in for
// the dev-k8s-hosted instance until that exists tomorrow. Unlike
// `local`/`public`, `server` IS entitlement-enforced for real —
// lib/ai/entitlement.ts — since it's the one class with a real,
// centrally-borne cost.
export type AiClass = 'local' | 'server' | 'public'; export type AiClass = 'local' | 'server' | 'public';