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:
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user