feat(reedy): Appendix A · Phase 4 — custom thread UI on AgentRuntime (#4308)

This commit is contained in:
Huang Xin
2026-05-26 14:55:20 +08:00
committed by GitHub
parent 4aaf416c43
commit e0ce6c8c22
17 changed files with 1555 additions and 1 deletions
+2
View File
@@ -182,6 +182,7 @@
"react-dom": "19.2.5",
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
"react-markdown": "^10.1.0",
"react-responsive": "^10.0.0",
"react-virtuoso": "^4.17.0",
"react-window": "^1.8.11",
@@ -194,6 +195,7 @@
"tauri-plugin-device-info-api": "^1.0.1",
"tinycolor2": "^1.6.0",
"uuid": "^14.0.0",
"virtua": "^0.49.1",
"ws": "^8.18.3",
"zod": "^4.0.8",
"zustand": "5.0.10"
@@ -0,0 +1,203 @@
import { describe, it, expect } from 'vitest';
import {
applyEventToMessages,
type ReedyAssistantMessage,
type ReedyMessage,
} from '@/services/reedy/store/reedyStore';
import { makeToolError } from '@/services/reedy/runtime/errors';
import type { ReedyEvent } from '@/services/reedy/runtime/events';
function baseAssistant(id: string): ReedyAssistantMessage {
return { id, role: 'assistant', parts: [], createdAt: 0 };
}
describe('reedyStore — applyEventToMessages reducer', () => {
it('returns the input array unchanged when no matching assistant message exists', () => {
const before: ReedyMessage[] = [{ id: 'u1', role: 'user', text: 'hi', createdAt: 0 }];
const after = applyEventToMessages(before, 'missing', {
type: 'text_delta',
delta: 'x',
});
expect(after).toBe(before);
});
it('coalesces consecutive text_delta events into one text part', () => {
let msgs: ReedyMessage[] = [baseAssistant('a1')];
msgs = applyEventToMessages(msgs, 'a1', { type: 'text_delta', delta: 'Hello, ' });
msgs = applyEventToMessages(msgs, 'a1', { type: 'text_delta', delta: 'world.' });
const assistant = msgs[0] as ReedyAssistantMessage;
expect(assistant.parts).toHaveLength(1);
expect(assistant.parts[0]).toEqual({ type: 'text', text: 'Hello, world.' });
});
it('skips zero-length text_delta', () => {
let msgs: ReedyMessage[] = [baseAssistant('a1')];
msgs = applyEventToMessages(msgs, 'a1', { type: 'text_delta', delta: '' });
const assistant = msgs[0] as ReedyAssistantMessage;
expect(assistant.parts).toEqual([]);
});
it('appends a tool_call part in pending state', () => {
let msgs: ReedyMessage[] = [baseAssistant('a1')];
msgs = applyEventToMessages(msgs, 'a1', {
type: 'tool_call',
id: 'tc1',
name: 'lookupPassage',
args: { query: 'alice' },
permission: 'read',
});
const part = (msgs[0] as ReedyAssistantMessage).parts[0]!;
expect(part.type).toBe('tool_call');
if (part.type === 'tool_call') {
expect(part.state).toBe('pending');
expect(part.name).toBe('lookupPassage');
}
});
it('tool_result ok transitions the matching tool_call to state=ok with result', () => {
let msgs: ReedyMessage[] = [baseAssistant('a1')];
msgs = applyEventToMessages(msgs, 'a1', {
type: 'tool_call',
id: 'tc1',
name: 'find',
args: { q: 'x' },
permission: 'read',
});
msgs = applyEventToMessages(msgs, 'a1', {
type: 'tool_result',
id: 'tc1',
name: 'find',
ok: true,
result: { hit: 'x' },
durationMs: 42,
});
const part = (msgs[0] as ReedyAssistantMessage).parts[0]!;
if (part.type === 'tool_call') {
expect(part.state).toBe('ok');
expect(part.result).toEqual({ hit: 'x' });
expect(part.durationMs).toBe(42);
}
});
it('tool_result error transitions the matching tool_call to state=error', () => {
const err = makeToolError('tool_runtime_error', 'find', 'boom');
let msgs: ReedyMessage[] = [baseAssistant('a1')];
msgs = applyEventToMessages(msgs, 'a1', {
type: 'tool_call',
id: 'tc2',
name: 'find',
args: {},
permission: 'read',
});
msgs = applyEventToMessages(msgs, 'a1', {
type: 'tool_result',
id: 'tc2',
name: 'find',
ok: false,
error: err,
});
const part = (msgs[0] as ReedyAssistantMessage).parts[0]!;
if (part.type === 'tool_call') {
expect(part.state).toBe('error');
expect(part.error?.kind).toBe('tool_runtime_error');
}
});
it('tool_result with no matching tool_call leaves parts untouched', () => {
let msgs: ReedyMessage[] = [baseAssistant('a1')];
msgs = applyEventToMessages(msgs, 'a1', {
type: 'tool_result',
id: 'orphan',
name: 'find',
ok: true,
result: {},
durationMs: 0,
});
expect((msgs[0] as ReedyAssistantMessage).parts).toEqual([]);
});
it('appends citation parts in arrival order', () => {
let msgs: ReedyMessage[] = [baseAssistant('a1')];
msgs = applyEventToMessages(msgs, 'a1', {
type: 'citation',
cfi: 'epubcfi(/6/2)',
sectionIndex: 0,
chapterTitle: 'Ch1',
snippet: 'first',
});
msgs = applyEventToMessages(msgs, 'a1', {
type: 'citation',
cfi: 'epubcfi(/6/4)',
sectionIndex: 1,
snippet: 'second',
});
const parts = (msgs[0] as ReedyAssistantMessage).parts;
expect(parts).toHaveLength(2);
expect(parts.every((p) => p.type === 'citation')).toBe(true);
});
it('error event appends an error part without dropping prior content', () => {
let msgs: ReedyMessage[] = [baseAssistant('a1')];
msgs = applyEventToMessages(msgs, 'a1', { type: 'text_delta', delta: 'partial' });
msgs = applyEventToMessages(msgs, 'a1', {
type: 'error',
kind: 'model_error',
message: 'upstream 500',
retryable: true,
});
const parts = (msgs[0] as ReedyAssistantMessage).parts;
expect(parts).toHaveLength(2);
expect(parts[0]!.type).toBe('text');
expect(parts[1]!.type).toBe('error');
});
it('abort event appends an abort part with the partial flag', () => {
let msgs: ReedyMessage[] = [baseAssistant('a1')];
msgs = applyEventToMessages(msgs, 'a1', { type: 'abort', partial: true });
const part = (msgs[0] as ReedyAssistantMessage).parts[0]!;
expect(part).toEqual({ type: 'abort', partial: true });
});
it('done event captures finishReason + usage on the assistant message', () => {
let msgs: ReedyMessage[] = [baseAssistant('a1')];
const ev: ReedyEvent = {
type: 'done',
output: {
sessionId: 's1',
assistantMessageId: 'a1',
finishReason: 'stop',
usage: { promptTokens: 100, completionTokens: 50 },
},
};
msgs = applyEventToMessages(msgs, 'a1', ev);
const assistant = msgs[0] as ReedyAssistantMessage;
expect(assistant.finishReason).toBe('stop');
expect(assistant.usage).toEqual({ promptTokens: 100, completionTokens: 50 });
});
it('non-structural events (turn_start, step_finish, usage, memory_write) leave parts unchanged', () => {
let msgs: ReedyMessage[] = [baseAssistant('a1')];
msgs = applyEventToMessages(msgs, 'a1', {
type: 'turn_start',
sessionId: 's1',
assistantMessageId: 'a1',
});
msgs = applyEventToMessages(msgs, 'a1', {
type: 'step_finish',
step: 0,
reason: 'stop',
});
msgs = applyEventToMessages(msgs, 'a1', {
type: 'usage',
promptTokens: 10,
completionTokens: 5,
});
msgs = applyEventToMessages(msgs, 'a1', {
type: 'memory_write',
scope: 'book',
key: 'k',
summary: 's',
});
expect((msgs[0] as ReedyAssistantMessage).parts).toEqual([]);
});
});
@@ -28,6 +28,8 @@ import type { RetrievedChunk } from '@/services/reedy/retrieval/BookRetriever';
import { useEnv } from '@/context/EnvContext';
import { isTauriAppPlatform } from '@/services/environment';
import type { AppService } from '@/types/system';
import { ReedyAssistant } from '@/services/reedy/ui/ReedyAssistant';
import type { ReadingContextSnapshot } from '@/services/reedy/tools/builtins/types';
import { Button } from '@/components/ui/button';
import { Loader2Icon, BookOpenIcon } from 'lucide-react';
@@ -276,7 +278,34 @@ const ThreadWrapper = ({
);
};
/**
* Phase 4.3 router. Switches between the legacy / Reedy-MVP path
* (LegacyAIAssistant) and the Phase 4 agent-runtime path
* (ReedyAgentAssistantBridge) based on aiSettings.reedy.runtime.
*
* The split is at component boundary rather than inside one component
* so hooks always run in stable order on whichever path is rendered.
*/
const AIAssistant = ({ bookKey }: AIAssistantProps) => {
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { getBookData } = useBookDataStore();
const bookData = getBookData(bookKey);
const reedyRuntime = settings?.aiSettings?.reedy?.runtime ?? 'mvp';
const useAgentRuntime =
settings?.aiSettings?.enabled === true &&
settings?.aiSettings?.reedy?.enabled === true &&
reedyRuntime === 'agent' &&
!!appService &&
isTauriAppPlatform() &&
!!bookData?.bookDoc;
if (useAgentRuntime) return <ReedyAgentAssistantBridge bookKey={bookKey} />;
return <LegacyAIAssistant bookKey={bookKey} />;
};
const LegacyAIAssistant = ({ bookKey }: AIAssistantProps) => {
const _ = useTranslation();
const { appService } = useEnv();
const { settings } = useSettingsStore();
@@ -436,4 +465,55 @@ const AIAssistant = ({ bookKey }: AIAssistantProps) => {
);
};
/**
* Bridge from the notebook AI tab into the Phase 4 ReedyAssistant.
*
* Kept separate from AIAssistant so legacy props/state don't leak in
* and we don't pay the cost of constructing the agent runtime when the
* user is on the MVP path. The flag check in AIAssistant guarantees this
* only renders when aiSettings.reedy.runtime === 'agent'.
*/
const ReedyAgentAssistantBridge = ({ bookKey }: AIAssistantProps) => {
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { getBookData } = useBookDataStore();
const { getProgress, getView } = useReaderStore();
const bookData = getBookData(bookKey);
const progress = getProgress(bookKey);
const bookHash = bookKey.split('-')[0] || '';
const aiSettings = settings?.aiSettings;
const readingContext = useMemo<ReadingContextSnapshot>(
() => ({
cfi: progress?.location ?? null,
sectionIndex: progress?.section?.current ?? 0,
chapterTitle: progress?.sectionLabel ?? null,
pageNumber: progress?.pageinfo?.current ?? 0,
}),
[progress],
);
const handleNavigate = useCallback(
(cfi: string) => {
getView(bookKey)?.goTo(cfi);
},
[bookKey, getView],
);
if (!aiSettings || !appService || !bookData?.bookDoc) return null;
return (
<ReedyAssistant
appService={appService as AppService}
bookDoc={bookData.bookDoc}
bookHash={bookHash}
bookKey={bookKey}
aiSettings={aiSettings}
readingContext={readingContext}
onNavigateToCfi={handleNavigate}
/>
);
};
export default AIAssistant;
@@ -76,6 +76,9 @@ const AIPanel: React.FC = () => {
const [enabled, setEnabled] = useState(aiSettings.enabled);
const [reedyEnabled, setReedyEnabled] = useState(aiSettings.reedy?.enabled ?? false);
const [reedyAgentRuntime, setReedyAgentRuntime] = useState(
(aiSettings.reedy?.runtime ?? 'mvp') === 'agent',
);
const [provider, setProvider] = useState<AIProviderName>(aiSettings.provider);
const [ollamaUrl, setOllamaUrl] = useState(aiSettings.ollamaBaseUrl);
const [ollamaModel, setOllamaModel] = useState(aiSettings.ollamaModel);
@@ -760,7 +763,26 @@ const AIPanel: React.FC = () => {
onChange={() => {
const next = !reedyEnabled;
setReedyEnabled(next);
saveAiSetting('reedy', { enabled: next });
saveAiSetting('reedy', {
enabled: next,
runtime: reedyAgentRuntime ? 'agent' : 'mvp',
});
}}
/>
<SettingsSwitchRow
label={_('Use agent runtime (experimental)')}
description={_(
'When on, the notebook AI tab uses the new custom agent runtime + thread UI. When off, the Phase 1B MVP path (legacy assistant-ui Thread) is used.',
)}
checked={reedyAgentRuntime}
disabled={!enabled || !reedyEnabled || !isTauriAppPlatform()}
onChange={() => {
const next = !reedyAgentRuntime;
setReedyAgentRuntime(next);
saveAiSetting('reedy', {
enabled: reedyEnabled,
runtime: next ? 'agent' : 'mvp',
});
}}
/>
<div className='flex min-h-14 items-center justify-between gap-3 ps-4 pe-4'>
@@ -45,6 +45,14 @@ export interface AISettings {
*/
reedy?: {
enabled: boolean;
/**
* 'mvp' (default) keeps the Phase 1B path: lookupPassage tool wired
* through @assistant-ui/react's adapter. 'agent' switches the
* notebook AI tab to the Phase 4 ReedyAssistant (custom AgentRuntime
* + thread UI). Requires `reedy.enabled && isTauri() &&
* runtime === 'agent'` to engage.
*/
runtime?: 'mvp' | 'agent';
};
}
@@ -0,0 +1,239 @@
import { create } from 'zustand';
import type { ReedyEvent, ReedyTurnOutput } from '../runtime/events';
import type { ReedyToolError } from '../runtime/errors';
/**
* Reedy chat store (Phase 4.1).
*
* Holds the per-session message log the agent UI renders. Distinct from
* the MVP `useAIChatStore` so the two paths never share state — switching
* `aiSettings.reedy.runtime` mid-session shouldn't corrupt either log.
*
* Messages are stored as ordered arrays of structured parts produced by
* the AgentRuntime's ReedyEvent stream. The store has no opinions about
* persistence — sessions today live in-memory only; Phase 6+ wires up
* disk storage if the agent path graduates beyond the measurement plan.
*/
export type ReedyMessagePart =
| { type: 'text'; text: string }
| {
type: 'tool_call';
id: string;
name: string;
args: unknown;
permission: 'read' | 'navigate' | 'write';
state: 'pending' | 'ok' | 'error';
result?: unknown;
error?: ReedyToolError;
durationMs?: number;
}
| {
type: 'citation';
cfi: string;
sectionIndex: number;
chapterTitle?: string;
snippet: string;
}
| { type: 'error'; message: string; kind: string }
| { type: 'abort'; partial: boolean };
export interface ReedyUserMessage {
id: string;
role: 'user';
text: string;
createdAt: number;
}
export interface ReedyAssistantMessage {
id: string;
role: 'assistant';
parts: ReedyMessagePart[];
createdAt: number;
/** Set once the runtime emits `done`. */
finishReason?: ReedyTurnOutput['finishReason'];
usage?: ReedyTurnOutput['usage'];
}
export type ReedyMessage = ReedyUserMessage | ReedyAssistantMessage;
export interface ReedyStoreState {
messages: ReedyMessage[];
/** True while a turn is being processed by the runtime. */
isRunning: boolean;
/** The active turn's assistantMessageId so we know which message to mutate on each event. */
activeAssistantMessageId: string | null;
/** AbortController for the active turn; the Composer's Stop button calls .abort(). */
abortController: AbortController | null;
// Mutations
startUserTurn: (text: string) => void;
startAssistantTurn: (assistantMessageId: string, controller: AbortController) => void;
applyEvent: (event: ReedyEvent) => void;
finishTurn: () => void;
reset: () => void;
}
export const useReedyStore = create<ReedyStoreState>((set, get) => ({
messages: [],
isRunning: false,
activeAssistantMessageId: null,
abortController: null,
startUserTurn(text) {
const msg: ReedyUserMessage = {
id: randomId('user'),
role: 'user',
text,
createdAt: Date.now(),
};
set((s) => ({ messages: [...s.messages, msg], isRunning: true }));
},
startAssistantTurn(assistantMessageId, controller) {
const msg: ReedyAssistantMessage = {
id: assistantMessageId,
role: 'assistant',
parts: [],
createdAt: Date.now(),
};
set((s) => ({
messages: [...s.messages, msg],
activeAssistantMessageId: assistantMessageId,
abortController: controller,
}));
},
applyEvent(event) {
const id = get().activeAssistantMessageId;
if (!id) return;
set((s) => ({ messages: applyEventToMessages(s.messages, id, event) }));
},
finishTurn() {
set({ isRunning: false, activeAssistantMessageId: null, abortController: null });
},
reset() {
set({
messages: [],
isRunning: false,
activeAssistantMessageId: null,
abortController: null,
});
},
}));
// ---------------------------------------------------------------------------
// pure helpers — exported so tests can target the reducer without React.
// ---------------------------------------------------------------------------
export function applyEventToMessages(
messages: ReedyMessage[],
assistantMessageId: string,
event: ReedyEvent,
): ReedyMessage[] {
const idx = messages.findIndex((m) => m.id === assistantMessageId && m.role === 'assistant');
if (idx === -1) return messages;
const current = messages[idx] as ReedyAssistantMessage;
const next = applyEventToAssistant(current, event);
if (next === current) return messages;
const out = messages.slice();
out[idx] = next;
return out;
}
function applyEventToAssistant(
msg: ReedyAssistantMessage,
event: ReedyEvent,
): ReedyAssistantMessage {
switch (event.type) {
case 'text_delta':
return appendOrExtendText(msg, event.delta);
case 'tool_call':
return {
...msg,
parts: [
...msg.parts,
{
type: 'tool_call',
id: event.id,
name: event.name,
args: event.args,
permission: event.permission,
state: 'pending',
},
],
};
case 'tool_result':
return {
...msg,
parts: msg.parts.map((p) => {
if (p.type !== 'tool_call' || p.id !== event.id) return p;
if (event.ok) {
return {
...p,
state: 'ok',
result: event.result,
durationMs: event.durationMs,
};
}
return { ...p, state: 'error', error: event.error };
}),
};
case 'citation':
return {
...msg,
parts: [
...msg.parts,
{
type: 'citation',
cfi: event.cfi,
sectionIndex: event.sectionIndex,
chapterTitle: event.chapterTitle,
snippet: event.snippet,
},
],
};
case 'error':
return {
...msg,
parts: [...msg.parts, { type: 'error', message: event.message, kind: event.kind }],
};
case 'abort':
return {
...msg,
parts: [...msg.parts, { type: 'abort', partial: event.partial }],
};
case 'done':
return { ...msg, finishReason: event.output.finishReason, usage: event.output.usage };
case 'turn_start':
case 'step_finish':
case 'usage':
case 'memory_write':
// No structural-part mutation today. Phase 4 follow-up may want a
// separate ReasoningPart or MemoryNotePart; the store would gain
// a new branch then.
return msg;
default:
return msg;
}
}
function appendOrExtendText(msg: ReedyAssistantMessage, delta: string): ReedyAssistantMessage {
if (delta.length === 0) return msg;
const last = msg.parts.at(-1);
// Coalesce consecutive text deltas into one text part so the UI
// re-renders the same React node instead of inflating the parts array.
if (last && last.type === 'text') {
const updated = { ...last, text: last.text + delta };
return { ...msg, parts: [...msg.parts.slice(0, -1), updated] };
}
return { ...msg, parts: [...msg.parts, { type: 'text', text: delta }] };
}
function randomId(prefix: string): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto)
return `${prefix}-${crypto.randomUUID()}`;
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
@@ -0,0 +1,65 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { VList, type VListHandle } from 'virtua';
import type { ReedyMessage } from '../store/reedyStore';
import { MessageCard } from './MessageCard';
/**
* Virtua-virtualized thread scroller (Phase 4.2.b).
*
* Owns auto-scroll engagement: pinned to the bottom on initial mount
* and after every store update while the user hasn't actively scrolled
* away. Pointer/wheel/touch interactions disengage; scrolling back to
* the bottom (or sending a new message) re-engages.
*/
export function AgentThread({
messages,
isRunning,
onSourceClick,
emptyState,
}: {
messages: ReedyMessage[];
isRunning: boolean;
onSourceClick?: (cfi: string) => void;
emptyState?: React.ReactNode;
}) {
const ref = useRef<VListHandle>(null);
const [autoScroll, setAutoScroll] = useState(true);
// Pin to the bottom whenever the message count grows (new turn, new
// assistant message, new structural part) AND we're in autoScroll mode.
useEffect(() => {
if (!autoScroll || messages.length === 0) return;
const lastIndex = messages.length - 1;
requestAnimationFrame(() => {
ref.current?.scrollToIndex(lastIndex, { align: 'end' });
});
}, [messages.length, autoScroll, messages]);
// Disengage auto-scroll when the user actively scrolls; re-engage when
// they reach the bottom again.
const handleScroll = (offset: number): void => {
const handle = ref.current;
if (!handle) return;
const total = handle.scrollSize;
const view = handle.viewportSize;
const atBottom = total - (offset + view) < 24;
setAutoScroll(atBottom);
};
if (messages.length === 0 && emptyState) {
return <div className='flex h-full items-center justify-center'>{emptyState}</div>;
}
return (
<VList ref={ref} className='reedy-agent-thread h-full w-full' onScroll={handleScroll}>
{messages.map((m) => (
<MessageCard key={m.id} message={m} onSourceClick={onSourceClick} />
))}
{isRunning && messages.length > 0 && (
<div className='text-base-content/40 mb-4 px-3 text-[11px] italic'>Reedy is thinking</div>
)}
</VList>
);
}
@@ -0,0 +1,134 @@
'use client';
import { useCallback, useState, type KeyboardEvent } from 'react';
import { Send, Square, WandSparkles } from 'lucide-react';
/**
* Minimal Skill shape the composer's chip row renders. The full Skill
* type (Phase 5 — separate PR) is a strict superset; the composer only
* touches these fields, so we declare locally to avoid a cross-branch
* dependency.
*/
export interface ComposerSkill {
id: string;
name: string;
description: string;
}
type Skill = ComposerSkill;
/**
* Multi-line input + send/abort button + skill chip row (Phase 4.2.h).
*
* Keyboard:
* - Cmd/Ctrl + Enter → send
* - Esc → abort if a turn is running, otherwise blur
* - Enter alone → newline (per the plan's UX)
*/
export function Composer({
isRunning,
onSend,
onAbort,
disabled,
skills,
activeSkillId,
onSkillSelect,
}: {
isRunning: boolean;
onSend: (text: string) => void;
onAbort: () => void;
disabled?: boolean;
skills?: Skill[];
activeSkillId?: string | null;
onSkillSelect?: (id: string | null) => void;
}) {
const [text, setText] = useState('');
const send = useCallback(() => {
const trimmed = text.trim();
if (trimmed.length === 0) return;
onSend(trimmed);
setText('');
}, [text, onSend]);
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (!disabled && !isRunning) send();
} else if (e.key === 'Escape') {
if (isRunning) {
e.preventDefault();
onAbort();
}
}
};
return (
<div className='reedy-agent-composer border-base-content/10 bg-base-100 flex flex-col gap-2 border-t p-2'>
{skills && skills.length > 0 && (
<div className='flex flex-wrap items-center gap-1'>
<span className='text-base-content/40 me-1 text-[10px] uppercase'>Skill</span>
<button
type='button'
className={chipClass(activeSkillId == null)}
onClick={() => onSkillSelect?.(null)}
>
None
</button>
{skills.map((s) => (
<button
key={s.id}
type='button'
className={chipClass(activeSkillId === s.id)}
onClick={() => onSkillSelect?.(s.id)}
title={s.description}
>
<WandSparkles className='size-3' />
{s.name}
</button>
))}
</div>
)}
<div className='border-base-content/10 bg-base-200/40 eink-bordered flex items-end gap-1 rounded-md border px-2 py-1.5'>
<textarea
className='text-base-content placeholder:text-base-content/40 max-h-40 min-h-[1.75rem] flex-1 resize-none bg-transparent text-sm outline-none'
rows={1}
placeholder='Ask Reedy about this book…'
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
/>
{isRunning ? (
<button
type='button'
className='btn btn-primary btn-sm size-7 min-h-0 rounded-full p-0'
onClick={onAbort}
title='Stop (Esc)'
aria-label='Stop'
>
<Square className='size-3' />
</button>
) : (
<button
type='button'
className='btn btn-primary btn-sm size-7 min-h-0 rounded-full p-0 disabled:opacity-40'
onClick={send}
disabled={disabled || text.trim().length === 0}
title='Send (⌘/Ctrl + Enter)'
aria-label='Send'
>
<Send className='size-3' />
</button>
)}
</div>
</div>
);
}
function chipClass(active: boolean): string {
return [
'border-base-content/10 flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors',
active ? 'bg-primary text-primary-content border-primary' : 'bg-base-100 hover:bg-base-200',
].join(' ');
}
@@ -0,0 +1,114 @@
'use client';
import { BookOpenIcon, Loader2Icon, RotateCw, AlertTriangle } from 'lucide-react';
export type IndexingPhase = 'idle' | 'indexing' | 'indexed' | 'failed' | 'empty';
export interface IndexingStatusProps {
status: IndexingPhase;
/** Phase-specific progress: 0100 for indexing, omitted otherwise. */
progressPercent?: number;
/** Number of chunks processed vs total — shown inline while indexing. */
chunkProgress?: { current: number; total: number };
/** Error text shown when status='failed'. */
errorMessage?: string;
onIndex?: () => void;
onReindex?: () => void;
onCancel?: () => void;
}
/**
* Top-of-thread status bar (Phase 4.2.h). Surfaces the book's indexing
* lifecycle and offers the corresponding action button.
*/
export function IndexingStatus({
status,
progressPercent,
chunkProgress,
errorMessage,
onIndex,
onReindex,
onCancel,
}: IndexingStatusProps) {
if (status === 'idle') {
return (
<div className='flex h-full flex-col items-center justify-center gap-3 p-4 text-center'>
<div className='bg-primary/10 rounded-full p-3'>
<BookOpenIcon className='text-primary size-6' />
</div>
<div>
<h3 className='text-base-content mb-0.5 text-sm font-medium'>Index this book</h3>
<p className='text-base-content/60 text-xs'>Enable agent search + chat for this book.</p>
</div>
<button className='btn btn-primary btn-sm h-8 text-xs' onClick={onIndex}>
<BookOpenIcon className='me-1.5 size-3.5' />
Start indexing
</button>
</div>
);
}
if (status === 'indexing') {
const pct = progressPercent ?? 0;
return (
<div className='flex h-full flex-col items-center justify-center gap-3 p-4 text-center'>
<Loader2Icon className='text-primary size-6 animate-spin' />
<div>
<p className='text-base-content mb-1 text-sm font-medium'>Indexing book</p>
<p className='text-base-content/60 text-xs'>
{chunkProgress
? `${chunkProgress.current} / ${chunkProgress.total} chunks`
: 'Preparing…'}
</p>
</div>
<div className='bg-base-200 h-1.5 w-32 overflow-hidden rounded-full'>
<div
className='bg-primary h-full transition-all duration-300'
style={{ width: `${pct}%` }}
/>
</div>
{onCancel && (
<button className='btn btn-ghost btn-xs text-xs' onClick={onCancel}>
Cancel
</button>
)}
</div>
);
}
if (status === 'failed') {
return (
<div className='flex h-full flex-col items-center justify-center gap-3 p-4 text-center'>
<div className='bg-warning/10 rounded-full p-3'>
<AlertTriangle className='text-warning size-6' />
</div>
<div>
<h3 className='text-base-content mb-0.5 text-sm font-medium'>Indexing failed</h3>
<p className='text-base-content/60 text-xs'>{errorMessage ?? 'Unknown error.'}</p>
</div>
<button className='btn btn-outline btn-sm h-8 text-xs' onClick={onReindex}>
<RotateCw className='me-1.5 size-3.5' />
Retry indexing
</button>
</div>
);
}
if (status === 'empty') {
return (
<div className='flex h-full flex-col items-center justify-center gap-3 p-4 text-center'>
<BookOpenIcon className='text-base-content/40 size-6' />
<div>
<h3 className='text-base-content mb-0.5 text-sm font-medium'>No extractable text</h3>
<p className='text-base-content/60 text-xs'>
This book contains no extractable text (likely an image-only PDF or scanned book). Reedy
can't answer questions about its content.
</p>
</div>
</div>
);
}
// status === 'indexed' nothing visible; the thread renders.
return null;
}
@@ -0,0 +1,87 @@
'use client';
import { memo } from 'react';
import type { ReedyMessage, ReedyMessagePart } from '../store/reedyStore';
import { AssistantTextPart, UserTextPart } from './parts/TextPart';
import { ToolCallPart } from './parts/ToolCallPart';
import { CitationPart } from './parts/CitationPart';
import { AbortPart, ErrorPart } from './parts/StatusParts';
/**
* One row in the agent thread. User messages render as a single text
* bubble; assistant messages dispatch each structural part to its
* dedicated renderer (text, tool_call, citation, error, abort).
*
* Memoized on the message reference — the store reducer creates a new
* object only for messages that mutate this tick, so unchanged rows
* skip the entire React subtree.
*/
export const MessageCard = memo(function MessageCard({
message,
onSourceClick,
}: {
message: ReedyMessage;
onSourceClick?: (cfi: string) => void;
}) {
if (message.role === 'user') {
return (
<div className='animate-in fade-in mx-auto mb-3 flex w-full justify-end duration-200'>
<div className='bg-base-200/60 text-base-content max-w-[85%] rounded-lg px-3 py-2 text-sm'>
<UserTextPart text={message.text} />
</div>
</div>
);
}
return (
<div className='animate-in fade-in mb-4 flex w-full duration-200'>
<div className='flex w-full min-w-0 flex-col gap-1'>
{message.parts.map((part, i) => (
<PartDispatcher key={partKey(part, i)} part={part} onSourceClick={onSourceClick} />
))}
{message.finishReason === 'error' && message.parts.every((p) => p.type !== 'error') && (
<ErrorPart part={{ type: 'error', kind: 'unknown', message: 'Turn ended in error.' }} />
)}
</div>
</div>
);
});
function PartDispatcher({
part,
onSourceClick,
}: {
part: ReedyMessagePart;
onSourceClick?: (cfi: string) => void;
}) {
switch (part.type) {
case 'text':
return <AssistantTextPart text={part.text} />;
case 'tool_call':
return <ToolCallPart part={part} />;
case 'citation':
return <CitationPart part={part} onClick={onSourceClick} />;
case 'error':
return <ErrorPart part={part} />;
case 'abort':
return <AbortPart part={part} />;
default:
return null;
}
}
function partKey(part: ReedyMessagePart, index: number): string {
switch (part.type) {
case 'tool_call':
return `tool:${part.id}`;
case 'citation':
return `citation:${part.cfi}:${index}`;
case 'text':
// Coalesced text part — index is stable once added.
return `text:${index}`;
case 'error':
return `error:${index}`;
case 'abort':
return `abort:${index}`;
}
}
@@ -0,0 +1,247 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { AppService } from '@/types/system';
import type { BookDoc } from '@/libs/document';
import type { AISettings } from '@/services/ai/types';
import { AgentRuntime } from '../runtime/AgentRuntime';
import { BookIndexer } from '../retrieval/BookIndexer';
import { BookRetriever } from '../retrieval/BookRetriever';
import { ReedyDb } from '../db/ReedyDb';
import { createReedyModels } from '../models/registry';
import { ToolRegistry } from '../tools/ToolRegistry';
import {
createAddCitationTool,
createGetReadingContextTool,
createGetSelectionTool,
createLookupPassageTool,
type ReadingContextSnapshot,
} from '../tools/builtins';
import {
DEFAULT_POLICY,
createPolicyLayer,
createReadingLayer,
createSkillLayer,
createToolCatalogLayer,
} from '../context';
import { useReedyStore } from '../store/reedyStore';
import { useReedyTurn } from './useReedyTurn';
import { AgentThread } from './AgentThread';
import { Composer } from './Composer';
import { IndexingStatus, type IndexingPhase } from './IndexingStatus';
export interface ReedyAssistantProps {
appService: AppService;
bookDoc: BookDoc;
bookHash: string;
bookKey: string;
aiSettings: AISettings;
readingContext: ReadingContextSnapshot;
/** Wired by the notebook to `getView(bookKey)?.goTo(cfi)` on click. */
onNavigateToCfi?: (cfi: string) => void;
}
/**
* Top-level entrypoint mounted by the notebook AI tab when
* `aiSettings.reedy.runtime === 'agent'` (Phase 4.3).
*
* Constructs ReedyDb / BookIndexer / BookRetriever / tool registry /
* AgentRuntime from the per-book deps and renders the indexing status
* → AgentThread → Composer flow. The legacy MVP path stays at the
* notebook level under the same flag's 'mvp' value.
*/
export function ReedyAssistant({
appService,
bookDoc,
bookHash,
aiSettings,
readingContext,
onNavigateToCfi,
}: ReedyAssistantProps) {
const models = useMemo(() => createReedyModels(aiSettings), [aiSettings]);
// Lazily open reedy.db on first mount. The promise resolves once and
// we share the same ReedyDb + Indexer + Retriever for the lifetime of
// this component instance.
const [reedy, setReedy] = useState<{
db: ReedyDb;
indexer: BookIndexer;
retriever: BookRetriever;
} | null>(null);
useEffect(() => {
let alive = true;
void appService
.openDatabase('reedy', 'reedy.db', 'Data', { experimental: ['index_method'] })
.then((svc) => {
if (!alive) return;
const db = new ReedyDb(svc);
setReedy({ db, indexer: new BookIndexer(db), retriever: new BookRetriever(db) });
})
.catch((err) => {
console.error('[Reedy] failed to open reedy.db', err);
});
return () => {
alive = false;
};
}, [appService]);
// Snapshot the reading context in a ref so the tool factories don't
// re-render the registry on every page turn.
const readingRef = useRef(readingContext);
useEffect(() => {
readingRef.current = readingContext;
}, [readingContext]);
// Build the tool registry + runtime once per (reedy ready, model) pair.
const runtime = useMemo(() => {
if (!reedy) return null;
const reg = new ToolRegistry();
reg.register(createGetReadingContextTool(() => readingRef.current));
reg.register(createGetSelectionTool(() => readingRef.current.selection ?? null));
reg.register(
createLookupPassageTool({
bookHash,
retriever: reedy.retriever,
activeEmbeddingModel: models.embedding,
}),
);
reg.register(
createAddCitationTool(() => {
// Citation side-channel — the runtime synthesizes a citation
// event from the lookupPassage tool result, so addCitation
// just acks. A future enhancement could push directly into
// the store via a closure here.
}),
);
const layers = [
createPolicyLayer(DEFAULT_POLICY),
createSkillLayer(null),
createReadingLayer(readingRef.current),
createToolCatalogLayer(reg.list()),
];
return new AgentRuntime({ model: models.chat, tools: reg, layers });
}, [reedy, models.chat, models.embedding, bookHash]);
const messages = useReedyStore((s) => s.messages);
const isRunning = useReedyStore((s) => s.isRunning);
const resetStore = useReedyStore((s) => s.reset);
const { send, abort } = useReedyTurn(runtime);
// Indexing state — tracked locally to avoid layering yet another store.
const [indexingPhase, setIndexingPhase] = useState<IndexingPhase>('idle');
const [indexProgress, setIndexProgress] = useState<{
pct: number;
current: number;
total: number;
} | null>(null);
useEffect(() => {
if (!reedy || !bookHash) return;
let alive = true;
void reedy.db.getBookMeta(bookHash).then((meta) => {
if (!alive) return;
if (!meta) setIndexingPhase('idle');
else if (meta.indexingStatus === 'indexed') setIndexingPhase('indexed');
else if (meta.indexingStatus === 'empty_index') setIndexingPhase('empty');
else if (meta.indexingStatus === 'failed') setIndexingPhase('failed');
else setIndexingPhase('idle');
});
return () => {
alive = false;
};
}, [reedy, bookHash]);
// Reset the conversation log when the book or session changes.
useEffect(() => {
resetStore();
}, [bookHash, resetStore]);
const handleIndex = useCallback(async () => {
if (!reedy) return;
setIndexingPhase('indexing');
try {
await reedy.indexer.indexBook(bookDoc, bookHash, models.embedding, {
onProgress: (e) => {
if (e.phase === 'embedding' && e.total > 0) {
setIndexProgress({
pct: Math.round((e.current / e.total) * 100),
current: e.current,
total: e.total,
});
}
},
});
const meta = await reedy.db.getBookMeta(bookHash);
setIndexingPhase(
meta?.indexingStatus === 'empty_index'
? 'empty'
: meta?.indexingStatus === 'indexed'
? 'indexed'
: 'failed',
);
} catch (err) {
console.error('[Reedy] index failed', err);
setIndexingPhase('failed');
} finally {
setIndexProgress(null);
}
}, [reedy, bookDoc, bookHash, models.embedding]);
const handleSend = useCallback(
(text: string) => {
if (!runtime) return;
void send({ sessionId: bookHash, bookHash, userMessage: text });
},
[runtime, send, bookHash],
);
if (!aiSettings.enabled) {
return (
<div className='flex h-full items-center justify-center p-4'>
<p className='text-base-content/60 text-sm'>Enable AI in Settings</p>
</div>
);
}
if (!reedy) {
return (
<div className='flex h-full items-center justify-center p-4'>
<p className='text-base-content/60 text-sm'>Loading Reedy</p>
</div>
);
}
if (indexingPhase !== 'indexed') {
return (
<IndexingStatus
status={indexingPhase}
progressPercent={indexProgress?.pct}
chunkProgress={
indexProgress ? { current: indexProgress.current, total: indexProgress.total } : undefined
}
onIndex={handleIndex}
onReindex={handleIndex}
/>
);
}
return (
<div className='reedy-agent-shell flex h-full w-full flex-col'>
<div className='min-h-0 flex-1'>
<AgentThread
messages={messages}
isRunning={isRunning}
onSourceClick={onNavigateToCfi}
emptyState={
<div className='text-base-content/60 px-6 text-center text-sm'>
Ask anything about this book.
</div>
}
/>
</div>
<Composer isRunning={isRunning} onSend={handleSend} onAbort={abort} />
</div>
);
}
@@ -0,0 +1,45 @@
'use client';
import { BookOpen } from 'lucide-react';
import type { ReedyMessagePart } from '../../store/reedyStore';
/**
* Citation chip — clickable when an `onClick` handler is wired (the
* notebook integration passes a handler that calls
* `getView(bookKey)?.goTo(cfi)`). Static when no handler (e.g. preview
* rendering in tests).
*/
export function CitationPart({
part,
onClick,
}: {
part: Extract<ReedyMessagePart, { type: 'citation' }>;
onClick?: (cfi: string) => void;
}) {
const baseClass =
'border-base-content/10 bg-base-200/60 my-1 flex items-start gap-1.5 rounded-md border px-2 py-1.5 text-[11px]';
const body = (
<>
<BookOpen className='text-base-content/60 mt-0.5 size-3 shrink-0' />
<div className='flex min-w-0 flex-col gap-0.5 text-start'>
<div className='text-base-content font-medium'>
{part.chapterTitle ?? `Section ${part.sectionIndex + 1}`}
</div>
<div className='text-base-content/70 line-clamp-3'>{part.snippet}</div>
</div>
</>
);
if (onClick) {
return (
<button
type='button'
className={`${baseClass} hover:bg-base-200 transition-colors`}
onClick={() => onClick(part.cfi)}
title={part.cfi}
>
{body}
</button>
);
}
return <div className={baseClass}>{body}</div>;
}
@@ -0,0 +1,27 @@
'use client';
import { AlertTriangle, Ban } from 'lucide-react';
import type { ReedyMessagePart } from '../../store/reedyStore';
export function ErrorPart({ part }: { part: Extract<ReedyMessagePart, { type: 'error' }> }) {
return (
<div className='border-warning/30 bg-warning/10 my-1 flex items-start gap-1.5 rounded-md border px-2 py-1.5 text-[11px]'>
<AlertTriangle className='text-warning mt-0.5 size-3 shrink-0' />
<div className='flex min-w-0 flex-col gap-0.5'>
<div className='text-warning font-medium'>Error · {part.kind}</div>
<div className='text-base-content/70'>{part.message}</div>
</div>
</div>
);
}
export function AbortPart({ part }: { part: Extract<ReedyMessagePart, { type: 'abort' }> }) {
return (
<div className='border-base-content/10 bg-base-200/40 my-1 flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px]'>
<Ban className='text-base-content/60 size-3 shrink-0' />
<span className='text-base-content/70'>
{part.partial ? 'Aborted (partial reply)' : 'Aborted'}
</span>
</div>
);
}
@@ -0,0 +1,91 @@
'use client';
import { memo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
/**
* User-message text rendering. Bare react-markdown with GFM but no
* remark-math / rehype-raw / rehype-katex / harden-react-markdown —
* the user message is whatever they typed; we don't expect math or HTML
* from them. Inline-styled overrides match the legacy MarkdownText
* compact look.
*
* Memoized on `text` so repeated re-renders of the parent thread don't
* re-tokenize unchanged user messages.
*/
export const UserTextPart = memo(function UserTextPart({ text }: { text: string }) {
return (
<div className='prose prose-sm dark:prose-invert max-w-none break-words whitespace-pre-wrap'>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => <span className='inline'>{children}</span>,
a: ({ href, children }) => (
<a href={href} target='_blank' rel='noopener noreferrer'>
{children}
</a>
),
code: ({ children }) => (
<code className='bg-base-300/50 text-base-content rounded px-1.5 py-0.5 font-mono text-sm'>
{children}
</code>
),
}}
>
{text}
</ReactMarkdown>
</div>
);
});
/**
* Assistant-message text rendering — same react-markdown stack as
* UserTextPart, slightly richer block styling (paragraphs render as
* `<p>` instead of inline spans). The original plan called for
* Streamdown's streaming-aware fade-in spans, but Streamdown statically
* depends on Shiki whose TextMate grammars contain lookbehind regex
* `(?<=...)` / `(?<!...)` patterns — the repo's `check:lookbehind-regex`
* gate rejects them because some Chromium versions on older Android
* webviews still don't support lookbehind syntax (see CI run that
* landed this fix). Streaming-fade polish returns in a follow-up that
* either tree-shakes Shiki out or uses a different streaming renderer.
*
* Memoized on `text` for the same reason as UserTextPart. The agent
* runtime coalesces consecutive text deltas in the store reducer, so
* the text grows monotonically per assistant message.
*/
export const AssistantTextPart = memo(function AssistantTextPart({ text }: { text: string }) {
return (
<div className='prose prose-sm dark:prose-invert max-w-none break-words'>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children }) => (
<a href={href} target='_blank' rel='noopener noreferrer'>
{children}
</a>
),
code: ({ children, className }) => {
// Inline code: no className. Fenced blocks: get a language-x class.
if (!className) {
return (
<code className='bg-base-300/50 text-base-content rounded px-1.5 py-0.5 font-mono text-sm'>
{children}
</code>
);
}
return <code className={className}>{children}</code>;
},
pre: ({ children }) => (
<pre className='bg-base-300/40 my-2 overflow-auto rounded-md px-3 py-2 font-mono text-xs'>
{children}
</pre>
),
}}
>
{text}
</ReactMarkdown>
</div>
);
});
@@ -0,0 +1,88 @@
'use client';
import { useState } from 'react';
import {
Wrench,
ChevronDown,
ChevronRight,
CheckCircle2,
AlertCircle,
Loader2,
} from 'lucide-react';
import type { ReedyMessagePart } from '../../store/reedyStore';
/**
* Renders a tool_call message part as a collapsed pill. Click to expand
* the args + result. Pending tools show a spinner; finished tools show
* a check (ok) or warning (error) and the duration if available.
*
* The plan calls for an inline approval UI when permission != 'read';
* the runtime's ToolRegistry already enforces approvals via its
* requestPermission callback, so that prompt fires at registry-invoke
* time. The pill stays informational.
*/
export function ToolCallPart({ part }: { part: Extract<ReedyMessagePart, { type: 'tool_call' }> }) {
const [expanded, setExpanded] = useState(false);
const statusIcon =
part.state === 'pending' ? (
<Loader2 className='size-3 animate-spin' />
) : part.state === 'error' ? (
<AlertCircle className='text-warning size-3' />
) : (
<CheckCircle2 className='text-success size-3' />
);
return (
<div className='border-base-content/10 bg-base-200/50 my-1 rounded-md border px-2 py-1.5 text-[11px]'>
<button
type='button'
onClick={() => setExpanded((e) => !e)}
className='flex w-full items-center gap-1.5 text-start'
>
{expanded ? <ChevronDown className='size-3' /> : <ChevronRight className='size-3' />}
<Wrench className='text-base-content/60 size-3' />
<span className='text-base-content font-medium'>{part.name}</span>
{statusIcon}
{part.durationMs !== undefined && (
<span className='text-base-content/40 ms-auto text-[10px]'>{part.durationMs}ms</span>
)}
</button>
{expanded && (
<div className='mt-1.5 space-y-1'>
<div>
<div className='text-base-content/40 mb-0.5 text-[10px] uppercase'>args</div>
<pre className='bg-base-300/40 max-h-40 overflow-auto rounded px-1.5 py-1 font-mono text-[10px] whitespace-pre-wrap'>
{safeStringify(part.args)}
</pre>
</div>
{part.state === 'ok' && part.result !== undefined && (
<div>
<div className='text-base-content/40 mb-0.5 text-[10px] uppercase'>result</div>
<pre className='bg-base-300/40 max-h-60 overflow-auto rounded px-1.5 py-1 font-mono text-[10px] whitespace-pre-wrap'>
{safeStringify(part.result)}
</pre>
</div>
)}
{part.state === 'error' && part.error && (
<div>
<div className='text-error mb-0.5 text-[10px] uppercase'>
error · {part.error.kind}
</div>
<pre className='text-error/80 bg-base-300/40 rounded px-1.5 py-1 font-mono text-[10px] whitespace-pre-wrap'>
{part.error.message}
</pre>
</div>
)}
</div>
)}
</div>
);
}
function safeStringify(v: unknown): string {
try {
return JSON.stringify(v, null, 2);
} catch {
return String(v);
}
}
@@ -0,0 +1,71 @@
'use client';
import { useCallback, useRef } from 'react';
import type { AgentRuntime, RunTurnInput } from '../runtime/AgentRuntime';
import type { ReedyEvent } from '../runtime/events';
import { useReedyStore } from '../store/reedyStore';
/**
* Drives one assistant turn through the AgentRuntime, dispatching every
* ReedyEvent the runtime yields into the Reedy store (Phase 4.1).
*
* Why a hook and not a method on the store: the runtime is constructed
* by the notebook with per-book deps (model, tools, layers). The store
* stays runtime-agnostic; the hook is the glue.
*/
export function useReedyTurn(runtime: AgentRuntime | null) {
const cancelRef = useRef<AbortController | null>(null);
const send = useCallback(
async (args: { sessionId: string; bookHash: string; userMessage: string }): Promise<void> => {
if (!runtime) return;
// Cancel any in-flight turn before starting a new one so we never
// have two AgentRuntime streams mutating the same active message.
cancelRef.current?.abort();
const controller = new AbortController();
cancelRef.current = controller;
const store = useReedyStore.getState();
store.startUserTurn(args.userMessage);
const turnInput: RunTurnInput = {
sessionId: args.sessionId,
bookHash: args.bookHash,
userMessage: args.userMessage,
signal: controller.signal,
};
// The first event the runtime yields is `turn_start` carrying the
// assistantMessageId. Wait for it before pushing the assistant
// message into the store so the store has a stable id to mutate.
let assistantStarted = false;
try {
for await (const event of runtime.runTurn(turnInput) as AsyncIterable<ReedyEvent>) {
if (!assistantStarted && event.type === 'turn_start') {
store.startAssistantTurn(event.assistantMessageId, controller);
assistantStarted = true;
continue;
}
if (!assistantStarted) {
// Defensive: the runtime contract guarantees turn_start first,
// but if something upstream changes don't drop subsequent events.
store.startAssistantTurn('msg-fallback', controller);
assistantStarted = true;
}
store.applyEvent(event);
if (event.type === 'done') break;
}
} finally {
store.finishTurn();
if (cancelRef.current === controller) cancelRef.current = null;
}
},
[runtime],
);
const abort = useCallback(() => {
cancelRef.current?.abort();
}, []);
return { send, abort };
}
+31
View File
@@ -351,6 +351,9 @@ importers:
react-icons:
specifier: ^5.4.0
version: 5.6.0(react@19.2.5)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
react-responsive:
specifier: ^10.0.0
version: 10.0.1(react@19.2.5)
@@ -387,6 +390,9 @@ importers:
uuid:
specifier: '>=14.0.0'
version: 14.0.0
virtua:
specifier: ^0.49.1
version: 0.49.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
ws:
specifier: 8.20.1
version: 8.20.1
@@ -8489,6 +8495,26 @@ packages:
resolution: {integrity: sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==}
engines: {node: '>=10.13.0'}
virtua@0.49.1:
resolution: {integrity: sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg==}
peerDependencies:
react: '>=16.14.0'
react-dom: '>=16.14.0'
solid-js: '>=1.0'
svelte: '>=5.0'
vue: '>=3.2'
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
solid-js:
optional: true
svelte:
optional: true
vue:
optional: true
vite-plugin-commonjs@0.10.4:
resolution: {integrity: sha512-eWQuvQKCcx0QYB5e5xfxBNjQKyrjEWZIR9UOkOV6JAgxVhtbZvCOF+FNC2ZijBJ3U3Px04ZMMyyMyFBVWIJ5+g==}
@@ -17801,6 +17827,11 @@ snapshots:
- bare-abort-controller
- react-native-b4a
virtua@0.49.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
optionalDependencies:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
vite-plugin-commonjs@0.10.4:
dependencies:
acorn: 8.16.0