dsh-ai-book-shelf 2.6.11 → 2.7.1

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 +4 -4
  2. package/client.js +389 -30
  3. package/index.js +330 -69
  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.6.11-2ea44f)](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.11)
4
+ [![Release](https://img.shields.io/badge/release-v2.7.1-2ea44f)](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.7.1)
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
@@ -23,9 +23,9 @@ files and AI summaries.
23
23
 
24
24
  ## Install
25
25
 
26
- 1. Download `dsh-ai-book-shelf-2.6.11.tgz` from the
27
- [latest release](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.11),
28
- or install from npm with `dsh-ai-book-shelf@2.6.11`.
26
+ 1. Download `dsh-ai-book-shelf-2.7.1.tgz` from the
27
+ [latest release](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.7.1),
28
+ or install from npm with `dsh-ai-book-shelf@2.7.1`.
29
29
  2. In DSH open **Plugins → Add plugin** and choose the downloaded tgz, or paste
30
30
  the npm package spec.
31
31
  3. Reload the plugin and open **AI 书架 / AI Book Shelf** in the sidebar.
package/client.js CHANGED
@@ -124,6 +124,19 @@ window.__ModuleLoader__.load({
124
124
  'dossiers.title': '卷宗库',
125
125
  'dossiers.subtitle': '按文件夹整理卷宗,支持全选导入、全文搜索、AI 总结和文件夹 Skill。',
126
126
  'dossiers.import': '导入卷宗',
127
+ 'dossiers.importJudgments': '导入裁判文书',
128
+ 'dossiers.filters': '多条件',
129
+ 'dossiers.extraKeyword': '关键字',
130
+ 'dossiers.addKeyword': '添加关键字',
131
+ 'dossiers.removeKeyword': '移除关键字',
132
+ 'dossiers.filterCourt': '法院',
133
+ 'dossiers.filterCaseNo': '案号',
134
+ 'dossiers.filterParty': '当事人',
135
+ 'dossiers.filterCause': '案由',
136
+ 'dossiers.filterFrom': '起始日期',
137
+ 'dossiers.filterTo': '截止日期',
138
+ 'dossiers.applyFilters': '应用筛选',
139
+ 'dossiers.clearFilters': '清除条件',
127
140
  'dossiers.importing': '正在导入卷宗…',
128
141
  'dossiers.importFailed': '卷宗导入失败',
129
142
  'dossiers.newFolder': '新建卷宗文件夹',
@@ -149,6 +162,18 @@ window.__ModuleLoader__.load({
149
162
  'dossiers.summaryTitle': 'AI 卷宗总结',
150
163
  'dossiers.summarySearchTitle': 'AI 总结搜索结果',
151
164
  'dossiers.summarizing': '正在分析卷宗…',
165
+ 'dossiers.summaryPreparing': '正在准备总结…',
166
+ 'dossiers.summaryEmpty': 'AI 没有返回总结内容,请重试。',
167
+ 'dossiers.retry': '重试',
168
+ 'dossiers.followUpPlaceholder': '继续追问 AI 总结…',
169
+ 'dossiers.followUpSelectedPlaceholder': '追问选中的总结文字…',
170
+ 'dossiers.followUpHint': '可直接输入追问,或先用鼠标选中上方总结文字。',
171
+ 'dossiers.selectedSummary': '已选中总结',
172
+ 'dossiers.clearSelection': '清除选中',
173
+ 'dossiers.askSelected': '请解释我选中的这段总结。',
174
+ 'dossiers.followUpLabel': '追问',
175
+ 'dossiers.thinking': '正在思考…',
176
+ 'dossiers.send': '追问',
152
177
  'dossiers.searching': '搜索中…',
153
178
  'dossiers.stopSearch': '停止搜索',
154
179
  'dossiers.searchCount': '找到 {count} 个',
@@ -218,6 +243,22 @@ window.__ModuleLoader__.load({
218
243
  'reader.next': '下一章',
219
244
  'reader.fontSmaller': '减小字号',
220
245
  'reader.fontLarger': '放大字号',
246
+ 'reader.zoomHint': 'Ctrl / ⌘ + 鼠标滚轮调整字号',
247
+ 'reader.zoomHintShort': '+ 滚轮',
248
+ 'settings.title': '存储与目录',
249
+ 'settings.booksDir': '书籍目录',
250
+ 'settings.papersDir': '论文存放目录',
251
+ 'settings.dossiersDir': '卷宗存放目录',
252
+ 'settings.hint': '留空使用默认路径。修改后返回对应模式重新加载;现有文件不会自动移动。',
253
+ 'settings.choose': '选择路径',
254
+ 'settings.save': '保存',
255
+ 'settings.cancel': '取消',
256
+ 'reader.settings': '阅读设置',
257
+ 'reader.booksDir': '书籍目录',
258
+ 'reader.booksDirHint': '修改后书架会扫描并优先保存到这个目录;留空使用默认目录:{path}',
259
+ 'reader.settingsSaved': '书籍目录已保存,返回书架后生效',
260
+ 'reader.save': '保存',
261
+ 'reader.cancel': '取消',
221
262
  'reader.ai': 'AI 解释',
222
263
  'reader.find': '全书搜索',
223
264
  'reader.findPlaceholder': '搜索全书文字…',
@@ -372,6 +413,19 @@ window.__ModuleLoader__.load({
372
413
  'dossiers.title': 'Dossier library',
373
414
  'dossiers.subtitle': 'Organize dossiers by folder, with multi-select import, full-text search, AI summaries and folder skills.',
374
415
  'dossiers.import': 'Import dossiers',
416
+ 'dossiers.importJudgments': 'Import judgments',
417
+ 'dossiers.filters': 'Filters',
418
+ 'dossiers.extraKeyword': 'Keyword',
419
+ 'dossiers.addKeyword': 'Add keyword',
420
+ 'dossiers.removeKeyword': 'Remove keyword',
421
+ 'dossiers.filterCourt': 'Court',
422
+ 'dossiers.filterCaseNo': 'Case no.',
423
+ 'dossiers.filterParty': 'Party',
424
+ 'dossiers.filterCause': 'Cause',
425
+ 'dossiers.filterFrom': 'From date',
426
+ 'dossiers.filterTo': 'To date',
427
+ 'dossiers.applyFilters': 'Apply filters',
428
+ 'dossiers.clearFilters': 'Clear filters',
375
429
  'dossiers.importing': 'Importing dossiers…',
376
430
  'dossiers.importFailed': 'Dossier import failed',
377
431
  'dossiers.newFolder': 'New dossier folder',
@@ -397,6 +451,19 @@ window.__ModuleLoader__.load({
397
451
  'dossiers.summaryTitle': 'AI dossier summary',
398
452
  'dossiers.summarySearchTitle': 'AI search summary',
399
453
  'dossiers.summarizing': 'Analyzing dossiers…',
454
+ 'dossiers.summaryPreparing': 'Preparing summary…',
455
+ 'dossiers.summaryEmpty': 'AI returned no summary content. Please retry.',
456
+ 'dossiers.retry': 'Retry',
457
+ 'dossiers.followUpPlaceholder': 'Ask a follow-up about the summary…',
458
+ 'dossiers.followUpSelectedPlaceholder': 'Ask about the selected summary text…',
459
+ 'dossiers.followUpHint': 'Type a follow-up, or select summary text with the mouse first.',
460
+ 'dossiers.followUpLocked': 'Follow-up will unlock when the summary finishes…',
461
+ 'dossiers.selectedSummary': 'Selected summary',
462
+ 'dossiers.clearSelection': 'Clear selection',
463
+ 'dossiers.askSelected': 'Explain the selected summary passage.',
464
+ 'dossiers.followUpLabel': 'Follow-up',
465
+ 'dossiers.thinking': 'Thinking…',
466
+ 'dossiers.send': 'Ask',
400
467
  'dossiers.searching': 'Searching…',
401
468
  'dossiers.stopSearch': 'Stop search',
402
469
  'dossiers.searchCount': '{count} found',
@@ -466,6 +533,22 @@ window.__ModuleLoader__.load({
466
533
  'reader.next': 'Next chapter',
467
534
  'reader.fontSmaller': 'Smaller text',
468
535
  'reader.fontLarger': 'Larger text',
536
+ 'reader.zoomHint': 'Ctrl / ⌘ + mouse wheel to resize text',
537
+ 'reader.zoomHintShort': '+ wheel',
538
+ 'settings.title': 'Storage folders',
539
+ 'settings.booksDir': 'Books folder',
540
+ 'settings.papersDir': 'Papers folder',
541
+ 'settings.dossiersDir': 'Dossiers folder',
542
+ 'settings.hint': 'Leave empty for the default path. Reload the matching mode after saving; existing files are not moved.',
543
+ 'settings.choose': 'Choose',
544
+ 'settings.save': 'Save',
545
+ 'settings.cancel': 'Cancel',
546
+ 'reader.settings': 'Reader settings',
547
+ 'reader.booksDir': 'Books folder',
548
+ 'reader.booksDirHint': 'The shelf scans and saves books here. Leave empty for the default: {path}',
549
+ 'reader.settingsSaved': 'Books folder saved; return to the shelf to apply',
550
+ 'reader.save': 'Save',
551
+ 'reader.cancel': 'Cancel',
469
552
  'reader.ai': 'Explain',
470
553
  'reader.find': 'Find in book',
471
554
  'reader.findPlaceholder': 'Search the whole book…',
@@ -580,6 +663,8 @@ window.__ModuleLoader__.load({
580
663
  .epub-library-inner { width: min(100%, 1200px); margin: 0 auto; }
581
664
  .epub-library-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
582
665
  .epub-brand { display: flex; align-items: center; gap: 15px; min-width: 0; }
666
+ .epub-brand-settings { display: grid; place-items: center; width: 34px; height: 34px; flex: none; margin-left: 8px; border: 1px solid var(--epub-line); border-radius: 8px; background: var(--epub-surface); color: var(--epub-muted); }
667
+ .epub-brand-settings:hover { color: var(--epub-text); border-color: var(--epub-line-strong); background: var(--epub-surface-2); }
583
668
  .epub-brand-mark {
584
669
  width: 52px; height: 52px; flex: none; border-radius: 16px;
585
670
  display: grid; place-items: center;
@@ -762,6 +847,18 @@ window.__ModuleLoader__.load({
762
847
  .epub-reader-book-title { font-size: 13.5px; line-height: 1.4; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
763
848
  .epub-reader-book-sub { margin-top: 2px; color: var(--epub-muted); font-size: 10.5px; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
764
849
  .epub-reader-head-right { display: flex; align-items: center; gap: 7px; flex: none; }
850
+ .epub-zoom-hint { display: inline-flex; align-items: center; gap: 2px; color: var(--epub-muted); font-size: 8.5px; white-space: nowrap; }
851
+ .epub-reader-settings { width: min(560px, 100%); padding: 16px; 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); }
852
+ .epub-reader-settings header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
853
+ .epub-reader-settings h3 { margin: 2px 0 0; font-size: 15px; }
854
+ .epub-reader-settings-row { margin-bottom: 9px; }
855
+ .epub-reader-settings-label { display: block; margin-bottom: 5px; color: var(--epub-muted); font-size: 9px; text-transform: uppercase; letter-spacing: .06em; }
856
+ .epub-reader-settings-path { display: flex; align-items: center; gap: 6px; }
857
+ .epub-reader-settings-path .epub-reader-settings-input { flex: 1; min-width: 0; }
858
+ .epub-reader-settings-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; }
859
+ .epub-reader-settings-input:focus { border-color: color-mix(in srgb, var(--epub-accent) 45%, var(--epub-line)); }
860
+ .epub-reader-settings-hint { margin: 7px 0 0; color: var(--epub-muted); font-size: 9px; line-height: 1.55; overflow-wrap: anywhere; }
861
+ .epub-reader-settings-error { margin: 8px 0 0; color: var(--epub-danger); font-size: 9.5px; line-height: 1.5; }
765
862
  .epub-kbd { margin-left: 3px; padding: 1px 4px; border: 1px solid var(--epub-line); border-radius: 4px; color: var(--epub-muted); font-family: inherit; font-size: 8.5px; font-weight: 500; line-height: 1.4; }
766
863
  .epub-find-panel { position: absolute; top: 68px; right: 18px; z-index: 45; display: flex; flex-direction: column; width: min(430px, calc(100% - 36px)); max-height: min(520px, calc(100% - 90px)); border: 1px solid var(--epub-line-strong); border-radius: 12px; background: var(--epub-surface); box-shadow: 0 18px 45px -18px rgba(0,0,0,.55); overflow: hidden; }
767
864
  .epub-find-head { display: flex; align-items: center; gap: 8px; padding: 10px; border-bottom: 1px solid var(--epub-line); }
@@ -1003,8 +1100,16 @@ window.__ModuleLoader__.load({
1003
1100
  .epub-dialog-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 12px; }
1004
1101
  .epub-dossier-actions { display: flex; align-items: center; gap: 7px; margin-left: auto; }
1005
1102
  .epub-dossier-summary-btn { height: 30px; padding: 0 8px; font-size: 10px; }
1103
+ .epub-dossier-search-group { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; flex: 1; min-width: 0; }
1104
+ .epub-dossier-search-group .epub-dossier-search { flex: 1 1 170px; }
1006
1105
  .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); }
1007
1106
  .epub-dossier-search input { width: 190px; border: 0; outline: 0; background: transparent; color: var(--epub-text); font-size: 10px; }
1107
+ .epub-dossier-filters { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 6px; margin: 0 0 10px; padding: 9px; border: 1px solid var(--epub-line); border-radius: 9px; background: var(--epub-surface); }
1108
+ .epub-dossier-filter-field { display: flex; flex-direction: column; gap: 3px; min-width: 0; color: var(--epub-muted); font-size: 8.5px; }
1109
+ .epub-dossier-filter-field input { width: 100%; height: 28px; padding: 0 7px; border: 1px solid var(--epub-line); border-radius: 6px; outline: 0; background: var(--dsw-alias-bg-base); color: var(--epub-text); font-size: 9.5px; }
1110
+ .epub-dossier-filter-actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; gap: 6px; }
1111
+ .epub-dossier-meta-tags { display: flex; flex-wrap: wrap; gap: 3px; margin: 0 0 6px; }
1112
+ .epub-dossier-meta-tags span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 1px 4px; border-radius: 3px; background: var(--epub-surface-2); color: var(--epub-muted); font-size: 8px; }
1008
1113
  .epub-dossier-hint { margin: 0 0 10px; color: var(--epub-muted); font-size: 9px; line-height: 1.5; }
1009
1114
  .epub-dossier-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 9px; }
1010
1115
  .epub-dossier-card { padding: 12px; border: 1px solid var(--epub-line); border-radius: 10px; background: var(--epub-surface); cursor: pointer; min-width: 0; }
@@ -1040,7 +1145,25 @@ window.__ModuleLoader__.load({
1040
1145
  .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); }
1041
1146
  .epub-dossier-summary h3 { margin: 2px 0 0; font-size: 15px; }
1042
1147
  .epub-dossier-summary-actions { display: flex; align-items: center; gap: 5px; }
1148
+ .epub-dossier-summary-status { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; color: var(--epub-muted); font-family: var(--epub-sans, inherit); font-size: 9.5px; line-height: 1.5; }
1043
1149
  .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; }
1150
+ .epub-dossier-turn { margin-top: 12px; padding: 8px 10px; border-radius: 8px; white-space: pre-wrap; font-family: var(--epub-sans, inherit); font-size: 11px; line-height: 1.65; }
1151
+ .epub-dossier-turn.is-user { background: var(--epub-surface-2); color: var(--epub-text); }
1152
+ .epub-dossier-turn.is-assistant { background: color-mix(in srgb, var(--epub-accent) 5%, var(--epub-surface)); color: var(--epub-text); }
1153
+ .epub-dossier-turn-label { display: block; margin-bottom: 3px; color: var(--epub-muted); font-size: 8.5px; text-transform: uppercase; letter-spacing: .05em; }
1154
+ .epub-dossier-followup { flex: none; padding: 9px 14px 12px; border-top: 1px solid var(--epub-line); background: var(--epub-surface); }
1155
+ .epub-dossier-followup-row { display: flex; align-items: center; gap: 6px; }
1156
+ .epub-dossier-followup-row input { flex: 1; min-width: 0; height: 32px; 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: 10.5px; }
1157
+ .epub-dossier-followup-row input:focus { border-color: color-mix(in srgb, var(--epub-accent) 45%, var(--epub-line)); }
1158
+ .epub-dossier-followup-row .epub-btn { height: 32px; font-size: 10px; }
1159
+ .epub-dossier-followup-locked { display: flex; align-items: center; gap: 7px; color: var(--epub-muted); font-size: 9.5px; }
1160
+ .epub-dossier-followup-hint { margin: 5px 0 0; color: var(--epub-muted); font-size: 8.5px; line-height: 1.45; }
1161
+ .epub-dossier-selection-chip { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; padding: 4px 6px; border-radius: 6px; background: color-mix(in srgb, var(--epub-accent) 8%, transparent); color: var(--epub-muted); font-size: 8.5px; line-height: 1.4; }
1162
+ .epub-dossier-selection-chip span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1163
+ .epub-dossier-selection-chip button { border: 0; background: transparent; color: var(--epub-muted); font-size: 13px; line-height: 1; }
1164
+ .epub-dossier-summary-error-block { display: flex; align-items: center; gap: 8px; margin: 8px 16px 0; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--epub-danger) 28%, var(--epub-line)); border-radius: 7px; background: color-mix(in srgb, var(--epub-danger) 5%, transparent); }
1165
+ .epub-dossier-summary-error-block p { flex: 1; margin: 0; color: var(--epub-danger); font-size: 9.5px; line-height: 1.5; }
1166
+ .epub-dossier-summary-error-block .epub-btn { flex: none; height: 27px; font-size: 9.5px; }
1044
1167
  .epub-dossier-summary-error { margin: 0; padding: 10px 16px 0; color: var(--epub-danger); font-size: 10px; }
1045
1168
  .epub-paper-compare { flex: 1; min-height: 0; display: flex; flex-direction: column; position: relative; }
1046
1169
  .epub-paper-body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); }
@@ -1091,7 +1214,9 @@ window.__ModuleLoader__.load({
1091
1214
  .epub-dossier-search { flex: 1; }
1092
1215
  .epub-dossier-search input { width: 100%; min-width: 0; }
1093
1216
  .epub-dossier-grid { grid-template-columns: 1fr; }
1217
+ .epub-dossier-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
1094
1218
  .epub-dossier-summary { max-height: 88vh; }
1219
+ .epub-zoom-hint { display: none; }
1095
1220
  .epub-paper-folders { display: flex; gap: 4px; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--epub-line); }
1096
1221
  .epub-paper-folder-wrap { flex: none; }
1097
1222
  .epub-paper-folder-top { flex: none; }
@@ -1680,6 +1805,13 @@ window.__ModuleLoader__.load({
1680
1805
  return iconBase(props ?? {}, [h('path', { key: 'a', d: 'M12 5v14M5 12h14' })])
1681
1806
  }
1682
1807
 
1808
+ function IconSettings(props) {
1809
+ return iconBase(props ?? {}, [
1810
+ h('circle', { key: 'a', cx: 12, cy: 12, r: 3.2 }),
1811
+ h('path', { key: 'b', d: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.51a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z' }),
1812
+ ])
1813
+ }
1814
+
1683
1815
  function IconStopCircle(props) {
1684
1816
  return iconBase(props ?? {}, [
1685
1817
  h('circle', { key: 'a', cx: 12, cy: 12, r: 8.5 }),
@@ -1903,6 +2035,12 @@ window.__ModuleLoader__.load({
1903
2035
  const [dragOver, setDragOver] = React.useState(false)
1904
2036
  const [showHidden, setShowHidden] = React.useState(false)
1905
2037
  const [bookMenu, setBookMenu] = React.useState(null)
2038
+ const [settingsOpen, setSettingsOpen] = React.useState(false)
2039
+ const [settings, setSettings] = React.useState({ booksDir: '', papersDir: '', dossiersDir: '' })
2040
+ const [settingsDefaults, setSettingsDefaults] = React.useState({ booksDir: '', papersDir: '', dossiersDir: '' })
2041
+ const [settingsSaving, setSettingsSaving] = React.useState(false)
2042
+ const [settingsPicking, setSettingsPicking] = React.useState('')
2043
+ const [settingsError, setSettingsError] = React.useState('')
1906
2044
  const fileRef = React.useRef(null)
1907
2045
 
1908
2046
  React.useEffect(() => {
@@ -2008,6 +2146,60 @@ window.__ModuleLoader__.load({
2008
2146
  await importFiles([file])
2009
2147
  }
2010
2148
 
2149
+ async function openSettings() {
2150
+ setSettingsOpen(true)
2151
+ setSettingsError('')
2152
+ try {
2153
+ const data = await apiGet('/settings')
2154
+ setSettings({
2155
+ booksDir: data.booksDir ?? '',
2156
+ papersDir: data.papersDir ?? '',
2157
+ dossiersDir: data.dossiersDir ?? '',
2158
+ })
2159
+ setSettingsDefaults({
2160
+ booksDir: data.defaultDir ?? '',
2161
+ papersDir: data.defaultPapersDir ?? '',
2162
+ dossiersDir: data.defaultDossiersDir ?? '',
2163
+ })
2164
+ } catch (reason) {
2165
+ setSettingsError(reason instanceof Error ? reason.message : String(reason))
2166
+ }
2167
+ }
2168
+
2169
+ async function chooseSettingsPath(field) {
2170
+ setSettingsPicking(field)
2171
+ setSettingsError('')
2172
+ try {
2173
+ const data = await apiPost('/settings/pick', {})
2174
+ if (typeof data.path === 'string' && data.path !== '') {
2175
+ setSettings((current) => ({ ...current, [field]: data.path }))
2176
+ }
2177
+ } catch (reason) {
2178
+ setSettingsError(reason instanceof Error ? reason.message : String(reason))
2179
+ } finally {
2180
+ setSettingsPicking('')
2181
+ }
2182
+ }
2183
+
2184
+ async function saveSettings() {
2185
+ setSettingsSaving(true)
2186
+ setSettingsError('')
2187
+ try {
2188
+ const data = await apiPost('/settings', settings)
2189
+ setSettings({
2190
+ booksDir: data.booksDir ?? '',
2191
+ papersDir: data.papersDir ?? '',
2192
+ dossiersDir: data.dossiersDir ?? '',
2193
+ })
2194
+ setSettingsOpen(false)
2195
+ await load()
2196
+ } catch (reason) {
2197
+ setSettingsError(reason instanceof Error ? reason.message : String(reason))
2198
+ } finally {
2199
+ setSettingsSaving(false)
2200
+ }
2201
+ }
2202
+
2011
2203
  function saveSourcePrefs(next) {
2012
2204
  setSourcePrefs(next)
2013
2205
  writeSourcePrefs(next)
@@ -2175,6 +2367,7 @@ window.__ModuleLoader__.load({
2175
2367
  h('p', { className: 'epub-subtitle' }, t('library.subtitle')),
2176
2368
  h('p', { className: 'epub-formats' }, t('library.supportedFormats')),
2177
2369
  ),
2370
+ h('button', { type: 'button', className: 'epub-brand-settings', onClick: () => void openSettings(), title: t('settings.title'), 'aria-label': t('settings.title') }, h(IconSettings, { size: 20 })),
2178
2371
  ),
2179
2372
  h(
2180
2373
  'div',
@@ -2466,7 +2659,32 @@ window.__ModuleLoader__.load({
2466
2659
  })),
2467
2660
  ),
2468
2661
  ),
2469
- bookMenu !== null
2662
+ settingsOpen
2663
+ ? h('div', { className: 'epub-dialog-backdrop', onMouseDown: (event) => { if (event.target === event.currentTarget) setSettingsOpen(false) } },
2664
+ h('section', { className: 'epub-reader-settings' },
2665
+ h('header', null,
2666
+ h('div', null, h('p', { className: 'epub-eyebrow' }, 'STORAGE'), h('h3', null, t('settings.title'))),
2667
+ h('button', { type: 'button', className: 'epub-icon-btn', onClick: () => setSettingsOpen(false), title: t('reader.close') }, h(IconX, { size: 16 })),
2668
+ ),
2669
+ [['booksDir', t('settings.booksDir'), settingsDefaults.booksDir], ['papersDir', t('settings.papersDir'), settingsDefaults.papersDir], ['dossiersDir', t('settings.dossiersDir'), settingsDefaults.dossiersDir]].map(([field, label, placeholder]) =>
2670
+ h('div', { className: 'epub-reader-settings-row', key: field },
2671
+ h('label', { className: 'epub-reader-settings-label' }, label),
2672
+ h('div', { className: 'epub-reader-settings-path' },
2673
+ h('input', { className: 'epub-reader-settings-input', value: settings[field], placeholder, onChange: (event) => setSettings({ ...settings, [field]: event.target.value }) }),
2674
+ h('button', { type: 'button', className: 'epub-btn', disabled: settingsPicking !== '', onClick: () => void chooseSettingsPath(field) }, settingsPicking === field ? '…' : t('settings.choose')),
2675
+ ),
2676
+ ),
2677
+ ),
2678
+ h('p', { className: 'epub-reader-settings-hint' }, t('settings.hint')),
2679
+ settingsError !== '' ? h('p', { className: 'epub-reader-settings-error' }, settingsError) : null,
2680
+ h('div', { className: 'epub-dialog-actions' },
2681
+ h('button', { type: 'button', className: 'epub-btn', onClick: () => setSettingsOpen(false) }, t('settings.cancel')),
2682
+ h('button', { type: 'button', className: 'epub-btn epub-btn-primary', onClick: () => void saveSettings(), disabled: settingsSaving }, settingsSaving ? '…' : t('settings.save')),
2683
+ ),
2684
+ ),
2685
+ )
2686
+ : null,
2687
+ bookMenu !== null
2470
2688
  ? h('div', { className: 'epub-context-menu', style: { left: `${bookMenu.x}px`, top: `${bookMenu.y}px` }, onClick: (event) => event.stopPropagation() },
2471
2689
  h('button', { type: 'button', className: 'epub-context-item', onClick: () => { const selected = bookMenu.book; setBookMenu(null); void toggleHidden(selected) } }, bookMenu.book.hidden === true ? t('library.unhide') : t('library.hide')),
2472
2690
  h('button', {
@@ -3045,7 +3263,8 @@ window.__ModuleLoader__.load({
3045
3263
  const [state, setState] = React.useState({ status: 'loading', folders: [], items: [], error: '' })
3046
3264
  const [activeFolder, setActiveFolder] = React.useState('all')
3047
3265
  const [query, setQuery] = React.useState('')
3048
- const [search, setSearch] = React.useState({ busy: false, results: [], error: '', query: '' })
3266
+ const [search, setSearch] = React.useState({ busy: false, results: [], error: '', query: '', active: false })
3267
+ const [extraKeywords, setExtraKeywords] = React.useState([])
3049
3268
  const [importing, setImporting] = React.useState(false)
3050
3269
  const [error, setError] = React.useState('')
3051
3270
  const [dialog, setDialog] = React.useState(null)
@@ -3054,9 +3273,10 @@ window.__ModuleLoader__.load({
3054
3273
  const [expandedFolders, setExpandedFolders] = React.useState(() => new Set())
3055
3274
  const [clearConfirm, setClearConfirm] = React.useState(null)
3056
3275
  const [skillEditor, setSkillEditor] = React.useState(null)
3057
- const [summary, setSummary] = React.useState({ open: false, status: 'idle', text: '', title: '', error: '' })
3276
+ const [summary, setSummary] = React.useState({ open: false, status: 'idle', busy: false, text: '', title: '', error: '', statusText: '', turns: [], question: '', selection: '', askStatus: 'idle', lastOptions: null })
3058
3277
  const fileRef = React.useRef(null)
3059
3278
  const summaryAbort = React.useRef(null)
3279
+ const summaryInputRef = React.useRef(null)
3060
3280
  const searchAbort = React.useRef(null)
3061
3281
 
3062
3282
  const load = React.useCallback(async () => {
@@ -3097,7 +3317,8 @@ window.__ModuleLoader__.load({
3097
3317
  const folder = state.folders.find((entry) => entry.id === activeFolder)
3098
3318
  const folderLabel = activeFolder === 'all' ? t('dossiers.all') : activeFolder === 'root' ? t('dossiers.root') : folder?.name ?? t('dossiers.title')
3099
3319
  const documents = state.items.filter((item) => item.kind === 'document' && (activeFolder === 'all' || item.folderId === folderId))
3100
- const shown = search.query !== '' ? search.results : documents
3320
+ const shown = search.active ? search.results : documents
3321
+ const summaryLocked = summary.busy || summary.status === 'streaming' || summary.askStatus === 'streaming'
3101
3322
 
3102
3323
  function skillsFor(id) {
3103
3324
  return state.items.filter((item) => item.kind === 'skill' && item.folderId === id)
@@ -3202,14 +3423,16 @@ window.__ModuleLoader__.load({
3202
3423
 
3203
3424
  async function runSearch(event) {
3204
3425
  if (event !== undefined) event.preventDefault()
3205
- const value = query.trim()
3206
- if (value === '') { stopSearch(); setSearch({ busy: false, results: [], error: '', query: '' }); return }
3426
+ const keywords = [query, ...extraKeywords].map((entry) => entry.trim()).filter((entry) => entry !== '')
3427
+ if (keywords.length === 0) { stopSearch(); setSearch({ busy: false, results: [], error: '', query: '', active: false }); return }
3207
3428
  searchAbort.current?.abort()
3208
3429
  const controller = new AbortController()
3209
3430
  searchAbort.current = controller
3210
- setSearch({ busy: true, results: [], error: '', query: value })
3431
+ setSearch({ busy: true, results: [], error: '', query: keywords.join(' '), active: true })
3432
+ const params = new URLSearchParams({ folder: folderId })
3433
+ for (const keyword of keywords) params.append('q', keyword)
3211
3434
  try {
3212
- await apiStreamSearch(`/dossiers/search/stream?folder=${encodeURIComponent(folderId)}&q=${encodeURIComponent(value)}`, {
3435
+ await apiStreamSearch(`/dossiers/search/stream?${params.toString()}`, {
3213
3436
  signal: controller.signal,
3214
3437
  onEvent: (streamEvent) => {
3215
3438
  if (streamEvent.type === 'match' && streamEvent.match !== undefined) {
@@ -3266,14 +3489,17 @@ window.__ModuleLoader__.load({
3266
3489
  function stopSummary() {
3267
3490
  summaryAbort.current?.abort()
3268
3491
  summaryAbort.current = null
3269
- setSummary((current) => ({ ...current, status: current.status === 'streaming' ? 'idle' : current.status }))
3492
+ setSummary((current) => {
3493
+ const turns = current.turns.map((turn, index) => index === current.turns.length - 1 && turn.role === 'assistant' && turn.status === 'streaming' ? { ...turn, status: 'idle' } : turn)
3494
+ return { ...current, turns, busy: false, status: current.status === 'streaming' ? 'idle' : current.status, askStatus: current.askStatus === 'streaming' ? 'idle' : current.askStatus, statusText: '' }
3495
+ })
3270
3496
  }
3271
3497
 
3272
3498
  async function startSummary(options = {}) {
3273
3499
  const controller = new AbortController()
3274
3500
  summaryAbort.current?.abort()
3275
3501
  summaryAbort.current = controller
3276
- setSummary({ open: true, status: 'streaming', text: '', title: options.title ?? t('dossiers.summaryTitle'), error: '' })
3502
+ setSummary({ open: true, status: 'streaming', busy: true, text: '', title: options.title ?? t('dossiers.summaryTitle'), error: '', statusText: t('dossiers.summaryPreparing'), turns: [], question: '', selection: '', askStatus: 'idle', lastOptions: options })
3277
3503
  try {
3278
3504
  await apiStreamJson('/dossiers/summary', {
3279
3505
  folderId,
@@ -3282,25 +3508,113 @@ window.__ModuleLoader__.load({
3282
3508
  }, {
3283
3509
  signal: controller.signal,
3284
3510
  onEvent: (event) => {
3285
- if (event.type === 'delta' && typeof event.text === 'string') {
3511
+ if (event.type === 'status' && typeof event.text === 'string') {
3512
+ setSummary((current) => ({ ...current, statusText: event.text }))
3513
+ } else if (event.type === 'delta' && typeof event.text === 'string') {
3286
3514
  setSummary((current) => ({ ...current, text: current.text + event.text }))
3287
3515
  } else if (event.type === 'done') {
3288
- setSummary((current) => ({ ...current, status: 'done', text: event.text ?? current.text }))
3516
+ setSummary((current) => {
3517
+ const text = event.text ?? current.text
3518
+ if (String(text).trim() === '') return { ...current, status: 'error', statusText: '', error: t('dossiers.summaryEmpty') }
3519
+ return { ...current, status: 'done', statusText: '', text }
3520
+ })
3289
3521
  } else if (event.type === 'error') {
3290
- setSummary((current) => ({ ...current, status: 'error', error: String(event.error ?? 'AI 总结失败') }))
3522
+ setSummary((current) => ({ ...current, status: 'error', statusText: '', error: String(event.error ?? 'AI 总结失败') }))
3291
3523
  }
3292
3524
  },
3293
3525
  })
3294
3526
  } catch (reason) {
3295
3527
  if (controller.signal.aborted) return
3296
- setSummary((current) => ({ ...current, status: 'error', error: reason instanceof Error ? reason.message : String(reason) }))
3528
+ setSummary((current) => ({ ...current, status: 'error', statusText: '', error: reason instanceof Error ? reason.message : String(reason) }))
3529
+ } finally {
3530
+ summaryAbort.current = null
3531
+ setSummary((current) => ({ ...current, busy: false }))
3297
3532
  }
3298
3533
  }
3299
3534
 
3535
+ function captureSummarySelection(event) {
3536
+ if (summaryLocked) return
3537
+ const selection = window.getSelection()
3538
+ const text = selection === null ? '' : selection.toString().trim()
3539
+ if (text === '') return
3540
+ const anchor = selection.anchorNode
3541
+ if (anchor !== null && anchor !== undefined && !event.currentTarget.contains(anchor)) return
3542
+ setSummary((current) => ({ ...current, selection: text }))
3543
+ window.requestAnimationFrame(() => summaryInputRef.current?.focus())
3544
+ }
3545
+
3546
+ async function askSummary() {
3547
+ if (summaryLocked) return
3548
+ const question = summary.question.trim()
3549
+ const selected = summary.selection.trim()
3550
+ if (question === '' && selected === '') return
3551
+ const text = question === '' ? t('dossiers.askSelected') : question
3552
+ const history = summary.turns
3553
+ .filter((turn) => (turn.role === 'user' || turn.role === 'assistant') && turn.text.trim() !== '')
3554
+ .slice(-10)
3555
+ const controller = new AbortController()
3556
+ summaryAbort.current?.abort()
3557
+ summaryAbort.current = controller
3558
+ setSummary((current) => ({
3559
+ ...current,
3560
+ turns: [...current.turns, { role: 'user', text }, { role: 'assistant', text: '', status: 'streaming' }],
3561
+ question: '',
3562
+ selection: '',
3563
+ askStatus: 'streaming',
3564
+ statusText: t('dossiers.thinking'),
3565
+ error: '',
3566
+ }))
3567
+ try {
3568
+ await apiStreamJson('/dossiers/ask', {
3569
+ folderId,
3570
+ summary: summary.text,
3571
+ selection: selected,
3572
+ question,
3573
+ history,
3574
+ }, {
3575
+ signal: controller.signal,
3576
+ onEvent: (event) => {
3577
+ if (event.type === 'status' && typeof event.text === 'string') {
3578
+ setSummary((current) => ({ ...current, statusText: event.text }))
3579
+ } else if (event.type === 'delta' && typeof event.text === 'string') {
3580
+ setSummary((current) => {
3581
+ const turns = [...current.turns]
3582
+ const last = turns[turns.length - 1]
3583
+ if (last !== undefined && last.role === 'assistant') turns[turns.length - 1] = { ...last, text: last.text + event.text }
3584
+ return { ...current, turns }
3585
+ })
3586
+ } else if (event.type === 'done') {
3587
+ setSummary((current) => {
3588
+ const turns = [...current.turns]
3589
+ const last = turns[turns.length - 1]
3590
+ if (last !== undefined && last.role === 'assistant') turns[turns.length - 1] = { ...last, text: event.text ?? last.text, status: 'done' }
3591
+ return { ...current, turns, askStatus: 'done', statusText: '' }
3592
+ })
3593
+ } else if (event.type === 'error') {
3594
+ setSummary((current) => {
3595
+ const turns = current.turns.map((turn, index) => index === current.turns.length - 1 && turn.role === 'assistant' ? { ...turn, status: 'error' } : turn)
3596
+ return { ...current, turns, askStatus: 'error', statusText: '', error: String(event.error ?? 'AI 追问失败') }
3597
+ })
3598
+ }
3599
+ },
3600
+ })
3601
+ } catch (reason) {
3602
+ if (controller.signal.aborted) return
3603
+ setSummary((current) => {
3604
+ const turns = current.turns.map((turn, index) => index === current.turns.length - 1 && turn.role === 'assistant' ? { ...turn, status: 'error' } : turn)
3605
+ return { ...current, turns, askStatus: 'error', statusText: '', error: reason instanceof Error ? reason.message : String(reason) }
3606
+ })
3607
+ }
3608
+ }
3609
+
3610
+ function retrySummary() {
3611
+ void startSummary(summary.lastOptions ?? {})
3612
+ }
3613
+
3300
3614
  function closeSummary() {
3301
3615
  summaryAbort.current?.abort()
3302
3616
  summaryAbort.current = null
3303
- setSummary({ open: false, status: 'idle', text: '', title: '', error: '' })
3617
+ setSummary({ open: false, status: 'idle', busy: false, text: '', title: '', error: '', statusText: '', turns: [], question: '', selection: '', askStatus: 'idle', lastOptions: null })
3304
3618
  }
3305
3619
 
3306
3620
  async function clearFolder() {
@@ -3309,7 +3623,7 @@ window.__ModuleLoader__.load({
3309
3623
  await apiPost('/dossiers/clear', { folderId: clearConfirm.scope })
3310
3624
  setClearConfirm(null)
3311
3625
  stopSearch()
3312
- setSearch({ busy: false, results: [], error: '', query: '' })
3626
+ setSearch({ busy: false, results: [], error: '', query: '', active: false })
3313
3627
  setQuery('')
3314
3628
  await load()
3315
3629
  } catch (reason) {
@@ -3318,7 +3632,7 @@ window.__ModuleLoader__.load({
3318
3632
  }
3319
3633
 
3320
3634
  function summaryScopeIds() {
3321
- if (search.query !== '' && search.results.length > 0) return search.results.map((entry) => entry.id)
3635
+ if (search.active && search.results.length > 0) return search.results.map((entry) => entry.id)
3322
3636
  return []
3323
3637
  }
3324
3638
 
@@ -3341,13 +3655,13 @@ window.__ModuleLoader__.load({
3341
3655
  h('div', { className: 'epub-papers-body' },
3342
3656
  h('aside', { className: 'epub-paper-folders' },
3343
3657
  h('div', { className: 'epub-paper-folder-top' },
3344
- 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))),
3658
+ h('button', { type: 'button', className: cn('epub-paper-folder', activeFolder === 'all' && 'is-active'), onClick: () => { setActiveFolder('all'); setSearch({ busy: false, results: [], error: '', query: '', active: false }); setQuery(''); setExtraKeywords([]) } }, t('dossiers.all'), h('span', null, String(state.items.filter((item) => item.kind === 'document').length))),
3345
3659
  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 })),
3346
3660
  ),
3347
3661
  h('div', { className: 'epub-dossier-folder-group', onContextMenu: (event) => openContext(event, 'root') },
3348
3662
  h('div', { className: cn('epub-paper-folder-wrap', activeFolder === 'root' && 'is-active') },
3349
3663
  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') ? '▾' : '▸'),
3350
- 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))),
3664
+ h('button', { type: 'button', className: cn('epub-paper-folder', activeFolder === 'root' && 'is-active'), onClick: () => { setActiveFolder('root'); setSearch({ busy: false, results: [], error: '', query: '', active: false }); setQuery(''); setExtraKeywords([]) } }, t('dossiers.root'), h('span', null, String(state.items.filter((item) => item.kind === 'document' && item.folderId === '').length))),
3351
3665
  h('button', { type: 'button', className: 'epub-paper-folder-rename', onClick: () => void createSkill(''), title: t('dossiers.createSkill') }, '+'),
3352
3666
  ),
3353
3667
  expandedFolders.has('root') ? renderFolderSkills('') : null,
@@ -3356,7 +3670,7 @@ window.__ModuleLoader__.load({
3356
3670
  h('div', { key: entry.id, className: 'epub-dossier-folder-group', onContextMenu: (event) => openContext(event, 'folder', { folderId: entry.id }) },
3357
3671
  h('div', { className: cn('epub-paper-folder-wrap', activeFolder === entry.id && 'is-active') },
3358
3672
  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) ? '▾' : '▸'),
3359
- 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))),
3673
+ h('button', { type: 'button', className: cn('epub-paper-folder', activeFolder === entry.id && 'is-active'), onClick: () => { setActiveFolder(entry.id); setSearch({ busy: false, results: [], error: '', query: '', active: false }); setQuery(''); setExtraKeywords([]) } }, entry.name, h('span', null, String(state.items.filter((item) => item.kind === 'document' && item.folderId === entry.id).length))),
3360
3674
  h('button', { type: 'button', className: 'epub-paper-folder-rename', onClick: () => openDialog('folderRename', entry.name, entry.id), title: t('dossiers.renameFolder') }, '✎'),
3361
3675
  h('button', { type: 'button', className: 'epub-paper-folder-rename', onClick: () => void createSkill(entry.id), title: t('dossiers.createSkill') }, '+'),
3362
3676
  ),
@@ -3368,16 +3682,26 @@ window.__ModuleLoader__.load({
3368
3682
  h('div', { className: 'epub-paper-main-head' },
3369
3683
  h('h2', null, folderLabel),
3370
3684
  h('div', { className: 'epub-dossier-actions' },
3371
- h('form', { className: 'epub-dossier-search', onSubmit: runSearch },
3372
- h(IconSearch, { size: 14 }),
3373
- h('input', { value: query, onChange: (event) => setQuery(event.target.value), placeholder: t('dossiers.searchPlaceholder'), 'aria-label': t('dossiers.searchPlaceholder'), type: 'search' }),
3685
+ h('form', { className: 'epub-dossier-search-group', onSubmit: runSearch },
3686
+ h('div', { className: 'epub-dossier-search' },
3687
+ h(IconSearch, { size: 14 }),
3688
+ h('input', { value: query, onChange: (event) => setQuery(event.target.value), placeholder: t('dossiers.searchPlaceholder'), 'aria-label': t('dossiers.searchPlaceholder'), type: 'search' }),
3689
+ ),
3690
+ extraKeywords.map((value, index) =>
3691
+ h('div', { className: 'epub-dossier-search', key: `keyword-${index}` },
3692
+ h(IconSearch, { size: 14 }),
3693
+ h('input', { value, onChange: (event) => setExtraKeywords((current) => current.map((entry, i) => i === index ? event.target.value : entry)), placeholder: t('dossiers.extraKeyword'), 'aria-label': t('dossiers.extraKeyword'), type: 'search' }),
3694
+ h('button', { type: 'button', className: 'epub-icon-btn', onClick: () => setExtraKeywords((current) => current.filter((_, i) => i !== index)), title: t('dossiers.removeKeyword') }, h(IconX, { size: 12 })),
3695
+ ),
3696
+ ),
3697
+ h('button', { type: 'button', className: 'epub-icon-btn', onClick: () => setExtraKeywords((current) => [...current, '']), title: t('dossiers.addKeyword') }, h(IconPlus, { size: 14 })),
3374
3698
  ),
3375
3699
  search.busy
3376
3700
  ? h('button', { type: 'button', className: 'epub-icon-btn epub-dossier-stop', onClick: stopSearch, title: t('dossiers.stopSearch'), 'aria-label': t('dossiers.stopSearch') }, h(IconStopCircle, { size: 16 }))
3377
- : search.query !== ''
3701
+ : search.active
3378
3702
  ? h('span', { className: 'epub-dossier-search-count' }, format(t('dossiers.searchCount'), { count: search.results.length }))
3379
3703
  : null,
3380
- h('button', { type: 'button', className: 'epub-btn epub-btn-primary epub-dossier-summary-btn', onClick: () => void startSummary({ ids: summaryScopeIds(), query: search.query, title: search.query === '' ? t('dossiers.summaryTitle') : t('dossiers.summarySearchTitle') }), disabled: search.busy || (documents.length === 0 && search.results.length === 0) }, h(IconSparkles, { size: 12 }), t('dossiers.summary')),
3704
+ h('button', { type: 'button', className: 'epub-btn epub-btn-primary epub-dossier-summary-btn', onClick: () => void startSummary({ ids: summaryScopeIds(), query: search.active ? search.query : '', title: search.active ? t('dossiers.summarySearchTitle') : t('dossiers.summaryTitle') }), disabled: search.busy || (documents.length === 0 && search.results.length === 0) }, h(IconSparkles, { size: 12 }), t('dossiers.summary')),
3381
3705
  ),
3382
3706
  ),
3383
3707
  h('div', { className: 'epub-dossier-hint' }, t('dossiers.skillHint')),
@@ -3392,7 +3716,7 @@ window.__ModuleLoader__.load({
3392
3716
  : search.busy && shown.length === 0
3393
3717
  ? h('div', { className: 'epub-paper-empty' }, h(Spinner, null))
3394
3718
  : shown.length === 0 && !importing
3395
- ? 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')))
3719
+ ? h('div', { className: 'epub-paper-empty' }, h(IconFolderPlus, { size: 30 }), h('h3', null, search.active ? t('dossiers.searchEmpty') : t('dossiers.empty')), h('p', null, search.active ? t('dossiers.searchEmptyHint') : t('dossiers.emptyHint')))
3396
3720
  : shown.length > 0
3397
3721
  ? h('div', { className: 'epub-dossier-grid' },
3398
3722
  shown.map((item) => h('article', {
@@ -3403,7 +3727,7 @@ window.__ModuleLoader__.load({
3403
3727
  },
3404
3728
  h('div', { className: 'epub-dossier-type' }, (item.format || 'DOC').toUpperCase()),
3405
3729
  h('h3', null, item.title),
3406
- h('p', null, search.query !== '' && item.snippet !== undefined ? item.snippet : (item.preview || t('dossiers.openHint'))),
3730
+ h('p', null, search.active && item.snippet !== undefined ? item.snippet : (item.preview || t('dossiers.openHint'))),
3407
3731
  h('div', { className: 'epub-dossier-meta' }, h('span', null, formatBytes(item.size ?? 0)), h('span', null, t('dossiers.open'))),
3408
3732
  )),
3409
3733
  )
@@ -3514,12 +3838,46 @@ window.__ModuleLoader__.load({
3514
3838
  h('header', null,
3515
3839
  h('div', null, h('p', { className: 'epub-eyebrow' }, 'AI DOSSIER'), h('h3', null, summary.title)),
3516
3840
  h('div', { className: 'epub-dossier-summary-actions' },
3517
- summary.status === 'streaming' ? h('button', { type: 'button', className: 'epub-btn', onClick: stopSummary }, t('dossiers.stop')) : null,
3841
+ summaryLocked ? h('button', { type: 'button', className: 'epub-btn', onClick: stopSummary }, t('dossiers.stop')) : null,
3518
3842
  h('button', { type: 'button', className: 'epub-icon-btn', onClick: closeSummary, title: t('papers.close') }, h(IconX, { size: 16 })),
3519
3843
  ),
3520
3844
  ),
3521
- summary.error !== '' ? h('p', { className: 'epub-dossier-summary-error' }, summary.error) : null,
3522
- h('div', { className: 'epub-dossier-summary-body' }, summary.status === 'streaming' && summary.text === '' ? h(Spinner, null) : summary.text),
3845
+ summary.error !== ''
3846
+ ? h('div', { className: 'epub-dossier-summary-error-block' },
3847
+ h('p', null, summary.error),
3848
+ h('button', { type: 'button', className: 'epub-btn', onClick: retrySummary }, t('dossiers.retry')),
3849
+ )
3850
+ : null,
3851
+ h('div', { className: 'epub-dossier-summary-body', onMouseUp: captureSummarySelection },
3852
+ summary.statusText !== '' ? h('div', { className: 'epub-dossier-summary-status' }, h(Spinner, null), h('span', null, summary.statusText)) : null,
3853
+ summary.status === 'streaming' && summary.text === '' ? null : summary.text,
3854
+ summary.turns.map((turn, index) => h('div', { key: `turn-${index}`, className: cn('epub-dossier-turn', turn.role === 'user' ? 'is-user' : 'is-assistant') },
3855
+ h('span', { className: 'epub-dossier-turn-label' }, turn.role === 'user' ? t('dossiers.followUpLabel') : 'AI'),
3856
+ h('div', null, turn.text === '' && turn.status === 'streaming' ? h(Spinner, null) : turn.text),
3857
+ )),
3858
+ ),
3859
+ summaryLocked
3860
+ ? h('div', { className: 'epub-dossier-followup epub-dossier-followup-locked' }, h(Spinner, null), h('span', null, t('dossiers.followUpLocked')))
3861
+ : h('div', { className: 'epub-dossier-followup' },
3862
+ summary.selection !== ''
3863
+ ? h('div', { className: 'epub-dossier-selection-chip' },
3864
+ h('span', null, `${t('dossiers.selectedSummary')}:${summary.selection.slice(0, 80)}${summary.selection.length > 80 ? '…' : ''}`),
3865
+ h('button', { type: 'button', onClick: () => setSummary((current) => ({ ...current, selection: '' })), title: t('dossiers.clearSelection') }, '×'),
3866
+ )
3867
+ : null,
3868
+ h('div', { className: 'epub-dossier-followup-row' },
3869
+ h('input', {
3870
+ ref: summaryInputRef,
3871
+ value: summary.question,
3872
+ onChange: (event) => setSummary((current) => ({ ...current, question: event.target.value })),
3873
+ onKeyDown: (event) => { if (event.key === 'Enter') { event.preventDefault(); void askSummary() } },
3874
+ placeholder: summary.selection === '' ? t('dossiers.followUpPlaceholder') : t('dossiers.followUpSelectedPlaceholder'),
3875
+ disabled: summaryLocked,
3876
+ }),
3877
+ h('button', { type: 'button', className: 'epub-btn epub-btn-primary', onClick: () => void askSummary(), disabled: summaryLocked || (summary.question.trim() === '' && summary.selection === '') }, t('dossiers.send')),
3878
+ ),
3879
+ h('p', { className: 'epub-dossier-followup-hint' }, t('dossiers.followUpHint')),
3880
+ ),
3523
3881
  ),
3524
3882
  )
3525
3883
  : null,
@@ -4818,6 +5176,7 @@ window.__ModuleLoader__.load({
4818
5176
  h('button', { type: 'button', onClick: () => updateFont(-1), title: t('reader.fontSmaller'), 'aria-label': t('reader.fontSmaller') }, h(IconMinus, { size: 13 })),
4819
5177
  h('button', { type: 'button', onClick: () => updateFont(1), title: t('reader.fontLarger'), 'aria-label': t('reader.fontLarger') }, h(IconPlus, { size: 13 })),
4820
5178
  ),
5179
+ h('span', { className: 'epub-zoom-hint', title: t('reader.zoomHint') }, h('kbd', { className: 'epub-kbd' }, 'Ctrl'), t('reader.zoomHintShort')),
4821
5180
  h('button', { type: 'button', className: cn('epub-ghost-btn', tocOpen && 'is-active'), onClick: () => setTocOpen((value) => !value), title: t('reader.toc'), 'aria-label': t('reader.toc') }, h(IconList, { size: 15 }), h('span', null, t('reader.toc'))),
4822
5181
  h('button', { type: 'button', className: 'epub-ai-trigger', onClick: () => setAi((current) => ({ ...current, open: !current.open })), title: t('reader.ai'), 'aria-label': t('reader.ai') }, h(IconSparkles, { size: 15 }), h('span', null, t('reader.ai'))),
4823
5182
  ),
package/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * credential.
8
8
  */
9
9
  import { createHash } from 'node:crypto'
10
- import { promises as fs } from 'node:fs'
10
+ import { promises as fs, readFileSync } from 'node:fs'
11
11
  import path from 'node:path'
12
12
  import { BookError, EpubError, parseBook } from './lib/books.js'
13
13
  import { stripTags } from './lib/sanitize.js'
@@ -24,6 +24,9 @@ const MAX_SCAN_DEPTH = 6
24
24
  const BOOK_EXTENSIONS = new Set([
25
25
  '.epub', '.pdf', '.txt', '.text', '.md', '.markdown', '.html', '.htm', '.xhtml', '.fb2', '.docx',
26
26
  ])
27
+ let activeBooksDir = ''
28
+ let activePapersDir = ''
29
+ let activeDossiersDir = ''
27
30
  const SEARCH_TTL_MS = 30 * 60 * 1000
28
31
  const MAX_SEARCH_RESULTS = 24
29
32
  const DOWNLOAD_TIMEOUT_MS = 180_000
@@ -167,7 +170,7 @@ function firstWorkspaceRoot(ctx) {
167
170
  return process.cwd()
168
171
  }
169
172
 
170
- function resolveArchiveDir(config, ctx) {
173
+ function defaultArchiveDir(config, ctx) {
171
174
  const configuredArchive = typeof config?.archiveDir === 'string' ? config.archiveDir.trim() : ''
172
175
  if (configuredArchive !== '') return path.resolve(configuredArchive)
173
176
  const configured = configuredLibraryDirs(config)
@@ -178,11 +181,19 @@ function resolveArchiveDir(config, ctx) {
178
181
  return path.resolve(firstWorkspaceRoot(ctx))
179
182
  }
180
183
 
184
+ function resolveArchiveDir(config, ctx) {
185
+ return defaultArchiveDir(config, ctx)
186
+ }
187
+
188
+ function resolveBooksDir(config, ctx) {
189
+ return activeBooksDir !== '' ? path.resolve(activeBooksDir) : defaultArchiveDir(config, ctx)
190
+ }
191
+
181
192
  function normalizeRoots(config, ctx) {
182
193
  const configured = configuredLibraryDirs(config)
183
194
  // Scan only the archive shelf by default. External books enter the shelf
184
195
  // through Open EPUB, which copies them into the archive before opening.
185
- if (configured.length === 0) configured.push(resolveArchiveDir(config, ctx))
196
+ if (configured.length === 0) configured.push(resolveBooksDir(config, ctx))
186
197
  const unique = []
187
198
  for (const root of configured) {
188
199
  const resolved = path.resolve(root)
@@ -231,6 +242,37 @@ async function ensureArchiveDir(config, ctx) {
231
242
  }
232
243
  }
233
244
 
245
+ async function ensureBooksDir(config, ctx) {
246
+ const directory = resolveBooksDir(config, ctx)
247
+ try {
248
+ await fs.mkdir(directory, { recursive: true })
249
+ return await fs.realpath(directory)
250
+ } catch (error) {
251
+ throw new HttpError(500, 'BOOKS_DIR_UNAVAILABLE', `无法创建书籍目录 ${directory}:${publicErrorMessage(error)}`)
252
+ }
253
+ }
254
+
255
+ function readerSettingsPath(config, ctx) {
256
+ return path.join(defaultArchiveDir(config, ctx), ANNOTATION_DIRECTORY, 'settings.json')
257
+ }
258
+
259
+ function readReaderSettingsSync(config, ctx) {
260
+ try {
261
+ const value = JSON.parse(readFileSync(readerSettingsPath(config, ctx), 'utf8'))
262
+ return value !== null && typeof value === 'object' ? value : {}
263
+ } catch {
264
+ return {}
265
+ }
266
+ }
267
+
268
+ async function writeReaderSettings(config, ctx, settings) {
269
+ const file = readerSettingsPath(config, ctx)
270
+ await fs.mkdir(path.dirname(file), { recursive: true })
271
+ const temporary = `${file}.${process.pid}.tmp`
272
+ await fs.writeFile(temporary, `${JSON.stringify(settings, null, 2)}\n`, 'utf8')
273
+ await fs.rename(temporary, file)
274
+ }
275
+
234
276
  /**
235
277
  * Put one EPUB into the archive directory. An identical file already stored
236
278
  * under the same name is reused; a name collision with different content gets
@@ -529,6 +571,66 @@ function traditionalizeChinese(value) {
529
571
  return [...text].map((character) => SIMPLIFIED_TO_TRADITIONAL[character] ?? character).join('')
530
572
  }
531
573
 
574
+ function fuzzyFindText(text, needle) {
575
+ const source = String(text ?? '').toLowerCase()
576
+ const term = String(needle ?? '').toLowerCase().trim()
577
+ if (source === '' || term === '') return null
578
+ const exact = source.indexOf(term)
579
+ if (exact !== -1) return { score: 1000 + term.length * 20, index: exact }
580
+ let from = 0
581
+ let first = -1
582
+ let last = -1
583
+ for (const character of term) {
584
+ const at = source.indexOf(character, from)
585
+ if (at === -1) return null
586
+ if (first === -1) first = at
587
+ last = at
588
+ from = at + 1
589
+ }
590
+ const span = last - first + 1
591
+ if (span > term.length * 6 + 12) return null
592
+ return { score: Math.max(1, 500 - (span - term.length) * 3 + term.length * 8), index: first }
593
+ }
594
+
595
+ function fuzzyDossierMatch(text, query) {
596
+ const variants = [...new Set([String(query ?? '').trim(), traditionalizeChinese(query)])].filter((entry) => entry !== '')
597
+ let best = null
598
+ for (const variant of variants) {
599
+ const whole = fuzzyFindText(text, variant)
600
+ if (whole !== null) {
601
+ const score = whole.score + 200
602
+ if (best === null || score > best.score) best = { score, index: whole.index }
603
+ }
604
+ const parts = variant.split(/\s+/).filter((entry) => entry !== '')
605
+ if (parts.length === 0) continue
606
+ let score = 0
607
+ let index = -1
608
+ let matched = true
609
+ for (const part of parts) {
610
+ const hit = fuzzyFindText(text, part)
611
+ if (hit === null) { matched = false; break }
612
+ score += hit.score
613
+ if (index === -1 || hit.index < index) index = hit.index
614
+ }
615
+ if (matched && (best === null || score > best.score)) best = { score, index }
616
+ }
617
+ return best
618
+ }
619
+
620
+ function dossierKeywordMatch(text, keywords) {
621
+ const list = keywords.map((entry) => String(entry ?? '').trim()).filter((entry) => entry !== '')
622
+ if (list.length === 0) return null
623
+ let score = 0
624
+ let index = -1
625
+ for (const keyword of list) {
626
+ const hit = fuzzyDossierMatch(text, keyword)
627
+ if (hit === null) return null
628
+ score += hit.score
629
+ if (index === -1 || hit.index < index) index = hit.index
630
+ }
631
+ return { score, index }
632
+ }
633
+
532
634
  function parseGutenbergSearch(html, sourceId = 'gutenberg', sourceName = 'Project Gutenberg', language = '') {
533
635
  const results = []
534
636
  const blocks = html.matchAll(/<li\b[^>]*class="[^"]*\bbooklink\b[^"]*"[^>]*>([\s\S]*?)<\/li>/gi)
@@ -1356,6 +1458,16 @@ async function openModelStream(ctx, route, payload, signal) {
1356
1458
  }
1357
1459
 
1358
1460
  function createHandler(ctx, config) {
1461
+ const savedSettings = readReaderSettingsSync(config, ctx)
1462
+ activeBooksDir = typeof savedSettings.booksDir === 'string' && savedSettings.booksDir.trim() !== ''
1463
+ ? path.resolve(savedSettings.booksDir.trim())
1464
+ : ''
1465
+ activePapersDir = typeof savedSettings.papersDir === 'string' && savedSettings.papersDir.trim() !== ''
1466
+ ? path.resolve(savedSettings.papersDir.trim())
1467
+ : ''
1468
+ activeDossiersDir = typeof savedSettings.dossiersDir === 'string' && savedSettings.dossiersDir.trim() !== ''
1469
+ ? path.resolve(savedSettings.dossiersDir.trim())
1470
+ : ''
1359
1471
  const store = new BookStore(MAX_OPEN_BOOKS)
1360
1472
  let catalog = new Map()
1361
1473
  const searchCache = new Map()
@@ -1438,9 +1550,9 @@ function createHandler(ctx, config) {
1438
1550
  throw new HttpError(500, 'READ_FAILED', `读取失败:${publicErrorMessage(error)}`)
1439
1551
  }
1440
1552
 
1441
- const archiveDir = await ensureArchiveDir(config, ctx)
1442
- if (!isInside(archiveDir, entry.path)) {
1443
- return await ingestBuffer(buffer, entry.name, archiveDir)
1553
+ const booksDir = await ensureBooksDir(config, ctx)
1554
+ if (!isInside(booksDir, entry.path)) {
1555
+ return await ingestBuffer(buffer, entry.name, booksDir)
1444
1556
  }
1445
1557
 
1446
1558
  let book
@@ -1465,7 +1577,7 @@ function createHandler(ctx, config) {
1465
1577
  return { book, copied: false, created: false, copyPath: '' }
1466
1578
  }
1467
1579
 
1468
- function paperRootFor(archiveDir) { return path.join(archiveDir, '.epub-reader', 'papers') }
1580
+ function paperRootFor(archiveDir) { return activePapersDir !== '' ? path.resolve(activePapersDir) : path.join(archiveDir, '.epub-reader', 'papers') }
1469
1581
 
1470
1582
  async function ensurePaperDirs(archiveDir) {
1471
1583
  const root = paperRootFor(archiveDir)
@@ -1805,7 +1917,7 @@ function createHandler(ctx, config) {
1805
1917
  }
1806
1918
 
1807
1919
  function dossierRootFor(archiveDir) {
1808
- return path.join(archiveDir, '.epub-reader', 'dossiers')
1920
+ return activeDossiersDir !== '' ? path.resolve(activeDossiersDir) : path.join(archiveDir, '.epub-reader', 'dossiers')
1809
1921
  }
1810
1922
 
1811
1923
  async function ensureDossierDirs(archiveDir) {
@@ -1997,34 +2109,25 @@ function createHandler(ctx, config) {
1997
2109
  return { deleted: true }
1998
2110
  }
1999
2111
 
2000
- async function searchDossiers(folderId, query) {
2112
+ async function searchDossiers(folderId, keywords) {
2001
2113
  const archiveDir = await ensureArchiveDir(config, ctx)
2002
2114
  const library = await readDossierLibrary(archiveDir)
2003
- const terms = [...new Set([query, traditionalizeChinese(query)])].filter((entry) => entry.trim() !== '')
2004
- const needles = terms.map((entry) => entry.toLowerCase())
2005
2115
  const docs = library.items.filter((item) => item.kind === 'document' && (folderId === '' || item.folderId === folderId))
2006
2116
  const matches = []
2007
2117
  for (const item of docs) {
2008
2118
  const text = await dossierTextForItem(item, archiveDir)
2009
- const lower = text.toLowerCase()
2010
- let best = -1
2011
- for (const needle of needles) {
2012
- const at = lower.indexOf(needle)
2013
- if (at !== -1 && (best === -1 || at < best)) best = at
2014
- }
2015
- if (best === -1) continue
2016
- const start = Math.max(0, best - 90)
2017
- matches.push({ id: item.id, title: item.title, folderId: item.folderId ?? '', snippet: text.slice(start, best + 180).replace(/\s+/g, ' ').trim() })
2119
+ const hit = dossierKeywordMatch(text, keywords)
2120
+ if (hit === null) continue
2121
+ const start = Math.max(0, hit.index - 90)
2122
+ matches.push({ id: item.id, title: item.title, folderId: item.folderId ?? '', score: hit.score, snippet: text.slice(start, hit.index + 220).replace(/\s+/g, ' ').trim() })
2018
2123
  if (matches.length >= 60) break
2019
2124
  }
2020
- return { matches, total: matches.length, query }
2125
+ return { matches, total: matches.length, query: keywords.join(' ') }
2021
2126
  }
2022
2127
 
2023
- async function streamDossierSearch(folderId, query, res) {
2128
+ async function streamDossierSearch(folderId, keywords, res) {
2024
2129
  const archiveDir = await ensureArchiveDir(config, ctx)
2025
2130
  const library = await readDossierLibrary(archiveDir)
2026
- const terms = [...new Set([query, traditionalizeChinese(query)])].filter((entry) => entry.trim() !== '')
2027
- const needles = terms.map((entry) => entry.toLowerCase())
2028
2131
  const docs = library.items.filter((item) => item.kind === 'document' && (folderId === '' || item.folderId === folderId))
2029
2132
  let closed = false
2030
2133
  res.on('close', () => { if (!res.writableEnded) closed = true })
@@ -2042,21 +2145,16 @@ function createHandler(ctx, config) {
2042
2145
  for (const item of docs) {
2043
2146
  if (closed) break
2044
2147
  const text = await dossierTextForItem(item, archiveDir)
2045
- const lower = text.toLowerCase()
2046
- let best = -1
2047
- for (const needle of needles) {
2048
- const at = lower.indexOf(needle)
2049
- if (at !== -1 && (best === -1 || at < best)) best = at
2050
- }
2051
- if (best === -1) continue
2052
- const start = Math.max(0, best - 90)
2053
- const match = { id: item.id, title: item.title, folderId: item.folderId ?? '', snippet: text.slice(start, best + 180).replace(/\s+/g, ' ').trim() }
2148
+ const hit = dossierKeywordMatch(text, keywords)
2149
+ if (hit === null) continue
2150
+ const start = Math.max(0, hit.index - 90)
2151
+ const match = { id: item.id, title: item.title, folderId: item.folderId ?? '', score: hit.score, snippet: text.slice(start, hit.index + 220).replace(/\s+/g, ' ').trim() }
2054
2152
  total += 1
2055
2153
  if (!(await writeSse(res, `data: ${JSON.stringify({ type: 'match', match })}\n\n`))) break
2056
2154
  if (total >= 60) break
2057
2155
  }
2058
2156
  if (!closed) {
2059
- await writeSse(res, `data: ${JSON.stringify({ type: 'done', total, query })}\n\n`)
2157
+ await writeSse(res, `data: ${JSON.stringify({ type: 'done', total, query: keywords.join(' ') })}\n\n`)
2060
2158
  res.end()
2061
2159
  }
2062
2160
  } catch (error) {
@@ -2103,40 +2201,68 @@ function createHandler(ctx, config) {
2103
2201
  const skills = library.items.filter((item) => item.kind === 'skill' && (folderId === '' || item.folderId === folderId))
2104
2202
  const route = resolveModelRoute(ctx, config)
2105
2203
  if (route === undefined) throw new HttpError(503, 'NO_MODEL', '还没有选择模型')
2106
- const sections = []
2107
- for (const item of selected.slice(0, 40)) {
2108
- const text = (await dossierTextForItem(item, archiveDir)).slice(0, 6000)
2109
- if (text.trim() !== '') sections.push(`【${item.title}】\n${text}`)
2110
- }
2111
- if (sections.length === 0) throw new HttpError(422, 'DOSSIER_NO_TEXT', '卷宗文件没有可总结的文字')
2112
- const skillText = skills
2113
- .filter((item) => String(item.text ?? '').trim() !== '')
2114
- .map((item) => `## ${item.title}\n${String(item.text ?? '').slice(0, 6000)}`)
2115
- .join('\n\n')
2116
- const system = '你是卷宗分析助手。请严格依据提供的卷宗内容作答;有 Skill 时优先遵循 Skill 中的方法与格式。不要编造卷宗中没有的事实。'
2117
- const prompt = [
2118
- `卷宗:${folder?.name ?? '未分类卷宗'}`,
2119
- query === '' ? '请总结这个卷宗的核心内容、关键事实、争议点、时间线和待办事项。' : `请围绕以下问题总结卷宗:${query}`,
2120
- skillText === '' ? '' : `\n<!-- SKILLS -->\n${skillText}\n<!-- /SKILLS -->`,
2121
- `\n<!-- DOSSIERS -->\n${sections.join('\n\n')}\n<!-- /DOSSIERS -->`,
2122
- ].filter(Boolean).join('\n')
2123
2204
  const llm = ctx.get('llm')
2124
2205
  if (llm === undefined || typeof llm.stream !== 'function') throw new HttpError(503, 'NO_MODEL', '当前环境没有可用的模型服务')
2206
+
2125
2207
  const controller = new AbortController()
2126
2208
  let closed = false
2127
2209
  res.on('close', () => { if (!res.writableEnded) { closed = true; controller.abort() } })
2128
- const request = { system, messages: [{ role: 'user', content: [{ type: 'text', text: prompt.slice(0, 60000) }] }], signal: controller.signal }
2129
- const call = { provider: route.provider, model: route.model, temperature: 0.2, maxTokens: 1800 }
2130
- const stream = typeof llm.prepareCall === 'function'
2131
- ? await (async () => { const prepared = await llm.prepareCall(call, controller.signal); return prepared.stream({ ...prepared.config, ...request }) })()
2132
- : llm.stream({ ...call, ...request })
2133
- res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
2210
+ res.writeHead(200, {
2211
+ 'Content-Type': 'text/event-stream; charset=utf-8',
2212
+ 'Cache-Control': 'no-cache, no-transform',
2213
+ Connection: 'keep-alive',
2214
+ 'X-Accel-Buffering': 'no',
2215
+ 'X-Content-Type-Options': 'nosniff',
2216
+ })
2134
2217
  if (typeof res.flushHeaders === 'function') res.flushHeaders()
2135
2218
  await writeSse(res, ': connected\n\n')
2136
2219
  const heartbeat = setInterval(() => { if (!closed) void writeSse(res, ': ping\n\n') }, 15000)
2137
2220
  if (typeof heartbeat.unref === 'function') heartbeat.unref()
2138
- let full = ''
2221
+
2222
+ const status = async (text) => {
2223
+ if (!closed) await writeSse(res, `data: ${JSON.stringify({ type: 'status', text })}\n\n`)
2224
+ }
2225
+ const fail = async (error) => {
2226
+ if (!closed) {
2227
+ await writeSse(res, `event: error\ndata: ${JSON.stringify({ type: 'error', error: publicErrorMessage(error) })}\n\n`)
2228
+ res.end()
2229
+ }
2230
+ }
2231
+
2139
2232
  try {
2233
+ const limit = Math.min(selected.length, 40)
2234
+ await status(`正在读取 ${limit} 个卷宗…`)
2235
+ const sections = []
2236
+ let read = 0
2237
+ for (const item of selected.slice(0, limit)) {
2238
+ if (closed) break
2239
+ read += 1
2240
+ const text = (await dossierTextForItem(item, archiveDir)).slice(0, 6000)
2241
+ if (text.trim() !== '') sections.push(`【${item.title}】\n${text}`)
2242
+ await status(`已读取 ${read}/${limit}:${item.title}`)
2243
+ }
2244
+ if (sections.length === 0) {
2245
+ await fail(new Error('卷宗文件没有可总结的文字'))
2246
+ return
2247
+ }
2248
+ const skillText = skills
2249
+ .filter((item) => String(item.text ?? '').trim() !== '')
2250
+ .map((item) => `## ${item.title}\n${String(item.text ?? '').slice(0, 6000)}`)
2251
+ .join('\n\n')
2252
+ const system = '你是卷宗分析助手。请严格依据提供的卷宗内容作答;有 Skill 时优先遵循 Skill 中的方法与格式。不要编造卷宗中没有的事实。'
2253
+ const prompt = [
2254
+ `卷宗:${folder?.name ?? '未分类卷宗'}`,
2255
+ query === '' ? '请总结这个卷宗的核心内容、关键事实、争议点、时间线和待办事项。' : `请围绕以下问题总结卷宗:${query}`,
2256
+ skillText === '' ? '' : `\n<!-- SKILLS -->\n${skillText}\n<!-- /SKILLS -->`,
2257
+ `\n<!-- DOSSIERS -->\n${sections.join('\n\n')}\n<!-- /DOSSIERS -->`,
2258
+ ].filter(Boolean).join('\n')
2259
+ await status('正在生成总结…')
2260
+ const request = { system, messages: [{ role: 'user', content: [{ type: 'text', text: prompt.slice(0, 60000) }] }], signal: controller.signal }
2261
+ const call = { provider: route.provider, model: route.model, temperature: 0.2, maxTokens: 1800 }
2262
+ const stream = typeof llm.prepareCall === 'function'
2263
+ ? await (async () => { const prepared = await llm.prepareCall(call, controller.signal); return prepared.stream({ ...prepared.config, ...request }) })()
2264
+ : llm.stream({ ...call, ...request })
2265
+ let full = ''
2140
2266
  for await (const chunk of stream) {
2141
2267
  if (closed) break
2142
2268
  if (chunk.type === 'text-delta') {
@@ -2151,8 +2277,74 @@ function createHandler(ctx, config) {
2151
2277
  res.end()
2152
2278
  }
2153
2279
  } catch (error) {
2280
+ await fail(error)
2281
+ } finally {
2282
+ clearInterval(heartbeat)
2283
+ if (!closed) controller.abort()
2284
+ }
2285
+ }
2286
+
2287
+ async function dossierAsk(req, res) {
2288
+ const payload = await readJson(req)
2289
+ const question = typeof payload.question === 'string' ? payload.question.trim().slice(0, 1000) : ''
2290
+ const selection = typeof payload.selection === 'string' ? payload.selection.trim().slice(0, 8000) : ''
2291
+ const summary = typeof payload.summary === 'string' ? payload.summary.trim().slice(0, 50000) : ''
2292
+ if (question === '' && selection === '') throw new HttpError(400, 'EMPTY_QUESTION', '请输入追问内容或选中一段总结')
2293
+ const route = resolveModelRoute(ctx, config)
2294
+ if (route === undefined) throw new HttpError(503, 'NO_MODEL', '还没有选择模型')
2295
+ const llm = ctx.get('llm')
2296
+ if (llm === undefined || typeof llm.stream !== 'function') throw new HttpError(503, 'NO_MODEL', '当前环境没有可用的模型服务')
2297
+ const history = Array.isArray(payload.history)
2298
+ ? payload.history.filter((turn) => turn !== null && typeof turn === 'object' && (turn.role === 'user' || turn.role === 'assistant') && typeof turn.text === 'string').slice(-10)
2299
+ : []
2300
+ const controller = new AbortController()
2301
+ let closed = false
2302
+ res.on('close', () => { if (!res.writableEnded) { closed = true; controller.abort() } })
2303
+ res.writeHead(200, {
2304
+ 'Content-Type': 'text/event-stream; charset=utf-8',
2305
+ 'Cache-Control': 'no-cache, no-transform',
2306
+ Connection: 'keep-alive',
2307
+ 'X-Accel-Buffering': 'no',
2308
+ 'X-Content-Type-Options': 'nosniff',
2309
+ })
2310
+ if (typeof res.flushHeaders === 'function') res.flushHeaders()
2311
+ await writeSse(res, ': connected\n\n')
2312
+ const heartbeat = setInterval(() => { if (!closed) void writeSse(res, ': ping\n\n') }, 15000)
2313
+ if (typeof heartbeat.unref === 'function') heartbeat.unref()
2314
+ const send = async (event) => {
2315
+ if (!closed) await writeSse(res, `data: ${JSON.stringify(event)}\n\n`)
2316
+ }
2317
+ try {
2318
+ await send({ type: 'status', text: '正在思考…' })
2319
+ const system = '你是卷宗分析助手。请基于卷宗 AI 总结回答追问;如果有选中的总结文字,优先解释该段。不要编造总结中没有的事实。用简体中文简洁回答。'
2320
+ const prompt = [
2321
+ summary === '' ? '' : `<!-- SUMMARY -->\n${summary}\n<!-- /SUMMARY -->`,
2322
+ selection === '' ? '' : `<!-- SELECTION -->\n${selection}\n<!-- /SELECTION -->`,
2323
+ history.length === 0 ? '' : `<!-- HISTORY -->\n${history.map((turn) => `${turn.role === 'user' ? '用户' : 'AI'}:${String(turn.text).slice(0, 4000)}`).join('\n')}\n<!-- /HISTORY -->`,
2324
+ `用户追问:${question === '' ? '请解释我选中的这段总结。' : question}`,
2325
+ ].filter(Boolean).join('\n\n')
2326
+ const request = { system, messages: [{ role: 'user', content: [{ type: 'text', text: prompt.slice(0, 70000) }] }], signal: controller.signal }
2327
+ const call = { provider: route.provider, model: route.model, temperature: 0.25, maxTokens: 1200 }
2328
+ const stream = typeof llm.prepareCall === 'function'
2329
+ ? await (async () => { const prepared = await llm.prepareCall(call, controller.signal); return prepared.stream({ ...prepared.config, ...request }) })()
2330
+ : llm.stream({ ...call, ...request })
2331
+ let full = ''
2332
+ for await (const chunk of stream) {
2333
+ if (closed) break
2334
+ if (chunk.type === 'text-delta') {
2335
+ full += chunk.text
2336
+ if (!(await writeSse(res, `data: ${JSON.stringify({ type: 'delta', text: chunk.text })}\n\n`))) break
2337
+ } else if (chunk.type === 'finish' && chunk.reason.kind === 'error') {
2338
+ throw new Error(chunk.reason.failure?.message ?? '模型返回了错误')
2339
+ }
2340
+ }
2154
2341
  if (!closed) {
2155
- await writeSse(res, `event: error\ndata: ${JSON.stringify({ error: publicErrorMessage(error) })}\n\n`)
2342
+ await writeSse(res, `data: ${JSON.stringify({ type: 'done', text: full })}\n\n`)
2343
+ res.end()
2344
+ }
2345
+ } catch (error) {
2346
+ if (!closed) {
2347
+ await writeSse(res, `event: error\ndata: ${JSON.stringify({ type: 'error', error: publicErrorMessage(error) })}\n\n`)
2156
2348
  res.end()
2157
2349
  }
2158
2350
  } finally {
@@ -2228,8 +2420,8 @@ function createHandler(ctx, config) {
2228
2420
  }
2229
2421
  const buffer = await readBody(req, MAX_IMPORT_BYTES)
2230
2422
  if (buffer.length === 0) throw new HttpError(400, 'EMPTY_FILE', '没有收到文件内容')
2231
- const archiveDir = await ensureArchiveDir(config, ctx)
2232
- return await ingestBuffer(buffer, name, archiveDir)
2423
+ const booksDir = await ensureBooksDir(config, ctx)
2424
+ return await ingestBuffer(buffer, name, booksDir)
2233
2425
  }
2234
2426
 
2235
2427
  function readSearchOptions(url) {
@@ -2331,12 +2523,12 @@ function createHandler(ctx, config) {
2331
2523
  ? lastError
2332
2524
  : new HttpError(502, 'DOWNLOAD_FAILED', '下载失败,请稍后重试')
2333
2525
  }
2334
- const archiveDir = await ensureArchiveDir(config, ctx)
2526
+ const booksDir = await ensureBooksDir(config, ctx)
2335
2527
  if (downloadFileName === '') {
2336
2528
  const author = item.author === '' ? '' : ` - ${item.author}`
2337
2529
  downloadFileName = `${item.title}${author}.epub`
2338
2530
  }
2339
- const result = await ingestBuffer(buffer, downloadFileName, archiveDir)
2531
+ const result = await ingestBuffer(buffer, downloadFileName, booksDir)
2340
2532
  return { ...result, source: item.source, sourceName: item.sourceName }
2341
2533
  }
2342
2534
 
@@ -2437,6 +2629,70 @@ function createHandler(ctx, config) {
2437
2629
  return
2438
2630
  }
2439
2631
 
2632
+ if (method === 'GET' && pathname === '/settings') {
2633
+ const base = defaultArchiveDir(config, ctx)
2634
+ sendJson(res, 200, {
2635
+ booksDir: resolveBooksDir(config, ctx),
2636
+ papersDir: activePapersDir !== '' ? activePapersDir : path.join(base, '.epub-reader', 'papers'),
2637
+ dossiersDir: activeDossiersDir !== '' ? activeDossiersDir : path.join(base, '.epub-reader', 'dossiers'),
2638
+ defaultDir: base,
2639
+ defaultPapersDir: path.join(base, '.epub-reader', 'papers'),
2640
+ defaultDossiersDir: path.join(base, '.epub-reader', 'dossiers'),
2641
+ })
2642
+ return
2643
+ }
2644
+
2645
+ if (method === 'POST' && pathname === '/settings/pick') {
2646
+ const picker = ctx.get('directoryPickerController')
2647
+ if (picker === undefined || typeof picker.pick !== 'function') {
2648
+ throw new HttpError(503, 'PICKER_UNAVAILABLE', '当前环境没有可用的目录选择器,请直接输入路径')
2649
+ }
2650
+ await readJson(req)
2651
+ const controller = new AbortController()
2652
+ res.on('close', () => { if (!res.writableEnded) controller.abort() })
2653
+ const selected = await picker.pick(controller.signal)
2654
+ sendJson(res, 200, { path: typeof selected === 'string' ? selected : '' })
2655
+ return
2656
+ }
2657
+
2658
+ if (method === 'POST' && pathname === '/settings') {
2659
+ const payload = await readJson(req)
2660
+ const fields = [
2661
+ ['booksDir', '书籍目录', 'BOOKS_DIR_UNAVAILABLE'],
2662
+ ['papersDir', '论文存放目录', 'PAPERS_DIR_UNAVAILABLE'],
2663
+ ['dossiersDir', '卷宗存放目录', 'DOSSIERS_DIR_UNAVAILABLE'],
2664
+ ]
2665
+ const next = {}
2666
+ for (const [field, label, code] of fields) {
2667
+ if (typeof payload[field] !== 'string') continue
2668
+ const requested = payload[field].trim()
2669
+ if (requested === '') { next[field] = ''; continue }
2670
+ const target = path.resolve(requested)
2671
+ try {
2672
+ await fs.mkdir(target, { recursive: true })
2673
+ next[field] = await fs.realpath(target)
2674
+ } catch (error) {
2675
+ throw new HttpError(400, code, `无法使用${label} ${target}:${publicErrorMessage(error)}`)
2676
+ }
2677
+ }
2678
+ const current = readReaderSettingsSync(config, ctx)
2679
+ await writeReaderSettings(config, ctx, { ...current, ...next })
2680
+ if ('booksDir' in next) activeBooksDir = next.booksDir
2681
+ if ('papersDir' in next) activePapersDir = next.papersDir
2682
+ if ('dossiersDir' in next) activeDossiersDir = next.dossiersDir
2683
+ catalog = new Map()
2684
+ const base = defaultArchiveDir(config, ctx)
2685
+ sendJson(res, 200, {
2686
+ booksDir: resolveBooksDir(config, ctx),
2687
+ papersDir: activePapersDir !== '' ? activePapersDir : path.join(base, '.epub-reader', 'papers'),
2688
+ dossiersDir: activeDossiersDir !== '' ? activeDossiersDir : path.join(base, '.epub-reader', 'dossiers'),
2689
+ defaultDir: base,
2690
+ defaultPapersDir: path.join(base, '.epub-reader', 'papers'),
2691
+ defaultDossiersDir: path.join(base, '.epub-reader', 'dossiers'),
2692
+ })
2693
+ return
2694
+ }
2695
+
2440
2696
  if (method === 'GET' && pathname === '/books') {
2441
2697
  const roots = normalizeRoots(config, ctx)
2442
2698
  const result = await scanLibrary(roots)
@@ -2790,9 +3046,9 @@ function createHandler(ctx, config) {
2790
3046
 
2791
3047
  if (method === 'GET' && pathname === '/dossiers/search/stream') {
2792
3048
  const folder = (url.searchParams.get('folder') ?? '').trim()
2793
- const query = (url.searchParams.get('q') ?? '').trim()
2794
- if (query === '') throw new HttpError(400, 'EMPTY_QUERY', '搜索内容不能为空')
2795
- await streamDossierSearch(folder, query, res)
3049
+ const keywords = url.searchParams.getAll('q').map((entry) => entry.trim()).filter((entry) => entry !== '')
3050
+ if (keywords.length === 0) throw new HttpError(400, 'EMPTY_QUERY', '请输入至少一个关键词')
3051
+ await streamDossierSearch(folder, keywords, res)
2796
3052
  return
2797
3053
  }
2798
3054
 
@@ -2805,9 +3061,9 @@ function createHandler(ctx, config) {
2805
3061
 
2806
3062
  if (method === 'GET' && pathname === '/dossiers/search') {
2807
3063
  const folder = (url.searchParams.get('folder') ?? '').trim()
2808
- const query = (url.searchParams.get('q') ?? '').trim()
2809
- if (query === '') throw new HttpError(400, 'EMPTY_QUERY', '搜索内容不能为空')
2810
- sendJson(res, 200, await searchDossiers(folder, query))
3064
+ const keywords = url.searchParams.getAll('q').map((entry) => entry.trim()).filter((entry) => entry !== '')
3065
+ if (keywords.length === 0) throw new HttpError(400, 'EMPTY_QUERY', '请输入至少一个关键词')
3066
+ sendJson(res, 200, await searchDossiers(folder, keywords))
2811
3067
  return
2812
3068
  }
2813
3069
 
@@ -2816,6 +3072,11 @@ function createHandler(ctx, config) {
2816
3072
  return
2817
3073
  }
2818
3074
 
3075
+ if (method === 'POST' && pathname === '/dossiers/ask') {
3076
+ await dossierAsk(req, res)
3077
+ return
3078
+ }
3079
+
2819
3080
  if (method === 'POST' && pathname === '/papers/translate') {
2820
3081
  const payload = await readJson(req)
2821
3082
  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.6.11",
3
+ "version": "2.7.1",
4
4
  "description": "AI Book Shelf: read EPUB, PDF, TXT, Markdown, HTML, FB2 and DOCX; select text for one-click background and explanations, then follow up without leaving the page; full-book search, online book search and custom sources; paper mode with translation, bilingual compare, explanations and follow-ups; dossier mode with batch import, keyword search, AI summaries and skills.",
5
5
  "keywords": [
6
6
  "dsh-plugin",