@ggboy123/dsh-paper-reader 1.0.0 → 1.2.0
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 +50 -4
- package/dist/babeldoc-install.js +118 -23
- package/dist/host.js +45 -0
- package/dist/rerank.js +85 -0
- package/dist/tools.js +24 -1
- package/dist/translate.js +19 -14
- package/dist/typesafe-config.js +105 -0
- package/lib/client.js +227 -10
- package/package.json +17 -5
- package/reader/index.html +38 -10
package/README.md
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
# dsh-paper-reader
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@ggboy123/dsh-paper-reader)
|
|
4
|
+
[](https://www.npmjs.com/package/@ggboy123/dsh-paper-reader)
|
|
5
|
+
[](https://github.com/GGboya/dsh-paper-reader/blob/main/LICENSE)
|
|
6
|
+
[](https://github.com/GGboya/dsh-paper-reader/stargazers)
|
|
7
|
+
|
|
8
|
+
**English**: [README_EN.md](README_EN.md)
|
|
9
|
+
|
|
3
10
|
[DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 插件:论文伴读工作台 —— PDF 转录 / 检索 + 原生对话伴读 + 内置阅读器。
|
|
4
11
|
|
|
12
|
+
> **👉 普通用户不用看这一页。** 如果你只是想装一个能用的 App,请直接去 [**PaperReader 论文伴读桌面版**](https://github.com/GGboya/PaperReader) —— 那是把本插件预装好的桌面应用,下载 DMG 拖进「应用程序」就能用,不需要任何环境。本仓库面向的是想自己装插件、或想改代码的用户。
|
|
13
|
+
|
|
5
14
|
功能迁移自本地独立产品 pdfqa(Go 实现的论文伴读工具)。原则:agent 循环 / 模型层 / 会话持久化全部交给 dsh 框架,插件只做「转录 + 检索 + 阅读器 UI」的薄壳组合。
|
|
6
15
|
|
|
7
16
|
**安装后不改动官方界面** —— 侧栏底部多一行「📚 论文伴读」开关,点击才进入伴读模式(左侧变为文献库树),再点即还原官方工作区列表:
|
|
8
17
|
|
|
18
|
+

|
|
19
|
+
|
|
9
20
|

|
|
10
21
|
|
|
11
22
|

|
|
@@ -19,11 +30,44 @@
|
|
|
19
30
|
- 📖 **PDF 阅读器居中**:中间是论文(PDF.js 缩放 / Retina 高清 / 文本层选择 / 适宽模式),最右是该论文的原生对话 —— 视觉换位实现,列宽拖拽/折叠仍是 dsh 原生行为,关掉 PDF 页签即还原官方布局
|
|
20
31
|
- 💬 **选中即问**:阅读器里选中文字 → 弹出提问框 → 注入当前会话,原生对话区实时回答
|
|
21
32
|
- 📍 **引用定位**:回答里的「第 N 页」可点击,平滑跳回 PDF 对应页并闪烁
|
|
22
|
-
- 🀄 **中英切换**:顶栏「中」按钮原文 ↔ 纯中文切换;无译文时一键后台生成(babeldoc),生成需配置 `translate` 端点;**无需预装 Python**——首次生成时自动下载 uv + 托管 Python + babeldoc(macOS/Linux,约几分钟),全程落在用户目录
|
|
23
|
-
- 🔍 **PDF 转录 + 检索**:本地提取(pdf.js,纯 Node 无需 Python),页眉页脚剔除 / 连字 / 断词愈合 /
|
|
33
|
+
- 🀄 **中英切换**:顶栏「中」按钮原文 ↔ 纯中文切换;无译文时一键后台生成(babeldoc),生成需配置 `translate` 端点;**无需预装 Python**——首次生成时自动下载 uv + 托管 Python + babeldoc(macOS / Linux / Windows,约几分钟),全程落在用户目录
|
|
34
|
+
- 🔍 **PDF 转录 + 检索**:本地提取(pdf.js,纯 Node 无需 Python),页眉页脚剔除 / 连字 / 断词愈合 / 段落重排,产出页码偏移表;可选接入 TypeSafe/Jev 语义重排(设置面板填端点即启用,不配置时退回纯关键词排序);兼容 pdfqa 的 `data/` 缓存布局(旧缓存读取时自动补建页码索引)
|
|
24
35
|
|
|
25
36
|
## 安装
|
|
26
37
|
|
|
38
|
+
### 方式一:PaperReader 桌面版(一键安装,推荐普通用户)
|
|
39
|
+
|
|
40
|
+
[PaperReader](https://github.com/GGboya/PaperReader) 是把本插件**预装好**的桌面应用(基于社区桌面客户端 [DSH Desktop](https://github.com/anywhere-labs/dsh-desktop) 打包):
|
|
41
|
+
|
|
42
|
+
1. 从 [Releases](https://github.com/GGboya/PaperReader/releases/latest) 下载 DMG(macOS Universal,Intel / M 系列芯片都行;Windows 版打包中)
|
|
43
|
+
2. 打开 DMG,把 PaperReader 拖进「应用程序」
|
|
44
|
+
3. 首次打开用 右键 → 打开(免签名发布,只弹这一次);首次启动有几分钟一次性初始化
|
|
45
|
+
|
|
46
|
+
打开就是文献库 + 阅读器 + 伴读对话,不用再执行任何命令。
|
|
47
|
+
|
|
48
|
+
### 方式二:已有桌面客户端,自己装插件
|
|
49
|
+
|
|
50
|
+
**DeepSeek 官方桌面端**([deepseek.com/download](https://www.deepseek.com/download/) 下载,即 DeepSeek Harness 桌面版):
|
|
51
|
+
|
|
52
|
+
1. 安装并启动,登录 DeepSeek 账号
|
|
53
|
+
2. 侧栏 → **插件** → **添加插件**,输入 `@ggboy123/dsh-paper-reader` 安装
|
|
54
|
+
3. 重启桌面端生效
|
|
55
|
+
|
|
56
|
+
> 已在官方桌面端 V0.2.0-rc.2(内置 dsh 0.2.0-rc.2,macOS arm64)实测通过:侧栏文献库、新建专题、上传 PDF、PDF 阅读器、原生对话伴读、历史/新建对话按钮全部可用。
|
|
57
|
+
> 需要 ≥1.2.0:更早版本会被 0.2.x 运行时的 peer 兼容校验拒载(插件列表里显示异常)。
|
|
58
|
+
|
|
59
|
+
**社区 DSH Desktop**([anywhere-labs/dsh-desktop](https://github.com/anywhere-labs/dsh-desktop),社区的 DeepSeek Harness 桌面客户端,macOS / Windows,开箱即用,不需要装 Node.js):
|
|
60
|
+
|
|
61
|
+
1. 下载安装 DSH Desktop 并启动
|
|
62
|
+
2. 托盘菜单 → **Open DSH Terminal**(终端里自带 `dsh`/`pnpm`,只对那个终端生效)
|
|
63
|
+
3. 执行 `dsh plugin add @ggboy123/dsh-paper-reader@1.2.0`
|
|
64
|
+
4. 退出并重开 DSH Desktop(插件变更要重启才进 Loader 组合)
|
|
65
|
+
|
|
66
|
+
> 已在 DSH Desktop 2.0.13(内置 dsh 0.1.5-rc.2)上实测通过:侧栏文献库、PDF 阅读器、选中即问、页码跳转、原生对话伴读、翻译引擎自动安装(uv + Python + babeldoc 全程落在用户目录)全部可用。
|
|
67
|
+
> 注意:Desktop 的 `desktop` profile 被 Electron 独占管理,外部 CLI 直接 `dsh plugin --profile desktop add` 会被拒(`managed exclusively by the Electron application`)——必须从 Desktop 自己的终端进。
|
|
68
|
+
|
|
69
|
+
### 方式三:命令行 dsh(开发者)
|
|
70
|
+
|
|
27
71
|
```bash
|
|
28
72
|
dsh plugin --profile web add @ggboy123/dsh-paper-reader
|
|
29
73
|
```
|
|
@@ -36,7 +80,7 @@ dsh plugin --profile web add @ggboy123/dsh-paper-reader
|
|
|
36
80
|
**已装过的用户升级必须带显式版本号**——不带版本的 `add` 对已存在的依赖是 no-op(pnpm 按首次安装时记录的版本范围解析,不会追新):
|
|
37
81
|
|
|
38
82
|
```bash
|
|
39
|
-
dsh plugin --profile web add @ggboy123/dsh-paper-reader@1.
|
|
83
|
+
dsh plugin --profile web add @ggboy123/dsh-paper-reader@1.2.0
|
|
40
84
|
# 重启 dsh web 生效;浏览器 Cmd+Shift+R 强刷,避免旧阅读器页面缓存
|
|
41
85
|
```
|
|
42
86
|
|
|
@@ -75,7 +119,7 @@ dsh --profile web # 侧栏变为文献库树
|
|
|
75
119
|
>
|
|
76
120
|
> 翻译引擎**零预装**:找不到 babeldoc 时自动走 uv 链路安装——uv 独立二进制(GitHub Releases API 下载 + sha256 校验)→
|
|
77
121
|
> uv 托管 Python 3.12 → `uv pip install babeldoc`,落在 `~/.dsh/.dsh-paper-reader/bin/` 与文献库同级 `.venv-pdf2zh/`,
|
|
78
|
-
> 不碰系统 Python。已装有 uv / babeldoc(含 pdfqa 的 `.venv-pdf2zh`)则直接复用。Windows
|
|
122
|
+
> 不碰系统 Python。已装有 uv / babeldoc(含 pdfqa 的 `.venv-pdf2zh`)则直接复用。macOS / Linux / Windows 均支持自动安装(Windows 走 uv 的 zip 包 + 系统自带 tar 解包)。
|
|
79
123
|
|
|
80
124
|
> 伴读模式是**按需开启**的:默认不注册 `sidebar.workspaces`(官方工作区/会话列表原样),侧栏底部「📚 论文伴读」开关点击才接管,再点还原;模式选择记在 localStorage。退出模式时已打开的 PDF 页签和伴读会话不受影响。
|
|
81
125
|
|
|
@@ -94,6 +138,8 @@ src/
|
|
|
94
138
|
translate.ts 纯函数:babeldoc 调用(中文/中英对照 PDF 生成)
|
|
95
139
|
babeldoc-install.ts 纯函数:无 Python 环境时自动安装 babeldoc(uv → 托管 Python → venv)
|
|
96
140
|
translate-config.ts 纯函数:翻译端点配置读写($DSH_HOME 下 0600)+ 保存前连接预检
|
|
141
|
+
rerank.ts 纯函数:search_paper 语义重排(TypeSafe/Jev,无凭据时退回关键词排序)
|
|
142
|
+
typesafe-config.ts 纯函数:TypeSafe 端点配置读写(与翻译配置同一套约定)
|
|
97
143
|
preset.ts 纯函数:自带 agent preset 安装到 $DSH_HOME/.agent-presets/
|
|
98
144
|
presets/paper-reader/ 「论文伴读」agent preset(persona 完整提示词 + compaction)
|
|
99
145
|
reader/index.html 阅读器页面(pdf.js,独立于 React 宿主,iframe 承载)
|
package/dist/babeldoc-install.js
CHANGED
|
@@ -2,21 +2,29 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 链路:uv 独立二进制(GitHub Releases,带 sha256 校验)
|
|
4
4
|
// → `uv venv`(uv 自动下载托管 Python,系统没有 Python 也能跑)
|
|
5
|
-
// → `uv pip install babeldoc
|
|
6
|
-
//
|
|
5
|
+
// → `uv pip install babeldoc`(失败时退到 pip + 国内镜像自举)
|
|
6
|
+
// 使用前后各做一次 `babeldoc --version` 自检:venv 残缺(依赖被杀软/中断搞掉一半,
|
|
7
|
+
// exe 还在但一跑就 ModuleNotFoundError)会被当场抓住并自动走重装修复,而不是让用户
|
|
8
|
+
// 在翻译时对着 import 报错干瞪眼。
|
|
9
|
+
// 全部落在用户目录:uv 二进制在 <dshHome>/.dsh-paper-reader/bin/uv(win32 为 uv.exe),
|
|
7
10
|
// venv 在 文献库同级 .venv-pdf2zh(必须与 translate.ts findBabeldoc 的第一候选位一致),
|
|
8
|
-
// 不碰系统 Python、不需要 sudo。
|
|
11
|
+
// 不碰系统 Python、不需要 sudo。
|
|
12
|
+
// win32 差异:uv 发 zip 而非 tar.gz(Win10 1803+ 自带 bsdtar 可解 zip,tar -xf 通吃两种格式)、
|
|
13
|
+
// exe 后缀、venv 用 Scripts/ 而非 bin/、PATH 查找用 where 替代 which、不需要 chmod。
|
|
9
14
|
import { execFile } from 'node:child_process';
|
|
10
15
|
import { createHash } from 'node:crypto';
|
|
11
16
|
import { existsSync } from 'node:fs';
|
|
12
17
|
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
13
18
|
import { dirname, join } from 'node:path';
|
|
14
19
|
import { homedir } from 'node:os';
|
|
20
|
+
const isWin = process.platform === 'win32';
|
|
15
21
|
/** uv 发布的 target triple;不支持的平台返回 null(给手动安装指引)。 */
|
|
16
22
|
function uvTriple() {
|
|
17
23
|
const { platform, arch } = process;
|
|
18
24
|
if (platform === 'darwin')
|
|
19
25
|
return arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin';
|
|
26
|
+
if (platform === 'win32')
|
|
27
|
+
return arch === 'arm64' ? 'aarch64-pc-windows-msvc' : 'x86_64-pc-windows-msvc';
|
|
20
28
|
// musl 静态构建:不依赖系统 glibc 版本,最省心
|
|
21
29
|
if (platform === 'linux') {
|
|
22
30
|
if (arch === 'x64')
|
|
@@ -43,13 +51,14 @@ function run(cmd, args, opts = {}) {
|
|
|
43
51
|
});
|
|
44
52
|
}
|
|
45
53
|
async function whichOk(cmd) {
|
|
54
|
+
// win32 没有 which,用 where(两者都找不到时都只是跳下一个候选,不算错误)
|
|
46
55
|
return new Promise((res) => {
|
|
47
|
-
execFile('which', [cmd], (err, stdout) => res(!err && stdout.trim() !== ''));
|
|
56
|
+
execFile(isWin ? 'where' : 'which', [cmd], (err, stdout) => res(!err && stdout.trim() !== ''));
|
|
48
57
|
});
|
|
49
58
|
}
|
|
50
59
|
/** 插件托管的 uv 位置(不落系统目录,删插件目录即清干净)。 */
|
|
51
60
|
export function uvPath(home) {
|
|
52
|
-
return join(home, '.dsh-paper-reader', 'bin', 'uv');
|
|
61
|
+
return join(home, '.dsh-paper-reader', 'bin', isWin ? 'uv.exe' : 'uv');
|
|
53
62
|
}
|
|
54
63
|
/** 找 uv:插件托管 → PATH → 常见安装位。 */
|
|
55
64
|
async function findUv(home) {
|
|
@@ -58,7 +67,10 @@ async function findUv(home) {
|
|
|
58
67
|
return own;
|
|
59
68
|
if (await whichOk('uv'))
|
|
60
69
|
return 'uv';
|
|
61
|
-
|
|
70
|
+
const candidates = isWin
|
|
71
|
+
? [join(homedir(), '.local', 'bin', 'uv.exe')]
|
|
72
|
+
: [join(homedir(), '.local', 'bin', 'uv'), join(homedir(), '.cargo', 'bin', 'uv')];
|
|
73
|
+
for (const c of candidates) {
|
|
62
74
|
if (existsSync(c))
|
|
63
75
|
return c;
|
|
64
76
|
}
|
|
@@ -75,7 +87,7 @@ async function downloadUv(home, onPhase) {
|
|
|
75
87
|
if (!triple) {
|
|
76
88
|
throw new Error(`当前平台(${process.platform}/${process.arch})暂不支持自动安装:请手动安装 uv(https://docs.astral.sh/uv/)后重试`);
|
|
77
89
|
}
|
|
78
|
-
const want = `uv-${triple}.tar.gz`;
|
|
90
|
+
const want = isWin ? `uv-${triple}.zip` : `uv-${triple}.tar.gz`;
|
|
79
91
|
const tmp = join(home, '.dsh-paper-reader', 'bin', `.uv-install-${Date.now()}`);
|
|
80
92
|
await mkdir(tmp, { recursive: true });
|
|
81
93
|
try {
|
|
@@ -110,30 +122,102 @@ async function downloadUv(home, onPhase) {
|
|
|
110
122
|
const actual = createHash('sha256').update(body).digest('hex');
|
|
111
123
|
if (actual !== expect)
|
|
112
124
|
throw new Error('uv 下载文件校验和不匹配,已丢弃(请重试)');
|
|
113
|
-
const
|
|
114
|
-
await writeFile(
|
|
115
|
-
|
|
116
|
-
|
|
125
|
+
const pkgPath = join(tmp, isWin ? 'uv.zip' : 'uv.tar.gz');
|
|
126
|
+
await writeFile(pkgPath, body);
|
|
127
|
+
// 不带 -z:bsdtar(macOS 自带 / Win10 1803+ 的 tar.exe)和 GNU tar 解包时都自动探测格式,
|
|
128
|
+
// 同一条命令通吃 tar.gz 与 zip(win32 的 uv 只发 zip)
|
|
129
|
+
await run('tar', ['-xf', pkgPath, '-C', tmp], { timeout: 60_000 });
|
|
130
|
+
const extracted = join(tmp, `uv-${triple}`, isWin ? 'uv.exe' : 'uv');
|
|
117
131
|
if (!existsSync(extracted))
|
|
118
132
|
throw new Error('uv 解包结果不符合预期');
|
|
119
133
|
const dst = uvPath(home);
|
|
120
134
|
await rename(extracted, dst);
|
|
121
|
-
|
|
135
|
+
if (!isWin)
|
|
136
|
+
await chmod(dst, 0o755); // win32 无可执行位概念
|
|
122
137
|
return dst;
|
|
123
138
|
}
|
|
124
139
|
finally {
|
|
125
140
|
await rm(tmp, { recursive: true, force: true });
|
|
126
141
|
}
|
|
127
142
|
}
|
|
128
|
-
/**
|
|
143
|
+
/** venv 里的 python 路径(win32 在 Scripts/ 且带 .exe)。 */
|
|
144
|
+
function venvPython(venvDir) {
|
|
145
|
+
return isWin ? join(venvDir, 'Scripts', 'python.exe') : join(venvDir, 'bin', 'python');
|
|
146
|
+
}
|
|
147
|
+
/** venv 里的 babeldoc 可执行文件路径(与 translate.ts findBabeldoc 的第一候选位一致)。 */
|
|
148
|
+
function venvBabeldoc(venvDir) {
|
|
149
|
+
return isWin ? join(venvDir, 'Scripts', 'babeldoc.exe') : join(venvDir, 'bin', 'babeldoc');
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 自检:跑一次 --version。import 链上缺任何包(configargparse/pymupdf/rich…)都会在这里炸,
|
|
153
|
+
* 正好覆盖「exe 在、依赖残缺」的坏状态。通过的 bin 记进 Set,后续调用零开销。
|
|
154
|
+
*/
|
|
155
|
+
const verified = new Set();
|
|
156
|
+
function smokeCheck(bin) {
|
|
157
|
+
if (verified.has(bin))
|
|
158
|
+
return Promise.resolve();
|
|
159
|
+
return new Promise((res, rej) => {
|
|
160
|
+
execFile(bin, ['--version'], { timeout: 60_000 }, (err, stdout, stderr) => {
|
|
161
|
+
if (err) {
|
|
162
|
+
const tail = (stdout + '\n' + stderr).trim().slice(-300);
|
|
163
|
+
rej(new Error(tail || err.message));
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
verified.add(bin);
|
|
167
|
+
res();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* uv 之外的备用安装通道:venv 自举 pip(ensurepip)→ 国内镜像轮询装 babeldoc。
|
|
174
|
+
* 触发场景(均在实测中出现过):
|
|
175
|
+
* - 杀毒软件锁 uv 临时入口 exe 的 PE 资源更新(UpdateResource 被拒,uv 全线失败);
|
|
176
|
+
* - uv 默认 PyPI 源在国内网络下超时/响应截断。pip 写入口 exe 用普通文件写,不踩这两坑。
|
|
177
|
+
*/
|
|
178
|
+
const CN_MIRRORS = [
|
|
179
|
+
'https://pypi.tuna.tsinghua.edu.cn/simple',
|
|
180
|
+
'https://mirrors.aliyun.com/pypi/simple/',
|
|
181
|
+
'https://mirrors.cloud.tencent.com/pypi/simple',
|
|
182
|
+
];
|
|
183
|
+
async function pipInstallBabeldoc(py) {
|
|
184
|
+
await run(py, ['-m', 'ensurepip', '--upgrade'], { timeout: 5 * 60_000 });
|
|
185
|
+
let lastErr = null;
|
|
186
|
+
for (const m of CN_MIRRORS) {
|
|
187
|
+
try {
|
|
188
|
+
await run(py, [
|
|
189
|
+
'-m', 'pip', 'install', '--no-cache-dir', '--timeout', '120', '--retries', '3', '-i', m, 'babeldoc',
|
|
190
|
+
], { timeout: 30 * 60_000 });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
lastErr = e; // 换下一个镜像;已装成功的包 pip 会跳过,重试是断点续传式的
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* 建 venv + 装 babeldoc(或修复残缺安装)。uv 没有可用 Python 时会自己下载托管版。
|
|
201
|
+
* venv 已存在时跳过 `uv venv`——它会清空重建,毁掉 pip 修复过的包,直接补依赖即可
|
|
202
|
+
* (`uv pip install` 对缺失的依赖是幂等补装)。
|
|
203
|
+
*/
|
|
129
204
|
async function installBabeldoc(uv, venvDir, onPhase) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
205
|
+
const py = venvPython(venvDir);
|
|
206
|
+
const bin = venvBabeldoc(venvDir);
|
|
207
|
+
if (!existsSync(join(venvDir, 'pyvenv.cfg'))) {
|
|
208
|
+
onPhase('正在安装翻译引擎(2/3 安装 Python)…');
|
|
209
|
+
// --python-preference only-managed:不碰用户系统里可能残缺/带 externally-managed 标记的 Python
|
|
210
|
+
await run(uv, ['venv', venvDir, '--python', '3.12', '--python-preference', 'only-managed'], { timeout: 10 * 60_000 });
|
|
211
|
+
}
|
|
134
212
|
onPhase('正在安装翻译引擎(3/3 安装 babeldoc,首次约几分钟)…');
|
|
135
|
-
|
|
136
|
-
|
|
213
|
+
try {
|
|
214
|
+
await run(uv, ['pip', 'install', '--python', py, 'babeldoc'], { timeout: 20 * 60_000 });
|
|
215
|
+
}
|
|
216
|
+
catch (uvErr) {
|
|
217
|
+
onPhase('正在安装翻译引擎(3/3 备用通道:pip + 国内镜像)…');
|
|
218
|
+
console.warn('[dsh-paper-reader] uv 安装 babeldoc 失败,改用 pip 镜像通道:', uvErr instanceof Error ? uvErr.message : uvErr);
|
|
219
|
+
await pipInstallBabeldoc(py);
|
|
220
|
+
}
|
|
137
221
|
if (!existsSync(bin))
|
|
138
222
|
throw new Error('babeldoc 安装完成但未找到可执行文件');
|
|
139
223
|
return bin;
|
|
@@ -142,18 +226,29 @@ async function installBabeldoc(uv, venvDir, onPhase) {
|
|
|
142
226
|
let inflight = null;
|
|
143
227
|
/**
|
|
144
228
|
* 确保 babeldoc 可用,返回可执行文件路径。
|
|
145
|
-
* 已存在(venv/PATH/miniconda
|
|
229
|
+
* 已存在(venv/PATH/miniconda)先自检:残缺(exe 在、依赖缺)不直接用,转修复流程。
|
|
146
230
|
* venv 落 dirname(dataDir)/.venv-pdf2zh —— 与 translate.ts findBabeldoc 的第一候选位严格一致。
|
|
147
231
|
*/
|
|
148
232
|
export function ensureBabeldoc(dataDir, home, findExisting, onPhase) {
|
|
149
233
|
if (!inflight) {
|
|
150
234
|
inflight = (async () => {
|
|
151
235
|
const existing = await findExisting();
|
|
152
|
-
if (existing)
|
|
153
|
-
|
|
236
|
+
if (existing) {
|
|
237
|
+
try {
|
|
238
|
+
await smokeCheck(existing);
|
|
239
|
+
return existing;
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
console.warn('[dsh-paper-reader] babeldoc 自检未过(残缺安装),进入修复:', e instanceof Error ? e.message : e);
|
|
243
|
+
onPhase('检测到翻译引擎组件缺失,正在修复…');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
154
246
|
const venvDir = join(dirname(dataDir), '.venv-pdf2zh');
|
|
155
247
|
const uv = (await findUv(home)) ?? (await downloadUv(home, onPhase));
|
|
156
|
-
|
|
248
|
+
const bin = await installBabeldoc(uv, venvDir, onPhase);
|
|
249
|
+
// 终检:把真实的缺包报错在安装阶段抛出来,别等到翻译跑一半才炸
|
|
250
|
+
await smokeCheck(bin);
|
|
251
|
+
return bin;
|
|
157
252
|
})().finally(() => {
|
|
158
253
|
// 成功后清掉单例让下次走快速路径;失败也清掉让下次可以重试
|
|
159
254
|
inflight = null;
|
package/dist/host.js
CHANGED
|
@@ -8,6 +8,7 @@ import { listPapers, listTopics, resolveDataDir, resolvePaper } from "./library.
|
|
|
8
8
|
import { readTranscript, transcribePaper } from "./transcribe.js";
|
|
9
9
|
import { pdfVariantPath, restartTranslation, startTranslation, zhStatus } from "./translate.js";
|
|
10
10
|
import { clearTranslateConfig, maskApiKey, readTranslateConfig, resolveTranslateEndpoint, testTranslateEndpoint, writeTranslateConfig, } from "./translate-config.js";
|
|
11
|
+
import { clearTypesafeConfig, readTypesafeConfig, resolveTypesafeConfig, testTypesafeEndpoint, writeTypesafeConfig, } from "./typesafe-config.js";
|
|
11
12
|
import { installPaperPreset, PAPER_PRESET_ID } from "./preset.js";
|
|
12
13
|
import { noteOrigin } from "./origin.js";
|
|
13
14
|
function json(res, status, body) {
|
|
@@ -353,6 +354,50 @@ export function registerRoutes(ctx, config) {
|
|
|
353
354
|
json(res, 200, { ok: true });
|
|
354
355
|
return;
|
|
355
356
|
}
|
|
357
|
+
// ── Jev/TypeSafe 端点配置(设置面板的读写口)─────────────────────────
|
|
358
|
+
// 与翻译配置同一套约定:GET 只回显掩码 key;POST 预检通过才落盘;DELETE 回落
|
|
359
|
+
if (sub === '/api/typesafe/config' && req.method === 'GET') {
|
|
360
|
+
const { cfg, source } = await resolveTypesafeConfig(config.typesafe);
|
|
361
|
+
json(res, 200, {
|
|
362
|
+
baseUrl: cfg.baseUrl ?? '',
|
|
363
|
+
model: cfg.model ?? '',
|
|
364
|
+
hasApiKey: Boolean(cfg.apiKey),
|
|
365
|
+
apiKeyHint: cfg.apiKey ? maskApiKey(cfg.apiKey) : '',
|
|
366
|
+
source,
|
|
367
|
+
});
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// POST:baseUrl/model 可空(SDK 有默认值);key 留空 = 沿用已存的那把。
|
|
371
|
+
// 校验 → 预检 → 通了才落盘:重排失败会静默降级,key 填错很难察觉,这里先拦。
|
|
372
|
+
if (sub === '/api/typesafe/config' && req.method === 'POST') {
|
|
373
|
+
const body = (await readBody(req));
|
|
374
|
+
const baseUrl = body.baseUrl?.trim() ?? '';
|
|
375
|
+
const model = body.model?.trim() ?? '';
|
|
376
|
+
const prev = await readTypesafeConfig();
|
|
377
|
+
const apiKey = body.apiKey?.trim() || prev?.apiKey || '';
|
|
378
|
+
if (baseUrl && !/^https?:\/\//i.test(baseUrl)) {
|
|
379
|
+
json(res, 400, { error: '端点 URL 需要以 http:// 或 https:// 开头(留空用官方默认)' });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (!apiKey) {
|
|
383
|
+
json(res, 400, { error: 'API Key 不能为空' });
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const test = await testTypesafeEndpoint({ apiKey, ...(baseUrl ? { baseUrl } : {}), ...(model ? { model } : {}) });
|
|
387
|
+
if (!test.ok) {
|
|
388
|
+
json(res, 400, { error: `连接测试失败:${test.detail ?? '未知原因'}` });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
await writeTypesafeConfig({ apiKey, ...(baseUrl ? { baseUrl } : {}), ...(model ? { model } : {}) });
|
|
392
|
+
json(res, 200, { ok: true });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
// DELETE:清掉文件,回落到 profile 的 typesafe / 环境变量
|
|
396
|
+
if (sub === '/api/typesafe/config' && req.method === 'DELETE') {
|
|
397
|
+
await clearTypesafeConfig();
|
|
398
|
+
json(res, 200, { ok: true });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
356
401
|
if (sub === '/api/transcribe' && req.method === 'POST') {
|
|
357
402
|
const body = (await readBody(req));
|
|
358
403
|
const ref = resolvePaper(dataDir, body);
|
package/dist/rerank.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// rerank.ts — 语义重排(Jev / TypeSafe System One,不依赖 Cordis)。
|
|
2
|
+
//
|
|
3
|
+
// 两段式检索的第二段:search.ts 的关键词打分是「快搜」——按词面捞回 shortlist;
|
|
4
|
+
// 这里对 shortlist 里每个候选问一个 Noul(「这段能回答学生的问题吗」),
|
|
5
|
+
// 按 noul 概率重排。快搜召回、Jev 定序,代码保留排序和截断。
|
|
6
|
+
// 参考 https://docs.typesafe.ai/cookbooks/rerank_typesafe.md
|
|
7
|
+
//
|
|
8
|
+
// 凭据按「设置面板文件 > profile YAML > 环境变量」解析(typesafe-config.ts),
|
|
9
|
+
// 都配不出 apiKey 时 resolveRerankClient 返回 null,调用方退回纯关键词排序
|
|
10
|
+
// ——重排是可迭增强,不是硬依赖。
|
|
11
|
+
import { TypeSafeClient, noul } from '@typesafe-ai/sdk';
|
|
12
|
+
import { patientFetch, resolveTypesafeConfig } from "./typesafe-config.js";
|
|
13
|
+
/** 重排超预算(区别于网络/鉴权错误,方便上层区分日志)。 */
|
|
14
|
+
export class RerankDeadlineError extends Error {
|
|
15
|
+
deadlineMs;
|
|
16
|
+
constructor(deadlineMs) {
|
|
17
|
+
super(`Jev 重排超过 ${deadlineMs}ms 预算,已放弃`);
|
|
18
|
+
this.deadlineMs = deadlineMs;
|
|
19
|
+
this.name = 'RerankDeadlineError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function resolveShortlistSize(config) {
|
|
23
|
+
const n = config?.shortlistSize ?? 15;
|
|
24
|
+
return Math.max(1, Math.min(Math.trunc(n), 30));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 有凭据则建客户端,否则返回 null(调用方走降级路径)。
|
|
28
|
+
* 每次检索都调用:设置面板保存后无需重启即生效(读的是个小 JSON,开销可忽略)。
|
|
29
|
+
*/
|
|
30
|
+
export async function resolveRerankClient(config) {
|
|
31
|
+
const { cfg } = await resolveTypesafeConfig(config);
|
|
32
|
+
if (!cfg.apiKey?.trim())
|
|
33
|
+
return null;
|
|
34
|
+
return new TypeSafeClient({
|
|
35
|
+
apiKey: cfg.apiKey,
|
|
36
|
+
...(cfg.baseUrl ? { baseURL: cfg.baseUrl } : {}),
|
|
37
|
+
...(cfg.model ? { defaultModel: cfg.model } : {}),
|
|
38
|
+
timeout: config?.timeoutMs ?? 60_000,
|
|
39
|
+
fetch: patientFetch,
|
|
40
|
+
logLevel: 'off',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 用 Jev 对 shortlist 重排,返回 top-k(按原文顺序)。
|
|
45
|
+
* 所有候选放进一份 state、一次请求并行问 N 个 Noul(同 state 的独立问题
|
|
46
|
+
* 官方建议同批发问);任何失败(网络/鉴权/超时)抛给调用方降级。
|
|
47
|
+
* deadlineMs > 0 时到点 abort 在飞请求并抛 RerankDeadlineError。
|
|
48
|
+
*/
|
|
49
|
+
export async function rerankHits(client, query, shortlist, k, deadlineMs = 1500) {
|
|
50
|
+
// state 与问题:候选按 c1..cN 编号,问题里用反引号路径引用对应候选。
|
|
51
|
+
const candidates = {};
|
|
52
|
+
const questions = {};
|
|
53
|
+
shortlist.forEach((h, i) => {
|
|
54
|
+
const key = `c${i + 1}`;
|
|
55
|
+
candidates[key] = h.chunk.text;
|
|
56
|
+
questions[key] = noul(`Could the passage at \`candidates.${key}\` help answer the student's question at \`question\`?`, {
|
|
57
|
+
true: 'The passage contains facts, reasoning, definitions, or data that directly help answer the question.',
|
|
58
|
+
false: 'The passage is only topically related or shares keywords, without content that actually helps answer the question.',
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
const ac = new AbortController();
|
|
62
|
+
const timer = deadlineMs > 0 ? setTimeout(() => ac.abort(new RerankDeadlineError(deadlineMs)), deadlineMs) : null;
|
|
63
|
+
let answers;
|
|
64
|
+
try {
|
|
65
|
+
;
|
|
66
|
+
({ answers } = await client.systemOne({ state: { question: query, candidates }, questions }, deadlineMs > 0 ? { signal: ac.signal } : {}));
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
// abort 触发时 SDK 抛 APIUserAbortError,换成我们带预算信息的错误
|
|
70
|
+
if (ac.signal.aborted)
|
|
71
|
+
throw ac.signal.reason ?? e;
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
if (timer)
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
}
|
|
78
|
+
return shortlist
|
|
79
|
+
.map((h, i) => ({ hit: h, noul: answers[`c${i + 1}`].noul }))
|
|
80
|
+
.sort((a, b) => b.noul - a.noul)
|
|
81
|
+
.slice(0, k)
|
|
82
|
+
// 重排后 score 改写为 noul(最终排序分),便于上层/调试脚本展示概率
|
|
83
|
+
.map((e) => ({ ...e.hit, score: Math.round(e.noul * 1000) / 1000 }))
|
|
84
|
+
.sort((a, b) => a.chunk.index - b.chunk.index); // 与 searchChunks 一致:按原文顺序返回
|
|
85
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -3,6 +3,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
|
3
3
|
import { listPapers, listTopics, resolveDataDir, resolvePaper } from "./library.js";
|
|
4
4
|
import { transcribePaper, readTranscript } from "./transcribe.js";
|
|
5
5
|
import { chunkText, searchChunks, formatHits } from "./search.js";
|
|
6
|
+
import { resolveRerankClient, resolveShortlistSize, rerankHits } from "./rerank.js";
|
|
6
7
|
import { completeStep, formatStudy, readStudy, recordQuiz, setPlan } from "./study.js";
|
|
7
8
|
import { readerOrigin } from "./origin.js";
|
|
8
9
|
const locateParams = {
|
|
@@ -60,6 +61,7 @@ const readerUrlSchema = {
|
|
|
60
61
|
const readerUrlLine = (url) => (url ? `\n阅读器链接基址:${url}` : '');
|
|
61
62
|
export function registerTools(ctx, config) {
|
|
62
63
|
const dataDir = resolveDataDir(config.dataDir);
|
|
64
|
+
const shortlistSize = resolveShortlistSize(config.typesafe);
|
|
63
65
|
ctx.tools.register(defineTool({
|
|
64
66
|
name: 'list_papers',
|
|
65
67
|
description: '列出文献库里的专题和文献。当用户提到某篇论文但你不知道它是否在库中、或用户问"我有哪些论文"时使用。',
|
|
@@ -164,6 +166,11 @@ export function registerTools(ctx, config) {
|
|
|
164
166
|
paper: { type: 'string', required: true },
|
|
165
167
|
query: { type: 'string', required: true },
|
|
166
168
|
totalChunks: { type: 'integer', required: true },
|
|
169
|
+
reranked: {
|
|
170
|
+
type: 'boolean',
|
|
171
|
+
required: true,
|
|
172
|
+
description: '返回片段是否经过语义重排(配置了 Jev/TypeSafe 凭据且候选充足时为 true)。',
|
|
173
|
+
},
|
|
167
174
|
hits: {
|
|
168
175
|
type: 'array',
|
|
169
176
|
required: true,
|
|
@@ -207,12 +214,28 @@ export function registerTools(ctx, config) {
|
|
|
207
214
|
throw new Error('转录失败,无法检索');
|
|
208
215
|
const chunks = chunkText(cached.text, 1500);
|
|
209
216
|
const k = Math.max(1, Math.min(args.k ?? 5, 8));
|
|
210
|
-
|
|
217
|
+
// 两段式检索:关键词快搜捞 shortlist(宽召回),Jev 按语义重排取 top-k。
|
|
218
|
+
// 凭据每次现查(设置面板保存即生效);无凭据或候选不超过 k 时跳过重排。
|
|
219
|
+
const rerankClient = await resolveRerankClient(config.typesafe);
|
|
220
|
+
let hits = searchChunks(chunks, cached.pages, args.query, rerankClient ? shortlistSize : k);
|
|
221
|
+
let reranked = false;
|
|
222
|
+
if (rerankClient && hits.length > k) {
|
|
223
|
+
try {
|
|
224
|
+
hits = await rerankHits(rerankClient, args.query, hits, k, config.typesafe?.deadlineMs ?? 1500);
|
|
225
|
+
reranked = true;
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
// 超预算/网络/鉴权任何失败都退回关键词 top-k(结果已算好,零成本降级)
|
|
229
|
+
console.warn('[dsh-paper-reader] 检索重排失败,退回关键词排序:', e);
|
|
230
|
+
hits = hits.slice(0, k);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
211
233
|
return {
|
|
212
234
|
paper: `${ref.topic}/${ref.name}`,
|
|
213
235
|
query: args.query,
|
|
214
236
|
totalChunks: chunks.length,
|
|
215
237
|
hits: hits.map((h) => ({ chunk: h.chunk.index, page: h.page, text: h.chunk.text })),
|
|
238
|
+
reranked,
|
|
216
239
|
readerUrl: readerUrl(ref),
|
|
217
240
|
};
|
|
218
241
|
},
|
package/dist/translate.js
CHANGED
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
// babeldoc 只支持 OpenAI 兼容端点 → 端点/key/模型由插件配置提供。
|
|
5
5
|
import { execFile } from 'node:child_process';
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
|
-
import { mkdir, rename, rm } from 'node:fs/promises';
|
|
8
|
-
import {
|
|
9
|
-
import { join, dirname, basename, extname } from 'node:path';
|
|
7
|
+
import { mkdir, rename, rm, readdir } from 'node:fs/promises';
|
|
8
|
+
import { join, dirname, basename, extname, isAbsolute } from 'node:path';
|
|
10
9
|
import { dshHome } from "./library.js";
|
|
11
10
|
import { ensureBabeldoc } from "./babeldoc-install.js";
|
|
12
11
|
export function zhPdfPath(ref) {
|
|
@@ -30,22 +29,26 @@ export function zhStatus(ref) {
|
|
|
30
29
|
...(b && !b.error && b.phase ? { phase: b.phase } : {}),
|
|
31
30
|
};
|
|
32
31
|
}
|
|
33
|
-
/** 找 babeldoc 可执行文件:pdfqa venv(文献库同级 .venv-pdf2zh)→ PATH → miniconda
|
|
32
|
+
/** 找 babeldoc 可执行文件:pdfqa venv(文献库同级 .venv-pdf2zh)→ PATH → miniconda(仅 macOS)。 */
|
|
34
33
|
async function findBabeldoc(dataDir) {
|
|
34
|
+
const isWin = process.platform === 'win32';
|
|
35
35
|
const candidates = [
|
|
36
|
-
|
|
36
|
+
// 与 babeldoc-install.ts installBabeldoc 的安装位严格一致(win32 venv 用 Scripts/)
|
|
37
|
+
isWin
|
|
38
|
+
? join(dirname(dataDir), '.venv-pdf2zh', 'Scripts', 'babeldoc.exe')
|
|
39
|
+
: join(dirname(dataDir), '.venv-pdf2zh', 'bin', 'babeldoc'),
|
|
37
40
|
'babeldoc',
|
|
38
|
-
'/opt/homebrew/Caskroom/miniconda/base/bin/babeldoc',
|
|
41
|
+
...(process.platform === 'darwin' ? ['/opt/homebrew/Caskroom/miniconda/base/bin/babeldoc'] : []),
|
|
39
42
|
];
|
|
40
43
|
for (const c of candidates) {
|
|
41
|
-
if (c
|
|
44
|
+
if (isAbsolute(c)) {
|
|
42
45
|
if (existsSync(c))
|
|
43
46
|
return c;
|
|
44
47
|
continue;
|
|
45
48
|
}
|
|
46
|
-
// PATH
|
|
49
|
+
// PATH 查找(win32 用 where)
|
|
47
50
|
const found = await new Promise((res) => {
|
|
48
|
-
execFile('which', [c], (err, stdout) => res(!err && stdout.trim() !== ''));
|
|
51
|
+
execFile(isWin ? 'where' : 'which', [c], (err, stdout) => res(!err && stdout.trim() !== ''));
|
|
49
52
|
});
|
|
50
53
|
if (found)
|
|
51
54
|
return c;
|
|
@@ -180,11 +183,13 @@ async function launch(ref, dataDir, endpoint, bin) {
|
|
|
180
183
|
}
|
|
181
184
|
// 产出从 scratch 挪回文献目录:<名>.zh-CN.mono.pdf → -zh.pdf,.dual.pdf → -dual.pdf
|
|
182
185
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
// 不用 glob:其 pattern 里反斜杠是转义符,win32 的 join 路径会踩雷;readdir + 前后缀匹配全平台稳
|
|
187
|
+
const files = await readdir(tmpDir);
|
|
188
|
+
for (const f of files) {
|
|
189
|
+
if (f.startsWith(stem) && f.endsWith('.zh-CN.mono.pdf'))
|
|
190
|
+
await rename(join(tmpDir, f), dstStem + '-zh.pdf');
|
|
191
|
+
if (f.startsWith(stem) && f.endsWith('.zh-CN.dual.pdf'))
|
|
192
|
+
await rename(join(tmpDir, f), dstStem + '-dual.pdf');
|
|
188
193
|
}
|
|
189
194
|
if (!existsSync(zhPdfPath(ref)) && !existsSync(dualPdfPath(ref))) {
|
|
190
195
|
busy.set(ref.pdfPath, { error: 'babeldoc 未产出译文 PDF' });
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// typesafe-config.ts — Jev/TypeSafe 端点配置的落盘与解析(纯函数,不依赖 Cordis)。
|
|
2
|
+
//
|
|
3
|
+
// 与 translate-config.ts 同一套约定:存 $DSH_HOME/.dsh-paper-reader/typesafe.json
|
|
4
|
+
// (0600,不进 dataDir——密钥不跟文献库走);密钥只落盘、只回显掩码。
|
|
5
|
+
// 优先级:文件(设置面板填的)> profile 的 cordis.patch.yml config.typesafe
|
|
6
|
+
// > 环境变量 TYPESAFE_API_KEY(部署方兜底)。
|
|
7
|
+
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { TypeSafeClient, noul } from '@typesafe-ai/sdk';
|
|
10
|
+
import { Agent, fetch as undiciFetch } from 'undici';
|
|
11
|
+
import { dshHome } from "./library.js";
|
|
12
|
+
import { maskApiKey } from "./translate-config.js";
|
|
13
|
+
export { maskApiKey };
|
|
14
|
+
// 跨境/弱网链路 TCP 握手可能远超 undici 默认的 10s connect 超时(实测 30s+),
|
|
15
|
+
// 单独放宽 connect;总耗时仍由客户端 timeout 控制。fetch 与 Agent 必须用同一个
|
|
16
|
+
// undici 包(混用 npm undici 的 Agent + Node 内置 fetch 会因 dispatcher 内部
|
|
17
|
+
// 接口版本不匹配而炸),per-call dispatcher,不动全局 dispatcher。
|
|
18
|
+
const longConnectAgent = new Agent({ connect: { timeout: 60_000 } });
|
|
19
|
+
export const patientFetch = (input, init) =>
|
|
20
|
+
// 运行时同源;类型上 @types/node 的 undici-types 与 npm undici 不完全兼容,双断言绕过
|
|
21
|
+
undiciFetch(input, { ...init, dispatcher: longConnectAgent });
|
|
22
|
+
export function typesafeConfigPath(home = dshHome()) {
|
|
23
|
+
return join(home, '.dsh-paper-reader', 'typesafe.json');
|
|
24
|
+
}
|
|
25
|
+
/** 读配置;文件缺失/损坏/形状不对一律返回 null(与 translate-config 的读策略一致)。 */
|
|
26
|
+
export async function readTypesafeConfig(home = dshHome()) {
|
|
27
|
+
try {
|
|
28
|
+
const data = JSON.parse(await readFile(typesafeConfigPath(home), 'utf8'));
|
|
29
|
+
if (typeof data !== 'object' || data === null)
|
|
30
|
+
return null;
|
|
31
|
+
const rec = data;
|
|
32
|
+
const out = {};
|
|
33
|
+
if (typeof rec['apiKey'] === 'string')
|
|
34
|
+
out.apiKey = rec['apiKey'];
|
|
35
|
+
if (typeof rec['baseUrl'] === 'string')
|
|
36
|
+
out.baseUrl = rec['baseUrl'];
|
|
37
|
+
if (typeof rec['model'] === 'string')
|
|
38
|
+
out.model = rec['model'];
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** 写配置。0600:文件含明文密钥,不靠 umask。 */
|
|
46
|
+
export async function writeTypesafeConfig(cfg, home = dshHome()) {
|
|
47
|
+
const file = typesafeConfigPath(home);
|
|
48
|
+
await mkdir(dirname(file), { recursive: true });
|
|
49
|
+
await writeFile(file, JSON.stringify(cfg, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
50
|
+
await chmod(file, 0o600); // mode 只在新建时生效;已存在的文件要显式收紧
|
|
51
|
+
}
|
|
52
|
+
/** 删除配置(回落到 profile 的 typesafe / 环境变量)。文件不存在也算成功。 */
|
|
53
|
+
export async function clearTypesafeConfig(home = dshHome()) {
|
|
54
|
+
await rm(typesafeConfigPath(home), { force: true });
|
|
55
|
+
}
|
|
56
|
+
/** 解析生效配置:按字段 file > profile > env(只 env 有 key 时 source=env)。 */
|
|
57
|
+
export async function resolveTypesafeConfig(profile, home = dshHome()) {
|
|
58
|
+
const file = await readTypesafeConfig(home);
|
|
59
|
+
const envKey = process.env['TYPESAFE_API_KEY'];
|
|
60
|
+
const cfg = {};
|
|
61
|
+
const apiKey = file?.apiKey || profile?.apiKey || envKey;
|
|
62
|
+
const baseUrl = file?.baseUrl || profile?.baseUrl;
|
|
63
|
+
const model = file?.model || profile?.model;
|
|
64
|
+
if (apiKey)
|
|
65
|
+
cfg.apiKey = apiKey;
|
|
66
|
+
if (baseUrl)
|
|
67
|
+
cfg.baseUrl = baseUrl;
|
|
68
|
+
if (model)
|
|
69
|
+
cfg.model = model;
|
|
70
|
+
const source = file?.apiKey
|
|
71
|
+
? 'file'
|
|
72
|
+
: profile?.apiKey
|
|
73
|
+
? 'profile'
|
|
74
|
+
: envKey?.trim()
|
|
75
|
+
? 'env'
|
|
76
|
+
: 'none';
|
|
77
|
+
return { cfg, source };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 连接预检:打一发极小的 systemOne(一个 Noul、几个 token),确认 key/端点/模型有效。
|
|
81
|
+
* 与翻译预检同理:重排失败会静默降级成关键词排序,key 填错用户很难察觉,落盘前先拦。
|
|
82
|
+
*/
|
|
83
|
+
export async function testTypesafeEndpoint(cfg) {
|
|
84
|
+
if (!cfg.apiKey?.trim())
|
|
85
|
+
return { ok: false, detail: 'API Key 不能为空' };
|
|
86
|
+
try {
|
|
87
|
+
const client = new TypeSafeClient({
|
|
88
|
+
apiKey: cfg.apiKey,
|
|
89
|
+
...(cfg.baseUrl ? { baseURL: cfg.baseUrl } : {}),
|
|
90
|
+
...(cfg.model ? { defaultModel: cfg.model } : {}),
|
|
91
|
+
timeout: 60_000, // 预检同样走慢链路,15s 容易误杀
|
|
92
|
+
fetch: patientFetch,
|
|
93
|
+
logLevel: 'off',
|
|
94
|
+
});
|
|
95
|
+
await client.systemOne({
|
|
96
|
+
state: 'ping',
|
|
97
|
+
questions: { ok: noul('Is this a connectivity check? Reply yes.') },
|
|
98
|
+
});
|
|
99
|
+
return { ok: true };
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
const cause = err instanceof Error && err.cause instanceof Error ? `: ${err.cause.message}` : '';
|
|
103
|
+
return { ok: false, detail: (err instanceof Error ? err.message : String(err)) + cause };
|
|
104
|
+
}
|
|
105
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -29,18 +29,44 @@ window.__ModuleLoader__.load({
|
|
|
29
29
|
const zh = {
|
|
30
30
|
library: '文献库',
|
|
31
31
|
empty: '文献库为空', hint: '新建专题或上传 PDF 开始', newTopic: '新建专题',
|
|
32
|
+
cancel: '取消', ok: '确定', defaultTopic: '默认专题', uploadBtn: '上传 PDF',
|
|
33
|
+
newTopicFail: '新建专题失败',
|
|
32
34
|
upload: '上传 PDF 到此专题', newChat: '新建对话', session: '会话', pdfTab: '论文 PDF',
|
|
33
35
|
history: '历史对话',
|
|
34
36
|
sync: '对话还停在另一篇文献的会话里,点击切到当前文献',
|
|
35
37
|
mode: '论文伴读', modeEnterTip: '进入论文伴读:左侧变为文献库树', modeExitTip: '退出论文伴读:恢复官方工作区列表',
|
|
38
|
+
settings: '论文伴读',
|
|
39
|
+
setRerankTitle: '检索重排(Jev)',
|
|
40
|
+
setRerankDesc: 'search_paper 检索的语义重排:关键词先捞候选,Jev 逐个判断「这段能否回答这个问题」再排序——大白话提问也能命中论文里的术语段落。不配则退回纯关键词排序。',
|
|
41
|
+
setTranslateTitle: '翻译端点',
|
|
42
|
+
setTranslateDesc: '「生成中文版」(babeldoc)用的 OpenAI 兼容端点,三项必填。保存前会先测连接。',
|
|
43
|
+
setBaseUrl: '端点 URL', setModel: '模型', setKey: 'API Key',
|
|
44
|
+
setSave: '测试并保存', setSaving: '测试连接中…', setClear: '清除自填配置',
|
|
45
|
+
setSaved: '已保存,立即生效', setCleared: '已清除,回落到部署方默认',
|
|
46
|
+
setKeyKeep: '留空沿用已保存的 ', setOptional: '可选',
|
|
47
|
+
setSourceFile: '当前来源:设置页', setSourceProfile: '当前来源:配置文件(部署方默认)',
|
|
48
|
+
setSourceEnv: '当前来源:环境变量', setSourceNone: '未配置',
|
|
36
49
|
}
|
|
37
50
|
const en = {
|
|
38
51
|
library: 'Library',
|
|
39
52
|
empty: 'Library is empty', hint: 'Create a topic or upload a PDF to start', newTopic: 'New topic',
|
|
53
|
+
cancel: 'Cancel', ok: 'OK', defaultTopic: 'Default', uploadBtn: 'Upload PDF',
|
|
54
|
+
newTopicFail: 'Failed to create topic',
|
|
40
55
|
upload: 'Upload PDF into this topic', newChat: 'New chat', session: 'Session', pdfTab: 'Paper PDF',
|
|
41
56
|
history: 'Chat history',
|
|
42
57
|
sync: 'Chat is still on another paper — click to switch to the current one',
|
|
43
58
|
mode: 'Paper Reading', modeEnterTip: 'Enter paper-reading mode: sidebar becomes the library tree', modeExitTip: 'Exit paper-reading mode: restore the official workspace list',
|
|
59
|
+
settings: 'Paper Reader',
|
|
60
|
+
setRerankTitle: 'Search rerank (Jev)',
|
|
61
|
+
setRerankDesc: 'Semantic rerank for search_paper: keyword search shortlists candidates, Jev judges "can this passage answer the question" and reorders — plain-language questions hit terminology passages. Without it, plain keyword ranking is used.',
|
|
62
|
+
setTranslateTitle: 'Translate endpoint',
|
|
63
|
+
setTranslateDesc: 'OpenAI-compatible endpoint for "Generate Chinese version" (babeldoc). All three fields required. Connection is tested before saving.',
|
|
64
|
+
setBaseUrl: 'Endpoint URL', setModel: 'Model', setKey: 'API Key',
|
|
65
|
+
setSave: 'Test & save', setSaving: 'Testing…', setClear: 'Clear my config',
|
|
66
|
+
setSaved: 'Saved, effective immediately', setCleared: 'Cleared, falling back to deployment defaults',
|
|
67
|
+
setKeyKeep: 'Leave empty to keep saved ', setOptional: 'optional',
|
|
68
|
+
setSourceFile: 'Source: settings page', setSourceProfile: 'Source: config file (deployment default)',
|
|
69
|
+
setSourceEnv: 'Source: environment variable', setSourceNone: 'Not configured',
|
|
44
70
|
}
|
|
45
71
|
|
|
46
72
|
let t = (k) => k
|
|
@@ -86,6 +112,34 @@ window.__ModuleLoader__.load({
|
|
|
86
112
|
return r.json()
|
|
87
113
|
}
|
|
88
114
|
|
|
115
|
+
/* 当前在屏(主视图)会话 id:0.1.x 是会话列表快照的 .current 字段;
|
|
116
|
+
0.2.0 起快照没有该字段,官方以 retainedBy.mainView>0 标记主视图保留的会话
|
|
117
|
+
(dsh-client-ui-workspace 自己的 current() 就是这么读的)—— 两代都取到。 */
|
|
118
|
+
function currentSessionOf(snap) {
|
|
119
|
+
if (!snap) return null
|
|
120
|
+
if (snap.current !== undefined) return snap.current
|
|
121
|
+
try {
|
|
122
|
+
return Object.values(snap.byId ?? {}).find((row) => (row.retainedBy?.mainView ?? 0) > 0)?.id ?? null
|
|
123
|
+
} catch { return null }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* ---------- 轻提示(DOM 直挂,不进 React 树) ----------
|
|
127
|
+
window.alert 桌面端(Electron)虽可用但阻塞且各壳表现不一,一律换掉。 */
|
|
128
|
+
function toast(msg) {
|
|
129
|
+
const el = document.createElement('div')
|
|
130
|
+
el.setAttribute('data-dpr-toast', '')
|
|
131
|
+
el.textContent = msg
|
|
132
|
+
Object.assign(el.style, {
|
|
133
|
+
position: 'fixed', left: '50%', bottom: '30px', transform: 'translateX(-50%)',
|
|
134
|
+
zIndex: 80, maxWidth: '76vw', padding: '8px 14px', borderRadius: '8px',
|
|
135
|
+
fontSize: '12px', lineHeight: 1.6, whiteSpace: 'pre-wrap', textAlign: 'center',
|
|
136
|
+
background: 'var(--dsw-alias-bg-inverse, #24292f)', color: 'var(--dsw-alias-label-inverse, #f6f8fa)',
|
|
137
|
+
boxShadow: '0 4px 16px rgba(0,0,0,.25)',
|
|
138
|
+
})
|
|
139
|
+
document.body.appendChild(el)
|
|
140
|
+
setTimeout(() => el.remove(), 4000)
|
|
141
|
+
}
|
|
142
|
+
|
|
89
143
|
/* ---------- 会话切换护栏:PDF/网格全程冻结,只允许对话区换内容 ----------
|
|
90
144
|
rightbar.session 是 session 作用域 slot,切会话 = seat 整体重挂:
|
|
91
145
|
旧 seat 卸载时汇报 closeRightbar,新 seat 初挂时 surface 尚未建立也汇报
|
|
@@ -282,7 +336,7 @@ window.__ModuleLoader__.load({
|
|
|
282
336
|
let docked = null
|
|
283
337
|
try {
|
|
284
338
|
const snap = uiWorkspace.sessions?.list?.getSnapshot?.()
|
|
285
|
-
const cur = snap
|
|
339
|
+
const cur = currentSessionOf(snap)
|
|
286
340
|
setCurSid((prev) => (prev === cur ? prev : cur))
|
|
287
341
|
const col = document.querySelector('[data-rightbar-col]')?.previousElementSibling
|
|
288
342
|
const conv = col?.querySelector('[data-phase]')
|
|
@@ -437,6 +491,10 @@ window.__ModuleLoader__.load({
|
|
|
437
491
|
root: { padding: '4px 0 12px', fontSize: '13px', color: 'var(--dsw-alias-label-primary, #24292f)' },
|
|
438
492
|
head: { display: 'flex', alignItems: 'center', gap: '4px', padding: '8px 10px 6px', fontSize: '12px', fontWeight: 600, color: 'var(--dsw-alias-label-secondary, #656d76)' },
|
|
439
493
|
iconBtn: { cursor: 'pointer', border: 'none', background: 'none', color: 'inherit', padding: '0 4px', fontSize: '13px', opacity: .75 },
|
|
494
|
+
// 页内弹窗(Electron 无 window.prompt)与空库入口按钮
|
|
495
|
+
btnPrimary: { fontSize: '13px', padding: '5px 14px', borderRadius: '6px', border: 'none', cursor: 'pointer', background: 'var(--dsw-alias-button-primary-fill, #1f6feb)', color: 'var(--dsw-alias-button-primary-foreground, #fff)' },
|
|
496
|
+
btnGhost: { fontSize: '13px', padding: '5px 14px', borderRadius: '6px', cursor: 'pointer', background: 'none', border: '1px solid var(--dsw-alias-border-l1, #d0d7de)', color: 'var(--dsw-alias-label-secondary, #656d76)' },
|
|
497
|
+
linkBtn: { display: 'inline-flex', alignItems: 'center', gap: '4px', fontSize: '12px', padding: '4px 10px', borderRadius: '6px', cursor: 'pointer', background: 'none', border: '1px solid var(--dsw-alias-border-l1, #d0d7de)', color: 'var(--dsw-alias-label-action-primary, #0969da)' },
|
|
440
498
|
topicHead: { display: 'flex', alignItems: 'center', gap: '6px', padding: '5px 8px', margin: '1px 6px 0', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, userSelect: 'none' },
|
|
441
499
|
caret: { fontSize: '10px', color: 'var(--dsw-alias-label-secondary, #656d76)', transition: 'transform .12s', display: 'inline-block' },
|
|
442
500
|
items: { marginLeft: '14px' },
|
|
@@ -450,6 +508,35 @@ window.__ModuleLoader__.load({
|
|
|
450
508
|
railBtn: { width: '36px', height: '36px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', border: 'none', background: 'none', borderRadius: '50%', color: 'var(--dsw-alias-label-primary, #24292f)', cursor: 'pointer', fontSize: '17px', padding: '0' },
|
|
451
509
|
}
|
|
452
510
|
|
|
511
|
+
/* ---------- 页内文本输入弹窗:Electron 渲染进程不实现 window.prompt
|
|
512
|
+
(实测同版本 Electron 调用即抛 "prompt() is not supported",桌面端点「+」
|
|
513
|
+
曾因此毫无反应),一律走 createPortal 挂 document.body,避免被侧栏 overflow 裁剪。 ---------- */
|
|
514
|
+
function AskText(props) {
|
|
515
|
+
const { title, confirmText, cancelText, onConfirm, onCancel } = props
|
|
516
|
+
const [v, setV] = useState('')
|
|
517
|
+
const ref = React.useRef(null)
|
|
518
|
+
useEffect(() => { ref.current?.focus() }, [])
|
|
519
|
+
const submit = () => { const s = v.trim(); if (s) onConfirm(s) }
|
|
520
|
+
return h('div', {
|
|
521
|
+
style: { position: 'fixed', inset: 0, zIndex: 99999, background: 'rgba(0,0,0,.35)', display: 'flex', alignItems: 'center', justifyContent: 'center', font: 'inherit' },
|
|
522
|
+
onClick: onCancel,
|
|
523
|
+
},
|
|
524
|
+
h('div', {
|
|
525
|
+
style: { width: '320px', maxWidth: '90vw', padding: '16px', borderRadius: '10px', boxShadow: '0 8px 30px rgba(0,0,0,.3)', background: 'var(--dsw-alias-bg-layer-1, #ffffff)', color: 'var(--dsw-alias-label-primary, #24292f)' },
|
|
526
|
+
onClick: (e) => e.stopPropagation(),
|
|
527
|
+
},
|
|
528
|
+
h('div', { style: { fontSize: '13px', fontWeight: 600, margin: '0 0 10px' } }, title),
|
|
529
|
+
h('input', {
|
|
530
|
+
ref, value: v, type: 'text',
|
|
531
|
+
style: { width: '100%', boxSizing: 'border-box', fontSize: '13px', padding: '6px 10px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l1, #d0d7de)', background: 'var(--dsw-alias-bg-layer-2, #f6f8fa)', color: 'inherit', outline: 'none' },
|
|
532
|
+
onChange: (e) => setV(e.target.value),
|
|
533
|
+
onKeyDown: (e) => { if (e.key === 'Enter') submit(); else if (e.key === 'Escape') onCancel() },
|
|
534
|
+
}),
|
|
535
|
+
h('div', { style: { display: 'flex', gap: '8px', justifyContent: 'flex-end', margin: '12px 0 0' } },
|
|
536
|
+
h('button', { type: 'button', style: S.btnGhost, onClick: onCancel }, cancelText),
|
|
537
|
+
h('button', { type: 'button', style: S.btnPrimary, onClick: submit }, confirmText))))
|
|
538
|
+
}
|
|
539
|
+
|
|
453
540
|
function LibraryTree(props) {
|
|
454
541
|
const { layout, uiWorkspace, sidebarRight, wide, expandSidebar } = props
|
|
455
542
|
const [lib, setLib] = useState(null)
|
|
@@ -476,11 +563,15 @@ window.__ModuleLoader__.load({
|
|
|
476
563
|
|
|
477
564
|
// 打开 PDF 页签:等 rightbar seat 绑定到目标会话后只调一次 openTab(成功即停,
|
|
478
565
|
// 不再轮询——否则用户关页签会被后续 tick 重开,「关很多次才能关掉」)。
|
|
566
|
+
// 「在屏会话」的读法两代不同:0.1.x 是 sidebarRight.binding.sessionId,
|
|
567
|
+
// 0.2.0 起没有 binding,换成 mounted store(快照=在屏 sessionId)——
|
|
568
|
+
// 只认旧的会把新版的页签轮询 30 次全部耗尽,PDF 永远开不出来(issue #1 实测)。
|
|
569
|
+
const onScreenSession = (sr) => sr.binding?.sessionId ?? sr.mounted?.getSnapshot?.()
|
|
479
570
|
const openPdfTab = useCallback((sessionId) => {
|
|
480
571
|
let tries = 30
|
|
481
572
|
const tick = () => {
|
|
482
573
|
try {
|
|
483
|
-
if (sidebarRight
|
|
574
|
+
if (onScreenSession(sidebarRight) !== sessionId) throw new Error('seat-not-bound')
|
|
484
575
|
sidebarRight.openTab('dpr-reader', { revealIfOpened: true })
|
|
485
576
|
} catch {
|
|
486
577
|
// 快轮询:seat 重绑一完成就开页签,把"面板空白"窗口压到最短
|
|
@@ -497,7 +588,7 @@ window.__ModuleLoader__.load({
|
|
|
497
588
|
const tick = () => {
|
|
498
589
|
try {
|
|
499
590
|
const snap = uiWorkspace.sessions?.list?.getSnapshot?.()
|
|
500
|
-
if (snap
|
|
591
|
+
if (currentSessionOf(snap) === sessionId) return // 已是当前会话
|
|
501
592
|
const listed = !!(snap && (snap.byId?.[sessionId] || snap.ids?.includes(sessionId)))
|
|
502
593
|
if (listed) uiWorkspace.openSession(sessionId)
|
|
503
594
|
if (--tries > 0) setTimeout(tick, 300)
|
|
@@ -537,7 +628,7 @@ window.__ModuleLoader__.load({
|
|
|
537
628
|
// 离开的若是空白草稿(点了新建又没问任何问题)→ 后台归档,不留在会话列表里
|
|
538
629
|
try {
|
|
539
630
|
const snap = uiWorkspace.sessions?.list?.getSnapshot?.()
|
|
540
|
-
const leaving = snap
|
|
631
|
+
const leaving = currentSessionOf(snap)
|
|
541
632
|
if (leaving && typeof leaving === 'string' && leaving.startsWith('dpr-') && leaving !== target.sessionId
|
|
542
633
|
&& snap?.byId?.[leaving]?.blank && !snap.byId[leaving].running) {
|
|
543
634
|
api('/sessions/archive', {
|
|
@@ -587,15 +678,19 @@ window.__ModuleLoader__.load({
|
|
|
587
678
|
loadSessions(topic, name) // 后台对账:让 host 的空白过滤把被顶替的旧空白会话收掉
|
|
588
679
|
}, [activate, loadSessions])
|
|
589
680
|
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
681
|
+
const [askTopic, setAskTopic] = useState(false)
|
|
682
|
+
const createTopic = useCallback(async (raw) => {
|
|
683
|
+
const name = raw?.trim()
|
|
684
|
+
if (!name) return
|
|
593
685
|
await api('/library/topic', {
|
|
594
686
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
595
|
-
body: JSON.stringify({ name
|
|
687
|
+
body: JSON.stringify({ name }),
|
|
596
688
|
})
|
|
597
689
|
refresh()
|
|
598
690
|
}, [refresh])
|
|
691
|
+
// 新建专题:window.prompt 在 Electron 渲染进程不可用(抛 "prompt() is not
|
|
692
|
+
// supported",点击无任何反应),改为 AskText 页内弹窗(见 LibraryTree return)。
|
|
693
|
+
const newTopic = useCallback(() => setAskTopic(true), [])
|
|
599
694
|
|
|
600
695
|
const upload = useCallback((topic) => {
|
|
601
696
|
uploadTopic.current = topic
|
|
@@ -616,13 +711,30 @@ window.__ModuleLoader__.load({
|
|
|
616
711
|
const body = await r.json().catch(() => ({}))
|
|
617
712
|
throw new Error(body.error || body.reason || ('HTTP ' + r.status))
|
|
618
713
|
}
|
|
619
|
-
}).catch((err) =>
|
|
714
|
+
}).catch((err) => toast('上传失败: ' + err.message))
|
|
620
715
|
refresh()
|
|
621
716
|
}, [refresh])
|
|
622
717
|
|
|
623
718
|
const topics = lib?.topics ?? []
|
|
624
719
|
const papers = lib?.papers ?? []
|
|
625
720
|
|
|
721
|
+
// 头部「⬆」与空库按钮共用:定位目标专题,没有专题时先自动建「默认专题」
|
|
722
|
+
// (宿主端 mkdir recursive,重名无害)再弹文件选择——空库不再是无入口的死路。
|
|
723
|
+
const uploadSmart = useCallback(async () => {
|
|
724
|
+
let topic = cur?.topic || topics[0]
|
|
725
|
+
if (!topic) {
|
|
726
|
+
topic = t('defaultTopic')
|
|
727
|
+
try {
|
|
728
|
+
await api('/library/topic', {
|
|
729
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
730
|
+
body: JSON.stringify({ name: topic }),
|
|
731
|
+
})
|
|
732
|
+
} catch { /* 已存在等情形忽略,继续走上传 */ }
|
|
733
|
+
refresh()
|
|
734
|
+
}
|
|
735
|
+
upload(topic)
|
|
736
|
+
}, [cur, topics, upload, refresh])
|
|
737
|
+
|
|
626
738
|
// 对话头部按钮 → 树的既有动作(enter/newChat 闭包在这里)
|
|
627
739
|
useEffect(() => {
|
|
628
740
|
const onEnter = (e) => enter(e.detail.topic, e.detail.name, e.detail.n)
|
|
@@ -647,10 +759,15 @@ window.__ModuleLoader__.load({
|
|
|
647
759
|
: h(React.Fragment, null,
|
|
648
760
|
h('div', { style: S.head },
|
|
649
761
|
h('span', { style: { flex: 1 } }, t('library')),
|
|
762
|
+
h('button', { style: S.iconBtn, title: t('upload'), onClick: () => uploadSmart() }, '⬆'),
|
|
650
763
|
h('button', { style: S.iconBtn, title: t('newTopic'), onClick: newTopic }, '+'),
|
|
651
764
|
h('button', { style: S.iconBtn, title: 'refresh', onClick: refresh }, '⟳'),
|
|
652
765
|
),
|
|
653
|
-
lib && topics.length === 0 && h('div', { style: S.empty },
|
|
766
|
+
lib && topics.length === 0 && h('div', { style: S.empty },
|
|
767
|
+
`${t('empty')}:${t('hint')}`,
|
|
768
|
+
h('div', { style: { display: 'flex', gap: '8px', margin: '8px 0 2px' } },
|
|
769
|
+
h('button', { type: 'button', style: S.linkBtn, onClick: () => uploadSmart() }, '⬆ ', t('uploadBtn')),
|
|
770
|
+
h('button', { type: 'button', style: S.linkBtn, onClick: newTopic }, '+ ', t('newTopic')))),
|
|
654
771
|
topics.map((topic) => {
|
|
655
772
|
const isClosed = !!closed[topic]
|
|
656
773
|
return h('div', { key: topic },
|
|
@@ -689,6 +806,16 @@ window.__ModuleLoader__.load({
|
|
|
689
806
|
// 阅读器 iframe 与对话头部按钮组已迁往 shell.overlay 常驻注册(见 applyPaperMode 下方)——
|
|
690
807
|
// 它们不属于侧栏,更不能跟着「退出伴读模式」被卸载(已打开的 PDF 页签要继续活着)
|
|
691
808
|
tree,
|
|
809
|
+
askTopic && createPortal(
|
|
810
|
+
h(AskText, {
|
|
811
|
+
title: t('newTopic') + ':', confirmText: t('ok'), cancelText: t('cancel'),
|
|
812
|
+
onConfirm: (name) => {
|
|
813
|
+
setAskTopic(false)
|
|
814
|
+
createTopic(name).catch((err) => toast(t('newTopicFail') + ': ' + err.message))
|
|
815
|
+
},
|
|
816
|
+
onCancel: () => setAskTopic(false),
|
|
817
|
+
}),
|
|
818
|
+
document.body),
|
|
692
819
|
)
|
|
693
820
|
}
|
|
694
821
|
|
|
@@ -744,6 +871,90 @@ window.__ModuleLoader__.load({
|
|
|
744
871
|
}, wide ? h(React.Fragment, null, '📚', h('span', null, t('mode'))) : '📚')
|
|
745
872
|
}
|
|
746
873
|
|
|
874
|
+
/* ---------- 设置面板:Jev 重排 + 翻译端点统一管理 ----------
|
|
875
|
+
settings.section 面板(与官方 通用设置/模型 同款缝)。两张端点卡片共用一套
|
|
876
|
+
读写口(GET 掩码回显 / POST 预检后落盘 / DELETE 回落默认),key 明文永不回传,
|
|
877
|
+
留空即沿用已保存的那把。保存即生效:宿主每次调用都现读配置文件。 */
|
|
878
|
+
const SET = {
|
|
879
|
+
card: { border: '1px solid var(--dsw-alias-border-l1, #30363d)', borderRadius: '10px', padding: '14px 16px', margin: '0 0 16px', background: 'var(--dsw-alias-bg-layer-1, #0d1117)' },
|
|
880
|
+
title: { fontSize: '14px', fontWeight: 600, color: 'var(--dsw-alias-label-primary, #24292f)', margin: '0 0 4px' },
|
|
881
|
+
desc: { fontSize: '12px', lineHeight: '1.6', color: 'var(--dsw-alias-label-secondary, #656d76)', margin: '0 0 12px' },
|
|
882
|
+
label: { display: 'block', fontSize: '12px', color: 'var(--dsw-alias-label-secondary, #656d76)', margin: '10px 0 4px' },
|
|
883
|
+
input: { width: '100%', boxSizing: 'border-box', fontSize: '13px', padding: '6px 8px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l1, #30363d)', background: 'var(--dsw-alias-bg-layer-2, #161b22)', color: 'var(--dsw-alias-label-primary, #24292f)' },
|
|
884
|
+
row: { display: 'flex', alignItems: 'center', gap: '10px', marginTop: '12px' },
|
|
885
|
+
btn: { fontSize: '13px', padding: '6px 14px', borderRadius: '6px', border: 'none', cursor: 'pointer', background: 'var(--dsw-alias-button-primary-fill, #1f6feb)', color: 'var(--dsw-alias-label-primary-foreground, #fff)' },
|
|
886
|
+
btnGhost: { fontSize: '12px', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer', background: 'none', border: '1px solid var(--dsw-alias-border-l1, #30363d)', color: 'var(--dsw-alias-label-secondary, #656d76)' },
|
|
887
|
+
ok: { fontSize: '12px', color: 'var(--dsw-alias-state-success-primary, #3fb950)' },
|
|
888
|
+
err: { fontSize: '12px', color: 'var(--dsw-alias-state-error-primary, #f85149)' },
|
|
889
|
+
meta: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary, #656d76)' },
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function EndpointCard(props) {
|
|
893
|
+
const [cfg, setCfg] = useState(null) // GET 结果(含 apiKeyHint/source)
|
|
894
|
+
const [form, setForm] = useState({ baseUrl: '', model: '', apiKey: '' })
|
|
895
|
+
const [busy, setBusy] = useState(false)
|
|
896
|
+
const [msg, setMsg] = useState(null) // { ok, text }
|
|
897
|
+
const load = useCallback(() => {
|
|
898
|
+
api(props.path).then((d) => {
|
|
899
|
+
setCfg(d)
|
|
900
|
+
setForm({ baseUrl: d.baseUrl || '', model: d.model || '', apiKey: '' })
|
|
901
|
+
}).catch((e) => setMsg({ ok: false, text: String(e.message || e) }))
|
|
902
|
+
}, [props.path])
|
|
903
|
+
useEffect(load, [load])
|
|
904
|
+
const sourceText = !cfg ? '' : { file: t('setSourceFile'), profile: t('setSourceProfile'), env: t('setSourceEnv'), none: t('setSourceNone') }[cfg.source] || ''
|
|
905
|
+
const save = async () => {
|
|
906
|
+
setBusy(true); setMsg(null)
|
|
907
|
+
try {
|
|
908
|
+
await api(props.path, {
|
|
909
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
910
|
+
body: JSON.stringify(form),
|
|
911
|
+
})
|
|
912
|
+
setMsg({ ok: true, text: t('setSaved') })
|
|
913
|
+
load()
|
|
914
|
+
} catch (e) { setMsg({ ok: false, text: String(e.message || e) }) } finally { setBusy(false) }
|
|
915
|
+
}
|
|
916
|
+
const clear = async () => {
|
|
917
|
+
setBusy(true); setMsg(null)
|
|
918
|
+
try {
|
|
919
|
+
await api(props.path, { method: 'DELETE' })
|
|
920
|
+
setMsg({ ok: true, text: t('setCleared') })
|
|
921
|
+
load()
|
|
922
|
+
} catch (e) { setMsg({ ok: false, text: String(e.message || e) }) } finally { setBusy(false) }
|
|
923
|
+
}
|
|
924
|
+
const field = (key, label, placeholder, type) => h('div', null,
|
|
925
|
+
h('label', { style: SET.label }, label, props.optional && key !== 'apiKey' ? `(${t('setOptional')})` : ''),
|
|
926
|
+
h('input', {
|
|
927
|
+
style: SET.input, type: type || 'text', value: form[key], placeholder, disabled: busy,
|
|
928
|
+
onChange: (e) => setForm({ ...form, [key]: e.target.value }),
|
|
929
|
+
}),
|
|
930
|
+
)
|
|
931
|
+
return h('div', { style: SET.card, 'data-dsh-plugin': 'dsh-paper-reader', 'data-dsh-part': 'settings-card' },
|
|
932
|
+
h('h3', { style: SET.title }, props.title),
|
|
933
|
+
h('p', { style: SET.desc }, props.desc),
|
|
934
|
+
field('baseUrl', t('setBaseUrl'), props.baseUrlPlaceholder),
|
|
935
|
+
field('model', t('setModel'), props.modelPlaceholder),
|
|
936
|
+
field('apiKey', t('setKey'), cfg?.hasApiKey ? t('setKeyKeep') + cfg.apiKeyHint : props.keyPlaceholder, 'password'),
|
|
937
|
+
h('div', { style: SET.row },
|
|
938
|
+
h('button', { type: 'button', style: SET.btn, disabled: busy, onClick: save }, busy ? t('setSaving') : t('setSave')),
|
|
939
|
+
cfg?.source === 'file' ? h('button', { type: 'button', style: SET.btnGhost, disabled: busy, onClick: clear }, t('setClear')) : null,
|
|
940
|
+
msg ? h('span', { style: msg.ok ? SET.ok : SET.err }, msg.text) : h('span', { style: SET.meta }, sourceText),
|
|
941
|
+
),
|
|
942
|
+
)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function SettingsPanel() {
|
|
946
|
+
return h('div', { style: { maxWidth: '560px' }, 'data-dsh-plugin': 'dsh-paper-reader', 'data-dsh-surface': 'settings-modal' },
|
|
947
|
+
h(EndpointCard, {
|
|
948
|
+
path: '/typesafe/config', title: t('setRerankTitle'), desc: t('setRerankDesc'), optional: true,
|
|
949
|
+
baseUrlPlaceholder: 'https://api.typesafe.ai', modelPlaceholder: 'jev-latest', keyPlaceholder: 'ts-...',
|
|
950
|
+
}),
|
|
951
|
+
h(EndpointCard, {
|
|
952
|
+
path: '/translate/config', title: t('setTranslateTitle'), desc: t('setTranslateDesc'),
|
|
953
|
+
baseUrlPlaceholder: 'https://api.deepseek.com/v1', modelPlaceholder: 'deepseek-chat', keyPlaceholder: 'sk-...',
|
|
954
|
+
}),
|
|
955
|
+
)
|
|
956
|
+
}
|
|
957
|
+
|
|
747
958
|
return {
|
|
748
959
|
inject: ['slots', 'locale', 'layout', 'uiWorkspace', 'sidebarRight', 'sidebarRightTabs'],
|
|
749
960
|
apply(ctx) {
|
|
@@ -779,6 +990,12 @@ window.__ModuleLoader__.load({
|
|
|
779
990
|
name: 'sidebar.right.pane.tab', key: 'dpr-reader', locale: NS,
|
|
780
991
|
}, PdfTab))
|
|
781
992
|
|
|
993
|
+
// 设置面板(设置 → 论文伴读):Jev 重排 + 翻译端点统一管理
|
|
994
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
995
|
+
name: 'settings.section', id: 'dsh-paper-reader', order: 70,
|
|
996
|
+
label: () => bound('settings'), locale: NS,
|
|
997
|
+
}, SettingsPanel))
|
|
998
|
+
|
|
782
999
|
clientApi = { slots: ctx.slots, layout: ctx.layout, uiWorkspace: ctx.uiWorkspace, sidebarRight: ctx.sidebarRight }
|
|
783
1000
|
|
|
784
1001
|
// 阅读器跳转链接拦截(capture 阶段,一次绑定):渲染器给 http(s) 链接加了
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggboy123/dsh-paper-reader",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "DeepSeek Harness 插件:论文伴读工作台 — PDF 转录 / 检索 / 原生对话伴读 + 内置阅读器页面。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -50,19 +50,31 @@
|
|
|
50
50
|
"keywords": [
|
|
51
51
|
"dsh-plugin",
|
|
52
52
|
"deepseek-harness",
|
|
53
|
+
"deepseek",
|
|
53
54
|
"pdf",
|
|
55
|
+
"pdf-reader",
|
|
54
56
|
"paper",
|
|
55
|
-
"
|
|
57
|
+
"paper-reading",
|
|
58
|
+
"research-paper",
|
|
59
|
+
"citation",
|
|
60
|
+
"reader",
|
|
61
|
+
"arxiv",
|
|
62
|
+
"ai-assistant",
|
|
63
|
+
"rag",
|
|
64
|
+
"translation"
|
|
56
65
|
],
|
|
57
66
|
"dependencies": {
|
|
58
|
-
"@
|
|
59
|
-
"pdfjs-dist": "^6.3.289"
|
|
67
|
+
"@typesafe-ai/sdk": "^0.6.0",
|
|
68
|
+
"pdfjs-dist": "^6.3.289",
|
|
69
|
+
"undici": "^8.10.2"
|
|
60
70
|
},
|
|
61
71
|
"peerDependencies": {
|
|
62
|
-
"@deepseek-ai/cordis": "^4.0.2"
|
|
72
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
73
|
+
"@deepseek-ai/dsh-tools": ">=0.1.5-rc.1 <0.3.0-0"
|
|
63
74
|
},
|
|
64
75
|
"devDependencies": {
|
|
65
76
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
77
|
+
"@deepseek-ai/dsh-tools": "0.1.5-rc.2",
|
|
66
78
|
"@types/node": "^22.10.0",
|
|
67
79
|
"typescript": "^5.6.0"
|
|
68
80
|
}
|
package/reader/index.html
CHANGED
|
@@ -4,12 +4,10 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>论文伴读 · dsh-paper-reader</title>
|
|
7
|
-
<!-- pdf.js 6.x 本地 vendor(不再走 CDN):3.11 的字体解析对 babeldoc 嵌入的 CID
|
|
8
|
-
<script type="module"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
pdfjsLib.GlobalWorkerOptions.workerSrc = '/paper-reader/vendor/pdf.worker.min.mjs';
|
|
12
|
-
</script>
|
|
7
|
+
<!-- pdf.js 6.x 本地 vendor(不再走 CDN):3.11 的字体解析对 babeldoc 嵌入的 CID 字体会回退成黑体。
|
|
8
|
+
不用 <script type="module">:module 是 defer 语义(整页解析完 + 458KB 拉取编译完才执行),
|
|
9
|
+
而底部经典脚本在解析期就开跑 bootstrap(),库里有论文时先到 loadPdf,此刻 window.pdfjsLib
|
|
10
|
+
还没就绪 → "pdfjsLib is not defined"。改为经典脚本里动态 import()(见 ensurePdfjs),顺序有保证。 -->
|
|
13
11
|
<style>
|
|
14
12
|
:root {
|
|
15
13
|
--bg: #f6f7f9; --panel: #ffffff; --border: #e2e5ea; --text: #24292f;
|
|
@@ -172,6 +170,7 @@ function esc(s) { return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<
|
|
|
172
170
|
|
|
173
171
|
let current = null; // { topic, name }
|
|
174
172
|
let zhState = { zh: false, dual: false, busy: false };
|
|
173
|
+
let statusMeta = ''; // openPaper 的「name · N 页」基线,翻译阶段标签褪去后恢复它
|
|
175
174
|
let zhVariant = null; // null=原文 'dual'=中英对照 'zh'=纯中文
|
|
176
175
|
// 每篇论文记住上次用的语言版本:伴读会话的跳转链接原地跳页不重载,
|
|
177
176
|
// 加上这里的恢复,「中文界面跳中文」在同篇跳转/重开/跨论文三条路径上都成立
|
|
@@ -190,9 +189,10 @@ async function openPaper(topic, name, opts = {}) {
|
|
|
190
189
|
$('status').textContent = '加载 PDF…';
|
|
191
190
|
const q = `topic=${encodeURIComponent(topic)}&name=${encodeURIComponent(name)}`;
|
|
192
191
|
const meta = await api('/paper?' + q);
|
|
193
|
-
|
|
192
|
+
statusMeta = meta.hasTranscript
|
|
194
193
|
? `${name} · ${meta.pageCount ?? '?'} 页`
|
|
195
194
|
: `${name} · 未转录(提问时会自动转录)`;
|
|
195
|
+
$('status').textContent = statusMeta;
|
|
196
196
|
await refreshZhState();
|
|
197
197
|
// 恢复该论文上次的语言版本(译文已不存在时回落原文)
|
|
198
198
|
const saved = localStorage.getItem(variantKey(topic, name));
|
|
@@ -213,8 +213,15 @@ async function refreshZhState() {
|
|
|
213
213
|
btn.title = zhState.busy ? (zhState.phase || `翻译进行中${zhState.model ? `(${zhState.model})` : ''}…`)
|
|
214
214
|
: (zhState.zh || zhState.dual) ? '切换中文译文'
|
|
215
215
|
: '还没有中文版,点击生成(babeldoc,需要几分钟)';
|
|
216
|
-
// 安装/准备阶段在状态栏持续可见(轮询每 5s
|
|
217
|
-
|
|
216
|
+
// 安装/准备阶段在状态栏持续可见(轮询每 5s 刷新一次);翻译结束后恢复论文基线信息
|
|
217
|
+
const statusEl = $('status');
|
|
218
|
+
if (zhState.busy && zhState.phase) {
|
|
219
|
+
statusEl.textContent = zhState.phase;
|
|
220
|
+
statusEl.dataset.phaseShown = '1';
|
|
221
|
+
} else if (!zhState.busy && statusEl.dataset.phaseShown) {
|
|
222
|
+
delete statusEl.dataset.phaseShown;
|
|
223
|
+
if (statusMeta) statusEl.textContent = statusMeta;
|
|
224
|
+
}
|
|
218
225
|
// ↻ 只在「已有译文且空闲」时出现;状态变化时顺手拆掉两击确认的待确认态
|
|
219
226
|
const regen = $('zh-regen');
|
|
220
227
|
const canRegen = (zhState.zh || zhState.dual) && !zhState.busy;
|
|
@@ -389,7 +396,27 @@ $('cfg-save').onclick = async () => {
|
|
|
389
396
|
};
|
|
390
397
|
|
|
391
398
|
/* ================= PDF 渲染(pdf.js:缩放 + devicePixelRatio 高清,移植自 pdfqa) ================= */
|
|
392
|
-
/*
|
|
399
|
+
/* pdf.js 6.x 本地 vendor:动态 import(缓存的 Promise),loadPdf 先 await 再用——
|
|
400
|
+
底部经典脚本解析期就开跑 bootstrap(),必须由这里保证「pdf.js 就绪后才渲染」;
|
|
401
|
+
失败(vendor 404 / MIME 拒绝)抛带 URL 的可读错误,而不是莫名其妙的 ReferenceError。 */
|
|
402
|
+
let pdfjsReadyP = null;
|
|
403
|
+
function ensurePdfjs() {
|
|
404
|
+
if (!pdfjsReadyP) {
|
|
405
|
+
pdfjsReadyP = import('/paper-reader/vendor/pdf.min.mjs').then(
|
|
406
|
+
(m) => {
|
|
407
|
+
window.pdfjsLib = m;
|
|
408
|
+
m.GlobalWorkerOptions.workerSrc = '/paper-reader/vendor/pdf.worker.min.mjs';
|
|
409
|
+
return m;
|
|
410
|
+
},
|
|
411
|
+
(e) => {
|
|
412
|
+
pdfjsReadyP = null; // 失败后清缓存:瞬时故障不至于让本页签永久坏掉,下次 loadPdf 重试
|
|
413
|
+
throw new Error('pdf.js 加载失败(/paper-reader/vendor/pdf.min.mjs):' + (e && e.message ? e.message : e));
|
|
414
|
+
},
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
return pdfjsReadyP;
|
|
418
|
+
}
|
|
419
|
+
ensurePdfjs().catch(() => {}); // 预热:与 bootstrap 的 API 请求并行拉取,不把首屏串行化
|
|
393
420
|
|
|
394
421
|
let pdfDoc = null;
|
|
395
422
|
let zoomScale = 1.5;
|
|
@@ -407,6 +434,7 @@ let renderToken = 0;
|
|
|
407
434
|
})();
|
|
408
435
|
|
|
409
436
|
async function loadPdf(url) {
|
|
437
|
+
await ensurePdfjs(); // 就绪前禁止触 pdfjsLib(defer 竞态,见文件头注释)
|
|
410
438
|
pdfDoc = await pdfjsLib.getDocument({ url }).promise; // v6 起不接受字符串参数,必须传对象
|
|
411
439
|
$('empty').style.display = 'none';
|
|
412
440
|
if (fitMode) { fitToWidth(); return; } // fitToWidth 内部 setZoom → renderAllPages
|