diff --git a/.gitignore b/.gitignore index b43db4a6..7dd19ead 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,4 @@ vnc/plugins/smime/smime.zip # macOS .DS_Store +electron-ai-local-index-result.png diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 74843917..c7aab870 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -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 | 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() { )} + diff --git a/app/(main)/admin/_tabs/ai-policy.tsx b/app/(main)/admin/_tabs/ai-policy.tsx index 0dc4e7a2..a0f21b4d 100644 --- a/app/(main)/admin/_tabs/ai-policy.tsx +++ b/app/(main)/admin/_tabs/ai-policy.tsx @@ -13,6 +13,7 @@ type EntitlementResponse = AiEntitlementState & { recentUsage: MeteringEntry[] } const CLASS_INFO: Record = { local: { name: 'Local', desc: "Ollama on the user's own machine. Free, unmetered, never reaches this server." }, server: { name: 'Server', desc: 'VNC-hosted. Entitlement-enforced, seat + usage tracked below.' }, + opencode: { name: 'OpenCode', desc: 'A locally-running OpenCode agent server. Holds its own provider credentials; nothing metered here.' }, public: { name: 'Public (BYOK)', desc: "User's own API key, direct from their browser to the provider." }, }; @@ -199,8 +200,8 @@ export function AiPolicyTab() {

Provider classes

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

-
- {(['local', 'server', 'public'] as AiClass[]).map((cls) => { +
+ {(['local', 'server', 'opencode', 'public'] as AiClass[]).map((cls) => { const enabled = config.classesEnabled[cls] !== false; const disabledByInfra = cls === 'server' && !serverInfraAvailable; return ( diff --git a/app/api/ai/opencode/chat/route.ts b/app/api/ai/opencode/chat/route.ts new file mode 100644 index 00000000..5c97ac63 --- /dev/null +++ b/app/api/ai/opencode/chat/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { configManager } from '@/lib/admin/config-manager'; +import { findOpencodeServer, parseModelRef, opencodePrompt } from '@/lib/ai/opencode'; +import { logger } from '@/lib/logger'; + +export const runtime = 'nodejs'; + +const MAX_BODY_BYTES = 200 * 1024; + +interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +/** + * POST /api/ai/opencode/chat — one-shot chat against a locally-running + * `opencode serve`. + * + * Deliberately NOT entitlement-metered, unlike /api/ai/server/chat: this runs + * on the user's own machine against provider credentials opencode itself + * holds, so there is no centrally-borne cost for this app to bill — the same + * reasoning that leaves `local` unmetered (lib/ai/entitlement.ts's header). + */ +export async function POST(request: NextRequest) { + const auth = await getStalwartCredentials(request); + if (!auth) { + return NextResponse.json({ error: 'not authenticated' }, { status: 401 }); + } + + await configManager.ensureLoaded(); + if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) { + return NextResponse.json({ error: 'the OpenCode class is disabled by admin policy' }, { status: 403 }); + } + + const rawBody = await request.text(); + if (rawBody.length > MAX_BODY_BYTES) { + return NextResponse.json({ error: 'request too large' }, { status: 413 }); + } + + let body: { model?: unknown; messages?: unknown }; + try { + body = JSON.parse(rawBody); + } catch { + return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 }); + } + + const model = typeof body.model === 'string' ? body.model : ''; + const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null; + if (!model || !messages || messages.length === 0) { + return NextResponse.json({ error: 'model and messages are required' }, { status: 400 }); + } + + const found = await findOpencodeServer(); + if (!found) { + return NextResponse.json( + { error: 'No local OpenCode server found. Start one with: opencode serve --port 4096' }, + { status: 503 }, + ); + } + if (!found.models.some((m) => m.ref === model)) { + // The picker is populated from this same list, so a mismatch means the + // saved model was removed/renamed in opencode since it was chosen - + // clearer to say so than to forward it and surface opencode's own error. + return NextResponse.json( + { error: `OpenCode no longer offers the model "${model}" \u2014 pick another in Settings.` }, + { status: 400 }, + ); + } + const parsed = parseModelRef(model); + if (!parsed) { + return NextResponse.json({ error: `Malformed model reference "${model}"` }, { status: 400 }); + } + + // Flatten our chat-messages shape onto opencode's (system field + text + // parts). Every non-system message is already just the built prompt. + const system = messages.filter((m) => m.role === 'system').map((m) => m.content).join('\n\n') || undefined; + const userText = messages.filter((m) => m.role !== 'system').map((m) => m.content).join('\n\n'); + if (!userText.trim()) { + return NextResponse.json({ error: 'no user content to send' }, { status: 400 }); + } + + try { + const result = await opencodePrompt(found.baseUrl, parsed, system, userText); + if (!result.ok) { + logger.error('opencode prompt failed', { error: result.error }); + return NextResponse.json({ error: result.error }, { status: 502 }); + } + return NextResponse.json({ answer: result.answer }); + } catch (cause) { + logger.error('opencode chat failed', { error: cause instanceof Error ? cause.message : String(cause) }); + return NextResponse.json({ error: 'OpenCode server unreachable' }, { status: 502 }); + } +} diff --git a/app/api/ai/opencode/models/route.ts b/app/api/ai/opencode/models/route.ts new file mode 100644 index 00000000..7b5e668a --- /dev/null +++ b/app/api/ai/opencode/models/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { configManager } from '@/lib/admin/config-manager'; +import { findOpencodeServer } from '@/lib/ai/opencode'; + +export const runtime = 'nodejs'; + +/** + * GET /api/ai/opencode/models — models a locally-running `opencode serve` + * exposes. Proxied rather than fetched directly by the renderer: the desktop + * shell's origin is a random localhost port that changes every launch, so a + * direct call would need opencode's CORS allowlist updated each time. + * + * Listing is not a billable action, so a valid session is enough — no seat + * check (matching /api/ai/server/models). + */ +export async function GET(request: NextRequest) { + const auth = await getStalwartCredentials(request); + if (!auth) { + return NextResponse.json({ error: 'not authenticated' }, { status: 401 }); + } + + await configManager.ensureLoaded(); + if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) { + return NextResponse.json({ error: 'the OpenCode class is disabled by admin policy' }, { status: 403 }); + } + + const found = await findOpencodeServer(); + if (!found) { + // 503 not 500: "nothing is listening" is a normal state (opencode simply + // isn't running), and the client turns it into setup guidance rather than + // an error banner. + return NextResponse.json( + { error: 'No local OpenCode server found. Start one with: opencode serve --port 4096' }, + { status: 503 }, + ); + } + + return NextResponse.json( + { models: found.models.map((m) => ({ ref: m.ref, label: m.label })) }, + { headers: { 'Cache-Control': 'no-store' } }, + ); +} diff --git a/app/api/ai/policy/route.ts b/app/api/ai/policy/route.ts index 6672ea59..bb17ab3c 100644 --- a/app/api/ai/policy/route.ts +++ b/app/api/ai/policy/route.ts @@ -28,6 +28,13 @@ export async function GET() { if (classAllowed('local')) classes.push('local'); if (classAllowed('public')) classes.push('public'); if (process.env.AI_SERVER_BASE_URL && classAllowed('server')) classes.push('server'); + // `opencode` is offered whenever the admin hasn't disabled it — unlike + // `server` there is no env var to gate on, because availability is "is a + // local `opencode serve` listening right now", which changes minute to + // minute and is answered by /api/ai/opencode/models (503 when absent). + // Advertising the class and letting that probe report the truth beats + // hiding it based on a stale check at policy-fetch time. + if (classAllowed('opencode')) classes.push('opencode'); const aiPolicy: AiPolicy = { enabled: policy.features.aiAssistantEnabled, diff --git a/components/ai/ai-ask-button.tsx b/components/ai/ai-ask-button.tsx new file mode 100644 index 00000000..a40b65df --- /dev/null +++ b/components/ai/ai-ask-button.tsx @@ -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(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(() => loadAiSettings()); + + const [question, setQuestion] = useState(''); + const [asking, setAsking] = useState(false); + const [askResult, setAskResult] = useState(null); + const [askError, setAskError] = useState(null); + const textareaRef = useRef(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 ( + <> + + + {open && ( +
{ + if (e.target === e.currentTarget) setOpen(false); + }} + role="dialog" + aria-modal="true" + aria-label="AI Assistant" + > +
+
+
+ +

AI Assistant

+
+ +
+ +
+ {!configured ? ( + <> +

+ 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. +

+
+ +
+ + ) : ( + <> +