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 <noreply@anthropic.com>
This commit is contained in:
Bernd Rodler
2026-08-07 14:01:00 +02:00
co-authored by Claude Sonnet 5
parent 1aa0a4686b
commit f121678e2a
13 changed files with 580 additions and 17 deletions
+20 -2
View File
@@ -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<AiPolicy>(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) {
+52 -9
View File
@@ -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<AskResult | null>(null);
const [askError, setAskError] = useState<string | null>(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 && (
<SettingItem
label="Org-managed providers"
description="Set up by your admin. Pick one below in “Answer with” — no key to paste, it's resolved on the server."
>
<div className="flex flex-col gap-2 w-full">
{policy.publicPresets.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} · managed by admin</p>
</div>
</div>
))}
</div>
</SettingItem>
)}
{settings.publicProfiles.length > 0 && (
<SettingItem label="Saved profiles">
<SettingItem label="Your own keys">
<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">
@@ -639,7 +679,7 @@ export function AiAssistantSettings() {
</div>
</SettingItem>
)}
<SettingItem label="Add a provider">
<SettingItem label="Add your own key" description="Prefer to bring your own instead of an org-managed provider above.">
<div className="flex flex-col gap-2 w-full">
<div className="flex gap-2 flex-wrap">
<input
@@ -704,12 +744,15 @@ export function AiAssistantSettings() {
{settings.provider && (
<SettingsSection title="Try it" description="Ask a question against your synced mail.">
<div className="flex flex-col gap-3">
{settings.provider === 'public' && settings.publicProfiles.length > 0 && (
{settings.provider === 'public' && (settings.publicProfiles.length > 0 || policy.publicPresets.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 }))}
options={[
...policy.publicPresets.map((p) => ({ value: presetActiveId(p.id), label: `${p.name} (org)` })),
...settings.publicProfiles.map((p) => ({ value: p.id, label: p.name })),
]}
/>
</SettingItem>
)}