promote: OpenCode provider class + retrieval slot/messaging fixes (dev→main)

This commit is contained in:
Bernd Rodler
2026-08-07 09:15:57 +02:00
17 changed files with 1053 additions and 87 deletions
+1
View File
@@ -73,3 +73,4 @@ vnc/plugins/smime/smime.zip
# macOS # macOS
.DS_Store .DS_Store
electron-ai-local-index-result.png
+31 -8
View File
@@ -67,6 +67,7 @@ import { findDraftIdentityId, resolveReplyFrom, type ReplyFromResolution } from
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react"; import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
import { AiAskButton } from "@/components/ai/ai-ask-button";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
@@ -1070,13 +1071,31 @@ export default function Home() {
// buildStatePollingRequest covers Mailbox/Email/Calendar/CalendarEvent/ // buildStatePollingRequest covers Mailbox/Email/Calendar/CalendarEvent/
// SieveScript only). So backfill a bounded recent window once per session, // SieveScript only). So backfill a bounded recent window once per session,
// after push is wired. Fire-and-forget; a no-op outside Electron. // after push is wired. Fire-and-forget; a no-op outside Electron.
const catchUpTimer = setTimeout(() => { //
void (async () => { // RETRIED, not one-shot. The first attempt races the login flow's own
// POST /api/auth/stalwart-context (stores/auth-store.ts's
// syncStalwartAuthContext) - if the index route is hit before that cookie
// is minted it 401s, and a single silent attempt would leave the index
// empty until the next app restart with nothing telling anyone why (this
// exact silence hid the packaged app's missing-SESSION_SECRET bug against
// a real mailbox). requestIndex() already distinguishes the permanent
// cases (404/503 -> unavailable) from the retryable ones, so retrying is
// cheap and self-limiting.
const catchUpRetryDelaysMs = [4000, 20000, 60000];
let catchUpCancelled = false;
let catchUpTimer: ReturnType<typeof setTimeout> | undefined;
const runCatchUp = async (attempt: number) => {
if (catchUpCancelled) return;
try { try {
const { catchUpIndex } = await import('@/lib/mail-index-client'); const { catchUpIndex } = await import('@/lib/mail-index-client');
await catchUpIndex( const result = await catchUpIndex(
useAccountStore.getState().getActiveAccount()?.cookieSlot, useAccountStore.getState().getActiveAccount()?.cookieSlot,
); );
if (!result.ok && !result.unavailable && attempt + 1 < catchUpRetryDelaysMs.length) {
catchUpTimer = setTimeout(() => void runCatchUp(attempt + 1), catchUpRetryDelaysMs[attempt + 1]);
return;
}
} catch { } catch {
/* the index is optional */ /* the index is optional */
} }
@@ -1086,21 +1105,24 @@ export default function Home() {
// drains the backlog. One cycle is bounded, so a first sync of a large // drains the backlog. One cycle is bounded, so a first sync of a large
// mailbox needs several - `chainSync` runs them with a hard cap. // mailbox needs several - `chainSync` runs them with a hard cap.
// //
// Sequenced AFTER the index rather than in parallel: both write the same // Sequenced AFTER the index (including its retries) rather than in
// SQLite file, and although `busy_timeout` makes concurrent writers safe, // parallel: both write the same SQLite file, and although `busy_timeout`
// there is no reason to spend the contention during first paint. // makes concurrent writers safe, there is no reason to spend the
// contention during first paint.
if (catchUpCancelled) return;
try { try {
const { chainSync } = await import('@/lib/offline-replica-client'); const { chainSync } = await import('@/lib/offline-replica-client');
await chainSync({ slot: useAccountStore.getState().getActiveAccount()?.cookieSlot }); await chainSync({ slot: useAccountStore.getState().getActiveAccount()?.cookieSlot });
} catch { } catch {
/* the replica is optional */ /* the replica is optional */
} }
})(); };
// Deliberately after the initial mailbox fetch settles: the catch-up is a // Deliberately after the initial mailbox fetch settles: the catch-up is a
// background nicety and must not compete with first paint. // background nicety and must not compete with first paint.
}, 4000); catchUpTimer = setTimeout(() => void runCatchUp(0), catchUpRetryDelaysMs[0]);
return () => { return () => {
catchUpCancelled = true;
clearTimeout(catchUpTimer); clearTimeout(catchUpTimer);
cleanups.forEach((fn) => fn()); cleanups.forEach((fn) => fn());
}; };
@@ -3164,6 +3186,7 @@ export default function Home() {
</span> </span>
)} )}
</button> </button>
<AiAskButton />
</div> </div>
</div> </div>
+3 -2
View File
@@ -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 (
+94
View File
@@ -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 });
}
}
+43
View File
@@ -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' } },
);
}
+7
View File
@@ -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,
+264
View File
@@ -0,0 +1,264 @@
'use client';
// The AI Assistant's entry point in the MAIN mail view — a Sparkles button in
// the search toolbar that opens a compact Ask dialog. Until this existed, the
// only way to ask the assistant anything was the "Try it" box buried in
// Settings → AI Assistant, which is a configuration screen, not a workflow.
//
// Deliberately reuses the exact same wire client (lib/ai/local-client's
// askMail) and the exact same persisted provider settings as the Settings
// pane — this is a second door to the same room, not a second room. When no
// provider is configured yet, the dialog deep-links to the Settings pane
// (where local-discovery offers the one-click Connect) instead of duplicating
// that setup flow here.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
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';
import { loadAiSettings, type AiLocalSettings } from '@/lib/ai/local-settings';
import { askMail, type AskResult } from '@/lib/ai/local-client';
function useAiPolicy(): { policy: AiPolicy; loaded: boolean } {
const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await apiFetch('/api/ai/policy');
if (res.ok && !cancelled) setPolicy(await res.json());
} catch {
/* stays at DEFAULT (disabled) — the button simply doesn't render */
} finally {
if (!cancelled) setLoaded(true);
}
})();
return () => {
cancelled = true;
};
}, []);
return { policy, loaded };
}
/** Mirrors the Settings pane's canAsk gating: is any provider actually ready? */
function providerConfigured(settings: AiLocalSettings, policy: AiPolicy): boolean {
const classes = policy.entitlement.classes;
switch (settings.provider) {
case 'local':
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;
}
default:
return false;
}
}
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
// say "not configured".
const [settings, setSettings] = useState<AiLocalSettings>(() => loadAiSettings());
const [question, setQuestion] = useState('');
const [asking, setAsking] = useState(false);
const [askResult, setAskResult] = useState<AskResult | null>(null);
const [askError, setAskError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const openDialog = useCallback(() => {
setSettings(loadAiSettings());
setAskResult(null);
setAskError(null);
setOpen(true);
}, []);
useEffect(() => {
if (!open) return;
textareaRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open]);
const configured = providerConfigured(settings, policy);
const canAsk = configured && question.trim().length > 0 && !asking;
const runAsk = useCallback(async () => {
if (!canAsk) return;
setAsking(true);
setAskError(null);
setAskResult(null);
try {
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' | '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);
} catch (err) {
setAskError(err instanceof Error ? err.message : String(err));
} finally {
setAsking(false);
}
}, [canAsk, question, settings, activeSlot]);
const goToSettings = useCallback(() => {
// The Settings page's one-shot deep-link channel (see readPersistedTab in
// app/(main)/[locale]/settings/page.tsx) — lands directly on the AI pane,
// where local-discovery's Connect banner does the actual setup.
try {
sessionStorage.setItem('settings-deep-link-tab', 'ai_assistant');
} catch {
/* private mode — the settings page just opens on its default tab */
}
setOpen(false);
router.push('/settings');
}, [router]);
// Hidden entirely when the admin gate is off or no provider class is
// allowed — same visibility rule as the Settings pane itself.
if (!loaded || !policy.enabled || policy.entitlement.classes.length === 0) return null;
return (
<>
<button
type="button"
onClick={openDialog}
className="flex-shrink-0 p-2 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="AI Assistant"
aria-label="AI Assistant"
data-tour="ai-assistant"
>
<Sparkles className="w-4 h-4" />
</button>
{open && (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-[10vh]"
onMouseDown={(e) => {
if (e.target === e.currentTarget) setOpen(false);
}}
role="dialog"
aria-modal="true"
aria-label="AI Assistant"
>
<div className="w-full max-w-xl rounded-xl border border-border bg-popover text-popover-foreground shadow-2xl">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary" />
<h2 className="text-sm font-semibold">AI Assistant</h2>
</div>
<button
type="button"
onClick={() => setOpen(false)}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-col gap-3 p-4">
{!configured ? (
<>
<p className="text-sm text-muted-foreground">
No AI provider is set up yet. Pick one in Settings if Ollama is running on this machine, a
one-click Connect is waiting there.
</p>
<div>
<Button size="sm" onClick={goToSettings}>
<Settings2 className="w-3.5 h-3.5 me-1.5" />
Open AI settings
</Button>
</div>
</>
) : (
<>
<textarea
ref={textareaRef}
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') void runAsk();
}}
rows={3}
placeholder="Ask a question about your mail…"
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 resize-y"
/>
<div className="flex items-center gap-3">
<Button size="sm" onClick={() => void runAsk()} disabled={!canAsk}>
{asking && <Loader2 className="w-3.5 h-3.5 me-1.5 animate-spin" />}
Ask
</Button>
<span className="text-xs text-muted-foreground"> to send</span>
</div>
{askError && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3">
<AlertTriangle className="w-4 h-4 mt-0.5 text-destructive shrink-0" />
<p className="text-sm text-destructive">{askError}</p>
</div>
)}
{askResult && (
<div className={cn('flex flex-col gap-2 rounded-lg border border-border p-4', 'max-h-[45vh] overflow-y-auto')}>
{askResult.retrievalState === 'no-index' && (
<p className="text-xs text-muted-foreground italic">
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>
{askResult.sources.length > 0 && (
<div className="flex flex-col gap-0.5 border-t border-border pt-2 mt-1">
<span className="text-xs font-medium text-muted-foreground">Sources</span>
{askResult.sources.map((s, i) => (
<span key={s.id} className="text-xs text-muted-foreground truncate">
[{i + 1}] {s.subject}
</span>
))}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
</div>
)}
</>
);
}
+81 -6
View File
@@ -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,6 +241,8 @@ export function AiAssistantSettings() {
? canUseLocal && !!settings.localModel ? canUseLocal && !!settings.localModel
: settings.provider === 'server' : settings.provider === 'server'
? canUseServer && !!settings.serverModel ? canUseServer && !!settings.serverModel
: settings.provider === 'opencode'
? canUseOpencode && !!settings.opencodeModel
: settings.provider === 'public' : settings.provider === 'public'
? canUsePublic && !!activeProfile && settings.publicConsentAccepted ? canUsePublic && !!activeProfile && settings.publicConsentAccepted
: false); : false);
@@ -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 &amp; 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> (&ldquo;what did
Anna say about the invoice?&rdquo;) work better than ones about recency
(&ldquo;the last mail&rdquo;).
</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,4 +5,4 @@ kind: Component
images: images:
- name: vncmail-plus - name: vncmail-plus
newName: registry.gitlab.vnc.biz/gitlab-instance-b9b5cf2f/vncmail-plus newName: registry.gitlab.vnc.biz/gitlab-instance-b9b5cf2f/vncmail-plus
newTag: sha-2a8778c9 newTag: sha-1f199fdc
+37 -35
View File
@@ -68,7 +68,13 @@ test.describe('Electron desktop shell - local LLM answers from the real encrypte
VNCMAIL_TEST_FIXED_PORT: String(FIXED_PORT), VNCMAIL_TEST_FIXED_PORT: String(FIXED_PORT),
DEV_MOCK_JMAP: 'true', DEV_MOCK_JMAP: 'true',
JMAP_SERVER_URL: `${ORIGIN}/api/dev-jmap`, JMAP_SERVER_URL: `${ORIGIN}/api/dev-jmap`,
SESSION_SECRET: 'electron-ai-local-index-verify-32-chars-min', // Deliberately NO SESSION_SECRET: the desktop shell must supply its
// own per-install secret (electron/main.ts's ensureSessionSecretFile)
// or the auth-context cookie can never be minted and every index
// route 401s. Up to 1.7.8 the packaged app shipped exactly that way,
// and every test masked it by injecting a secret here — this test
// now proves the shell stands on its own.
SESSION_SECRET: '',
// Deliberately UNSET: isolates grounding to the local FTS leg (see // Deliberately UNSET: isolates grounding to the local FTS leg (see
// module header) — the server embeddings leg 404s cleanly instead // module header) — the server embeddings leg 404s cleanly instead
// of silently also being able to answer the question. // of silently also being able to answer the question.
@@ -93,41 +99,37 @@ test.describe('Electron desktop shell - local LLM answers from the real encrypte
await devLoginContainer.getByRole('button').click(); await devLoginContainer.getByRole('button').click();
await appWindow.waitForURL((url) => !url.pathname.includes('login'), { timeout: 20000 }); await appWindow.waitForURL((url) => !url.pathname.includes('login'), { timeout: 20000 });
// ── 2. Build the real encrypted local index: delta-sync the mock // ── 2. Wait for the AUTOMATIC boot catch-up to build the encrypted
// account's mail into the replica store, then write it into SQLite/FTS5. // index — NO manual /api/offline/sync or /api/offline/reindex calls.
// Chains /api/offline/sync while unfinishedWork is true, capped so a // This is the load-bearing change from this spec's first version: the
// real bug can't hang the test forever. ── // real user experience is "log in, index appears on its own", and the
const syncOutcome = await appWindow.evaluate(async () => { // first version's manual calls proved only that the plumbing COULD
let unfinished = true; // work, not that anything actually drives it. page.tsx schedules the
let calls = 0; // first attempt ~4s after the authenticated mail page mounts (with
const statuses: number[] = []; // retries at 20s/60s for the login race), so poll generously. ──
while (unfinished && calls < 10) { await expect
const res = await fetch('/api/offline/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); .poll(
statuses.push(res.status); async () =>
if (!res.ok) break; appWindow.evaluate(async () => {
const body = await res.json();
unfinished = body.unfinishedWork === true;
calls++;
}
const reindexRes = await fetch('/api/offline/reindex', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ catchUp: true }) });
return { syncStatuses: statuses, syncCalls: calls, reindexStatus: reindexRes.status, reindexBody: await reindexRes.json().catch(() => null) };
});
console.log('[ai-local-index] sync+reindex outcome:', JSON.stringify(syncOutcome));
expect(syncOutcome.syncStatuses.every((s) => s === 200)).toBe(true);
expect(syncOutcome.reindexStatus).toBe(200);
// ── 3. Prove the local index itself is real and queryable BEFORE
// touching the LLM at all — isolates "is the SQLite/FTS5 index working"
// from "did the model use it correctly". ──
const directSearch = await appWindow.evaluate(async () => {
const res = await fetch(`/api/offline/search?q=${encodeURIComponent('Villa sul Lago check-in')}&limit=6`); const res = await fetch(`/api/offline/search?q=${encodeURIComponent('Villa sul Lago check-in')}&limit=6`);
return { status: res.status, body: await res.json().catch(() => null) }; if (!res.ok) return `http-${res.status}`;
}); const body = await res.json().catch(() => null);
console.log('[ai-local-index] direct /api/offline/search result:', JSON.stringify(directSearch.body)); const hits = (body?.hits ?? []) as Array<{ title?: string }>;
expect(directSearch.status, 'the encrypted local index must be reachable (200), not 404 (feature disabled) or 503 (no key channel)').toBe(200); return hits.some((h) => /villa sul lago/i.test(h.title ?? '')) ? 'hit' : 'indexed-but-empty';
expect(directSearch.body?.ok).toBe(true); }),
const hitTitles = (directSearch.body?.hits ?? []).map((h: { title?: string }) => h.title ?? ''); {
expect(hitTitles.some((t: string) => /villa sul lago/i.test(t)), `expected a "Villa sul Lago" hit in the real index, got: ${JSON.stringify(hitTitles)}`).toBe(true); timeout: 90000,
intervals: [2000],
message:
'the boot catch-up (page.tsx) must build the index automatically after login — http-401 here means the auth-context cookie was never minted (the missing-SESSION_SECRET class of bug), http-404 means the key channel/store dir never activated',
},
)
.toBe('hit');
// ── 3. The new toolbar entry point must be present in the main mail
// view — the AI Assistant is a feature of the app, not of the Settings
// page. ──
await expect(appWindow.getByRole('button', { name: 'AI Assistant' })).toBeVisible({ timeout: 10000 });
// ── 4. Navigate to the real AI Assistant settings UI and use the // ── 4. Navigate to the real AI Assistant settings UI and use the
// local-discovery "Connect" banner — the exact flow a real user takes, // local-discovery "Connect" banner — the exact flow a real user takes,
+52
View File
@@ -9,6 +9,7 @@
import { app, BrowserWindow, ipcMain, Notification } from "electron"; import { app, BrowserWindow, ipcMain, Notification } from "electron";
import { autoUpdater } from "electron-updater"; import { autoUpdater } from "electron-updater";
import { spawn, type ChildProcess } from "node:child_process"; import { spawn, type ChildProcess } from "node:child_process";
import { randomBytes } from "node:crypto";
import { createServer } from "node:net"; import { createServer } from "node:net";
import { get as httpGet } from "node:http"; import { get as httpGet } from "node:http";
import path from "node:path"; import path from "node:path";
@@ -115,6 +116,50 @@ function getDesktopDefaults(): Record<string, string> {
}; };
} }
/**
* Per-install session secret for the standalone server.
*
* The server's cookie crypto (lib/auth/crypto.ts) refuses to mint the
* `jmap_stalwart_ctx` auth-context cookie without a >=32-char SESSION_SECRET,
* and every server-side-identity feature hangs off that cookie: the encrypted
* local search index and offline replica (their routes 401 without it),
* S/MIME enrolment, and the AI `server` class. A web deployment gets the
* secret from an operator (env var or the setup wizard); the desktop shell
* has NO operator, and up to 1.7.8 the packaged app simply shipped without
* one — so every login's stalwart-context POST failed with 500, the index
* stayed permanently empty, and the AI assistant answered "No local mail
* index available in this session" against real accounts. Caught live on a
* real mailbox, not by tests: every test run had injected its own
* SESSION_SECRET into the child env, masking exactly this.
*
* Generated once per install (64 hex chars, comfortably over the minimum),
* persisted 0600 under userData next to the rest of the per-user state, and
* handed to the server as SESSION_SECRET_FILE rather than SESSION_SECRET so
* the value itself stays out of the child's environment block. A
* deployment-provided SESSION_SECRET env var still wins — getSessionSecret()
* resolves the env var before the file.
*/
function ensureSessionSecretFile(): string | null {
const secretPath = path.join(app.getPath("userData"), "session-secret");
try {
const existing = fs.readFileSync(secretPath, "utf8").trim();
if (existing.length >= 32) return secretPath;
} catch {
/* first run — generate below */
}
try {
fs.mkdirSync(path.dirname(secretPath), { recursive: true });
fs.writeFileSync(secretPath, randomBytes(32).toString("hex"), { mode: 0o600 });
return secretPath;
} catch (cause) {
// Loud, because the downstream symptom is otherwise "index/AI features
// return 401" with no hint of why — but never fatal: reading mail does
// not depend on this cookie.
console.error("[electron] could not persist a session secret:", cause);
return null;
}
}
/** /**
* Locates the standalone server's entrypoint. Packaged builds ship it as an * Locates the standalone server's entrypoint. Packaged builds ship it as an
* extraResource (see electron-builder.config.js) because .next/standalone * extraResource (see electron-builder.config.js) because .next/standalone
@@ -210,12 +255,19 @@ async function startStandaloneServer(): Promise<string> {
// readable by any process running as the same OS user, which would defeat // readable by any process running as the same OS user, which would defeat
// using the OS keychain at all. The fd NUMBER below is not a secret; only // using the OS keychain at all. The fd NUMBER below is not a secret; only
// what travels over it is. // what travels over it is.
const sessionSecretFile = ensureSessionSecretFile();
serverProcess = spawn(process.execPath, [serverEntry], { serverProcess = spawn(process.execPath, [serverEntry], {
env: { env: {
// First, so any real deployment env (a future per-install override, // First, so any real deployment env (a future per-install override,
// or this same binary run somewhere JMAP_SERVER_URL is already set) // or this same binary run somewhere JMAP_SERVER_URL is already set)
// wins over these desktop-shell defaults - see getDesktopDefaults(). // wins over these desktop-shell defaults - see getDesktopDefaults().
...getDesktopDefaults(), ...getDesktopDefaults(),
// Also before ...process.env: an operator-provided SESSION_SECRET or
// SESSION_SECRET_FILE must win over the per-install default (and a
// SESSION_SECRET env var outranks any file in getSessionSecret()'s
// resolution order regardless).
...(sessionSecretFile ? { SESSION_SECRET_FILE: sessionSecretFile } : {}),
...process.env, ...process.env,
ELECTRON_RUN_AS_NODE: "1", ELECTRON_RUN_AS_NODE: "1",
PORT: String(port), PORT: String(port),
+147
View File
@@ -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
View File
@@ -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,
}; };
} }
+3 -1
View File
@@ -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,
+164
View File
@@ -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
View File
@@ -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;
+8 -1
View File
@@ -61,8 +61,15 @@ const nextConfig: NextConfig = {
// Sibling repos checked out under ./repos/ are unrelated source trees that // Sibling repos checked out under ./repos/ are unrelated source trees that
// Turbopack's NFT can otherwise rope into the trace when dynamic fs calls // Turbopack's NFT can otherwise rope into the trace when dynamic fs calls
// confuse it. Keeps the build from ballooning memory tracing dead code. // confuse it. Keeps the build from ballooning memory tracing dead code.
//
// dist-electron-builds/ is the OUTPUT of electron-builder — a previous
// packaged .app sitting there contains its own data/ tree, and tracing
// once tried to copy pieces of the old app into the new standalone output
// ("Failed to copy traced files ... dist-electron-builds/..." warnings,
// observed 2026-08-06). Same reasoning for ./data/, a dev-run server's
// local state dir.
outputFileTracingExcludes: { outputFileTracingExcludes: {
"*": ["./repos/**/*"], "*": ["./repos/**/*", "./dist-electron-builds/**/*", "./data/**/*"],
}, },
turbopack: { turbopack: {
root: import.meta.dirname, root: import.meta.dirname,