增加语音对话功能

This commit is contained in:
yhy
2026-08-29 22:13:55 +08:00
parent 1ffa069151
commit 3a2438f566
51 changed files with 6207 additions and 3 deletions
+212
View File
@@ -752,3 +752,215 @@
.chatpapers-cite-btn {
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;
}
+28
View File
@@ -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

+63
View File
@@ -116,6 +116,69 @@
</hbox>
</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>
<label><html:h2 data-l10n-id="prefs-section-prompts"></html:h2></label>
<html:label data-l10n-id="prefs-system-prompt"></html:label>
+35
View File
@@ -58,3 +58,38 @@ cite-empty = No styles available
cite-copied = Bibliography copied
cite-exported = Bibliography exported
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
+8
View File
@@ -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-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-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.
+34
View File
@@ -59,3 +59,37 @@ cite-copied = 参考文献已复制到剪贴板
cite-exported = 参考文献已导出
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 = macOSMinerU 路径可在偏好设置 → 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 = 语音合成完成,正在播放
+8
View File
@@ -27,3 +27,11 @@ prefs-hint-cloud = 云端 Provider:论文内容将发送到所选 API 端点
prefs-models-empty = 未返回任何模型。
prefs-models-pick = 从列表中输入模型 ID(最多显示 30 个):
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
View File
@@ -14,7 +14,7 @@
"id": "__addonID__",
"update_url": "__updateURL__",
"strict_min_version": "9.0",
"strict_max_version": "9.*"
"strict_max_version": "10.*"
}
}
}
+7
View File
@@ -13,3 +13,10 @@ pref("systemPrompt", "");
pref("summaryPrompt", "");
pref("sendMetadata", true);
pref("verboseLog", false);
pref("ttsProvider", "");
pref("ttsApiKey", "");
pref("ttsVoice", "");
pref("lectureTeacherStyle", "balanced");
pref("mineruPath", "");
pref("piperPath", "");
pref("piperModelPath", "");
+680
View File
@@ -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(表现层) │
│ • BanduPaneItem 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(基础设施层) │
│ • ZoteroAdapterItem / Attachment / Note
│ • PdfParserMinerU 封装) │
│ • LlmClientOpenAI-compatible / Ollama
│ • TtsClient(多提供商 + timestamps
│ • SqliteRepository │
│ • AudioStoragemp3 路径管理) │
│ • 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.20.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 或 BM25MVP 可不引入 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)
);
-- 段落 FTSRAG
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 确认)**:子进程 CLIstdin/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 + CSSFluent 风格贴近 Zotero |
| 音频 | HTML5 `<audio>` |
| 数据库 | better-sqlite3 或 Zotero 内置 SQL(待 POC |
| PDF | MinerUPython,本地 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 拆分为架构初稿 |
+944
View File
@@ -0,0 +1,944 @@
# ChatPapers 双模式集成架构设计
| 字段 | 内容 |
|------|------|
| 产品名称 | ChatPapers for Zotero |
| 文档名称 | 双模式集成架构(方案 A) |
| 文档版本 | v0.1 |
| 状态 | 草案 |
| 目标平台 | Zotero 9macOS / 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.tsP1
│ 【共享】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 PdfParserMinerU
**与文字对话 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` | 同上 | 815 beats + refs[] |
| `generateParagraphExplanation` | 同上 | 三层讲解 |
| `generateSentenceExplanation` | 同上 | 逐句讲解 |
| `answerWithRag` | `lecture/application/askQuestion.ts` | 锚定问答 |
**配置**:语音备课 temperature 0.20.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(语音专属)
- 集成:子进程 CLIstdout 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 并发控制
- 备课 LLMbeats 小批量并行 ≤ 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 | SQLitebetter-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/` |
+485
View File
@@ -0,0 +1,485 @@
# ChatPapers 双模式集成需求规格说明书
| 字段 | 内容 |
|------|------|
| 文档名称 | 双模式集成 PRD(方案 A) |
| 插件名称 | ChatPapers for Zotero |
| 新增模式 | BanDu(伴读) |
| 文档版本 | v0.1 |
| 状态 | 草案 |
| 目标平台 | Zotero 9macOS / 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 | 解析结果持久化至 SQLitekey = 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 | 生成 815 个整体讲解 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 | 维护 | M1M2:解析 + 备课 + TTS + 播放器 |
| v0.6.0 | 维护 | M3M4:问答 RAG + 报告 + dogfooding |
| v1.0.0 | 稳定 | 伴读 MVP 功能完整 |
### 9.2 伴读模式开发里程碑
| 阶段 | 时间 | 交付 |
|------|------|------|
| Phase 0 | 第 0 周 | 三项 POC + 双 pane 骨架占位 |
| M1 | 第 12 周 | MinerU 解析 + 段落树 + 备课流水线(纯文本) |
| M2 | 第 34 周 | TTS + 播放器双模式 + 高亮 |
| M3 | 第 56 周 | 锚定问答 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 双模式集成需求 |
+350
View File
@@ -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 CopilotZotero 插件) | 插件 | ◐ | ✗ | ✓(文字) | ✓ | 无音频、无老师式授课、无伴读报告闭环 |
| OwlZotero 插件) | 插件 | ✗ | ✗ | ✓(文字) | ✓ | 同上 |
| 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 词的长段拆 13 个"要点") | 第二次精读,吃透方法/实验 |
| **逐句讲解** | 按需:选中任意句子,问"解释这句" | 句 | 硬句子手术 |
**逐段与逐句并存的理由**:老师的粒度应随内容难度走——
简单章节整体带过、核心章节逐段精读、难句子逐句解剖。
**"整篇逐句自动播放"不做**(成本高、收益低),但逐句**按需解释**永远可用;
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 一句话理由
## 参考文献卡片 一句话总结 + 推荐引用句 + BibTeXBBT citation key
```
- 存为 Zotero 子笔记(markdown,挂 item 下、可搜索),可导出 .md;
- **为写论文服务**:推荐引用句 + 速览可直接进 related workBibTeX 走 Zotero 原生管道;
后续写相关论文时,Zotero 里搜论文标题即可调出全部伴读素材;
- v1.1+:理解度评估(按提问分布:"你在实验部分提问最多,建议重听该章节")。
### 3.5 与 Zotero 的深度集成(一期核心卖点)
| 集成点 | 做法 |
|---|---|
| 读 PDF | 插件取 item 的 PDF attachment 本地路径,直接读,**论文不出 Zotero** |
| 触发 | 选中带 PDF 的 item → Item pane 出现"伴读"标签页 |
| 缓存 | 本地 SQLiteLecture 对象(beats/三层讲解)+ mp3 + 问答记录,key = attachment ID + 文件哈希;二次打开秒进、离线可重听 |
| 伴读报告回写 | 报告 + 每段 Q&A + 重点标记 → `Zotero.Note` 子笔记 |
| 参考文献卡片 | 中文速览(问题/方法/结果/局限)+ 推荐引用句 + BibTeX 条目(有 Better BibTeX 时取 citation key |
| 写论文导入 | 写 related work 时:卡片摘要可直接粘贴,BibTeX 经 Zotero 原生/BBT 进 LaTeX;重点段落笔记提供引用上下文 |
| 伴读库视图 | 插件主窗口列全部论文:未备课 / 已备课 / 已听 x% / 有报告,快速进入 |
### 3.6 播放器 UIZotero 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-firstPDF 不出机器、只发段落文本给 LLM API;设置支持 Ollama 纯本地模型(质量换隐私) |
| Zotero 7 API 较新 | 官方 boilerplate、pin 版本;UI 只依赖稳定 APIpane/notes/attachments |
### 4.3 单篇成本/耗时估算(8 页英文论文)
- 解析:本地 CPU 1–3 min,免费
- 备课 LLM:全文摘要 + 12 beats + 5070 段三层讲解 ≈ 30k token ≈ $0.050.3(一次性)
- TTS:整体讲解 ≈ 3k–5k 中文字 + 逐段 ≈ 8k15k 字 ≈ ¥15(火山/Azure)或 $0.20.6ElevenLabs flash
- 问答:按需,每轮 ≈ $0.002 + 几分钱 TTS
- **二次打开 / 换模式重听:零成本**(全部本地缓存,离线可听)
---
## 5. MVP 范围(68 周,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 | 第 12 周 | 插件骨架 + PDF 解析(MinerU 本地)+ 段落树 + 备课流水线(摘要/beats/三层讲解,纯文本跑通) |
| M2 | 第 34 周 | TTS 流水线 + 播放器(双模式切换)+ 文字面板高亮 + refs 高亮 |
| M3 | 第 56 周 | 随时问答(RAG + 流式)+ 伴读报告/卡片回写 + BibTeX |
| M4 | 第 7–8 周 | 增量备课/缓存/断点续播 + 设置页 + dogfooding 10 篇 + 打磨 |
| v1.1 | +46 周 | 语音输入(ASR)、barge-in、难点句清单、软提示(默认关)、PDF 内高亮、原文朗读模式、理解度评估、伴读库完善、开源核心 |
| v2 | 后续 | **独立移动端 App**(云后端同步论文与伴读进度)、**检验模式**(老师考读者)、多论文对比听、团队版/订阅 |
---
## 7. 商业模式(分期)
- **一期**:个人工具,插件免费 + 用户自带 LLM/TTS key(零边际成本,dogfooding + 口碑);
- **v1.1**:核心开源,Pro 云服务(TTS 代付、跨设备同步、热门 arXiv 论文讲义共享缓存)$9.919.9/月;
- **长期**:学术团队版(共享伴读库、写作引用协作);移动端 App 跟随 v2。
- 成本结构:local-first 下服务器成本极低,主要成本在 TTS 代付(共享缓存可摊薄)。
---
## 8. 风险与对策
| 风险 | 等级 | 对策 |
|---|---|---|
| 整体讲解"太泛"不像老师(核心体验风险) | 中 | beat 三要素模板 + dogfooding 迭代;教师风格可配置 |
| Zotero 7 插件 API 不稳定 / 未来版本变动 | 中 | 官方 boilerplate、pin 版本、只依赖稳定 APIUI 层与 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>` 播一段 mp312 天);
4. **TTS 选型**:中文自然度 + 价格 + 是否返回 word timestamps,对比 ElevenLabs / Azure / 火山,各合成 30 秒样本试听;
5. **Prompt 设计**:写三套 prompt 初版——全文摘要 / 整体讲解 beats(含 refs 约束)/ 逐段三层讲解;用 1 篇论文人工评估"像不像老师"。
+323
View File
@@ -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 | 解析结果持久化至 SQLitekey = attachment ID + 文件哈希 | P0 | PDF 未变时跳过重复解析 |
| FR-013 | 解析进度与错误对用户可见 | P0 | 失败时给出可行动提示(如扫描版) |
| FR-014 | 文字面板展示解析原文,供用户核对 | P1 | 用户可发现 bad case |
### 4.3 备课(Lecture 生成)
| ID | 需求描述 | 优先级 | 验收标准 |
|----|----------|--------|----------|
| FR-020 | 生成全文摘要:问题 / 方法 / 结果 / 局限 | P0 | 四字段非空、中文 |
| FR-021 | 生成 815 个整体讲解 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 minCPU |
| 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 |
| MVPM1M4 | 本文 §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 前 |
+29
View File
@@ -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
```
+2
View File
@@ -1,5 +1,6 @@
import { initLocale } from "./utils/locale";
import { createZToolkit } from "./utils/ztoolkit";
import { registerLecturePane } from "./modules/lecture/ui/registerLecturePane";
import { registerChatPane, registerPrefs } from "./modules/ui/readerPane";
import { registerPrefsScripts } from "./modules/ui/prefs";
import { registerItemMenus } from "./modules/ui/itemMenu";
@@ -15,6 +16,7 @@ async function onStartup() {
initLocale();
registerPrefs();
registerChatPane();
registerLecturePane();
registerReaderSelectionHook();
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();
+97
View File
@@ -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 };
+533
View File
@@ -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);
}
},
});
}
+6 -1
View File
@@ -149,19 +149,24 @@ export async function chatStream(options: {
messages: ChatMessage[];
onDelta: (text: string) => void;
signal?: AbortSignalLike;
/** OpenAI-compatible response_format json_object */
jsonMode?: boolean;
}): Promise<string> {
const cfg = getRuntimeConfig();
assertConfig(cfg);
throwIfAborted(options.signal);
const url = joinUrl(cfg.baseUrl, "/chat/completions");
const body = {
const body: Record<string, unknown> = {
model: cfg.model,
messages: options.messages,
stream: true,
temperature: cfg.temperature,
max_tokens: cfg.maxTokens,
};
if (options.jsonMode) {
body.response_format = { type: "json_object" };
}
const doFetch = resolveFetch();
let res: Response;
+124
View File
@@ -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` / `keyvalue` 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;
}
+87
View File
@@ -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();
}
+3
View File
@@ -9,6 +9,8 @@ import {
} from "../llm/providers";
import { getString } from "../../utils/locale";
import { registerLecturePrefsScripts } from "./lecturePrefs";
export function registerPrefsScripts(win: Window) {
const doc = win.document;
const providerSelect = doc.getElementById(
@@ -99,4 +101,5 @@ export function registerPrefsScripts(win: Window) {
});
updateHint();
registerLecturePrefsScripts(win);
}
+5 -1
View File
@@ -64,7 +64,7 @@ export function registerPrefs() {
});
}
function ensureStyles(doc: Document) {
export function ensureChatPapersStyles(doc: Document) {
const id = "chatpapers-styles";
if (doc.getElementById(id)) return;
const link = doc.createElement("link");
@@ -74,3 +74,7 @@ function ensureStyles(doc: Document) {
link.href = `chrome://${config.addonRef}/content/chatpapers.css`;
doc.documentElement?.appendChild(link);
}
function ensureStyles(doc: Document) {
ensureChatPapersStyles(doc);
}
+31
View File
@@ -23,10 +23,13 @@ declare const Zotero_Tabs: {
declare const PathUtils: {
join: (...parts: string[]) => string;
parent: (path: string) => string;
tempDir: string;
};
declare const IOUtils: {
exists: (path: string) => Promise<boolean>;
read: (path: string) => Promise<Uint8Array>;
readUTF8: (path: string) => Promise<string>;
writeUTF8: (path: string, data: string) => Promise<void>;
remove: (path: string) => Promise<void>;
@@ -34,4 +37,32 @@ declare const IOUtils: {
path: string,
options?: { createAncestors?: boolean; ignoreExisting?: boolean },
) => 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;
}
+39
View File
@@ -34,9 +34,41 @@ export type FluentMessageId =
| 'cite-style'
| 'item-section-chat-head'
| 'item-section-chat-sidenav'
| 'item-section-lecture-head'
| 'item-section-lecture-sidenav'
| 'itemmenu-chat'
| 'itemmenu-compare'
| '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-paper'
| 'multi-close'
@@ -65,16 +97,20 @@ export type FluentMessageId =
| 'prefs-hint-openrouter'
| 'prefs-max-context'
| 'prefs-max-tokens'
| 'prefs-mineru-path'
| 'prefs-model'
| 'prefs-models-empty'
| 'prefs-models-pick'
| 'prefs-openrouter-referer'
| 'prefs-openrouter-title'
| 'prefs-piper-model'
| 'prefs-piper-path'
| 'prefs-privacy'
| 'prefs-provider'
| 'prefs-refresh-models'
| 'prefs-reset'
| 'prefs-section-generation'
| 'prefs-section-lecture'
| 'prefs-section-openrouter'
| 'prefs-section-prompts'
| 'prefs-section-provider'
@@ -84,5 +120,8 @@ export type FluentMessageId =
| 'prefs-temperature'
| 'prefs-timeout'
| 'prefs-title'
| 'prefs-tts-hint-empty'
| 'prefs-tts-provider'
| 'prefs-tts-voice'
| 'startup-begin'
| 'startup-finish';
+7
View File
@@ -22,6 +22,13 @@ declare namespace _ZoteroTypes {
"summaryPrompt": string;
"sendMetadata": boolean;
"verboseLog": boolean;
"ttsProvider": string;
"ttsApiKey": string;
"ttsVoice": string;
"lectureTeacherStyle": string;
"mineruPath": string;
"piperPath": string;
"piperModelPath": string;
};
}
}