Explain this: ask the local model about a failure, verdict, log line or setting #18

Merged
jcoffey-dev merged 2 commits from feature/ai-explain into main 2026-09-26 08:30:46 +00:00
17 changed files with 1089 additions and 48 deletions

No files matched your search

+11
View File
@@ -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) }
}
/>
);
+18 -1
View File
@@ -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"
+24 -7
View File
@@ -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>
);
})
+12
View File
@@ -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">
+2
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+137
View File
@@ -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>
);
}
+187
View File
@@ -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();
});
});
+250
View File
@@ -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 };
}
+77
View File
@@ -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>
+45
View File
@@ -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;
}
+6
View File
@@ -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>
);
}