)}
@@ -86,12 +93,7 @@ export function ExplainPanel() {
{panel.status === 'done' && (
{panel.explanation.text}
-
- {t('explain.byline', 'Local AI · {{model}} · {{node}}', {
- model: panel.explanation.model,
- node: panel.explanation.node,
- })}
-
+
{byline(t, panel.explanation)}
{t(
'explain.caution',
@@ -119,6 +121,27 @@ export function ExplainPanel() {
);
}
+/** Where the answer came from, in plain words (EX-27). */
+function byline(t: TFunction, explanation: Explanation): string {
+ switch (explanation.source) {
+ case 'prepared':
+ return t('explain.bylinePrepared', 'Prepared for release {{release}} with {{model}}', {
+ release: explanation.preparedFor ?? '',
+ model: explanation.model,
+ });
+ case 'remembered':
+ return t('explain.bylineRemembered', 'Local AI · {{model}} · answered earlier on {{node}}', {
+ model: explanation.model,
+ node: explanation.node,
+ });
+ default:
+ return t('explain.byline', 'Local AI · {{model}} · {{node}}', {
+ model: explanation.model,
+ node: explanation.node,
+ });
+ }
+}
+
function failureText(t: TFunction, failure: ExplainFailure): string {
switch (failure.kind) {
case 'busy':
diff --git a/src/features/ai/explain/explain.test.ts b/src/features/ai/explain/explain.test.ts
index 9d2e63b..d6d7821 100644
--- a/src/features/ai/explain/explain.test.ts
+++ b/src/features/ai/explain/explain.test.ts
@@ -11,9 +11,15 @@ vi.mock('@/services/jmap/client', () => ({
getAccountId: () => 'a',
jmapRequest: (...args: unknown[]) => jmapRequest(...args),
}));
+const apiFetch = vi.fn();
+vi.mock('@/services/api', () => ({
+ apiFetch: (...args: unknown[]) => apiFetch(...args),
+}));
import {
DEFAULT_EXPLAIN,
+ parseEvent,
+ streamExplanation,
explainAvailable,
failedRecipients,
isRawEvent,
@@ -137,7 +143,7 @@ describe('requestExplanation', () => {
const result = await requestExplanation(setting);
expect(result).toEqual({
ok: true,
- explanation: { text: 'It turns it on.', model: 'qwen', node: 'host2', elapsedMs: 900 },
+ explanation: { text: 'It turns it on.', model: 'qwen', node: 'host2', elapsedMs: 900, source: 'model' },
});
const [calls, , using] = jmapRequest.mock.calls[0];
expect(calls[0][1]).toEqual({ accountId: 'a', create: { e: { subject: setting } } });
@@ -185,3 +191,68 @@ describe('saveExplainSettings', () => {
expect(jmapRequest).not.toHaveBeenCalled();
});
});
+
+describe('streamExplanation (EX-23)', () => {
+ const setting = { '@type': 'Setting', object: 'x:Domain', id: 'b', property: 'isEnabled' } as const;
+
+ function streamed(chunks: string[], status = 200): Response {
+ const encoder = new TextEncoder();
+ const body = new ReadableStream({
+ start(controller) {
+ for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
+ controller.close();
+ },
+ });
+ return new Response(body, { status, headers: { 'Content-Type': 'text/event-stream' } });
+ }
+
+ it('shows the text as it arrives, then the whole explanation', async () => {
+ apiFetch.mockResolvedValue(
+ streamed([
+ 'event: delta\ndata: {"text":"It turns"}\n\nevent: de',
+ 'lta\ndata: {"text":" it on."}\n\n',
+ 'event: done\ndata: {"text":"It turns it on.","model":"qwen","node":"host2","elapsedMs":900,"source":"model"}\n\n',
+ ]),
+ );
+ const seen: string[] = [];
+ const result = await streamExplanation(setting, (t) => seen.push(t));
+ expect(seen).toEqual(['It turns', 'It turns it on.']);
+ expect(result).toEqual({
+ ok: true,
+ explanation: { text: 'It turns it on.', model: 'qwen', node: 'host2', elapsedMs: 900, source: 'model' },
+ });
+ const [path, init] = apiFetch.mock.calls.at(-1)!;
+ expect(path).toBe('/api/explain');
+ expect(JSON.parse((init as RequestInit).body as string)).toEqual({ subject: setting });
+ });
+
+ it('keeps where a remembered or prepared answer came from (EX-27)', async () => {
+ apiFetch.mockResolvedValue(
+ streamed([
+ 'event: done\ndata: {"text":"Prepared.","model":"qwen","node":"n","elapsedMs":0,"source":"prepared","preparedFor":"2026.9.27"}\n\n',
+ ]),
+ );
+ const result = await streamExplanation(setting, () => {});
+ expect(result).toMatchObject({ ok: true, explanation: { source: 'prepared', preparedFor: '2026.9.27' } });
+ });
+
+ it('words a refusal as the JMAP call does', async () => {
+ apiFetch.mockResolvedValue(streamed(['event: error\ndata: {"type":"serverFail","description":"busy"}\n\n']));
+ expect(await streamExplanation(setting, () => {})).toEqual({ ok: false, failure: { kind: 'busy' } });
+ });
+
+ it('falls back to the JMAP call on a server without the stream', async () => {
+ apiFetch.mockResolvedValue(new Response('not found', { status: 404 }));
+ jmapRequest.mockResolvedValue([
+ ['inbuxa:Explanation/set', { created: { e: { text: 'Old.', model: 'q', node: 'n', elapsedMs: 1 } } }],
+ ]);
+ expect(await streamExplanation(setting, () => {})).toMatchObject({ ok: true, explanation: { text: 'Old.' } });
+ });
+
+ it('parses one event block', () => {
+ expect(parseEvent('event: delta\ndata: {"text":"x"}')).toEqual({ name: 'delta', data: { text: 'x' } });
+ expect(parseEvent(': keep-alive')).toBeNull();
+ expect(parseEvent('data: {broken')).toBeNull();
+ });
+});
+
diff --git a/src/features/ai/explain/explain.ts b/src/features/ai/explain/explain.ts
index 136e9ff..ce0a73d 100644
--- a/src/features/ai/explain/explain.ts
+++ b/src/features/ai/explain/explain.ts
@@ -12,6 +12,7 @@
*/
import { getAccountId, jmapRequest } from '@/services/jmap/client';
+import { apiFetch } from '@/services/api';
import { INBUXA_CAPABILITY } from '../localAi';
/** What to explain: the `subject` of an `inbuxa:Explanation`. */
@@ -33,6 +34,25 @@ export interface Explanation {
model: string;
node: string;
elapsedMs: number;
+ /** Where it came from (EX-27): asked now, remembered, or prepared for the release. */
+ source: 'model' | 'remembered' | 'prepared';
+ /** When a remembered answer was first given. */
+ answeredAt?: string;
+ /** The release a prepared answer ships with. */
+ preparedFor?: string;
+}
+
+function explanationFrom(created: Partial & Record): Explanation {
+ const source = created.source === 'remembered' || created.source === 'prepared' ? created.source : 'model';
+ return {
+ text: String(created.text ?? ''),
+ model: String(created.model ?? ''),
+ node: String(created.node ?? ''),
+ elapsedMs: Number(created.elapsedMs ?? 0),
+ source,
+ answeredAt: typeof created.answeredAt === 'string' ? created.answeredAt : undefined,
+ preparedFor: typeof created.preparedFor === 'string' ? created.preparedFor : undefined,
+ };
}
/** Why there's no explanation, in the few kinds the panel words differently (EX-20). */
@@ -133,19 +153,76 @@ export async function requestExplanation(subject: ExplainSubject, signal?: Abort
};
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: true, explanation: explanationFrom(created as Partial & Record) };
}
return { ok: false, failure: failureFrom(body.notCreated?.e) };
}
+/**
+ * Asks for one explanation and shows it as the model writes it (EX-23):
+ * `onText` gets the answer so far after each piece. A server without the
+ * streaming route gets the ordinary request instead.
+ */
+export async function streamExplanation(
+ subject: ExplainSubject,
+ onText: (textSoFar: string) => void,
+ signal?: AbortSignal,
+): Promise {
+ const response = await apiFetch('/api/explain', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
+ body: JSON.stringify({ subject }),
+ signal,
+ });
+ if (response.status === 404 || response.status === 405 || !response.body) {
+ return requestExplanation(subject, signal);
+ }
+ if (!response.ok) {
+ return { ok: false, failure: failureFrom({ type: 'serverFail', description: 'unavailable' }) };
+ }
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+ let soFar = '';
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ let end: number;
+ while ((end = buffer.indexOf('\n\n')) !== -1) {
+ const block = buffer.slice(0, end);
+ buffer = buffer.slice(end + 2);
+ const event = parseEvent(block);
+ if (!event) continue;
+ if (event.name === 'delta' && typeof event.data.text === 'string') {
+ soFar += event.data.text;
+ onText(soFar);
+ } else if (event.name === 'done') {
+ return { ok: true, explanation: explanationFrom(event.data as Partial & Record) };
+ } else if (event.name === 'error') {
+ return { ok: false, failure: failureFrom(event.data as { type?: string; description?: string }) };
+ }
+ }
+ }
+ return { ok: false, failure: { kind: 'unavailable' } };
+}
+
+/** One server-sent event: its name and JSON data. */
+export function parseEvent(block: string): { name: string; data: Record } | null {
+ let name = 'message';
+ const data: string[] = [];
+ for (const line of block.split('\n')) {
+ if (line.startsWith('event:')) name = line.slice(6).trim();
+ else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
+ }
+ if (!data.length) return null;
+ try {
+ return { name, data: JSON.parse(data.join('\n')) as Record };
+ } catch {
+ return null;
+ }
+}
+
/** The result of Actions › Classify a message, sent back as a verdict. */
export function spamVerdictSubject(result: Record): ExplainSubject {
const tags: Record = {};
diff --git a/src/features/ai/explain/explainStore.ts b/src/features/ai/explain/explainStore.ts
index b808b9d..e445987 100644
--- a/src/features/ai/explain/explainStore.ts
+++ b/src/features/ai/explain/explainStore.ts
@@ -8,15 +8,15 @@
* 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.
+ * or times out on its own. The answer shows as the model writes it (EX-23).
*/
import { create } from 'zustand';
-import { requestExplanation, type ExplainFailure, type ExplainSubject, type Explanation } from './explain';
+import { streamExplanation, type ExplainFailure, type ExplainSubject, type Explanation } from './explain';
type PanelState =
| { status: 'closed' }
- | { status: 'asking'; title: string; subject: ExplainSubject }
+ | { status: 'asking'; title: string; subject: ExplainSubject; soFar: string }
| { status: 'done'; title: string; subject: ExplainSubject; explanation: Explanation }
| { status: 'failed'; title: string; subject: ExplainSubject; failure: ExplainFailure };
@@ -41,8 +41,14 @@ export const useExplainStore = create()((set, get) => ({
controller?.abort();
const mine = new AbortController();
controller = mine;
- set({ panel: { status: 'asking', title, subject } });
- requestExplanation(subject, mine.signal)
+ set({ panel: { status: 'asking', title, subject, soFar: '' } });
+ streamExplanation(
+ subject,
+ (soFar) => {
+ if (controller === mine) set({ panel: { status: 'asking', title, subject, soFar } });
+ },
+ mine.signal,
+ )
.then((result) => {
if (controller !== mine) return;
set({