Three things, all from running the real thing rather than trusting a status code.
1. OpenCode as a 4th AI class (lib/ai/opencode.ts + app/api/ai/opencode/*).
A locally-running `opencode serve` — the same runtime Paperclip drives as
an adapter. Its appeal over a BYOK profile is precisely what was broken
before: opencode owns provider auth itself, so there is NO api key for
this app to hold, and it reports a REAL model list (25 on this machine)
instead of asking the user to type an exact provider-specific model id
from memory. Typing "Sonnet 5" into a free-text box and getting a bare
"Provider returned 401" is the failure this removes.
IMPORTANT trap, documented in the module header and pinned by a test:
opencode is NOT OpenAI-compatible. `/v1/models` and `/v1/chat/completions`
both answer 200 — because a web-UI catch-all serves index.html for ANY
unknown path. I built the first version against that assumed compatibility
on the strength of two 200s and had to throw it away once I read a body.
Every probe now validates the parsed shape and content-type, never the
status alone. The real API is GET /api/model + POST /session +
POST /session/{id}/message, and the reply's `reasoning` parts are stripped
so a model's private chain of thought can never surface as the answer.
Proxied through our own backend (like the `server` class) because the
desktop renderer's origin is a random port that changes every launch;
same-origin sidesteps opencode's CORS allowlist entirely. Loopback-only by
construction: a non-loopback OPENCODE_BASE_URL is refused, since "local,
no keys, nothing leaves the device" is the whole point of this class.
2. Retrieval read the WRONG ACCOUNT'S index. The indexer writes under the
active account's cookie slot (catchUpIndex passes it) but fetchLocalLeg
omitted `?slot=`, so search resolved to whichever account the multi-slot
resolver found first. Single-account installs never noticed; a real
multi-account/shared-mailbox setup reads an empty store every time. Both
call sites now pass the active slot.
3. "No local mail index available in this session" was shown even when the
index existed and simply matched nothing — actively misleading, and it
masked the missing-SESSION_SECRET bug for hours. AskResult now carries
retrievalState ('augmented' | 'no-match' | 'no-index') and the two cases
get different words: build the index, versus rephrase (with the honest
caveat that keyword search answers content questions better than recency
ones like "the last mail").
Verified live against real opencode 1.18.14: discovery found 25 models and a
real prompt round-tripped the exact expected answer through the real helper
code, not curl. Gate: tsc clean, eslint clean, 2512/2512 unit tests (10 new,
incl. one that fails if the HTML catch-all is ever accepted as an API), build clean.
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
'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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|