/* * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only */ /** * inbuxa: Settings › Spam Filter › Local AI. Spam filtering with a language * model the operator runs, off until someone turns it on here or on the * classifier's own form. * * Setting it up asks "Guided or manual?" each time (admin UX roadmap): guided * walks through what it does, the model's address and the prompt, then turns * it on; manual goes to the ordinary forms. Turning it off is one click and * keeps the model, so turning it back on is easy too. */ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Brain, Loader2, Power, PowerOff, RotateCcw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { LoadingFallback } from '@/components/common/LoadingFallback'; import { useAccountStore } from '@/stores/accountStore'; import { toast } from '@/hooks/use-toast'; import { DEFAULT_LIMITS, DEFAULT_PROMPT, disableClassifier, enableWithModel, EXAMPLE_URLS, fetchLimits, fetchStatus, LIMIT_FIELDS, LimitsUnavailable, locality, RECOMMENDED_MODEL, saveLimits, type AiLimits, type Status, } from './localAi'; import { formNotices } from './formExtras'; export const LOCAL_AI_VIEW = 'CustomComponent/LocalAi'; type Load = { kind: 'loading' } | { kind: 'ready'; value: T } | { kind: 'error'; message: string }; export function LocalAiPage() { const { t } = useTranslation(); const canUpdate = useAccountStore( (s) => s.hasObjectPermission('sysSpamLlm', 'Update') && s.hasObjectPermission('sysAiModel', 'Create'), ); const [status, setStatus] = useState>({ kind: 'loading' }); const [mode, setMode] = useState<'idle' | 'choose' | 'guided'>('idle'); const [busy, setBusy] = useState(false); const reload = useCallback((signal?: AbortSignal) => { fetchStatus(signal) .then((value) => { if (!signal?.aborted) setStatus({ kind: 'ready', value }); }) .catch((e: unknown) => { if (!signal?.aborted) setStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) }); }); }, []); useEffect(() => { const ctl = new AbortController(); reload(ctl.signal); return () => ctl.abort(); }, [reload]); const turnOff = async () => { setBusy(true); const outcome = await disableClassifier().catch((e: unknown) => ({ ok: false, message: e instanceof Error ? e.message : String(e), })); setBusy(false); if (outcome.ok) { toast({ title: t('localAi.turnedOff', 'Local AI spam filtering is off.') }); reload(); } else { toast({ title: t('localAi.failed', 'That didn’t work'), description: outcome.message, variant: 'destructive' }); } }; if (status.kind === 'loading') return ; return (

{t('localAi.title', 'Local AI')}

{t( 'localAi.subtitle', 'Spam filtering with a language model you run on your own machines. Off until you turn it on.', )}

{status.kind === 'error' ? ( {status.message} ) : ( setMode('choose')} onTurnOff={turnOff} /> )} {mode === 'choose' && ( {t('localAi.howTitle', 'Guided or manual?')} )} {mode === 'guided' && status.kind === 'ready' && ( { setMode('idle'); reload(); }} onCancel={() => setMode('idle')} /> )}
); } function StatusCard({ status, canUpdate, busy, onSetUp, onTurnOff, }: { status: Status; canUpdate: boolean; busy: boolean; onSetUp: () => void; onTurnOff: () => void; }) { const { t } = useTranslation(); const model = status.models.find((m) => m.id === status.modelId); return ( {status.enabled ? (

{t('localAi.on', 'On')}

{model && (

{t('localAi.asks', 'Asks {{name}} ({{model}}) at {{url}}', { name: model.name, model: model.model, url: model.url, })}

)}
) : (

{t('localAi.off', 'Off')}

{t( 'localAi.offExplain', 'The spam filter doesn’t use a language model. Nothing is sent anywhere until you set one up.', )}

)} {canUpdate && (
{status.enabled ? ( <> ) : ( )}
)}
); } function GuidedSetup({ status, onDone, onCancel }: { status: Status; onDone: () => void; onCancel: () => void }) { const { t } = useTranslation(); const [step, setStep] = useState(0); const [name, setName] = useState('local'); const [url, setUrl] = useState(EXAMPLE_URLS.llamaCpp); const [model, setModel] = useState(RECOMMENDED_MODEL.model); const [prompt, setPrompt] = useState(DEFAULT_PROMPT); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const notices = useMemo(() => formNotices('x:AiModel', { url }), [url]); const where = locality(url); const finish = async () => { setBusy(true); setError(null); const outcome = await enableWithModel({ name, url, model, prompt }, status.models).catch((e: unknown) => ({ ok: false, property: undefined, message: e instanceof Error ? e.message : String(e), })); setBusy(false); if (outcome.ok) { toast({ title: t('localAi.turnedOn', 'Local AI spam filtering is on.') }); onDone(); } else { setError(outcome.property ? `${outcome.property}: ${outcome.message}` : (outcome.message ?? '')); } }; const steps = [

{t('localAi.whatLead', 'The spam filter will ask a language model for its opinion of each incoming message.')}

  • {t('localAi.whatSent', 'Only the subject and text are sent: no addresses, headers or attachments.')}
  • {t('localAi.whatBounded', 'Its opinion is one signal among many, adding at most {{max}} points by default.', { max: DEFAULT_LIMITS.spamMaxAdded, })}
  • {t('localAi.whatNeverHolds', 'If the model is slow or down, mail is never held up.')}
  • {t( 'localAi.whatModel', 'Run the model yourself, on this machine or your own network: llama.cpp’s server or Ollama both work. We recommend {{label}} ({{license}}) with at least {{cpus}} CPU cores.', { label: RECOMMENDED_MODEL.label, license: RECOMMENDED_MODEL.license, cpus: RECOMMENDED_MODEL.minCpus }, )}
,
setUrl(e.target.value)} />

{t('localAi.urlHint', 'llama.cpp: {{llama}} · Ollama: {{ollama}}', { llama: EXAMPLE_URLS.llamaCpp, ollama: EXAMPLE_URLS.ollama, })}

{notices.map((n) => (

{t(n.key, n.text)}

))}
setModel(e.target.value)} />
setName(e.target.value)} />
,