New admin tab "AI" (app/(main)/admin/_tabs/ai-policy.tsx): provider-class toggles, server model allow-list, BYOK provider allow-list, seats/usage (front-end for the already-real lib/ai/entitlement.ts), retrieval on/off, consent text + version bump. Real backend, not cosmetic: AiConsoleConfig persisted via config-manager (lib/ai/types.ts, ai-policy.json in the CONFIG dir). New GET/PUT /api/admin/ai/policy. Enforcement wired at every real chokepoint, not just the picker: /api/ai/server/chat checks classesEnabled.server and the model allow-list, /api/ai/retrieve checks retrievalEnabled, /api/ai/server/models filters by allow-list. GET /api/ai/policy folds classesEnabled into the classes list clients see. Resolved the spec's 3 open questions as recommended: BYOK allow-list stays client-side/advisory (wired into ai-assistant-settings.tsx's addProfile), tier picker stays cosmetic, master aiAssistantEnabled toggle stays in the existing Policy tab (this tab links to it instead of duplicating it). Defaults preserve today's behavior exactly (classesEnabled/allowlists all start empty/null) — turning this on changes nothing until an admin touches it.
77 lines
3.1 KiB
TypeScript
77 lines
3.1 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
import { serverSearchMail, hydrateMailRefs } from '@/lib/ai/retrieval/mail-embeddings';
|
|
import { configManager } from '@/lib/admin/config-manager';
|
|
import { logger } from '@/lib/logger';
|
|
|
|
export const runtime = 'nodejs';
|
|
|
|
const MAX_QUERY_CHARS = 512;
|
|
const DEFAULT_LIMIT = 6;
|
|
|
|
/**
|
|
* POST /api/ai/retrieve — the server embedding leg (docs/AI-ASSISTANT-CONCEPT.md
|
|
* §7 step 2). Real JMAP fetch + real Ollama embeddings + real cosine ranking
|
|
* (lib/ai/retrieval/mail-embeddings.ts), not a mock.
|
|
*
|
|
* ACL note (§7 step 2b): this only ever embeds/searches the *authenticated
|
|
* session's own* JMAP account — there is no shared-mailbox fan-out to
|
|
* pre-filter yet, since group accounts are still deferred entirely (matches
|
|
* the doc's own "shared-mailbox retrieval ships server-only" decision, which
|
|
* itself hasn't been reached because there's no group account to retrieve
|
|
* from). Nothing here can leak across accounts because nothing crosses the
|
|
* account boundary in the first place.
|
|
*/
|
|
export async function POST(request: NextRequest) {
|
|
const auth = await getStalwartCredentials(request);
|
|
if (!auth) {
|
|
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
|
}
|
|
|
|
if (!process.env.AI_SERVER_BASE_URL) {
|
|
return new NextResponse(null, { status: 404 });
|
|
}
|
|
|
|
// Real enforcement, not cosmetic client hiding (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md
|
|
// §6): an admin can disable mail-content-to-embeddings augmentation
|
|
// independent of disabling the `server` chat class outright.
|
|
await configManager.ensureLoaded();
|
|
if (!configManager.getAiConsoleConfig().retrievalEnabled) {
|
|
return NextResponse.json({ error: 'retrieval is disabled by admin policy' }, { status: 403 });
|
|
}
|
|
|
|
let body: { query?: unknown; limit?: unknown };
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
|
}
|
|
|
|
const query = typeof body.query === 'string' ? body.query.trim() : '';
|
|
if (!query) {
|
|
return NextResponse.json({ error: 'query is required' }, { status: 400 });
|
|
}
|
|
if (query.length > MAX_QUERY_CHARS) {
|
|
return NextResponse.json({ error: 'query too long' }, { status: 400 });
|
|
}
|
|
const limit = typeof body.limit === 'number' ? Math.min(Math.max(Math.trunc(body.limit), 1), 20) : DEFAULT_LIMIT;
|
|
|
|
try {
|
|
const scored = await serverSearchMail(auth.serverUrl, auth.authHeader, query, limit);
|
|
const chunks = await hydrateMailRefs(auth.serverUrl, auth.authHeader, scored.map((s) => s.ref));
|
|
|
|
const contextBlock = chunks
|
|
.map((c, i) => `[${i + 1}] Subject: ${c.title}\n${c.text}`)
|
|
.join('\n\n');
|
|
|
|
return NextResponse.json({
|
|
ok: true,
|
|
hits: chunks.map((c, i) => ({ ref: c.ref, title: c.title, snippet: c.text.slice(0, 200), rank: i + 1 })),
|
|
contextBlock,
|
|
}, { headers: { 'Cache-Control': 'no-store' } });
|
|
} catch (cause) {
|
|
logger.error('ai retrieve failed', { error: cause instanceof Error ? cause.message : String(cause) });
|
|
return NextResponse.json({ error: 'retrieval unavailable' }, { status: 502 });
|
|
}
|
|
}
|