diff --git a/packages/app-expo/src/screens/ReaderScreen.tsx b/packages/app-expo/src/screens/ReaderScreen.tsx index a87fe5edc..d7f6fa451 100644 --- a/packages/app-expo/src/screens/ReaderScreen.tsx +++ b/packages/app-expo/src/screens/ReaderScreen.tsx @@ -40,6 +40,7 @@ import { runWithDbRetry } from "@readany/core/db/write-retry"; import { useChapterTranslation } from "@readany/core/hooks"; import { useReadingSession } from "@readany/core/hooks/use-reading-session"; import { createSelectionNoteMutation } from "@readany/core/reader"; +import { resolveCurrentChapterFromToc } from "@readany/core/reader/toc"; import { getPlatformService } from "@readany/core/services"; import { getCSSFontFace, useFontStore } from "@readany/core/stores"; import type { HighlightColor, ReadSettings, TOCItem } from "@readany/core/types"; @@ -149,11 +150,7 @@ const NOTE_TOOLTIP_TOP_THRESHOLD = 180; import { useRubyStore } from "@readany/core/stores/ruby-store"; import { ReaderSettingsPanel } from "./reader/ReaderSettingsPanel"; import { ReaderTOCPanel } from "./reader/ReaderTOCPanel"; -import { - CONTROLS_TIMEOUT, - SCREEN_HEIGHT, - SCREEN_WIDTH, -} from "./reader/reader-constants"; +import { CONTROLS_TIMEOUT, SCREEN_HEIGHT, SCREEN_WIDTH } from "./reader/reader-constants"; import { BatteryIcon, ListIcon, SettingsIcon } from "./reader/reader-icons"; import { makeStyles, noteTooltipMdStyles } from "./reader/reader-styles"; import { useReaderBookmark } from "./reader/useReaderBookmark"; @@ -358,6 +355,7 @@ export function ReaderScreen({ route, navigation }: Props) { } | null>(null); const totalBookCharactersRef = useRef(null); const progressTrackingGuardUntilRef = useRef(0); + const lastRelocateRef = useRef(null); const incrementPagesRead = useReadingSessionStore((s) => s.incrementPagesRead); const incrementCharactersRead = useReadingSessionStore((s) => s.incrementCharactersRead); @@ -384,6 +382,25 @@ export function ReaderScreen({ route, navigation }: Props) { } = useAnnotationStore(); const book = useMemo(() => books.find((b) => b.id === bookId), [books, bookId]); + const syncAIReadingContext = useCallback( + (detail: RelocateEvent, tocItems: TOCItem[]) => { + readingContextService.updateContext({ + bookId, + bookTitle: book?.meta?.title || "", + currentChapter: resolveCurrentChapterFromToc( + tocItems, + detail.tocItem ?? {}, + detail.section?.current ?? 0, + ), + currentPosition: { + cfi: detail.cfi || "", + percentage: (detail.fraction ?? 0) * 100, + }, + }); + }, + [book?.meta?.title, bookId], + ); + // ── System info (clock/battery/statusBar/SafeArea) ───────────────────────── const { readerClock, batteryLevel, isBatteryCharging, stableTopInset, insets } = useReaderSystemInfo({ showSearch, isIPadLayout, shouldToggleSystemStatusBar, baseTopInset }); @@ -438,6 +455,7 @@ export function ReaderScreen({ route, navigation }: Props) { progressRef.current = progress; }, [progress]); + // biome-ignore lint/correctness/useExhaustiveDependencies: Reset only when the active book changes. useEffect(() => { sessionProgressRef.current = null; totalBookCharactersRef.current = null; @@ -626,6 +644,7 @@ export function ReaderScreen({ route, navigation }: Props) { totalBookCharactersRef.current = totalCharacters > 0 ? totalCharacters : null; }, onRelocate: (detail: RelocateEvent) => { + lastRelocateRef.current = detail; console.log("[ReaderScreen] onRelocate", { section: detail.section, fraction: detail.fraction, @@ -769,22 +788,13 @@ export function ReaderScreen({ route, navigation }: Props) { } // Sync reading context for AI tools - readingContextService.updateContext({ - bookId, - bookTitle: book?.meta?.title || "", - currentChapter: { - index: detail.section?.current ?? 0, - title: detail.tocItem?.label || "", - href: detail.tocItem?.href || "", - }, - currentPosition: { - cfi: detail.cfi || "", - percentage: (detail.fraction ?? 0) * 100, - }, - }); + syncAIReadingContext(detail, toc); }, onTocReady: (items: TOCItem[]) => { setToc(items); + if (lastRelocateRef.current) { + syncAIReadingContext(lastRelocateRef.current, items); + } }, onSelection: (detail: SelectionEvent) => { setSelection(detail); @@ -908,10 +918,25 @@ export function ReaderScreen({ route, navigation }: Props) { appActive, // 维护约定:任何新增遮盖正文/输入态/导航跳转,必须在此追加判定。 [ - readSettings.volumeButtonsPageTurn, webViewReady, loading, error, isReimporting, - showSearch, showTOC, showSettings, showNotebook, showTTS, - showTranslation, showChapterTranslation, chapterTranslation.state.status, - selection, noteViewHighlight, noteTooltip, ttsPlayState, isFocused, appActive, + readSettings.volumeButtonsPageTurn, + webViewReady, + loading, + error, + isReimporting, + showSearch, + showTOC, + showSettings, + showNotebook, + showTTS, + showTranslation, + showChapterTranslation, + chapterTranslation.state.status, + selection, + noteViewHighlight, + noteTooltip, + ttsPlayState, + isFocused, + appActive, ], ); @@ -1057,6 +1082,7 @@ export function ReaderScreen({ route, navigation }: Props) { // ── Book loading effects ─────────────────────────────────────────────────── // Load book metadata and annotations + // biome-ignore lint/correctness/useExhaustiveDependencies: This lifecycle is intentionally keyed to bookId; book progress changes must not reload metadata. useEffect(() => { if (!book) { setError(t("reader.bookNotFound", "书籍未找到")); @@ -1102,6 +1128,7 @@ export function ReaderScreen({ route, navigation }: Props) { }, [bookId]); // When WebView is ready and book is available, send the open command + // biome-ignore lint/correctness/useExhaustiveDependencies: Opening is intentionally keyed to file/readiness/retry; later effects apply live settings and theme changes. useEffect(() => { if (!webViewReady || !book?.filePath) { return; @@ -1222,7 +1249,7 @@ export function ReaderScreen({ route, navigation }: Props) { } finally { setIsReimporting(false); } - }, [bookId, isReimporting, t]); + }, [book, bookId, isReimporting, t]); // Apply theme colors when theme changes useEffect(() => { @@ -1234,7 +1261,15 @@ export function ReaderScreen({ route, navigation }: Props) { primary: colors.primary, themeMode, }); - }, [themeMode, webViewReady]); + }, [ + bridge.setThemeColors, + colors.background, + colors.foreground, + colors.mutedForeground, + colors.primary, + themeMode, + webViewReady, + ]); // Re-apply font settings when custom fonts or selected font changes useEffect(() => { @@ -1243,7 +1278,7 @@ export function ReaderScreen({ route, navigation }: Props) { customFontFaceCSS: customFontFaceCSS, customFontFamily: customFontFamily, }); - }, [customFontFaceCSS, customFontFamily, webViewReady]); + }, [bridge.applySettings, customFontFaceCSS, customFontFamily, webViewReady]); // Re-apply effective fontSize when the OS-level font scale changes while // the reader is open (e.g. user changes "Display & Brightness → Text Size" @@ -1255,6 +1290,7 @@ export function ReaderScreen({ route, navigation }: Props) { // layoutScale-based scaling (in reader.template.html) re-runs against the // new effective font size — otherwise the renderer would keep margins // computed from the previous size. + // biome-ignore lint/correctness/useExhaustiveDependencies: systemFontScale is an intentional external trigger even though the computed settings read the stored scale. useEffect(() => { if (!webViewReady) return; if (!readSettings.followSystemFontScale) return; @@ -1280,9 +1316,10 @@ export function ReaderScreen({ route, navigation }: Props) { for (const h of highlights) { bridge.addAnnotation({ value: h.cfi, type: "highlight", color: h.color, note: h.note }); } - }, [webViewReady, loading, highlights]); + }, [bridge.addAnnotation, webViewReady, loading, highlights]); // Reset last navigated CFI when book changes + // biome-ignore lint/correctness/useExhaustiveDependencies: bookId is an intentional reset trigger for this ref. useEffect(() => { lastNavigatedCfiRef.current = undefined; }, [bookId]); @@ -1304,7 +1341,16 @@ export function ReaderScreen({ route, navigation }: Props) { }; setTimeout(doFlash, 100); } - }, [webViewReady, loading, cfi, shouldHighlight, goToCFISafely, navigation, bookId]); + }, [ + bridge.flashHighlight, + webViewReady, + loading, + cfi, + shouldHighlight, + goToCFISafely, + navigation, + bookId, + ]); // Open TTS lyrics page when navigating from notification useEffect(() => { @@ -1328,7 +1374,17 @@ export function ReaderScreen({ route, navigation }: Props) { return () => { cancelled = true; }; - }, [bookId, currentCfi, goToCFISafely, loading, navigation, openTTS, webViewReady]); + }, [ + bookId, + currentCfi, + goToCFISafely, + loading, + navigation, + openTTS, + tts.resolvedTTSSegmentCfi, + tts.ttsDisplaySegments, + webViewReady, + ]); if (loading && !webViewReady && !readerHtmlUri) { return ( @@ -1944,7 +2000,8 @@ export function ReaderScreen({ route, navigation }: Props) { { - if (search.searchStartCfi && search.searchResultCount > 0) { + const searchStartCfi = search.searchStartCfi; + if (searchStartCfi && search.searchResultCount > 0) { Alert.alert( t("reader.searchComplete", "搜索完成"), t("reader.returnToOriginal", "是否返回搜索前的位置?"), @@ -1959,7 +2016,7 @@ export function ReaderScreen({ route, navigation }: Props) { { text: t("common.confirm", "确定"), onPress: () => { - goToCFISafely(search.searchStartCfi!); + goToCFISafely(searchStartCfi); search.setSearchStartCfi(null); }, }, 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..e4cc6a151 100644 --- a/packages/core/src/ai/__tests__/reading-agent-tools.test.ts +++ b/packages/core/src/ai/__tests__/reading-agent-tools.test.ts @@ -88,7 +88,9 @@ describe("streamReadingAgent tool registration", () => { events.push(event); } - expect(events).toEqual([{ type: "token", content: "内容过长,请分段提问。" }]); + expect(events).toEqual([ + { type: "token", content: "That message is too long. Please send it in smaller parts." }, + ]); expect(createReactAgentMock).not.toHaveBeenCalled(); }); @@ -572,10 +574,46 @@ describe("streamReadingAgent tool registration", () => { const fourth = JSON.parse(await wrappedResolveTool.func({ query: "张三疯那一章讲了什么" })); expect(fourth.attemptLimitReached).toBe(true); - expect(fourth.notice).toBe("未能可靠定位章节,请补充更准确的章节名"); + expect(fourth.notice).toBe( + "I couldn't reliably identify that chapter. Please give me a more specific chapter title.", + ); expect(fourth.attemptedQueries).toEqual(["张三疯那一章讲了什么", "张三疯", "张三疯"]); }); + it("returns the retrieval-limit guardrail in the active UI language", async () => { + createReactAgentMock.mockReturnValue({ + streamEvents: vi.fn(() => ({ + [Symbol.asyncIterator]() { + return { + next: vi.fn().mockRejectedValue(new Error("Recursion limit of 24 reached")), + }; + }, + })), + }); + + const events = []; + for await (const event of streamReadingAgent( + { + aiConfig: makeAIConfig(), + book: null, + bookId: "book-1", + semanticContext: null, + enabledSkills: [], + isVectorized: true, + getAvailableTools, + }, + "Analyze the themes across this book", + )) { + events.push(event); + } + + expect(events).toContainEqual({ + type: "token", + content: + "I couldn't complete that retrieval reliably. Please try a more specific question or retry.", + }); + }); + it("keeps RAG fallback available for current-page questions on indexed books", async () => { let capturedTools: any[] = []; createReactAgentMock.mockImplementation((config) => { diff --git a/packages/core/src/ai/__tests__/system-prompt.test.ts b/packages/core/src/ai/__tests__/system-prompt.test.ts index 41f26b3fd..e837381cc 100644 --- a/packages/core/src/ai/__tests__/system-prompt.test.ts +++ b/packages/core/src/ai/__tests__/system-prompt.test.ts @@ -133,4 +133,19 @@ describe("buildSystemPrompt citations", () => { expect(prompt).not.toContain("addCitation"); expect(prompt).not.toContain("mindmap"); }); + + it("does not inject Chinese failure copy into English chapter requests", () => { + const prompt = buildSystemPrompt({ + book: makeBook(), + semanticContext: null, + enabledSkills: [], + isVectorized: true, + userLanguage: "en", + questionCategory: "specific_chapter_request", + allowedToolNames: ["resolveChapterReference", "ragToc", "ragContext"], + }); + + expect(prompt).not.toContain("未能可靠定位章节"); + expect(prompt).toContain("in the user's language"); + }); }); diff --git a/packages/core/src/ai/__tests__/tools.test.ts b/packages/core/src/ai/__tests__/tools.test.ts index c879a3196..4f2acfa8d 100644 --- a/packages/core/src/ai/__tests__/tools.test.ts +++ b/packages/core/src/ai/__tests__/tools.test.ts @@ -357,6 +357,27 @@ describe("ragToc tool", () => { ]); }); + it("reuses cached original-book chapters across repeated generic TOC reads", async () => { + vi.mocked(getChunks).mockResolvedValue([ + makeChunk({ chapterIndex: 1, chapterTitle: "Section 2" }), + makeChunk({ chapterIndex: 2, chapterTitle: "Section 3" }), + ] as any); + vi.mocked(getBook).mockResolvedValue(makeBook({ isVectorized: true }) as any); + const getChapters = vi.fn(async () => [ + { index: 0, title: "Chapter 1", content: "chapter one" }, + { index: 1, title: "Chapter 2", content: "chapter two" }, + ]); + setFallbackContentProvider({ getChapters }); + + const tools = getAvailableTools({ bookId: "book-1", isVectorized: true, enabledSkills: [] }); + const tool = findTool(tools, "ragToc"); + + await tool.execute({}); + await tool.execute({}); + + expect(getChapters).toHaveBeenCalledTimes(1); + }); + it("returns visible diagnostics when generic section fallback fails", async () => { vi.mocked(getChunks).mockResolvedValue([ makeChunk({ chapterIndex: 1, chapterTitle: "Section 2" }), diff --git a/packages/core/src/ai/agents/reading-agent.ts b/packages/core/src/ai/agents/reading-agent.ts index 37bf5f654..7013ba222 100644 --- a/packages/core/src/ai/agents/reading-agent.ts +++ b/packages/core/src/ai/agents/reading-agent.ts @@ -1,8 +1,8 @@ import { AIMessage, HumanMessage } from "@langchain/core/messages"; import type { BaseMessage } from "@langchain/core/messages"; import { createReactAgent } from "@langchain/langgraph/prebuilt"; -import i18n from "i18next"; import { z } from "zod"; +import i18n from "../../i18n"; import { estimateTokens } from "../../rag/chunker"; /** * Reading Agent — AI-powered reading assistant using LangGraph ReAct agent @@ -57,7 +57,19 @@ const TOOL_TIMEOUT_MS_BY_NAME: Record = { mindmap: 10_000, }; const MAX_USER_INPUT_TOKENS = 8_000; -const USER_INPUT_TOO_LONG_MESSAGE = "内容过长,请分段提问。"; +const AGENT_ERROR_DEFAULTS = { + inputTooLong: "That message is too long. Please send it in smaller parts.", + chapterLookupFailed: + "I couldn't reliably identify that chapter. Please give me a more specific chapter title.", + retrievalLimitReached: + "I couldn't complete that retrieval reliably. Please try a more specific question or retry.", +} as const; + +type AgentErrorKey = keyof typeof AGENT_ERROR_DEFAULTS; + +function getAgentErrorMessage(key: AgentErrorKey): string { + return i18n.t(`chat.errors.${key}`, { defaultValue: AGENT_ERROR_DEFAULTS[key] }); +} const OUTPUT_LIMIT_FINISH_REASONS = new Set([ "length", @@ -517,7 +529,7 @@ function buildChapterReferenceLimitResult( detectedChapterNumber: undefined, attemptLimitReached: true, attemptedQueries, - notice: "未能可靠定位章节,请补充更准确的章节名", + notice: getAgentErrorMessage("chapterLookupFailed"), reason: "Chapter lookup attempt limit reached. Stop chapter search in this turn and ask the user for a more accurate chapter title.", }; @@ -853,7 +865,7 @@ export async function* streamReadingAgent( // Reject oversized input before creating a model or making an API request. if (estimateTokens(userInput.normalize("NFKC").trim()) > MAX_USER_INPUT_TOKENS) { - yield { type: "token", content: USER_INPUT_TOO_LONG_MESSAGE }; + yield { type: "token", content: getAgentErrorMessage("inputTooLong") }; return; } @@ -1410,13 +1422,14 @@ export async function* streamReadingAgent( } yield { type: "token", - content: "未能可靠定位章节,请补充更准确的章节名", + content: String(limitResult.notice), }; return; } if (isRecursionError) { + const retrievalLimitMessage = getAgentErrorMessage("retrievalLimitReached"); const noticeResult = { - notice: "本轮检索步骤过多,没有稳定完成。请换个更具体的问法,或直接重试一次。", + notice: retrievalLimitMessage, reason: errorMessage, }; const uniquePendingNames = Array.from(new Set(pendingToolCallNames)); @@ -1425,7 +1438,7 @@ export async function* streamReadingAgent( } yield { type: "token", - content: "本轮检索步骤过多,没有稳定完成。请换个更具体的问法,或直接重试一次。", + content: retrievalLimitMessage, }; return; } diff --git a/packages/core/src/ai/system-prompt.ts b/packages/core/src/ai/system-prompt.ts index 91aaf916a..c2a4eb450 100644 --- a/packages/core/src/ai/system-prompt.ts +++ b/packages/core/src/ai/system-prompt.ts @@ -558,7 +558,7 @@ function buildWorkflowSection( "- For a specific chapter request, call resolveChapterReference first. If matched=false, present the candidates or ask for clarification instead of guessing chapterIndex.", ); steps.push( - "- For chapter lookup failures, chapter search gets at most three chances in one turn. The first uses the user's original wording, the second may use one simplified query, and the third is the last chance. After that, STOP and tell the user: 未能可靠定位章节,请补充更准确的章节名", + "- For chapter lookup failures, chapter search gets at most three chances in one turn. The first uses the user's original wording, the second may use one simplified query, and the third is the last chance. After that, STOP and explain in the user's language that the chapter could not be identified reliably and ask for a more specific chapter title.", ); } steps.push( diff --git a/packages/core/src/ai/tools/rag-tools.ts b/packages/core/src/ai/tools/rag-tools.ts index b6245f1bc..1bee64b1f 100644 --- a/packages/core/src/ai/tools/rag-tools.ts +++ b/packages/core/src/ai/tools/rag-tools.ts @@ -6,7 +6,6 @@ import { estimateTokens } from "../../rag/chunker"; import { search } from "../../rag/search"; import type { SearchQuery } from "../../types"; import { resolveChapterReference } from "../chapter-reference-resolver"; -import { fallbackContentService } from "../fallback-content-service"; import { getFallbackChaptersForBook } from "../fallback-source-resolver"; import type { ToolDefinition } from "./tool-types"; @@ -236,7 +235,6 @@ export function createRagTocTool(bookId: string): ToolDefinition { .map(([index, title]) => ({ index, title })); if (shouldPreferOriginalToc(chapters)) { - fallbackContentService.clear(bookId); const fallback = await getFallbackChaptersForBook(bookId); if (!("error" in fallback) && fallback.chapters.length > 0) { console.log("[ragToc] Rebuilt generic section TOC from original book", { diff --git a/packages/core/src/i18n/locales/en/chat.json b/packages/core/src/i18n/locales/en/chat.json index fc1a91d71..a3ddc289a 100644 --- a/packages/core/src/i18n/locales/en/chat.json +++ b/packages/core/src/i18n/locales/en/chat.json @@ -28,6 +28,11 @@ "explainConcepts": "Explain the key concepts", "analyzeAuthor": "Analyze the author's argument" }, + "errors": { + "inputTooLong": "That message is too long. Please send it in smaller parts.", + "chapterLookupFailed": "I couldn't reliably identify that chapter. Please give me a more specific chapter title.", + "retrievalLimitReached": "I couldn't complete that retrieval reliably. Please try a more specific question or retry." + }, "aiAssistant": "AI Reading Assistant", "aiAssistantDesc": "Analyze content, answer questions, and help you understand your books better.", "deepThinking": "Deep Thinking", diff --git a/packages/core/src/i18n/locales/es/chat.json b/packages/core/src/i18n/locales/es/chat.json index fcfb5587a..7bd9dd2eb 100644 --- a/packages/core/src/i18n/locales/es/chat.json +++ b/packages/core/src/i18n/locales/es/chat.json @@ -27,6 +27,11 @@ "explainConcepts": "Explicar los conceptos clave", "analyzeAuthor": "Analizar el argumento del autor" }, + "errors": { + "inputTooLong": "Este mensaje es demasiado largo. Envíalo en varias partes.", + "chapterLookupFailed": "No pude identificar ese capítulo con fiabilidad. Indica un título de capítulo más específico.", + "retrievalLimitReached": "No pude completar esta búsqueda de forma fiable. Haz una pregunta más específica o vuelve a intentarlo." + }, "aiAssistant": "Asistente de lectura IA", "aiAssistantDesc": "Analiza contenido, responde preguntas y te ayuda a entender mejor tus libros.", "deepThinking": "Pensamiento profundo", diff --git a/packages/core/src/i18n/locales/fr/chat.json b/packages/core/src/i18n/locales/fr/chat.json index e8ed5b24e..e60ebf4b0 100644 --- a/packages/core/src/i18n/locales/fr/chat.json +++ b/packages/core/src/i18n/locales/fr/chat.json @@ -27,6 +27,11 @@ "explainConcepts": "Expliquer les concepts clés", "analyzeAuthor": "Analyser l'argumentation de l'auteur" }, + "errors": { + "inputTooLong": "Ce message est trop long. Veuillez l'envoyer en plusieurs parties.", + "chapterLookupFailed": "Je n'ai pas pu identifier ce chapitre avec certitude. Veuillez fournir un titre de chapitre plus précis.", + "retrievalLimitReached": "Je n'ai pas pu terminer cette recherche de manière fiable. Posez une question plus précise ou réessayez." + }, "aiAssistant": "Assistant de lecture IA", "aiAssistantDesc": "Analysez le contenu, répondez aux questions et comprenez mieux vos livres.", "deepThinking": "Réflexion approfondie", diff --git a/packages/core/src/i18n/locales/ja/chat.json b/packages/core/src/i18n/locales/ja/chat.json index 243d0f4c7..7e88aa2e4 100644 --- a/packages/core/src/i18n/locales/ja/chat.json +++ b/packages/core/src/i18n/locales/ja/chat.json @@ -27,6 +27,11 @@ "explainConcepts": "主要な概念を説明", "analyzeAuthor": "著者の主張を分析" }, + "errors": { + "inputTooLong": "メッセージが長すぎます。いくつかに分けて送信してください。", + "chapterLookupFailed": "章を正確に特定できませんでした。より具体的な章タイトルを入力してください。", + "retrievalLimitReached": "検索を正常に完了できませんでした。より具体的な質問にするか、もう一度お試しください。" + }, "aiAssistant": "AI読書アシスタント", "aiAssistantDesc": "コンテンツを分析し、質問に答え、本の理解を深めるお手伝いをします。", "deepThinking": "深い思考", diff --git a/packages/core/src/i18n/locales/ko/chat.json b/packages/core/src/i18n/locales/ko/chat.json index 9087df1ca..b0938ee58 100644 --- a/packages/core/src/i18n/locales/ko/chat.json +++ b/packages/core/src/i18n/locales/ko/chat.json @@ -27,6 +27,11 @@ "explainConcepts": "핵심 개념 설명", "analyzeAuthor": "저자의 논점 분석" }, + "errors": { + "inputTooLong": "메시지가 너무 깁니다. 여러 부분으로 나누어 보내 주세요.", + "chapterLookupFailed": "챕터를 정확히 찾지 못했습니다. 더 구체적인 챕터 제목을 입력해 주세요.", + "retrievalLimitReached": "검색을 안정적으로 완료하지 못했습니다. 더 구체적으로 질문하거나 다시 시도해 주세요." + }, "aiAssistant": "AI 독서 어시스턴트", "aiAssistantDesc": "내용을 분석하고, 질문에 답하며, 책을 더 잘 이해하도록 도와줘요.", "deepThinking": "깊은 사고", diff --git a/packages/core/src/i18n/locales/zh-TW/chat.json b/packages/core/src/i18n/locales/zh-TW/chat.json index d910492d4..64d713f82 100644 --- a/packages/core/src/i18n/locales/zh-TW/chat.json +++ b/packages/core/src/i18n/locales/zh-TW/chat.json @@ -27,6 +27,11 @@ "explainConcepts": "解釋關鍵概念", "analyzeAuthor": "分析作者的論點" }, + "errors": { + "inputTooLong": "內容過長,請分段提問。", + "chapterLookupFailed": "未能可靠定位章節,請補充更準確的章節名稱。", + "retrievalLimitReached": "本輪檢索未能穩定完成,請換個更具體的問法或重試一次。" + }, "aiAssistant": "AI 閱讀助手", "aiAssistantDesc": "分析內容、回答問題,幫助你更好地理解書籍。", "deepThinking": "深度思考", diff --git a/packages/core/src/i18n/locales/zh/chat.json b/packages/core/src/i18n/locales/zh/chat.json index 8d3c3fdac..f658c20f7 100644 --- a/packages/core/src/i18n/locales/zh/chat.json +++ b/packages/core/src/i18n/locales/zh/chat.json @@ -28,6 +28,11 @@ "explainConcepts": "解释关键概念", "analyzeAuthor": "分析作者的论点" }, + "errors": { + "inputTooLong": "内容过长,请分段提问。", + "chapterLookupFailed": "未能可靠定位章节,请补充更准确的章节名。", + "retrievalLimitReached": "本轮检索未能稳定完成,请换个更具体的问法或重试一次。" + }, "aiAssistant": "AI 阅读助手", "aiAssistantDesc": "分析内容、回答问题,帮助你更好地理解书籍。", "deepThinking": "深度思考", diff --git a/packages/core/src/reader/toc.test.ts b/packages/core/src/reader/toc.test.ts index ba9dafb5b..7881d36f4 100644 --- a/packages/core/src/reader/toc.test.ts +++ b/packages/core/src/reader/toc.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getFirstTocHref } from "./toc"; +import { getFirstTocHref, resolveCurrentChapterFromToc } from "./toc"; describe("getFirstTocHref", () => { it("returns the current item href when present", () => { @@ -76,3 +76,70 @@ describe("getFirstTocHref", () => { ).toBeNull(); }); }); + +describe("resolveCurrentChapterFromToc", () => { + it("maps a same-section subsection back to its logical parent chapter", () => { + const result = resolveCurrentChapterFromToc( + [ + { id: "chapter-7", title: "Chapter 7", level: 0, href: "Text/c07.xhtml" }, + { + id: "chapter-8", + title: "Chapter 8: Higher Libido Partners", + level: 0, + href: "Text/c08.xhtml", + subitems: [ + { + id: "opening-up", + title: "Opening Up", + level: 1, + href: "Text/c08.xhtml#opening-up", + }, + ], + }, + ], + { label: "Opening Up", href: "Text/c08.xhtml#opening-up" }, + 12, + ); + + expect(result).toEqual({ + index: 1, + title: "Chapter 8: Higher Libido Partners", + href: "Text/c08.xhtml", + }); + }); + + it("keeps a nested chapter when its parent spans multiple section hrefs", () => { + const result = resolveCurrentChapterFromToc( + [ + { + id: "part-1", + title: "Part I", + level: 0, + href: "Text/part-1.xhtml", + subitems: [ + { id: "chapter-1", title: "Chapter 1", level: 1, href: "Text/c01.xhtml" }, + { id: "chapter-2", title: "Chapter 2", level: 1, href: "Text/c02.xhtml" }, + ], + }, + ], + { label: "Chapter 2", href: "Text/c02.xhtml" }, + 9, + ); + + expect(result).toEqual({ index: 1, title: "Chapter 2", href: "Text/c02.xhtml" }); + }); + + it("falls back to the relocate payload while the TOC is still loading", () => { + const result = resolveCurrentChapterFromToc( + [], + { label: "Opening Up", href: "Text/c08.xhtml#opening-up" }, + 12, + ); + + expect(result).toEqual({ + index: 12, + title: "Opening Up", + href: "Text/c08.xhtml#opening-up", + }); + }); +}); diff --git a/packages/core/src/reader/toc.ts b/packages/core/src/reader/toc.ts index c45baf6cd..10ded0282 100644 --- a/packages/core/src/reader/toc.ts +++ b/packages/core/src/reader/toc.ts @@ -1,5 +1,16 @@ import type { TOCItem } from "../types"; +export interface CurrentChapterReference { + index: number; + title: string; + href: string; +} + +interface CurrentTocItemLike { + label?: string; + href?: string; +} + export function getFirstTocHref(item: TOCItem | null | undefined): string | null { const href = item?.href?.trim(); if (href) return href; @@ -11,3 +22,84 @@ export function getFirstTocHref(item: TOCItem | null | undefined): string | null return null; } + +export function resolveCurrentChapterFromToc( + toc: TOCItem[], + currentItem: CurrentTocItemLike, + currentSectionIndex: number, +): CurrentChapterReference { + const fallback = { + index: currentSectionIndex, + title: currentItem.label?.trim() || "", + href: currentItem.href?.trim() || "", + }; + if (toc.length === 0) return fallback; + + const logicalChapters = collectLogicalChapters(toc); + const currentHrefKey = getHrefKey(currentItem.href); + const currentTitle = currentItem.label?.trim(); + const chapterIndex = logicalChapters.findIndex((item) => { + const itemHrefKey = getHrefKey(item.href); + if (currentHrefKey && itemHrefKey) return itemHrefKey === currentHrefKey; + return Boolean(currentTitle && item.title.trim() === currentTitle); + }); + if (chapterIndex < 0) return fallback; + + const chapter = logicalChapters[chapterIndex]; + return { + index: chapterIndex, + title: chapter.title.trim(), + href: chapter.href?.trim() || fallback.href, + }; +} + +function collectLogicalChapters(toc: TOCItem[]): TOCItem[] { + const chapters: TOCItem[] = []; + const seenHrefKeys = new Set(); + + const visit = (item: TOCItem) => { + const itemHrefKey = getHrefKey(item.href); + const descendantHrefKeys = collectDescendantHrefKeys(item.subitems ?? []); + const isSameSectionChapter = + Boolean(itemHrefKey) && + descendantHrefKeys.length > 0 && + descendantHrefKeys.every((hrefKey) => hrefKey === itemHrefKey); + + if (isSameSectionChapter || !item.subitems?.length) { + if (itemHrefKey && !seenHrefKeys.has(itemHrefKey)) { + seenHrefKeys.add(itemHrefKey); + chapters.push(item); + } + return; + } + + for (const child of item.subitems) visit(child); + }; + + for (const item of toc) visit(item); + return chapters; +} + +function collectDescendantHrefKeys(items: TOCItem[]): string[] { + const hrefKeys: string[] = []; + for (const item of items) { + const hrefKey = getHrefKey(item.href); + if (hrefKey) hrefKeys.push(hrefKey); + if (item.subitems?.length) hrefKeys.push(...collectDescendantHrefKeys(item.subitems)); + } + return hrefKeys; +} + +function getHrefKey(href: string | undefined): string { + if (!href?.trim()) return ""; + const decoded = safeDecodeUri(href.trim()); + return (decoded.split("#")[0] || decoded).replace(/^\.?\//, ""); +} + +function safeDecodeUri(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}