Explain this: ask the local model about a failure, verdict, log line or setting #18
No files matched your search
@@ -11,6 +11,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 { FailedRecipients } from '@/features/ai/explain/FailedRecipients';
|
||||
import { iconForView } from '@/lib/viewIcon';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
@@ -809,6 +810,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* inbuxa: "Explain this" for each failed recipient (ai-explain EX-17) */}
|
||||
{resolved.obj.objectName === 'x:QueuedMessage' && objectId && (
|
||||
<FailedRecipients queueId={objectId} recipients={formData.recipients} />
|
||||
)}
|
||||
|
||||
{sectionsToRender.map((section, sectionIdx) => (
|
||||
<Card key={sectionIdx}>
|
||||
{section.title && (
|
||||
@@ -883,6 +889,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
isSieveScriptField(resolved.obj.objectName, formField.name) ? scriptName : undefined
|
||||
}
|
||||
helpScope={helpScope}
|
||||
explainTarget={
|
||||
isCreate
|
||||
? undefined
|
||||
: { object: resolved.obj.objectName, id: isSingleton ? 'singleton' : String(objectId) }
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useState, useEffect, useMemo, type KeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue';
|
||||
import { HelpTip } from '@/help/HelpTip';
|
||||
import { ExplainButton } from '@/features/ai/explain/ExplainButton';
|
||||
import { fieldHelp } from '@/help/texts';
|
||||
import { describeDefault, differsFromDefault } from '@/help/defaults';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -69,6 +70,8 @@ export interface FieldWidgetProps {
|
||||
sieveScriptName?: string;
|
||||
/** INBUXA: the object or schema that owns this field, for its help id (`scope.field`). */
|
||||
helpScope?: string;
|
||||
/** inbuxa: the saved record this field belongs to, for "Explain this" (ai-explain EX-17). */
|
||||
explainTarget?: { object: string; id: string };
|
||||
}
|
||||
|
||||
function getRequiredMarker(field: Field, readOnly: boolean): 'required' | 'optional' | null {
|
||||
@@ -89,7 +92,8 @@ function getRequiredMarker(field: Field, readOnly: boolean): 'required' | 'optio
|
||||
|
||||
export function FieldWidget(props: FieldWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
const { field, formField, value, onChange, readOnly, error, schema, sieveScriptName, helpScope } = props;
|
||||
const { field, formField, value, onChange, readOnly, error, schema, sieveScriptName, helpScope, explainTarget } =
|
||||
props;
|
||||
const helpId = helpScope ? `${helpScope}.${formField.name}` : undefined;
|
||||
// INBUXA: the option's default, for its tooltip, and whether it has been changed.
|
||||
const defaultValue = helpScope ? schema.fields[helpScope]?.defaults?.[formField.name] : undefined;
|
||||
@@ -250,6 +254,19 @@ export function FieldWidget(props: FieldWidgetProps) {
|
||||
)}
|
||||
</Label>
|
||||
<HelpTip id={helpId} text={fieldHelp(helpId, field.description)} footnote={defaultNote} />
|
||||
{/* inbuxa: the server refuses any setting that holds a secret (EX-9); a plain secret isn't offered */}
|
||||
{explainTarget && !(ft.type === 'string' && ft.format === 'secret') && (
|
||||
<ExplainButton
|
||||
icon
|
||||
title={String(formField.label ?? formField.name)}
|
||||
subject={{
|
||||
'@type': 'Setting',
|
||||
object: explainTarget.object,
|
||||
id: explainTarget.id,
|
||||
property: formField.name,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{changed && (
|
||||
<span
|
||||
className="rounded-full bg-primary/10 px-1.5 py-px text-[10px] font-medium text-primary"
|
||||
|
||||
@@ -73,6 +73,8 @@ import { jmapGetBatched, jmapQueryAll, jmapQueryAndGet, jmapSet, getAccountId }
|
||||
import type { Schema, Field, MassAction, ItemAction, Filter as FilterDef } from '@/types/schema';
|
||||
import type { JmapSetResponse, JmapSetError } from '@/types/jmap';
|
||||
import type { ResolvedSchema } from '@/lib/schemaResolver';
|
||||
import { ExplainButton } from '@/features/ai/explain/ExplainButton';
|
||||
import { useExplainStore } from '@/features/ai/explain/explainStore';
|
||||
|
||||
const ENUM_FILTER_COMBOBOX_THRESHOLD = 15;
|
||||
|
||||
@@ -359,6 +361,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||
const edition = useAccountStore((s) => s.edition);
|
||||
const explainAvailable = useExplainStore((s) => s.available);
|
||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||
|
||||
const resolved = useMemo(() => {
|
||||
@@ -856,6 +859,9 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
|
||||
const hasMassActions = effectiveMassActions.length > 0;
|
||||
const hasItemActions = (list.itemActions?.length ?? 0) > 0;
|
||||
// inbuxa: "Explain this" on each log entry (ai-explain EX-17)
|
||||
const explainLogs = explainAvailable && obj.objectName === 'x:Log';
|
||||
const hasRowCell = hasItemActions || explainLogs;
|
||||
|
||||
const pageStart = anchorStack.length * PAGE_SIZE;
|
||||
const rangeStart = pageStart + 1;
|
||||
@@ -1265,7 +1271,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
{hasItemActions && (
|
||||
{hasRowCell && (
|
||||
<th className="w-12 px-3 py-3 text-right font-medium text-muted-foreground">
|
||||
{t('list.actions', 'Actions')}
|
||||
</th>
|
||||
@@ -1276,7 +1282,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
{loading && items.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
|
||||
colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasRowCell ? 1 : 0)}
|
||||
className="px-3 py-12 text-center"
|
||||
>
|
||||
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
||||
@@ -1284,10 +1290,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
</tr>
|
||||
) : items.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
|
||||
className="px-3"
|
||||
>
|
||||
<td colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasRowCell ? 1 : 0)} className="px-3">
|
||||
<EmptyState
|
||||
title={t('list.emptyTitle', 'Nothing here yet')}
|
||||
hint={t('list.noResults', 'No results found')}
|
||||
@@ -1333,7 +1336,21 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
{hasItemActions && <td className="px-3 py-2 text-right">{renderItemActions(item)}</td>}
|
||||
{hasRowCell && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{explainLogs && item.id != null && (
|
||||
<ExplainButton
|
||||
icon
|
||||
className="h-8 w-8"
|
||||
title={String(item.event ?? item.id)}
|
||||
subject={{ '@type': 'LogEntry', logId: String(item.id) }}
|
||||
/>
|
||||
)}
|
||||
{renderItemActions(item)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
@@ -19,6 +21,8 @@ import { FieldWidget } from '@/components/forms/FieldWidget';
|
||||
import { DynamicView } from '@/components/views/DynamicView';
|
||||
import type { Schema, ObjectVariant } from '@/types/schema';
|
||||
import type { JmapSetResponse } from '@/types/jmap';
|
||||
import { ExplainButton } from '@/features/ai/explain/ExplainButton';
|
||||
import { spamVerdictSubject } from '@/features/ai/explain/explain';
|
||||
|
||||
type ActionState =
|
||||
| { kind: 'pick' }
|
||||
@@ -467,6 +471,14 @@ function ActionResultView({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* inbuxa: "Explain this" for a spam verdict (ai-explain EX-17) */}
|
||||
{variant.name === 'ClassifySpam' && props && (
|
||||
<ExplainButton
|
||||
subject={() => spamVerdictSubject(props)}
|
||||
title={t('explain.spamVerdictTitle', 'Spam verdict: {{result}}', { result: String(props.result ?? '') })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hasData && (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
type Status,
|
||||
} from './localAi';
|
||||
import { formNotices } from './formExtras';
|
||||
import { ExplanationsCard } from './explain/ExplanationsCard';
|
||||
|
||||
export const LOCAL_AI_VIEW = 'CustomComponent/LocalAi';
|
||||
|
||||
@@ -148,6 +149,7 @@ export function LocalAiPage() {
|
||||
)}
|
||||
|
||||
<LimitsCard canUpdate={canUpdate} />
|
||||
<ExplanationsCard canUpdate={canUpdate} models={status.kind === 'ready' ? status.value.models : []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useExplainStore } from './explainStore';
|
||||
import type { ExplainSubject } from './explain';
|
||||
|
||||
/**
|
||||
* inbuxa: asks the local model to explain `subject` in the side panel
|
||||
* (EX-17). Drawn only when the session says Explain is available; `icon`
|
||||
* is the small form for help tips and table rows.
|
||||
*/
|
||||
export function ExplainButton({
|
||||
subject,
|
||||
title,
|
||||
icon = false,
|
||||
className,
|
||||
}: {
|
||||
subject: ExplainSubject | (() => ExplainSubject);
|
||||
/** What the panel is about, e.g. the recipient or the setting's label. */
|
||||
title: string;
|
||||
icon?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const available = useExplainStore((s) => s.available);
|
||||
const ask = useExplainStore((s) => s.ask);
|
||||
if (!available) return null;
|
||||
const label = t('explain.button', 'Explain');
|
||||
const onClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
ask(typeof subject === 'function' ? subject() : subject, title);
|
||||
};
|
||||
if (icon) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={t('explain.buttonAbout', 'Explain {{what}}', { what: title })}
|
||||
title={label}
|
||||
className={cn(
|
||||
'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:text-primary focus-visible:text-primary focus-visible:outline-none',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClick} className={cn('h-7 gap-1.5 px-2', className)}>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Check, Copy, Loader2, Sparkles, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useExplainStore } from './explainStore';
|
||||
import type { ExplainFailure } from './explain';
|
||||
|
||||
/**
|
||||
* inbuxa: the explanation, beside the page rather than over it, so the
|
||||
* details it explains stay in view (EX-18 to EX-20). Mounted once.
|
||||
*/
|
||||
export function ExplainPanel() {
|
||||
const { t } = useTranslation();
|
||||
const panel = useExplainStore((s) => s.panel);
|
||||
const close = useExplainStore((s) => s.close);
|
||||
const retry = useExplainStore((s) => s.retry);
|
||||
const [copiedText, setCopiedText] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (panel.status === 'closed') return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [panel.status, close]);
|
||||
|
||||
if (panel.status === 'closed') return null;
|
||||
const copied = panel.status === 'done' && copiedText === panel.explanation.text;
|
||||
|
||||
return (
|
||||
<aside
|
||||
role="complementary"
|
||||
aria-label={t('explain.panelLabel', 'Explanation')}
|
||||
className="fixed inset-x-0 bottom-0 z-40 flex max-h-[70vh] flex-col border-t bg-card shadow-soft sm:inset-x-auto sm:right-4 sm:bottom-4 sm:top-auto sm:w-[26rem] sm:rounded-xl sm:border"
|
||||
>
|
||||
<header className="flex items-start gap-2 border-b px-4 py-3">
|
||||
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">{t('explain.title', 'Explanation')}</p>
|
||||
<p className="truncate text-xs text-muted-foreground" title={panel.title}>
|
||||
{panel.title}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={close}
|
||||
aria-label={t('explain.close', 'Close')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="overflow-y-auto px-4 py-3 text-sm" aria-live="polite">
|
||||
{panel.status === 'asking' && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>{t('explain.asking', 'Asking the local model…')}</span>
|
||||
<Button type="button" variant="ghost" size="sm" className="ml-auto h-7" onClick={close}>
|
||||
{t('explain.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panel.status === 'failed' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground">{failureText(t, panel.failure)}</p>
|
||||
{(panel.failure.kind === 'busy' || panel.failure.kind === 'timeout') && (
|
||||
<Button type="button" variant="outline" size="sm" className="h-7" onClick={retry}>
|
||||
{t('explain.retry', 'Try again')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panel.status === 'done' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 whitespace-pre-line leading-relaxed">{panel.explanation.text}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('explain.byline', 'Local AI · {{model}} · {{node}}', {
|
||||
model: panel.explanation.model,
|
||||
node: panel.explanation.node,
|
||||
})}
|
||||
</p>
|
||||
<p className="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t(
|
||||
'explain.caution',
|
||||
"This is the local model's reading, and it can be wrong. Check it against the details shown.",
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5"
|
||||
onClick={() =>
|
||||
void navigator.clipboard
|
||||
.writeText(panel.explanation.text)
|
||||
.then(() => setCopiedText(panel.explanation.text))
|
||||
}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? t('explain.copied', 'Copied') : t('explain.copy', 'Copy')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function failureText(t: TFunction, failure: ExplainFailure): string {
|
||||
switch (failure.kind) {
|
||||
case 'busy':
|
||||
return t('explain.busy', 'The model is busy with incoming mail. Try again in a moment.');
|
||||
case 'timeout':
|
||||
return t('explain.timeout', "The model didn't answer in time.");
|
||||
case 'paused':
|
||||
return t('explain.paused', 'The model is paused after repeated failures. It will be tried again shortly.');
|
||||
case 'rateLimit':
|
||||
return t('explain.rateLimit', "You've asked for as many explanations as this hour allows.");
|
||||
case 'unavailable':
|
||||
return t('explain.unavailable', 'No local model is available to explain this right now.');
|
||||
case 'refused':
|
||||
return failure.message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2 } 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 { Switch } from '@/components/ui/switch';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { fetchSession } from '@/services/jmap/client';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import type { AiModelSummary } from '../localAi';
|
||||
import { useExplainStore } from './explainStore';
|
||||
import {
|
||||
DEFAULT_EXPLAIN,
|
||||
explainAvailable,
|
||||
fetchExplainSettings,
|
||||
saveExplainSettings,
|
||||
type ExplainSettings,
|
||||
} from './explain';
|
||||
|
||||
/**
|
||||
* inbuxa: Local AI › Explanations (ai-explain EX-21): the switch, which model
|
||||
* explains, and the limits. Hidden on a server that predates explanations.
|
||||
*/
|
||||
export function ExplanationsCard({ canUpdate, models }: { canUpdate: boolean; models: AiModelSummary[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [current, setCurrent] = useState<ExplainSettings | null>(null);
|
||||
const [draft, setDraft] = useState({ enabled: true, modelId: '', perHour: '', ceiling: '' });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fill = (s: ExplainSettings) =>
|
||||
setDraft({
|
||||
enabled: s.explainEnabled,
|
||||
modelId: s.explainModelId ?? '',
|
||||
perHour: String(s.explainCallsPerHour),
|
||||
ceiling: String(s.explainCeiling / 1000),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const ctl = new AbortController();
|
||||
fetchExplainSettings(ctl.signal)
|
||||
.then((s) => {
|
||||
if (ctl.signal.aborted || !s) return;
|
||||
setCurrent(s);
|
||||
fill(s);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => ctl.abort();
|
||||
}, []);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const perHour = Number(draft.perHour);
|
||||
const ceiling = Number(draft.ceiling);
|
||||
const valid = Number.isInteger(perHour) && perHour >= 1 && Number.isFinite(ceiling) && ceiling >= 1 && ceiling <= 600;
|
||||
|
||||
const save = async () => {
|
||||
if (!valid) return;
|
||||
const next: ExplainSettings = {
|
||||
explainEnabled: draft.enabled,
|
||||
explainModelId: draft.modelId || null,
|
||||
explainCallsPerHour: perHour,
|
||||
explainCeiling: Math.round(ceiling * 1000),
|
||||
};
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const outcome = await saveExplainSettings(current, next).catch((e: unknown) => ({
|
||||
ok: false,
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
}));
|
||||
if (outcome.ok) {
|
||||
setCurrent(next);
|
||||
// Explain buttons follow the switch at once
|
||||
const session = await fetchSession().catch(() => null);
|
||||
if (session) {
|
||||
useExplainStore.getState().setAvailable(explainAvailable(session, useAuthStore.getState().primaryAccountId));
|
||||
}
|
||||
toast({ title: t('explain.settingsSaved', 'Explanation settings saved.') });
|
||||
} else {
|
||||
setError(outcome.message ?? '');
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('explain.settingsTitle', 'Explanations')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
'explain.settingsLead',
|
||||
'An Explain button beside delivery failures, spam verdicts, log and trace events, and settings asks the model for a short explanation in plain words. It never takes a slot the spam filter needs, and message text is never sent.',
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id="explain-enabled"
|
||||
checked={draft.enabled}
|
||||
disabled={!canUpdate || busy}
|
||||
onCheckedChange={(enabled) => setDraft({ ...draft, enabled })}
|
||||
/>
|
||||
<Label htmlFor="explain-enabled">{t('explain.enabled', 'Offer explanations')}</Label>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="explain-model">{t('explain.model', 'Model')}</Label>
|
||||
<select
|
||||
id="explain-model"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm disabled:opacity-50"
|
||||
value={draft.modelId}
|
||||
disabled={!canUpdate || busy}
|
||||
onChange={(e) => setDraft({ ...draft, modelId: e.target.value })}
|
||||
>
|
||||
<option value="">{t('explain.modelDefault', 'Same as the spam filter')}</option>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name || m.model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="explain-per-hour">{t('explain.perHour', 'Per administrator, per hour')}</Label>
|
||||
<Input
|
||||
id="explain-per-hour"
|
||||
inputMode="numeric"
|
||||
value={draft.perHour}
|
||||
disabled={!canUpdate || busy}
|
||||
onChange={(e) => setDraft({ ...draft, perHour: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('localAi.limitDefault', 'Default: {{value}}', { value: DEFAULT_EXPLAIN.explainCallsPerHour })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="explain-ceiling">{t('explain.ceiling', 'Longest wait')}</Label>
|
||||
<Input
|
||||
id="explain-ceiling"
|
||||
inputMode="decimal"
|
||||
value={draft.ceiling}
|
||||
disabled={!canUpdate || busy}
|
||||
onChange={(e) => setDraft({ ...draft, ceiling: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('localAi.limitDefault', 'Default: {{value}}', { value: DEFAULT_EXPLAIN.explainCeiling / 1000 })}{' '}
|
||||
{t('localAi.seconds', 's')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{canUpdate && (
|
||||
<Button onClick={save} disabled={busy || !valid}>
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('common.save', 'Save')}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ExplainButton } from './ExplainButton';
|
||||
import { useExplainStore } from './explainStore';
|
||||
import { failedRecipients } from './explain';
|
||||
|
||||
/**
|
||||
* inbuxa: a queued message's failed recipients, each with Explain (EX-17).
|
||||
* Shown above the message's form, and only when there is something to
|
||||
* explain and a model to explain it.
|
||||
*/
|
||||
export function FailedRecipients({ queueId, recipients }: { queueId: string; recipients: unknown }) {
|
||||
const { t } = useTranslation();
|
||||
const available = useExplainStore((s) => s.available);
|
||||
const failed = failedRecipients(recipients);
|
||||
if (!available || failed.length === 0) return null;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('explain.failedRecipients', 'Failed deliveries')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y">
|
||||
{failed.map((rcpt) => (
|
||||
<li key={rcpt.address} className="flex items-center gap-3 py-2 first:pt-0 last:pb-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{rcpt.address}</p>
|
||||
<p className="truncate text-xs text-muted-foreground" title={rcpt.summary}>
|
||||
{rcpt.status === 'PermanentFailure'
|
||||
? t('explain.permanent', 'Permanent failure')
|
||||
: t('explain.temporary', 'Temporary failure')}
|
||||
{rcpt.summary && ` · ${rcpt.summary}`}
|
||||
</p>
|
||||
</div>
|
||||
<ExplainButton
|
||||
subject={{ '@type': 'DeliveryFailure', queueId, recipient: rcpt.address }}
|
||||
title={rcpt.address}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* 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_EXPLAIN,
|
||||
explainAvailable,
|
||||
failedRecipients,
|
||||
isRawEvent,
|
||||
liveEventSubject,
|
||||
requestExplanation,
|
||||
saveExplainSettings,
|
||||
spamVerdictSubject,
|
||||
valueText,
|
||||
} from './explain';
|
||||
import { normalizeTraceEvents } from '@/features/tracing/normalize';
|
||||
|
||||
beforeEach(() => jmapRequest.mockReset());
|
||||
|
||||
describe('explainAvailable', () => {
|
||||
it('reads the flag from the primary account only', () => {
|
||||
const session = {
|
||||
accounts: {
|
||||
b: { accountCapabilities: { 'urn:inbuxa:jmap': { aiExplain: true } } },
|
||||
c: { accountCapabilities: { 'urn:inbuxa:jmap': { aiExplain: false } } },
|
||||
},
|
||||
};
|
||||
expect(explainAvailable(session, 'b')).toBe(true);
|
||||
expect(explainAvailable(session, 'c')).toBe(false);
|
||||
expect(explainAvailable(session, null)).toBe(false);
|
||||
expect(explainAvailable({ accounts: { b: { accountCapabilities: {} } } }, 'b')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subjects', () => {
|
||||
it('trims a live event to what the server accepts, without raw contents', () => {
|
||||
const keyValues = [
|
||||
{ key: 'contents', value: { '@type': 'String', value: 'a LOGIN bob hunter2' } },
|
||||
{ key: 'remoteIp', value: { '@type': 'IpAddr', value: '192.0.2.1' } },
|
||||
{ key: 'long', value: { '@type': 'String', value: 'x'.repeat(900) } },
|
||||
...Array.from({ length: 60 }, (_, n) => ({ key: `k${n}`, value: { '@type': 'UnsignedInt', value: n } })),
|
||||
];
|
||||
const subject = liveEventSubject({ event: 'imap.command', keyValues });
|
||||
if (!('keyValues' in subject)) throw new Error('not a live event');
|
||||
expect(subject.keyValues).toHaveLength(50);
|
||||
expect(JSON.stringify(subject)).not.toContain('hunter2');
|
||||
expect(subject.keyValues[0]).toEqual({ key: 'remoteIp', value: '192.0.2.1' });
|
||||
expect(subject.keyValues[1].value.length).toBeLessThanOrEqual(500);
|
||||
});
|
||||
|
||||
it('takes a live event as the stream sends it, key-values as a JMAP map', () => {
|
||||
const [event] = normalizeTraceEvents(
|
||||
JSON.parse(
|
||||
'[{"event":"http.response-body","timestamp":"2026-09-26T08:07:09Z","keyValues":{' +
|
||||
'"0":{"key":"spanId","value":{"value":331622707493871616,"@type":"UnsignedInt"}},' +
|
||||
'"1":{"key":"contents","value":{"value":"{\\"accessToken\\":\\"secret\\"}","@type":"String"}},' +
|
||||
'"2":{"key":"code","value":{"value":200,"@type":"UnsignedInt"}}}}]',
|
||||
),
|
||||
);
|
||||
const subject = liveEventSubject(event);
|
||||
if (!('keyValues' in subject)) throw new Error('not a live event');
|
||||
expect(subject.keyValues.map((kv) => kv.key)).toEqual(['spanId', 'code']);
|
||||
expect(subject.keyValues[1]).toEqual({ key: 'code', value: '200' });
|
||||
expect(JSON.stringify(subject)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('flattens typed values the way the server does', () => {
|
||||
expect(
|
||||
valueText({
|
||||
'@type': 'List',
|
||||
value: [
|
||||
{ '@type': 'String', value: 'a' },
|
||||
{ '@type': 'UnsignedInt', value: 2 },
|
||||
],
|
||||
}),
|
||||
).toBe('a, 2');
|
||||
expect(isRawEvent('smtp.raw-input')).toBe(true);
|
||||
expect(isRawEvent('smtp.ehlo')).toBe(false);
|
||||
});
|
||||
|
||||
it('lists only failed recipients, with their reply', () => {
|
||||
const failed = failedRecipients({
|
||||
'[email protected]': { status: { '@type': 'Scheduled' } },
|
||||
'[email protected]': {
|
||||
status: {
|
||||
'@type': 'PermanentFailure',
|
||||
responseCode: 550,
|
||||
responseEnhanced: '5.7.26',
|
||||
responseMessage: 'DMARC',
|
||||
},
|
||||
},
|
||||
'[email protected]': { status: { '@type': 'TemporaryFailure', errorType: 'ConnectionError' } },
|
||||
});
|
||||
expect(failed).toEqual([
|
||||
{ address: '[email protected]', status: 'PermanentFailure', summary: '550 5.7.26 DMARC' },
|
||||
{ address: '[email protected]', status: 'TemporaryFailure', summary: 'ConnectionError' },
|
||||
]);
|
||||
expect(failedRecipients(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends a Classify result back as a verdict', () => {
|
||||
expect(
|
||||
spamVerdictSubject({
|
||||
result: 'spam',
|
||||
score: 7.5,
|
||||
tags: { DMARC_POLICY_REJECT: { score: 5, disposition: 'score' } },
|
||||
}),
|
||||
).toEqual({
|
||||
'@type': 'SpamVerdict',
|
||||
result: 'spam',
|
||||
score: 7.5,
|
||||
tags: { DMARC_POLICY_REJECT: { score: 5, disposition: 'score' } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestExplanation', () => {
|
||||
const setting = { '@type': 'Setting', object: 'x:Domain', id: 'b', property: 'isEnabled' } as const;
|
||||
|
||||
it('returns the explanation and its provenance', async () => {
|
||||
jmapRequest.mockResolvedValue([
|
||||
[
|
||||
'inbuxa:Explanation/set',
|
||||
{ created: { e: { text: 'It turns it on.', model: 'qwen', node: 'host2', elapsedMs: 900 } } },
|
||||
],
|
||||
]);
|
||||
const result = await requestExplanation(setting);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
explanation: { text: 'It turns it on.', model: 'qwen', node: 'host2', elapsedMs: 900 },
|
||||
});
|
||||
const [calls, , using] = jmapRequest.mock.calls[0];
|
||||
expect(calls[0][1]).toEqual({ accountId: 'a', create: { e: { subject: setting } } });
|
||||
expect(using).toEqual(['urn:inbuxa:jmap']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ type: 'serverFail', description: 'busy' }, { kind: 'busy' }],
|
||||
[{ type: 'serverFail', description: 'timeout' }, { kind: 'timeout' }],
|
||||
[{ type: 'serverFail', description: 'paused' }, { kind: 'paused' }],
|
||||
[{ type: 'serverFail', description: 'unavailable' }, { kind: 'unavailable' }],
|
||||
[{ type: 'rateLimit' }, { kind: 'rateLimit' }],
|
||||
[
|
||||
{ type: 'forbidden', description: 'That setting holds a secret.' },
|
||||
{ kind: 'refused', message: 'That setting holds a secret.' },
|
||||
],
|
||||
])('words %o as %o (EX-20)', async (error, failure) => {
|
||||
jmapRequest.mockResolvedValue([['inbuxa:Explanation/set', { notCreated: { e: error } }]]);
|
||||
expect(await requestExplanation(setting)).toEqual({ ok: false, failure });
|
||||
});
|
||||
|
||||
it('treats a server without explanations as unavailable', async () => {
|
||||
jmapRequest.mockResolvedValue([['error', { type: 'unknownMethod' }]]);
|
||||
expect(await requestExplanation(setting)).toEqual({ ok: false, failure: { kind: 'unavailable' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveExplainSettings', () => {
|
||||
it('sends only what changed, and a default as null', async () => {
|
||||
jmapRequest.mockResolvedValue([['inbuxa:AiLimits/set', { updated: { singleton: null } }]]);
|
||||
const outcome = await saveExplainSettings(
|
||||
{ ...DEFAULT_EXPLAIN, explainCallsPerHour: 10 },
|
||||
{ ...DEFAULT_EXPLAIN, explainEnabled: false, explainModelId: 'm' },
|
||||
);
|
||||
expect(outcome).toEqual({ ok: true });
|
||||
expect(jmapRequest.mock.calls[0][0][0][1].update.singleton).toEqual({
|
||||
explainEnabled: false,
|
||||
explainModelId: 'm',
|
||||
explainCallsPerHour: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing when nothing changed', async () => {
|
||||
expect(await saveExplainSettings(DEFAULT_EXPLAIN, { ...DEFAULT_EXPLAIN })).toEqual({ ok: true });
|
||||
expect(jmapRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: "Explain this" (inbuxa-server's ai-explain spec). The console
|
||||
* names what to explain; the server reads the data, builds the prompt and
|
||||
* asks the local model on the node that answers. Nothing here sends free
|
||||
* text to a model.
|
||||
*/
|
||||
|
||||
import { getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
import { INBUXA_CAPABILITY } from '../localAi';
|
||||
|
||||
/** What to explain: the `subject` of an `inbuxa:Explanation`. */
|
||||
export type ExplainSubject =
|
||||
| { '@type': 'DeliveryFailure'; queueId: string; recipient: string }
|
||||
| {
|
||||
'@type': 'SpamVerdict';
|
||||
result: string;
|
||||
score: number;
|
||||
tags: Record<string, { score?: number; disposition?: string }>;
|
||||
}
|
||||
| { '@type': 'LogEntry'; logId: string }
|
||||
| { '@type': 'TraceEvent'; traceId: string; index: number }
|
||||
| { '@type': 'TraceEvent'; event: string; keyValues: { key: string; value: string }[] }
|
||||
| { '@type': 'Setting'; object: string; id: string; property: string };
|
||||
|
||||
export interface Explanation {
|
||||
text: string;
|
||||
model: string;
|
||||
node: string;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
/** Why there's no explanation, in the few kinds the panel words differently (EX-20). */
|
||||
export type ExplainFailure =
|
||||
{ kind: 'busy' | 'timeout' | 'paused' | 'unavailable' | 'rateLimit' } | { kind: 'refused'; message: string };
|
||||
|
||||
export type ExplainResult = { ok: true; explanation: Explanation } | { ok: false; failure: ExplainFailure };
|
||||
|
||||
/** The session's flag (EX-1 to EX-4): Explain is shown only when it's true. */
|
||||
export function explainAvailable(session: Record<string, unknown>, accountId: string | null): boolean {
|
||||
const accounts = session.accounts as Record<string, { accountCapabilities?: Record<string, unknown> }> | undefined;
|
||||
const capability = accountId ? accounts?.[accountId]?.accountCapabilities?.[INBUXA_CAPABILITY] : undefined;
|
||||
return (capability as { aiExplain?: unknown } | undefined)?.aiExplain === true;
|
||||
}
|
||||
|
||||
// The server's limits (EX-8), kept here so a live event is trimmed rather than refused
|
||||
const MAX_KEY_VALUES = 50;
|
||||
const MAX_VALUE_CHARS = 500;
|
||||
/** Raw protocol bytes: never sent (EX-9). */
|
||||
const DROPPED_KEYS = new Set(['contents']);
|
||||
|
||||
export function isRawEvent(event: string): boolean {
|
||||
return event.endsWith('.raw-input') || event.endsWith('.raw-output');
|
||||
}
|
||||
|
||||
/** A trace value as plain text, the way the server flattens a stored one. */
|
||||
export function valueText(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value)) return value.map(valueText).filter(Boolean).join(', ');
|
||||
if (typeof value === 'object') {
|
||||
return Object.entries(value as Record<string, unknown>)
|
||||
.filter(([k]) => k !== '@type')
|
||||
.map(([, v]) => valueText(v))
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** A live tracing event, trimmed to what the server accepts. */
|
||||
export function liveEventSubject(event: {
|
||||
event: string;
|
||||
keyValues: { key: string; value: unknown }[];
|
||||
}): ExplainSubject {
|
||||
const keyValues = event.keyValues
|
||||
.filter((kv) => !DROPPED_KEYS.has(kv.key))
|
||||
.slice(0, MAX_KEY_VALUES)
|
||||
.map((kv) => {
|
||||
const text = valueText(kv.value);
|
||||
return { key: kv.key, value: text.length > MAX_VALUE_CHARS ? `${text.slice(0, MAX_VALUE_CHARS - 1)}…` : text };
|
||||
});
|
||||
return { '@type': 'TraceEvent', event: event.event, keyValues };
|
||||
}
|
||||
|
||||
/** The failed recipients of a queued message, as the queue view holds it. */
|
||||
export function failedRecipients(
|
||||
recipients: unknown,
|
||||
): { address: string; status: 'TemporaryFailure' | 'PermanentFailure'; summary: string }[] {
|
||||
if (!recipients || typeof recipients !== 'object') return [];
|
||||
const out: { address: string; status: 'TemporaryFailure' | 'PermanentFailure'; summary: string }[] = [];
|
||||
for (const [address, rcpt] of Object.entries(recipients as Record<string, unknown>)) {
|
||||
const status = (rcpt as { status?: Record<string, unknown> } | null)?.status;
|
||||
const type = status?.['@type'];
|
||||
if (type !== 'TemporaryFailure' && type !== 'PermanentFailure') continue;
|
||||
const code = [status?.responseCode, status?.responseEnhanced].filter((v) => v != null && v !== '').join(' ');
|
||||
const words = status?.responseMessage ?? status?.errorMessage ?? status?.errorType ?? '';
|
||||
out.push({ address, status: type, summary: [code, String(words)].filter(Boolean).join(' ') });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function failureFrom(error: { type?: string; description?: string } | undefined): ExplainFailure {
|
||||
const type = error?.type;
|
||||
const description = error?.description ?? '';
|
||||
if (type === 'serverFail') {
|
||||
if (description === 'busy' || description === 'timeout' || description === 'paused') return { kind: description };
|
||||
return { kind: 'unavailable' };
|
||||
}
|
||||
if (type === 'rateLimit') return { kind: 'rateLimit' };
|
||||
if (type === 'unknownMethod' || type === 'unknownCapability') return { kind: 'unavailable' };
|
||||
return { kind: 'refused', message: description || type || 'error' };
|
||||
}
|
||||
|
||||
/** Asks for one explanation. */
|
||||
export async function requestExplanation(subject: ExplainSubject, signal?: AbortSignal): Promise<ExplainResult> {
|
||||
const accountId = getAccountId('x:');
|
||||
const responses = await jmapRequest(
|
||||
[['inbuxa:Explanation/set', { accountId, create: { e: { subject } } }, '0']],
|
||||
signal,
|
||||
[INBUXA_CAPABILITY],
|
||||
);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name === 'error') return { ok: false, failure: failureFrom(result as { type?: string; description?: string }) };
|
||||
const body = result as {
|
||||
created?: Record<string, Partial<Explanation>>;
|
||||
notCreated?: Record<string, { type?: string; description?: string }>;
|
||||
};
|
||||
const created = body.created?.e;
|
||||
if (created && typeof created.text === 'string') {
|
||||
return {
|
||||
ok: true,
|
||||
explanation: {
|
||||
text: created.text,
|
||||
model: String(created.model ?? ''),
|
||||
node: String(created.node ?? ''),
|
||||
elapsedMs: Number(created.elapsedMs ?? 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: false, failure: failureFrom(body.notCreated?.e) };
|
||||
}
|
||||
|
||||
/** The result of Actions › Classify a message, sent back as a verdict. */
|
||||
export function spamVerdictSubject(result: Record<string, unknown>): ExplainSubject {
|
||||
const tags: Record<string, { score?: number; disposition?: string }> = {};
|
||||
const raw = result.tags;
|
||||
if (raw && typeof raw === 'object') {
|
||||
for (const [name, tag] of Object.entries(raw as Record<string, Record<string, unknown> | null>)) {
|
||||
tags[name] = {
|
||||
score: typeof tag?.score === 'number' ? tag.score : undefined,
|
||||
disposition: typeof tag?.disposition === 'string' ? tag.disposition : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
'@type': 'SpamVerdict',
|
||||
result: String(result.result ?? ''),
|
||||
score: typeof result.score === 'number' ? result.score : 0,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Settings, in inbuxa:AiLimits (EX-21) ─────────────────────────────────
|
||||
|
||||
export interface ExplainSettings {
|
||||
explainEnabled: boolean;
|
||||
/** Unset: the spam classifier's model, or the only model there is (EX-3). */
|
||||
explainModelId: string | null;
|
||||
explainCallsPerHour: number;
|
||||
/** Milliseconds. */
|
||||
explainCeiling: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_EXPLAIN: ExplainSettings = {
|
||||
explainEnabled: true,
|
||||
explainModelId: null,
|
||||
explainCallsPerHour: 30,
|
||||
explainCeiling: 45_000,
|
||||
};
|
||||
|
||||
export function parseExplainSettings(raw: Record<string, unknown>): ExplainSettings {
|
||||
return {
|
||||
explainEnabled: typeof raw.explainEnabled === 'boolean' ? raw.explainEnabled : DEFAULT_EXPLAIN.explainEnabled,
|
||||
explainModelId: typeof raw.explainModelId === 'string' && raw.explainModelId ? raw.explainModelId : null,
|
||||
explainCallsPerHour:
|
||||
typeof raw.explainCallsPerHour === 'number' ? raw.explainCallsPerHour : DEFAULT_EXPLAIN.explainCallsPerHour,
|
||||
explainCeiling: typeof raw.explainCeiling === 'number' ? raw.explainCeiling : DEFAULT_EXPLAIN.explainCeiling,
|
||||
};
|
||||
}
|
||||
|
||||
/** Null when the server predates explanations. */
|
||||
export async function fetchExplainSettings(signal?: AbortSignal): Promise<ExplainSettings | null> {
|
||||
const accountId = getAccountId('x:');
|
||||
const responses = await jmapRequest(
|
||||
[
|
||||
[
|
||||
'inbuxa:AiLimits/get',
|
||||
{
|
||||
accountId,
|
||||
ids: null,
|
||||
properties: ['explainEnabled', 'explainModelId', 'explainCallsPerHour', 'explainCeiling'],
|
||||
},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
signal,
|
||||
[INBUXA_CAPABILITY],
|
||||
);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name === 'error') return null;
|
||||
const row = ((result as { list?: Record<string, unknown>[] }).list ?? [])[0] ?? {};
|
||||
// An older server ignores the properties it doesn't know
|
||||
if (!('explainEnabled' in row)) return null;
|
||||
return parseExplainSettings(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves what changed; a value equal to the default goes as null, so the
|
||||
* server keeps following the default. The error names the property, if any.
|
||||
*/
|
||||
export async function saveExplainSettings(
|
||||
current: ExplainSettings,
|
||||
next: ExplainSettings,
|
||||
): Promise<{ ok: boolean; message?: string }> {
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(DEFAULT_EXPLAIN) as (keyof ExplainSettings)[]) {
|
||||
if (next[key] === current[key]) continue;
|
||||
patch[key] = next[key] === DEFAULT_EXPLAIN[key] ? null : next[key];
|
||||
}
|
||||
if (Object.keys(patch).length === 0) return { ok: true };
|
||||
const accountId = getAccountId('x:');
|
||||
const responses = await jmapRequest(
|
||||
[['inbuxa:AiLimits/set', { accountId, update: { singleton: patch } }, '0']],
|
||||
undefined,
|
||||
[INBUXA_CAPABILITY],
|
||||
);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name === 'error') {
|
||||
return { ok: false, message: String((result as { description?: string })?.description ?? 'error') };
|
||||
}
|
||||
const failure = (result as { notUpdated?: Record<string, { description?: string; type?: string }> }).notUpdated
|
||||
?.singleton;
|
||||
return failure ? { ok: false, message: failure.description ?? failure.type } : { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: whether Explain is offered (the session's `aiExplain`), and the one
|
||||
* explanation panel. Asking again replaces what the panel shows; a request
|
||||
* left behind is abandoned in the console (EX-19), and the server finishes
|
||||
* or times out on its own.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { requestExplanation, type ExplainFailure, type ExplainSubject, type Explanation } from './explain';
|
||||
|
||||
type PanelState =
|
||||
| { status: 'closed' }
|
||||
| { status: 'asking'; title: string; subject: ExplainSubject }
|
||||
| { status: 'done'; title: string; subject: ExplainSubject; explanation: Explanation }
|
||||
| { status: 'failed'; title: string; subject: ExplainSubject; failure: ExplainFailure };
|
||||
|
||||
interface ExplainState {
|
||||
available: boolean;
|
||||
panel: PanelState;
|
||||
setAvailable: (available: boolean) => void;
|
||||
ask: (subject: ExplainSubject, title: string) => void;
|
||||
retry: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
export const useExplainStore = create<ExplainState>()((set, get) => ({
|
||||
available: false,
|
||||
panel: { status: 'closed' },
|
||||
|
||||
setAvailable: (available) => set({ available }),
|
||||
|
||||
ask: (subject, title) => {
|
||||
controller?.abort();
|
||||
const mine = new AbortController();
|
||||
controller = mine;
|
||||
set({ panel: { status: 'asking', title, subject } });
|
||||
requestExplanation(subject, mine.signal)
|
||||
.then((result) => {
|
||||
if (controller !== mine) return;
|
||||
set({
|
||||
panel: result.ok
|
||||
? { status: 'done', title, subject, explanation: result.explanation }
|
||||
: { status: 'failed', title, subject, failure: result.failure },
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller !== mine || mine.signal.aborted) return;
|
||||
set({
|
||||
panel: {
|
||||
status: 'failed',
|
||||
title,
|
||||
subject,
|
||||
failure: { kind: 'refused', message: err instanceof Error ? err.message : String(err) },
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
retry: () => {
|
||||
const panel = get().panel;
|
||||
if (panel.status !== 'closed') get().ask(panel.subject, panel.title);
|
||||
},
|
||||
|
||||
close: () => {
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
set({ panel: { status: 'closed' } });
|
||||
},
|
||||
}));
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
@@ -15,7 +17,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { apiFetch, getApiBaseUrl } from '@/services/api';
|
||||
import type { TraceEvent } from '../types';
|
||||
import { normalizeTraceEvents } from '../normalize';
|
||||
import { TraceTimeline } from './TraceTimeline';
|
||||
import { liveEventSubject } from '@/features/ai/explain/explain';
|
||||
|
||||
const MAX_EVENTS = 1000;
|
||||
const MAX_RECONNECT = 5;
|
||||
@@ -87,7 +91,7 @@ export function LiveTracingPage() {
|
||||
|
||||
es.addEventListener('trace', (event) => {
|
||||
try {
|
||||
const batch = JSON.parse(event.data) as TraceEvent[];
|
||||
const batch = normalizeTraceEvents(JSON.parse(event.data));
|
||||
const currentEvents = eventsRef.current;
|
||||
|
||||
for (const evt of batch) {
|
||||
@@ -336,7 +340,7 @@ export function LiveTracingPage() {
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<TraceTimeline events={events} anchorTimestamp={anchor} />
|
||||
<TraceTimeline events={events} anchorTimestamp={anchor} explainSubject={(event) => liveEventSubject(event)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -13,41 +15,10 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { jmapGet, getAccountId } from '@/services/jmap/client';
|
||||
import { resolveObject } from '@/lib/schemaResolver';
|
||||
import type { TraceEvent, TraceKeyValue, TraceValue } from '../types';
|
||||
import type { TraceEvent } from '../types';
|
||||
import { normalizeTraceEvents } from '../normalize';
|
||||
import { TraceTimeline } from './TraceTimeline';
|
||||
|
||||
import { jmapMapToArray } from '@/lib/jmapUtils';
|
||||
|
||||
function normalizeTraceEvents(raw: unknown): TraceEvent[] {
|
||||
const events = jmapMapToArray<Record<string, unknown>>(raw);
|
||||
return events.map((evt) => ({
|
||||
event: String(evt.event ?? ''),
|
||||
timestamp: String(evt.timestamp ?? ''),
|
||||
keyValues: normalizeKeyValues(evt.keyValues),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeKeyValues(raw: unknown): TraceKeyValue[] {
|
||||
const kvs = jmapMapToArray<Record<string, unknown>>(raw);
|
||||
return kvs.map((kv) => ({
|
||||
key: String(kv.key ?? ''),
|
||||
value: normalizeTraceValue(kv.value),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeTraceValue(raw: unknown): TraceValue {
|
||||
if (!raw || typeof raw !== 'object') return { '@type': 'Null' };
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const type = obj['@type'] as string;
|
||||
|
||||
if (type === 'List') {
|
||||
return { '@type': 'List', value: jmapMapToArray<unknown>(obj.value).map(normalizeTraceValue) };
|
||||
}
|
||||
if (type === 'Event') {
|
||||
return { '@type': 'Event', event: String(obj.event ?? ''), value: normalizeKeyValues(obj.value) };
|
||||
}
|
||||
return raw as TraceValue;
|
||||
}
|
||||
|
||||
interface TraceDetailViewProps {
|
||||
viewName: string;
|
||||
@@ -200,7 +171,11 @@ export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) {
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<TraceTimeline events={events} anchorTimestamp={firstEvent?.timestamp} />
|
||||
<TraceTimeline
|
||||
events={events}
|
||||
anchorTimestamp={firstEvent?.timestamp}
|
||||
explainSubject={(_, index) => ({ '@type': 'TraceEvent', traceId: objectId, index })}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
@@ -10,6 +12,8 @@ import { Link } from 'react-router-dom';
|
||||
import { CircleDot, CircleX, Clock } from 'lucide-react';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import type { TraceEvent, TraceKeyValue, TraceValue } from '../types';
|
||||
import { ExplainButton } from '@/features/ai/explain/ExplainButton';
|
||||
import { isRawEvent, type ExplainSubject } from '@/features/ai/explain/explain';
|
||||
|
||||
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013';
|
||||
|
||||
@@ -36,9 +40,11 @@ function isIdKey(key: string): boolean {
|
||||
interface TraceTimelineProps {
|
||||
events: TraceEvent[];
|
||||
anchorTimestamp?: string;
|
||||
/** inbuxa: what "Explain this" asks about an event (ai-explain EX-17). */
|
||||
explainSubject?: (event: TraceEvent, index: number) => ExplainSubject;
|
||||
}
|
||||
|
||||
export function TraceTimeline({ events, anchorTimestamp }: TraceTimelineProps) {
|
||||
export function TraceTimeline({ events, anchorTimestamp, explainSubject }: TraceTimelineProps) {
|
||||
const { t } = useTranslation();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -139,9 +145,20 @@ export function TraceTimeline({ events, anchorTimestamp }: TraceTimelineProps) {
|
||||
)}
|
||||
{explanation && <p className="text-xs text-muted-foreground mt-0.5">{explanation}</p>}
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-xs text-muted-foreground">{formatAbsoluteTime(event.timestamp)}</div>
|
||||
<div className="text-xs text-muted-foreground/60">{formatRelativeTime(event.timestamp)}</div>
|
||||
<div className="flex shrink-0 items-start gap-2">
|
||||
{/* inbuxa: raw protocol traffic isn't sent to the model (EX-9) */}
|
||||
{explainSubject && !isRawEvent(event.event) && (
|
||||
<ExplainButton
|
||||
icon
|
||||
className="mt-0.5"
|
||||
title={label}
|
||||
subject={() => explainSubject(event, idx)}
|
||||
/>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-muted-foreground">{formatAbsoluteTime(event.timestamp)}</div>
|
||||
<div className="text-xs text-muted-foreground/60">{formatRelativeTime(event.timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { jmapMapToArray } from '@/lib/jmapUtils';
|
||||
import type { TraceEvent, TraceKeyValue, TraceValue } from './types';
|
||||
|
||||
/**
|
||||
* Trace events as the server sends them, stored or live: key-values arrive as
|
||||
* a JMAP map ({"0": …, "1": …}), not an array.
|
||||
*/
|
||||
export function normalizeTraceEvents(raw: unknown): TraceEvent[] {
|
||||
const events = jmapMapToArray<Record<string, unknown>>(raw);
|
||||
return events.map((evt) => ({
|
||||
event: String(evt.event ?? ''),
|
||||
timestamp: String(evt.timestamp ?? ''),
|
||||
keyValues: normalizeKeyValues(evt.keyValues),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeKeyValues(raw: unknown): TraceKeyValue[] {
|
||||
const kvs = jmapMapToArray<Record<string, unknown>>(raw);
|
||||
return kvs.map((kv) => ({
|
||||
key: String(kv.key ?? ''),
|
||||
value: normalizeTraceValue(kv.value),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeTraceValue(raw: unknown): TraceValue {
|
||||
if (!raw || typeof raw !== 'object') return { '@type': 'Null' };
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const type = obj['@type'] as string;
|
||||
|
||||
if (type === 'List') {
|
||||
return { '@type': 'List', value: jmapMapToArray<unknown>(obj.value).map(normalizeTraceValue) };
|
||||
}
|
||||
if (type === 'Event') {
|
||||
return { '@type': 'Event', event: String(obj.event ?? ''), value: normalizeKeyValues(obj.value) };
|
||||
}
|
||||
return raw as TraceValue;
|
||||
}
|
||||
@@ -23,6 +23,9 @@ import { Sidebar } from '@/components/layout/Sidebar';
|
||||
import { SectionNav } from '@/components/layout/SectionNav';
|
||||
import { MainContent } from '@/components/layout/MainContent';
|
||||
import { SettingsApplyBanner } from '@/components/layout/SettingsApplyBanner';
|
||||
import { ExplainPanel } from '@/features/ai/explain/ExplainPanel';
|
||||
import { useExplainStore } from '@/features/ai/explain/explainStore';
|
||||
import { explainAvailable } from '@/features/ai/explain/explain';
|
||||
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
import {
|
||||
@@ -164,6 +167,8 @@ export default function AdminPanel() {
|
||||
|
||||
if (cancelled) return;
|
||||
setSession(accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet);
|
||||
// inbuxa: whether "Explain this" is offered (ai-explain EX-1 to EX-4)
|
||||
useExplainStore.getState().setAvailable(explainAvailable(session, primaryAccountId));
|
||||
useAuthStore.getState().setUsername(typeof session.username === 'string' ? session.username : null);
|
||||
// INBUXA: the theme lives with the account, shared with the webmail.
|
||||
setAccountSettingsTarget(session);
|
||||
@@ -327,6 +332,7 @@ export default function AdminPanel() {
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
<ExplainPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user