feat(reedy): Appendix A · Phase 4 — custom thread UI on AgentRuntime (#4308)
This commit is contained in:
@@ -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: 0–100 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 };
|
||||
}
|
||||
Generated
+31
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user