增加阅读是pdf中高亮显示
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<!-- ChatPapers voice lecture: paper + headphones -->
|
||||
<!-- ChatPapers 语音伴读:论文 + 声波 + 耳机(与对话气泡图标区分) -->
|
||||
<path
|
||||
fill="context-fill"
|
||||
fill-opacity="0.92"
|
||||
@@ -10,19 +10,55 @@
|
||||
fill-opacity="0.35"
|
||||
d="M10.6 2.85v2.35c0 .45.37.82.82.82h2.35L10.6 2.85z"
|
||||
/>
|
||||
<!-- 声波(替代对话图标的横线文本) -->
|
||||
<rect x="4.4" y="8.35" width="1.15" height="2.8" rx="0.4" fill="context-fill" fill-opacity="0.5" />
|
||||
<rect x="6.05" y="7.25" width="1.15" height="4.2" rx="0.4" fill="context-fill" fill-opacity="0.72" />
|
||||
<rect x="7.7" y="8.65" width="1.15" height="2.2" rx="0.4" fill="context-fill" fill-opacity="0.5" />
|
||||
<rect x="9.35" y="7.75" width="1.15" height="3.5" rx="0.4" fill="context-fill" fill-opacity="0.62" />
|
||||
<!-- 耳机头梁 -->
|
||||
<path
|
||||
stroke="context-fill"
|
||||
stroke-opacity="0.92"
|
||||
stroke-width="1.15"
|
||||
stroke-linecap="round"
|
||||
fill="none"
|
||||
d="M12.85 9.15c0-1.55 1.15-2.8 2.55-2.8s2.55 1.25 2.55 2.8"
|
||||
/>
|
||||
<!-- 左耳罩 -->
|
||||
<rect
|
||||
x="11.55"
|
||||
y="9.05"
|
||||
width="2.15"
|
||||
height="3.55"
|
||||
rx="0.85"
|
||||
fill="context-fill"
|
||||
fill-opacity="0.92"
|
||||
/>
|
||||
<!-- 右耳罩 -->
|
||||
<rect
|
||||
x="14.55"
|
||||
y="9.05"
|
||||
width="2.15"
|
||||
height="3.55"
|
||||
rx="0.85"
|
||||
fill="context-fill"
|
||||
fill-opacity="0.92"
|
||||
/>
|
||||
<!-- 向外声波弧线 -->
|
||||
<path
|
||||
stroke="context-stroke"
|
||||
stroke-opacity="0.55"
|
||||
stroke-width="0.9"
|
||||
stroke-opacity="0.45"
|
||||
stroke-width="0.85"
|
||||
stroke-linecap="round"
|
||||
d="M4.35 7.1h5.1M4.35 9.15h4.2"
|
||||
fill="none"
|
||||
d="M17.15 10.1c.55.45.9 1.05.9 1.75s-.35 1.3-.9 1.75"
|
||||
/>
|
||||
<path
|
||||
fill="context-fill"
|
||||
d="M12.1 8.35c1.45 0 2.65 1.15 2.65 2.55v2.1c0 .35-.28.62-.62.62h-.75v1.05c0 .55-.45 1-1 1h-.55c-.55 0-1-.45-1-1v-1.05h-.55c-.35 0-.62-.28-.62-.62v-2.1c0-1.4 1.2-2.55 2.65-2.55z"
|
||||
/>
|
||||
<path
|
||||
fill="context-fill"
|
||||
d="M16.55 9.05c1.05 0 1.9.82 1.9 1.85v1.35c0 1-.85 1.85-1.9 1.85-.35 0-.62-.28-.62-.62v-3.8c0-.35.28-.62.62-.62z"
|
||||
stroke="context-stroke"
|
||||
stroke-opacity="0.3"
|
||||
stroke-width="0.85"
|
||||
stroke-linecap="round"
|
||||
fill="none"
|
||||
d="M17.85 10.1c.85.7 1.35 1.65 1.35 2.75s-.5 2.05-1.35 2.75"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1020 B After Width: | Height: | Size: 1.9 KiB |
@@ -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
|
||||
|
||||
@@ -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 = 语音伴读面板加载失败
|
||||
|
||||
@@ -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<Zotero.Item> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<void>;
|
||||
pause: () => void;
|
||||
next: () => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
jumpToParagraphId: (paragraphId: string, kind?: "en" | "zh") => Promise<void>;
|
||||
jumpToSentenceId: (sentenceId: string, kind?: "en" | "zh") => Promise<void>;
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, Promise<PaperCache>>();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<Record<string, unknown>>(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<ParagraphLlmResult> {
|
||||
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",
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
interface RawPara {
|
||||
text: string;
|
||||
page: number;
|
||||
}
|
||||
|
||||
function normalizePara(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
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((s) => s.replace(/\s+/g, " ").trim())
|
||||
.map(normalizePara)
|
||||
.filter((s) => s.length > 40);
|
||||
for (const para of paras) {
|
||||
out.push({ text: para, page: pageNum });
|
||||
}
|
||||
}
|
||||
|
||||
const merged: string[] = [];
|
||||
if (!out.length && text.trim()) {
|
||||
out.push({ text: normalizePara(text).slice(0, MAX_CHUNK_CHARS), page: 1 });
|
||||
}
|
||||
|
||||
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 paragraphs) {
|
||||
for (const para of paras) {
|
||||
if (
|
||||
buffer &&
|
||||
(buffer.length + para.length > MAX_CHUNK_CHARS ||
|
||||
(buffer.length + para.text.length > MAX_CHUNK_CHARS ||
|
||||
(buffer.length >= MIN_CHUNK_CHARS && merged.length >= MAX_CHUNKS - 1))
|
||||
) {
|
||||
merged.push(buffer);
|
||||
buffer = para;
|
||||
merged.push({ text: buffer, page: bufferPage });
|
||||
buffer = para.text;
|
||||
bufferPage = para.page;
|
||||
} else {
|
||||
buffer = buffer ? `${buffer} ${para}` : para;
|
||||
if (!buffer) bufferPage = para.page;
|
||||
buffer = buffer ? `${buffer} ${para.text}` : para.text;
|
||||
}
|
||||
}
|
||||
if (buffer) merged.push(buffer);
|
||||
|
||||
if (!merged.length && text.trim()) {
|
||||
merged.push(text.trim().slice(0, MAX_CHUNK_CHARS));
|
||||
if (buffer) merged.push({ text: buffer, page: bufferPage });
|
||||
return merged;
|
||||
}
|
||||
|
||||
return merged.slice(0, MAX_CHUNKS).map((chunkText, orderIndex) => ({
|
||||
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.slice(0, 2000),
|
||||
}));
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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,9 +439,72 @@ 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;
|
||||
if (hasParagraphs && !hasBeats) this.playMode = "paragraph";
|
||||
if (!hasParagraphs && hasBeats) this.playMode = "overview";
|
||||
|
||||
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) {
|
||||
@@ -322,22 +526,239 @@ export class LecturePaneView {
|
||||
} 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<void> {
|
||||
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 } });
|
||||
|
||||
@@ -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<HTMLElement, LecturePaneView>();
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<any> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLElement, ChatView>();
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+18
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user