From 98dcd3b1e97e2a19e31bd25e3e8fc36951a5b852 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Thu, 6 Aug 2026 20:17:46 +0200 Subject: [PATCH] feat(ai): OpenCode provider class; fix retrieval reading the wrong account's index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, all from running the real thing rather than trusting a status code. 1. OpenCode as a 4th AI class (lib/ai/opencode.ts + app/api/ai/opencode/*). A locally-running `opencode serve` — the same runtime Paperclip drives as an adapter. Its appeal over a BYOK profile is precisely what was broken before: opencode owns provider auth itself, so there is NO api key for this app to hold, and it reports a REAL model list (25 on this machine) instead of asking the user to type an exact provider-specific model id from memory. Typing "Sonnet 5" into a free-text box and getting a bare "Provider returned 401" is the failure this removes. IMPORTANT trap, documented in the module header and pinned by a test: opencode is NOT OpenAI-compatible. `/v1/models` and `/v1/chat/completions` both answer 200 — because a web-UI catch-all serves index.html for ANY unknown path. I built the first version against that assumed compatibility on the strength of two 200s and had to throw it away once I read a body. Every probe now validates the parsed shape and content-type, never the status alone. The real API is GET /api/model + POST /session + POST /session/{id}/message, and the reply's `reasoning` parts are stripped so a model's private chain of thought can never surface as the answer. Proxied through our own backend (like the `server` class) because the desktop renderer's origin is a random port that changes every launch; same-origin sidesteps opencode's CORS allowlist entirely. Loopback-only by construction: a non-loopback OPENCODE_BASE_URL is refused, since "local, no keys, nothing leaves the device" is the whole point of this class. 2. Retrieval read the WRONG ACCOUNT'S index. The indexer writes under the active account's cookie slot (catchUpIndex passes it) but fetchLocalLeg omitted `?slot=`, so search resolved to whichever account the multi-slot resolver found first. Single-account installs never noticed; a real multi-account/shared-mailbox setup reads an empty store every time. Both call sites now pass the active slot. 3. "No local mail index available in this session" was shown even when the index existed and simply matched nothing — actively misleading, and it masked the missing-SESSION_SECRET bug for hours. AskResult now carries retrievalState ('augmented' | 'no-match' | 'no-index') and the two cases get different words: build the index, versus rephrase (with the honest caveat that keyword search answers content questions better than recency ones like "the last mail"). Verified live against real opencode 1.18.14: discovery found 25 models and a real prompt round-tripped the exact expected answer through the real helper code, not curl. Gate: tsc clean, eslint clean, 2512/2512 unit tests (10 new, incl. one that fails if the HTML catch-all is ever accepted as an API), build clean. --- .gitignore | 1 + app/(main)/admin/_tabs/ai-policy.tsx | 5 +- app/api/ai/opencode/chat/route.ts | 94 ++++++++++ app/api/ai/opencode/models/route.ts | 43 +++++ app/api/ai/policy/route.ts | 7 + components/ai/ai-ask-button.tsx | 22 ++- components/settings/ai-assistant-settings.tsx | 93 +++++++++- lib/ai/__tests__/opencode.test.ts | 147 ++++++++++++++++ lib/ai/local-client.ts | 102 ++++++++++- lib/ai/local-settings.ts | 4 +- lib/ai/opencode.ts | 164 ++++++++++++++++++ lib/ai/types.ts | 2 +- 12 files changed, 658 insertions(+), 26 deletions(-) create mode 100644 app/api/ai/opencode/chat/route.ts create mode 100644 app/api/ai/opencode/models/route.ts create mode 100644 lib/ai/__tests__/opencode.test.ts create mode 100644 lib/ai/opencode.ts diff --git a/.gitignore b/.gitignore index b43db4a6..7dd19ead 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,4 @@ vnc/plugins/smime/smime.zip # macOS .DS_Store +electron-ai-local-index-result.png diff --git a/app/(main)/admin/_tabs/ai-policy.tsx b/app/(main)/admin/_tabs/ai-policy.tsx index 0dc4e7a2..a0f21b4d 100644 --- a/app/(main)/admin/_tabs/ai-policy.tsx +++ b/app/(main)/admin/_tabs/ai-policy.tsx @@ -13,6 +13,7 @@ type EntitlementResponse = AiEntitlementState & { recentUsage: MeteringEntry[] } const CLASS_INFO: Record = { local: { name: 'Local', desc: "Ollama on the user's own machine. Free, unmetered, never reaches this server." }, server: { name: 'Server', desc: 'VNC-hosted. Entitlement-enforced, seat + usage tracked below.' }, + opencode: { name: 'OpenCode', desc: 'A locally-running OpenCode agent server. Holds its own provider credentials; nothing metered here.' }, public: { name: 'Public (BYOK)', desc: "User's own API key, direct from their browser to the provider." }, }; @@ -199,8 +200,8 @@ export function AiPolicyTab() {

Provider classes

Which of the three AI classes users can reach at all.

-
- {(['local', 'server', 'public'] as AiClass[]).map((cls) => { +
+ {(['local', 'server', 'opencode', 'public'] as AiClass[]).map((cls) => { const enabled = config.classesEnabled[cls] !== false; const disabledByInfra = cls === 'server' && !serverInfraAvailable; return ( diff --git a/app/api/ai/opencode/chat/route.ts b/app/api/ai/opencode/chat/route.ts new file mode 100644 index 00000000..5c97ac63 --- /dev/null +++ b/app/api/ai/opencode/chat/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { configManager } from '@/lib/admin/config-manager'; +import { findOpencodeServer, parseModelRef, opencodePrompt } from '@/lib/ai/opencode'; +import { logger } from '@/lib/logger'; + +export const runtime = 'nodejs'; + +const MAX_BODY_BYTES = 200 * 1024; + +interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +/** + * POST /api/ai/opencode/chat — one-shot chat against a locally-running + * `opencode serve`. + * + * Deliberately NOT entitlement-metered, unlike /api/ai/server/chat: this runs + * on the user's own machine against provider credentials opencode itself + * holds, so there is no centrally-borne cost for this app to bill — the same + * reasoning that leaves `local` unmetered (lib/ai/entitlement.ts's header). + */ +export async function POST(request: NextRequest) { + const auth = await getStalwartCredentials(request); + if (!auth) { + return NextResponse.json({ error: 'not authenticated' }, { status: 401 }); + } + + await configManager.ensureLoaded(); + if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) { + return NextResponse.json({ error: 'the OpenCode 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: { model?: unknown; messages?: unknown }; + try { + body = JSON.parse(rawBody); + } catch { + return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 }); + } + + const model = typeof body.model === 'string' ? body.model : ''; + const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null; + if (!model || !messages || messages.length === 0) { + return NextResponse.json({ error: 'model and messages are required' }, { status: 400 }); + } + + const found = await findOpencodeServer(); + if (!found) { + return NextResponse.json( + { error: 'No local OpenCode server found. Start one with: opencode serve --port 4096' }, + { status: 503 }, + ); + } + if (!found.models.some((m) => m.ref === model)) { + // The picker is populated from this same list, so a mismatch means the + // saved model was removed/renamed in opencode since it was chosen - + // clearer to say so than to forward it and surface opencode's own error. + return NextResponse.json( + { error: `OpenCode no longer offers the model "${model}" \u2014 pick another in Settings.` }, + { status: 400 }, + ); + } + const parsed = parseModelRef(model); + if (!parsed) { + return NextResponse.json({ error: `Malformed model reference "${model}"` }, { status: 400 }); + } + + // Flatten our chat-messages shape onto opencode's (system field + text + // parts). Every non-system message is already just the built prompt. + const system = messages.filter((m) => m.role === 'system').map((m) => m.content).join('\n\n') || undefined; + const userText = messages.filter((m) => m.role !== 'system').map((m) => m.content).join('\n\n'); + if (!userText.trim()) { + return NextResponse.json({ error: 'no user content to send' }, { status: 400 }); + } + + try { + const result = await opencodePrompt(found.baseUrl, parsed, system, userText); + if (!result.ok) { + logger.error('opencode prompt failed', { error: result.error }); + return NextResponse.json({ error: result.error }, { status: 502 }); + } + return NextResponse.json({ answer: result.answer }); + } catch (cause) { + logger.error('opencode chat failed', { error: cause instanceof Error ? cause.message : String(cause) }); + return NextResponse.json({ error: 'OpenCode server unreachable' }, { status: 502 }); + } +} diff --git a/app/api/ai/opencode/models/route.ts b/app/api/ai/opencode/models/route.ts new file mode 100644 index 00000000..7b5e668a --- /dev/null +++ b/app/api/ai/opencode/models/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { configManager } from '@/lib/admin/config-manager'; +import { findOpencodeServer } from '@/lib/ai/opencode'; + +export const runtime = 'nodejs'; + +/** + * GET /api/ai/opencode/models — models a locally-running `opencode serve` + * exposes. Proxied rather than fetched directly by the renderer: the desktop + * shell's origin is a random localhost port that changes every launch, so a + * direct call would need opencode's CORS allowlist updated each time. + * + * Listing is not a billable action, so a valid session is enough — no seat + * check (matching /api/ai/server/models). + */ +export async function GET(request: NextRequest) { + const auth = await getStalwartCredentials(request); + if (!auth) { + return NextResponse.json({ error: 'not authenticated' }, { status: 401 }); + } + + await configManager.ensureLoaded(); + if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) { + return NextResponse.json({ error: 'the OpenCode class is disabled by admin policy' }, { status: 403 }); + } + + const found = await findOpencodeServer(); + if (!found) { + // 503 not 500: "nothing is listening" is a normal state (opencode simply + // isn't running), and the client turns it into setup guidance rather than + // an error banner. + return NextResponse.json( + { error: 'No local OpenCode server found. Start one with: opencode serve --port 4096' }, + { status: 503 }, + ); + } + + return NextResponse.json( + { models: found.models.map((m) => ({ ref: m.ref, label: m.label })) }, + { headers: { 'Cache-Control': 'no-store' } }, + ); +} diff --git a/app/api/ai/policy/route.ts b/app/api/ai/policy/route.ts index 6672ea59..bb17ab3c 100644 --- a/app/api/ai/policy/route.ts +++ b/app/api/ai/policy/route.ts @@ -28,6 +28,13 @@ export async function GET() { if (classAllowed('local')) classes.push('local'); if (classAllowed('public')) classes.push('public'); if (process.env.AI_SERVER_BASE_URL && classAllowed('server')) classes.push('server'); + // `opencode` is offered whenever the admin hasn't disabled it — unlike + // `server` there is no env var to gate on, because availability is "is a + // local `opencode serve` listening right now", which changes minute to + // minute and is answered by /api/ai/opencode/models (503 when absent). + // Advertising the class and letting that probe report the truth beats + // hiding it based on a stale check at policy-fetch time. + if (classAllowed('opencode')) classes.push('opencode'); const aiPolicy: AiPolicy = { enabled: policy.features.aiAssistantEnabled, diff --git a/components/ai/ai-ask-button.tsx b/components/ai/ai-ask-button.tsx index 91f616df..a40b65df 100644 --- a/components/ai/ai-ask-button.tsx +++ b/components/ai/ai-ask-button.tsx @@ -18,6 +18,7 @@ import { AlertTriangle, Loader2, Settings2, Sparkles, X } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/browser-navigation'; +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'; @@ -54,6 +55,8 @@ function providerConfigured(settings: AiLocalSettings, policy: AiPolicy): boolea return supportsLocalLlm() && classes.includes('local') && !!settings.localModel; case 'server': return classes.includes('server') && !!settings.serverModel; + case 'opencode': + return classes.includes('opencode') && !!settings.opencodeModel; case 'public': { const active = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null; return classes.includes('public') && !!active && settings.publicConsentAccepted; @@ -66,6 +69,8 @@ function providerConfigured(settings: AiLocalSettings, policy: AiPolicy): boolea export function AiAskButton() { const router = useRouter(); const { policy, loaded } = useAiPolicy(); + // Retrieval must read the SAME account slot the indexer wrote under. + const activeSlot = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.cookieSlot); const [open, setOpen] = useState(false); // Re-read on every open: the user may have just configured a provider in // Settings and come straight back here — a mount-time snapshot would still @@ -107,10 +112,12 @@ export function AiAskButton() { const activeProfile = 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', + provider: settings.provider as 'local' | 'server' | 'public' | 'opencode', localBaseUrl: settings.localBaseUrl, localModel: settings.localModel, serverModel: settings.serverModel, + opencodeModel: settings.opencodeModel, + slot: activeSlot, publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null, }); setAskResult(result); @@ -119,7 +126,7 @@ export function AiAskButton() { } finally { setAsking(false); } - }, [canAsk, question, settings]); + }, [canAsk, question, settings, activeSlot]); const goToSettings = useCallback(() => { // The Settings page's one-shot deep-link channel (see readPersistedTab in @@ -221,9 +228,16 @@ export function AiAskButton() { {askResult && (
- {askResult.unaugmented && ( + {askResult.retrievalState === 'no-index' && (

- No local mail index available in this session — answered without retrieval context. + No local mail index available in this session — answered without your mail. +

+ )} + {askResult.retrievalState === 'no-match' && ( +

+ Your mail index is available, but nothing in it matched this question — answered + without your mail. It matches on keywords, so content questions work better than + recency ones.

)}

{askResult.answer}

diff --git a/components/settings/ai-assistant-settings.tsx b/components/settings/ai-assistant-settings.tsx index c834f86c..e4465ecb 100644 --- a/components/settings/ai-assistant-settings.tsx +++ b/components/settings/ai-assistant-settings.tsx @@ -5,6 +5,7 @@ import { RefreshCw, CheckCircle, AlertTriangle, Loader2, Plus, Trash2, Sparkles, import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup, Select } from './settings-section'; import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/browser-navigation'; +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'; @@ -21,6 +22,8 @@ import { askMail, listLocalModels, listServerModels, + listOpencodeModels, + type OpencodeModelOption, testLocalConnection, type AskResult, } from '@/lib/ai/local-client'; @@ -40,6 +43,9 @@ export function AiAssistantSettings() { const [policy, setPolicy] = useState(DEFAULT_AI_POLICY); const [policyLoading, setPolicyLoading] = useState(true); const [settings, setSettings] = useState(() => loadAiSettings()); + // The index is written under the ACTIVE account's cookie slot, so retrieval + // must read the same one — see fetchLocalLeg in lib/ai/local-client.ts. + const activeSlot = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.cookieSlot); useEffect(() => { let cancelled = false; @@ -67,6 +73,7 @@ export function AiAssistantSettings() { const canUseLocal = supportsLocalLlm() && policy.entitlement.classes.includes('local'); const canUseServer = policy.entitlement.classes.includes('server'); const canUsePublic = policy.entitlement.classes.includes('public'); + const canUseOpencode = policy.entitlement.classes.includes('opencode'); // ── Local provider ── const [localModels, setLocalModels] = useState([]); @@ -140,6 +147,28 @@ export function AiAssistantSettings() { setDiscovery(null); }, []); + // ── OpenCode provider — a locally-running `opencode serve`. No key to + // manage (opencode holds provider auth itself) and a real model list, which + // is why this is its own class rather than another BYOK profile. ── + const [opencodeModels, setOpencodeModels] = useState([]); + const [refreshingOpencode, setRefreshingOpencode] = useState(false); + const [opencodeError, setOpencodeError] = useState(null); + + const refreshOpencodeModels = useCallback(async () => { + setRefreshingOpencode(true); + setOpencodeError(null); + try { + const models = await listOpencodeModels(); + setOpencodeModels(models); + if (!settings.opencodeModel && models[0]) update('opencodeModel', models[0].ref); + } catch (err) { + setOpencodeModels([]); + setOpencodeError(err instanceof Error ? err.message : String(err)); + } finally { + setRefreshingOpencode(false); + } + }, [settings.opencodeModel, update]); + // ── Server provider ── const [serverModels, setServerModels] = useState([]); const [refreshingServer, setRefreshingServer] = useState(false); @@ -212,9 +241,11 @@ export function AiAssistantSettings() { ? canUseLocal && !!settings.localModel : settings.provider === 'server' ? canUseServer && !!settings.serverModel - : settings.provider === 'public' - ? canUsePublic && !!activeProfile && settings.publicConsentAccepted - : false); + : settings.provider === 'opencode' + ? canUseOpencode && !!settings.opencodeModel + : settings.provider === 'public' + ? canUsePublic && !!activeProfile && settings.publicConsentAccepted + : false); const runAsk = useCallback(async () => { setAsking(true); @@ -224,10 +255,12 @@ export function AiAssistantSettings() { try { const key = activeProfile ? getAiApiKey(activeProfile.id) : null; const result = await askMail(question.trim(), { - provider: settings.provider as 'local' | 'server' | 'public', + provider: settings.provider as 'local' | 'server' | 'public' | 'opencode', localBaseUrl: settings.localBaseUrl, localModel: settings.localModel, serverModel: settings.serverModel, + opencodeModel: settings.opencodeModel, + slot: activeSlot, publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null, }); setAskResult(result); @@ -239,15 +272,16 @@ export function AiAssistantSettings() { } finally { setAsking(false); } - }, [question, settings, activeProfile]); + }, [question, settings, activeProfile, activeSlot]); const providerOptions = useMemo( () => [ ...(canUseLocal ? [{ value: 'local', label: 'Local (Ollama)' }] : []), ...(canUseServer ? [{ value: 'server', label: 'Server (VNC-hosted)' }] : []), + ...(canUseOpencode ? [{ value: 'opencode', label: 'OpenCode (local agent)' }] : []), ...(canUsePublic ? [{ value: 'public', label: 'Public (your API keys)' }] : []), ], - [canUseLocal, canUseServer, canUsePublic], + [canUseLocal, canUseServer, canUsePublic, canUseOpencode], ); if (policyLoading) { @@ -295,7 +329,7 @@ export function AiAssistantSettings() { {providerOptions.length > 0 ? ( update('provider', v as 'local' | 'server' | 'public')} + onChange={(v) => update('provider', v as 'local' | 'server' | 'public' | 'opencode')} options={providerOptions} /> ) : ( @@ -360,6 +394,38 @@ export function AiAssistantSettings() { )} + {settings.provider === 'opencode' && canUseOpencode && ( + + +
+ {opencodeModels.length > 0 ? ( +