'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, isPresetActiveId, presetIdFromActiveId, type AiLocalSettings } from '@/lib/ai/local-settings'; import { askMail, type AskResult } from '@/lib/ai/local-client'; import { ensureDefaultProvider } from '@/lib/ai/auto-provision'; 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': { if (isPresetActiveId(settings.activeProfileId)) { return classes.includes('public') && !!presetIdFromActiveId(settings.activeProfileId) && settings.publicConsentAccepted; } 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); }, []); // Zero-config default (see lib/ai/auto-provision.ts): resolves as soon as // policy loads, so a user who never visits Settings still finds AI // already on the first time they open this dialog, if OpenCode or Ollama // is available. useEffect(() => { if (!loaded) return; (async () => { const next = await ensureDefaultProvider(policy); setSettings(next); })(); }, [loaded, policy]); 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 managedPresetId = presetIdFromActiveId(settings.activeProfileId); const activeProfile = managedPresetId ? null : 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, publicPresetId: managedPresetId, }); 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.

) : ( <>