增加语音对话功能
This commit is contained in:
@@ -752,3 +752,215 @@
|
|||||||
.chatpapers-cite-btn {
|
.chatpapers-cite-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Voice lecture pane --- */
|
||||||
|
.chatpapers-lecture-root {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-empty {
|
||||||
|
padding: 20px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-status {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
background: color-mix(in srgb, #1f7a8c 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, #1f7a8c 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-status[data-status="failed"] {
|
||||||
|
background: color-mix(in srgb, #dc2626 10%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #dc2626 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-status[data-status="ready"] {
|
||||||
|
background: color-mix(in srgb, #16a34a 10%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #16a34a 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1f7a8c;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-primary:hover:not(:disabled) {
|
||||||
|
background: #186678;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-status-text {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-primary:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid color-mix(in srgb, #1f7a8c 35%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-secondary:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, #1f7a8c 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-secondary:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-tts-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-audio {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-btn-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.65;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-player {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-player-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-beat-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-beat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid color-mix(in srgb, #1f7a8c 18%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, #1f7a8c 4%, transparent);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-beat-item:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, #1f7a8c 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-beat-item.is-active {
|
||||||
|
border-color: color-mix(in srgb, #16a34a 45%, transparent);
|
||||||
|
background: color-mix(in srgb, #16a34a 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-beat-item.is-pending {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-beat-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-beat-type {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.65;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-beat-title {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-beat-status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-player-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-player-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid color-mix(in srgb, #1f7a8c 30%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-player-btn:hover {
|
||||||
|
background: color-mix(in srgb, #1f7a8c 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-lecture-error {
|
||||||
|
padding: 12px;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<!-- ChatPapers voice lecture: paper + headphones -->
|
||||||
|
<path
|
||||||
|
fill="context-fill"
|
||||||
|
fill-opacity="0.92"
|
||||||
|
d="M3.2 2.8h7.4c.4 0 .7.15.95.4l2.05 2.1c.25.25.4.58.4.95V14.2c0 .9-.73 1.63-1.63 1.63H3.2c-.9 0-1.63-.73-1.63-1.63V4.43C1.57 3.53 2.3 2.8 3.2 2.8z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="context-fill"
|
||||||
|
fill-opacity="0.35"
|
||||||
|
d="M10.6 2.85v2.35c0 .45.37.82.82.82h2.35L10.6 2.85z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke="context-stroke"
|
||||||
|
stroke-opacity="0.55"
|
||||||
|
stroke-width="0.9"
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="M4.35 7.1h5.1M4.35 9.15h4.2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="context-fill"
|
||||||
|
d="M12.1 8.35c1.45 0 2.65 1.15 2.65 2.55v2.1c0 .35-.28.62-.62.62h-.75v1.05c0 .55-.45 1-1 1h-.55c-.55 0-1-.45-1-1v-1.05h-.55c-.35 0-.62-.28-.62-.62v-2.1c0-1.4 1.2-2.55 2.65-2.55z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="context-fill"
|
||||||
|
d="M16.55 9.05c1.05 0 1.9.82 1.9 1.85v1.35c0 1-.85 1.85-1.9 1.85-.35 0-.62-.28-.62-.62v-3.8c0-.35.28-.62.62-.62z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1020 B |
@@ -116,6 +116,69 @@
|
|||||||
</hbox>
|
</hbox>
|
||||||
</groupbox>
|
</groupbox>
|
||||||
|
|
||||||
|
<groupbox>
|
||||||
|
<label><html:h2 data-l10n-id="prefs-section-lecture"></html:h2></label>
|
||||||
|
<html:p id="chatpapers-pref-tts-hint" class="chatpapers-pref-hint"></html:p>
|
||||||
|
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label
|
||||||
|
for="chatpapers-pref-tts-provider"
|
||||||
|
data-l10n-id="prefs-tts-provider"
|
||||||
|
></html:label>
|
||||||
|
<html:select id="chatpapers-pref-tts-provider" preference="ttsProvider">
|
||||||
|
</html:select>
|
||||||
|
</hbox>
|
||||||
|
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label
|
||||||
|
for="chatpapers-pref-tts-voice"
|
||||||
|
data-l10n-id="prefs-tts-voice"
|
||||||
|
></html:label>
|
||||||
|
<html:input
|
||||||
|
type="text"
|
||||||
|
id="chatpapers-pref-tts-voice"
|
||||||
|
preference="ttsVoice"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
|
||||||
|
<vbox id="chatpapers-pref-piper-box" hidden="true">
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label
|
||||||
|
for="chatpapers-pref-piper-path"
|
||||||
|
data-l10n-id="prefs-piper-path"
|
||||||
|
></html:label>
|
||||||
|
<html:input
|
||||||
|
type="text"
|
||||||
|
id="chatpapers-pref-piper-path"
|
||||||
|
preference="piperPath"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label
|
||||||
|
for="chatpapers-pref-piper-model"
|
||||||
|
data-l10n-id="prefs-piper-model"
|
||||||
|
></html:label>
|
||||||
|
<html:input
|
||||||
|
type="text"
|
||||||
|
id="chatpapers-pref-piper-model"
|
||||||
|
preference="piperModelPath"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
</vbox>
|
||||||
|
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label
|
||||||
|
for="chatpapers-pref-mineru-path"
|
||||||
|
data-l10n-id="prefs-mineru-path"
|
||||||
|
></html:label>
|
||||||
|
<html:input
|
||||||
|
type="text"
|
||||||
|
id="chatpapers-pref-mineru-path"
|
||||||
|
preference="mineruPath"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
</groupbox>
|
||||||
|
|
||||||
<groupbox>
|
<groupbox>
|
||||||
<label><html:h2 data-l10n-id="prefs-section-prompts"></html:h2></label>
|
<label><html:h2 data-l10n-id="prefs-section-prompts"></html:h2></label>
|
||||||
<html:label data-l10n-id="prefs-system-prompt"></html:label>
|
<html:label data-l10n-id="prefs-system-prompt"></html:label>
|
||||||
|
|||||||
@@ -58,3 +58,38 @@ cite-empty = No styles available
|
|||||||
cite-copied = Bibliography copied
|
cite-copied = Bibliography copied
|
||||||
cite-exported = Bibliography exported
|
cite-exported = Bibliography exported
|
||||||
cite-export-cancel = Export cancelled
|
cite-export-cancel = Export cancelled
|
||||||
|
|
||||||
|
item-section-lecture-head = Voice lecture
|
||||||
|
item-section-lecture-sidenav = Voice lecture
|
||||||
|
|
||||||
|
lecture-loading = Loading voice lecture…
|
||||||
|
lecture-mode-label = Voice lecture · Prepare then listen
|
||||||
|
lecture-empty-title = AI lecture mode
|
||||||
|
lecture-empty-desc = One-click prep: overview and paragraph-by-paragraph listening, with typed Q&A and spoken answers.
|
||||||
|
lecture-start = Start preparation
|
||||||
|
lecture-status-idle = Click Start preparation — AI reads the full paper first
|
||||||
|
lecture-status-preparing = Preparing lecture…
|
||||||
|
lecture-status-ready = Lecture ready ✓
|
||||||
|
lecture-status-ready-phase1 = Preparation done (Phase 1: summary ready)
|
||||||
|
lecture-status-ready-step1 = Ready: { $count } segments — tap a segment or press Play
|
||||||
|
lecture-status-failed = Preparation incomplete
|
||||||
|
lecture-beat-list-title = Overview lecture
|
||||||
|
lecture-play-start = Play
|
||||||
|
lecture-play-pause = Pause
|
||||||
|
lecture-play-next = Next
|
||||||
|
lecture-play-prev = Previous
|
||||||
|
lecture-play-complete = Playback finished
|
||||||
|
lecture-beat-tts-pending = Synthesizing
|
||||||
|
lecture-beat-tts-failed = Failed
|
||||||
|
lecture-reprepare = Re-prepare
|
||||||
|
lecture-no-pdf = No PDF attachment on this item
|
||||||
|
lecture-attach-error = Cannot read PDF attachment
|
||||||
|
lecture-mount-error = Failed to load voice lecture pane
|
||||||
|
lecture-platform-hint-mac = macOS: set MinerU path under Preferences → ChatPapers → Voice lecture, or leave empty to use mineru on PATH.
|
||||||
|
lecture-platform-hint-win = Windows: set the full path to mineru.exe under Preferences → ChatPapers → Voice lecture.
|
||||||
|
lecture-platform-hint-other = Configure MinerU path in Preferences to enable structured PDF parsing.
|
||||||
|
|
||||||
|
lecture-tts-provider = TTS: { $provider }
|
||||||
|
lecture-tts-test = Test voice
|
||||||
|
lecture-tts-testing = Synthesizing test speech…
|
||||||
|
lecture-tts-test-done = Speech ready — playing
|
||||||
|
|||||||
@@ -27,3 +27,11 @@ prefs-hint-cloud = Cloud provider: paper text is sent to the selected API endpoi
|
|||||||
prefs-models-empty = No models returned.
|
prefs-models-empty = No models returned.
|
||||||
prefs-models-pick = Enter a model id from the list (showing up to 30):
|
prefs-models-pick = Enter a model id from the list (showing up to 30):
|
||||||
prefs-privacy = Privacy: ChatPapers only sends content when you click Send/Summarize, and only to the endpoint you configured. API keys are stored in local Zotero preferences.
|
prefs-privacy = Privacy: ChatPapers only sends content when you click Send/Summarize, and only to the endpoint you configured. API keys are stored in local Zotero preferences.
|
||||||
|
|
||||||
|
prefs-section-lecture = Voice lecture
|
||||||
|
prefs-tts-provider = TTS engine
|
||||||
|
prefs-tts-voice = Voice (optional)
|
||||||
|
prefs-piper-path = Piper executable
|
||||||
|
prefs-piper-model = Piper model path (.onnx)
|
||||||
|
prefs-mineru-path = MinerU path
|
||||||
|
prefs-tts-hint-empty = Choose a TTS engine. Local engines are fully offline — no API key needed.
|
||||||
|
|||||||
@@ -59,3 +59,37 @@ cite-copied = 参考文献已复制到剪贴板
|
|||||||
cite-exported = 参考文献已导出
|
cite-exported = 参考文献已导出
|
||||||
cite-export-cancel = 已取消导出
|
cite-export-cancel = 已取消导出
|
||||||
|
|
||||||
|
item-section-lecture-head = 语音伴读
|
||||||
|
item-section-lecture-sidenav = 语音伴读
|
||||||
|
|
||||||
|
lecture-loading = 正在加载语音伴读…
|
||||||
|
lecture-mode-label = 语音伴读 · 先备课再听
|
||||||
|
lecture-empty-title = AI 老师式伴读
|
||||||
|
lecture-empty-desc = 一键备课:整体讲解与逐段精读,随时文字提问并获得语音回答。
|
||||||
|
lecture-start = 开始备课
|
||||||
|
lecture-status-idle = 点击「开始备课」,AI 将先读完整篇论文
|
||||||
|
lecture-status-preparing = 正在备课…
|
||||||
|
lecture-status-ready = 老师已备课 ✓
|
||||||
|
lecture-status-ready-phase1 = 备课完成(Phase 1:摘要已生成)
|
||||||
|
lecture-status-ready-step1 = 备课完成:{ $count } 段讲解可播放,点击下方段落或播放按钮开始听课
|
||||||
|
lecture-status-failed = 备课未完成,请重试或检查 LLM 设置
|
||||||
|
lecture-beat-list-title = 整体讲解
|
||||||
|
lecture-play-start = 播放
|
||||||
|
lecture-play-pause = 暂停
|
||||||
|
lecture-play-next = 下一段
|
||||||
|
lecture-play-prev = 上一段
|
||||||
|
lecture-play-complete = 本轮讲解播放完毕
|
||||||
|
lecture-beat-tts-pending = 合成中
|
||||||
|
lecture-beat-tts-failed = 失败
|
||||||
|
lecture-reprepare = 重新备课
|
||||||
|
lecture-no-pdf = 当前条目没有可用的 PDF 附件
|
||||||
|
lecture-attach-error = 无法读取 PDF 附件
|
||||||
|
lecture-mount-error = 语音伴读面板加载失败
|
||||||
|
lecture-platform-hint-mac = macOS:MinerU 路径可在偏好设置 → ChatPapers → 语音伴读 中配置;留空则尝试 PATH 中的 mineru。
|
||||||
|
lecture-platform-hint-win = Windows:请在偏好设置中填写 MinerU 可执行文件完整路径(如 mineru.exe)。
|
||||||
|
lecture-platform-hint-other = 请在偏好设置中配置 MinerU 路径以启用 PDF 结构化解析。
|
||||||
|
|
||||||
|
lecture-tts-provider = TTS 引擎:{ $provider }
|
||||||
|
lecture-tts-test = 试听语音
|
||||||
|
lecture-tts-testing = 正在合成测试语音…
|
||||||
|
lecture-tts-test-done = 语音合成完成,正在播放
|
||||||
|
|||||||
@@ -27,3 +27,11 @@ prefs-hint-cloud = 云端 Provider:论文内容将发送到所选 API 端点
|
|||||||
prefs-models-empty = 未返回任何模型。
|
prefs-models-empty = 未返回任何模型。
|
||||||
prefs-models-pick = 从列表中输入模型 ID(最多显示 30 个):
|
prefs-models-pick = 从列表中输入模型 ID(最多显示 30 个):
|
||||||
prefs-privacy = 隐私说明:仅在你点击发送/总结时,向你配置的端点发送内容。API Key 仅保存在本地 Zotero 偏好设置中。
|
prefs-privacy = 隐私说明:仅在你点击发送/总结时,向你配置的端点发送内容。API Key 仅保存在本地 Zotero 偏好设置中。
|
||||||
|
|
||||||
|
prefs-section-lecture = 语音伴读
|
||||||
|
prefs-tts-provider = TTS 引擎
|
||||||
|
prefs-tts-voice = 音色(可选)
|
||||||
|
prefs-piper-path = Piper 可执行文件
|
||||||
|
prefs-piper-model = Piper 模型路径 (.onnx)
|
||||||
|
prefs-mineru-path = MinerU 路径
|
||||||
|
prefs-tts-hint-empty = 请选择 TTS 引擎。本地引擎完全离线,无需 API Key。
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
"id": "__addonID__",
|
"id": "__addonID__",
|
||||||
"update_url": "__updateURL__",
|
"update_url": "__updateURL__",
|
||||||
"strict_min_version": "9.0",
|
"strict_min_version": "9.0",
|
||||||
"strict_max_version": "9.*"
|
"strict_max_version": "10.*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,3 +13,10 @@ pref("systemPrompt", "");
|
|||||||
pref("summaryPrompt", "");
|
pref("summaryPrompt", "");
|
||||||
pref("sendMetadata", true);
|
pref("sendMetadata", true);
|
||||||
pref("verboseLog", false);
|
pref("verboseLog", false);
|
||||||
|
pref("ttsProvider", "");
|
||||||
|
pref("ttsApiKey", "");
|
||||||
|
pref("ttsVoice", "");
|
||||||
|
pref("lectureTeacherStyle", "balanced");
|
||||||
|
pref("mineruPath", "");
|
||||||
|
pref("piperPath", "");
|
||||||
|
pref("piperModelPath", "");
|
||||||
|
|||||||
@@ -0,0 +1,680 @@
|
|||||||
|
# 软件架构设计文档
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 产品名称 | BanDu(伴读)— Zotero 插件 |
|
||||||
|
| 中文名 | 伴读 |
|
||||||
|
| 英文名 | BanDu |
|
||||||
|
| 文档版本 | v0.1 |
|
||||||
|
| 状态 | 草案 |
|
||||||
|
| 关联文档 | [PLAN.md](./PLAN.md)、[PRD.md](./PRD.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 架构概述
|
||||||
|
|
||||||
|
### 1.1 架构风格
|
||||||
|
|
||||||
|
**Local-first 桌面插件架构**:核心计算与缓存均在用户机器完成;仅将段落文本与用户问题发送至用户配置的 LLM / TTS 服务。无自建后端(一期)。
|
||||||
|
|
||||||
|
### 1.2 设计原则
|
||||||
|
|
||||||
|
| 原则 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 备课一次、缓存复用 | Lecture 持久化;attachment ID + 文件哈希为缓存键 |
|
||||||
|
| 增量交付 | 摘要 → beats → 逐段 → TTS 分阶段就绪,缩短 Time-to-Listen |
|
||||||
|
| UI / 领域 / 集成 分离 | 降低 Zotero API 变动影响 |
|
||||||
|
| Grounding 优先 | 生成内容必须可追溯到段落 ID |
|
||||||
|
| 优雅降级 | refs 无效、无 timestamps、无 BBT 均有 fallback |
|
||||||
|
|
||||||
|
### 1.3 系统上下文
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ 用户 │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌──────────────▼──────────────┐
|
||||||
|
│ Zotero 7 (宿主) │
|
||||||
|
│ ┌────────────────────────┐ │
|
||||||
|
│ │ BanDu 插件 │ │
|
||||||
|
│ │ UI │ Engine │ Store │ │
|
||||||
|
│ └─────────┬──────────────┘ │
|
||||||
|
└────────────┼────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────┼─────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌───────────┐ ┌────────────┐ ┌─────────────┐
|
||||||
|
│ MinerU │ │ LLM API │ │ TTS API │
|
||||||
|
│ (本地子进程)│ │ / Ollama │ │ ElevenLabs等 │
|
||||||
|
└───────────┘ └────────────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────┐
|
||||||
|
│ SQLite + │
|
||||||
|
│ mp3 文件 │
|
||||||
|
└───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 逻辑分层
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Presentation Layer(表现层) │
|
||||||
|
│ • BanduPane(Item pane 伴读页) │
|
||||||
|
│ • LibraryWindow(伴读库) │
|
||||||
|
│ • SettingsPane(设置) │
|
||||||
|
│ • PlayerController(播放状态机 + <audio>) │
|
||||||
|
└───────────────────────────┬─────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────────▼─────────────────────────────┐
|
||||||
|
│ Application Layer(应用层 / 用例) │
|
||||||
|
│ • PrepareLectureUseCase(备课编排) │
|
||||||
|
│ • PlayUseCase(播放 / 跳播 / 断点) │
|
||||||
|
│ • AskQuestionUseCase(问答 + RAG) │
|
||||||
|
│ • GenerateReportUseCase(报告 + Note 回写) │
|
||||||
|
└───────────────────────────┬─────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────────▼─────────────────────────────┐
|
||||||
|
│ Domain Layer(领域层) │
|
||||||
|
│ • Paper, Paragraph, Chapter │
|
||||||
|
│ • Lecture, Beat, ParagraphExplanation │
|
||||||
|
│ • QARecord, Progress, Highlight │
|
||||||
|
│ • LecturePreparationState(状态机) │
|
||||||
|
└───────────────────────────┬─────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────────▼─────────────────────────────┐
|
||||||
|
│ Infrastructure Layer(基础设施层) │
|
||||||
|
│ • ZoteroAdapter(Item / Attachment / Note) │
|
||||||
|
│ • PdfParser(MinerU 封装) │
|
||||||
|
│ • LlmClient(OpenAI-compatible / Ollama) │
|
||||||
|
│ • TtsClient(多提供商 + timestamps) │
|
||||||
|
│ • SqliteRepository │
|
||||||
|
│ • AudioStorage(mp3 路径管理) │
|
||||||
|
│ • RagIndex(段落检索) │
|
||||||
|
│ • PromptRegistry(模板版本管理) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 模块说明
|
||||||
|
|
||||||
|
### 3.1 ZoteroAdapter
|
||||||
|
|
||||||
|
**职责**:封装所有 Zotero 7 API 调用。
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `getPdfAttachment(itemId)` | 返回主 PDF 路径、attachmentId |
|
||||||
|
| `computeFileHash(path)` | SHA-256 |
|
||||||
|
| `openPdfAtPage(itemId, page)` | 跳转 PDF 阅读器 |
|
||||||
|
| `createBanduNote(itemId, markdown)` | 创建伴读子笔记 |
|
||||||
|
| `getCitationKey(itemId)` | Better BibTeX 可选集成 |
|
||||||
|
| `listLibraryItems()` | 伴读库数据源 |
|
||||||
|
|
||||||
|
**约束**:UI 与用例层不得直接调用 `Zotero.*` 全局对象。
|
||||||
|
|
||||||
|
### 3.2 PdfParser
|
||||||
|
|
||||||
|
**职责**:调用 MinerU(一期),输出规范化段落树。
|
||||||
|
|
||||||
|
**输入**:PDF 本地路径
|
||||||
|
**输出**:`ParseResult { chapters[], paragraphs[], metadata }`
|
||||||
|
|
||||||
|
**段落字段**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Paragraph {
|
||||||
|
id: string; // 稳定 ID,如 "p-0017"
|
||||||
|
chapterId: string;
|
||||||
|
orderIndex: number;
|
||||||
|
text: string;
|
||||||
|
page: number;
|
||||||
|
language?: 'en' | 'zh';
|
||||||
|
bulletPoints?: string[]; // >150 词长段拆分要点
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**失败策略**:抛出 typed error(`ScannedPdfError`, `ParseTimeoutError`),UI 映射为用户文案。
|
||||||
|
|
||||||
|
### 3.3 PrepareLectureUseCase
|
||||||
|
|
||||||
|
**职责**:编排备课流水线,驱动增量就绪事件。
|
||||||
|
|
||||||
|
详见 §5 流水线与 §6 状态机。
|
||||||
|
|
||||||
|
### 3.4 LlmClient
|
||||||
|
|
||||||
|
**职责**:统一 LLM 调用;支持 streaming。
|
||||||
|
|
||||||
|
| 方法 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `generateSummary(paragraphs[])` | 全文摘要 |
|
||||||
|
| `generateBeats(summary, paragraphIndex[])` | 整体讲解 beats |
|
||||||
|
| `generateParagraphExplanation(para, summary, chapterTitle)` | 三层讲解 |
|
||||||
|
| `generateSentenceExplanation(sentence, context)` | 逐句讲解 |
|
||||||
|
| `answerQuestion(context, question)` | 问答 stream |
|
||||||
|
|
||||||
|
**配置**:provider、baseUrl、apiKey、model、temperature(默认 0.2–0.4)。
|
||||||
|
|
||||||
|
### 3.5 TtsClient
|
||||||
|
|
||||||
|
**职责**:文本 → mp3 + timestamps。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TtsResult {
|
||||||
|
audioPath: string;
|
||||||
|
timestamps: WordTimestamp[]; // 可为空
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WordTimestamp {
|
||||||
|
text: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**策略**:优先 provider 原生 boundaries;缺失则 `HighlightGranularity = SENTENCE`。
|
||||||
|
|
||||||
|
### 3.6 RagIndex
|
||||||
|
|
||||||
|
**职责**:段落级检索,供问答组装上下文。
|
||||||
|
|
||||||
|
**一期实现**:SQLite FTS5 或 BM25;MVP 可不引入 embedding。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RagIndex {
|
||||||
|
build(paragraphs: Paragraph[]): void;
|
||||||
|
search(query: string, topK: number): Paragraph[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.7 SqliteRepository + AudioStorage
|
||||||
|
|
||||||
|
**职责**:结构化数据与二进制音频分离存储。
|
||||||
|
|
||||||
|
- SQLite:`{ZoteroDataDir}/bandu/bandu.db`
|
||||||
|
- 音频:`{ZoteroDataDir}/bandu/audio/{cacheKey}/{unitId}.mp3`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 数据模型
|
||||||
|
|
||||||
|
### 4.1 ER 关系
|
||||||
|
|
||||||
|
```
|
||||||
|
PaperCache 1──* Paragraph
|
||||||
|
PaperCache 1──1 Lecture
|
||||||
|
Lecture 1──* Beat
|
||||||
|
Lecture 1──* ParagraphExplanation
|
||||||
|
PaperCache 1──* QARecord
|
||||||
|
PaperCache 1──1 Progress
|
||||||
|
PaperCache 1──* Highlight
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 表结构(SQLite)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 论文缓存根表
|
||||||
|
CREATE TABLE paper_cache (
|
||||||
|
id TEXT PRIMARY KEY, -- hash(attachment_id + file_hash)
|
||||||
|
attachment_id TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
file_hash TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
language TEXT, -- 'en' | 'zh'
|
||||||
|
parse_status TEXT NOT NULL, --见状态机
|
||||||
|
lecture_status TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(attachment_id, file_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 章节
|
||||||
|
CREATE TABLE chapter (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
order_index INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 段落
|
||||||
|
CREATE TABLE paragraph (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
chapter_id TEXT REFERENCES chapter(id),
|
||||||
|
order_index INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
page INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 讲义
|
||||||
|
CREATE TABLE lecture (
|
||||||
|
paper_id TEXT PRIMARY KEY REFERENCES paper_cache(id),
|
||||||
|
summary_json TEXT NOT NULL, -- {problem, method, result, limitation}
|
||||||
|
prompt_version TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 整体讲解 beat
|
||||||
|
CREATE TABLE beat (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
order_index INTEGER NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
type TEXT NOT NULL, -- intro|method|experiment|conclusion|extension
|
||||||
|
script TEXT NOT NULL,
|
||||||
|
refs_json TEXT NOT NULL, -- ["p-0012","p-0013"]
|
||||||
|
audio_path TEXT,
|
||||||
|
timestamps_json TEXT,
|
||||||
|
tts_status TEXT NOT NULL -- pending|ready|failed
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 逐段三层讲解
|
||||||
|
CREATE TABLE paragraph_explanation (
|
||||||
|
paragraph_id TEXT PRIMARY KEY REFERENCES paragraph(id),
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
layer_what TEXT NOT NULL,
|
||||||
|
layer_how TEXT NOT NULL,
|
||||||
|
layer_where TEXT NOT NULL,
|
||||||
|
audio_path TEXT,
|
||||||
|
timestamps_json TEXT,
|
||||||
|
gen_status TEXT NOT NULL,
|
||||||
|
tts_status TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 播放进度
|
||||||
|
CREATE TABLE progress (
|
||||||
|
paper_id TEXT PRIMARY KEY REFERENCES paper_cache(id),
|
||||||
|
last_mode TEXT, -- overview|paragraph
|
||||||
|
last_unit_id TEXT,
|
||||||
|
last_position_ms INTEGER DEFAULT 0,
|
||||||
|
listened_units_json TEXT, -- 已听 unit id 列表
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 问答
|
||||||
|
CREATE TABLE qa_record (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
anchor_type TEXT NOT NULL, -- beat|paragraph|sentence|global
|
||||||
|
anchor_id TEXT,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
audio_path TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 重点标记
|
||||||
|
CREATE TABLE highlight (
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
paragraph_id TEXT NOT NULL REFERENCES paragraph(id),
|
||||||
|
note TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (paper_id, paragraph_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 段落 FTS(RAG)
|
||||||
|
CREATE VIRTUAL TABLE paragraph_fts USING fts5(
|
||||||
|
paragraph_id,
|
||||||
|
paper_id,
|
||||||
|
text,
|
||||||
|
tokenize='unicode61'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 缓存键策略
|
||||||
|
|
||||||
|
```
|
||||||
|
cacheKey = SHA256(attachmentId + "|" + fileHash)
|
||||||
|
|
||||||
|
失效条件:
|
||||||
|
• fileHash 变化(PDF 被替换)
|
||||||
|
• 用户「强制重新备课」
|
||||||
|
• prompt_version _major 升级(可选策略:仅影响新生成)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Lecture JSON 摘要结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"problem": "…",
|
||||||
|
"method": "…",
|
||||||
|
"result": "…",
|
||||||
|
"limitation": "…"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Beat refs 校验算法
|
||||||
|
|
||||||
|
```
|
||||||
|
输入: beats[], validParagraphIds (Set)
|
||||||
|
对每个 beat:
|
||||||
|
beat.refs = beat.refs.filter(id => validParagraphIds.has(id))
|
||||||
|
if beat.refs.isEmpty:
|
||||||
|
beat.degraded = true // UI: 不展示原文高亮
|
||||||
|
输出: beats[]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 核心流水线
|
||||||
|
|
||||||
|
### 5.1 端到端数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
PDF path
|
||||||
|
→ [PdfParser] → paragraphs[] → SQLite
|
||||||
|
→ [LlmClient.generateSummary] → lecture.summary
|
||||||
|
→ [LlmClient.generateBeats] → beats[] → refs 校验 → SQLite
|
||||||
|
→ (event: beats_ready) → UI 可播整体模式
|
||||||
|
→ [LlmClient.generateParagraphExplanation] × N → SQLite
|
||||||
|
→ (event: paragraph_batch_ready)
|
||||||
|
→ [TtsClient.synthesize] × units → mp3 → SQLite
|
||||||
|
→ (event: unit_audio_ready)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 增量备课时序
|
||||||
|
|
||||||
|
```
|
||||||
|
时间 ──────────────────────────────────────────────►
|
||||||
|
|
||||||
|
解析 ████████
|
||||||
|
摘要 ███
|
||||||
|
beats ██████ ← beats_ready(~1-2min)
|
||||||
|
逐段讲解 ████████████████████
|
||||||
|
TTS(beats) ████████ ← 整体模式有声音
|
||||||
|
TTS(段落) █████████████████
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 问答上下文组装
|
||||||
|
|
||||||
|
```
|
||||||
|
AskContext {
|
||||||
|
question: string
|
||||||
|
anchor: { type, id, text, explanation? }
|
||||||
|
summary: SummaryJson
|
||||||
|
ragParagraphs: Paragraph[] // topK=5
|
||||||
|
paperTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
Prompt 模板顺序:
|
||||||
|
系统角色 → 全文摘要 → RAG 段落 → 锚点原文+讲解 → 用户问题
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 报告生成
|
||||||
|
|
||||||
|
```
|
||||||
|
ReportBuilder.build(paperId):
|
||||||
|
summary ← lecture.summary
|
||||||
|
progress ← progress.listened_units
|
||||||
|
qas ← qa_record ORDER BY created_at
|
||||||
|
highlights ← highlight JOIN paragraph
|
||||||
|
bibtex ← ZoteroAdapter.getCitationKey + export
|
||||||
|
|
||||||
|
MarkdownRenderer → ZoteroAdapter.createBanduNote
|
||||||
|
```
|
||||||
|
|
||||||
|
**报告策略(建议 MVP)**:同一 Item 只保留一个「伴读报告」笔记;再次生成时**更新**同名 Note(`BanDu 伴读报告: {title}`),避免笔记爆炸。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 状态机
|
||||||
|
|
||||||
|
### 6.1 论文 / 备课状态(lecture_status)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐
|
||||||
|
│ idle │ 未开始
|
||||||
|
└────┬────┘
|
||||||
|
│ start
|
||||||
|
▼
|
||||||
|
┌─────────┐
|
||||||
|
┌────│ parsing │────┐ fail
|
||||||
|
│ └────┬────┘ │
|
||||||
|
│ │ success ▼
|
||||||
|
│ │ ┌─────────┐
|
||||||
|
│ └───►│ parsed │
|
||||||
|
│ └────┬────┘
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ┌──────────────┐
|
||||||
|
│ │ summarizing │
|
||||||
|
│ └──────┬───────┘
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ┌──────────────┐
|
||||||
|
│ │ beats_generating │
|
||||||
|
│ └──────┬───────┘
|
||||||
|
│ │ beats_ready
|
||||||
|
│ ▼
|
||||||
|
│ ┌────────────────────┐
|
||||||
|
│ │ paragraphs_generating │◄──┐
|
||||||
|
│ └──────────┬─────────┘ │ batch loop
|
||||||
|
│ │ │
|
||||||
|
│ └─────────────┘
|
||||||
|
│ │ all para done
|
||||||
|
│ ▼
|
||||||
|
│ ┌────────────────────┐
|
||||||
|
│ │ tts_generating │
|
||||||
|
│ └──────────┬─────────┘
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ┌──────────────┐
|
||||||
|
└─────────────►│ ready │
|
||||||
|
error └──────────────┘
|
||||||
|
┌─────────┐
|
||||||
|
│ failed │
|
||||||
|
└─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**可恢复**:每个阶段完成后持久化;重启插件从 `lecture_status` 继续,跳过已完成步骤。
|
||||||
|
|
||||||
|
### 6.2 播放器状态
|
||||||
|
|
||||||
|
```
|
||||||
|
PlayerState {
|
||||||
|
mode: 'overview' | 'paragraph'
|
||||||
|
currentUnitId: string
|
||||||
|
isPlaying: boolean
|
||||||
|
playbackRate: 1 | 1.5 | 2
|
||||||
|
highlightCursorMs: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**跳播**:切换 `currentUnitId` → 加载对应 mp3 → seek(0) → 更新 Progress。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 接口设计(内部事件)
|
||||||
|
|
||||||
|
插件内使用 EventBus 解耦 UI 与长时间任务:
|
||||||
|
|
||||||
|
| 事件 | 载荷 | 订阅者 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `parse:progress` | `{ percent, message }` | BanduPane |
|
||||||
|
| `lecture:beats_ready` | `{ paperId, beatCount }` | BanduPane, Player |
|
||||||
|
| `lecture:paragraph_ready` | `{ paperId, paragraphId }` | BanduPane |
|
||||||
|
| `tts:unit_ready` | `{ unitId, audioPath }` | Player |
|
||||||
|
| `lecture:failed` | `{ paperId, error }` | BanduPane |
|
||||||
|
| `qa:chunk` | `{ textDelta }` | QAPanel |
|
||||||
|
| `qa:complete` | `{ qaRecordId }` | QAPanel, Report |
|
||||||
|
|
||||||
|
**长时间任务**:在 Zotero 主线程外使用 Web Worker 或子进程(MinerU);LLM/TTS 使用 async fetch,避免阻塞 UI。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 外部集成
|
||||||
|
|
||||||
|
### 8.1 LLM
|
||||||
|
|
||||||
|
| 提供商 | 协议 | 用途 |
|
||||||
|
|--------|------|------|
|
||||||
|
| OpenAI 兼容 API | HTTPS SSE | 备课 + 问答 |
|
||||||
|
| Ollama | localhost HTTP | 全本地备选 |
|
||||||
|
|
||||||
|
### 8.2 TTS
|
||||||
|
|
||||||
|
| 提供商 | 选型维度 |
|
||||||
|
|--------|----------|
|
||||||
|
| 火山 / Azure / MiniMax / ElevenLabs | 中文自然度、价格、word timestamps |
|
||||||
|
|
||||||
|
**抽象接口**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TtsProvider {
|
||||||
|
name: string;
|
||||||
|
synthesize(text: string, options: TtsOptions): Promise<TtsResult>;
|
||||||
|
supportsTimestamps: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 MinerU
|
||||||
|
|
||||||
|
- **集成方式(待 Phase 0 确认)**:子进程 CLI,stdin/stdout JSON
|
||||||
|
- **打包**:插件安装包内含 MinerU 二进制或安装脚本;文档说明 Python 环境依赖
|
||||||
|
|
||||||
|
### 8.4 Better BibTeX(可选)
|
||||||
|
|
||||||
|
- 检测 `Zotero.BetterBibTeX` 是否存在
|
||||||
|
- 不存在:从 Item 字段生成简易 BibTeX 或留空字段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 安全与隐私
|
||||||
|
|
||||||
|
| 项 | 方案 |
|
||||||
|
|----|------|
|
||||||
|
| PDF 文件 | 不离开本地 |
|
||||||
|
| 发往 LLM 的数据 | 段落 text + 用户问题 + 摘要;设置页明示 |
|
||||||
|
| API Key | Zotero prefs 加密存储或 OS Keychain |
|
||||||
|
| 日志 | 不记录 Key、不完整记录段落正文(可截断) |
|
||||||
|
| Ollama | 默认 localhost,无外网 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 部署与构建
|
||||||
|
|
||||||
|
### 10.1 技术栈
|
||||||
|
|
||||||
|
| 层 | 选型 |
|
||||||
|
|----|------|
|
||||||
|
| 插件框架 | [zotero-plugin-template](https://github.com/windingwind/zotero-plugin-template) |
|
||||||
|
| 语言 | TypeScript |
|
||||||
|
| UI | HTML + CSS(Fluent 风格贴近 Zotero) |
|
||||||
|
| 音频 | HTML5 `<audio>` |
|
||||||
|
| 数据库 | better-sqlite3 或 Zotero 内置 SQL(待 POC) |
|
||||||
|
| PDF | MinerU(Python,本地 CPU) |
|
||||||
|
|
||||||
|
### 10.2 目录结构(建议)
|
||||||
|
|
||||||
|
```
|
||||||
|
bandu/
|
||||||
|
├── addon/
|
||||||
|
│ ├── bootstrap.js
|
||||||
|
│ ├── manifest.json
|
||||||
|
│ └── prefs.js
|
||||||
|
├── src/
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ ├── BanduPane.tsx
|
||||||
|
│ │ ├── LibraryWindow.tsx
|
||||||
|
│ │ └── SettingsPane.tsx
|
||||||
|
│ ├── application/
|
||||||
|
│ │ ├── PrepareLectureUseCase.ts
|
||||||
|
│ │ ├── PlayUseCase.ts
|
||||||
|
│ │ ├── AskQuestionUseCase.ts
|
||||||
|
│ │ └── GenerateReportUseCase.ts
|
||||||
|
│ ├── domain/
|
||||||
|
│ ├── infrastructure/
|
||||||
|
│ │ ├── zotero/
|
||||||
|
│ │ ├── pdf/
|
||||||
|
│ │ ├── llm/
|
||||||
|
│ │ ├── tts/
|
||||||
|
│ │ └── storage/
|
||||||
|
│ └── prompts/
|
||||||
|
├── tests/
|
||||||
|
└── docs/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 版本兼容
|
||||||
|
|
||||||
|
- `manifest.json` 声明 `applications.zotero.strict_min_version` / `strict_max_version`
|
||||||
|
- CI 固定 Zotero 7.x 版本做 smoke test
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Phase 0 技术验证(Go / No-Go)
|
||||||
|
|
||||||
|
| # | 验证项 | 通过标准 | 阻塞 MVP |
|
||||||
|
|---|--------|----------|----------|
|
||||||
|
| P0-1 | Item pane 嵌入自定义页 | 可展示 UI、读取当前 Item | 是 |
|
||||||
|
| P0-2 | 读取 PDF attachment 路径 | 路径有效、哈希可算 | 是 |
|
||||||
|
| P0-3 | `<audio>` 长音频播放 | 10 min+ 无泄漏/崩溃 | 是 |
|
||||||
|
| P0-4 | MinerU 解析 3 篇论文 | 段落切分可用率 ≥ 80% | 是 |
|
||||||
|
| P0-5 | TTS 中文样本 | 自然度可接受 | 是 |
|
||||||
|
| P0-6 | TTS timestamps | 有则记录格式;无则确认句子级降级 | 否(降级) |
|
||||||
|
| P0-7 | Zotero Note Markdown | 报告渲染可接受 | 否 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 性能与扩展
|
||||||
|
|
||||||
|
### 12.1 长论文策略
|
||||||
|
|
||||||
|
| 页数 | 策略 |
|
||||||
|
|------|------|
|
||||||
|
| ≤ 30 | 全量逐段备课 |
|
||||||
|
| 30–80 | 逐段 lazy:按章节或用户点击生成 |
|
||||||
|
| > 80 | 默认仅整体讲解;逐段需用户确认(成本提示) |
|
||||||
|
|
||||||
|
### 12.2 并发控制
|
||||||
|
|
||||||
|
- LLM 备课:beats 串行或小批量并行(≤ 3)避免 rate limit
|
||||||
|
- TTS:队列化,优先 beats 音频
|
||||||
|
- 同一 paper 不允许重复触发备课(互斥锁)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 可观测性
|
||||||
|
|
||||||
|
| 类型 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 结构化日志 | paperId, stage, durationMs, errorCode |
|
||||||
|
| 用户可见进度 | 解析 %、beats 数、段落 i/n、TTS i/n |
|
||||||
|
| Dogfooding 导出 | 单篇 metrics JSON(可选调试菜单) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 风险与架构对策
|
||||||
|
|
||||||
|
| 风险 | 架构对策 |
|
||||||
|
|------|----------|
|
||||||
|
| Zotero API 变更 | Adapter 层隔离;pin 版本 |
|
||||||
|
| MinerU 打包复杂 | Phase 0 验证;文档化外部依赖安装 |
|
||||||
|
| refs 幻觉 | 校验器 + degraded beat |
|
||||||
|
| TTS 无 timestamps | HighlightGranularity 降级 |
|
||||||
|
| SQLite 锁 | 写操作单线程队列 |
|
||||||
|
| 备课中断 | 状态机 + 阶段持久化 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 开放问题
|
||||||
|
|
||||||
|
1. SQLite 用 `better-sqlite3` 还是 Zotero `Services.storage` — 待插件 POC 验证 WASM/ native 限制
|
||||||
|
2. MinerU 随插件分发 vs 用户自行安装 Python 环境
|
||||||
|
3. 问答 TTS MVP 用流式还是整段合成(影响架构复杂度)
|
||||||
|
4. 伴读库窗口:全库扫描性能(>5000 items 时的索引策略)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 文档修订记录
|
||||||
|
|
||||||
|
| 版本 | 日期 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| v0.1 | 2026-08-29 | 自 PLAN.md 拆分为架构初稿 |
|
||||||
@@ -0,0 +1,944 @@
|
|||||||
|
# ChatPapers 双模式集成架构设计
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 产品名称 | ChatPapers for Zotero |
|
||||||
|
| 文档名称 | 双模式集成架构(方案 A) |
|
||||||
|
| 文档版本 | v0.1 |
|
||||||
|
| 状态 | 草案 |
|
||||||
|
| 目标平台 | Zotero 9(macOS / Windows) |
|
||||||
|
| 关联文档 | [双模式 PRD](./DUAL_MODE_PRD.md)、[开发文档](../development.md)、[BanDu 架构参考](./ARCHITECTURE.md) |
|
||||||
|
|
||||||
|
> **本文档定位**:描述 ChatPapers 插件在保留**文字对话**能力的基础上,扩展**语音伴读**(备课 → 听课 → 语音问答)的架构设计。
|
||||||
|
> 产品统一品牌为 **ChatPapers**;两种模式是同一条「与论文对话」产品线的不同交互形态——文字即时聊 vs 语音系统讲。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 架构概述
|
||||||
|
|
||||||
|
### 1.1 架构演进
|
||||||
|
|
||||||
|
```
|
||||||
|
v0.2(当前) v1.0(目标)
|
||||||
|
───────────────── ─────────────────────────────────
|
||||||
|
ChatPapers 插件 ChatPapers 插件(单一 .xpi)
|
||||||
|
└── 文字对话 pane ├── 文字对话 pane(已有)
|
||||||
|
PDFWorker 纯文本 │ 即时问答 / 总结 / JSON 会话
|
||||||
|
LLM 流式 │
|
||||||
|
sessions/*.json └── 语音伴读 pane(新增)
|
||||||
|
MinerU 段落树
|
||||||
|
Lecture 备课 + TTS + 播放器
|
||||||
|
SQLite + mp3 缓存
|
||||||
|
共享:LLM · PDF 附件 · 笔记 · BibTeX
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 架构风格
|
||||||
|
|
||||||
|
**Local-first 双模式插件架构**:核心计算与缓存均在用户机器完成;仅将段落文本与用户问题发送至用户配置的 LLM / TTS 服务。无自建后端。
|
||||||
|
|
||||||
|
### 1.3 设计原则
|
||||||
|
|
||||||
|
| 原则 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 单一插件、两种对话形态 | 文字对话与语音伴读并存,共享 LLM 与 Zotero 集成 |
|
||||||
|
| 模块隔离 | 语音模块不侵入文字对话代码路径 |
|
||||||
|
| 备课一次、缓存复用 | Lecture 持久化;attachment ID + 文件哈希为缓存键 |
|
||||||
|
| 增量交付 | 摘要 → beats → 逐段 → TTS 分阶段就绪 |
|
||||||
|
| UI / 领域 / 集成 分离 | 降低 Zotero API 变动影响 |
|
||||||
|
| Grounding 优先 | 生成内容必须可追溯到段落 ID |
|
||||||
|
| 优雅降级 | refs 无效、无 timestamps、无 BBT 均有 fallback |
|
||||||
|
|
||||||
|
### 1.4 系统上下文
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ 用户 │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌──────────────▼──────────────┐
|
||||||
|
│ Zotero 9(宿主) │
|
||||||
|
│ ┌────────────────────────┐ │
|
||||||
|
│ │ ChatPapers 插件 │ │
|
||||||
|
│ │ ┌──────────┬─────────┐ │ │
|
||||||
|
│ │ │文字对话 │语音伴读 │ │ │
|
||||||
|
│ │ │ ChatView │LectureUI│ │ │
|
||||||
|
│ │ └────┬─────┴────┬────┘ │ │
|
||||||
|
│ │ │ Shared │ │ │
|
||||||
|
│ │ └─────┬─────┘ │ │
|
||||||
|
│ └────────────┼────────────┘ │
|
||||||
|
└───────────────┼──────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────────┼─────────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Zotero │ │ LLM API │ │ TTS API │
|
||||||
|
│ PDFWorker │ │ / Ollama │ │ 火山/Azure等 │
|
||||||
|
│ MinerU │ │ (共享) │ │(语音模式) │
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ {Profile}/chatpapers/ │
|
||||||
|
│ sessions/*.json ← 文字对话 │
|
||||||
|
│ lecture/lecture.db ← 语音伴读 │
|
||||||
|
│ lecture/audio/ │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 两种对话形态对比
|
||||||
|
|
||||||
|
| 维度 | 文字对话(已有) | 语音伴读(新增) |
|
||||||
|
|------|------------------|------------------|
|
||||||
|
| 用户意图 | 「我现在想问什么」 | 「请 AI 先读完再给我讲」 |
|
||||||
|
| 交互 | 打字 → 文字流式回复 | 听课 → 打字提问 → 文字 + 语音回复 |
|
||||||
|
| PDF 输入 | PDFWorker 整篇纯文本 | MinerU 结构化段落树 |
|
||||||
|
| LLM 用法 | 每轮即时组装 context | 一次性备课 + 按需问答 |
|
||||||
|
| 持久化 | `sessions/{itemKey}.json` | `lecture/lecture.db` + mp3 |
|
||||||
|
| TTS | 无 | 讲解 + 问答回答均支持 |
|
||||||
|
| 笔记 | `ChatPapers:` 前缀 | `ChatPapers 伴读报告:` 前缀 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 整体分层架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Presentation Layer(表现层) │
|
||||||
|
│ 【文字】chatView.ts · multiChatView.ts · readerPane (Chat sec) │
|
||||||
|
│ 【语音】lecturePane.ts · player/ · libraryWindow.ts(P1) │
|
||||||
|
│ 【共享】prefs.ts · preferences.xhtml │
|
||||||
|
└───────────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────────────▼──────────────────────────────────┐
|
||||||
|
│ Application Layer(应用层 / 用例) │
|
||||||
|
│ 【文字】buildChatMessages · buildMultiChatMessages(已有) │
|
||||||
|
│ 【语音】PrepareLectureUseCase · PlayUseCase │
|
||||||
|
│ AskQuestionUseCase · GenerateReportUseCase │
|
||||||
|
└───────────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────────────▼──────────────────────────────────┐
|
||||||
|
│ Domain Layer(领域层) │
|
||||||
|
│ 【文字】ChatMessage · ExtractResult(已有) │
|
||||||
|
│ 【语音】PaperCache · Paragraph · Chapter · Lecture · Beat │
|
||||||
|
│ ParagraphExplanation · QARecord · Progress · Highlight │
|
||||||
|
│ LecturePreparationState · PlayerState │
|
||||||
|
└───────────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────────────▼──────────────────────────────────┐
|
||||||
|
│ Infrastructure Layer(基础设施层) │
|
||||||
|
│ ┌─ 共享 ─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ llm/client · llm/providers · zotero/notes · zotero/citeExport│ │
|
||||||
|
│ │ pdf/extractor (附件定位) · pdf/selection · utils/* │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─ 文字专属 ─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ pdf/context · pdf/multiContext · storage/sessions │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─ 语音专属 ─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ lecture/infrastructure/pdf (MinerU) · tts/ · storage/sqlite │ │
|
||||||
|
│ │ lecture/infrastructure/rag · audioStorage · promptRegistry │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 现有代码映射
|
||||||
|
|
||||||
|
### 3.1 共享层(已实现,语音模式直接复用)
|
||||||
|
|
||||||
|
| 模块路径 | 职责 | 文字对话 | 语音伴读 |
|
||||||
|
|----------|------|:--------:|:--------:|
|
||||||
|
| `src/hooks.ts` | 生命周期、注册入口 | ✓ | 扩展注册 |
|
||||||
|
| `src/modules/ui/readerPane.ts` | Item pane section 注册 | ✓ | 扩展第二 section |
|
||||||
|
| `src/modules/llm/client.ts` | LLM 流式请求 | ✓ | ✓ 备课 + 问答 |
|
||||||
|
| `src/modules/llm/providers.ts` | Provider 预设 | ✓ | ✓ 只读共享 prefs |
|
||||||
|
| `src/modules/llm/types.ts` | ChatMessage 等类型 | ✓ | 问答层可复用类型 |
|
||||||
|
| `src/modules/pdf/extractor.ts` | PDF 附件定位 | ✓ | ✓ 路径 + 扩展 hash |
|
||||||
|
| `src/modules/pdf/selection.ts` | Reader 选区 | ✓ | ✓ 「解释这句」 |
|
||||||
|
| `src/modules/zotero/notes.ts` | 子笔记创建 | ✓ | ✓ 伴读报告 |
|
||||||
|
| `src/modules/zotero/citeExport.ts` | BibTeX 导出 | ✓ | ✓ 参考文献卡片 |
|
||||||
|
| `src/utils/markdown.ts` | Markdown 渲染 | ✓ | ✓ 报告预览 |
|
||||||
|
| `src/utils/prefs.ts` | Preference 读写 | ✓ | ✓ 扩展 TTS prefs |
|
||||||
|
| `src/utils/abort.ts` | 请求取消 | ✓ | ✓ |
|
||||||
|
|
||||||
|
### 3.2 文字对话专属(已实现,语音模式不得引用)
|
||||||
|
|
||||||
|
| 模块路径 | 职责 |
|
||||||
|
|----------|------|
|
||||||
|
| `src/modules/ui/chatView.ts` | 文字对话 UI |
|
||||||
|
| `src/modules/ui/multiChatView.ts` | 多 PDF 联合对话 |
|
||||||
|
| `src/modules/pdf/context.ts` | 单篇 context 组装 |
|
||||||
|
| `src/modules/pdf/multiContext.ts` | 多篇 context 组装 |
|
||||||
|
| `src/modules/llm/prompts.ts` | 文字对话 system / summary prompt |
|
||||||
|
| `src/modules/llm/history.ts` | 论文材料持久化到消息历史 |
|
||||||
|
| `src/modules/storage/sessions.ts` | JSON 会话文件 |
|
||||||
|
|
||||||
|
### 3.3 语音伴读新建(`src/modules/lecture/`)
|
||||||
|
|
||||||
|
| 模块 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `lecture/ui/lecturePane.ts` | Item pane「语音伴读」section |
|
||||||
|
| `lecture/ui/playerController.ts` | 播放状态机 + `<audio>` |
|
||||||
|
| `lecture/ui/unitList.ts` | beats / 段落列表 |
|
||||||
|
| `lecture/ui/qaPanel.ts` | 锚定提问 + 流式 + 重播 |
|
||||||
|
| `lecture/application/prepareLecture.ts` | 备课流水线编排 |
|
||||||
|
| `lecture/application/playLecture.ts` | 播放 / 跳播 / 断点 |
|
||||||
|
| `lecture/application/askQuestion.ts` | 锚定问答 + RAG |
|
||||||
|
| `lecture/application/generateReport.ts` | 报告组装 + Note 回写 |
|
||||||
|
| `lecture/domain/*` | 领域实体与状态机 |
|
||||||
|
| `lecture/infrastructure/pdf/parser.ts` | MinerU 封装 |
|
||||||
|
| `lecture/infrastructure/pdf/fileHash.ts` | SHA-256 |
|
||||||
|
| `lecture/infrastructure/tts/client.ts` | TTS 多提供商 |
|
||||||
|
| `lecture/infrastructure/tts/providers.ts` | TTS 预设 |
|
||||||
|
| `lecture/infrastructure/storage/repository.ts` | SQLite CRUD |
|
||||||
|
| `lecture/infrastructure/storage/audioStorage.ts` | mp3 路径管理 |
|
||||||
|
| `lecture/infrastructure/rag/index.ts` | FTS5 段落检索 |
|
||||||
|
| `lecture/infrastructure/llm/lecturePrompts.ts` | 备课 / beats / 三层讲解 prompt |
|
||||||
|
| `lecture/infrastructure/events.ts` | EventBus |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 依赖规则
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ utils/ │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
┌────────────────┼────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ llm/ │ │ pdf/ext │ │ zotero/ │
|
||||||
|
└────┬─────┘ └────┬─────┘ └────┬─────┘
|
||||||
|
│ │ │
|
||||||
|
┌────┴───────┐ │ ┌────┴─────┐
|
||||||
|
│ │ │ │ │
|
||||||
|
▼ ▼ ▼ ▼ ▼
|
||||||
|
chatView multiChat selection notes citeExport
|
||||||
|
(文字 UI) (文字 UI) (共享)
|
||||||
|
|
||||||
|
lecture/* ──可引用──► llm/, pdf/extractor, pdf/selection, zotero/, utils/
|
||||||
|
|
||||||
|
chatView / multiChat / context / sessions ──禁止引用──► lecture/*
|
||||||
|
lecture/* ──禁止引用──► chatView, context, sessions, multiContext
|
||||||
|
```
|
||||||
|
|
||||||
|
**运行时隔离**:两个 pane 的 `onAsyncRender` 各自实例化 View,异常在 View 边界捕获,不向上冒泡至 `hooks.ts` 导致整个插件失效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 启动与注册流程
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks.ts(目标形态,伪代码)
|
||||||
|
async function onStartup() {
|
||||||
|
await Promise.all([Zotero.initializationPromise, ...]);
|
||||||
|
|
||||||
|
initLocale();
|
||||||
|
registerPrefs(); // 扩展 TTS / 教师风格分组
|
||||||
|
registerChatPane(); // 已有:文字对话 section
|
||||||
|
registerLecturePane(); // 新增:语音伴读 section
|
||||||
|
registerReaderSelectionHook();
|
||||||
|
|
||||||
|
addon.data.initialized = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// readerPane.ts 扩展策略
|
||||||
|
export function registerChatPane() { /* 现有,不动 */ }
|
||||||
|
|
||||||
|
export function registerLecturePane() {
|
||||||
|
Zotero.ItemPaneManager.registerSection({
|
||||||
|
paneID: "chatpapers-lecture",
|
||||||
|
pluginID: config.addonID,
|
||||||
|
header: { l10nID: "chatpapers-item-section-lecture-head", ... },
|
||||||
|
onItemChange: ({ item, setEnabled }) => {
|
||||||
|
setEnabled(Boolean(item && findPdfAttachment(item)));
|
||||||
|
},
|
||||||
|
onAsyncRender: async ({ body, item }) => {
|
||||||
|
const view = new LecturePaneView(doc, body, item);
|
||||||
|
await view.mount();
|
||||||
|
},
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 语音伴读模块详细设计
|
||||||
|
|
||||||
|
### 6.1 Zotero 集成适配(复用 + 薄封装)
|
||||||
|
|
||||||
|
语音模块不新建独立 Adapter 文件树,而是在 `lecture/infrastructure/zotero/` 做薄封装,内部调用已有模块:
|
||||||
|
|
||||||
|
| 接口 | 实现 |
|
||||||
|
|------|------|
|
||||||
|
| `getPdfAttachment(item)` | 委托 `findPdfAttachment` |
|
||||||
|
| `computeFileHash(path)` | 新增:`IOUtils.read` + SubtleCrypto / nsICryptoHash |
|
||||||
|
| `openPdfAtPage(itemId, page)` | 新增:Zotero.Reader API |
|
||||||
|
| `createLectureNote(item, markdown)` | 委托 `createChildNote`,标题 `ChatPapers 伴读报告: {title}` |
|
||||||
|
| `exportBibtex(item)` | 委托 `citeExport.formatCiteText` |
|
||||||
|
|
||||||
|
### 6.2 PdfParser(MinerU)
|
||||||
|
|
||||||
|
**与文字对话 PDF 路径分离**:文字对话继续 `PDFWorker.getFullText`;语音伴读走 MinerU。
|
||||||
|
|
||||||
|
**输入**:PDF 本地路径(来自 attachment)
|
||||||
|
**输出**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ParseResult {
|
||||||
|
chapters: Chapter[];
|
||||||
|
paragraphs: Paragraph[];
|
||||||
|
metadata: { pageCount: number; detectedLanguage?: "en" | "zh" };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Paragraph {
|
||||||
|
id: string; // "p-0017"
|
||||||
|
chapterId: string;
|
||||||
|
orderIndex: number;
|
||||||
|
text: string;
|
||||||
|
page: number;
|
||||||
|
language?: "en" | "zh";
|
||||||
|
bulletPoints?: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**失败策略**:
|
||||||
|
|
||||||
|
| 错误类型 | UI 文案 |
|
||||||
|
|----------|---------|
|
||||||
|
| `ScannedPdfError` | 扫描版 PDF,请先 OCR |
|
||||||
|
| `ParseTimeoutError` | 解析超时,请重试 |
|
||||||
|
| `MinerUNotFoundError` | 未检测到 MinerU,见设置页安装说明 |
|
||||||
|
|
||||||
|
### 6.3 LLM 扩展(共享 client,专属 prompt)
|
||||||
|
|
||||||
|
`llm/client.ts` 保持通用 `chatStream`;语音备课在 `lecture/infrastructure/llm/lecturePrompts.ts` 定义模板,通过 client 调用:
|
||||||
|
|
||||||
|
| 方法 | 位置 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| `chatStream` | `llm/client.ts`(已有) | 文字对话 + 语音问答 |
|
||||||
|
| `generateSummary` | `lecture/.../lectureLlm.ts` | 全文摘要四字段 |
|
||||||
|
| `generateBeats` | 同上 | 8–15 beats + refs[] |
|
||||||
|
| `generateParagraphExplanation` | 同上 | 三层讲解 |
|
||||||
|
| `generateSentenceExplanation` | 同上 | 逐句讲解 |
|
||||||
|
| `answerWithRag` | `lecture/application/askQuestion.ts` | 锚定问答 |
|
||||||
|
|
||||||
|
**配置**:语音备课 temperature 0.2–0.4;与文字对话共用 `getRuntimeConfig()`,不新增 LLM prefs 键。
|
||||||
|
|
||||||
|
### 6.4 TtsClient
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TtsResult {
|
||||||
|
audioPath: string;
|
||||||
|
timestamps: WordTimestamp[];
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WordTimestamp {
|
||||||
|
text: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TtsProvider {
|
||||||
|
name: string;
|
||||||
|
synthesize(text: string, options: TtsOptions): Promise<TtsResult>;
|
||||||
|
supportsTimestamps: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**高亮降级链**:`WORD` → `SENTENCE` → `PARAGRAPH` → `NONE`(仅进度条)
|
||||||
|
|
||||||
|
**Prefs 键(建议)**:`extensions.zotero.chatpapers.ttsProvider` / `ttsApiKey` / `ttsVoice`
|
||||||
|
|
||||||
|
### 6.5 RagIndex
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RagIndex {
|
||||||
|
build(paperId: string, paragraphs: Paragraph[]): Promise<void>;
|
||||||
|
search(paperId: string, query: string, topK: number): Paragraph[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
一期 SQLite FTS5,不引入 embedding。索引在解析完成后构建,与 `paragraph_fts` 表同步。
|
||||||
|
|
||||||
|
### 6.6 PrepareLectureUseCase
|
||||||
|
|
||||||
|
**职责**:编排备课流水线;发出增量就绪事件;保证同一 paper 互斥(备课锁)。
|
||||||
|
|
||||||
|
**伪流程**:
|
||||||
|
|
||||||
|
```
|
||||||
|
start(paperId):
|
||||||
|
if lecture_status != idle && != failed: return // 防重入
|
||||||
|
set status → parsing
|
||||||
|
paragraphs ← PdfParser.parse(path)
|
||||||
|
persist paragraphs + FTS
|
||||||
|
set status → summarizing
|
||||||
|
summary ← Llm.generateSummary(paragraphs)
|
||||||
|
persist lecture.summary
|
||||||
|
set status → beats_generating
|
||||||
|
beats ← Llm.generateBeats(summary, paragraphIndex)
|
||||||
|
beats ← validateRefs(beats, paragraphIds)
|
||||||
|
persist beats
|
||||||
|
emit lecture:beats_ready // UI 可开整体讲解
|
||||||
|
set status → paragraphs_generating
|
||||||
|
for batch in paragraphs.chunk(5):
|
||||||
|
explanations ← Llm.generateParagraphExplanation(batch)
|
||||||
|
persist + emit lecture:paragraph_ready
|
||||||
|
set status → tts_generating
|
||||||
|
for unit in beats + explanations:
|
||||||
|
audio ← Tts.synthesize(unit.script)
|
||||||
|
persist audio_path + emit tts:unit_ready
|
||||||
|
set status → ready
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.7 PlayUseCase + PlayerController
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PlayerState {
|
||||||
|
mode: "overview" | "paragraph";
|
||||||
|
currentUnitId: string;
|
||||||
|
isPlaying: boolean;
|
||||||
|
playbackRate: 1 | 1.5 | 2;
|
||||||
|
highlightCursorMs: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**跳播**:`currentUnitId` 变更 → 加载 mp3 → `audio.currentTime = 0` → 更新 `progress` 表。
|
||||||
|
|
||||||
|
**断点续播**:pane 打开时读 `progress.last_unit_id` + `last_position_ms`。
|
||||||
|
|
||||||
|
### 6.8 AskQuestionUseCase
|
||||||
|
|
||||||
|
与文字对话的差异:
|
||||||
|
|
||||||
|
| 项 | 文字对话 | 语音伴读问答 |
|
||||||
|
|----|----------|--------------|
|
||||||
|
| 上下文 | 整篇 PDF 文本(可能截断) | 锚点 + 摘要 + RAG topK=5 |
|
||||||
|
| 存储 | sessions JSON | qa_record 表 |
|
||||||
|
| 输出 | 仅文字流 | 文字流 + TTS mp3 |
|
||||||
|
| 锚点 | 选区字符串 | beat / paragraph / sentence / global |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AskContext {
|
||||||
|
question: string;
|
||||||
|
anchor: {
|
||||||
|
type: "beat" | "paragraph" | "sentence" | "global";
|
||||||
|
id?: string;
|
||||||
|
text?: string;
|
||||||
|
explanation?: string;
|
||||||
|
};
|
||||||
|
summary: SummaryJson;
|
||||||
|
ragParagraphs: Paragraph[];
|
||||||
|
paperTitle: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.9 GenerateReportUseCase
|
||||||
|
|
||||||
|
```
|
||||||
|
ReportBuilder.build(paperId):
|
||||||
|
summary ← lecture.summary_json
|
||||||
|
progress ← progress.listened_units_json
|
||||||
|
qas ← qa_record ORDER BY created_at
|
||||||
|
highlights ← highlight JOIN paragraph
|
||||||
|
bibtex ← citeExport
|
||||||
|
|
||||||
|
MarkdownRenderer → createLectureNote(parentItem, markdown)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note 策略**:同名 `ChatPapers 伴读报告: {title}` 存在则更新,避免笔记爆炸。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 数据模型
|
||||||
|
|
||||||
|
### 7.1 存储目录
|
||||||
|
|
||||||
|
```
|
||||||
|
{ZoteroProfile}/chatpapers/
|
||||||
|
├── sessions/ # 文字对话(已有)
|
||||||
|
│ └── {itemKey}-{attachmentKey}.json
|
||||||
|
└── lecture/ # 语音伴读(新增)
|
||||||
|
├── lecture.db # SQLite
|
||||||
|
└── audio/
|
||||||
|
└── {cacheKey}/
|
||||||
|
├── beat-{id}.mp3
|
||||||
|
├── para-{id}.mp3
|
||||||
|
└── qa-{id}.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 缓存键
|
||||||
|
|
||||||
|
```
|
||||||
|
cacheKey = SHA256(attachmentId + "|" + fileHash)
|
||||||
|
|
||||||
|
失效条件:
|
||||||
|
• fileHash 变化
|
||||||
|
• 用户强制重新备课
|
||||||
|
• lecture prompt_version major 升级(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 ER 关系
|
||||||
|
|
||||||
|
```
|
||||||
|
PaperCache 1──* Paragraph
|
||||||
|
PaperCache 1──1 Lecture
|
||||||
|
Lecture 1──* Beat
|
||||||
|
Lecture 1──* ParagraphExplanation
|
||||||
|
PaperCache 1──* QARecord
|
||||||
|
PaperCache 1──1 Progress
|
||||||
|
PaperCache 1──* Highlight
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 SQLite 表结构
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 论文缓存根表
|
||||||
|
CREATE TABLE paper_cache (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
attachment_id TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
file_hash TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
language TEXT,
|
||||||
|
parse_status TEXT NOT NULL,
|
||||||
|
lecture_status TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(attachment_id, file_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE chapter (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
order_index INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE paragraph (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
chapter_id TEXT REFERENCES chapter(id),
|
||||||
|
order_index INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
page INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE lecture (
|
||||||
|
paper_id TEXT PRIMARY KEY REFERENCES paper_cache(id),
|
||||||
|
summary_json TEXT NOT NULL,
|
||||||
|
prompt_version TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE beat (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
order_index INTEGER NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
script TEXT NOT NULL,
|
||||||
|
refs_json TEXT NOT NULL,
|
||||||
|
audio_path TEXT,
|
||||||
|
timestamps_json TEXT,
|
||||||
|
tts_status TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE paragraph_explanation (
|
||||||
|
paragraph_id TEXT PRIMARY KEY REFERENCES paragraph(id),
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
layer_what TEXT NOT NULL,
|
||||||
|
layer_how TEXT NOT NULL,
|
||||||
|
layer_where TEXT NOT NULL,
|
||||||
|
audio_path TEXT,
|
||||||
|
timestamps_json TEXT,
|
||||||
|
gen_status TEXT NOT NULL,
|
||||||
|
tts_status TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE progress (
|
||||||
|
paper_id TEXT PRIMARY KEY REFERENCES paper_cache(id),
|
||||||
|
last_mode TEXT,
|
||||||
|
last_unit_id TEXT,
|
||||||
|
last_position_ms INTEGER DEFAULT 0,
|
||||||
|
listened_units_json TEXT,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE qa_record (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
anchor_type TEXT NOT NULL,
|
||||||
|
anchor_id TEXT,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
audio_path TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE highlight (
|
||||||
|
paper_id TEXT NOT NULL REFERENCES paper_cache(id),
|
||||||
|
paragraph_id TEXT NOT NULL REFERENCES paragraph(id),
|
||||||
|
note TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (paper_id, paragraph_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE paragraph_fts USING fts5(
|
||||||
|
paragraph_id,
|
||||||
|
paper_id,
|
||||||
|
text,
|
||||||
|
tokenize='unicode61'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 Beat refs 校验
|
||||||
|
|
||||||
|
```
|
||||||
|
输入: beats[], validParagraphIds (Set)
|
||||||
|
对每个 beat:
|
||||||
|
beat.refs = beat.refs.filter(id => validParagraphIds.has(id))
|
||||||
|
if beat.refs.isEmpty:
|
||||||
|
beat.degraded = true
|
||||||
|
输出: beats[]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.6 摘要 JSON
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"problem": "…",
|
||||||
|
"method": "…",
|
||||||
|
"result": "…",
|
||||||
|
"limitation": "…"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 核心流水线
|
||||||
|
|
||||||
|
### 8.1 语音伴读端到端
|
||||||
|
|
||||||
|
```
|
||||||
|
PDF attachment path
|
||||||
|
→ findPdfAttachment + computeFileHash
|
||||||
|
→ [MinerU PdfParser] → paragraphs[] → SQLite + FTS
|
||||||
|
→ [generateSummary] → lecture.summary
|
||||||
|
→ [generateBeats] → refs 校验 → SQLite
|
||||||
|
→ emit lecture:beats_ready ──► 整体讲解可播(文本)
|
||||||
|
→ [generateParagraphExplanation] × N → SQLite
|
||||||
|
→ emit lecture:paragraph_ready
|
||||||
|
→ [Tts.synthesize] × units → mp3 → SQLite
|
||||||
|
→ emit tts:unit_ready ──► 播放器加载音频
|
||||||
|
→ 用户提问 → [AskQuestion + RAG] → 文字流 + TTS
|
||||||
|
→ [GenerateReport] → Zotero Note
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 增量备课时序
|
||||||
|
|
||||||
|
```
|
||||||
|
时间 ──────────────────────────────────────────────►
|
||||||
|
|
||||||
|
解析 ████████
|
||||||
|
摘要 ███
|
||||||
|
beats ██████ ← beats_ready(~1-2min,可先听文本/TTS排队)
|
||||||
|
逐段讲解 ████████████████████
|
||||||
|
TTS(beats) ████████
|
||||||
|
TTS(段落) █████████████████
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 文字对话路径(不变)
|
||||||
|
|
||||||
|
```
|
||||||
|
Item → findPdfAttachment → PDFWorker.getFullText
|
||||||
|
→ buildChatMessages → chatStream → ChatView 渲染
|
||||||
|
→ saveSession (JSON)
|
||||||
|
→ createChildNote(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 状态机
|
||||||
|
|
||||||
|
### 9.1 备课状态(lecture_status)
|
||||||
|
|
||||||
|
```
|
||||||
|
idle → parsing → parsed → summarizing → beats_generating
|
||||||
|
→ paragraphs_generating (batch loop) → tts_generating → ready
|
||||||
|
任何阶段 ──error──► failed(可从 failed 或任意已完成阶段 resume)
|
||||||
|
```
|
||||||
|
|
||||||
|
**可恢复**:每阶段结束写库;重启后读 `lecture_status`,跳过已完成步骤。
|
||||||
|
|
||||||
|
### 9.2 播放器状态
|
||||||
|
|
||||||
|
见 §6.7。pane `onDestroy` 时持久化 progress,释放 `<audio>` 引用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 内部事件(EventBus)
|
||||||
|
|
||||||
|
| 事件 | 载荷 | 订阅者 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `parse:progress` | `{ paperId, percent, message }` | LecturePane |
|
||||||
|
| `lecture:beats_ready` | `{ paperId, beatCount }` | LecturePane, Player |
|
||||||
|
| `lecture:paragraph_ready` | `{ paperId, paragraphId }` | LecturePane |
|
||||||
|
| `tts:unit_ready` | `{ paperId, unitId, audioPath }` | Player |
|
||||||
|
| `lecture:failed` | `{ paperId, error }` | LecturePane |
|
||||||
|
| `qa:chunk` | `{ textDelta }` | QAPanel |
|
||||||
|
| `qa:complete` | `{ qaRecordId, audioPath? }` | QAPanel |
|
||||||
|
|
||||||
|
EventBus 实例按 `paperId` 或全局单例均可;MVP 用模块级 `EventTarget` 即可。
|
||||||
|
|
||||||
|
**长时间任务**:
|
||||||
|
- MinerU:子进程(不阻塞 UI)
|
||||||
|
- LLM / TTS:`async fetch` + AbortController
|
||||||
|
- SQLite 写:单线程队列,避免并发锁
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 设置与 Preference 设计
|
||||||
|
|
||||||
|
### 11.1 分组结构(preferences.xhtml)
|
||||||
|
|
||||||
|
```
|
||||||
|
ChatPapers 设置
|
||||||
|
├── 通用 · LLM ← 两模式共享
|
||||||
|
│ Provider / Base URL / API Key / Model / Temperature
|
||||||
|
├── 文字对话 ← 已有
|
||||||
|
│ System Prompt / Summary Prompt / Answer Language
|
||||||
|
└── 语音伴读 ← 新增
|
||||||
|
TTS Provider / API Key / Voice
|
||||||
|
教师风格(简洁 / 详细)
|
||||||
|
MinerU 路径(若需)
|
||||||
|
数据流向说明(静态文案)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 Prefs 键命名
|
||||||
|
|
||||||
|
沿用 `extensions.zotero.chatpapers.*` 前缀,不新增 addon ID:
|
||||||
|
|
||||||
|
| 键 | 模式 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| `provider` | 共享 | 已有 |
|
||||||
|
| `apiKey` | 共享 | 已有 |
|
||||||
|
| `systemPrompt` | 文字 | 已有 |
|
||||||
|
| `ttsProvider` | 语音 | 新增 |
|
||||||
|
| `ttsApiKey` | 语音 | 新增 |
|
||||||
|
| `ttsVoice` | 语音 | 新增 |
|
||||||
|
| `lectureTeacherStyle` | 语音 | 新增 |
|
||||||
|
| `mineruPath` | 语音 | 新增,可选 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 目标工程结构
|
||||||
|
|
||||||
|
在 [development.md](../development.md) 现有结构基础上扩展:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
├── hooks.ts
|
||||||
|
├── modules/
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ ├── readerPane.ts # 注册 Chat + Lecture 两个 section
|
||||||
|
│ │ ├── chatView.ts # 文字对话(不动)
|
||||||
|
│ │ ├── multiChatView.ts # 文字多 PDF(不动)
|
||||||
|
│ │ └── prefs.ts
|
||||||
|
│ ├── pdf/
|
||||||
|
│ │ ├── extractor.ts # 共享:附件定位
|
||||||
|
│ │ ├── context.ts # 文字专属
|
||||||
|
│ │ ├── multiContext.ts # 文字专属
|
||||||
|
│ │ └── selection.ts # 共享
|
||||||
|
│ ├── llm/ # 共享 client + providers
|
||||||
|
│ ├── storage/
|
||||||
|
│ │ └── sessions.ts # 文字专属
|
||||||
|
│ ├── zotero/ # 共享
|
||||||
|
│ └── lecture/ # ★ 语音伴读(新建)
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ ├── lecturePane.ts
|
||||||
|
│ │ ├── playerController.ts
|
||||||
|
│ │ ├── unitList.ts
|
||||||
|
│ │ └── qaPanel.ts
|
||||||
|
│ ├── application/
|
||||||
|
│ │ ├── prepareLecture.ts
|
||||||
|
│ │ ├── playLecture.ts
|
||||||
|
│ │ ├── askQuestion.ts
|
||||||
|
│ │ └── generateReport.ts
|
||||||
|
│ ├── domain/
|
||||||
|
│ │ ├── types.ts
|
||||||
|
│ │ ├── lectureState.ts
|
||||||
|
│ │ └── playerState.ts
|
||||||
|
│ └── infrastructure/
|
||||||
|
│ ├── pdf/
|
||||||
|
│ │ ├── parser.ts
|
||||||
|
│ │ └── fileHash.ts
|
||||||
|
│ ├── tts/
|
||||||
|
│ │ ├── client.ts
|
||||||
|
│ │ └── providers.ts
|
||||||
|
│ ├── storage/
|
||||||
|
│ │ ├── repository.ts
|
||||||
|
│ │ ├── schema.ts
|
||||||
|
│ │ └── audioStorage.ts
|
||||||
|
│ ├── rag/
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ ├── llm/
|
||||||
|
│ │ ├── lectureLlm.ts
|
||||||
|
│ │ └── lecturePrompts.ts
|
||||||
|
│ ├── zotero/
|
||||||
|
│ │ └── adapter.ts
|
||||||
|
│ └── events.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**样式**:`addon/content/chatpapers.css` 增加 `.chatpapers-lecture-*` 前缀类,与文字对话样式并列。
|
||||||
|
|
||||||
|
**i18n**:`addon/locale/*/chatpapers.ftl` 增加 `item-section-lecture-*` 等键。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 外部集成
|
||||||
|
|
||||||
|
### 13.1 LLM(共享)
|
||||||
|
|
||||||
|
| 提供商 | 协议 | 文字对话 | 语音伴读 |
|
||||||
|
|--------|------|:--------:|:--------:|
|
||||||
|
| OpenAI 兼容 | HTTPS SSE | ✓ | ✓ 备课 + 问答 |
|
||||||
|
| Ollama | localhost | ✓ | ✓ |
|
||||||
|
|
||||||
|
### 13.2 TTS(语音专属)
|
||||||
|
|
||||||
|
| 提供商 | 选型维度 |
|
||||||
|
|--------|----------|
|
||||||
|
| 火山 / Azure / MiniMax / ElevenLabs | 中文自然度、价格、word timestamps |
|
||||||
|
|
||||||
|
### 13.3 MinerU(语音专属)
|
||||||
|
|
||||||
|
- 集成:子进程 CLI,stdout JSON
|
||||||
|
- 分发:Phase 0 决定「随插件文档安装」vs「用户自装 Python 环境」
|
||||||
|
|
||||||
|
### 13.4 Better BibTeX(共享,可选)
|
||||||
|
|
||||||
|
语音报告参考文献卡片复用 `citeExport`;逻辑已在 Chat 多 PDF 模式验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 安全与隐私
|
||||||
|
|
||||||
|
| 项 | 文字对话 | 语音伴读 |
|
||||||
|
|----|----------|----------|
|
||||||
|
| PDF 文件 | 不上传 | 不上传 |
|
||||||
|
| 发往 LLM | 正文 text(可能截断) | 段落 text + 摘要 + 问题 |
|
||||||
|
| 发往 TTS | — | 讲解/回答中文文本 |
|
||||||
|
| API Key | Zotero prefs | LLM 共享;TTS 独立 pref |
|
||||||
|
| 日志 | 不记录 Key | 不记录 Key;段落正文可截断 |
|
||||||
|
|
||||||
|
设置页「数据流向」分组须同时覆盖两种模式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Phase 0 技术验证(Go / No-Go)
|
||||||
|
|
||||||
|
| # | 验证项 | 通过标准 | 阻塞 MVP |
|
||||||
|
|---|--------|----------|----------|
|
||||||
|
| P0-1 | 第二 Item pane 与 Chat 并存 | 切换 tab 互不影响 | 是 |
|
||||||
|
| P0-2 | MinerU 解析 3 篇论文 | 段落可用率 ≥ 80% | 是 |
|
||||||
|
| P0-3 | Zotero 9 插件内 SQLite CRUD | lecture.db 读写正常 | 是 |
|
||||||
|
| P0-4 | `<audio>` 10 min+ 播放 | 无泄漏 / 崩溃 | 是 |
|
||||||
|
| P0-5 | TTS 中文样本 | 自然度可接受 | 是 |
|
||||||
|
| P0-6 | TTS timestamps | 有则记录;无则句子级降级 | 否 |
|
||||||
|
| P0-7 | Chat 模式回归 | 对话 / 总结 / 存笔记不受影响 | 是 |
|
||||||
|
|
||||||
|
P0-1、P0-7 验证**双模式隔离**;P0-2 ~ P0-6 验证**语音链路**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 性能与扩展
|
||||||
|
|
||||||
|
### 16.1 长论文策略
|
||||||
|
|
||||||
|
| 页数 | 策略 |
|
||||||
|
|------|------|
|
||||||
|
| ≤ 30 | 全量逐段备课 |
|
||||||
|
| 30–80 | 逐段 lazy:按章节或用户触发 |
|
||||||
|
| > 80 | 默认仅整体讲解;逐段需用户确认 |
|
||||||
|
|
||||||
|
### 16.2 并发控制
|
||||||
|
|
||||||
|
- 备课 LLM:beats 小批量并行 ≤ 3
|
||||||
|
- TTS:队列化,beats 优先
|
||||||
|
- 同一 paper 备课互斥锁
|
||||||
|
- SQLite 写串行队列
|
||||||
|
|
||||||
|
### 16.3 伴读库窗口(P1)
|
||||||
|
|
||||||
|
`libraryWindow.ts` 扫描 `paper_cache` 表 + Zotero Items;>5000 items 时仅索引「有过备课记录」的 attachment。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 可观测性
|
||||||
|
|
||||||
|
| 类型 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 结构化日志 | `mode: "lecture"`, paperId, stage, durationMs, errorCode |
|
||||||
|
| 用户可见进度 | 解析 %、beats n/total、TTS n/total |
|
||||||
|
| 调试导出 | 单篇 metrics JSON(开发者菜单,可选) |
|
||||||
|
|
||||||
|
日志前缀建议 `[ChatPapers:Lecture]`,与 `[ChatPapers:Chat]` 区分。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. 风险与对策
|
||||||
|
|
||||||
|
| 风险 | 对策 |
|
||||||
|
|------|------|
|
||||||
|
| 语音模块影响文字对话稳定性 | 依赖隔离 + pane 级 try/catch + P0-7 回归 |
|
||||||
|
| MinerU 打包复杂 | Phase 0 验证;文档化安装 |
|
||||||
|
| refs 幻觉 | 校验器 + degraded beat |
|
||||||
|
| TTS 无 timestamps | 高亮降级链 |
|
||||||
|
| SQLite 在插件环境不可用 | Phase 0 备选 sql.js / JSON 分文件 |
|
||||||
|
| 双 pane 内存占用 | onDestroy 释放 audio、取消进行中的 fetch |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. 开放问题
|
||||||
|
|
||||||
|
| # | 问题 | 影响 |
|
||||||
|
|---|------|------|
|
||||||
|
| 1 | SQLite:better-sqlite3 vs sql.js vs 纯 JSON | 存储层选型 |
|
||||||
|
| 2 | MinerU 分发方式 | 安装体验 |
|
||||||
|
| 3 | 问答 TTS:流式 vs 整段合成 | AskQuestion 复杂度 |
|
||||||
|
| 4 | 文字对话是否未来支持 TTS 朗读回复 | v1.1+ 可统一 TtsClient |
|
||||||
|
| 5 | Chat 会话 → 语音问答上下文导入 | v1.1 产品决策 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. 文档关系
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/requirements.md ChatPapers 文字对话需求(不变)
|
||||||
|
docs/development.md 现有工程与工具链(不变)
|
||||||
|
docs/newidea/
|
||||||
|
├── PLAN.md / PRD.md BanDu 原始策划(参考)
|
||||||
|
├── ARCHITECTURE.md 独立插件架构(参考)
|
||||||
|
├── DUAL_MODE_PRD.md ★ 双模式集成需求
|
||||||
|
└── DUAL_MODE_ARCHITECTURE.md ★ 本文:双模式集成架构
|
||||||
|
```
|
||||||
|
|
||||||
|
**实施顺序**:需求看 DUAL_MODE_PRD.md → 架构看本文 → 领域细节参考 ARCHITECTURE.md §4–§6。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 21. 文档修订记录
|
||||||
|
|
||||||
|
| 版本 | 日期 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| v0.1 | 2026-08-29 | 初稿:ChatPapers 双模式集成架构;语音模块命名 `lecture/` |
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
# ChatPapers 双模式集成需求规格说明书
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 文档名称 | 双模式集成 PRD(方案 A) |
|
||||||
|
| 插件名称 | ChatPapers for Zotero |
|
||||||
|
| 新增模式 | BanDu(伴读) |
|
||||||
|
| 文档版本 | v0.1 |
|
||||||
|
| 状态 | 草案 |
|
||||||
|
| 目标平台 | Zotero 9(macOS / Windows) |
|
||||||
|
| 关联文档 | [ChatPapers 需求](../requirements.md)、[BanDu 策划](./PLAN.md)、[BanDu PRD](./PRD.md)、[BanDu 架构](./ARCHITECTURE.md) |
|
||||||
|
|
||||||
|
> **本文档定位**:在**不拆分、不 fork** 的前提下,将 BanDu(伴读)作为 ChatPapers 插件的**第二种工作模式**纳入同一产品。
|
||||||
|
> 原有 ChatPapers 对话模式的需求以 [requirements.md](../requirements.md) 为准,本文仅描述**集成策略、共享边界、伴读模式增量需求**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 产品概述
|
||||||
|
|
||||||
|
### 1.1 一句话定位
|
||||||
|
|
||||||
|
**ChatPapers** = Zotero 内的 AI 论文助手:**对话模式**用于即时问答与总结,**伴读模式**用于 AI 老师式音频授课与知识沉淀——同一插件、同一论文库、两种互补工作流。
|
||||||
|
|
||||||
|
### 1.2 双模式心智模型
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ ChatPapers for Zotero │
|
||||||
|
│ (一个插件 · 一套 Zotero 集成) │
|
||||||
|
└───────────────┬─────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────┴─────────────────────┐
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ 模式 A:对话 Chat │ │ 模式 B:伴读 BanDu │
|
||||||
|
│ 「我现在想问什么」 │ │ 「请老师先备课再讲」 │
|
||||||
|
├──────────────────────┤ ├──────────────────────┤
|
||||||
|
│ 即时问答 / 一键总结 │ │ 备课 → 听课 → 报告 │
|
||||||
|
│ 文字流式回复 │ │ 整体讲解 / 逐段精读 │
|
||||||
|
│ 选区提问 │ │ TTS 音频 + 播放器 │
|
||||||
|
│ 会话 JSON 持久化 │ │ Lecture SQLite 缓存 │
|
||||||
|
└──────────────────────┘ └──────────────────────┘
|
||||||
|
│ │
|
||||||
|
└─────────────────────┬─────────────────────┘
|
||||||
|
▼
|
||||||
|
共享:PDF 附件 · LLM 配置 · 子笔记 · BibTeX
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 为什么采用双模式(方案 A)
|
||||||
|
|
||||||
|
| 考量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 用户价值互补 | Chat 适合「带着问题读」;伴读适合「先听一遍再精读」 |
|
||||||
|
| 工程复用 | LLM 客户端、PDF 附件解析、笔记回写、引用导出、设置页已在 Chat 模式实现 |
|
||||||
|
| 安装成本 | 用户只装一个插件,不重复配置 Provider / API Key |
|
||||||
|
| 数据闭环 | 伴读报告与 Chat 笔记均写入同一 Zotero Item,写作时可统一检索 |
|
||||||
|
| 品牌演进 | 短期保留 ChatPapers 主品牌;伴读作为子品牌「BanDu」在 UI 内露出 |
|
||||||
|
|
||||||
|
### 1.4 产品目标(伴读模式增量)
|
||||||
|
|
||||||
|
| 目标 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| G-B1 降低读论文门槛 | 10–20 分钟整体讲解把握论文核心 |
|
||||||
|
| G-B2 支持深度精读 | 逐段三层讲解 + 按需逐句解释 |
|
||||||
|
| G-B3 闭环沉淀知识 | 伴读报告、问答、重点标记写入 Zotero 子笔记 |
|
||||||
|
| G-B4 零迁移成本 | 论文库沿用 Zotero,PDF 不出本地 |
|
||||||
|
|
||||||
|
### 1.5 成功指标(伴读 MVP 验证期)
|
||||||
|
|
||||||
|
| 指标 | 目标 |
|
||||||
|
|------|------|
|
||||||
|
| 整体讲解完成率 | ≥ 60% 启动后听完 |
|
||||||
|
| 单篇备课可听时间 | 摘要 + beats 生成后 ≤ 2 min 可开听 |
|
||||||
|
| 问答首字延迟 | ≤ 3 s(文字) |
|
||||||
|
| 解析可用率 | 典型 8 页 NLP 论文 ≥ 90% 段落无需手动修正 |
|
||||||
|
| 模式切换无摩擦 | 同一 Item 可在 Chat / 伴读 pane 间切换,互不丢状态 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 范围定义
|
||||||
|
|
||||||
|
### 2.1 一期包含(伴读模式 MVP)
|
||||||
|
|
||||||
|
- 在现有 ChatPapers 插件内新增 Item pane **「伴读」** 标签页(与现有 **「ChatPapers」** 标签页并存)
|
||||||
|
- 读取 Zotero PDF 附件 → 本地结构化解析 → 段落树
|
||||||
|
- 备课流水线:全文摘要 + 整体讲解 beats + 逐段三层讲解;增量备课
|
||||||
|
- TTS 合成 + 本地 mp3 缓存
|
||||||
|
- 播放器:整体讲解 / 逐段精读、跳播、语速、文字面板高亮、PDF 页跳转
|
||||||
|
- 文字提问(锚定 beat / 段落 / 句子 / 全局)→ 文字 + 语音回答
|
||||||
|
- 伴读报告 + 参考文献卡片 → Zotero 子笔记
|
||||||
|
- 伴读专属设置项(TTS、教师风格);LLM 设置与 Chat 模式**共享**
|
||||||
|
- 断点续播、本地 Lecture 缓存
|
||||||
|
|
||||||
|
### 2.2 一期明确不包含
|
||||||
|
|
||||||
|
| 功能 | 计划版本 | 备注 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 语音输入(ASR)、barge-in | v1.1 | 两模式均不涉及 |
|
||||||
|
| 章节边界软提示、难点句清单 | v1.1 | 伴读专属 |
|
||||||
|
| PDF viewer 内段落级高亮 | v1.1 | 伴读专属 |
|
||||||
|
| 检验模式(老师考读者) | v2 | 伴读专属 |
|
||||||
|
| Chat 模式重构或合并 UI | — | Chat 保持独立 pane |
|
||||||
|
| 独立 BanDu 插件安装包 | — | 不 fork |
|
||||||
|
|
||||||
|
### 2.3 共享能力边界(不重复建设)
|
||||||
|
|
||||||
|
以下能力由 ChatPapers 基座提供,伴读模式**直接复用**,不在伴读 MVP 中重写:
|
||||||
|
|
||||||
|
| 共享模块 | 现有实现 | 伴读用法 |
|
||||||
|
|----------|----------|----------|
|
||||||
|
| Item pane 注册机制 | `readerPane.ts` | 注册第二个 section |
|
||||||
|
| PDF 附件定位 | `extractor.findPdfAttachment` | 获取路径;伴读另需 file hash |
|
||||||
|
| LLM 流式客户端 | `llm/client.ts` | 备课 + 问答 |
|
||||||
|
| Provider 预设 | `llm/providers.ts` | 共享同一套 LLM 配置 |
|
||||||
|
| 子笔记创建 | `zotero/notes.ts` | 伴读报告写入 |
|
||||||
|
| BibTeX / 引用导出 | `zotero/citeExport.ts` | 参考文献卡片 |
|
||||||
|
| PDF 选区读取 | `pdf/selection.ts` | 「解释这句」入口 |
|
||||||
|
| 偏好设置框架 | `ui/prefs.ts` | 扩展 TTS / 教师风格分组 |
|
||||||
|
| Markdown 渲染 | `utils/markdown.ts` | 报告预览(可选) |
|
||||||
|
|
||||||
|
### 2.4 模式专属能力(伴读新建)
|
||||||
|
|
||||||
|
| 模块 | 说明 | 代码归属(建议) |
|
||||||
|
|------|------|------------------|
|
||||||
|
| 结构化 PDF 解析 | MinerU → 段落树 | `src/modules/bandu/infrastructure/pdf/` |
|
||||||
|
| 备课流水线 | 摘要 / beats / 三层讲解 | `src/modules/bandu/application/` |
|
||||||
|
| TTS 客户端 | mp3 + timestamps | `src/modules/bandu/infrastructure/tts/` |
|
||||||
|
| SQLite 存储 | Lecture / 进度 / Q&A | `src/modules/bandu/infrastructure/storage/` |
|
||||||
|
| RAG 检索 | FTS5 段落检索 | `src/modules/bandu/infrastructure/rag/` |
|
||||||
|
| 播放器 UI | `<audio>` + 状态机 | `src/modules/bandu/ui/` |
|
||||||
|
| 伴读 pane | BanduPane | `src/modules/bandu/ui/banduPane.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 用户故事
|
||||||
|
|
||||||
|
### 3.1 跨模式用户故事
|
||||||
|
|
||||||
|
| ID | 作为… | 我想要… | 以便… | 优先级 |
|
||||||
|
|----|-------|---------|-------|--------|
|
||||||
|
| US-X01 | 研究者 | 在同一篇论文上切换「对话」与「伴读」 | 先听课再针对性 Chat,或 Chat 后再系统听课 | P1 |
|
||||||
|
| US-X02 | 研究者 | 只配置一次 LLM API Key | 两种模式共用,降低设置成本 | P0 |
|
||||||
|
| US-X03 | 研究者 | Chat 笔记与伴读报告都挂在同一 Item 下 | 写论文时在 Zotero 内统一搜索 | P0 |
|
||||||
|
|
||||||
|
### 3.2 伴读模式用户故事(P0)
|
||||||
|
|
||||||
|
| ID | 作为… | 我想要… | 以便… |
|
||||||
|
|----|-------|---------|-------|
|
||||||
|
| US-B01 | 研究者 | 选中论文后一键「开始备课」 | AI 先读完整篇再讲解 |
|
||||||
|
| US-B02 | 研究者 | 选择「整体讲解」收听 | 20 分钟内把握核心 |
|
||||||
|
| US-B03 | 研究者 | 选择「逐段精读」按章节听 | 深入理解方法与实验 |
|
||||||
|
| US-B04 | 研究者 | 播放中随时文字提问 | 不打断思路又能澄清疑问 |
|
||||||
|
| US-B05 | 研究者 | 选中难句点「解释这句」 | 快速理解长难句 |
|
||||||
|
| US-B06 | 研究者 | 听完后生成伴读报告到 Zotero | 写论文时搜索调出素材 |
|
||||||
|
| US-B07 | 研究者 | 二次打开同一篇秒进、离线重听 | 不重复花 API 费用 |
|
||||||
|
| US-B08 | 研究者 | 标记重点段落 | 报告汇总关心部分 |
|
||||||
|
| US-B09 | 研究者 | 配置 TTS API Key | 控制成本与音质 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 界面与交互需求
|
||||||
|
|
||||||
|
### 4.1 Item Pane 双标签布局
|
||||||
|
|
||||||
|
```
|
||||||
|
Zotero Item Pane(同一 PDF 条目)
|
||||||
|
├── [ChatPapers] ← 现有:对话 / 总结 / 存笔记
|
||||||
|
└── [伴读 BanDu] ← 新增:备课 / 播放 / 报告
|
||||||
|
```
|
||||||
|
|
||||||
|
| ID | 需求 | 优先级 | 验收标准 |
|
||||||
|
|----|------|--------|----------|
|
||||||
|
| UI-001 | 两个 pane section 独立注册、独立生命周期 | P0 | 切换 tab 不互相 destroy 对方状态 |
|
||||||
|
| UI-002 | 伴读 pane 在无 PDF 时禁用并提示 | P0 | 与 Chat pane 行为一致 |
|
||||||
|
| UI-003 | 伴读 pane 头部展示论文标题 + 备课状态 | P0 | 见 §4.3 状态文案 |
|
||||||
|
| UI-004 | 设置页分「通用 LLM」「ChatPapers」「伴读」三个分组 | P0 | LLM 配置只填一次 |
|
||||||
|
| UI-005 | 伴读库窗口(全库状态列表) | P1 | MVP 可简版或后置 |
|
||||||
|
|
||||||
|
### 4.2 伴读页布局(BanDu Pane)
|
||||||
|
|
||||||
|
布局沿用 [PLAN.md §3.6](./PLAN.md) wireframe,关键区域:
|
||||||
|
|
||||||
|
- 模式切换:整体讲解 | 逐段精读
|
||||||
|
- 播放控制:▶ ⏸ ⏮ ⏭ 1x / 1.5x / 2x
|
||||||
|
- 左侧:单位列表(beats 或段落),含已听 / 未听 / ★ 标记
|
||||||
|
- 右侧:当前讲解文本 + 原文对照 + 提问区
|
||||||
|
- 底部/侧栏:「生成报告」「看原文(跳 PDF 页)」
|
||||||
|
|
||||||
|
### 4.3 状态展示
|
||||||
|
|
||||||
|
| 状态 | 用户可见文案 |
|
||||||
|
|------|--------------|
|
||||||
|
| 未备课 | 「点击开始备课」 |
|
||||||
|
| 解析中 | 「正在读取论文… {percent}%」 |
|
||||||
|
| 备课中(可部分播放) | 「整体讲解已就绪,逐段讲解生成中 {n}/{total}」 |
|
||||||
|
| 已备课 | 「老师已备课 ✓」 |
|
||||||
|
| 播放中 | 当前单位高亮 + 进度条 |
|
||||||
|
| 离线可听 | 「已缓存,离线可播放」 |
|
||||||
|
|
||||||
|
### 4.4 模式间交互原则
|
||||||
|
|
||||||
|
- **不强制串联**:用户可只用 Chat、只用伴读,或组合使用
|
||||||
|
- **不自动同步上下文**:Chat 会话历史不自动注入伴读问答(v1.1 可评估「从 Chat 导入问题」)
|
||||||
|
- **笔记类型区分**:Chat 存笔记标题前缀 `ChatPapers:`;伴读报告 `BanDu 伴读报告:`,避免覆盖
|
||||||
|
- **读者发起,系统不打扰**:伴读播完不弹「有疑问吗」(与 BanDu 策划一致)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 功能需求
|
||||||
|
|
||||||
|
### 5.1 插件集成(双模式)
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| DM-001 | 伴读作为 ChatPapers 插件内独立模块加载,不新增 addon ID | P0 | 单一 `.xpi`,manifest 不变 |
|
||||||
|
| DM-002 | `hooks.ts` 启动时同时注册 Chat pane 与 Bandu pane | P0 | 两 pane 均可用 |
|
||||||
|
| DM-003 | 模块代码物理隔离:`src/modules/bandu/` 与 `src/modules/ui/chatView.ts` 分离 | P0 | 无循环依赖 |
|
||||||
|
| DM-004 | 伴读不得修改 Chat 模式现有行为与 API | P0 | Chat 回归测试通过 |
|
||||||
|
|
||||||
|
### 5.2 PDF 解析与段落树(伴读专属)
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-B010 | 本地解析 PDF,输出章节 / 段落 / 要点三层结构 | P0 | 段落含唯一 ID、原文、页码、章节路径 |
|
||||||
|
| FR-B011 | 支持双栏、公式、表格的常见学术论文版式 | P0 | Dogfooding 3 类典型论文可用 |
|
||||||
|
| FR-B012 | 解析结果持久化至 SQLite,key = attachment ID + 文件哈希 | P0 | PDF 未变时跳过重复解析 |
|
||||||
|
| FR-B013 | 解析进度与错误对用户可见 | P0 | 扫描版等给出可行动提示 |
|
||||||
|
| FR-B014 | 文字面板展示解析原文供核对 | P1 | 用户可发现 bad case |
|
||||||
|
|
||||||
|
> **与 Chat 模式关系**:Chat 继续使用 `PDFWorker` 纯文本提取;伴读使用 MinerU 结构化解析。两路径并存,互不替换。
|
||||||
|
|
||||||
|
### 5.3 备课(Lecture 生成)
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-B020 | 生成全文摘要:问题 / 方法 / 结果 / 局限 | P0 | 四字段非空、中文 |
|
||||||
|
| FR-B021 | 生成 8–15 个整体讲解 beat,按教学逻辑排序 | P0 | 每 beat 含讲解文本、类型、refs[] |
|
||||||
|
| FR-B022 | beat 的 refs[] 生成后校验,非法引用剔除 | P0 | 无效 refs 的 beat 降级 |
|
||||||
|
| FR-B023 | 每个 beat 含三要素:为什么重要 / 例子或数字 / 易混淆点 | P0 | Prompt 约束 |
|
||||||
|
| FR-B024 | 每个段落生成三层讲解 | P0 | 讲了什么 / 怎么理解 / 全文定位 |
|
||||||
|
| FR-B025 | 增量备课:摘要 + beats 完成后即可播放整体讲解 | P0 | 无需等逐段全部完成 |
|
||||||
|
| FR-B026 | 逐段讲解后台继续生成,UI 显示进度 | P0 | 新段落就绪后可播放 |
|
||||||
|
| FR-B027 | Lecture 本地缓存,二次打开不重复生成 | P0 | 除非哈希变化或强制重备 |
|
||||||
|
|
||||||
|
### 5.4 TTS 与音频
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-B030 | 讲解文本按 beat / 段落分片合成 mp3 并本地缓存 | P0 | 路径与 Lecture 单元绑定 |
|
||||||
|
| FR-B031 | 英文论文音频为中文讲解 | P0 | 自动检测论文语言 |
|
||||||
|
| FR-B032 | 保存 word timestamps(若 TTS 提供) | P0 | 用于文字高亮 |
|
||||||
|
| FR-B033 | 无 timestamps 时降级句子级 / 段落级高亮 | P0 | UI 不崩溃 |
|
||||||
|
| FR-B034 | 问答回答支持 TTS 播放 | P0 | 每轮 Q&A 可重播 |
|
||||||
|
| FR-B035 | TTS 配置在设置页「伴读」分组,与 LLM 配置分离 | P0 | Chat 模式不依赖 TTS |
|
||||||
|
|
||||||
|
### 5.5 播放器
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-B040 | 模式切换:整体讲解 / 逐段精读 | P0 | 列表与播放单位正确 |
|
||||||
|
| FR-B041 | 播放 / 暂停 / 上一单位 / 下一单位 / 1x·1.5x·2x | P0 | 与 `<audio>` 同步 |
|
||||||
|
| FR-B042 | 单位列表展示已听 / 未听;断点续播 | P0 | 恢复正确单位 |
|
||||||
|
| FR-B043 | 整体模式:beat 文本 + refs 来源段落高亮 | P0 | — |
|
||||||
|
| FR-B044 | 逐段模式:原文 + 三层讲解,高亮跟随音频 | P0 | 至少句子级 |
|
||||||
|
| FR-B045 | 「看原文」跳转 PDF 对应页 | P0 | 页码与段落 page 一致 |
|
||||||
|
| FR-B046 | 点击列表任意行跳播 | P0 | Seek 到该单位起始 |
|
||||||
|
| FR-B047 | 段落「重点标记」★ | P0 | 写入进度并进入报告 |
|
||||||
|
|
||||||
|
### 5.6 问答(伴读上下文)
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-B050 | 文字提问,锚定 beat / 段落 / 句子 / 全局 | P0 | 上下文含锚点原文与讲解 |
|
||||||
|
| FR-B051 | 选中句子触发「解释这句」 | P0 | 翻译 + 句法 + 上下文作用 |
|
||||||
|
| FR-B052 | 回答基于 RAG:当前单位 + 摘要 + 检索相关段 | P0 | 长论文不整篇塞入 Prompt |
|
||||||
|
| FR-B053 | 回答文字流式显示,随后 TTS | P0 | 首 token ≤ 3 s |
|
||||||
|
| FR-B054 | Q&A 写入本地记录并纳入伴读报告 | P0 | 可按段落分组 |
|
||||||
|
|
||||||
|
> **与 Chat 模式关系**:伴读问答使用独立 `qa_record` 表与锚点模型;不复用 Chat 的 `sessions/*.json`,避免数据结构冲突。
|
||||||
|
|
||||||
|
### 5.7 伴读报告与参考文献
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-B060 | 手动或条件触发「生成报告」 | P0 | 更新策略:同名 Note 覆盖 |
|
||||||
|
| FR-B061 | 报告结构:速览 / 收听 / 问答 / 重点 / 参考文献卡片 | P0 | Markdown 结构固定 |
|
||||||
|
| FR-B062 | 参考文献卡片含 BibTeX | P0 | 复用 `citeExport` |
|
||||||
|
| FR-B063 | Note 标题格式 `BanDu 伴读报告: {title}` | P0 | 与 Chat 笔记可区分 |
|
||||||
|
|
||||||
|
### 5.8 设置(共享 + 专属)
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-S010 | LLM Provider / Base URL / API Key / Model | P0 | **共享**,两模式只读同一 prefs |
|
||||||
|
| FR-S011 | Chat 专属:system prompt、summary prompt、answer language | P0 | 已有,不变 |
|
||||||
|
| FR-S012 | 伴读专属:TTS Provider / API Key / 音色 | P0 | 新增 prefs 前缀或分组 |
|
||||||
|
| FR-S013 | 伴读专属:教师风格预设(简洁 / 详细) | P1 | 仅影响新生成内容 |
|
||||||
|
| FR-S014 | 数据流向说明(哪些文本发往外网) | P0 | 设置页固定文案 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 非功能需求
|
||||||
|
|
||||||
|
| ID | 类别 | 需求描述 | 指标 |
|
||||||
|
|----|------|----------|------|
|
||||||
|
| NFR-D001 | 隔离性 | 伴读模块崩溃不影响 Chat pane 可用 | 异常捕获 + 独立 try/catch 边界 |
|
||||||
|
| NFR-D002 | 性能 | 8 页论文本地解析 | ≤ 3 min |
|
||||||
|
| NFR-D003 | 性能 | 缓存命中后打开伴读页 | ≤ 2 s 可播放 |
|
||||||
|
| NFR-D004 | 成本 | 单篇 8 页首次备课总 API 成本 | ≤ ¥10 |
|
||||||
|
| NFR-D005 | 隐私 | PDF 不上传;段落文本按请求发送 | Local-first |
|
||||||
|
| NFR-D006 | 离线 | 已缓存 Lecture + mp3 可离线播放 | 除问答外不依赖网络 |
|
||||||
|
| NFR-D007 | 可维护性 | UI / 领域 / Zotero 适配三层分离 | 见 ARCHITECTURE.md |
|
||||||
|
| NFR-D008 | 存储 | 伴读数据目录 `{Profile}/chatpapers/bandu/` | 与 `sessions/` 并列,不混用 |
|
||||||
|
| NFR-D009 | 兼容性 | Zotero 9.x | 与现有 manifest 一致 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 数据与存储策略
|
||||||
|
|
||||||
|
### 7.1 目录布局
|
||||||
|
|
||||||
|
```
|
||||||
|
{ZoteroProfile}/chatpapers/
|
||||||
|
├── sessions/ # Chat 模式(已有):*.json
|
||||||
|
└── bandu/ # 伴读模式(新增)
|
||||||
|
├── bandu.db # SQLite
|
||||||
|
└── audio/
|
||||||
|
└── {cacheKey}/
|
||||||
|
└── {unitId}.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 缓存键
|
||||||
|
|
||||||
|
```
|
||||||
|
cacheKey = SHA256(attachmentId + "|" + fileHash)
|
||||||
|
```
|
||||||
|
|
||||||
|
Chat 模式继续使用 `itemKey + attachmentKey` 会话文件;伴读使用 `cacheKey`,**互不干扰**。
|
||||||
|
|
||||||
|
### 7.3 笔记命名约定
|
||||||
|
|
||||||
|
| 来源 | Note 标题前缀 | 覆盖策略 |
|
||||||
|
|------|---------------|----------|
|
||||||
|
| ChatPapers | `ChatPapers:` 或用户自定义 | 每次新建或用户选择 |
|
||||||
|
| BanDu | `BanDu 伴读报告:` | 同名更新,避免笔记爆炸 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 技术集成约束
|
||||||
|
|
||||||
|
### 8.1 建议目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── hooks.ts # 注册双 pane
|
||||||
|
├── modules/
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ ├── readerPane.ts # 注册 Chat + Bandu section
|
||||||
|
│ │ ├── chatView.ts # Chat 模式(不动)
|
||||||
|
│ │ └── prefs.ts # 扩展伴读设置分组
|
||||||
|
│ ├── llm/ # 共享
|
||||||
|
│ ├── pdf/
|
||||||
|
│ │ ├── extractor.ts # 共享:附件定位
|
||||||
|
│ │ └── selection.ts # 共享:选区
|
||||||
|
│ ├── zotero/ # 共享:notes, citeExport
|
||||||
|
│ ├── storage/
|
||||||
|
│ │ └── sessions.ts # Chat 专属(不动)
|
||||||
|
│ └── bandu/ # 伴读专属(新建)
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ ├── banduPane.ts
|
||||||
|
│ │ └── player/
|
||||||
|
│ ├── application/
|
||||||
|
│ ├── domain/
|
||||||
|
│ └── infrastructure/
|
||||||
|
│ ├── pdf/ # MinerU 封装
|
||||||
|
│ ├── tts/
|
||||||
|
│ ├── storage/ # SQLite
|
||||||
|
│ └── rag/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 依赖规则
|
||||||
|
|
||||||
|
```
|
||||||
|
bandu/* ──可引用──► llm/, pdf/extractor, pdf/selection, zotero/, utils/
|
||||||
|
chatView ──不可引用──► bandu/* (Chat 保持独立,避免耦合)
|
||||||
|
bandu/* ──不可引用──► chatView, storage/sessions
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Phase 0 验证项(开工前 Go / No-Go)
|
||||||
|
|
||||||
|
| # | 验证项 | 通过标准 | 阻塞 MVP |
|
||||||
|
|---|--------|----------|----------|
|
||||||
|
| P0-1 | 第二 Item pane section 注册 | 与 Chat pane 并存无冲突 | 是 |
|
||||||
|
| P0-2 | MinerU 解析 3 篇论文 | 段落可用率 ≥ 80% | 是 |
|
||||||
|
| P0-3 | Zotero 9 插件内 SQLite 读写 | CRUD 正常 | 是 |
|
||||||
|
| P0-4 | `<audio>` 长音频播放 | 10 min+ 无泄漏 | 是 |
|
||||||
|
| P0-5 | TTS 中文样本 + timestamps | 自然度可接受;无则确认降级 | 否 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 版本与路线图
|
||||||
|
|
||||||
|
### 9.1 与现有 ChatPapers 版本关系
|
||||||
|
|
||||||
|
| 插件版本 | Chat 模式 | 伴读模式 |
|
||||||
|
|----------|-----------|----------|
|
||||||
|
| v0.2.x(当前) | 对话 / 总结 / 多 PDF | — |
|
||||||
|
| v0.3.0 | 维护 | Phase 0 POC |
|
||||||
|
| v0.4.0 – v0.5.0 | 维护 | M1–M2:解析 + 备课 + TTS + 播放器 |
|
||||||
|
| v0.6.0 | 维护 | M3–M4:问答 RAG + 报告 + dogfooding |
|
||||||
|
| v1.0.0 | 稳定 | 伴读 MVP 功能完整 |
|
||||||
|
|
||||||
|
### 9.2 伴读模式开发里程碑
|
||||||
|
|
||||||
|
| 阶段 | 时间 | 交付 |
|
||||||
|
|------|------|------|
|
||||||
|
| Phase 0 | 第 0 周 | 三项 POC + 双 pane 骨架占位 |
|
||||||
|
| M1 | 第 1–2 周 | MinerU 解析 + 段落树 + 备课流水线(纯文本) |
|
||||||
|
| M2 | 第 3–4 周 | TTS + 播放器双模式 + 高亮 |
|
||||||
|
| M3 | 第 5–6 周 | 锚定问答 RAG + 伴读报告回写 |
|
||||||
|
| M4 | 第 7–8 周 | 增量缓存 / 断点续播 / 设置 / dogfooding 10 篇 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. MVP 验收清单(伴读模式)
|
||||||
|
|
||||||
|
- [ ] 安装单一 ChatPapers `.xpi`,Item pane 同时出现 ChatPapers 与伴读两个标签
|
||||||
|
- [ ] LLM 配置一次,两模式均可调用
|
||||||
|
- [ ] 对 1 篇 8 页英文 PDF 完成:解析 → 备课 → 整体讲解播放
|
||||||
|
- [ ] 切换到逐段精读并完成至少 1 个章节播放
|
||||||
|
- [ ] 提问 3 次(含 1 次全局 + 1 次逐句),文字与语音均有
|
||||||
|
- [ ] 标记 2 个重点段落
|
||||||
|
- [ ] 生成伴读报告至 Zotero 子笔记,含 BibTeX;与 Chat 笔记共存不覆盖
|
||||||
|
- [ ] 关闭 Zotero 再打开,缓存命中、断点续播
|
||||||
|
- [ ] 断网重听已缓存内容
|
||||||
|
- [ ] Chat 模式原有功能回归通过(对话 / 总结 / 存笔记)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 术语表
|
||||||
|
|
||||||
|
| 术语 | 定义 |
|
||||||
|
|------|------|
|
||||||
|
| Chat 模式 | ChatPapers 现有对话 / 总结工作流 |
|
||||||
|
| 伴读模式 / BanDu | 本插件内第二种工作流:备课 → 听课 → 报告 |
|
||||||
|
| 双模式 | 同一插件、两个 Item pane、共享 LLM 与 Zotero 集成 |
|
||||||
|
| Lecture | 一次备课产物:摘要 + beats + 逐段讲解 |
|
||||||
|
| Beat | 整体讲解播放单位,含 refs[] |
|
||||||
|
| 共享层 | llm / zotero / pdf 附件 / utils,两模式复用 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 文档关系
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/requirements.md ChatPapers 对话模式需求(不变)
|
||||||
|
docs/newidea/
|
||||||
|
├── PLAN.md BanDu 产品策划(参考)
|
||||||
|
├── PRD.md BanDu 独立 PRD(参考)
|
||||||
|
├── ARCHITECTURE.md BanDu 技术架构(参考)
|
||||||
|
└── DUAL_MODE_PRD.md ★ 本文:双模式集成需求(实施依据)
|
||||||
|
```
|
||||||
|
|
||||||
|
**实施优先级**:开发伴读功能时,以**本文**为集成需求主文档;细节 Prompt、数据表、状态机见 ARCHITECTURE.md;产品动机与竞品见 PLAN.md。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 待决事项
|
||||||
|
|
||||||
|
| 事项 | 负责人 | 备注 |
|
||||||
|
|------|--------|------|
|
||||||
|
| TTS 提供商选型 | 技术 | Phase 0 |
|
||||||
|
| SQLite 实现方案(better-sqlite3 vs sql.js) | 技术 | Phase 0 |
|
||||||
|
| 伴读 pane 图标与 l10n 命名(BanDu / 伴读) | 产品 | M1 前 |
|
||||||
|
| Chat → 伴读上下文导入是否做 v1.1 | 产品 | MVP 不做 |
|
||||||
|
| 插件对外宣传:单品牌 vs 双品牌露出 | 产品 | MVP 后 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 文档修订记录
|
||||||
|
|
||||||
|
| 版本 | 日期 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| v0.1 | 2026-08-29 | 初稿:方案 A 双模式集成需求 |
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
# 产品策划书:BanDu(伴读)— Zotero 插件
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 中文名 | 伴读 |
|
||||||
|
| 英文名 | BanDu |
|
||||||
|
| 插件全名 | BanDu for Zotero |
|
||||||
|
|
||||||
|
> 一句话定位:**BanDu(伴读)——在 Zotero 里给每篇论文配一位 AI 老师**——先读完论文备好课,
|
||||||
|
> 再按你选的模式授课(整体讲解 / 逐段精读 / 逐句按需),随时提问、系统不打扰,
|
||||||
|
> 听完后把伴读笔记和参考文献卡片留在 Zotero,写论文时一键引用。
|
||||||
|
|
||||||
|
## 0. 一期范围声明(重要)
|
||||||
|
|
||||||
|
- **一期 = PC 端 + Zotero 插件**,这是产品本体,不是独立 App 的附属渠道;
|
||||||
|
- 论文库 = Zotero 现有库(零迁移),插件直接读 Zotero 里的 PDF;
|
||||||
|
- 移动端是**未来的独立产品**(v2+),通过云后端与 Zotero 端同步,不在一期考虑;
|
||||||
|
- 一期核心用户 = 自己(dogfooding)→ 再面向 Zotero 学术用户开放。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与机会
|
||||||
|
|
||||||
|
### 1.1 核心工作流(一期要服务的完整闭环)
|
||||||
|
|
||||||
|
```
|
||||||
|
Zotero 论文库(已有)
|
||||||
|
│ 打开某篇 PDF
|
||||||
|
▼
|
||||||
|
BanDu 插件:AI 老师读完整篇 → 备课 → 按模式授课 ── 随时提问 ── 标记重点
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
伴读产出留在 Zotero 里:
|
||||||
|
• 伴读报告(问答记录 + 重点标记 + 收听情况)→ Zotero 子笔记,可搜索
|
||||||
|
• 参考文献卡片(中文速览 + 推荐引用句 + BibTeX)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
写论文时:从 Zotero 直接导入引用 + 复用伴读总结(Related Work 素材)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 机会点
|
||||||
|
|
||||||
|
- 在 Zotero 生态里,**文字 AI 已是红海**(Paper Copilot、Owl 等:摘要/问答/翻译),
|
||||||
|
**"AI 老师式音频授课 + 段落/句子级交互"仍是空白**——即使桌面端,"边干活边听论文"也是真实场景;
|
||||||
|
- EndNote / Mendeley 无开放插件生态,**插件路线 = Zotero 路线**,护城河是生态卡位;
|
||||||
|
- Paper Copilot 的流行证明 Zotero 用户对论文 AI 的需求强且愿意装插件。
|
||||||
|
|
||||||
|
> 立项前需实测确认:Paper Copilot / Owl 最新版是否已加 TTS 朗读(目前判断没有,需录屏存档)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 竞品分析
|
||||||
|
|
||||||
|
| 产品 | 形态 | 段落级锚定 | 语音伴读 | 听完提问 | 与 Zotero 集成 | 差距 |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Paper Copilot(Zotero 插件) | 插件 | ◐ | ✗ | ✓(文字) | ✓ | 无音频、无老师式授课、无伴读报告闭环 |
|
||||||
|
| Owl(Zotero 插件) | 插件 | ✗ | ✗ | ✓(文字) | ✓ | 同上 |
|
||||||
|
| SciSpace | 网页 | ✓ | ✗ | ✓(文字) | ✗ | 不在 Zotero 工作流里 |
|
||||||
|
| NotebookLM Audio | 网页 | ✗ | ◐(预生成播客,不可交互) | ✗ | ✗ | 不可交互、不可锚定、不在 Zotero |
|
||||||
|
| ChatGPT 语音 + PDF | App | ✗ | ✓ | ✓ | ✗ | 对话不锚定段落、无播放器、无笔记回写 |
|
||||||
|
| Speechify 类 | 多端 | ✗ | ✓(裸读) | ✗ | ✗ | 机器朗读,无 AI 讲解 |
|
||||||
|
|
||||||
|
**空白确认:Zotero 内 × 老师式授课(整体+逐段+逐句)× 随时提问 × 伴读报告回写,同时满足的产品不存在。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 核心设计决策
|
||||||
|
|
||||||
|
### 3.0 心智模型:AI = 论文老师(不是 TTS 播放器)
|
||||||
|
|
||||||
|
产品的概念基础是一位"**先读完整篇论文、再给读者讲解的老师**",四个阶段:
|
||||||
|
|
||||||
|
```
|
||||||
|
① 读 读完整篇论文(解析 + 段落索引 + 全文摘要)
|
||||||
|
② 备课 基于全文理解生成"讲义"(Lecture 对象,缓存复用):
|
||||||
|
- 整体讲解脚本(按教学逻辑组织,不按论文物理顺序)
|
||||||
|
- 逐段三层讲解(按论文结构组织)
|
||||||
|
- (按需)逐句讲解
|
||||||
|
③ 授课 读者自选模式收听;随时可提问(读者发起,系统不打扰)
|
||||||
|
④ 复盘 听完后生成伴读报告/笔记 → 回写 Zotero,为写论文提供素材
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键架构含义:②"备课"是一次性、可缓存的产物。**
|
||||||
|
老师备一次课,读者听 N 次、换模式重听、隔周再听,都不再花 LLM 成本;
|
||||||
|
整体讲解与逐段精读**共享同一份备课**(同一个全文摘要上下文),边际成本只多一次生成。
|
||||||
|
|
||||||
|
### 3.1 多种授课模式(粒度 = 模式,不是全局开关)
|
||||||
|
|
||||||
|
| 模式 | 组织逻辑 | 播放单位 | 场景 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **整体讲解** | **教学逻辑**:问题→动机→核心方法→关键证据→结论与局限(不是论文物理顺序) | beat(30s–2min 讲解片段,全篇 8–15 个) | 第一次过论文,听"具体讲了什么、核心是什么" |
|
||||||
|
| **逐段精读** | 论文物理顺序:章节→段落 | 段落(>150 词的长段拆 1–3 个"要点") | 第二次精读,吃透方法/实验 |
|
||||||
|
| **逐句讲解** | 按需:选中任意句子,问"解释这句" | 句 | 硬句子手术 |
|
||||||
|
|
||||||
|
**逐段与逐句并存的理由**:老师的粒度应随内容难度走——
|
||||||
|
简单章节整体带过、核心章节逐段精读、难句子逐句解剖。
|
||||||
|
**"整篇逐句自动播放"不做**(成本高、收益低),但逐句**按需解释**永远可用;
|
||||||
|
v1.1 加"**难点句清单**":备课时 AI 主动标出全篇 5–10 个最难读的句子,逐个听讲解
|
||||||
|
(比整篇逐句性价比高得多的折中)。
|
||||||
|
|
||||||
|
**整体讲解的 beat ≠ 段落(关键数据结构差异)**:老师会重组材料
|
||||||
|
("这个 motivation 其实在 p12,我们回去看一下")。
|
||||||
|
每个 beat 携带 `refs[]` 指回来源段落,用于文字面板同步高亮对应原文、点击"看原文"跳 PDF 页。
|
||||||
|
|
||||||
|
v2:**检验模式**——老师反考读者("你觉得作者为什么这么设计?",答完给反馈),
|
||||||
|
直接服务"提高理解能力"的终极目标。
|
||||||
|
|
||||||
|
### 3.2 老师讲什么:三种内容模型
|
||||||
|
|
||||||
|
**① 整体讲解 beat**(全篇 10–20 分钟 ≈ 一杯咖啡过完论文核心):
|
||||||
|
- 讲解文本(中文口语)
|
||||||
|
- `refs[]`:支撑该 beat 的来源段落 ID
|
||||||
|
- 类型标签:问题引入 / 核心方法 / 关键实验 / 结论局限 / 延伸思考
|
||||||
|
- 质量要求(防"像 Wikipedia 摘要"):每个 beat 必须含 **为什么重要 / 一个具体例子或数字 / 一个易混淆点** 三要素
|
||||||
|
|
||||||
|
**② 逐段三层讲解**(对应核心诉求"什么意思 / 怎么理解 / 整体研究思路"):
|
||||||
|
1. **讲了什么**(白话翻译/转述;公式转口语不念 LaTeX;专有名词保留英文 + 中文解释)
|
||||||
|
2. **怎么理解**(关键概念、重要数字、和常规做法的差异)
|
||||||
|
3. **全文定位**("这是 motivation 收尾,为下一节 method 引出问题")
|
||||||
|
|
||||||
|
**③ 逐句讲解**(按需触发):句翻译 + 句法拆解(长句怎么断)+ 这句话在上下文的作用。
|
||||||
|
|
||||||
|
**教师风格(prompt 层可配置)**:主动"点"、不主动"问"——
|
||||||
|
提示性内容(易混淆点、作者隐含假设、"注意这里做了一个强假设")**写进生成的讲义里**,
|
||||||
|
运行时不弹任何提问提示(见 3.3)。
|
||||||
|
|
||||||
|
**语言处理**:
|
||||||
|
- 英文论文:音频 = 中文讲解(不是英文裸读);文字面板 = 英文原文 + 中文讲解对照;
|
||||||
|
- 中文论文:音频 = 中文"白话简化 + 方法分析 + 全文定位"(价值不在翻译,在降维讲解);
|
||||||
|
- 自动检测论文语言,切换 prompt 模板;v1.1 可选"原文朗读模式"(英文 TTS 练听力)。
|
||||||
|
|
||||||
|
### 3.3 交互:读者发起,系统不打扰
|
||||||
|
|
||||||
|
- **不做**每段/每句播完的"这段有什么疑问吗?"提示(已确认砍掉);
|
||||||
|
- 交互入口(任意时刻):
|
||||||
|
1. **随时提问**:锚定当前位置(当前 beat / 段落),也可问全局
|
||||||
|
("这篇和 Transformer 什么关系?");
|
||||||
|
2. **暂停即问**:暂停是天然交互点,暂停时提问入口高亮;
|
||||||
|
3. **选中句子问**:"解释这句"(逐句讲解的入口)。
|
||||||
|
- 问答上下文:当前单位原文 + 对应讲解 + 全文摘要 + 全文索引(RAG);
|
||||||
|
回答**文字先行显示 + TTS 流式播放**;所有问答沉淀进伴读记录(进报告)。
|
||||||
|
- v1.1:章节边界软提示(**默认关闭**:"方法部分讲完了,要问点什么吗?")。
|
||||||
|
|
||||||
|
**系统必须答好的问题类型**(prompt 与 RAG 设计依据):
|
||||||
|
翻译类("这句什么意思")/ 理解类("为什么不用 X")/ 逻辑类("结论怎么从实验得出")/
|
||||||
|
关联类("这和前面 Y 什么关系")/ 元认知类("这段在全文什么作用")。
|
||||||
|
|
||||||
|
### 3.4 伴读报告(听后沉淀,存成 Zotero 笔记)
|
||||||
|
|
||||||
|
听完(或随时点"生成报告")自动汇总:
|
||||||
|
|
||||||
|
```
|
||||||
|
# 伴读报告:<论文标题>
|
||||||
|
## 论文速览 问题/方法/结果/局限(中文四句)
|
||||||
|
## 收听情况 模式、已听 x%、已听段落列表
|
||||||
|
## 问答记录 按段落:Q + AI 回答(可重播)
|
||||||
|
## 重点标记 读者标记的段落 + AI 一句话理由
|
||||||
|
## 参考文献卡片 一句话总结 + 推荐引用句 + BibTeX(BBT citation key)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 存为 Zotero 子笔记(markdown,挂 item 下、可搜索),可导出 .md;
|
||||||
|
- **为写论文服务**:推荐引用句 + 速览可直接进 related work,BibTeX 走 Zotero 原生管道;
|
||||||
|
后续写相关论文时,Zotero 里搜论文标题即可调出全部伴读素材;
|
||||||
|
- v1.1+:理解度评估(按提问分布:"你在实验部分提问最多,建议重听该章节")。
|
||||||
|
|
||||||
|
### 3.5 与 Zotero 的深度集成(一期核心卖点)
|
||||||
|
|
||||||
|
| 集成点 | 做法 |
|
||||||
|
|---|---|
|
||||||
|
| 读 PDF | 插件取 item 的 PDF attachment 本地路径,直接读,**论文不出 Zotero** |
|
||||||
|
| 触发 | 选中带 PDF 的 item → Item pane 出现"伴读"标签页 |
|
||||||
|
| 缓存 | 本地 SQLite:Lecture 对象(beats/三层讲解)+ mp3 + 问答记录,key = attachment ID + 文件哈希;二次打开秒进、离线可重听 |
|
||||||
|
| 伴读报告回写 | 报告 + 每段 Q&A + 重点标记 → `Zotero.Note` 子笔记 |
|
||||||
|
| 参考文献卡片 | 中文速览(问题/方法/结果/局限)+ 推荐引用句 + BibTeX 条目(有 Better BibTeX 时取 citation key) |
|
||||||
|
| 写论文导入 | 写 related work 时:卡片摘要可直接粘贴,BibTeX 经 Zotero 原生/BBT 进 LaTeX;重点段落笔记提供引用上下文 |
|
||||||
|
| 伴读库视图 | 插件主窗口列全部论文:未备课 / 已备课 / 已听 x% / 有报告,快速进入 |
|
||||||
|
|
||||||
|
### 3.6 播放器 UI(Zotero Item pane 内的"伴读"页)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ 论文标题 [英] 老师已备课 ✓ [备课进度(首次)] │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ 模式: [整体讲解 | 逐段精读] │
|
||||||
|
│ ▶ ⏸ ⏮ ⏭ 1x/1.5x/2x │
|
||||||
|
├────────────────────────┬─────────────────────┤
|
||||||
|
│ 单位列表: │ 当前内容: │
|
||||||
|
│ 整体讲解: │ 整体模式: beat 文本 │
|
||||||
|
│ b1 问题引入 ●已讲 │ + refs 来源段落高亮 │
|
||||||
|
│ b2 核心方法 ○ │ 逐段模式: 原文(英)+ │
|
||||||
|
│ b3 关键实验 ○ │ 三层中文讲解(高亮跟随) │
|
||||||
|
│ 逐段精读: │ ── 提问(随时)── │
|
||||||
|
│ 2 Method │ Q: ... [重播] │
|
||||||
|
│ p17 ●已听 [问2] [★] │ A: ... [重播] │
|
||||||
|
│ p18 ○ │ [输入框: 问当前段/全局] │
|
||||||
|
│ ... │ [选中句子→解释这句] │
|
||||||
|
└────────────────────────┴─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 文字面板逐句/逐词高亮跟随音频(TTS word timestamps);
|
||||||
|
- 整体讲解模式:面板高亮当前 beat 的 `refs[]` 来源段落原文,可点"看原文"跳 PDF 页;
|
||||||
|
- 点列表任意行跳播;播放中列表自动滚动跟随;断点续播。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 技术架构(Local-first)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ Zotero 7 (PC, macOS/Win) │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 插件 (TypeScript, 官方 zotero-plugin boilerplate) │ │
|
||||||
|
│ │ • UI 层:伴读 pane / 伴读库窗口 (HTML5 + <audio>) │ │
|
||||||
|
│ │ • 本地引擎:PDF 解析 (MinerU/Marker, 本地 CPU 跑) │ │
|
||||||
|
│ │ • 本地存储:SQLite(Lecture 对象/问答/进度) + mp3 文件│ │
|
||||||
|
│ │ • 设置:LLM 提供商+key / TTS 提供商 / 教师风格 │ │
|
||||||
|
│ │ • 回写:Zotero.Note / 附件路径 / BBT citation key │ │
|
||||||
|
│ └──────────────┬─────────────────────────────────────┘ │
|
||||||
|
└─────────────────┼──────────────────────────────────────┘
|
||||||
|
│ (仅发送段落文本/提问,论文库本身不上云)
|
||||||
|
┌─────────┴──────────┐
|
||||||
|
│ LLM API │ TTS API
|
||||||
|
│ (用户自带 key; │ (ElevenLabs/Azure/
|
||||||
|
│ 或 Ollama 本地) │ 火山/MiniMax; mp3 落盘)
|
||||||
|
└────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 关键流水线
|
||||||
|
|
||||||
|
1. **解析**:attachment 路径 → 本地 layout 切段(双栏/公式/表格)→ 段落树(章节/段落/要点三层)→ SQLite;
|
||||||
|
2. **备课**(Lecture 对象,一次生成、缓存复用):
|
||||||
|
a. 全文摘要(问题/方法/结果/局限)→ 作为后续所有生成的全局上下文;
|
||||||
|
b. **整体讲解 beats**(8–15 个,教学逻辑序,每个 beat 带 `refs[]` 来源段落 ID;
|
||||||
|
prompt 只允许引用已分配的段落 ID,**生成后校验,非法引用剔除**);
|
||||||
|
c. **逐段三层讲解**(按 5 段一批,带章节标题 + 全文摘要上下文);
|
||||||
|
d. v1.1:难点句清单(5–10 句,带句内定位);
|
||||||
|
→ **增量备课**:全文摘要 + beats 先生成(约 1–2 min),即可开听整体讲解;逐段内容后台继续生成("边备课边开讲")。
|
||||||
|
3. **合成**:讲解文本 → TTS → mp3 + word timestamps → 落盘(按 beat / 段落分片);
|
||||||
|
4. **播放**:三种模式共用同一播放器引擎(beat 序 / 段落序 / 单句);
|
||||||
|
word timestamps 驱动文字面板高亮;整体模式按 beat 的 `refs[]` 高亮来源段落;
|
||||||
|
5. **问答**(随时发起):锚定当前单位(beat/段落/句子)→ 组装 prompt
|
||||||
|
(当前单位原文 + 讲解 + 全文摘要 + RAG 相关段)→ LLM 流式 → 文字即时显示 → TTS 流式播放;问答写入伴读记录;
|
||||||
|
6. **报告**:收听完成或手动触发 → 伴读报告 + 参考文献卡片 → Zotero Note / 导出 .md。
|
||||||
|
|
||||||
|
### 4.2 关键技术难点与对策
|
||||||
|
|
||||||
|
| 难点 | 对策 |
|
||||||
|
|---|---|
|
||||||
|
| 双栏/公式/表格切分质量 | 首选 MinerU(CPU 可跑、公式识别好),Marker 备选;解析结果在文字面板可见,bad case 可兜底 |
|
||||||
|
| beat 的 refs 幻觉(引用不存在的段落) | prompt 约束只引用已分配 ID + 生成后校验剔除;refs 缺失的 beat 降级为纯讲解(不高亮原文) |
|
||||||
|
| 整体讲解"太泛"(像 Wikipedia 摘要,不像老师) | beat 模板强制三要素(为什么重要/具体例子或数字/易混淆点);dogfooding 迭代 prompt |
|
||||||
|
| PDF viewer 内段落高亮 | 依赖 PDF viewer 内部 API,稳定性未验证 → **MVP 不做**,用"文字面板高亮 + PDF 页跳转";v1.1 调研 |
|
||||||
|
| 音文同步 | 优先 TTS 提供商返回的 word boundary;缺失则 WhisperX 强制对齐(离线跑一次) |
|
||||||
|
| 问答延迟 | LLM 流式 + TTS 流式,首句 < 2–3s;文字答案先行显示,音频随后 |
|
||||||
|
| LLM 幻觉(讲解偏离原文) | prompt 严格 grounding 到段落原文;温度低;原文始终并排可见;"这段讲解"可一键重生成 |
|
||||||
|
| 长论文成本 | 备课一次、终身缓存(本地);单篇总成本 < ¥10,一次性;二次打开零成本、可离线重听 |
|
||||||
|
| 隐私 | local-first:PDF 不出机器、只发段落文本给 LLM API;设置支持 Ollama 纯本地模型(质量换隐私) |
|
||||||
|
| Zotero 7 API 较新 | 官方 boilerplate、pin 版本;UI 只依赖稳定 API(pane/notes/attachments) |
|
||||||
|
|
||||||
|
### 4.3 单篇成本/耗时估算(8 页英文论文)
|
||||||
|
|
||||||
|
- 解析:本地 CPU 1–3 min,免费
|
||||||
|
- 备课 LLM:全文摘要 + 12 beats + 50–70 段三层讲解 ≈ 30k token ≈ $0.05–0.3(一次性)
|
||||||
|
- TTS:整体讲解 ≈ 3k–5k 中文字 + 逐段 ≈ 8k–15k 字 ≈ ¥1–5(火山/Azure)或 $0.2–0.6(ElevenLabs flash)
|
||||||
|
- 问答:按需,每轮 ≈ $0.002 + 几分钱 TTS
|
||||||
|
- **二次打开 / 换模式重听:零成本**(全部本地缓存,离线可听)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. MVP 范围(6–8 周,PC + Zotero 插件)
|
||||||
|
|
||||||
|
**MVP 一句话:在 Zotero 里打开一篇英文论文,点"备课",AI 读完整篇后按你选的模式授课
|
||||||
|
(整体讲解 / 逐段精读),可随时提问并听到语音回答,听完后伴读报告与参考文献卡片留在 Zotero 里。**
|
||||||
|
|
||||||
|
✅ 包含:
|
||||||
|
1. Zotero 7 插件骨架 + Item pane "伴读"标签页 + 伴读库主窗口(简版)
|
||||||
|
2. 读 Zotero 附件 PDF → 本地解析 → 段落树(章节/段落/要点)
|
||||||
|
3. **备课流水线**:全文摘要 + 整体讲解 beats(含 refs 校验)+ 逐段三层讲解;增量备课(边备边开讲)
|
||||||
|
4. TTS 逐单元合成 + 本地 mp3 缓存
|
||||||
|
5. 播放器:**模式切换(整体讲解 / 逐段精读)**、播放/暂停/跳单位/语速 + 列表 + 文字面板高亮 + PDF 页跳转
|
||||||
|
6. 随时提问(文字输入,锚定当前位置或全局)→ 文字先行 + 语音回答 + 追问;选中句子问"解释这句"(逐句按需)
|
||||||
|
7. 伴读报告(问答记录/重点标记/收听情况)→ Zotero 子笔记;参考文献卡片 + BibTeX
|
||||||
|
8. 设置页(LLM/TTS 提供商与 key、教师风格)、断点续播
|
||||||
|
9. Dogfooding:自己读 10 篇论文,迭代 prompt 与节奏
|
||||||
|
|
||||||
|
❌ 明确砍到 v1.1+:
|
||||||
|
- 语音输入提问(ASR)、barge-in 打断(MVP 用打字提问)
|
||||||
|
- 章节边界软提示(默认关闭)、难点句清单、PDF 内段落级高亮、原文朗读模式
|
||||||
|
- 检验模式(老师考读者,v2)
|
||||||
|
- 移动端 App、云同步、多用户/订阅
|
||||||
|
|
||||||
|
**MVP 验证的假设:**
|
||||||
|
1. 整体讲解 10–20 分钟的信息密度是否合适 → dogfooding 调 beat 数与单 beat 长度;
|
||||||
|
2. "无提示、随时问"的交互是否够用 → 记录自己"想问但没问"的时刻,决定 v1.1 软提示的默认值;
|
||||||
|
3. 伴读报告回 Zotero 后,写论文时是否真的被复用(**关键闭环验证**)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 路线图
|
||||||
|
|
||||||
|
| 阶段 | 时间 | 内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| M1 | 第 1–2 周 | 插件骨架 + PDF 解析(MinerU 本地)+ 段落树 + 备课流水线(摘要/beats/三层讲解,纯文本跑通) |
|
||||||
|
| M2 | 第 3–4 周 | TTS 流水线 + 播放器(双模式切换)+ 文字面板高亮 + refs 高亮 |
|
||||||
|
| M3 | 第 5–6 周 | 随时问答(RAG + 流式)+ 伴读报告/卡片回写 + BibTeX |
|
||||||
|
| M4 | 第 7–8 周 | 增量备课/缓存/断点续播 + 设置页 + dogfooding 10 篇 + 打磨 |
|
||||||
|
| v1.1 | +4–6 周 | 语音输入(ASR)、barge-in、难点句清单、软提示(默认关)、PDF 内高亮、原文朗读模式、理解度评估、伴读库完善、开源核心 |
|
||||||
|
| v2 | 后续 | **独立移动端 App**(云后端同步论文与伴读进度)、**检验模式**(老师考读者)、多论文对比听、团队版/订阅 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 商业模式(分期)
|
||||||
|
|
||||||
|
- **一期**:个人工具,插件免费 + 用户自带 LLM/TTS key(零边际成本,dogfooding + 口碑);
|
||||||
|
- **v1.1**:核心开源,Pro 云服务(TTS 代付、跨设备同步、热门 arXiv 论文讲义共享缓存)$9.9–19.9/月;
|
||||||
|
- **长期**:学术团队版(共享伴读库、写作引用协作);移动端 App 跟随 v2。
|
||||||
|
- 成本结构:local-first 下服务器成本极低,主要成本在 TTS 代付(共享缓存可摊薄)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 风险与对策
|
||||||
|
|
||||||
|
| 风险 | 等级 | 对策 |
|
||||||
|
|---|---|---|
|
||||||
|
| 整体讲解"太泛"不像老师(核心体验风险) | 中 | beat 三要素模板 + dogfooding 迭代;教师风格可配置 |
|
||||||
|
| Zotero 7 插件 API 不稳定 / 未来版本变动 | 中 | 官方 boilerplate、pin 版本、只依赖稳定 API;UI 层与 API 层隔离 |
|
||||||
|
| PDF viewer 高亮依赖内部 API | 中 | MVP 绕开(文字面板高亮 + 页跳转);v1.1 专项调研 |
|
||||||
|
| LLM 讲解幻觉 / 质量不稳定 | 中 | grounding prompt + 原文并排 + 一键重生成 + dogfooding 迭代 prompt |
|
||||||
|
| 无提示交互下用户忘了提问(交互率过低) | 低 | dogfooding 记录数据;v1.1 软提示(默认关)兜底 |
|
||||||
|
| 大厂在 Zotero 生态做音频伴读(或 Zotero 官方 AI 化) | 中 | 生态卡位 + "备课-授课-报告"闭环是差异化;持续盯 Paper Copilot 与 Zotero roadmap |
|
||||||
|
| 隐私顾虑(论文文本发给 LLM API) | 低 | local-first + 可选 Ollama 纯本地;设置中明示数据流向 |
|
||||||
|
| 解析 bad case(复杂版面) | 中 | 双引擎备选 + 文字面板兜底 + 用户反馈入口 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 团队与预算(一期)
|
||||||
|
|
||||||
|
- 1 名全栈(插件 + 后端流水线)+ 1 名 AI 工程(解析/prompt/RAG,可同一人);
|
||||||
|
- 云成本 ≈ 0(全 local-first,仅 LLM/TTS API 按量,个人使用 <$10/月);
|
||||||
|
- 8 周 dogfooding 就绪,不含人力的直接成本 <$200。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 下一步行动(本周)
|
||||||
|
|
||||||
|
1. **竞品复核**:装 Paper Copilot + Owl,实测是否已有 TTS/音频能力,录屏存档;
|
||||||
|
2. **解析 POC**:取自己 Zotero 里 3 篇典型论文(双栏 + 公式密集)跑 MinerU(本地 CPU),评估段落切分与公式识别质量;
|
||||||
|
3. **插件 POC**:zotero-plugin boilerplate 跑通三件事——取 attachment 路径、Item pane 加标签页、页面里 `<audio>` 播一段 mp3(1–2 天);
|
||||||
|
4. **TTS 选型**:中文自然度 + 价格 + 是否返回 word timestamps,对比 ElevenLabs / Azure / 火山,各合成 30 秒样本试听;
|
||||||
|
5. **Prompt 设计**:写三套 prompt 初版——全文摘要 / 整体讲解 beats(含 refs 约束)/ 逐段三层讲解;用 1 篇论文人工评估"像不像老师"。
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
# 产品需求规格说明书(PRD)
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 产品名称 | BanDu(伴读)— Zotero 插件 |
|
||||||
|
| 中文名 | 伴读 |
|
||||||
|
| 英文名 | BanDu |
|
||||||
|
| 文档版本 | v0.1 |
|
||||||
|
| 状态 | 草案 |
|
||||||
|
| 关联文档 | [PLAN.md](./PLAN.md)(产品策划)、[ARCHITECTURE.md](./ARCHITECTURE.md)(架构设计) |
|
||||||
|
| 一期范围 | Zotero 7 桌面插件(macOS / Windows) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 产品概述
|
||||||
|
|
||||||
|
### 1.1 一句话定位
|
||||||
|
|
||||||
|
在 Zotero 里给每篇论文配一位 AI 老师:先读完整篇论文并备好课,再按用户选择的模式授课(整体讲解 / 逐段精读 / 逐句按需),随时提问且系统不主动打扰;听完后将伴读笔记与参考文献卡片写回 Zotero,供写论文时复用。
|
||||||
|
|
||||||
|
**品牌**:中文名 **伴读**,英文名 **BanDu**(「伴读」音译)。
|
||||||
|
|
||||||
|
### 1.2 产品目标
|
||||||
|
|
||||||
|
| 目标 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| G1 降低读论文门槛 | 用户可在 10–20 分钟内通过「整体讲解」把握论文核心 |
|
||||||
|
| G2 支持深度精读 | 通过「逐段精读」与「逐句按需解释」吃透方法与实验 |
|
||||||
|
| G3 闭环沉淀知识 | 伴读报告、问答、重点标记留在 Zotero,可直接用于 Related Work |
|
||||||
|
| G4 零迁移成本 | 论文库沿用 Zotero,PDF 不出本地机器 |
|
||||||
|
|
||||||
|
### 1.3 成功指标(MVP 验证期)
|
||||||
|
|
||||||
|
| 指标 | 目标 | 测量方式 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 整体讲解完成率 | ≥ 60% 启动后听完 | 播放进度日志 |
|
||||||
|
| 单篇备课可听时间 | 摘要 + beats 生成后 ≤ 2 min 可开听 | 备课流水线耗时 |
|
||||||
|
| 问答首字延迟 | ≤ 3 s(文字) | 问答链路埋点 |
|
||||||
|
| 报告复用率 | Dogfooding 10 篇中 ≥ 3 篇报告内容被粘贴进写作 | 人工记录 |
|
||||||
|
| 解析可用率 | 典型 8 页 NLP 论文 ≥ 90% 段落无需手动修正 | Dogfooding |
|
||||||
|
|
||||||
|
### 1.4 目标用户
|
||||||
|
|
||||||
|
| 角色 | 描述 | 一期优先级 |
|
||||||
|
|------|------|------------|
|
||||||
|
| 研究者(Primary) | 使用 Zotero 管理文献,需快速过大量论文并精读关键篇 | P0 |
|
||||||
|
| 研究生 | 英文论文阅读吃力,需要中文讲解与随时提问 | P0 |
|
||||||
|
| 论文写作者 | 需要 Related Work 素材、引用句与 BibTeX | P0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 范围定义
|
||||||
|
|
||||||
|
### 2.1 一期包含(MVP)
|
||||||
|
|
||||||
|
- Zotero 7 插件:Item pane「伴读」标签页 + 伴读库主窗口(简版)
|
||||||
|
- 读取 Zotero PDF 附件 → 本地解析 → 段落树
|
||||||
|
- 备课流水线:全文摘要 + 整体讲解 beats + 逐段三层讲解;增量备课
|
||||||
|
- TTS 合成 + 本地 mp3 缓存
|
||||||
|
- 播放器:整体讲解 / 逐段精读双模式、跳播、语速、文字面板高亮、PDF 页跳转
|
||||||
|
- 文字提问(锚定当前位置 / 全局 / 选中句子)→ 文字 + 语音回答
|
||||||
|
- 伴读报告 + 参考文献卡片 → Zotero 子笔记;BibTeX
|
||||||
|
- 设置页(LLM / TTS / 教师风格)、断点续播、本地缓存
|
||||||
|
|
||||||
|
### 2.2 一期明确不包含
|
||||||
|
|
||||||
|
| 功能 | 计划版本 |
|
||||||
|
|------|----------|
|
||||||
|
| 语音输入提问(ASR)、barge-in 打断 | v1.1 |
|
||||||
|
| 章节边界软提示 | v1.1 |
|
||||||
|
| 难点句清单(备课预标) | v1.1 |
|
||||||
|
| PDF viewer 内段落级高亮 | v1.1 |
|
||||||
|
| 英文原文朗读模式 | v1.1 |
|
||||||
|
| 检验模式(老师考读者) | v2 |
|
||||||
|
| 移动端 App、云同步、多用户订阅 | v2 |
|
||||||
|
|
||||||
|
### 2.3 不支持场景(需在 UI 明示)
|
||||||
|
|
||||||
|
- 扫描版 PDF(无 OCR 文本层)
|
||||||
|
- Item 无 PDF 附件或非 PDF 附件
|
||||||
|
- 加密且无法本地读取的 PDF
|
||||||
|
- 极长论文(> 80 页)默认仅推荐整体讲解模式(逐段按需生成)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 用户故事与用例
|
||||||
|
|
||||||
|
### 3.1 核心用户故事
|
||||||
|
|
||||||
|
| ID | 作为… | 我想要… | 以便… | 优先级 |
|
||||||
|
|----|-------|---------|-------|--------|
|
||||||
|
| US-01 | 研究者 | 选中 Zotero 论文后一键「备课」 | AI 先读完整篇再给我讲解 | P0 |
|
||||||
|
| US-02 | 研究者 | 选择「整体讲解」模式收听 | 20 分钟内把握论文核心 | P0 |
|
||||||
|
| US-03 | 研究者 | 选择「逐段精读」按章节听 | 深入理解方法与实验 | P0 |
|
||||||
|
| US-04 | 研究者 | 播放中随时文字提问 | 不打断思路又能澄清疑问 | P0 |
|
||||||
|
| US-05 | 研究者 | 选中难句点「解释这句」 | 快速理解长难句 | P0 |
|
||||||
|
| US-06 | 研究者 | 听完后自动生成伴读报告到 Zotero | 写论文时搜索即可调出素材 | P0 |
|
||||||
|
| US-07 | 研究者 | 二次打开同一篇论文秒进、离线重听 | 不重复花 API 费用 | P0 |
|
||||||
|
| US-08 | 研究者 | 在伴读库看到全部论文的备课/收听状态 | 快速找到未读论文 | P1 |
|
||||||
|
| US-09 | 研究者 | 标记重点段落 | 报告里汇总我关心的部分 | P0 |
|
||||||
|
| US-10 | 研究者 | 配置自己的 LLM / TTS API Key | 控制成本与隐私 | P0 |
|
||||||
|
|
||||||
|
### 3.2 用例:首次伴读一篇论文
|
||||||
|
|
||||||
|
**前置条件**:Zotero 7 已安装插件;Item 有 PDF 附件;用户已配置 LLM Key。
|
||||||
|
|
||||||
|
**主流程**:
|
||||||
|
|
||||||
|
1. 用户在 Zotero 选中带 PDF 的 Item
|
||||||
|
2. 打开 Item pane「伴读」标签页
|
||||||
|
3. 点击「开始备课」
|
||||||
|
4. 系统解析 PDF,展示进度(解析 → 摘要 → beats → 逐段讲解 → TTS)
|
||||||
|
5. 摘要 + beats 就绪后(约 1–2 min),整体讲解模式可用,用户开始收听
|
||||||
|
6. 用户可随时暂停、提问、切换逐段精读、标记重点
|
||||||
|
7. 用户点击「生成报告」或收听达阈值后,系统创建 Zotero 子笔记
|
||||||
|
|
||||||
|
**后置条件**:本地 SQLite + mp3 缓存就绪;Zotero 子笔记(若生成)可搜索。
|
||||||
|
|
||||||
|
### 3.3 用例:缓存命中二次打开
|
||||||
|
|
||||||
|
**前置条件**:该 attachment 已备课且文件哈希未变。
|
||||||
|
|
||||||
|
**主流程**:
|
||||||
|
|
||||||
|
1. 用户打开同一 Item 的伴读页
|
||||||
|
2. 系统校验 attachment ID + 文件哈希,命中缓存
|
||||||
|
3. 立即展示播放器,恢复上次播放进度
|
||||||
|
4. 不调用 LLM / TTS(除非用户触发重生成或问答)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 功能需求
|
||||||
|
|
||||||
|
### 4.1 插件与 Zotero 集成
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-001 | 插件基于 Zotero 7 官方 boilerplate,支持 macOS 与 Windows | P0 | 两平台可安装、启用 |
|
||||||
|
| FR-002 | 选中含 PDF 附件的 Item 时,Item pane 显示「伴读」标签页 | P0 | 无 PDF 时标签页禁用并提示原因 |
|
||||||
|
| FR-003 | 通过 Zotero API 读取 PDF attachment 本地路径 | P0 | 路径可读、文件哈希可计算 |
|
||||||
|
| FR-004 | 伴读报告写入 Item 下 `Zotero.Note` 子笔记(Markdown) | P0 | 笔记在 Zotero 内可搜索、可导出 .md |
|
||||||
|
| FR-005 | 有 Better BibTeX 时读取 citation key;无则降级 | P1 | 卡片中 BibTeX 字段正确或标注缺失 |
|
||||||
|
| FR-006 | 伴读库窗口列出库内论文及状态:未备课 / 已备课 / 已听 x% / 有报告 | P1 | 点击条目进入对应伴读页 |
|
||||||
|
|
||||||
|
### 4.2 PDF 解析与段落树
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-010 | 本地解析 PDF,输出章节 / 段落 / 要点三层结构 | P0 | 段落含唯一 ID、原文、页码、章节路径 |
|
||||||
|
| FR-011 | 支持双栏、公式、表格的常见学术论文版式 | P0 | Dogfooding 3 类典型论文可用 |
|
||||||
|
| FR-012 | 解析结果持久化至 SQLite,key = attachment ID + 文件哈希 | P0 | PDF 未变时跳过重复解析 |
|
||||||
|
| FR-013 | 解析进度与错误对用户可见 | P0 | 失败时给出可行动提示(如扫描版) |
|
||||||
|
| FR-014 | 文字面板展示解析原文,供用户核对 | P1 | 用户可发现 bad case |
|
||||||
|
|
||||||
|
### 4.3 备课(Lecture 生成)
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-020 | 生成全文摘要:问题 / 方法 / 结果 / 局限 | P0 | 四字段非空、中文 |
|
||||||
|
| FR-021 | 生成 8–15 个整体讲解 beat,按教学逻辑排序 | P0 | 每 beat 含讲解文本、类型标签、refs[] |
|
||||||
|
| FR-022 | beat 的 refs[] 仅允许引用已存在段落 ID;生成后校验,非法引用剔除 | P0 | 校验日志可审计;无效 refs 的 beat 降级 |
|
||||||
|
| FR-023 | 每个 beat 含三要素:为什么重要 / 具体例子或数字 / 易混淆点 | P0 | Prompt 模板约束 + 抽检 |
|
||||||
|
| FR-024 | 为每个段落生成三层讲解:讲了什么 / 怎么理解 / 全文定位 | P0 | 三层均非空 |
|
||||||
|
| FR-025 | 增量备课:摘要 + beats 完成后即可播放整体讲解 | P0 | 用户无需等逐段全部完成 |
|
||||||
|
| FR-026 | 逐段讲解后台继续生成,UI 显示进度 | P0 | 新段落就绪后可播放 |
|
||||||
|
| FR-027 | Lecture 对象本地缓存,二次打开不重复生成 | P0 | 除非文件哈希变化或用户强制重备 |
|
||||||
|
| FR-028 | 支持一键重生成单个 beat 或单段讲解 | P1 | 重生成后更新缓存与音频 |
|
||||||
|
|
||||||
|
### 4.4 TTS 与音频
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-030 | 讲解文本按 beat / 段落分片合成 mp3 并本地缓存 | P0 | 文件路径与 Lecture 单元绑定 |
|
||||||
|
| FR-031 | 英文论文音频为中文讲解;自动检测论文语言切换 Prompt | P0 | 英/中论文各测 1 篇 |
|
||||||
|
| FR-032 | 保存 word / character timestamps(若 TTS 提供) | P0 | 用于文字面板高亮 |
|
||||||
|
| FR-033 | TTS 不支持 timestamps 时,降级为句子级或段落级高亮 | P0 | UI 不崩溃,高亮粒度下降可接受 |
|
||||||
|
| FR-034 | 问答回答支持 TTS 播放(可先整段合成,非必须流式) | P0 | 每轮 Q&A 可重播语音 |
|
||||||
|
|
||||||
|
### 4.5 播放器
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-040 | 支持模式切换:整体讲解 / 逐段精读 | P0 | 切换后列表与播放单位正确 |
|
||||||
|
| FR-041 | 播放控制:播放 / 暂停 / 上一单位 / 下一单位 / 1x·1.5x·2x | P0 | 状态与 `<audio>` 同步 |
|
||||||
|
| FR-042 | 单位列表展示进度:已听 / 未听;播放中自动滚动 | P0 | 断点续播恢复正确单位 |
|
||||||
|
| FR-043 | 整体模式:文字面板展示 beat 文本 + refs 来源段落高亮 | P0 | 高亮与当前 beat 一致 |
|
||||||
|
| FR-044 | 逐段模式:左侧原文 + 右侧三层讲解,高亮跟随音频 | P0 | 至少句子级跟随 |
|
||||||
|
| FR-045 | 「看原文」跳转 PDF 对应页 | P0 | 页码与段落 page 字段一致 |
|
||||||
|
| FR-046 | 点击列表任意行跳播 | P0 | Seek 到该单位起始 |
|
||||||
|
| FR-047 | 段落支持「重点标记」★ | P0 | 标记写入进度并进入报告 |
|
||||||
|
|
||||||
|
### 4.6 问答
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-050 | 任意时刻文字提问,锚定类型:beat / 段落 / 句子 / 全局 | P0 | 上下文含锚点原文与讲解 |
|
||||||
|
| FR-051 | 暂停时提问入口视觉强化 | P1 | 暂停态输入框高亮 |
|
||||||
|
| FR-052 | 选中句子触发「解释这句」(逐句讲解) | P0 | 输出:翻译 + 句法拆解 + 上下文作用 |
|
||||||
|
| FR-053 | 回答基于 RAG:当前单位 + 全文摘要 + 检索相关段 | P0 | 长论文不整篇塞入 Prompt |
|
||||||
|
| FR-054 | 回答文字流式显示,随后 TTS 播放 | P0 | 首 token ≤ 3 s(网络正常) |
|
||||||
|
| FR-055 | 所有 Q&A 写入本地记录并纳入伴读报告 | P0 | 可按段落分组展示 |
|
||||||
|
| FR-056 | 不支持语音输入与 barge-in(MVP) | — | 仅文字输入 |
|
||||||
|
|
||||||
|
### 4.7 伴读报告与参考文献卡片
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-060 | 手动或条件触发「生成报告」 | P0 | 不覆盖已有报告时需确认(或版本策略见架构) |
|
||||||
|
| FR-061 | 报告结构:论文速览 / 收听情况 / 问答记录 / 重点标记 / 参考文献卡片 | P0 | Markdown 结构固定 |
|
||||||
|
| FR-062 | 参考文献卡片:中文速览 + 推荐引用句 + BibTeX | P0 | 可直接粘贴进 LaTeX 写作流 |
|
||||||
|
| FR-063 | 支持导出 .md 文件 | P1 | 与 Note 内容一致 |
|
||||||
|
|
||||||
|
### 4.8 设置
|
||||||
|
|
||||||
|
| ID | 需求描述 | 优先级 | 验收标准 |
|
||||||
|
|----|----------|--------|----------|
|
||||||
|
| FR-070 | 配置 LLM 提供商、Endpoint、API Key | P0 | 支持 OpenAI 兼容 API + Ollama |
|
||||||
|
| FR-071 | 配置 TTS 提供商与 Key | P0 | 至少支持一种中文 TTS |
|
||||||
|
| FR-072 | 教师风格 Prompt 预设(如:简洁 / 详细) | P1 | 变更后对**新生成**内容生效 |
|
||||||
|
| FR-073 | 明示数据流向:哪些文本会发往外部 API | P0 | 设置页固定文案 |
|
||||||
|
| FR-074 | API Key 安全存储(Zotero 偏好或 OS Keychain) | P0 | 不以明文写入日志 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 非功能需求
|
||||||
|
|
||||||
|
| ID | 类别 | 需求描述 | 指标 |
|
||||||
|
|----|------|----------|------|
|
||||||
|
| NFR-001 | 性能 | 8 页论文本地解析 | ≤ 3 min(CPU) |
|
||||||
|
| NFR-002 | 性能 | 缓存命中后打开伴读页 | ≤ 2 s 可播放 |
|
||||||
|
| NFR-003 | 性能 | 问答首字延迟 | ≤ 3 s |
|
||||||
|
| NFR-004 | 成本 | 单篇 8 页论文首次备课总 API 成本 | ≤ ¥10 |
|
||||||
|
| NFR-005 | 可用性 | 二次打开 / 换模式重听 | 零 LLM/TTS 调用 |
|
||||||
|
| NFR-006 | 隐私 | PDF 文件不上传;仅段落文本按请求发送 | Local-first |
|
||||||
|
| NFR-007 | 离线 | 已缓存 Lecture + mp3 可离线播放 | 除问答外不依赖网络 |
|
||||||
|
| NFR-008 | 可靠性 | 备课/Task 中断后可恢复 | 状态机支持断点续跑 |
|
||||||
|
| NFR-009 | 兼容性 | Zotero 7.x 指定 minor 版本范围 | 文档 pin 版本 |
|
||||||
|
| NFR-010 | 可维护性 | UI 层与 Zotero API 适配层分离 | 代码模块边界清晰 |
|
||||||
|
| NFR-011 | 无障碍 | 播放器键盘可操作(播放/暂停/跳段) | P1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 界面需求摘要
|
||||||
|
|
||||||
|
伴读页布局见 [PLAN.md §3.6](./PLAN.md);关键交互原则:
|
||||||
|
|
||||||
|
- **读者发起,系统不打扰**:播完不弹「有疑问吗」
|
||||||
|
- **原文始终可见**:讲解与原文并排,降低幻觉信任成本
|
||||||
|
- **进度可感知**:首次备课、增量生成、TTS 合成均有进度反馈
|
||||||
|
- **错误可恢复**:解析失败、API 失败提供重试与降级路径
|
||||||
|
|
||||||
|
### 6.1 状态展示
|
||||||
|
|
||||||
|
| 状态 | 用户可见文案示例 |
|
||||||
|
|------|------------------|
|
||||||
|
| 未备课 | 「点击开始备课」 |
|
||||||
|
| 解析中 | 「正在读取论文… 45%」 |
|
||||||
|
| 备课中(可部分播放) | 「整体讲解已就绪,逐段讲解生成中 12/58」 |
|
||||||
|
| 已备课 | 「老师已备课 ✓」 |
|
||||||
|
| 播放中 | 当前单位高亮 + 进度条 |
|
||||||
|
| 离线可听 | 「已缓存,离线可播放」 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 内容质量需求(Prompt / 讲解)
|
||||||
|
|
||||||
|
| ID | 需求 |
|
||||||
|
|----|------|
|
||||||
|
| CQ-001 | 讲解必须 grounding 到段落原文,温度偏低 |
|
||||||
|
| CQ-002 | 公式转口语,不朗读 LaTeX 符号 |
|
||||||
|
| CQ-003 | 专有名词:英文保留 + 中文解释 |
|
||||||
|
| CQ-004 | beat 类型标签枚举:问题引入 / 核心方法 / 关键实验 / 结论局限 / 延伸思考 |
|
||||||
|
| CQ-005 | 问答需覆盖:翻译 / 理解 / 逻辑 / 关联 / 元认知五类问题 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. MVP 验收清单
|
||||||
|
|
||||||
|
- [ ] 在 Zotero 7 安装插件,Item pane 出现伴读页
|
||||||
|
- [ ] 对 1 篇 8 页英文 PDF 完成:解析 → 备课 → 整体讲解播放
|
||||||
|
- [ ] 切换到逐段精读并完成至少 1 个章节播放
|
||||||
|
- [ ] 提问 3 次(含 1 次全局 + 1 次逐句),文字与语音均有
|
||||||
|
- [ ] 标记 2 个重点段落
|
||||||
|
- [ ] 生成伴读报告至 Zotero 子笔记,含 BibTeX
|
||||||
|
- [ ] 关闭 Zotero 再打开,缓存命中、断点续播
|
||||||
|
- [ ] airplane 模式(或断网)下重听已缓存内容
|
||||||
|
- [ ] Dogfooding 累计 10 篇,记录指标表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 版本规划(需求视角)
|
||||||
|
|
||||||
|
| 版本 | 核心交付 |
|
||||||
|
|------|----------|
|
||||||
|
| Phase 0 | 三项 POC 通过:Zotero pane、MinerU 解析、TTS timestamps |
|
||||||
|
| MVP(M1–M4) | 本文 §2.1 全部 P0 需求 |
|
||||||
|
| v1.1 | ASR、barge-in、难点句清单、PDF 内高亮、理解度评估 |
|
||||||
|
| v2 | 移动端、云同步、检验模式 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 术语表
|
||||||
|
|
||||||
|
| 术语 | 定义 |
|
||||||
|
|------|------|
|
||||||
|
| BanDu / 伴读 | 产品品牌名;英文 BanDu 为「伴读」音译,Zotero 插件全名 BanDu for Zotero |
|
||||||
|
| Lecture | 一次备课的完整产物:摘要 + beats + 逐段讲解 + 元数据 |
|
||||||
|
| Beat | 整体讲解模式下的播放单位,30 s–2 min,含 refs[] |
|
||||||
|
| 三层讲解 | 讲了什么 / 怎么理解 / 全文定位 |
|
||||||
|
| 增量备课 | 摘要与 beats 优先生成,逐段与 TTS 后台继续 |
|
||||||
|
| refs[] | Beat 引用的来源段落 ID 列表 |
|
||||||
|
| 伴读报告 | 听后汇总写入 Zotero 的 Markdown 笔记 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 待决事项
|
||||||
|
|
||||||
|
| 事项 | 负责人 | 截止 |
|
||||||
|
|------|--------|------|
|
||||||
|
| TTS 提供商最终选型 | 技术 | Phase 0 |
|
||||||
|
| 报告生成:覆盖 vs 追加策略 | 产品 | M3 前 |
|
||||||
|
| 极长论文逐段生成策略(全量 vs 按章 lazy) | 产品 + 技术 | M1 前 |
|
||||||
|
| Better BibTeX 缺失时的 BibTeX 生成规则 | 技术 | M3 前 |
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 文档索引
|
||||||
|
|
||||||
|
**BanDu(伴读)** — Zotero AI 论文伴读插件项目文档。
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 中文名 | 伴读 |
|
||||||
|
| 英文名 | BanDu |
|
||||||
|
| 插件全名 | BanDu for Zotero |
|
||||||
|
|
||||||
|
| 文档 | 说明 | 读者 |
|
||||||
|
|------|------|------|
|
||||||
|
| [PLAN.md](./PLAN.md) | 产品策划书:背景、竞品、设计决策、路线图 | 产品 / 全员 |
|
||||||
|
| [PRD.md](./PRD.md) | 产品需求规格说明书:功能需求、非功能需求、验收标准 | 产品 / 开发 / 测试 |
|
||||||
|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | 软件架构设计:分层、数据模型、流水线、状态机 | 开发 / 架构 |
|
||||||
|
|
||||||
|
## 阅读顺序建议
|
||||||
|
|
||||||
|
1. **PLAN.md** — 理解产品定位与为什么做
|
||||||
|
2. **PRD.md** — 明确做什么、验收标准
|
||||||
|
3. **ARCHITECTURE.md** — 明确怎么做、模块与数据如何组织
|
||||||
|
|
||||||
|
## 文档关系
|
||||||
|
|
||||||
|
```
|
||||||
|
PLAN.md(策划)
|
||||||
|
├── PRD.md(需求:What)
|
||||||
|
└── ARCHITECTURE.md(架构:How)
|
||||||
|
```
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { initLocale } from "./utils/locale";
|
import { initLocale } from "./utils/locale";
|
||||||
import { createZToolkit } from "./utils/ztoolkit";
|
import { createZToolkit } from "./utils/ztoolkit";
|
||||||
|
import { registerLecturePane } from "./modules/lecture/ui/registerLecturePane";
|
||||||
import { registerChatPane, registerPrefs } from "./modules/ui/readerPane";
|
import { registerChatPane, registerPrefs } from "./modules/ui/readerPane";
|
||||||
import { registerPrefsScripts } from "./modules/ui/prefs";
|
import { registerPrefsScripts } from "./modules/ui/prefs";
|
||||||
import { registerItemMenus } from "./modules/ui/itemMenu";
|
import { registerItemMenus } from "./modules/ui/itemMenu";
|
||||||
@@ -15,6 +16,7 @@ async function onStartup() {
|
|||||||
initLocale();
|
initLocale();
|
||||||
registerPrefs();
|
registerPrefs();
|
||||||
registerChatPane();
|
registerChatPane();
|
||||||
|
registerLecturePane();
|
||||||
registerReaderSelectionHook();
|
registerReaderSelectionHook();
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { StoredBeat } from "../infrastructure/storage/lectureStore";
|
||||||
|
import { pathToFileUrl } from "../infrastructure/platform/paths";
|
||||||
|
|
||||||
|
export interface PlayLectureHandle {
|
||||||
|
play: () => Promise<void>;
|
||||||
|
pause: () => void;
|
||||||
|
next: () => Promise<void>;
|
||||||
|
prev: () => Promise<void>;
|
||||||
|
jumpToBeatId: (beatId: string) => Promise<void>;
|
||||||
|
getCurrentBeatId: () => string | undefined;
|
||||||
|
isPlaying: () => boolean;
|
||||||
|
destroy: () => void;
|
||||||
|
setBeats: (beats: StoredBeat[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlayLecture(options: {
|
||||||
|
audio: HTMLAudioElement;
|
||||||
|
beats: StoredBeat[];
|
||||||
|
onBeatChange?: (beatId: string | undefined) => void;
|
||||||
|
onPlayingChange?: (playing: boolean) => void;
|
||||||
|
onComplete?: () => void;
|
||||||
|
}): PlayLectureHandle {
|
||||||
|
let beats = options.beats.filter((b) => b.ttsStatus === "ready" && b.audioPath);
|
||||||
|
let index = 0;
|
||||||
|
let playing = false;
|
||||||
|
|
||||||
|
const notifyBeat = () => {
|
||||||
|
options.onBeatChange?.(beats[index]?.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadIndex = (i: number): boolean => {
|
||||||
|
if (i < 0 || i >= beats.length) return false;
|
||||||
|
index = i;
|
||||||
|
const beat = beats[i];
|
||||||
|
if (!beat.audioPath) return false;
|
||||||
|
options.audio.src = pathToFileUrl(beat.audioPath);
|
||||||
|
options.audio.load();
|
||||||
|
notifyBeat();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
options.audio.onended = () => {
|
||||||
|
void (async () => {
|
||||||
|
if (index + 1 < beats.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 {
|
||||||
|
setBeats(next) {
|
||||||
|
beats = next.filter((b) => b.ttsStatus === "ready" && b.audioPath);
|
||||||
|
if (index >= beats.length) index = Math.max(0, beats.length - 1);
|
||||||
|
notifyBeat();
|
||||||
|
},
|
||||||
|
|
||||||
|
async play() {
|
||||||
|
if (!beats.length) return;
|
||||||
|
if (!options.audio.src || options.audio.paused) {
|
||||||
|
loadIndex(index);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await options.audio.play();
|
||||||
|
playing = true;
|
||||||
|
options.onPlayingChange?.(true);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] audio play failed", e);
|
||||||
|
playing = false;
|
||||||
|
options.onPlayingChange?.(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
options.audio.pause();
|
||||||
|
playing = false;
|
||||||
|
options.onPlayingChange?.(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
async next() {
|
||||||
|
if (index + 1 >= beats.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 jumpToBeatId(beatId: string) {
|
||||||
|
const i = beats.findIndex((b) => b.id === beatId);
|
||||||
|
if (i < 0) return;
|
||||||
|
index = i;
|
||||||
|
if (loadIndex(index)) await this.play();
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentBeatId() {
|
||||||
|
return beats[index]?.id;
|
||||||
|
},
|
||||||
|
|
||||||
|
isPlaying() {
|
||||||
|
return playing && !options.audio.paused;
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
options.audio.onended = null;
|
||||||
|
options.audio.pause();
|
||||||
|
playing = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import type { LectureStatus, PaperCache } from "../domain/types";
|
||||||
|
import { generateBeats } from "../infrastructure/llm/beatLlm";
|
||||||
|
import {
|
||||||
|
formatSummaryForDisplay,
|
||||||
|
generatePaperSummary,
|
||||||
|
} from "../infrastructure/llm/lectureLlm";
|
||||||
|
import {
|
||||||
|
chunkPaperText,
|
||||||
|
formatChunkOutline,
|
||||||
|
} from "../infrastructure/pdf/chunker";
|
||||||
|
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
|
||||||
|
import {
|
||||||
|
ensureAudioDir,
|
||||||
|
} from "../infrastructure/storage/audioStorage";
|
||||||
|
import { lectureEvents } from "../infrastructure/events";
|
||||||
|
import {
|
||||||
|
isStep1Ready,
|
||||||
|
loadLectureData,
|
||||||
|
mergeLectureData,
|
||||||
|
type LectureData,
|
||||||
|
type StoredBeat,
|
||||||
|
} from "../infrastructure/storage/lectureStore";
|
||||||
|
import { audioFilePath } from "../infrastructure/storage/paths";
|
||||||
|
import { paperRepository } from "../infrastructure/storage/repository";
|
||||||
|
import { synthesizeSpeech } from "../infrastructure/tts/client";
|
||||||
|
import {
|
||||||
|
getRegularItem,
|
||||||
|
resolvePaperAttachment,
|
||||||
|
} from "../infrastructure/zotero/adapter";
|
||||||
|
import {
|
||||||
|
buildMetadataBlock,
|
||||||
|
extractPdfContext,
|
||||||
|
} from "../../pdf/extractor";
|
||||||
|
import { LlmError } from "../../llm/types";
|
||||||
|
|
||||||
|
export class PrepareLectureUseCase {
|
||||||
|
private inflight = new Map<string, Promise<PaperCache>>();
|
||||||
|
|
||||||
|
async start(item: Zotero.Item): Promise<PaperCache> {
|
||||||
|
const ctx = await resolvePaperAttachment(item);
|
||||||
|
const pending = this.inflight.get(ctx.cacheKey);
|
||||||
|
if (pending) return pending;
|
||||||
|
|
||||||
|
const task = this.runStart(item, ctx);
|
||||||
|
this.inflight.set(ctx.cacheKey, task);
|
||||||
|
try {
|
||||||
|
return await task;
|
||||||
|
} finally {
|
||||||
|
this.inflight.delete(ctx.cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runStart(
|
||||||
|
item: Zotero.Item,
|
||||||
|
ctx: Awaited<ReturnType<typeof resolvePaperAttachment>>,
|
||||||
|
): Promise<PaperCache> {
|
||||||
|
let paper = await paperRepository.getByCacheKey(ctx.cacheKey);
|
||||||
|
const now = Date.now();
|
||||||
|
const parent = getRegularItem(item);
|
||||||
|
const paperId = paper?.id ?? paperIdFromCacheKey(ctx.cacheKey);
|
||||||
|
|
||||||
|
if (!paper) {
|
||||||
|
paper = {
|
||||||
|
id: paperId,
|
||||||
|
attachmentId: String(ctx.attachment.id),
|
||||||
|
itemId: String(parent.id),
|
||||||
|
fileHash: ctx.fileHash,
|
||||||
|
filePath: ctx.filePath,
|
||||||
|
title: String(parent.getField("title") || ""),
|
||||||
|
parseStatus: "pending",
|
||||||
|
lectureStatus: "idle",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await loadLectureData(ctx.cacheKey);
|
||||||
|
if (paper.lectureStatus === "ready" && isStep1Ready(data)) {
|
||||||
|
return paper;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.runPipeline(paper, ctx.cacheKey, item);
|
||||||
|
const updated = await paperRepository.getByCacheKey(ctx.cacheKey);
|
||||||
|
return updated ?? paper;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runPipeline(
|
||||||
|
paper: PaperCache,
|
||||||
|
cacheKey: string,
|
||||||
|
item: Zotero.Item,
|
||||||
|
): Promise<void> {
|
||||||
|
const emitStatus = async (
|
||||||
|
lectureStatus: LectureStatus,
|
||||||
|
message: string,
|
||||||
|
percent?: number,
|
||||||
|
) => {
|
||||||
|
paper.lectureStatus = lectureStatus;
|
||||||
|
paper.updatedAt = Date.now();
|
||||||
|
await paperRepository.upsert(cacheKey, paper);
|
||||||
|
lectureEvents.emit("lecture:status", {
|
||||||
|
paperId: paper.id,
|
||||||
|
lectureStatus,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
if (percent != null) {
|
||||||
|
lectureEvents.emit("parse:progress", {
|
||||||
|
paperId: paper.id,
|
||||||
|
percent,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data: LectureData =
|
||||||
|
(await loadLectureData(cacheKey)) ?? {
|
||||||
|
paperId: paper.id,
|
||||||
|
cacheKey,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const parent = getRegularItem(item);
|
||||||
|
const title = String(parent.getField("title") || paper.title || "");
|
||||||
|
|
||||||
|
// --- PDF extract ---
|
||||||
|
let extractText = "";
|
||||||
|
if (!data.summary || !data.chunks?.length) {
|
||||||
|
paper.parseStatus = "running";
|
||||||
|
await emitStatus("parsing", "正在读取论文…", 10);
|
||||||
|
|
||||||
|
const extract = await extractPdfContext(item);
|
||||||
|
if (!extract.text) {
|
||||||
|
throw new Error(
|
||||||
|
extract.error ||
|
||||||
|
"未能提取 PDF 文本(可能是扫描版)。语音伴读需要可复制的文本层。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
extractText = extract.text;
|
||||||
|
data.chunks = chunkPaperText(extract.text);
|
||||||
|
data.extractChars = extract.text.length;
|
||||||
|
data.extractSource = extract.source;
|
||||||
|
await mergeLectureData(cacheKey, paper.id, data);
|
||||||
|
|
||||||
|
await emitStatus(
|
||||||
|
"parsing",
|
||||||
|
`已提取 ${extract.text.length} 字符,${data.chunks.length} 个段落块`,
|
||||||
|
25,
|
||||||
|
);
|
||||||
|
paper.parseStatus = "done";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Summary ---
|
||||||
|
if (!data.summary) {
|
||||||
|
await emitStatus("summarizing", "正在生成全文摘要…", 35);
|
||||||
|
|
||||||
|
const extract = await extractPdfContext(item);
|
||||||
|
const meta = buildMetadataBlock(parent);
|
||||||
|
const paperText =
|
||||||
|
extract.text || extractText || data.chunks?.map((c) => c.text).join("\n\n") || "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.summary = await generatePaperSummary({
|
||||||
|
title,
|
||||||
|
paperText: meta ? `${meta}\n\n${paperText}` : paperText,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof LlmError) throw e;
|
||||||
|
throw new Error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
data.phase = 1;
|
||||||
|
await mergeLectureData(cacheKey, paper.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Beats ---
|
||||||
|
if (!data.beats?.length) {
|
||||||
|
await emitStatus("beats_generating", "正在生成整体讲解 beats…", 50);
|
||||||
|
|
||||||
|
const chunks = data.chunks ?? [];
|
||||||
|
try {
|
||||||
|
data.beats = await generateBeats({
|
||||||
|
title,
|
||||||
|
summary: data.summary!,
|
||||||
|
chunkOutline: formatChunkOutline(chunks),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof LlmError) throw e;
|
||||||
|
throw new Error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
await mergeLectureData(cacheKey, paper.id, data);
|
||||||
|
|
||||||
|
lectureEvents.emit("lecture:beats_ready", {
|
||||||
|
paperId: paper.id,
|
||||||
|
beatCount: data.beats.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TTS for beats ---
|
||||||
|
const beats = data.beats ?? [];
|
||||||
|
const pendingTts = beats.filter((b) => b.ttsStatus !== "ready");
|
||||||
|
if (pendingTts.length) {
|
||||||
|
await emitStatus(
|
||||||
|
"tts_generating",
|
||||||
|
`正在合成语音(0/${pendingTts.length})…`,
|
||||||
|
65,
|
||||||
|
);
|
||||||
|
await ensureAudioDir(cacheKey);
|
||||||
|
|
||||||
|
let done = 0;
|
||||||
|
for (const beat of beats) {
|
||||||
|
if (beat.ttsStatus === "ready" && beat.audioPath) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outPath = audioFilePath(cacheKey, beat.id, "wav");
|
||||||
|
const result = await synthesizeSpeech({
|
||||||
|
text: beat.script,
|
||||||
|
outputPath: outPath,
|
||||||
|
lang: "zh",
|
||||||
|
});
|
||||||
|
beat.audioPath = result.audioPath;
|
||||||
|
beat.ttsStatus = "ready";
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] TTS failed for", beat.id, e);
|
||||||
|
beat.ttsStatus = "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
done += 1;
|
||||||
|
data.beats = beats;
|
||||||
|
await mergeLectureData(cacheKey, paper.id, data);
|
||||||
|
|
||||||
|
if (beat.ttsStatus === "ready" && beat.audioPath) {
|
||||||
|
lectureEvents.emit("tts:unit_ready", {
|
||||||
|
paperId: paper.id,
|
||||||
|
unitId: beat.id,
|
||||||
|
audioPath: beat.audioPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await emitStatus(
|
||||||
|
"tts_generating",
|
||||||
|
`正在合成语音(${done}/${pendingTts.length})…`,
|
||||||
|
65 + Math.floor((done / pendingTts.length) * 30),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!beats.some((b) => b.ttsStatus === "ready")) {
|
||||||
|
throw new Error("所有 beat 语音合成均失败,请检查 TTS 设置。");
|
||||||
|
}
|
||||||
|
|
||||||
|
data.phase = 2;
|
||||||
|
await mergeLectureData(cacheKey, paper.id, data);
|
||||||
|
|
||||||
|
paper.lectureStatus = "ready";
|
||||||
|
paper.updatedAt = Date.now();
|
||||||
|
await paperRepository.upsert(cacheKey, paper);
|
||||||
|
|
||||||
|
const readyCount = beats.filter((b) => b.ttsStatus === "ready").length;
|
||||||
|
const preview = formatSummaryForDisplay(data.summary!);
|
||||||
|
lectureEvents.emit("lecture:status", {
|
||||||
|
paperId: paper.id,
|
||||||
|
lectureStatus: "ready",
|
||||||
|
message: `备课完成:${readyCount} 段讲解可播放\n${preview}`,
|
||||||
|
});
|
||||||
|
lectureEvents.emit("parse:progress", {
|
||||||
|
paperId: paper.id,
|
||||||
|
percent: 100,
|
||||||
|
message: "整体讲解已就绪",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
paper.parseStatus = "failed";
|
||||||
|
paper.lectureStatus = "failed";
|
||||||
|
paper.updatedAt = Date.now();
|
||||||
|
await paperRepository.upsert(cacheKey, paper);
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
lectureEvents.emit("lecture:failed", {
|
||||||
|
paperId: paper.id,
|
||||||
|
error: msg,
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prepareLecture = new PrepareLectureUseCase();
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/** 备课 / 解析流水线状态 */
|
||||||
|
export type LectureStatus =
|
||||||
|
| "idle"
|
||||||
|
| "parsing"
|
||||||
|
| "parsed"
|
||||||
|
| "summarizing"
|
||||||
|
| "beats_generating"
|
||||||
|
| "paragraphs_generating"
|
||||||
|
| "tts_generating"
|
||||||
|
| "ready"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export type ParseStatus = "pending" | "running" | "done" | "failed";
|
||||||
|
|
||||||
|
export type BeatType =
|
||||||
|
| "intro"
|
||||||
|
| "method"
|
||||||
|
| "experiment"
|
||||||
|
| "conclusion"
|
||||||
|
| "extension";
|
||||||
|
|
||||||
|
export type PlayerMode = "overview" | "paragraph";
|
||||||
|
|
||||||
|
export type AnchorType = "beat" | "paragraph" | "sentence" | "global";
|
||||||
|
|
||||||
|
export interface SummaryJson {
|
||||||
|
problem: string;
|
||||||
|
method: string;
|
||||||
|
result: string;
|
||||||
|
limitation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chapter {
|
||||||
|
id: string;
|
||||||
|
paperId: string;
|
||||||
|
title: string;
|
||||||
|
orderIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Paragraph {
|
||||||
|
id: string;
|
||||||
|
paperId: string;
|
||||||
|
chapterId: string;
|
||||||
|
orderIndex: number;
|
||||||
|
text: string;
|
||||||
|
page: number;
|
||||||
|
language?: "en" | "zh";
|
||||||
|
bulletPoints?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Beat {
|
||||||
|
id: string;
|
||||||
|
paperId: string;
|
||||||
|
orderIndex: number;
|
||||||
|
title?: string;
|
||||||
|
type: BeatType;
|
||||||
|
script: string;
|
||||||
|
refs: string[];
|
||||||
|
degraded?: boolean;
|
||||||
|
audioPath?: string;
|
||||||
|
timestampsJson?: string;
|
||||||
|
ttsStatus: "pending" | "ready" | "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParagraphExplanation {
|
||||||
|
paragraphId: string;
|
||||||
|
paperId: string;
|
||||||
|
layerWhat: string;
|
||||||
|
layerHow: string;
|
||||||
|
layerWhere: string;
|
||||||
|
audioPath?: string;
|
||||||
|
timestampsJson?: string;
|
||||||
|
genStatus: "pending" | "ready" | "failed";
|
||||||
|
ttsStatus: "pending" | "ready" | "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaperCache {
|
||||||
|
id: string;
|
||||||
|
attachmentId: string;
|
||||||
|
itemId: string;
|
||||||
|
fileHash: string;
|
||||||
|
filePath: string;
|
||||||
|
title?: string;
|
||||||
|
language?: "en" | "zh";
|
||||||
|
parseStatus: ParseStatus;
|
||||||
|
lectureStatus: LectureStatus;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaperAttachmentContext {
|
||||||
|
item: Zotero.Item;
|
||||||
|
attachment: Zotero.Item;
|
||||||
|
filePath: string;
|
||||||
|
fileHash: string;
|
||||||
|
cacheKey: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
export type LectureEventMap = {
|
||||||
|
"parse:progress": { paperId: string; percent: number; message: string };
|
||||||
|
"lecture:beats_ready": { paperId: string; beatCount: number };
|
||||||
|
"lecture:paragraph_ready": { paperId: string; paragraphId: string };
|
||||||
|
"tts:unit_ready": { paperId: string; unitId: string; audioPath: string };
|
||||||
|
"lecture:failed": { paperId: string; error: string };
|
||||||
|
"lecture:status": {
|
||||||
|
paperId: string;
|
||||||
|
lectureStatus: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type Handler<K extends keyof LectureEventMap> = (
|
||||||
|
detail: LectureEventMap[K],
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-process event bus for lecture pipeline.
|
||||||
|
* Avoids EventTarget / CustomEvent — not available in Zotero chrome scopes.
|
||||||
|
*/
|
||||||
|
class LectureEventBus {
|
||||||
|
private listeners = new Map<
|
||||||
|
keyof LectureEventMap,
|
||||||
|
Set<Handler<keyof LectureEventMap>>
|
||||||
|
>();
|
||||||
|
|
||||||
|
on<K extends keyof LectureEventMap>(
|
||||||
|
type: K,
|
||||||
|
handler: Handler<K>,
|
||||||
|
): () => void {
|
||||||
|
let set = this.listeners.get(type);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
this.listeners.set(type, set);
|
||||||
|
}
|
||||||
|
const fn = handler as Handler<keyof LectureEventMap>;
|
||||||
|
set.add(fn);
|
||||||
|
return () => set.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<K extends keyof LectureEventMap>(
|
||||||
|
type: K,
|
||||||
|
detail: LectureEventMap[K],
|
||||||
|
): void {
|
||||||
|
const set = this.listeners.get(type);
|
||||||
|
if (!set) return;
|
||||||
|
for (const handler of set) {
|
||||||
|
try {
|
||||||
|
handler(detail);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] event handler error", type, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lectureEvents = new LectureEventBus();
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { LlmError } from "../../../llm/types";
|
||||||
|
import type { BeatType, SummaryJson } from "../../domain/types";
|
||||||
|
import type { StoredBeat } from "../storage/lectureStore";
|
||||||
|
import { parseJsonLoose, requestStructuredOutput } from "./structuredOutput";
|
||||||
|
|
||||||
|
const VALID_TYPES = new Set<BeatType>([
|
||||||
|
"intro",
|
||||||
|
"method",
|
||||||
|
"experiment",
|
||||||
|
"conclusion",
|
||||||
|
"extension",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BEATS_SYSTEM = `你是 ChatPapers 语音伴读的备课助手。根据论文摘要与段落索引,生成「整体讲解」beats。
|
||||||
|
必须只输出一个 JSON 对象,不要 markdown 代码块,不要其他说明。
|
||||||
|
格式:{"beats":[{"title":"小节标题","type":"intro","script":"口语讲解文本"}]}
|
||||||
|
要求:
|
||||||
|
- beats 数组含 6–10 项,按教学逻辑:问题引入 → 核心方法 → 关键实验/证据 → 结论与局限
|
||||||
|
- script 为中文口语,每段 80–180 字,适合朗读;不要念公式符号,用白话解释
|
||||||
|
- type 只能是 intro / method / experiment / conclusion / extension`;
|
||||||
|
|
||||||
|
interface BeatJson {
|
||||||
|
title?: string;
|
||||||
|
type?: string;
|
||||||
|
script?: string;
|
||||||
|
content?: string;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beatScript(b: BeatJson): string {
|
||||||
|
return String(b.script || b.content || b.text || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBeatType(raw?: string): BeatType {
|
||||||
|
const t = String(raw || "intro").toLowerCase() as BeatType;
|
||||||
|
return VALID_TYPES.has(t) ? t : "intro";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStoredBeats(items: BeatJson[]): StoredBeat[] {
|
||||||
|
return items
|
||||||
|
.map((b) => ({ ...b, script: beatScript(b) }))
|
||||||
|
.filter((b) => b.script)
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((b, orderIndex) => ({
|
||||||
|
id: `beat-${orderIndex}`,
|
||||||
|
orderIndex,
|
||||||
|
title: String(b.title || `第 ${orderIndex + 1} 段`).trim(),
|
||||||
|
type: normalizeBeatType(b.type),
|
||||||
|
script: b.script,
|
||||||
|
refs: [],
|
||||||
|
ttsStatus: "pending" as const,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBeatsJson(raw: string): StoredBeat[] {
|
||||||
|
const parsed = parseJsonLoose<BeatJson[] | { beats?: BeatJson[] }>(raw);
|
||||||
|
const items = Array.isArray(parsed)
|
||||||
|
? parsed
|
||||||
|
: Array.isArray(parsed?.beats)
|
||||||
|
? parsed.beats!
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const beats = toStoredBeats(items);
|
||||||
|
if (beats.length) return beats;
|
||||||
|
|
||||||
|
throw new LlmError("provider", "beats 数组为空或格式无效,请重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic fallback when JSON parsing fails — still usable for TTS. */
|
||||||
|
export function beatsFromSummaryFallback(summary: SummaryJson): StoredBeat[] {
|
||||||
|
const templates: Array<{ title: string; type: BeatType; script: string }> = [
|
||||||
|
{ title: "研究问题", type: "intro", script: summary.problem },
|
||||||
|
{ title: "核心方法", type: "method", script: summary.method },
|
||||||
|
{
|
||||||
|
title: "方法细节",
|
||||||
|
type: "method",
|
||||||
|
script: `${summary.method} 这部分是理解全文的关键,建议结合原文图表再听一遍。`,
|
||||||
|
},
|
||||||
|
{ title: "主要结果", type: "experiment", script: summary.result },
|
||||||
|
{ title: "结论与局限", type: "conclusion", script: summary.limitation },
|
||||||
|
];
|
||||||
|
|
||||||
|
return templates
|
||||||
|
.filter((t) => t.script.trim())
|
||||||
|
.map((t, orderIndex) => ({
|
||||||
|
id: `beat-${orderIndex}`,
|
||||||
|
orderIndex,
|
||||||
|
title: t.title,
|
||||||
|
type: t.type,
|
||||||
|
script: t.script.trim(),
|
||||||
|
refs: [],
|
||||||
|
ttsStatus: "pending" as const,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateBeats(options: {
|
||||||
|
title: string;
|
||||||
|
summary: SummaryJson;
|
||||||
|
chunkOutline: string;
|
||||||
|
}): Promise<StoredBeat[]> {
|
||||||
|
const summaryBlock = [
|
||||||
|
`问题:${options.summary.problem}`,
|
||||||
|
`方法:${options.summary.method}`,
|
||||||
|
`结果:${options.summary.result}`,
|
||||||
|
`局限:${options.summary.limitation}`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return requestStructuredOutput({
|
||||||
|
system: BEATS_SYSTEM,
|
||||||
|
user: `【论文标题】${options.title}
|
||||||
|
|
||||||
|
【全文摘要】
|
||||||
|
${summaryBlock}
|
||||||
|
|
||||||
|
【段落索引(供参考)】
|
||||||
|
${options.chunkOutline.slice(0, 8000)}
|
||||||
|
|
||||||
|
请输出 {"beats":[...]} JSON。`,
|
||||||
|
parse: parseBeatsJson,
|
||||||
|
jsonMode: true,
|
||||||
|
fallback: () => beatsFromSummaryFallback(options.summary),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BEAT_TYPE_LABELS: Record<BeatType, string> = {
|
||||||
|
intro: "引入",
|
||||||
|
method: "方法",
|
||||||
|
experiment: "实验",
|
||||||
|
conclusion: "结论",
|
||||||
|
extension: "延伸",
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { getRuntimeConfig } from "../../../llm/client";
|
||||||
|
import { parseJsonLoose, parseLabeledFields } from "../../../llm/jsonParse";
|
||||||
|
import { LlmError } from "../../../llm/types";
|
||||||
|
import type { SummaryJson } from "../../domain/types";
|
||||||
|
import { requestStructuredOutput } from "./structuredOutput";
|
||||||
|
|
||||||
|
const SUMMARY_SYSTEM = `你是 ChatPapers 语音伴读的备课助手。根据论文正文生成结构化摘要。
|
||||||
|
必须只输出一个 JSON 对象,不要 markdown 代码块,不要其他说明。
|
||||||
|
格式:{"problem":"...","method":"...","result":"...","limitation":"..."}
|
||||||
|
每项 1-3 句中文,准确、简洁,基于正文,不要编造。`;
|
||||||
|
|
||||||
|
const SUMMARY_LABELS = [
|
||||||
|
{ field: "problem", labels: ["问题", "研究问题", "problem"] },
|
||||||
|
{ field: "method", labels: ["方法", "核心方法", "method"] },
|
||||||
|
{ field: "result", labels: ["结果", "主要结果", "result"] },
|
||||||
|
{ field: "limitation", labels: ["局限", "局限性", "limitation"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeSummary(obj: Record<string, unknown>): SummaryJson | undefined {
|
||||||
|
const problem = String(obj.problem || "").trim();
|
||||||
|
const method = String(obj.method || "").trim();
|
||||||
|
const result = String(obj.result || obj.results || "").trim();
|
||||||
|
const limitation = String(obj.limitation || obj.limitations || "").trim();
|
||||||
|
if (!problem || !method) return undefined;
|
||||||
|
return {
|
||||||
|
problem,
|
||||||
|
method,
|
||||||
|
result: result || "(模型未单独给出结果,请重试或换模型)",
|
||||||
|
limitation: limitation || "(模型未单独给出局限,请重试或换模型)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSummaryJson(raw: string): SummaryJson {
|
||||||
|
const parsed = parseJsonLoose<Record<string, unknown>>(raw);
|
||||||
|
const summary = normalizeSummary(parsed);
|
||||||
|
if (summary) return summary;
|
||||||
|
|
||||||
|
const labeled = parseLabeledFields(raw, SUMMARY_LABELS);
|
||||||
|
const fromLabels = normalizeSummary(labeled);
|
||||||
|
if (fromLabels) return fromLabels;
|
||||||
|
|
||||||
|
throw new LlmError("provider", "模型未返回有效 JSON 摘要,请重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePaperSummary(options: {
|
||||||
|
title: string;
|
||||||
|
paperText: string;
|
||||||
|
}): Promise<SummaryJson> {
|
||||||
|
const cfg = getRuntimeConfig();
|
||||||
|
const text = options.paperText.slice(0, Number(cfg.maxTokens) * 3 || 120000);
|
||||||
|
|
||||||
|
return requestStructuredOutput({
|
||||||
|
system: SUMMARY_SYSTEM,
|
||||||
|
user: `【论文标题】${options.title}\n\n【论文正文】\n${text}\n\n请输出 JSON 摘要。`,
|
||||||
|
parse: parseSummaryJson,
|
||||||
|
jsonMode: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSummaryForDisplay(summary: SummaryJson): string {
|
||||||
|
return [
|
||||||
|
`问题:${summary.problem}`,
|
||||||
|
`方法:${summary.method}`,
|
||||||
|
`结果:${summary.result}`,
|
||||||
|
`局限:${summary.limitation}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { chatStream } from "../../../llm/client";
|
||||||
|
import { stripMarkdownFence } from "../../../llm/jsonParse";
|
||||||
|
import type { ChatMessage } from "../../../llm/types";
|
||||||
|
import { LlmError } from "../../../llm/types";
|
||||||
|
|
||||||
|
export interface StructuredRequestOptions<T> {
|
||||||
|
system: string;
|
||||||
|
user: string;
|
||||||
|
parse: (raw: string) => T;
|
||||||
|
/** OpenAI-compatible json_object mode when supported */
|
||||||
|
jsonMode?: boolean;
|
||||||
|
maxAttempts?: number;
|
||||||
|
fallback?: () => T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callLlm(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
jsonMode: boolean,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await chatStream({ messages, onDelta: () => {}, jsonMode });
|
||||||
|
} catch (e) {
|
||||||
|
if (!jsonMode) throw e;
|
||||||
|
// Local / older endpoints may reject response_format
|
||||||
|
ztoolkit.log("[ChatPapers] jsonMode rejected, retrying without", e);
|
||||||
|
return chatStream({ messages, onDelta: () => {}, jsonMode: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request structured LLM output with parse retries + optional JSON repair pass.
|
||||||
|
*/
|
||||||
|
export async function requestStructuredOutput<T>(
|
||||||
|
options: StructuredRequestOptions<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const maxAttempts = options.maxAttempts ?? 3;
|
||||||
|
let lastRaw = "";
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
let messages: ChatMessage[];
|
||||||
|
|
||||||
|
if (attempt === 0) {
|
||||||
|
messages = [
|
||||||
|
{ role: "system", content: options.system },
|
||||||
|
{ role: "user", content: options.user },
|
||||||
|
];
|
||||||
|
} else if (attempt === 1) {
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `${options.system}\n\n重要:只输出 JSON,不要 markdown 代码块,不要前后解释。`,
|
||||||
|
},
|
||||||
|
{ role: "user", content: options.user },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
"你是 JSON 修复助手。根据用户给出的内容输出修复后的合法 JSON。只输出 JSON本身,不要其他文字。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `以下内容解析失败,请输出修复后的 JSON:\n${stripMarkdownFence(lastRaw).slice(0, 14000)}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const useJsonMode = options.jsonMode !== false && attempt < 2;
|
||||||
|
lastRaw = await callLlm(messages, useJsonMode);
|
||||||
|
return options.parse(lastRaw);
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e instanceof Error ? e : new Error(String(e));
|
||||||
|
if (e instanceof LlmError && (e as LlmError).code !== "provider") throw e;
|
||||||
|
ztoolkit.log(
|
||||||
|
`[ChatPapers:Lecture] structured output attempt ${attempt + 1} failed`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.fallback) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] using structured output fallback");
|
||||||
|
return options.fallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw (
|
||||||
|
lastError ??
|
||||||
|
new LlmError("provider", "模型多次未能返回可解析的 JSON,请更换模型后重试。")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { parseJsonLoose } from "../../../llm/jsonParse";
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/** Coarse paragraph chunks from PDFWorker plain text (MinerU MVP fallback). */
|
||||||
|
|
||||||
|
export interface TextChunk {
|
||||||
|
id: string;
|
||||||
|
orderIndex: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_CHUNK_CHARS = 200;
|
||||||
|
const MAX_CHUNK_CHARS = 900;
|
||||||
|
const MAX_CHUNKS = 40;
|
||||||
|
|
||||||
|
export function chunkPaperText(text: string): TextChunk[] {
|
||||||
|
const paragraphs = text
|
||||||
|
.split(/\n\s*\n+/)
|
||||||
|
.map((s) => s.replace(/\s+/g, " ").trim())
|
||||||
|
.filter((s) => s.length > 40);
|
||||||
|
|
||||||
|
const merged: string[] = [];
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
for (const para of paragraphs) {
|
||||||
|
if (
|
||||||
|
buffer &&
|
||||||
|
(buffer.length + para.length > MAX_CHUNK_CHARS ||
|
||||||
|
(buffer.length >= MIN_CHUNK_CHARS && merged.length >= MAX_CHUNKS - 1))
|
||||||
|
) {
|
||||||
|
merged.push(buffer);
|
||||||
|
buffer = para;
|
||||||
|
} else {
|
||||||
|
buffer = buffer ? `${buffer} ${para}` : para;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer) merged.push(buffer);
|
||||||
|
|
||||||
|
if (!merged.length && text.trim()) {
|
||||||
|
merged.push(text.trim().slice(0, MAX_CHUNK_CHARS));
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged.slice(0, MAX_CHUNKS).map((chunkText, orderIndex) => ({
|
||||||
|
id: `p-${orderIndex}`,
|
||||||
|
orderIndex,
|
||||||
|
text: chunkText.slice(0, 2000),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact outline for LLM beats generation. */
|
||||||
|
export function formatChunkOutline(chunks: TextChunk[]): string {
|
||||||
|
return chunks
|
||||||
|
.map(
|
||||||
|
(c) =>
|
||||||
|
`[${c.id}] ${c.text.slice(0, 100)}${c.text.length > 100 ? "…" : ""}`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* SHA-256 file hash for lecture cache invalidation.
|
||||||
|
* Uses Web Crypto (available in Zotero/Firefox) — works on macOS and Windows.
|
||||||
|
*/
|
||||||
|
export async function computeFileHash(filePath: string): Promise<string> {
|
||||||
|
const data = await IOUtils.read(filePath);
|
||||||
|
const view = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", view);
|
||||||
|
return [...new Uint8Array(digest)]
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCacheKey(attachmentId: string, fileHash: string): string {
|
||||||
|
return `${attachmentId}|${fileHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paperIdFromCacheKey(cacheKey: string): string {
|
||||||
|
// Stable id for JSON store; SQLite POC may use hash of cacheKey
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < cacheKey.length; i++) {
|
||||||
|
hash = (hash * 31 + cacheKey.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return `paper-${Math.abs(hash).toString(36)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { isMac, isWindows } from "./os";
|
||||||
|
import { joinPath, normalizeExecutablePath } from "./paths";
|
||||||
|
|
||||||
|
/** Well-known absolute paths before PATH lookup */
|
||||||
|
const SYSTEM_HINTS: Record<string, string[]> = {
|
||||||
|
say: ["/usr/bin/say"],
|
||||||
|
afconvert: ["/usr/bin/afconvert"],
|
||||||
|
piper: ["/usr/local/bin/piper", "/opt/homebrew/bin/piper"],
|
||||||
|
powershell: [
|
||||||
|
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
||||||
|
],
|
||||||
|
ffmpeg: ["/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveExecutable(
|
||||||
|
name: string,
|
||||||
|
customPath?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const trimmed = (customPath || "").trim();
|
||||||
|
if (trimmed) {
|
||||||
|
const normalized = normalizeExecutablePath(trimmed);
|
||||||
|
if (await IOUtils.exists(normalized)) return normalized;
|
||||||
|
throw new Error(`可执行文件不存在: ${normalized}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hints = SYSTEM_HINTS[name] || [];
|
||||||
|
for (const hint of hints) {
|
||||||
|
try {
|
||||||
|
if (await IOUtils.exists(hint)) return hint;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: bare name (relies on PATH when process starts — may fail in Zotero)
|
||||||
|
return normalizeExecutablePath(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeTempTextFile(text: string, prefix: string): Promise<string> {
|
||||||
|
const dir = PathUtils.tempDir;
|
||||||
|
const path = joinPath(dir, `chatpapers-${prefix}-${Date.now()}.txt`);
|
||||||
|
await IOUtils.writeUTF8(path, text);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeIfExists(path: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (await IOUtils.exists(path)) await IOUtils.remove(path);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultVoiceForLang(lang?: "zh" | "en"): string {
|
||||||
|
if (isMac()) {
|
||||||
|
return lang === "en" ? "Samantha" : "Ting-Ting";
|
||||||
|
}
|
||||||
|
if (isWindows()) {
|
||||||
|
return lang === "en" ? "Microsoft Zira Desktop" : "Microsoft Huihui Desktop";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export type HostPlatform = "mac" | "win" | "linux" | "other";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve host OS for path / subprocess branching.
|
||||||
|
* Uses Services.appinfo.OS (Darwin | WINNT | Linux | …).
|
||||||
|
*/
|
||||||
|
export function getHostPlatform(): HostPlatform {
|
||||||
|
try {
|
||||||
|
const os = String(Services.appinfo.OS || "").toLowerCase();
|
||||||
|
if (os.includes("darwin")) return "mac";
|
||||||
|
if (os.includes("win")) return "win";
|
||||||
|
if (os.includes("linux")) return "linux";
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWindows(): boolean {
|
||||||
|
return getHostPlatform() === "win";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMac(): boolean {
|
||||||
|
return getHostPlatform() === "mac";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path list separator (`PATH` env): `;` on Windows, `:` elsewhere. */
|
||||||
|
export function pathListSeparator(): string {
|
||||||
|
return isWindows() ? ";" : ":";
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { isWindows } from "./os";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize user-provided executable path for the current OS.
|
||||||
|
* - Expands ~ on macOS/Linux via PathUtils (when available)
|
||||||
|
* - Ensures .exe suffix on Windows when missing
|
||||||
|
*/
|
||||||
|
export function normalizeExecutablePath(input: string): string {
|
||||||
|
const trimmed = (input || "").trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
|
||||||
|
const path = trimmed;
|
||||||
|
|
||||||
|
if (isWindows() && !/\.(exe|cmd|bat)$/i.test(path)) {
|
||||||
|
return `${path}.exe`;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Join path segments using Zotero PathUtils (cross-platform). */
|
||||||
|
export function joinPath(...parts: string[]): string {
|
||||||
|
return PathUtils.join(...parts.filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert local file path to file:// URL for <audio src>. */
|
||||||
|
export function pathToFileUrl(filePath: string): string {
|
||||||
|
const normalized = filePath.replace(/\\/g, "/");
|
||||||
|
if (normalized.startsWith("file://")) return normalized;
|
||||||
|
if (/^[A-Za-z]:\//.test(normalized)) {
|
||||||
|
return `file:///${encodeURI(normalized)}`;
|
||||||
|
}
|
||||||
|
return `file://${encodeURI(normalized)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { getHostPlatform, isWindows, pathListSeparator } from "./os";
|
||||||
|
import { normalizeExecutablePath } from "./paths";
|
||||||
|
import { getPref } from "../../../../utils/prefs";
|
||||||
|
|
||||||
|
export interface RunCommandOptions {
|
||||||
|
cwd?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunCommandResult {
|
||||||
|
exitCode: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a platform-aware command argv.
|
||||||
|
* On Windows, wraps `.cmd` / `.bat` via cmd.exe when needed.
|
||||||
|
*/
|
||||||
|
export function buildCommandArgv(
|
||||||
|
executable: string,
|
||||||
|
args: string[],
|
||||||
|
): string[] {
|
||||||
|
const exe = normalizeExecutablePath(executable);
|
||||||
|
if (!exe) throw new Error("Executable path is empty");
|
||||||
|
|
||||||
|
if (isWindows() && /\.(cmd|bat)$/i.test(exe)) {
|
||||||
|
return ["cmd.exe", "/c", exe, ...args];
|
||||||
|
}
|
||||||
|
return [exe, ...args];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMinerUCommand(customPath?: string): {
|
||||||
|
executable: string;
|
||||||
|
args: string[];
|
||||||
|
platform: ReturnType<typeof getHostPlatform>;
|
||||||
|
} {
|
||||||
|
const platform = getHostPlatform();
|
||||||
|
const trimmed = (customPath || "").trim();
|
||||||
|
|
||||||
|
if (trimmed) {
|
||||||
|
return { executable: normalizeExecutablePath(trimmed), args: [], platform };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === "win") {
|
||||||
|
return { executable: "mineru", args: [], platform };
|
||||||
|
}
|
||||||
|
return { executable: "mineru", args: [], platform };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitPathEnv(pathValue: string | undefined): string[] {
|
||||||
|
if (!pathValue) return [];
|
||||||
|
const sep = pathListSeparator();
|
||||||
|
return pathValue.split(sep).map((p) => p.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNsIProcess(): MozNsIProcess {
|
||||||
|
return Components.classes["@mozilla.org/process/util;1"].createInstance(
|
||||||
|
Components.interfaces.nsIProcess,
|
||||||
|
) as MozNsIProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathToNsIFile(path: string): MozNsIFile {
|
||||||
|
const file = Zotero.File.pathToFile(path);
|
||||||
|
if (!file?.exists()) {
|
||||||
|
throw new Error(`找不到可执行文件: ${path}`);
|
||||||
|
}
|
||||||
|
if (file.isExecutable && !file.isExecutable()) {
|
||||||
|
throw new Error(`文件不可执行: ${path}`);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runViaZoteroExec(
|
||||||
|
exePath: string,
|
||||||
|
procArgs: string[],
|
||||||
|
): Promise<RunCommandResult | undefined> {
|
||||||
|
const exec = (Zotero as any).Utilities?.Internal?.exec;
|
||||||
|
if (typeof exec !== "function") return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exec(exePath, procArgs);
|
||||||
|
return { exitCode: 0, stdout: "", stderr: "" };
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
throw new Error(`子进程执行失败 (${exePath}): ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runViaNsIProcess(
|
||||||
|
exePath: string,
|
||||||
|
procArgs: string[],
|
||||||
|
options: RunCommandOptions,
|
||||||
|
): RunCommandResult {
|
||||||
|
const proc = getNsIProcess();
|
||||||
|
proc.init(pathToNsIFile(exePath));
|
||||||
|
proc.startHidden = true;
|
||||||
|
|
||||||
|
if (options.cwd) {
|
||||||
|
try {
|
||||||
|
(proc as any).workingDirectory = pathToNsIFile(options.cwd);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] workingDirectory unsupported", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run()/runw() return void — never treat return value as success flag
|
||||||
|
try {
|
||||||
|
proc.run(true, procArgs, procArgs.length);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
throw new Error(`无法启动子进程 (${exePath}): ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = proc.exitValue ?? 0;
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(`子进程退出码 ${code}: ${exePath}`);
|
||||||
|
}
|
||||||
|
return { exitCode: code, stdout: "", stderr: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run external command (blocking).
|
||||||
|
* Prefers Zotero.Utilities.Internal.exec; falls back to nsIProcess.run.
|
||||||
|
*/
|
||||||
|
export async function runCommand(
|
||||||
|
executable: string,
|
||||||
|
args: string[],
|
||||||
|
options: RunCommandOptions = {},
|
||||||
|
): Promise<RunCommandResult> {
|
||||||
|
const argv = buildCommandArgv(executable, args);
|
||||||
|
const exePath = argv[0]!;
|
||||||
|
const procArgs = argv.slice(1);
|
||||||
|
|
||||||
|
if (getPref("verboseLog")) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] runCommand", exePath, procArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const viaExec = await runViaZoteroExec(exePath, procArgs);
|
||||||
|
if (viaExec) return viaExec;
|
||||||
|
|
||||||
|
return runViaNsIProcess(exePath, procArgs, options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
audioDirForPaper,
|
||||||
|
audioFilePath,
|
||||||
|
ensureLectureDirs,
|
||||||
|
} from "./paths";
|
||||||
|
import { pathToFileUrl } from "../platform/paths";
|
||||||
|
|
||||||
|
export async function ensureAudioDir(cacheKey: string): Promise<string> {
|
||||||
|
await ensureLectureDirs();
|
||||||
|
const dir = audioDirForPaper(cacheKey);
|
||||||
|
await IOUtils.makeDirectory(dir, {
|
||||||
|
createAncestors: true,
|
||||||
|
ignoreExisting: true,
|
||||||
|
});
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUnitAudioPath(
|
||||||
|
cacheKey: string,
|
||||||
|
unitId: string,
|
||||||
|
): string {
|
||||||
|
return audioFilePath(cacheKey, unitId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function audioFileExists(
|
||||||
|
cacheKey: string,
|
||||||
|
unitId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const path = audioFilePath(cacheKey, unitId);
|
||||||
|
try {
|
||||||
|
return await IOUtils.exists(path);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** file:// URL safe for HTML5 audio element */
|
||||||
|
export function audioFileUrl(cacheKey: string, unitId: string): string {
|
||||||
|
return pathToFileUrl(audioFilePath(cacheKey, unitId));
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { BeatType, SummaryJson } from "../../domain/types";
|
||||||
|
import type { TextChunk } from "../pdf/chunker";
|
||||||
|
import { joinPath } from "../platform/paths";
|
||||||
|
import { ensureLectureDirs, lectureDataRoot } from "./paths";
|
||||||
|
|
||||||
|
export interface StoredBeat {
|
||||||
|
id: string;
|
||||||
|
orderIndex: number;
|
||||||
|
title?: string;
|
||||||
|
type: BeatType;
|
||||||
|
script: string;
|
||||||
|
refs: string[];
|
||||||
|
audioPath?: string;
|
||||||
|
ttsStatus: "pending" | "ready" | "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LectureData {
|
||||||
|
paperId: string;
|
||||||
|
cacheKey: string;
|
||||||
|
/** 1 = summary only, 2 = beats + TTS (Step 1) */
|
||||||
|
phase?: 1 | 2;
|
||||||
|
summary?: SummaryJson;
|
||||||
|
chunks?: TextChunk[];
|
||||||
|
beats?: StoredBeat[];
|
||||||
|
extractChars?: number;
|
||||||
|
extractSource?: string;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lectureDataPath(cacheKey: string): string {
|
||||||
|
const safe = cacheKey.replace(/[^a-zA-Z0-9_|.-]/g, "_");
|
||||||
|
return joinPath(lectureDataRoot(), "data", `${safe}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLectureData(
|
||||||
|
cacheKey: string,
|
||||||
|
): Promise<LectureData | undefined> {
|
||||||
|
await ensureLectureDirs();
|
||||||
|
const path = lectureDataPath(cacheKey);
|
||||||
|
try {
|
||||||
|
if (!(await IOUtils.exists(path))) return undefined;
|
||||||
|
const raw = await IOUtils.readUTF8(path);
|
||||||
|
return JSON.parse(raw) as LectureData;
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] loadLectureData failed", e);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLectureData(data: LectureData): Promise<void> {
|
||||||
|
await ensureLectureDirs();
|
||||||
|
const dir = joinPath(lectureDataRoot(), "data");
|
||||||
|
await IOUtils.makeDirectory(dir, {
|
||||||
|
createAncestors: true,
|
||||||
|
ignoreExisting: true,
|
||||||
|
});
|
||||||
|
data.updatedAt = Date.now();
|
||||||
|
await IOUtils.writeUTF8(
|
||||||
|
lectureDataPath(data.cacheKey),
|
||||||
|
JSON.stringify(data, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeLectureData(
|
||||||
|
cacheKey: string,
|
||||||
|
paperId: string,
|
||||||
|
patch: Partial<LectureData>,
|
||||||
|
): Promise<LectureData> {
|
||||||
|
const existing =
|
||||||
|
(await loadLectureData(cacheKey)) ??
|
||||||
|
({
|
||||||
|
paperId,
|
||||||
|
cacheKey,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
} satisfies LectureData);
|
||||||
|
const merged: LectureData = {
|
||||||
|
...existing,
|
||||||
|
...patch,
|
||||||
|
paperId,
|
||||||
|
cacheKey,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
await saveLectureData(merged);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStep1Ready(data: LectureData | undefined): boolean {
|
||||||
|
if (!data?.beats?.length) return false;
|
||||||
|
return data.beats.some((b) => b.ttsStatus === "ready" && b.audioPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playableBeats(data: LectureData | undefined): StoredBeat[] {
|
||||||
|
if (!data?.beats) return [];
|
||||||
|
return data.beats.filter((b) => b.ttsStatus === "ready" && b.audioPath);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { joinPath } from "../platform/paths";
|
||||||
|
|
||||||
|
function profileDir(): string {
|
||||||
|
const profile =
|
||||||
|
(Zotero as any).Profile?.dir ||
|
||||||
|
(Zotero as any).getProfileDirectory?.()?.path;
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error("Cannot resolve Zotero profile directory");
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `{Profile}/chatpapers/lecture/` — voice lecture data root */
|
||||||
|
export function lectureDataRoot(): string {
|
||||||
|
return joinPath(profileDir(), "chatpapers", "lecture");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lectureDbPath(): string {
|
||||||
|
return joinPath(lectureDataRoot(), "lecture.db");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interim JSON index until SQLite POC lands */
|
||||||
|
export function lectureIndexPath(): string {
|
||||||
|
return joinPath(lectureDataRoot(), "cache-index.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function audioRootDir(): string {
|
||||||
|
return joinPath(lectureDataRoot(), "audio");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function audioDirForPaper(cacheKey: string): string {
|
||||||
|
return joinPath(audioRootDir(), cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function audioFilePath(
|
||||||
|
cacheKey: string,
|
||||||
|
unitId: string,
|
||||||
|
ext: "wav" | "mp3" = "wav",
|
||||||
|
): string {
|
||||||
|
return joinPath(audioDirForPaper(cacheKey), `${unitId}.${ext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lectureSamplePath(name: string): string {
|
||||||
|
return joinPath(lectureDataRoot(), "samples", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureSampleDir(): Promise<string> {
|
||||||
|
const dir = joinPath(lectureDataRoot(), "samples");
|
||||||
|
await IOUtils.makeDirectory(dir, {
|
||||||
|
createAncestors: true,
|
||||||
|
ignoreExisting: true,
|
||||||
|
});
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureLectureDirs(): Promise<void> {
|
||||||
|
const root = lectureDataRoot();
|
||||||
|
await IOUtils.makeDirectory(root, {
|
||||||
|
createAncestors: true,
|
||||||
|
ignoreExisting: true,
|
||||||
|
});
|
||||||
|
await IOUtils.makeDirectory(audioRootDir(), {
|
||||||
|
createAncestors: true,
|
||||||
|
ignoreExisting: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { PaperCache } from "../../domain/types";
|
||||||
|
import { buildCacheKey } from "../pdf/fileHash";
|
||||||
|
import {
|
||||||
|
ensureLectureDirs,
|
||||||
|
lectureIndexPath,
|
||||||
|
} from "./paths";
|
||||||
|
|
||||||
|
interface IndexFile {
|
||||||
|
schemaVersion: number;
|
||||||
|
/** keyed by cacheKey (attachmentId|fileHash) */
|
||||||
|
papers: Record<string, PaperCache>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
|
function migratePapers(
|
||||||
|
papers: Record<string, PaperCache>,
|
||||||
|
): Record<string, PaperCache> {
|
||||||
|
const out: Record<string, PaperCache> = {};
|
||||||
|
for (const [key, paper] of Object.entries(papers)) {
|
||||||
|
const cacheKey =
|
||||||
|
key.includes("|") && key.includes(paper.fileHash)
|
||||||
|
? key
|
||||||
|
: buildCacheKey(paper.attachmentId, paper.fileHash);
|
||||||
|
out[cacheKey] = paper;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readIndex(): Promise<IndexFile> {
|
||||||
|
await ensureLectureDirs();
|
||||||
|
const path = lectureIndexPath();
|
||||||
|
try {
|
||||||
|
if (!(await IOUtils.exists(path))) {
|
||||||
|
return { schemaVersion: SCHEMA_VERSION, papers: {} };
|
||||||
|
}
|
||||||
|
const raw = await IOUtils.readUTF8(path);
|
||||||
|
const data = JSON.parse(raw) as IndexFile;
|
||||||
|
const papers = migratePapers(data.papers ?? {});
|
||||||
|
return {
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
papers,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] readIndex failed", e);
|
||||||
|
return { schemaVersion: SCHEMA_VERSION, papers: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeIndex(index: IndexFile): Promise<void> {
|
||||||
|
await ensureLectureDirs();
|
||||||
|
const path = lectureIndexPath();
|
||||||
|
await IOUtils.writeUTF8(
|
||||||
|
path,
|
||||||
|
JSON.stringify({ ...index, schemaVersion: SCHEMA_VERSION }, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PaperRepository {
|
||||||
|
async getByCacheKey(cacheKey: string): Promise<PaperCache | undefined> {
|
||||||
|
const index = await readIndex();
|
||||||
|
return index.papers[cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByAttachment(
|
||||||
|
attachmentId: string,
|
||||||
|
fileHash: string,
|
||||||
|
): Promise<PaperCache | undefined> {
|
||||||
|
const cacheKey = `${attachmentId}|${fileHash}`;
|
||||||
|
return this.getByCacheKey(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsert(cacheKey: string, paper: PaperCache): Promise<void> {
|
||||||
|
const index = await readIndex();
|
||||||
|
index.papers[cacheKey] = paper;
|
||||||
|
await writeIndex(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAll(): Promise<PaperCache[]> {
|
||||||
|
const index = await readIndex();
|
||||||
|
return Object.values(index.papers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paperRepository = new PaperRepository();
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* SQLite schema for voice lecture mode.
|
||||||
|
* Applied when sql.js / native SQLite backend is confirmed in Phase 0 POC.
|
||||||
|
*/
|
||||||
|
export const LECTURE_SCHEMA_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS paper_cache (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
attachment_id TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
file_hash TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
language TEXT,
|
||||||
|
parse_status TEXT NOT NULL,
|
||||||
|
lecture_status TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(attachment_id, file_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chapter (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
order_index INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS paragraph (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL,
|
||||||
|
chapter_id TEXT,
|
||||||
|
order_index INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
page INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lecture (
|
||||||
|
paper_id TEXT PRIMARY KEY,
|
||||||
|
summary_json TEXT NOT NULL,
|
||||||
|
prompt_version TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS beat (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL,
|
||||||
|
order_index INTEGER NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
script TEXT NOT NULL,
|
||||||
|
refs_json TEXT NOT NULL,
|
||||||
|
audio_path TEXT,
|
||||||
|
timestamps_json TEXT,
|
||||||
|
tts_status TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS paragraph_explanation (
|
||||||
|
paragraph_id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL,
|
||||||
|
layer_what TEXT NOT NULL,
|
||||||
|
layer_how TEXT NOT NULL,
|
||||||
|
layer_where TEXT NOT NULL,
|
||||||
|
audio_path TEXT,
|
||||||
|
timestamps_json TEXT,
|
||||||
|
gen_status TEXT NOT NULL,
|
||||||
|
tts_status TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS progress (
|
||||||
|
paper_id TEXT PRIMARY KEY,
|
||||||
|
last_mode TEXT,
|
||||||
|
last_unit_id TEXT,
|
||||||
|
last_position_ms INTEGER DEFAULT 0,
|
||||||
|
listened_units_json TEXT,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS qa_record (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paper_id TEXT NOT NULL,
|
||||||
|
anchor_type TEXT NOT NULL,
|
||||||
|
anchor_id TEXT,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
audio_path TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS highlight (
|
||||||
|
paper_id TEXT NOT NULL,
|
||||||
|
paragraph_id TEXT NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (paper_id, paragraph_id)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LECTURE_SCHEMA_VERSION = 1;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { getPref } from "../../../../utils/prefs";
|
||||||
|
import {
|
||||||
|
createTtsProvider,
|
||||||
|
getDefaultTtsProviderId,
|
||||||
|
TTS_PROVIDER_PRESETS,
|
||||||
|
type TtsProviderId,
|
||||||
|
} from "./providers";
|
||||||
|
import type { TtsResult, TtsSynthesizeOptions } from "./types";
|
||||||
|
import { TtsError } from "./types";
|
||||||
|
|
||||||
|
export interface TtsRuntimeConfig {
|
||||||
|
provider: TtsProviderId | string;
|
||||||
|
voice: string;
|
||||||
|
piperPath: string;
|
||||||
|
piperModelPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTtsRuntimeConfig(): TtsRuntimeConfig {
|
||||||
|
const provider =
|
||||||
|
(getPref("ttsProvider") as string) || getDefaultTtsProviderId();
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
voice: (getPref("ttsVoice") as string) || "",
|
||||||
|
piperPath: (getPref("piperPath") as string) || "",
|
||||||
|
piperModelPath: (getPref("piperModelPath") as string) || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTtsProviderLabel(id: string): string {
|
||||||
|
const preset = TTS_PROVIDER_PRESETS[id as TtsProviderId];
|
||||||
|
return preset?.label || id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function synthesizeSpeech(
|
||||||
|
options: TtsSynthesizeOptions,
|
||||||
|
): Promise<TtsResult> {
|
||||||
|
const cfg = getTtsRuntimeConfig();
|
||||||
|
if (!cfg.provider) {
|
||||||
|
throw new TtsError("请先在偏好设置中选择 TTS Provider", "config");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const provider = createTtsProvider(cfg.provider);
|
||||||
|
return await provider.synthesize(options);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof TtsError) throw e;
|
||||||
|
throw new TtsError(
|
||||||
|
e instanceof Error ? e.message : String(e),
|
||||||
|
"runtime",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short sample for prefs / lecture pane smoke test */
|
||||||
|
export const TTS_SAMPLE_ZH =
|
||||||
|
"你好,我是 ChatPapers 语音伴读。这是一段本地语音合成测试。";
|
||||||
|
|
||||||
|
export async function synthesizeSample(outputPath: string): Promise<TtsResult> {
|
||||||
|
return synthesizeSpeech({
|
||||||
|
text: TTS_SAMPLE_ZH,
|
||||||
|
outputPath,
|
||||||
|
lang: "zh",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
defaultVoiceForLang,
|
||||||
|
removeIfExists,
|
||||||
|
resolveExecutable,
|
||||||
|
writeTempTextFile,
|
||||||
|
} from "../../platform/executable";
|
||||||
|
import { runCommand } from "../../platform/subprocess";
|
||||||
|
import type { TtsProvider, TtsSynthesizeOptions, TtsResult } from "../types";
|
||||||
|
import { TtsError } from "../types";
|
||||||
|
import { getPref } from "../../../../../utils/prefs";
|
||||||
|
|
||||||
|
async function estimateWavDurationMs(path: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const stat = await IOUtils.stat(path);
|
||||||
|
return Math.max(1000, Math.floor((stat.size / 32000) * 1000));
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMacSayProvider(): TtsProvider {
|
||||||
|
return {
|
||||||
|
id: "system-mac",
|
||||||
|
label: "macOS say",
|
||||||
|
kind: "local",
|
||||||
|
supportsTimestamps: false,
|
||||||
|
async synthesize(options: TtsSynthesizeOptions): Promise<TtsResult> {
|
||||||
|
const say = await resolveExecutable("say");
|
||||||
|
const afconvert = await resolveExecutable("afconvert");
|
||||||
|
|
||||||
|
const voice =
|
||||||
|
options.voice ||
|
||||||
|
(getPref("ttsVoice") as string) ||
|
||||||
|
defaultVoiceForLang(options.lang);
|
||||||
|
|
||||||
|
const outWav = options.outputPath.endsWith(".wav")
|
||||||
|
? options.outputPath
|
||||||
|
: options.outputPath.replace(/\.\w+$/, ".wav");
|
||||||
|
const aiffPath = outWav.replace(/\.wav$/i, ".aiff");
|
||||||
|
|
||||||
|
const textPath = await writeTempTextFile(options.text, "say-text");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sayResult = await runCommand(say, [
|
||||||
|
"-v",
|
||||||
|
voice,
|
||||||
|
"-f",
|
||||||
|
textPath,
|
||||||
|
"-o",
|
||||||
|
aiffPath,
|
||||||
|
]);
|
||||||
|
if (sayResult.exitCode !== 0) {
|
||||||
|
throw new TtsError(`say 退出码 ${sayResult.exitCode}`, "runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
const conv = await runCommand(afconvert, [
|
||||||
|
"-f",
|
||||||
|
"WAVE",
|
||||||
|
"-d",
|
||||||
|
"LEI16",
|
||||||
|
aiffPath,
|
||||||
|
outWav,
|
||||||
|
]);
|
||||||
|
if (conv.exitCode !== 0) {
|
||||||
|
throw new TtsError(`afconvert 退出码 ${conv.exitCode}`, "runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await IOUtils.exists(outWav))) {
|
||||||
|
throw new TtsError("未生成音频文件", "runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioPath: outWav,
|
||||||
|
timestamps: [],
|
||||||
|
durationMs: await estimateWavDurationMs(outWav),
|
||||||
|
format: "wav",
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await removeIfExists(textPath);
|
||||||
|
await removeIfExists(aiffPath);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { joinPath } from "../../platform/paths";
|
||||||
|
import {
|
||||||
|
removeIfExists,
|
||||||
|
resolveExecutable,
|
||||||
|
writeTempTextFile,
|
||||||
|
} from "../../platform/executable";
|
||||||
|
import { runCommand } from "../../platform/subprocess";
|
||||||
|
import type { TtsProvider, TtsSynthesizeOptions, TtsResult } from "../types";
|
||||||
|
import { TtsError } from "../types";
|
||||||
|
import { getPref } from "../../../../../utils/prefs";
|
||||||
|
|
||||||
|
async function estimateWavDurationMs(path: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const stat = await IOUtils.stat(path);
|
||||||
|
return Math.max(1000, Math.floor((stat.size / 32000) * 1000));
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPiperProvider(): TtsProvider {
|
||||||
|
return {
|
||||||
|
id: "piper",
|
||||||
|
label: "Piper",
|
||||||
|
kind: "local",
|
||||||
|
supportsTimestamps: false,
|
||||||
|
needsPath: true,
|
||||||
|
async synthesize(options: TtsSynthesizeOptions): Promise<TtsResult> {
|
||||||
|
const piperPath = (getPref("piperPath") || "") as string;
|
||||||
|
const modelPath = (getPref("piperModelPath") || "") as string;
|
||||||
|
|
||||||
|
if (!modelPath) {
|
||||||
|
throw new TtsError(
|
||||||
|
"请先在偏好设置 → 语音伴读 中配置 Piper 模型路径(.onnx)",
|
||||||
|
"config",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!(await IOUtils.exists(modelPath))) {
|
||||||
|
throw new TtsError(`Piper 模型不存在: ${modelPath}`, "config");
|
||||||
|
}
|
||||||
|
|
||||||
|
const piper = await resolveExecutable("piper", piperPath);
|
||||||
|
|
||||||
|
const outWav = options.outputPath.endsWith(".wav")
|
||||||
|
? options.outputPath
|
||||||
|
: options.outputPath.replace(/\.\w+$/, ".wav");
|
||||||
|
|
||||||
|
const textPath = await writeTempTextFile(options.text, "piper-text");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// piper --model model.onnx --output_file out.wav --file input.txt
|
||||||
|
const result = await runCommand(piper, [
|
||||||
|
"--model",
|
||||||
|
modelPath,
|
||||||
|
"--output_file",
|
||||||
|
outWav,
|
||||||
|
"--file",
|
||||||
|
textPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new TtsError(`piper 退出码 ${result.exitCode}`, "runtime");
|
||||||
|
}
|
||||||
|
if (!(await IOUtils.exists(outWav))) {
|
||||||
|
throw new TtsError("Piper 未生成音频文件", "runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioPath: outWav,
|
||||||
|
timestamps: [],
|
||||||
|
durationMs: await estimateWavDurationMs(outWav),
|
||||||
|
format: "wav",
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await removeIfExists(textPath);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suggested default model directory hint for prefs UI */
|
||||||
|
export function piperModelHint(): string {
|
||||||
|
return joinPath("{HOME}", ".local", "share", "piper", "models");
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { joinPath } from "../../platform/paths";
|
||||||
|
import {
|
||||||
|
defaultVoiceForLang,
|
||||||
|
removeIfExists,
|
||||||
|
resolveExecutable,
|
||||||
|
writeTempTextFile,
|
||||||
|
} from "../../platform/executable";
|
||||||
|
import { runCommand } from "../../platform/subprocess";
|
||||||
|
import type { TtsProvider, TtsSynthesizeOptions, TtsResult } from "../types";
|
||||||
|
import { TtsError } from "../types";
|
||||||
|
import { getPref } from "../../../../../utils/prefs";
|
||||||
|
|
||||||
|
async function estimateWavDurationMs(path: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const stat = await IOUtils.stat(path);
|
||||||
|
return Math.max(1000, Math.floor((stat.size / 32000) * 1000));
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWindowsSapiProvider(): TtsProvider {
|
||||||
|
return {
|
||||||
|
id: "system-win",
|
||||||
|
label: "Windows SAPI",
|
||||||
|
kind: "local",
|
||||||
|
supportsTimestamps: false,
|
||||||
|
async synthesize(options: TtsSynthesizeOptions): Promise<TtsResult> {
|
||||||
|
const powershell = await resolveExecutable("powershell");
|
||||||
|
const voice =
|
||||||
|
options.voice ||
|
||||||
|
(getPref("ttsVoice") as string) ||
|
||||||
|
defaultVoiceForLang(options.lang);
|
||||||
|
|
||||||
|
const outWav = options.outputPath.endsWith(".wav")
|
||||||
|
? options.outputPath
|
||||||
|
: options.outputPath.replace(/\.\w+$/, ".wav");
|
||||||
|
|
||||||
|
const textPath = await writeTempTextFile(options.text, "sapi-text");
|
||||||
|
const scriptPath = joinPath(
|
||||||
|
PathUtils.tempDir,
|
||||||
|
`chatpapers-sapi-${Date.now()}.ps1`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const psScript = [
|
||||||
|
"Add-Type -AssemblyName System.Speech",
|
||||||
|
"$text = Get-Content -LiteralPath $args[0] -Raw -Encoding UTF8",
|
||||||
|
"$out = $args[1]",
|
||||||
|
"$voice = $args[2]",
|
||||||
|
"$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer",
|
||||||
|
"if ($voice) { try { $synth.SelectVoice($voice) } catch {} }",
|
||||||
|
"$synth.SetOutputToWaveFile($out)",
|
||||||
|
"$synth.Speak($text)",
|
||||||
|
"$synth.Dispose()",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
await IOUtils.writeUTF8(scriptPath, psScript);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runCommand(powershell, [
|
||||||
|
"-NoProfile",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-File",
|
||||||
|
scriptPath,
|
||||||
|
textPath,
|
||||||
|
outWav,
|
||||||
|
voice,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new TtsError(
|
||||||
|
`PowerShell SAPI 退出码 ${result.exitCode}`,
|
||||||
|
"runtime",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!(await IOUtils.exists(outWav))) {
|
||||||
|
throw new TtsError("SAPI 未生成音频文件", "runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioPath: outWav,
|
||||||
|
timestamps: [],
|
||||||
|
durationMs: await estimateWavDurationMs(outWav),
|
||||||
|
format: "wav",
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await removeIfExists(textPath);
|
||||||
|
await removeIfExists(scriptPath);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { getHostPlatform, isMac, isWindows } from "../platform/os";
|
||||||
|
import type { TtsProvider } from "./types";
|
||||||
|
import { createMacSayProvider } from "./local/macSay";
|
||||||
|
import { createPiperProvider } from "./local/piper";
|
||||||
|
import { createWindowsSapiProvider } from "./local/windowsSapi";
|
||||||
|
|
||||||
|
export interface TtsProviderPreset {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: "local" | "cloud";
|
||||||
|
description: string;
|
||||||
|
/** Shown when this provider is not available on current OS */
|
||||||
|
platforms?: Array<"mac" | "win" | "linux" | "other">;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TTS_PROVIDER_ORDER = [
|
||||||
|
"system",
|
||||||
|
"piper",
|
||||||
|
"openai",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TtsProviderId = (typeof TTS_PROVIDER_ORDER)[number];
|
||||||
|
|
||||||
|
export const TTS_PROVIDER_PRESETS: Record<TtsProviderId, TtsProviderPreset> = {
|
||||||
|
system: {
|
||||||
|
id: "system",
|
||||||
|
label: "本地 · 系统语音",
|
||||||
|
kind: "local",
|
||||||
|
description: "macOS say / Windows SAPI,零配置、完全离线",
|
||||||
|
platforms: ["mac", "win"],
|
||||||
|
},
|
||||||
|
piper: {
|
||||||
|
id: "piper",
|
||||||
|
label: "本地 · Piper",
|
||||||
|
kind: "local",
|
||||||
|
description: "离线神经 TTS,需安装 piper 并配置模型路径",
|
||||||
|
platforms: ["mac", "win", "linux", "other"],
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
id: "openai",
|
||||||
|
label: "云端 · OpenAI 兼容",
|
||||||
|
kind: "cloud",
|
||||||
|
description: "OpenAI / 兼容端点 TTS(待接入)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isTtsProviderAvailable(preset: TtsProviderPreset): boolean {
|
||||||
|
if (!preset.platforms) return true;
|
||||||
|
return preset.platforms.includes(getHostPlatform());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultTtsProviderId(): TtsProviderId {
|
||||||
|
if (isMac() || isWindows()) return "system";
|
||||||
|
return "piper";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTtsProvider(id: string): TtsProvider {
|
||||||
|
switch (id) {
|
||||||
|
case "system":
|
||||||
|
if (isMac()) return createMacSayProvider();
|
||||||
|
if (isWindows()) return createWindowsSapiProvider();
|
||||||
|
throw new Error("系统语音在当前平台不可用,请选择 Piper 或云端 TTS");
|
||||||
|
case "piper":
|
||||||
|
return createPiperProvider();
|
||||||
|
case "openai":
|
||||||
|
throw new Error("云端 TTS 尚未接入,请先用本地系统语音或 Piper");
|
||||||
|
default:
|
||||||
|
throw new Error(`未知 TTS Provider: ${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export interface WordTimestamp {
|
||||||
|
text: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TtsSynthesizeOptions {
|
||||||
|
text: string;
|
||||||
|
/** Full output path including extension (.wav / .mp3) */
|
||||||
|
outputPath: string;
|
||||||
|
voice?: string;
|
||||||
|
lang?: "zh" | "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TtsResult {
|
||||||
|
audioPath: string;
|
||||||
|
timestamps: WordTimestamp[];
|
||||||
|
durationMs: number;
|
||||||
|
format: "wav" | "mp3" | "aiff";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TtsProvider {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: "local" | "cloud";
|
||||||
|
supportsTimestamps: boolean;
|
||||||
|
/** Whether user must configure an executable or model path */
|
||||||
|
needsPath?: boolean;
|
||||||
|
synthesize(options: TtsSynthesizeOptions): Promise<TtsResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TtsError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public code: "config" | "runtime" | "unsupported",
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "TtsError";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { findPdfAttachment } from "../../../pdf/extractor";
|
||||||
|
import type { PaperAttachmentContext } from "../../domain/types";
|
||||||
|
import {
|
||||||
|
buildCacheKey,
|
||||||
|
computeFileHash,
|
||||||
|
paperIdFromCacheKey,
|
||||||
|
} from "../pdf/fileHash";
|
||||||
|
|
||||||
|
function attachmentFilePath(attachment: Zotero.Item): string {
|
||||||
|
const path = attachment.getFilePath?.() as string | false;
|
||||||
|
if (!path) {
|
||||||
|
throw new Error("PDF attachment has no local file path");
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolvePaperAttachment(
|
||||||
|
item: Zotero.Item,
|
||||||
|
): Promise<PaperAttachmentContext> {
|
||||||
|
const attachment = findPdfAttachment(item);
|
||||||
|
if (!attachment) {
|
||||||
|
throw new Error("NO_PDF");
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = attachmentFilePath(attachment);
|
||||||
|
const exists = await IOUtils.exists(filePath);
|
||||||
|
if (!exists) {
|
||||||
|
throw new Error("PDF_FILE_MISSING");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileHash = await computeFileHash(filePath);
|
||||||
|
const attachmentId = String(attachment.id);
|
||||||
|
const cacheKey = buildCacheKey(attachmentId, fileHash);
|
||||||
|
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
attachment,
|
||||||
|
filePath,
|
||||||
|
fileHash,
|
||||||
|
cacheKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegularItem(item: Zotero.Item): Zotero.Item {
|
||||||
|
if (item.isRegularItem()) return item;
|
||||||
|
if (item.parentItemID) {
|
||||||
|
const parent = Zotero.Items.get(item.parentItemID);
|
||||||
|
if (parent) return parent;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPdfAtPage(
|
||||||
|
item: Zotero.Item,
|
||||||
|
page: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const attachment = findPdfAttachment(item);
|
||||||
|
if (!attachment) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reader = await (Zotero.Reader as any).open(attachment.id);
|
||||||
|
if (reader?.navigate) {
|
||||||
|
await reader.navigate({ pageNumber: page });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reader?.setPageIndex) {
|
||||||
|
reader.setPageIndex(Math.max(0, page - 1));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] openPdfAtPage failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { paperIdFromCacheKey };
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
import { Headphones, Pause, Play, SkipBack, SkipForward, Sparkles, Volume2 } from "lucide";
|
||||||
|
import type { LectureStatus, PaperCache } from "../domain/types";
|
||||||
|
import { prepareLecture } from "../application/prepareLecture";
|
||||||
|
import {
|
||||||
|
createPlayLecture,
|
||||||
|
type PlayLectureHandle,
|
||||||
|
} from "../application/playLecture";
|
||||||
|
import { lectureEvents } from "../infrastructure/events";
|
||||||
|
import { BEAT_TYPE_LABELS } from "../infrastructure/llm/beatLlm";
|
||||||
|
import { pathToFileUrl } from "../infrastructure/platform/paths";
|
||||||
|
import { getHostPlatform } from "../infrastructure/platform/os";
|
||||||
|
import {
|
||||||
|
ensureSampleDir,
|
||||||
|
lectureSamplePath,
|
||||||
|
} from "../infrastructure/storage/paths";
|
||||||
|
import {
|
||||||
|
isStep1Ready,
|
||||||
|
loadLectureData,
|
||||||
|
type StoredBeat,
|
||||||
|
} from "../infrastructure/storage/lectureStore";
|
||||||
|
import { formatSummaryForDisplay } from "../infrastructure/llm/lectureLlm";
|
||||||
|
import { paperRepository } from "../infrastructure/storage/repository";
|
||||||
|
import {
|
||||||
|
getTtsProviderLabel,
|
||||||
|
getTtsRuntimeConfig,
|
||||||
|
synthesizeSample,
|
||||||
|
} from "../infrastructure/tts/client";
|
||||||
|
import { TtsError } from "../infrastructure/tts/types";
|
||||||
|
import {
|
||||||
|
getRegularItem,
|
||||||
|
resolvePaperAttachment,
|
||||||
|
} from "../infrastructure/zotero/adapter";
|
||||||
|
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
|
||||||
|
import { getString } from "../../../utils/locale";
|
||||||
|
import { createLucideIcon } from "../../../utils/icons";
|
||||||
|
|
||||||
|
export class LecturePaneView {
|
||||||
|
private doc: Document;
|
||||||
|
private body: HTMLElement;
|
||||||
|
private item: Zotero.Item;
|
||||||
|
private unsubscribers: Array<() => void> = [];
|
||||||
|
private paper?: PaperCache;
|
||||||
|
private preparing = false;
|
||||||
|
private ttsTesting = false;
|
||||||
|
private audioEl?: HTMLAudioElement;
|
||||||
|
private player?: PlayLectureHandle;
|
||||||
|
private activeBeatId?: string;
|
||||||
|
|
||||||
|
private statusEl!: HTMLElement;
|
||||||
|
private statusTextEl!: HTMLElement;
|
||||||
|
private ttsMetaEl!: HTMLElement;
|
||||||
|
private actionBtn!: HTMLButtonElement;
|
||||||
|
private ttsTestBtn!: HTMLButtonElement;
|
||||||
|
private hintEl!: HTMLElement;
|
||||||
|
private playerSection!: HTMLElement;
|
||||||
|
private beatListEl!: HTMLElement;
|
||||||
|
private playBtn!: HTMLButtonElement;
|
||||||
|
private pauseBtn!: HTMLButtonElement;
|
||||||
|
private prevBtn!: HTMLButtonElement;
|
||||||
|
private nextBtn!: HTMLButtonElement;
|
||||||
|
|
||||||
|
constructor(doc: Document, body: HTMLElement, item: Zotero.Item) {
|
||||||
|
this.doc = doc;
|
||||||
|
this.body = body;
|
||||||
|
this.item = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async mount(): Promise<void> {
|
||||||
|
this.body.replaceChildren();
|
||||||
|
this.body.classList.add("chatpapers-root", "chatpapers-lecture-root");
|
||||||
|
|
||||||
|
const parent = getRegularItem(this.item);
|
||||||
|
const title = String(parent.getField("title") || "ChatPapers");
|
||||||
|
|
||||||
|
const header = this.el("div", "chatpapers-header");
|
||||||
|
const brand = this.el("div", "chatpapers-brand");
|
||||||
|
brand.append(
|
||||||
|
createLucideIcon(this.doc, Headphones, {
|
||||||
|
size: 18,
|
||||||
|
className: "chatpapers-brand-icon",
|
||||||
|
}),
|
||||||
|
this.el("div", "chatpapers-brand-text", "ChatPapers"),
|
||||||
|
);
|
||||||
|
header.append(
|
||||||
|
brand,
|
||||||
|
this.el("div", "chatpapers-title", title),
|
||||||
|
this.el("div", "chatpapers-meta", getString("lecture-mode-label")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const empty = this.el("div", "chatpapers-empty chatpapers-lecture-empty");
|
||||||
|
empty.append(
|
||||||
|
createLucideIcon(this.doc, Sparkles, {
|
||||||
|
size: 28,
|
||||||
|
className: "chatpapers-empty-icon",
|
||||||
|
}),
|
||||||
|
this.el("div", "chatpapers-empty-title", getString("lecture-empty-title")),
|
||||||
|
this.el(
|
||||||
|
"div",
|
||||||
|
"chatpapers-empty-desc",
|
||||||
|
getString("lecture-empty-desc"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.statusEl = this.el("div", "chatpapers-lecture-status");
|
||||||
|
this.statusTextEl = this.el("span", "chatpapers-lecture-status-text");
|
||||||
|
this.statusEl.append(this.statusTextEl);
|
||||||
|
|
||||||
|
this.ttsMetaEl = this.el("div", "chatpapers-lecture-tts-meta");
|
||||||
|
this.updateTtsMeta();
|
||||||
|
|
||||||
|
this.actionBtn = this.doc.createElement("button");
|
||||||
|
this.actionBtn.className = "chatpapers-lecture-primary";
|
||||||
|
this.actionBtn.type = "button";
|
||||||
|
this.actionBtn.addEventListener("click", () => void this.onStartPrepare());
|
||||||
|
|
||||||
|
this.ttsTestBtn = this.doc.createElement("button");
|
||||||
|
this.ttsTestBtn.className = "chatpapers-lecture-secondary";
|
||||||
|
this.ttsTestBtn.type = "button";
|
||||||
|
this.ttsTestBtn.append(
|
||||||
|
createLucideIcon(this.doc, Volume2, {
|
||||||
|
size: 14,
|
||||||
|
className: "chatpapers-lecture-btn-icon",
|
||||||
|
}),
|
||||||
|
this.doc.createTextNode(getString("lecture-tts-test")),
|
||||||
|
);
|
||||||
|
this.ttsTestBtn.addEventListener("click", () => void this.onTtsTest());
|
||||||
|
|
||||||
|
this.playerSection = this.el("div", "chatpapers-lecture-player");
|
||||||
|
this.playerSection.hidden = true;
|
||||||
|
|
||||||
|
const playerTitle = this.el(
|
||||||
|
"div",
|
||||||
|
"chatpapers-lecture-player-title",
|
||||||
|
getString("lecture-beat-list-title"),
|
||||||
|
);
|
||||||
|
this.beatListEl = this.el("div", "chatpapers-lecture-beat-list");
|
||||||
|
|
||||||
|
const playerControls = this.el("div", "chatpapers-lecture-player-controls");
|
||||||
|
|
||||||
|
this.prevBtn = this.makeIconBtn("lecture-play-prev", SkipBack, () =>
|
||||||
|
void this.player?.prev(),
|
||||||
|
);
|
||||||
|
this.playBtn = this.makeIconBtn("lecture-play-start", Play, () =>
|
||||||
|
void this.player?.play(),
|
||||||
|
);
|
||||||
|
this.pauseBtn = this.makeIconBtn("lecture-play-pause", Pause, () =>
|
||||||
|
this.player?.pause(),
|
||||||
|
);
|
||||||
|
this.nextBtn = this.makeIconBtn("lecture-play-next", SkipForward, () =>
|
||||||
|
void this.player?.next(),
|
||||||
|
);
|
||||||
|
this.pauseBtn.hidden = true;
|
||||||
|
|
||||||
|
playerControls.append(this.prevBtn, this.playBtn, this.pauseBtn, this.nextBtn);
|
||||||
|
this.playerSection.append(playerTitle, this.beatListEl, playerControls);
|
||||||
|
|
||||||
|
this.audioEl = this.doc.createElement("audio");
|
||||||
|
this.audioEl.className = "chatpapers-lecture-audio";
|
||||||
|
this.audioEl.controls = true;
|
||||||
|
this.audioEl.preload = "none";
|
||||||
|
|
||||||
|
this.hintEl = this.el("div", "chatpapers-lecture-hint");
|
||||||
|
this.updatePlatformHint();
|
||||||
|
|
||||||
|
const actions = this.el("div", "chatpapers-lecture-actions");
|
||||||
|
actions.append(this.actionBtn, this.ttsTestBtn);
|
||||||
|
|
||||||
|
this.body.append(
|
||||||
|
header,
|
||||||
|
empty,
|
||||||
|
this.statusEl,
|
||||||
|
this.ttsMetaEl,
|
||||||
|
actions,
|
||||||
|
this.playerSection,
|
||||||
|
this.audioEl,
|
||||||
|
this.hintEl,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.updateActionButton();
|
||||||
|
this.bindEvents();
|
||||||
|
await this.refreshFromStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
for (const off of this.unsubscribers) off();
|
||||||
|
this.unsubscribers = [];
|
||||||
|
this.player?.destroy();
|
||||||
|
this.player = undefined;
|
||||||
|
this.audioEl?.pause();
|
||||||
|
this.body.replaceChildren();
|
||||||
|
this.body.classList.remove("chatpapers-root", "chatpapers-lecture-root");
|
||||||
|
}
|
||||||
|
|
||||||
|
private cacheKey?: string;
|
||||||
|
|
||||||
|
private bindEvents(): void {
|
||||||
|
this.unsubscribers.push(
|
||||||
|
lectureEvents.on("lecture:status", (d) => {
|
||||||
|
if (!this.shouldHandleEvent(d.paperId)) return;
|
||||||
|
this.setStatus(d.lectureStatus as LectureStatus, d.message);
|
||||||
|
if (this.paper) this.paper.lectureStatus = d.lectureStatus as LectureStatus;
|
||||||
|
if (d.lectureStatus === "ready") void this.refreshPlayer();
|
||||||
|
}),
|
||||||
|
lectureEvents.on("parse:progress", (d) => {
|
||||||
|
if (!this.shouldHandleEvent(d.paperId)) return;
|
||||||
|
this.setStatus(this.paper?.lectureStatus ?? "parsing", d.message);
|
||||||
|
}),
|
||||||
|
lectureEvents.on("lecture:failed", (d) => {
|
||||||
|
if (!this.shouldHandleEvent(d.paperId)) return;
|
||||||
|
this.setStatus("failed", d.error);
|
||||||
|
this.preparing = false;
|
||||||
|
this.actionBtn.disabled = false;
|
||||||
|
}),
|
||||||
|
lectureEvents.on("lecture:beats_ready", (d) => {
|
||||||
|
if (!this.shouldHandleEvent(d.paperId)) return;
|
||||||
|
void this.refreshPlayer();
|
||||||
|
}),
|
||||||
|
lectureEvents.on("tts:unit_ready", (d) => {
|
||||||
|
if (!this.shouldHandleEvent(d.paperId)) return;
|
||||||
|
void this.refreshPlayer();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldHandleEvent(paperId: string): boolean {
|
||||||
|
if (this.preparing) return this.paper?.id === paperId || !this.paper;
|
||||||
|
return this.paper?.id === paperId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeIconBtn(
|
||||||
|
labelKey: Parameters<typeof getString>[0],
|
||||||
|
icon: typeof Play,
|
||||||
|
onClick: () => void,
|
||||||
|
): HTMLButtonElement {
|
||||||
|
const btn = this.doc.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "chatpapers-lecture-player-btn";
|
||||||
|
btn.title = getString(labelKey);
|
||||||
|
btn.append(
|
||||||
|
createLucideIcon(this.doc, icon, {
|
||||||
|
size: 16,
|
||||||
|
className: "chatpapers-lecture-btn-icon",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
btn.addEventListener("click", onClick);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateTtsMeta(): void {
|
||||||
|
const cfg = getTtsRuntimeConfig();
|
||||||
|
const label = getTtsProviderLabel(cfg.provider);
|
||||||
|
this.ttsMetaEl.textContent = getString("lecture-tts-provider", {
|
||||||
|
args: { provider: label },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateActionButton(): void {
|
||||||
|
this.actionBtn.replaceChildren();
|
||||||
|
const isReady =
|
||||||
|
this.paper?.lectureStatus === "ready" && this.playerSection && !this.playerSection.hidden;
|
||||||
|
const label = isReady
|
||||||
|
? getString("lecture-reprepare")
|
||||||
|
: getString("lecture-start");
|
||||||
|
this.actionBtn.append(
|
||||||
|
createLucideIcon(this.doc, Play, {
|
||||||
|
size: 14,
|
||||||
|
className: "chatpapers-lecture-btn-icon",
|
||||||
|
}),
|
||||||
|
this.doc.createTextNode(label),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshFromStore(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const ctx = await resolvePaperAttachment(this.item);
|
||||||
|
this.cacheKey = ctx.cacheKey;
|
||||||
|
const cached = await paperRepository.getByCacheKey(ctx.cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
this.paper = cached;
|
||||||
|
const msg = await this.statusMessage(cached, ctx.cacheKey);
|
||||||
|
this.setStatus(cached.lectureStatus, msg);
|
||||||
|
} else {
|
||||||
|
this.setStatus("idle", getString("lecture-status-idle"));
|
||||||
|
}
|
||||||
|
await this.refreshPlayer();
|
||||||
|
} catch (e) {
|
||||||
|
const msg =
|
||||||
|
e instanceof Error && e.message === "NO_PDF"
|
||||||
|
? getString("lecture-no-pdf")
|
||||||
|
: getString("lecture-attach-error");
|
||||||
|
this.setStatus("failed", msg);
|
||||||
|
this.actionBtn.disabled = true;
|
||||||
|
this.ttsTestBtn.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshPlayer(): Promise<void> {
|
||||||
|
if (!this.cacheKey || !this.audioEl) return;
|
||||||
|
const data = await loadLectureData(this.cacheKey);
|
||||||
|
const beats = data?.beats ?? [];
|
||||||
|
const canPlay = isStep1Ready(data);
|
||||||
|
|
||||||
|
this.playerSection.hidden = !beats.length;
|
||||||
|
this.renderBeatList(beats);
|
||||||
|
|
||||||
|
if (!this.player) {
|
||||||
|
this.player = createPlayLecture({
|
||||||
|
audio: this.audioEl,
|
||||||
|
beats,
|
||||||
|
onBeatChange: (id) => {
|
||||||
|
this.activeBeatId = id;
|
||||||
|
this.highlightBeat(id);
|
||||||
|
},
|
||||||
|
onPlayingChange: (playing) => {
|
||||||
|
this.playBtn.hidden = playing;
|
||||||
|
this.pauseBtn.hidden = !playing;
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
this.setStatus("ready", getString("lecture-play-complete"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.player.setBeats(beats);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateActionButton();
|
||||||
|
|
||||||
|
if (canPlay && this.paper?.lectureStatus === "ready") {
|
||||||
|
const readyCount = beats.filter((b) => b.ttsStatus === "ready").length;
|
||||||
|
if (!this.preparing) {
|
||||||
|
this.setStatus(
|
||||||
|
"ready",
|
||||||
|
getString("lecture-status-ready-step1", {
|
||||||
|
args: { count: readyCount },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderBeatList(beats: StoredBeat[]): void {
|
||||||
|
this.beatListEl.replaceChildren();
|
||||||
|
for (const beat of beats) {
|
||||||
|
const row = this.doc.createElement("button") as HTMLButtonElement;
|
||||||
|
row.type = "button";
|
||||||
|
row.className = "chatpapers-lecture-beat-item";
|
||||||
|
row.dataset.beatId = beat.id;
|
||||||
|
if (beat.id === this.activeBeatId) {
|
||||||
|
row.classList.add("is-active");
|
||||||
|
}
|
||||||
|
if (beat.ttsStatus !== "ready") {
|
||||||
|
row.classList.add("is-pending");
|
||||||
|
row.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = this.el("div", "chatpapers-lecture-beat-meta");
|
||||||
|
meta.append(
|
||||||
|
this.el(
|
||||||
|
"span",
|
||||||
|
"chatpapers-lecture-beat-type",
|
||||||
|
BEAT_TYPE_LABELS[beat.type] || beat.type,
|
||||||
|
),
|
||||||
|
this.el(
|
||||||
|
"span",
|
||||||
|
"chatpapers-lecture-beat-title",
|
||||||
|
beat.title || beat.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = this.el("span", "chatpapers-lecture-beat-status");
|
||||||
|
if (beat.ttsStatus === "ready") {
|
||||||
|
status.textContent = "✓";
|
||||||
|
} else if (beat.ttsStatus === "failed") {
|
||||||
|
status.textContent = getString("lecture-beat-tts-failed");
|
||||||
|
} else {
|
||||||
|
status.textContent = getString("lecture-beat-tts-pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
row.append(meta, status);
|
||||||
|
row.addEventListener("click", () => {
|
||||||
|
void this.player?.jumpToBeatId(beat.id);
|
||||||
|
});
|
||||||
|
this.beatListEl.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private highlightBeat(beatId?: string): void {
|
||||||
|
this.activeBeatId = beatId;
|
||||||
|
for (const node of this.beatListEl.querySelectorAll(
|
||||||
|
".chatpapers-lecture-beat-item",
|
||||||
|
)) {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
el.classList.toggle("is-active", el.dataset.beatId === beatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async statusMessage(
|
||||||
|
paper: PaperCache,
|
||||||
|
cacheKey?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
switch (paper.lectureStatus) {
|
||||||
|
case "ready": {
|
||||||
|
if (cacheKey) {
|
||||||
|
const data = await loadLectureData(cacheKey);
|
||||||
|
if (isStep1Ready(data)) {
|
||||||
|
const count = data!.beats!.filter((b) => b.ttsStatus === "ready").length;
|
||||||
|
return getString("lecture-status-ready-step1", { args: { count } });
|
||||||
|
}
|
||||||
|
if (data?.summary) {
|
||||||
|
return `${getString("lecture-status-ready-phase1")}\n${formatSummaryForDisplay(data.summary)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getString("lecture-status-ready");
|
||||||
|
}
|
||||||
|
case "failed":
|
||||||
|
return getString("lecture-status-failed");
|
||||||
|
case "idle":
|
||||||
|
return getString("lecture-status-idle");
|
||||||
|
default:
|
||||||
|
return getString("lecture-status-preparing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatus(status: LectureStatus | "idle", message?: string): void {
|
||||||
|
this.statusEl.dataset.status = status;
|
||||||
|
this.statusTextEl.textContent =
|
||||||
|
message || getString("lecture-status-idle");
|
||||||
|
const busy = [
|
||||||
|
"parsing",
|
||||||
|
"summarizing",
|
||||||
|
"beats_generating",
|
||||||
|
"paragraphs_generating",
|
||||||
|
"tts_generating",
|
||||||
|
].includes(status);
|
||||||
|
this.actionBtn.disabled = this.preparing || busy;
|
||||||
|
this.ttsTestBtn.disabled = this.ttsTesting || this.preparing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePlatformHint(): void {
|
||||||
|
const platform = getHostPlatform();
|
||||||
|
const key =
|
||||||
|
platform === "win"
|
||||||
|
? "lecture-platform-hint-win"
|
||||||
|
: platform === "mac"
|
||||||
|
? "lecture-platform-hint-mac"
|
||||||
|
: "lecture-platform-hint-other";
|
||||||
|
this.hintEl.textContent = getString(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onStartPrepare(): Promise<void> {
|
||||||
|
if (this.preparing) return;
|
||||||
|
this.preparing = true;
|
||||||
|
this.actionBtn.disabled = true;
|
||||||
|
this.ttsTestBtn.disabled = true;
|
||||||
|
this.setStatus("parsing", getString("lecture-status-preparing"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctx = await resolvePaperAttachment(this.item);
|
||||||
|
this.cacheKey = ctx.cacheKey;
|
||||||
|
|
||||||
|
const existing = await paperRepository.getByCacheKey(ctx.cacheKey);
|
||||||
|
if (existing) {
|
||||||
|
this.paper = existing;
|
||||||
|
} else {
|
||||||
|
const parent = getRegularItem(this.item);
|
||||||
|
this.paper = {
|
||||||
|
id: paperIdFromCacheKey(ctx.cacheKey),
|
||||||
|
attachmentId: String(ctx.attachment.id),
|
||||||
|
itemId: String(parent.id),
|
||||||
|
fileHash: ctx.fileHash,
|
||||||
|
filePath: ctx.filePath,
|
||||||
|
title: String(parent.getField("title") || ""),
|
||||||
|
parseStatus: "pending",
|
||||||
|
lectureStatus: "idle",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.paper = await prepareLecture.start(this.item);
|
||||||
|
const msg = await this.statusMessage(this.paper, ctx.cacheKey);
|
||||||
|
this.setStatus(this.paper.lectureStatus, msg);
|
||||||
|
await this.refreshPlayer();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
this.setStatus("failed", msg);
|
||||||
|
} finally {
|
||||||
|
this.preparing = false;
|
||||||
|
this.ttsTestBtn.disabled = false;
|
||||||
|
if (this.paper?.lectureStatus !== "parsing") {
|
||||||
|
this.actionBtn.disabled = false;
|
||||||
|
}
|
||||||
|
this.updateActionButton();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onTtsTest(): Promise<void> {
|
||||||
|
if (this.ttsTesting) return;
|
||||||
|
this.ttsTesting = true;
|
||||||
|
this.ttsTestBtn.disabled = true;
|
||||||
|
this.setStatus("idle", getString("lecture-tts-testing"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureSampleDir();
|
||||||
|
const outPath = lectureSamplePath("tts-test.wav");
|
||||||
|
const result = await synthesizeSample(outPath);
|
||||||
|
if (!this.audioEl) return;
|
||||||
|
|
||||||
|
this.audioEl.src = pathToFileUrl(result.audioPath);
|
||||||
|
this.audioEl.load();
|
||||||
|
await this.audioEl.play();
|
||||||
|
this.setStatus("idle", getString("lecture-tts-test-done"));
|
||||||
|
} catch (e) {
|
||||||
|
const msg =
|
||||||
|
e instanceof TtsError
|
||||||
|
? e.message
|
||||||
|
: e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: String(e);
|
||||||
|
this.setStatus("failed", msg);
|
||||||
|
} finally {
|
||||||
|
this.ttsTesting = false;
|
||||||
|
this.ttsTestBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private el(tag: string, className?: string, text?: string): HTMLElement {
|
||||||
|
const node = this.doc.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text != null) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { config } from "../../../../package.json";
|
||||||
|
import { getLocaleID, getString } from "../../../utils/locale";
|
||||||
|
import { findPdfAttachment } from "../../pdf/extractor";
|
||||||
|
import { ensureChatPapersStyles } from "../../ui/readerPane";
|
||||||
|
import { LecturePaneView } from "./lecturePane";
|
||||||
|
|
||||||
|
const views = new WeakMap<HTMLElement, LecturePaneView>();
|
||||||
|
|
||||||
|
function iconURL(file: string) {
|
||||||
|
return `chrome://${config.addonRef}/content/icons/${file}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerLecturePane() {
|
||||||
|
Zotero.ItemPaneManager.registerSection({
|
||||||
|
paneID: "chatpapers-lecture",
|
||||||
|
pluginID: config.addonID,
|
||||||
|
header: {
|
||||||
|
l10nID: getLocaleID("item-section-lecture-head"),
|
||||||
|
icon: iconURL("lecture.svg"),
|
||||||
|
},
|
||||||
|
sidenav: {
|
||||||
|
l10nID: getLocaleID("item-section-lecture-sidenav"),
|
||||||
|
icon: iconURL("lecture.svg"),
|
||||||
|
},
|
||||||
|
onInit: ({ body }) => {
|
||||||
|
const doc = body.ownerDocument;
|
||||||
|
if (doc) ensureChatPapersStyles(doc);
|
||||||
|
},
|
||||||
|
onDestroy: ({ body }) => {
|
||||||
|
views.get(body)?.destroy();
|
||||||
|
views.delete(body);
|
||||||
|
},
|
||||||
|
onItemChange: ({ item, setEnabled }) => {
|
||||||
|
setEnabled(Boolean(item && findPdfAttachment(item)));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onRender: ({ body }) => {
|
||||||
|
body.replaceChildren();
|
||||||
|
const doc = body.ownerDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
const loading = doc.createElement("div");
|
||||||
|
loading.className = "chatpapers-loading";
|
||||||
|
loading.textContent = getString("lecture-loading");
|
||||||
|
body.append(loading);
|
||||||
|
},
|
||||||
|
onAsyncRender: async ({ body, item }) => {
|
||||||
|
if (!item) return;
|
||||||
|
const doc = body.ownerDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
views.get(body)?.destroy();
|
||||||
|
const view = new LecturePaneView(doc, body, item);
|
||||||
|
views.set(body, view);
|
||||||
|
try {
|
||||||
|
await view.mount();
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("[ChatPapers:Lecture] mount failed", e);
|
||||||
|
body.replaceChildren();
|
||||||
|
const err = doc.createElement("div");
|
||||||
|
err.className = "chatpapers-lecture-error";
|
||||||
|
err.textContent = getString("lecture-mount-error");
|
||||||
|
body.append(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -149,19 +149,24 @@ export async function chatStream(options: {
|
|||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
onDelta: (text: string) => void;
|
onDelta: (text: string) => void;
|
||||||
signal?: AbortSignalLike;
|
signal?: AbortSignalLike;
|
||||||
|
/** OpenAI-compatible response_format json_object */
|
||||||
|
jsonMode?: boolean;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const cfg = getRuntimeConfig();
|
const cfg = getRuntimeConfig();
|
||||||
assertConfig(cfg);
|
assertConfig(cfg);
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
|
|
||||||
const url = joinUrl(cfg.baseUrl, "/chat/completions");
|
const url = joinUrl(cfg.baseUrl, "/chat/completions");
|
||||||
const body = {
|
const body: Record<string, unknown> = {
|
||||||
model: cfg.model,
|
model: cfg.model,
|
||||||
messages: options.messages,
|
messages: options.messages,
|
||||||
stream: true,
|
stream: true,
|
||||||
temperature: cfg.temperature,
|
temperature: cfg.temperature,
|
||||||
max_tokens: cfg.maxTokens,
|
max_tokens: cfg.maxTokens,
|
||||||
};
|
};
|
||||||
|
if (options.jsonMode) {
|
||||||
|
body.response_format = { type: "json_object" };
|
||||||
|
}
|
||||||
|
|
||||||
const doFetch = resolveFetch();
|
const doFetch = resolveFetch();
|
||||||
let res: Response;
|
let res: Response;
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { LlmError } from "./types";
|
||||||
|
|
||||||
|
/** Remove markdown fences and leading/trailing prose wrappers. */
|
||||||
|
export function stripMarkdownFence(text: string): string {
|
||||||
|
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||||
|
if (fenced) return fenced[1].trim();
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract first balanced `{...}` or `[...]` substring. */
|
||||||
|
export function extractBalancedJson(
|
||||||
|
text: string,
|
||||||
|
open: "{" | "[",
|
||||||
|
): string | undefined {
|
||||||
|
const start = text.indexOf(open);
|
||||||
|
if (start < 0) return undefined;
|
||||||
|
const close = open === "{" ? "}" : "]";
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escape = false;
|
||||||
|
|
||||||
|
for (let i = start; i < text.length; i++) {
|
||||||
|
const c = text[i];
|
||||||
|
if (inString) {
|
||||||
|
if (escape) escape = false;
|
||||||
|
else if (c === "\\") escape = true;
|
||||||
|
else if (c === '"') inString = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '"') {
|
||||||
|
inString = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === open) depth++;
|
||||||
|
if (c === close) {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return text.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fix common LLM JSON mistakes (not a full repair engine). */
|
||||||
|
export function repairJsonText(json: string): string {
|
||||||
|
return json
|
||||||
|
.replace(/[\u201c\u201d\u201e]/g, '"')
|
||||||
|
.replace(/[\u2018\u2019]/g, "'")
|
||||||
|
.replace(/,\s*([}\]])/g, "$1")
|
||||||
|
.replace(/\r\n/g, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParse<T>(text: string): T | undefined {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort JSON parse for LLM outputs.
|
||||||
|
* Tries: direct → fenced → balanced extract → repaired variants.
|
||||||
|
*/
|
||||||
|
export function parseJsonLoose<T>(raw: string): T {
|
||||||
|
const trimmed = stripMarkdownFence(raw);
|
||||||
|
const attempts: string[] = [trimmed];
|
||||||
|
|
||||||
|
const obj = extractBalancedJson(trimmed, "{");
|
||||||
|
const arr = extractBalancedJson(trimmed, "[");
|
||||||
|
if (obj) attempts.push(obj, repairJsonText(obj));
|
||||||
|
if (arr) attempts.push(arr, repairJsonText(arr));
|
||||||
|
attempts.push(repairJsonText(trimmed));
|
||||||
|
|
||||||
|
for (const candidate of attempts) {
|
||||||
|
const parsed = tryParse<T>(candidate);
|
||||||
|
if (parsed !== undefined) return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new LlmError(
|
||||||
|
"provider",
|
||||||
|
"模型返回的内容无法解析为 JSON。可尝试更换模型,或降低 temperature。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse `key: value` / `key:value` labeled blocks (Chinese labels). */
|
||||||
|
export function parseLabeledFields(
|
||||||
|
text: string,
|
||||||
|
keys: Array<{ field: string; labels: string[] }>,
|
||||||
|
): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
const lines = text.split(/\n/);
|
||||||
|
let currentField: string | undefined;
|
||||||
|
let buffer: string[] = [];
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (currentField && buffer.length) {
|
||||||
|
out[currentField] = buffer.join(" ").trim();
|
||||||
|
}
|
||||||
|
buffer = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
let matched = false;
|
||||||
|
for (const { field, labels } of keys) {
|
||||||
|
for (const label of labels) {
|
||||||
|
const re = new RegExp(`^\\s*${label}\\s*[::]\\s*(.*)$`, "i");
|
||||||
|
const m = line.match(re);
|
||||||
|
if (m) {
|
||||||
|
flush();
|
||||||
|
currentField = field;
|
||||||
|
if (m[1]?.trim()) buffer.push(m[1].trim());
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched) break;
|
||||||
|
}
|
||||||
|
if (!matched && currentField && line.trim()) {
|
||||||
|
buffer.push(line.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { getPref, setPref } from "../../utils/prefs";
|
||||||
|
import { getString } from "../../utils/locale";
|
||||||
|
import { getHostPlatform } from "../lecture/infrastructure/platform/os";
|
||||||
|
import {
|
||||||
|
TTS_PROVIDER_ORDER,
|
||||||
|
TTS_PROVIDER_PRESETS,
|
||||||
|
getDefaultTtsProviderId,
|
||||||
|
isTtsProviderAvailable,
|
||||||
|
} from "../lecture/infrastructure/tts/providers";
|
||||||
|
|
||||||
|
export function registerLecturePrefsScripts(win: Window) {
|
||||||
|
const doc = win.document;
|
||||||
|
const providerSelect = doc.getElementById(
|
||||||
|
"chatpapers-pref-tts-provider",
|
||||||
|
) as HTMLSelectElement | null;
|
||||||
|
const voiceInput = doc.getElementById(
|
||||||
|
"chatpapers-pref-tts-voice",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
const piperPathInput = doc.getElementById(
|
||||||
|
"chatpapers-pref-piper-path",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
const piperModelInput = doc.getElementById(
|
||||||
|
"chatpapers-pref-piper-model",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
const mineruPathInput = doc.getElementById(
|
||||||
|
"chatpapers-pref-mineru-path",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
const hint = doc.getElementById("chatpapers-pref-tts-hint");
|
||||||
|
const piperBox = doc.getElementById("chatpapers-pref-piper-box");
|
||||||
|
|
||||||
|
if (providerSelect && providerSelect.options.length === 0) {
|
||||||
|
for (const id of TTS_PROVIDER_ORDER) {
|
||||||
|
const preset = TTS_PROVIDER_PRESETS[id];
|
||||||
|
if (!isTtsProviderAvailable(preset)) continue;
|
||||||
|
const opt = doc.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = preset.label;
|
||||||
|
providerSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
const current = getPref("ttsProvider") || getDefaultTtsProviderId();
|
||||||
|
providerSelect.value = current;
|
||||||
|
if (!getPref("ttsProvider")) setPref("ttsProvider", current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateHint = () => {
|
||||||
|
const id = providerSelect?.value || getPref("ttsProvider") || "";
|
||||||
|
const preset = TTS_PROVIDER_PRESETS[id as keyof typeof TTS_PROVIDER_PRESETS];
|
||||||
|
if (hint) {
|
||||||
|
hint.textContent = preset?.description || getString("prefs-tts-hint-empty");
|
||||||
|
}
|
||||||
|
if (piperBox) {
|
||||||
|
(piperBox as HTMLElement).hidden = id !== "piper";
|
||||||
|
}
|
||||||
|
if (voiceInput) {
|
||||||
|
const platform = getHostPlatform();
|
||||||
|
voiceInput.placeholder =
|
||||||
|
platform === "mac"
|
||||||
|
? "Ting-Ting(中文)/ Samantha(英文)"
|
||||||
|
: platform === "win"
|
||||||
|
? "Microsoft Huihui Desktop"
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
providerSelect?.addEventListener("change", () => {
|
||||||
|
setPref("ttsProvider", providerSelect.value);
|
||||||
|
updateHint();
|
||||||
|
});
|
||||||
|
|
||||||
|
voiceInput?.addEventListener("change", () => {
|
||||||
|
setPref("ttsVoice", voiceInput.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
piperPathInput?.addEventListener("change", () => {
|
||||||
|
setPref("piperPath", piperPathInput.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
piperModelInput?.addEventListener("change", () => {
|
||||||
|
setPref("piperModelPath", piperModelInput.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
mineruPathInput?.addEventListener("change", () => {
|
||||||
|
setPref("mineruPath", mineruPathInput.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateHint();
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
} from "../llm/providers";
|
} from "../llm/providers";
|
||||||
import { getString } from "../../utils/locale";
|
import { getString } from "../../utils/locale";
|
||||||
|
|
||||||
|
import { registerLecturePrefsScripts } from "./lecturePrefs";
|
||||||
|
|
||||||
export function registerPrefsScripts(win: Window) {
|
export function registerPrefsScripts(win: Window) {
|
||||||
const doc = win.document;
|
const doc = win.document;
|
||||||
const providerSelect = doc.getElementById(
|
const providerSelect = doc.getElementById(
|
||||||
@@ -99,4 +101,5 @@ export function registerPrefsScripts(win: Window) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
updateHint();
|
updateHint();
|
||||||
|
registerLecturePrefsScripts(win);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function registerPrefs() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureStyles(doc: Document) {
|
export function ensureChatPapersStyles(doc: Document) {
|
||||||
const id = "chatpapers-styles";
|
const id = "chatpapers-styles";
|
||||||
if (doc.getElementById(id)) return;
|
if (doc.getElementById(id)) return;
|
||||||
const link = doc.createElement("link");
|
const link = doc.createElement("link");
|
||||||
@@ -74,3 +74,7 @@ function ensureStyles(doc: Document) {
|
|||||||
link.href = `chrome://${config.addonRef}/content/chatpapers.css`;
|
link.href = `chrome://${config.addonRef}/content/chatpapers.css`;
|
||||||
doc.documentElement?.appendChild(link);
|
doc.documentElement?.appendChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureStyles(doc: Document) {
|
||||||
|
ensureChatPapersStyles(doc);
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+31
@@ -23,10 +23,13 @@ declare const Zotero_Tabs: {
|
|||||||
|
|
||||||
declare const PathUtils: {
|
declare const PathUtils: {
|
||||||
join: (...parts: string[]) => string;
|
join: (...parts: string[]) => string;
|
||||||
|
parent: (path: string) => string;
|
||||||
|
tempDir: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
declare const IOUtils: {
|
declare const IOUtils: {
|
||||||
exists: (path: string) => Promise<boolean>;
|
exists: (path: string) => Promise<boolean>;
|
||||||
|
read: (path: string) => Promise<Uint8Array>;
|
||||||
readUTF8: (path: string) => Promise<string>;
|
readUTF8: (path: string) => Promise<string>;
|
||||||
writeUTF8: (path: string, data: string) => Promise<void>;
|
writeUTF8: (path: string, data: string) => Promise<void>;
|
||||||
remove: (path: string) => Promise<void>;
|
remove: (path: string) => Promise<void>;
|
||||||
@@ -34,4 +37,32 @@ declare const IOUtils: {
|
|||||||
path: string,
|
path: string,
|
||||||
options?: { createAncestors?: boolean; ignoreExisting?: boolean },
|
options?: { createAncestors?: boolean; ignoreExisting?: boolean },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
stat: (path: string) => Promise<{ size: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
declare const Services: {
|
||||||
|
appinfo: {
|
||||||
|
OS: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
declare const Components: {
|
||||||
|
classes: Record<string, { createInstance: (iface: unknown) => unknown }>;
|
||||||
|
interfaces: { nsIProcess: unknown };
|
||||||
|
utils: {
|
||||||
|
isDeadWrapper: (obj: unknown) => boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MozNsIFile {
|
||||||
|
exists: () => boolean;
|
||||||
|
isExecutable?: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MozNsIProcess {
|
||||||
|
init: (file: MozNsIFile) => void;
|
||||||
|
run: (blocking: boolean, args: string[], count: number) => void;
|
||||||
|
runw: (blocking: boolean, args: string[], count: number) => void;
|
||||||
|
exitValue: number;
|
||||||
|
startHidden: boolean;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+39
@@ -34,9 +34,41 @@ export type FluentMessageId =
|
|||||||
| 'cite-style'
|
| 'cite-style'
|
||||||
| 'item-section-chat-head'
|
| 'item-section-chat-head'
|
||||||
| 'item-section-chat-sidenav'
|
| 'item-section-chat-sidenav'
|
||||||
|
| 'item-section-lecture-head'
|
||||||
|
| 'item-section-lecture-sidenav'
|
||||||
| 'itemmenu-chat'
|
| 'itemmenu-chat'
|
||||||
| 'itemmenu-compare'
|
| 'itemmenu-compare'
|
||||||
| 'itemmenu-label'
|
| 'itemmenu-label'
|
||||||
|
| 'lecture-attach-error'
|
||||||
|
| 'lecture-beat-list-title'
|
||||||
|
| 'lecture-beat-tts-failed'
|
||||||
|
| 'lecture-beat-tts-pending'
|
||||||
|
| 'lecture-empty-desc'
|
||||||
|
| 'lecture-empty-title'
|
||||||
|
| 'lecture-loading'
|
||||||
|
| 'lecture-mode-label'
|
||||||
|
| 'lecture-mount-error'
|
||||||
|
| 'lecture-no-pdf'
|
||||||
|
| 'lecture-platform-hint-mac'
|
||||||
|
| 'lecture-platform-hint-other'
|
||||||
|
| 'lecture-platform-hint-win'
|
||||||
|
| 'lecture-play-complete'
|
||||||
|
| 'lecture-play-next'
|
||||||
|
| 'lecture-play-pause'
|
||||||
|
| 'lecture-play-prev'
|
||||||
|
| 'lecture-play-start'
|
||||||
|
| 'lecture-reprepare'
|
||||||
|
| 'lecture-start'
|
||||||
|
| 'lecture-status-failed'
|
||||||
|
| 'lecture-status-idle'
|
||||||
|
| 'lecture-status-preparing'
|
||||||
|
| 'lecture-status-ready'
|
||||||
|
| 'lecture-status-ready-phase1'
|
||||||
|
| 'lecture-status-ready-step1'
|
||||||
|
| 'lecture-tts-provider'
|
||||||
|
| 'lecture-tts-test'
|
||||||
|
| 'lecture-tts-test-done'
|
||||||
|
| 'lecture-tts-testing'
|
||||||
| 'multi-add-none'
|
| 'multi-add-none'
|
||||||
| 'multi-add-paper'
|
| 'multi-add-paper'
|
||||||
| 'multi-close'
|
| 'multi-close'
|
||||||
@@ -65,16 +97,20 @@ export type FluentMessageId =
|
|||||||
| 'prefs-hint-openrouter'
|
| 'prefs-hint-openrouter'
|
||||||
| 'prefs-max-context'
|
| 'prefs-max-context'
|
||||||
| 'prefs-max-tokens'
|
| 'prefs-max-tokens'
|
||||||
|
| 'prefs-mineru-path'
|
||||||
| 'prefs-model'
|
| 'prefs-model'
|
||||||
| 'prefs-models-empty'
|
| 'prefs-models-empty'
|
||||||
| 'prefs-models-pick'
|
| 'prefs-models-pick'
|
||||||
| 'prefs-openrouter-referer'
|
| 'prefs-openrouter-referer'
|
||||||
| 'prefs-openrouter-title'
|
| 'prefs-openrouter-title'
|
||||||
|
| 'prefs-piper-model'
|
||||||
|
| 'prefs-piper-path'
|
||||||
| 'prefs-privacy'
|
| 'prefs-privacy'
|
||||||
| 'prefs-provider'
|
| 'prefs-provider'
|
||||||
| 'prefs-refresh-models'
|
| 'prefs-refresh-models'
|
||||||
| 'prefs-reset'
|
| 'prefs-reset'
|
||||||
| 'prefs-section-generation'
|
| 'prefs-section-generation'
|
||||||
|
| 'prefs-section-lecture'
|
||||||
| 'prefs-section-openrouter'
|
| 'prefs-section-openrouter'
|
||||||
| 'prefs-section-prompts'
|
| 'prefs-section-prompts'
|
||||||
| 'prefs-section-provider'
|
| 'prefs-section-provider'
|
||||||
@@ -84,5 +120,8 @@ export type FluentMessageId =
|
|||||||
| 'prefs-temperature'
|
| 'prefs-temperature'
|
||||||
| 'prefs-timeout'
|
| 'prefs-timeout'
|
||||||
| 'prefs-title'
|
| 'prefs-title'
|
||||||
|
| 'prefs-tts-hint-empty'
|
||||||
|
| 'prefs-tts-provider'
|
||||||
|
| 'prefs-tts-voice'
|
||||||
| 'startup-begin'
|
| 'startup-begin'
|
||||||
| 'startup-finish';
|
| 'startup-finish';
|
||||||
|
|||||||
Vendored
+7
@@ -22,6 +22,13 @@ declare namespace _ZoteroTypes {
|
|||||||
"summaryPrompt": string;
|
"summaryPrompt": string;
|
||||||
"sendMetadata": boolean;
|
"sendMetadata": boolean;
|
||||||
"verboseLog": boolean;
|
"verboseLog": boolean;
|
||||||
|
"ttsProvider": string;
|
||||||
|
"ttsApiKey": string;
|
||||||
|
"ttsVoice": string;
|
||||||
|
"lectureTeacherStyle": string;
|
||||||
|
"mineruPath": string;
|
||||||
|
"piperPath": string;
|
||||||
|
"piperModelPath": string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user