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:
@@ -73,3 +73,4 @@ vnc/plugins/smime/smime.zip
|
|||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
electron-ai-local-index-result.png
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type EntitlementResponse = AiEntitlementState & { recentUsage: MeteringEntry[] }
|
|||||||
const CLASS_INFO: Record<AiClass, { name: string; desc: string }> = {
|
const CLASS_INFO: Record<AiClass, { name: string; desc: string }> = {
|
||||||
local: { name: 'Local', desc: "Ollama on the user's own machine. Free, unmetered, never reaches this server." },
|
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.' },
|
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." },
|
public: { name: 'Public (BYOK)', desc: "User's own API key, direct from their browser to the provider." },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -199,8 +200,8 @@ export function AiPolicyTab() {
|
|||||||
<h2 className="text-sm font-medium text-foreground">Provider classes</h2>
|
<h2 className="text-sm font-medium text-foreground">Provider classes</h2>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Which of the three AI classes users can reach at all.</p>
|
<p className="text-xs text-muted-foreground mt-0.5">Which of the three AI classes users can reach at all.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 p-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 p-4">
|
||||||
{(['local', 'server', 'public'] as AiClass[]).map((cls) => {
|
{(['local', 'server', 'opencode', 'public'] as AiClass[]).map((cls) => {
|
||||||
const enabled = config.classesEnabled[cls] !== false;
|
const enabled = config.classesEnabled[cls] !== false;
|
||||||
const disabledByInfra = cls === 'server' && !serverInfraAvailable;
|
const disabledByInfra = cls === 'server' && !serverInfraAvailable;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,13 @@ export async function GET() {
|
|||||||
if (classAllowed('local')) classes.push('local');
|
if (classAllowed('local')) classes.push('local');
|
||||||
if (classAllowed('public')) classes.push('public');
|
if (classAllowed('public')) classes.push('public');
|
||||||
if (process.env.AI_SERVER_BASE_URL && classAllowed('server')) classes.push('server');
|
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 = {
|
const aiPolicy: AiPolicy = {
|
||||||
enabled: policy.features.aiAssistantEnabled,
|
enabled: policy.features.aiAssistantEnabled,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { AlertTriangle, Loader2, Settings2, Sparkles, X } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { useAccountStore } from '@/stores/account-store';
|
||||||
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
|
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
|
||||||
import { supportsLocalLlm } from '@/lib/platform-capabilities';
|
import { supportsLocalLlm } from '@/lib/platform-capabilities';
|
||||||
import { getAiApiKey } from '@/lib/ai/key-store';
|
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;
|
return supportsLocalLlm() && classes.includes('local') && !!settings.localModel;
|
||||||
case 'server':
|
case 'server':
|
||||||
return classes.includes('server') && !!settings.serverModel;
|
return classes.includes('server') && !!settings.serverModel;
|
||||||
|
case 'opencode':
|
||||||
|
return classes.includes('opencode') && !!settings.opencodeModel;
|
||||||
case 'public': {
|
case 'public': {
|
||||||
const active = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
|
const active = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
|
||||||
return classes.includes('public') && !!active && settings.publicConsentAccepted;
|
return classes.includes('public') && !!active && settings.publicConsentAccepted;
|
||||||
@@ -66,6 +69,8 @@ function providerConfigured(settings: AiLocalSettings, policy: AiPolicy): boolea
|
|||||||
export function AiAskButton() {
|
export function AiAskButton() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { policy, loaded } = useAiPolicy();
|
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);
|
const [open, setOpen] = useState(false);
|
||||||
// Re-read on every open: the user may have just configured a provider in
|
// 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
|
// 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 activeProfile = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
|
||||||
const key = activeProfile ? getAiApiKey(activeProfile.id) : null;
|
const key = activeProfile ? getAiApiKey(activeProfile.id) : null;
|
||||||
const result = await askMail(question.trim(), {
|
const result = await askMail(question.trim(), {
|
||||||
provider: settings.provider as 'local' | 'server' | 'public',
|
provider: settings.provider as 'local' | 'server' | 'public' | 'opencode',
|
||||||
localBaseUrl: settings.localBaseUrl,
|
localBaseUrl: settings.localBaseUrl,
|
||||||
localModel: settings.localModel,
|
localModel: settings.localModel,
|
||||||
serverModel: settings.serverModel,
|
serverModel: settings.serverModel,
|
||||||
|
opencodeModel: settings.opencodeModel,
|
||||||
|
slot: activeSlot,
|
||||||
publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null,
|
publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null,
|
||||||
});
|
});
|
||||||
setAskResult(result);
|
setAskResult(result);
|
||||||
@@ -119,7 +126,7 @@ export function AiAskButton() {
|
|||||||
} finally {
|
} finally {
|
||||||
setAsking(false);
|
setAsking(false);
|
||||||
}
|
}
|
||||||
}, [canAsk, question, settings]);
|
}, [canAsk, question, settings, activeSlot]);
|
||||||
|
|
||||||
const goToSettings = useCallback(() => {
|
const goToSettings = useCallback(() => {
|
||||||
// The Settings page's one-shot deep-link channel (see readPersistedTab in
|
// The Settings page's one-shot deep-link channel (see readPersistedTab in
|
||||||
@@ -221,9 +228,16 @@ export function AiAskButton() {
|
|||||||
|
|
||||||
{askResult && (
|
{askResult && (
|
||||||
<div className={cn('flex flex-col gap-2 rounded-lg border border-border p-4', 'max-h-[45vh] overflow-y-auto')}>
|
<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">
|
<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>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-foreground whitespace-pre-wrap">{askResult.answer}</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 { SettingsSection, SettingItem, ToggleSwitch, RadioGroup, Select } from './settings-section';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { useAccountStore } from '@/stores/account-store';
|
||||||
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
|
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
|
||||||
import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities';
|
import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities';
|
||||||
import { getAiApiKey, setAiApiKey, clearAiApiKey } from '@/lib/ai/key-store';
|
import { getAiApiKey, setAiApiKey, clearAiApiKey } from '@/lib/ai/key-store';
|
||||||
@@ -21,6 +22,8 @@ import {
|
|||||||
askMail,
|
askMail,
|
||||||
listLocalModels,
|
listLocalModels,
|
||||||
listServerModels,
|
listServerModels,
|
||||||
|
listOpencodeModels,
|
||||||
|
type OpencodeModelOption,
|
||||||
testLocalConnection,
|
testLocalConnection,
|
||||||
type AskResult,
|
type AskResult,
|
||||||
} from '@/lib/ai/local-client';
|
} from '@/lib/ai/local-client';
|
||||||
@@ -40,6 +43,9 @@ export function AiAssistantSettings() {
|
|||||||
const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY);
|
const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY);
|
||||||
const [policyLoading, setPolicyLoading] = useState(true);
|
const [policyLoading, setPolicyLoading] = useState(true);
|
||||||
const [settings, setSettings] = useState<AiLocalSettings>(() => loadAiSettings());
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -67,6 +73,7 @@ export function AiAssistantSettings() {
|
|||||||
const canUseLocal = supportsLocalLlm() && policy.entitlement.classes.includes('local');
|
const canUseLocal = supportsLocalLlm() && policy.entitlement.classes.includes('local');
|
||||||
const canUseServer = policy.entitlement.classes.includes('server');
|
const canUseServer = policy.entitlement.classes.includes('server');
|
||||||
const canUsePublic = policy.entitlement.classes.includes('public');
|
const canUsePublic = policy.entitlement.classes.includes('public');
|
||||||
|
const canUseOpencode = policy.entitlement.classes.includes('opencode');
|
||||||
|
|
||||||
// ── Local provider ──
|
// ── Local provider ──
|
||||||
const [localModels, setLocalModels] = useState<string[]>([]);
|
const [localModels, setLocalModels] = useState<string[]>([]);
|
||||||
@@ -140,6 +147,28 @@ export function AiAssistantSettings() {
|
|||||||
setDiscovery(null);
|
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 ──
|
// ── Server provider ──
|
||||||
const [serverModels, setServerModels] = useState<string[]>([]);
|
const [serverModels, setServerModels] = useState<string[]>([]);
|
||||||
const [refreshingServer, setRefreshingServer] = useState(false);
|
const [refreshingServer, setRefreshingServer] = useState(false);
|
||||||
@@ -212,9 +241,11 @@ export function AiAssistantSettings() {
|
|||||||
? canUseLocal && !!settings.localModel
|
? canUseLocal && !!settings.localModel
|
||||||
: settings.provider === 'server'
|
: settings.provider === 'server'
|
||||||
? canUseServer && !!settings.serverModel
|
? canUseServer && !!settings.serverModel
|
||||||
: settings.provider === 'public'
|
: settings.provider === 'opencode'
|
||||||
? canUsePublic && !!activeProfile && settings.publicConsentAccepted
|
? canUseOpencode && !!settings.opencodeModel
|
||||||
: false);
|
: settings.provider === 'public'
|
||||||
|
? canUsePublic && !!activeProfile && settings.publicConsentAccepted
|
||||||
|
: false);
|
||||||
|
|
||||||
const runAsk = useCallback(async () => {
|
const runAsk = useCallback(async () => {
|
||||||
setAsking(true);
|
setAsking(true);
|
||||||
@@ -224,10 +255,12 @@ export function AiAssistantSettings() {
|
|||||||
try {
|
try {
|
||||||
const key = activeProfile ? getAiApiKey(activeProfile.id) : null;
|
const key = activeProfile ? getAiApiKey(activeProfile.id) : null;
|
||||||
const result = await askMail(question.trim(), {
|
const result = await askMail(question.trim(), {
|
||||||
provider: settings.provider as 'local' | 'server' | 'public',
|
provider: settings.provider as 'local' | 'server' | 'public' | 'opencode',
|
||||||
localBaseUrl: settings.localBaseUrl,
|
localBaseUrl: settings.localBaseUrl,
|
||||||
localModel: settings.localModel,
|
localModel: settings.localModel,
|
||||||
serverModel: settings.serverModel,
|
serverModel: settings.serverModel,
|
||||||
|
opencodeModel: settings.opencodeModel,
|
||||||
|
slot: activeSlot,
|
||||||
publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null,
|
publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null,
|
||||||
});
|
});
|
||||||
setAskResult(result);
|
setAskResult(result);
|
||||||
@@ -239,15 +272,16 @@ export function AiAssistantSettings() {
|
|||||||
} finally {
|
} finally {
|
||||||
setAsking(false);
|
setAsking(false);
|
||||||
}
|
}
|
||||||
}, [question, settings, activeProfile]);
|
}, [question, settings, activeProfile, activeSlot]);
|
||||||
|
|
||||||
const providerOptions = useMemo(
|
const providerOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
...(canUseLocal ? [{ value: 'local', label: 'Local (Ollama)' }] : []),
|
...(canUseLocal ? [{ value: 'local', label: 'Local (Ollama)' }] : []),
|
||||||
...(canUseServer ? [{ value: 'server', label: 'Server (VNC-hosted)' }] : []),
|
...(canUseServer ? [{ value: 'server', label: 'Server (VNC-hosted)' }] : []),
|
||||||
|
...(canUseOpencode ? [{ value: 'opencode', label: 'OpenCode (local agent)' }] : []),
|
||||||
...(canUsePublic ? [{ value: 'public', label: 'Public (your API keys)' }] : []),
|
...(canUsePublic ? [{ value: 'public', label: 'Public (your API keys)' }] : []),
|
||||||
],
|
],
|
||||||
[canUseLocal, canUseServer, canUsePublic],
|
[canUseLocal, canUseServer, canUsePublic, canUseOpencode],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (policyLoading) {
|
if (policyLoading) {
|
||||||
@@ -295,7 +329,7 @@ export function AiAssistantSettings() {
|
|||||||
{providerOptions.length > 0 ? (
|
{providerOptions.length > 0 ? (
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={settings.provider ?? ''}
|
value={settings.provider ?? ''}
|
||||||
onChange={(v) => update('provider', v as 'local' | 'server' | 'public')}
|
onChange={(v) => update('provider', v as 'local' | 'server' | 'public' | 'opencode')}
|
||||||
options={providerOptions}
|
options={providerOptions}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -360,6 +394,38 @@ export function AiAssistantSettings() {
|
|||||||
</SettingsSection>
|
</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 && (
|
{settings.provider === 'server' && canUseServer && (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Server (VNC-hosted)"
|
title="Server (VNC-hosted)"
|
||||||
@@ -516,9 +582,18 @@ export function AiAssistantSettings() {
|
|||||||
|
|
||||||
{askResult && (
|
{askResult && (
|
||||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-4">
|
<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">
|
<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>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-foreground whitespace-pre-wrap">{askResult.answer}</p>
|
<p className="text-sm text-foreground whitespace-pre-wrap">{askResult.answer}</p>
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||||
|
import { findOpencodeServer, parseModelRef, opencodeBaseUrls, opencodePrompt } from '../opencode';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single most important behaviour under test is the SPA-catch-all trap:
|
||||||
|
* `opencode serve` answers 200 with the web UI's index.html for ANY unknown
|
||||||
|
* path, so a probe that trusts `res.ok` "verifies" endpoints that do not
|
||||||
|
* exist. That is not hypothetical — it is exactly how this integration was
|
||||||
|
* first built wrong (against an assumed OpenAI-compatible `/v1/models` that
|
||||||
|
* only ever returned HTML).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HTML_CATCHALL = {
|
||||||
|
ok: true,
|
||||||
|
headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }),
|
||||||
|
json: async () => {
|
||||||
|
throw new Error('not json');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
json: async () => body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseModelRef', () => {
|
||||||
|
it('splits providerID/modelID, keeping slashes inside the model id', () => {
|
||||||
|
expect(parseModelRef('opencode/deepseek-v4-flash-free')).toEqual({
|
||||||
|
providerID: 'opencode',
|
||||||
|
modelID: 'deepseek-v4-flash-free',
|
||||||
|
});
|
||||||
|
// Real provider ids do contain slashes (e.g. openrouter's
|
||||||
|
// "anthropic/claude-..."), so only the FIRST slash separates.
|
||||||
|
expect(parseModelRef('openrouter/anthropic/claude-sonnet-4.5')).toEqual({
|
||||||
|
providerID: 'openrouter',
|
||||||
|
modelID: 'anthropic/claude-sonnet-4.5',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed refs rather than guessing', () => {
|
||||||
|
expect(parseModelRef('noslash')).toBeNull();
|
||||||
|
expect(parseModelRef('/leading')).toBeNull();
|
||||||
|
expect(parseModelRef('trailing/')).toBeNull();
|
||||||
|
expect(parseModelRef('')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('opencodeBaseUrls', () => {
|
||||||
|
const original = process.env.OPENCODE_BASE_URL;
|
||||||
|
afterEach(() => {
|
||||||
|
if (original === undefined) delete process.env.OPENCODE_BASE_URL;
|
||||||
|
else process.env.OPENCODE_BASE_URL = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a non-loopback override — this class must never reach off-machine', () => {
|
||||||
|
process.env.OPENCODE_BASE_URL = 'https://evil.example.com';
|
||||||
|
const urls = opencodeBaseUrls();
|
||||||
|
expect(urls.some((u) => u.includes('evil.example.com'))).toBe(false);
|
||||||
|
expect(urls[0]).toMatch(/127\.0\.0\.1|localhost/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a loopback override, trying it first', () => {
|
||||||
|
process.env.OPENCODE_BASE_URL = 'http://127.0.0.1:9999/';
|
||||||
|
expect(opencodeBaseUrls()[0]).toBe('http://127.0.0.1:9999');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findOpencodeServer', () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT accept the web UI catch-all as a working API (200 + HTML)', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue(HTML_CATCHALL) as unknown as typeof fetch;
|
||||||
|
expect(await findOpencodeServer()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the real /api/model shape into providerID/modelID refs', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue(
|
||||||
|
jsonResponse({
|
||||||
|
data: [
|
||||||
|
{ id: 'deepseek-v4-flash-free', providerID: 'opencode', name: 'DeepSeek V4 Flash Free' },
|
||||||
|
{ id: 'deepseek-chat', providerID: 'deepseek' },
|
||||||
|
{ id: '', providerID: 'broken' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const found = await findOpencodeServer();
|
||||||
|
expect(found?.models.map((m) => m.ref)).toEqual([
|
||||||
|
'opencode/deepseek-v4-flash-free',
|
||||||
|
'deepseek/deepseek-chat',
|
||||||
|
]);
|
||||||
|
expect(found?.models[0].label).toBe('DeepSeek V4 Flash Free (opencode)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the server answers JSON with no models', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue(jsonResponse({ data: [] })) as unknown as typeof fetch;
|
||||||
|
expect(await findOpencodeServer()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('opencodePrompt', () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only the text parts — never the model\'s private reasoning', async () => {
|
||||||
|
global.fetch = vi.fn()
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ id: 'ses_abc' }))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
parts: [
|
||||||
|
{ type: 'step-start' },
|
||||||
|
{ type: 'reasoning', text: 'SECRET chain of thought that must not be shown' },
|
||||||
|
{ type: 'text', text: 'The visible answer.' },
|
||||||
|
{ type: 'step-finish' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const result = await opencodePrompt('http://127.0.0.1:4096', { providerID: 'opencode', modelID: 'm' }, 'sys', 'q');
|
||||||
|
expect(result).toEqual({ ok: true, answer: 'The visible answer.' });
|
||||||
|
if (result.ok) expect(result.answer).not.toContain('SECRET');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails cleanly when no session can be created', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue(HTML_CATCHALL) as unknown as typeof fetch;
|
||||||
|
const result = await opencodePrompt('http://127.0.0.1:4096', { providerID: 'p', modelID: 'm' }, undefined, 'q');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails cleanly when the reply carries no text part', async () => {
|
||||||
|
global.fetch = vi.fn()
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ id: 'ses_abc' }))
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ parts: [{ type: 'step-start' }, { type: 'reasoning', text: 'only thinking' }] })) as unknown as typeof fetch;
|
||||||
|
const result = await opencodePrompt('http://127.0.0.1:4096', { providerID: 'p', modelID: 'm' }, undefined, 'q');
|
||||||
|
expect(result).toEqual({ ok: false, error: 'OpenCode returned no message content' });
|
||||||
|
});
|
||||||
|
});
|
||||||
+93
-9
@@ -150,6 +150,49 @@ export async function chatPublic(
|
|||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
// reason the `server` class is: the renderer's origin is a random localhost
|
||||||
|
// port that changes every desktop launch, so a direct fetch would need
|
||||||
|
// opencode's CORS allowlist updated on every start. Same-origin sidesteps it.
|
||||||
|
//
|
||||||
|
// It is NOT OpenAI-compatible (its `/v1/*` paths only answer 200 because a
|
||||||
|
// web-UI catch-all serves index.html for anything unknown) - the server-side
|
||||||
|
// helper lib/ai/opencode.ts speaks its real session API and documents that
|
||||||
|
// trap. The reason to have it as its own class rather than "just another BYOK profile": opencode
|
||||||
|
// owns provider auth itself, so there is no API key for this app to hold, and
|
||||||
|
// its /api/model endpoint gives a REAL model list to pick from instead of
|
||||||
|
// asking the user to type an exact provider-specific model id from memory.
|
||||||
|
|
||||||
|
export interface OpencodeModelOption {
|
||||||
|
/** "providerID/modelID" — what gets stored and sent back on ask. */
|
||||||
|
ref: string;
|
||||||
|
/** Human-readable, e.g. "DeepSeek V4 Flash Free (opencode)". */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOpencodeModels(): Promise<OpencodeModelOption[]> {
|
||||||
|
const res = await fetch('/api/ai/opencode/models');
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
// 503 carries real setup guidance ("start opencode serve ..."), so surface
|
||||||
|
// the server's own message rather than a bare status code.
|
||||||
|
if (!res.ok) throw new Error(body?.error || `OpenCode returned ${res.status}`);
|
||||||
|
return (body?.models ?? []) as OpencodeModelOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chatOpencode(model: string, messages: ChatMessage[]): Promise<string> {
|
||||||
|
const res = await fetch('/api/ai/opencode/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model, messages }),
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(body?.error || `OpenCode returned ${res.status}`);
|
||||||
|
if (!body?.answer) throw new Error('OpenCode returned no message content');
|
||||||
|
return body.answer as string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Retrieval: two legs run in parallel and get Reciprocal-Rank-Fused
|
// ── Retrieval: two legs run in parallel and get Reciprocal-Rank-Fused
|
||||||
// (docs/AI-ASSISTANT-CONCEPT.md §7 steps 2-3), exactly like the doc
|
// (docs/AI-ASSISTANT-CONCEPT.md §7 steps 2-3), exactly like the doc
|
||||||
// describes — this is real, not a single degraded leg wearing SourceRef's
|
// describes — this is real, not a single degraded leg wearing SourceRef's
|
||||||
@@ -179,6 +222,18 @@ export interface AskResult {
|
|||||||
sources: AskSource[];
|
sources: AskSource[];
|
||||||
/** True when the question was answered without any retrieved context. */
|
/** True when the question was answered without any retrieved context. */
|
||||||
unaugmented: boolean;
|
unaugmented: boolean;
|
||||||
|
/**
|
||||||
|
* WHY the answer was unaugmented — the two cases need different words and
|
||||||
|
* different user action, and conflating them is actively misleading:
|
||||||
|
* - 'no-index': the local index isn't available at all (not the desktop
|
||||||
|
* app, no keyring, not signed in, or never built). Told to build it.
|
||||||
|
* - 'no-match': the index IS there and answered; this query just matched
|
||||||
|
* nothing. Told to rephrase. Recency questions ("the last mail", "all
|
||||||
|
* mail in July") land here by design: the index ranks by keyword
|
||||||
|
* relevance and has no notion of "latest" or a date range.
|
||||||
|
* - 'augmented': context was found and used.
|
||||||
|
*/
|
||||||
|
retrievalState: 'augmented' | 'no-match' | 'no-index';
|
||||||
/** True the moment this call consumed a previously-unassigned licensed
|
/** True the moment this call consumed a previously-unassigned licensed
|
||||||
* seat on the `server` class (lib/ai/entitlement.ts). Always false for
|
* seat on the `server` class (lib/ai/entitlement.ts). Always false for
|
||||||
* `local`/`public`, which aren't entitlement-gated. */
|
* `local`/`public`, which aren't entitlement-gated. */
|
||||||
@@ -213,11 +268,25 @@ interface RetrievedContext {
|
|||||||
hits: Array<{ id: string; title: string }>;
|
hits: Array<{ id: string; title: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLocalLeg(question: string): Promise<{ scored: Scored<SourceRef>[]; text: Map<string, { title: string; snippet: string }> }> {
|
/** Set by the most recent retrieveContext() call so askMail can report WHY an
|
||||||
const empty = { scored: [] as Scored<SourceRef>[], text: new Map<string, { title: string; snippet: string }>() };
|
* answer was unaugmented. Module-scoped rather than threaded through the
|
||||||
|
* return type because retrieveContext returns null precisely in the case we
|
||||||
|
* need to describe, and a null can't carry a reason. Single-threaded UI, one
|
||||||
|
* question at a time - no interleaving to worry about. */
|
||||||
|
let lastLocalIndexReachable = false;
|
||||||
|
|
||||||
|
async function fetchLocalLeg(question: string, slot?: number): Promise<{ scored: Scored<SourceRef>[]; text: Map<string, { title: string; snippet: string }>; indexReachable: boolean }> {
|
||||||
|
const empty = { scored: [] as Scored<SourceRef>[], text: new Map<string, { title: string; snippet: string }>(), indexReachable: false };
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/offline/search?q=${encodeURIComponent(question)}&limit=6`);
|
// `slot` is load-bearing, not optional decoration: the INDEXER writes under
|
||||||
if (!res.ok) return empty; // 404/503 — no local index this session, not an error
|
// the active account's cookie slot (lib/mail-index-client.ts's catchUpIndex
|
||||||
|
// passes it), so a search that omits it resolves to whatever account the
|
||||||
|
// multi-slot resolver finds FIRST and can read a different - usually empty -
|
||||||
|
// account's index. Single-account installs never noticed; a real
|
||||||
|
// multi-account/shared-mailbox setup reads the wrong store every time.
|
||||||
|
const slotQuery = typeof slot === 'number' ? `&slot=${slot}` : '';
|
||||||
|
const res = await fetch(`/api/offline/search?q=${encodeURIComponent(question)}&limit=6${slotQuery}`);
|
||||||
|
if (!res.ok) return empty; // 404/503/401 — no usable index this session, not an error
|
||||||
const body = (await res.json()) as OfflineSearchResponse;
|
const body = (await res.json()) as OfflineSearchResponse;
|
||||||
if (!body.ok) return empty;
|
if (!body.ok) return empty;
|
||||||
const text = new Map(body.hits.map((h) => [h.id, { title: h.title, snippet: h.snippet ?? '' }]));
|
const text = new Map(body.hits.map((h) => [h.id, { title: h.title, snippet: h.snippet ?? '' }]));
|
||||||
@@ -225,7 +294,11 @@ async function fetchLocalLeg(question: string): Promise<{ scored: Scored<SourceR
|
|||||||
ref: { product: 'mail' as const, accountId: h.jmapAccountId, collectionId: '', itemId: h.id, chunkIx: 0 },
|
ref: { product: 'mail' as const, accountId: h.jmapAccountId, collectionId: '', itemId: h.id, chunkIx: 0 },
|
||||||
score: 1 / (i + 1), // rank position is all reciprocalRankFusion reads
|
score: 1 / (i + 1), // rank position is all reciprocalRankFusion reads
|
||||||
}));
|
}));
|
||||||
return { scored, text };
|
// Reachable even with zero hits: a 200 means the index answered. That
|
||||||
|
// distinction is the whole point - "the index isn't there" and "the index
|
||||||
|
// is there and this query matched nothing" are different facts the user
|
||||||
|
// deserves to be told apart (see AskResult.retrievalState).
|
||||||
|
return { scored, text, indexReachable: true };
|
||||||
} catch {
|
} catch {
|
||||||
return empty;
|
return empty;
|
||||||
}
|
}
|
||||||
@@ -250,8 +323,9 @@ async function fetchServerLeg(question: string): Promise<{ scored: Scored<Source
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function retrieveContext(question: string): Promise<RetrievedContext | null> {
|
async function retrieveContext(question: string, slot?: number): Promise<RetrievedContext | null> {
|
||||||
const [local, server] = await Promise.all([fetchLocalLeg(question), fetchServerLeg(question)]);
|
const [local, server] = await Promise.all([fetchLocalLeg(question, slot), fetchServerLeg(question)]);
|
||||||
|
lastLocalIndexReachable = local.indexReachable;
|
||||||
const fused = reciprocalRankFusion([local.scored, server.scored], 6);
|
const fused = reciprocalRankFusion([local.scored, server.scored], 6);
|
||||||
if (fused.length === 0) return null;
|
if (fused.length === 0) return null;
|
||||||
|
|
||||||
@@ -293,11 +367,15 @@ export interface ResolvedPublicProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AskConfig {
|
export interface AskConfig {
|
||||||
provider: 'local' | 'server' | 'public';
|
provider: 'local' | 'server' | 'public' | 'opencode';
|
||||||
localBaseUrl: string;
|
localBaseUrl: string;
|
||||||
localModel: string | null;
|
localModel: string | null;
|
||||||
serverModel: string | null;
|
serverModel: string | null;
|
||||||
publicProfile: ResolvedPublicProfile | null;
|
publicProfile: ResolvedPublicProfile | 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. */
|
||||||
|
slot?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function askMail(question: string, config: AskConfig): Promise<AskResult> {
|
export async function askMail(question: string, config: AskConfig): Promise<AskResult> {
|
||||||
@@ -310,8 +388,11 @@ export async function askMail(question: string, config: AskConfig): Promise<AskR
|
|||||||
if (config.provider === 'public' && !config.publicProfile) {
|
if (config.provider === 'public' && !config.publicProfile) {
|
||||||
throw new Error('No provider profile selected');
|
throw new Error('No provider profile selected');
|
||||||
}
|
}
|
||||||
|
if (config.provider === 'opencode' && !config.opencodeModel) {
|
||||||
|
throw new Error('No OpenCode model selected');
|
||||||
|
}
|
||||||
|
|
||||||
const retrieved = await retrieveContext(question);
|
const retrieved = await retrieveContext(question, config.slot);
|
||||||
const messages = retrieved
|
const messages = retrieved
|
||||||
? buildPrompt(question, retrieved.contextBlock)
|
? buildPrompt(question, retrieved.contextBlock)
|
||||||
: [{ role: 'user' as const, content: question }];
|
: [{ role: 'user' as const, content: question }];
|
||||||
@@ -325,6 +406,8 @@ export async function askMail(question: string, config: AskConfig): Promise<AskR
|
|||||||
const result = await chatServer(config.serverModel as string, messages);
|
const result = await chatServer(config.serverModel as string, messages);
|
||||||
answer = result.answer;
|
answer = result.answer;
|
||||||
seatJustAssigned = result.seatJustAssigned;
|
seatJustAssigned = result.seatJustAssigned;
|
||||||
|
} else if (config.provider === 'opencode') {
|
||||||
|
answer = await chatOpencode(config.opencodeModel as string, messages);
|
||||||
} else {
|
} else {
|
||||||
answer = await chatLocal(config.localBaseUrl, config.localModel as string, messages);
|
answer = await chatLocal(config.localBaseUrl, config.localModel as string, messages);
|
||||||
}
|
}
|
||||||
@@ -333,6 +416,7 @@ export async function askMail(question: string, config: AskConfig): Promise<AskR
|
|||||||
answer,
|
answer,
|
||||||
sources: (retrieved?.hits ?? []).map((h) => ({ id: h.id, subject: h.title })),
|
sources: (retrieved?.hits ?? []).map((h) => ({ id: h.id, subject: h.title })),
|
||||||
unaugmented: !retrieved,
|
unaugmented: !retrieved,
|
||||||
|
retrievalState: retrieved ? 'augmented' : lastLocalIndexReachable ? 'no-match' : 'no-index',
|
||||||
seatJustAssigned,
|
seatJustAssigned,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// (docs/AI-ASSISTANT-CONCEPT.md §12's P0/P5), and migrating it into the
|
// (docs/AI-ASSISTANT-CONCEPT.md §12's P0/P5), and migrating it into the
|
||||||
// shared store belongs with whichever phase makes these settings real
|
// shared store belongs with whichever phase makes these settings real
|
||||||
// product config rather than a local-AI test harness.
|
// product config rather than a local-AI test harness.
|
||||||
export type AiProvider = 'local' | 'server' | 'public';
|
export type AiProvider = 'local' | 'server' | 'public' | 'opencode';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A named public-provider configuration (BYOK). Decision 2026-08-05: several
|
* A named public-provider configuration (BYOK). Decision 2026-08-05: several
|
||||||
@@ -25,6 +25,7 @@ export interface AiLocalSettings {
|
|||||||
localBaseUrl: string;
|
localBaseUrl: string;
|
||||||
localModel: string | null;
|
localModel: string | null;
|
||||||
serverModel: string | null;
|
serverModel: string | null;
|
||||||
|
opencodeModel: string | null;
|
||||||
publicProfiles: AiProviderProfile[];
|
publicProfiles: AiProviderProfile[];
|
||||||
/** Which saved profile answers the next question. Not a permanent default —
|
/** Which saved profile answers the next question. Not a permanent default —
|
||||||
* the "Try it" UI lets this be changed per question. */
|
* the "Try it" UI lets this be changed per question. */
|
||||||
@@ -39,6 +40,7 @@ export const DEFAULT_AI_SETTINGS: AiLocalSettings = {
|
|||||||
localBaseUrl: 'http://127.0.0.1:11434',
|
localBaseUrl: 'http://127.0.0.1:11434',
|
||||||
localModel: null,
|
localModel: null,
|
||||||
serverModel: null,
|
serverModel: null,
|
||||||
|
opencodeModel: null,
|
||||||
publicProfiles: [],
|
publicProfiles: [],
|
||||||
activeProfileId: null,
|
activeProfileId: null,
|
||||||
publicConsentAccepted: false,
|
publicConsentAccepted: false,
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// Shared server-side helpers for the OpenCode AI class.
|
||||||
|
//
|
||||||
|
// OpenCode (github.com/sst/opencode) runs as a local headless server
|
||||||
|
// (`opencode serve`) — the same runtime Paperclip drives as an agent adapter.
|
||||||
|
// Here it is used only as a one-shot chat backend for the mail assistant, so
|
||||||
|
// its HTTP surface is enough and no subprocess needs spawning from this app.
|
||||||
|
//
|
||||||
|
// IT IS NOT OpenAI-COMPATIBLE, despite `/v1/models` and `/v1/chat/completions`
|
||||||
|
// both answering 200: opencode serves a web UI from the same port with a
|
||||||
|
// catch-all route, so ANY unknown path returns the SPA's index.html with a 200.
|
||||||
|
// Checking `res.ok` alone therefore "verifies" endpoints that do not exist —
|
||||||
|
// verified the hard way, by believing exactly that before reading a body.
|
||||||
|
// Every probe here validates the parsed SHAPE, never the status code alone.
|
||||||
|
//
|
||||||
|
// The real API (from the server's own /doc OpenAPI spec):
|
||||||
|
// GET /api/model -> { data: [{ id, providerID, name, ... }] }
|
||||||
|
// POST /session -> { id: "ses_..." }
|
||||||
|
// POST /session/{id}/message -> { info, parts: [{ type: 'text', text }, ...] }
|
||||||
|
//
|
||||||
|
// Address resolution is deliberately narrow: loopback only. This class exists
|
||||||
|
// to reach a runtime on the user's OWN machine — pointing it at a remote host
|
||||||
|
// would silently turn "local, no keys, nothing leaves the device" into the
|
||||||
|
// opposite, so a non-loopback OPENCODE_BASE_URL is refused rather than honoured.
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URLS = ['http://127.0.0.1:4096', 'http://localhost:4096'];
|
||||||
|
const PROBE_TIMEOUT_MS = 2500;
|
||||||
|
const PROMPT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
function isLoopback(raw: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
return url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '::1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Candidate addresses, honouring an explicit OPENCODE_BASE_URL when it is
|
||||||
|
* loopback. `opencode serve` defaults to a RANDOM port (`--port 0`), so the
|
||||||
|
* conventional 4096 only finds a server deliberately started there; the env
|
||||||
|
* var is how someone on another port points us at it. */
|
||||||
|
export function opencodeBaseUrls(): string[] {
|
||||||
|
const configured = process.env.OPENCODE_BASE_URL?.trim();
|
||||||
|
if (configured) {
|
||||||
|
if (!isLoopback(configured)) {
|
||||||
|
console.error('[opencode] ignoring non-loopback OPENCODE_BASE_URL:', configured);
|
||||||
|
return DEFAULT_BASE_URLS;
|
||||||
|
}
|
||||||
|
return [configured.replace(/\/+$/, ''), ...DEFAULT_BASE_URLS];
|
||||||
|
}
|
||||||
|
return DEFAULT_BASE_URLS;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpencodeModelListResponse {
|
||||||
|
data?: Array<{ id?: string; providerID?: string; name?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpencodeModel {
|
||||||
|
/** "providerID/modelID" — the reference shown in the picker and stored in
|
||||||
|
* settings, matching how opencode itself names models on the CLI. */
|
||||||
|
ref: string;
|
||||||
|
providerID: string;
|
||||||
|
modelID: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits the stored "providerID/modelID" reference back into the pair the
|
||||||
|
* message API wants. Returns null for anything malformed rather than
|
||||||
|
* guessing, so a corrupted setting surfaces as a clear error. */
|
||||||
|
export function parseModelRef(ref: string): { providerID: string; modelID: string } | null {
|
||||||
|
const slash = ref.indexOf('/');
|
||||||
|
if (slash <= 0 || slash === ref.length - 1) return null;
|
||||||
|
return { providerID: ref.slice(0, slash), modelID: ref.slice(slash + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url: string, init: RequestInit, timeoutMs: number): Promise<unknown | null> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { ...init, signal: controller.signal });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
// The SPA catch-all returns HTML with a 200 for unknown paths — see the
|
||||||
|
// module header. Content-type is what actually distinguishes a real API
|
||||||
|
// response from the web UI.
|
||||||
|
const contentType = res.headers.get('content-type') ?? '';
|
||||||
|
if (!contentType.includes('application/json')) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First reachable candidate that answers /api/model with a real model list. */
|
||||||
|
export async function findOpencodeServer(): Promise<{ baseUrl: string; models: OpencodeModel[] } | null> {
|
||||||
|
for (const baseUrl of opencodeBaseUrls()) {
|
||||||
|
const body = (await fetchJson(`${baseUrl}/api/model`, {}, PROBE_TIMEOUT_MS)) as OpencodeModelListResponse | null;
|
||||||
|
if (!body || !Array.isArray(body.data)) continue;
|
||||||
|
const models: OpencodeModel[] = body.data
|
||||||
|
.filter((m): m is { id: string; providerID: string; name?: string } =>
|
||||||
|
typeof m?.id === 'string' && !!m.id && typeof m?.providerID === 'string' && !!m.providerID)
|
||||||
|
.map((m) => ({
|
||||||
|
ref: `${m.providerID}/${m.id}`,
|
||||||
|
providerID: m.providerID,
|
||||||
|
modelID: m.id,
|
||||||
|
label: m.name ? `${m.name} (${m.providerID})` : `${m.providerID}/${m.id}`,
|
||||||
|
}));
|
||||||
|
if (models.length > 0) return { baseUrl, models };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpencodeMessageResponse {
|
||||||
|
parts?: Array<{ type?: string; text?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One prompt, one answer. Creates a throwaway session per question — this is
|
||||||
|
* a stateless "ask about my mail" box, not a running conversation, and a fresh
|
||||||
|
* session keeps one question's context from leaking into the next.
|
||||||
|
*
|
||||||
|
* `system` is passed as opencode's own system field rather than as a message
|
||||||
|
* part, so the retrieved-mail prompt keeps the same shape it has for every
|
||||||
|
* other provider class (see buildPrompt in lib/ai/local-client.ts).
|
||||||
|
*/
|
||||||
|
export async function opencodePrompt(
|
||||||
|
baseUrl: string,
|
||||||
|
model: { providerID: string; modelID: string },
|
||||||
|
system: string | undefined,
|
||||||
|
userText: string,
|
||||||
|
): Promise<{ ok: true; answer: string } | { ok: false; error: string }> {
|
||||||
|
const session = (await fetchJson(
|
||||||
|
`${baseUrl}/session`,
|
||||||
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' },
|
||||||
|
PROBE_TIMEOUT_MS,
|
||||||
|
)) as { id?: string } | null;
|
||||||
|
if (!session?.id) return { ok: false, error: 'OpenCode would not start a session' };
|
||||||
|
|
||||||
|
const body = (await fetchJson(
|
||||||
|
`${baseUrl}/session/${encodeURIComponent(session.id)}/message`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
...(system ? { system } : {}),
|
||||||
|
parts: [{ type: 'text', text: userText }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
PROMPT_TIMEOUT_MS,
|
||||||
|
)) as OpencodeMessageResponse | null;
|
||||||
|
|
||||||
|
if (!body) return { ok: false, error: 'OpenCode returned no usable response' };
|
||||||
|
// A reply carries several parts (step-start / reasoning / text / step-finish).
|
||||||
|
// Only the `text` parts are the answer; `reasoning` is the model's private
|
||||||
|
// chain of thought and must not be shown as the reply.
|
||||||
|
const answer = (body.parts ?? [])
|
||||||
|
.filter((p) => p.type === 'text' && typeof p.text === 'string' && p.text.trim())
|
||||||
|
.map((p) => (p.text as string).trim())
|
||||||
|
.join('\n\n');
|
||||||
|
if (!answer) return { ok: false, error: 'OpenCode returned no message content' };
|
||||||
|
return { ok: true, answer };
|
||||||
|
}
|
||||||
+1
-1
@@ -20,7 +20,7 @@
|
|||||||
// lib/ai/entitlement.ts — since it's the one class with a real,
|
// lib/ai/entitlement.ts — since it's the one class with a real,
|
||||||
// centrally-borne cost.
|
// centrally-borne cost.
|
||||||
|
|
||||||
export type AiClass = 'local' | 'server' | 'public';
|
export type AiClass = 'local' | 'server' | 'public' | 'opencode';
|
||||||
|
|
||||||
export interface AiEntitlement {
|
export interface AiEntitlement {
|
||||||
licensed: boolean;
|
licensed: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user