Files
SRCmail/components/ai/ai-ask-button.tsx
T
Bernd RodlerandClaude Sonnet 5 f121678e2a feat(ai): Paperclip-style env-var provider presets + zero-config local default
Two product decisions from tonight:

1. Public AI providers can now be published by an admin as named presets
   (lib/ai/types.ts's PublicAiPreset: name/baseUrl/model/apiKeyEnvVar).
   The admin names an env var, never a secret value - the actual key is
   whatever ops has set in the server's real environment, same custody
   model as the existing AI_SERVER_BASE_URL var. A new server route
   (app/api/ai/public/chat) resolves it and makes the call itself, which
   also sidesteps the CORS/wrong-base-URL failure class chatPublic hit
   earlier tonight. Users pick a preset from a dropdown in Settings -
   Answer with - no key field at all; personal BYOK (paste your own key)
   stays available as a secondary "Add your own key" option, not removed.
   Admin UI: new "Public - org-managed presets" card in the AI policy tab.

2. AI now defaults ON instead of requiring setup (lib/ai/auto-provision.ts):
   on first load, if no provider is chosen yet, probe OpenCode (this app
   auto-spawns `opencode serve` itself, so it's the one local option with
   zero external install step) then Ollama via the existing auto-discovery,
   and adopt whichever answers. Never overrides an explicit choice - only
   fires while provider is still null. Wired into both AI entry points
   (the Ask button and the Settings pane) so it resolves before either
   renders its "not configured" state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 14:01:00 +02:00

283 lines
12 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, 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<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': {
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<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);
}, []);
// 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 (
<>
<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>
)}
</>
);
}