@ggboy123/dsh-paper-reader 0.1.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 +73 -0
- package/cordis.patch.yml +9 -0
- package/dist/host.js +451 -0
- package/dist/index.js +17 -0
- package/dist/library.js +103 -0
- package/dist/preset.js +31 -0
- package/dist/search.js +98 -0
- package/dist/study.js +100 -0
- package/dist/tools.js +307 -0
- package/dist/transcribe.js +185 -0
- package/dist/translate-config.js +104 -0
- package/dist/translate.js +125 -0
- package/lib/client.js +720 -0
- package/package.json +68 -0
- package/presets/paper-reader/agent.cordis.yml +57 -0
- package/presets/paper-reader/preset.yml +3 -0
- package/reader/index.html +433 -0
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# dsh-paper-reader
|
|
2
|
+
|
|
3
|
+
[DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 插件:论文伴读工作台 —— PDF 转录 / 检索 + 原生对话伴读 + 内置阅读器。
|
|
4
|
+
|
|
5
|
+
功能迁移自本地独立产品 pdfqa(Go 实现的论文伴读工具)。原则:agent 循环 / 模型层 / 会话持久化全部交给 dsh 框架,插件只做「转录 + 检索 + 阅读器 UI」的薄壳组合。
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
- 📚 **侧栏文献库树**:接管工作区侧栏,专题 → 论文两级;支持新建专题、上传 PDF
|
|
10
|
+
- 💬 **原生对话伴读**:点击论文即在右侧打开它的 dsh 原生会话(流式 / 工具卡 / 用量条全是官方 UI);默认只呈现当前对话,对话列头部的 🕐 下拉回看**历史对话**(真实标题+日期,点哪条进哪条),+ 新建对话
|
|
11
|
+
- 🔗 **会话绑定论文**:会话 id 编码文献身份,工具调用自动定位当前论文——直接在输入框提问无需点名论文,回答必带页码
|
|
12
|
+
- 🤖 **专用 agent preset「论文伴读」**:paper 会话自动使用专用 preset —— 像老师一样带读:制定分步阅读计划(每步带页码和思考题)→ 逐节指导、点评 → 出题检验、逐题批改 → 成绩与薄弱点记入 `<文献>.study.json` 学习档案,跨会话累积;快速问答(选中即问)则直接回答,不上课
|
|
13
|
+
- 📖 **PDF 阅读器居中**:中间是论文(PDF.js 缩放 / Retina 高清 / 文本层选择 / 适宽模式),最右是该论文的原生对话 —— 视觉换位实现,列宽拖拽/折叠仍是 dsh 原生行为,关掉 PDF 页签即还原官方布局
|
|
14
|
+
- 💬 **选中即问**:阅读器里选中文字 → 弹出提问框 → 注入当前会话,原生对话区实时回答
|
|
15
|
+
- 📍 **引用定位**:回答里的「第 N 页」可点击,平滑跳回 PDF 对应页并闪烁
|
|
16
|
+
- 🀄 **中英切换**:顶栏「中」按钮原文 ↔ 纯中文切换;无译文时一键后台生成(babeldoc),生成需配置 `translate` 端点
|
|
17
|
+
- 🔍 **PDF 转录 + 检索**:本地提取(python3 + PyMuPDF),页眉页脚剔除 / 连字 / 断词愈合 / 段落重排,产出页码偏移表;兼容 pdfqa 的 `data/` 缓存布局(旧缓存读取时自动补建页码索引)
|
|
18
|
+
|
|
19
|
+
## 安装(待发布)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
dsh plugin --profile web add github:GGboya/dsh-paper-reader
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
本地开发:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pnpm install && pnpm run build
|
|
29
|
+
dsh plugin --profile web add ./dsh-paper-reader # 从父目录执行
|
|
30
|
+
dsh --profile web # 侧栏变为文献库树
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
文献库目录默认 `~/.dsh-paper-reader/data`;在 profile 的 `cordis.patch.yml` 里可改:
|
|
34
|
+
|
|
35
|
+
```yaml
|
|
36
|
+
- id: dsh-paper-reader
|
|
37
|
+
config:
|
|
38
|
+
dataDir: /path/to/pdfqa/data # 直接指向 pdfqa 文献库即可复用全部缓存
|
|
39
|
+
# translate: # 可选:生成中文版(babeldoc)用的 OpenAI 兼容端点。
|
|
40
|
+
# baseUrl: https://api.deepseek.com/v1 # 一般不用写在这里——阅读器里点「中」
|
|
41
|
+
# apiKey: sk-... # 或右上角 ⚙ 可直接填,存到
|
|
42
|
+
# model: deepseek-chat # ~/.dsh/.dsh-paper-reader/translate.json(0600)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
> 中文版端点:**优先在阅读器 UI 里填**(点「中」→ 没配会自动弹表单,或点 ⚙)——填完会先测连接再落盘,不用重启。
|
|
46
|
+
> 上面的 `translate` 配置退为**部署方默认值**,仅当 UI 未配置时生效。babeldoc 只支持 OpenAI 协议端点,
|
|
47
|
+
> Anthropic 协议的端点(如 `api.kimi.com/coding/`)不能直连。
|
|
48
|
+
|
|
49
|
+
⚠️ 本插件按「论文伴读专用 profile」设计:侧栏的 `sidebar.workspaces` 被文献库树接管,官方工作区/会话列表在该 profile 侧栏不可见(从 `cordis.patch.yml` 删掉本插件即恢复)。
|
|
50
|
+
|
|
51
|
+
## 架构
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
src/
|
|
55
|
+
index.ts Cordis 壳:inject tools;webServer 等服务就绪后挂路由
|
|
56
|
+
tools.ts 5 个 agent 工具:list_papers / transcribe_pdf / search_paper / study_progress / study_update
|
|
57
|
+
host.ts webServer 路由(/paper-reader/*),connection.requestRejection 鉴权;
|
|
58
|
+
sessionController.create/prompt(agentPreset=paper-reader)+ follow SSE 桥
|
|
59
|
+
library.ts 纯函数:文献库目录约定与解析
|
|
60
|
+
transcribe.ts 纯函数:PyMuPDF 提取 + 页码偏移表(视觉兜底后置)
|
|
61
|
+
search.ts 纯函数:分段 + 关键词打分 + 页码映射
|
|
62
|
+
study.ts 纯函数:学习档案(计划 + 检验成绩)读写
|
|
63
|
+
translate.ts 纯函数:babeldoc 调用(中文/中英对照 PDF 生成)
|
|
64
|
+
translate-config.ts 纯函数:翻译端点配置读写($DSH_HOME 下 0600)+ 保存前连接预检
|
|
65
|
+
preset.ts 纯函数:自带 agent preset 安装到 $DSH_HOME/.agent-presets/
|
|
66
|
+
presets/paper-reader/ 「论文伴读」agent preset(persona 完整提示词 + compaction)
|
|
67
|
+
reader/index.html 阅读器页面(pdf.js,独立于 React 宿主,iframe 承载)
|
|
68
|
+
lib/client.js 浏览器半边:文献库树(sidebar.workspaces 接管) + PDF 页签 + 主面板
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# dsh-paper-reader bundle layer — applied when a profile lists this bundle.
|
|
2
|
+
# `name` is a PACKAGE NAME (resolved through the profile's node_modules or the
|
|
3
|
+
# installation fallback $DSH_HOME/profiles/node_modules), NOT a relative path.
|
|
4
|
+
# id 是配置定位用的短 slug(README 里 - id: dsh-paper-reader 的示例靠它命中),
|
|
5
|
+
# 与 npm 包名解耦 —— npm 上 dsh-paper-reader 已被占用,包名走 @ggboy123 作用域。
|
|
6
|
+
# YAML 里 @ 开头的字符串必须加引号。
|
|
7
|
+
- insert:
|
|
8
|
+
- id: dsh-paper-reader
|
|
9
|
+
name: '@ggboy123/dsh-paper-reader'
|
package/dist/host.js
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// host.ts — webServer 路由:阅读器页面 + PDF 文件 + 转录数据接口 + 选中即问。
|
|
2
|
+
// 壳层代码:只做 HTTP ↔ 纯函数核心(library/transcribe)的转接。
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
4
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { listPapers, listTopics, resolveDataDir, resolvePaper } from "./library.js";
|
|
8
|
+
import { readTranscript, transcribePaper } from "./transcribe.js";
|
|
9
|
+
import { pdfVariantPath, startTranslation, zhStatus } from "./translate.js";
|
|
10
|
+
import { clearTranslateConfig, maskApiKey, readTranslateConfig, resolveTranslateEndpoint, testTranslateEndpoint, writeTranslateConfig, } from "./translate-config.js";
|
|
11
|
+
import { installPaperPreset, PAPER_PRESET_ID } from "./preset.js";
|
|
12
|
+
function json(res, status, body) {
|
|
13
|
+
const buf = Buffer.from(JSON.stringify(body), 'utf8');
|
|
14
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': buf.length });
|
|
15
|
+
res.end(buf);
|
|
16
|
+
}
|
|
17
|
+
async function readBody(req) {
|
|
18
|
+
const chunks = [];
|
|
19
|
+
for await (const c of req)
|
|
20
|
+
chunks.push(c);
|
|
21
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
22
|
+
return raw ? JSON.parse(raw) : {};
|
|
23
|
+
}
|
|
24
|
+
/** header 元数据解码:客户端 encodeURIComponent 过(HTTP header 值不允许非 Latin-1,如中文文件名);非字符串/未编码原样返回。 */
|
|
25
|
+
function decodeHdr(v) {
|
|
26
|
+
if (typeof v !== 'string')
|
|
27
|
+
return v;
|
|
28
|
+
try {
|
|
29
|
+
return decodeURIComponent(v);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return v;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function registerRoutes(ctx, config) {
|
|
36
|
+
const ws = ctx.webServer;
|
|
37
|
+
const connection = ctx.connection;
|
|
38
|
+
const sessionController = ctx.sessionController;
|
|
39
|
+
const workspaceController = ctx.workspaceController;
|
|
40
|
+
const workspaceRegistry = ctx.workspaceRegistry;
|
|
41
|
+
const dataDir = resolveDataDir(config.dataDir);
|
|
42
|
+
// dist/host.js → 包根/reader/index.html
|
|
43
|
+
const readerHtml = new URL('../reader/index.html', import.meta.url);
|
|
44
|
+
// 自带「论文伴读」agent preset → $DSH_HOME/.agent-presets/(实时扫描,免重启);
|
|
45
|
+
// 失败(旧版 dsh/无权限)则回退默认 preset,功能不受影响。
|
|
46
|
+
const presetDir = installPaperPreset(new URL('../', import.meta.url));
|
|
47
|
+
const locate = (url) => {
|
|
48
|
+
const path = url.searchParams.get('path') ?? undefined;
|
|
49
|
+
const topic = url.searchParams.get('topic') ?? undefined;
|
|
50
|
+
const name = url.searchParams.get('name') ?? undefined;
|
|
51
|
+
const args = {};
|
|
52
|
+
if (path)
|
|
53
|
+
args.path = path;
|
|
54
|
+
if (topic)
|
|
55
|
+
args.topic = topic;
|
|
56
|
+
if (name)
|
|
57
|
+
args.name = name;
|
|
58
|
+
return resolvePaper(dataDir, args);
|
|
59
|
+
};
|
|
60
|
+
/** 每篇文献的伴读会话 id(确定性,重启/刷新后可续);n 支持同文献多会话(会话1/2/…)。 */
|
|
61
|
+
const sessionPrefixFor = (ref) => `dpr-${Buffer.from(`${ref.topic}/${ref.name}`).toString('base64url')}`;
|
|
62
|
+
/** 扫描某文献的全部伴读会话(不过滤):按 dsh 会话列表过滤 id 前缀;兼容 v1 无后缀旧会话=会话1。
|
|
63
|
+
* n 的分配必须基于这份全量扫描(含归档/空白)——用过滤后的列表算 maxN 会跟
|
|
64
|
+
* 磁盘上已存在(可能还被别的进程 flock 着)的会话目录撞号,create 直接失败(实测)。 */
|
|
65
|
+
const scanPaperSessions = async (ref) => {
|
|
66
|
+
const prefix = sessionPrefixFor(ref);
|
|
67
|
+
const all = await sessionController.list({}, AbortSignal.timeout(15_000));
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const s of all.items) {
|
|
70
|
+
if (s.sessionId === prefix) {
|
|
71
|
+
out.push({ n: 1, sessionId: s.sessionId, running: s.running, blank: s.blank, updatedAt: s.updatedAt }); // 旧格式 → 会话1
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (s.sessionId.startsWith(prefix + '-')) {
|
|
75
|
+
const n = +s.sessionId.slice(prefix.length + 1) || 0;
|
|
76
|
+
if (n > 0)
|
|
77
|
+
out.push({ n, sessionId: s.sessionId, running: s.running, blank: s.blank, updatedAt: s.updatedAt });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// 同 n 去重(旧格式与新格式并存时旧格式让位);按 n 排序
|
|
81
|
+
const byN = new Map();
|
|
82
|
+
for (const s of out)
|
|
83
|
+
byN.set(s.n, s);
|
|
84
|
+
return [...byN.values()].sort((a, b) => a.n - b.n);
|
|
85
|
+
};
|
|
86
|
+
/** 列表视图:过滤归档集;空白会话(从未提问)只保留最新一个——更早的空会话是被遗弃的
|
|
87
|
+
* 「新建对话」,顺手真归档(官方语义:日志保留、所有列表隐藏),不只是从树上消失。 */
|
|
88
|
+
const listPaperSessions = async (ref) => {
|
|
89
|
+
const archived = new Set(workspaceRegistry?.archivedSessionIds ?? []);
|
|
90
|
+
const sorted = await scanPaperSessions(ref);
|
|
91
|
+
const visible = sorted.filter((s) => !archived.has(s.sessionId));
|
|
92
|
+
const newestBlank = [...visible].reverse().find((s) => s.blank);
|
|
93
|
+
// 非最新的空白会话 = 被遗弃的草稿:后台归档(幂等;最新空白是活跃草稿,留给复用/显式归档)
|
|
94
|
+
for (const s of visible) {
|
|
95
|
+
if (s.blank && s !== newestBlank && !s.running) {
|
|
96
|
+
workspaceRegistry?.archiveSession(s.sessionId).catch(() => { });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return visible.filter((s) => !s.blank || s === newestBlank);
|
|
100
|
+
};
|
|
101
|
+
/** n → 实际 sessionId:旧格式存在时优先(历史不丢),否则新格式。全量扫描(含归档)。 */
|
|
102
|
+
const sessionIdFor = async (ref, n) => {
|
|
103
|
+
const sessions = await scanPaperSessions(ref);
|
|
104
|
+
const hit = sessions.find((s) => s.n === n);
|
|
105
|
+
return hit?.sessionId ?? `${sessionPrefixFor(ref)}-${n}`;
|
|
106
|
+
};
|
|
107
|
+
/** 专题目录 → dsh 工作区(幂等,进程内缓存)。会话挂到工作区后原生 composer 才可用。 */
|
|
108
|
+
const workspaceCache = new Map();
|
|
109
|
+
const ensureTopicWorkspace = async (topic) => {
|
|
110
|
+
const dir = join(dataDir, topic);
|
|
111
|
+
const cached = workspaceCache.get(dir);
|
|
112
|
+
if (cached)
|
|
113
|
+
return cached;
|
|
114
|
+
try {
|
|
115
|
+
const { workspace } = await workspaceController.create({ path: dir });
|
|
116
|
+
workspaceCache.set(dir, workspace.workspaceId);
|
|
117
|
+
return workspace.workspaceId;
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
console.warn('[dsh-paper-reader] workspace 注册失败(回退 cwd 模式):', err);
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
/** 创建/复用会话:优先挂工作区 + 论文伴读 preset;失败(preset 缺失或旧会话 preset 冲突)逐级回退。 */
|
|
125
|
+
const createPaperSession = async (sessionId, topic) => {
|
|
126
|
+
const workspaceId = await ensureTopicWorkspace(topic);
|
|
127
|
+
const usePreset = (await presetDir) !== null;
|
|
128
|
+
if (workspaceId) {
|
|
129
|
+
if (usePreset) {
|
|
130
|
+
try {
|
|
131
|
+
return await sessionController.create({ sessionId, workspaceId, agentPreset: PAPER_PRESET_ID });
|
|
132
|
+
}
|
|
133
|
+
catch { /* preset 未注册或旧会话属另一 preset → 去掉 preset 重试 */ }
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
return await sessionController.create({ sessionId, workspaceId });
|
|
137
|
+
}
|
|
138
|
+
catch { /* 工作区挂载失败则回退 */ }
|
|
139
|
+
}
|
|
140
|
+
return sessionController.create({ sessionId, cwd: join(dataDir, topic) });
|
|
141
|
+
};
|
|
142
|
+
const dispatch = async (req, res, url) => {
|
|
143
|
+
const sub = url.pathname.slice('/paper-reader'.length); // '' | '/' | '/api/...'
|
|
144
|
+
if ((sub === '' || sub === '/') && req.method === 'GET') {
|
|
145
|
+
const html = await readFile(readerHtml);
|
|
146
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': html.length });
|
|
147
|
+
res.end(html);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (sub === '/api/library' && req.method === 'GET') {
|
|
151
|
+
json(res, 200, { dataDir, topics: listTopics(dataDir), papers: listPapers(dataDir) });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// 新建专题(目录)
|
|
155
|
+
if (sub === '/api/library/topic' && req.method === 'POST') {
|
|
156
|
+
const body = (await readBody(req));
|
|
157
|
+
const name = body.name?.trim();
|
|
158
|
+
if (!name || /[/\\]|\.\./.test(name)) {
|
|
159
|
+
json(res, 400, { error: '专题名不能为空,且不能包含 / \\ ..' });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
await mkdir(join(dataDir, name), { recursive: true });
|
|
163
|
+
json(res, 200, { ok: true, topic: name });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// 上传 PDF 到专题(raw body;文件名/专题走 header,避免 multipart 解析。
|
|
167
|
+
// header 值不允许非 Latin-1 字符,中文一律 encodeURIComponent 编码传输,这里解码)
|
|
168
|
+
if (sub === '/api/library/upload' && req.method === 'POST') {
|
|
169
|
+
const topic = decodeHdr(req.headers['x-dpr-topic']);
|
|
170
|
+
const filename = decodeHdr(req.headers['x-dpr-name']);
|
|
171
|
+
if (typeof topic !== 'string' || typeof filename !== 'string' || !topic.trim() || !filename.trim()) {
|
|
172
|
+
json(res, 400, { error: '缺少 x-dpr-topic / x-dpr-name 头' });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!filename.toLowerCase().endsWith('.pdf')) {
|
|
176
|
+
json(res, 400, { error: '只支持 PDF 文件' });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const safeName = filename.replaceAll('/', '_').replaceAll('\\', '_');
|
|
180
|
+
const chunks = [];
|
|
181
|
+
for await (const c of req)
|
|
182
|
+
chunks.push(c);
|
|
183
|
+
const buf = Buffer.concat(chunks);
|
|
184
|
+
if (buf.length < 100) {
|
|
185
|
+
json(res, 400, { error: '文件内容为空' });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const dir = join(dataDir, topic.trim());
|
|
189
|
+
await mkdir(dir, { recursive: true });
|
|
190
|
+
const target = join(dir, safeName);
|
|
191
|
+
await writeFile(target, buf);
|
|
192
|
+
json(res, 200, { ok: true, topic: topic.trim(), name: safeName.slice(0, -4), bytes: buf.length });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// 新建伴读会话(会话N+1)。最新可见会话还是空白草稿时直接复用它——
|
|
196
|
+
// 连点「新建」不再堆积空会话(毫无意义的点击什么都不存)。
|
|
197
|
+
// n 用全量扫描分配(含归档/空白),绝不与磁盘上已有目录撞号。
|
|
198
|
+
if (sub === '/api/sessions/new' && req.method === 'POST') {
|
|
199
|
+
const body = (await readBody(req));
|
|
200
|
+
const ref = resolvePaper(dataDir, body);
|
|
201
|
+
const visible = await listPaperSessions(ref);
|
|
202
|
+
const newest = visible[visible.length - 1];
|
|
203
|
+
if (newest && newest.blank && !newest.running) {
|
|
204
|
+
json(res, 200, { ok: true, n: newest.n, sessionId: newest.sessionId, reused: true });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const all = await scanPaperSessions(ref);
|
|
208
|
+
const n = all.length ? all[all.length - 1].n + 1 : 1;
|
|
209
|
+
const sessionId = `${sessionPrefixFor(ref)}-${n}`;
|
|
210
|
+
await createPaperSession(sessionId, ref.topic);
|
|
211
|
+
json(res, 200, { ok: true, n, sessionId });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// 归档空白草稿(切换会话时客户端后台调用):只接受该文献的空白会话,内容会话绝不归档
|
|
215
|
+
if (sub === '/api/sessions/archive' && req.method === 'POST') {
|
|
216
|
+
const body = (await readBody(req));
|
|
217
|
+
const sessionId = body.sessionId ?? '';
|
|
218
|
+
const ref = resolvePaper(dataDir, body);
|
|
219
|
+
if (!sessionId.startsWith(sessionPrefixFor(ref) + '-') && sessionId !== sessionPrefixFor(ref)) {
|
|
220
|
+
json(res, 400, { error: '会话不属于该文献' });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (!workspaceRegistry) {
|
|
224
|
+
json(res, 503, { error: 'workspaceRegistry 服务不可用' });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const sessions = await listPaperSessions(ref);
|
|
228
|
+
const target = sessions.find((s) => s.sessionId === sessionId);
|
|
229
|
+
if (!target || !target.blank || target.running) {
|
|
230
|
+
json(res, 409, { error: '只允许归档无内容的空白会话' });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
await workspaceRegistry.archiveSession(sessionId);
|
|
234
|
+
json(res, 200, { ok: true });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (sub === '/api/paper' && req.method === 'GET') {
|
|
238
|
+
const ref = locate(url);
|
|
239
|
+
const t = await readTranscript(ref);
|
|
240
|
+
const st = await stat(ref.pdfPath);
|
|
241
|
+
json(res, 200, {
|
|
242
|
+
topic: ref.topic,
|
|
243
|
+
name: ref.name,
|
|
244
|
+
pdfBytes: st.size,
|
|
245
|
+
hasTranscript: t !== null,
|
|
246
|
+
chars: t?.text.length ?? 0,
|
|
247
|
+
hasPageIndex: (t?.pages?.length ?? 0) > 0,
|
|
248
|
+
pageCount: t?.pages?.length ?? null,
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (sub === '/api/pdf' && req.method === 'GET') {
|
|
253
|
+
const ref = locate(url);
|
|
254
|
+
const pdfPath = pdfVariantPath(ref, url.searchParams.get('variant'));
|
|
255
|
+
const st = await stat(pdfPath); // 变体不存在时 404/500 由外层兜底
|
|
256
|
+
res.writeHead(200, {
|
|
257
|
+
'content-type': 'application/pdf',
|
|
258
|
+
'content-length': st.size,
|
|
259
|
+
'cache-control': 'no-cache', // 切换文献/重新生成后必须拿到新的
|
|
260
|
+
});
|
|
261
|
+
createReadStream(pdfPath).pipe(res);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// 中文版状态:zh=纯中文 dual=中英对照 busy=翻译中
|
|
265
|
+
if (sub === '/api/zh' && req.method === 'GET') {
|
|
266
|
+
json(res, 200, zhStatus(locate(url)));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
// 启动后台翻译(幂等)。端点:UI 里填的 > profile 的 translate
|
|
270
|
+
if (sub === '/api/zh/generate' && req.method === 'POST') {
|
|
271
|
+
const body = (await readBody(req));
|
|
272
|
+
const ref = resolvePaper(dataDir, body);
|
|
273
|
+
const { endpoint } = await resolveTranslateEndpoint(config.translate);
|
|
274
|
+
const r = await startTranslation(ref, dataDir, endpoint);
|
|
275
|
+
json(res, r.started ? 200 : 409, r);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// ── 翻译端点配置(阅读器浮层的读写口)─────────────────────────────
|
|
279
|
+
// GET 只回显掩码 key:明文永不离开服务端
|
|
280
|
+
if (sub === '/api/translate/config' && req.method === 'GET') {
|
|
281
|
+
const { endpoint, source } = await resolveTranslateEndpoint(config.translate);
|
|
282
|
+
json(res, 200, {
|
|
283
|
+
baseUrl: endpoint.baseUrl ?? '',
|
|
284
|
+
model: endpoint.model ?? '',
|
|
285
|
+
hasApiKey: Boolean(endpoint.apiKey),
|
|
286
|
+
apiKeyHint: endpoint.apiKey ? maskApiKey(endpoint.apiKey) : '',
|
|
287
|
+
source,
|
|
288
|
+
});
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
// POST:校验 → 预检 → 通了才落盘。key 填错在这里就拦下,
|
|
292
|
+
// 不让它拖到 babeldoc 跑几分钟后才异步失败。
|
|
293
|
+
if (sub === '/api/translate/config' && req.method === 'POST') {
|
|
294
|
+
const body = (await readBody(req));
|
|
295
|
+
const baseUrl = body.baseUrl?.trim() ?? '';
|
|
296
|
+
const model = body.model?.trim() ?? '';
|
|
297
|
+
// key 留空 = 沿用已存的那把(明文不回传,前端无法预填)
|
|
298
|
+
const prev = await readTranslateConfig();
|
|
299
|
+
const apiKey = body.apiKey?.trim() || prev?.apiKey || '';
|
|
300
|
+
if (!/^https?:\/\//i.test(baseUrl)) {
|
|
301
|
+
json(res, 400, { error: '端点 URL 需要以 http:// 或 https:// 开头' });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (!model) {
|
|
305
|
+
json(res, 400, { error: '模型名不能为空(如 deepseek-chat)' });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (!apiKey) {
|
|
309
|
+
json(res, 400, { error: 'API Key 不能为空' });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const test = await testTranslateEndpoint({ baseUrl, apiKey, model });
|
|
313
|
+
if (!test.ok) {
|
|
314
|
+
json(res, 400, { error: `连接测试失败:${test.detail ?? '未知原因'}` });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
await writeTranslateConfig({ baseUrl, apiKey, model });
|
|
318
|
+
json(res, 200, { ok: true });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// DELETE:清掉文件,回落到 profile 的 translate
|
|
322
|
+
if (sub === '/api/translate/config' && req.method === 'DELETE') {
|
|
323
|
+
await clearTranslateConfig();
|
|
324
|
+
json(res, 200, { ok: true });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (sub === '/api/transcribe' && req.method === 'POST') {
|
|
328
|
+
const body = (await readBody(req));
|
|
329
|
+
const ref = resolvePaper(dataDir, body);
|
|
330
|
+
const t = await transcribePaper(ref);
|
|
331
|
+
json(res, 200, { chars: t.chars, pageCount: t.pageCount, hasPageIndex: t.pages.length > 0, source: t.source });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
// 文献的伴读会话列表(多会话:会话1/会话2/…)
|
|
335
|
+
if (sub === '/api/sessions' && req.method === 'GET') {
|
|
336
|
+
const ref = locate(url);
|
|
337
|
+
const sessions = await listPaperSessions(ref);
|
|
338
|
+
json(res, 200, { sessions, latest: sessions.length ? sessions[sessions.length - 1].n : 1 });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
// 实时会话流(SSE):桥接官方 sessionController.follow —— 阅读器右侧就是真正的 dsh 会话
|
|
342
|
+
if (sub === '/api/session/follow' && req.method === 'GET') {
|
|
343
|
+
const ref = locate(url);
|
|
344
|
+
const sessions = await listPaperSessions(ref);
|
|
345
|
+
const nParam = +(url.searchParams.get('n') ?? 0) || 0;
|
|
346
|
+
// 未指定时进最新会话;全新文献进会话1
|
|
347
|
+
const n = nParam > 0 ? nParam : sessions.length ? sessions[sessions.length - 1].n : 1;
|
|
348
|
+
const sessionId = await sessionIdFor(ref, n);
|
|
349
|
+
try {
|
|
350
|
+
await createPaperSession(sessionId, ref.topic);
|
|
351
|
+
}
|
|
352
|
+
catch { /* 已存在则复用;失败由 follow 报错 */ }
|
|
353
|
+
res.writeHead(200, {
|
|
354
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
355
|
+
'cache-control': 'no-cache',
|
|
356
|
+
connection: 'keep-alive',
|
|
357
|
+
});
|
|
358
|
+
res.write(': connected\n\n');
|
|
359
|
+
res.write(`event: meta\ndata: ${JSON.stringify({ n, sessionId })}\n\n`);
|
|
360
|
+
const ac = new AbortController();
|
|
361
|
+
req.on('close', () => ac.abort());
|
|
362
|
+
try {
|
|
363
|
+
const frames = sessionController.follow({ address: { kind: 'session', sessionId }, maxMessages: 200, assistantStream: true }, ac.signal);
|
|
364
|
+
for await (const frame of frames) {
|
|
365
|
+
if (ac.signal.aborted)
|
|
366
|
+
break;
|
|
367
|
+
res.write(`data: ${JSON.stringify(frame)}\n\n`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
if (!ac.signal.aborted) {
|
|
372
|
+
res.write(`event: error\ndata: ${JSON.stringify({ error: String(err instanceof Error ? err.message : err) })}\n\n`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (!res.writableEnded)
|
|
376
|
+
res.end();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (sub === '/api/ask' && req.method === 'POST') {
|
|
380
|
+
const body = (await readBody(req));
|
|
381
|
+
if (!body.question?.trim()) {
|
|
382
|
+
json(res, 400, { error: 'question 不能为空' });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const sc = sessionController;
|
|
386
|
+
if (!sc) {
|
|
387
|
+
json(res, 503, { error: '当前 profile 没有 sessionController 服务,无法注入会话(headless?)' });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const ref = resolvePaper(dataDir, body);
|
|
391
|
+
const existing = await listPaperSessions(ref);
|
|
392
|
+
let n;
|
|
393
|
+
if (body.fresh) {
|
|
394
|
+
// 全量扫描分配(含归档/空白),避免与磁盘上已有会话目录撞号
|
|
395
|
+
const all = await scanPaperSessions(ref);
|
|
396
|
+
n = all.length ? all[all.length - 1].n + 1 : 1;
|
|
397
|
+
}
|
|
398
|
+
else if (body.n && body.n > 0)
|
|
399
|
+
n = body.n;
|
|
400
|
+
else
|
|
401
|
+
n = existing.length ? existing[existing.length - 1].n : 1;
|
|
402
|
+
const sessionId = await sessionIdFor(ref, n);
|
|
403
|
+
try {
|
|
404
|
+
await createPaperSession(sessionId, ref.topic);
|
|
405
|
+
}
|
|
406
|
+
catch { /* 已存在则复用 */ }
|
|
407
|
+
const hasQuote = typeof body.selectedText === 'string' && body.selectedText.trim() !== '';
|
|
408
|
+
const where = hasQuote && body.page ? `(选中于第 ${body.page} 页)` : '';
|
|
409
|
+
const lines = [
|
|
410
|
+
`[论文伴读] 文献:${ref.topic}/${ref.name}(同目录下,search_paper 可直接检索;回答注明页码)`,
|
|
411
|
+
];
|
|
412
|
+
if (hasQuote) {
|
|
413
|
+
lines.push(`用户在阅读器里选中了一段文字${where}:`, `"""${body.selectedText.trim()}"""`);
|
|
414
|
+
}
|
|
415
|
+
lines.push(`用户的问题:${body.question}`);
|
|
416
|
+
const prompt = lines.join('\n');
|
|
417
|
+
await sc.prompt({
|
|
418
|
+
sessionId,
|
|
419
|
+
content: [{ type: 'text', text: prompt }],
|
|
420
|
+
requestId: randomUUID(),
|
|
421
|
+
}, AbortSignal.timeout(30_000));
|
|
422
|
+
json(res, 200, { ok: true, sessionId, n });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
json(res, 404, { error: 'not found: ' + url.pathname });
|
|
426
|
+
};
|
|
427
|
+
ctx.effect(() => {
|
|
428
|
+
const dispose = ws.register({
|
|
429
|
+
kind: 'prefix',
|
|
430
|
+
path: '/paper-reader',
|
|
431
|
+
handler: (req, res) => {
|
|
432
|
+
// 认证+Host 围栏(与宿主 /api 通道同一套校验):插件路由不在宿主 index
|
|
433
|
+
// 鉴权路径上,必须自己校验。本路由暴露 PDF 读取和会话注入,不能裸奔。
|
|
434
|
+
const rejection = connection.requestRejection(req);
|
|
435
|
+
if (rejection !== undefined) {
|
|
436
|
+
json(res, rejection, { error: rejection === 401 ? 'unauthorized' : 'forbidden' });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const url = new URL(req.url ?? '/', 'http://x');
|
|
440
|
+
dispatch(req, res, url).catch((err) => {
|
|
441
|
+
if (!res.headersSent)
|
|
442
|
+
json(res, 500, { error: String(err instanceof Error ? err.message : err) });
|
|
443
|
+
else
|
|
444
|
+
res.end();
|
|
445
|
+
});
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
console.log('[dsh-paper-reader] routes registered under /paper-reader');
|
|
449
|
+
return dispose;
|
|
450
|
+
});
|
|
451
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// dsh-paper-reader — Cordis 插件壳。
|
|
2
|
+
// 壳保持薄:agent 循环/模型层交给 dsh 框架,本插件只注册工具和 HTTP 路由。
|
|
3
|
+
// 核心逻辑(转录/检索)是 src/ 下不依赖 Cordis 的纯函数模块,方便日后迁移。
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
import { registerRoutes } from "./host.js";
|
|
6
|
+
export const name = 'dsh-paper-reader';
|
|
7
|
+
// 等宿主的工具注册服务(ctx.tools)就绪后再运行。
|
|
8
|
+
export const inject = ['tools'];
|
|
9
|
+
export function apply(ctx, config = {}) {
|
|
10
|
+
registerTools(ctx, config);
|
|
11
|
+
console.log(`[dsh-paper-reader] registered tools: list_papers, transcribe_pdf, search_paper, study_progress, study_update`);
|
|
12
|
+
// webServer 是可选服务(web profile 有,headless 没有):出现时再挂路由。
|
|
13
|
+
// connection/sessionController/workspaceController/workspaceRegistry 同属 web profile(cordis 要求访问前先声明 inject)。
|
|
14
|
+
ctx.inject(['webServer', 'connection', 'sessionController', 'workspaceController', 'workspaceRegistry'], (rctx) => {
|
|
15
|
+
registerRoutes(rctx, config);
|
|
16
|
+
});
|
|
17
|
+
}
|
package/dist/library.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// library.ts — 文献库目录约定(纯函数,不依赖 Cordis)。
|
|
2
|
+
//
|
|
3
|
+
// 布局兼容 pdfqa 的 data/ 思路:
|
|
4
|
+
// <dataDir>/<专题>/<文献>.pdf 原始 PDF
|
|
5
|
+
// <dataDir>/<专题>/<文献>.txt 转录缓存(清洗后的全文文本)
|
|
6
|
+
// <dataDir>/<专题>/<文献>.pages.json 页码偏移表(转录时生成,检索映射页码用)
|
|
7
|
+
// pdf2zh 的产物(-en / -zh / -dual)不算用户文献。
|
|
8
|
+
// (问答不落盘存档——会话持久化交给 dsh 自己;pdfqa 时代的 -qa/ 目录不再读写。)
|
|
9
|
+
import { readdirSync, existsSync, mkdirSync, statSync } from 'node:fs';
|
|
10
|
+
import { join, resolve, extname, basename } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
const PDF2ZH_SUFFIXES = ['-en', '-zh', '-dual'];
|
|
13
|
+
/** 判定是否为用户文献:排除 pdf2zh 的中间/产出文件。 */
|
|
14
|
+
export function isPaperPDF(filename) {
|
|
15
|
+
if (!filename.toLowerCase().endsWith('.pdf'))
|
|
16
|
+
return false;
|
|
17
|
+
const stem = filename.slice(0, -extname(filename).length);
|
|
18
|
+
return !PDF2ZH_SUFFIXES.some((s) => stem.endsWith(s));
|
|
19
|
+
}
|
|
20
|
+
/** dsh home:$DSH_HOME > ~/.dsh(与 dsh-home-paths 的解析顺序一致)。 */
|
|
21
|
+
export function dshHome(env = process.env) {
|
|
22
|
+
const fromEnv = env.DSH_HOME?.trim();
|
|
23
|
+
return fromEnv || join(homedir(), '.dsh');
|
|
24
|
+
}
|
|
25
|
+
/** 解析数据目录:显式配置 > 环境变量 > 默认 ~/.dsh-paper-reader/data */
|
|
26
|
+
export function resolveDataDir(configured) {
|
|
27
|
+
const dir = configured ?? process.env['DSH_PAPER_READER_DATA'] ?? join(homedir(), '.dsh-paper-reader', 'data');
|
|
28
|
+
const abs = resolve(dir);
|
|
29
|
+
mkdirSync(abs, { recursive: true });
|
|
30
|
+
return abs;
|
|
31
|
+
}
|
|
32
|
+
/** 列出专题(一级子目录,跳过隐藏目录)。 */
|
|
33
|
+
export function listTopics(dataDir) {
|
|
34
|
+
if (!existsSync(dataDir))
|
|
35
|
+
return [];
|
|
36
|
+
return readdirSync(dataDir, { withFileTypes: true })
|
|
37
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
38
|
+
.map((e) => e.name)
|
|
39
|
+
.sort();
|
|
40
|
+
}
|
|
41
|
+
/** 列出某专题下的文献名(不含后缀);topic 省略时跨全部专题。 */
|
|
42
|
+
export function listPapers(dataDir, topic) {
|
|
43
|
+
const topics = topic ? [topic] : listTopics(dataDir);
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const t of topics) {
|
|
46
|
+
const dir = join(dataDir, t);
|
|
47
|
+
if (!existsSync(dir))
|
|
48
|
+
continue;
|
|
49
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
50
|
+
if (e.isFile() && isPaperPDF(e.name)) {
|
|
51
|
+
out.push({ topic: t, name: e.name.slice(0, -4) });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
56
|
+
}
|
|
57
|
+
function refFor(topic, name, pdfPath) {
|
|
58
|
+
const base = pdfPath.slice(0, -extname(pdfPath).length);
|
|
59
|
+
return {
|
|
60
|
+
topic,
|
|
61
|
+
name,
|
|
62
|
+
pdfPath,
|
|
63
|
+
txtPath: base + '.txt',
|
|
64
|
+
pagesPath: base + '.pages.json',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 解析文献:三种方式
|
|
69
|
+
* 1. path 绝对/相对路径直指 PDF
|
|
70
|
+
* 2. topic + name 在专题目录里找 <name>.pdf
|
|
71
|
+
* 3. 只有 name 时跨专题找,同名多篇报歧义
|
|
72
|
+
*/
|
|
73
|
+
export function resolvePaper(dataDir, args) {
|
|
74
|
+
if (args.path) {
|
|
75
|
+
const abs = resolve(args.path);
|
|
76
|
+
if (!existsSync(abs))
|
|
77
|
+
throw new Error(`PDF 不存在: ${abs}`);
|
|
78
|
+
const name = basename(abs, extname(abs));
|
|
79
|
+
return refFor(basename(join(abs, '..')), name, abs);
|
|
80
|
+
}
|
|
81
|
+
if (!args.name)
|
|
82
|
+
throw new Error('需要 path 或 name 参数定位文献');
|
|
83
|
+
const name = args.name.replace(/\.pdf$/i, '');
|
|
84
|
+
const candidates = listPapers(dataDir, args.topic).filter((p) => p.name === name);
|
|
85
|
+
if (candidates.length === 0) {
|
|
86
|
+
const hint = listPapers(dataDir).map((p) => `${p.topic}/${p.name}`).join(', ');
|
|
87
|
+
throw new Error(`文献库中找不到 "${name}"。现有文献: ${hint || '(空)'}`);
|
|
88
|
+
}
|
|
89
|
+
if (candidates.length > 1) {
|
|
90
|
+
throw new Error(`"${name}" 命中多篇: ${candidates.map((c) => c.topic).join(', ')},请用 topic 参数限定`);
|
|
91
|
+
}
|
|
92
|
+
const hit = candidates[0];
|
|
93
|
+
return refFor(hit.topic, hit.name, join(dataDir, hit.topic, hit.name + '.pdf'));
|
|
94
|
+
}
|
|
95
|
+
/** 转录缓存是否可用(存在且非空)。 */
|
|
96
|
+
export function transcriptCached(ref) {
|
|
97
|
+
try {
|
|
98
|
+
return statSync(ref.txtPath).size > 1000;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|