From d0eddd2cda7578a392c010ca68dcac5dee12f366 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 22 Sep 2026 22:08:33 -0700 Subject: [PATCH] Local AI: a page to set up AI spam filtering, and the form hooks the spec asks for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Spam Filter › Local AI (CustomComponent/LocalAi) shows whether the language-model classifier is on and which model it asks. It's off until someone turns it on. Setting it up asks "Guided or manual?": guided explains what's sent (subject and text only), that the model's word is one bounded signal and never holds up mail, and recommends running Qwen3 4B Instruct 2507 locally under llama.cpp or Ollama; it then takes the model's address and the prompt, creates the model (or reuses one of the same name) and switches the classifier on with its real id. Manual goes to the ordinary forms. Turning it off is one click and keeps the model. The page also edits inbuxa:AiLimits, sending a return to a default as null so the server keeps following it. On the schema-driven forms (ai-spam-classification spec, "INBUXA Admin"): the default prompt is prefilled when the classifier is switched to Enabled, the model form suggests local addresses, a model outside the network gets the AI-2 warning (advisory, never blocking), and the classifier form says failures never hold up mail. The server's schema gains the menu link separately, after this release. --- src/components/forms/DynamicForm.tsx | 32 +- src/components/layout/MainContent.tsx | 8 + src/features/ai/LocalAiPage.tsx | 507 ++++++++++++++++++++++++++ src/features/ai/formExtras.ts | 80 ++++ src/features/ai/localAi.test.ts | 173 +++++++++ src/features/ai/localAi.ts | 292 +++++++++++++++ src/lib/layout.ts | 5 + 7 files changed, 1095 insertions(+), 2 deletions(-) create mode 100644 src/features/ai/LocalAiPage.tsx create mode 100644 src/features/ai/formExtras.ts create mode 100644 src/features/ai/localAi.test.ts create mode 100644 src/features/ai/localAi.ts diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index ef45c72..24bceb7 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -10,6 +10,7 @@ import { humanize } from '@/lib/humanize'; import { PageHeader } from '@/components/common/PageHeader'; import { HelpPanel } from '@/help/HelpPanel'; +import { fieldPlaceholder, formNotices, variantPrefill } from '@/features/ai/formExtras'; import { iconForView } from '@/lib/viewIcon'; import { useState, useEffect, useCallback, useMemo } from 'react'; import { flushSync } from 'react-dom'; @@ -305,7 +306,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { setSelectedVariant(newVariant); - const newData = buildEmbeddedDefaults(schema, obj.objectName, {}, newVariant); + const newData = { + ...buildEmbeddedDefaults(schema, obj.objectName, {}, newVariant), + // inbuxa: e.g. the default prompt when the AI classifier is switched on + ...variantPrefill(obj.objectName, newVariant), + }; setFormData(newData); }, [schema, resolved], @@ -782,6 +787,21 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { )} + {/* inbuxa: notices for the AI objects (locality warning, AI-2) */} + {formNotices(resolved.obj.objectName, formData).map((notice) => ( +
+ {t(notice.key, notice.text)} +
+ ))} + {sectionsToRender.map((section, sectionIdx) => ( {section.title && ( @@ -838,7 +858,15 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { handleFieldChange(formField.name, v)} readOnly={fieldReadOnly} diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 01068ab..6bf4c32 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -51,6 +51,11 @@ const LegacyProtocolsPage = lazyFeature( () => import('@/features/hardening/LegacyProtocolsPage'), (m) => m.LegacyProtocolsPage, ); +// inbuxa: Settings › Spam Filter › Local AI (ai-spam-classification spec). +const LocalAiPage = lazyFeature( + () => import('@/features/ai/LocalAiPage'), + (m) => m.LocalAiPage, +); const TenantLegacyProtocols = lazyFeature( () => import('@/features/hardening/TenantLegacyProtocols'), (m) => m.TenantLegacyProtocols, @@ -112,6 +117,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti if (componentName === 'LegacyProtocols') { return ; } + if (componentName === 'LocalAi') { + return ; + } return (
Unknown component: {componentName} diff --git a/src/features/ai/LocalAiPage.tsx b/src/features/ai/LocalAiPage.tsx new file mode 100644 index 0000000..00feab3 --- /dev/null +++ b/src/features/ai/LocalAiPage.tsx @@ -0,0 +1,507 @@ +/* + * 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)} /> +
+
, +
+ +