@ggboy123/dsh-paper-reader 1.3.5 → 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.
- package/README.md +70 -70
- package/dist/embed-config.js +116 -0
- package/dist/embed.js +349 -0
- package/dist/formulas.js +272 -0
- package/dist/host.js +681 -145
- package/dist/library-manage.js +752 -0
- package/dist/library.js +88 -67
- package/dist/mineru-config.js +343 -0
- package/dist/mineru.js +1168 -0
- package/dist/search.js +52 -0
- package/dist/tools.js +150 -21
- package/dist/transcribe.js +316 -39
- package/dist/translate-config.js +8 -2
- package/lib/client.js +479 -60
- package/package.json +2 -2
- package/reader/index.html +465 -46
package/README.md
CHANGED
|
@@ -7,35 +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)
|
|
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
|

|
|
21
15
|
|
|
22
16
|

|
|
23
17
|
|
|
24
18
|

|
|
25
19
|
|
|
26
|
-
##
|
|
20
|
+
## 核心能力
|
|
27
21
|
|
|
28
|
-
- 📚
|
|
29
|
-
- 💬
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
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/` 缓存布局(旧缓存读取时自动补建页码索引)
|
|
22
|
+
- 📚 **三栏工作台**:专题 → 论文文献库、居中 PDF 阅读器(缩放 / 适宽 / 滚动 ↔ 单页翻页 / 键盘翻页)、右侧原生对话
|
|
23
|
+
- 💬 **带页码的伴读**:回答必带页码,点页码平滑跳回 PDF 对应位置并高亮;选中文字即问;🕐 回看历史对话、+ 新开对话
|
|
24
|
+
- 🤖 **教练式带读**:paper 会话自动用专用 preset——定分步阅读计划、逐节指导、出题批改,学习进度跨会话累积
|
|
25
|
+
- 🀄 **中文版**:原文 ↔ 纯中文原地切换(babeldoc 零预装,自动安装),译文 PDF 一键导出(纯中文 / 中英对照)
|
|
26
|
+
- 🗂️ **文献管理**:专题/论文改名、删除(删除前列文件清单、进回收站**可恢复**、非空专题批量清空一次确认)
|
|
27
|
+
- ➕ **两个可选增强**(默认关,见「进阶」):MinerU 解析——扫描件可读、公式可点引用可检索;语义检索端点——大白话命中术语段落
|
|
37
28
|
|
|
38
|
-
|
|
29
|
+
安装后**不改动官方界面**:侧栏底部多一行「📚 论文伴读」开关,点击才进入伴读模式,再点即还原。
|
|
39
30
|
|
|
40
31
|
## 安装
|
|
41
32
|
|
|
@@ -52,15 +43,16 @@
|
|
|
52
43
|
|
|
53
44
|

|
|
54
45
|
|
|
55
|
-
> 已在官方桌面端 V0.2.0-rc.2(内置 dsh 0.2.0-rc.2,macOS arm64
|
|
46
|
+
> 已在官方桌面端 V0.2.0-rc.2(内置 dsh 0.2.0-rc.2,macOS arm64)实测通过。
|
|
56
47
|
> 需要 ≥1.2.0:更早版本会被 0.2.x 运行时的 peer 兼容校验拒载(插件列表里显示异常)。
|
|
57
48
|
|
|
58
49
|
> 想直接在客户端里逛插件市场(4000+ 插件、分类、评论、一键安装),可另装社区插件 [`dshmarket`](https://github.com/dsh-market/dsh-market),本插件已在其目录中。
|
|
59
50
|
|
|
60
51
|
**排坑(都是实测踩过的)**:
|
|
61
52
|
|
|
62
|
-
- **升级后插件开关会回到停用**:侧栏 → 插件 → 打开「启用」→
|
|
53
|
+
- **升级后插件开关会回到停用**:侧栏 → 插件 → 打开「启用」→ 重启。症状有迷惑性——文献树还在但点论文没反应,其实是没启用
|
|
63
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`
|
|
64
56
|
- 旧版 PaperReader 桌面版(社区 DSH Desktop 预装打包)**已停止维护**,请迁移到本方式
|
|
65
57
|
|
|
66
58
|
### 开发者:命令行 dsh
|
|
@@ -69,74 +61,79 @@
|
|
|
69
61
|
dsh plugin --profile web add @ggboy123/dsh-paper-reader@latest
|
|
70
62
|
```
|
|
71
63
|
|
|
72
|
-
> npm 包名走 `@ggboy123` 作用域:裸名 `dsh-paper-reader`
|
|
73
|
-
> 不要用 `github:GGboya/dsh-paper-reader` 安装 —— dist 不入库,git 安装会导致 profile
|
|
64
|
+
> npm 包名走 `@ggboy123` 作用域:裸名 `dsh-paper-reader` 已被另一个插件占用。
|
|
65
|
+
> 不要用 `github:GGboya/dsh-paper-reader` 安装 —— dist 不入库,git 安装会导致 profile 起不来。
|
|
74
66
|
|
|
75
|
-
|
|
67
|
+
**升级**也用 `@latest`(或显式版本号)——不带任何版本的 `add` 对已装依赖是 no-op:
|
|
76
68
|
|
|
77
69
|
```bash
|
|
78
70
|
dsh plugin --profile web add @ggboy123/dsh-paper-reader@latest # 永远装最新
|
|
79
|
-
|
|
71
|
+
grep '"version"' ~/.dsh/profiles/web/node_modules/@ggboy123/dsh-paper-reader/package.json # 查当前版本
|
|
80
72
|
# 重启 dsh web 生效;浏览器 Cmd+Shift+R 强刷,避免旧阅读器页面缓存
|
|
81
73
|
```
|
|
82
74
|
|
|
83
|
-
|
|
75
|
+
## 怎么用
|
|
84
76
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
77
|
+
1. **进入**:侧栏底部点「📚 论文伴读」——左侧变为文献库树(再点还原官方工作区列表)
|
|
78
|
+
2. **入库**:+ 新建专题 → ⬆ 上传 PDF(空库直接点「上传 PDF」会自动建「默认专题」)
|
|
79
|
+
3. **读 + 问**:点论文打开——中间阅读器、右侧对话。直接在输入框提问(无需点名论文,回答自带页码),或**选中论文里的文字**直接弹框提问;点回答里的「第 N 页」跳回原文
|
|
80
|
+
4. **中文版**:顶栏「中」——没译文时一键生成(首次自动装翻译引擎,需在弹出的表单里配一次 OpenAI 兼容端点,DeepSeek/GLM/Kimi 的 key 都行);⤓ 下载当前版本 PDF;▣ 单页翻页 ↔ 滚动
|
|
81
|
+
5. **管理**:行悬停出 ✎ 改名 / 🗑 删除。删除会先列出将处理的文件清单,确认后**移入回收站**(`data/.trash/`,可手工恢复);非空专题删除 = 批量清空清单一次确认
|
|
82
|
+
6. **对话**:对话头部 🕐 看该论文的历史对话、+ 新开一个
|
|
88
83
|
|
|
89
|
-
|
|
84
|
+
## 进阶(可选,默认关闭)
|
|
90
85
|
|
|
91
|
-
###
|
|
86
|
+
### MinerU 解析:扫描件可读、公式可点引用
|
|
92
87
|
|
|
93
|
-
|
|
88
|
+
默认用本地 pdf.js 提取文本——**普通文本型 PDF 完全够用,什么都不用配**。两类场景需要 MinerU:
|
|
94
89
|
|
|
95
|
-
|
|
90
|
+
- **扫描版 PDF**(老论文、书章、扫描讲义):没有文本层,pdf.js 读不了,MinerU(OCR)能读
|
|
91
|
+
- **公式**:PDF 里公式是图形乱码,选不中也搜不到。**公式引用**——MinerU 把公式识别成 LaTeX 后,点 PDF 上的公式即引用进对话提问;`QK^T` 这类公式检索也能命中;顶栏「∑ 公式」面板按页浏览全文公式
|
|
96
92
|
|
|
97
|
-
|
|
98
|
-
git clone https://github.com/GGboya/dsh-paper-reader
|
|
99
|
-
cd dsh-paper-reader && pnpm install && pnpm run build
|
|
100
|
-
dsh plugin --profile web add ./dsh-paper-reader # 从父目录执行,或用绝对路径
|
|
101
|
-
dsh --profile web # 侧栏变为文献库树
|
|
102
|
-
```
|
|
93
|
+
**需要自己准备(二选一)**,在 **设置 → 论文伴读 → 「MinerU 解析后端」** 里配置:
|
|
103
94
|
|
|
104
|
-
|
|
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` → 设置里模式选「本地」 | 重度/隐私敏感;免费无限、论文不出本机 |
|
|
105
99
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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)提交,任一批失败整体降级、不落盘。端点本次不可达时照常返回关键词结果并标注降级原因,不静默失败。
|
|
115
111
|
|
|
116
|
-
>
|
|
117
|
-
> 上面的 `translate` 配置退为**部署方默认值**,仅当 UI 未配置时生效。babeldoc 只支持 OpenAI 协议端点,
|
|
118
|
-
> Anthropic 协议的端点(如 `api.kimi.com/coding/`)不能直连。
|
|
119
|
-
>
|
|
120
|
-
> 翻译引擎**零预装**:找不到 babeldoc 时自动走 uv 链路安装——uv 独立二进制(GitHub Releases API 下载 + sha256 校验)→
|
|
121
|
-
> uv 托管 Python 3.12 → `uv pip install babeldoc`,落在 `~/.dsh/.dsh-paper-reader/bin/` 与文献库同级 `.venv-pdf2zh/`,
|
|
122
|
-
> 不碰系统 Python。已装有 uv / babeldoc(含 pdfqa 的 `.venv-pdf2zh`)则直接复用。macOS / Linux / Windows 均支持自动安装(Windows 走 uv 的 zip 包 + 系统自带 tar 解包)。
|
|
112
|
+
> **非目标**(留后续):本地 ONNX / transformers.js 嵌入模型、全库预计算、向量数据库——本轮只做 API 端点。
|
|
123
113
|
|
|
124
|
-
|
|
114
|
+
## 文献管理细则
|
|
125
115
|
|
|
126
|
-
|
|
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`)也能正常删除/改名,不会成为孤儿
|
|
127
122
|
|
|
128
|
-
|
|
123
|
+
## 参与开发
|
|
129
124
|
|
|
130
125
|
```bash
|
|
131
|
-
|
|
132
|
-
pnpm
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
pnpm smoke:engine repair # 破坏 venv(删 pymupdf)后验证自检→自动修复
|
|
136
|
-
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 # 侧栏变为文献库树
|
|
137
130
|
```
|
|
138
131
|
|
|
139
|
-
|
|
132
|
+

|
|
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 全新干净机器每周一定时跑
|
|
140
137
|
|
|
141
138
|
## 架构
|
|
142
139
|
|
|
@@ -146,15 +143,18 @@ src/
|
|
|
146
143
|
tools.ts 5 个 agent 工具:list_papers / transcribe_pdf / search_paper / study_progress / study_update
|
|
147
144
|
host.ts webServer 路由(/paper-reader/*),connection.requestRejection 鉴权;
|
|
148
145
|
sessionController.create/prompt(agentPreset=paper-reader)+ follow SSE 桥
|
|
149
|
-
library.ts
|
|
150
|
-
|
|
151
|
-
|
|
146
|
+
library.ts 纯函数:文献库目录约定与解析(.txt/.pages.json/.transcript.json/.mineru.* 产物路径)
|
|
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 → 阅读器热区/面板)
|
|
152
154
|
study.ts 纯函数:学习档案(计划 + 检验成绩)读写
|
|
153
155
|
translate.ts 纯函数:babeldoc 调用(中文/中英对照 PDF 生成)
|
|
154
156
|
babeldoc-install.ts 纯函数:无 Python 环境时自动安装 babeldoc(uv → 托管 Python → venv)
|
|
155
|
-
translate-config.ts 纯函数:翻译端点配置读写($DSH_HOME 下 0600)+ 保存前连接预检
|
|
156
157
|
rerank.ts 纯函数:search_paper 语义重排(TypeSafe/Jev,无凭据时退回关键词排序)
|
|
157
|
-
typesafe-config.ts 纯函数:TypeSafe 端点配置读写(与翻译配置同一套约定)
|
|
158
158
|
preset.ts 纯函数:自带 agent preset 安装到 $DSH_HOME/.agent-presets/
|
|
159
159
|
presets/paper-reader/ 「论文伴读」agent preset(persona 完整提示词 + compaction)
|
|
160
160
|
reader/index.html 阅读器页面(pdf.js,独立于 React 宿主,iframe 承载)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// embed-config.ts — OpenAI 兼容嵌入端点配置的落盘与解析(纯函数,不依赖 Cordis)。
|
|
2
|
+
//
|
|
3
|
+
// 与 translate-config.ts / typesafe-config.ts **同一套约定**:存
|
|
4
|
+
// $DSH_HOME/.dsh-paper-reader/embed.json(0600,刻意不放 dataDir——密钥不跟文献库/云盘走);
|
|
5
|
+
// 密钥只落盘、只回显掩码(apiKeyHint)。优先级(逐字段):
|
|
6
|
+
// 文件(设置面板填的)> profile 的 cordis.patch.yml config.embed > 环境变量 DSH_EMBED_API_KEY。
|
|
7
|
+
// 未配到 baseUrl+model 时嵌入功能整体关闭(默认关闭,零行为变化)。
|
|
8
|
+
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { dshHome } from "./library.js";
|
|
11
|
+
import { maskApiKey } from "./translate-config.js";
|
|
12
|
+
import { EmbedError, requestEmbeddings } from "./embed.js";
|
|
13
|
+
export { maskApiKey };
|
|
14
|
+
export function embedConfigPath(home = dshHome()) {
|
|
15
|
+
return join(home, '.dsh-paper-reader', 'embed.json');
|
|
16
|
+
}
|
|
17
|
+
/** 读配置;文件缺失/损坏/形状不对一律返回 null(与 translate/typesafe 的读策略一致)。 */
|
|
18
|
+
export async function readEmbedConfig(home = dshHome()) {
|
|
19
|
+
try {
|
|
20
|
+
const data = JSON.parse(await readFile(embedConfigPath(home), 'utf8'));
|
|
21
|
+
if (typeof data !== 'object' || data === null)
|
|
22
|
+
return null;
|
|
23
|
+
const rec = data;
|
|
24
|
+
const out = {};
|
|
25
|
+
if (typeof rec['baseUrl'] === 'string')
|
|
26
|
+
out.baseUrl = rec['baseUrl'];
|
|
27
|
+
if (typeof rec['apiKey'] === 'string')
|
|
28
|
+
out.apiKey = rec['apiKey'];
|
|
29
|
+
if (typeof rec['model'] === 'string')
|
|
30
|
+
out.model = rec['model'];
|
|
31
|
+
if (Number.isInteger(rec['timeoutMs']))
|
|
32
|
+
out.timeoutMs = rec['timeoutMs'];
|
|
33
|
+
if (Number.isInteger(rec['batchSize']))
|
|
34
|
+
out.batchSize = rec['batchSize'];
|
|
35
|
+
if (Number.isInteger(rec['candidateSize']))
|
|
36
|
+
out.candidateSize = rec['candidateSize'];
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** 写配置。0600:文件含明文密钥,不靠 umask。 */
|
|
44
|
+
export async function writeEmbedConfig(cfg, home = dshHome()) {
|
|
45
|
+
const file = embedConfigPath(home);
|
|
46
|
+
await mkdir(dirname(file), { recursive: true });
|
|
47
|
+
await writeFile(file, JSON.stringify(cfg, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
48
|
+
await chmod(file, 0o600); // mode 只在新建时生效;已存在的文件要显式收紧
|
|
49
|
+
}
|
|
50
|
+
/** 删除配置(回落到 profile 的 embed / 环境变量)。文件不存在也算成功。 */
|
|
51
|
+
export async function clearEmbedConfig(home = dshHome()) {
|
|
52
|
+
await rm(embedConfigPath(home), { force: true });
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 解析生效配置:按字段 file > profile > env(只 env 有 key 时 source=env)。
|
|
56
|
+
* enabled 要求 baseUrl + model + apiKey 三者齐全——缺任一项都退回「嵌入关闭」。
|
|
57
|
+
*/
|
|
58
|
+
export async function resolveEmbedConfig(profile, home = dshHome()) {
|
|
59
|
+
const file = await readEmbedConfig(home);
|
|
60
|
+
const envKey = process.env['DSH_EMBED_API_KEY']?.trim() || undefined;
|
|
61
|
+
const cfg = {};
|
|
62
|
+
const apiKey = file?.apiKey || profile?.apiKey || envKey;
|
|
63
|
+
const baseUrl = file?.baseUrl || profile?.baseUrl;
|
|
64
|
+
const model = file?.model || profile?.model;
|
|
65
|
+
const timeoutMs = file?.timeoutMs ?? profile?.timeoutMs;
|
|
66
|
+
const batchSize = file?.batchSize ?? profile?.batchSize;
|
|
67
|
+
const candidateSize = file?.candidateSize ?? profile?.candidateSize;
|
|
68
|
+
if (apiKey)
|
|
69
|
+
cfg.apiKey = apiKey;
|
|
70
|
+
if (baseUrl)
|
|
71
|
+
cfg.baseUrl = baseUrl;
|
|
72
|
+
if (model)
|
|
73
|
+
cfg.model = model;
|
|
74
|
+
if (timeoutMs !== undefined)
|
|
75
|
+
cfg.timeoutMs = timeoutMs;
|
|
76
|
+
if (batchSize !== undefined)
|
|
77
|
+
cfg.batchSize = batchSize;
|
|
78
|
+
if (candidateSize !== undefined)
|
|
79
|
+
cfg.candidateSize = candidateSize;
|
|
80
|
+
const source = file?.apiKey
|
|
81
|
+
? 'file'
|
|
82
|
+
: profile?.apiKey
|
|
83
|
+
? 'profile'
|
|
84
|
+
: envKey
|
|
85
|
+
? 'env'
|
|
86
|
+
: 'none';
|
|
87
|
+
return { cfg, source, enabled: Boolean(cfg.baseUrl && cfg.model && cfg.apiKey) };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 连接预检:拿一个极小的输入打一发 /embeddings,确认 url/key/model 三者匹配且返回维度正常。
|
|
91
|
+
* 必要性同翻译/重排:嵌入失败是**静默降级**,key 或模型名填错用户很难察觉,落盘前先拦。
|
|
92
|
+
*/
|
|
93
|
+
export async function testEmbedEndpoint(cfg) {
|
|
94
|
+
if (!cfg.baseUrl?.trim())
|
|
95
|
+
return { ok: false, detail: '端点 URL 不能为空' };
|
|
96
|
+
if (!cfg.apiKey?.trim())
|
|
97
|
+
return { ok: false, detail: 'API Key 不能为空' };
|
|
98
|
+
if (!cfg.model?.trim())
|
|
99
|
+
return { ok: false, detail: '嵌入模型名不能为空' };
|
|
100
|
+
try {
|
|
101
|
+
const [vec] = await requestEmbeddings({
|
|
102
|
+
baseUrl: cfg.baseUrl.trim(),
|
|
103
|
+
apiKey: cfg.apiKey.trim(),
|
|
104
|
+
model: cfg.model.trim(),
|
|
105
|
+
timeoutMs: cfg.timeoutMs ?? 30_000,
|
|
106
|
+
}, ['connectivity check']);
|
|
107
|
+
if (!vec || vec.length === 0)
|
|
108
|
+
return { ok: false, detail: '端点返回了空向量' };
|
|
109
|
+
return { ok: true, detail: `维度 ${vec.length}` };
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (err instanceof EmbedError)
|
|
113
|
+
return { ok: false, detail: err.message };
|
|
114
|
+
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
|
115
|
+
}
|
|
116
|
+
}
|