dsh-ai-book-shelf 2.5.1 → 2.6.3
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 +8 -4
- package/client.js +618 -40
- package/index.js +422 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# AI Book Shelf / AI 书架
|
|
2
2
|
|
|
3
3
|
[](https://github.com/0avatar0/dsh-ai-book-shelf)
|
|
4
|
-
[](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.3)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
7
|
A persistent DSH Web UI plugin that adds a full-page multi-format reader to the
|
|
@@ -11,9 +11,9 @@ comparison.
|
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
14
|
-
1. Download `dsh-ai-book-shelf-2.
|
|
15
|
-
[latest release](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.
|
|
16
|
-
or install from npm with `dsh-ai-book-shelf@2.
|
|
14
|
+
1. Download `dsh-ai-book-shelf-2.6.3.tgz` from the
|
|
15
|
+
[latest release](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.3),
|
|
16
|
+
or install from npm with `dsh-ai-book-shelf@2.6.3`.
|
|
17
17
|
2. In DSH open **Plugins → Add plugin** and choose the downloaded tgz, or paste
|
|
18
18
|
the npm package spec.
|
|
19
19
|
3. Reload the plugin and open **AI 书架 / AI Book Shelf** in the sidebar.
|
|
@@ -29,6 +29,10 @@ instructions: [PUBLISHING.md](PUBLISHING.md).
|
|
|
29
29
|
- **Copies external books** — a book opened or imported from outside the
|
|
30
30
|
archive shelf is copied into `archiveDir` first. Identical files are reused;
|
|
31
31
|
a name collision gets a ` (2)` suffix rather than overwriting anything.
|
|
32
|
+
- **Dossier mode** — a separate dossier library with folders, multi-select
|
|
33
|
+
import (Ctrl/Cmd or Ctrl+A in the file picker), folder-scoped full-text
|
|
34
|
+
search, streamed AI summaries, and folder skills. A Markdown file named
|
|
35
|
+
Creating a folder adds an empty skill you can fill in; an imported `SKILL.md` can replace it, and AI summaries follow it.
|
|
32
36
|
- **Public-domain search** — the **Search books** tab queries Project Gutenberg
|
|
33
37
|
(Gutendex, with the Gutenberg website search as a fallback), a Chinese-only
|
|
34
38
|
Project Gutenberg catalog (`lang=zh`), Chinese Wikisource, Chinese Wikibooks,
|
package/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
window.__ModuleLoader__.load({
|
|
2
|
-
id: '
|
|
2
|
+
id: 'dsh-ai-book-shelf',
|
|
3
3
|
factory(require) {
|
|
4
4
|
const React = require('react')
|
|
5
5
|
const h = React.createElement
|
|
@@ -119,6 +119,46 @@ window.__ModuleLoader__.load({
|
|
|
119
119
|
'library.openLocal': '打开本地书籍',
|
|
120
120
|
'library.dragHint': '也可以把书籍文件拖到这里',
|
|
121
121
|
'papers.mode': '论文模式',
|
|
122
|
+
'papers.close': '关闭',
|
|
123
|
+
'dossiers.mode': '卷宗模式',
|
|
124
|
+
'dossiers.title': '卷宗库',
|
|
125
|
+
'dossiers.subtitle': '按文件夹整理卷宗,支持全选导入、全文搜索、AI 总结和文件夹 Skill。',
|
|
126
|
+
'dossiers.import': '导入卷宗',
|
|
127
|
+
'dossiers.importing': '正在导入卷宗…',
|
|
128
|
+
'dossiers.importFailed': '卷宗导入失败',
|
|
129
|
+
'dossiers.newFolder': '新建卷宗文件夹',
|
|
130
|
+
'dossiers.all': '全部卷宗',
|
|
131
|
+
'dossiers.root': '未分类',
|
|
132
|
+
'dossiers.searchPlaceholder': '搜索当前文件夹内的卷宗',
|
|
133
|
+
'dossiers.searchEmpty': '没有找到匹配的卷宗',
|
|
134
|
+
'dossiers.searchEmptyHint': '试试更短的关键词,或切换到“全部卷宗”。',
|
|
135
|
+
'dossiers.empty': '这个卷宗文件夹还是空的',
|
|
136
|
+
'dossiers.emptyHint': '导入卷宗文件;可以按住 Ctrl / Cmd 或按 Ctrl+A 全选多个文件。文件名正好是 SKILL.md 的 Markdown 会作为该文件夹的 Skill。',
|
|
137
|
+
'dossiers.skillHint': '提示:一次可全选多个文件;把 SKILL.md 和卷宗一起导入,AI 总结时会优先遵循该 Skill。',
|
|
138
|
+
'dossiers.skills': '文件夹 Skill',
|
|
139
|
+
'dossiers.expand': '展开 Skill',
|
|
140
|
+
'dossiers.collapse': '收起 Skill',
|
|
141
|
+
'dossiers.createSkill': '新建 Skill',
|
|
142
|
+
'dossiers.noSkill': '还没有 Skill。新建文件夹会自动生成一个空 Skill。',
|
|
143
|
+
'dossiers.skillEmptyHint': '空 Skill,点击填写',
|
|
144
|
+
'dossiers.editSkill': '编辑 Skill',
|
|
145
|
+
'dossiers.skillEditorHint': '在这里写下这个卷宗的总结要求,例如关注的时间线、风险、输出格式。保存后 AI 总结会按它执行。',
|
|
146
|
+
'dossiers.skillPlaceholder': '# 卷宗总结 Skill\n\n请填写:\n- 需要重点关注的事实和时间线\n- 风险判断标准\n- 希望输出的格式',
|
|
147
|
+
'dossiers.save': '保存',
|
|
148
|
+
'dossiers.summary': 'AI 总结',
|
|
149
|
+
'dossiers.summaryTitle': 'AI 卷宗总结',
|
|
150
|
+
'dossiers.summarySearchTitle': 'AI 总结搜索结果',
|
|
151
|
+
'dossiers.summarizing': '正在分析卷宗…',
|
|
152
|
+
'dossiers.open': '打开阅读',
|
|
153
|
+
'dossiers.openHint': '点击打开阅读',
|
|
154
|
+
'dossiers.openFailed': '卷宗打开失败',
|
|
155
|
+
'dossiers.rename': '重命名卷宗',
|
|
156
|
+
'dossiers.renameFolder': '重命名文件夹',
|
|
157
|
+
'dossiers.delete': '删除卷宗',
|
|
158
|
+
'dossiers.confirmDelete': '确认删除?',
|
|
159
|
+
'dossiers.importHere': '导入卷宗到此文件夹',
|
|
160
|
+
'dossiers.namePlaceholder': '输入名称',
|
|
161
|
+
'dossiers.stop': '停止总结',
|
|
122
162
|
'papers.title': '论文库',
|
|
123
163
|
'papers.subtitle': '英文论文翻译、逐段校正与中英对照',
|
|
124
164
|
'papers.storageHint': '论文副本自动保存在 .epub-reader/papers,与书籍目录分开。',
|
|
@@ -320,6 +360,46 @@ window.__ModuleLoader__.load({
|
|
|
320
360
|
'library.openLocal': 'Open local book',
|
|
321
361
|
'library.dragHint': 'You can also drop a book file here',
|
|
322
362
|
'papers.mode': 'Papers',
|
|
363
|
+
'papers.close': 'Close',
|
|
364
|
+
'dossiers.mode': 'Dossiers',
|
|
365
|
+
'dossiers.title': 'Dossier library',
|
|
366
|
+
'dossiers.subtitle': 'Organize dossiers by folder, with multi-select import, full-text search, AI summaries and folder skills.',
|
|
367
|
+
'dossiers.import': 'Import dossiers',
|
|
368
|
+
'dossiers.importing': 'Importing dossiers…',
|
|
369
|
+
'dossiers.importFailed': 'Dossier import failed',
|
|
370
|
+
'dossiers.newFolder': 'New dossier folder',
|
|
371
|
+
'dossiers.all': 'All dossiers',
|
|
372
|
+
'dossiers.root': 'Unsorted',
|
|
373
|
+
'dossiers.searchPlaceholder': 'Search this folder',
|
|
374
|
+
'dossiers.searchEmpty': 'No matching dossiers',
|
|
375
|
+
'dossiers.searchEmptyHint': 'Try a shorter keyword or switch to All dossiers.',
|
|
376
|
+
'dossiers.empty': 'This dossier folder is empty',
|
|
377
|
+
'dossiers.emptyHint': 'Import dossier files; hold Ctrl / Cmd or press Ctrl+A to select many. A Markdown file named SKILL.md becomes a folder skill.',
|
|
378
|
+
'dossiers.skillHint': 'Tip: select many files at once; import SKILL.md with the dossiers and AI summaries will follow it.',
|
|
379
|
+
'dossiers.skills': 'Folder skills',
|
|
380
|
+
'dossiers.expand': 'Expand skills',
|
|
381
|
+
'dossiers.collapse': 'Collapse skills',
|
|
382
|
+
'dossiers.createSkill': 'New skill',
|
|
383
|
+
'dossiers.noSkill': 'No skill yet. Creating a folder adds an empty skill automatically.',
|
|
384
|
+
'dossiers.skillEmptyHint': 'Empty skill — click to fill in',
|
|
385
|
+
'dossiers.editSkill': 'Edit skill',
|
|
386
|
+
'dossiers.skillEditorHint': 'Write the summary instructions for this dossier: timeline, risks, output format. AI summaries will follow it.',
|
|
387
|
+
'dossiers.skillPlaceholder': '# Dossier summary skill\n\nDescribe what to focus on:\n- key facts and timeline\n- risk criteria\n- preferred output format',
|
|
388
|
+
'dossiers.save': 'Save',
|
|
389
|
+
'dossiers.summary': 'AI summary',
|
|
390
|
+
'dossiers.summaryTitle': 'AI dossier summary',
|
|
391
|
+
'dossiers.summarySearchTitle': 'AI search summary',
|
|
392
|
+
'dossiers.summarizing': 'Analyzing dossiers…',
|
|
393
|
+
'dossiers.open': 'Open',
|
|
394
|
+
'dossiers.openHint': 'Click to open',
|
|
395
|
+
'dossiers.openFailed': 'Could not open dossier',
|
|
396
|
+
'dossiers.rename': 'Rename dossier',
|
|
397
|
+
'dossiers.renameFolder': 'Rename folder',
|
|
398
|
+
'dossiers.delete': 'Delete dossier',
|
|
399
|
+
'dossiers.confirmDelete': 'Confirm delete?',
|
|
400
|
+
'dossiers.importHere': 'Import dossiers into this folder',
|
|
401
|
+
'dossiers.namePlaceholder': 'Name',
|
|
402
|
+
'dossiers.stop': 'Stop summary',
|
|
323
403
|
'papers.title': 'Paper library',
|
|
324
404
|
'papers.subtitle': 'Translate English papers, correct paragraphs, compare side by side',
|
|
325
405
|
'papers.storageHint': 'Paper copies are stored in .epub-reader/papers, separate from books.',
|
|
@@ -865,6 +945,10 @@ window.__ModuleLoader__.load({
|
|
|
865
945
|
.epub-papers-head-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
866
946
|
.epub-papers-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 185px minmax(0, 1fr); }
|
|
867
947
|
.epub-paper-folders { overflow: auto; padding: 12px 10px; border-right: 1px solid var(--epub-line); background: var(--epub-surface); }
|
|
948
|
+
.epub-dossier-folder-group { margin: 0; }
|
|
949
|
+
.epub-dossier-expand { display: grid; place-items: center; width: 18px; height: 28px; flex: none; padding: 0; border: 0; background: transparent; color: var(--epub-muted); font-size: 11px; }
|
|
950
|
+
.epub-dossier-expand:hover { color: var(--epub-text); }
|
|
951
|
+
.epub-dossier-folder-skills { margin: 1px 0 5px 19px; padding: 3px 0 3px 7px; border-left: 1px solid var(--epub-line); }
|
|
868
952
|
.epub-paper-folder-top { display: flex; align-items: center; gap: 2px; }
|
|
869
953
|
.epub-paper-folder-top .epub-paper-folder { flex: 1; }
|
|
870
954
|
.epub-paper-folder-add { display: grid; place-items: center; width: 27px; height: 30px; flex: none; border: 0; border-radius: 7px; background: transparent; color: var(--epub-muted); }
|
|
@@ -902,6 +986,39 @@ window.__ModuleLoader__.load({
|
|
|
902
986
|
.epub-dialog-input { width: 100%; height: 34px; padding: 0 9px; border: 1px solid var(--epub-line); border-radius: 7px; outline: 0; background: var(--dsw-alias-bg-base); color: var(--epub-text); font-size: 11px; }
|
|
903
987
|
.epub-dialog-input:focus { border-color: color-mix(in srgb, var(--epub-accent) 45%, var(--epub-line)); }
|
|
904
988
|
.epub-dialog-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 12px; }
|
|
989
|
+
.epub-dossier-actions { display: flex; align-items: center; gap: 7px; margin-left: auto; }
|
|
990
|
+
.epub-dossier-search { display: flex; align-items: center; gap: 5px; height: 30px; padding: 0 8px; border: 1px solid var(--epub-line); border-radius: 7px; background: var(--dsw-alias-bg-base); color: var(--epub-muted); }
|
|
991
|
+
.epub-dossier-search input { width: 190px; border: 0; outline: 0; background: transparent; color: var(--epub-text); font-size: 10px; }
|
|
992
|
+
.epub-dossier-hint { margin: 0 0 10px; color: var(--epub-muted); font-size: 9px; line-height: 1.5; }
|
|
993
|
+
.epub-dossier-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 9px; }
|
|
994
|
+
.epub-dossier-card { padding: 12px; border: 1px solid var(--epub-line); border-radius: 10px; background: var(--epub-surface); cursor: pointer; min-width: 0; }
|
|
995
|
+
.epub-dossier-card:hover { border-color: var(--epub-line-strong); background: var(--epub-surface-2); }
|
|
996
|
+
.epub-dossier-type { display: inline-block; margin-bottom: 6px; padding: 2px 5px; border-radius: 4px; background: color-mix(in srgb, var(--epub-accent) 10%, transparent); color: var(--epub-accent); font-size: 8px; font-weight: 700; letter-spacing: .04em; }
|
|
997
|
+
.epub-dossier-card h3 { margin: 0; font-size: 12px; line-height: 1.4; overflow-wrap: anywhere; }
|
|
998
|
+
.epub-dossier-card p { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; margin: 6px 0 9px; color: var(--epub-muted); font-size: 9.5px; line-height: 1.55; overflow-wrap: anywhere; }
|
|
999
|
+
.epub-dossier-meta { display: flex; justify-content: space-between; gap: 6px; color: var(--epub-muted); font-size: 8.5px; }
|
|
1000
|
+
.epub-dossier-skills { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--epub-line); }
|
|
1001
|
+
.epub-dossier-skills-head { display: flex; align-items: center; justify-content: space-between; gap: 5px; margin-bottom: 5px; color: var(--epub-muted); font-size: 8.5px; text-transform: uppercase; letter-spacing: .06em; }
|
|
1002
|
+
.epub-dossier-skill-add { display: grid; place-items: center; width: 20px; height: 20px; border: 0; border-radius: 4px; background: transparent; color: var(--epub-muted); font-size: 14px; line-height: 1; }
|
|
1003
|
+
.epub-dossier-skill-add:hover { color: var(--epub-text); background: var(--epub-surface-2); }
|
|
1004
|
+
.epub-dossier-skill-empty { margin: 3px 0 0; color: var(--epub-muted); font-size: 8.5px; line-height: 1.5; }
|
|
1005
|
+
.epub-dossier-skill.is-empty { color: var(--epub-muted); font-style: italic; }
|
|
1006
|
+
.epub-skill-editor { display: flex; flex-direction: column; width: min(620px, 100%); max-height: min(82vh, 680px); border: 1px solid var(--epub-line-strong); border-radius: 13px; background: var(--epub-surface); box-shadow: 0 22px 60px -18px rgba(0,0,0,.6); }
|
|
1007
|
+
.epub-skill-editor header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; padding: 14px 16px 0; }
|
|
1008
|
+
.epub-skill-editor h3 { margin: 2px 0 0; font-size: 15px; }
|
|
1009
|
+
.epub-skill-editor-hint { margin: 8px 16px 0; color: var(--epub-muted); font-size: 9.5px; line-height: 1.55; }
|
|
1010
|
+
.epub-skill-editor-text { flex: 1; min-height: 260px; margin: 10px 16px 0; padding: 10px; border: 1px solid var(--epub-line); border-radius: 8px; outline: 0; background: var(--dsw-alias-bg-base); color: var(--epub-text); font-family: var(--epub-mono, ui-monospace, monospace); font-size: 11px; line-height: 1.65; resize: vertical; }
|
|
1011
|
+
.epub-skill-editor-text:focus { border-color: color-mix(in srgb, var(--epub-accent) 45%, var(--epub-line)); }
|
|
1012
|
+
.epub-skill-editor .epub-dialog-actions { padding: 0 16px 14px; }
|
|
1013
|
+
.epub-dossier-skill { display: flex; align-items: center; gap: 4px; width: 100%; margin: 2px 0; padding: 4px 5px; border: 0; border-radius: 5px; background: transparent; color: var(--epub-text); font-size: 9px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1014
|
+
.epub-dossier-skill:hover { background: var(--epub-surface-2); }
|
|
1015
|
+
.epub-dossier-summary-backdrop { position: fixed; inset: 0; z-index: 95; display: grid; place-items: center; padding: 20px; background: rgba(0,0,0,.38); }
|
|
1016
|
+
.epub-dossier-summary { display: flex; flex-direction: column; width: min(680px, 100%); max-height: min(78vh, 720px); border: 1px solid var(--epub-line-strong); border-radius: 14px; background: var(--epub-surface); box-shadow: 0 24px 70px -20px rgba(0,0,0,.65); overflow: hidden; }
|
|
1017
|
+
.epub-dossier-summary header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--epub-line); }
|
|
1018
|
+
.epub-dossier-summary h3 { margin: 2px 0 0; font-size: 15px; }
|
|
1019
|
+
.epub-dossier-summary-actions { display: flex; align-items: center; gap: 5px; }
|
|
1020
|
+
.epub-dossier-summary-body { flex: 1; overflow: auto; padding: 16px; color: var(--epub-text); font-family: var(--epub-serif); font-size: 13px; line-height: 1.8; white-space: pre-wrap; }
|
|
1021
|
+
.epub-dossier-summary-error { margin: 0; padding: 10px 16px 0; color: var(--epub-danger); font-size: 10px; }
|
|
905
1022
|
.epub-paper-compare { flex: 1; min-height: 0; display: flex; flex-direction: column; position: relative; }
|
|
906
1023
|
.epub-paper-body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); }
|
|
907
1024
|
.epub-paper-body.ai-open { grid-template-columns: minmax(0, 1fr) 372px; }
|
|
@@ -946,6 +1063,12 @@ window.__ModuleLoader__.load({
|
|
|
946
1063
|
.epub-paper-ai-collapsed { padding: 8px 10px; color: var(--epub-muted); font-size: 9.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
947
1064
|
@media (max-width: 760px) {
|
|
948
1065
|
.epub-papers-body { grid-template-columns: 1fr; }
|
|
1066
|
+
.epub-paper-main-head { flex-wrap: wrap; }
|
|
1067
|
+
.epub-dossier-actions { width: 100%; margin-left: 0; }
|
|
1068
|
+
.epub-dossier-search { flex: 1; }
|
|
1069
|
+
.epub-dossier-search input { width: 100%; min-width: 0; }
|
|
1070
|
+
.epub-dossier-grid { grid-template-columns: 1fr; }
|
|
1071
|
+
.epub-dossier-summary { max-height: 88vh; }
|
|
949
1072
|
.epub-paper-folders { display: flex; gap: 4px; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--epub-line); }
|
|
950
1073
|
.epub-paper-folder-wrap { flex: none; }
|
|
951
1074
|
.epub-paper-folder-top { flex: none; }
|
|
@@ -1725,7 +1848,7 @@ window.__ModuleLoader__.load({
|
|
|
1725
1848
|
)
|
|
1726
1849
|
}
|
|
1727
1850
|
|
|
1728
|
-
function Library({ ctx, t, onOpenBook, opening, onLocalBook, onPapers }) {
|
|
1851
|
+
function Library({ ctx, t, onOpenBook, opening, onLocalBook, onPapers, onDossiers }) {
|
|
1729
1852
|
const [state, setState] = React.useState({ status: 'loading', books: [], roots: [], error: '' })
|
|
1730
1853
|
const [query, setQuery] = React.useState('')
|
|
1731
1854
|
const [mode, setMode] = React.useState(() => {
|
|
@@ -1800,45 +1923,61 @@ window.__ModuleLoader__.load({
|
|
|
1800
1923
|
}
|
|
1801
1924
|
const progressMap = React.useMemo(() => readProgressMap(), [state.books])
|
|
1802
1925
|
|
|
1803
|
-
async function
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
if (CONVERSION_EXTENSIONS.has(extension)) {
|
|
1807
|
-
setImportError(t('library.conversionRequired'))
|
|
1808
|
-
return
|
|
1809
|
-
}
|
|
1810
|
-
if (!BOOK_EXTENSIONS.has(extension)) {
|
|
1811
|
-
setImportError(t('library.importFailed') + ':' + file.name)
|
|
1812
|
-
return
|
|
1813
|
-
}
|
|
1926
|
+
async function importFiles(files) {
|
|
1927
|
+
const list = Array.from(files ?? []).filter((file) => file !== null && file !== undefined)
|
|
1928
|
+
if (list.length === 0) return
|
|
1814
1929
|
setImporting(true)
|
|
1815
1930
|
setImportError('')
|
|
1931
|
+
const failures = []
|
|
1932
|
+
let last = null
|
|
1816
1933
|
try {
|
|
1817
|
-
const
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1934
|
+
for (const file of list) {
|
|
1935
|
+
const extension = file.name.includes('.') ? `.${file.name.split('.').pop().toLowerCase()}` : ''
|
|
1936
|
+
if (CONVERSION_EXTENSIONS.has(extension)) {
|
|
1937
|
+
failures.push(`${file.name}:${t('library.conversionRequired')}`)
|
|
1938
|
+
continue
|
|
1939
|
+
}
|
|
1940
|
+
if (!BOOK_EXTENSIONS.has(extension)) {
|
|
1941
|
+
failures.push(`${file.name}:${t('library.importFailed')}`)
|
|
1942
|
+
continue
|
|
1943
|
+
}
|
|
1944
|
+
try {
|
|
1945
|
+
const buffer = await file.arrayBuffer()
|
|
1946
|
+
const response = await fetch(`${API}/import`, {
|
|
1947
|
+
method: 'POST',
|
|
1948
|
+
headers: {
|
|
1949
|
+
'content-type': 'application/epub+zip',
|
|
1950
|
+
'x-book-name': encodeURIComponent(file.name),
|
|
1951
|
+
},
|
|
1952
|
+
body: buffer,
|
|
1953
|
+
})
|
|
1954
|
+
const data = await responseJson(response)
|
|
1955
|
+
if (data !== null && data.book !== undefined) {
|
|
1956
|
+
last = {
|
|
1957
|
+
book: data.book,
|
|
1958
|
+
info: {
|
|
1959
|
+
copied: data.copied === true,
|
|
1960
|
+
created: data.created === true,
|
|
1961
|
+
copyPath: typeof data.copyPath === 'string' ? data.copyPath : '',
|
|
1962
|
+
},
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
} catch (error) {
|
|
1966
|
+
failures.push(`${file.name}:${error instanceof Error ? error.message : String(error)}`)
|
|
1967
|
+
}
|
|
1833
1968
|
}
|
|
1834
|
-
|
|
1835
|
-
|
|
1969
|
+
if (last !== null) onLocalBook(last.book, last.info)
|
|
1970
|
+
if (failures.length > 0) setImportError(failures.join('\n'))
|
|
1836
1971
|
} finally {
|
|
1837
1972
|
setImporting(false)
|
|
1838
1973
|
if (fileRef.current !== null) fileRef.current.value = ''
|
|
1839
1974
|
}
|
|
1840
1975
|
}
|
|
1841
1976
|
|
|
1977
|
+
async function importFile(file) {
|
|
1978
|
+
await importFiles([file])
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1842
1981
|
function saveSourcePrefs(next) {
|
|
1843
1982
|
setSourcePrefs(next)
|
|
1844
1983
|
writeSourcePrefs(next)
|
|
@@ -1987,7 +2126,7 @@ window.__ModuleLoader__.load({
|
|
|
1987
2126
|
|
|
1988
2127
|
return h(
|
|
1989
2128
|
'div',
|
|
1990
|
-
{ className: 'epub-library', onDragOver: (event) => { event.preventDefault(); setDragOver(true) }, onDragLeave: () => setDragOver(false), onDrop: (event) => { event.preventDefault(); setDragOver(false); void
|
|
2129
|
+
{ className: 'epub-library', onDragOver: (event) => { event.preventDefault(); setDragOver(true) }, onDragLeave: () => setDragOver(false), onDrop: (event) => { event.preventDefault(); setDragOver(false); void importFiles(event.dataTransfer.files) } },
|
|
1991
2130
|
h(
|
|
1992
2131
|
'div',
|
|
1993
2132
|
{ className: 'epub-library-inner' },
|
|
@@ -2033,8 +2172,9 @@ window.__ModuleLoader__.load({
|
|
|
2033
2172
|
ref: fileRef,
|
|
2034
2173
|
type: 'file',
|
|
2035
2174
|
accept: BOOK_ACCEPT,
|
|
2175
|
+
multiple: true,
|
|
2036
2176
|
style: { display: 'none' },
|
|
2037
|
-
onChange: (event) => { void
|
|
2177
|
+
onChange: (event) => { void importFiles(event.target.files); event.target.value = '' },
|
|
2038
2178
|
}),
|
|
2039
2179
|
),
|
|
2040
2180
|
),
|
|
@@ -2048,6 +2188,7 @@ window.__ModuleLoader__.load({
|
|
|
2048
2188
|
h('button', { type: 'button', role: 'tab', 'aria-selected': mode === 'online', className: cn('epub-mode-tab', mode === 'online' && 'is-active'), onClick: () => switchLibraryMode('online') }, t('library.onlineTab')),
|
|
2049
2189
|
),
|
|
2050
2190
|
h('button', { type: 'button', className: 'epub-mode-paper', onClick: onPapers }, h(IconList, { size: 14 }), t('papers.mode')),
|
|
2191
|
+
h('button', { type: 'button', className: 'epub-mode-paper', onClick: onDossiers }, h(IconFolderPlus, { size: 14 }), t('dossiers.mode')),
|
|
2051
2192
|
),
|
|
2052
2193
|
mode === 'online'
|
|
2053
2194
|
? h(
|
|
@@ -2684,7 +2825,7 @@ window.__ModuleLoader__.load({
|
|
|
2684
2825
|
setDragOver('')
|
|
2685
2826
|
}
|
|
2686
2827
|
|
|
2687
|
-
async function importPdf(file) {
|
|
2828
|
+
async function importPdf(file, openAfter = true) {
|
|
2688
2829
|
if (file === undefined) return
|
|
2689
2830
|
setImporting(true)
|
|
2690
2831
|
setImportError('')
|
|
@@ -2696,7 +2837,7 @@ window.__ModuleLoader__.load({
|
|
|
2696
2837
|
})
|
|
2697
2838
|
setImportFolderId(null)
|
|
2698
2839
|
await load()
|
|
2699
|
-
if (data?.paper?.id !== undefined) onRead(data.paper.id)
|
|
2840
|
+
if (openAfter && data?.paper?.id !== undefined) onRead(data.paper.id)
|
|
2700
2841
|
} catch (error) {
|
|
2701
2842
|
setImportError(error instanceof Error ? error.message : String(error))
|
|
2702
2843
|
} finally {
|
|
@@ -2705,6 +2846,34 @@ window.__ModuleLoader__.load({
|
|
|
2705
2846
|
}
|
|
2706
2847
|
}
|
|
2707
2848
|
|
|
2849
|
+
async function importPdfs(files) {
|
|
2850
|
+
const list = Array.from(files ?? []).filter((file) => file !== null && file !== undefined)
|
|
2851
|
+
if (list.length === 0) return
|
|
2852
|
+
if (list.length === 1) { await importPdf(list[0]); return }
|
|
2853
|
+
setImporting(true)
|
|
2854
|
+
setImportError('')
|
|
2855
|
+
const folderId = importFolderId !== null ? importFolderId : activeFolder === 'all' || activeFolder === 'root' ? '' : activeFolder
|
|
2856
|
+
const failures = []
|
|
2857
|
+
try {
|
|
2858
|
+
for (const file of list) {
|
|
2859
|
+
if (!file.name.toLowerCase().endsWith('.pdf')) { failures.push(`${file.name}:只支持 PDF`); continue }
|
|
2860
|
+
try {
|
|
2861
|
+
await apiUpload('/papers/import', file, {
|
|
2862
|
+
'x-paper-name': encodeURIComponent(file.name),
|
|
2863
|
+
'x-paper-folder': folderId,
|
|
2864
|
+
})
|
|
2865
|
+
} catch (error) {
|
|
2866
|
+
failures.push(`${file.name}:${error instanceof Error ? error.message : String(error)}`)
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
await load()
|
|
2870
|
+
} finally {
|
|
2871
|
+
setImporting(false)
|
|
2872
|
+
setImportFolderId(null)
|
|
2873
|
+
if (failures.length > 0) setImportError(failures.join('\n'))
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2708
2877
|
const visible = state.papers.filter((paper) => {
|
|
2709
2878
|
if (activeFolder === 'all') return true
|
|
2710
2879
|
if (activeFolder === 'root') return (paper.folderId ?? '') === ''
|
|
@@ -2728,7 +2897,7 @@ window.__ModuleLoader__.load({
|
|
|
2728
2897
|
),
|
|
2729
2898
|
h('div', { className: 'epub-papers-head-actions' },
|
|
2730
2899
|
h('button', { type: 'button', className: 'epub-btn epub-btn-primary', onClick: () => { setImportFolderId(null); fileRef.current?.click() } }, h(IconUpload, { size: 14 }), t('papers.import')),
|
|
2731
|
-
h('input', { ref: fileRef, type: 'file', accept: '.pdf,application/pdf', style: { display: 'none' }, onChange: (event) => { void
|
|
2900
|
+
h('input', { ref: fileRef, type: 'file', accept: '.pdf,application/pdf', multiple: true, style: { display: 'none' }, onChange: (event) => { void importPdfs(event.target.files); event.target.value = '' } }),
|
|
2732
2901
|
),
|
|
2733
2902
|
),
|
|
2734
2903
|
importError !== '' ? h(ErrorBanner, { title: t('papers.importFailed'), message: importError, actionLabel: t('papers.retry'), onAction: () => setImportError('') }) : null,
|
|
@@ -2842,6 +3011,396 @@ window.__ModuleLoader__.load({
|
|
|
2842
3011
|
)
|
|
2843
3012
|
}
|
|
2844
3013
|
|
|
3014
|
+
function DossierLibrary({ t, onBack, onRead }) {
|
|
3015
|
+
const [state, setState] = React.useState({ status: 'loading', folders: [], items: [], error: '' })
|
|
3016
|
+
const [activeFolder, setActiveFolder] = React.useState('all')
|
|
3017
|
+
const [query, setQuery] = React.useState('')
|
|
3018
|
+
const [search, setSearch] = React.useState({ busy: false, results: [], error: '', query: '' })
|
|
3019
|
+
const [importing, setImporting] = React.useState(false)
|
|
3020
|
+
const [error, setError] = React.useState('')
|
|
3021
|
+
const [dialog, setDialog] = React.useState(null)
|
|
3022
|
+
const [contextMenu, setContextMenu] = React.useState(null)
|
|
3023
|
+
const [importFolderId, setImportFolderId] = React.useState(null)
|
|
3024
|
+
const [expandedFolders, setExpandedFolders] = React.useState(() => new Set())
|
|
3025
|
+
const [skillEditor, setSkillEditor] = React.useState(null)
|
|
3026
|
+
const [summary, setSummary] = React.useState({ open: false, status: 'idle', text: '', title: '', error: '' })
|
|
3027
|
+
const fileRef = React.useRef(null)
|
|
3028
|
+
const summaryAbort = React.useRef(null)
|
|
3029
|
+
|
|
3030
|
+
const load = React.useCallback(async () => {
|
|
3031
|
+
setState((current) => ({ ...current, status: current.items.length === 0 ? 'loading' : current.status, error: '' }))
|
|
3032
|
+
try {
|
|
3033
|
+
const data = await apiGet('/dossiers')
|
|
3034
|
+
setState({ status: 'ready', folders: data.folders ?? [], items: data.items ?? [], error: '' })
|
|
3035
|
+
} catch (reason) {
|
|
3036
|
+
setState({ status: 'error', folders: [], items: [], error: reason instanceof Error ? reason.message : String(reason) })
|
|
3037
|
+
}
|
|
3038
|
+
}, [])
|
|
3039
|
+
|
|
3040
|
+
React.useEffect(() => { void load() }, [load])
|
|
3041
|
+
|
|
3042
|
+
React.useEffect(() => {
|
|
3043
|
+
if (contextMenu === null) return undefined
|
|
3044
|
+
const close = () => setContextMenu(null)
|
|
3045
|
+
window.addEventListener('click', close)
|
|
3046
|
+
return () => window.removeEventListener('click', close)
|
|
3047
|
+
}, [contextMenu])
|
|
3048
|
+
|
|
3049
|
+
React.useEffect(() => {
|
|
3050
|
+
if (activeFolder === 'all' || activeFolder === '') return
|
|
3051
|
+
setExpandedFolders((current) => new Set(current).add(activeFolder))
|
|
3052
|
+
}, [activeFolder])
|
|
3053
|
+
|
|
3054
|
+
const folderId = activeFolder === 'all' || activeFolder === 'root' ? '' : activeFolder
|
|
3055
|
+
const folder = state.folders.find((entry) => entry.id === activeFolder)
|
|
3056
|
+
const folderLabel = activeFolder === 'all' ? t('dossiers.all') : activeFolder === 'root' ? t('dossiers.root') : folder?.name ?? t('dossiers.title')
|
|
3057
|
+
const documents = state.items.filter((item) => item.kind === 'document' && (activeFolder === 'all' || item.folderId === folderId))
|
|
3058
|
+
const shown = search.query !== '' && search.results.length > 0 ? search.results : documents
|
|
3059
|
+
|
|
3060
|
+
function skillsFor(id) {
|
|
3061
|
+
return state.items.filter((item) => item.kind === 'skill' && item.folderId === id)
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
function toggleFolder(id) {
|
|
3065
|
+
setExpandedFolders((current) => {
|
|
3066
|
+
const next = new Set(current)
|
|
3067
|
+
if (next.has(id)) next.delete(id)
|
|
3068
|
+
else next.add(id)
|
|
3069
|
+
return next
|
|
3070
|
+
})
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
function renderFolderSkills(id) {
|
|
3074
|
+
const list = skillsFor(id)
|
|
3075
|
+
return h('div', { className: 'epub-dossier-folder-skills' },
|
|
3076
|
+
list.length === 0
|
|
3077
|
+
? h('p', { className: 'epub-dossier-skill-empty' }, t('dossiers.noSkill'))
|
|
3078
|
+
: list.map((item) => h('button', {
|
|
3079
|
+
key: item.id,
|
|
3080
|
+
type: 'button',
|
|
3081
|
+
className: cn('epub-dossier-skill', item.preview === '' && 'is-empty'),
|
|
3082
|
+
onClick: () => void openSkillEditor(item),
|
|
3083
|
+
onContextMenu: (event) => openContext(event, 'item', { itemId: item.id }),
|
|
3084
|
+
title: item.preview || t('dossiers.skillEmptyHint'),
|
|
3085
|
+
}, h(IconSparkles, { size: 12 }), item.title)),
|
|
3086
|
+
)
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
function openContext(event, kind, data = {}) {
|
|
3090
|
+
event.preventDefault()
|
|
3091
|
+
event.stopPropagation()
|
|
3092
|
+
setContextMenu({ x: Math.min(event.clientX, window.innerWidth - 210), y: Math.min(event.clientY, window.innerHeight - 170), kind, confirmDelete: false, ...data })
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
function openDialog(mode, value = '', id = '') {
|
|
3096
|
+
setDialog({ mode, value, id })
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
async function submitDialog() {
|
|
3100
|
+
if (dialog === null || dialog.value.trim() === '') return
|
|
3101
|
+
const name = dialog.value.trim()
|
|
3102
|
+
try {
|
|
3103
|
+
let createdFolderId = ''
|
|
3104
|
+
if (dialog.mode === 'folder') {
|
|
3105
|
+
const data = await apiPost('/dossiers/folder', { name })
|
|
3106
|
+
createdFolderId = data?.folder?.id ?? ''
|
|
3107
|
+
} else if (dialog.mode === 'folderRename') await apiPost('/dossiers/folder/rename', { id: dialog.id, name })
|
|
3108
|
+
else if (dialog.mode === 'rename') await apiPost('/dossiers/rename', { id: dialog.id, title: name })
|
|
3109
|
+
setDialog(null)
|
|
3110
|
+
await load()
|
|
3111
|
+
if (createdFolderId !== '') {
|
|
3112
|
+
setActiveFolder(createdFolderId)
|
|
3113
|
+
setExpandedFolders((current) => new Set(current).add(createdFolderId))
|
|
3114
|
+
}
|
|
3115
|
+
} catch (reason) {
|
|
3116
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
async function importFiles(files) {
|
|
3121
|
+
const list = Array.from(files ?? []).filter((file) => file !== null && file !== undefined)
|
|
3122
|
+
if (list.length === 0) return
|
|
3123
|
+
setImporting(true)
|
|
3124
|
+
setError('')
|
|
3125
|
+
const failures = []
|
|
3126
|
+
const targetFolder = importFolderId !== null ? importFolderId : folderId
|
|
3127
|
+
try {
|
|
3128
|
+
for (const file of list) {
|
|
3129
|
+
try {
|
|
3130
|
+
await apiUpload('/dossiers/import', file, {
|
|
3131
|
+
'x-dossier-name': encodeURIComponent(file.name),
|
|
3132
|
+
'x-dossier-folder': targetFolder,
|
|
3133
|
+
})
|
|
3134
|
+
} catch (reason) {
|
|
3135
|
+
failures.push(`${file.name}:${reason instanceof Error ? reason.message : String(reason)}`)
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
await load()
|
|
3139
|
+
} finally {
|
|
3140
|
+
setImporting(false)
|
|
3141
|
+
setImportFolderId(null)
|
|
3142
|
+
if (fileRef.current !== null) fileRef.current.value = ''
|
|
3143
|
+
if (failures.length > 0) setError(failures.join('\n'))
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
async function runSearch(event) {
|
|
3148
|
+
if (event !== undefined) event.preventDefault()
|
|
3149
|
+
const value = query.trim()
|
|
3150
|
+
if (value === '') { setSearch({ busy: false, results: [], error: '', query: '' }); return }
|
|
3151
|
+
setSearch((current) => ({ ...current, busy: true, error: '', query: value }))
|
|
3152
|
+
try {
|
|
3153
|
+
const data = await apiGet(`/dossiers/search?folder=${encodeURIComponent(folderId)}&q=${encodeURIComponent(value)}`)
|
|
3154
|
+
setSearch({ busy: false, results: data.matches ?? [], error: '', query: value })
|
|
3155
|
+
} catch (reason) {
|
|
3156
|
+
setSearch({ busy: false, results: [], error: reason instanceof Error ? reason.message : String(reason), query: value })
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
async function openSkillEditor(item) {
|
|
3161
|
+
try {
|
|
3162
|
+
const data = await apiGet(`/dossiers/skill?id=${encodeURIComponent(item.id)}`)
|
|
3163
|
+
setSkillEditor({ id: item.id, title: item.title, text: data.text ?? '' })
|
|
3164
|
+
} catch (reason) {
|
|
3165
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
async function saveSkill() {
|
|
3170
|
+
if (skillEditor === null) return
|
|
3171
|
+
try {
|
|
3172
|
+
await apiPost('/dossiers/skill', { id: skillEditor.id, text: skillEditor.text })
|
|
3173
|
+
setSkillEditor(null)
|
|
3174
|
+
await load()
|
|
3175
|
+
} catch (reason) {
|
|
3176
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
async function createSkill(target = folderId) {
|
|
3181
|
+
try {
|
|
3182
|
+
await apiPost('/dossiers/skill/create', { folderId: target })
|
|
3183
|
+
setExpandedFolders((current) => new Set(current).add(target))
|
|
3184
|
+
await load()
|
|
3185
|
+
} catch (reason) {
|
|
3186
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
function stopSummary() {
|
|
3191
|
+
summaryAbort.current?.abort()
|
|
3192
|
+
summaryAbort.current = null
|
|
3193
|
+
setSummary((current) => ({ ...current, status: current.status === 'streaming' ? 'idle' : current.status }))
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
async function startSummary(options = {}) {
|
|
3197
|
+
const controller = new AbortController()
|
|
3198
|
+
summaryAbort.current?.abort()
|
|
3199
|
+
summaryAbort.current = controller
|
|
3200
|
+
setSummary({ open: true, status: 'streaming', text: '', title: options.title ?? t('dossiers.summaryTitle'), error: '' })
|
|
3201
|
+
try {
|
|
3202
|
+
await apiStreamJson('/dossiers/summary', {
|
|
3203
|
+
folderId,
|
|
3204
|
+
ids: Array.isArray(options.ids) ? options.ids : [],
|
|
3205
|
+
query: options.query ?? '',
|
|
3206
|
+
}, {
|
|
3207
|
+
signal: controller.signal,
|
|
3208
|
+
onEvent: (event) => {
|
|
3209
|
+
if (event.type === 'delta' && typeof event.text === 'string') {
|
|
3210
|
+
setSummary((current) => ({ ...current, text: current.text + event.text }))
|
|
3211
|
+
} else if (event.type === 'done') {
|
|
3212
|
+
setSummary((current) => ({ ...current, status: 'done', text: event.text ?? current.text }))
|
|
3213
|
+
} else if (event.type === 'error') {
|
|
3214
|
+
setSummary((current) => ({ ...current, status: 'error', error: String(event.error ?? 'AI 总结失败') }))
|
|
3215
|
+
}
|
|
3216
|
+
},
|
|
3217
|
+
})
|
|
3218
|
+
} catch (reason) {
|
|
3219
|
+
if (controller.signal.aborted) return
|
|
3220
|
+
setSummary((current) => ({ ...current, status: 'error', error: reason instanceof Error ? reason.message : String(reason) }))
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
function closeSummary() {
|
|
3225
|
+
summaryAbort.current?.abort()
|
|
3226
|
+
summaryAbort.current = null
|
|
3227
|
+
setSummary({ open: false, status: 'idle', text: '', title: '', error: '' })
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
function summaryScopeIds() {
|
|
3231
|
+
if (search.query !== '' && search.results.length > 0) return search.results.map((entry) => entry.id)
|
|
3232
|
+
return []
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
return h('div', { className: 'epub-panel epub-dossiers' },
|
|
3236
|
+
h('header', { className: 'epub-papers-head' },
|
|
3237
|
+
h('div', null,
|
|
3238
|
+
h('button', { type: 'button', className: 'epub-icon-btn', onClick: onBack, title: t('papers.back'), 'aria-label': t('papers.back') }, h(IconArrowLeft, { size: 16 })),
|
|
3239
|
+
h('div', { className: 'epub-papers-title-wrap' },
|
|
3240
|
+
h('p', { className: 'epub-eyebrow' }, 'DOSSIER MODE'),
|
|
3241
|
+
h('h1', { className: 'epub-papers-title' }, t('dossiers.title')),
|
|
3242
|
+
h('p', { className: 'epub-papers-subtitle' }, t('dossiers.subtitle')),
|
|
3243
|
+
),
|
|
3244
|
+
),
|
|
3245
|
+
h('div', { className: 'epub-papers-head-actions' },
|
|
3246
|
+
h('button', { type: 'button', className: 'epub-btn epub-btn-primary', onClick: () => { setImportFolderId(null); fileRef.current?.click() } }, h(IconUpload, { size: 14 }), t('dossiers.import')),
|
|
3247
|
+
h('input', { ref: fileRef, type: 'file', accept: BOOK_ACCEPT, multiple: true, style: { display: 'none' }, onChange: (event) => { void importFiles(event.target.files); event.target.value = '' } }),
|
|
3248
|
+
),
|
|
3249
|
+
),
|
|
3250
|
+
error !== '' ? h(ErrorBanner, { title: t('dossiers.importFailed'), message: error, actionLabel: t('papers.retry'), onAction: () => setError('') }) : null,
|
|
3251
|
+
h('div', { className: 'epub-papers-body' },
|
|
3252
|
+
h('aside', { className: 'epub-paper-folders' },
|
|
3253
|
+
h('div', { className: 'epub-paper-folder-top' },
|
|
3254
|
+
h('button', { type: 'button', className: cn('epub-paper-folder', activeFolder === 'all' && 'is-active'), onClick: () => { setActiveFolder('all'); setSearch({ busy: false, results: [], error: '', query: '' }); setQuery('') } }, t('dossiers.all'), h('span', null, String(state.items.filter((item) => item.kind === 'document').length))),
|
|
3255
|
+
h('button', { type: 'button', className: 'epub-paper-folder-add', onClick: () => openDialog('folder'), title: t('dossiers.newFolder'), 'aria-label': t('dossiers.newFolder') }, h(IconFolderPlus, { size: 15 })),
|
|
3256
|
+
),
|
|
3257
|
+
h('div', { className: 'epub-dossier-folder-group' },
|
|
3258
|
+
h('div', { className: cn('epub-paper-folder-wrap', activeFolder === 'root' && 'is-active') },
|
|
3259
|
+
h('button', { type: 'button', className: 'epub-dossier-expand', onClick: () => toggleFolder('root'), title: expandedFolders.has('root') ? t('dossiers.collapse') : t('dossiers.expand'), 'aria-label': expandedFolders.has('root') ? t('dossiers.collapse') : t('dossiers.expand') }, expandedFolders.has('root') ? '▾' : '▸'),
|
|
3260
|
+
h('button', { type: 'button', className: cn('epub-paper-folder', activeFolder === 'root' && 'is-active'), onClick: () => { setActiveFolder('root'); setSearch({ busy: false, results: [], error: '', query: '' }); setQuery('') } }, t('dossiers.root'), h('span', null, String(state.items.filter((item) => item.kind === 'document' && item.folderId === '').length))),
|
|
3261
|
+
h('button', { type: 'button', className: 'epub-paper-folder-rename', onClick: () => void createSkill(''), title: t('dossiers.createSkill') }, '+'),
|
|
3262
|
+
),
|
|
3263
|
+
expandedFolders.has('root') ? renderFolderSkills('') : null,
|
|
3264
|
+
),
|
|
3265
|
+
state.folders.map((entry) =>
|
|
3266
|
+
h('div', { key: entry.id, className: 'epub-dossier-folder-group', onContextMenu: (event) => openContext(event, 'folder', { folderId: entry.id }) },
|
|
3267
|
+
h('div', { className: cn('epub-paper-folder-wrap', activeFolder === entry.id && 'is-active') },
|
|
3268
|
+
h('button', { type: 'button', className: 'epub-dossier-expand', onClick: () => toggleFolder(entry.id), title: expandedFolders.has(entry.id) ? t('dossiers.collapse') : t('dossiers.expand'), 'aria-label': expandedFolders.has(entry.id) ? t('dossiers.collapse') : t('dossiers.expand') }, expandedFolders.has(entry.id) ? '▾' : '▸'),
|
|
3269
|
+
h('button', { type: 'button', className: cn('epub-paper-folder', activeFolder === entry.id && 'is-active'), onClick: () => { setActiveFolder(entry.id); setSearch({ busy: false, results: [], error: '', query: '' }); setQuery('') } }, entry.name, h('span', null, String(state.items.filter((item) => item.kind === 'document' && item.folderId === entry.id).length))),
|
|
3270
|
+
h('button', { type: 'button', className: 'epub-paper-folder-rename', onClick: () => openDialog('folderRename', entry.name, entry.id), title: t('dossiers.renameFolder') }, '✎'),
|
|
3271
|
+
h('button', { type: 'button', className: 'epub-paper-folder-rename', onClick: () => void createSkill(entry.id), title: t('dossiers.createSkill') }, '+'),
|
|
3272
|
+
),
|
|
3273
|
+
expandedFolders.has(entry.id) ? renderFolderSkills(entry.id) : null,
|
|
3274
|
+
),
|
|
3275
|
+
),
|
|
3276
|
+
),
|
|
3277
|
+
h('main', { className: 'epub-paper-main', onContextMenu: (event) => openContext(event, 'library') },
|
|
3278
|
+
h('div', { className: 'epub-paper-main-head' },
|
|
3279
|
+
h('h2', null, folderLabel),
|
|
3280
|
+
h('div', { className: 'epub-dossier-actions' },
|
|
3281
|
+
h('form', { className: 'epub-dossier-search', onSubmit: runSearch },
|
|
3282
|
+
h(IconSearch, { size: 14 }),
|
|
3283
|
+
h('input', { value: query, onChange: (event) => setQuery(event.target.value), placeholder: t('dossiers.searchPlaceholder'), 'aria-label': t('dossiers.searchPlaceholder'), type: 'search' }),
|
|
3284
|
+
),
|
|
3285
|
+
h('button', { type: 'button', className: 'epub-btn epub-btn-primary', onClick: () => void startSummary({ ids: summaryScopeIds(), query: search.query, title: search.query === '' ? t('dossiers.summaryTitle') : t('dossiers.summarySearchTitle') }), disabled: documents.length === 0 && search.results.length === 0 }, h(IconSparkles, { size: 13 }), t('dossiers.summary')),
|
|
3286
|
+
),
|
|
3287
|
+
),
|
|
3288
|
+
h('div', { className: 'epub-dossier-hint' }, t('dossiers.skillHint')),
|
|
3289
|
+
search.error !== '' ? h('div', { className: 'epub-dossier-empty' }, search.error) : null,
|
|
3290
|
+
importing
|
|
3291
|
+
? h('div', { className: 'epub-paper-empty' }, h(Spinner, null), h('p', null, t('dossiers.importing')))
|
|
3292
|
+
: state.status === 'loading'
|
|
3293
|
+
? h('div', { className: 'epub-paper-empty' }, h(Spinner, null), h('p', null, t('papers.importing')))
|
|
3294
|
+
: state.status === 'error'
|
|
3295
|
+
? h(ErrorBanner, { title: t('dossiers.importFailed'), message: state.error, actionLabel: t('papers.retry'), onAction: () => void load() })
|
|
3296
|
+
: shown.length === 0
|
|
3297
|
+
? h('div', { className: 'epub-paper-empty' }, h(IconFolderPlus, { size: 30 }), h('h3', null, search.query !== '' ? t('dossiers.searchEmpty') : t('dossiers.empty')), h('p', null, search.query !== '' ? t('dossiers.searchEmptyHint') : t('dossiers.emptyHint')))
|
|
3298
|
+
: h('div', { className: 'epub-dossier-grid' },
|
|
3299
|
+
shown.map((item) => h('article', {
|
|
3300
|
+
key: item.id,
|
|
3301
|
+
className: 'epub-dossier-card',
|
|
3302
|
+
onClick: () => onRead(item.id),
|
|
3303
|
+
onContextMenu: (event) => openContext(event, 'item', { itemId: item.id }),
|
|
3304
|
+
},
|
|
3305
|
+
h('div', { className: 'epub-dossier-type' }, (item.format || 'DOC').toUpperCase()),
|
|
3306
|
+
h('h3', null, item.title),
|
|
3307
|
+
h('p', null, search.query !== '' && item.snippet !== undefined ? item.snippet : (item.preview || t('dossiers.openHint'))),
|
|
3308
|
+
h('div', { className: 'epub-dossier-meta' }, h('span', null, formatBytes(item.size ?? 0)), h('span', null, t('dossiers.open'))),
|
|
3309
|
+
)),
|
|
3310
|
+
),
|
|
3311
|
+
),
|
|
3312
|
+
),
|
|
3313
|
+
contextMenu !== null
|
|
3314
|
+
? h('div', { className: 'epub-context-menu', style: { left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }, onClick: (event) => event.stopPropagation() },
|
|
3315
|
+
contextMenu.kind === 'library' || contextMenu.kind === 'folder'
|
|
3316
|
+
? h('button', { type: 'button', className: 'epub-context-item', onClick: () => { setContextMenu(null); openDialog('folder') } }, t('dossiers.newFolder'))
|
|
3317
|
+
: null,
|
|
3318
|
+
contextMenu.kind === 'folder'
|
|
3319
|
+
? h('button', { type: 'button', className: 'epub-context-item', onClick: () => { const id = contextMenu.folderId; setContextMenu(null); setActiveFolder(id); setImportFolderId(id); fileRef.current?.click() } }, t('dossiers.importHere'))
|
|
3320
|
+
: null,
|
|
3321
|
+
contextMenu.kind === 'folder'
|
|
3322
|
+
? h('button', { type: 'button', className: 'epub-context-item', onClick: () => { const entry = state.folders.find((value) => value.id === contextMenu.folderId); setContextMenu(null); if (entry !== undefined) openDialog('folderRename', entry.name, entry.id) } }, t('dossiers.renameFolder'))
|
|
3323
|
+
: null,
|
|
3324
|
+
contextMenu.kind === 'item'
|
|
3325
|
+
? (() => {
|
|
3326
|
+
const item = state.items.find((entry) => entry.id === contextMenu.itemId)
|
|
3327
|
+
return item === undefined ? null : h(React.Fragment, null,
|
|
3328
|
+
item.kind === 'document' ? h('button', { type: 'button', className: 'epub-context-item', onClick: () => { setContextMenu(null); onRead(item.id) } }, t('dossiers.open')) : null,
|
|
3329
|
+
item.kind === 'skill' ? h('button', { type: 'button', className: 'epub-context-item', onClick: () => { setContextMenu(null); void openSkillEditor(item) } }, t('dossiers.editSkill')) : null,
|
|
3330
|
+
h('button', { type: 'button', className: 'epub-context-item', onClick: () => { setContextMenu(null); openDialog('rename', item.title, item.id) } }, t('dossiers.rename')),
|
|
3331
|
+
h('button', {
|
|
3332
|
+
type: 'button',
|
|
3333
|
+
className: cn('epub-context-item', contextMenu.confirmDelete && 'is-danger'),
|
|
3334
|
+
onClick: () => {
|
|
3335
|
+
if (!contextMenu.confirmDelete) { setContextMenu({ ...contextMenu, confirmDelete: true }); return }
|
|
3336
|
+
const id = item.id
|
|
3337
|
+
setContextMenu(null)
|
|
3338
|
+
void (async () => { try { await apiPost('/dossiers/delete', { id }); await load() } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) } })()
|
|
3339
|
+
},
|
|
3340
|
+
}, contextMenu.confirmDelete ? t('dossiers.confirmDelete') : t('dossiers.delete')),
|
|
3341
|
+
)
|
|
3342
|
+
})()
|
|
3343
|
+
: null,
|
|
3344
|
+
)
|
|
3345
|
+
: null,
|
|
3346
|
+
dialog !== null
|
|
3347
|
+
? h('div', { className: 'epub-dialog-backdrop', onMouseDown: (event) => { if (event.target === event.currentTarget) setDialog(null) } },
|
|
3348
|
+
h('div', { className: 'epub-dialog' },
|
|
3349
|
+
h('h3', null, dialog.mode === 'folder' ? t('dossiers.newFolder') : dialog.mode === 'folderRename' ? t('dossiers.renameFolder') : t('dossiers.rename')),
|
|
3350
|
+
h('input', {
|
|
3351
|
+
className: 'epub-dialog-input',
|
|
3352
|
+
autoFocus: true,
|
|
3353
|
+
value: dialog.value,
|
|
3354
|
+
onChange: (event) => setDialog({ ...dialog, value: event.target.value }),
|
|
3355
|
+
onKeyDown: (event) => { if (event.key === 'Enter') { event.preventDefault(); void submitDialog() } if (event.key === 'Escape') setDialog(null) },
|
|
3356
|
+
placeholder: t('dossiers.namePlaceholder'),
|
|
3357
|
+
}),
|
|
3358
|
+
h('div', { className: 'epub-dialog-actions' },
|
|
3359
|
+
h('button', { type: 'button', className: 'epub-btn', onClick: () => setDialog(null) }, t('papers.cancel')),
|
|
3360
|
+
h('button', { type: 'button', className: 'epub-btn epub-btn-primary', onClick: () => void submitDialog() }, t('papers.confirm')),
|
|
3361
|
+
),
|
|
3362
|
+
),
|
|
3363
|
+
)
|
|
3364
|
+
: null,
|
|
3365
|
+
skillEditor !== null
|
|
3366
|
+
? h('div', { className: 'epub-dialog-backdrop', onMouseDown: (event) => { if (event.target === event.currentTarget) setSkillEditor(null) } },
|
|
3367
|
+
h('section', { className: 'epub-skill-editor' },
|
|
3368
|
+
h('header', null,
|
|
3369
|
+
h('div', null, h('p', { className: 'epub-eyebrow' }, 'DOSSIER SKILL'), h('h3', null, t('dossiers.editSkill'))),
|
|
3370
|
+
h('button', { type: 'button', className: 'epub-icon-btn', onClick: () => setSkillEditor(null), title: t('papers.close') }, h(IconX, { size: 16 })),
|
|
3371
|
+
),
|
|
3372
|
+
h('p', { className: 'epub-skill-editor-hint' }, t('dossiers.skillEditorHint')),
|
|
3373
|
+
h('textarea', {
|
|
3374
|
+
className: 'epub-skill-editor-text',
|
|
3375
|
+
value: skillEditor.text,
|
|
3376
|
+
onChange: (event) => setSkillEditor({ ...skillEditor, text: event.target.value }),
|
|
3377
|
+
placeholder: t('dossiers.skillPlaceholder'),
|
|
3378
|
+
}),
|
|
3379
|
+
h('div', { className: 'epub-dialog-actions' },
|
|
3380
|
+
h('button', { type: 'button', className: 'epub-btn', onClick: () => setSkillEditor(null) }, t('papers.cancel')),
|
|
3381
|
+
h('button', { type: 'button', className: 'epub-btn epub-btn-primary', onClick: () => void saveSkill() }, t('dossiers.save')),
|
|
3382
|
+
),
|
|
3383
|
+
),
|
|
3384
|
+
)
|
|
3385
|
+
: null,
|
|
3386
|
+
summary.open
|
|
3387
|
+
? h('div', { className: 'epub-dossier-summary-backdrop', onMouseDown: (event) => { if (event.target === event.currentTarget) closeSummary() } },
|
|
3388
|
+
h('section', { className: 'epub-dossier-summary' },
|
|
3389
|
+
h('header', null,
|
|
3390
|
+
h('div', null, h('p', { className: 'epub-eyebrow' }, 'AI DOSSIER'), h('h3', null, summary.title)),
|
|
3391
|
+
h('div', { className: 'epub-dossier-summary-actions' },
|
|
3392
|
+
summary.status === 'streaming' ? h('button', { type: 'button', className: 'epub-btn', onClick: stopSummary }, t('dossiers.stop')) : null,
|
|
3393
|
+
h('button', { type: 'button', className: 'epub-icon-btn', onClick: closeSummary, title: t('papers.close') }, h(IconX, { size: 16 })),
|
|
3394
|
+
),
|
|
3395
|
+
),
|
|
3396
|
+
summary.error !== '' ? h('p', { className: 'epub-dossier-summary-error' }, summary.error) : null,
|
|
3397
|
+
h('div', { className: 'epub-dossier-summary-body' }, summary.status === 'streaming' && summary.text === '' ? h(Spinner, null) : summary.text),
|
|
3398
|
+
),
|
|
3399
|
+
)
|
|
3400
|
+
: null,
|
|
3401
|
+
)
|
|
3402
|
+
}
|
|
3403
|
+
|
|
2845
3404
|
function PaperTranslationBox({ t, paperId, unit, value, collapsed, onChange, onToggle, onExplain, onSelectText, actions }) {
|
|
2846
3405
|
const key = `${paperId}:${unit.key}`
|
|
2847
3406
|
const ref = React.useRef(null)
|
|
@@ -4372,6 +4931,21 @@ window.__ModuleLoader__.load({
|
|
|
4372
4931
|
announceCopy(info)
|
|
4373
4932
|
}
|
|
4374
4933
|
|
|
4934
|
+
async function openDossier(id) {
|
|
4935
|
+
if (opening !== '') return
|
|
4936
|
+
setOpening(id)
|
|
4937
|
+
setOpenError('')
|
|
4938
|
+
try {
|
|
4939
|
+
const data = await apiPost('/dossiers/open', { id })
|
|
4940
|
+
setReaderBook(data.book)
|
|
4941
|
+
setScreen('dossier-reader')
|
|
4942
|
+
} catch (error) {
|
|
4943
|
+
setOpenError(`${t('dossiers.openFailed')}:${error instanceof Error ? error.message : String(error)}`)
|
|
4944
|
+
} finally {
|
|
4945
|
+
setOpening('')
|
|
4946
|
+
}
|
|
4947
|
+
}
|
|
4948
|
+
|
|
4375
4949
|
async function openPaper(id) {
|
|
4376
4950
|
if (opening !== '') return
|
|
4377
4951
|
setOpening(id)
|
|
@@ -4393,7 +4967,9 @@ window.__ModuleLoader__.load({
|
|
|
4393
4967
|
{ className: 'epub-panel' },
|
|
4394
4968
|
screen === 'reader' && readerBook !== null
|
|
4395
4969
|
? h(Reader, { book: readerBook, t, onBack: () => { setReaderBook(null); setScreen('library') }, onToast: showToast })
|
|
4396
|
-
: screen === '
|
|
4970
|
+
: screen === 'dossier-reader' && readerBook !== null
|
|
4971
|
+
? h(Reader, { book: readerBook, t, onBack: () => { setReaderBook(null); setScreen('dossiers') }, onToast: showToast })
|
|
4972
|
+
: screen === 'paper-reader' && readerBook !== null
|
|
4397
4973
|
? h(PaperReaderShell, {
|
|
4398
4974
|
paperId: paperReaderId,
|
|
4399
4975
|
book: readerBook,
|
|
@@ -4402,7 +4978,9 @@ window.__ModuleLoader__.load({
|
|
|
4402
4978
|
onRead: (id) => void openPaper(id),
|
|
4403
4979
|
onToast: showToast,
|
|
4404
4980
|
})
|
|
4405
|
-
: screen === '
|
|
4981
|
+
: screen === 'dossiers'
|
|
4982
|
+
? h(DossierLibrary, { t, onBack: () => setScreen('library'), onRead: (id) => void openDossier(id) })
|
|
4983
|
+
: screen === 'papers'
|
|
4406
4984
|
? h(PaperLibrary, {
|
|
4407
4985
|
t,
|
|
4408
4986
|
onBack: () => setScreen('library'),
|
|
@@ -4417,7 +4995,7 @@ window.__ModuleLoader__.load({
|
|
|
4417
4995
|
onRead: (id) => void openPaper(id),
|
|
4418
4996
|
})
|
|
4419
4997
|
: h(React.Fragment, null,
|
|
4420
|
-
h(Library, { ctx, t, onOpenBook: openBook, opening, onLocalBook: openImported, onPapers: () => setScreen('papers') }),
|
|
4998
|
+
h(Library, { ctx, t, onOpenBook: openBook, opening, onLocalBook: openImported, onPapers: () => setScreen('papers'), onDossiers: () => setScreen('dossiers') }),
|
|
4421
4999
|
openError !== ''
|
|
4422
5000
|
? h('div', { style: { position: 'absolute', left: 20, right: 20, bottom: 20, zIndex: 50 } }, h(ErrorBanner, { title: t('library.openFailed'), message: openError }))
|
|
4423
5001
|
: null,
|
|
@@ -4443,7 +5021,7 @@ window.__ModuleLoader__.load({
|
|
|
4443
5021
|
|
|
4444
5022
|
ctx.effect(() => {
|
|
4445
5023
|
const tag = document.createElement('style')
|
|
4446
|
-
tag.dataset.plugin = '
|
|
5024
|
+
tag.dataset.plugin = 'dsh-ai-book-shelf'
|
|
4447
5025
|
tag.textContent = CSS
|
|
4448
5026
|
document.head.append(tag)
|
|
4449
5027
|
return () => tag.remove()
|
package/index.js
CHANGED
|
@@ -1803,6 +1803,271 @@ function createHandler(ctx, config) {
|
|
|
1803
1803
|
await writePaperLibrary(archiveDir, library)
|
|
1804
1804
|
}
|
|
1805
1805
|
|
|
1806
|
+
function dossierRootFor(archiveDir) {
|
|
1807
|
+
return path.join(archiveDir, '.epub-reader', 'dossiers')
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
async function ensureDossierDirs(archiveDir) {
|
|
1811
|
+
const root = dossierRootFor(archiveDir)
|
|
1812
|
+
await fs.mkdir(path.join(root, 'files'), { recursive: true })
|
|
1813
|
+
await fs.mkdir(path.join(root, 'skills'), { recursive: true })
|
|
1814
|
+
return root
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
async function createDossierSkill(archiveDir, folderId, force = false) {
|
|
1818
|
+
const root = await ensureDossierDirs(archiveDir)
|
|
1819
|
+
const library = await readDossierLibrary(archiveDir)
|
|
1820
|
+
const existing = library.items.find((entry) => entry.kind === 'skill' && entry.folderId === folderId)
|
|
1821
|
+
if (existing !== undefined && !force) return existing
|
|
1822
|
+
const id = existing?.id ?? `skill-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
|
1823
|
+
const target = path.join(root, 'skills', `${folderId || 'unsorted'}-${id}.md`)
|
|
1824
|
+
await fs.writeFile(target, '', 'utf8')
|
|
1825
|
+
const item = { id, kind: 'skill', title: '卷宗 Skill(待填写)', path: target, text: '', folderId, addedAt: Date.now(), size: 0, format: 'SKILL' }
|
|
1826
|
+
library.items = library.items.filter((entry) => entry.id !== id)
|
|
1827
|
+
library.items.push(item)
|
|
1828
|
+
await writeDossierLibrary(archiveDir, library)
|
|
1829
|
+
return item
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
async function readDossierLibrary(archiveDir) {
|
|
1833
|
+
const root = await ensureDossierDirs(archiveDir)
|
|
1834
|
+
try {
|
|
1835
|
+
const value = JSON.parse(await fs.readFile(path.join(root, 'library.json'), 'utf8'))
|
|
1836
|
+
return {
|
|
1837
|
+
folders: Array.isArray(value?.folders) ? value.folders : [],
|
|
1838
|
+
items: Array.isArray(value?.items) ? value.items : [],
|
|
1839
|
+
}
|
|
1840
|
+
} catch (error) {
|
|
1841
|
+
if (error?.code === 'ENOENT') return { folders: [], items: [] }
|
|
1842
|
+
throw new HttpError(500, 'DOSSIER_LIBRARY_READ_FAILED', `卷宗库读取失败:${publicErrorMessage(error)}`)
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
async function writeDossierLibrary(archiveDir, library) {
|
|
1847
|
+
const root = await ensureDossierDirs(archiveDir)
|
|
1848
|
+
const file = path.join(root, 'library.json')
|
|
1849
|
+
const temporary = `${file}.${process.pid}.tmp`
|
|
1850
|
+
await fs.writeFile(temporary, JSON.stringify(library, null, 2), 'utf8')
|
|
1851
|
+
await fs.rename(temporary, file)
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function dossierItemPublic(item) {
|
|
1855
|
+
return {
|
|
1856
|
+
id: item.id,
|
|
1857
|
+
kind: item.kind === 'skill' ? 'skill' : 'document',
|
|
1858
|
+
title: item.title,
|
|
1859
|
+
folderId: item.folderId ?? '',
|
|
1860
|
+
addedAt: item.addedAt ?? 0,
|
|
1861
|
+
size: item.size ?? 0,
|
|
1862
|
+
format: item.format ?? 'TEXT',
|
|
1863
|
+
pageCount: item.pageCount ?? 0,
|
|
1864
|
+
preview: item.kind === 'skill' ? String(item.text ?? '').slice(0, 240) : '',
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
async function loadDossierBook(item, archiveDir) {
|
|
1869
|
+
const cached = store.get(item.id)
|
|
1870
|
+
if (cached !== undefined) return cached
|
|
1871
|
+
if (typeof item.path !== 'string' || item.path === '') throw new HttpError(404, 'DOSSIER_MISSING', '卷宗文件不存在')
|
|
1872
|
+
const stat = await fs.stat(item.path).catch(() => null)
|
|
1873
|
+
if (stat === null) throw new HttpError(404, 'DOSSIER_MISSING', '卷宗文件已不在原位置')
|
|
1874
|
+
const buffer = await fs.readFile(item.path)
|
|
1875
|
+
const book = await parseBook(buffer, {
|
|
1876
|
+
id: item.id,
|
|
1877
|
+
fileName: path.basename(item.path),
|
|
1878
|
+
assetUrl: (bookId, assetPath) => `api/epub-reader/asset?book=${encodeURIComponent(bookId)}&path=${encodeURIComponent(assetPath)}`,
|
|
1879
|
+
})
|
|
1880
|
+
book.size = buffer.length
|
|
1881
|
+
book.addedAt = item.addedAt
|
|
1882
|
+
book.annotationKey = `sha1-${hashText(buffer)}`
|
|
1883
|
+
store.put(book)
|
|
1884
|
+
return book
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function dossierPlainText(book) {
|
|
1888
|
+
if (Array.isArray(book.paperPages)) {
|
|
1889
|
+
return book.paperPages.map((page) => {
|
|
1890
|
+
if (typeof page.text === 'string') return page.text
|
|
1891
|
+
return (page.units ?? []).map((unit) => unit.text ?? unit.source ?? '').join('\n')
|
|
1892
|
+
}).join('\n\n')
|
|
1893
|
+
}
|
|
1894
|
+
return (book.chapters ?? []).map((chapter) => stripTags(chapter.html ?? '')).join('\n\n')
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
async function dossierTextForItem(item, archiveDir) {
|
|
1898
|
+
try {
|
|
1899
|
+
return dossierPlainText(await loadDossierBook(item, archiveDir))
|
|
1900
|
+
} catch {
|
|
1901
|
+
return ''
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
async function importDossier(req) {
|
|
1906
|
+
const rawName = req.headers['x-dossier-name']
|
|
1907
|
+
let name = 'document.txt'
|
|
1908
|
+
if (typeof rawName === 'string' && rawName !== '') {
|
|
1909
|
+
try { name = decodeURIComponent(rawName) } catch { name = rawName }
|
|
1910
|
+
}
|
|
1911
|
+
const extension = path.extname(name).toLowerCase()
|
|
1912
|
+
const isSkill = path.basename(name).toLowerCase() === 'skill.md'
|
|
1913
|
+
if (!isSkill && !BOOK_EXTENSIONS.has(extension)) throw new HttpError(415, 'DOSSIER_UNSUPPORTED', '卷宗只支持 EPUB、PDF、TXT、Markdown、HTML、FB2、DOCX')
|
|
1914
|
+
const buffer = await readBody(req, MAX_IMPORT_BYTES)
|
|
1915
|
+
if (buffer.length === 0) throw new HttpError(400, 'EMPTY_FILE', '没有收到卷宗文件')
|
|
1916
|
+
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
1917
|
+
const root = await ensureDossierDirs(archiveDir)
|
|
1918
|
+
const folderId = typeof req.headers['x-dossier-folder'] === 'string' ? req.headers['x-dossier-folder'] : ''
|
|
1919
|
+
const digest = hashText(buffer)
|
|
1920
|
+
if (isSkill) {
|
|
1921
|
+
const target = path.join(root, 'skills', `${digest.slice(0, 16)}-SKILL.md`)
|
|
1922
|
+
await fs.writeFile(target, buffer)
|
|
1923
|
+
const text = buffer.toString('utf8')
|
|
1924
|
+
const id = `skill-${digest.slice(0, 20)}`
|
|
1925
|
+
const library = await readDossierLibrary(archiveDir)
|
|
1926
|
+
const item = { id, kind: 'skill', title: text.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? path.basename(name, extension), path: target, text, folderId, addedAt: Date.now(), size: buffer.length, format: 'SKILL' }
|
|
1927
|
+
library.items = library.items.filter((entry) => entry.id !== id)
|
|
1928
|
+
library.items.push(item)
|
|
1929
|
+
await writeDossierLibrary(archiveDir, library)
|
|
1930
|
+
return { item: dossierItemPublic(item) }
|
|
1931
|
+
}
|
|
1932
|
+
const target = path.join(root, 'files', `${digest.slice(0, 16)}-${path.basename(name).replace(/[^\w.\-\u4e00-\u9fff]+/g, '-').slice(-80)}`)
|
|
1933
|
+
await fs.writeFile(target, buffer)
|
|
1934
|
+
let book
|
|
1935
|
+
try {
|
|
1936
|
+
book = await parseBook(buffer, { id: `dossier-${digest.slice(0, 20)}`, fileName: name, assetUrl: (bookId, assetPath) => `api/epub-reader/asset?book=${encodeURIComponent(bookId)}&path=${encodeURIComponent(assetPath)}` })
|
|
1937
|
+
} catch (error) {
|
|
1938
|
+
await fs.unlink(target).catch(() => {})
|
|
1939
|
+
throw error instanceof HttpError ? error : new HttpError(422, 'INVALID_DOSSIER', `卷宗解析失败:${publicErrorMessage(error)}`)
|
|
1940
|
+
}
|
|
1941
|
+
const text = dossierPlainText(book)
|
|
1942
|
+
if (text.trim() === '') {
|
|
1943
|
+
await fs.unlink(target).catch(() => {})
|
|
1944
|
+
throw new HttpError(422, 'DOSSIER_NO_TEXT', '这个文件没有可提取的文字')
|
|
1945
|
+
}
|
|
1946
|
+
const id = `dossier-${digest.slice(0, 20)}`
|
|
1947
|
+
book.size = buffer.length
|
|
1948
|
+
book.addedAt = Date.now()
|
|
1949
|
+
book.annotationKey = `sha1-${digest}`
|
|
1950
|
+
store.put(book)
|
|
1951
|
+
const library = await readDossierLibrary(archiveDir)
|
|
1952
|
+
const item = { id, kind: 'document', title: book.title && book.title !== '未命名书籍' ? book.title : path.basename(name, extension), path: target, folderId, addedAt: Date.now(), size: buffer.length, format: book.format ?? path.extname(name).slice(1).toUpperCase(), pageCount: book.paperPages?.length ?? 0 }
|
|
1953
|
+
library.items = library.items.filter((entry) => entry.id !== id)
|
|
1954
|
+
library.items.push(item)
|
|
1955
|
+
await writeDossierLibrary(archiveDir, library)
|
|
1956
|
+
return { item: dossierItemPublic(item), book: publicBook(book) }
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
async function deleteDossier(id) {
|
|
1960
|
+
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
1961
|
+
const library = await readDossierLibrary(archiveDir)
|
|
1962
|
+
const item = library.items.find((entry) => entry.id === id)
|
|
1963
|
+
if (item === undefined) throw new HttpError(404, 'DOSSIER_NOT_FOUND', '卷宗不存在')
|
|
1964
|
+
const root = await ensureDossierDirs(archiveDir)
|
|
1965
|
+
const trash = path.join(root, 'trash')
|
|
1966
|
+
await fs.mkdir(trash, { recursive: true })
|
|
1967
|
+
if (typeof item.path === 'string' && item.path !== '') {
|
|
1968
|
+
try { await fs.rename(item.path, path.join(trash, `${Date.now()}-${path.basename(item.path)}`)) } catch { /* already gone */ }
|
|
1969
|
+
}
|
|
1970
|
+
library.items = library.items.filter((entry) => entry.id !== id)
|
|
1971
|
+
store.remove(id)
|
|
1972
|
+
await writeDossierLibrary(archiveDir, library)
|
|
1973
|
+
return { deleted: true }
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
async function searchDossiers(folderId, query) {
|
|
1977
|
+
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
1978
|
+
const library = await readDossierLibrary(archiveDir)
|
|
1979
|
+
const terms = [...new Set([query, traditionalizeChinese(query)])].filter((entry) => entry.trim() !== '')
|
|
1980
|
+
const needles = terms.map((entry) => entry.toLowerCase())
|
|
1981
|
+
const docs = library.items.filter((item) => item.kind === 'document' && (folderId === '' || item.folderId === folderId))
|
|
1982
|
+
const matches = []
|
|
1983
|
+
for (const item of docs) {
|
|
1984
|
+
const text = await dossierTextForItem(item, archiveDir)
|
|
1985
|
+
const lower = text.toLowerCase()
|
|
1986
|
+
let best = -1
|
|
1987
|
+
for (const needle of needles) {
|
|
1988
|
+
const at = lower.indexOf(needle)
|
|
1989
|
+
if (at !== -1 && (best === -1 || at < best)) best = at
|
|
1990
|
+
}
|
|
1991
|
+
if (best === -1) continue
|
|
1992
|
+
const start = Math.max(0, best - 90)
|
|
1993
|
+
matches.push({ id: item.id, title: item.title, folderId: item.folderId ?? '', snippet: text.slice(start, best + 180).replace(/\s+/g, ' ').trim() })
|
|
1994
|
+
if (matches.length >= 60) break
|
|
1995
|
+
}
|
|
1996
|
+
return { matches, total: matches.length, query }
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
async function dossierSummary(req, res) {
|
|
2000
|
+
const payload = await readJson(req)
|
|
2001
|
+
const folderId = typeof payload.folderId === 'string' ? payload.folderId : ''
|
|
2002
|
+
const query = typeof payload.query === 'string' ? payload.query.trim() : ''
|
|
2003
|
+
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
2004
|
+
const library = await readDossierLibrary(archiveDir)
|
|
2005
|
+
const folder = library.folders.find((entry) => entry.id === folderId)
|
|
2006
|
+
const docs = library.items.filter((item) => item.kind === 'document' && (folderId === '' || item.folderId === folderId))
|
|
2007
|
+
const ids = Array.isArray(payload.ids) ? payload.ids.filter((id) => typeof id === 'string') : []
|
|
2008
|
+
const selected = ids.length > 0 ? docs.filter((item) => ids.includes(item.id)) : docs
|
|
2009
|
+
if (selected.length === 0) throw new HttpError(400, 'DOSSIER_EMPTY', '这个卷宗里没有可总结的文件')
|
|
2010
|
+
const skills = library.items.filter((item) => item.kind === 'skill' && (folderId === '' || item.folderId === folderId))
|
|
2011
|
+
const route = resolveModelRoute(ctx, config)
|
|
2012
|
+
if (route === undefined) throw new HttpError(503, 'NO_MODEL', '还没有选择模型')
|
|
2013
|
+
const sections = []
|
|
2014
|
+
for (const item of selected.slice(0, 30)) {
|
|
2015
|
+
const text = (await dossierTextForItem(item, archiveDir)).slice(0, 12000)
|
|
2016
|
+
if (text.trim() !== '') sections.push(`【${item.title}】\n${text}`)
|
|
2017
|
+
}
|
|
2018
|
+
if (sections.length === 0) throw new HttpError(422, 'DOSSIER_NO_TEXT', '卷宗文件没有可总结的文字')
|
|
2019
|
+
const skillText = skills
|
|
2020
|
+
.filter((item) => String(item.text ?? '').trim() !== '')
|
|
2021
|
+
.map((item) => `## ${item.title}\n${String(item.text ?? '').slice(0, 6000)}`)
|
|
2022
|
+
.join('\n\n')
|
|
2023
|
+
const system = '你是卷宗分析助手。请严格依据提供的卷宗内容作答;有 Skill 时优先遵循 Skill 中的方法与格式。不要编造卷宗中没有的事实。'
|
|
2024
|
+
const prompt = [
|
|
2025
|
+
`卷宗:${folder?.name ?? '未分类卷宗'}`,
|
|
2026
|
+
query === '' ? '请总结这个卷宗的核心内容、关键事实、争议点、时间线和待办事项。' : `请围绕以下问题总结卷宗:${query}`,
|
|
2027
|
+
skillText === '' ? '' : `\n<!-- SKILLS -->\n${skillText}\n<!-- /SKILLS -->`,
|
|
2028
|
+
`\n<!-- DOSSIERS -->\n${sections.join('\n\n')}\n<!-- /DOSSIERS -->`,
|
|
2029
|
+
].filter(Boolean).join('\n')
|
|
2030
|
+
const llm = ctx.get('llm')
|
|
2031
|
+
if (llm === undefined || typeof llm.stream !== 'function') throw new HttpError(503, 'NO_MODEL', '当前环境没有可用的模型服务')
|
|
2032
|
+
const controller = new AbortController()
|
|
2033
|
+
let closed = false
|
|
2034
|
+
res.on('close', () => { if (!res.writableEnded) { closed = true; controller.abort() } })
|
|
2035
|
+
const request = { system, messages: [{ role: 'user', content: [{ type: 'text', text: prompt.slice(0, 120000) }] }], signal: controller.signal }
|
|
2036
|
+
const call = { provider: route.provider, model: route.model, temperature: 0.2, maxTokens: 1800 }
|
|
2037
|
+
const stream = typeof llm.prepareCall === 'function'
|
|
2038
|
+
? await (async () => { const prepared = await llm.prepareCall(call, controller.signal); return prepared.stream({ ...prepared.config, ...request }) })()
|
|
2039
|
+
: llm.stream({ ...call, ...request })
|
|
2040
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
|
|
2041
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders()
|
|
2042
|
+
await writeSse(res, ': connected\n\n')
|
|
2043
|
+
const heartbeat = setInterval(() => { if (!closed) void writeSse(res, ': ping\n\n') }, 15000)
|
|
2044
|
+
if (typeof heartbeat.unref === 'function') heartbeat.unref()
|
|
2045
|
+
let full = ''
|
|
2046
|
+
try {
|
|
2047
|
+
for await (const chunk of stream) {
|
|
2048
|
+
if (closed) break
|
|
2049
|
+
if (chunk.type === 'text-delta') {
|
|
2050
|
+
full += chunk.text
|
|
2051
|
+
if (!(await writeSse(res, `data: ${JSON.stringify({ type: 'delta', text: chunk.text })}\n\n`))) break
|
|
2052
|
+
} else if (chunk.type === 'finish' && chunk.reason.kind === 'error') {
|
|
2053
|
+
throw new Error(chunk.reason.failure?.message ?? '模型返回了错误')
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
if (!closed) {
|
|
2057
|
+
await writeSse(res, `data: ${JSON.stringify({ type: 'done', text: full })}\n\n`)
|
|
2058
|
+
res.end()
|
|
2059
|
+
}
|
|
2060
|
+
} catch (error) {
|
|
2061
|
+
if (!closed) {
|
|
2062
|
+
await writeSse(res, `event: error\ndata: ${JSON.stringify({ error: publicErrorMessage(error) })}\n\n`)
|
|
2063
|
+
res.end()
|
|
2064
|
+
}
|
|
2065
|
+
} finally {
|
|
2066
|
+
clearInterval(heartbeat)
|
|
2067
|
+
if (!closed) controller.abort()
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
|
|
1806
2071
|
async function translatePaperUnit(id, key, force) {
|
|
1807
2072
|
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
1808
2073
|
const { sourcePaper, sourceBook, translation } = await paperContext(id, archiveDir)
|
|
@@ -2286,6 +2551,163 @@ function createHandler(ctx, config) {
|
|
|
2286
2551
|
return
|
|
2287
2552
|
}
|
|
2288
2553
|
|
|
2554
|
+
if (method === 'GET' && pathname === '/dossiers') {
|
|
2555
|
+
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
2556
|
+
const library = await readDossierLibrary(archiveDir)
|
|
2557
|
+
sendJson(res, 200, { folders: library.folders, items: library.items.map(dossierItemPublic) })
|
|
2558
|
+
return
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
if (method === 'POST' && pathname === '/dossiers/folder') {
|
|
2562
|
+
const payload = await readJson(req)
|
|
2563
|
+
const name = typeof payload.name === 'string' ? payload.name.trim().slice(0, 120) : ''
|
|
2564
|
+
if (name === '') throw new HttpError(400, 'EMPTY_FOLDER', '文件夹名称不能为空')
|
|
2565
|
+
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
2566
|
+
const library = await readDossierLibrary(archiveDir)
|
|
2567
|
+
const folder = { id: `dfolder-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, name, createdAt: Date.now() }
|
|
2568
|
+
library.folders.push(folder)
|
|
2569
|
+
await writeDossierLibrary(archiveDir, library)
|
|
2570
|
+
const skill = await createDossierSkill(archiveDir, folder.id)
|
|
2571
|
+
sendJson(res, 200, { folder, skill: dossierItemPublic(skill) })
|
|
2572
|
+
return
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
if (method === 'POST' && pathname === '/dossiers/folder/rename') {
|
|
2576
|
+
const payload = await readJson(req)
|
|
2577
|
+
const id = typeof payload.id === 'string' ? payload.id : ''
|
|
2578
|
+
const name = typeof payload.name === 'string' ? payload.name.trim().slice(0, 120) : ''
|
|
2579
|
+
if (name === '') throw new HttpError(400, 'EMPTY_FOLDER', '文件夹名称不能为空')
|
|
2580
|
+
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
2581
|
+
const library = await readDossierLibrary(archiveDir)
|
|
2582
|
+
const folder = library.folders.find((entry) => entry.id === id)
|
|
2583
|
+
if (folder === undefined) throw new HttpError(404, 'FOLDER_NOT_FOUND', '卷宗文件夹不存在')
|
|
2584
|
+
folder.name = name
|
|
2585
|
+
await writeDossierLibrary(archiveDir, library)
|
|
2586
|
+
sendJson(res, 200, { folder })
|
|
2587
|
+
return
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
if (method === 'POST' && pathname === '/dossiers/folder/delete') {
|
|
2591
|
+
const payload = await readJson(req)
|
|
2592
|
+
const id = typeof payload.id === 'string' ? payload.id : ''
|
|
2593
|
+
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
2594
|
+
const library = await readDossierLibrary(archiveDir)
|
|
2595
|
+
library.folders = library.folders.filter((entry) => entry.id !== id)
|
|
2596
|
+
library.items = library.items.map((entry) => entry.folderId === id ? { ...entry, folderId: '' } : entry)
|
|
2597
|
+
await writeDossierLibrary(archiveDir, library)
|
|
2598
|
+
sendJson(res, 200, { deleted: true })
|
|
2599
|
+
return
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
if (method === 'POST' && pathname === '/dossiers/skill/create') {
|
|
2603
|
+
const payload = await readJson(req)
|
|
2604
|
+
const folderId = typeof payload.folderId === 'string' ? payload.folderId : ''
|
|
2605
|
+
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
2606
|
+
const item = await createDossierSkill(archiveDir, folderId)
|
|
2607
|
+
sendJson(res, 200, { item: dossierItemPublic(item) })
|
|
2608
|
+
return
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
if (method === 'GET' && pathname === '/dossiers/skill') {
|
|
2612
|
+
const id = (url.searchParams.get('id') ?? '').trim()
|
|
2613
|
+
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
2614
|
+
const library = await readDossierLibrary(archiveDir)
|
|
2615
|
+
const item = library.items.find((entry) => entry.id === id && entry.kind === 'skill')
|
|
2616
|
+
if (item === undefined) throw new HttpError(404, 'SKILL_NOT_FOUND', '卷宗 Skill 不存在')
|
|
2617
|
+
let text = item.text ?? ''
|
|
2618
|
+
if (typeof item.path === 'string' && item.path !== '') {
|
|
2619
|
+
try { text = await fs.readFile(item.path, 'utf8') } catch { /* keep cached text */ }
|
|
2620
|
+
}
|
|
2621
|
+
sendJson(res, 200, { item: dossierItemPublic(item), text })
|
|
2622
|
+
return
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
if (method === 'POST' && pathname === '/dossiers/skill') {
|
|
2626
|
+
const payload = await readJson(req)
|
|
2627
|
+
const id = typeof payload.id === 'string' ? payload.id : ''
|
|
2628
|
+
if (typeof payload.text !== 'string') throw new HttpError(400, 'BAD_SKILL', '缺少 Skill 内容')
|
|
2629
|
+
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
2630
|
+
const library = await readDossierLibrary(archiveDir)
|
|
2631
|
+
const item = library.items.find((entry) => entry.id === id && entry.kind === 'skill')
|
|
2632
|
+
if (item === undefined) throw new HttpError(404, 'SKILL_NOT_FOUND', '卷宗 Skill 不存在')
|
|
2633
|
+
const text = payload.text.slice(0, 50000)
|
|
2634
|
+
item.text = text
|
|
2635
|
+
item.size = Buffer.byteLength(text)
|
|
2636
|
+
item.title = text.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? (text.trim() === '' ? '卷宗 Skill(待填写)' : '卷宗 Skill')
|
|
2637
|
+
if (typeof item.path === 'string' && item.path !== '') {
|
|
2638
|
+
await fs.mkdir(path.dirname(item.path), { recursive: true })
|
|
2639
|
+
await fs.writeFile(item.path, text, 'utf8')
|
|
2640
|
+
}
|
|
2641
|
+
await writeDossierLibrary(archiveDir, library)
|
|
2642
|
+
sendJson(res, 200, { item: dossierItemPublic(item), text })
|
|
2643
|
+
return
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
if (method === 'POST' && pathname === '/dossiers/import') {
|
|
2647
|
+
sendJson(res, 200, await importDossier(req))
|
|
2648
|
+
return
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
if (method === 'POST' && pathname === '/dossiers/open') {
|
|
2652
|
+
const payload = await readJson(req)
|
|
2653
|
+
const id = typeof payload.id === 'string' ? payload.id : ''
|
|
2654
|
+
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
2655
|
+
const library = await readDossierLibrary(archiveDir)
|
|
2656
|
+
const item = library.items.find((entry) => entry.id === id && entry.kind === 'document')
|
|
2657
|
+
if (item === undefined) throw new HttpError(404, 'DOSSIER_NOT_FOUND', '卷宗不存在')
|
|
2658
|
+
sendJson(res, 200, { item: dossierItemPublic(item), book: publicBook(await loadDossierBook(item, archiveDir)) })
|
|
2659
|
+
return
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
if (method === 'POST' && pathname === '/dossiers/move') {
|
|
2663
|
+
const payload = await readJson(req)
|
|
2664
|
+
const id = typeof payload.id === 'string' ? payload.id : ''
|
|
2665
|
+
const folderId = typeof payload.folderId === 'string' ? payload.folderId : ''
|
|
2666
|
+
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
2667
|
+
const library = await readDossierLibrary(archiveDir)
|
|
2668
|
+
const item = library.items.find((entry) => entry.id === id)
|
|
2669
|
+
if (item === undefined) throw new HttpError(404, 'DOSSIER_NOT_FOUND', '卷宗不存在')
|
|
2670
|
+
item.folderId = folderId
|
|
2671
|
+
await writeDossierLibrary(archiveDir, library)
|
|
2672
|
+
sendJson(res, 200, { item: dossierItemPublic(item) })
|
|
2673
|
+
return
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
if (method === 'POST' && pathname === '/dossiers/rename') {
|
|
2677
|
+
const payload = await readJson(req)
|
|
2678
|
+
const id = typeof payload.id === 'string' ? payload.id : ''
|
|
2679
|
+
const title = typeof payload.title === 'string' ? payload.title.trim().slice(0, 200) : ''
|
|
2680
|
+
if (title === '') throw new HttpError(400, 'EMPTY_TITLE', '标题不能为空')
|
|
2681
|
+
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
2682
|
+
const library = await readDossierLibrary(archiveDir)
|
|
2683
|
+
const item = library.items.find((entry) => entry.id === id)
|
|
2684
|
+
if (item === undefined) throw new HttpError(404, 'DOSSIER_NOT_FOUND', '卷宗不存在')
|
|
2685
|
+
item.title = title
|
|
2686
|
+
await writeDossierLibrary(archiveDir, library)
|
|
2687
|
+
sendJson(res, 200, { item: dossierItemPublic(item) })
|
|
2688
|
+
return
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
if (method === 'POST' && pathname === '/dossiers/delete') {
|
|
2692
|
+
const payload = await readJson(req)
|
|
2693
|
+
const id = typeof payload.id === 'string' ? payload.id : ''
|
|
2694
|
+
sendJson(res, 200, await deleteDossier(id))
|
|
2695
|
+
return
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
if (method === 'GET' && pathname === '/dossiers/search') {
|
|
2699
|
+
const folder = (url.searchParams.get('folder') ?? '').trim()
|
|
2700
|
+
const query = (url.searchParams.get('q') ?? '').trim()
|
|
2701
|
+
if (query === '') throw new HttpError(400, 'EMPTY_QUERY', '搜索内容不能为空')
|
|
2702
|
+
sendJson(res, 200, await searchDossiers(folder, query))
|
|
2703
|
+
return
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
if (method === 'POST' && pathname === '/dossiers/summary') {
|
|
2707
|
+
await dossierSummary(req, res)
|
|
2708
|
+
return
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2289
2711
|
if (method === 'POST' && pathname === '/papers/translate') {
|
|
2290
2712
|
const payload = await readJson(req)
|
|
2291
2713
|
const id = typeof payload.id === 'string' ? payload.id : ''
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ai-book-shelf",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.3",
|
|
4
4
|
"description": "AI Book Shelf: read books and papers in Harness, search full text, explain and discuss with AI, translate papers and compare bilingual text.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|