diff --git a/src/features/ai/explain/ExplainPanel.tsx b/src/features/ai/explain/ExplainPanel.tsx index 8453369..452d62e 100644 --- a/src/features/ai/explain/ExplainPanel.tsx +++ b/src/features/ai/explain/ExplainPanel.tsx @@ -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() {
{panel.status === 'asking' && ( -
- - {t('explain.asking', 'Asking the local model…')} - +
+ {panel.soFar &&
{panel.soFar}
} +
+ + + {panel.soFar + ? t('explain.writing', 'Still writing…') + : t('explain.asking', 'Asking the local model…')} + + +
)} @@ -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({