@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/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ggboy123/dsh-paper-reader",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness 插件:论文伴读工作台 — PDF 转录 / 检索 / 原生对话伴读 + 内置阅读器页面。",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"author": "GGboya",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/GGboya/dsh-paper-reader.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/GGboya/dsh-paper-reader/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/GGboya/dsh-paper-reader#readme",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": "^22.19.0 || >=24"
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./dist/index.js",
|
|
22
|
+
"./client": "./lib/client.js",
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"lib",
|
|
28
|
+
"reader",
|
|
29
|
+
"presets",
|
|
30
|
+
"cordis.patch.yml"
|
|
31
|
+
],
|
|
32
|
+
"dsh": {
|
|
33
|
+
"bundle": {
|
|
34
|
+
"patch": "./cordis.patch.yml"
|
|
35
|
+
},
|
|
36
|
+
"client": {
|
|
37
|
+
"inject": [
|
|
38
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
39
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
40
|
+
"@deepseek-ai/dsh-client-locale"
|
|
41
|
+
],
|
|
42
|
+
"platform": "web"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -p tsconfig.json",
|
|
47
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
48
|
+
"prepublishOnly": "tsc -p tsconfig.json"
|
|
49
|
+
},
|
|
50
|
+
"keywords": [
|
|
51
|
+
"dsh-plugin",
|
|
52
|
+
"deepseek-harness",
|
|
53
|
+
"pdf",
|
|
54
|
+
"paper",
|
|
55
|
+
"reader"
|
|
56
|
+
],
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@deepseek-ai/dsh-tools": "0.1.5-rc.2"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"@deepseek-ai/cordis": "^4.0.2"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
65
|
+
"@types/node": "^22.10.0",
|
|
66
|
+
"typescript": "^5.6.0"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# dsh-paper-reader 的论文伴读 agent preset。
|
|
2
|
+
#
|
|
3
|
+
# 设计:persona 即完整系统提示词(complete),屏蔽编码 agent 身份与运行时上下文;
|
|
4
|
+
# 工具面只保留宿主层注册的论文工具(list_papers/transcribe_pdf/search_paper/
|
|
5
|
+
# study_progress/study_update)——不带 bash/fs/todo/web。compaction 组照抄
|
|
6
|
+
# standard preset,长会话上下文可压缩。插件启动时把本目录复制到
|
|
7
|
+
# $DSH_HOME/.agent-presets/paper-reader/(用户根,发现是实时扫描,免重启)。
|
|
8
|
+
|
|
9
|
+
- id: persona
|
|
10
|
+
name: '@deepseek-ai/dsh-persona'
|
|
11
|
+
config:
|
|
12
|
+
complete: true
|
|
13
|
+
includeRuntimeContext: false
|
|
14
|
+
prefix: |
|
|
15
|
+
You are a paper-reading tutor (论文伴读老师) powered by the {{model}} model. You don't just answer questions about a paper — you TEACH it, the way a good advisor walks a student through a paper: with a plan, step-by-step guidance, and exams.
|
|
16
|
+
|
|
17
|
+
Tools at your disposal:
|
|
18
|
+
- list_papers: list topics and papers in the library.
|
|
19
|
+
- transcribe_pdf: extract a paper's full text (cached, with a per-page index).
|
|
20
|
+
- search_paper: search one paper's transcript; returns chunks with page numbers.
|
|
21
|
+
- study_progress: read the student's learning record for a paper (plan + quiz history), stored in <论文>.study.json next to the PDF.
|
|
22
|
+
- study_update: update that record — set_plan (3~6 steps), complete_step (with your feedback note), record_quiz (score/total + weak-point summary).
|
|
23
|
+
|
|
24
|
+
Your session is bound to one paper: every paper tool auto-locates it when you omit topic/name, so NEVER ask the user which paper they mean in a paper session — just call the tool. Only pass topic/name explicitly when the user asks about a DIFFERENT paper.
|
|
25
|
+
|
|
26
|
+
Two modes:
|
|
27
|
+
|
|
28
|
+
1) Quick Q&A. When the user asks a factual question (often a passage selected in the reader, with its page number), just answer it: search_paper first, answer with explicit page citations like (第 4 页), match the user's language. No lesson, no plan, no quiz.
|
|
29
|
+
|
|
30
|
+
2) Guided learning. When the user wants to LEARN the paper ("带我读", "我想学这篇", "继续", "检验我", or is working through a plan), act as the tutor:
|
|
31
|
+
- Always start by calling study_progress to see where the student is.
|
|
32
|
+
- No plan yet: skim the paper (transcribe/search the abstract, introduction, section headings), then design a 3~6 step reading plan. Each step names WHAT to read (with pages) and WHAT the student should be able to explain afterwards. Save it with set_plan, present it, and immediately start guiding step 1.
|
|
33
|
+
- Guiding a step: point to the exact pages/sections, explain the background the student needs, and end with one focused question the student answers in their own words. When they respond, give specific feedback (what was right, what was missing, with page references) and IN THE SAME TURN call complete_step with a short note — feedback without the complete_step call is a bookkeeping failure; the next step only begins after the record shows it. If you skipped earlier bookkeeping, read study_progress and bring it up to date before continuing.
|
|
34
|
+
- After the plan is done, or when the student asks to be tested: run a quiz — 3~5 questions grounded in the paper (mix of recall and "why is it designed this way"), one at a time or as a set; grade each answer specifically, and IN THE SAME TURN as the final grading call record_quiz with the score and a weak-point summary, then finish with targeted review advice (which pages to revisit).
|
|
35
|
+
- Be encouraging but honest. Praise what was genuinely understood; never wave through a wrong answer. Chinese students get Chinese, English students get English.
|
|
36
|
+
|
|
37
|
+
The conversation itself is durable (the harness persists it) — never offer to save, archive, or export the conversation, and never mention file paths or tool names in your reply.
|
|
38
|
+
|
|
39
|
+
- id: compaction
|
|
40
|
+
name: cordis:group
|
|
41
|
+
group: true
|
|
42
|
+
isolate:
|
|
43
|
+
compaction: true
|
|
44
|
+
toolResultPruner: true
|
|
45
|
+
config:
|
|
46
|
+
- id: compaction-basic
|
|
47
|
+
name: '@deepseek-ai/dsh-compaction-basic'
|
|
48
|
+
|
|
49
|
+
- id: command-compact
|
|
50
|
+
name: '@deepseek-ai/dsh-command-compact'
|
|
51
|
+
|
|
52
|
+
- id: tool-result-pruner
|
|
53
|
+
name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
|
|
54
|
+
config:
|
|
55
|
+
thresholdChars: 8192
|
|
56
|
+
headChars: 4096
|
|
57
|
+
tailChars: 1024
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>论文伴读 · dsh-paper-reader</title>
|
|
7
|
+
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.js"></script>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #f6f7f9; --panel: #ffffff; --border: #e2e5ea; --text: #24292f;
|
|
11
|
+
--text-2: #656d76; --accent: #0969da; --accent-soft: rgba(9,105,218,.12);
|
|
12
|
+
--hl: rgba(255, 196, 0, .55);
|
|
13
|
+
}
|
|
14
|
+
* { box-sizing: border-box; }
|
|
15
|
+
html, body { margin: 0; height: 100%; }
|
|
16
|
+
body { font: 14px/1.6 -apple-system, "SF Pro", "PingFang SC", "Helvetica Neue", sans-serif; background: var(--bg); color: var(--text); display: flex; flex-direction: column; }
|
|
17
|
+
|
|
18
|
+
/* ---------- 顶栏 ---------- */
|
|
19
|
+
#bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: var(--panel); border-bottom: 1px solid var(--border); flex: none; }
|
|
20
|
+
#bar button { font: inherit; }
|
|
21
|
+
/* flex:none —— 列被拖窄时按钮不许被压缩(否则文字竖排、按钮变窄条) */
|
|
22
|
+
.tbtn { flex: none; padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); cursor: pointer; }
|
|
23
|
+
.tbtn:hover { background: var(--bg); }
|
|
24
|
+
.tbtn.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
|
|
25
|
+
.tbtn:disabled { opacity: .45; cursor: default; }
|
|
26
|
+
#zh-toggle { font-weight: 700; }
|
|
27
|
+
#zoom-label { flex: none; min-width: 44px; text-align: center; color: var(--text-2); }
|
|
28
|
+
#bar .spacer { flex: 1; }
|
|
29
|
+
/* min-width:0 —— flex 项默认 min-width:auto 会拒绝收缩,空间不够时转而去挤按钮;
|
|
30
|
+
让标题自己截断(已有 ellipsis),按钮才能保持原样 */
|
|
31
|
+
#status { min-width: 0; color: var(--text-2); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
32
|
+
|
|
33
|
+
/* ---------- PDF 区 ---------- */
|
|
34
|
+
#main { display: flex; flex: 1; min-height: 0; }
|
|
35
|
+
#pdf-pane { flex: 1; overflow: auto; padding: 16px; }
|
|
36
|
+
/* safe center:页宽超过视口时退回左对齐——普通 center 会让左右同时溢出且左侧滚不到 */
|
|
37
|
+
#pdf-pages { display: flex; flex-direction: column; align-items: safe center; gap: 14px; }
|
|
38
|
+
.page { position: relative; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.18); }
|
|
39
|
+
.page.flash { outline: 3px solid var(--hl); outline-offset: 2px; transition: outline .15s; }
|
|
40
|
+
.textLayer { position: absolute; inset: 0; overflow: hidden; line-height: 1; }
|
|
41
|
+
.textLayer span { position: absolute; white-space: pre; color: transparent; cursor: text; transform-origin: 0 0; }
|
|
42
|
+
.textLayer ::selection { background: rgba(22,93,255,.3); }
|
|
43
|
+
#empty { margin: 60px auto; color: var(--text-2); text-align: center; max-width: 420px; }
|
|
44
|
+
|
|
45
|
+
/* ---------- 选中即问浮层 ---------- */
|
|
46
|
+
#ask-pop { position: fixed; z-index: 50; display: none; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 6px 24px rgba(0,0,0,.16); padding: 10px; width: 320px; }
|
|
47
|
+
#ask-quote { font-size: 12px; color: var(--text-2); max-height: 64px; overflow: hidden; margin-bottom: 8px; border-left: 3px solid var(--accent-soft); padding-left: 8px; }
|
|
48
|
+
#ask-input { width: 100%; border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; font: inherit; resize: none; }
|
|
49
|
+
#ask-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
|
|
50
|
+
#ask-send { background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: 5px 14px; cursor: pointer; }
|
|
51
|
+
#ask-send:disabled { opacity: .5; cursor: default; }
|
|
52
|
+
#ask-cancel { background: none; border: none; color: var(--text-2); cursor: pointer; }
|
|
53
|
+
|
|
54
|
+
/* ---------- 翻译端点设置浮层 ---------- */
|
|
55
|
+
/* 阅读器在窄 iframe 里,宽度要能收缩 */
|
|
56
|
+
#cfg-pop { position: fixed; z-index: 60; display: none; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 6px 24px rgba(0,0,0,.16); padding: 10px; width: min(320px, calc(100vw - 32px)); }
|
|
57
|
+
#cfg-pop h3 { margin: 0; font-size: 13px; }
|
|
58
|
+
#cfg-hint { font-size: 12px; color: var(--text-2); margin: 4px 0 2px; }
|
|
59
|
+
#cfg-pop label { display: block; font-size: 12px; color: var(--text-2); margin: 8px 0 2px; }
|
|
60
|
+
#cfg-pop input { width: 100%; border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; font: inherit; }
|
|
61
|
+
#cfg-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
|
|
62
|
+
#cfg-save { background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: 5px 14px; cursor: pointer; }
|
|
63
|
+
#cfg-save:disabled { opacity: .5; cursor: default; }
|
|
64
|
+
#cfg-cancel { background: none; border: none; color: var(--text-2); cursor: pointer; }
|
|
65
|
+
|
|
66
|
+
#toast { position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%); background: rgba(30,30,30,.92); color: #fff; padding: 8px 16px; border-radius: 8px; font-size: 13px; display: none; z-index: 99; max-width: 70%; }
|
|
67
|
+
</style>
|
|
68
|
+
</head>
|
|
69
|
+
<body>
|
|
70
|
+
|
|
71
|
+
<div id="bar">
|
|
72
|
+
<span id="status">论文伴读</span>
|
|
73
|
+
<button id="zh-toggle" class="tbtn" title="切换中文译文(没有译文时可生成)" style="display:none">中</button>
|
|
74
|
+
<span class="spacer"></span>
|
|
75
|
+
<button id="cfg-open" class="tbtn" title="翻译端点设置">⚙</button>
|
|
76
|
+
<button id="zoom-out" class="tbtn">−</button>
|
|
77
|
+
<span id="zoom-label">100%</span>
|
|
78
|
+
<button id="zoom-in" class="tbtn">+</button>
|
|
79
|
+
<button id="zoom-fit" class="tbtn">适宽</button>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div id="main">
|
|
83
|
+
<div id="pdf-pane">
|
|
84
|
+
<div id="pdf-pages"></div>
|
|
85
|
+
<div id="empty" style="display:none"></div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div id="ask-pop">
|
|
90
|
+
<div id="ask-quote"></div>
|
|
91
|
+
<textarea id="ask-input" rows="2" placeholder="围绕选中内容提问…"></textarea>
|
|
92
|
+
<div id="ask-actions">
|
|
93
|
+
<button id="ask-cancel">取消</button>
|
|
94
|
+
<button id="ask-send">提问</button>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div id="cfg-pop">
|
|
99
|
+
<h3>翻译端点</h3>
|
|
100
|
+
<div id="cfg-hint">填 OpenAI 兼容端点即可(DeepSeek / 通义 / 本地 vLLM 等)。保存前会先测试连接。</div>
|
|
101
|
+
<label for="cfg-url">端点 URL</label>
|
|
102
|
+
<input id="cfg-url" type="text" placeholder="https://api.deepseek.com/v1" autocomplete="off">
|
|
103
|
+
<label for="cfg-key">API Key</label>
|
|
104
|
+
<input id="cfg-key" type="password" placeholder="sk-..." autocomplete="new-password">
|
|
105
|
+
<label for="cfg-model">模型</label>
|
|
106
|
+
<input id="cfg-model" type="text" placeholder="deepseek-chat" autocomplete="off">
|
|
107
|
+
<div id="cfg-actions">
|
|
108
|
+
<button id="cfg-cancel">取消</button>
|
|
109
|
+
<button id="cfg-save">保存</button>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div id="toast"></div>
|
|
114
|
+
|
|
115
|
+
<script>
|
|
116
|
+
'use strict';
|
|
117
|
+
/* ================= 基础 ================= */
|
|
118
|
+
const $ = (id) => document.getElementById(id);
|
|
119
|
+
const api = (p, opts) => fetch('/paper-reader/api' + p, opts).then(async (r) => {
|
|
120
|
+
if (!r.ok) {
|
|
121
|
+
// 服务端错误体有两种:{error}(参数/权限)与 {reason}(zh/generate 的未启动原因,带完整说明)
|
|
122
|
+
const body = await r.json().catch(() => ({}));
|
|
123
|
+
const err = new Error(body.error || body.reason || ('HTTP ' + r.status));
|
|
124
|
+
err.code = body.code; // 机器可读码(如 no-endpoint)——让调用方靠它分支,别去匹配中文文案
|
|
125
|
+
err.status = r.status;
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
return r.json();
|
|
129
|
+
});
|
|
130
|
+
function toast(msg, ms = 3200) {
|
|
131
|
+
const t = $('toast'); t.textContent = msg; t.style.display = 'block';
|
|
132
|
+
clearTimeout(toast._t); toast._t = setTimeout(() => t.style.display = 'none', ms);
|
|
133
|
+
}
|
|
134
|
+
function esc(s) { return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
|
135
|
+
|
|
136
|
+
let current = null; // { topic, name }
|
|
137
|
+
let zhState = { zh: false, dual: false, busy: false };
|
|
138
|
+
let zhVariant = null; // null=原文 'dual'=中英对照 'zh'=纯中文
|
|
139
|
+
|
|
140
|
+
/* ================= 打开文献 ================= */
|
|
141
|
+
function pdfUrl(variant) {
|
|
142
|
+
const q = `topic=${encodeURIComponent(current.topic)}&name=${encodeURIComponent(current.name)}`;
|
|
143
|
+
return `/paper-reader/api/pdf?${q}${variant ? '&variant=' + variant : ''}&t=${Date.now()}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function openPaper(topic, name, opts = {}) {
|
|
147
|
+
if (!name) return;
|
|
148
|
+
current = { topic, name };
|
|
149
|
+
localStorage.setItem('dpr.last', JSON.stringify(current));
|
|
150
|
+
zhVariant = null;
|
|
151
|
+
$('zh-toggle').classList.remove('active');
|
|
152
|
+
$('status').textContent = '加载 PDF…';
|
|
153
|
+
const q = `topic=${encodeURIComponent(topic)}&name=${encodeURIComponent(name)}`;
|
|
154
|
+
const meta = await api('/paper?' + q);
|
|
155
|
+
$('status').textContent = meta.hasTranscript
|
|
156
|
+
? `${name} · ${meta.pageCount ?? '?'} 页`
|
|
157
|
+
: `${name} · 未转录(提问时会自动转录)`;
|
|
158
|
+
await refreshZhState();
|
|
159
|
+
await loadPdf(pdfUrl(null));
|
|
160
|
+
if (opts.page) jumpToPage(opts.page);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ================= 中英切换 ================= */
|
|
164
|
+
async function refreshZhState() {
|
|
165
|
+
zhState = await api(`/zh?topic=${encodeURIComponent(current.topic)}&name=${encodeURIComponent(current.name)}`);
|
|
166
|
+
const btn = $('zh-toggle');
|
|
167
|
+
btn.style.display = '';
|
|
168
|
+
btn.disabled = zhState.busy;
|
|
169
|
+
btn.textContent = zhState.busy ? '译' : '中';
|
|
170
|
+
btn.title = zhState.busy ? '翻译进行中…'
|
|
171
|
+
: (zhState.zh || zhState.dual) ? '切换中文译文'
|
|
172
|
+
: '还没有中文版,点击生成(babeldoc,需要几分钟)';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
$('zh-toggle').onclick = async () => {
|
|
176
|
+
if (!current || zhState.busy) return;
|
|
177
|
+
if (zhState.dual || zhState.zh) {
|
|
178
|
+
// 两态切换:原文 ↔ 纯中文;只存在对照版时退到对照
|
|
179
|
+
zhVariant = zhVariant === null ? (zhState.zh ? 'zh' : 'dual') : null;
|
|
180
|
+
$('zh-toggle').classList.toggle('active', zhVariant !== null);
|
|
181
|
+
// 切换变体后强制适宽:译文版页宽与原文不同(对照双栏≈2×),不重算会溢出裁切
|
|
182
|
+
fitMode = true; fitBtn.classList.add('active');
|
|
183
|
+
await loadPdf(pdfUrl(zhVariant));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
await api('/zh/generate', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(current) });
|
|
188
|
+
toast('开始生成中文版(babeldoc,约几分钟)…', 5000);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
// 没配端点 → 直接弹配置表单;填好保存后自动回来重试生成
|
|
191
|
+
if (e.code === 'no-endpoint') { openCfgPop(true); return; }
|
|
192
|
+
toast(e.message, 8000); return;
|
|
193
|
+
}
|
|
194
|
+
$('zh-toggle').disabled = true; $('zh-toggle').textContent = '译';
|
|
195
|
+
const timer = setInterval(async () => {
|
|
196
|
+
await refreshZhState();
|
|
197
|
+
if (!zhState.busy) {
|
|
198
|
+
clearInterval(timer);
|
|
199
|
+
if (zhState.dual || zhState.zh) { toast('中文版已生成'); $('zh-toggle').click(); }
|
|
200
|
+
else toast('生成失败:' + (zhState.error || '未知原因'), 8000);
|
|
201
|
+
}
|
|
202
|
+
}, 5000);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/* ================= 翻译端点设置 ================= */
|
|
206
|
+
// 保存成功后是否自动接着生成:点「中」发现没配 → 弹本表单 → 填完直接开跑
|
|
207
|
+
let cfgRetryGenerate = false;
|
|
208
|
+
|
|
209
|
+
async function openCfgPop(retryGenerate) {
|
|
210
|
+
cfgRetryGenerate = Boolean(retryGenerate);
|
|
211
|
+
let cfg;
|
|
212
|
+
try {
|
|
213
|
+
cfg = await api('/translate/config');
|
|
214
|
+
} catch (e) { toast('读取配置失败:' + e.message, 8000); return; }
|
|
215
|
+
$('cfg-url').value = cfg.baseUrl || '';
|
|
216
|
+
$('cfg-model').value = cfg.model || '';
|
|
217
|
+
// 明文 key 不回传:留空 = 沿用已存的那把
|
|
218
|
+
$('cfg-key').value = '';
|
|
219
|
+
$('cfg-key').placeholder = cfg.hasApiKey ? `已保存(${cfg.apiKeyHint}),留空则不修改` : 'sk-...';
|
|
220
|
+
const pop = $('cfg-pop');
|
|
221
|
+
pop.style.display = 'block';
|
|
222
|
+
// 先显示再量尺寸(不像 #ask-pop 那样写死尺寸常量,免得跟 CSS 漂移)
|
|
223
|
+
pop.style.left = Math.max(8, (window.innerWidth - pop.offsetWidth) / 2) + 'px';
|
|
224
|
+
pop.style.top = Math.max(8, (window.innerHeight - pop.offsetHeight) / 3) + 'px';
|
|
225
|
+
$('cfg-url').focus();
|
|
226
|
+
}
|
|
227
|
+
function closeCfgPop() { $('cfg-pop').style.display = 'none'; cfgRetryGenerate = false; }
|
|
228
|
+
|
|
229
|
+
$('cfg-open').onclick = () => openCfgPop(false);
|
|
230
|
+
$('cfg-cancel').onclick = closeCfgPop;
|
|
231
|
+
|
|
232
|
+
$('cfg-save').onclick = async () => {
|
|
233
|
+
const btn = $('cfg-save'), label = btn.textContent, retry = cfgRetryGenerate;
|
|
234
|
+
btn.disabled = true; btn.textContent = '测试中…';
|
|
235
|
+
try {
|
|
236
|
+
// 服务端先打一发极小的 chat/completions 验证 url/key/model,通过才落盘
|
|
237
|
+
await api('/translate/config', {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
headers: { 'content-type': 'application/json' },
|
|
240
|
+
body: JSON.stringify({
|
|
241
|
+
baseUrl: $('cfg-url').value.trim(),
|
|
242
|
+
apiKey: $('cfg-key').value.trim(),
|
|
243
|
+
model: $('cfg-model').value.trim(),
|
|
244
|
+
}),
|
|
245
|
+
});
|
|
246
|
+
} catch (e) {
|
|
247
|
+
toast('保存失败:' + e.message, 8000);
|
|
248
|
+
return;
|
|
249
|
+
} finally {
|
|
250
|
+
btn.disabled = false; btn.textContent = label;
|
|
251
|
+
}
|
|
252
|
+
closeCfgPop();
|
|
253
|
+
await refreshZhState();
|
|
254
|
+
if (retry) $('zh-toggle').click(); // 复用「中」的生成逻辑(含它自己的 toast)
|
|
255
|
+
else toast('翻译端点已保存');
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/* ================= PDF 渲染(pdf.js:缩放 + devicePixelRatio 高清,移植自 pdfqa) ================= */
|
|
259
|
+
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js';
|
|
260
|
+
|
|
261
|
+
let pdfDoc = null;
|
|
262
|
+
let zoomScale = 1.5;
|
|
263
|
+
let dpr = window.devicePixelRatio || 1;
|
|
264
|
+
let renderToken = 0;
|
|
265
|
+
|
|
266
|
+
(function watchDpr() {
|
|
267
|
+
const mq = matchMedia(`(resolution: ${dpr}dppx)`);
|
|
268
|
+
mq.addEventListener('change', function onChange() {
|
|
269
|
+
mq.removeEventListener('change', onChange);
|
|
270
|
+
dpr = window.devicePixelRatio || 1;
|
|
271
|
+
if (pdfDoc) renderAllPages();
|
|
272
|
+
watchDpr();
|
|
273
|
+
});
|
|
274
|
+
})();
|
|
275
|
+
|
|
276
|
+
async function loadPdf(url) {
|
|
277
|
+
pdfDoc = await pdfjsLib.getDocument(url).promise;
|
|
278
|
+
$('empty').style.display = 'none';
|
|
279
|
+
if (fitMode) { fitToWidth(); return; } // fitToWidth 内部 setZoom → renderAllPages
|
|
280
|
+
await renderAllPages();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function renderAllPages() {
|
|
284
|
+
const token = ++renderToken;
|
|
285
|
+
const container = $('pdf-pages');
|
|
286
|
+
const pane = $('pdf-pane');
|
|
287
|
+
if (pane.clientHeight === 0) return; // 悬浮层隐藏期间不重渲(否则锚点恢复会把滚动清零)
|
|
288
|
+
// 滚动比例锚点:不用 DOM rect(并发重渲时旧页可能已被清空,rect 全灭 → 锚点归零)
|
|
289
|
+
const range = pane.scrollHeight - pane.clientHeight;
|
|
290
|
+
const anchorRatio = range > 0 ? pane.scrollTop / range : 0;
|
|
291
|
+
container.innerHTML = '';
|
|
292
|
+
$('zoom-label').textContent = Math.round(zoomScale / 1.5 * 100) + '%';
|
|
293
|
+
|
|
294
|
+
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
|
295
|
+
if (token !== renderToken) return;
|
|
296
|
+
const page = await pdfDoc.getPage(i);
|
|
297
|
+
if (token !== renderToken) return;
|
|
298
|
+
const viewport = page.getViewport({ scale: zoomScale });
|
|
299
|
+
const div = document.createElement('div');
|
|
300
|
+
div.className = 'page';
|
|
301
|
+
div.dataset.page = i;
|
|
302
|
+
div.style.width = viewport.width + 'px';
|
|
303
|
+
|
|
304
|
+
const canvas = document.createElement('canvas');
|
|
305
|
+
canvas.width = Math.ceil(viewport.width * dpr);
|
|
306
|
+
canvas.height = Math.ceil(viewport.height * dpr);
|
|
307
|
+
canvas.style.width = viewport.width + 'px';
|
|
308
|
+
canvas.style.height = viewport.height + 'px';
|
|
309
|
+
div.appendChild(canvas);
|
|
310
|
+
|
|
311
|
+
const tl = document.createElement('div');
|
|
312
|
+
tl.className = 'textLayer';
|
|
313
|
+
tl.style.width = viewport.width + 'px';
|
|
314
|
+
tl.style.height = viewport.height + 'px';
|
|
315
|
+
tl.style.setProperty('--scale-factor', String(zoomScale));
|
|
316
|
+
div.appendChild(tl);
|
|
317
|
+
container.appendChild(div);
|
|
318
|
+
|
|
319
|
+
await page.render({
|
|
320
|
+
canvasContext: canvas.getContext('2d'),
|
|
321
|
+
viewport,
|
|
322
|
+
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : null,
|
|
323
|
+
}).promise;
|
|
324
|
+
if (token !== renderToken) return;
|
|
325
|
+
const tc = await page.getTextContent();
|
|
326
|
+
if (token !== renderToken) return;
|
|
327
|
+
await pdfjsLib.renderTextLayer({ textContent: tc, container: tl, viewport, textDivs: [] }).promise;
|
|
328
|
+
}
|
|
329
|
+
const newRange = pane.scrollHeight - pane.clientHeight;
|
|
330
|
+
pane.scrollTop = anchorRatio * Math.max(0, newRange);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function setZoom(newScale) { if (!pdfDoc) return; zoomScale = Math.min(4.5, Math.max(0.6, newScale)); renderAllPages(); }
|
|
334
|
+
// 默认适宽:rightbar 窄列里 1.5 倍缩放会把页面撑出视口(中英对照双栏页宽≈2×,更明显)
|
|
335
|
+
let fitMode = true;
|
|
336
|
+
const fitBtn = $('zoom-fit');
|
|
337
|
+
fitBtn.classList.add('active');
|
|
338
|
+
function exitFitMode() { fitMode = false; fitBtn.classList.remove('active'); }
|
|
339
|
+
function fitToWidth() {
|
|
340
|
+
if (!pdfDoc) return // resize 可能先于首次 loadPdf 触发(布局换位/条带注入会改容器尺寸)
|
|
341
|
+
const paneWidth = $('pdf-pane').clientWidth - 32
|
|
342
|
+
if (paneWidth <= 0) return // 隐藏期间尺寸为 0,算出的缩放无意义
|
|
343
|
+
pdfDoc.getPage(1).then((p) => setZoom(paneWidth / p.getViewport({ scale: 1 }).width))
|
|
344
|
+
}
|
|
345
|
+
$('zoom-in').onclick = () => { exitFitMode(); setZoom(zoomScale + 0.25); };
|
|
346
|
+
$('zoom-out').onclick = () => { exitFitMode(); setZoom(zoomScale - 0.25); };
|
|
347
|
+
fitBtn.onclick = () => { if (!pdfDoc) return; fitMode = true; fitBtn.classList.add('active'); fitBtn.blur(); fitToWidth(); };
|
|
348
|
+
window.addEventListener('resize', () => { if (fitMode) fitToWidth(); });
|
|
349
|
+
|
|
350
|
+
function jumpToPage(n) {
|
|
351
|
+
const div = document.querySelector(`.page[data-page="${n}"]`);
|
|
352
|
+
if (!div) return;
|
|
353
|
+
div.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
354
|
+
div.classList.add('flash');
|
|
355
|
+
setTimeout(() => div.classList.remove('flash'), 1600);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* ================= 选中即问 ================= */
|
|
359
|
+
const askPop = $('ask-pop');
|
|
360
|
+
let askSelection = null;
|
|
361
|
+
|
|
362
|
+
document.addEventListener('mouseup', (e) => {
|
|
363
|
+
if (askPop.contains(e.target)) return;
|
|
364
|
+
setTimeout(() => {
|
|
365
|
+
const sel = window.getSelection();
|
|
366
|
+
const text = sel ? sel.toString().trim() : '';
|
|
367
|
+
if (!text || !current) { askPop.style.display = 'none'; return; }
|
|
368
|
+
const node = sel.anchorNode && sel.anchorNode.parentElement;
|
|
369
|
+
if (!node || !node.closest('.textLayer')) { askPop.style.display = 'none'; return; }
|
|
370
|
+
const range = sel.getRangeAt(0).getBoundingClientRect();
|
|
371
|
+
askSelection = { text, page: +((node.closest('.page') || {}).dataset?.page || 0) || null };
|
|
372
|
+
$('ask-quote').textContent = text.length > 140 ? text.slice(0, 140) + '…' : text;
|
|
373
|
+
$('ask-input').value = '';
|
|
374
|
+
askPop.style.display = 'block';
|
|
375
|
+
// 夹在 iframe 视口内(rightbar 窄列里也要能用)
|
|
376
|
+
const popW = 336, popH = 190;
|
|
377
|
+
const x = Math.max(8, Math.min(e.clientX, window.innerWidth - popW - 8));
|
|
378
|
+
const y = Math.max(8, Math.min(range.bottom + 8, window.innerHeight - popH - 8));
|
|
379
|
+
askPop.style.left = x + 'px';
|
|
380
|
+
askPop.style.top = y + 'px';
|
|
381
|
+
$('ask-input').focus();
|
|
382
|
+
}, 10);
|
|
383
|
+
});
|
|
384
|
+
document.addEventListener('mousedown', (e) => { if (!askPop.contains(e.target)) askPop.style.display = 'none'; });
|
|
385
|
+
$('ask-cancel').onclick = () => { askPop.style.display = 'none'; };
|
|
386
|
+
|
|
387
|
+
$('ask-send').onclick = async () => {
|
|
388
|
+
const question = $('ask-input').value.trim();
|
|
389
|
+
if (!question || !askSelection || !current) return;
|
|
390
|
+
$('ask-send').disabled = true;
|
|
391
|
+
try {
|
|
392
|
+
// 会话序号由侧栏树激活时写入 localStorage(默认最新)
|
|
393
|
+
const key = `dpr.sessionN.${current.topic}/${current.name}`;
|
|
394
|
+
const n = +(localStorage.getItem(key) || 0) || undefined;
|
|
395
|
+
const r = await api('/ask', {
|
|
396
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
397
|
+
body: JSON.stringify({ ...current, question, selectedText: askSelection.text, page: askSelection.page, ...(n ? { n } : {}) }),
|
|
398
|
+
});
|
|
399
|
+
askPop.style.display = 'none';
|
|
400
|
+
toast(`已发送到 会话${r.n},回答见对话区`);
|
|
401
|
+
} catch (e) { toast('提问失败:' + e.message, 6000); }
|
|
402
|
+
$('ask-send').disabled = false;
|
|
403
|
+
};
|
|
404
|
+
$('ask-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); $('ask-send').click(); } });
|
|
405
|
+
|
|
406
|
+
/* ================= 启动:URL 参数 > localStorage > 第一篇 ================= */
|
|
407
|
+
async function bootstrap() {
|
|
408
|
+
const params = new URLSearchParams(location.search);
|
|
409
|
+
const pTopic = params.get('topic'), pName = params.get('name');
|
|
410
|
+
const lib = await api('/library');
|
|
411
|
+
if (lib.topics.length === 0) {
|
|
412
|
+
$('empty').style.display = 'block';
|
|
413
|
+
$('empty').innerHTML = '文献库为空。<br>在左侧文献库树上传 PDF 或新建专题即可开始。';
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
let target = null;
|
|
417
|
+
if (pTopic && pName && lib.papers.some((p) => p.topic === pTopic && p.name === pName)) target = { topic: pTopic, name: pName };
|
|
418
|
+
else {
|
|
419
|
+
const last = JSON.parse(localStorage.getItem('dpr.last') || 'null');
|
|
420
|
+
if (last && lib.papers.some((p) => p.topic === last.topic && p.name === last.name)) target = last;
|
|
421
|
+
else target = lib.papers[0];
|
|
422
|
+
}
|
|
423
|
+
if (target) await openPaper(target.topic, target.name);
|
|
424
|
+
}
|
|
425
|
+
bootstrap().catch((e) => {
|
|
426
|
+
$('empty').style.display = 'block';
|
|
427
|
+
$('empty').textContent = '文献库加载失败:' + e.message;
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
window.dpr = { openPaper: (topic, name, page) => openPaper(topic, name, { page }), jumpToPage };
|
|
431
|
+
</script>
|
|
432
|
+
</body>
|
|
433
|
+
</html>
|