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)} /> +
+
, +
+ +