promote: OpenCode provider class + retrieval slot/messaging fixes (dev→main)
This commit is contained in:
@@ -73,3 +73,4 @@ vnc/plugins/smime/smime.zip
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
electron-ai-local-index-result.png
|
||||
|
||||
@@ -67,6 +67,7 @@ import { findDraftIdentityId, resolveReplyFrom, type ReplyFromResolution } from
|
||||
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
|
||||
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 { AiAskButton } from "@/components/ai/ai-ask-button";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
@@ -1070,37 +1071,58 @@ export default function Home() {
|
||||
// buildStatePollingRequest covers Mailbox/Email/Calendar/CalendarEvent/
|
||||
// SieveScript only). So backfill a bounded recent window once per session,
|
||||
// after push is wired. Fire-and-forget; a no-op outside Electron.
|
||||
const catchUpTimer = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const { catchUpIndex } = await import('@/lib/mail-index-client');
|
||||
await catchUpIndex(
|
||||
useAccountStore.getState().getActiveAccount()?.cookieSlot,
|
||||
);
|
||||
} catch {
|
||||
/* the index is optional */
|
||||
//
|
||||
// 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 {
|
||||
const { catchUpIndex } = await import('@/lib/mail-index-client');
|
||||
const result = await catchUpIndex(
|
||||
useAccountStore.getState().getActiveAccount()?.cookieSlot,
|
||||
);
|
||||
if (!result.ok && !result.unavailable && attempt + 1 < catchUpRetryDelaysMs.length) {
|
||||
catchUpTimer = setTimeout(() => void runCatchUp(attempt + 1), catchUpRetryDelaysMs[attempt + 1]);
|
||||
return;
|
||||
}
|
||||
// The offline REPLICA's launch catch-up. Same reasoning as the index's,
|
||||
// plus one of its own: a `/changes` cursor cannot tell us about anything
|
||||
// that happened while the process was dead, so a cycle at launch is what
|
||||
// drains the backlog. One cycle is bounded, so a first sync of a large
|
||||
// mailbox needs several - `chainSync` runs them with a hard cap.
|
||||
//
|
||||
// Sequenced AFTER the index rather than in parallel: both write the same
|
||||
// SQLite file, and although `busy_timeout` makes concurrent writers safe,
|
||||
// there is no reason to spend the contention during first paint.
|
||||
try {
|
||||
const { chainSync } = await import('@/lib/offline-replica-client');
|
||||
await chainSync({ slot: useAccountStore.getState().getActiveAccount()?.cookieSlot });
|
||||
} catch {
|
||||
/* the replica is optional */
|
||||
}
|
||||
})();
|
||||
// Deliberately after the initial mailbox fetch settles: the catch-up is a
|
||||
// background nicety and must not compete with first paint.
|
||||
}, 4000);
|
||||
} catch {
|
||||
/* the index is optional */
|
||||
}
|
||||
// The offline REPLICA's launch catch-up. Same reasoning as the index's,
|
||||
// plus one of its own: a `/changes` cursor cannot tell us about anything
|
||||
// that happened while the process was dead, so a cycle at launch is what
|
||||
// drains the backlog. One cycle is bounded, so a first sync of a large
|
||||
// mailbox needs several - `chainSync` runs them with a hard cap.
|
||||
//
|
||||
// Sequenced AFTER the index (including its retries) rather than in
|
||||
// parallel: both write the same SQLite file, and although `busy_timeout`
|
||||
// makes concurrent writers safe, there is no reason to spend the
|
||||
// contention during first paint.
|
||||
if (catchUpCancelled) return;
|
||||
try {
|
||||
const { chainSync } = await import('@/lib/offline-replica-client');
|
||||
await chainSync({ slot: useAccountStore.getState().getActiveAccount()?.cookieSlot });
|
||||
} catch {
|
||||
/* the replica is optional */
|
||||
}
|
||||
};
|
||||
// Deliberately after the initial mailbox fetch settles: the catch-up is a
|
||||
// background nicety and must not compete with first paint.
|
||||
catchUpTimer = setTimeout(() => void runCatchUp(0), catchUpRetryDelaysMs[0]);
|
||||
|
||||
return () => {
|
||||
catchUpCancelled = true;
|
||||
clearTimeout(catchUpTimer);
|
||||
cleanups.forEach((fn) => fn());
|
||||
};
|
||||
@@ -3164,6 +3186,7 @@ export default function Home() {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<AiAskButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ type EntitlementResponse = AiEntitlementState & { recentUsage: MeteringEntry[] }
|
||||
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." },
|
||||
server: { name: 'Server', desc: 'VNC-hosted. Entitlement-enforced, seat + usage tracked below.' },
|
||||
opencode: { name: 'OpenCode', desc: 'A locally-running OpenCode agent server. Holds its own provider credentials; nothing metered here.' },
|
||||
public: { name: 'Public (BYOK)', desc: "User's own API key, direct from their browser to the provider." },
|
||||
};
|
||||
|
||||
@@ -199,8 +200,8 @@ export function AiPolicyTab() {
|
||||
<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>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 p-4">
|
||||
{(['local', 'server', 'public'] as AiClass[]).map((cls) => {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 p-4">
|
||||
{(['local', 'server', 'opencode', 'public'] as AiClass[]).map((cls) => {
|
||||
const enabled = config.classesEnabled[cls] !== false;
|
||||
const disabledByInfra = cls === 'server' && !serverInfraAvailable;
|
||||
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('public')) classes.push('public');
|
||||
if (process.env.AI_SERVER_BASE_URL && classAllowed('server')) classes.push('server');
|
||||
// `opencode` is offered whenever the admin hasn't disabled it — unlike
|
||||
// `server` there is no env var to gate on, because availability is "is a
|
||||
// local `opencode serve` listening right now", which changes minute to
|
||||
// minute and is answered by /api/ai/opencode/models (503 when absent).
|
||||
// Advertising the class and letting that probe report the truth beats
|
||||
// hiding it based on a stale check at policy-fetch time.
|
||||
if (classAllowed('opencode')) classes.push('opencode');
|
||||
|
||||
const aiPolicy: AiPolicy = {
|
||||
enabled: policy.features.aiAssistantEnabled,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { RefreshCw, CheckCircle, AlertTriangle, Loader2, Plus, Trash2, Sparkles,
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup, Select } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
|
||||
import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities';
|
||||
import { getAiApiKey, setAiApiKey, clearAiApiKey } from '@/lib/ai/key-store';
|
||||
@@ -21,6 +22,8 @@ import {
|
||||
askMail,
|
||||
listLocalModels,
|
||||
listServerModels,
|
||||
listOpencodeModels,
|
||||
type OpencodeModelOption,
|
||||
testLocalConnection,
|
||||
type AskResult,
|
||||
} from '@/lib/ai/local-client';
|
||||
@@ -40,6 +43,9 @@ export function AiAssistantSettings() {
|
||||
const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY);
|
||||
const [policyLoading, setPolicyLoading] = useState(true);
|
||||
const [settings, setSettings] = useState<AiLocalSettings>(() => loadAiSettings());
|
||||
// The index is written under the ACTIVE account's cookie slot, so retrieval
|
||||
// must read the same one — see fetchLocalLeg in lib/ai/local-client.ts.
|
||||
const activeSlot = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.cookieSlot);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -67,6 +73,7 @@ export function AiAssistantSettings() {
|
||||
const canUseLocal = supportsLocalLlm() && policy.entitlement.classes.includes('local');
|
||||
const canUseServer = policy.entitlement.classes.includes('server');
|
||||
const canUsePublic = policy.entitlement.classes.includes('public');
|
||||
const canUseOpencode = policy.entitlement.classes.includes('opencode');
|
||||
|
||||
// ── Local provider ──
|
||||
const [localModels, setLocalModels] = useState<string[]>([]);
|
||||
@@ -140,6 +147,28 @@ export function AiAssistantSettings() {
|
||||
setDiscovery(null);
|
||||
}, []);
|
||||
|
||||
// ── OpenCode provider — a locally-running `opencode serve`. No key to
|
||||
// manage (opencode holds provider auth itself) and a real model list, which
|
||||
// is why this is its own class rather than another BYOK profile. ──
|
||||
const [opencodeModels, setOpencodeModels] = useState<OpencodeModelOption[]>([]);
|
||||
const [refreshingOpencode, setRefreshingOpencode] = useState(false);
|
||||
const [opencodeError, setOpencodeError] = useState<string | null>(null);
|
||||
|
||||
const refreshOpencodeModels = useCallback(async () => {
|
||||
setRefreshingOpencode(true);
|
||||
setOpencodeError(null);
|
||||
try {
|
||||
const models = await listOpencodeModels();
|
||||
setOpencodeModels(models);
|
||||
if (!settings.opencodeModel && models[0]) update('opencodeModel', models[0].ref);
|
||||
} catch (err) {
|
||||
setOpencodeModels([]);
|
||||
setOpencodeError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setRefreshingOpencode(false);
|
||||
}
|
||||
}, [settings.opencodeModel, update]);
|
||||
|
||||
// ── Server provider ──
|
||||
const [serverModels, setServerModels] = useState<string[]>([]);
|
||||
const [refreshingServer, setRefreshingServer] = useState(false);
|
||||
@@ -212,9 +241,11 @@ export function AiAssistantSettings() {
|
||||
? canUseLocal && !!settings.localModel
|
||||
: settings.provider === 'server'
|
||||
? canUseServer && !!settings.serverModel
|
||||
: settings.provider === 'public'
|
||||
? canUsePublic && !!activeProfile && settings.publicConsentAccepted
|
||||
: false);
|
||||
: settings.provider === 'opencode'
|
||||
? canUseOpencode && !!settings.opencodeModel
|
||||
: settings.provider === 'public'
|
||||
? canUsePublic && !!activeProfile && settings.publicConsentAccepted
|
||||
: false);
|
||||
|
||||
const runAsk = useCallback(async () => {
|
||||
setAsking(true);
|
||||
@@ -224,10 +255,12 @@ export function AiAssistantSettings() {
|
||||
try {
|
||||
const key = activeProfile ? getAiApiKey(activeProfile.id) : null;
|
||||
const result = await askMail(question.trim(), {
|
||||
provider: settings.provider as 'local' | 'server' | 'public',
|
||||
provider: settings.provider as 'local' | 'server' | 'public' | 'opencode',
|
||||
localBaseUrl: settings.localBaseUrl,
|
||||
localModel: settings.localModel,
|
||||
serverModel: settings.serverModel,
|
||||
opencodeModel: settings.opencodeModel,
|
||||
slot: activeSlot,
|
||||
publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null,
|
||||
});
|
||||
setAskResult(result);
|
||||
@@ -239,15 +272,16 @@ export function AiAssistantSettings() {
|
||||
} finally {
|
||||
setAsking(false);
|
||||
}
|
||||
}, [question, settings, activeProfile]);
|
||||
}, [question, settings, activeProfile, activeSlot]);
|
||||
|
||||
const providerOptions = useMemo(
|
||||
() => [
|
||||
...(canUseLocal ? [{ value: 'local', label: 'Local (Ollama)' }] : []),
|
||||
...(canUseServer ? [{ value: 'server', label: 'Server (VNC-hosted)' }] : []),
|
||||
...(canUseOpencode ? [{ value: 'opencode', label: 'OpenCode (local agent)' }] : []),
|
||||
...(canUsePublic ? [{ value: 'public', label: 'Public (your API keys)' }] : []),
|
||||
],
|
||||
[canUseLocal, canUseServer, canUsePublic],
|
||||
[canUseLocal, canUseServer, canUsePublic, canUseOpencode],
|
||||
);
|
||||
|
||||
if (policyLoading) {
|
||||
@@ -295,7 +329,7 @@ export function AiAssistantSettings() {
|
||||
{providerOptions.length > 0 ? (
|
||||
<RadioGroup
|
||||
value={settings.provider ?? ''}
|
||||
onChange={(v) => update('provider', v as 'local' | 'server' | 'public')}
|
||||
onChange={(v) => update('provider', v as 'local' | 'server' | 'public' | 'opencode')}
|
||||
options={providerOptions}
|
||||
/>
|
||||
) : (
|
||||
@@ -360,6 +394,38 @@ export function AiAssistantSettings() {
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{settings.provider === 'opencode' && canUseOpencode && (
|
||||
<SettingsSection
|
||||
title="OpenCode (local agent)"
|
||||
description="Uses a locally-running OpenCode server on this machine. OpenCode holds its own provider credentials, so there is no API key to enter here — and it reports the exact models it can reach, so there is nothing to type by hand."
|
||||
>
|
||||
<SettingItem label="Model" description={opencodeModels.length === 0 ? 'Refresh to list the models OpenCode can reach.' : undefined}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{opencodeModels.length > 0 ? (
|
||||
<Select
|
||||
value={settings.opencodeModel ?? ''}
|
||||
onChange={(v) => update('opencodeModel', v)}
|
||||
options={opencodeModels.map((m) => ({ value: m.ref, label: m.label }))}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{settings.opencodeModel || 'None selected'}</span>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={refreshOpencodeModels} disabled={refreshingOpencode}>
|
||||
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshingOpencode ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
{opencodeError && (
|
||||
<SettingItem label="Status">
|
||||
<span className="flex items-start gap-1.5 text-sm text-destructive">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5" /> {opencodeError}
|
||||
</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{settings.provider === 'server' && canUseServer && (
|
||||
<SettingsSection
|
||||
title="Server (VNC-hosted)"
|
||||
@@ -516,9 +582,18 @@ export function AiAssistantSettings() {
|
||||
|
||||
{askResult && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-4">
|
||||
{askResult.unaugmented && (
|
||||
{askResult.retrievalState === 'no-index' && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No local mail index available in this session — answered without retrieval context.
|
||||
No local mail index available in this session — answered without your mail. The index is
|
||||
desktop-only; build it under Settings → About & Data.
|
||||
</p>
|
||||
)}
|
||||
{askResult.retrievalState === 'no-match' && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Your mail index is available, but nothing in it matched this question — answered without
|
||||
your mail. It matches on keywords, so questions about <em>content</em> (“what did
|
||||
Anna say about the invoice?”) work better than ones about recency
|
||||
(“the last mail”).
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap">{askResult.answer}</p>
|
||||
|
||||
@@ -5,4 +5,4 @@ kind: Component
|
||||
images:
|
||||
- name: vncmail-plus
|
||||
newName: registry.gitlab.vnc.biz/gitlab-instance-b9b5cf2f/vncmail-plus
|
||||
newTag: sha-2a8778c9
|
||||
newTag: sha-1f199fdc
|
||||
|
||||
@@ -68,7 +68,13 @@ test.describe('Electron desktop shell - local LLM answers from the real encrypte
|
||||
VNCMAIL_TEST_FIXED_PORT: String(FIXED_PORT),
|
||||
DEV_MOCK_JMAP: 'true',
|
||||
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
|
||||
// module header) — the server embeddings leg 404s cleanly instead
|
||||
// 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 appWindow.waitForURL((url) => !url.pathname.includes('login'), { timeout: 20000 });
|
||||
|
||||
// ── 2. Build the real encrypted local index: delta-sync the mock
|
||||
// account's mail into the replica store, then write it into SQLite/FTS5.
|
||||
// Chains /api/offline/sync while unfinishedWork is true, capped so a
|
||||
// real bug can't hang the test forever. ──
|
||||
const syncOutcome = await appWindow.evaluate(async () => {
|
||||
let unfinished = true;
|
||||
let calls = 0;
|
||||
const statuses: number[] = [];
|
||||
while (unfinished && calls < 10) {
|
||||
const res = await fetch('/api/offline/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||
statuses.push(res.status);
|
||||
if (!res.ok) break;
|
||||
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);
|
||||
// ── 2. Wait for the AUTOMATIC boot catch-up to build the encrypted
|
||||
// index — NO manual /api/offline/sync or /api/offline/reindex calls.
|
||||
// This is the load-bearing change from this spec's first version: the
|
||||
// real user experience is "log in, index appears on its own", and the
|
||||
// first version's manual calls proved only that the plumbing COULD
|
||||
// work, not that anything actually drives it. page.tsx schedules the
|
||||
// first attempt ~4s after the authenticated mail page mounts (with
|
||||
// retries at 20s/60s for the login race), so poll generously. ──
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
appWindow.evaluate(async () => {
|
||||
const res = await fetch(`/api/offline/search?q=${encodeURIComponent('Villa sul Lago check-in')}&limit=6`);
|
||||
if (!res.ok) return `http-${res.status}`;
|
||||
const body = await res.json().catch(() => null);
|
||||
const hits = (body?.hits ?? []) as Array<{ title?: string }>;
|
||||
return hits.some((h) => /villa sul lago/i.test(h.title ?? '')) ? 'hit' : 'indexed-but-empty';
|
||||
}),
|
||||
{
|
||||
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. 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`);
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
});
|
||||
console.log('[ai-local-index] direct /api/offline/search result:', JSON.stringify(directSearch.body));
|
||||
expect(directSearch.status, 'the encrypted local index must be reachable (200), not 404 (feature disabled) or 503 (no key channel)').toBe(200);
|
||||
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);
|
||||
// ── 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
|
||||
// local-discovery "Connect" banner — the exact flow a real user takes,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { app, BrowserWindow, ipcMain, Notification } from "electron";
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createServer } from "node:net";
|
||||
import { get as httpGet } from "node:http";
|
||||
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
|
||||
* 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
|
||||
// using the OS keychain at all. The fd NUMBER below is not a secret; only
|
||||
// what travels over it is.
|
||||
const sessionSecretFile = ensureSessionSecretFile();
|
||||
|
||||
serverProcess = spawn(process.execPath, [serverEntry], {
|
||||
env: {
|
||||
// First, so any real deployment env (a future per-install override,
|
||||
// or this same binary run somewhere JMAP_SERVER_URL is already set)
|
||||
// wins over these desktop-shell defaults - see 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,
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
PORT: String(port),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ── 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
|
||||
// (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
|
||||
@@ -179,6 +222,18 @@ export interface AskResult {
|
||||
sources: AskSource[];
|
||||
/** True when the question was answered without any retrieved context. */
|
||||
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
|
||||
* seat on the `server` class (lib/ai/entitlement.ts). Always false for
|
||||
* `local`/`public`, which aren't entitlement-gated. */
|
||||
@@ -213,11 +268,25 @@ interface RetrievedContext {
|
||||
hits: Array<{ id: string; title: string }>;
|
||||
}
|
||||
|
||||
async function fetchLocalLeg(question: string): Promise<{ scored: Scored<SourceRef>[]; text: Map<string, { title: string; snippet: string }> }> {
|
||||
const empty = { scored: [] as Scored<SourceRef>[], text: new Map<string, { title: string; snippet: string }>() };
|
||||
/** Set by the most recent retrieveContext() call so askMail can report WHY an
|
||||
* 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 {
|
||||
const res = await fetch(`/api/offline/search?q=${encodeURIComponent(question)}&limit=6`);
|
||||
if (!res.ok) return empty; // 404/503 — no local index this session, not an error
|
||||
// `slot` is load-bearing, not optional decoration: the INDEXER writes under
|
||||
// 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;
|
||||
if (!body.ok) return empty;
|
||||
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 },
|
||||
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 {
|
||||
return empty;
|
||||
}
|
||||
@@ -250,8 +323,9 @@ async function fetchServerLeg(question: string): Promise<{ scored: Scored<Source
|
||||
}
|
||||
}
|
||||
|
||||
async function retrieveContext(question: string): Promise<RetrievedContext | null> {
|
||||
const [local, server] = await Promise.all([fetchLocalLeg(question), fetchServerLeg(question)]);
|
||||
async function retrieveContext(question: string, slot?: number): Promise<RetrievedContext | null> {
|
||||
const [local, server] = await Promise.all([fetchLocalLeg(question, slot), fetchServerLeg(question)]);
|
||||
lastLocalIndexReachable = local.indexReachable;
|
||||
const fused = reciprocalRankFusion([local.scored, server.scored], 6);
|
||||
if (fused.length === 0) return null;
|
||||
|
||||
@@ -293,11 +367,15 @@ export interface ResolvedPublicProfile {
|
||||
}
|
||||
|
||||
export interface AskConfig {
|
||||
provider: 'local' | 'server' | 'public';
|
||||
provider: 'local' | 'server' | 'public' | 'opencode';
|
||||
localBaseUrl: string;
|
||||
localModel: string | null;
|
||||
serverModel: string | 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> {
|
||||
@@ -310,8 +388,11 @@ export async function askMail(question: string, config: AskConfig): Promise<AskR
|
||||
if (config.provider === 'public' && !config.publicProfile) {
|
||||
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
|
||||
? buildPrompt(question, retrieved.contextBlock)
|
||||
: [{ 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);
|
||||
answer = result.answer;
|
||||
seatJustAssigned = result.seatJustAssigned;
|
||||
} else if (config.provider === 'opencode') {
|
||||
answer = await chatOpencode(config.opencodeModel as string, messages);
|
||||
} else {
|
||||
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,
|
||||
sources: (retrieved?.hits ?? []).map((h) => ({ id: h.id, subject: h.title })),
|
||||
unaugmented: !retrieved,
|
||||
retrievalState: retrieved ? 'augmented' : lastLocalIndexReachable ? 'no-match' : 'no-index',
|
||||
seatJustAssigned,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// (docs/AI-ASSISTANT-CONCEPT.md §12's P0/P5), and migrating it into the
|
||||
// shared store belongs with whichever phase makes these settings real
|
||||
// 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
|
||||
@@ -25,6 +25,7 @@ export interface AiLocalSettings {
|
||||
localBaseUrl: string;
|
||||
localModel: string | null;
|
||||
serverModel: string | null;
|
||||
opencodeModel: string | null;
|
||||
publicProfiles: AiProviderProfile[];
|
||||
/** Which saved profile answers the next question. Not a permanent default —
|
||||
* 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',
|
||||
localModel: null,
|
||||
serverModel: null,
|
||||
opencodeModel: null,
|
||||
publicProfiles: [],
|
||||
activeProfileId: null,
|
||||
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,
|
||||
// centrally-borne cost.
|
||||
|
||||
export type AiClass = 'local' | 'server' | 'public';
|
||||
export type AiClass = 'local' | 'server' | 'public' | 'opencode';
|
||||
|
||||
export interface AiEntitlement {
|
||||
licensed: boolean;
|
||||
|
||||
+8
-1
@@ -61,8 +61,15 @@ const nextConfig: NextConfig = {
|
||||
// Sibling repos checked out under ./repos/ are unrelated source trees that
|
||||
// Turbopack's NFT can otherwise rope into the trace when dynamic fs calls
|
||||
// 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: {
|
||||
"*": ["./repos/**/*"],
|
||||
"*": ["./repos/**/*", "./dist-electron-builds/**/*", "./data/**/*"],
|
||||
},
|
||||
turbopack: {
|
||||
root: import.meta.dirname,
|
||||
|
||||
Reference in New Issue
Block a user