Explain: stream the answer and say where it came from #20
No files matched your search
@@ -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':
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }> = {};
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user