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.
Files changed (4) hide show
  1. package/README.md +8 -4
  2. package/client.js +618 -40
  3. package/index.js +422 -0
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # AI Book Shelf / AI 书架
2
2
 
3
3
  [![GitHub](https://img.shields.io/badge/GitHub-0avatar0%2Fdsh--ai--book--shelf-181717?logo=github)](https://github.com/0avatar0/dsh-ai-book-shelf)
4
- [![Release](https://img.shields.io/badge/release-v2.5.1-2ea44f)](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.5.1)
4
+ [![Release](https://img.shields.io/badge/release-v2.6.3-2ea44f)](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.3)
5
5
  [![License](https://img.shields.io/badge/license-MIT-blue)](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.5.1.tgz` from the
15
- [latest release](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.5.1),
16
- or install from npm with `dsh-ai-book-shelf@2.5.1`.
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: '@local/dsh-epub-reader',
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 importFile(file) {
1804
- if (file === undefined || file === null) return
1805
- const extension = file.name.includes('.') ? `.${file.name.split('.').pop().toLowerCase()}` : ''
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 buffer = await file.arrayBuffer()
1818
- const response = await fetch(`${API}/import`, {
1819
- method: 'POST',
1820
- headers: {
1821
- 'content-type': 'application/epub+zip',
1822
- 'x-book-name': encodeURIComponent(file.name),
1823
- },
1824
- body: buffer,
1825
- })
1826
- const data = await responseJson(response)
1827
- if (data !== null && data.book !== undefined) {
1828
- onLocalBook(data.book, {
1829
- copied: data.copied === true,
1830
- created: data.created === true,
1831
- copyPath: typeof data.copyPath === 'string' ? data.copyPath : '',
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
- } catch (error) {
1835
- setImportError(`${t('library.importFailed')}:${error instanceof Error ? error.message : String(error)}`)
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 importFile(event.dataTransfer.files?.[0]) } },
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 importFile(event.target.files?.[0]) },
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 importPdf(event.target.files?.[0]); event.target.value = '' } }),
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 === 'paper-reader' && readerBook !== null
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 === 'papers'
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 = '@local/dsh-epub-reader'
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.5.1",
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",