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)/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 index 91f616df..a40b65df 100644 --- a/components/ai/ai-ask-button.tsx +++ b/components/ai/ai-ask-button.tsx @@ -18,6 +18,7 @@ import { AlertTriangle, Loader2, Settings2, Sparkles, X } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/browser-navigation'; +import { useAccountStore } from '@/stores/account-store'; import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types'; import { supportsLocalLlm } from '@/lib/platform-capabilities'; import { getAiApiKey } from '@/lib/ai/key-store'; @@ -54,6 +55,8 @@ function providerConfigured(settings: AiLocalSettings, policy: AiPolicy): boolea return supportsLocalLlm() && classes.includes('local') && !!settings.localModel; case 'server': return classes.includes('server') && !!settings.serverModel; + case 'opencode': + return classes.includes('opencode') && !!settings.opencodeModel; case 'public': { const active = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null; return classes.includes('public') && !!active && settings.publicConsentAccepted; @@ -66,6 +69,8 @@ function providerConfigured(settings: AiLocalSettings, policy: AiPolicy): boolea export function AiAskButton() { const router = useRouter(); const { policy, loaded } = useAiPolicy(); + // Retrieval must read the SAME account slot the indexer wrote under. + const activeSlot = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.cookieSlot); const [open, setOpen] = useState(false); // Re-read on every open: the user may have just configured a provider in // Settings and come straight back here — a mount-time snapshot would still @@ -107,10 +112,12 @@ export function AiAskButton() { const activeProfile = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null; const key = activeProfile ? getAiApiKey(activeProfile.id) : null; const result = await askMail(question.trim(), { - provider: settings.provider as 'local' | 'server' | 'public', + provider: settings.provider as 'local' | 'server' | 'public' | 'opencode', localBaseUrl: settings.localBaseUrl, localModel: settings.localModel, serverModel: settings.serverModel, + opencodeModel: settings.opencodeModel, + slot: activeSlot, publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null, }); setAskResult(result); @@ -119,7 +126,7 @@ export function AiAskButton() { } finally { setAsking(false); } - }, [canAsk, question, settings]); + }, [canAsk, question, settings, activeSlot]); const goToSettings = useCallback(() => { // The Settings page's one-shot deep-link channel (see readPersistedTab in @@ -221,9 +228,16 @@ export function AiAskButton() { {askResult && (
- {askResult.unaugmented && ( + {askResult.retrievalState === 'no-index' && (

- No local mail index available in this session — answered without retrieval context. + No local mail index available in this session — answered without your mail. +

+ )} + {askResult.retrievalState === 'no-match' && ( +

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

)}

{askResult.answer}

diff --git a/components/settings/ai-assistant-settings.tsx b/components/settings/ai-assistant-settings.tsx index c834f86c..e4465ecb 100644 --- a/components/settings/ai-assistant-settings.tsx +++ b/components/settings/ai-assistant-settings.tsx @@ -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(DEFAULT_AI_POLICY); const [policyLoading, setPolicyLoading] = useState(true); const [settings, setSettings] = useState(() => 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([]); @@ -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([]); + const [refreshingOpencode, setRefreshingOpencode] = useState(false); + const [opencodeError, setOpencodeError] = useState(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([]); 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 ? ( 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() { )} + {settings.provider === 'opencode' && canUseOpencode && ( + + +
+ {opencodeModels.length > 0 ? ( +