Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 36 additions & 15 deletions packages/app-expo/src/components/rag/ExtractorWebView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChapterData[]>;
}

interface PendingExtraction {
resolve: (chapters: ChapterData[]) => void;
reject: (err: Error) => void;
timeoutId: ReturnType<typeof setTimeout>;
}

export const ExtractorWebView = forwardRef<ExtractorRef>((_, ref) => {
const webViewRef = useRef<WebView>(null);
const [htmlUri, setHtmlUri] = useState<string | null>(null);
const [ready, setReady] = useState(false);

// Pending extraction requests
const pendingRequests = useRef<((chapters: ChapterData[]) => void)[]>([]);
const pendingErrors = useRef<((err: Error) => void)[]>([]);
const pendingRequests = useRef<PendingExtraction[]>([]);

useEffect(() => {
return () => {
for (const pending of pendingRequests.current) {
clearTimeout(pending.timeoutId);
pending.reject(new Error("Extractor WebView unmounted"));
}
pendingRequests.current = [];
};
}, []);

useEffect(() => {
const loadAsset = async () => {
Expand Down Expand Up @@ -50,21 +66,21 @@ export const ExtractorWebView = forwardRef<ExtractorRef>((_, 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) {
Expand All @@ -79,8 +95,13 @@ export const ExtractorWebView = forwardRef<ExtractorRef>((_, 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.
Expand Down
36 changes: 31 additions & 5 deletions packages/app-expo/src/screens/LibraryScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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<string, string> = {
epub: "application/epub+zip",
Expand All @@ -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",
);
},
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/ai/__tests__/fallback-content-service.test.ts
Original file line number Diff line number Diff line change
@@ -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;
});
});
53 changes: 53 additions & 0 deletions packages/core/src/ai/__tests__/reading-agent-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function makeAIConfig(): AIConfig {

beforeEach(() => {
createReactAgentMock.mockReset();
vi.useRealTimers();
});

describe("streamReadingAgent tool registration", () => {
Expand Down Expand Up @@ -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<string> }>
).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(() => ({
Expand Down
30 changes: 27 additions & 3 deletions packages/core/src/ai/agents/reading-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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 ---
Expand Down Expand Up @@ -98,9 +102,26 @@ function buildZodSchema(

// --- Tool Executor (error-safe wrapper) ---

async function executeTool(tool: ToolDefinition, args: Record<string, unknown>): Promise<unknown> {
function withToolTimeout<T>(promise: Promise<T>, timeoutMs: number, toolName: string): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, 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<string, unknown>,
timeoutMs: number,
): Promise<unknown> {
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),
Expand All @@ -127,6 +148,7 @@ export async function* streamReadingAgent(
memorySummary,
getAvailableTools,
signal,
toolTimeoutMs = DEFAULT_TOOL_TIMEOUT_MS,
} = options;

// Helper to check if aborted
Expand Down Expand Up @@ -255,7 +277,9 @@ export async function* streamReadingAgent(
description: tool.description,
schema,
func: async (input) => {
return JSON.stringify(await executeTool(tool, input as Record<string, unknown>));
return JSON.stringify(
await executeTool(tool, input as Record<string, unknown>, toolTimeoutMs),
);
},
});
});
Expand Down
16 changes: 15 additions & 1 deletion packages/core/src/ai/fallback-content-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, 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<string, CachedChapters>();
Expand Down Expand Up @@ -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) {
Expand Down