diff --git a/app/(main)/admin/_tabs/ai-policy.tsx b/app/(main)/admin/_tabs/ai-policy.tsx index a0f21b4d..d0b07220 100644 --- a/app/(main)/admin/_tabs/ai-policy.tsx +++ b/app/(main)/admin/_tabs/ai-policy.tsx @@ -1,8 +1,8 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Save, Loader2, X, ArrowRight } from 'lucide-react'; -import type { AiConsoleConfig, AiClass } from '@/lib/ai/types'; +import { Save, Loader2, X, ArrowRight, Plus, Trash2 } from 'lucide-react'; +import type { AiConsoleConfig, AiClass, PublicAiPreset } from '@/lib/ai/types'; import { DEFAULT_AI_CONSOLE_CONFIG } from '@/lib/ai/types'; import type { AiEntitlementState, MeteringEntry } from '@/lib/ai/entitlement'; import { apiFetch } from '@/lib/browser-navigation'; @@ -73,6 +73,85 @@ function AllowlistEditor({ ); } +function newPresetId(): string { + return `preset-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * The Paperclip-style env-var-key picker (decision 2026-08-07): an admin + * names a preset and an env var; the actual secret value is never entered + * here — it's whatever ops has set in the server's real environment. This is + * what lets a user in Settings pick a provider from a dropdown instead of + * pasting a key. + */ +function PublicPresetsEditor({ + presets, onChange, +}: { presets: PublicAiPreset[]; onChange: (next: PublicAiPreset[]) => void }) { + const [name, setName] = useState(''); + const [baseUrl, setBaseUrl] = useState('https://api.deepseek.com'); + const [model, setModel] = useState(''); + const [envVar, setEnvVar] = useState(''); + + const canAdd = name.trim() && baseUrl.trim() && model.trim() && envVar.trim(); + + function addPreset() { + if (!canAdd) return; + onChange([...presets, { id: newPresetId(), name: name.trim(), baseUrl: baseUrl.trim(), model: model.trim(), apiKeyEnvVar: envVar.trim() }]); + setName(''); + setBaseUrl('https://api.deepseek.com'); + setModel(''); + setEnvVar(''); + } + + return ( + <> + {presets.length > 0 && ( +
+ {presets.map((p) => ( +
+
+ {p.name} +

+ {p.model} · {p.baseUrl} · reads {p.apiKeyEnvVar} +

+
+ +
+ ))} +
+ )} +
+
+ setName(e.target.value)} placeholder="Name, e.g. DeepSeek (org)" + className="flex-1 min-w-[160px] h-8 rounded border border-input bg-background px-2.5 text-xs" /> + setModel(e.target.value)} placeholder="Model, e.g. deepseek-chat" + className="flex-1 min-w-[160px] h-8 rounded border border-input bg-background px-2.5 text-xs" /> +
+
+ setBaseUrl(e.target.value)} placeholder="API base URL" + className="flex-1 min-w-[200px] h-8 rounded border border-input bg-background px-2.5 text-xs" /> + setEnvVar(e.target.value)} placeholder="Env var, e.g. DEEPSEEK_API_KEY" + className="flex-1 min-w-[200px] h-8 rounded border border-input bg-background px-2.5 text-xs" /> + +
+

+ Only the env var name is stored here — provision the actual key as a real environment variable on + the server (k8s secret, .env, Electron packaging). This app never sees or stores the value. +

+
+ + ); +} + export function AiPolicyTab() { const setActiveTab = useAdminTabStore((s) => s.setActiveTab); const [config, setConfig] = useState({ ...DEFAULT_AI_CONSOLE_CONFIG }); @@ -247,6 +326,17 @@ export function AiPolicyTab() { /> +
+
+

Public — org-managed presets

+

+ Paperclip-style: publish a provider by name instead of making every user paste their own key. Users pick + one of these in Settings with no key field at all — the server resolves the named env var at request time. +

+
+ update({ publicPresets: v })} /> +
+

Entitlement & seats

diff --git a/app/api/admin/ai/policy/route.ts b/app/api/admin/ai/policy/route.ts index 062822e0..1e14ed1f 100644 --- a/app/api/admin/ai/policy/route.ts +++ b/app/api/admin/ai/policy/route.ts @@ -48,6 +48,24 @@ function validate(body: Partial): string | null { return 'publicProviderAllowlist must be an array of strings or null'; } } + if (body.publicPresets !== undefined) { + if (!Array.isArray(body.publicPresets)) return 'publicPresets must be an array'; + const ids = new Set(); + for (const preset of body.publicPresets) { + if ( + typeof preset !== 'object' || preset === null || + typeof preset.id !== 'string' || !preset.id || + typeof preset.name !== 'string' || !preset.name || + typeof preset.baseUrl !== 'string' || !preset.baseUrl || + typeof preset.model !== 'string' || !preset.model || + typeof preset.apiKeyEnvVar !== 'string' || !preset.apiKeyEnvVar + ) { + return 'each publicPresets entry needs non-empty id, name, baseUrl, model, apiKeyEnvVar'; + } + if (ids.has(preset.id)) return `duplicate publicPresets id "${preset.id}"`; + ids.add(preset.id); + } + } if (body.retrievalEnabled !== undefined && typeof body.retrievalEnabled !== 'boolean') { return 'retrievalEnabled must be a boolean'; } @@ -83,6 +101,7 @@ export async function PUT(request: NextRequest) { consentVersion: next.consent?.version ?? null, serverModelAllowlistCount: next.serverModelAllowlist?.length ?? null, publicProviderAllowlistCount: next.publicProviderAllowlist?.length ?? null, + publicPresetsCount: next.publicPresets.length, }, ip); return NextResponse.json(next); } catch (error) { diff --git a/app/api/ai/policy/route.ts b/app/api/ai/policy/route.ts index bb17ab3c..d326a430 100644 --- a/app/api/ai/policy/route.ts +++ b/app/api/ai/policy/route.ts @@ -43,6 +43,11 @@ export async function GET() { retrievalEnabled: consoleConfig.retrievalEnabled, consent: consoleConfig.consent, publicProviderAllowlist: consoleConfig.publicProviderAllowlist, + // Sanitized: {id,name,model} only. baseUrl/apiKeyEnvVar stay server-side — + // the client only ever refers to a preset by id (app/api/ai/public/chat + // resolves the rest), so there's no reason to hand a browser tab even + // an internal env var *name*, let alone a provider base URL. + publicPresets: consoleConfig.publicPresets.map((p) => ({ id: p.id, name: p.name, model: p.model })), }; return NextResponse.json(aiPolicy, { diff --git a/app/api/ai/public/chat/route.ts b/app/api/ai/public/chat/route.ts new file mode 100644 index 00000000..70838f55 --- /dev/null +++ b/app/api/ai/public/chat/route.ts @@ -0,0 +1,97 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { configManager } from '@/lib/admin/config-manager'; +import { logger } from '@/lib/logger'; + +export const runtime = 'nodejs'; + +const MAX_BODY_BYTES = 200 * 1024; + +interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +interface OpenAiChatResponse { + choices?: Array<{ message?: { content?: string } }>; +} + +/** + * POST /api/ai/public/chat — the Paperclip-style, admin-managed alternative + * to the personal-key `chatPublic` path (lib/ai/local-client.ts): the client + * sends a `presetId`, never a key. The preset (name/baseUrl/model/ + * apiKeyEnvVar) lives in admin config (lib/ai/types.ts's PublicAiPreset); + * the actual secret value is read from THIS PROCESS's real environment at + * request time and never leaves this route — same custody model as + * AI_SERVER_BASE_URL, just admin-nameable per preset instead of one fixed var. + * + * Deliberately NOT entitlement-metered, same reasoning as `local`/`opencode` + * (lib/ai/entitlement.ts's header): this is still the `public` class, just + * with the org supplying the key instead of the user — no centrally-borne + * inference cost this app is billing for. + */ +export async function POST(request: NextRequest) { + const auth = await getStalwartCredentials(request); + if (!auth) { + return NextResponse.json({ error: 'not authenticated' }, { status: 401 }); + } + + await configManager.ensureLoaded(); + const consoleConfig = configManager.getAiConsoleConfig(); + if (consoleConfig.classesEnabled.public === false) { + return NextResponse.json({ error: 'the Public AI class is disabled by admin policy' }, { status: 403 }); + } + + const rawBody = await request.text(); + if (rawBody.length > MAX_BODY_BYTES) { + return NextResponse.json({ error: 'request too large' }, { status: 413 }); + } + + let body: { presetId?: unknown; messages?: unknown }; + try { + body = JSON.parse(rawBody); + } catch { + return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 }); + } + + const presetId = typeof body.presetId === 'string' ? body.presetId : ''; + const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null; + if (!presetId || !messages || messages.length === 0) { + return NextResponse.json({ error: 'presetId and messages are required' }, { status: 400 }); + } + + const preset = consoleConfig.publicPresets.find((p) => p.id === presetId); + if (!preset) { + return NextResponse.json({ error: `No such preset "${presetId}" — it may have been removed by an admin.` }, { status: 404 }); + } + + const apiKey = process.env[preset.apiKeyEnvVar]; + if (!apiKey) { + return NextResponse.json( + { error: `Env var "${preset.apiKeyEnvVar}" is not set on the server for preset "${preset.name}" — ask an admin to provision it.` }, + { status: 503 }, + ); + } + + try { + const res = await fetch(`${preset.baseUrl.replace(/\/+$/, '')}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ model: preset.model, messages }), + }); + if (!res.ok) { + return NextResponse.json({ error: `Provider returned ${res.status}` }, { status: 502 }); + } + const data = (await res.json()) as OpenAiChatResponse; + const content = data.choices?.[0]?.message?.content; + if (!content) { + return NextResponse.json({ error: 'Provider returned no message content' }, { status: 502 }); + } + return NextResponse.json({ answer: content }); + } catch (cause) { + logger.error('public ai preset chat failed', { + presetId, error: cause instanceof Error ? cause.message : String(cause), + }); + return NextResponse.json({ error: `Could not reach ${preset.baseUrl}` }, { status: 502 }); + } +} diff --git a/components/ai/ai-ask-button.tsx b/components/ai/ai-ask-button.tsx index a40b65df..5cbf2cdd 100644 --- a/components/ai/ai-ask-button.tsx +++ b/components/ai/ai-ask-button.tsx @@ -22,8 +22,9 @@ import { useAccountStore } from '@/stores/account-store'; import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types'; import { supportsLocalLlm } from '@/lib/platform-capabilities'; import { getAiApiKey } from '@/lib/ai/key-store'; -import { loadAiSettings, type AiLocalSettings } from '@/lib/ai/local-settings'; +import { loadAiSettings, isPresetActiveId, presetIdFromActiveId, type AiLocalSettings } from '@/lib/ai/local-settings'; import { askMail, type AskResult } from '@/lib/ai/local-client'; +import { ensureDefaultProvider } from '@/lib/ai/auto-provision'; function useAiPolicy(): { policy: AiPolicy; loaded: boolean } { const [policy, setPolicy] = useState(DEFAULT_AI_POLICY); @@ -58,6 +59,9 @@ function providerConfigured(settings: AiLocalSettings, policy: AiPolicy): boolea case 'opencode': return classes.includes('opencode') && !!settings.opencodeModel; case 'public': { + if (isPresetActiveId(settings.activeProfileId)) { + return classes.includes('public') && !!presetIdFromActiveId(settings.activeProfileId) && settings.publicConsentAccepted; + } const active = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null; return classes.includes('public') && !!active && settings.publicConsentAccepted; } @@ -90,6 +94,18 @@ export function AiAskButton() { setOpen(true); }, []); + // Zero-config default (see lib/ai/auto-provision.ts): resolves as soon as + // policy loads, so a user who never visits Settings still finds AI + // already on the first time they open this dialog, if OpenCode or Ollama + // is available. + useEffect(() => { + if (!loaded) return; + (async () => { + const next = await ensureDefaultProvider(policy); + setSettings(next); + })(); + }, [loaded, policy]); + useEffect(() => { if (!open) return; textareaRef.current?.focus(); @@ -109,7 +125,8 @@ export function AiAskButton() { setAskError(null); setAskResult(null); try { - const activeProfile = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null; + const managedPresetId = presetIdFromActiveId(settings.activeProfileId); + const activeProfile = managedPresetId ? null : settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null; const key = activeProfile ? getAiApiKey(activeProfile.id) : null; const result = await askMail(question.trim(), { provider: settings.provider as 'local' | 'server' | 'public' | 'opencode', @@ -119,6 +136,7 @@ export function AiAskButton() { opencodeModel: settings.opencodeModel, slot: activeSlot, publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null, + publicPresetId: managedPresetId, }); setAskResult(result); } catch (err) { diff --git a/components/settings/ai-assistant-settings.tsx b/components/settings/ai-assistant-settings.tsx index d5fc3c8d..dff2d171 100644 --- a/components/settings/ai-assistant-settings.tsx +++ b/components/settings/ai-assistant-settings.tsx @@ -9,7 +9,11 @@ import { useAccountStore } from '@/stores/account-store'; import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types'; import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities'; import { getAiApiKey, setAiApiKey, clearAiApiKey } from '@/lib/ai/key-store'; -import { loadAiSettings, saveAiSettings, createProfile, type AiLocalSettings } from '@/lib/ai/local-settings'; +import { + loadAiSettings, saveAiSettings, createProfile, presetActiveId, presetIdFromActiveId, + type AiLocalSettings, +} from '@/lib/ai/local-settings'; +import { ensureDefaultProvider } from '@/lib/ai/auto-provision'; import { discoverLocalOllama, recommendDefaultModel, @@ -56,7 +60,23 @@ export function AiAssistantSettings() { (async () => { try { const res = await apiFetch('/api/ai/policy'); - if (res.ok && !cancelled) setPolicy(await res.json()); + if (res.ok && !cancelled) { + const loadedPolicy = (await res.json()) as AiPolicy; + setPolicy(loadedPolicy); + // Zero-config default (lib/ai/auto-provision.ts) — a no-op once a + // provider is already chosen, so this is safe to run on every + // visit to this pane, not just first-run. + const next = await ensureDefaultProvider(loadedPolicy); + // Separately, once an admin has published at least one org-managed + // preset, make IT the default "Answer with" pick too — pasting a + // personal key should be the fallback a user reaches for, not the + // thing they have to do to get any answer at all. + if (!next.activeProfileId && loadedPolicy.publicPresets[0]) { + next.activeProfileId = presetActiveId(loadedPolicy.publicPresets[0].id); + saveAiSettings(next); + } + if (!cancelled) setSettings(next); + } } finally { if (!cancelled) setPolicyLoading(false); } @@ -289,7 +309,9 @@ export function AiAssistantSettings() { const [askResult, setAskResult] = useState(null); const [askError, setAskError] = useState(null); - const activeProfile = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null; + const activePresetId = presetIdFromActiveId(settings.activeProfileId); + const activeProfile = activePresetId ? null : settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null; + const activePublicSelection = !!activeProfile || (!!activePresetId && policy.publicPresets.some((p) => p.id === activePresetId)); const canAsk = question.trim().length > 0 && @@ -300,7 +322,7 @@ export function AiAssistantSettings() { : settings.provider === 'opencode' ? canUseOpencode && !!settings.opencodeModel : settings.provider === 'public' - ? canUsePublic && !!activeProfile && settings.publicConsentAccepted + ? canUsePublic && activePublicSelection && settings.publicConsentAccepted : false); const runAsk = useCallback(async () => { @@ -318,6 +340,7 @@ export function AiAssistantSettings() { opencodeModel: settings.opencodeModel, slot: activeSlot, publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null, + publicPresetId: activePresetId, }); setAskResult(result); if (result.seatJustAssigned) { @@ -328,7 +351,7 @@ export function AiAssistantSettings() { } finally { setAsking(false); } - }, [question, settings, activeProfile, activeSlot]); + }, [question, settings, activeProfile, activePresetId, activeSlot]); const providerOptions = useMemo( () => [ @@ -622,8 +645,25 @@ export function AiAssistantSettings() { title="Public providers" 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." > + {policy.publicPresets.length > 0 && ( + +
+ {policy.publicPresets.map((p) => ( +
+
+

{p.name}

+

{p.model} · managed by admin

+
+
+ ))} +
+
+ )} {settings.publicProfiles.length > 0 && ( - +
{settings.publicProfiles.map((p) => (
@@ -639,7 +679,7 @@ export function AiAssistantSettings() {
)} - +
- {settings.provider === 'public' && settings.publicProfiles.length > 0 && ( + {settings.provider === 'public' && (settings.publicProfiles.length > 0 || policy.publicPresets.length > 0) && (