增加阅读是pdf中高亮显示
This commit is contained in:
@@ -3,13 +3,32 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
min-height: 320px;
|
min-height: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
-moz-user-select: text;
|
-moz-user-select: text;
|
||||||
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 {
|
.chatpapers-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -216,9 +235,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chatpapers-messages {
|
.chatpapers-messages {
|
||||||
flex: 1;
|
flex: 1 1 auto;
|
||||||
min-height: 180px;
|
min-height: 0;
|
||||||
max-height: 420px;
|
max-height: min(420px, 45vh);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -856,6 +875,183 @@
|
|||||||
line-height: 1.4;
|
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 {
|
.chatpapers-lecture-player {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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">
|
<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
|
<path
|
||||||
fill="context-fill"
|
fill="context-fill"
|
||||||
fill-opacity="0.92"
|
fill-opacity="0.92"
|
||||||
@@ -10,19 +10,55 @@
|
|||||||
fill-opacity="0.35"
|
fill-opacity="0.35"
|
||||||
d="M10.6 2.85v2.35c0 .45.37.82.82.82h2.35L10.6 2.85z"
|
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
|
<path
|
||||||
stroke="context-stroke"
|
stroke="context-stroke"
|
||||||
stroke-opacity="0.55"
|
stroke-opacity="0.45"
|
||||||
stroke-width="0.9"
|
stroke-width="0.85"
|
||||||
stroke-linecap="round"
|
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
|
<path
|
||||||
fill="context-fill"
|
stroke="context-stroke"
|
||||||
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"
|
stroke-opacity="0.3"
|
||||||
/>
|
stroke-width="0.85"
|
||||||
<path
|
stroke-linecap="round"
|
||||||
fill="context-fill"
|
fill="none"
|
||||||
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"
|
d="M17.85 10.1c.85.7 1.35 1.65 1.35 2.75s-.5 2.05-1.35 2.75"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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-pending = Synthesizing
|
||||||
lecture-beat-tts-failed = Failed
|
lecture-beat-tts-failed = Failed
|
||||||
lecture-reprepare = Re-prepare
|
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-no-pdf = No PDF attachment on this item
|
||||||
lecture-attach-error = Cannot read PDF attachment
|
lecture-attach-error = Cannot read PDF attachment
|
||||||
lecture-mount-error = Failed to load voice lecture pane
|
lecture-mount-error = Failed to load voice lecture pane
|
||||||
|
|||||||
@@ -82,6 +82,24 @@ lecture-play-complete = 本轮讲解播放完毕
|
|||||||
lecture-beat-tts-pending = 合成中
|
lecture-beat-tts-pending = 合成中
|
||||||
lecture-beat-tts-failed = 失败
|
lecture-beat-tts-failed = 失败
|
||||||
lecture-reprepare = 重新备课
|
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-no-pdf = 当前条目没有可用的 PDF 附件
|
||||||
lecture-attach-error = 无法读取 PDF 附件
|
lecture-attach-error = 无法读取 PDF 附件
|
||||||
lecture-mount-error = 语音伴读面板加载失败
|
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 type { LectureStatus, PaperCache } from "../domain/types";
|
||||||
|
import { upsertLectureNote } from "./generateLectureNote";
|
||||||
import { generateBeats } from "../infrastructure/llm/beatLlm";
|
import { generateBeats } from "../infrastructure/llm/beatLlm";
|
||||||
import {
|
import {
|
||||||
formatSummaryForDisplay,
|
|
||||||
generatePaperSummary,
|
generatePaperSummary,
|
||||||
} from "../infrastructure/llm/lectureLlm";
|
} from "../infrastructure/llm/lectureLlm";
|
||||||
|
import {
|
||||||
|
buildRollingContext,
|
||||||
|
generateParagraphExplanation,
|
||||||
|
isPrimarilyEnglish,
|
||||||
|
toStoredParagraph,
|
||||||
|
} from "../infrastructure/llm/paragraphLlm";
|
||||||
import {
|
import {
|
||||||
chunkPaperText,
|
chunkPaperText,
|
||||||
formatChunkOutline,
|
formatChunkOutline,
|
||||||
} from "../infrastructure/pdf/chunker";
|
} from "../infrastructure/pdf/chunker";
|
||||||
|
import { splitSentences } from "../infrastructure/pdf/sentenceSplit";
|
||||||
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
|
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
|
||||||
import {
|
import {
|
||||||
ensureAudioDir,
|
ensureAudioDir,
|
||||||
} from "../infrastructure/storage/audioStorage";
|
} from "../infrastructure/storage/audioStorage";
|
||||||
import { lectureEvents } from "../infrastructure/events";
|
import { lectureEvents } from "../infrastructure/events";
|
||||||
import {
|
import {
|
||||||
isStep1Ready,
|
isLectureComplete,
|
||||||
loadLectureData,
|
loadLectureData,
|
||||||
mergeLectureData,
|
mergeLectureData,
|
||||||
type LectureData,
|
type LectureData,
|
||||||
type StoredBeat,
|
type StoredParagraph,
|
||||||
} from "../infrastructure/storage/lectureStore";
|
} from "../infrastructure/storage/lectureStore";
|
||||||
import { audioFilePath } from "../infrastructure/storage/paths";
|
import { audioFilePath } from "../infrastructure/storage/paths";
|
||||||
import { paperRepository } from "../infrastructure/storage/repository";
|
import { paperRepository } from "../infrastructure/storage/repository";
|
||||||
@@ -33,6 +40,11 @@ import {
|
|||||||
} from "../../pdf/extractor";
|
} from "../../pdf/extractor";
|
||||||
import { LlmError } from "../../llm/types";
|
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 {
|
export class PrepareLectureUseCase {
|
||||||
private inflight = new Map<string, Promise<PaperCache>>();
|
private inflight = new Map<string, Promise<PaperCache>>();
|
||||||
|
|
||||||
@@ -75,7 +87,7 @@ export class PrepareLectureUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await loadLectureData(ctx.cacheKey);
|
const data = await loadLectureData(ctx.cacheKey);
|
||||||
if (paper.lectureStatus === "ready" && isStep1Ready(data)) {
|
if (paper.lectureStatus === "ready" && isLectureComplete(data)) {
|
||||||
return paper;
|
return paper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,21 +262,293 @@ export class PrepareLectureUseCase {
|
|||||||
data.phase = 2;
|
data.phase = 2;
|
||||||
await mergeLectureData(cacheKey, paper.id, data);
|
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.lectureStatus = "ready";
|
||||||
paper.updatedAt = Date.now();
|
paper.updatedAt = Date.now();
|
||||||
await paperRepository.upsert(cacheKey, paper);
|
await paperRepository.upsert(cacheKey, paper);
|
||||||
|
|
||||||
const readyCount = beats.filter((b) => b.ttsStatus === "ready").length;
|
const readyBeats = beats.filter((b) => b.ttsStatus === "ready").length;
|
||||||
const preview = formatSummaryForDisplay(data.summary!);
|
const readyParas = paragraphs.filter((p) => p.ttsZhStatus === "ready").length;
|
||||||
lectureEvents.emit("lecture:status", {
|
lectureEvents.emit("lecture:status", {
|
||||||
paperId: paper.id,
|
paperId: paper.id,
|
||||||
lectureStatus: "ready",
|
lectureStatus: "ready",
|
||||||
message: `备课完成:${readyCount} 段讲解可播放\n${preview}`,
|
message: `备课完成:${readyParas} 段逐段精读 + ${readyBeats} 段整体讲解\n笔记已写入 Zotero 子笔记`,
|
||||||
});
|
});
|
||||||
lectureEvents.emit("parse:progress", {
|
lectureEvents.emit("parse:progress", {
|
||||||
paperId: paper.id,
|
paperId: paper.id,
|
||||||
percent: 100,
|
percent: 100,
|
||||||
message: "整体讲解已就绪",
|
message: "逐段精读与笔记已就绪",
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
paper.parseStatus = "failed";
|
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;
|
id: string;
|
||||||
orderIndex: number;
|
orderIndex: number;
|
||||||
text: string;
|
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 MIN_CHUNK_CHARS = 200;
|
||||||
const MAX_CHUNK_CHARS = 900;
|
const MAX_CHUNK_CHARS = 900;
|
||||||
const MAX_CHUNKS = 40;
|
const MAX_CHUNKS = 40;
|
||||||
|
|
||||||
export function chunkPaperText(text: string): TextChunk[] {
|
interface RawPara {
|
||||||
const paragraphs = text
|
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+/)
|
.split(/\n\s*\n+/)
|
||||||
.map((s) => s.replace(/\s+/g, " ").trim())
|
.map(normalizePara)
|
||||||
.filter((s) => s.length > 40);
|
.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 buffer = "";
|
||||||
|
let bufferPage = 1;
|
||||||
|
|
||||||
for (const para of paragraphs) {
|
for (const para of paras) {
|
||||||
if (
|
if (
|
||||||
buffer &&
|
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))
|
(buffer.length >= MIN_CHUNK_CHARS && merged.length >= MAX_CHUNKS - 1))
|
||||||
) {
|
) {
|
||||||
merged.push(buffer);
|
merged.push({ text: buffer, page: bufferPage });
|
||||||
buffer = para;
|
buffer = para.text;
|
||||||
|
bufferPage = para.page;
|
||||||
} else {
|
} else {
|
||||||
buffer = buffer ? `${buffer} ${para}` : para;
|
if (!buffer) bufferPage = para.page;
|
||||||
|
buffer = buffer ? `${buffer} ${para.text}` : para.text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (buffer) merged.push(buffer);
|
if (buffer) merged.push({ text: buffer, page: bufferPage });
|
||||||
|
return merged;
|
||||||
if (!merged.length && text.trim()) {
|
|
||||||
merged.push(text.trim().slice(0, MAX_CHUNK_CHARS));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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}`,
|
id: `p-${orderIndex}`,
|
||||||
orderIndex,
|
orderIndex,
|
||||||
text: chunkText.slice(0, 2000),
|
text: chunkText,
|
||||||
}));
|
page: chunk.page,
|
||||||
|
searchText: chunkText.slice(0, 80),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compact outline for LLM beats generation. */
|
/** Compact outline for LLM beats generation. */
|
||||||
@@ -49,7 +90,15 @@ export function formatChunkOutline(chunks: TextChunk[]): string {
|
|||||||
return chunks
|
return chunks
|
||||||
.map(
|
.map(
|
||||||
(c) =>
|
(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");
|
.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";
|
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 {
|
export interface LectureData {
|
||||||
paperId: string;
|
paperId: string;
|
||||||
cacheKey: string;
|
cacheKey: string;
|
||||||
/** 1 = summary only, 2 = beats + TTS (Step 1) */
|
/** 1 = summary, 2 = beats+TTS, 3 = paragraphs+note */
|
||||||
phase?: 1 | 2;
|
phase?: 1 | 2 | 3;
|
||||||
summary?: SummaryJson;
|
summary?: SummaryJson;
|
||||||
chunks?: TextChunk[];
|
chunks?: TextChunk[];
|
||||||
beats?: StoredBeat[];
|
beats?: StoredBeat[];
|
||||||
|
paragraphs?: StoredParagraph[];
|
||||||
|
noteItemId?: string;
|
||||||
extractChars?: number;
|
extractChars?: number;
|
||||||
extractSource?: string;
|
extractSource?: string;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -89,7 +121,27 @@ export function isStep1Ready(data: LectureData | undefined): boolean {
|
|||||||
return data.beats.some((b) => b.ttsStatus === "ready" && b.audioPath);
|
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[] {
|
export function playableBeats(data: LectureData | undefined): StoredBeat[] {
|
||||||
if (!data?.beats) return [];
|
if (!data?.beats) return [];
|
||||||
return data.beats.filter((b) => b.ttsStatus === "ready" && b.audioPath);
|
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,
|
createPlayLecture,
|
||||||
type PlayLectureHandle,
|
type PlayLectureHandle,
|
||||||
} from "../application/playLecture";
|
} from "../application/playLecture";
|
||||||
|
import {
|
||||||
|
createParagraphPlayer,
|
||||||
|
type PlayParagraphHandle,
|
||||||
|
type ReadGranularity,
|
||||||
|
} from "../application/playParagraphLecture";
|
||||||
|
import { upsertLectureNote } from "../application/generateLectureNote";
|
||||||
import { lectureEvents } from "../infrastructure/events";
|
import { lectureEvents } from "../infrastructure/events";
|
||||||
import { BEAT_TYPE_LABELS } from "../infrastructure/llm/beatLlm";
|
import { BEAT_TYPE_LABELS } from "../infrastructure/llm/beatLlm";
|
||||||
import { pathToFileUrl } from "../infrastructure/platform/paths";
|
import { pathToFileUrl } from "../infrastructure/platform/paths";
|
||||||
@@ -15,8 +21,11 @@ import {
|
|||||||
} from "../infrastructure/storage/paths";
|
} from "../infrastructure/storage/paths";
|
||||||
import {
|
import {
|
||||||
isStep1Ready,
|
isStep1Ready,
|
||||||
|
isStep2Ready,
|
||||||
loadLectureData,
|
loadLectureData,
|
||||||
type StoredBeat,
|
type StoredBeat,
|
||||||
|
type StoredParagraph,
|
||||||
|
type StoredSentence,
|
||||||
} from "../infrastructure/storage/lectureStore";
|
} from "../infrastructure/storage/lectureStore";
|
||||||
import { formatSummaryForDisplay } from "../infrastructure/llm/lectureLlm";
|
import { formatSummaryForDisplay } from "../infrastructure/llm/lectureLlm";
|
||||||
import { paperRepository } from "../infrastructure/storage/repository";
|
import { paperRepository } from "../infrastructure/storage/repository";
|
||||||
@@ -31,6 +40,8 @@ import {
|
|||||||
resolvePaperAttachment,
|
resolvePaperAttachment,
|
||||||
} from "../infrastructure/zotero/adapter";
|
} from "../infrastructure/zotero/adapter";
|
||||||
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
|
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
|
||||||
|
import { chunkById } from "../infrastructure/pdf/chunker";
|
||||||
|
import { highlightTextInPdf } from "../../pdf/readerSync";
|
||||||
import { getString } from "../../../utils/locale";
|
import { getString } from "../../../utils/locale";
|
||||||
import { createLucideIcon } from "../../../utils/icons";
|
import { createLucideIcon } from "../../../utils/icons";
|
||||||
|
|
||||||
@@ -44,16 +55,28 @@ export class LecturePaneView {
|
|||||||
private ttsTesting = false;
|
private ttsTesting = false;
|
||||||
private audioEl?: HTMLAudioElement;
|
private audioEl?: HTMLAudioElement;
|
||||||
private player?: PlayLectureHandle;
|
private player?: PlayLectureHandle;
|
||||||
|
private paragraphPlayer?: PlayParagraphHandle;
|
||||||
|
private playMode: "overview" | "paragraph" = "paragraph";
|
||||||
|
private readGranularity: ReadGranularity = "paragraph";
|
||||||
private activeBeatId?: string;
|
private activeBeatId?: string;
|
||||||
|
private activeParagraphId?: string;
|
||||||
|
private activeSentenceId?: string;
|
||||||
|
private pdfSyncEnabled = true;
|
||||||
|
private lectureChunks?: import("../infrastructure/pdf/chunker").TextChunk[];
|
||||||
|
|
||||||
private statusEl!: HTMLElement;
|
private statusEl!: HTMLElement;
|
||||||
private statusTextEl!: HTMLElement;
|
private statusTextEl!: HTMLElement;
|
||||||
private ttsMetaEl!: HTMLElement;
|
private ttsMetaEl!: HTMLElement;
|
||||||
private actionBtn!: HTMLButtonElement;
|
private actionBtn!: HTMLButtonElement;
|
||||||
private ttsTestBtn!: HTMLButtonElement;
|
private ttsTestBtn!: HTMLButtonElement;
|
||||||
|
private noteBtn!: HTMLButtonElement;
|
||||||
private hintEl!: HTMLElement;
|
private hintEl!: HTMLElement;
|
||||||
private playerSection!: HTMLElement;
|
private playerSection!: HTMLElement;
|
||||||
|
private modeTabsEl!: HTMLElement;
|
||||||
|
private granularityTabsEl!: HTMLElement;
|
||||||
private beatListEl!: HTMLElement;
|
private beatListEl!: HTMLElement;
|
||||||
|
private paragraphDetailEl!: HTMLElement;
|
||||||
|
private playerTitleEl!: HTMLElement;
|
||||||
private playBtn!: HTMLButtonElement;
|
private playBtn!: HTMLButtonElement;
|
||||||
private pauseBtn!: HTMLButtonElement;
|
private pauseBtn!: HTMLButtonElement;
|
||||||
private prevBtn!: HTMLButtonElement;
|
private prevBtn!: HTMLButtonElement;
|
||||||
@@ -125,34 +148,63 @@ export class LecturePaneView {
|
|||||||
);
|
);
|
||||||
this.ttsTestBtn.addEventListener("click", () => void this.onTtsTest());
|
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 = this.el("div", "chatpapers-lecture-player");
|
||||||
this.playerSection.hidden = true;
|
this.playerSection.hidden = true;
|
||||||
|
|
||||||
const playerTitle = this.el(
|
this.modeTabsEl = this.el("div", "chatpapers-lecture-mode-tabs");
|
||||||
"div",
|
this.modeTabsEl.setAttribute("role", "tablist");
|
||||||
"chatpapers-lecture-player-title",
|
this.modeTabsEl.append(
|
||||||
getString("lecture-beat-list-title"),
|
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.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");
|
const playerControls = this.el("div", "chatpapers-lecture-player-controls");
|
||||||
|
|
||||||
this.prevBtn = this.makeIconBtn("lecture-play-prev", SkipBack, () =>
|
this.prevBtn = this.makeIconBtn("lecture-play-prev", SkipBack, () => {
|
||||||
void this.player?.prev(),
|
if (this.playMode === "paragraph") void this.paragraphPlayer?.prev();
|
||||||
);
|
else void this.player?.prev();
|
||||||
this.playBtn = this.makeIconBtn("lecture-play-start", Play, () =>
|
});
|
||||||
void this.player?.play(),
|
this.playBtn = this.makeIconBtn("lecture-play-start", Play, () => {
|
||||||
);
|
if (this.playMode === "paragraph") void this.paragraphPlayer?.play();
|
||||||
this.pauseBtn = this.makeIconBtn("lecture-play-pause", Pause, () =>
|
else void this.player?.play();
|
||||||
this.player?.pause(),
|
});
|
||||||
);
|
this.pauseBtn = this.makeIconBtn("lecture-play-pause", Pause, () => {
|
||||||
this.nextBtn = this.makeIconBtn("lecture-play-next", SkipForward, () =>
|
if (this.playMode === "paragraph") this.paragraphPlayer?.pause();
|
||||||
void this.player?.next(),
|
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;
|
this.pauseBtn.hidden = true;
|
||||||
|
|
||||||
playerControls.append(this.prevBtn, this.playBtn, this.pauseBtn, this.nextBtn);
|
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 = this.doc.createElement("audio");
|
||||||
this.audioEl.className = "chatpapers-lecture-audio";
|
this.audioEl.className = "chatpapers-lecture-audio";
|
||||||
@@ -163,7 +215,7 @@ export class LecturePaneView {
|
|||||||
this.updatePlatformHint();
|
this.updatePlatformHint();
|
||||||
|
|
||||||
const actions = this.el("div", "chatpapers-lecture-actions");
|
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(
|
this.body.append(
|
||||||
header,
|
header,
|
||||||
@@ -186,6 +238,8 @@ export class LecturePaneView {
|
|||||||
this.unsubscribers = [];
|
this.unsubscribers = [];
|
||||||
this.player?.destroy();
|
this.player?.destroy();
|
||||||
this.player = undefined;
|
this.player = undefined;
|
||||||
|
this.paragraphPlayer?.destroy();
|
||||||
|
this.paragraphPlayer = undefined;
|
||||||
this.audioEl?.pause();
|
this.audioEl?.pause();
|
||||||
this.body.replaceChildren();
|
this.body.replaceChildren();
|
||||||
this.body.classList.remove("chatpapers-root", "chatpapers-lecture-root");
|
this.body.classList.remove("chatpapers-root", "chatpapers-lecture-root");
|
||||||
@@ -219,9 +273,96 @@ export class LecturePaneView {
|
|||||||
if (!this.shouldHandleEvent(d.paperId)) return;
|
if (!this.shouldHandleEvent(d.paperId)) return;
|
||||||
void this.refreshPlayer();
|
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 {
|
private shouldHandleEvent(paperId: string): boolean {
|
||||||
if (this.preparing) return this.paper?.id === paperId || !this.paper;
|
if (this.preparing) return this.paper?.id === paperId || !this.paper;
|
||||||
return this.paper?.id === paperId;
|
return this.paper?.id === paperId;
|
||||||
@@ -298,9 +439,72 @@ export class LecturePaneView {
|
|||||||
if (!this.cacheKey || !this.audioEl) return;
|
if (!this.cacheKey || !this.audioEl) return;
|
||||||
const data = await loadLectureData(this.cacheKey);
|
const data = await loadLectureData(this.cacheKey);
|
||||||
const beats = data?.beats ?? [];
|
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);
|
this.renderBeatList(beats);
|
||||||
|
|
||||||
if (!this.player) {
|
if (!this.player) {
|
||||||
@@ -322,22 +526,239 @@ export class LecturePaneView {
|
|||||||
} else {
|
} else {
|
||||||
this.player.setBeats(beats);
|
this.player.setBeats(beats);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.updateActionButton();
|
this.updateActionButton();
|
||||||
|
|
||||||
if (canPlay && this.paper?.lectureStatus === "ready") {
|
if (this.paper?.lectureStatus === "ready" && !this.preparing) {
|
||||||
const readyCount = beats.filter((b) => b.ttsStatus === "ready").length;
|
if (isStep2Ready(data)) {
|
||||||
if (!this.preparing) {
|
const count = paragraphs.filter((p) => p.ttsZhStatus === "ready").length;
|
||||||
this.setStatus(
|
this.setStatus(
|
||||||
"ready",
|
"ready",
|
||||||
getString("lecture-status-ready-step1", {
|
getString("lecture-status-ready-step2", { args: { count } }),
|
||||||
args: { count: readyCount },
|
);
|
||||||
}),
|
} 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 {
|
private renderBeatList(beats: StoredBeat[]): void {
|
||||||
this.beatListEl.replaceChildren();
|
this.beatListEl.replaceChildren();
|
||||||
for (const beat of beats) {
|
for (const beat of beats) {
|
||||||
@@ -402,6 +823,12 @@ export class LecturePaneView {
|
|||||||
case "ready": {
|
case "ready": {
|
||||||
if (cacheKey) {
|
if (cacheKey) {
|
||||||
const data = await loadLectureData(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)) {
|
if (isStep1Ready(data)) {
|
||||||
const count = data!.beats!.filter((b) => b.ttsStatus === "ready").length;
|
const count = data!.beats!.filter((b) => b.ttsStatus === "ready").length;
|
||||||
return getString("lecture-status-ready-step1", { args: { count } });
|
return getString("lecture-status-ready-step1", { args: { count } });
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { config } from "../../../../package.json";
|
|||||||
import { getLocaleID, getString } from "../../../utils/locale";
|
import { getLocaleID, getString } from "../../../utils/locale";
|
||||||
import { findPdfAttachment } from "../../pdf/extractor";
|
import { findPdfAttachment } from "../../pdf/extractor";
|
||||||
import { ensureChatPapersStyles } from "../../ui/readerPane";
|
import { ensureChatPapersStyles } from "../../ui/readerPane";
|
||||||
|
import {
|
||||||
|
onItemPaneSectionToggle,
|
||||||
|
prepareItemPaneBody,
|
||||||
|
} from "../../ui/itemPaneSection";
|
||||||
import { LecturePaneView } from "./lecturePane";
|
import { LecturePaneView } from "./lecturePane";
|
||||||
|
|
||||||
const views = new WeakMap<HTMLElement, LecturePaneView>();
|
const views = new WeakMap<HTMLElement, LecturePaneView>();
|
||||||
@@ -25,6 +29,7 @@ export function registerLecturePane() {
|
|||||||
onInit: ({ body }) => {
|
onInit: ({ body }) => {
|
||||||
const doc = body.ownerDocument;
|
const doc = body.ownerDocument;
|
||||||
if (doc) ensureChatPapersStyles(doc);
|
if (doc) ensureChatPapersStyles(doc);
|
||||||
|
prepareItemPaneBody(body);
|
||||||
},
|
},
|
||||||
onDestroy: ({ body }) => {
|
onDestroy: ({ body }) => {
|
||||||
views.get(body)?.destroy();
|
views.get(body)?.destroy();
|
||||||
@@ -60,6 +65,13 @@ export function registerLecturePane() {
|
|||||||
err.textContent = getString("lecture-mount-error");
|
err.textContent = getString("lecture-mount-error");
|
||||||
body.append(err);
|
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 { config } from "../../../package.json";
|
||||||
import { getLocaleID, getString } from "../../utils/locale";
|
import { getLocaleID, getString } from "../../utils/locale";
|
||||||
import { ChatView } from "./chatView";
|
import { ChatView } from "./chatView";
|
||||||
|
import { onItemPaneSectionToggle, prepareItemPaneBody } from "./itemPaneSection";
|
||||||
|
|
||||||
const views = new WeakMap<HTMLElement, ChatView>();
|
const views = new WeakMap<HTMLElement, ChatView>();
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export function registerChatPane() {
|
|||||||
onInit: ({ body }) => {
|
onInit: ({ body }) => {
|
||||||
const doc = body.ownerDocument;
|
const doc = body.ownerDocument;
|
||||||
if (doc) ensureStyles(doc);
|
if (doc) ensureStyles(doc);
|
||||||
|
prepareItemPaneBody(body);
|
||||||
},
|
},
|
||||||
onDestroy: ({ body }) => {
|
onDestroy: ({ body }) => {
|
||||||
views.get(body)?.destroy();
|
views.get(body)?.destroy();
|
||||||
@@ -51,6 +53,13 @@ export function registerChatPane() {
|
|||||||
const view = new ChatView(doc, body, item);
|
const view = new ChatView(doc, body, item);
|
||||||
views.set(body, view);
|
views.set(body, view);
|
||||||
await view.mount();
|
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-beat-tts-pending'
|
||||||
| 'lecture-empty-desc'
|
| 'lecture-empty-desc'
|
||||||
| 'lecture-empty-title'
|
| 'lecture-empty-title'
|
||||||
|
| 'lecture-granularity-paragraph'
|
||||||
|
| 'lecture-granularity-sentence'
|
||||||
| 'lecture-loading'
|
| 'lecture-loading'
|
||||||
| 'lecture-mode-label'
|
| 'lecture-mode-label'
|
||||||
|
| 'lecture-mode-overview'
|
||||||
|
| 'lecture-mode-paragraph'
|
||||||
| 'lecture-mount-error'
|
| 'lecture-mount-error'
|
||||||
| 'lecture-no-pdf'
|
| '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-mac'
|
||||||
| 'lecture-platform-hint-other'
|
| 'lecture-platform-hint-other'
|
||||||
| 'lecture-platform-hint-win'
|
| 'lecture-platform-hint-win'
|
||||||
@@ -57,7 +68,13 @@ export type FluentMessageId =
|
|||||||
| 'lecture-play-pause'
|
| 'lecture-play-pause'
|
||||||
| 'lecture-play-prev'
|
| 'lecture-play-prev'
|
||||||
| 'lecture-play-start'
|
| 'lecture-play-start'
|
||||||
|
| 'lecture-playing-en'
|
||||||
|
| 'lecture-playing-zh'
|
||||||
| 'lecture-reprepare'
|
| 'lecture-reprepare'
|
||||||
|
| 'lecture-save-note'
|
||||||
|
| 'lecture-sentence-label'
|
||||||
|
| 'lecture-sentence-list-hint'
|
||||||
|
| 'lecture-sentence-list-title'
|
||||||
| 'lecture-start'
|
| 'lecture-start'
|
||||||
| 'lecture-status-failed'
|
| 'lecture-status-failed'
|
||||||
| 'lecture-status-idle'
|
| 'lecture-status-idle'
|
||||||
@@ -65,6 +82,7 @@ export type FluentMessageId =
|
|||||||
| 'lecture-status-ready'
|
| 'lecture-status-ready'
|
||||||
| 'lecture-status-ready-phase1'
|
| 'lecture-status-ready-phase1'
|
||||||
| 'lecture-status-ready-step1'
|
| 'lecture-status-ready-step1'
|
||||||
|
| 'lecture-status-ready-step2'
|
||||||
| 'lecture-tts-provider'
|
| 'lecture-tts-provider'
|
||||||
| 'lecture-tts-test'
|
| 'lecture-tts-test'
|
||||||
| 'lecture-tts-test-done'
|
| 'lecture-tts-test-done'
|
||||||
|
|||||||
Reference in New Issue
Block a user