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.
- package/README.md +4 -4
- package/client.js +389 -30
- package/index.js +330 -69
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# AI Book Shelf / AI 书架
|
|
2
2
|
|
|
3
3
|
[](https://github.com/0avatar0/dsh-ai-book-shelf)
|
|
4
|
-
[](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.7.1)
|
|
5
5
|
[](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.
|
|
27
|
-
[latest release](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.
|
|
28
|
-
or install from npm with `dsh-ai-book-shelf@2.
|
|
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
|
-
|
|
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.
|
|
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
|
|
3206
|
-
if (
|
|
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:
|
|
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
|
|
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) =>
|
|
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 === '
|
|
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) =>
|
|
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.
|
|
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(
|
|
3373
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 !== ''
|
|
3522
|
-
|
|
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
|
|
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(
|
|
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
|
|
1442
|
-
if (!isInside(
|
|
1443
|
-
return await ingestBuffer(buffer, entry.name,
|
|
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,
|
|
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
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
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,
|
|
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
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
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
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
:
|
|
2133
|
-
|
|
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
|
-
|
|
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, `
|
|
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
|
|
2232
|
-
return await ingestBuffer(buffer, name,
|
|
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
|
|
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,
|
|
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
|
|
2794
|
-
if (
|
|
2795
|
-
await streamDossierSearch(folder,
|
|
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
|
|
2809
|
-
if (
|
|
2810
|
-
sendJson(res, 200, await searchDossiers(folder,
|
|
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.
|
|
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",
|