Explain: stream the answer and say where it came from #20

Merged
jcoffey-dev merged 1 commits from feature/explain-streaming into main 2026-09-26 23:14:18 +00:00
4 changed files with 205 additions and 28 deletions

No files matched your search

+36 -13
View File
@@ -10,7 +10,7 @@ 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';
import type { ExplainFailure, Explanation } from './explain';
/**
* inbuxa: the explanation, beside the page rather than over it, so the
@@ -63,12 +63,19 @@ export function ExplainPanel() {
<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 className="space-y-3">
{panel.soFar && <div className="whitespace-pre-line leading-relaxed">{panel.soFar}</div>}
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>
{panel.soFar
? t('explain.writing', 'Still writing…')
: 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>
</div>
)}
@@ -86,12 +93,7 @@ export function ExplainPanel() {
{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="text-xs text-muted-foreground">{byline(t, panel.explanation)}</p>
<p className="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
{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':
+72 -1
View File
@@ -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<Uint8Array>({
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();
});
});
+86 -9
View File
@@ -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<Explanation> & Record<string, unknown>): 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<Explanation> & Record<string, unknown>) };
}
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<ExplainResult> {
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<Explanation> & Record<string, unknown>) };
} 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<string, unknown> } | 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<string, unknown> };
} catch {
return null;
}
}
/** 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 }> = {};
+11 -5
View File
@@ -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<ExplainState>()((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({