@ggboy123/dsh-paper-reader 1.4.1 → 1.5.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.
- package/README.md +34 -100
- package/dist/babeldoc-install.js +6 -2
- package/dist/formulas.js +87 -14
- package/dist/host.js +107 -3
- package/dist/mineru.js +5 -2
- package/dist/search.js +11 -3
- package/dist/translate-config.js +4 -1
- package/dist/translate-selection.js +772 -0
- package/lib/client.js +70 -0
- package/package.json +2 -1
- package/reader/index.html +1023 -42
- package/reader/vendor/KaTeX_AMS-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/reader/vendor/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/reader/vendor/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Main-Bold.woff2 +0 -0
- package/reader/vendor/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/reader/vendor/KaTeX_Main-Italic.woff2 +0 -0
- package/reader/vendor/KaTeX_Main-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/reader/vendor/KaTeX_Math-Italic.woff2 +0 -0
- package/reader/vendor/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/reader/vendor/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/reader/vendor/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Script-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Size1-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Size2-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Size3-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Size4-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/reader/vendor/katex.LICENSE +21 -0
- package/reader/vendor/katex.NOTICE.md +26 -0
- package/reader/vendor/katex.min.css +1 -0
- package/reader/vendor/katex.min.js +1 -0
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
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 阅读器 + AI 伴读,回答自带页码、点页码跳回原文。当前版本 **v1.
|
|
10
|
+
[DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 插件:**论文伴读工作台** —— 文献库 + PDF 阅读器 + AI 伴读,回答自带页码、点页码跳回原文。当前版本 **v1.5.1**。
|
|
11
11
|
|
|
12
12
|
> 💬 **QQ 交流群:1127244508** —— 安装/使用问题、功能建议、bug 反馈都欢迎来聊。
|
|
13
13
|
|
|
@@ -19,14 +19,16 @@
|
|
|
19
19
|
|
|
20
20
|
## 核心能力
|
|
21
21
|
|
|
22
|
-
- 📚
|
|
23
|
-
- 💬
|
|
24
|
-
- 🤖
|
|
25
|
-
- 🀄
|
|
26
|
-
-
|
|
27
|
-
-
|
|
22
|
+
- 📚 **三栏工作台**:专题文献库 + PDF 阅读器 + 右侧原生对话;安装后不改动官方界面,侧栏只多一行开关
|
|
23
|
+
- 💬 **带页码的伴读**:回答必带页码,点页码跳回原文并高亮;选中文字即问;支持历史对话
|
|
24
|
+
- 🤖 **教练式带读**:专用 preset 定分步阅读计划、逐节指导、出题批改,学习进度跨会话累积
|
|
25
|
+
- 🀄 **一键中文版**:原文 ↔ 纯中文原地切换,译文 PDF 一键导出(纯中文 / 中英对照),翻译引擎零预装自动安装
|
|
26
|
+
- 🈯 **划词翻译(公式保留)**(v1.5.1):选中点「译」出纯译文气泡;选区含公式时用 MinerU 识别的 LaTeX 替换文本层乱码、要求模型原样保留
|
|
27
|
+
- 🖥️ **中英对照 / 分屏**(v1.5.1):并排对照页或左右分屏同步滚动,随时切换、按文献记忆
|
|
28
|
+
- 🗂️ **文献管理**:专题/论文改名、删除进回收站可恢复
|
|
29
|
+
- ➕ **可选增强**(默认关):MinerU 解析——扫描件可读、公式可点引用可检索;嵌入端点——大白话命中术语段落
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
各功能的前提与配置细节见 [docs/features.md](docs/features.md)。
|
|
30
32
|
|
|
31
33
|
## 安装
|
|
32
34
|
|
|
@@ -39,86 +41,36 @@
|
|
|
39
41
|
|
|
40
42
|
完成。侧栏底部点「📚 论文伴读」进入文献库,上传 PDF 开始用。
|
|
41
43
|
|
|
42
|
-
完整流程演示(添加插件 → 启用 → 重启 → 打开论文直接提问):
|
|
43
|
-
|
|
44
44
|

|
|
45
45
|
|
|
46
|
-
> 已在官方桌面端 V0.2.0-rc.2(内置 dsh 0.2.0-rc.2,macOS arm64
|
|
47
|
-
>
|
|
48
|
-
|
|
49
|
-
> 想直接在客户端里逛插件市场(4000+ 插件、分类、评论、一键安装),可另装社区插件 [`dshmarket`](https://github.com/dsh-market/dsh-market),本插件已在其目录中。
|
|
46
|
+
> 已在官方桌面端 V0.2.0-rc.2(内置 dsh 0.2.0-rc.2,macOS arm64)实测通过;插件需 ≥1.2.0。
|
|
47
|
+
> 想逛插件市场可另装社区插件 [`dshmarket`](https://github.com/dsh-market/dsh-market),本插件已在其目录中。
|
|
50
48
|
|
|
51
|
-
|
|
49
|
+
**排坑**(都是实测踩过的):
|
|
52
50
|
|
|
53
|
-
-
|
|
54
|
-
-
|
|
55
|
-
- pnpm
|
|
56
|
-
- 旧版 PaperReader 桌面版(社区 DSH Desktop 预装打包)**已停止维护**,请迁移到本方式
|
|
51
|
+
- **升级后插件开关会回到停用**:重新打开「启用」并重启。症状有迷惑性——文献树还在但点论文没反应
|
|
52
|
+
- **添加务必带 `@latest`**:不带版本号对已装过的插件是 no-op,不会追新
|
|
53
|
+
- pnpm 供应链策略 `minimumReleaseAge` 拦新包时:等过窗口期,或把本包加进豁免清单(`~/.dsh/profiles/web/.npmrc` 的 `minimum-release-age-exclude[]`;桌面端在 `~/.dsh/profiles/desktop/pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude`)
|
|
57
54
|
|
|
58
55
|
### 开发者:命令行 dsh
|
|
59
56
|
|
|
60
57
|
```bash
|
|
61
|
-
dsh plugin --profile web add @ggboy123/dsh-paper-reader@latest
|
|
58
|
+
dsh plugin --profile web add @ggboy123/dsh-paper-reader@latest # 升级也是这条;不带版本号是 no-op
|
|
62
59
|
```
|
|
63
60
|
|
|
64
|
-
> npm 包名走 `@ggboy123` 作用域:裸名 `dsh-paper-reader` 已被另一个插件占用。
|
|
65
61
|
> 不要用 `github:GGboya/dsh-paper-reader` 安装 —— dist 不入库,git 安装会导致 profile 起不来。
|
|
66
62
|
|
|
67
|
-
**升级**也用 `@latest`(或显式版本号)——不带任何版本的 `add` 对已装依赖是 no-op:
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
dsh plugin --profile web add @ggboy123/dsh-paper-reader@latest # 永远装最新
|
|
71
|
-
grep '"version"' ~/.dsh/profiles/web/node_modules/@ggboy123/dsh-paper-reader/package.json # 查当前版本
|
|
72
|
-
# 重启 dsh web 生效;浏览器 Cmd+Shift+R 强刷,避免旧阅读器页面缓存
|
|
73
|
-
```
|
|
74
|
-
|
|
75
63
|
## 怎么用
|
|
76
64
|
|
|
77
|
-
1. **进入**:侧栏底部点「📚
|
|
78
|
-
2. **入库**:+ 新建专题 → ⬆ 上传 PDF
|
|
79
|
-
3. **读 +
|
|
80
|
-
4.
|
|
81
|
-
5. **管理**:行悬停出 ✎ 改名 / 🗑
|
|
82
|
-
6. **对话**:对话头部 🕐 看该论文的历史对话、+ 新开一个
|
|
83
|
-
|
|
84
|
-
## 进阶(可选,默认关闭)
|
|
85
|
-
|
|
86
|
-
### MinerU 解析:扫描件可读、公式可点引用
|
|
87
|
-
|
|
88
|
-
默认用本地 pdf.js 提取文本——**普通文本型 PDF 完全够用,什么都不用配**。两类场景需要 MinerU:
|
|
89
|
-
|
|
90
|
-
- **扫描版 PDF**(老论文、书章、扫描讲义):没有文本层,pdf.js 读不了,MinerU(OCR)能读
|
|
91
|
-
- **公式**:PDF 里公式是图形乱码,选不中也搜不到。**公式引用**——MinerU 把公式识别成 LaTeX 后,点 PDF 上的公式即引用进对话提问;`QK^T` 这类公式检索也能命中;顶栏「∑ 公式」面板按页浏览全文公式
|
|
92
|
-
|
|
93
|
-
**需要自己准备(二选一)**,在 **设置 → 论文伴读 → 「MinerU 解析后端」** 里配置:
|
|
94
|
-
|
|
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` → 设置里模式选「本地」 | 重度/隐私敏感;免费无限、论文不出本机 |
|
|
65
|
+
1. **进入**:侧栏底部点「📚 论文伴读」(再点还原)
|
|
66
|
+
2. **入库**:+ 新建专题 → ⬆ 上传 PDF
|
|
67
|
+
3. **读 + 问**:直接提问(回答自带页码),或选中文字弹框提问;点「第 N 页」跳回原文
|
|
68
|
+
4. **中文版**:顶栏「中」一键生成(首次需在弹出表单里配一次 OpenAI 兼容端点,DeepSeek/GLM/Kimi 的 key 都行)
|
|
69
|
+
5. **管理**:行悬停出 ✎ 改名 / 🗑 删除;对话头部 🕐 历史、+ 新开
|
|
99
70
|
|
|
100
|
-
|
|
71
|
+
## 文献管理(安全说明)
|
|
101
72
|
|
|
102
|
-
|
|
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)提交,任一批失败整体降级、不落盘。端点本次不可达时照常返回关键词结果并标注降级原因,不静默失败。
|
|
111
|
-
|
|
112
|
-
> **非目标**(留后续):本地 ONNX / transformers.js 嵌入模型、全库预计算、向量数据库——本轮只做 API 端点。
|
|
113
|
-
|
|
114
|
-
## 文献管理细则
|
|
115
|
-
|
|
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`)也能正常删除/改名,不会成为孤儿
|
|
73
|
+
- 删除 = **移入回收站** `data/.trash/`(带 manifest,移回即恢复);会处理该文献的全部产物:`<文献>.pdf`、`.txt`、`.pages.json`、`.transcript.json`、`.mineru.md`、`.mineru.json`、`.embeddings.json`、`.study.json` 与译文变体 `<文献>-en.pdf` / `-zh.pdf` / `-dual.pdf`(含 `.tmp-*` 残留);伴读会话历史不删。运行中会话/翻译中的文献会被前置拒绝;路径穿越一律拒绝、失败自动回滚——细则见 [docs/features.md](docs/features.md)
|
|
122
74
|
|
|
123
75
|
## 参与开发
|
|
124
76
|
|
|
@@ -129,37 +81,19 @@ dsh plugin --profile web add ./dsh-paper-reader # 从父目录执行,或用
|
|
|
129
81
|
dsh --profile web # 侧栏变为文献库树
|
|
130
82
|
```
|
|
131
83
|
|
|
132
|
-
|
|
84
|
+
改完 `pnpm run build` 重启 profile 即生效;`npm test` 跑全量测试(354 用例,CI 三平台)。架构、测试守卫与开发约定见 [AGENTS.md](AGENTS.md)。
|
|
133
85
|
|
|
134
|
-
|
|
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 全新干净机器每周一定时跑
|
|
86
|
+
## 贡献者
|
|
137
87
|
|
|
138
|
-
|
|
88
|
+
感谢每一位贡献者:
|
|
139
89
|
|
|
140
|
-
|
|
141
|
-
src/
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
library-manage.ts 纯函数:删除/重命名(回收站、精确 stem、路径穿越防护、批量清空)
|
|
148
|
-
transcribe.ts 纯函数:pdf.js 提取 + 页码偏移表 + MinerU 后端选择与缓存来源语义
|
|
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 → 阅读器热区/面板)
|
|
154
|
-
study.ts 纯函数:学习档案(计划 + 检验成绩)读写
|
|
155
|
-
translate.ts 纯函数:babeldoc 调用(中文/中英对照 PDF 生成)
|
|
156
|
-
babeldoc-install.ts 纯函数:无 Python 环境时自动安装 babeldoc(uv → 托管 Python → venv)
|
|
157
|
-
rerank.ts 纯函数:search_paper 语义重排(TypeSafe/Jev,无凭据时退回关键词排序)
|
|
158
|
-
preset.ts 纯函数:自带 agent preset 安装到 $DSH_HOME/.agent-presets/
|
|
159
|
-
presets/paper-reader/ 「论文伴读」agent preset(persona 完整提示词 + compaction)
|
|
160
|
-
reader/index.html 阅读器页面(pdf.js,独立于 React 宿主,iframe 承载)
|
|
161
|
-
lib/client.js 浏览器半边:文献库树(sidebar.workspaces 接管) + PDF 页签 + 主面板
|
|
162
|
-
```
|
|
90
|
+
<a href="https://github.com/GGboya/dsh-paper-reader/graphs/contributors">
|
|
91
|
+
<img src="https://contrib.rocks/image?repo=GGboya/dsh-paper-reader" alt="贡献者头像墙" />
|
|
92
|
+
</a>
|
|
93
|
+
|
|
94
|
+
## Star 趋势
|
|
95
|
+
|
|
96
|
+
[](https://star-history.com/#GGboya/dsh-paper-reader&Date)
|
|
163
97
|
|
|
164
98
|
## License
|
|
165
99
|
|
package/dist/babeldoc-install.js
CHANGED
|
@@ -217,13 +217,17 @@ const CN_MIRRORS = [
|
|
|
217
217
|
'https://mirrors.aliyun.com/pypi/simple/',
|
|
218
218
|
'https://mirrors.cloud.tencent.com/pypi/simple',
|
|
219
219
|
];
|
|
220
|
+
// babeldoc 依赖 bitstring>=4.3.0 无上界,而 bitstring 5.0(2026-10 发布)删除了 BitStream,
|
|
221
|
+
// babeldoc 的 pdf_creater.py 仍在 `from bitstring import BitStream` —— 裸装必装出残缺 venv。
|
|
222
|
+
// 上游修掉该 import 或锁上界后可移除此 pin。
|
|
223
|
+
const BITSTRING_PIN = 'bitstring<5';
|
|
220
224
|
async function pipInstallBabeldoc(py) {
|
|
221
225
|
await run(py, ['-m', 'ensurepip', '--upgrade'], { timeout: 5 * 60_000 });
|
|
222
226
|
let lastErr = null;
|
|
223
227
|
for (const m of CN_MIRRORS) {
|
|
224
228
|
try {
|
|
225
229
|
await run(py, [
|
|
226
|
-
'-m', 'pip', 'install', '--no-cache-dir', '--timeout', '120', '--retries', '3', '-i', m, 'babeldoc',
|
|
230
|
+
'-m', 'pip', 'install', '--no-cache-dir', '--timeout', '120', '--retries', '3', '-i', m, 'babeldoc', BITSTRING_PIN,
|
|
227
231
|
], { timeout: 30 * 60_000 });
|
|
228
232
|
return;
|
|
229
233
|
}
|
|
@@ -260,7 +264,7 @@ async function installBabeldoc(uv, venvDir, onPhase) {
|
|
|
260
264
|
await viaPipFallback(null);
|
|
261
265
|
else {
|
|
262
266
|
try {
|
|
263
|
-
await run(uv, ['pip', 'install', '--python', py, 'babeldoc'], { timeout: 20 * 60_000 });
|
|
267
|
+
await run(uv, ['pip', 'install', '--python', py, 'babeldoc', BITSTRING_PIN], { timeout: 20 * 60_000 });
|
|
264
268
|
}
|
|
265
269
|
catch (uvErr) {
|
|
266
270
|
await viaPipFallback(uvErr);
|
package/dist/formulas.js
CHANGED
|
@@ -19,6 +19,51 @@
|
|
|
19
19
|
// `\text{}`/`\textrm`/`\mbox` 一族按既有规则整组原样保留(组内空格是排版语义)。
|
|
20
20
|
import { readFile, stat } from 'node:fs/promises';
|
|
21
21
|
import { compressMathSpaces, normalizeMathBraces } from "./mineru.js";
|
|
22
|
+
/** 从 `.mineru.json` 的原始文本建 text 块索引(只收含 `$` 的块)。 */
|
|
23
|
+
export function buildTextBlockIndex(raw) {
|
|
24
|
+
if (raw === null)
|
|
25
|
+
return { source: 'none', blocks: [], reason: 'no-mineru-artifact' };
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(raw);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { source: 'none', blocks: [], reason: 'bad-artifact' };
|
|
32
|
+
}
|
|
33
|
+
if (!isRecord(parsed))
|
|
34
|
+
return { source: 'none', blocks: [], reason: 'bad-artifact' };
|
|
35
|
+
const list = parsed['contentList'];
|
|
36
|
+
if (!Array.isArray(list))
|
|
37
|
+
return { source: 'none', blocks: [], reason: 'bad-artifact' };
|
|
38
|
+
// page_idx 缺失的块沿用上一个有页码的块(与 buildFormulaIndex / transcribe 的投影口径一致)
|
|
39
|
+
const blocks = [];
|
|
40
|
+
const perPage = new Map();
|
|
41
|
+
let lastPageIdx = 0;
|
|
42
|
+
for (const b of list) {
|
|
43
|
+
if (!isRecord(b))
|
|
44
|
+
continue;
|
|
45
|
+
if (b['type'] !== 'text')
|
|
46
|
+
continue;
|
|
47
|
+
const pi = b['page_idx'];
|
|
48
|
+
if (typeof pi === 'number' && Number.isFinite(pi) && pi >= 0)
|
|
49
|
+
lastPageIdx = Math.trunc(pi);
|
|
50
|
+
const text = b['text'];
|
|
51
|
+
if (typeof text !== 'string' || !text.includes('$'))
|
|
52
|
+
continue;
|
|
53
|
+
const index = (perPage.get(lastPageIdx) ?? 0) + 1;
|
|
54
|
+
perPage.set(lastPageIdx, index);
|
|
55
|
+
blocks.push({
|
|
56
|
+
page: lastPageIdx + 1,
|
|
57
|
+
pageIdx: lastPageIdx,
|
|
58
|
+
index,
|
|
59
|
+
bbox: toBbox(b['bbox']),
|
|
60
|
+
text: compactLatex(text),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// 注意:blocks 为空**不是**错误——产物可能根本没有含 `$` 的 text 块(行内还原直接
|
|
64
|
+
// 走 unavailable)。reason 只在「读不到 contentList」时给,语义与 FormulaReason 一致。
|
|
65
|
+
return { source: 'mineru', blocks };
|
|
66
|
+
}
|
|
22
67
|
/** 供测试与调用方复用的紧凑化:既有投影两步 + 算子名统一(见下)。 */
|
|
23
68
|
export function compactLatex(rawLatex) {
|
|
24
69
|
return unifyOperatorNames(normalizeMathBraces(compressMathSpaces(rawLatex)));
|
|
@@ -240,33 +285,61 @@ export function buildFormulaIndex(raw, opts = {}) {
|
|
|
240
285
|
};
|
|
241
286
|
}
|
|
242
287
|
let cache = null;
|
|
243
|
-
|
|
244
|
-
|
|
288
|
+
let rawCache = null;
|
|
289
|
+
let textCache = null;
|
|
290
|
+
/**
|
|
291
|
+
* 读 `.mineru.json` 原始文本(缺失 → null)。**不抛异常**:权限等异常也降级成 null,
|
|
292
|
+
* 由调用方决定 reason(读取失败按 bad-artifact 记)。
|
|
293
|
+
*/
|
|
294
|
+
async function readMineruRaw(ref) {
|
|
245
295
|
let key = `${ref.mineruJsonPath}:absent`;
|
|
246
296
|
try {
|
|
247
297
|
const st = await stat(ref.mineruJsonPath);
|
|
248
298
|
key = `${ref.mineruJsonPath}:${st.mtimeMs}:${st.size}`;
|
|
249
|
-
if (
|
|
250
|
-
return
|
|
299
|
+
if (rawCache && rawCache.key === key)
|
|
300
|
+
return { key, raw: rawCache.raw };
|
|
251
301
|
const raw = await readFile(ref.mineruJsonPath, 'utf8');
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
return value;
|
|
302
|
+
rawCache = { key, raw };
|
|
303
|
+
return { key, raw };
|
|
255
304
|
}
|
|
256
305
|
catch (err) {
|
|
257
306
|
const code = err.code;
|
|
258
307
|
if (code === 'ENOENT' || code === 'EISDIR') {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
cache = { key, value };
|
|
263
|
-
return value;
|
|
308
|
+
if (!(rawCache && rawCache.key === key))
|
|
309
|
+
rawCache = { key, raw: null };
|
|
310
|
+
return { key, raw: null };
|
|
264
311
|
}
|
|
265
|
-
|
|
266
|
-
return EMPTY('bad-artifact', `读取 mineru.json 失败:${code ?? 'unknown'}`);
|
|
312
|
+
return { key, raw: null, failed: code ?? 'unknown' };
|
|
267
313
|
}
|
|
268
314
|
}
|
|
315
|
+
/** 读 `<ref.mineruJsonPath>` 并建索引。文件缺失/损坏一律返回可用的空索引,**不抛异常**。 */
|
|
316
|
+
export async function readFormulaIndex(ref) {
|
|
317
|
+
const { key, raw, failed } = await readMineruRaw(ref);
|
|
318
|
+
if (failed !== undefined)
|
|
319
|
+
return EMPTY('bad-artifact', `读取 mineru.json 失败:${failed}`);
|
|
320
|
+
if (cache && cache.key === key)
|
|
321
|
+
return cache.value;
|
|
322
|
+
const value = buildFormulaIndex(raw);
|
|
323
|
+
cache = { key, value };
|
|
324
|
+
return value;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* 读同一份产物并建 **text 块索引**(行内公式还原用,服务端内部;不经 `/api/formulas`
|
|
328
|
+
* 下发——§7 R14/R15 明确要求响应面不变)。与 `readFormulaIndex` 共用原始文本缓存。
|
|
329
|
+
*/
|
|
330
|
+
export async function readTextBlockIndex(ref) {
|
|
331
|
+
const { key, raw, failed } = await readMineruRaw(ref);
|
|
332
|
+
if (failed !== undefined)
|
|
333
|
+
return { source: 'none', blocks: [], reason: 'bad-artifact' };
|
|
334
|
+
if (textCache && textCache.key === key)
|
|
335
|
+
return textCache.value;
|
|
336
|
+
const value = buildTextBlockIndex(raw);
|
|
337
|
+
textCache = { key, value };
|
|
338
|
+
return value;
|
|
339
|
+
}
|
|
269
340
|
/** 仅供测试:清空记忆化。 */
|
|
270
341
|
export function resetFormulaCache() {
|
|
271
342
|
cache = null;
|
|
343
|
+
rawCache = null;
|
|
344
|
+
textCache = null;
|
|
272
345
|
}
|
package/dist/host.js
CHANGED
|
@@ -6,7 +6,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
6
6
|
import { basename, join, resolve } from 'node:path';
|
|
7
7
|
import { listPapers, listTopics, resolveDataDir, resolvePaper, resolveUploadTopic, validateEntryBoundary, validateEntryName, } from "./library.js";
|
|
8
8
|
import { ManageError, assertInside, clearTopic, planTopicClear, deletePaper, deleteTopic, isManageError, planPaperDelete, planTopicDelete, realDataDir, renamePaper, renameTopic, resolveTopicDir, validateName, } from "./library-manage.js";
|
|
9
|
-
import { readFormulaIndex } from "./formulas.js";
|
|
9
|
+
import { readFormulaIndex, readTextBlockIndex } from "./formulas.js";
|
|
10
|
+
import { MAX_CHUNKS, MAX_LATEX_CHARS, MAX_TEXT_CHARS, MAX_TOTAL_CHARS, SelectionTranslateError, assembleSource, formulaStatusOf, translateSelection, } from "./translate-selection.js";
|
|
10
11
|
import { MineruError, sanitizeDetail } from "./mineru.js";
|
|
11
12
|
import { readTranscript, transcribePaper, ScannedPdfError, ShortTextError } from "./transcribe.js";
|
|
12
13
|
import { pdfVariantPath, restartTranslation, startTranslation, zhStatus } from "./translate.js";
|
|
@@ -347,10 +348,17 @@ export function registerRoutes(ctx, config) {
|
|
|
347
348
|
json(res, 400, { error: 'bad asset name' });
|
|
348
349
|
return;
|
|
349
350
|
}
|
|
350
|
-
const mime = { '.mjs': 'text/javascript', '.js': 'text/javascript', '.css': 'text/css', '.map': 'application/json'
|
|
351
|
+
const mime = { '.mjs': 'text/javascript', '.js': 'text/javascript', '.css': 'text/css', '.map': 'application/json',
|
|
352
|
+
// 字体(vendored KaTeX 的 woff2 就靠这条;不给对 MIME 时某些浏览器会拒绝 @font-face)
|
|
353
|
+
'.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf' };
|
|
351
354
|
try {
|
|
352
355
|
const body = await readFile(new URL(name, readerVendorDir));
|
|
353
|
-
|
|
356
|
+
const ext = name.slice(name.lastIndexOf('.'));
|
|
357
|
+
const type = mime[ext] ?? 'application/octet-stream';
|
|
358
|
+
// 只有文本类才追加 charset:给 font/woff2 这类二进制资源加 `; charset=utf-8` 是错的
|
|
359
|
+
// (实测 font/woff2 被浏览器按未知类型处理的风险;见 test/reader-vendor-http.test.mjs)
|
|
360
|
+
const texty = type.startsWith('text/') || type === 'application/json';
|
|
361
|
+
res.writeHead(200, { 'content-type': texty ? type + '; charset=utf-8' : type, 'content-length': body.length, 'cache-control': 'no-cache' });
|
|
354
362
|
res.end(body);
|
|
355
363
|
}
|
|
356
364
|
catch {
|
|
@@ -777,6 +785,102 @@ export function registerRoutes(ctx, config) {
|
|
|
777
785
|
json(res, 200, await readFormulaIndex(ref));
|
|
778
786
|
return;
|
|
779
787
|
}
|
|
788
|
+
/* ── 划词翻译(公式保留,契约 docs/selection-translate-requirements.md §3.9)─────
|
|
789
|
+
客户端只送「按页 + 按 display 归属切好的 chunk」;行内公式的还原(选区文本 ↔ MinerU
|
|
790
|
+
text 块对齐)在服务端做,所以请求体里**没有任何矩形/bbox/pageSize**。
|
|
791
|
+
本接口只读(不写盘)⇒ 定位沿用 /api/formulas 的读路径语义,不做写路径边界。
|
|
792
|
+
API key 只存在于本进程:请求体只有 model(客户端从 /api/zh 拿到的 apiKeyHint 仅用于展示),
|
|
793
|
+
响应体与错误文案一律不带 key。 */
|
|
794
|
+
if (sub === '/api/translate-selection' && req.method === 'POST') {
|
|
795
|
+
const body = (await readBody(req));
|
|
796
|
+
const bad = (msg, code = 'bad-request') => json(res, 400, { error: msg, code });
|
|
797
|
+
if (!Array.isArray(body.chunks))
|
|
798
|
+
return bad('chunks 必须是数组');
|
|
799
|
+
if (body.chunks.length < 1 || body.chunks.length > MAX_CHUNKS) {
|
|
800
|
+
return bad(`chunks 数量必须在 1~${MAX_CHUNKS} 之间(收到 ${body.chunks.length})`, 'too-large');
|
|
801
|
+
}
|
|
802
|
+
const chunks = [];
|
|
803
|
+
let total = 0;
|
|
804
|
+
for (const raw of body.chunks) {
|
|
805
|
+
if (typeof raw !== 'object' || raw === null)
|
|
806
|
+
return bad('chunks 的每一项必须是对象');
|
|
807
|
+
const c = raw;
|
|
808
|
+
const page = c.page;
|
|
809
|
+
if (typeof page !== 'number' || !Number.isInteger(page) || page < 1)
|
|
810
|
+
return bad('chunk.page 必须是 ≥1 的整数');
|
|
811
|
+
const text = typeof c.text === 'string' ? c.text : '';
|
|
812
|
+
const latex = typeof c.latex === 'string' ? c.latex : '';
|
|
813
|
+
if (c.kind === 'formula') {
|
|
814
|
+
if (!latex)
|
|
815
|
+
return bad('formula chunk 的 latex 不能为空');
|
|
816
|
+
if (latex.length > MAX_LATEX_CHARS)
|
|
817
|
+
return bad(`latex 超过 ${MAX_LATEX_CHARS} 字符`, 'too-large');
|
|
818
|
+
total += latex.length;
|
|
819
|
+
chunks.push({ page, kind: 'formula', latex });
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
if (c.kind !== 'text')
|
|
823
|
+
return bad("chunk.kind 必须为 'text' 或 'formula'");
|
|
824
|
+
if (text.length > MAX_TEXT_CHARS)
|
|
825
|
+
return bad(`text 超过 ${MAX_TEXT_CHARS} 字符`, 'too-large');
|
|
826
|
+
total += text.length;
|
|
827
|
+
chunks.push({ page, kind: 'text', text });
|
|
828
|
+
}
|
|
829
|
+
if (total === 0)
|
|
830
|
+
return bad('选区为空');
|
|
831
|
+
if (total > MAX_TOTAL_CHARS)
|
|
832
|
+
return bad(`选区总长超过 ${MAX_TOTAL_CHARS} 字符`, 'too-large');
|
|
833
|
+
let ref;
|
|
834
|
+
try {
|
|
835
|
+
const args = {};
|
|
836
|
+
if (typeof body.path === 'string' && body.path)
|
|
837
|
+
args.path = body.path;
|
|
838
|
+
else {
|
|
839
|
+
if (typeof body.topic !== 'string' || typeof body.name !== 'string' || !body.topic || !body.name) {
|
|
840
|
+
return bad('必须提供 topic + name(或 path)');
|
|
841
|
+
}
|
|
842
|
+
args.topic = body.topic;
|
|
843
|
+
args.name = body.name;
|
|
844
|
+
}
|
|
845
|
+
ref = resolvePaper(dataDir, args);
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
throw notFound('文献不存在(划词翻译需要先能定位到该文献)');
|
|
849
|
+
}
|
|
850
|
+
const { endpoint, source } = await resolveTranslateEndpoint(config.translate);
|
|
851
|
+
if (source === 'none') {
|
|
852
|
+
json(res, 503, { error: '未配置翻译端点:在阅读器里点 ⚙ 填写(OpenAI 兼容端点即可)', code: 'no-endpoint' });
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const index = await readTextBlockIndex(ref);
|
|
856
|
+
const asm = assembleSource(chunks, index);
|
|
857
|
+
try {
|
|
858
|
+
const r = await translateSelection(endpoint, asm.source);
|
|
859
|
+
json(res, 200, {
|
|
860
|
+
translation: r.translation,
|
|
861
|
+
model: r.model,
|
|
862
|
+
cached: r.cached,
|
|
863
|
+
formulaTotal: r.formulaTotal,
|
|
864
|
+
formulaKept: r.formulaKept,
|
|
865
|
+
formulaLost: r.formulaLost,
|
|
866
|
+
formulaApplied: asm.applied,
|
|
867
|
+
formulaSkipped: asm.skipped,
|
|
868
|
+
formulaStatus: formulaStatusOf(asm),
|
|
869
|
+
hasBlockIndex: asm.hasBlockIndex,
|
|
870
|
+
ms: r.ms,
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
catch (err) {
|
|
874
|
+
if (!(err instanceof SelectionTranslateError))
|
|
875
|
+
throw err;
|
|
876
|
+
json(res, err.status, {
|
|
877
|
+
error: err.message,
|
|
878
|
+
code: err.code,
|
|
879
|
+
...(err.upstreamStatus !== null ? { upstreamStatus: err.upstreamStatus } : {}),
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
780
884
|
// 新建伴读会话(会话N+1)。最新可见会话还是空白草稿时直接复用它——
|
|
781
885
|
// 连点「新建」不再堆积空会话(毫无意义的点击什么都不存)。
|
|
782
886
|
// n 用全量扫描分配(含归档/空白),绝不与磁盘上已有目录撞号。
|
package/dist/mineru.js
CHANGED
|
@@ -64,8 +64,7 @@ function redactSecret(msg, secret) {
|
|
|
64
64
|
* 导出供 `mineru-config.ts` 的预检复用(预检也会把响应体片段拼进错误,见 F-R2-1)。
|
|
65
65
|
*/
|
|
66
66
|
export function sanitizeDetail(msg, secret = '') {
|
|
67
|
-
const out = redactSecret(msg, secret)
|
|
68
|
-
return out
|
|
67
|
+
const out = redactSecret(msg, secret)
|
|
69
68
|
// Bearer 之后只吃到「不可能属于令牌」的字符为止:不要把紧跟的 ) ] } , ; 引号 一起吃掉(验证报告 O4)
|
|
70
69
|
.replace(/Bearer\s+[^\s)\]}>"',;]+/gi, 'Bearer [redacted]')
|
|
71
70
|
.replace(/\bsk-[A-Za-z0-9_-]{4,}\b/g, '[redacted]')
|
|
@@ -73,6 +72,10 @@ export function sanitizeDetail(msg, secret = '') {
|
|
|
73
72
|
.replace(/\s+/g, ' ')
|
|
74
73
|
.trim()
|
|
75
74
|
.slice(0, 240);
|
|
75
|
+
// 截断同样不能落在代理对中间:错误详情会进 tool result / 模型上下文,孤立代理项会让
|
|
76
|
+
// 宿主的请求整轮 400(issue #9 同类)。宁可短 1 个码元。
|
|
77
|
+
const last = out.charCodeAt(out.length - 1);
|
|
78
|
+
return last >= 0xd800 && last <= 0xdbff ? out.slice(0, -1) : out;
|
|
76
79
|
}
|
|
77
80
|
function isRecord(v) {
|
|
78
81
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
package/dist/search.js
CHANGED
|
@@ -30,9 +30,17 @@ export function chunkText(text, size = 1500) {
|
|
|
30
30
|
while (rest.length > size) {
|
|
31
31
|
if (cur.length > 0)
|
|
32
32
|
flush(restStart);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
// 硬切回退到码点边界:slice 按 UTF-16 码元计数,切点若落在代理对中间(数学论文的
|
|
34
|
+
// LaTeX 斜体全在 U+1D400 区段,如 𝑋 = D835 DC4B),前后片段就各带一个孤立代理项。
|
|
35
|
+
// 片段文本经 search_paper 原样返回 → 留在会话历史 → 宿主之后每一轮模型请求都
|
|
36
|
+
// 400 INVALID_REQUEST,整条会话报废(issue #9)。宁可片段短 1 个码元,绝不切开代理对。
|
|
37
|
+
let cut = size;
|
|
38
|
+
const beforeCut = rest.charCodeAt(cut - 1);
|
|
39
|
+
if (beforeCut >= 0xd800 && beforeCut <= 0xdbff && cut > 1)
|
|
40
|
+
cut -= 1;
|
|
41
|
+
chunks.push({ index: chunks.length + 1, start: restStart, end: restStart + cut, text: rest.slice(0, cut) });
|
|
42
|
+
rest = rest.slice(cut);
|
|
43
|
+
restStart += cut;
|
|
36
44
|
}
|
|
37
45
|
if (rest !== '') {
|
|
38
46
|
if (cur.length > 0)
|
package/dist/translate-config.js
CHANGED
|
@@ -117,6 +117,9 @@ export async function testTranslateEndpoint(ep) {
|
|
|
117
117
|
// Node 的 fetch 把真实原因(ECONNREFUSED / 证书错误 / 超时)塞在 cause 里,
|
|
118
118
|
// 只报 "fetch failed" 对用户毫无帮助
|
|
119
119
|
const cause = err instanceof Error && err.cause instanceof Error ? `: ${err.cause.message}` : '';
|
|
120
|
-
|
|
120
|
+
// ⚠️ t7(t4 评审 R-04 的同类项):这条 detail 会进响应体、再上屏(设置浮层)。
|
|
121
|
+
// fetch 抛出的原因串可能带上 baseUrl(含凭据/查询参数)或回显 Authorization ——
|
|
122
|
+
// 与同函数上面两条分支一样先过 sanitizeDetail;可读原因(ECONNREFUSED 等)不受影响。
|
|
123
|
+
return { ok: false, detail: sanitizeDetail((err instanceof Error ? err.message : String(err)) + cause, apiKey) };
|
|
121
124
|
}
|
|
122
125
|
}
|