Files
SRCmail/lib/ai/auto-provision.ts
T
Bernd RodlerandClaude Sonnet 5 f121678e2a 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>
2026-08-07 14:01:00 +02:00

67 lines
2.7 KiB
TypeScript

// 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;
}