From 6bc4a96b99dbe19bca4786eb82ef4ceee1f0139b Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Tue, 26 May 2026 02:41:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(reedy):=20Phase=201B=20=E2=80=94=20wire=20?= =?UTF-8?q?Reedy=20into=20the=20chat,=20settings,=20and=20Sources=20UI=20(?= =?UTF-8?q?#4296)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(reedy): wire RetrievalBackend interface + metrics into the chat adapter Phase 1B backend integration. Adds a RetrievalBackend interface so TauriChatAdapter holds a uniform reference instead of branching across the file, with two impls — LegacyIdbBackend (wraps the existing IDB ragService unchanged) and ReedyBackend (lazy-opens reedy.db, adapts the active provider's embedding model to Reedy's narrower shape, exposes a Vercel `lookupPassage` tool). selectBackend() gates Reedy behind both aiSettings.reedy.enabled AND isTauriAppPlatform() per plan D15 so the MVP cohort is desktop-only. The Reedy path streams via streamText({ tools: { lookupPassage }, stopWhen: stepCountIs(3) }) with a status-aware system prompt that tells the model how to phrase responses for each RetrieverStatus value. Replaces the module-global `lastSources` + 500ms poll with a per-instance ReedySourceStore keyed by a synthetic per-turn id the adapter generates, so the Sources dropdown stops racing on global state. Both legacy and Reedy backends now feed citations through the same store; the UI is backend-agnostic via a shared SourceItem shape both ScoredChunk and RetrievedChunk satisfy. Adds the reedy_metrics table to the reedy migration (versioned with app_version + session_id + turn_id per row) plus a ReedyMetrics writer that ReedyBackend uses to record indexing-lifecycle and tool-use events. Always-on local; no network egress. NoopReedyMetrics keeps construction cheap before the DB opens. Tests: retrievalBackend selectBackend gates, ReedySourceStore semantics (append/replace/subscribe/clear), ReedyMetrics debounced batching + exportBundle, and a TauriChatAdapter contract test that asserts the Reedy/legacy code-paths pass the right args to streamText. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(reedy): UI wiring — settings toggle, clickable sources, feedback bundle Phase 1B UI integration. AIAssistant now constructs the active backend (legacy or Reedy) via selectBackend with the platform gate from M1.7, wires a ReedySourceStore for the chat adapter, and routes Sources-dropdown clicks to `getView(bookKey)?.goTo(source.cfi)` when the source has a CFI. Legacy-path sources still render as static rows because they have no CFI. AIPanel grows a 'Reedy Retrieval (Beta)' BoxedList with the toggle and a 'Send Reedy feedback' button that calls exportReedyMetricsBundle and triggers a JSON download of the last 90 days of events. The toggle is disabled on web with an explanatory description per plan D15. Thread accepts an onSourceClick callback and renders each source as a button when its source carries a CFI, otherwise as a static div — so the Sources dropdown is backend-agnostic via the shared SourceItem shape. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../__tests__/reedy/TauriChatAdapter.test.ts | 238 +++++++++++++++++ .../__tests__/reedy/instrumentation.test.ts | 126 +++++++++ .../src/__tests__/reedy/lookupPassage.test.ts | 1 + .../__tests__/reedy/retrievalBackend.test.ts | 156 +++++++++++ .../components/notebook/AIAssistant.tsx | 134 +++++++--- .../src/components/assistant/Thread.tsx | 62 +++-- .../src/components/settings/AIPanel.tsx | 64 ++++- .../services/ai/adapters/LegacyIdbBackend.ts | 47 ++++ .../src/services/ai/adapters/ReedyBackend.ts | 244 ++++++++++++++++++ .../services/ai/adapters/TauriChatAdapter.ts | 183 +++++++++---- .../src/services/ai/adapters/index.ts | 9 +- .../services/ai/adapters/reedySourceStore.ts | 101 ++++++++ .../services/ai/adapters/retrievalBackend.ts | 72 ++++++ apps/readest-app/src/services/ai/constants.ts | 1 + apps/readest-app/src/services/ai/types.ts | 9 + .../src/services/database/migrations/index.ts | 25 ++ .../src/services/reedy/instrumentation.ts | 243 +++++++++++++++++ .../services/reedy/retrieval/BookRetriever.ts | 3 + 18 files changed, 1617 insertions(+), 101 deletions(-) create mode 100644 apps/readest-app/src/__tests__/reedy/TauriChatAdapter.test.ts create mode 100644 apps/readest-app/src/__tests__/reedy/instrumentation.test.ts create mode 100644 apps/readest-app/src/__tests__/reedy/retrievalBackend.test.ts create mode 100644 apps/readest-app/src/services/ai/adapters/LegacyIdbBackend.ts create mode 100644 apps/readest-app/src/services/ai/adapters/ReedyBackend.ts create mode 100644 apps/readest-app/src/services/ai/adapters/reedySourceStore.ts create mode 100644 apps/readest-app/src/services/ai/adapters/retrievalBackend.ts create mode 100644 apps/readest-app/src/services/reedy/instrumentation.ts diff --git a/apps/readest-app/src/__tests__/reedy/TauriChatAdapter.test.ts b/apps/readest-app/src/__tests__/reedy/TauriChatAdapter.test.ts new file mode 100644 index 00000000..b20b393d --- /dev/null +++ b/apps/readest-app/src/__tests__/reedy/TauriChatAdapter.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Tool } from 'ai'; +import type { RetrievalBackend } from '@/services/ai/adapters/retrievalBackend'; +import { ReedySourceStore } from '@/services/ai/adapters/reedySourceStore'; +import { DEFAULT_AI_SETTINGS } from '@/services/ai/constants'; +import type { AISettings, ScoredChunk } from '@/services/ai/types'; + +// streamText is mocked so the test runs without an LLM provider — we assert +// what arguments the adapter PASSES, not what the model produces. +const streamTextMock = vi.fn(); +const stepCountIsMock = vi.fn((n: number) => ({ __stepCountIs: n })); + +vi.mock('ai', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + streamText: streamTextMock, + stepCountIs: stepCountIsMock, + }; +}); + +// Provider must return a model object that streamText accepts. Since +// streamText itself is mocked we can hand back any opaque sentinel. +vi.mock('@/services/ai/providers', () => ({ + getAIProvider: () => ({ + getModel: () => ({ __mock: 'language-model' }), + getEmbeddingModel: () => ({ __mock: 'embedding-model' }), + }), +})); + +// Import after mocks so the adapter picks up the mocked streamText. +const { createTauriAdapter } = await import('@/services/ai/adapters/TauriChatAdapter'); + +interface FakeBackendOverrides { + searchResult?: ScoredChunk[]; + buildLookupTool?: RetrievalBackend['buildLookupTool']; +} + +function fakeLegacy(overrides: FakeBackendOverrides = {}): RetrievalBackend { + return { + kind: 'legacy-idb', + isIndexed: vi.fn(async () => true), + indexBook: vi.fn(async () => {}), + clearBook: vi.fn(async () => {}), + searchForSystemPrompt: vi.fn(async () => overrides.searchResult ?? []), + }; +} + +function fakeReedy(overrides: FakeBackendOverrides = {}): RetrievalBackend { + const tool = { __mock: 'tool' } as unknown as Tool; + return { + kind: 'reedy', + isIndexed: vi.fn(async () => true), + indexBook: vi.fn(async () => {}), + clearBook: vi.fn(async () => {}), + buildLookupTool: overrides.buildLookupTool ?? vi.fn(() => tool), + }; +} + +const baseSettings: AISettings = { + ...DEFAULT_AI_SETTINGS, + enabled: true, + provider: 'ollama', + reedy: { enabled: true }, +}; + +async function* asyncIter(items: T[]): AsyncGenerator { + for (const item of items) yield item; +} + +interface RunCall { + model: unknown; + system: string; + messages: Array<{ role: string; content: string }>; + tools?: Record; + stopWhen?: unknown; + abortSignal?: AbortSignal; +} + +async function drainRun(adapterRun: AsyncIterable): Promise { + for await (const _ of adapterRun) { + /* swallow */ + } +} + +beforeEach(() => { + streamTextMock.mockReset(); + stepCountIsMock.mockClear(); + // Default: streamText returns an empty text stream so the for-await loop + // in the adapter completes immediately. + streamTextMock.mockImplementation( + (args: RunCall) => + ({ + ...args, + textStream: asyncIter([]), + }) as unknown, + ); +}); + +describe('TauriChatAdapter wiring (M1.11)', () => { + it('Reedy backend: streamText receives tools.lookupPassage and stopWhen=stepCountIs(3)', async () => { + const sourceStore = new ReedySourceStore(); + const backend = fakeReedy(); + const adapter = createTauriAdapter(() => ({ + settings: baseSettings, + bookHash: 'bk1', + bookTitle: 'Title', + authorName: 'Author', + currentPage: 0, + backend, + sourceStore, + })); + + await drainRun( + adapter.run({ + messages: [{ role: 'user', content: [{ type: 'text', text: 'What is Alice doing?' }] }], + abortSignal: undefined, + } as unknown as Parameters[0]) as AsyncIterable, + ); + + expect(streamTextMock).toHaveBeenCalledTimes(1); + const call = streamTextMock.mock.calls[0]![0] as RunCall; + expect(call.tools).toBeDefined(); + expect(call.tools!['lookupPassage']).toBeDefined(); + expect(call.stopWhen).toEqual({ __stepCountIs: 3 }); + expect(call.system).toMatch(/lookupPassage/); + expect(call.system).toMatch(/ { + const sourceStore = new ReedySourceStore(); + const chunks: ScoredChunk[] = [ + { + id: 'c1', + bookHash: 'bk1', + sectionIndex: 0, + chapterTitle: 'Ch1', + pageNumber: 0, + text: 'Alice met the rabbit', + searchMethod: 'hybrid', + score: 0.9, + }, + ]; + const backend = fakeLegacy({ searchResult: chunks }); + const adapter = createTauriAdapter(() => ({ + settings: { ...baseSettings, reedy: { enabled: false }, provider: 'ollama' }, + bookHash: 'bk1', + bookTitle: 'Title', + authorName: 'Author', + currentPage: 0, + backend, + sourceStore, + })); + + await drainRun( + adapter.run({ + messages: [{ role: 'user', content: [{ type: 'text', text: 'rabbit' }] }], + abortSignal: undefined, + } as unknown as Parameters[0]) as AsyncIterable, + ); + + expect(streamTextMock).toHaveBeenCalledTimes(1); + const call = streamTextMock.mock.calls[0]![0] as RunCall; + expect(call.tools).toBeUndefined(); + expect(backend.searchForSystemPrompt).toHaveBeenCalled(); + // Source store should be populated with the legacy chunks under the turn id. + const turnIds = (sourceStore as unknown as { sources: Map }).sources; + const populatedTurns = [...turnIds.entries()].filter(([, v]) => v.length > 0); + expect(populatedTurns.length).toBe(1); + }); + + it('onTurnStart fires synchronously before the stream begins so the UI can subscribe', async () => { + const sourceStore = new ReedySourceStore(); + const backend = fakeReedy(); + const turnStartedWith: string[] = []; + + const adapter = createTauriAdapter(() => ({ + settings: baseSettings, + bookHash: 'bk1', + bookTitle: 'Title', + authorName: 'Author', + currentPage: 0, + backend, + sourceStore, + onTurnStart: (id) => turnStartedWith.push(id), + })); + + await drainRun( + adapter.run({ + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + abortSignal: undefined, + } as unknown as Parameters[0]) as AsyncIterable, + ); + + expect(turnStartedWith).toHaveLength(1); + expect(turnStartedWith[0]).toMatch(/.+/); + // The store should hold the same key (replaced with [] at turn start). + expect(sourceStore.get(turnStartedWith[0]!)).toEqual([]); + }); + + it('Reedy backend: every system prompt includes the status-handling instructions', async () => { + const sourceStore = new ReedySourceStore(); + const adapter = createTauriAdapter(() => ({ + settings: baseSettings, + bookHash: 'bk1', + bookTitle: 'My Book', + authorName: '', + currentPage: 5, + backend: fakeReedy(), + sourceStore, + })); + + await drainRun( + adapter.run({ + messages: [{ role: 'user', content: [{ type: 'text', text: 'q' }] }], + abortSignal: undefined, + } as unknown as Parameters[0]) as AsyncIterable, + ); + + const call = streamTextMock.mock.calls[0]![0] as RunCall; + for (const status of [ + 'not_indexed', + 'empty_index', + 'stale_index', + 'degraded', + 'budget_exceeded', + ]) { + expect(call.system, `system prompt should mention ${status}`).toContain(status); + } + expect(call.system).toContain('My Book'); + }); +}); diff --git a/apps/readest-app/src/__tests__/reedy/instrumentation.test.ts b/apps/readest-app/src/__tests__/reedy/instrumentation.test.ts new file mode 100644 index 00000000..55e8247f --- /dev/null +++ b/apps/readest-app/src/__tests__/reedy/instrumentation.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { NodeDatabaseService } from '@/services/database/nodeDatabaseService'; +import { DatabaseService } from '@/types/database'; +import { migrate } from '@/services/database/migrate'; +import { getMigrations } from '@/services/database/migrations'; +import { + NoopReedyMetrics, + REEDY_METRICS_SCHEMA_VERSION, + ReedyMetrics, +} from '@/services/reedy/instrumentation'; + +describe('ReedyMetrics', () => { + let svc: DatabaseService; + let metrics: ReedyMetrics; + + beforeEach(async () => { + vi.useFakeTimers(); + svc = await NodeDatabaseService.open(':memory:', { experimental: ['index_method'] }); + await migrate(svc, getMigrations('reedy')); + metrics = new ReedyMetrics(svc, '0.9.99-test', 'session-1'); + }); + + afterEach(async () => { + vi.useRealTimers(); + await svc.close(); + }); + + it('creates the reedy_metrics table via the new migration entry', async () => { + const tables = await svc.select<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='table' AND name='reedy_metrics'", + ); + expect(tables).toHaveLength(1); + }); + + it('flush writes buffered rows with app_version, session_id, schema_version', async () => { + metrics.log('ai_tab_opened'); + metrics.log('tool_called', { bookHash: 'bk1', turnId: 't1', payload: { q: 5 } }); + await metrics.flush(); + + const rows = await svc.select<{ + event: string; + app_version: string; + session_id: string | null; + book_hash: string | null; + turn_id: string | null; + schema_version: number; + payload: string | null; + }>('SELECT * FROM reedy_metrics ORDER BY id ASC'); + + expect(rows).toHaveLength(2); + expect(rows[0]!.event).toBe('ai_tab_opened'); + expect(rows[0]!.app_version).toBe('0.9.99-test'); + expect(rows[0]!.session_id).toBe('session-1'); + expect(rows[0]!.schema_version).toBe(REEDY_METRICS_SCHEMA_VERSION); + + expect(rows[1]!.event).toBe('tool_called'); + expect(rows[1]!.book_hash).toBe('bk1'); + expect(rows[1]!.turn_id).toBe('t1'); + expect(JSON.parse(rows[1]!.payload!)).toEqual({ q: 5 }); + }); + + it('flushes automatically after the debounce interval', async () => { + metrics.log('ai_tab_opened'); + let rows = await svc.select('SELECT * FROM reedy_metrics'); + expect(rows).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(2_500); + await metrics.flush(); + + rows = await svc.select('SELECT * FROM reedy_metrics'); + expect(rows).toHaveLength(1); + }); + + it('flushes immediately when the buffer overflows', async () => { + for (let i = 0; i < 50; i++) metrics.log('tool_called', { payload: { i } }); + // The 50th log triggers a synchronous flush() call; await it indirectly. + await metrics.flush(); + const rows = await svc.select('SELECT * FROM reedy_metrics'); + expect(rows.length).toBe(50); + }); + + it('exportBundle returns a JSON envelope with the schema version + events', async () => { + metrics.log('book_indexing_started', { bookHash: 'bk1' }); + // Advance the fake clock so the second event's ts strictly increases + // and the ORDER BY ts ASC inside exportBundle is stable. + await vi.advanceTimersByTimeAsync(5); + metrics.log('book_indexed', { bookHash: 'bk1', payload: { chunks: 42 } }); + await metrics.flush(); + + const json = await metrics.exportBundle({ days: 30 }); + const parsed = JSON.parse(json); + expect(parsed.format).toBe('reedy-metrics-bundle'); + expect(parsed.schemaVersion).toBe(REEDY_METRICS_SCHEMA_VERSION); + expect(parsed.windowDays).toBe(30); + expect(parsed.eventCount).toBe(2); + const indexed = parsed.events.find((e: { event: string }) => e.event === 'book_indexed'); + expect(indexed.payload).toEqual({ chunks: 42 }); + }); + + it('exportBundle excludes rows older than the requested window', async () => { + // Write a row "from 100 days ago" by direct SQL. + const oldTs = Date.now() - 100 * 24 * 60 * 60 * 1000; + await svc.execute( + `INSERT INTO reedy_metrics (ts, event, app_version, schema_version) + VALUES (?, ?, ?, ?)`, + [oldTs, 'ai_tab_opened', '0.0.1', REEDY_METRICS_SCHEMA_VERSION], + ); + metrics.log('book_indexed', { bookHash: 'bk1' }); + await metrics.flush(); + + const json = await metrics.exportBundle({ days: 30 }); + const parsed = JSON.parse(json); + expect(parsed.eventCount).toBe(1); + expect(parsed.events[0].event).toBe('book_indexed'); + }); +}); + +describe('NoopReedyMetrics', () => { + it('log and flush are no-ops; exportBundle returns an empty envelope', async () => { + const noop = new NoopReedyMetrics(); + noop.log(); + await noop.flush(); + const json = await noop.exportBundle(); + expect(JSON.parse(json).events).toEqual([]); + }); +}); diff --git a/apps/readest-app/src/__tests__/reedy/lookupPassage.test.ts b/apps/readest-app/src/__tests__/reedy/lookupPassage.test.ts index 9a578e87..d9eb0292 100644 --- a/apps/readest-app/src/__tests__/reedy/lookupPassage.test.ts +++ b/apps/readest-app/src/__tests__/reedy/lookupPassage.test.ts @@ -23,6 +23,7 @@ function passage(id: string, text: string, position = 0): RetrieverResult['passa bookHash: 'bk1', cfi: `epubcfi(/6/2!/4/${position * 2 + 2})`, endCfi: `epubcfi(/6/2!/4/${position * 2 + 4})`, + sectionIndex: 0, chapterTitle: 'Ch1', text, positionIndex: position, diff --git a/apps/readest-app/src/__tests__/reedy/retrievalBackend.test.ts b/apps/readest-app/src/__tests__/reedy/retrievalBackend.test.ts new file mode 100644 index 00000000..d843ef0b --- /dev/null +++ b/apps/readest-app/src/__tests__/reedy/retrievalBackend.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi } from 'vitest'; +import { selectBackend, type RetrievalBackend } from '@/services/ai/adapters/retrievalBackend'; +import { ReedySourceStore } from '@/services/ai/adapters/reedySourceStore'; +import { DEFAULT_AI_SETTINGS } from '@/services/ai/constants'; +import type { AISettings } from '@/services/ai/types'; +import type { RetrievedChunk } from '@/services/reedy/retrieval/BookRetriever'; + +const fakeLegacy: RetrievalBackend = { + kind: 'legacy-idb', + isIndexed: vi.fn(async () => true), + indexBook: vi.fn(async () => {}), + clearBook: vi.fn(async () => {}), +}; + +const fakeReedy: RetrievalBackend = { + kind: 'reedy', + isIndexed: vi.fn(async () => true), + indexBook: vi.fn(async () => {}), + clearBook: vi.fn(async () => {}), +}; + +function settingsWith(reedyEnabled: boolean): AISettings { + return { ...DEFAULT_AI_SETTINGS, enabled: true, reedy: { enabled: reedyEnabled } }; +} + +describe('selectBackend', () => { + it('returns Reedy when reedy.enabled=true and isTauri=true and a reedy backend is provided', () => { + const out = selectBackend({ + settings: settingsWith(true), + isTauri: true, + legacy: fakeLegacy, + reedy: fakeReedy, + }); + expect(out.kind).toBe('reedy'); + }); + + it('falls back to Legacy on web (isTauri=false) even when reedy.enabled=true', () => { + const out = selectBackend({ + settings: settingsWith(true), + isTauri: false, + legacy: fakeLegacy, + reedy: fakeReedy, + }); + expect(out.kind).toBe('legacy-idb'); + }); + + it('falls back to Legacy when reedy.enabled=false even on Tauri', () => { + const out = selectBackend({ + settings: settingsWith(false), + isTauri: true, + legacy: fakeLegacy, + reedy: fakeReedy, + }); + expect(out.kind).toBe('legacy-idb'); + }); + + it('falls back to Legacy when reedy backend is missing (constructor failed)', () => { + const out = selectBackend({ + settings: settingsWith(true), + isTauri: true, + legacy: fakeLegacy, + reedy: null, + }); + expect(out.kind).toBe('legacy-idb'); + }); + + it('treats missing reedy settings field as disabled', () => { + const out = selectBackend({ + settings: { ...DEFAULT_AI_SETTINGS, enabled: true, reedy: undefined }, + isTauri: true, + legacy: fakeLegacy, + reedy: fakeReedy, + }); + expect(out.kind).toBe('legacy-idb'); + }); +}); + +describe('ReedySourceStore', () => { + function chunk(id: string, text: string): RetrievedChunk { + return { + id, + bookHash: 'bk1', + cfi: `epubcfi(${id})`, + endCfi: `epubcfi(${id}-end)`, + sectionIndex: 0, + chapterTitle: 'Ch1', + text, + positionIndex: 0, + score: 0.5, + }; + } + + it('get returns empty array for an unknown turnId (no throw)', () => { + const store = new ReedySourceStore(); + expect(store.get('missing')).toEqual([]); + }); + + it('replace overwrites; get returns the new snapshot', () => { + const store = new ReedySourceStore(); + store.replace('t1', [chunk('a', 'one')]); + store.replace('t1', [chunk('b', 'two')]); + const out = store.get('t1'); + expect(out).toHaveLength(1); + expect(out[0]!.id).toBe('b'); + }); + + it('append merges, dedup by id, preserves insertion order', () => { + const store = new ReedySourceStore(); + store.append('t1', [chunk('a', 'A'), chunk('b', 'B')]); + store.append('t1', [chunk('b', 'B-again'), chunk('c', 'C')]); + const out = store.get('t1'); + expect(out.map((c) => c.id)).toEqual(['a', 'b', 'c']); + // dedup keeps the FIRST occurrence (preserves rank from the first call) + expect(out[1]!.text).toBe('B'); + }); + + it('subscribe is invoked on replace/append; unsubscribe stops notifications', () => { + const store = new ReedySourceStore(); + const calls: RetrievedChunk[][] = []; + const off = store.subscribe('t1', (snapshot) => calls.push(snapshot)); + + store.replace('t1', [chunk('a', 'A')]); + expect(calls).toHaveLength(1); + expect(calls[0]!.map((c) => c.id)).toEqual(['a']); + + store.append('t1', [chunk('b', 'B')]); + expect(calls).toHaveLength(2); + + off(); + store.append('t1', [chunk('c', 'C')]); + expect(calls).toHaveLength(2); + }); + + it('clear drops every turn and stops notifications', () => { + const store = new ReedySourceStore(); + const listener = vi.fn(); + store.subscribe('t1', listener); + store.replace('t1', [chunk('a', 'A')]); + expect(listener).toHaveBeenCalledTimes(1); + + store.clear(); + expect(store.get('t1')).toEqual([]); + + store.replace('t1', [chunk('b', 'B')]); + // After clear() subscription is gone — listener not invoked. + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('turns are independent — replacing t1 does not affect t2', () => { + const store = new ReedySourceStore(); + store.replace('t1', [chunk('a', 'A')]); + store.replace('t2', [chunk('b', 'B'), chunk('c', 'C')]); + expect(store.get('t1')).toHaveLength(1); + expect(store.get('t2')).toHaveLength(2); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/notebook/AIAssistant.tsx b/apps/readest-app/src/app/reader/components/notebook/AIAssistant.tsx index 95265d18..ab32da77 100644 --- a/apps/readest-app/src/app/reader/components/notebook/AIAssistant.tsx +++ b/apps/readest-app/src/app/reader/components/notebook/AIAssistant.tsx @@ -14,17 +14,20 @@ import { useSettingsStore } from '@/store/settingsStore'; import { useBookDataStore } from '@/store/bookDataStore'; import { useReaderStore } from '@/store/readerStore'; import { useAIChatStore } from '@/store/aiChatStore'; +import { aiLogger, createTauriAdapter } from '@/services/ai'; import { - indexBook, - isBookIndexed, - aiStore, - aiLogger, - createTauriAdapter, - getLastSources, - clearLastSources, -} from '@/services/ai'; + LegacyIdbBackend, + ReedyBackend, + ReedySourceStore, + selectBackend, + type RetrievalBackend, + type SourceItem, +} from '@/services/ai/adapters'; import type { EmbeddingProgress, AISettings, AIMessage } from '@/services/ai/types'; +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 { Button } from '@/components/ui/button'; import { Loader2Icon, BookOpenIcon } from 'lucide-react'; @@ -75,6 +78,11 @@ const AIAssistantChat = ({ bookTitle, authorName, currentPage, + backend, + sourceStore, + currentTurnId, + setCurrentTurnId, + onSourceClick, onResetIndex, }: { aiSettings: AISettings; @@ -82,6 +90,11 @@ const AIAssistantChat = ({ bookTitle: string; authorName: string; currentPage: number; + backend: RetrievalBackend; + sourceStore: ReedySourceStore; + currentTurnId: string | null; + setCurrentTurnId: (id: string) => void; + onSourceClick?: (source: SourceItem) => void; onResetIndex: () => void; }) => { const { @@ -98,6 +111,9 @@ const AIAssistantChat = ({ bookTitle, authorName, currentPage, + backend, + sourceStore, + onTurnStart: setCurrentTurnId, }); // update ref on every render with latest values @@ -108,6 +124,9 @@ const AIAssistantChat = ({ bookTitle, authorName, currentPage, + backend, + sourceStore, + onTurnStart: setCurrentTurnId, }; }); @@ -160,6 +179,9 @@ const AIAssistantChat = ({ onResetIndex={onResetIndex} isLoadingHistory={isLoadingHistory} hasActiveConversation={!!activeConversationId} + sourceStore={sourceStore} + currentTurnId={currentTurnId} + onSourceClick={onSourceClick} /> ); }; @@ -170,12 +192,18 @@ const AIAssistantWithRuntime = ({ onResetIndex, isLoadingHistory, hasActiveConversation, + sourceStore, + currentTurnId, + onSourceClick, }: { adapter: NonNullable>; historyAdapter?: ThreadHistoryAdapter; onResetIndex: () => void; isLoadingHistory: boolean; hasActiveConversation: boolean; + sourceStore: ReedySourceStore; + currentTurnId: string | null; + onSourceClick?: (source: SourceItem) => void; }) => { const runtime = useLocalRuntime(adapter, { adapters: historyAdapter ? { history: historyAdapter } : undefined, @@ -189,6 +217,9 @@ const AIAssistantWithRuntime = ({ onResetIndex={onResetIndex} isLoadingHistory={isLoadingHistory} hasActiveConversation={hasActiveConversation} + sourceStore={sourceStore} + currentTurnId={currentTurnId} + onSourceClick={onSourceClick} /> ); @@ -198,32 +229,45 @@ const ThreadWrapper = ({ onResetIndex, isLoadingHistory, hasActiveConversation, + sourceStore, + currentTurnId, + onSourceClick, }: { onResetIndex: () => void; isLoadingHistory: boolean; hasActiveConversation: boolean; + sourceStore: ReedySourceStore; + currentTurnId: string | null; + onSourceClick?: (source: SourceItem) => void; }) => { - const [sources, setSources] = useState(getLastSources()); + const [sources, setSources] = useState( + currentTurnId ? sourceStore.get(currentTurnId) : [], + ); const assistantRuntime = useAssistantRuntime(); const { setActiveConversation } = useAIChatStore(); + // Subscribe to the active turn's slot in the source store. Replaces the + // pre-Reedy 500ms poll over a module-global lastSources (per plan §M1.7). useEffect(() => { - const interval = setInterval(() => { - setSources(getLastSources()); - }, 500); - return () => clearInterval(interval); - }, []); + if (!currentTurnId) { + setSources([]); + return; + } + setSources(sourceStore.get(currentTurnId)); + return sourceStore.subscribe(currentTurnId, setSources); + }, [currentTurnId, sourceStore]); const handleClear = useCallback(() => { - clearLastSources(); + sourceStore.clear(); setSources([]); setActiveConversation(null); assistantRuntime.switchToNewThread(); - }, [assistantRuntime, setActiveConversation]); + }, [assistantRuntime, setActiveConversation, sourceStore]); return ( { const { appService } = useEnv(); const { settings } = useSettingsStore(); const { getBookData } = useBookDataStore(); - const { getProgress } = useReaderStore(); + const { getProgress, getView } = useReaderStore(); const bookData = getBookData(bookKey); const progress = getProgress(bookKey); @@ -245,6 +289,7 @@ const AIAssistant = ({ bookKey }: AIAssistantProps) => { const [isIndexing, setIsIndexing] = useState(false); const [indexProgress, setIndexProgress] = useState(null); const [indexed, setIndexed] = useState(false); + const [currentTurnId, setCurrentTurnId] = useState(null); const bookHash = bookKey.split('-')[0] || ''; const bookTitle = bookData?.book?.title || 'Unknown'; @@ -252,28 +297,39 @@ const AIAssistant = ({ bookKey }: AIAssistantProps) => { const currentPage = progress?.pageinfo?.current ?? 0; const aiSettings = settings?.aiSettings; + // Per-instance source store, plus the active backend chosen via the same + // selectBackend gate the chat adapter will hit (Reedy on Tauri when + // enabled; legacy IDB otherwise). + const sourceStore = useMemo(() => new ReedySourceStore(), []); + const backend = useMemo(() => { + if (!aiSettings) return null; + const legacy = new LegacyIdbBackend(aiSettings); + const reedy: RetrievalBackend | null = + appService && isTauriAppPlatform() + ? new ReedyBackend(appService as AppService, aiSettings) + : null; + return selectBackend({ settings: aiSettings, isTauri: isTauriAppPlatform(), legacy, reedy }); + }, [aiSettings, appService]); + // check if book is indexed on mount useEffect(() => { - if (bookHash) { - isBookIndexed(bookHash).then((result) => { + if (bookHash && backend) { + backend.isIndexed(bookHash).then((result) => { setIndexed(result); setIsLoading(false); }); + } else if (!backend) { + setIsLoading(false); } else { setIsLoading(false); } - }, [bookHash]); + }, [bookHash, backend]); const handleIndex = useCallback(async () => { - if (!bookData?.bookDoc || !aiSettings) return; + if (!bookData?.bookDoc || !aiSettings || !backend) return; setIsIndexing(true); try { - await indexBook( - bookData.bookDoc as Parameters[0], - bookHash, - aiSettings, - setIndexProgress, - ); + await backend.indexBook(bookData.bookDoc, bookHash, { onProgress: setIndexProgress }); setIndexed(true); } catch (e) { aiLogger.rag.indexError(bookHash, (e as Error).message); @@ -284,11 +340,22 @@ const AIAssistant = ({ bookKey }: AIAssistantProps) => { }, [bookData?.bookDoc, bookHash, aiSettings]); const handleResetIndex = useCallback(async () => { - if (!appService) return; + if (!appService || !backend) return; if (!(await appService.ask(_('Are you sure you want to re-index this book?')))) return; - await aiStore.clearBook(bookHash); + await backend.clearBook(bookHash); setIndexed(false); - }, [bookHash, appService, _]); + }, [bookHash, appService, backend, _]); + + // Navigate the reader to a clicked source's CFI. Legacy backend chunks have + // no CFI so the Thread component renders them as static rows — only Reedy + // sources are clickable in M1.10. + const handleSourceClick = useCallback( + (source: SourceItem) => { + if (!source.cfi) return; + getView(bookKey)?.goTo(source.cfi); + }, + [bookKey, getView], + ); if (!aiSettings?.enabled) { return ( @@ -350,6 +417,8 @@ const AIAssistant = ({ bookKey }: AIAssistantProps) => { ); } + if (!backend) return null; + return ( { bookTitle={bookTitle} authorName={authorName} currentPage={currentPage} + backend={backend} + sourceStore={sourceStore} + currentTurnId={currentTurnId} + setCurrentTurnId={setCurrentTurnId} + onSourceClick={handleSourceClick} onResetIndex={handleResetIndex} /> ); diff --git a/apps/readest-app/src/components/assistant/Thread.tsx b/apps/readest-app/src/components/assistant/Thread.tsx index 0e8e2ea0..394e6f9f 100644 --- a/apps/readest-app/src/components/assistant/Thread.tsx +++ b/apps/readest-app/src/components/assistant/Thread.tsx @@ -34,10 +34,12 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { cn } from '@/utils/tailwind'; -import type { ScoredChunk } from '@/services/ai/types'; +import type { SourceItem } from '@/services/ai/adapters/reedySourceStore'; interface ThreadProps { - sources?: ScoredChunk[]; + sources?: SourceItem[]; + /** Invoked when a source row is clicked. Reedy passes the CFI to the reader's goTo. */ + onSourceClick?: (source: SourceItem) => void; onClear?: () => void; onResetIndex?: () => void; isLoadingHistory?: boolean; @@ -101,6 +103,7 @@ const ScrollToBottomButton: FC = () => { export const Thread: FC = ({ sources = [], + onSourceClick, onClear, onResetIndex, isLoadingHistory = false, @@ -192,7 +195,9 @@ export const Thread: FC = ({ components={{ UserMessage, EditComposer, - AssistantMessage: () => , + AssistantMessage: () => ( + + ), }} />

@@ -280,10 +285,11 @@ const Composer: FC = ({ onClear, onResetIndex }) => { }; interface AssistantMessageProps { - sources?: ScoredChunk[]; + sources?: SourceItem[]; + onSourceClick?: (source: SourceItem) => void; } -const AssistantMessage: FC = ({ sources = [] }) => { +const AssistantMessage: FC = ({ sources = [], onSourceClick }) => { return (

@@ -316,19 +322,41 @@ const AssistantMessage: FC = ({ sources = [] }) => { Sources from book
- {sources.map((source, i) => ( -
-
- {source.chapterTitle || `Section ${source.sectionIndex + 1}`} + {sources.map((source, i) => { + const clickable = !!source.cfi && !!onSourceClick; + const baseClass = + 'border-base-content/10 bg-base-200/50 rounded-lg border px-2 py-1.5 text-[11px]'; + const content = ( + <> +
+ {source.chapterTitle || `Section ${source.sectionIndex + 1}`} +
+
+ {source.text} +
+ + ); + if (clickable) { + return ( + + ); + } + return ( +
+ {content}
-
- {source.text} -
-
- ))} + ); + })}
diff --git a/apps/readest-app/src/components/settings/AIPanel.tsx b/apps/readest-app/src/components/settings/AIPanel.tsx index 9f490a8e..d878c4f6 100644 --- a/apps/readest-app/src/components/settings/AIPanel.tsx +++ b/apps/readest-app/src/components/settings/AIPanel.tsx @@ -12,6 +12,8 @@ import { } from '@/services/ai/providers/OpenRouterProvider'; import { DEFAULT_AI_SETTINGS, GATEWAY_MODELS, MODEL_PRICING } from '@/services/ai/constants'; import type { AISettings, AIProviderName } from '@/services/ai/types'; +import { exportReedyMetricsBundle } from '@/services/reedy/instrumentation'; +import { isTauriAppPlatform } from '@/services/environment'; import { BoxedList, SettingLabel, SettingsRow, SettingsSwitchRow } from './primitives'; type ConnectionStatus = 'idle' | 'testing' | 'success' | 'error'; @@ -67,12 +69,13 @@ const getModelOptions = (): ModelOption[] => [ const AIPanel: React.FC = () => { const _ = useTranslation(); - const { envConfig } = useEnv(); + const { envConfig, appService } = useEnv(); const { settings, setSettings, saveSettings } = useSettingsStore(); const aiSettings: AISettings = settings?.aiSettings ?? DEFAULT_AI_SETTINGS; const [enabled, setEnabled] = useState(aiSettings.enabled); + const [reedyEnabled, setReedyEnabled] = useState(aiSettings.reedy?.enabled ?? false); const [provider, setProvider] = useState(aiSettings.provider); const [ollamaUrl, setOllamaUrl] = useState(aiSettings.ollamaBaseUrl); const [ollamaModel, setOllamaModel] = useState(aiSettings.ollamaModel); @@ -736,6 +739,65 @@ const AIPanel: React.FC = () => { )} + + { + const next = !reedyEnabled; + setReedyEnabled(next); + saveAiSetting('reedy', { enabled: next }); + }} + /> +
+
+ {_('Send Reedy feedback')} + + {_( + 'Download the last 90 days of Reedy events as a JSON bundle (local-only by default). Paste into a GitHub issue or feedback form.', + )} + +
+ +
+
+