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:
co-authored by
Claude Sonnet 5
parent
1aa0a4686b
commit
f121678e2a
@@ -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 && (
|
||||
<div className="divide-y divide-border">
|
||||
{presets.map((p) => (
|
||||
<div key={p.id} className="px-4 py-2.5 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium">{p.name}</span>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{p.model} · {p.baseUrl} · reads <code className="text-[11px]">{p.apiKeyEnvVar}</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onChange(presets.filter((x) => x.id !== p.id))}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
aria-label={`Remove ${p.name}`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-4 py-3 flex flex-col gap-2 border-t border-border">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input value={name} onChange={(e) => 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" />
|
||||
<input value={model} onChange={(e) => 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" />
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input value={baseUrl} onChange={(e) => 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" />
|
||||
<input value={envVar} onChange={(e) => 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" />
|
||||
<button onClick={addPreset} disabled={!canAdd}
|
||||
className="h-8 px-3 rounded border border-border bg-muted text-xs font-medium hover:bg-muted/70 disabled:opacity-50 inline-flex items-center gap-1.5">
|
||||
<Plus className="w-3 h-3" /> Add
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only the env var <em>name</em> 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.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiPolicyTab() {
|
||||
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
|
||||
const [config, setConfig] = useState<AiConsoleConfig>({ ...DEFAULT_AI_CONSOLE_CONFIG });
|
||||
@@ -247,6 +326,17 @@ export function AiPolicyTab() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Public — org-managed presets</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<PublicPresetsEditor presets={config.publicPresets} onChange={(v) => update({ publicPresets: v })} />
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Entitlement & seats</h2>
|
||||
|
||||
@@ -48,6 +48,24 @@ function validate(body: Partial<AiConsoleConfig>): 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<string>();
|
||||
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) {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { DEFAULT_AI_ENTITLEMENT, DEFAULT_AI_POLICY, type AiPolicy } from '../types';
|
||||
import { loadAiSettings } from '../local-settings';
|
||||
|
||||
const { listOpencodeModels } = vi.hoisted(() => ({ listOpencodeModels: vi.fn() }));
|
||||
vi.mock('../local-client', () => ({ listOpencodeModels }));
|
||||
|
||||
const { discoverLocalOllama, recommendDefaultModel } = vi.hoisted(() => ({
|
||||
discoverLocalOllama: vi.fn(),
|
||||
recommendDefaultModel: vi.fn(),
|
||||
}));
|
||||
vi.mock('../local-discovery', () => ({ discoverLocalOllama, recommendDefaultModel }));
|
||||
|
||||
const { supportsLocalLlm } = vi.hoisted(() => ({ supportsLocalLlm: vi.fn(() => true) }));
|
||||
vi.mock('../../platform-capabilities', () => ({ supportsLocalLlm }));
|
||||
|
||||
// Imported after the mocks so it picks up the mocked modules.
|
||||
const { ensureDefaultProvider, _resetAutoProvisionForTests } = await import('../auto-provision');
|
||||
|
||||
function policyWith(classes: AiPolicy['entitlement']['classes']): AiPolicy {
|
||||
return { ...DEFAULT_AI_POLICY, entitlement: { ...DEFAULT_AI_ENTITLEMENT, classes } };
|
||||
}
|
||||
|
||||
describe('ensureDefaultProvider', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
_resetAutoProvisionForTests();
|
||||
listOpencodeModels.mockReset();
|
||||
discoverLocalOllama.mockReset();
|
||||
recommendDefaultModel.mockReset();
|
||||
supportsLocalLlm.mockReturnValue(true);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('prefers OpenCode when it has a usable model', async () => {
|
||||
listOpencodeModels.mockResolvedValue([{ ref: 'opencode/deepseek-v4-flash-free', label: 'DeepSeek V4 Flash Free' }]);
|
||||
|
||||
const next = await ensureDefaultProvider(policyWith(['opencode', 'local']));
|
||||
|
||||
expect(next.provider).toBe('opencode');
|
||||
expect(next.opencodeModel).toBe('opencode/deepseek-v4-flash-free');
|
||||
expect(discoverLocalOllama).not.toHaveBeenCalled();
|
||||
expect(loadAiSettings().provider).toBe('opencode'); // persisted, not just returned
|
||||
});
|
||||
|
||||
it('falls back to Ollama when OpenCode is unreachable', async () => {
|
||||
listOpencodeModels.mockRejectedValue(new Error('No local OpenCode server is running'));
|
||||
discoverLocalOllama.mockResolvedValue({ baseUrl: 'http://127.0.0.1:11434', models: [{ name: 'qwen2.5:32b' }] });
|
||||
recommendDefaultModel.mockReturnValue('qwen2.5:32b');
|
||||
|
||||
const next = await ensureDefaultProvider(policyWith(['opencode', 'local']));
|
||||
|
||||
expect(next.provider).toBe('local');
|
||||
expect(next.localModel).toBe('qwen2.5:32b');
|
||||
});
|
||||
|
||||
it('leaves provider unset when neither is available', async () => {
|
||||
listOpencodeModels.mockResolvedValue([]);
|
||||
discoverLocalOllama.mockResolvedValue(null);
|
||||
|
||||
const next = await ensureDefaultProvider(policyWith(['opencode', 'local']));
|
||||
|
||||
expect(next.provider).toBeNull();
|
||||
});
|
||||
|
||||
it('never overrides an explicit choice already saved', async () => {
|
||||
const { saveAiSettings, DEFAULT_AI_SETTINGS } = await import('../local-settings');
|
||||
saveAiSettings({ ...DEFAULT_AI_SETTINGS, provider: 'server', serverModel: 'qwen2.5:32b' });
|
||||
|
||||
const next = await ensureDefaultProvider(policyWith(['opencode', 'local', 'server']));
|
||||
|
||||
expect(next.provider).toBe('server');
|
||||
expect(listOpencodeModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only probes once per module lifetime even if called again', async () => {
|
||||
listOpencodeModels.mockResolvedValue([]);
|
||||
discoverLocalOllama.mockResolvedValue(null);
|
||||
|
||||
await ensureDefaultProvider(policyWith(['opencode', 'local']));
|
||||
await ensureDefaultProvider(policyWith(['opencode', 'local']));
|
||||
|
||||
expect(listOpencodeModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { chatPublic } from '../local-client';
|
||||
import { chatPublic, chatPublicManaged } from '../local-client';
|
||||
|
||||
describe('chatPublic', () => {
|
||||
afterEach(() => {
|
||||
@@ -46,3 +46,36 @@ describe('chatPublic', () => {
|
||||
).resolves.toBe('hello there');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatPublicManaged', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('posts presetId (never a key) to the same-origin route', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ answer: 'hi from the org preset' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const answer = await chatPublicManaged('preset-abc123', [{ role: 'user', content: 'hi' }]);
|
||||
|
||||
expect(answer).toBe('hi from the org preset');
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/ai/public/chat', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ presetId: 'preset-abc123', messages: [{ role: 'user', content: 'hi' }] }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('surfaces the server-side error (e.g. env var not set) verbatim', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({ error: 'Env var "DEEPSEEK_API_KEY" is not set on the server for preset "DeepSeek (org)"' }),
|
||||
}));
|
||||
|
||||
await expect(chatPublicManaged('preset-abc123', [{ role: 'user', content: 'hi' }]))
|
||||
.rejects.toThrow(/Env var "DEEPSEEK_API_KEY" is not set/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { presetActiveId, isPresetActiveId, presetIdFromActiveId } from '../local-settings';
|
||||
|
||||
describe('preset active-id helpers', () => {
|
||||
it('round-trips a preset id through the prefixed activeProfileId space', () => {
|
||||
const activeId = presetActiveId('preset-abc123');
|
||||
expect(activeId).toBe('preset:preset-abc123');
|
||||
expect(isPresetActiveId(activeId)).toBe(true);
|
||||
expect(presetIdFromActiveId(activeId)).toBe('preset-abc123');
|
||||
});
|
||||
|
||||
it('does not mistake a personal profile id for a preset id', () => {
|
||||
const profileId = 'profile-xyz789-abc123';
|
||||
expect(isPresetActiveId(profileId)).toBe(false);
|
||||
expect(presetIdFromActiveId(profileId)).toBeNull();
|
||||
});
|
||||
|
||||
it('handles null safely', () => {
|
||||
expect(isPresetActiveId(null)).toBe(false);
|
||||
expect(presetIdFromActiveId(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// First-run, zero-config default provider (product decision 2026-08-07:
|
||||
// "user wants to use AI so set the local one to on always by default" — not
|
||||
// "user wants to configure AI"). Before this, a fresh install left
|
||||
// `settings.provider` at `null` and every AI entry point just told the user
|
||||
// to go set one up in Settings.
|
||||
//
|
||||
// Priority: OpenCode first, then Ollama. OpenCode is the one local option
|
||||
// this app controls end to end — electron/main.ts auto-spawns
|
||||
// `opencode serve` itself, so "OpenCode has a model" only depends on what's
|
||||
// already authenticated in its own auth.json, not on the user having
|
||||
// installed anything separately. Ollama is second because it's an external
|
||||
// dependency the user must have installed and started themselves — real,
|
||||
// but not zero-config the way OpenCode is here.
|
||||
//
|
||||
// Never overrides an explicit choice: fires only while `provider` is still
|
||||
// `null`, and at most once per page load (module-level `attempted`) so a
|
||||
// component re-mounting doesn't re-probe on every render.
|
||||
import { loadAiSettings, saveAiSettings, type AiLocalSettings } from './local-settings';
|
||||
import { listOpencodeModels } from './local-client';
|
||||
import { discoverLocalOllama, recommendDefaultModel } from './local-discovery';
|
||||
import { supportsLocalLlm } from '../platform-capabilities';
|
||||
import type { AiPolicy } from './types';
|
||||
|
||||
let attempted = false;
|
||||
|
||||
/** Test-only: lets a fresh module state be simulated without a full reload. */
|
||||
export function _resetAutoProvisionForTests(): void {
|
||||
attempted = false;
|
||||
}
|
||||
|
||||
export async function ensureDefaultProvider(policy: AiPolicy): Promise<AiLocalSettings> {
|
||||
const current = loadAiSettings();
|
||||
if (current.provider !== null || attempted) return current;
|
||||
attempted = true;
|
||||
|
||||
if (policy.entitlement.classes.includes('opencode')) {
|
||||
try {
|
||||
const models = await listOpencodeModels();
|
||||
if (models[0]) {
|
||||
const next: AiLocalSettings = { ...current, provider: 'opencode', opencodeModel: models[0].ref };
|
||||
saveAiSettings(next);
|
||||
return next;
|
||||
}
|
||||
} catch {
|
||||
// opencode not reachable yet (still starting, or the CLI isn't
|
||||
// installed) — fall through to Ollama rather than surfacing an error
|
||||
// for a default the user never asked for.
|
||||
}
|
||||
}
|
||||
|
||||
if (supportsLocalLlm() && policy.entitlement.classes.includes('local')) {
|
||||
const discovery = await discoverLocalOllama();
|
||||
if (discovery) {
|
||||
const recommended = recommendDefaultModel(discovery.models) ?? discovery.models[0]?.name ?? null;
|
||||
if (recommended) {
|
||||
const next: AiLocalSettings = {
|
||||
...current, provider: 'local', localBaseUrl: discovery.baseUrl, localModel: recommended,
|
||||
};
|
||||
saveAiSettings(next);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
+30
-3
@@ -169,6 +169,24 @@ export async function chatPublic(
|
||||
return content;
|
||||
}
|
||||
|
||||
// ── Public, admin-managed presets — the Paperclip-style alternative to
|
||||
// pasting a personal key (decision 2026-08-07). The client only ever sends a
|
||||
// presetId; the server resolves the actual key from its own environment (see
|
||||
// app/api/ai/public/chat/route.ts) and makes the call itself, which also
|
||||
// sidesteps the CORS/wrong-base-URL failure class chatPublic is exposed to. ──
|
||||
|
||||
export async function chatPublicManaged(presetId: string, messages: ChatMessage[]): Promise<string> {
|
||||
const res = await fetch('/api/ai/public/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ presetId, messages }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body?.error || `Provider returned ${res.status}`);
|
||||
if (!body?.answer) throw new Error('Provider returned no message content');
|
||||
return body.answer as string;
|
||||
}
|
||||
|
||||
// ── OpenCode: a locally-running `opencode serve` (github.com/sst/opencode),
|
||||
// the same agent runtime Paperclip drives as an adapter. Reached through THIS
|
||||
// app's own backend (app/api/ai/opencode/*) rather than directly, for the same
|
||||
@@ -438,7 +456,12 @@ export interface AskConfig {
|
||||
localBaseUrl: string;
|
||||
localModel: string | null;
|
||||
serverModel: string | null;
|
||||
/** Personal BYOK profile — mutually exclusive with publicPresetId; the
|
||||
* caller sets exactly one depending on which the user picked. */
|
||||
publicProfile: ResolvedPublicProfile | null;
|
||||
/** Admin-managed preset id (see chatPublicManaged) — mutually exclusive
|
||||
* with publicProfile. */
|
||||
publicPresetId?: string | null;
|
||||
opencodeModel?: string | null;
|
||||
/** Cookie slot of the account whose local index should be searched. Omitting
|
||||
* it reads whichever account the resolver finds first — see fetchLocalLeg. */
|
||||
@@ -452,7 +475,7 @@ export async function askMail(question: string, config: AskConfig): Promise<AskR
|
||||
if (config.provider === 'server' && !config.serverModel) {
|
||||
throw new Error('No server model selected');
|
||||
}
|
||||
if (config.provider === 'public' && !config.publicProfile) {
|
||||
if (config.provider === 'public' && !config.publicProfile && !config.publicPresetId) {
|
||||
throw new Error('No provider profile selected');
|
||||
}
|
||||
if (config.provider === 'opencode' && !config.opencodeModel) {
|
||||
@@ -467,8 +490,12 @@ export async function askMail(question: string, config: AskConfig): Promise<AskR
|
||||
let answer: string;
|
||||
let seatJustAssigned = false;
|
||||
if (config.provider === 'public') {
|
||||
const profile = config.publicProfile as ResolvedPublicProfile;
|
||||
answer = await chatPublic(profile.baseUrl, profile.apiKey, profile.model, messages);
|
||||
if (config.publicPresetId) {
|
||||
answer = await chatPublicManaged(config.publicPresetId, messages);
|
||||
} else {
|
||||
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;
|
||||
|
||||
@@ -50,6 +50,26 @@ function newProfileId(): string {
|
||||
return `profile-${Math.random().toString(36).slice(2, 10)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `activeProfileId` names either a personal BYOK profile (its own id, as
|
||||
* always) or an admin-managed preset (see PublicAiPreset), prefixed so the
|
||||
* two id spaces can never collide without adding a second field everywhere
|
||||
* that reads/writes activeProfileId.
|
||||
*/
|
||||
const PRESET_PREFIX = 'preset:';
|
||||
|
||||
export function presetActiveId(presetId: string): string {
|
||||
return PRESET_PREFIX + presetId;
|
||||
}
|
||||
|
||||
export function isPresetActiveId(activeId: string | null): boolean {
|
||||
return !!activeId && activeId.startsWith(PRESET_PREFIX);
|
||||
}
|
||||
|
||||
export function presetIdFromActiveId(activeId: string | null): string | null {
|
||||
return activeId && activeId.startsWith(PRESET_PREFIX) ? activeId.slice(PRESET_PREFIX.length) : null;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
|
||||
@@ -22,6 +22,33 @@
|
||||
|
||||
export type AiClass = 'local' | 'server' | 'public' | 'opencode';
|
||||
|
||||
/**
|
||||
* An admin-published, server-managed `public`-class provider — the
|
||||
* Paperclip-style alternative to a user pasting their own key (decision
|
||||
* 2026-08-07): the admin names an env var (e.g. "DEEPSEEK_API_KEY") instead
|
||||
* of typing a secret value anywhere in this config. The actual value is
|
||||
* whatever ops has set in the server's real environment (k8s secret, .env,
|
||||
* Electron packaging) — same custody model as the existing AI_SERVER_BASE_URL
|
||||
* var, just admin-nameable instead of hardcoded. Resolved server-side only,
|
||||
* in app/api/ai/public/chat/route.ts; never sent to a browser.
|
||||
*/
|
||||
export interface PublicAiPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
apiKeyEnvVar: string;
|
||||
}
|
||||
|
||||
/** What a client is allowed to know about a preset — no baseUrl/apiKeyEnvVar,
|
||||
* since the client only ever refers to a preset by id and never calls the
|
||||
* provider itself. */
|
||||
export interface PublicAiPresetOption {
|
||||
id: string;
|
||||
name: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface AiEntitlement {
|
||||
licensed: boolean;
|
||||
subject: 'user' | 'tenant';
|
||||
@@ -45,6 +72,9 @@ export interface AiPolicy {
|
||||
/** Base-URL prefixes a BYOK profile's baseUrl must match. null = unrestricted
|
||||
* (today's behavior). Advisory/client-side only — see spec §6.1. */
|
||||
publicProviderAllowlist: string[] | null;
|
||||
/** Admin-managed provider presets available to every user (see
|
||||
* PublicAiPreset) — sanitized to {id,name,model} for the client. */
|
||||
publicPresets: PublicAiPresetOption[];
|
||||
}
|
||||
|
||||
export const DEFAULT_AI_ENTITLEMENT: AiEntitlement = {
|
||||
@@ -63,6 +93,7 @@ export const DEFAULT_AI_POLICY: AiPolicy = {
|
||||
retrievalEnabled: true,
|
||||
consent: null,
|
||||
publicProviderAllowlist: null,
|
||||
publicPresets: [],
|
||||
};
|
||||
|
||||
// Admin-authored console config (docs/AI-ASSISTANT-CONCEPT.md §6 /
|
||||
@@ -83,6 +114,10 @@ export interface AiConsoleConfig {
|
||||
/** null = unrestricted BYOK base URLs (today's behavior, unchanged).
|
||||
* Non-null = base URL must start with one of these prefixes. */
|
||||
publicProviderAllowlist: string[] | null;
|
||||
/** Server-managed `public`-class presets — the Paperclip-style env-var-key
|
||||
* picker (decision 2026-08-07). Empty by default; adding one here is what
|
||||
* makes it show up in every user's Settings picker and in AiPolicy.publicPresets. */
|
||||
publicPresets: PublicAiPreset[];
|
||||
/** Master switch for the retrieval leg (mail-content → embeddings).
|
||||
* Independent of classesEnabled.server. Defaults true. */
|
||||
retrievalEnabled: boolean;
|
||||
@@ -93,6 +128,7 @@ export const DEFAULT_AI_CONSOLE_CONFIG: AiConsoleConfig = {
|
||||
classesEnabled: {},
|
||||
serverModelAllowlist: null,
|
||||
publicProviderAllowlist: null,
|
||||
publicPresets: [],
|
||||
retrievalEnabled: true,
|
||||
consent: null,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user