feat(ai): OpenCode provider class; fix retrieval reading the wrong account's index
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.
This commit is contained in:
@@ -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 && (
|
||||
<div className={cn('flex flex-col gap-2 rounded-lg border border-border p-4', 'max-h-[45vh] overflow-y-auto')}>
|
||||
{askResult.unaugmented && (
|
||||
{askResult.retrievalState === 'no-index' && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No local mail index available in this session — answered without retrieval context.
|
||||
No local mail index available in this session — answered without your mail.
|
||||
</p>
|
||||
)}
|
||||
{askResult.retrievalState === 'no-match' && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap">{askResult.answer}</p>
|
||||
|
||||
@@ -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<AiPolicy>(DEFAULT_AI_POLICY);
|
||||
const [policyLoading, setPolicyLoading] = useState(true);
|
||||
const [settings, setSettings] = useState<AiLocalSettings>(() => 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<string[]>([]);
|
||||
@@ -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<OpencodeModelOption[]>([]);
|
||||
const [refreshingOpencode, setRefreshingOpencode] = useState(false);
|
||||
const [opencodeError, setOpencodeError] = useState<string | null>(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<string[]>([]);
|
||||
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 ? (
|
||||
<RadioGroup
|
||||
value={settings.provider ?? ''}
|
||||
onChange={(v) => 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() {
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{settings.provider === 'opencode' && canUseOpencode && (
|
||||
<SettingsSection
|
||||
title="OpenCode (local agent)"
|
||||
description="Uses a locally-running OpenCode server on this machine. OpenCode holds its own provider credentials, so there is no API key to enter here — and it reports the exact models it can reach, so there is nothing to type by hand."
|
||||
>
|
||||
<SettingItem label="Model" description={opencodeModels.length === 0 ? 'Refresh to list the models OpenCode can reach.' : undefined}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{opencodeModels.length > 0 ? (
|
||||
<Select
|
||||
value={settings.opencodeModel ?? ''}
|
||||
onChange={(v) => update('opencodeModel', v)}
|
||||
options={opencodeModels.map((m) => ({ value: m.ref, label: m.label }))}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{settings.opencodeModel || 'None selected'}</span>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={refreshOpencodeModels} disabled={refreshingOpencode}>
|
||||
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshingOpencode ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
{opencodeError && (
|
||||
<SettingItem label="Status">
|
||||
<span className="flex items-start gap-1.5 text-sm text-destructive">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5" /> {opencodeError}
|
||||
</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{settings.provider === 'server' && canUseServer && (
|
||||
<SettingsSection
|
||||
title="Server (VNC-hosted)"
|
||||
@@ -516,9 +582,18 @@ export function AiAssistantSettings() {
|
||||
|
||||
{askResult && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-4">
|
||||
{askResult.unaugmented && (
|
||||
{askResult.retrievalState === 'no-index' && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No local mail index available in this session — answered without retrieval context.
|
||||
No local mail index available in this session — answered without your mail. The index is
|
||||
desktop-only; build it under Settings → About & Data.
|
||||
</p>
|
||||
)}
|
||||
{askResult.retrievalState === 'no-match' && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Your mail index is available, but nothing in it matched this question — answered without
|
||||
your mail. It matches on keywords, so questions about <em>content</em> (“what did
|
||||
Anna say about the invoice?”) work better than ones about recency
|
||||
(“the last mail”).
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap">{askResult.answer}</p>
|
||||
|
||||
Reference in New Issue
Block a user