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,
};
}
}