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'