From 709ee8bbdc9ec27f18abd5c47b3f9968cdc9e658 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Sat, 13 Jun 2026 06:20:50 +0800 Subject: [PATCH] fix(mobile): bound AI content search tool hangs --- .../src/components/rag/ExtractorWebView.tsx | 51 ++++++++++++------ .../app-expo/src/screens/LibraryScreen.tsx | 36 +++++++++++-- .../fallback-content-service.test.ts | 32 +++++++++++ .../ai/__tests__/reading-agent-tools.test.ts | 53 +++++++++++++++++++ packages/core/src/ai/agents/reading-agent.ts | 30 +++++++++-- .../core/src/ai/fallback-content-service.ts | 16 +++++- 6 files changed, 194 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/ai/__tests__/fallback-content-service.test.ts diff --git a/packages/app-expo/src/components/rag/ExtractorWebView.tsx b/packages/app-expo/src/components/rag/ExtractorWebView.tsx index 044be0a6..af607c08 100644 --- a/packages/app-expo/src/components/rag/ExtractorWebView.tsx +++ b/packages/app-expo/src/components/rag/ExtractorWebView.tsx @@ -5,19 +5,35 @@ import { StyleSheet, View } from "react-native"; import { WebView } from "react-native-webview"; const READER_HTML_ASSET = Asset.fromModule(require("../../../assets/reader/reader.html")); +const EXTRACTION_TIMEOUT_MS = 45_000; export interface ExtractorRef { extractChapters: (base64BookData: string, mimeType?: string) => Promise; } +interface PendingExtraction { + resolve: (chapters: ChapterData[]) => void; + reject: (err: Error) => void; + timeoutId: ReturnType; +} + export const ExtractorWebView = forwardRef((_, ref) => { const webViewRef = useRef(null); const [htmlUri, setHtmlUri] = useState(null); const [ready, setReady] = useState(false); // Pending extraction requests - const pendingRequests = useRef<((chapters: ChapterData[]) => void)[]>([]); - const pendingErrors = useRef<((err: Error) => void)[]>([]); + const pendingRequests = useRef([]); + + useEffect(() => { + return () => { + for (const pending of pendingRequests.current) { + clearTimeout(pending.timeoutId); + pending.reject(new Error("Extractor WebView unmounted")); + } + pendingRequests.current = []; + }; + }, []); useEffect(() => { const loadAsset = async () => { @@ -50,21 +66,21 @@ export const ExtractorWebView = forwardRef((_, ref) => { true; `); } else if (msg.type === "chaptersExtracted") { - const resolve = pendingRequests.current.shift(); - const reject = pendingErrors.current.shift(); + const pending = pendingRequests.current.shift(); + if (!pending) return; - if (msg.error && reject) { - reject(new Error(msg.error)); - } else if (msg.chapters && resolve) { - resolve(msg.chapters); + clearTimeout(pending.timeoutId); + if (msg.error) { + pending.reject(new Error(msg.error)); + } else if (msg.chapters) { + pending.resolve(msg.chapters); } } else if (msg.type === "error") { console.error("[ExtractorWebView] WebView error:", msg.message); - // Only reject if we were waiting for it - if (pendingErrors.current.length > 0) { - const reject = pendingErrors.current.shift(); - pendingRequests.current.shift(); // remove corresponding resolve - reject?.(new Error(msg.message)); + const pending = pendingRequests.current.shift(); + if (pending) { + clearTimeout(pending.timeoutId); + pending.reject(new Error(msg.message)); } } } catch (err) { @@ -79,8 +95,13 @@ export const ExtractorWebView = forwardRef((_, ref) => { return reject(new Error("Extractor WebView not ready")); } - pendingRequests.current.push(resolve); - pendingErrors.current.push(reject); + const timeoutId = setTimeout(() => { + const index = pendingRequests.current.findIndex((pending) => pending.reject === reject); + if (index >= 0) pendingRequests.current.splice(index, 1); + reject(new Error("Timed out extracting book content")); + }, EXTRACTION_TIMEOUT_MS); + + pendingRequests.current.push({ resolve, reject, timeoutId }); // Command the webview to open the book first. // It will reply with "loaded" when it finishes rendering. diff --git a/packages/app-expo/src/screens/LibraryScreen.tsx b/packages/app-expo/src/screens/LibraryScreen.tsx index 63ee686f..bc9b5804 100644 --- a/packages/app-expo/src/screens/LibraryScreen.tsx +++ b/packages/app-expo/src/screens/LibraryScreen.tsx @@ -49,6 +49,7 @@ 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, @@ -78,6 +79,19 @@ import { TagManagementSheet } from "./library/TagManagementSheet"; import { useBookDownload } from "./library/useBookDownload"; import { useVectorizationQueue } from "./library/useVectorizationQueue"; +const MOBILE_FALLBACK_EXTRACTOR_MAX_BYTES = 12 * 1024 * 1024; + +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"); @@ -258,11 +272,23 @@ export function LibraryScreen() { 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"); + if (file.size > MOBILE_FALLBACK_EXTRACTOR_MAX_BYTES) { + throw new Error( + "Mobile original-file search is disabled for books larger than 12 MB. Please vectorize the book first.", + ); + } + const bytes = await platform.readFile(filePath); - const chunkSize = 0x8000; - let binary = ""; - for (let i = 0; i < bytes.length; i += chunkSize) { - binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + if (bytes.byteLength > MOBILE_FALLBACK_EXTRACTOR_MAX_BYTES) { + throw new Error( + "Mobile original-file search is disabled for books larger than 12 MB. Please vectorize the book first.", + ); } const mimeTypes: Record = { epub: "application/epub+zip", @@ -277,7 +303,7 @@ export function LibraryScreen() { txt: "text/plain", }; return extractorRef.current.extractChapters( - btoa(binary), + bytesToBase64(bytes), mimeTypes[String(book.format || "").toLowerCase()] || "application/epub+zip", ); }, diff --git a/packages/core/src/ai/__tests__/fallback-content-service.test.ts b/packages/core/src/ai/__tests__/fallback-content-service.test.ts new file mode 100644 index 00000000..c548c974 --- /dev/null +++ b/packages/core/src/ai/__tests__/fallback-content-service.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Book } from "../../types"; +import { fallbackContentService, setFallbackContentProvider } from "../fallback-content-service"; + +const book = { + id: "book-1", + filePath: "books/book-1.epub", + format: "epub", + meta: { title: "Book 1" }, +} as Book; + +afterEach(() => { + vi.useRealTimers(); + setFallbackContentProvider(null); + fallbackContentService.clear(); +}); + +describe("fallbackContentService", () => { + it("rejects stalled providers instead of leaving tool calls pending forever", async () => { + vi.useFakeTimers(); + setFallbackContentProvider({ + getChapters: () => new Promise(() => {}), + }); + + const pending = expect(fallbackContentService.getChapters(book)).rejects.toThrow( + "Timed out reading original book content", + ); + await vi.advanceTimersByTimeAsync(45_000); + + await pending; + }); +}); 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 3daa5eaf..a28af056 100644 --- a/packages/core/src/ai/__tests__/reading-agent-tools.test.ts +++ b/packages/core/src/ai/__tests__/reading-agent-tools.test.ts @@ -39,6 +39,7 @@ function makeAIConfig(): AIConfig { beforeEach(() => { createReactAgentMock.mockReset(); + vi.useRealTimers(); }); describe("streamReadingAgent tool registration", () => { @@ -77,6 +78,58 @@ describe("streamReadingAgent tool registration", () => { expect(toolNames).toContain("addCitation"); }); + it("returns a structured error when a tool execution times out", async () => { + createReactAgentMock.mockReturnValue({ + streamEvents: vi.fn(() => ({ + [Symbol.asyncIterator]: async function* () { + // no-op stream + }, + })), + }); + + const tools: ToolDefinition[] = [ + { + name: "slowTool", + description: "A tool that never resolves", + parameters: {}, + execute: () => new Promise(() => {}), + }, + ]; + + for await (const _event of streamReadingAgent( + { + aiConfig: makeAIConfig(), + book: null, + bookId: "book-1", + semanticContext: null, + enabledSkills: [], + isVectorized: false, + getAvailableTools: () => tools, + toolTimeoutMs: 1_000, + }, + "search", + )) { + // drain stream + } + + const call = createReactAgentMock.mock.calls[createReactAgentMock.mock.calls.length - 1]?.[0]; + const registeredTool = ( + call.tools as Array<{ name: string; func: (input: unknown) => Promise }> + ).find((tool) => tool.name === "slowTool"); + + expect(registeredTool).toBeDefined(); + if (!registeredTool) throw new Error("Expected slowTool to be registered"); + + vi.useFakeTimers(); + const result = registeredTool.func({}); + const pending = expect(result).resolves.toBe( + JSON.stringify({ error: 'Tool "slowTool" timed out after 1s' }), + ); + await vi.advanceTimersByTimeAsync(1_000); + + await pending; + }); + 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 f928cc51..09b38b35 100644 --- a/packages/core/src/ai/agents/reading-agent.ts +++ b/packages/core/src/ai/agents/reading-agent.ts @@ -19,6 +19,8 @@ import { buildSystemPrompt } from "../system-prompt"; import { ThinkTagStreamParser } from "../think-tag-parser"; import type { ToolDefinition, ToolParameter } from "../tools/tool-types"; +const DEFAULT_TOOL_TIMEOUT_MS = 45_000; + // --- Stream Event Types --- export type AgentStreamEvent = @@ -62,6 +64,8 @@ export interface ReadingAgentOptions { }) => ToolDefinition[]; /** Abort signal for immediate cancellation */ signal?: AbortSignal; + /** Maximum time a single tool may run before returning an error result. */ + toolTimeoutMs?: number; } // --- Build Zod schema from ToolDefinition.parameters --- @@ -98,9 +102,26 @@ function buildZodSchema( // --- Tool Executor (error-safe wrapper) --- -async function executeTool(tool: ToolDefinition, args: Record): Promise { +function withToolTimeout(promise: Promise, timeoutMs: number, toolName: string): Promise { + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`Tool "${toolName}" timed out after ${Math.round(timeoutMs / 1000)}s`)); + }, timeoutMs); + }); + + return Promise.race([promise, timeout]).finally(() => { + if (timeoutId) clearTimeout(timeoutId); + }); +} + +async function executeTool( + tool: ToolDefinition, + args: Record, + timeoutMs: number, +): Promise { try { - return await tool.execute(args); + return await withToolTimeout(Promise.resolve(tool.execute(args)), timeoutMs, tool.name); } catch (error) { return { error: error instanceof Error ? error.message : String(error), @@ -127,6 +148,7 @@ export async function* streamReadingAgent( memorySummary, getAvailableTools, signal, + toolTimeoutMs = DEFAULT_TOOL_TIMEOUT_MS, } = options; // Helper to check if aborted @@ -255,7 +277,9 @@ export async function* streamReadingAgent( description: tool.description, schema, func: async (input) => { - return JSON.stringify(await executeTool(tool, input as Record)); + return JSON.stringify( + await executeTool(tool, input as Record, toolTimeoutMs), + ); }, }); }); diff --git a/packages/core/src/ai/fallback-content-service.ts b/packages/core/src/ai/fallback-content-service.ts index a3401f7c..2d74c705 100644 --- a/packages/core/src/ai/fallback-content-service.ts +++ b/packages/core/src/ai/fallback-content-service.ts @@ -18,12 +18,26 @@ export interface FallbackContentProvider { const CACHE_TTL_MS = 5 * 60 * 1000; const MAX_CACHE_ENTRIES = 8; +const PROVIDER_TIMEOUT_MS = 45_000; interface CachedChapters { chapters: FallbackChapter[]; cachedAt: number; } +function withTimeout(promise: Promise, timeoutMs: number): Promise { + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error("Timed out reading original book content")); + }, timeoutMs); + }); + + return Promise.race([promise, timeout]).finally(() => { + if (timeoutId) clearTimeout(timeoutId); + }); +} + class FallbackContentService { private provider: FallbackContentProvider | null = null; private cache = new Map(); @@ -51,7 +65,7 @@ class FallbackContentService { return cached.chapters; } - const chapters = await this.provider.getChapters(book); + 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) {