diff --git a/packages/app-expo/src/App.tsx b/packages/app-expo/src/App.tsx index 5e9ecbe6d..87148ef75 100644 --- a/packages/app-expo/src/App.tsx +++ b/packages/app-expo/src/App.tsx @@ -31,6 +31,7 @@ import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; +import { MobileFallbackExtractorHost } from "@/components/rag/MobileFallbackExtractorHost"; import { AnimatedSplash } from "@/components/splash/AnimatedSplash"; import { rnSessionEventSource } from "@/hooks"; import { setStreamingFetch } from "@readany/core/ai/llm-provider"; @@ -290,6 +291,7 @@ function AppInner() { + diff --git a/packages/app-expo/src/components/rag/ExtractorWebView.tsx b/packages/app-expo/src/components/rag/ExtractorWebView.tsx index 2609a7b01..7d9611ebc 100644 --- a/packages/app-expo/src/components/rag/ExtractorWebView.tsx +++ b/packages/app-expo/src/components/rag/ExtractorWebView.tsx @@ -145,11 +145,11 @@ export const ExtractorWebView = forwardRef((_, ref) => { if (!htmlUri) return null; return ( - + ((_, ref) => { ); }); + +const styles = StyleSheet.create({ + host: { + position: "absolute", + left: 0, + bottom: 0, + width: 1, + height: 1, + overflow: "hidden", + opacity: 0.01, + }, + webView: { + width: 1, + height: 1, + }, +}); diff --git a/packages/app-expo/src/components/rag/MobileFallbackExtractorHost.tsx b/packages/app-expo/src/components/rag/MobileFallbackExtractorHost.tsx new file mode 100644 index 000000000..109c1512a --- /dev/null +++ b/packages/app-expo/src/components/rag/MobileFallbackExtractorHost.tsx @@ -0,0 +1,22 @@ +import { createMobileFallbackContentProvider } from "@/lib/rag/mobile-fallback-content-provider"; +import { setFallbackContentProvider } from "@readany/core/ai"; +import { getPlatformService } from "@readany/core/services"; +import { useEffect, useRef } from "react"; +import { type ExtractorRef, ExtractorWebView } from "./ExtractorWebView"; + +export function MobileFallbackExtractorHost() { + const extractorRef = useRef(null); + + useEffect(() => { + setFallbackContentProvider( + createMobileFallbackContentProvider({ + getExtractor: () => extractorRef.current, + platform: getPlatformService(), + }), + ); + + return () => setFallbackContentProvider(null); + }, []); + + return ; +} diff --git a/packages/app-expo/src/components/rag/mobile-fallback-extractor-host.test.ts b/packages/app-expo/src/components/rag/mobile-fallback-extractor-host.test.ts new file mode 100644 index 000000000..47db5e4f5 --- /dev/null +++ b/packages/app-expo/src/components/rag/mobile-fallback-extractor-host.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const componentDir = dirname(fileURLToPath(import.meta.url)); +const srcDir = resolve(componentDir, "../.."); + +describe("mobile fallback extractor ownership", () => { + it("mounts the fallback host beside root navigation", () => { + const appSource = readFileSync(resolve(srcDir, "App.tsx"), "utf8"); + + expect(appSource).toContain("import { MobileFallbackExtractorHost }"); + expect(appSource).toMatch(/[\s\S]*/); + }); + + it("does not register the AI fallback provider from LibraryScreen", () => { + const librarySource = readFileSync(resolve(srcDir, "screens/LibraryScreen.tsx"), "utf8"); + + expect(librarySource).not.toContain("setFallbackContentProvider"); + }); + + it("keeps the extraction WebView non-zero-sized", () => { + const extractorSource = readFileSync(resolve(componentDir, "ExtractorWebView.tsx"), "utf8"); + + expect(extractorSource).not.toMatch(/width:\s*0|height:\s*0/); + expect(extractorSource).toMatch(/width:\s*1/); + expect(extractorSource).toMatch(/height:\s*1/); + }); +}); diff --git a/packages/app-expo/src/lib/rag/mobile-fallback-content-provider.test.ts b/packages/app-expo/src/lib/rag/mobile-fallback-content-provider.test.ts new file mode 100644 index 000000000..2b5b48009 --- /dev/null +++ b/packages/app-expo/src/lib/rag/mobile-fallback-content-provider.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMobileFallbackContentProvider } from "./mobile-fallback-content-provider"; + +const book = { + id: "book-1", + filePath: "books/book-1.epub", + format: "epub", + meta: { title: "Book 1" }, +} as const; + +function makeDependencies(overrides: Record = {}) { + const extractChapters = vi.fn(async () => [ + { index: 0, title: "Chapter 1", content: "Text", segments: [] }, + ]); + return { + dependencies: { + getExtractor: () => ({ extractChapters }), + platform: { + getAppDataDir: vi.fn(async () => "/app-data"), + joinPath: vi.fn(async (...parts: string[]) => parts.join("/")), + exists: vi.fn(async () => true), + readFile: vi.fn(async () => new Uint8Array([1, 2, 3])), + }, + ...overrides, + }, + extractChapters, + }; +} + +describe("mobile fallback content provider", () => { + it("resolves a relative local book and extracts it with the matching MIME type", async () => { + const { dependencies, extractChapters } = makeDependencies(); + const provider = createMobileFallbackContentProvider(dependencies); + + await expect(provider.getChapters(book as never)).resolves.toEqual([ + { index: 0, title: "Chapter 1", content: "Text", segments: [] }, + ]); + expect(dependencies.platform.joinPath).toHaveBeenCalledWith("/app-data", "books/book-1.epub"); + expect(dependencies.platform.exists).toHaveBeenCalledWith("/app-data/books/book-1.epub"); + expect(extractChapters).toHaveBeenCalledWith("AQID", "application/epub+zip"); + }); + + it("rejects remote files before trying to read them", async () => { + const { dependencies } = makeDependencies(); + const provider = createMobileFallbackContentProvider(dependencies); + + await expect( + provider.getChapters({ ...book, filePath: "https://example.com/book.epub" } as never), + ).rejects.toThrow("requires a local book file"); + expect(dependencies.platform.readFile).not.toHaveBeenCalled(); + }); + + it("reports a missing local file without invoking the extractor", async () => { + const { dependencies, extractChapters } = makeDependencies({ + platform: { + getAppDataDir: vi.fn(async () => "/app-data"), + joinPath: vi.fn(async (...parts: string[]) => parts.join("/")), + exists: vi.fn(async () => false), + readFile: vi.fn(async () => new Uint8Array()), + }, + }); + const provider = createMobileFallbackContentProvider(dependencies); + + await expect(provider.getChapters(book as never)).rejects.toThrow( + "Book file is not available on this device", + ); + expect(extractChapters).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app-expo/src/lib/rag/mobile-fallback-content-provider.ts b/packages/app-expo/src/lib/rag/mobile-fallback-content-provider.ts new file mode 100644 index 000000000..8a90182ff --- /dev/null +++ b/packages/app-expo/src/lib/rag/mobile-fallback-content-provider.ts @@ -0,0 +1,70 @@ +import type { ExtractorRef } from "@/components/rag/ExtractorWebView"; +import type { FallbackContentProvider } from "@readany/core/ai"; +import type { IPlatformService } from "@readany/core/services"; + +type MobileFallbackPlatform = Pick< + IPlatformService, + "exists" | "getAppDataDir" | "joinPath" | "readFile" +>; + +interface MobileFallbackContentProviderDependencies { + getExtractor: () => ExtractorRef | null; + platform: MobileFallbackPlatform; +} + +const MIME_TYPES: Record = { + epub: "application/epub+zip", + pdf: "application/pdf", + mobi: "application/x-mobipocket-ebook", + azw: "application/vnd.amazon.ebook", + azw3: "application/vnd.amazon.ebook", + cbz: "application/vnd.comicbook+zip", + cbr: "application/vnd.comicbook+zip", + fb2: "application/x-fictionbook+xml", + fbz: "application/x-zip-compressed-fb2", + txt: "text/plain", +}; + +function bytesToBase64(bytes: Uint8Array): string { + const chunkSize = 0x8000; + let binary = ""; + + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + + return btoa(binary); +} + +function isAbsoluteBookPath(filePath: string): boolean { + return /^(?:\/|file:\/\/|asset:\/\/|https?:\/\/)/i.test(filePath); +} + +export function createMobileFallbackContentProvider( + dependencies: MobileFallbackContentProviderDependencies, +): FallbackContentProvider { + return { + async getChapters(book) { + const extractor = dependencies.getExtractor(); + if (!extractor) throw new Error("Mobile fallback extractor is not ready"); + + const { platform } = dependencies; + const filePath = isAbsoluteBookPath(book.filePath) + ? book.filePath + : await platform.joinPath(await platform.getAppDataDir(), book.filePath); + if (/^https?:\/\//i.test(filePath)) { + throw new Error("Mobile original-file search requires a local book file"); + } + if (!(await platform.exists(filePath))) { + throw new Error("Book file is not available on this device"); + } + + const bytes = await platform.readFile(filePath); + const format = String(book.format || "").toLowerCase(); + return extractor.extractChapters( + bytesToBase64(bytes), + MIME_TYPES[format] || "application/epub+zip", + ); + }, + }; +} diff --git a/packages/app-expo/src/screens/LibraryScreen.tsx b/packages/app-expo/src/screens/LibraryScreen.tsx index d5f3a1fb6..08db440d3 100644 --- a/packages/app-expo/src/screens/LibraryScreen.tsx +++ b/packages/app-expo/src/screens/LibraryScreen.tsx @@ -43,13 +43,11 @@ import { type WebDavImportSource, getPlatformService, } from "@readany/core"; -import { setFallbackContentProvider } from "@readany/core/ai"; import { onLibraryChanged } from "@readany/core/events/library-events"; import { useSyncStore } from "@readany/core/stores"; import { SYNC_SECRET_KEYS } from "@readany/core/sync/sync-backend"; import type { Book, BookGroup, SortField } from "@readany/core/types"; import * as DocumentPicker from "expo-document-picker"; -import { File as ExpoFile } from "expo-file-system"; /** * LibraryScreen — matching Tauri mobile LibraryPage exactly. * Features: header search/sort/import, tag filter, vectorization progress banner, @@ -80,17 +78,6 @@ import { TagManagementSheet } from "./library/TagManagementSheet"; import { useBookDownload } from "./library/useBookDownload"; import { useVectorizationQueue } from "./library/useVectorizationQueue"; -function bytesToBase64(bytes: Uint8Array): string { - const chunkSize = 0x8000; - let binary = ""; - - for (let i = 0; i < bytes.length; i += chunkSize) { - binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); - } - - return btoa(binary); -} - const BOOK_PNG = require("../../assets/book.png"); const BOOK_DARK_PNG = require("../../assets/book-dark.png"); @@ -259,44 +246,6 @@ export function LibraryScreen() { useEffect(() => { setExtractorRef(extractorRef.current); - setFallbackContentProvider({ - async getChapters(book) { - if (!extractorRef.current) throw new Error("Mobile fallback extractor is not ready"); - const platform = getPlatformService(); - const appData = await platform.getAppDataDir(); - const filePath = - book.filePath.startsWith("/") || - book.filePath.startsWith("file://") || - book.filePath.startsWith("asset://") || - book.filePath.startsWith("http") - ? book.filePath - : await platform.joinPath(appData, book.filePath); - if (/^https?:\/\//i.test(filePath)) { - throw new Error("Mobile original-file search requires a local book file"); - } - - const file = new ExpoFile(filePath); - if (!file.exists) throw new Error("Book file is not available on this device"); - - const bytes = await platform.readFile(filePath); - const mimeTypes: Record = { - epub: "application/epub+zip", - pdf: "application/pdf", - mobi: "application/x-mobipocket-ebook", - azw: "application/vnd.amazon.ebook", - azw3: "application/vnd.amazon.ebook", - cbz: "application/vnd.comicbook+zip", - cbr: "application/vnd.comicbook+zip", - fb2: "application/x-fictionbook+xml", - fbz: "application/x-zip-compressed-fb2", - txt: "text/plain", - }; - return extractorRef.current.extractChapters( - bytesToBase64(bytes), - mimeTypes[String(book.format || "").toLowerCase()] || "application/epub+zip", - ); - }, - }); setCallback((bookId, progress) => { console.log( `[AutoVectorize] Book ${bookId}: ${progress.status} (${Math.round(progress.progress * 100)}%)`, @@ -304,7 +253,6 @@ export function LibraryScreen() { }); return () => { setExtractorRef(null); - setFallbackContentProvider(null); setCallback(null); }; }, []); diff --git a/packages/core/src/ai/__tests__/fallback-content-service.test.ts b/packages/core/src/ai/__tests__/fallback-content-service.test.ts index c548c974e..3b106c365 100644 --- a/packages/core/src/ai/__tests__/fallback-content-service.test.ts +++ b/packages/core/src/ai/__tests__/fallback-content-service.test.ts @@ -29,4 +29,63 @@ describe("fallbackContentService", () => { await pending; }); + + it("shares one provider request between concurrent reads of the same book", async () => { + let resolveProvider: + | ((chapters: Array<{ index: number; title: string; content: string }>) => void) + | undefined; + const providerRequest = new Promise>( + (resolve) => { + resolveProvider = resolve; + }, + ); + const getChapters = vi.fn(() => providerRequest); + setFallbackContentProvider({ getChapters }); + + const first = fallbackContentService.getChapters(book); + const second = fallbackContentService.getChapters(book); + const chapters = [{ index: 0, title: "Chapter 1", content: "Text" }]; + resolveProvider?.(chapters); + + await expect(first).resolves.toBe(chapters); + await expect(second).resolves.toBe(chapters); + expect(getChapters).toHaveBeenCalledTimes(1); + }); + + it("clears a failed in-flight request so a later read can retry", async () => { + const chapters = [{ index: 0, title: "Chapter 1", content: "Text" }]; + const getChapters = vi + .fn() + .mockRejectedValueOnce(new Error("Extractor failed")) + .mockResolvedValueOnce(chapters); + setFallbackContentProvider({ getChapters }); + + await expect(fallbackContentService.getChapters(book)).rejects.toThrow("Extractor failed"); + await expect(fallbackContentService.getChapters(book)).resolves.toBe(chapters); + expect(getChapters).toHaveBeenCalledTimes(2); + }); + + it("does not let an old provider completion replace the new provider cache", async () => { + let resolveOldProvider: + | ((chapters: Array<{ index: number; title: string; content: string }>) => void) + | undefined; + setFallbackContentProvider({ + getChapters: () => + new Promise((resolve) => { + resolveOldProvider = resolve; + }), + }); + const oldRequest = fallbackContentService.getChapters(book); + + const newChapters = [{ index: 0, title: "New chapter", content: "New text" }]; + const newProvider = vi.fn(async () => newChapters); + setFallbackContentProvider({ getChapters: newProvider }); + await expect(fallbackContentService.getChapters(book)).resolves.toBe(newChapters); + + const oldChapters = [{ index: 0, title: "Old chapter", content: "Old text" }]; + resolveOldProvider?.(oldChapters); + await expect(oldRequest).resolves.toBe(oldChapters); + await expect(fallbackContentService.getChapters(book)).resolves.toBe(newChapters); + expect(newProvider).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/core/src/ai/__tests__/reading-agent-tools.test.ts b/packages/core/src/ai/__tests__/reading-agent-tools.test.ts index d96d8ac1c..ccc215bfb 100644 --- a/packages/core/src/ai/__tests__/reading-agent-tools.test.ts +++ b/packages/core/src/ai/__tests__/reading-agent-tools.test.ts @@ -259,6 +259,90 @@ describe("streamReadingAgent tool registration", () => { await pending; }); + it("stops calling original-file tools after the fallback source fails in a turn", async () => { + createReactAgentMock.mockReturnValue({ + streamEvents: vi.fn(() => ({ + [Symbol.asyncIterator]: async function* () { + // no-op stream + }, + })), + }); + const fallbackSearch = vi.fn(async () => ({ + error: "Timed out reading original book content", + sourceUnavailable: true, + })); + const fallbackToc = vi.fn(async () => ({ chapters: [] })); + const getCurrentChapter = vi.fn(async () => ({ title: "Chapter 1" })); + const tools: ToolDefinition[] = [ + { + name: "fallbackSearch", + description: "Search the original book", + parameters: {}, + execute: fallbackSearch, + }, + { + name: "fallbackToc", + description: "Read the original table of contents", + parameters: {}, + execute: fallbackToc, + }, + { + name: "getCurrentChapter", + description: "Read the current chapter metadata", + parameters: {}, + execute: getCurrentChapter, + }, + ]; + + for await (const _event of streamReadingAgent( + { + aiConfig: makeAIConfig(), + book: null, + bookId: "book-1", + semanticContext: null, + enabledSkills: [], + isVectorized: false, + getAvailableTools: () => tools, + }, + "Analyze this book", + )) { + // drain stream + } + + const call = createReactAgentMock.mock.calls[createReactAgentMock.mock.calls.length - 1]?.[0]; + const registeredTools = call.tools as Array<{ + name: string; + func: (input: unknown) => Promise; + }>; + const execute = (name: string) => { + const tool = registeredTools.find((candidate) => candidate.name === name); + if (!tool) throw new Error(`Expected ${name} to be registered`); + return tool.func({}); + }; + + await expect(execute("fallbackSearch")).resolves.toBe( + JSON.stringify({ + error: "Timed out reading original book content", + sourceUnavailable: true, + stopFallbackToolCalls: true, + }), + ); + await expect(execute("fallbackToc")).resolves.toBe( + JSON.stringify({ + error: "Timed out reading original book content", + sourceUnavailable: true, + stopFallbackToolCalls: true, + }), + ); + await expect(execute("getCurrentChapter")).resolves.toBe( + JSON.stringify({ title: "Chapter 1" }), + ); + + expect(fallbackSearch).toHaveBeenCalledTimes(1); + expect(fallbackToc).not.toHaveBeenCalled(); + expect(getCurrentChapter).toHaveBeenCalledTimes(1); + }); + it("keeps tool-call turn text out of the final response before addCitation completes", async () => { createReactAgentMock.mockReturnValue({ streamEvents: vi.fn(() => ({ diff --git a/packages/core/src/ai/agents/reading-agent.ts b/packages/core/src/ai/agents/reading-agent.ts index 37bf5f654..e5e57e86f 100644 --- a/packages/core/src/ai/agents/reading-agent.ts +++ b/packages/core/src/ai/agents/reading-agent.ts @@ -30,6 +30,11 @@ const CHAPTER_TASK_RECURSION_LIMIT = 24; const DEFAULT_TOOL_TIMEOUT_MS = 45_000; const TOOL_EXECUTION_LIMIT = 12; const REPEATED_TOOL_CALL_LIMIT = 2; +const FALLBACK_CONTENT_TOOL_NAMES = new Set([ + "fallbackSearch", + "fallbackToc", + "fallbackChapterContext", +]); const TOOL_TIMEOUT_MS_BY_NAME: Record = { getSelection: 5_000, getCurrentChapter: 5_000, @@ -842,6 +847,7 @@ export async function* streamReadingAgent( const searchResultCache = new Map(); const toolExecutionCounts = new Map(); const lastToolResults = new Map(); + let fallbackSourceFailure: Record | null = null; let totalToolExecutions = 0; const pendingToolCallNames: string[] = []; const isChapterTask = @@ -1012,6 +1018,10 @@ export async function* streamReadingAgent( ); } + if (fallbackSourceFailure && FALLBACK_CONTENT_TOOL_NAMES.has(tool.name)) { + return JSON.stringify(fallbackSourceFailure); + } + if (isChapterTask && isChapterLookupTool) { if (chapterReferenceState.totalChapterToolExecutions >= CHAPTER_TOOL_EXECUTION_LIMIT) { chapterReferenceState.limitReached = true; @@ -1089,7 +1099,20 @@ export async function* streamReadingAgent( return JSON.stringify(cachedResult); } - const result = await executeTool(tool, toolInput, getToolTimeoutMs(tool, toolTimeoutMs)); + let result = await executeTool(tool, toolInput, getToolTimeoutMs(tool, toolTimeoutMs)); + if ( + FALLBACK_CONTENT_TOOL_NAMES.has(tool.name) && + result && + typeof result === "object" && + (result as Record).sourceUnavailable === true && + typeof (result as Record).error === "string" + ) { + fallbackSourceFailure = { + ...(result as Record), + stopFallbackToolCalls: true, + }; + result = fallbackSourceFailure; + } if (exactCacheKey) { toolResultCache.set(exactCacheKey, result); } diff --git a/packages/core/src/ai/fallback-content-service.ts b/packages/core/src/ai/fallback-content-service.ts index 2d74c7050..396d7fcba 100644 --- a/packages/core/src/ai/fallback-content-service.ts +++ b/packages/core/src/ai/fallback-content-service.ts @@ -41,10 +41,12 @@ function withTimeout(promise: Promise, timeoutMs: number): Promise { class FallbackContentService { private provider: FallbackContentProvider | null = null; private cache = new Map(); + private inFlight = new Map>(); setProvider(provider: FallbackContentProvider | null): void { this.provider = provider; this.cache.clear(); + this.inFlight.clear(); } clear(bookId?: string): void { @@ -65,15 +67,33 @@ class FallbackContentService { return cached.chapters; } - const chapters = await withTimeout(this.provider.getChapters(book), PROVIDER_TIMEOUT_MS); - this.cache.set(book.id, { chapters, cachedAt: Date.now() }); - - if (this.cache.size > MAX_CACHE_ENTRIES) { - const oldestKey = this.cache.keys().next().value; - if (oldestKey) this.cache.delete(oldestKey); - } - - return chapters; + const pending = this.inFlight.get(book.id); + if (pending) return pending; + + const provider = this.provider; + const request = withTimeout( + Promise.resolve().then(() => provider.getChapters(book)), + PROVIDER_TIMEOUT_MS, + ) + .then((chapters) => { + if (this.provider === provider) { + this.cache.set(book.id, { chapters, cachedAt: Date.now() }); + + if (this.cache.size > MAX_CACHE_ENTRIES) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey) this.cache.delete(oldestKey); + } + } + return chapters; + }) + .finally(() => { + if (this.inFlight.get(book.id) === request) { + this.inFlight.delete(book.id); + } + }); + + this.inFlight.set(book.id, request); + return request; } } diff --git a/packages/core/src/ai/fallback-source-resolver.ts b/packages/core/src/ai/fallback-source-resolver.ts index 9ebbab7cd..40b917c9a 100644 --- a/packages/core/src/ai/fallback-source-resolver.ts +++ b/packages/core/src/ai/fallback-source-resolver.ts @@ -10,6 +10,11 @@ export interface FallbackChaptersResult { chapters: FallbackChapter[]; } +export interface FallbackSourceUnavailableResult { + error: string; + sourceUnavailable: true; +} + export interface ResolvedFallbackSource { chapterTitle: string; chapterIndex: number; @@ -19,18 +24,21 @@ export interface ResolvedFallbackSource { export async function getFallbackChaptersForBook( bookId: string, -): Promise { +): Promise { const book = await getBook(bookId); - if (!book) return { error: "Book not found" }; + if (!book) return { error: "Book not found", sourceUnavailable: true }; try { const chapters = await fallbackContentService.getChapters(book); - if (chapters.length === 0) return { error: "No readable content found for this book" }; + if (chapters.length === 0) { + return { error: "No readable content found for this book", sourceUnavailable: true }; + } return { bookTitle: book.meta.title, chapters }; } catch (error) { return { error: error instanceof Error ? error.message : "Unable to read the book without vectorization", + sourceUnavailable: true, }; } }