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
+30 -3
View File
@@ -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;