Local AI: a page to set up AI spam filtering, and the form hooks the spec asks for
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.
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
import { humanize } from '@/lib/humanize';
|
import { humanize } from '@/lib/humanize';
|
||||||
import { PageHeader } from '@/components/common/PageHeader';
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
import { HelpPanel } from '@/help/HelpPanel';
|
import { HelpPanel } from '@/help/HelpPanel';
|
||||||
|
import { fieldPlaceholder, formNotices, variantPrefill } from '@/features/ai/formExtras';
|
||||||
import { iconForView } from '@/lib/viewIcon';
|
import { iconForView } from '@/lib/viewIcon';
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
@@ -305,7 +306,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
|
|
||||||
setSelectedVariant(newVariant);
|
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);
|
setFormData(newData);
|
||||||
},
|
},
|
||||||
[schema, resolved],
|
[schema, resolved],
|
||||||
@@ -782,6 +787,21 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* inbuxa: notices for the AI objects (locality warning, AI-2) */}
|
||||||
|
{formNotices(resolved.obj.objectName, formData).map((notice) => (
|
||||||
|
<div
|
||||||
|
key={notice.key}
|
||||||
|
role={notice.tone === 'warning' ? 'alert' : 'note'}
|
||||||
|
className={
|
||||||
|
notice.tone === 'warning'
|
||||||
|
? 'rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm'
|
||||||
|
: 'rounded-md border bg-muted/40 p-4 text-sm text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(notice.key, notice.text)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{sectionsToRender.map((section, sectionIdx) => (
|
{sectionsToRender.map((section, sectionIdx) => (
|
||||||
<Card key={sectionIdx}>
|
<Card key={sectionIdx}>
|
||||||
{section.title && (
|
{section.title && (
|
||||||
@@ -838,7 +858,15 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
<FieldWidget
|
<FieldWidget
|
||||||
key={formField.name}
|
key={formField.name}
|
||||||
field={field}
|
field={field}
|
||||||
formField={formField}
|
formField={
|
||||||
|
formField.placeholder
|
||||||
|
? formField
|
||||||
|
: {
|
||||||
|
...formField,
|
||||||
|
// inbuxa: local example addresses on the AI model form
|
||||||
|
placeholder: fieldPlaceholder(resolved.obj.objectName, formField.name),
|
||||||
|
}
|
||||||
|
}
|
||||||
value={fieldValue}
|
value={fieldValue}
|
||||||
onChange={(v) => handleFieldChange(formField.name, v)}
|
onChange={(v) => handleFieldChange(formField.name, v)}
|
||||||
readOnly={fieldReadOnly}
|
readOnly={fieldReadOnly}
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ const LegacyProtocolsPage = lazyFeature(
|
|||||||
() => import('@/features/hardening/LegacyProtocolsPage'),
|
() => import('@/features/hardening/LegacyProtocolsPage'),
|
||||||
(m) => m.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(
|
const TenantLegacyProtocols = lazyFeature(
|
||||||
() => import('@/features/hardening/TenantLegacyProtocols'),
|
() => import('@/features/hardening/TenantLegacyProtocols'),
|
||||||
(m) => m.TenantLegacyProtocols,
|
(m) => m.TenantLegacyProtocols,
|
||||||
@@ -112,6 +117,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
|||||||
if (componentName === 'LegacyProtocols') {
|
if (componentName === 'LegacyProtocols') {
|
||||||
return <LegacyProtocolsPage />;
|
return <LegacyProtocolsPage />;
|
||||||
}
|
}
|
||||||
|
if (componentName === 'LocalAi') {
|
||||||
|
return <LocalAiPage />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||||
Unknown component: {componentName}
|
Unknown component: {componentName}
|
||||||
|
|||||||
@@ -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<T> = { 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<Load<Status>>({ 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 <LoadingFallback />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
<header className="space-y-1">
|
||||||
|
<h1 className="flex items-center gap-2 text-2xl font-semibold">
|
||||||
|
<Brain className="h-6 w-6" /> {t('localAi.title', 'Local AI')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
'localAi.subtitle',
|
||||||
|
'Spam filtering with a language model you run on your own machines. Off until you turn it on.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{status.kind === 'error' ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 text-sm text-destructive">{status.message}</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<StatusCard
|
||||||
|
status={status.value}
|
||||||
|
canUpdate={canUpdate}
|
||||||
|
busy={busy}
|
||||||
|
onSetUp={() => setMode('choose')}
|
||||||
|
onTurnOff={turnOff}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'choose' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t('localAi.howTitle', 'Guided or manual?')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-2">
|
||||||
|
<Button onClick={() => setMode('guided')}>{t('localAi.guided', 'Guided')}</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link to="/Settings/x:AiModel">{t('localAi.manual', 'Manual: the model and classifier forms')}</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setMode('idle')}>
|
||||||
|
{t('common.cancel', 'Cancel')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'guided' && status.kind === 'ready' && (
|
||||||
|
<GuidedSetup
|
||||||
|
status={status.value}
|
||||||
|
onDone={() => {
|
||||||
|
setMode('idle');
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
onCancel={() => setMode('idle')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LimitsCard canUpdate={canUpdate} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-4 pt-6">
|
||||||
|
{status.enabled ? (
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p className="flex items-center gap-2 font-medium">
|
||||||
|
<Power className="h-4 w-4 text-green-600" /> {t('localAi.on', 'On')}
|
||||||
|
</p>
|
||||||
|
{model && (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t('localAi.asks', 'Asks {{name}} ({{model}}) at {{url}}', {
|
||||||
|
name: model.name,
|
||||||
|
model: model.model,
|
||||||
|
url: model.url,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p className="flex items-center gap-2 font-medium">
|
||||||
|
<PowerOff className="h-4 w-4 text-muted-foreground" /> {t('localAi.off', 'Off')}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
'localAi.offExplain',
|
||||||
|
'The spam filter doesn’t use a language model. Nothing is sent anywhere until you set one up.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canUpdate && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{status.enabled ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={onTurnOff} disabled={busy}>
|
||||||
|
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t('localAi.turnOff', 'Turn off')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" asChild>
|
||||||
|
<Link to="/Settings/x:SpamLlm">{t('localAi.editClassifier', 'Edit the classifier')}</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button onClick={onSetUp}>{t('localAi.setUp', 'Set up local AI spam filtering')}</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(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 = [
|
||||||
|
<div key="what" className="space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
{t('localAi.whatLead', 'The spam filter will ask a language model for its opinion of each incoming message.')}
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc space-y-1 pl-5 text-muted-foreground">
|
||||||
|
<li>{t('localAi.whatSent', 'Only the subject and text are sent: no addresses, headers or attachments.')}</li>
|
||||||
|
<li>
|
||||||
|
{t('localAi.whatBounded', 'Its opinion is one signal among many, adding at most {{max}} points by default.', {
|
||||||
|
max: DEFAULT_LIMITS.spamMaxAdded,
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
<li>{t('localAi.whatNeverHolds', 'If the model is slow or down, mail is never held up.')}</li>
|
||||||
|
<li>
|
||||||
|
{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 },
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>,
|
||||||
|
<div key="model" className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ai-url">{t('localAi.url', 'Model address')}</Label>
|
||||||
|
<Input id="ai-url" value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('localAi.urlHint', 'llama.cpp: {{llama}} · Ollama: {{ollama}}', {
|
||||||
|
llama: EXAMPLE_URLS.llamaCpp,
|
||||||
|
ollama: EXAMPLE_URLS.ollama,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{notices.map((n) => (
|
||||||
|
<p
|
||||||
|
key={n.key}
|
||||||
|
role={n.tone === 'warning' ? 'alert' : 'note'}
|
||||||
|
className={
|
||||||
|
n.tone === 'warning'
|
||||||
|
? 'rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-sm'
|
||||||
|
: 'rounded-md border bg-muted/40 p-3 text-sm text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(n.key, n.text)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ai-model">{t('localAi.model', 'Model name')}</Label>
|
||||||
|
<Input id="ai-model" value={model} onChange={(e) => setModel(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ai-name">{t('localAi.name', 'Name in inbuxa')}</Label>
|
||||||
|
<Input id="ai-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
<div key="prompt" className="space-y-1.5">
|
||||||
|
<Label htmlFor="ai-prompt">{t('localAi.prompt', 'Instructions for the model')}</Label>
|
||||||
|
<Textarea id="ai-prompt" rows={6} value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
'localAi.promptHint',
|
||||||
|
'The default was measured against real mail. The server adds its own framing so the message is treated as data, not instructions.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>,
|
||||||
|
];
|
||||||
|
|
||||||
|
const canNext =
|
||||||
|
step === 0 || (step === 1 && where !== 'invalid' && model.trim() !== '' && name.trim() !== '') || step === 2;
|
||||||
|
const last = step === steps.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{t('localAi.stepOf', 'Step {{n}} of {{total}}', { n: step + 1, total: steps.length })}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{steps[step]}
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{step > 0 && (
|
||||||
|
<Button variant="outline" onClick={() => setStep(step - 1)} disabled={busy}>
|
||||||
|
{t('common.back', 'Back')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{last ? (
|
||||||
|
<Button onClick={finish} disabled={busy || prompt.trim() === ''}>
|
||||||
|
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t('localAi.turnOn', 'Turn on')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={() => setStep(step + 1)} disabled={!canNext}>
|
||||||
|
{t('common.next', 'Next')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" onClick={onCancel} disabled={busy}>
|
||||||
|
{t('common.cancel', 'Cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIMIT_LABELS: Record<
|
||||||
|
keyof AiLimits,
|
||||||
|
{ key: string; text: string; unit: 'points' | 'seconds' | 'count' | 'bytes' }
|
||||||
|
> = {
|
||||||
|
spamMaxAdded: { key: 'localAi.limit.spamMaxAdded', text: 'Most the model can add to a score', unit: 'points' },
|
||||||
|
spamMaxSubtracted: {
|
||||||
|
key: 'localAi.limit.spamMaxSubtracted',
|
||||||
|
text: 'Most the model can take off a score',
|
||||||
|
unit: 'points',
|
||||||
|
},
|
||||||
|
spamCallCeiling: {
|
||||||
|
key: 'localAi.limit.spamCallCeiling',
|
||||||
|
text: 'Longest the spam filter waits for the model',
|
||||||
|
unit: 'seconds',
|
||||||
|
},
|
||||||
|
maxConcurrentCalls: { key: 'localAi.limit.maxConcurrentCalls', text: 'Requests in flight at once', unit: 'count' },
|
||||||
|
maxContentBytes: { key: 'localAi.limit.maxContentBytes', text: 'Most message text sent', unit: 'bytes' },
|
||||||
|
failureBackoff: { key: 'localAi.limit.failureBackoff', text: 'Pause after repeated failures', unit: 'seconds' },
|
||||||
|
userCallsPerHour: {
|
||||||
|
key: 'localAi.limit.userCallsPerHour',
|
||||||
|
text: 'Calls per account per hour from its own Sieve scripts',
|
||||||
|
unit: 'count',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Durations travel in milliseconds and show in seconds. */
|
||||||
|
function toShown(key: keyof AiLimits, v: number): number {
|
||||||
|
return LIMIT_LABELS[key].unit === 'seconds' ? v / 1000 : v;
|
||||||
|
}
|
||||||
|
function fromShown(key: keyof AiLimits, v: number): number {
|
||||||
|
return LIMIT_LABELS[key].unit === 'seconds' ? Math.round(v * 1000) : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LimitsCard({ canUpdate }: { canUpdate: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [load, setLoad] = useState<Load<AiLimits>>({ kind: 'loading' });
|
||||||
|
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fill = (limits: AiLimits) =>
|
||||||
|
setDraft(Object.fromEntries(LIMIT_FIELDS.map((k) => [k, String(toShown(k, limits[k]))])));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ctl = new AbortController();
|
||||||
|
fetchLimits(ctl.signal)
|
||||||
|
.then((value) => {
|
||||||
|
if (ctl.signal.aborted) return;
|
||||||
|
setLoad({ kind: 'ready', value });
|
||||||
|
fill(value);
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
if (ctl.signal.aborted) return;
|
||||||
|
setLoad({
|
||||||
|
kind: 'error',
|
||||||
|
message:
|
||||||
|
e instanceof LimitsUnavailable
|
||||||
|
? t('localAi.limitsUnavailable', 'This server doesn’t offer AI limits.')
|
||||||
|
: e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: String(e),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => ctl.abort();
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
if (load.kind !== 'ready') {
|
||||||
|
return load.kind === 'error' ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 text-sm text-muted-foreground">{load.message}</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = (): AiLimits | null => {
|
||||||
|
const out = { ...load.value };
|
||||||
|
for (const k of LIMIT_FIELDS) {
|
||||||
|
const n = Number(draft[k]);
|
||||||
|
if (draft[k] === undefined || draft[k].trim() === '' || !Number.isFinite(n) || n < 0) return null;
|
||||||
|
out[k] = fromShown(k, n);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async (next: AiLimits) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
const outcome = await saveLimits(load.value, next).catch((e: unknown) => ({
|
||||||
|
ok: false,
|
||||||
|
property: undefined,
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
}));
|
||||||
|
setBusy(false);
|
||||||
|
if (outcome.ok) {
|
||||||
|
setLoad({ kind: 'ready', value: next });
|
||||||
|
fill(next);
|
||||||
|
toast({ title: t('localAi.limitsSaved', 'Limits saved.') });
|
||||||
|
} else {
|
||||||
|
setError(outcome.message ?? '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = parsed();
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t('localAi.limitsTitle', 'Limits')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
'localAi.limitsLead',
|
||||||
|
'These keep the model’s influence small and its load bounded. The defaults suit a small CPU-only server.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{LIMIT_FIELDS.map((k) => (
|
||||||
|
<div key={k} className="space-y-1.5">
|
||||||
|
<Label htmlFor={`limit-${k}`}>{t(LIMIT_LABELS[k].key, LIMIT_LABELS[k].text)}</Label>
|
||||||
|
<Input
|
||||||
|
id={`limit-${k}`}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={draft[k] ?? ''}
|
||||||
|
disabled={!canUpdate || busy}
|
||||||
|
onChange={(e) => setDraft({ ...draft, [k]: e.target.value })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('localAi.limitDefault', 'Default: {{value}}', { value: toShown(k, DEFAULT_LIMITS[k]) })}
|
||||||
|
{LIMIT_LABELS[k].unit === 'seconds' ? ` ${t('localAi.seconds', 's')}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
{canUpdate && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button onClick={() => next && save(next)} disabled={busy || !next}>
|
||||||
|
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t('common.save', 'Save')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => save({ ...DEFAULT_LIMITS })} disabled={busy}>
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
{t('localAi.resetDefaults', 'Reset to defaults')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* inbuxa: what the schema-driven forms add for the AI objects (spec, "INBUXA
|
||||||
|
* Admin"): the default prompt when the classifier is switched on, local
|
||||||
|
* example placeholders on the model form, and the notices above both forms.
|
||||||
|
* DynamicForm calls these at three marked points; everything else about the
|
||||||
|
* forms stays as the schema draws them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_PROMPT, EXAMPLE_URLS, locality, RECOMMENDED_MODEL } from './localAi';
|
||||||
|
|
||||||
|
/** Values to prefill when a form switches to a variant. */
|
||||||
|
export function variantPrefill(objectName: string, variant: string): Record<string, unknown> {
|
||||||
|
if (objectName === 'x:SpamLlm' && variant === 'Enable') return { prompt: DEFAULT_PROMPT };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A placeholder for a field the schema gives none. */
|
||||||
|
export function fieldPlaceholder(objectName: string, fieldName: string): string | undefined {
|
||||||
|
if (objectName !== 'x:AiModel') return undefined;
|
||||||
|
if (fieldName === 'url') return EXAMPLE_URLS.llamaCpp;
|
||||||
|
if (fieldName === 'model') return RECOMMENDED_MODEL.model;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormNotice {
|
||||||
|
tone: 'info' | 'warning';
|
||||||
|
/** An i18n key and its English default. */
|
||||||
|
key: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notices to show above a form, from what it currently holds. */
|
||||||
|
export function formNotices(objectName: string, data: Record<string, unknown>): FormNotice[] {
|
||||||
|
if (objectName === 'x:AiModel') {
|
||||||
|
const url = typeof data.url === 'string' ? data.url.trim() : '';
|
||||||
|
if (!url) return [];
|
||||||
|
switch (locality(url)) {
|
||||||
|
case 'remote':
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tone: 'warning',
|
||||||
|
key: 'localAi.remoteWarning',
|
||||||
|
text:
|
||||||
|
'This address is outside your network. The spam filter will send the subject and text of ' +
|
||||||
|
'incoming mail to it. For privacy, run the model on your own machines.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case 'unknown':
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tone: 'info',
|
||||||
|
key: 'localAi.nameNotice',
|
||||||
|
text:
|
||||||
|
'If this name points outside your network, message text will leave it. The server checks when ' +
|
||||||
|
'you save and warns in its log.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (objectName === 'x:SpamLlm') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tone: 'info',
|
||||||
|
key: 'localAi.neverHoldsMail',
|
||||||
|
text:
|
||||||
|
"The model's opinion is one signal among many, and adds at most a few points. If the model is " +
|
||||||
|
'slow or unavailable, mail is never held up: the message is scored without it.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const jmapRequest = vi.fn();
|
||||||
|
vi.mock('@/services/jmap/client', () => ({
|
||||||
|
getAccountId: () => 'a',
|
||||||
|
jmapRequest: (...args: unknown[]) => jmapRequest(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_LIMITS,
|
||||||
|
DEFAULT_PROMPT,
|
||||||
|
enableWithModel,
|
||||||
|
fetchLimits,
|
||||||
|
fetchStatus,
|
||||||
|
LimitsUnavailable,
|
||||||
|
locality,
|
||||||
|
parseLimits,
|
||||||
|
saveLimits,
|
||||||
|
} from './localAi';
|
||||||
|
import { fieldPlaceholder, formNotices, variantPrefill } from './formExtras';
|
||||||
|
|
||||||
|
beforeEach(() => jmapRequest.mockReset());
|
||||||
|
|
||||||
|
describe('locality (AI-2)', () => {
|
||||||
|
it.each([
|
||||||
|
['http://127.0.0.1:8080/v1/chat/completions', 'local'],
|
||||||
|
['http://localhost:11434/v1/chat/completions', 'local'],
|
||||||
|
['http://10.0.0.5/v1', 'local'],
|
||||||
|
['http://172.16.1.1/v1', 'local'],
|
||||||
|
['http://172.31.255.1/v1', 'local'],
|
||||||
|
['http://192.168.1.20/v1', 'local'],
|
||||||
|
['http://[::1]:8080/v1', 'local'],
|
||||||
|
['http://[fd12:3456::1]/v1', 'local'],
|
||||||
|
['http://172.32.0.1/v1', 'remote'],
|
||||||
|
['https://8.8.8.8/v1', 'remote'],
|
||||||
|
['https://[2001:db8::1]/v1', 'remote'],
|
||||||
|
['https://api.example.com/v1/chat/completions', 'unknown'],
|
||||||
|
['ai.lan', 'invalid'],
|
||||||
|
['', 'invalid'],
|
||||||
|
])('%s is %s', (url, expected) => {
|
||||||
|
expect(locality(url)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('limits', () => {
|
||||||
|
it('fills anything unset with the defaults', () => {
|
||||||
|
expect(parseLimits({ spamMaxAdded: 1.5 })).toEqual({ ...DEFAULT_LIMITS, spamMaxAdded: 1.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the singleton under the fork capability', async () => {
|
||||||
|
jmapRequest.mockResolvedValue([['inbuxa:AiLimits/get', { list: [{ maxContentBytes: 4096 }] }, '0']]);
|
||||||
|
expect((await fetchLimits()).maxContentBytes).toBe(4096);
|
||||||
|
expect(jmapRequest.mock.calls[0][2]).toEqual(['urn:inbuxa:jmap']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when the server has no AI limits', async () => {
|
||||||
|
jmapRequest.mockResolvedValue([['error', { type: 'unknownMethod' }, '0']]);
|
||||||
|
await expect(fetchLimits()).rejects.toBeInstanceOf(LimitsUnavailable);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends only what changed, and a return to the default as null', async () => {
|
||||||
|
jmapRequest.mockResolvedValue([['inbuxa:AiLimits/set', { updated: { singleton: null } }, '0']]);
|
||||||
|
const current = { ...DEFAULT_LIMITS, spamMaxAdded: 3 };
|
||||||
|
const next = { ...current, spamMaxAdded: DEFAULT_LIMITS.spamMaxAdded, userCallsPerHour: 10 };
|
||||||
|
expect(await saveLimits(current, next)).toEqual({ ok: true });
|
||||||
|
const [, args] = jmapRequest.mock.calls[0][0][0];
|
||||||
|
expect(args.update.singleton).toEqual({ spamMaxAdded: null, userCallsPerHour: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends nothing when nothing changed', async () => {
|
||||||
|
expect(await saveLimits(DEFAULT_LIMITS, { ...DEFAULT_LIMITS })).toEqual({ ok: true });
|
||||||
|
expect(jmapRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the property the server rejected', async () => {
|
||||||
|
jmapRequest.mockResolvedValue([
|
||||||
|
[
|
||||||
|
'inbuxa:AiLimits/set',
|
||||||
|
{
|
||||||
|
notUpdated: {
|
||||||
|
singleton: { type: 'invalidProperties', properties: ['spamMaxAdded'], description: 'too big' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const outcome = await saveLimits(DEFAULT_LIMITS, { ...DEFAULT_LIMITS, spamMaxAdded: 99 });
|
||||||
|
expect(outcome).toEqual({ ok: false, property: 'spamMaxAdded', message: 'too big' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('status', () => {
|
||||||
|
it('is off by default, and names the model when on', async () => {
|
||||||
|
jmapRequest.mockResolvedValueOnce([
|
||||||
|
['x:SpamLlm/get', { list: [{ '@type': 'Disable' }] }, 'c'],
|
||||||
|
['x:AiModel/get', { list: [] }, 'm'],
|
||||||
|
]);
|
||||||
|
expect(await fetchStatus()).toEqual({ enabled: false, modelId: null, models: [] });
|
||||||
|
|
||||||
|
jmapRequest.mockResolvedValueOnce([
|
||||||
|
['x:SpamLlm/get', { list: [{ '@type': 'Enable', modelId: 'm1' }] }, 'c'],
|
||||||
|
['x:AiModel/get', { list: [{ id: 'm1', name: 'local', model: 'q', url: 'http://127.0.0.1/v1' }] }, 'm'],
|
||||||
|
]);
|
||||||
|
const on = await fetchStatus();
|
||||||
|
expect(on.enabled).toBe(true);
|
||||||
|
expect(on.modelId).toBe('m1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('guided setup', () => {
|
||||||
|
const input = { name: 'local', url: 'http://127.0.0.1:8080/v1/chat/completions', model: 'q', prompt: 'p' };
|
||||||
|
|
||||||
|
it('creates the model, then enables the classifier with its real id', async () => {
|
||||||
|
jmapRequest
|
||||||
|
.mockResolvedValueOnce([['x:AiModel/set', { created: { m: { id: 'm9' } } }, '0']])
|
||||||
|
.mockResolvedValueOnce([['x:SpamLlm/set', { updated: { singleton: null } }, '0']]);
|
||||||
|
expect(await enableWithModel(input, [])).toEqual({ ok: true });
|
||||||
|
const enable = jmapRequest.mock.calls[1][0][0][1];
|
||||||
|
expect(enable.update.singleton).toEqual({ '@type': 'Enable', modelId: 'm9', prompt: 'p' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses a model of the same name instead of creating a duplicate', async () => {
|
||||||
|
jmapRequest
|
||||||
|
.mockResolvedValueOnce([['x:AiModel/set', { updated: { m1: null } }, '0']])
|
||||||
|
.mockResolvedValueOnce([['x:SpamLlm/set', { updated: { singleton: null } }, '0']]);
|
||||||
|
await enableWithModel(input, [{ id: 'm1', name: 'local', model: 'old', url: 'http://10.0.0.1/v1' }]);
|
||||||
|
const update = jmapRequest.mock.calls[0][0][0][1];
|
||||||
|
expect(update.update).toHaveProperty('m1');
|
||||||
|
expect(update.create).toBeUndefined();
|
||||||
|
expect(jmapRequest.mock.calls[1][0][0][1].update.singleton.modelId).toBe('m1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never switches the classifier on when the model could not be made', async () => {
|
||||||
|
jmapRequest.mockResolvedValueOnce([
|
||||||
|
['x:AiModel/set', { notCreated: { m: { type: 'invalidProperties', properties: ['url'] } } }, '0'],
|
||||||
|
]);
|
||||||
|
const outcome = await enableWithModel(input, []);
|
||||||
|
expect(outcome.ok).toBe(false);
|
||||||
|
expect(outcome.property).toBe('url');
|
||||||
|
expect(jmapRequest).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('form extras', () => {
|
||||||
|
it('prefills the default prompt only when the classifier is switched on', () => {
|
||||||
|
expect(variantPrefill('x:SpamLlm', 'Enable')).toEqual({ prompt: DEFAULT_PROMPT });
|
||||||
|
expect(variantPrefill('x:SpamLlm', 'Disable')).toEqual({});
|
||||||
|
expect(variantPrefill('x:Domain', 'Enable')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggests local addresses on the model form only', () => {
|
||||||
|
expect(fieldPlaceholder('x:AiModel', 'url')).toMatch(/^http:\/\/127\.0\.0\.1/);
|
||||||
|
expect(fieldPlaceholder('x:AiModel', 'name')).toBeUndefined();
|
||||||
|
expect(fieldPlaceholder('x:Domain', 'url')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns about a model outside the network, and never about a local one', () => {
|
||||||
|
expect(formNotices('x:AiModel', { url: 'https://8.8.8.8/v1' })[0]?.tone).toBe('warning');
|
||||||
|
expect(formNotices('x:AiModel', { url: 'https://api.example.com/v1' })[0]?.tone).toBe('info');
|
||||||
|
expect(formNotices('x:AiModel', { url: 'http://127.0.0.1:8080/v1' })).toEqual([]);
|
||||||
|
expect(formNotices('x:AiModel', {})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells the classifier form that failures never hold up mail', () => {
|
||||||
|
expect(formNotices('x:SpamLlm', {})[0]?.key).toBe('localAi.neverHoldsMail');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* inbuxa: local AI spam filtering (inbuxa-server's ai-spam-classification
|
||||||
|
* spec). The wire and the rules; the page and the form hooks draw from it.
|
||||||
|
*
|
||||||
|
* The feature is off until an administrator turns it on, and meant for a
|
||||||
|
* model running on the operator's own machines: nothing here presets a hosted
|
||||||
|
* endpoint, and the locality check says so when one is chosen (AI-2).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||||
|
import type { JmapMethodResponse, JmapSetError } from '@/types/jmap';
|
||||||
|
|
||||||
|
export const INBUXA_CAPABILITY = 'urn:inbuxa:jmap';
|
||||||
|
const LIMITS = 'inbuxa:AiLimits';
|
||||||
|
const CLASSIFIER = 'x:SpamLlm';
|
||||||
|
const MODEL = 'x:AiModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fork's default classification prompt, prefilled only when an
|
||||||
|
* administrator enables the classifier (spec, "Default prompt"). The
|
||||||
|
* calibration measured it; the server adds its own framing around it.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_PROMPT =
|
||||||
|
'Classify the email below as one of: Unsolicited, Commercial, Harmful, Legitimate. ' +
|
||||||
|
"Unsolicited: bulk mail the recipient didn't ask for. Commercial: selling something. " +
|
||||||
|
'Harmful: phishing, fraud or malware. Legitimate: anything else. Then give your confidence: ' +
|
||||||
|
'High, Medium or Low. Answer on one line as Category,Confidence,Reason with a reason of at most 20 words.';
|
||||||
|
|
||||||
|
/** The calibration's recommendation (spec, "Calibration"): Apache-2.0, 4 vCPU minimum on CPU only. */
|
||||||
|
export const RECOMMENDED_MODEL = {
|
||||||
|
label: 'Qwen3 4B Instruct 2507',
|
||||||
|
model: 'qwen3-4b-instruct-2507',
|
||||||
|
license: 'Apache-2.0',
|
||||||
|
minCpus: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Where a model served beside the mail server usually answers: llama.cpp's server, or Ollama. */
|
||||||
|
export const EXAMPLE_URLS = {
|
||||||
|
llamaCpp: 'http://127.0.0.1:8080/v1/chat/completions',
|
||||||
|
ollama: 'http://127.0.0.1:11434/v1/chat/completions',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Locality (AI-2) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type Locality = 'local' | 'unknown' | 'remote' | 'invalid';
|
||||||
|
|
||||||
|
function ipv4Octets(host: string): number[] | null {
|
||||||
|
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
||||||
|
if (!m) return null;
|
||||||
|
const o = m.slice(1).map(Number);
|
||||||
|
return o.every((n) => n <= 255) ? o : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the endpoint on this network? `local` for localhost, loopback, RFC 1918
|
||||||
|
* and RFC 4193 addresses; `remote` for any other address; `unknown` for a
|
||||||
|
* name the browser can't resolve, where only the server can tell (it logs
|
||||||
|
* its own warning); `invalid` when there's no URL to judge. Advisory only: it
|
||||||
|
* never blocks an endpoint the operator chose.
|
||||||
|
*/
|
||||||
|
export function locality(url: string): Locality {
|
||||||
|
let host: string;
|
||||||
|
try {
|
||||||
|
host = new URL(url).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return 'invalid';
|
||||||
|
}
|
||||||
|
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
|
||||||
|
if (host === 'localhost' || host.endsWith('.localhost')) return 'local';
|
||||||
|
const v4 = ipv4Octets(host);
|
||||||
|
if (v4) {
|
||||||
|
const [a, b] = v4;
|
||||||
|
if (a === 127 || a === 10) return 'local';
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return 'local';
|
||||||
|
if (a === 192 && b === 168) return 'local';
|
||||||
|
return 'remote';
|
||||||
|
}
|
||||||
|
if (host.includes(':')) {
|
||||||
|
if (host === '::1') return 'local';
|
||||||
|
const first = parseInt(host.split(':')[0] || '0', 16);
|
||||||
|
if ((first & 0xfe00) === 0xfc00) return 'local';
|
||||||
|
return 'remote';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The limits, inbuxa:AiLimits ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AiLimits {
|
||||||
|
spamMaxAdded: number;
|
||||||
|
spamMaxSubtracted: number;
|
||||||
|
/** Milliseconds. */
|
||||||
|
spamCallCeiling: number;
|
||||||
|
maxConcurrentCalls: number;
|
||||||
|
maxContentBytes: number;
|
||||||
|
/** Milliseconds. */
|
||||||
|
failureBackoff: number;
|
||||||
|
userCallsPerHour: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The spec's defaults, which the server also applies to anything unset. */
|
||||||
|
export const DEFAULT_LIMITS: AiLimits = {
|
||||||
|
spamMaxAdded: 2.0,
|
||||||
|
spamMaxSubtracted: 1.0,
|
||||||
|
spamCallCeiling: 20_000,
|
||||||
|
maxConcurrentCalls: 4,
|
||||||
|
maxContentBytes: 2048,
|
||||||
|
failureBackoff: 60_000,
|
||||||
|
userCallsPerHour: 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIMIT_FIELDS = Object.keys(DEFAULT_LIMITS) as (keyof AiLimits)[];
|
||||||
|
|
||||||
|
export class LimitsUnavailable extends Error {}
|
||||||
|
|
||||||
|
export function parseLimits(raw: Record<string, unknown>): AiLimits {
|
||||||
|
const out = { ...DEFAULT_LIMITS };
|
||||||
|
for (const key of LIMIT_FIELDS) {
|
||||||
|
const v = raw[key];
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) out[key] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function methodError(responses: JmapMethodResponse[]): string | null {
|
||||||
|
const [name, result] = responses[0] ?? [];
|
||||||
|
if (name !== 'error') return null;
|
||||||
|
return typeof result?.description === 'string' ? result.description : String(result?.type ?? 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLimits(signal?: AbortSignal): Promise<AiLimits> {
|
||||||
|
const accountId = getAccountId('x:');
|
||||||
|
const responses = await jmapRequest([[`${LIMITS}/get`, { accountId, ids: null }, '0']], signal, [INBUXA_CAPABILITY]);
|
||||||
|
const [name, result] = responses[0] ?? [];
|
||||||
|
if (name === 'error') {
|
||||||
|
const type = (result as { type?: string } | undefined)?.type;
|
||||||
|
if (type === 'unknownMethod' || type === 'unknownCapability') throw new LimitsUnavailable();
|
||||||
|
throw new Error(methodError(responses) ?? 'error');
|
||||||
|
}
|
||||||
|
const list = (result as { list?: Record<string, unknown>[] }).list ?? [];
|
||||||
|
return parseLimits(list[0] ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveOutcome {
|
||||||
|
ok: boolean;
|
||||||
|
/** The property the server rejected, and why. */
|
||||||
|
property?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves what changed. A value equal to the default is sent as null, so the
|
||||||
|
* server keeps following the default rather than pinning today's number.
|
||||||
|
*/
|
||||||
|
export async function saveLimits(current: AiLimits, next: AiLimits): Promise<SaveOutcome> {
|
||||||
|
const patch: Record<string, number | null> = {};
|
||||||
|
for (const key of LIMIT_FIELDS) {
|
||||||
|
if (next[key] === current[key]) continue;
|
||||||
|
patch[key] = next[key] === DEFAULT_LIMITS[key] ? null : next[key];
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length === 0) return { ok: true };
|
||||||
|
const accountId = getAccountId('x:');
|
||||||
|
const responses = await jmapRequest(
|
||||||
|
[[`${LIMITS}/set`, { accountId, update: { singleton: patch } }, '0']],
|
||||||
|
undefined,
|
||||||
|
[INBUXA_CAPABILITY],
|
||||||
|
);
|
||||||
|
const err = methodError(responses);
|
||||||
|
if (err) return { ok: false, message: err };
|
||||||
|
const notUpdated = (responses[0][1] as { notUpdated?: Record<string, JmapSetError> }).notUpdated;
|
||||||
|
const failure = notUpdated?.singleton;
|
||||||
|
if (failure) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
property: failure.properties?.[0],
|
||||||
|
message: failure.description ?? failure.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The classifier and its models ────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AiModelSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
model: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Status {
|
||||||
|
enabled: boolean;
|
||||||
|
/** The model the classifier asks, when enabled. */
|
||||||
|
modelId: string | null;
|
||||||
|
models: AiModelSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStatus(signal?: AbortSignal): Promise<Status> {
|
||||||
|
const accountId = getAccountId('x:');
|
||||||
|
const responses = await jmapRequest(
|
||||||
|
[
|
||||||
|
[`${CLASSIFIER}/get`, { accountId, ids: ['singleton'] }, 'c'],
|
||||||
|
[`${MODEL}/get`, { accountId, ids: null, properties: ['name', 'model', 'url'] }, 'm'],
|
||||||
|
],
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
const err = methodError(responses);
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
const classifier = ((responses[0][1] as { list?: Record<string, unknown>[] }).list ?? [])[0] ?? {};
|
||||||
|
const models = ((responses[1]?.[1] as { list?: Record<string, unknown>[] } | undefined)?.list ?? []).map((m) => ({
|
||||||
|
id: String(m.id),
|
||||||
|
name: String(m.name ?? ''),
|
||||||
|
model: String(m.model ?? ''),
|
||||||
|
url: String(m.url ?? ''),
|
||||||
|
}));
|
||||||
|
const enabled = classifier['@type'] === 'Enable';
|
||||||
|
return { enabled, modelId: enabled ? String(classifier.modelId ?? '') || null : null, models };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupInput {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The guided setup: makes sure the model exists, then switches the
|
||||||
|
* classifier on with it. A model already configured under the same name is
|
||||||
|
* reused (and its address and model name updated), so running the setup again
|
||||||
|
* after a failure never piles up duplicates. The classifier is only switched
|
||||||
|
* on once the model is known to exist, with its real id.
|
||||||
|
*/
|
||||||
|
export async function enableWithModel(input: SetupInput, existing: AiModelSummary[]): Promise<SaveOutcome> {
|
||||||
|
const accountId = getAccountId('x:');
|
||||||
|
const fields = { name: input.name, url: input.url, model: input.model, modelType: 'Chat' };
|
||||||
|
const same = existing.find((m) => m.name === input.name);
|
||||||
|
|
||||||
|
let modelId: string;
|
||||||
|
if (same) {
|
||||||
|
const responses = await jmapRequest([[`${MODEL}/set`, { accountId, update: { [same.id]: fields } }, '0']]);
|
||||||
|
const err = methodError(responses);
|
||||||
|
if (err) return { ok: false, message: err };
|
||||||
|
const f = (responses[0][1] as { notUpdated?: Record<string, JmapSetError> }).notUpdated?.[same.id];
|
||||||
|
if (f) return { ok: false, property: f.properties?.[0], message: f.description ?? f.type };
|
||||||
|
modelId = same.id;
|
||||||
|
} else {
|
||||||
|
const responses = await jmapRequest([[`${MODEL}/set`, { accountId, create: { m: fields } }, '0']]);
|
||||||
|
const err = methodError(responses);
|
||||||
|
if (err) return { ok: false, message: err };
|
||||||
|
const result = responses[0][1] as {
|
||||||
|
created?: Record<string, { id?: string }>;
|
||||||
|
notCreated?: Record<string, JmapSetError>;
|
||||||
|
};
|
||||||
|
const f = result.notCreated?.m;
|
||||||
|
if (f) return { ok: false, property: f.properties?.[0], message: f.description ?? f.type };
|
||||||
|
const id = result.created?.m?.id;
|
||||||
|
if (!id) return { ok: false, message: 'The server created the model but did not return its id.' };
|
||||||
|
modelId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = await jmapRequest([
|
||||||
|
[
|
||||||
|
`${CLASSIFIER}/set`,
|
||||||
|
{ accountId, update: { singleton: { '@type': 'Enable', modelId, prompt: input.prompt } } },
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const err = methodError(responses);
|
||||||
|
if (err) return { ok: false, message: err };
|
||||||
|
const f = (responses[0][1] as { notUpdated?: Record<string, JmapSetError> }).notUpdated?.singleton;
|
||||||
|
if (f) return { ok: false, property: f.properties?.[0], message: f.description ?? f.type };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switches the classifier off. The model stays configured, for turning it back on. */
|
||||||
|
export async function disableClassifier(): Promise<SaveOutcome> {
|
||||||
|
const accountId = getAccountId('x:');
|
||||||
|
const responses = await jmapRequest([
|
||||||
|
[`${CLASSIFIER}/set`, { accountId, update: { singleton: { '@type': 'Disable' } } }, '0'],
|
||||||
|
]);
|
||||||
|
const err = methodError(responses);
|
||||||
|
if (err) return { ok: false, message: err };
|
||||||
|
const f = (responses[0][1] as { notUpdated?: Record<string, JmapSetError> }).notUpdated?.singleton;
|
||||||
|
return f ? { ok: false, message: f.description ?? f.type } : { ok: true };
|
||||||
|
}
|
||||||
@@ -71,6 +71,11 @@ function checkSpecialLink(
|
|||||||
return { visible: canGet ? canGet('sysNetworkListener') : true, enterprise: false };
|
return { visible: canGet ? canGet('sysNetworkListener') : true, enterprise: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: the Local AI page is for whoever may see the AI classifier.
|
||||||
|
if (viewName === 'CustomComponent/LocalAi') {
|
||||||
|
return { visible: canGet ? canGet('sysSpamLlm') : true, enterprise: false };
|
||||||
|
}
|
||||||
|
|
||||||
if (viewName.startsWith('CustomComponent/')) {
|
if (viewName.startsWith('CustomComponent/')) {
|
||||||
return { visible: true, enterprise: false };
|
return { visible: true, enterprise: false };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user