@ggboy123/dsh-paper-reader 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +68 -263
  2. package/package.json +1 -1
  3. package/reader/index.html +50 -24
package/README.md CHANGED
@@ -7,228 +7,26 @@
7
7
 
8
8
  **English**: [README_EN.md](README_EN.md)
9
9
 
10
- [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 插件:论文伴读工作台 —— PDF 转录 / 检索 + 原生对话伴读 + 内置阅读器。
11
-
12
- > **👉 普通用户直接看「安装 → 普通用户:桌面端装好即用」**:装 DeepSeek 官方桌面端,添加插件即可,不需要任何开发环境。本仓库面向的是想自己装插件、或想改代码的用户。
10
+ [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 插件:**论文伴读工作台** —— 文献库 + PDF 阅读器 + AI 伴读,回答自带页码、点页码跳回原文。当前版本 **v1.4.1**。
13
11
 
14
12
  > 💬 **QQ 交流群:1127244508** —— 安装/使用问题、功能建议、bug 反馈都欢迎来聊。
15
13
 
16
- 功能迁移自本地独立产品 pdfqa(Go 实现的论文伴读工具)。原则:agent 循环 / 模型层 / 会话持久化全部交给 dsh 框架,插件只做「转录 + 检索 + 阅读器 UI」的薄壳组合。
17
-
18
- **安装后不改动官方界面** —— 侧栏底部多一行「📚 论文伴读」开关,点击才进入伴读模式(左侧变为文献库树),再点即还原官方工作区列表:
19
-
20
14
  ![演示:选中即问 → 回答带页码 → 点页码跳回 PDF 并高亮原文](docs/demo.gif)
21
15
 
22
16
  ![默认安装:官方侧栏原样,只多一行开关](https://raw.githubusercontent.com/GGboya/dsh-paper-reader/main/docs/screenshot-default.png)
23
17
 
24
18
  ![伴读模式:文献库 + PDF 阅读器 + 原生对话](https://raw.githubusercontent.com/GGboya/dsh-paper-reader/main/docs/screenshot-reading.png)
25
19
 
26
- ## 功能
27
-
28
- - 📚 **侧栏文献库树**:「论文伴读」模式开启时接管工作区侧栏,专题 → 论文两级;支持新建专题、上传 PDF、**专题/论文改名与删除**(行悬停出 ✎ 🗑;删除先出「服务端删除清单 + 确认框」、专题还要手输专题名;产物移入回收站可恢复;改名连带转录缓存 / 译文 / 学习档案一起搬家)(默认关闭,不覆盖官方界面)
29
- - 💬 **原生对话伴读**:点击论文即在右侧打开它的 dsh 原生会话(流式 / 工具卡 / 用量条全是官方 UI);默认只呈现当前对话,对话列头部的 🕐 下拉回看**历史对话**(真实标题+日期,点哪条进哪条),+ 新建对话
30
- - 🔗 **会话绑定论文**:会话 id 编码文献身份,工具调用自动定位当前论文——直接在输入框提问无需点名论文,回答必带页码
31
- - 🤖 **专用 agent preset「论文伴读」**:paper 会话自动使用专用 preset —— 像老师一样带读:制定分步阅读计划(每步带页码和思考题)→ 逐节指导、点评 → 出题检验、逐题批改 → 成绩与薄弱点记入 `<文献>.study.json` 学习档案,跨会话累积;快速问答(选中即问)则直接回答,不上课
32
- - 📖 **PDF 阅读器居中**:中间是论文(PDF.js 缩放 / Retina 高清 / 文本层选择 / 适宽模式 / **滚动 ↔ 单页翻页双模式**——‹ › 翻页、页码输入跳转、← → / PageUp / PageDown / Space / Home / End 键盘翻页),最右是该论文的原生对话 —— 视觉换位实现,列宽拖拽/折叠仍是 dsh 原生行为,关掉 PDF 页签即还原官方布局;状态栏显示 PDF 完整路径,点击复制
33
- - 💬 **选中即问**:阅读器里选中文字 → 弹出提问框 → 注入当前会话,原生对话区实时回答
34
- - 📍 **引用定位**:回答里的「第 N 页」可点击,平滑跳回 PDF 对应页并闪烁
35
- - 🀄 **中英切换 + 导出**:顶栏「中」按钮原文 ↔ 纯中文切换;无译文时一键后台生成(babeldoc),生成需配置 `translate` 端点;**无需预装 Python**——首次生成时自动下载 uv + 托管 Python + babeldoc(macOS / Linux / Windows,约几分钟),全程落在用户目录;顶栏 ⤓ 一键**下载当前版本**(原文 / 纯中文 / 中英对照 PDF)
36
- - 🔍 **PDF 转录 + 检索**:本地提取(pdf.js,纯 Node 无需 Python),页眉页脚剔除 / 连字 / 断词愈合 / 段落重排,产出页码偏移表;可选接入 TypeSafe/Jev 语义重排(设置面板填端点即启用,不配置时退回纯关键词排序);兼容 pdfqa 的 `data/` 缓存布局(旧缓存读取时自动补建页码索引)
37
- - 🧲 **可选 MinerU 解析后端**:扫描件 / 复杂版式 / 表格公式场景可切到 MinerU(本地 API 或 mineru.net v4 云端),产出仍是同一套带页码索引的缓存;默认关闭,不配置时行为与旧版本完全一致(详见下文「MinerU 解析后端」)
38
- - 🧮 **公式鼠标引用**(v1.4.0):PDF 文本层里的公式是乱码(`𝑧𝑧1 𝑥𝑥𝑡𝑡` 这类重复字形),选中它毫无意义;现在直接用鼠标**点 PDF 上的公式**,引用的是 MinerU 识别出的 **LaTeX**(如 `$$\pi(\mathbf{s}_t)$$`),经「选中即问」链路进入会话。**前提:该论文必须先用 MinerU 转录过**(见下文「公式引用与文献管理」)
39
- - 🗂️ **文献管理:删除与重命名**(v1.4.0):侧栏文献库树的专题行/文献行悬停出现 `✎`(重命名)/ `🗑`(删除)两个按钮——重命名专题/文献、删除文献/专题,均走页内 portal 弹窗(Electron 无 window.prompt/confirm)。删除是**破坏性操作**:**点一次 `🗑`** 即弹出确认框,框里逐条列出的文件清单**来自服务端**(`/api/library/paper/delete-plan`,前端不自己拼);产物会移入文献库内的回收站 `data/.trash/`(可恢复),另有服务端前置校验(详见下文,务必先读「删除的后果」)
40
-
41
- ## 公式引用与文献管理(v1.4.0)
42
-
43
- ### 公式鼠标引用
44
-
45
- 阅读器顶栏新增 **「∑ 公式」** 按钮,两条入口:
46
-
47
- 1. **点击 PDF 上的公式** → 弹出提问框,正文自动填入该公式的 LaTeX(气泡里显示「第 N 页 · 公式 k」+ LaTeX 预览),提问框里写你的问题、回车即发送;气泡里另有「复制 LaTeX」按钮(**不会**自动写剪贴板,避免静默覆盖你的剪贴板)。
48
- 2. **「∑ 公式」面板**:按页列出该论文的全部公式与 LaTeX,点条目也能引用(热区点不中时的兜底,也方便抄 LaTeX)。
49
-
50
- - **前提:这篇论文必须先用 MinerU 转录过**(设置 → 论文伴读 → MinerU 解析后端,或上传后在阅读器里点面板中的「用 MinerU 重新转录」)。公式块(`equation` 的 `bbox` + LaTeX)只存在于 MinerU 产物 `<文献>.mineru.json` 里,pdf.js 文本层给不出可用公式。
51
- - **没有产物时不会报错**:入口置灰并在面板里说明原因,提供「用 MinerU 重新转录」按钮;「选中即问」等原有能力完全不受影响。
52
- - 产物损坏 / 没有公式块时同样只是「没有公式可用」,不会影响阅读与提问。
53
- - **译文视图(中/对照)下不显示公式热区**:bbox 只对原文页面成立,切回原文即可点击。
54
- - 公式引用与划词提问**互不抢**:有选中文字时优先按文字提问;公式热区不拦截鼠标事件(不破坏拖拽划选)。
55
-
56
- ### 文献管理:删除与重命名
57
-
58
- 侧栏文献库树里,**专题行**和**文献行**右侧都有 `✎`(重命名)与 `🗑`(删除)两个按钮(悬停出现):
59
-
60
- | 操作 | 行为 |
61
- | --- | --- |
62
- | 重命名专题 / 重命名文献 | 改名(文献的全部同名产物与译文变体一起改)。**注意**:伴读会话 id 内嵌「专题/文献」名,改名后旧会话不会删除,但不再显示在该文献下面(确认框会写明) |
63
- | 删除文献 | **点一次 `🗑`** → 弹出确认框,逐条列出**服务端返回**的将删除文件与大小 → 确认后**移入回收站** |
64
- | 删除专题 | **空专题**:点 `🗑` → 按提示输入专题名确认即删。**非空专题**:点 `🗑` 进入**批量清空**中间态(逐篇列出服务端清空清单 → 手输专题名 → 一次确认) |
65
-
66
- **批量清空非空专题(一次确认)**:非空专题不再要求「30 篇点 30 次」。点 `🗑` 后的确认框里**逐篇**列出将移入回收站的文献(篇名 + 文件数 + 大小,清单来自服务端 dry-run `/api/library/topic/clear-plan`,前端不自己拼),手输专题名后**一次确认**即可:
67
-
68
- - 每篇仍走**同一个回收站**(`data/.trash/<bundle>/<文献名>/`,每篇一份 `manifest.json` + bundle 根一份总清单,都可恢复),仍按「同名产物 + 译文变体」的**精确 stem** 逐个移动,`.pdf` 最后移;
69
- - 仍受**运行中会话 / 正在翻译**的**前置拒绝**(确认框会先把会被拦下的篇名标出来;服务端在任何字节被移动之前完成全部校验);
70
- - 任何一步失败都会**回滚本次已移动的全部文件**(rename 回原位,绝不 unlink),未能回滚的文件会如实列在响应里(`rollbackFailed` + `trashDir`,可手动恢复)——不会留下「删了一半」的散落状态;
71
- - 全部移完后才**非递归**删除专题目录(绝无 `rm -rf`);专题内的隐藏条目(`.DS_Store` 等)也进回收站,不销毁。
72
-
73
- **删除的后果(重要)**:
74
-
75
- - 会被处理的文件 = 该文献在 `data/` 下的**同名产物**:`<文献>.pdf`、`.txt`、`.pages.json`、`.transcript.json`、`.mineru.md`、`.mineru.json`、`.embeddings.json`、`.study.json`(学习档案:阅读计划与检验成绩),以及译文变体 `<文献>-zh.pdf` / `-dual.pdf` / `-en.pdf` 与中断残留的 `.tmp-*`。
76
- - **可以恢复**:文件被 `rename` 到 `data/.trash/<时间戳>-<随机>/`(同文件系统,不是 unlink),里面有 `manifest.json` 记录清单。把需要的文件从回收站移回专题目录即恢复(本版**不**做自动清理,也不提供一键恢复 UI)。删除成功的响应里会带该回收站的绝对路径 `trashDir`(契约字段,方便脚本/用户直接去恢复;**错误**响应一律不含服务端绝对路径)。
77
- - **不会被删除**:伴读会话历史(保存在 dsh 会话库里;注意**学习档案文件** `<文献>.study.json` 属于上面的派生产物,会随文献移入回收站,可在回收站恢复)、共享翻译环境 `data/.venv-pdf2zh/`、翻译草稿 `data/.pdf2zh-tmp/`、其他文献与其他专题、专题目录本身。
78
- - **非规范文献名(手工放进库的)**:插件的写入路径(新建专题 / 上传 / 改名)都按命名规范校验,所以插件自己不会产生不合规的名字。但如果你手工往 `data/` 里放了以 `.` 开头的 PDF(如 `.hidden.pdf`),它会被列进文献树,并且**可以正常删除 / 改名**——删除、以及改名的**原名**只做**边界校验**(拒绝路径分隔符、`..`、NUL、保留名),命名规范只在**新建**或**改成新名字**时强制。这样任何已经存在于磁盘上的条目都不会变成「看得见、删不掉」的孤儿。
79
- - **安全边界**:所有删除/重命名只作用于**文献库目录内**(`DSH_PAPER_READER_DATA` 指定的 `data/`,默认 `~/.dsh-paper-reader/data`)。路径穿越(`..`、绝对路径、符号链接逃逸)一律拒绝;写路径**只接受**「专题名 + 文献名」,不接受任意路径参数;符号链接专题直接拒绝,符号链接文件只移动链接本身、不动其指向的目标。
80
- - **前置校验**:文献有**运行中的伴读会话**或**正在生成译文**时,删除/重命名会被拒绝(避免删掉运行中会话的工作目录、或翻译完成后把文件写回来)。
81
- - 另两种会被拒绝的情况(都是刻意的保守设计):**会话状态一时查不到**时一律拒绝(提示稍后重试,而不是「当作没有会话」冒险动手);**同一篇文献已有一个删除/重命名在进行中**时,第二个请求会被拒绝(避免两个操作交错改同一批文件)。
82
-
83
- ## MinerU 解析后端(可选)
84
-
85
- 默认用本地 pdf.js 抽文本层(纯 Node,无需 Python,文本型 PDF 毫秒级)。可选接入 **MinerU** 作为解析后端,
86
- 用于扫描件 / 复杂版式 / 表格公式场景 —— 产出仍然是同一套带页码索引的缓存(`.txt` + `.pages.json`),
87
- 检索、跳页、旧缓存行为都不变。
88
-
89
- 两种模式,在 **设置 → 论文伴读 → 「MinerU 解析后端」卡片** 里选(也可写配置文件):
90
-
91
- | 模式 | 说明 |
92
- | --- | --- |
93
- | `off`(默认) | 不启用,行为与旧版本完全一致 |
94
- | `local` | 本机 MinerU API(默认 `http://127.0.0.1:8000`),启动方式见下 |
95
- | `cloud` | mineru.net v4 云端,需要一个 token |
96
-
97
- **本地 MinerU**(官方 API 服务,本仓库用 3.4.5 实测):
98
-
99
- ```bash
100
- pip install -U "mineru[core]"
101
- mineru-api --host 127.0.0.1 --port 8000 # 或 uvicorn mineru.cli.fast_api:app
102
- curl -s http://127.0.0.1:8000/health # {"status":"healthy","version":"3.4.5",...}
103
- ```
104
-
105
- 默认 backend 取 `pipeline`(通用、不依赖 VLM 模型);有 GPU 且想更准可切 `hybrid-engine`
106
- (需要 VLM 模型已就绪,实测 2 页合成 PDF 约 5 秒)。扫描件建议把「本地解析方式」设为 `auto`,由 MinerU 自行决定 OCR。
107
-
108
- **云端 MinerU(三步,约 1 分钟)**:
109
-
110
- 1. 到 [mineru.net](https://mineru.net) 注册账号;
111
- 2. 在站内「API 管理」里创建一个 token 并复制;
112
- 3. 打开 dsh **设置 → 论文伴读 → MinerU 解析后端**:模式选「云端」,把 token 粘进「云端 token」,点「测试连接」→ 保存。
20
+ ## 核心能力
113
21
 
114
- 两类受益场景(各一句):
22
+ - 📚 **三栏工作台**:专题 → 论文文献库、居中 PDF 阅读器(缩放 / 适宽 / 滚动 ↔ 单页翻页 / 键盘翻页)、右侧原生对话
23
+ - 💬 **带页码的伴读**:回答必带页码,点页码平滑跳回 PDF 对应位置并高亮;选中文字即问;🕐 回看历史对话、+ 新开对话
24
+ - 🤖 **教练式带读**:paper 会话自动用专用 preset——定分步阅读计划、逐节指导、出题批改,学习进度跨会话累积
25
+ - 🀄 **中文版**:原文 ↔ 纯中文原地切换(babeldoc 零预装,自动安装),译文 PDF 一键导出(纯中文 / 中英对照)
26
+ - 🗂️ **文献管理**:专题/论文改名、删除(删除前列文件清单、进回收站**可恢复**、非空专题批量清空一次确认)
27
+ - ➕ **两个可选增强**(默认关,见「进阶」):MinerU 解析——扫描件可读、公式可点引用可检索;语义检索端点——大白话命中术语段落
115
28
 
116
- - **公式引用**:PDF 文本层里的公式是乱码(`𝑧𝑧1 𝑥𝑥𝑡𝑡` 这类重复字形),只有 MinerU 识别出的 LaTeX 能点着引用;
117
- - **扫描版 / 复杂版式**:这类 PDF 没有文本层,pdf.js 抽不出东西,MinerU(OCR)能读。
118
-
119
- **不配置 / 关闭时能做什么**(与实现一致,不夸大):转录走本地 pdf.js——有文本层的普通 PDF 完全够用(转录、检索、划词提问、导出译文都不受影响);**扫描件与公式引用**才需要 MinerU。配置并保存后,阅读器公式面板里才会出现「用 MinerU 重新转录」(模式为「关闭」时该按钮不渲染,避免误用 pdf.js 覆盖已有的 MinerU 产物)。
120
-
121
- 一次解析的序列是:申请批量上传地址 → PUT 上传 → 轮询结果 → 下载并解析 zip。
122
- ✅ **云端 `pipeline` 通道已端到端实测**(2026-10-11,真实 token + 一篇 **19 页真实论文**:提交 → 轮询 → 下载 zip → 投影 → 检索,**25 条断言 0 失败,约 19s**;**18/18** 个 equation 的 bbox 与 LaTeX 与本地产物**逐字相同**)。
123
- ⚠️ **仍未验证**(按实验特性对待):`vlm` 系列模型(`vlm-engine`/`vlm-http-client`)、配额耗尽 / HTTP 429 / zip 重试等异常路径——这些只有 mock 覆盖。
124
- token 只落盘到 `~/.dsh/.dsh-paper-reader/mineru.json`(0600);读取接口只回掩码,日志 / 错误 / 响应都不出现明文。
125
-
126
- **配置文件 / 环境变量**(优先级逐字段:文件 > profile YAML `config.mineru` > 环境变量 > 默认值):
127
-
128
- ```yaml
129
- - id: dsh-paper-reader
130
- config:
131
- mineru:
132
- mode: local
133
- local:
134
- baseUrl: http://127.0.0.1:8000
135
- backend: pipeline
136
- parseMethod: auto
137
- # cloud:
138
- # apiKey: '' # 一般不用写在这里,UI 里填即可
139
- ```
140
-
141
- | 环境变量 | 覆盖字段 |
142
- | --- | --- |
143
- | `DSH_MINERU_LOCAL_URL` | `local.baseUrl` |
144
- | `MINERU_API_KEY` | `cloud.apiKey` |
145
-
146
- **缓存与来源**:MinerU 解析仍然写 `.txt` + `.pages.json`(`page` 从 1 起,检索映射页码用),
147
- 另加 `.transcript.json`(来源标记:`pdfjs` / `mineru-local` / `mineru-cloud`)与 MinerU 富产物
148
- `.mineru.md` / `.mineru.json`(Markdown + content_list,供人工核对)。旧缓存没有来源标记时归类为 pdfjs,
149
- **升级后不会触发重新解析**;要换来源就对目标论文显式指定(agent 侧 `transcribe_pdf` 的 `source` 参数,
150
- HTTP 侧 `POST /api/transcribe` 的 `source`,取值 `auto|pdfjs|mineru-local|mineru-cloud`)。
151
- MinerU 解析失败时**不动**已有缓存,错误信息带 HTTP 状态与脱敏后的服务端 message。
152
-
153
- **公式可检索(v1.3.4 起)**:MinerU 产出的 LaTeX 在**每个 token 之间**都带空格(`x _ { t - 1 }`),
154
- 此前 `search_paper` 只有逐字照抄带空格写法才命中。投影成 `.txt` 时会先把数学区间(`$...$` / `$$...$$`)
155
- 内的空白压掉、再把**单 token** 下标/上标的花括号归一化(`Q_{t}` → `Q_t`、`^{2}` → `^2`),
156
- 因此 `x_{t-1}`、`\mathbb{R}`、`Q_t`、`q(x_t|x_{t-1})` 这类**正常写法**的公式查询可直接命中
157
- (真实论文实测:改造前 0 命中 → 改造后命中原页)。`\text{...}` 等空格有语义的命令整组原样保留;
158
- 多 token 参数(`x_{t-1}`、`x_{ij}`、`^{K \times K}`)保留花括号;`.mineru.md` / `.mineru.json`
159
- 仍是 MinerU 原样产出,不受影响。
160
-
161
- > **升级须知**:盘上已有的 `.txt` 缓存**不会被自动改写**(仍是旧格式,公式查询依旧搜不到)。
162
- > 要让某篇论文的公式可搜,需对该论文**显式重新转录**:agent 侧 `transcribe_pdf` 传
163
- > `source: mineru-local`(或 `force: true`),HTTP 侧 `POST /api/transcribe` 传同样的 `source`。
164
- >
165
- > **写法约定**:`.txt` 里存的是**紧凑写法**(`Q_t`)。查询侧会用**与投影侧同一套规则**先归一化再用原查询
166
- > 一并匹配,所以 `Q_t` 与 `Q_{t}` **两种写法都能命中同一页**(原查询永远保留,不会丢任何既有命中);
167
- > 多 token 的 `x_{t-1}`、`x_{ij}` 不被归一化,按原样书写即可。
168
- >
169
- > **两处 low 级待修项(已排入下一轮)**:
170
- > F-R1 `keepsInnerSpaces` 用前缀匹配把双参数 `\textcolor` 误纳入「空格有语义」保护
171
- > (`$\textcolor{red}{hello world}$` 的第二组空格被压缩);F-R2 区间界定扫 `$` 时不感知 `\text{}` 组
172
- > (`$\text{costs $5}$` 会被内层 `$` 切断,属已声明边界的子形态)。
173
-
174
- **API**(设置面板用的读写口,走 `/paper-reader` 前缀与既有鉴权):`GET|POST|DELETE /api/mineru/config`
175
- (GET 只回掩码;POST 预检通过才落盘;DELETE 回落 profile/环境变量)、`POST /api/mineru/test`
176
- 与 `GET /api/mineru/health`(连通性预检,不落盘)。
177
-
178
- **常见错误**:
179
-
180
- | 现象 | 原因 / 处理 |
181
- | --- | --- |
182
- | 保存时报「本地 MinerU 连接测试失败:HTTP 404 …」 | 地址指向了别的服务,或 MinerU 版本过老(2.x 没有 `/health`);`curl {baseUrl}/health` 自查 |
183
- | 保存时报「不是已识别的 MinerU API 服务」 | `/health` 返回里缺 `status`/`version`,不是 MinerU |
184
- | 解析报「本地 MinerU 解析失败(HTTP 409)」 | 服务端任务执行失败,看响应里的 error 字段。MinerU 3.4.5 的 409 是**解析失败**,不是「服务忙」 |
185
- | 解析报「MinerU 未返回可用内容(md_content 与投影文本均为空)」或「MinerU 转录结果过短(N 字符),疑似失败,请重试」 | 服务端没产出 md/content_list(模型未就绪等);先用 `curl -F files=@x.pdf http://127.0.0.1:8000/file_parse` 验证服务本身 |
186
- | 解析超时 | 长论文默认总上限 30 分钟(`jobTimeoutMs`),仍不够时在配置文件里调大 |
187
- | 云端报「HTTP 401:云端 token 无效或已过期」 | token 过期,或把 `Bearer` 前缀也填进了输入框(只填 token 本体) |
188
-
189
- > **云端实测范围(如实交代,别读成「全链路已验证」)**:mineru.net v4 的 **`pipeline` 通道已用真实 token 跑通**
190
- > (19 页真实论文一篇,25 条断言 0 失败、约 19s;18/18 equation 的 bbox/LaTeX 与本地产物逐字一致);
191
- > **`vlm` 系列模型、配额耗尽 / 429 / zip 重试**等异常路径**仍未验证**,只有 mock 覆盖。
192
- > 本地链路在 MinerU 3.4.5 上端到端实测通过。其余为若干 low 级记录项(云端多文件写盘非事务、云端轮询间隔不随时间递增、
193
- > zip 解析里的残留死代码等),均不影响默认路径:`mode` 默认 `off`,不配置 MinerU 时行为与接入前完全一致。
194
-
195
-
196
- ## 嵌入模型检索(可选,默认关闭)
197
-
198
- 除了关键词子串匹配,还可以给 `search_paper` 接一个 **OpenAI 兼容 `/embeddings` 端点**做语义召回:
199
- 关键词一个词都没命中的**语义相近**片段也能进候选(融合,不替代关键词)。跨语言提问、换同义词、
200
- 问「那段讲扩散过程的公式」这类场景命中率明显更高。
201
-
202
- **怎么配**(三选一,与翻译/重排端点同一套优先级:设置面板 > profile YAML > 环境变量):
203
-
204
- | 方式 | 写法 |
205
- | --- | --- |
206
- | 设置面板(推荐) | 设置 → 论文伴读 → **「嵌入模型检索(可选)」** 卡片,填 端点 URL / 模型 / API Key,保存前会**真打一发** `/embeddings` 预检 |
207
- | profile YAML | `config.embed: { baseUrl, apiKey, model }` |
208
- | 环境变量 | `DSH_EMBED_API_KEY`(只补 key;baseUrl/model 仍要另配) |
209
-
210
- 落盘位置 `$DSH_HOME/.dsh-paper-reader/embed.json`(**0600**,与 `translate.json`/`typesafe.json` 同一约定);
211
- 读取接口只回 `hasApiKey` + 掩码 `apiKeyHint`,明文 key 不回传。
212
-
213
- > ⚠️ **隐私提示**:启用后,**两类文本都会发送到你配置的那个端点**——① 论文的**分块文本(正文)**(算分块向量;
214
- > mock 抓包确认发出去的就是分块文本本身);② **检索时的查询文本**(每次检索先把查询发去算查询向量)。
215
- > 端点必须是你要信任的服务;**不配则一条请求都不发**。缓存文件与错误信息里都不会出现 API Key
216
- > (查询向量只留在进程内备忘,不落盘)。
217
-
218
- **行为与降级**:不配(或只配一半)= **默认关闭**,`search_paper` 与改造前**逐字节一致**(纯关键词排序、零新请求)。
219
- 配了之后:关键词候选 ∪ 嵌入候选 → RRF 融合 → 若配了 Jev/TypeSafe 重排则继续走既有重排 → top-k。
220
- **融合行为说明**:关键词候选与语义候选按**名次**融合(RRF,k=60)——只看名次、**不看分数大小**,所以两列候选会**交替占用**最终 top-k 名额(语义第 1 名可能排到关键词第 2 名之前);「关键词优先」只在**精确同分**时生效。嵌入是**召回**步骤、不替代关键词(关键词检索始终在跑并贡献候选),但**语义候选确实可能占用部分 top-k 名额**——这是有意为之的形态(无真实嵌入可调参,故不引入按分数插值/名额保底这类无法验证的策略)。**未配置嵌入端点时完全不受影响**:一条请求都不发,排序与改造前逐字节一致。
221
- 端点在这次检索里不可达 / 超时 / 返回错误 / 维度或数值非法时:**照常返回关键词结果**,并在结果末尾如实标注降级原因
222
- (`(嵌入检索不可用,已降级为纯关键词:…)`),不会静默失败、也不会中断检索。
223
-
224
- **缓存与失效**:`<专题>/<论文名>.embeddings.json`,键 = `{baseUrl, model, dimensions, 内容哈希}`。
225
- **换端点(baseUrl)/ 换模型 / 改 `.txt`(重新转录)/ 改分块大小 / 返回维度与缓存不一致** → 自动判定失效并重算;
226
- **命中缓存时不再发分块嵌入请求**(查询向量另有进程内备忘,同一查询重复检索零请求)。
227
- 按 `batchSize`(默认 32)分批提交;**任一批失败就整体降级且不落盘**——不产生半截缓存。
228
-
229
- 惰性:**首次检索该论文时才计算**,转录(`transcribe_pdf`)阶段完全不碰嵌入,转录速度与失败面不受影响。
230
-
231
- > **非目标(留后续)**:本地 ONNX / transformers.js 嵌入模型、全库预计算、向量数据库 —— 本轮只做 API 端点。
29
+ 安装后**不改动官方界面**:侧栏底部多一行「📚 论文伴读」开关,点击才进入伴读模式,再点即还原。
232
30
 
233
31
  ## 安装
234
32
 
@@ -245,15 +43,16 @@ MinerU 解析失败时**不动**已有缓存,错误信息带 HTTP 状态与脱
245
43
 
246
44
  ![官方桌面端安装并使用论文伴读插件](https://raw.githubusercontent.com/GGboya/dsh-paper-reader/main/docs/desktop-official-use.gif)
247
45
 
248
- > 已在官方桌面端 V0.2.0-rc.2(内置 dsh 0.2.0-rc.2,macOS arm64)实测通过:侧栏文献库、新建专题、上传 PDF、PDF 阅读器、原生对话伴读、历史/新建对话按钮全部可用。
46
+ > 已在官方桌面端 V0.2.0-rc.2(内置 dsh 0.2.0-rc.2,macOS arm64)实测通过。
249
47
  > 需要 ≥1.2.0:更早版本会被 0.2.x 运行时的 peer 兼容校验拒载(插件列表里显示异常)。
250
48
 
251
49
  > 想直接在客户端里逛插件市场(4000+ 插件、分类、评论、一键安装),可另装社区插件 [`dshmarket`](https://github.com/dsh-market/dsh-market),本插件已在其目录中。
252
50
 
253
51
  **排坑(都是实测踩过的)**:
254
52
 
255
- - **升级后插件开关会回到停用**:侧栏 → 插件 → 打开「启用」→ 重启。症状有迷惑性——文献树还在但点论文没反应(浏览器侧缓存活着、宿主侧已下线),其实是没启用
53
+ - **升级后插件开关会回到停用**:侧栏 → 插件 → 打开「启用」→ 重启。症状有迷惑性——文献树还在但点论文没反应,其实是没启用
256
54
  - **添加时务必带 `@latest`**:不带版本号对已装过的插件是 no-op(dsh 的插件管理器按首次记录的版本处理,不会追新)
55
+ - pnpm 若开了供应链策略 `minimumReleaseAge`(发布 N 小时内的新包拒装,桌面端默认开):等过窗口期,或在 `~/.dsh/profiles/web/.npmrc` 加 `minimum-release-age-exclude[]=@ggboy123/dsh-paper-reader`;桌面端的豁免清单在 `~/.dsh/profiles/desktop/pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude`
257
56
  - 旧版 PaperReader 桌面版(社区 DSH Desktop 预装打包)**已停止维护**,请迁移到本方式
258
57
 
259
58
  ### 开发者:命令行 dsh
@@ -262,74 +61,79 @@ MinerU 解析失败时**不动**已有缓存,错误信息带 HTTP 状态与脱
262
61
  dsh plugin --profile web add @ggboy123/dsh-paper-reader@latest
263
62
  ```
264
63
 
265
- > npm 包名走 `@ggboy123` 作用域:裸名 `dsh-paper-reader` 已被另一个插件占用(那是「输入文献、吐精读报告」的一次性分析工具,与本插件的「阅读器 + 会话伴读」定位不同)。
266
- > 不要用 `github:GGboya/dsh-paper-reader` 安装 —— dist 不入库,git 安装会导致 profile 起不来(构建产物只随 npm 包分发)。
64
+ > npm 包名走 `@ggboy123` 作用域:裸名 `dsh-paper-reader` 已被另一个插件占用。
65
+ > 不要用 `github:GGboya/dsh-paper-reader` 安装 —— dist 不入库,git 安装会导致 profile 起不来。
267
66
 
268
- **升级**:用 `@latest`(或显式版本号)——不带任何版本/tag 的 `add` 对已装过的依赖是 no-op(实测):
67
+ **升级**也用 `@latest`(或显式版本号)——不带任何版本的 `add` 对已装依赖是 no-op:
269
68
 
270
69
  ```bash
271
70
  dsh plugin --profile web add @ggboy123/dsh-paper-reader@latest # 永远装最新
272
- # dsh plugin --profile web add @ggboy123/dsh-paper-reader@1.4.0 # 或钉住具体版本
71
+ grep '"version"' ~/.dsh/profiles/web/node_modules/@ggboy123/dsh-paper-reader/package.json # 查当前版本
273
72
  # 重启 dsh web 生效;浏览器 Cmd+Shift+R 强刷,避免旧阅读器页面缓存
274
73
  ```
275
74
 
276
- 确认当前装的是哪个版本:
75
+ ## 怎么用
277
76
 
278
- ```bash
279
- grep '"version"' ~/.dsh/profiles/web/node_modules/@ggboy123/dsh-paper-reader/package.json
280
- ```
77
+ 1. **进入**:侧栏底部点「📚 论文伴读」——左侧变为文献库树(再点还原官方工作区列表)
78
+ 2. **入库**:+ 新建专题 → ⬆ 上传 PDF(空库直接点「上传 PDF」会自动建「默认专题」)
79
+ 3. **读 + 问**:点论文打开——中间阅读器、右侧对话。直接在输入框提问(无需点名论文,回答自带页码),或**选中论文里的文字**直接弹框提问;点回答里的「第 N 页」跳回原文
80
+ 4. **中文版**:顶栏「中」——没译文时一键生成(首次自动装翻译引擎,需在弹出的表单里配一次 OpenAI 兼容端点,DeepSeek/GLM/Kimi 的 key 都行);⤓ 下载当前版本 PDF;▣ 单页翻页 ↔ 滚动
81
+ 5. **管理**:行悬停出 ✎ 改名 / 🗑 删除。删除会先列出将处理的文件清单,确认后**移入回收站**(`data/.trash/`,可手工恢复);非空专题删除 = 批量清空清单一次确认
82
+ 6. **对话**:对话头部 🕐 看该论文的历史对话、+ 新开一个
281
83
 
282
- > pnpm 若开了供应链策略 `minimumReleaseAge`(发布 N 小时内的新包拒装,桌面端默认开):要么等过窗口期,要么在 `~/.dsh/profiles/web/.npmrc` 加一行 `minimum-release-age-exclude[]=@ggboy123/dsh-paper-reader`(只豁免本插件,不动全局策略)。桌面端的豁免清单在 `~/.dsh/profiles/desktop/pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude`。
84
+ ## 进阶(可选,默认关闭)
283
85
 
284
- ### 参与开发(本地调试)
86
+ ### MinerU 解析:扫描件可读、公式可点引用
285
87
 
286
- 欢迎改代码。流程:clone → 构建 → 以本地目录装进 profile,之后改完 `pnpm run build` 重启对应 profile 即可验证:
88
+ 默认用本地 pdf.js 提取文本——**普通文本型 PDF 完全够用,什么都不用配**。两类场景需要 MinerU:
287
89
 
288
- ![参与开发:clone → pnpm install → build → 本地安装](https://raw.githubusercontent.com/GGboya/dsh-paper-reader/main/docs/dev-workflow.gif)
90
+ - **扫描版 PDF**(老论文、书章、扫描讲义):没有文本层,pdf.js 读不了,MinerU(OCR)能读
91
+ - **公式**:PDF 里公式是图形乱码,选不中也搜不到。**公式引用**——MinerU 把公式识别成 LaTeX 后,点 PDF 上的公式即引用进对话提问;`QK^T` 这类公式检索也能命中;顶栏「∑ 公式」面板按页浏览全文公式
289
92
 
290
- ```bash
291
- git clone https://github.com/GGboya/dsh-paper-reader
292
- cd dsh-paper-reader && pnpm install && pnpm run build
293
- dsh plugin --profile web add ./dsh-paper-reader # 从父目录执行,或用绝对路径
294
- dsh --profile web # 侧栏变为文献库树
295
- ```
93
+ **需要自己准备(二选一)**,在 **设置 → 论文伴读 → 「MinerU 解析后端」** 里配置:
296
94
 
297
- 文献库目录默认 `~/.dsh-paper-reader/data`;在 profile 的 `cordis.patch.yml` 里可改:
95
+ | 方式 | 准备 | 适合 |
96
+ | --- | --- | --- |
97
+ | 云端(最简单) | 到 [mineru.net](https://mineru.net) 注册 → 站内「API 管理」创建 token → 设置里模式选「云端」、粘 token、「测试连接」→ 保存。约 1 分钟,免费额度 | 大多数人 |
98
+ | 本地自建(免注册) | `pip install -U "mineru[core]"` → `mineru-api --host 127.0.0.1 --port 8000` → 设置里模式选「本地」 | 重度/隐私敏感;免费无限、论文不出本机 |
298
99
 
299
- ```yaml
300
- - id: dsh-paper-reader
301
- config:
302
- dataDir: /path/to/pdfqa/data # 直接指向 pdfqa 文献库即可复用全部缓存
303
- # translate: # 可选:生成中文版(babeldoc)用的 OpenAI 兼容端点。
304
- # baseUrl: https://api.deepseek.com/v1 # 一般不用写在这里——阅读器里点「中」
305
- # apiKey: sk-... # 或右上角 ⚙ 可直接填,存到
306
- # model: deepseek-chat # ~/.dsh/.dsh-paper-reader/translate.json(0600)
307
- ```
100
+ 配置好后:新上传的扫描件自动走 MinerU;已入库的论文在「∑ 公式」面板里点「用 MinerU 重新转录」即可(**旧缓存不会自动重录**,想要公式可搜需对那篇显式重录一次)。
101
+
102
+ > 说明:云端 `pipeline` 通道已用真实 token 端到端实测(19 页论文,18/18 公式与本地一致);`vlm` 系列模型未验证。token 落盘 0600,界面只回掩码。常见报错排查见 [docs/reader-ux-requirements.md](docs/reader-ux-requirements.md)。
103
+
104
+ ### 嵌入语义检索
105
+
106
+ 给 `search_paper` 接一个 OpenAI 兼容 `/embeddings` 端点(设置 → 论文伴读 →「嵌入模型检索(可选)」卡片,填 URL/模型/Key,保存前会真打一发预检):关键词一个没命中的**语义相近**片段也能进候选——大白话提问(「训练时怎么调学习率」)命中术语段落,与关键词结果融合、不替代。不配(或只配一半)= 默认关闭,一条请求都不发,检索行为与原来完全一致。
107
+
108
+ > ⚠️ **隐私提示**:启用后,两类文本都会发送到你配置的那个端点——① 论文的分块文本(正文片段,算分块向量);② 检索时的查询文本(每次检索的提问,算查询向量)。端点必须是你要信任的服务;不配则一条请求都不发。
109
+
110
+ 配置三选一(优先级同翻译端点):设置面板(推荐)> profile YAML `config.embed` > 环境变量 `DSH_EMBED_API_KEY`(只补 key)。向量缓存在 `<专题>/<论文名>.embeddings.json`(只存向量:不含明文 key、不含查询文本),按端点/模型/内容哈希失效——换端点、换模型、重新转录后自动重算;分块按 `batchSize`(默认 32)提交,任一批失败整体降级、不落盘。端点本次不可达时照常返回关键词结果并标注降级原因,不静默失败。
308
111
 
309
- > 中文版端点:**优先在阅读器 UI 里填**(点「中」→ 没配会自动弹表单,或点 ⚙)——填完会先测连接再落盘,不用重启。
310
- > 上面的 `translate` 配置退为**部署方默认值**,仅当 UI 未配置时生效。babeldoc 只支持 OpenAI 协议端点,
311
- > Anthropic 协议的端点(如 `api.kimi.com/coding/`)不能直连。
312
- >
313
- > 翻译引擎**零预装**:找不到 babeldoc 时自动走 uv 链路安装——uv 独立二进制(GitHub Releases API 下载 + sha256 校验)→
314
- > uv 托管 Python 3.12 → `uv pip install babeldoc`,落在 `~/.dsh/.dsh-paper-reader/bin/` 与文献库同级 `.venv-pdf2zh/`,
315
- > 不碰系统 Python。已装有 uv / babeldoc(含 pdfqa 的 `.venv-pdf2zh`)则直接复用。macOS / Linux / Windows 均支持自动安装(Windows 走 uv 的 zip 包 + 系统自带 tar 解包)。
112
+ > **非目标**(留后续):本地 ONNX / transformers.js 嵌入模型、全库预计算、向量数据库——本轮只做 API 端点。
316
113
 
317
- > 伴读模式是**按需开启**的:默认不注册 `sidebar.workspaces`(官方工作区/会话列表原样),侧栏底部「📚 论文伴读」开关点击才接管,再点还原;模式选择记在 localStorage。退出模式时已打开的 PDF 页签和伴读会话不受影响。
114
+ ## 文献管理细则
318
115
 
319
- ### 翻译引擎冒烟测试(Windows 依赖完整性)
116
+ - 删除 = **移入回收站** `data/.trash/<时间戳>/`(带 manifest 清单),把文件移回专题目录即恢复;本版不自动清理、无一键恢复 UI
117
+ - 会被移入回收站的文件 = 该文献的全部同名产物:`<文献>.pdf`、`.txt`、`.pages.json`、`.transcript.json`、`.mineru.md`、`.mineru.json`、`.embeddings.json`、`.study.json`,以及译文变体 `<文献>-en.pdf` / `-zh.pdf` / `-dual.pdf`(含中断残留的 `.tmp-*`);伴读会话历史**不删**(存在 dsh 会话库里)
118
+ - **前置拒绝**:该文献有运行中的会话、正在生成译文、或同篇已有删除/改名在进行中时,操作会被拦(防止删掉正在用的东西)
119
+ - **安全边界**:删除/改名只作用于文献库目录内;路径穿越(`..`、绝对路径、符号链接逃逸)一律拒绝;中途失败自动回滚已移动的文件
120
+ - 改名后:转录缓存/译文/学习档案全部跟随搬家;历史伴读会话**本体保留**但不再挂在新名字下(会话 id 编码旧名);纯大小写改名在 macOS/Windows 可用
121
+ - 手工放进 `data/` 的怪名字文件(如 `.hidden.pdf`)也能正常删除/改名,不会成为孤儿
320
122
 
321
- Windows 用户最常见的翻车点是翻译引擎装不上(杀软拦 uv、网络截断、venv 残缺)。安装逻辑 `src/babeldoc-install.ts` 是纯 Node 模块(不依赖 Cordis/Electron),配了 GitHub Actions 在**全新干净机器**上跑冒烟,无需本地 Windows:
123
+ ## 参与开发
322
124
 
323
125
  ```bash
324
- pnpm build
325
- pnpm smoke:engine clean # 全新安装全链路
326
- pnpm smoke:engine managed-uv # 强制走插件托管 uv 下载(sha256 校验路径)
327
- pnpm smoke:engine pip-fallback # 强制走 pip + 国内镜像备用通道
328
- pnpm smoke:engine repair # 破坏 venv(删 pymupdf)后验证自检→自动修复
329
- pnpm smoke:engine translate # 再起本地 mock OpenAI 端点,真跑一遍 babeldoc 翻译管线
126
+ git clone https://github.com/GGboya/dsh-paper-reader
127
+ cd dsh-paper-reader && pnpm install && pnpm run build
128
+ dsh plugin --profile web add ./dsh-paper-reader # 从父目录执行,或用绝对路径
129
+ dsh --profile web # 侧栏变为文献库树
330
130
  ```
331
131
 
332
- CI(`.github/workflows/engine-smoke.yml`):`windows-latest` 跑全部四个场景 + translate-e2e,`macos-latest`/`ubuntu-latest` 跑 clean 防回归;改安装/翻译代码的 push 触发,外加每周一定时(uv/PyPI 在变,代码不动也可能突然坏)。两个测试钩子环境变量:`DSH_PR_MANAGED_UV_ONLY=1`(跳过系统 uv 查找)、`DSH_PR_FORCE_PIP_CHANNEL=1`(跳过 uv 装包通道)。
132
+ ![参与开发:clone → pnpm install → build → 本地安装](https://raw.githubusercontent.com/GGboya/dsh-paper-reader/main/docs/dev-workflow.gif)
133
+
134
+ - 改完 `pnpm run build` 重启 profile 即生效;`npm test` 跑全量测试(354 用例,CI 在 ubuntu/macos/windows 三平台跑)
135
+ - 文献库目录默认 `~/.dsh-paper-reader/data`,在 profile 的 `cordis.patch.yml` 里可改(可直指 pdfqa 的 `data/` 复用旧缓存);翻译/MinerU/嵌入端点优先在设置 UI 里填(存 `$DSH_HOME/.dsh-paper-reader/` 下,0600),YAML 里的 `config.translate` / `config.mineru` / `config.embed` 退为部署方默认值
136
+ - 翻译引擎冒烟(Windows 依赖完整性,无需本地 Windows):`pnpm smoke:engine clean|managed-uv|pip-fallback|repair|translate`,CI 全新干净机器每周一定时跑
333
137
 
334
138
  ## 架构
335
139
 
@@ -340,16 +144,17 @@ src/
340
144
  host.ts webServer 路由(/paper-reader/*),connection.requestRejection 鉴权;
341
145
  sessionController.create/prompt(agentPreset=paper-reader)+ follow SSE 桥
342
146
  library.ts 纯函数:文献库目录约定与解析(.txt/.pages.json/.transcript.json/.mineru.* 产物路径)
147
+ library-manage.ts 纯函数:删除/重命名(回收站、精确 stem、路径穿越防护、批量清空)
343
148
  transcribe.ts 纯函数:pdf.js 提取 + 页码偏移表 + MinerU 后端选择与缓存来源语义
344
- mineru.ts 纯函数:MinerU 客户端(本地 legacy /tasks 轮询 + /file_parse;mineru.net v4 云端 + 最小 ZIP 读取器)
345
- mineru-config.ts 纯函数:MinerU 配置读写($DSH_HOME 下 0600)+ 掩码 + 连通性预检
346
- search.ts 纯函数:分段 + 关键词打分 + 页码映射
149
+ mineru.ts 纯函数:MinerU 客户端(本地 /tasks 轮询;mineru.net v4 云端 + 最小 ZIP 读取器)
150
+ mineru-config.ts / embed-config.ts / translate-config.ts / typesafe-config.ts 纯函数:各端点配置读写(0600)+ 预检
151
+ search.ts 纯函数:分段 + 关键词打分 + 公式查询归一化 + 嵌入混合召回
152
+ embed.ts 纯函数:/embeddings 客户端 + 分块向量缓存
153
+ formulas.ts 纯函数:MinerU 公式索引(LaTeX + bbox → 阅读器热区/面板)
347
154
  study.ts 纯函数:学习档案(计划 + 检验成绩)读写
348
155
  translate.ts 纯函数:babeldoc 调用(中文/中英对照 PDF 生成)
349
156
  babeldoc-install.ts 纯函数:无 Python 环境时自动安装 babeldoc(uv → 托管 Python → venv)
350
- translate-config.ts 纯函数:翻译端点配置读写($DSH_HOME 下 0600)+ 保存前连接预检
351
157
  rerank.ts 纯函数:search_paper 语义重排(TypeSafe/Jev,无凭据时退回关键词排序)
352
- typesafe-config.ts 纯函数:TypeSafe 端点配置读写(与翻译配置同一套约定)
353
158
  preset.ts 纯函数:自带 agent preset 安装到 $DSH_HOME/.agent-presets/
354
159
  presets/paper-reader/ 「论文伴读」agent preset(persona 完整提示词 + compaction)
355
160
  reader/index.html 阅读器页面(pdf.js,独立于 React 宿主,iframe 承载)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggboy123/dsh-paper-reader",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "DeepSeek Harness 插件:论文伴读工作台 — PDF 转录 / 检索 / 原生对话伴读 + 内置阅读器页面。",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/reader/index.html CHANGED
@@ -24,11 +24,16 @@
24
24
  #bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; row-gap: 6px; padding: 8px 12px; background: var(--panel); border-bottom: 1px solid var(--border); flex: none; }
25
25
  #bar button { font: inherit; }
26
26
  /* flex:none —— 列被拖窄时按钮不许被压缩(否则文字竖排、按钮变窄条) */
27
- .tbtn { flex: none; padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); cursor: pointer; }
27
+ /* inline-flex + 固定高:图标/文字统一垂直居中,按钮高度不再随各家字体字形漂移 */
28
+ .tbtn { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 5px; height: 28px; padding: 0 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); cursor: pointer; }
28
29
  .tbtn:hover { background: var(--bg); }
29
30
  .tbtn.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
30
31
  .tbtn:disabled { opacity: .45; cursor: default; }
31
- #zh-toggle { font-weight: 700; }
32
+ /* 统一 SVG 图标:stroke=currentColor,active/disabled 的变色自动继承到图标 */
33
+ .tbtn svg.ic { width: 16px; height: 16px; flex: none; }
34
+ .tbtn svg.spin { transform-origin: center; animation: dpr-spin 1s linear infinite; }
35
+ @keyframes dpr-spin { to { transform: rotate(360deg); } }
36
+ @media (prefers-reduced-motion: reduce) { .tbtn svg.spin { animation-duration: 2.4s; } }
32
37
  #zoom-label { flex: none; min-width: 44px; text-align: center; color: var(--text-2); }
33
38
  #bar .spacer { flex: 1; }
34
39
  /* min-width:0 —— flex 项默认 min-width:auto 会拒绝收缩,空间不够时转而去挤按钮;
@@ -52,7 +57,9 @@
52
57
 
53
58
  /* ---------- 翻页控件 ---------- */
54
59
  #pager { display: flex; align-items: center; gap: 4px; flex: none; }
55
- #page-input { width: 3.5em; padding: 3px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; font-size: 12.5px; text-align: right; }
60
+ /* 数字居中:text-align:right 会让单页码贴右框边、左侧空一块;
61
+ 上 padding 多 1px —— 数字无下伸部,光字心本就偏高,对称内边距看起来反而偏上 */
62
+ #page-input { width: 3.5em; padding: 4px 6px 2px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; font-size: 12.5px; line-height: 20px; text-align: center; }
56
63
  #page-total { flex: none; color: var(--text-2); font-size: 12px; white-space: nowrap; }
57
64
  /* 插件版本号(host 注入;未被替换时隐藏——比如旧 host 配新页面的错位场景) */
58
65
  #ver { flex: none; font-size: 10.5px; color: var(--text-2); opacity: .65; user-select: none; }
@@ -143,23 +150,23 @@
143
150
  <div id="bar">
144
151
  <span id="status">论文伴读</span>
145
152
  <button id="pdf-path" style="display:none" title="点击复制文件路径"></button>
146
- <button id="zh-toggle" class="tbtn" title="切换中文译文(没有译文时可生成)" style="display:none">中</button>
147
- <button id="zh-regen" class="tbtn" title="重新翻译:删除现有译文,用当前端点重跑(换模型后用)" style="display:none">↻</button>
148
- <button id="dl-btn" class="tbtn" title="下载 PDF" style="display:none">⤓</button>
153
+ <button id="zh-toggle" class="tbtn" title="切换中文译文(没有译文时可生成)" style="display:none" data-icon="translate"></button>
154
+ <button id="zh-regen" class="tbtn" title="重新翻译:删除现有译文,用当前端点重跑(换模型后用)" style="display:none" data-icon="regen"></button>
155
+ <button id="dl-btn" class="tbtn" title="下载 PDF" style="display:none" data-icon="download"></button>
149
156
  <span class="spacer"></span>
150
- <button id="mode-toggle" class="tbtn" title="切换 滚动/翻页模式" style="display:none">▣</button>
157
+ <button id="mode-toggle" class="tbtn" title="切换 滚动/翻页模式" style="display:none" data-icon="paged"></button>
151
158
  <span id="pager" style="display:none">
152
- <button id="page-prev" class="tbtn" title="上一页">‹</button>
159
+ <button id="page-prev" class="tbtn" title="上一页" data-icon="prev"></button>
153
160
  <input id="page-input" type="text" inputmode="numeric" title="页码,回车跳转">
154
161
  <span id="page-total">/ ?</span>
155
- <button id="page-next" class="tbtn" title="下一页">›</button>
162
+ <button id="page-next" class="tbtn" title="下一页" data-icon="next"></button>
156
163
  </span>
157
- <button id="cfg-open" class="tbtn" title="翻译端点设置">⚙</button>
158
- <button id="zoom-out" class="tbtn">−</button>
164
+ <button id="cfg-open" class="tbtn" title="翻译端点设置" data-icon="settings"></button>
165
+ <button id="zoom-out" class="tbtn" title="缩小" data-icon="minus"></button>
159
166
  <span id="zoom-label">100%</span>
160
- <button id="zoom-in" class="tbtn">+</button>
167
+ <button id="zoom-in" class="tbtn" title="放大" data-icon="plus"></button>
161
168
  <button id="zoom-fit" class="tbtn">适宽</button>
162
- <button id="eq-toggle" class="tbtn" title="公式引用(MinerU LaTeX)">∑ 公式</button>
169
+ <button id="eq-toggle" class="tbtn" title="公式引用(MinerU LaTeX)" data-icon="sigma">公式</button>
163
170
  <span id="ver" title="插件版本">__DPR_VERSION__</span>
164
171
  </div>
165
172
 
@@ -211,6 +218,25 @@
211
218
  'use strict';
212
219
  /* ================= 基础 ================= */
213
220
  const $ = (id) => document.getElementById(id);
221
+ /* 顶栏图标:统一的 24×24 描边图标集(stroke=currentColor,active/disabled 变色自动跟随按钮)。
222
+ 曾经混用 Unicode 字符(↻/⤓/▣/⚙/+…)——各自渲染自不同字体,粗细、大小、基线互不一致。 */
223
+ const ICONS = {
224
+ translate: '<path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/>',
225
+ regen: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
226
+ download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
227
+ paged: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
228
+ scroll: '<path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
229
+ prev: '<path d="m15 18-6-6 6-6"/>',
230
+ next: '<path d="m9 18 6-6-6-6"/>',
231
+ settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
232
+ minus: '<path d="M5 12h14"/>',
233
+ plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
234
+ sigma: '<path d="M18 7V5H6l6 7-6 7h12v-2"/>',
235
+ spin: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>',
236
+ };
237
+ const iconHtml = (name) => `<svg class="ic${name === 'spin' ? ' spin' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`;
238
+ // 静态按钮按 data-icon 注入一次;动态按钮(翻译/重翻/模式切换)在状态变化时用同一函数换图标
239
+ document.querySelectorAll('#bar [data-icon]').forEach((b) => b.insertAdjacentHTML('afterbegin', iconHtml(b.dataset.icon)));
214
240
  const api = (p, opts) => fetch('/paper-reader/api' + p, opts).then(async (r) => {
215
241
  if (!r.ok) {
216
242
  // 服务端错误体有两种:{error}(参数/权限)与 {reason}(zh/generate 的未启动原因,带完整说明)
@@ -313,7 +339,7 @@ async function refreshZhState() {
313
339
  const btn = $('zh-toggle');
314
340
  btn.style.display = '';
315
341
  btn.disabled = zhState.busy;
316
- btn.textContent = zhState.busy ? '译' : '中';
342
+ btn.innerHTML = iconHtml(zhState.busy ? 'spin' : 'translate'); // busy:旋转弧图标(原「译」字)
317
343
  btn.title = zhState.busy ? (zhState.phase || `翻译进行中${zhState.model ? `(${zhState.model})` : ''}…`)
318
344
  : (zhState.zh || zhState.dual) ? '切换中文译文'
319
345
  : '还没有中文版,点击生成(babeldoc,需要几分钟)';
@@ -326,7 +352,7 @@ async function refreshZhState() {
326
352
  delete statusEl.dataset.phaseShown;
327
353
  if (statusMeta) statusEl.textContent = statusMeta;
328
354
  }
329
- // ↻ 只在「已有译文且空闲」时出现;状态变化时顺手拆掉两击确认的待确认态
355
+ // 重翻按钮只在「已有译文且空闲」时出现;状态变化时顺手拆掉两击确认的待确认态
330
356
  const regen = $('zh-regen');
331
357
  const canRegen = (zhState.zh || zhState.dual) && !zhState.busy;
332
358
  regen.style.display = canRegen ? '' : 'none';
@@ -366,7 +392,7 @@ async function startGenerate(force) {
366
392
  if (e.code === 'no-endpoint') { cfgRetryForce = force; openCfgPop(true); return; }
367
393
  toast(e.message, 8000); return;
368
394
  }
369
- $('zh-toggle').disabled = true; $('zh-toggle').textContent = '译';
395
+ $('zh-toggle').disabled = true; $('zh-toggle').innerHTML = iconHtml('spin');
370
396
  $('zh-regen').style.display = 'none';
371
397
  const timer = setInterval(async () => {
372
398
  await refreshZhState();
@@ -383,14 +409,14 @@ async function startGenerate(force) {
383
409
  }, 5000);
384
410
  }
385
411
 
386
- /* ================= 重新翻译(↻,两击确认) ================= */
412
+ /* ================= 重新翻译(重翻按钮,两击确认) ================= */
387
413
  // 不用原生 confirm:阅读器跑在 dsh 桌面壳的 iframe 里,原生对话框行为不可靠。
388
414
  // 第一击把按钮变成「重翻?」待确认态(3 秒有效),第二击才真删真跑。
389
415
  let regenArmTimer = null;
390
416
  function disarmRegen() {
391
417
  clearTimeout(regenArmTimer); regenArmTimer = null;
392
418
  const btn = $('zh-regen');
393
- btn.textContent = '↻'; btn.classList.remove('active');
419
+ btn.innerHTML = iconHtml('regen'); btn.classList.remove('active');
394
420
  }
395
421
  $('zh-regen').onclick = async () => {
396
422
  if (!current || zhState.busy) return;
@@ -435,9 +461,9 @@ const providerOf = (baseUrl) => PROVIDERS.find((p) => p.id !== 'custom' && normU
435
461
  };
436
462
  })();
437
463
 
438
- // 保存成功后是否自动接着生成:点「中」/「↻」发现没配 → 弹本表单 → 填完直接开跑
464
+ // 保存成功后是否自动接着生成:点「中译」/「重翻」发现没配 → 弹本表单 → 填完直接开跑
439
465
  let cfgRetryGenerate = false;
440
- let cfgRetryForce = false; // 待重试的是不是「重翻」(↻ 进来时旧译文还在,zh-toggle 点击语义不对,必须带 force)
466
+ let cfgRetryForce = false; // 待重试的是不是「重翻」(重翻进来时旧译文还在,zh-toggle 点击语义不对,必须带 force)
441
467
 
442
468
  async function openCfgPop(retryGenerate) {
443
469
  cfgRetryGenerate = Boolean(retryGenerate);
@@ -497,7 +523,7 @@ $('cfg-save').onclick = async () => {
497
523
  }
498
524
  closeCfgPop();
499
525
  await refreshZhState();
500
- if (retry) startGenerate(force); // 沿用触发来源的语义:「中」=生成,「↻」=重翻
526
+ if (retry) startGenerate(force); // 沿用触发来源的语义:「中译」=生成,「重翻」=重翻
501
527
  else toast('翻译端点已保存');
502
528
  };
503
529
 
@@ -749,10 +775,10 @@ $('page-input').addEventListener('blur', () => {
749
775
  });
750
776
 
751
777
  /** 模式切换:paged→scroll 先全量渲染再跳回当前页;scroll→paged 用观察器最近回写的 curPage。
752
- 图标显示「当前模式」:▣ 单页翻页(active)/ ≣ 连续滚动。 */
778
+ 图标显示「当前模式」:书本 = 单页翻页(active)/ 卷轴 = 连续滚动。 */
753
779
  function updateModeToggle() {
754
780
  const btn = $('mode-toggle');
755
- btn.textContent = pageMode === 'paged' ? '▣' : '≣';
781
+ btn.innerHTML = iconHtml(pageMode === 'paged' ? 'paged' : 'scroll');
756
782
  btn.classList.toggle('active', pageMode === 'paged');
757
783
  btn.title = pageMode === 'paged' ? '当前:单页翻页,点击切回连续滚动' : '当前:连续滚动,点击切到单页翻页';
758
784
  updatePager();
@@ -1123,7 +1149,7 @@ async function transcribeWithMineru(btn) {
1123
1149
  toast('MinerU 转录完成');
1124
1150
  await loadFormulas();
1125
1151
  // 新索引到手:按**当前模式**重渲一次把热区铺上去(翻页模式下必须仍是 renderPaged,
1126
- // 否则会把全量页渲出来、摘掉 #pdf-pages 的 paged 类,与顶栏 ▣ 的 active 状态脱节)
1152
+ // 否则会把全量页渲出来、摘掉 #pdf-pages 的 paged 类,与顶栏模式切换按钮的 active 状态脱节)
1127
1153
  if (pdfDoc) await rerender();
1128
1154
  } catch (e) {
1129
1155
  toast('MinerU 转录失败:' + e.message, 6000);