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
+92 -2
View File
@@ -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 &amp; seats</h2>