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.', + )} + +
+ +
+
+