From fbfaf528ab623f7d7f5622696a1539eddbd029f9 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Fri, 7 Aug 2026 12:50:33 +0200 Subject: [PATCH 1/6] fix(build): copy next/dist/lib/metadata into standalone output output: "standalone" + next build --webpack drops the whole metadata directory despite a plain top-level require in router-utils/filesystem.js ("../../../lib/metadata/get-metadata-route") - every packaged build (Electron dmg and the Docker image) crashed on its very first line with Cannot find module. Verified: a fresh dist:mac build failed to boot at all; copying the directory by hand (same pattern already used for the sqlcipher prebuilds and plugin bundles) fixes it, confirmed by booting .next/standalone directly and getting a real HTTP response. Co-Authored-By: Claude Sonnet 5 --- Dockerfile | 7 +++++++ scripts/assemble-standalone.mjs | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/Dockerfile b/Dockerfile index c5bfa163..6fb18bec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,13 @@ RUN apk upgrade --no-cache && \ COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +# next/dist/lib/metadata/** (get-metadata-route.js and its neighbours). A +# plain top-level require in router-utils/filesystem.js, yet Next's own +# output file tracing for `output: "standalone"` + `next build --webpack` +# drops the whole directory - the server crashes on its first line with +# "Cannot find module '../../../lib/metadata/get-metadata-route'" without +# this. Same tracing-gap class as the plugins copy below. +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/next/dist/lib/metadata ./node_modules/next/dist/lib/metadata # Staged first-party plugin bundles. Read by path at runtime, so Next's output # file tracing does not carry them into .next/standalone - copy explicitly or # the image boots with the S/MIME policy toggle on and no plugin installed. diff --git a/scripts/assemble-standalone.mjs b/scripts/assemble-standalone.mjs index adb05fb2..bc238dc3 100644 --- a/scripts/assemble-standalone.mjs +++ b/scripts/assemble-standalone.mjs @@ -81,4 +81,20 @@ if (existsSync(pluginsSrc)) { ); } +// next/dist/lib/metadata/** (get-metadata-route.js and its neighbours). +// +// router-utils/filesystem.js has a plain top-level `require("../../../lib/ +// metadata/get-metadata-route")` - not dynamic, not conditional - yet Next's +// own output-file-tracing for `output: "standalone"` + `next build --webpack` +// drops the entire directory. Verified by inspecting a real build: the +// package was present, this one subfolder was missing, so the standalone +// server crashed on its very first line with "Cannot find module" - every +// packaged build (Electron and Docker) was affected. Same failure class as +// the sqlcipher prebuilds above: copy by hand what the tracer misses. +const metadataSrc = path.join(rootDir, "node_modules", "next", "dist", "lib", "metadata"); +const metadataDest = path.join(standaloneDir, "node_modules", "next", "dist", "lib", "metadata"); +rmSync(metadataDest, { recursive: true, force: true }); +cpSync(metadataSrc, metadataDest, { recursive: true }); +console.log("Copied next/dist/lib/metadata into the standalone output"); + console.log("Assembled standalone server at", standaloneDir); From 1aa0a4686b9574be066dfbf70030b0efff45b56a Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Fri, 7 Aug 2026 13:10:00 +0200 Subject: [PATCH 2/6] fix(ai): turn a bare 'Failed to fetch' into an actionable BYOK error chatPublic() calls the provider's /chat/completions directly from the renderer. Verified live: a saved profile pointing at platform.deepseek.com (DeepSeek's console) instead of api.deepseek.com (their actual API) fails the CORS preflight outright - 403, no Access-Control-* headers - which surfaces to fetch() as an undifferentiated "Failed to fetch" with no status to inspect. Confirmed the real API and OpenRouter both support being called directly from a browser fine, so the architecture is sound; only the error message was useless. Now names the URL and the likely cause instead. Co-Authored-By: Claude Sonnet 5 --- lib/ai/__tests__/local-client.test.ts | 48 +++++++++++++++++++++++++++ lib/ai/local-client.ts | 35 ++++++++++++++----- 2 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 lib/ai/__tests__/local-client.test.ts diff --git a/lib/ai/__tests__/local-client.test.ts b/lib/ai/__tests__/local-client.test.ts new file mode 100644 index 00000000..b1d631a7 --- /dev/null +++ b/lib/ai/__tests__/local-client.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { chatPublic } from '../local-client'; + +describe('chatPublic', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('turns a network/CORS-level failure into an actionable message naming the Base URL', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))); + + await expect( + chatPublic('https://platform.deepseek.com/', 'sk-test', 'deepseek-chat', [ + { role: 'user', content: 'hi' }, + ]), + ).rejects.toThrow(/Could not reach https:\/\/platform\.deepseek\.com\/chat\/completions/); + }); + + it('still reports the provider-returned status when the request completes', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) }), + ); + + await expect( + chatPublic('https://api.deepseek.com', 'sk-bad', 'deepseek-chat', [ + { role: 'user', content: 'hi' }, + ]), + ).rejects.toThrow('Provider returned 401'); + }); + + it('returns the message content on success', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ choices: [{ message: { content: 'hello there' } }] }), + }), + ); + + await expect( + chatPublic('https://api.deepseek.com', 'sk-good', 'deepseek-chat', [ + { role: 'user', content: 'hi' }, + ]), + ).resolves.toBe('hello there'); + }); +}); diff --git a/lib/ai/local-client.ts b/lib/ai/local-client.ts index 37420720..5dc41d25 100644 --- a/lib/ai/local-client.ts +++ b/lib/ai/local-client.ts @@ -135,14 +135,33 @@ export async function chatPublic( model: string, messages: ChatMessage[], ): Promise { - const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ model, messages }), - }); + const url = `${baseUrl.replace(/\/+$/, '')}/chat/completions`; + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ model, messages }), + }); + } catch { + // A request that never got a response (DNS failure, TLS failure, or - + // by far the most common cause in practice - a CORS preflight the + // target rejected) surfaces to fetch() as a bare, undifferentiated + // "TypeError: Failed to fetch" with no status code to inspect. Verified + // live: a Base URL pointing at a provider's website instead of its API + // (platform.deepseek.com vs api.deepseek.com) fails exactly this way, + // the preflight OPTIONS getting a 403 with no Access-Control-* headers + // at all. Naming the Base URL is the one actionable thing this error + // can tell the user, since the browser gives back nothing else. + throw new Error( + `Could not reach ${url} — check the Base URL is the provider's API endpoint, not its ` + + 'website or console (e.g. api.deepseek.com, not platform.deepseek.com), and that it ' + + 'allows being called directly from a browser.', + ); + } if (!res.ok) throw new Error(`Provider returned ${res.status}`); const body = (await res.json()) as OpenAiChatResponse; const content = body.choices?.[0]?.message?.content; From f121678e2af17d20accffa0d5309c2f5f70ebfb1 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Fri, 7 Aug 2026 14:01:00 +0200 Subject: [PATCH 3/6] feat(ai): Paperclip-style env-var provider presets + zero-config local default Two product decisions from tonight: 1. Public AI providers can now be published by an admin as named presets (lib/ai/types.ts's PublicAiPreset: name/baseUrl/model/apiKeyEnvVar). The admin names an env var, never a secret value - the actual key is whatever ops has set in the server's real environment, same custody model as the existing AI_SERVER_BASE_URL var. A new server route (app/api/ai/public/chat) resolves it and makes the call itself, which also sidesteps the CORS/wrong-base-URL failure class chatPublic hit earlier tonight. Users pick a preset from a dropdown in Settings - Answer with - no key field at all; personal BYOK (paste your own key) stays available as a secondary "Add your own key" option, not removed. Admin UI: new "Public - org-managed presets" card in the AI policy tab. 2. AI now defaults ON instead of requiring setup (lib/ai/auto-provision.ts): on first load, if no provider is chosen yet, probe OpenCode (this app auto-spawns `opencode serve` itself, so it's the one local option with zero external install step) then Ollama via the existing auto-discovery, and adopt whichever answers. Never overrides an explicit choice - only fires while provider is still null. Wired into both AI entry points (the Ask button and the Settings pane) so it resolves before either renders its "not configured" state. Co-Authored-By: Claude Sonnet 5 --- app/(main)/admin/_tabs/ai-policy.tsx | 94 +++++++++++++++++- app/api/admin/ai/policy/route.ts | 19 ++++ app/api/ai/policy/route.ts | 5 + app/api/ai/public/chat/route.ts | 97 +++++++++++++++++++ components/ai/ai-ask-button.tsx | 22 ++++- components/settings/ai-assistant-settings.tsx | 61 ++++++++++-- lib/ai/__tests__/auto-provision.test.ts | 87 +++++++++++++++++ lib/ai/__tests__/local-client.test.ts | 35 ++++++- lib/ai/__tests__/local-settings.test.ts | 22 +++++ lib/ai/auto-provision.ts | 66 +++++++++++++ lib/ai/local-client.ts | 33 ++++++- lib/ai/local-settings.ts | 20 ++++ lib/ai/types.ts | 36 +++++++ 13 files changed, 580 insertions(+), 17 deletions(-) create mode 100644 app/api/ai/public/chat/route.ts create mode 100644 lib/ai/__tests__/auto-provision.test.ts create mode 100644 lib/ai/__tests__/local-settings.test.ts create mode 100644 lib/ai/auto-provision.ts 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) && (