From 1475f8b3c58a0f8c005cd13556f7f96f1d5e4853 Mon Sep 17 00:00:00 2001 From: yhy Date: Sat, 29 Aug 2026 22:46:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=98=85=E8=AF=BB=E6=98=AFpd?= =?UTF-8?q?f=E4=B8=AD=E9=AB=98=E4=BA=AE=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addon/content/chatpapers.css | 204 ++++++- addon/content/icons/lecture.svg | 56 +- addon/locale/en-US/addon.ftl | 18 + addon/locale/zh-CN/addon.ftl | 18 + .../application/generateLectureNote.ts | 109 ++++ .../application/playParagraphLecture.ts | 306 +++++++++++ .../lecture/application/prepareLecture.ts | 300 +++++++++- .../infrastructure/llm/paragraphLlm.ts | 177 ++++++ .../lecture/infrastructure/pdf/chunker.ts | 101 +++- .../infrastructure/pdf/sentenceSplit.ts | 22 + .../infrastructure/storage/lectureStore.ts | 56 +- src/modules/lecture/ui/lecturePane.ts | 517 ++++++++++++++++-- src/modules/lecture/ui/registerLecturePane.ts | 12 + src/modules/pdf/readerSync.ts | 248 +++++++++ src/modules/ui/itemPaneSection.ts | 49 ++ src/modules/ui/readerPane.ts | 9 + typings/i10n.d.ts | 18 + 17 files changed, 2125 insertions(+), 95 deletions(-) create mode 100644 src/modules/lecture/application/generateLectureNote.ts create mode 100644 src/modules/lecture/application/playParagraphLecture.ts create mode 100644 src/modules/lecture/infrastructure/llm/paragraphLlm.ts create mode 100644 src/modules/lecture/infrastructure/pdf/sentenceSplit.ts create mode 100644 src/modules/pdf/readerSync.ts create mode 100644 src/modules/ui/itemPaneSection.ts diff --git a/addon/content/chatpapers.css b/addon/content/chatpapers.css index 7b830c9..a5a4eb3 100644 --- a/addon/content/chatpapers.css +++ b/addon/content/chatpapers.css @@ -3,13 +3,32 @@ flex-direction: column; gap: 8px; padding: 8px; - min-height: 320px; + min-height: 0; box-sizing: border-box; font-size: 13px; -moz-user-select: text; user-select: text; } +.chatpapers-pane-body { + min-height: 0 !important; +} + +collapsible-section:not([open]) > :not(.head) .chatpapers-root, +collapsible-section:not([open]) > :not(.head) .chatpapers-lecture-root { + min-height: 0 !important; + padding-block: 0; + overflow: hidden; +} + +collapsible-section[open] > :not(.head) .chatpapers-root { + min-height: 240px; +} + +collapsible-section[open] > :not(.head) .chatpapers-lecture-root { + min-height: 180px; +} + .chatpapers-header { display: flex; flex-direction: column; @@ -216,9 +235,9 @@ } .chatpapers-messages { - flex: 1; - min-height: 180px; - max-height: 420px; + flex: 1 1 auto; + min-height: 0; + max-height: min(420px, 45vh); overflow: auto; border: 1px solid color-mix(in srgb, currentColor 18%, transparent); border-radius: 8px; @@ -856,6 +875,183 @@ line-height: 1.4; } +.chatpapers-lecture-mode-tabs { + display: flex; + gap: 0; + padding: 3px; + border-radius: 10px; + background: #e8eef0; + border: 1px solid #c5d3d8; +} + +.chatpapers-lecture-mode-tab { + flex: 1; + -moz-appearance: none; + appearance: none; + padding: 8px 10px; + border: none; + border-radius: 8px; + background: transparent; + font-size: 12px; + font-weight: 700; + cursor: pointer; + color: #4a5568; + transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; +} + +.chatpapers-lecture-mode-tab:hover:not(.is-active) { + background: rgba(255, 255, 255, 0.55); + color: #2d3748; +} + +.chatpapers-lecture-mode-tab.is-active, +.chatpapers-lecture-mode-tab[aria-selected="true"] { + color: #ffffff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18); +} + +.chatpapers-lecture-mode-tabs.mode-paragraph + .chatpapers-lecture-mode-tab[data-mode="paragraph"].is-active, +.chatpapers-lecture-mode-tabs.mode-paragraph + .chatpapers-lecture-mode-tab[data-mode="paragraph"][aria-selected="true"] { + background: #1f7a8c; +} + +.chatpapers-lecture-mode-tabs.mode-overview + .chatpapers-lecture-mode-tab[data-mode="overview"].is-active, +.chatpapers-lecture-mode-tabs.mode-overview + .chatpapers-lecture-mode-tab[data-mode="overview"][aria-selected="true"] { + background: #2563eb; +} + +.chatpapers-lecture-granularity-tabs { + display: none; + gap: 0; + padding: 2px; + margin-top: 2px; + border-radius: 8px; + background: #f3f6f8; + border: 1px solid #d8e2e8; +} + +.chatpapers-lecture-granularity-tabs.is-visible { + display: flex; +} + +.chatpapers-lecture-granularity-tab { + flex: 1; + -moz-appearance: none; + appearance: none; + padding: 6px 8px; + border: none; + border-radius: 6px; + background: transparent; + font-size: 11px; + font-weight: 700; + cursor: pointer; + color: #64748b; + transition: background 0.15s ease, color 0.15s ease; +} + +.chatpapers-lecture-granularity-tab:hover:not(.is-active) { + background: rgba(255, 255, 255, 0.7); + color: #334155; +} + +.chatpapers-lecture-granularity-tab.is-active, +.chatpapers-lecture-granularity-tab[aria-selected="true"] { + background: #ffffff; + color: #1f7a8c; + box-shadow: 0 1px 3px rgba(31, 122, 140, 0.2); + outline: 1px solid rgba(31, 122, 140, 0.35); +} + +.chatpapers-lecture-sentence-item .chatpapers-lecture-beat-title { + font-size: 11px; +} + +.chatpapers-lecture-para-original.is-active-sentence { + border-left: 3px solid #1f7a8c; + padding-left: 8px; + background: color-mix(in srgb, #1f7a8c 8%, transparent); +} + +.chatpapers-lecture-sentence-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.chatpapers-lecture-sentence-chip { + padding: 4px 8px; + border-radius: 6px; + border: 1px solid color-mix(in srgb, #1f7a8c 20%, transparent); + cursor: pointer; + font-size: 11px; + line-height: 1.35; +} + +.chatpapers-lecture-sentence-chip.is-active { + background: color-mix(in srgb, #1f7a8c 12%, transparent); + border-color: color-mix(in srgb, #1f7a8c 40%, transparent); +} + +.chatpapers-lecture-pdf-sync-hint { + margin-top: 4px; + font-size: 10px; + opacity: 0.72; + font-style: italic; +} + +.chatpapers-lecture-para-detail { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + border-radius: 8px; + background: color-mix(in srgb, #1f7a8c 5%, transparent); + border: 1px solid color-mix(in srgb, #1f7a8c 15%, transparent); + font-size: 12px; + line-height: 1.45; + max-height: 200px; + overflow-y: auto; +} + +.chatpapers-lecture-para-kind { + font-size: 11px; + font-weight: 600; + opacity: 0.75; +} + +.chatpapers-lecture-para-role { + font-size: 11px; + opacity: 0.85; + font-style: italic; +} + +.chatpapers-lecture-para-original-label, +.chatpapers-lecture-para-translation-label, +.chatpapers-lecture-para-context-label { + font-size: 10px; + font-weight: 600; + opacity: 0.6; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-top: 4px; +} + +.chatpapers-lecture-para-original { + opacity: 0.9; +} + +.chatpapers-lecture-para-translation { + font-weight: 500; +} + +.chatpapers-lecture-para-context { + opacity: 0.85; +} + .chatpapers-lecture-player { display: flex; flex-direction: column; diff --git a/addon/content/icons/lecture.svg b/addon/content/icons/lecture.svg index 7aa8efe..dd6f9fe 100644 --- a/addon/content/icons/lecture.svg +++ b/addon/content/icons/lecture.svg @@ -1,5 +1,5 @@ - + + + + + + + + + + + + + - diff --git a/addon/locale/en-US/addon.ftl b/addon/locale/en-US/addon.ftl index 9809c6a..02f5a34 100644 --- a/addon/locale/en-US/addon.ftl +++ b/addon/locale/en-US/addon.ftl @@ -82,6 +82,24 @@ lecture-play-complete = Playback finished lecture-beat-tts-pending = Synthesizing lecture-beat-tts-failed = Failed lecture-reprepare = Re-prepare +lecture-save-note = Save note +lecture-note-saved = Lecture note saved to Zotero +lecture-mode-overview = Overview +lecture-mode-paragraph = Paragraph reading +lecture-paragraph-list-title = Paper paragraphs (original, then translation) +lecture-para-label = Para { $n } +lecture-para-original = Original +lecture-para-translation = Translation +lecture-para-context = Context +lecture-granularity-paragraph = By paragraph +lecture-granularity-sentence = By sentence +lecture-sentence-list-title = Sentences (original, then explanation) +lecture-sentence-label = P{ $p }·S{ $s } +lecture-sentence-list-hint = Sentences in this paragraph +lecture-pdf-sync-hint = The matching passage is highlighted in the PDF reader while playing. +lecture-playing-en = Playing: original +lecture-playing-zh = Playing: translation +lecture-status-ready-step2 = Ready: { $count } paragraphs + note synced lecture-no-pdf = No PDF attachment on this item lecture-attach-error = Cannot read PDF attachment lecture-mount-error = Failed to load voice lecture pane diff --git a/addon/locale/zh-CN/addon.ftl b/addon/locale/zh-CN/addon.ftl index c1a51bc..2500126 100644 --- a/addon/locale/zh-CN/addon.ftl +++ b/addon/locale/zh-CN/addon.ftl @@ -82,6 +82,24 @@ lecture-play-complete = 本轮讲解播放完毕 lecture-beat-tts-pending = 合成中 lecture-beat-tts-failed = 失败 lecture-reprepare = 重新备课 +lecture-save-note = 保存笔记 +lecture-note-saved = 伴读笔记已写入 Zotero 子笔记 +lecture-mode-overview = 整体讲解 +lecture-mode-paragraph = 逐段精读 +lecture-paragraph-list-title = 论文段落(先读原文,再听译文) +lecture-para-label = 段落 { $n } +lecture-para-original = 原文 +lecture-para-translation = 译文 / 讲解 +lecture-para-context = 上下文衔接 +lecture-granularity-paragraph = 按段 +lecture-granularity-sentence = 按句 +lecture-sentence-list-title = 逐句精读(先读原文,再听讲解) +lecture-sentence-label = 段{ $p }·句{ $s } +lecture-sentence-list-hint = 本段各句 +lecture-pdf-sync-hint = 播放时会在 PDF 阅读器中高亮对应原文。 +lecture-playing-en = 正在播放:原文朗读 +lecture-playing-zh = 正在播放:中文讲解 +lecture-status-ready-step2 = 备课完成:{ $count } 段逐段精读可播放,笔记已同步 lecture-no-pdf = 当前条目没有可用的 PDF 附件 lecture-attach-error = 无法读取 PDF 附件 lecture-mount-error = 语音伴读面板加载失败 diff --git a/src/modules/lecture/application/generateLectureNote.ts b/src/modules/lecture/application/generateLectureNote.ts new file mode 100644 index 0000000..0f03ce4 --- /dev/null +++ b/src/modules/lecture/application/generateLectureNote.ts @@ -0,0 +1,109 @@ +import { formatSummaryForDisplay } from "../infrastructure/llm/lectureLlm"; +import type { LectureData } from "../infrastructure/storage/lectureStore"; +import { createChildNote } from "../../zotero/notes"; +import { markdownToNoteHtml } from "../../../utils/markdown"; + +const NOTE_TITLE_PREFIX = "ChatPapers 伴读笔记:"; + +export function buildLectureNoteMarkdown( + data: LectureData, + paperTitle: string, +): string { + const lines: string[] = [ + `> 由 ChatPapers 语音伴读自动生成。`, + "", + ]; + + if (data.summary) { + lines.push("## 全文摘要", "", formatSummaryForDisplay(data.summary), ""); + } + + if (data.paragraphs?.length) { + lines.push("## 逐段精读(原文 + 译文 + 上下文)", ""); + for (const p of data.paragraphs) { + if (p.genStatus !== "ready") continue; + lines.push( + `### 段落 ${p.orderIndex + 1}`, + "", + `**在全文中的作用**:${p.roleInPaper || "—"}`, + "", + "**原文**", + "", + "```", + p.originalText, + "```", + "", + "**译文 / 讲解**", + "", + p.translation, + "", + `**上下文衔接**:${p.contextLink || "—"}`, + "", + "---", + "", + ); + } + } + + if (data.beats?.length) { + lines.push("## 整体讲解(beats)", ""); + for (const b of data.beats) { + lines.push(`### ${b.title || b.id}`, "", b.script, ""); + } + } + + lines.push( + "", + `---`, + `*论文:${paperTitle} · 更新于 ${new Date(data.updatedAt).toLocaleString()}*`, + ); + + return lines.join("\n"); +} + +function findExistingLectureNote( + parentItem: Zotero.Item, + noteTitle: string, +): Zotero.Item | undefined { + try { + const noteIds = parentItem.getNotes(); + for (const id of noteIds) { + const note = Zotero.Items.get(id) as Zotero.Item | false; + if (!note) continue; + const html = String(note.getNote() || ""); + if ( + html.includes(NOTE_TITLE_PREFIX) || + html.includes(noteTitle) || + html.includes("ChatPapers 伴读") + ) { + return note; + } + } + } catch (e) { + ztoolkit.log("[ChatPapers:Lecture] findExistingLectureNote failed", e); + } + return undefined; +} + +export async function upsertLectureNote(options: { + parentItem: Zotero.Item; + data: LectureData; + paperTitle: string; +}): Promise { + const noteTitle = `${NOTE_TITLE_PREFIX} ${options.paperTitle}`; + const bodyMarkdown = buildLectureNoteMarkdown(options.data, options.paperTitle); + const html = markdownToNoteHtml(noteTitle, bodyMarkdown); + + const existing = findExistingLectureNote(options.parentItem, noteTitle); + if (existing) { + existing.setNote(html); + await existing.saveTx(); + return existing; + } + + return createChildNote({ + parentItem: options.parentItem, + title: noteTitle, + bodyMarkdown, + }); +} diff --git a/src/modules/lecture/application/playParagraphLecture.ts b/src/modules/lecture/application/playParagraphLecture.ts new file mode 100644 index 0000000..2c25f6d --- /dev/null +++ b/src/modules/lecture/application/playParagraphLecture.ts @@ -0,0 +1,306 @@ +import type { + StoredParagraph, + StoredSentence, +} from "../infrastructure/storage/lectureStore"; +import { pathToFileUrl } from "../infrastructure/platform/paths"; + +export type ReadGranularity = "paragraph" | "sentence"; + +interface AudioSegment { + paragraphId: string; + sentenceId?: string; + kind: "en" | "zh"; + audioPath: string; + highlightText: string; + page?: number; +} + +export interface PlayParagraphHandle { + play: () => Promise; + pause: () => void; + next: () => Promise; + prev: () => Promise; + jumpToParagraphId: (paragraphId: string, kind?: "en" | "zh") => Promise; + jumpToSentenceId: (sentenceId: string, kind?: "en" | "zh") => Promise; + getCurrentParagraphId: () => string | undefined; + getCurrentSentenceId: () => string | undefined; + getCurrentKind: () => "en" | "zh" | undefined; + getCurrentHighlight: () => { text: string; page?: number } | undefined; + isPlaying: () => boolean; + destroy: () => void; + setParagraphs: (paragraphs: StoredParagraph[]) => void; + setGranularity: (granularity: ReadGranularity) => void; +} + +function sentenceReady(s: StoredSentence, kind: "en" | "zh"): boolean { + if (kind === "en") { + return s.ttsEnStatus === "ready" && !!s.audioPathEn; + } + return s.ttsZhStatus === "ready" && !!s.audioPathZh && !!s.translation; +} + +function paragraphReady(p: StoredParagraph, kind: "en" | "zh"): boolean { + if (kind === "en") { + return p.ttsEnStatus === "ready" && !!p.audioPathEn && p.genStatus === "ready"; + } + return p.ttsZhStatus === "ready" && !!p.audioPathZh && p.genStatus === "ready"; +} + +function pushSentenceSegments( + segments: AudioSegment[], + paragraphId: string, + sentences: StoredSentence[], +): void { + for (const s of sentences) { + if (sentenceReady(s, "en")) { + segments.push({ + paragraphId, + sentenceId: s.id, + kind: "en", + audioPath: s.audioPathEn!, + highlightText: s.text, + page: s.page, + }); + } + if (sentenceReady(s, "zh")) { + segments.push({ + paragraphId, + sentenceId: s.id, + kind: "zh", + audioPath: s.audioPathZh!, + highlightText: s.text, + page: s.page, + }); + } + } +} + +function buildSegments( + paragraphs: StoredParagraph[], + granularity: ReadGranularity, +): AudioSegment[] { + const segments: AudioSegment[] = []; + for (const p of paragraphs) { + if (p.genStatus !== "ready") continue; + + const sentences = (p.sentences ?? []).filter( + (s) => sentenceReady(s, "en") || sentenceReady(s, "zh"), + ); + const useSentences = + granularity === "sentence" && + sentences.some((s) => sentenceReady(s, "en") || sentenceReady(s, "zh")); + + if (useSentences) { + pushSentenceSegments(segments, p.id, sentences); + continue; + } + + if (paragraphReady(p, "en")) { + segments.push({ + paragraphId: p.id, + kind: "en", + audioPath: p.audioPathEn!, + highlightText: p.originalText, + page: p.page, + }); + } + if (paragraphReady(p, "zh")) { + segments.push({ + paragraphId: p.id, + kind: "zh", + audioPath: p.audioPathZh!, + highlightText: p.originalText, + page: p.page, + }); + } + } + return segments; +} + +export function createParagraphPlayer(options: { + audio: HTMLAudioElement; + paragraphs: StoredParagraph[]; + granularity?: ReadGranularity; + onSegmentChange?: (info: { + paragraphId: string | undefined; + sentenceId: string | undefined; + kind: "en" | "zh" | undefined; + highlightText?: string; + page?: number; + }) => void; + onPlayingChange?: (playing: boolean) => void; + onComplete?: () => void; +}): PlayParagraphHandle { + let granularity = options.granularity ?? "paragraph"; + let segments = buildSegments(options.paragraphs, granularity); + let index = 0; + let playing = false; + + const notify = () => { + const seg = segments[index]; + options.onSegmentChange?.({ + paragraphId: seg?.paragraphId, + sentenceId: seg?.sentenceId, + kind: seg?.kind, + highlightText: seg?.highlightText, + page: seg?.page, + }); + }; + + const loadIndex = (i: number): boolean => { + if (i < 0 || i >= segments.length) return false; + index = i; + const seg = segments[i]; + options.audio.src = pathToFileUrl(seg.audioPath); + options.audio.load(); + notify(); + return true; + }; + + options.audio.onended = () => { + void (async () => { + if (index + 1 < segments.length) { + index += 1; + if (loadIndex(index)) { + try { + await options.audio.play(); + } catch { + playing = false; + options.onPlayingChange?.(false); + } + } + } else { + playing = false; + options.onPlayingChange?.(false); + options.onComplete?.(); + } + })(); + }; + + return { + setGranularity(next) { + granularity = next; + const current = segments[index]; + segments = buildSegments(options.paragraphs, granularity); + if (current) { + const i = segments.findIndex( + (s) => + s.paragraphId === current.paragraphId && + s.sentenceId === current.sentenceId && + s.kind === current.kind, + ); + index = i >= 0 ? i : 0; + } else { + index = 0; + } + notify(); + }, + + setParagraphs(next) { + options.paragraphs = next; + const current = segments[index]; + segments = buildSegments(next, granularity); + if (current) { + const i = segments.findIndex( + (s) => + s.paragraphId === current.paragraphId && + s.sentenceId === current.sentenceId && + s.kind === current.kind, + ); + index = i >= 0 ? i : Math.min(index, Math.max(0, segments.length - 1)); + } else if (index >= segments.length) { + index = Math.max(0, segments.length - 1); + } + notify(); + }, + + async play() { + if (!segments.length) return; + if (!options.audio.src) { + loadIndex(index); + } else { + notify(); + } + try { + await options.audio.play(); + playing = true; + options.onPlayingChange?.(true); + } catch (e) { + ztoolkit.log("[ChatPapers:Lecture] paragraph play failed", e); + playing = false; + options.onPlayingChange?.(false); + } + }, + + pause() { + options.audio.pause(); + playing = false; + options.onPlayingChange?.(false); + }, + + async next() { + if (index + 1 >= segments.length) return; + index += 1; + if (loadIndex(index)) await this.play(); + }, + + async prev() { + if (index <= 0) return; + index -= 1; + if (loadIndex(index)) await this.play(); + }, + + async jumpToParagraphId(paragraphId: string, kind: "en" | "zh" = "en") { + const i = segments.findIndex( + (s) => + s.paragraphId === paragraphId && + !s.sentenceId && + (kind === "en" ? s.kind === "en" : s.kind === "zh"), + ); + const fallback = segments.findIndex((s) => s.paragraphId === paragraphId); + const target = i >= 0 ? i : fallback; + if (target < 0) return; + index = target; + if (loadIndex(index)) await this.play(); + }, + + async jumpToSentenceId(sentenceId: string, kind: "en" | "zh" = "en") { + const i = segments.findIndex( + (s) => + s.sentenceId === sentenceId && + (kind === "en" ? s.kind === "en" : s.kind === "zh"), + ); + if (i < 0) return; + index = i; + if (loadIndex(index)) await this.play(); + }, + + getCurrentParagraphId() { + return segments[index]?.paragraphId; + }, + + getCurrentSentenceId() { + return segments[index]?.sentenceId; + }, + + getCurrentKind() { + return segments[index]?.kind; + }, + + getCurrentHighlight() { + const seg = segments[index]; + if (!seg) return undefined; + return { text: seg.highlightText, page: seg.page }; + }, + + isPlaying() { + return playing && !options.audio.paused; + }, + + destroy() { + options.audio.onended = null; + options.audio.pause(); + playing = false; + }, + }; +} diff --git a/src/modules/lecture/application/prepareLecture.ts b/src/modules/lecture/application/prepareLecture.ts index 3eeb85d..9cf0bac 100644 --- a/src/modules/lecture/application/prepareLecture.ts +++ b/src/modules/lecture/application/prepareLecture.ts @@ -1,24 +1,31 @@ import type { LectureStatus, PaperCache } from "../domain/types"; +import { upsertLectureNote } from "./generateLectureNote"; import { generateBeats } from "../infrastructure/llm/beatLlm"; import { - formatSummaryForDisplay, generatePaperSummary, } from "../infrastructure/llm/lectureLlm"; +import { + buildRollingContext, + generateParagraphExplanation, + isPrimarilyEnglish, + toStoredParagraph, +} from "../infrastructure/llm/paragraphLlm"; import { chunkPaperText, formatChunkOutline, } from "../infrastructure/pdf/chunker"; +import { splitSentences } from "../infrastructure/pdf/sentenceSplit"; import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash"; import { ensureAudioDir, } from "../infrastructure/storage/audioStorage"; import { lectureEvents } from "../infrastructure/events"; import { - isStep1Ready, + isLectureComplete, loadLectureData, mergeLectureData, type LectureData, - type StoredBeat, + type StoredParagraph, } from "../infrastructure/storage/lectureStore"; import { audioFilePath } from "../infrastructure/storage/paths"; import { paperRepository } from "../infrastructure/storage/repository"; @@ -33,6 +40,11 @@ import { } from "../../pdf/extractor"; import { LlmError } from "../../llm/types"; +/** Max paragraphs for Step 2 (long papers: lazy gen later). */ +const MAX_PARAGRAPH_UNITS = 25; +const TTS_EN_MAX_CHARS = 600; +const TTS_SENTENCE_ZH_MAX_CHARS = 280; + export class PrepareLectureUseCase { private inflight = new Map>(); @@ -75,7 +87,7 @@ export class PrepareLectureUseCase { } const data = await loadLectureData(ctx.cacheKey); - if (paper.lectureStatus === "ready" && isStep1Ready(data)) { + if (paper.lectureStatus === "ready" && isLectureComplete(data)) { return paper; } @@ -250,21 +262,293 @@ export class PrepareLectureUseCase { data.phase = 2; await mergeLectureData(cacheKey, paper.id, data); + // --- Paragraph explanations (Step 2) --- + const targetChunks = (data.chunks ?? []).slice(0, MAX_PARAGRAPH_UNITS); + let paragraphs: StoredParagraph[] = data.paragraphs ?? []; + + const needsParaGen = targetChunks.some( + (c) => + !paragraphs.find((p) => p.id === c.id && p.genStatus === "ready"), + ); + + if (needsParaGen && targetChunks.length) { + await emitStatus( + "paragraphs_generating", + `正在生成逐段译文(0/${targetChunks.length})…`, + 70, + ); + + const rolling: Array<{ translation: string; contextLink: string }> = []; + let done = 0; + + for (const chunk of targetChunks) { + const existing = paragraphs.find((p) => p.id === chunk.id); + if (existing?.genStatus === "ready") { + rolling.push({ + translation: existing.translation, + contextLink: existing.contextLink, + }); + if (rolling.length > 3) rolling.shift(); + continue; + } + + try { + const priorContext = buildRollingContext(rolling); + const llm = await generateParagraphExplanation({ + paperTitle: title, + summary: data.summary!, + chunk, + priorContext, + }); + const para = toStoredParagraph(chunk, llm); + paragraphs = paragraphs.filter((p) => p.id !== chunk.id); + paragraphs.push(para); + rolling.push({ + translation: para.translation, + contextLink: para.contextLink, + }); + if (rolling.length > 3) rolling.shift(); + } catch (e) { + if (e instanceof LlmError) throw e; + ztoolkit.log("[ChatPapers:Lecture] paragraph gen failed", chunk.id, e); + paragraphs = paragraphs.filter((p) => p.id !== chunk.id); + paragraphs.push({ + id: chunk.id, + orderIndex: chunk.orderIndex, + originalText: chunk.text, + translation: chunk.text.slice(0, 300), + contextLink: "", + roleInPaper: "", + ttsEnStatus: isPrimarilyEnglish(chunk.text) ? "pending" : "skipped", + ttsZhStatus: "pending", + genStatus: "failed", + }); + } + + done += 1; + data.paragraphs = paragraphs.sort( + (a, b) => a.orderIndex - b.orderIndex, + ); + await mergeLectureData(cacheKey, paper.id, data); + + lectureEvents.emit("lecture:paragraph_ready", { + paperId: paper.id, + paragraphId: chunk.id, + }); + + await emitStatus( + "paragraphs_generating", + `正在生成逐段译文(${done}/${targetChunks.length})…`, + 70 + Math.floor((done / targetChunks.length) * 10), + ); + } + } + + // --- Backfill sentences for cached paragraphs (re-prepare upgrade) --- + paragraphs = (data.paragraphs ?? []).sort( + (a, b) => a.orderIndex - b.orderIndex, + ); + let backfilled = false; + for (const para of paragraphs) { + if (para.genStatus === "ready" && !(para.sentences?.length ?? 0)) { + const chunk = targetChunks.find((c) => c.id === para.id); + para.sentences = splitSentences(para.originalText).map( + (text, orderIndex) => ({ + id: `${para.id}-s${orderIndex}`, + orderIndex, + text, + translation: + orderIndex === 0 ? para.translation.slice(0, 120) : "", + page: chunk?.page ?? para.page, + searchText: text.slice(0, 80), + ttsEnStatus: isPrimarilyEnglish(text) + ? ("pending" as const) + : ("skipped" as const), + ttsZhStatus: "pending" as const, + }), + ); + backfilled = true; + } + } + if (backfilled) { + data.paragraphs = paragraphs; + await mergeLectureData(cacheKey, paper.id, data); + } + + // --- TTS for paragraphs (EN then ZH per unit; then per-sentence) --- + const paraTtsPending = paragraphs.filter( + (p) => + (p.ttsEnStatus === "pending" && isPrimarilyEnglish(p.originalText)) || + p.ttsZhStatus !== "ready" || + (p.sentences ?? []).some( + (s) => + (s.ttsEnStatus === "pending" && + isPrimarilyEnglish(s.text)) || + s.ttsZhStatus === "pending", + ), + ); + + if (paraTtsPending.length) { + await ensureAudioDir(cacheKey); + let ttsDone = 0; + const ttsTotal = paraTtsPending.length; + + for (const para of paragraphs) { + let updated = false; + + if ( + para.ttsEnStatus === "pending" && + isPrimarilyEnglish(para.originalText) + ) { + try { + const enText = para.originalText.slice(0, TTS_EN_MAX_CHARS); + const outPath = audioFilePath(cacheKey, `${para.id}-en`, "wav"); + const result = await synthesizeSpeech({ + text: enText, + outputPath: outPath, + lang: "en", + }); + para.audioPathEn = result.audioPath; + para.ttsEnStatus = "ready"; + updated = true; + } catch (e) { + ztoolkit.log("[ChatPapers:Lecture] EN TTS failed", para.id, e); + para.ttsEnStatus = "failed"; + updated = true; + } + } + + if (para.ttsZhStatus !== "ready" && para.translation) { + try { + const outPath = audioFilePath(cacheKey, `${para.id}-zh`, "wav"); + const result = await synthesizeSpeech({ + text: para.translation.slice(0, 1200), + outputPath: outPath, + lang: "zh", + }); + para.audioPathZh = result.audioPath; + para.ttsZhStatus = "ready"; + updated = true; + } catch (e) { + ztoolkit.log("[ChatPapers:Lecture] ZH TTS failed", para.id, e); + para.ttsZhStatus = "failed"; + updated = true; + } + } + + for (const sentence of para.sentences ?? []) { + if ( + sentence.ttsEnStatus === "pending" && + isPrimarilyEnglish(sentence.text) + ) { + try { + const outPath = audioFilePath( + cacheKey, + `${sentence.id}-en`, + "wav", + ); + const result = await synthesizeSpeech({ + text: sentence.text.slice(0, TTS_EN_MAX_CHARS), + outputPath: outPath, + lang: "en", + }); + sentence.audioPathEn = result.audioPath; + sentence.ttsEnStatus = "ready"; + updated = true; + } catch (e) { + ztoolkit.log( + "[ChatPapers:Lecture] sentence EN TTS failed", + sentence.id, + e, + ); + sentence.ttsEnStatus = "failed"; + updated = true; + } + } + + if (sentence.ttsZhStatus === "pending" && sentence.translation) { + try { + const outPath = audioFilePath( + cacheKey, + `${sentence.id}-zh`, + "wav", + ); + const result = await synthesizeSpeech({ + text: sentence.translation.slice(0, TTS_SENTENCE_ZH_MAX_CHARS), + outputPath: outPath, + lang: "zh", + }); + sentence.audioPathZh = result.audioPath; + sentence.ttsZhStatus = "ready"; + updated = true; + } catch (e) { + ztoolkit.log( + "[ChatPapers:Lecture] sentence ZH TTS failed", + sentence.id, + e, + ); + sentence.ttsZhStatus = "failed"; + updated = true; + } + } + } + + if (updated) { + ttsDone += 1; + data.paragraphs = paragraphs; + await mergeLectureData(cacheKey, paper.id, data); + + if (para.audioPathZh) { + lectureEvents.emit("tts:unit_ready", { + paperId: paper.id, + unitId: para.id, + audioPath: para.audioPathZh, + }); + } + + await emitStatus( + "tts_generating", + `正在合成逐段/逐句语音(${ttsDone}/${ttsTotal})…`, + 82 + Math.floor((ttsDone / ttsTotal) * 15), + ); + } + } + } + + if (!paragraphs.some((p) => p.ttsZhStatus === "ready")) { + throw new Error("逐段中文语音均合成失败,请检查 TTS 设置。"); + } + + // --- Zotero note --- + try { + const note = await upsertLectureNote({ + parentItem: parent, + data: { ...data, paragraphs, summary: data.summary! }, + paperTitle: title, + }); + data.noteItemId = String(note.id); + } catch (e) { + ztoolkit.log("[ChatPapers:Lecture] upsert note failed", e); + } + + data.phase = 3; + await mergeLectureData(cacheKey, paper.id, data); + paper.lectureStatus = "ready"; paper.updatedAt = Date.now(); await paperRepository.upsert(cacheKey, paper); - const readyCount = beats.filter((b) => b.ttsStatus === "ready").length; - const preview = formatSummaryForDisplay(data.summary!); + const readyBeats = beats.filter((b) => b.ttsStatus === "ready").length; + const readyParas = paragraphs.filter((p) => p.ttsZhStatus === "ready").length; lectureEvents.emit("lecture:status", { paperId: paper.id, lectureStatus: "ready", - message: `备课完成:${readyCount} 段讲解可播放\n${preview}`, + message: `备课完成:${readyParas} 段逐段精读 + ${readyBeats} 段整体讲解\n笔记已写入 Zotero 子笔记`, }); lectureEvents.emit("parse:progress", { paperId: paper.id, percent: 100, - message: "整体讲解已就绪", + message: "逐段精读与笔记已就绪", }); } catch (e) { paper.parseStatus = "failed"; diff --git a/src/modules/lecture/infrastructure/llm/paragraphLlm.ts b/src/modules/lecture/infrastructure/llm/paragraphLlm.ts new file mode 100644 index 0000000..e0ed05c --- /dev/null +++ b/src/modules/lecture/infrastructure/llm/paragraphLlm.ts @@ -0,0 +1,177 @@ +import { parseJsonLoose } from "../../../llm/jsonParse"; +import { LlmError } from "../../../llm/types"; +import type { SummaryJson } from "../../domain/types"; +import type { TextChunk } from "../pdf/chunker"; +import { splitSentences } from "../pdf/sentenceSplit"; +import type { StoredParagraph, StoredSentence } from "../storage/lectureStore"; +import { requestStructuredOutput } from "./structuredOutput"; + +const PARA_SYSTEM = `你是 ChatPapers 论文伴读助手。用户会按论文物理顺序逐段听课。 +必须只输出一个 JSON 对象,不要 markdown 代码块。 +格式:{"translation":"...","contextLink":"...","roleInPaper":"...","sentences":[{"original":"...","translation":"..."}]} + +要求: +- translation:连贯的中文。英文段落需先准确翻译,再用简短口语解释难词/公式(不要念 LaTeX);中文段落则做白话梳理。 +- contextLink:1–2 句,说明本段与上一段及全文摘要的衔接(代词指代、逻辑转折等)。 +- roleInPaper:本段在论文结构中的作用(如「方法开篇」「实验设置」)。 +- sentences:将本段拆成 2–8 个朗读单元(按句号/问号/叹号分句),每项含 original(原文句)与 translation(该句的中文讲解,30–80 字)。 +- 术语与专有名词翻译前后一致;指代前文概念时用相同译法。`; + +export interface ParagraphLlmResult { + translation: string; + contextLink: string; + roleInPaper: string; + sentences: Array<{ original: string; translation: string }>; +} + +function parseSentences( + raw: unknown, + chunkText: string, +): Array<{ original: string; translation: string }> { + if (!Array.isArray(raw)) return []; + const parsed = raw + .map((item) => { + if (!item || typeof item !== "object") return null; + const o = item as Record; + const original = String(o.original || o.text || o.en || "").trim(); + const translation = String(o.translation || o.zh || "").trim(); + if (!original) return null; + return { original, translation: translation || original }; + }) + .filter(Boolean) as Array<{ original: string; translation: string }>; + + if (parsed.length) return parsed.slice(0, 12); + + return splitSentences(chunkText).map((s) => ({ + original: s, + translation: "", + })); +} + +function parseParagraphJson(raw: string, chunkText: string): ParagraphLlmResult { + const parsed = parseJsonLoose>(raw); + const translation = String(parsed.translation || parsed.zh || "").trim(); + if (!translation) { + throw new LlmError("provider", "段落讲解 JSON 缺少 translation 字段。"); + } + const sentences = parseSentences(parsed.sentences, chunkText); + return { + translation, + contextLink: String(parsed.contextLink || parsed.context || "").trim(), + roleInPaper: String(parsed.roleInPaper || parsed.role || "").trim(), + sentences, + }; +} + +function fallbackParagraph(chunk: TextChunk): ParagraphLlmResult { + const sentences = splitSentences(chunk.text).map((s) => ({ + original: s, + translation: s.slice(0, 120), + })); + return { + translation: chunk.text.slice(0, 500), + contextLink: "(模型未能生成衔接说明,请重试备课或更换模型。)", + roleInPaper: "(待补充)", + sentences, + }; +} + +export async function generateParagraphExplanation(options: { + paperTitle: string; + summary: SummaryJson; + chunk: TextChunk; + priorContext: string; +}): Promise { + const summaryBlock = [ + `问题:${options.summary.problem}`, + `方法:${options.summary.method}`, + `结果:${options.summary.result}`, + `局限:${options.summary.limitation}`, + ].join("\n"); + + const user = `【论文标题】${options.paperTitle} + +【全文摘要】 +${summaryBlock} + +【前文衔接(最近几段要点,可能为空)】 +${options.priorContext || "(这是论文开头第一段)"} + +【当前段落 ${options.chunk.id}】 +${options.chunk.text} + +请输出 JSON。`; + + return requestStructuredOutput({ + system: PARA_SYSTEM, + user, + parse: (raw) => parseParagraphJson(raw, options.chunk.text), + jsonMode: true, + fallback: () => fallbackParagraph(options.chunk), + }); +} + +/** Detect if chunk is primarily English for TTS lang selection. */ +export function isPrimarilyEnglish(text: string): boolean { + const letters = text.match(/[a-zA-Z]/g)?.length ?? 0; + const cjk = text.match(/[\u4e00-\u9fff]/g)?.length ?? 0; + return letters > cjk * 2 && letters > 40; +} + +export function buildRollingContext( + recent: Array<{ translation: string; contextLink: string }>, + maxChars = 1200, +): string { + const parts = recent.map( + (p, i) => + `[${i + 1}] ${p.contextLink ? `${p.contextLink} ` : ""}${p.translation.slice(0, 200)}`, + ); + let out = parts.join("\n"); + if (out.length > maxChars) out = out.slice(-maxChars); + return out; +} + +function buildStoredSentences( + chunk: TextChunk, + llmSentences: Array<{ original: string; translation: string }>, + paragraphTranslation: string, +): StoredSentence[] { + const en = isPrimarilyEnglish(chunk.text); + return llmSentences.map((s, orderIndex) => { + const zh = + s.translation.trim() || + (orderIndex === 0 ? paragraphTranslation.slice(0, 120) : ""); + return { + id: `${chunk.id}-s${orderIndex}`, + orderIndex, + text: s.original, + translation: zh, + page: chunk.page, + searchText: s.original.slice(0, 80), + ttsEnStatus: en ? ("pending" as const) : ("skipped" as const), + ttsZhStatus: zh ? ("pending" as const) : ("failed" as const), + }; + }); +} + +export function toStoredParagraph( + chunk: TextChunk, + llm: ParagraphLlmResult, +): StoredParagraph { + const en = isPrimarilyEnglish(chunk.text); + const sentences = buildStoredSentences(chunk, llm.sentences, llm.translation); + return { + id: chunk.id, + orderIndex: chunk.orderIndex, + originalText: chunk.text, + translation: llm.translation, + contextLink: llm.contextLink, + roleInPaper: llm.roleInPaper, + page: chunk.page, + searchText: chunk.searchText ?? chunk.text.slice(0, 80), + sentences, + ttsEnStatus: en ? "pending" : "skipped", + ttsZhStatus: "pending", + genStatus: "ready", + }; +} diff --git a/src/modules/lecture/infrastructure/pdf/chunker.ts b/src/modules/lecture/infrastructure/pdf/chunker.ts index f4193bf..bf4d501 100644 --- a/src/modules/lecture/infrastructure/pdf/chunker.ts +++ b/src/modules/lecture/infrastructure/pdf/chunker.ts @@ -4,44 +4,85 @@ export interface TextChunk { id: string; orderIndex: number; text: string; + /** 1-based page where this chunk starts (from PDF form-feed breaks). */ + page?: number; + /** Short snippet for PDF in-document search. */ + searchText?: string; } const MIN_CHUNK_CHARS = 200; const MAX_CHUNK_CHARS = 900; const MAX_CHUNKS = 40; -export function chunkPaperText(text: string): TextChunk[] { - const paragraphs = text - .split(/\n\s*\n+/) - .map((s) => s.replace(/\s+/g, " ").trim()) - .filter((s) => s.length > 40); +interface RawPara { + text: string; + page: number; +} - const merged: string[] = []; - let buffer = ""; +function normalizePara(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} - for (const para of paragraphs) { - if ( - buffer && - (buffer.length + para.length > MAX_CHUNK_CHARS || - (buffer.length >= MIN_CHUNK_CHARS && merged.length >= MAX_CHUNKS - 1)) - ) { - merged.push(buffer); - buffer = para; - } else { - buffer = buffer ? `${buffer} ${para}` : para; +function extractParagraphsWithPages(text: string): RawPara[] { + const hasPages = text.includes("\f"); + const pageBlocks = hasPages ? text.split("\f") : [text]; + const out: RawPara[] = []; + + for (let pageIndex = 0; pageIndex < pageBlocks.length; pageIndex++) { + const pageNum = pageIndex + 1; + const paras = pageBlocks[pageIndex] + .split(/\n\s*\n+/) + .map(normalizePara) + .filter((s) => s.length > 40); + for (const para of paras) { + out.push({ text: para, page: pageNum }); } } - if (buffer) merged.push(buffer); - if (!merged.length && text.trim()) { - merged.push(text.trim().slice(0, MAX_CHUNK_CHARS)); + if (!out.length && text.trim()) { + out.push({ text: normalizePara(text).slice(0, MAX_CHUNK_CHARS), page: 1 }); } - return merged.slice(0, MAX_CHUNKS).map((chunkText, orderIndex) => ({ - id: `p-${orderIndex}`, - orderIndex, - text: chunkText.slice(0, 2000), - })); + return out; +} + +function mergeParagraphs(paras: RawPara[]): Array<{ text: string; page: number }> { + const merged: Array<{ text: string; page: number }> = []; + let buffer = ""; + let bufferPage = 1; + + for (const para of paras) { + if ( + buffer && + (buffer.length + para.text.length > MAX_CHUNK_CHARS || + (buffer.length >= MIN_CHUNK_CHARS && merged.length >= MAX_CHUNKS - 1)) + ) { + merged.push({ text: buffer, page: bufferPage }); + buffer = para.text; + bufferPage = para.page; + } else { + if (!buffer) bufferPage = para.page; + buffer = buffer ? `${buffer} ${para.text}` : para.text; + } + } + if (buffer) merged.push({ text: buffer, page: bufferPage }); + return merged; +} + +export function chunkPaperText(text: string): TextChunk[] { + const paras = extractParagraphsWithPages(text); + const merged = mergeParagraphs(paras); + + return merged.slice(0, MAX_CHUNKS).map((chunk, orderIndex) => { + const chunkText = chunk.text.slice(0, 2000); + return { + id: `p-${orderIndex}`, + orderIndex, + text: chunkText, + page: chunk.page, + searchText: chunkText.slice(0, 80), + }; + }); } /** Compact outline for LLM beats generation. */ @@ -49,7 +90,15 @@ export function formatChunkOutline(chunks: TextChunk[]): string { return chunks .map( (c) => - `[${c.id}] ${c.text.slice(0, 100)}${c.text.length > 100 ? "…" : ""}`, + `[${c.id}${c.page ? ` p${c.page}` : ""}] ${c.text.slice(0, 100)}${c.text.length > 100 ? "…" : ""}`, ) .join("\n"); } + +/** Lookup chunk metadata by paragraph id. */ +export function chunkById( + chunks: TextChunk[] | undefined, + id: string, +): TextChunk | undefined { + return chunks?.find((c) => c.id === id); +} diff --git a/src/modules/lecture/infrastructure/pdf/sentenceSplit.ts b/src/modules/lecture/infrastructure/pdf/sentenceSplit.ts new file mode 100644 index 0000000..0be5511 --- /dev/null +++ b/src/modules/lecture/infrastructure/pdf/sentenceSplit.ts @@ -0,0 +1,22 @@ +/** Split paragraph text into sentences (EN + CJK punctuation). */ + +const MIN_SENTENCE_CHARS = 12; +const MAX_SENTENCES = 12; + +export function splitSentences(text: string): string[] { + const normalized = text.replace(/\s+/g, " ").trim(); + if (!normalized) return []; + + const parts = + normalized.match(/[^.!?。!?]+[.!?。!?]+|[^.!?。!?]+$/g) ?? [normalized]; + + const sentences = parts + .map((s) => s.trim()) + .filter((s) => s.length >= MIN_SENTENCE_CHARS); + + if (!sentences.length && normalized.length >= MIN_SENTENCE_CHARS) { + return [normalized]; + } + + return sentences.slice(0, MAX_SENTENCES); +} diff --git a/src/modules/lecture/infrastructure/storage/lectureStore.ts b/src/modules/lecture/infrastructure/storage/lectureStore.ts index 2a04ad2..bc7a68b 100644 --- a/src/modules/lecture/infrastructure/storage/lectureStore.ts +++ b/src/modules/lecture/infrastructure/storage/lectureStore.ts @@ -14,14 +14,46 @@ export interface StoredBeat { ttsStatus: "pending" | "ready" | "failed"; } +export interface StoredSentence { + id: string; + orderIndex: number; + text: string; + translation: string; + page?: number; + searchText?: string; + audioPathEn?: string; + audioPathZh?: string; + ttsEnStatus: "pending" | "ready" | "failed" | "skipped"; + ttsZhStatus: "pending" | "ready" | "failed"; +} + +export interface StoredParagraph { + id: string; + orderIndex: number; + originalText: string; + translation: string; + contextLink: string; + roleInPaper: string; + page?: number; + searchText?: string; + sentences?: StoredSentence[]; + audioPathEn?: string; + audioPathZh?: string; + ttsEnStatus: "pending" | "ready" | "failed" | "skipped"; + ttsZhStatus: "pending" | "ready" | "failed"; + genStatus: "pending" | "ready" | "failed"; +} + export interface LectureData { paperId: string; cacheKey: string; - /** 1 = summary only, 2 = beats + TTS (Step 1) */ - phase?: 1 | 2; + /** 1 = summary, 2 = beats+TTS, 3 = paragraphs+note */ + phase?: 1 | 2 | 3; summary?: SummaryJson; chunks?: TextChunk[]; beats?: StoredBeat[]; + paragraphs?: StoredParagraph[]; + noteItemId?: string; extractChars?: number; extractSource?: string; updatedAt: number; @@ -89,7 +121,27 @@ export function isStep1Ready(data: LectureData | undefined): boolean { return data.beats.some((b) => b.ttsStatus === "ready" && b.audioPath); } +export function isStep2Ready(data: LectureData | undefined): boolean { + if (!data?.paragraphs?.length) return false; + return data.paragraphs.some( + (p) => p.genStatus === "ready" && p.ttsZhStatus === "ready" && p.audioPathZh, + ); +} + +export function isLectureComplete(data: LectureData | undefined): boolean { + return isStep1Ready(data) && isStep2Ready(data); +} + export function playableBeats(data: LectureData | undefined): StoredBeat[] { if (!data?.beats) return []; return data.beats.filter((b) => b.ttsStatus === "ready" && b.audioPath); } + +export function playableParagraphs( + data: LectureData | undefined, +): StoredParagraph[] { + if (!data?.paragraphs) return []; + return data.paragraphs.filter( + (p) => p.genStatus === "ready" && p.ttsZhStatus === "ready" && p.audioPathZh, + ); +} diff --git a/src/modules/lecture/ui/lecturePane.ts b/src/modules/lecture/ui/lecturePane.ts index 508d207..baaeb58 100644 --- a/src/modules/lecture/ui/lecturePane.ts +++ b/src/modules/lecture/ui/lecturePane.ts @@ -5,6 +5,12 @@ import { createPlayLecture, type PlayLectureHandle, } from "../application/playLecture"; +import { + createParagraphPlayer, + type PlayParagraphHandle, + type ReadGranularity, +} from "../application/playParagraphLecture"; +import { upsertLectureNote } from "../application/generateLectureNote"; import { lectureEvents } from "../infrastructure/events"; import { BEAT_TYPE_LABELS } from "../infrastructure/llm/beatLlm"; import { pathToFileUrl } from "../infrastructure/platform/paths"; @@ -15,8 +21,11 @@ import { } from "../infrastructure/storage/paths"; import { isStep1Ready, + isStep2Ready, loadLectureData, type StoredBeat, + type StoredParagraph, + type StoredSentence, } from "../infrastructure/storage/lectureStore"; import { formatSummaryForDisplay } from "../infrastructure/llm/lectureLlm"; import { paperRepository } from "../infrastructure/storage/repository"; @@ -31,6 +40,8 @@ import { resolvePaperAttachment, } from "../infrastructure/zotero/adapter"; import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash"; +import { chunkById } from "../infrastructure/pdf/chunker"; +import { highlightTextInPdf } from "../../pdf/readerSync"; import { getString } from "../../../utils/locale"; import { createLucideIcon } from "../../../utils/icons"; @@ -44,16 +55,28 @@ export class LecturePaneView { private ttsTesting = false; private audioEl?: HTMLAudioElement; private player?: PlayLectureHandle; + private paragraphPlayer?: PlayParagraphHandle; + private playMode: "overview" | "paragraph" = "paragraph"; + private readGranularity: ReadGranularity = "paragraph"; private activeBeatId?: string; + private activeParagraphId?: string; + private activeSentenceId?: string; + private pdfSyncEnabled = true; + private lectureChunks?: import("../infrastructure/pdf/chunker").TextChunk[]; private statusEl!: HTMLElement; private statusTextEl!: HTMLElement; private ttsMetaEl!: HTMLElement; private actionBtn!: HTMLButtonElement; private ttsTestBtn!: HTMLButtonElement; + private noteBtn!: HTMLButtonElement; private hintEl!: HTMLElement; private playerSection!: HTMLElement; + private modeTabsEl!: HTMLElement; + private granularityTabsEl!: HTMLElement; private beatListEl!: HTMLElement; + private paragraphDetailEl!: HTMLElement; + private playerTitleEl!: HTMLElement; private playBtn!: HTMLButtonElement; private pauseBtn!: HTMLButtonElement; private prevBtn!: HTMLButtonElement; @@ -125,34 +148,63 @@ export class LecturePaneView { ); this.ttsTestBtn.addEventListener("click", () => void this.onTtsTest()); + this.noteBtn = this.doc.createElement("button"); + this.noteBtn.className = "chatpapers-lecture-secondary"; + this.noteBtn.type = "button"; + this.noteBtn.textContent = getString("lecture-save-note"); + this.noteBtn.addEventListener("click", () => void this.onSaveNote()); + this.playerSection = this.el("div", "chatpapers-lecture-player"); this.playerSection.hidden = true; - const playerTitle = this.el( - "div", - "chatpapers-lecture-player-title", - getString("lecture-beat-list-title"), + this.modeTabsEl = this.el("div", "chatpapers-lecture-mode-tabs"); + this.modeTabsEl.setAttribute("role", "tablist"); + this.modeTabsEl.append( + this.makeModeTab("paragraph", getString("lecture-mode-paragraph")), + this.makeModeTab("overview", getString("lecture-mode-overview")), ); + + this.granularityTabsEl = this.el("div", "chatpapers-lecture-granularity-tabs"); + this.granularityTabsEl.setAttribute("role", "tablist"); + this.granularityTabsEl.append( + this.makeGranularityTab("paragraph", getString("lecture-granularity-paragraph")), + this.makeGranularityTab("sentence", getString("lecture-granularity-sentence")), + ); + + const playerTitle = this.el("div", "chatpapers-lecture-player-title"); + this.playerTitleEl = playerTitle; this.beatListEl = this.el("div", "chatpapers-lecture-beat-list"); + this.paragraphDetailEl = this.el("div", "chatpapers-lecture-para-detail"); const playerControls = this.el("div", "chatpapers-lecture-player-controls"); - this.prevBtn = this.makeIconBtn("lecture-play-prev", SkipBack, () => - void this.player?.prev(), - ); - this.playBtn = this.makeIconBtn("lecture-play-start", Play, () => - void this.player?.play(), - ); - this.pauseBtn = this.makeIconBtn("lecture-play-pause", Pause, () => - this.player?.pause(), - ); - this.nextBtn = this.makeIconBtn("lecture-play-next", SkipForward, () => - void this.player?.next(), - ); + this.prevBtn = this.makeIconBtn("lecture-play-prev", SkipBack, () => { + if (this.playMode === "paragraph") void this.paragraphPlayer?.prev(); + else void this.player?.prev(); + }); + this.playBtn = this.makeIconBtn("lecture-play-start", Play, () => { + if (this.playMode === "paragraph") void this.paragraphPlayer?.play(); + else void this.player?.play(); + }); + this.pauseBtn = this.makeIconBtn("lecture-play-pause", Pause, () => { + if (this.playMode === "paragraph") this.paragraphPlayer?.pause(); + else this.player?.pause(); + }); + this.nextBtn = this.makeIconBtn("lecture-play-next", SkipForward, () => { + if (this.playMode === "paragraph") void this.paragraphPlayer?.next(); + else void this.player?.next(); + }); this.pauseBtn.hidden = true; playerControls.append(this.prevBtn, this.playBtn, this.pauseBtn, this.nextBtn); - this.playerSection.append(playerTitle, this.beatListEl, playerControls); + this.playerSection.append( + this.modeTabsEl, + this.granularityTabsEl, + playerTitle, + this.beatListEl, + this.paragraphDetailEl, + playerControls, + ); this.audioEl = this.doc.createElement("audio"); this.audioEl.className = "chatpapers-lecture-audio"; @@ -163,7 +215,7 @@ export class LecturePaneView { this.updatePlatformHint(); const actions = this.el("div", "chatpapers-lecture-actions"); - actions.append(this.actionBtn, this.ttsTestBtn); + actions.append(this.actionBtn, this.noteBtn, this.ttsTestBtn); this.body.append( header, @@ -186,6 +238,8 @@ export class LecturePaneView { this.unsubscribers = []; this.player?.destroy(); this.player = undefined; + this.paragraphPlayer?.destroy(); + this.paragraphPlayer = undefined; this.audioEl?.pause(); this.body.replaceChildren(); this.body.classList.remove("chatpapers-root", "chatpapers-lecture-root"); @@ -219,9 +273,96 @@ export class LecturePaneView { if (!this.shouldHandleEvent(d.paperId)) return; void this.refreshPlayer(); }), + lectureEvents.on("lecture:paragraph_ready", (d) => { + if (!this.shouldHandleEvent(d.paperId)) return; + void this.refreshPlayer(); + }), ); } + private makeModeTab( + mode: "overview" | "paragraph", + label: string, + ): HTMLButtonElement { + const btn = this.doc.createElement("button"); + btn.type = "button"; + btn.className = "chatpapers-lecture-mode-tab"; + btn.dataset.mode = mode; + btn.setAttribute("role", "tab"); + btn.setAttribute("aria-selected", "false"); + btn.textContent = label; + btn.addEventListener("click", () => { + this.playMode = mode; + this.updateModeTabs(); + void this.refreshPlayer(); + }); + return btn; + } + + private makeGranularityTab( + granularity: ReadGranularity, + label: string, + ): HTMLButtonElement { + const btn = this.doc.createElement("button"); + btn.type = "button"; + btn.className = "chatpapers-lecture-granularity-tab"; + btn.dataset.granularity = granularity; + btn.setAttribute("role", "tab"); + btn.setAttribute("aria-selected", "false"); + btn.textContent = label; + btn.addEventListener("click", () => { + this.readGranularity = granularity; + this.updateGranularityTabs(); + this.paragraphPlayer?.setGranularity(granularity); + void this.refreshPlayer(); + }); + return btn; + } + + private updateGranularityTabs(): void { + for (const node of this.granularityTabsEl.querySelectorAll( + ".chatpapers-lecture-granularity-tab", + )) { + const el = node as HTMLElement; + const active = el.dataset.granularity === this.readGranularity; + el.classList.toggle("is-active", active); + el.setAttribute("aria-selected", active ? "true" : "false"); + } + this.granularityTabsEl.classList.toggle( + "is-visible", + this.playMode === "paragraph", + ); + } + + private async syncPdfHighlight( + text?: string, + page?: number, + paragraphId?: string, + ): Promise { + if (!this.pdfSyncEnabled || !text?.trim()) return; + const chunk = paragraphId + ? chunkById(this.lectureChunks, paragraphId) + : undefined; + await highlightTextInPdf(this.item, { + text, + page: page ?? chunk?.page, + }); + } + + private updateModeTabs(): void { + for (const node of this.modeTabsEl.querySelectorAll( + ".chatpapers-lecture-mode-tab", + )) { + const el = node as HTMLElement; + const active = el.dataset.mode === this.playMode; + el.classList.toggle("is-active", active); + el.setAttribute("aria-selected", active ? "true" : "false"); + } + this.modeTabsEl.classList.toggle("mode-paragraph", this.playMode === "paragraph"); + this.modeTabsEl.classList.toggle("mode-overview", this.playMode === "overview"); + this.updateGranularityTabs(); + } + private shouldHandleEvent(paperId: string): boolean { if (this.preparing) return this.paper?.id === paperId || !this.paper; return this.paper?.id === paperId; @@ -298,46 +439,326 @@ export class LecturePaneView { if (!this.cacheKey || !this.audioEl) return; const data = await loadLectureData(this.cacheKey); const beats = data?.beats ?? []; - const canPlay = isStep1Ready(data); + const paragraphs = data?.paragraphs ?? []; + const hasParagraphs = isStep2Ready(data) || paragraphs.length > 0; + const hasBeats = isStep1Ready(data); - this.playerSection.hidden = !beats.length; - this.renderBeatList(beats); + if (hasParagraphs && !hasBeats) this.playMode = "paragraph"; + if (!hasParagraphs && hasBeats) this.playMode = "overview"; - if (!this.player) { - this.player = createPlayLecture({ - audio: this.audioEl, - beats, - onBeatChange: (id) => { - this.activeBeatId = id; - this.highlightBeat(id); - }, - onPlayingChange: (playing) => { - this.playBtn.hidden = playing; - this.pauseBtn.hidden = !playing; - }, - onComplete: () => { - this.setStatus("ready", getString("lecture-play-complete")); - }, - }); - } else { - this.player.setBeats(beats); + this.playerSection.hidden = !(hasBeats || hasParagraphs); + this.updateModeTabs(); + this.noteBtn.disabled = !data?.summary; + + this.lectureChunks = data?.chunks; + + if (this.playMode === "paragraph" && hasParagraphs) { + this.playerTitleEl.textContent = + this.readGranularity === "sentence" + ? getString("lecture-sentence-list-title") + : getString("lecture-paragraph-list-title"); + this.renderParagraphList(paragraphs); + this.renderParagraphDetail( + paragraphs, + this.activeParagraphId, + this.paragraphPlayer?.getCurrentKind(), + this.activeSentenceId, + ); + + if (!this.paragraphPlayer) { + this.paragraphPlayer = createParagraphPlayer({ + audio: this.audioEl, + paragraphs, + granularity: this.readGranularity, + onSegmentChange: (info) => { + this.activeParagraphId = info.paragraphId; + this.activeSentenceId = info.sentenceId; + this.renderParagraphList(paragraphs); + this.renderParagraphDetail( + paragraphs, + info.paragraphId, + info.kind, + info.sentenceId, + ); + void this.syncPdfHighlight( + info.highlightText, + info.page, + info.paragraphId, + ); + }, + onPlayingChange: (playing) => { + this.playBtn.hidden = playing; + this.pauseBtn.hidden = !playing; + }, + onComplete: () => { + this.setStatus("ready", getString("lecture-play-complete")); + }, + }); + } else { + this.paragraphPlayer.setParagraphs(paragraphs); + this.paragraphPlayer.setGranularity(this.readGranularity); + } + this.beatListEl.hidden = false; + this.granularityTabsEl.classList.add("is-visible"); + } else if (hasBeats) { + this.granularityTabsEl.classList.remove("is-visible"); + this.playerTitleEl.textContent = getString("lecture-beat-list-title"); + this.beatListEl.hidden = false; + this.paragraphDetailEl.hidden = true; + this.renderBeatList(beats); + + if (!this.player) { + this.player = createPlayLecture({ + audio: this.audioEl, + beats, + onBeatChange: (id) => { + this.activeBeatId = id; + this.highlightBeat(id); + }, + onPlayingChange: (playing) => { + this.playBtn.hidden = playing; + this.pauseBtn.hidden = !playing; + }, + onComplete: () => { + this.setStatus("ready", getString("lecture-play-complete")); + }, + }); + } else { + this.player.setBeats(beats); + } } this.updateActionButton(); - if (canPlay && this.paper?.lectureStatus === "ready") { - const readyCount = beats.filter((b) => b.ttsStatus === "ready").length; - if (!this.preparing) { + if (this.paper?.lectureStatus === "ready" && !this.preparing) { + if (isStep2Ready(data)) { + const count = paragraphs.filter((p) => p.ttsZhStatus === "ready").length; this.setStatus( "ready", - getString("lecture-status-ready-step1", { - args: { count: readyCount }, - }), + getString("lecture-status-ready-step2", { args: { count } }), + ); + } else if (isStep1Ready(data)) { + const count = beats.filter((b) => b.ttsStatus === "ready").length; + this.setStatus( + "ready", + getString("lecture-status-ready-step1", { args: { count } }), ); } } } + private renderParagraphList(paragraphs: StoredParagraph[]): void { + this.beatListEl.replaceChildren(); + + if (this.readGranularity === "sentence") { + for (const para of paragraphs) { + for (const sentence of para.sentences ?? []) { + this.beatListEl.append(this.makeSentenceRow(para, sentence)); + } + } + return; + } + + for (const para of paragraphs) { + this.beatListEl.append(this.makeParagraphRow(para)); + } + } + + private makeParagraphRow(para: StoredParagraph): HTMLButtonElement { + const row = this.doc.createElement("button") as HTMLButtonElement; + row.type = "button"; + row.className = "chatpapers-lecture-beat-item"; + row.dataset.beatId = para.id; + if (para.id === this.activeParagraphId && !this.activeSentenceId) { + row.classList.add("is-active"); + } + if (para.ttsZhStatus !== "ready") { + row.classList.add("is-pending"); + row.disabled = para.genStatus !== "ready"; + } + + const meta = this.el("div", "chatpapers-lecture-beat-meta"); + const preview = para.originalText.slice(0, 60); + meta.append( + this.el( + "span", + "chatpapers-lecture-beat-type", + getString("lecture-para-label", { args: { n: para.orderIndex + 1 } }), + ), + this.el( + "span", + "chatpapers-lecture-beat-title", + `${preview}${para.originalText.length > 60 ? "…" : ""}`, + ), + ); + + const status = this.el("span", "chatpapers-lecture-beat-status"); + if (para.ttsZhStatus === "ready") status.textContent = "✓"; + else if (para.genStatus === "ready") { + status.textContent = getString("lecture-beat-tts-pending"); + } else { + status.textContent = "…"; + } + + row.append(meta, status); + row.addEventListener("click", () => { + void this.paragraphPlayer?.jumpToParagraphId(para.id, "en"); + void this.syncPdfHighlight( + para.originalText, + para.page, + para.id, + ); + }); + return row; + } + + private makeSentenceRow( + para: StoredParagraph, + sentence: StoredSentence, + ): HTMLButtonElement { + const row = this.doc.createElement("button") as HTMLButtonElement; + row.type = "button"; + row.className = + "chatpapers-lecture-beat-item chatpapers-lecture-sentence-item"; + row.dataset.sentenceId = sentence.id; + if (sentence.id === this.activeSentenceId) row.classList.add("is-active"); + if (sentence.ttsZhStatus !== "ready" && sentence.ttsEnStatus !== "ready") { + row.classList.add("is-pending"); + row.disabled = para.genStatus !== "ready"; + } + + const meta = this.el("div", "chatpapers-lecture-beat-meta"); + const preview = sentence.text.slice(0, 72); + meta.append( + this.el( + "span", + "chatpapers-lecture-beat-type", + getString("lecture-sentence-label", { + args: { p: para.orderIndex + 1, s: sentence.orderIndex + 1 }, + }), + ), + this.el( + "span", + "chatpapers-lecture-beat-title", + `${preview}${sentence.text.length > 72 ? "…" : ""}`, + ), + ); + + const status = this.el("span", "chatpapers-lecture-beat-status"); + if (sentence.ttsZhStatus === "ready" || sentence.ttsEnStatus === "ready") { + status.textContent = "✓"; + } else { + status.textContent = getString("lecture-beat-tts-pending"); + } + + row.append(meta, status); + row.addEventListener("click", () => { + void this.paragraphPlayer?.jumpToSentenceId(sentence.id, "en"); + void this.syncPdfHighlight( + sentence.text, + sentence.page ?? para.page, + para.id, + ); + }); + return row; + } + + private renderParagraphDetail( + paragraphs: StoredParagraph[], + paragraphId?: string, + kind?: "en" | "zh", + sentenceId?: string, + ): void { + const para = paragraphs.find((p) => p.id === paragraphId); + this.paragraphDetailEl.replaceChildren(); + if (!para) { + this.paragraphDetailEl.hidden = true; + return; + } + this.paragraphDetailEl.hidden = false; + + const sentence = sentenceId + ? para.sentences?.find((s) => s.id === sentenceId) + : undefined; + + const kindLabel = + kind === "en" + ? getString("lecture-playing-en") + : kind === "zh" + ? getString("lecture-playing-zh") + : ""; + + if (kindLabel) { + this.paragraphDetailEl.append( + this.el("div", "chatpapers-lecture-para-kind", kindLabel), + ); + } + if (para.roleInPaper && !sentence) { + this.paragraphDetailEl.append( + this.el("div", "chatpapers-lecture-para-role", para.roleInPaper), + ); + } + + const originalText = sentence?.text ?? para.originalText; + const translationText = sentence?.translation ?? para.translation; + + this.paragraphDetailEl.append( + this.el("div", "chatpapers-lecture-para-original-label", getString("lecture-para-original")), + this.el( + "div", + `chatpapers-lecture-para-original${sentence ? " is-active-sentence" : ""}`, + originalText, + ), + this.el("div", "chatpapers-lecture-para-translation-label", getString("lecture-para-translation")), + this.el("div", "chatpapers-lecture-para-translation", translationText), + ); + + if (sentence && (para.sentences?.length ?? 0) > 1) { + const sentenceList = this.el("div", "chatpapers-lecture-sentence-list"); + for (const s of para.sentences ?? []) { + const item = this.el( + "div", + `chatpapers-lecture-sentence-chip${s.id === sentence.id ? " is-active" : ""}`, + s.text, + ); + item.addEventListener("click", () => { + void this.paragraphPlayer?.jumpToSentenceId(s.id, "en"); + }); + sentenceList.append(item); + } + this.paragraphDetailEl.append( + this.el("div", "chatpapers-lecture-para-context-label", getString("lecture-sentence-list-hint")), + sentenceList, + ); + } + + if (para.contextLink && !sentence) { + this.paragraphDetailEl.append( + this.el("div", "chatpapers-lecture-para-context-label", getString("lecture-para-context")), + this.el("div", "chatpapers-lecture-para-context", para.contextLink), + ); + } + + this.paragraphDetailEl.append( + this.el("div", "chatpapers-lecture-pdf-sync-hint", getString("lecture-pdf-sync-hint")), + ); + } + + private async onSaveNote(): Promise { + if (!this.cacheKey) return; + const data = await loadLectureData(this.cacheKey); + if (!data?.summary) return; + try { + const parent = getRegularItem(this.item); + const title = String(parent.getField("title") || ""); + await upsertLectureNote({ parentItem: parent, data, paperTitle: title }); + this.setStatus("ready", getString("lecture-note-saved")); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + this.setStatus("failed", msg); + } + } + private renderBeatList(beats: StoredBeat[]): void { this.beatListEl.replaceChildren(); for (const beat of beats) { @@ -402,6 +823,12 @@ export class LecturePaneView { case "ready": { if (cacheKey) { const data = await loadLectureData(cacheKey); + if (isStep2Ready(data)) { + const count = data!.paragraphs!.filter( + (p) => p.ttsZhStatus === "ready", + ).length; + return getString("lecture-status-ready-step2", { args: { count } }); + } if (isStep1Ready(data)) { const count = data!.beats!.filter((b) => b.ttsStatus === "ready").length; return getString("lecture-status-ready-step1", { args: { count } }); diff --git a/src/modules/lecture/ui/registerLecturePane.ts b/src/modules/lecture/ui/registerLecturePane.ts index eeecc15..33aa5a1 100644 --- a/src/modules/lecture/ui/registerLecturePane.ts +++ b/src/modules/lecture/ui/registerLecturePane.ts @@ -2,6 +2,10 @@ import { config } from "../../../../package.json"; import { getLocaleID, getString } from "../../../utils/locale"; import { findPdfAttachment } from "../../pdf/extractor"; import { ensureChatPapersStyles } from "../../ui/readerPane"; +import { + onItemPaneSectionToggle, + prepareItemPaneBody, +} from "../../ui/itemPaneSection"; import { LecturePaneView } from "./lecturePane"; const views = new WeakMap(); @@ -25,6 +29,7 @@ export function registerLecturePane() { onInit: ({ body }) => { const doc = body.ownerDocument; if (doc) ensureChatPapersStyles(doc); + prepareItemPaneBody(body); }, onDestroy: ({ body }) => { views.get(body)?.destroy(); @@ -60,6 +65,13 @@ export function registerLecturePane() { err.textContent = getString("lecture-mount-error"); body.append(err); } + const section = body.closest("collapsible-section"); + if (section && !section.hasAttribute("open")) { + onItemPaneSectionToggle({ body }); + } + }, + onToggle: ({ body, event }) => { + onItemPaneSectionToggle({ body, event }); }, }); } diff --git a/src/modules/pdf/readerSync.ts b/src/modules/pdf/readerSync.ts new file mode 100644 index 0000000..f4e02e7 --- /dev/null +++ b/src/modules/pdf/readerSync.ts @@ -0,0 +1,248 @@ +/** + * Sync lecture playback to Zotero PDF reader: navigate + in-document find/highlight. + * Uses internal reader APIs (best-effort; may break across Zotero versions). + */ + +import { findPdfAttachment } from "./extractor"; + +function getInternalReader(reader: any): any { + if (!reader) return undefined; + try { + if (reader._internalReader) return reader._internalReader; + const wrapped = + reader._iframeWindow?.wrappedJSObject?._reader || + reader._iframeWindow?.wrappedJSObject?.reader; + if (wrapped) return wrapped; + } catch { + // ignore cross-origin + } + return undefined; +} + +function getPrimaryView(reader: any): any { + const internal = getInternalReader(reader); + return ( + internal?._activePrimaryView ?? + internal?._primaryView ?? + internal?._lastView + ); +} + +async function openReaderForAttachment(attachment: Zotero.Item): Promise { + try { + return await (Zotero.Reader as any).open(attachment.id); + } catch (e) { + ztoolkit.log("[ChatPapers:ReaderSync] open failed", e); + return undefined; + } +} + +function findReaderForAttachment(attachmentId: number): any { + try { + const readers = + (Zotero.Reader as any).getAll?.() || + (Zotero.Reader as any)._readers || + []; + return readers.find( + (r: any) => Number(r.itemID ?? r._itemID) === Number(attachmentId), + ); + } catch { + return undefined; + } +} + +/** Build a short phrase suitable for PDF in-document search. */ +export function buildPdfSearchQuery(text: string, maxLen = 72): string { + const normalized = text + .replace(/[\u0000-\u001f\f]/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!normalized) return ""; + if (normalized.length <= maxLen) return normalized; + const snippet = normalized.slice(0, maxLen); + const lastSpace = snippet.lastIndexOf(" "); + return lastSpace > 32 ? snippet.slice(0, lastSpace) : snippet; +} + +/** Multiple progressively shorter queries — PDF text may differ from extraction. */ +function buildQueryCandidates(text: string): string[] { + const normalized = text + .replace(/[\u0000-\u001f\f]/g, " ") + .replace(/\s+/g, " ") + .trim(); + const words = normalized.split(/\s+/).filter((w) => w.length > 1); + const out: string[] = []; + + if (words.length >= 8) out.push(words.slice(0, 10).join(" ")); + if (words.length >= 5) out.push(words.slice(0, 6).join(" ")); + if (words.length >= 3) out.push(words.slice(0, 4).join(" ")); + + const q50 = buildPdfSearchQuery(text, 50); + const q30 = buildPdfSearchQuery(text, 30); + if (q50) out.push(q50); + if (q30 && q30 !== q50) out.push(q30); + + return [...new Set(out.filter((q) => q.length >= 8))]; +} + +async function waitForReaderReady(reader: any): Promise { + if (reader?._initPromise) { + try { + await reader._initPromise; + } catch (e) { + ztoolkit.log("[ChatPapers:ReaderSync] reader init failed", e); + } + } + + for (let i = 0; i < 40; i++) { + const internal = getInternalReader(reader); + if (internal?._updateState || getPrimaryView(reader)?.setFindState) return; + await Zotero.Promise.delay(100); + } +} + +async function navigateReader(reader: any, page?: number): Promise { + if (!page || page < 1) return; + const location = { pageIndex: page - 1, pageNumber: page }; + try { + if (typeof reader?.navigate === "function") { + await reader.navigate(location); + return; + } + const internal = getInternalReader(reader); + if (typeof internal?.navigate === "function") { + await internal.navigate(location); + } + } catch (e) { + ztoolkit.log("[ChatPapers:ReaderSync] navigate failed", e); + } +} + +function makeFindState(query: string) { + return { + popupOpen: false, + active: true, + query, + highlightAll: true, + caseSensitive: false, + entireWord: false, + index: 0, + result: null, + }; +} + +function applyFindHighlight(reader: any, query: string): boolean { + const internal = getInternalReader(reader); + const findState = makeFindState(query); + const payload = { primaryViewFindState: findState }; + + try { + if (internal?._updateState) { + const win = reader?._iframeWindow; + const state = + win && typeof Components !== "undefined" + ? Components.utils.cloneInto(payload, win) + : payload; + internal._updateState(state); + try { + internal.findNext?.(true); + } catch { + // ignore + } + return true; + } + } catch (e) { + ztoolkit.log("[ChatPapers:ReaderSync] _updateState find failed", e); + } + + try { + const view = getPrimaryView(reader); + if (view?.setFindState) { + view.setFindState(findState); + try { + view.findNext?.(); + } catch { + // ignore + } + return true; + } + } catch (e) { + ztoolkit.log("[ChatPapers:ReaderSync] view setFindState failed", e); + } + + return false; +} + +function clearFindHighlight(reader: any): void { + const cleared = makeFindState(""); + cleared.active = false; + const payload = { primaryViewFindState: cleared }; + try { + const internal = getInternalReader(reader); + if (internal?._updateState) { + const win = reader?._iframeWindow; + const state = + win && typeof Components !== "undefined" + ? Components.utils.cloneInto(payload, win) + : payload; + internal._updateState(state); + return; + } + getPrimaryView(reader)?.setFindState?.(cleared); + } catch (e) { + ztoolkit.log("[ChatPapers:ReaderSync] clear find failed", e); + } +} + +export interface PdfHighlightTarget { + text: string; + page?: number; +} + +/** Open PDF (if needed), jump to page, and highlight matching text. */ +export async function highlightTextInPdf( + item: Zotero.Item, + target: PdfHighlightTarget, +): Promise { + const attachment = findPdfAttachment(item); + if (!attachment || !target.text?.trim()) return; + + const candidates = buildQueryCandidates(target.text); + if (!candidates.length) return; + + try { + let reader = findReaderForAttachment(attachment.id); + if (!reader) { + reader = await openReaderForAttachment(attachment); + } + if (!reader) return; + + await waitForReaderReady(reader); + await navigateReader(reader, target.page); + await Zotero.Promise.delay(350); + + for (const query of candidates) { + if (applyFindHighlight(reader, query)) { + await Zotero.Promise.delay(200); + ztoolkit.log("[ChatPapers:ReaderSync] find applied:", query.slice(0, 40)); + return; + } + } + ztoolkit.log("[ChatPapers:ReaderSync] all find attempts failed"); + } catch (e) { + ztoolkit.log("[ChatPapers:ReaderSync] highlightTextInPdf failed", e); + } +} + +/** Remove in-document search highlight from the open reader. */ +export async function clearPdfHighlight(item: Zotero.Item): Promise { + const attachment = findPdfAttachment(item); + if (!attachment) return; + try { + const reader = findReaderForAttachment(attachment.id); + if (!reader) return; + clearFindHighlight(reader); + } catch (e) { + ztoolkit.log("[ChatPapers:ReaderSync] clearPdfHighlight failed", e); + } +} diff --git a/src/modules/ui/itemPaneSection.ts b/src/modules/ui/itemPaneSection.ts new file mode 100644 index 0000000..11ad068 --- /dev/null +++ b/src/modules/ui/itemPaneSection.ts @@ -0,0 +1,49 @@ +/** Helpers for Zotero ItemPane collapsible sections. */ + +export function prepareItemPaneBody(body: HTMLElement): void { + body.classList.add("chatpapers-pane-body"); + body.style.minHeight = "0"; +} + +export function onItemPaneSectionToggle(options: { + body: HTMLElement; + event?: Event; +}): void { + const section = resolveCollapsibleSection(options.body, options.event); + if (!section) return; + + const open = section.hasAttribute("open"); + if (open) { + options.body.style.removeProperty("height"); + options.body.style.removeProperty("overflow"); + options.body.style.minHeight = "0"; + const raf = options.body.ownerDocument?.defaultView?.requestAnimationFrame; + const updateHeight = () => { + const h = options.body.scrollHeight; + if (h > 0) { + section.style.setProperty("--open-height", `${h}px`); + } + }; + if (raf) raf(updateHeight); + else updateHeight(); + return; + } + + options.body.style.minHeight = "0"; + options.body.style.height = "0"; + options.body.style.overflow = "hidden"; + section.style.setProperty("--open-height", "0px"); +} + +function resolveCollapsibleSection( + body: HTMLElement, + event?: Event, +): HTMLElement | null { + const fromEvent = (event?.currentTarget ?? event?.target) as + | HTMLElement + | undefined; + return ( + (fromEvent?.closest?.("collapsible-section") as HTMLElement | null) ?? + (body.closest("collapsible-section") as HTMLElement | null) + ); +} diff --git a/src/modules/ui/readerPane.ts b/src/modules/ui/readerPane.ts index 27a1c6c..59e66e4 100644 --- a/src/modules/ui/readerPane.ts +++ b/src/modules/ui/readerPane.ts @@ -1,6 +1,7 @@ import { config } from "../../../package.json"; import { getLocaleID, getString } from "../../utils/locale"; import { ChatView } from "./chatView"; +import { onItemPaneSectionToggle, prepareItemPaneBody } from "./itemPaneSection"; const views = new WeakMap(); @@ -23,6 +24,7 @@ export function registerChatPane() { onInit: ({ body }) => { const doc = body.ownerDocument; if (doc) ensureStyles(doc); + prepareItemPaneBody(body); }, onDestroy: ({ body }) => { views.get(body)?.destroy(); @@ -51,6 +53,13 @@ export function registerChatPane() { const view = new ChatView(doc, body, item); views.set(body, view); await view.mount(); + const section = body.closest("collapsible-section"); + if (section && !section.hasAttribute("open")) { + onItemPaneSectionToggle({ body }); + } + }, + onToggle: ({ body, event }) => { + onItemPaneSectionToggle({ body, event }); }, }); } diff --git a/typings/i10n.d.ts b/typings/i10n.d.ts index 94d6738..90ea476 100644 --- a/typings/i10n.d.ts +++ b/typings/i10n.d.ts @@ -45,10 +45,21 @@ export type FluentMessageId = | 'lecture-beat-tts-pending' | 'lecture-empty-desc' | 'lecture-empty-title' + | 'lecture-granularity-paragraph' + | 'lecture-granularity-sentence' | 'lecture-loading' | 'lecture-mode-label' + | 'lecture-mode-overview' + | 'lecture-mode-paragraph' | 'lecture-mount-error' | 'lecture-no-pdf' + | 'lecture-note-saved' + | 'lecture-para-context' + | 'lecture-para-label' + | 'lecture-para-original' + | 'lecture-para-translation' + | 'lecture-paragraph-list-title' + | 'lecture-pdf-sync-hint' | 'lecture-platform-hint-mac' | 'lecture-platform-hint-other' | 'lecture-platform-hint-win' @@ -57,7 +68,13 @@ export type FluentMessageId = | 'lecture-play-pause' | 'lecture-play-prev' | 'lecture-play-start' + | 'lecture-playing-en' + | 'lecture-playing-zh' | 'lecture-reprepare' + | 'lecture-save-note' + | 'lecture-sentence-label' + | 'lecture-sentence-list-hint' + | 'lecture-sentence-list-title' | 'lecture-start' | 'lecture-status-failed' | 'lecture-status-idle' @@ -65,6 +82,7 @@ export type FluentMessageId = | 'lecture-status-ready' | 'lecture-status-ready-phase1' | 'lecture-status-ready-step1' + | 'lecture-status-ready-step2' | 'lecture-tts-provider' | 'lecture-tts-test' | 'lecture-tts-test-done'