@ggboy123/dsh-paper-reader 1.3.4 → 1.4.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/LICENSE +21 -0
- package/README.md +221 -21
- package/dist/embed-config.js +116 -0
- package/dist/embed.js +349 -0
- package/dist/formulas.js +272 -0
- package/dist/host.js +733 -110
- package/dist/library-manage.js +752 -0
- package/dist/library.js +187 -69
- package/dist/mineru-config.js +343 -0
- package/dist/mineru.js +1168 -0
- package/dist/search.js +52 -0
- package/dist/tools.js +150 -21
- package/dist/transcribe.js +316 -39
- package/dist/translate-config.js +8 -2
- package/lib/client.js +479 -60
- package/package.json +3 -1
- package/reader/index.html +417 -24
package/lib/client.js
CHANGED
|
@@ -15,6 +15,30 @@
|
|
|
15
15
|
// 加载机制:package.json 声明 dsh.client.inject + exports["./client"];require 解析
|
|
16
16
|
// 官方客户端包,不要用裸 import。
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* 失败文案组装(**纯函数**:入参 `err` + i18n 的 `t`,返回 `{ text, recovery }`)。
|
|
20
|
+
* 放在 `load()` **之外**的文件顶层:Node 里执行本文件即可直接取用(测试用 harness 追加导出),
|
|
21
|
+
* 不需要给模块对象加任何测试钩子。
|
|
22
|
+
*
|
|
23
|
+
* 为什么必须在这里组装(评审 F-B):clear 失败会返回 409 `file-busy` 且带 `rollbackFailed` ——
|
|
24
|
+
* 那些文件**真的还在 `.trash/` 里**,而 `listPapers` 只看常规文件,于是文献树里该篇看起来
|
|
25
|
+
* 「消失」了。若只 toast 一句「文件被占用,关闭后重试」,用户永远不知道该去回收站恢复。
|
|
26
|
+
* 规则:
|
|
27
|
+
* · `rollbackFailed` 非空 ⇒ 点名文件 + 给出回收站**相对**路径(`trashRel`,绝不用绝对路径)与恢复方法;
|
|
28
|
+
* · 未回滚非空时**不得**声称「文献库未改变」(F-C:那句是服务端内层文案,只对失败那一篇成立);
|
|
29
|
+
* · 没有回滚失败 ⇒ 只给简洁文案,不吓唬用户。
|
|
30
|
+
*/
|
|
31
|
+
function clearFailureText(err, t) {
|
|
32
|
+
const files = Array.isArray(err && err.rollbackFailed) ? err.rollbackFailed.filter((x) => typeof x === 'string') : []
|
|
33
|
+
const where = err && typeof err.trashRel === 'string' && err.trashRel ? err.trashRel : '.trash'
|
|
34
|
+
const base = err && err.code === 'file-busy' ? t('delBusyFile') : ((err && err.message) || String(err))
|
|
35
|
+
if (files.length === 0) return { text: base, recovery: '' }
|
|
36
|
+
return {
|
|
37
|
+
text: t('clearPartialFailText').replace('{n}', String(files.length)).replace('{names}', files.join('、')),
|
|
38
|
+
recovery: t('clearRecoveryHint').replace('{where}', where).replace('{names}', files.join('、')),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
18
42
|
window.__ModuleLoader__.load({
|
|
19
43
|
// 必须等于 npm 包名:浏览器端 loader 按包名期待注册(改名时漏改这里 →
|
|
20
44
|
// "loaded without registering" 整页插件加载失败,实测)
|
|
@@ -32,7 +56,7 @@ window.__ModuleLoader__.load({
|
|
|
32
56
|
cancel: '取消', ok: '确定', defaultTopic: '默认专题', uploadBtn: '上传 PDF',
|
|
33
57
|
newTopicFail: '新建专题失败',
|
|
34
58
|
rename: '重命名', renameTopic: '重命名专题', renamePaper: '重命名论文',
|
|
35
|
-
|
|
59
|
+
renameFail: '重命名失败', delFail: '删除失败',
|
|
36
60
|
upload: '上传 PDF 到此专题', newChat: '新建对话', session: '会话', pdfTab: '论文 PDF',
|
|
37
61
|
history: '历史对话',
|
|
38
62
|
sync: '对话还停在另一篇文献的会话里,点击切到当前文献',
|
|
@@ -40,6 +64,8 @@ window.__ModuleLoader__.load({
|
|
|
40
64
|
settings: '论文伴读',
|
|
41
65
|
setRerankTitle: '检索重排(Jev)',
|
|
42
66
|
setRerankDesc: 'search_paper 检索的语义重排:关键词先捞候选,Jev 逐个判断「这段能否回答这个问题」再排序——大白话提问也能命中论文里的术语段落。不配则退回纯关键词排序。',
|
|
67
|
+
setEmbedTitle: '嵌入模型检索(可选)',
|
|
68
|
+
setEmbedDesc: 'search_paper 的语义召回:分块文本会发送到该 OpenAI 兼容 /embeddings 端点,用向量把「关键词一个字都没命中」的语义相近片段也捞进候选(与关键词结果融合,不替代)。留空即关闭,检索行为与现在完全一致。注意:分块文本(论文正文)会发往该端点。',
|
|
43
69
|
setTranslateTitle: '翻译端点',
|
|
44
70
|
setTranslateDesc: '「生成中文版」(babeldoc)用的 OpenAI 兼容端点,三项必填。保存前会先测连接。',
|
|
45
71
|
setBaseUrl: '端点 URL', setModel: '模型', setKey: 'API Key',
|
|
@@ -48,6 +74,55 @@ window.__ModuleLoader__.load({
|
|
|
48
74
|
setKeyKeep: '留空沿用已保存的 ', setOptional: '可选',
|
|
49
75
|
setSourceFile: '当前来源:设置页', setSourceProfile: '当前来源:配置文件(部署方默认)',
|
|
50
76
|
setSourceEnv: '当前来源:环境变量', setSourceNone: '未配置',
|
|
77
|
+
setMineruTitle: 'MinerU 解析后端',
|
|
78
|
+
setMineruDesc: 'PDF 转录的 MinerU 后端:本地 MinerU API(127.0.0.1:8000,扫描件/OCR 友好)或 mineru.net v4 云端。默认关闭(走本地 pdf.js)。保存前会先测试连接。',
|
|
79
|
+
setMineruMode: '模式', setMineruModeOff: '关闭', setMineruModeLocal: '本地', setMineruModeCloud: '云端',
|
|
80
|
+
setMineruLocalUrl: '本地服务地址', setMineruLocalKey: '本地 API Key', setMineruBackend: '本地解析后端',
|
|
81
|
+
setMineruEffort: 'hybrid effort', setMineruParseMethod: '本地解析方式', setMineruCloudKey: '云端 token',
|
|
82
|
+
setMineruTestConnection: '测试连接', setMineruHealthOk: '连接正常', setMineruHealthFail: '连接失败',
|
|
83
|
+
// 激活门槛人话(上游评审第 7 条):普通用户不知道 MinerU 是什么。① 什么时候需要它
|
|
84
|
+
// (扫描版读不了 / 公式点不了);② 配置后入口出现在哪(阅读器公式面板的「用 MinerU 重新转录」)。
|
|
85
|
+
setMineruWhy: '扫描版论文读不了?公式点不了?——在这里配置后就能解析:本地 MinerU 或 mineru.net 云端 token,二选一;保存后阅读器公式面板里会出现「用 MinerU 重新转录」。',
|
|
86
|
+
setMineruWhyLocal: '本地模式需要你自己跑一个 MinerU 服务(127.0.0.1:8000),适合有显卡、论文不外传的场景。',
|
|
87
|
+
setMineruWhyCloud: '云端模式只要一个 mineru.net token,无需本地显卡;论文会上传到 mineru.net 解析。',
|
|
88
|
+
setMineruOffHint: '当前为「关闭」:转录走本地 pdf.js——有文本层的普通 PDF 完全够用;扫描版(没有文本层)与公式引用才需要 MinerU(选本地或云端)。',
|
|
89
|
+
// 文献管理(删除/重命名):契约见 docs/reader-ux-requirements.md §2.9。
|
|
90
|
+
// 去重说明:rename/renameTopic/renamePaper/renameFail/delFail 只保留上面上游那组
|
|
91
|
+
// (JS 对象字面量重复 key 不报错、后者静默胜出 → 必须手工去重,否则文案悄悄取自另一份)。
|
|
92
|
+
newName: '新名称', renameDone: '已重命名',
|
|
93
|
+
renameDetached: '注意:{n} 个历史会话不会删除,但不再显示在列表里',
|
|
94
|
+
renameTargetExists: '已存在同名文献或专题',
|
|
95
|
+
delPaper: '删除文献', delTopic: '删除专题',
|
|
96
|
+
delConfirmTitle: '确认删除', delToTrash: '移入回收站',
|
|
97
|
+
delConfirmBody: '将移入回收站(可恢复):共 {files} 个文件、{size}',
|
|
98
|
+
delNotDeleted: '不会删除:伴读会话历史、共享翻译环境',
|
|
99
|
+
delTopicTypeName: '请输入专题名「{name}」以确认',
|
|
100
|
+
delDone: '已移入回收站({files} 个文件)',
|
|
101
|
+
delBusySession: '该文献有正在运行的会话,请先结束后再操作',
|
|
102
|
+
delBusyTranslate: '正在生成译文,请等待完成后再操作',
|
|
103
|
+
delTopicNotEmpty: '专题内还有 {n} 篇文献,需批量清空或先逐篇删除',
|
|
104
|
+
delTopicClearTitle: '清空「{topic}」并删除',
|
|
105
|
+
delTopicClearBody: '共 {papers} 篇文献、{files} 个文件({size}):确认后逐篇移入回收站(可恢复),然后删除专题目录。有运行中会话或正在翻译的文献会被服务端先拦下。',
|
|
106
|
+
delTopicClearConfirm: '清空并删除',
|
|
107
|
+
delTopicClearFiles: '{n} 个文件',
|
|
108
|
+
delTopicClearBlocked: '注意:以下文献正在翻译或有运行中会话,会被服务端拦下(先处理再清空):{names}',
|
|
109
|
+
delTopicClearPlanFail: '无法获取清空清单',
|
|
110
|
+
delTopicClearHidden: '隐藏条目 {names} 也会进回收站(不销毁)',
|
|
111
|
+
clearDone: '已清空「{topic}」:{papers} 篇 / {files} 个文件 → 已移入回收站',
|
|
112
|
+
delBusyFile: '文件被占用(可能正在阅读器或其他程序中打开),关闭后重试',
|
|
113
|
+
// F-B/F-C:回滚失败必须把「哪些文件、在哪个回收站、怎么恢复」说清楚;未回滚时不得声称「库未改变」
|
|
114
|
+
clearPartialFailText: '操作未完成:{n} 个文件未能回滚,仍在回收站里(文献树里可能已看不到):{names}',
|
|
115
|
+
clearRecoveryHint: '恢复方法:把 {where}/ 里列出的这些文件移回原专题目录即可',
|
|
116
|
+
delPlanFail: '无法获取删除清单',
|
|
117
|
+
delBusy: '同一篇文献的另一个删除/重命名操作正在进行,请稍后重试',
|
|
118
|
+
delSessionUnknown: '暂时无法确认该文献的伴读会话状态,为避免误删已取消本次操作;请稍后重试',
|
|
119
|
+
eqTitle: '公式', eqPanelEmpty: '本论文未解析出公式',
|
|
120
|
+
eqNeedMineru: '需要 MinerU 解析产物才能点击引用公式',
|
|
121
|
+
eqTranscribe: '用 MinerU 重新转录', eqBadArtifact: 'MinerU 产物损坏,已忽略',
|
|
122
|
+
eqCopy: '复制 LaTeX', eqCopied: '已复制公式 LaTeX',
|
|
123
|
+
eqChip: '第 {page} 页 · 公式 {k}',
|
|
124
|
+
eqTranslatedView: '译文视图没有公式位置信息,切回原文可点击',
|
|
125
|
+
eqNoPosition: '无位置信息',
|
|
51
126
|
}
|
|
52
127
|
const en = {
|
|
53
128
|
library: 'Library',
|
|
@@ -55,7 +130,7 @@ window.__ModuleLoader__.load({
|
|
|
55
130
|
cancel: 'Cancel', ok: 'OK', defaultTopic: 'Default', uploadBtn: 'Upload PDF',
|
|
56
131
|
newTopicFail: 'Failed to create topic',
|
|
57
132
|
rename: 'Rename', renameTopic: 'Rename topic', renamePaper: 'Rename paper',
|
|
58
|
-
|
|
133
|
+
renameFail: 'Rename failed', delFail: 'Delete failed',
|
|
59
134
|
upload: 'Upload PDF into this topic', newChat: 'New chat', session: 'Session', pdfTab: 'Paper PDF',
|
|
60
135
|
history: 'Chat history',
|
|
61
136
|
sync: 'Chat is still on another paper — click to switch to the current one',
|
|
@@ -63,6 +138,8 @@ window.__ModuleLoader__.load({
|
|
|
63
138
|
settings: 'Paper Reader',
|
|
64
139
|
setRerankTitle: 'Search rerank (Jev)',
|
|
65
140
|
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.',
|
|
141
|
+
setEmbedTitle: 'Embedding search (optional)',
|
|
142
|
+
setEmbedDesc: 'Semantic recall for search_paper: chunk texts are sent to this OpenAI-compatible /embeddings endpoint so passages that share no keywords but are semantically close still enter the candidate set (fused with keyword hits, never replacing them). Leave empty to keep it off — search behaviour stays exactly as today. Note: chunk texts (paper body) are sent to this endpoint.',
|
|
66
143
|
setTranslateTitle: 'Translate endpoint',
|
|
67
144
|
setTranslateDesc: 'OpenAI-compatible endpoint for "Generate Chinese version" (babeldoc). All three fields required. Connection is tested before saving.',
|
|
68
145
|
setBaseUrl: 'Endpoint URL', setModel: 'Model', setKey: 'API Key',
|
|
@@ -71,6 +148,49 @@ window.__ModuleLoader__.load({
|
|
|
71
148
|
setKeyKeep: 'Leave empty to keep saved ', setOptional: 'optional',
|
|
72
149
|
setSourceFile: 'Source: settings page', setSourceProfile: 'Source: config file (deployment default)',
|
|
73
150
|
setSourceEnv: 'Source: environment variable', setSourceNone: 'Not configured',
|
|
151
|
+
setMineruTitle: 'MinerU parser',
|
|
152
|
+
setMineruDesc: 'MinerU backend for PDF transcription: local MinerU API (127.0.0.1:8000, scan/OCR friendly) or mineru.net v4 cloud. Off by default (local pdf.js). Connection is tested before saving.',
|
|
153
|
+
setMineruMode: 'Mode', setMineruModeOff: 'Off', setMineruModeLocal: 'Local', setMineruModeCloud: 'Cloud',
|
|
154
|
+
setMineruLocalUrl: 'Local service URL', setMineruLocalKey: 'Local API key', setMineruBackend: 'Local backend',
|
|
155
|
+
setMineruEffort: 'hybrid effort', setMineruParseMethod: 'Parse method', setMineruCloudKey: 'Cloud token',
|
|
156
|
+
setMineruTestConnection: 'Test connection', setMineruHealthOk: 'Connection OK', setMineruHealthFail: 'Connection failed',
|
|
157
|
+
setMineruWhy: 'Scanned PDFs unreadable? Formulas not clickable? Configure a parser here: either a local MinerU API or a mineru.net cloud token. Once saved, the reader adds a "Re-parse with MinerU" button in the formula panel.',
|
|
158
|
+
setMineruWhyLocal: 'Local mode expects your own MinerU service (127.0.0.1:8000) — for GPUs and papers that should stay on your machine.',
|
|
159
|
+
setMineruWhyCloud: 'Cloud mode needs only a mineru.net token and no local GPU; the paper is uploaded to mineru.net for parsing.',
|
|
160
|
+
setMineruOffHint: 'Currently Off: transcription uses local pdf.js — fine for PDFs that already have a text layer; scanned PDFs (no text layer) and formula citing need MinerU (local or cloud).',
|
|
161
|
+
newName: 'New name', renameDone: 'Renamed',
|
|
162
|
+
renameDetached: 'Note: {n} past chats are kept but no longer listed',
|
|
163
|
+
renameTargetExists: 'A paper or topic with that name already exists',
|
|
164
|
+
delPaper: 'Delete paper', delTopic: 'Delete topic',
|
|
165
|
+
delConfirmTitle: 'Confirm deletion', delToTrash: 'Move to trash',
|
|
166
|
+
delConfirmBody: 'Will be moved to trash (recoverable): {files} files, {size}',
|
|
167
|
+
delNotDeleted: 'Not deleted: chat history, shared translation venv',
|
|
168
|
+
delTopicTypeName: 'Type the topic name "{name}" to confirm',
|
|
169
|
+
delDone: 'Moved to trash ({files} files)',
|
|
170
|
+
delBusySession: 'A session is still running — end it first',
|
|
171
|
+
delBusyTranslate: 'Translation in progress — try again later',
|
|
172
|
+
delTopicNotEmpty: 'Topic still has {n} papers — use bulk clear, or delete them one by one',
|
|
173
|
+
delTopicClearTitle: 'Clear \u201c{topic}\u201d and delete',
|
|
174
|
+
delTopicClearBody: '{papers} papers, {files} files ({size}): after confirmation each paper is moved to the trash (recoverable), then the topic folder is removed. Papers with a running chat or an in-progress translation are blocked first.',
|
|
175
|
+
delTopicClearConfirm: 'Clear & delete',
|
|
176
|
+
delTopicClearFiles: '{n} files',
|
|
177
|
+
delTopicClearBlocked: 'Heads up: these papers are translating or have running chats and will be blocked by the server — handle them first: {names}',
|
|
178
|
+
delTopicClearPlanFail: 'Could not fetch the clear plan',
|
|
179
|
+
delTopicClearHidden: 'Hidden entries {names} are moved to the trash too (never destroyed)',
|
|
180
|
+
clearDone: 'Cleared \u201c{topic}\u201d: {papers} papers / {files} files \u2192 moved to trash',
|
|
181
|
+
delBusyFile: 'File is in use (open in the reader or another program) — close it and retry',
|
|
182
|
+
clearPartialFailText: 'The operation did not finish: {n} file(s) could not be rolled back and are still in the trash (the paper may look gone in the tree): {names}',
|
|
183
|
+
clearRecoveryHint: 'To recover: move those files from {where}/ back into the original topic folder',
|
|
184
|
+
delPlanFail: 'Could not fetch the deletion manifest',
|
|
185
|
+
delBusy: 'Another delete/rename on this paper is in progress — try again later',
|
|
186
|
+
delSessionUnknown: 'Could not confirm chat status — cancelled to avoid accidental deletion; try again later',
|
|
187
|
+
eqTitle: 'Formulas', eqPanelEmpty: 'No formulas parsed for this paper',
|
|
188
|
+
eqNeedMineru: 'MinerU output required to click-cite formulas',
|
|
189
|
+
eqTranscribe: 'Re-parse with MinerU', eqBadArtifact: 'MinerU output is corrupt — ignored',
|
|
190
|
+
eqCopy: 'Copy LaTeX', eqCopied: 'Formula LaTeX copied',
|
|
191
|
+
eqChip: 'p.{page} · formula {k}',
|
|
192
|
+
eqTranslatedView: 'No positions in the translated view — switch back to the original',
|
|
193
|
+
eqNoPosition: 'No position info',
|
|
74
194
|
}
|
|
75
195
|
|
|
76
196
|
let t = (k) => k
|
|
@@ -111,7 +231,13 @@ window.__ModuleLoader__.load({
|
|
|
111
231
|
const r = await fetch('/paper-reader/api' + path, opts)
|
|
112
232
|
if (!r.ok) {
|
|
113
233
|
const body = await r.json().catch(() => ({}))
|
|
114
|
-
|
|
234
|
+
const err = new Error(body.error || body.reason || ('HTTP ' + r.status))
|
|
235
|
+
// 机器可读码挂到 Error 上:调用方按 code 分支(别去匹配中文文案)
|
|
236
|
+
err.code = body.code
|
|
237
|
+
err.status = r.status
|
|
238
|
+
if (body.papers) err.papers = body.papers
|
|
239
|
+
if (body.entries) err.entries = body.entries
|
|
240
|
+
throw err
|
|
115
241
|
}
|
|
116
242
|
return r.json()
|
|
117
243
|
}
|
|
@@ -561,9 +687,12 @@ window.__ModuleLoader__.load({
|
|
|
561
687
|
曾因此毫无反应),一律走 createPortal 挂 document.body,避免被侧栏 overflow 裁剪。 ---------- */
|
|
562
688
|
function AskText(props) {
|
|
563
689
|
const { title, confirmText, cancelText, onConfirm, onCancel, initial } = props
|
|
564
|
-
const [v, setV] = useState(() => initial ?? '')
|
|
690
|
+
const [v, setV] = useState(() => initial ?? '') // 上游写法:惰性初值(避免每次渲染重算)
|
|
565
691
|
const ref = React.useRef(null)
|
|
566
|
-
useEffect(() => {
|
|
692
|
+
useEffect(() => {
|
|
693
|
+
ref.current?.focus()
|
|
694
|
+
if (ref.current && initial) ref.current.select() // 重命名场景:默认选中旧名,直接覆盖输入
|
|
695
|
+
}, [])
|
|
567
696
|
const submit = () => { const s = v.trim(); if (s) onConfirm(s) }
|
|
568
697
|
return h('div', {
|
|
569
698
|
style: { position: 'fixed', inset: 0, zIndex: 99999, background: 'rgba(0,0,0,.35)', display: 'flex', alignItems: 'center', justifyContent: 'center', font: 'inherit' },
|
|
@@ -585,6 +714,44 @@ window.__ModuleLoader__.load({
|
|
|
585
714
|
h('button', { type: 'button', style: S.btnPrimary, onClick: submit }, confirmText))))
|
|
586
715
|
}
|
|
587
716
|
|
|
717
|
+
/* ---------- 破坏性操作确认弹窗(删除专用)----------
|
|
718
|
+
与 AskText 同一套 portal + 页内弹窗约定:Electron 渲染进程不支持 window.confirm/prompt。
|
|
719
|
+
契约要求:文案必须**逐条列出将删除的内容**,且清单来自服务端 dry-run(delete-plan),
|
|
720
|
+
不允许前端自己拼。专题删除额外要求输入专题名(type-to-confirm)。 */
|
|
721
|
+
function ConfirmDelete(props) {
|
|
722
|
+
const { title, body, detail, notDeleted, confirmText, cancelText, requireText, onConfirm, onCancel } = props
|
|
723
|
+
const [typed, setTyped] = useState('')
|
|
724
|
+
const ok = !requireText || typed.trim() === requireText
|
|
725
|
+
return h('div', {
|
|
726
|
+
style: { position: 'fixed', inset: 0, zIndex: 99999, background: 'rgba(0,0,0,.35)', display: 'flex', alignItems: 'center', justifyContent: 'center', font: 'inherit' },
|
|
727
|
+
onClick: onCancel,
|
|
728
|
+
},
|
|
729
|
+
h('div', {
|
|
730
|
+
style: { width: '380px', maxWidth: '92vw', maxHeight: '86vh', overflow: 'auto', 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)' },
|
|
731
|
+
onClick: (e) => e.stopPropagation(),
|
|
732
|
+
},
|
|
733
|
+
h('div', { style: { fontSize: '13px', fontWeight: 600, margin: '0 0 8px' } }, title),
|
|
734
|
+
body ? h('div', { style: { fontSize: '12.5px', lineHeight: 1.6 } }, body) : null,
|
|
735
|
+
detail ? h('div', {
|
|
736
|
+
style: { margin: '8px 0 0', padding: '6px 8px', borderRadius: '6px', background: 'var(--dsw-alias-bg-layer-2, #f6f8fa)', fontSize: '11.5px', lineHeight: 1.7, maxHeight: '32vh', overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-all' },
|
|
737
|
+
}, detail) : null,
|
|
738
|
+
notDeleted ? h('div', { style: { marginTop: '8px', fontSize: '11.5px', color: 'var(--dsw-alias-label-secondary, #656d76)', lineHeight: 1.6 } }, notDeleted) : null,
|
|
739
|
+
requireText ? h('input', {
|
|
740
|
+
value: typed, type: 'text', placeholder: requireText,
|
|
741
|
+
style: { width: '100%', boxSizing: 'border-box', marginTop: '10px', 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' },
|
|
742
|
+
onChange: (e) => setTyped(e.target.value),
|
|
743
|
+
onKeyDown: (e) => { if (e.key === 'Enter' && ok) onConfirm(); else if (e.key === 'Escape') onCancel() },
|
|
744
|
+
}) : null,
|
|
745
|
+
h('div', { style: { display: 'flex', gap: '8px', justifyContent: 'flex-end', margin: '12px 0 0' } },
|
|
746
|
+
h('button', { type: 'button', style: S.btnGhost, onClick: onCancel }, cancelText),
|
|
747
|
+
h('button', {
|
|
748
|
+
type: 'button',
|
|
749
|
+
style: { ...S.btnPrimary, ...(ok ? {} : { opacity: .5, cursor: 'default' }) },
|
|
750
|
+
disabled: !ok,
|
|
751
|
+
onClick: () => { if (ok) onConfirm() },
|
|
752
|
+
}, confirmText))))
|
|
753
|
+
}
|
|
754
|
+
|
|
588
755
|
function LibraryTree(props) {
|
|
589
756
|
const { layout, uiWorkspace, sidebarRight, wide, expandSidebar } = props
|
|
590
757
|
const [lib, setLib] = useState(null)
|
|
@@ -756,24 +923,25 @@ window.__ModuleLoader__.load({
|
|
|
756
923
|
已知代价(README 明示):sessionId 编码旧 topic/name,改名后历史伴读会话从树上
|
|
757
924
|
消失(会话本体仍在 dsh 全局列表);study.json/译文/转录缓存随文件改名全部保留。 */
|
|
758
925
|
const [renaming, setRenaming] = useState(null) // {kind:'topic'|'paper', topic, name}
|
|
759
|
-
const [armDel, setArmDel] = useState(null) // {kind, topic, name}:两段式删除的待确认态
|
|
760
|
-
|
|
761
|
-
// 两段式删除的待确认态 3s 自动复位(复刻阅读器 ↻ 重译按钮的 arm 模式)
|
|
762
|
-
useEffect(() => {
|
|
763
|
-
if (!armDel) return undefined
|
|
764
|
-
const timer = setTimeout(() => setArmDel(null), 3000)
|
|
765
|
-
return () => clearTimeout(timer)
|
|
766
|
-
}, [armDel])
|
|
767
926
|
|
|
768
927
|
const doRename = useCallback(async (kind, topic, name, to) => {
|
|
769
928
|
const newTopicName = kind === 'topic' ? to : topic
|
|
770
929
|
const newName = kind === 'paper' ? to : name
|
|
930
|
+
let r = null
|
|
771
931
|
try {
|
|
772
|
-
await api(`/library/rename-${kind}`, {
|
|
932
|
+
r = await api(`/library/rename-${kind}`, {
|
|
773
933
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
774
934
|
body: JSON.stringify(kind === 'topic' ? { topic, to } : { topic, name, to }),
|
|
775
935
|
})
|
|
776
|
-
} catch (err) {
|
|
936
|
+
} catch (err) {
|
|
937
|
+
// 与删除一致:改名失败也可能带回 rollbackFailed(部分产物名字没能回滚)→ 必须给出恢复指引
|
|
938
|
+
const f = clearFailureText(err, t)
|
|
939
|
+
toast(t('renameFail') + ': ' + f.text + (f.recovery ? ' ' + f.recovery : ''), f.recovery ? 15000 : 6000)
|
|
940
|
+
return
|
|
941
|
+
}
|
|
942
|
+
// 我们分支的加固实现会回 detachedSessions(会话 id 内嵌旧 topic/name → 改名后不再挂树上,
|
|
943
|
+
// 但会话本体不删除)。上游 UI 不提示这一点,用户会以为会话丢了——保留我们的提示。
|
|
944
|
+
if (r && r.detachedSessions) toast(t('renameDone') + ' · ' + t('renameDetached').replace('{n}', String(r.detachedSessions)), 6000)
|
|
777
945
|
// 本地记忆(语言版本/会话序号/上次打开/折叠态)搬到新身份
|
|
778
946
|
migrateLsKeys(topic, newTopicName, kind === 'paper' ? name : null, kind === 'paper' ? newName : null)
|
|
779
947
|
setSessions((m) => { const c = { ...m }; delete c[`${topic}/${name}`]; return c })
|
|
@@ -791,36 +959,9 @@ window.__ModuleLoader__.load({
|
|
|
791
959
|
refresh()
|
|
792
960
|
}, [cur, refresh])
|
|
793
961
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
798
|
-
body: JSON.stringify(kind === 'topic' ? { topic } : { topic, name }),
|
|
799
|
-
})
|
|
800
|
-
} catch (err) { toast(t('delFail') + ': ' + err.message); return }
|
|
801
|
-
setSessions((m) => { const c = { ...m }; delete c[`${topic}/${name}`]; return c })
|
|
802
|
-
refresh()
|
|
803
|
-
// 删的是当前论文(或其所在专题)→ 切到剩余第一篇;全空则收起阅读器
|
|
804
|
-
if (cur && (kind === 'topic' ? cur.topic === topic : (cur.topic === topic && cur.name === name))) {
|
|
805
|
-
const d = await api('/library').catch(() => null)
|
|
806
|
-
const next = d?.papers?.[0] || null
|
|
807
|
-
if (next) {
|
|
808
|
-
setCur({ topic: next.topic, name: next.name })
|
|
809
|
-
window.dispatchEvent(new CustomEvent('dpr:paper', { detail: { topic: next.topic, name: next.name } }))
|
|
810
|
-
} else {
|
|
811
|
-
setCur(null)
|
|
812
|
-
try { localStorage.removeItem('dpr.last') } catch {}
|
|
813
|
-
window.dispatchEvent(new CustomEvent('dpr:paper', { detail: { empty: true } }))
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
}, [cur, refresh])
|
|
817
|
-
|
|
818
|
-
// 两段式删除点击:第一击进入待确认(按钮变红显示「确认删除?」),第二击执行
|
|
819
|
-
const clickDelete = useCallback((kind, topic, name) => {
|
|
820
|
-
const armed = armDel && armDel.kind === kind && armDel.topic === topic && armDel.name === name
|
|
821
|
-
if (armed) { setArmDel(null); doDelete(kind, topic, name); return }
|
|
822
|
-
setArmDel({ kind, topic, name })
|
|
823
|
-
}, [armDel, doDelete])
|
|
962
|
+
// 删除不走上游的「同一按钮点两次(3s arm)」:改用我们的「服务端删除计划 + 页内确认框」
|
|
963
|
+
// (文献列清单/总字节,专题要手输专题名)。语义上严格包含上游的两段式——第二击之前
|
|
964
|
+
// 还多一次带清单的确认,且底层是移入回收站(可恢复)而不是 rmSync。
|
|
824
965
|
|
|
825
966
|
const upload = useCallback((topic) => {
|
|
826
967
|
uploadTopic.current = topic
|
|
@@ -877,6 +1018,140 @@ window.__ModuleLoader__.load({
|
|
|
877
1018
|
}
|
|
878
1019
|
}, [enter, newChat])
|
|
879
1020
|
|
|
1021
|
+
/* ---------- 文献管理:重命名 / 删除(契约 §2)----------
|
|
1022
|
+
破坏性操作的 UI 规矩:
|
|
1023
|
+
· 一律页内 portal 弹窗(Electron 无 window.confirm/prompt);
|
|
1024
|
+
· 删除前先打 /library/paper/delete-plan 拿服务端清单,把「将删除什么 / 不删什么」
|
|
1025
|
+
逐条写进确认框(前端不许自己拼清单);
|
|
1026
|
+
· 服务端 409 用机器可读 code 分支(session-running / translation-busy /
|
|
1027
|
+
topic-not-empty / target-exists),不匹配中文文案。 */
|
|
1028
|
+
const [delDlg, setDelDlg] = useState(null) // {kind, topic, name, plan}
|
|
1029
|
+
|
|
1030
|
+
const fmtBytes = (n) => (n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? Math.round(n / 1024) + ' KB' : n + ' B')
|
|
1031
|
+
const errText = (err) => {
|
|
1032
|
+
switch (err && err.code) {
|
|
1033
|
+
case 'session-running': return t('delBusySession')
|
|
1034
|
+
case 'translation-busy': return t('delBusyTranslate')
|
|
1035
|
+
case 'busy': return t('delBusy')
|
|
1036
|
+
// 上游必修 3:Windows 文件占用(EBUSY/EPERM)→ 409 file-busy,给「关掉占用程序再试」的可执行文案
|
|
1037
|
+
case 'file-busy': return t('delBusyFile')
|
|
1038
|
+
case 'session-unknown': return t('delSessionUnknown')
|
|
1039
|
+
case 'target-exists': return t('renameTargetExists')
|
|
1040
|
+
case 'topic-not-empty': return t('delTopicNotEmpty').replace('{n}', String((err.papers || []).length || (err.entries || []).length || 0))
|
|
1041
|
+
default: return (err && err.message) || String(err)
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const openDelete = useCallback(async (kind, topic, name) => {
|
|
1046
|
+
try {
|
|
1047
|
+
if (kind === 'topic') {
|
|
1048
|
+
const n = (lib?.papers ?? []).filter((p) => p.topic === name).length
|
|
1049
|
+
if (n > 0) {
|
|
1050
|
+
// 非空专题 → 「批量清空」中间态(上游评论第 10 条:逐篇列清单、一次确认)。
|
|
1051
|
+
// 清单来自服务端 dry-run(topic/clear-plan),前端不自己拼;拿不到就如实报错,不弹空壳。
|
|
1052
|
+
const plan = await api(`/library/topic/clear-plan?topic=${encodeURIComponent(name)}`)
|
|
1053
|
+
setDelDlg({ kind: 'topic-clear', topic: name, name, plan })
|
|
1054
|
+
return
|
|
1055
|
+
}
|
|
1056
|
+
setDelDlg({ kind, topic: name, name, plan: null })
|
|
1057
|
+
return
|
|
1058
|
+
}
|
|
1059
|
+
const plan = await api(`/library/paper/delete-plan?topic=${encodeURIComponent(topic)}&name=${encodeURIComponent(name)}`)
|
|
1060
|
+
setDelDlg({ kind, topic, name, plan })
|
|
1061
|
+
} catch (e) {
|
|
1062
|
+
// 预览失败(含 409/400/404):用 code 给出可读原因,不弹一个空壳确认框
|
|
1063
|
+
toast(t('delPlanFail') + ':' + errText(e))
|
|
1064
|
+
}
|
|
1065
|
+
}, [lib])
|
|
1066
|
+
|
|
1067
|
+
/** 删掉的是当前打开的文献(或其所在专题)→ 切到剩余第一篇;全空则收起阅读器。
|
|
1068
|
+
* (上游 v1.3.0 doDelete 的行为,整合时保留——否则 iframe 会指向已被删除的文件。) */
|
|
1069
|
+
const switchAfterDelete = useCallback(async (kind, topic, name) => {
|
|
1070
|
+
if (!(cur && (kind === 'topic' ? cur.topic === topic : (cur.topic === topic && cur.name === name)))) return
|
|
1071
|
+
const d = await api('/library').catch(() => null)
|
|
1072
|
+
const next = d?.papers?.[0] || null
|
|
1073
|
+
if (next) {
|
|
1074
|
+
setCur({ topic: next.topic, name: next.name })
|
|
1075
|
+
window.dispatchEvent(new CustomEvent('dpr:paper', { detail: { topic: next.topic, name: next.name } }))
|
|
1076
|
+
} else {
|
|
1077
|
+
setCur(null)
|
|
1078
|
+
try { localStorage.removeItem('dpr.last') } catch { /* 隐私模式 */ }
|
|
1079
|
+
window.dispatchEvent(new CustomEvent('dpr:paper', { detail: { empty: true } }))
|
|
1080
|
+
}
|
|
1081
|
+
}, [cur])
|
|
1082
|
+
|
|
1083
|
+
const doDeletePaper = useCallback(async () => {
|
|
1084
|
+
const d = delDlg
|
|
1085
|
+
if (!d) return
|
|
1086
|
+
setDelDlg(null)
|
|
1087
|
+
try {
|
|
1088
|
+
// O2(t4 评审):删除也走**上游规范名**(与重命名的 /library/rename-${kind} 一致);
|
|
1089
|
+
// 别名族仍保留在服务端(旧测试与旧客户端用),但本 UI 只用规范名这一条。
|
|
1090
|
+
const r = await api('/library/delete-paper', {
|
|
1091
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1092
|
+
body: JSON.stringify({ topic: d.topic, name: d.name }),
|
|
1093
|
+
})
|
|
1094
|
+
toast(t('delDone').replace('{files}', String((r.moved || []).length)))
|
|
1095
|
+
await switchAfterDelete('paper', d.topic, d.name)
|
|
1096
|
+
refresh()
|
|
1097
|
+
} catch (e) {
|
|
1098
|
+
const f = clearFailureText(e, t)
|
|
1099
|
+
toast(t('delFail') + ':' + f.text + (f.recovery ? ' ' + f.recovery : ''), f.recovery ? 15000 : 6000)
|
|
1100
|
+
refresh()
|
|
1101
|
+
}
|
|
1102
|
+
}, [delDlg, refresh, switchAfterDelete])
|
|
1103
|
+
|
|
1104
|
+
/** 批量清空 + 删除专题:**一次**确认(手输专题名保留在这一步),逐篇走回收站;
|
|
1105
|
+
* 失败由服务端「整体回滚」并把 rollbackFailed 如实带回,这里只按 code 给可读文案。 */
|
|
1106
|
+
const doClearTopic = useCallback(async () => {
|
|
1107
|
+
const d = delDlg
|
|
1108
|
+
if (!d) return
|
|
1109
|
+
setDelDlg(null)
|
|
1110
|
+
try {
|
|
1111
|
+
const r = await api('/library/topic/clear', {
|
|
1112
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1113
|
+
body: JSON.stringify({ topic: d.name, confirmName: d.name }),
|
|
1114
|
+
})
|
|
1115
|
+
toast(t('clearDone').replace('{topic}', d.name).replace('{papers}', String(r.cleared)).replace('{files}', String(r.movedFiles)), 5000)
|
|
1116
|
+
await switchAfterDelete('topic', d.name, undefined)
|
|
1117
|
+
refresh()
|
|
1118
|
+
} catch (e) {
|
|
1119
|
+
// F-B:clear 失败正是走 409 file-busy + rollbackFailed —— 必须把「文件还在回收站、
|
|
1120
|
+
// 在哪个回收站、怎么恢复」显示出来(只报「文件被占用」会让用户以为文献丢了)。
|
|
1121
|
+
const f = clearFailureText(e, t)
|
|
1122
|
+
toast(t('delFail') + ':' + f.text + (f.recovery ? ' ' + f.recovery : ''), f.recovery ? 15000 : 8000)
|
|
1123
|
+
refresh()
|
|
1124
|
+
}
|
|
1125
|
+
}, [delDlg, refresh, switchAfterDelete])
|
|
1126
|
+
|
|
1127
|
+
const doDeleteTopic = useCallback(async () => {
|
|
1128
|
+
const d = delDlg
|
|
1129
|
+
if (!d) return
|
|
1130
|
+
setDelDlg(null)
|
|
1131
|
+
try {
|
|
1132
|
+
await api('/library/delete-topic', {
|
|
1133
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1134
|
+
body: JSON.stringify({ name: d.name, confirmName: d.name }),
|
|
1135
|
+
})
|
|
1136
|
+
toast(t('delDone').replace('{files}', '0'))
|
|
1137
|
+
await switchAfterDelete('topic', d.name, undefined)
|
|
1138
|
+
refresh()
|
|
1139
|
+
} catch (e) {
|
|
1140
|
+
const f = clearFailureText(e, t)
|
|
1141
|
+
toast(t('delFail') + ':' + f.text + (f.recovery ? ' ' + f.recovery : ''), f.recovery ? 15000 : 6000)
|
|
1142
|
+
// 竞态:确认框弹出后该专题又有了文献 → 服务端 409 topic-not-empty 且带 clearEndpoint,
|
|
1143
|
+
// 直接改走「批量清空」中间态(服务端同源清单),不让用户从头再来一次。
|
|
1144
|
+
if (e?.code === 'topic-not-empty' && e?.clearEndpoint) await openDelete('topic', d.name, d.name)
|
|
1145
|
+
refresh()
|
|
1146
|
+
}
|
|
1147
|
+
}, [delDlg, refresh, switchAfterDelete, openDelete])
|
|
1148
|
+
|
|
1149
|
+
// 清空确认框里的「会被服务端拦下」提示:清单里的 translation.busy / sessions.running 篇名
|
|
1150
|
+
// (在用户点确认**之前**就提示,而不是等 409)
|
|
1151
|
+
const clearBlockedNames = delDlg?.kind === 'topic-clear'
|
|
1152
|
+
? (delDlg.plan?.papers ?? []).filter((p) => p.translation?.busy || (p.sessions?.running ?? 0) > 0).map((p) => p.name)
|
|
1153
|
+
: []
|
|
1154
|
+
|
|
880
1155
|
// 收起态(rail):官方契约 —— sidebar.workspaces 在窄栏里只该给一个图标入口,点击请求
|
|
881
1156
|
// 宿主展开。整棵树不渲染:塞进 56px 会被挤成竖排文字(实测)。
|
|
882
1157
|
// 用 wide === false 而非 !wide:宿主万一没传这个 prop 时按展开态走,即改动前的行为。
|
|
@@ -900,7 +1175,6 @@ window.__ModuleLoader__.load({
|
|
|
900
1175
|
h('button', { type: 'button', style: S.linkBtn, onClick: newTopic }, '+ ', t('newTopic')))),
|
|
901
1176
|
topics.map((topic) => {
|
|
902
1177
|
const isClosed = !!closed[topic]
|
|
903
|
-
const topicArmed = armDel && armDel.kind === 'topic' && armDel.topic === topic
|
|
904
1178
|
return h('div', { key: topic },
|
|
905
1179
|
h('div', {
|
|
906
1180
|
style: S.topicHead, 'data-dpr-row': '',
|
|
@@ -921,16 +1195,13 @@ window.__ModuleLoader__.load({
|
|
|
921
1195
|
onClick: (e) => { e.stopPropagation(); setRenaming({ kind: 'topic', topic }) },
|
|
922
1196
|
}, '✎'),
|
|
923
1197
|
h('button', {
|
|
924
|
-
'data-dpr-act': 'del',
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
onClick: (e) => { e.stopPropagation(); clickDelete('topic', topic, undefined) },
|
|
928
|
-
}, topicArmed ? t('delArm') : '🗑'),
|
|
1198
|
+
'data-dpr-act': 'del', style: S.iconBtn, title: t('delTopic'),
|
|
1199
|
+
onClick: (e) => { e.stopPropagation(); openDelete('topic', topic, topic) },
|
|
1200
|
+
}, '🗑'),
|
|
929
1201
|
),
|
|
930
1202
|
!isClosed && h('div', { style: S.items },
|
|
931
1203
|
papers.filter((p) => p.topic === topic).map((p) => {
|
|
932
1204
|
const isCurrentPaper = cur && cur.topic === p.topic && cur.name === p.name
|
|
933
|
-
const paperArmed = armDel && armDel.kind === 'paper' && armDel.topic === p.topic && armDel.name === p.name
|
|
934
1205
|
return h('div', {
|
|
935
1206
|
key: p.name,
|
|
936
1207
|
style: { ...S.item, ...(isCurrentPaper ? S.active : {}) },
|
|
@@ -944,18 +1215,18 @@ window.__ModuleLoader__.load({
|
|
|
944
1215
|
onClick: (e) => { e.stopPropagation(); setRenaming({ kind: 'paper', topic: p.topic, name: p.name }) },
|
|
945
1216
|
}, '✎'),
|
|
946
1217
|
h('button', {
|
|
947
|
-
'data-dpr-act': 'del',
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
onClick: (e) => { e.stopPropagation(); clickDelete('paper', p.topic, p.name) },
|
|
951
|
-
}, paperArmed ? t('delArm') : '🗑'))
|
|
1218
|
+
'data-dpr-act': 'del', style: S.iconBtn, title: t('delPaper'),
|
|
1219
|
+
onClick: (e) => { e.stopPropagation(); openDelete('paper', p.topic, p.name) },
|
|
1220
|
+
}, '🗑'))
|
|
952
1221
|
}),
|
|
953
1222
|
),
|
|
954
1223
|
)
|
|
955
1224
|
}),
|
|
956
1225
|
)
|
|
957
1226
|
|
|
958
|
-
return h('div', {
|
|
1227
|
+
return h('div', {
|
|
1228
|
+
style: S.root, 'data-dsh-plugin': 'dsh-paper-reader', 'data-dsh-surface': 'sidebar',
|
|
1229
|
+
},
|
|
959
1230
|
h('input', { ref: fileInput, type: 'file', accept: '.pdf', style: { display: 'none' }, onChange: onFile }),
|
|
960
1231
|
// 阅读器 iframe 与对话头部按钮组已迁往 shell.overlay 常驻注册(见 applyPaperMode 下方)——
|
|
961
1232
|
// 它们不属于侧栏,更不能跟着「退出伴读模式」被卸载(已打开的 PDF 页签要继续活着)
|
|
@@ -970,6 +1241,7 @@ window.__ModuleLoader__.load({
|
|
|
970
1241
|
onCancel: () => setAskTopic(false),
|
|
971
1242
|
}),
|
|
972
1243
|
document.body),
|
|
1244
|
+
// 重命名:上游 v1.3.0 的页内输入弹窗(初始值 = 旧名,走 migrateLsKeys 迁移本地记忆)
|
|
973
1245
|
renaming && createPortal(
|
|
974
1246
|
h(AskText, {
|
|
975
1247
|
title: (renaming.kind === 'topic' ? t('renameTopic') : t('renamePaper')) + ':',
|
|
@@ -983,6 +1255,43 @@ window.__ModuleLoader__.load({
|
|
|
983
1255
|
onCancel: () => setRenaming(null),
|
|
984
1256
|
}),
|
|
985
1257
|
document.body),
|
|
1258
|
+
// 删除:确认框必须列出服务端给的清单;专题删除还要输入专题名
|
|
1259
|
+
delDlg && createPortal(
|
|
1260
|
+
h(ConfirmDelete, {
|
|
1261
|
+
title: delDlg.kind === 'topic-clear'
|
|
1262
|
+
? t('delTopicClearTitle').replace('{topic}', delDlg.topic)
|
|
1263
|
+
: `${t('delConfirmTitle')} · ${delDlg.kind === 'paper' ? delDlg.name : delDlg.topic}`,
|
|
1264
|
+
body: delDlg.kind === 'topic-clear'
|
|
1265
|
+
? t('delTopicClearBody')
|
|
1266
|
+
.replace('{papers}', String(delDlg.plan.papersCount))
|
|
1267
|
+
.replace('{files}', String(delDlg.plan.filesCount))
|
|
1268
|
+
.replace('{size}', fmtBytes(delDlg.plan.totalBytes))
|
|
1269
|
+
: delDlg.kind === 'paper' && delDlg.plan
|
|
1270
|
+
? t('delConfirmBody').replace('{files}', String(delDlg.plan.files.length)).replace('{size}', fmtBytes(delDlg.plan.totalBytes))
|
|
1271
|
+
: t('delTopicTypeName').replace('{name}', delDlg.name),
|
|
1272
|
+
detail: delDlg.kind === 'topic-clear'
|
|
1273
|
+
// 逐篇列出清单(服务端同源):篇名 — k 个文件 · 大小,再跟该篇的产物文件名
|
|
1274
|
+
? delDlg.plan.papers.map((p) => `· ${p.name} — ${t('delTopicClearFiles').replace('{n}', String(p.files.length))} · ${fmtBytes(p.totalBytes)}\n ${p.files.map((f) => f.name).join('、')}`).join('\n')
|
|
1275
|
+
: delDlg.kind === 'paper' && delDlg.plan
|
|
1276
|
+
? delDlg.plan.files.map((f) => `· ${f.name} ${fmtBytes(f.bytes)}`).join('\n')
|
|
1277
|
+
: null,
|
|
1278
|
+
// R4-F4:清单必须与服务端同源——用 delete-plan 返回的 notDeleted 派生出文案,
|
|
1279
|
+
// 不再用本地 i18n 硬编码(那份已经与契约漂移了)。专题删除没有 plan,保留本地兜底文案。
|
|
1280
|
+
notDeleted: delDlg.kind === 'topic-clear'
|
|
1281
|
+
? [Array.isArray(delDlg.plan.notDeleted) ? delDlg.plan.notDeleted.join(' · ') : '',
|
|
1282
|
+
clearBlockedNames.length ? t('delTopicClearBlocked').replace('{names}', clearBlockedNames.join('、')) : '',
|
|
1283
|
+
delDlg.plan.hidden?.length ? t('delTopicClearHidden').replace('{names}', delDlg.plan.hidden.join('、')) : ''].filter(Boolean).join(' ')
|
|
1284
|
+
: delDlg.kind === 'paper' && delDlg.plan && Array.isArray(delDlg.plan.notDeleted)
|
|
1285
|
+
? delDlg.plan.notDeleted.join(' · ')
|
|
1286
|
+
: t('delNotDeleted'),
|
|
1287
|
+
confirmText: delDlg.kind === 'topic-clear' ? t('delTopicClearConfirm') : delDlg.kind === 'paper' ? t('delToTrash') : t('delTopic'),
|
|
1288
|
+
cancelText: t('cancel'),
|
|
1289
|
+
// 手输专题名确认保留在「批量清空」这一步(与单篇删除同强度,不放松)
|
|
1290
|
+
requireText: delDlg.kind === 'paper' ? undefined : delDlg.name,
|
|
1291
|
+
onConfirm: delDlg.kind === 'topic-clear' ? doClearTopic : delDlg.kind === 'paper' ? doDeletePaper : doDeleteTopic,
|
|
1292
|
+
onCancel: () => setDelDlg(null),
|
|
1293
|
+
}),
|
|
1294
|
+
document.body),
|
|
986
1295
|
)
|
|
987
1296
|
}
|
|
988
1297
|
|
|
@@ -1115,10 +1424,120 @@ window.__ModuleLoader__.load({
|
|
|
1115
1424
|
path: '/typesafe/config', title: t('setRerankTitle'), desc: t('setRerankDesc'), optional: true,
|
|
1116
1425
|
baseUrlPlaceholder: 'https://api.typesafe.ai', modelPlaceholder: 'jev-latest', keyPlaceholder: 'ts-...',
|
|
1117
1426
|
}),
|
|
1427
|
+
h(EndpointCard, {
|
|
1428
|
+
path: '/embed/config', title: t('setEmbedTitle'), desc: t('setEmbedDesc'), optional: true,
|
|
1429
|
+
baseUrlPlaceholder: 'https://api.openai.com/v1', modelPlaceholder: 'text-embedding-3-small', keyPlaceholder: 'sk-...',
|
|
1430
|
+
}),
|
|
1118
1431
|
h(EndpointCard, {
|
|
1119
1432
|
path: '/translate/config', title: t('setTranslateTitle'), desc: t('setTranslateDesc'),
|
|
1120
1433
|
baseUrlPlaceholder: 'https://api.deepseek.com/v1', modelPlaceholder: 'deepseek-chat', keyPlaceholder: 'sk-...',
|
|
1121
1434
|
}),
|
|
1435
|
+
h(MineruCard, null),
|
|
1436
|
+
)
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/* ---------- MinerU 卡片:模式 + 本地/云端参数 + 保存前预检 ---------- */
|
|
1440
|
+
function MineruCard() {
|
|
1441
|
+
const [cfg, setCfg] = useState(null)
|
|
1442
|
+
const [form, setForm] = useState({ mode: 'off', baseUrl: '', backend: 'pipeline', effort: 'medium', parseMethod: 'auto', apiKey: '', cloudApiKey: '' })
|
|
1443
|
+
const [busy, setBusy] = useState(false)
|
|
1444
|
+
const [msg, setMsg] = useState(null) // { ok, text }
|
|
1445
|
+
const load = useCallback(() => {
|
|
1446
|
+
api('/mineru/config').then((d) => {
|
|
1447
|
+
setCfg(d)
|
|
1448
|
+
setForm({
|
|
1449
|
+
mode: d.mode || 'off',
|
|
1450
|
+
baseUrl: d.local?.baseUrl || '',
|
|
1451
|
+
backend: d.local?.backend || 'pipeline',
|
|
1452
|
+
effort: d.local?.effort || 'medium',
|
|
1453
|
+
parseMethod: d.local?.parseMethod || 'auto',
|
|
1454
|
+
apiKey: '',
|
|
1455
|
+
cloudApiKey: '',
|
|
1456
|
+
})
|
|
1457
|
+
}).catch((e) => setMsg({ ok: false, text: String(e.message || e) }))
|
|
1458
|
+
}, [])
|
|
1459
|
+
useEffect(load, [load])
|
|
1460
|
+
const sourceText = !cfg ? '' : { file: t('setSourceFile'), profile: t('setSourceProfile'), env: t('setSourceEnv'), none: t('setSourceNone') }[cfg.source] || ''
|
|
1461
|
+
const payload = () => ({
|
|
1462
|
+
mode: form.mode,
|
|
1463
|
+
local: { baseUrl: form.baseUrl, backend: form.backend, effort: form.effort, parseMethod: form.parseMethod, apiKey: form.apiKey },
|
|
1464
|
+
cloud: { apiKey: form.cloudApiKey },
|
|
1465
|
+
})
|
|
1466
|
+
const save = async () => {
|
|
1467
|
+
setBusy(true); setMsg(null)
|
|
1468
|
+
try {
|
|
1469
|
+
await api('/mineru/config', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload()) })
|
|
1470
|
+
setMsg({ ok: true, text: t('setSaved') })
|
|
1471
|
+
load()
|
|
1472
|
+
} catch (e) { setMsg({ ok: false, text: String(e.message || e) }) } finally { setBusy(false) }
|
|
1473
|
+
}
|
|
1474
|
+
const clear = async () => {
|
|
1475
|
+
setBusy(true); setMsg(null)
|
|
1476
|
+
try {
|
|
1477
|
+
await api('/mineru/config', { method: 'DELETE' })
|
|
1478
|
+
setMsg({ ok: true, text: t('setCleared') })
|
|
1479
|
+
load()
|
|
1480
|
+
} catch (e) { setMsg({ ok: false, text: String(e.message || e) }) } finally { setBusy(false) }
|
|
1481
|
+
}
|
|
1482
|
+
const testConn = async () => {
|
|
1483
|
+
setBusy(true); setMsg(null)
|
|
1484
|
+
try {
|
|
1485
|
+
const d = await api('/mineru/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload()) })
|
|
1486
|
+
setMsg({ ok: d.reachable, text: d.reachable ? t('setMineruHealthOk') : (t('setMineruHealthFail') + (d.error ? ': ' + d.error : '')) })
|
|
1487
|
+
} catch (e) { setMsg({ ok: false, text: String(e.message || e) }) } finally { setBusy(false) }
|
|
1488
|
+
}
|
|
1489
|
+
const field = (key, label, placeholder, type) => h('div', null,
|
|
1490
|
+
h('label', { style: SET.label }, label),
|
|
1491
|
+
h('input', {
|
|
1492
|
+
style: SET.input, type: type || 'text', value: form[key], placeholder, disabled: busy,
|
|
1493
|
+
onChange: (e) => setForm({ ...form, [key]: e.target.value }),
|
|
1494
|
+
}),
|
|
1495
|
+
)
|
|
1496
|
+
const select = (key, label, options) => h('div', null,
|
|
1497
|
+
h('label', { style: SET.label }, label),
|
|
1498
|
+
h('select', {
|
|
1499
|
+
style: SET.input, value: form[key], disabled: busy,
|
|
1500
|
+
onChange: (e) => setForm({ ...form, [key]: e.target.value }),
|
|
1501
|
+
}, options.map((o) => h('option', { value: o.value, key: o.value }, o.label))),
|
|
1502
|
+
)
|
|
1503
|
+
return h('div', { style: SET.card, 'data-dsh-plugin': 'dsh-paper-reader', 'data-dsh-part': 'settings-card' },
|
|
1504
|
+
h('h3', { style: SET.title }, t('setMineruTitle')),
|
|
1505
|
+
h('p', { style: SET.desc }, t('setMineruDesc')),
|
|
1506
|
+
// 激活门槛人话(上游评审第 7 条):说清「什么时候需要 + 配好之后入口出现在哪」,
|
|
1507
|
+
// 并按当前模式给一句取舍;「关闭」时明确 pdf.js 与 MinerU 各自能干什么(不夸大)。
|
|
1508
|
+
h('p', { style: SET.desc }, t('setMineruWhy')),
|
|
1509
|
+
h('p', { style: SET.meta },
|
|
1510
|
+
form.mode === 'local' ? t('setMineruWhyLocal') : form.mode === 'cloud' ? t('setMineruWhyCloud') : t('setMineruOffHint')),
|
|
1511
|
+
select('mode', t('setMineruMode'), [
|
|
1512
|
+
{ value: 'off', label: t('setMineruModeOff') },
|
|
1513
|
+
{ value: 'local', label: t('setMineruModeLocal') },
|
|
1514
|
+
{ value: 'cloud', label: t('setMineruModeCloud') },
|
|
1515
|
+
]),
|
|
1516
|
+
field('baseUrl', t('setMineruLocalUrl'), 'http://127.0.0.1:8000'),
|
|
1517
|
+
select('backend', t('setMineruBackend'), [
|
|
1518
|
+
{ value: 'pipeline', label: 'pipeline' },
|
|
1519
|
+
{ value: 'hybrid-engine', label: 'hybrid-engine' },
|
|
1520
|
+
{ value: 'vlm-engine', label: 'vlm-engine' },
|
|
1521
|
+
{ value: 'vlm-http-client', label: 'vlm-http-client' },
|
|
1522
|
+
{ value: 'hybrid-http-client', label: 'hybrid-http-client' },
|
|
1523
|
+
]),
|
|
1524
|
+
select('parseMethod', t('setMineruParseMethod'), [
|
|
1525
|
+
{ value: 'auto', label: 'auto' },
|
|
1526
|
+
{ value: 'txt', label: 'txt' },
|
|
1527
|
+
{ value: 'ocr', label: 'ocr' },
|
|
1528
|
+
]),
|
|
1529
|
+
select('effort', t('setMineruEffort'), [
|
|
1530
|
+
{ value: 'medium', label: 'medium' },
|
|
1531
|
+
{ value: 'high', label: 'high' },
|
|
1532
|
+
]),
|
|
1533
|
+
field('apiKey', t('setMineruLocalKey'), cfg?.local?.hasApiKey ? t('setKeyKeep') + cfg.local.apiKeyHint : '', 'password'),
|
|
1534
|
+
field('cloudApiKey', t('setMineruCloudKey'), cfg?.cloud?.hasApiKey ? t('setKeyKeep') + cfg.cloud.apiKeyHint : 'mineru.net token', 'password'),
|
|
1535
|
+
h('div', { style: SET.row },
|
|
1536
|
+
h('button', { type: 'button', style: SET.btn, disabled: busy, onClick: save }, busy ? t('setSaving') : t('setSave')),
|
|
1537
|
+
h('button', { type: 'button', style: SET.btnGhost, disabled: busy, onClick: testConn }, t('setMineruTestConnection')),
|
|
1538
|
+
cfg?.source === 'file' ? h('button', { type: 'button', style: SET.btnGhost, disabled: busy, onClick: clear }, t('setClear')) : null,
|
|
1539
|
+
msg ? h('span', { style: msg.ok ? SET.ok : SET.err }, msg.text) : h('span', { style: SET.meta }, sourceText),
|
|
1540
|
+
),
|
|
1122
1541
|
)
|
|
1123
1542
|
}
|
|
1124
1543
|
|
|
@@ -1146,9 +1565,9 @@ window.__ModuleLoader__.load({
|
|
|
1146
1565
|
const styleEl = document.createElement('style')
|
|
1147
1566
|
styleEl.id = 'dpr-surface-css'
|
|
1148
1567
|
styleEl.textContent = '[data-dpr-rail]:hover,[data-dpr-mode]:hover{background:var(--dsw-alias-interactive-bg-hover)!important}'
|
|
1149
|
-
// 树行内的 ✎🗑
|
|
1568
|
+
// 树行内的 ✎🗑 操作按钮:默认隐身,行悬停才现身(不再有「待确认态常亮」的 armed 状态)
|
|
1150
1569
|
+ '[data-dpr-act]{opacity:0!important;transition:opacity .12s}'
|
|
1151
|
-
+ '[data-dpr-row]:hover [data-dpr-act]
|
|
1570
|
+
+ '[data-dpr-row]:hover [data-dpr-act]{opacity:1!important}'
|
|
1152
1571
|
document.head.appendChild(styleEl)
|
|
1153
1572
|
}
|
|
1154
1573
|
|