dsh-ai-book-shelf 2.6.15 → 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 +247 -29
- package/index.js +202 -57
- 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} 个',
|
|
@@ -388,6 +413,19 @@ window.__ModuleLoader__.load({
|
|
|
388
413
|
'dossiers.title': 'Dossier library',
|
|
389
414
|
'dossiers.subtitle': 'Organize dossiers by folder, with multi-select import, full-text search, AI summaries and folder skills.',
|
|
390
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',
|
|
391
429
|
'dossiers.importing': 'Importing dossiers…',
|
|
392
430
|
'dossiers.importFailed': 'Dossier import failed',
|
|
393
431
|
'dossiers.newFolder': 'New dossier folder',
|
|
@@ -413,6 +451,19 @@ window.__ModuleLoader__.load({
|
|
|
413
451
|
'dossiers.summaryTitle': 'AI dossier summary',
|
|
414
452
|
'dossiers.summarySearchTitle': 'AI search summary',
|
|
415
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',
|
|
416
467
|
'dossiers.searching': 'Searching…',
|
|
417
468
|
'dossiers.stopSearch': 'Stop search',
|
|
418
469
|
'dossiers.searchCount': '{count} found',
|
|
@@ -1049,8 +1100,16 @@ window.__ModuleLoader__.load({
|
|
|
1049
1100
|
.epub-dialog-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 12px; }
|
|
1050
1101
|
.epub-dossier-actions { display: flex; align-items: center; gap: 7px; margin-left: auto; }
|
|
1051
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; }
|
|
1052
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); }
|
|
1053
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; }
|
|
1054
1113
|
.epub-dossier-hint { margin: 0 0 10px; color: var(--epub-muted); font-size: 9px; line-height: 1.5; }
|
|
1055
1114
|
.epub-dossier-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 9px; }
|
|
1056
1115
|
.epub-dossier-card { padding: 12px; border: 1px solid var(--epub-line); border-radius: 10px; background: var(--epub-surface); cursor: pointer; min-width: 0; }
|
|
@@ -1086,7 +1145,25 @@ window.__ModuleLoader__.load({
|
|
|
1086
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); }
|
|
1087
1146
|
.epub-dossier-summary h3 { margin: 2px 0 0; font-size: 15px; }
|
|
1088
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; }
|
|
1089
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; }
|
|
1090
1167
|
.epub-dossier-summary-error { margin: 0; padding: 10px 16px 0; color: var(--epub-danger); font-size: 10px; }
|
|
1091
1168
|
.epub-paper-compare { flex: 1; min-height: 0; display: flex; flex-direction: column; position: relative; }
|
|
1092
1169
|
.epub-paper-body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); }
|
|
@@ -1137,6 +1214,7 @@ window.__ModuleLoader__.load({
|
|
|
1137
1214
|
.epub-dossier-search { flex: 1; }
|
|
1138
1215
|
.epub-dossier-search input { width: 100%; min-width: 0; }
|
|
1139
1216
|
.epub-dossier-grid { grid-template-columns: 1fr; }
|
|
1217
|
+
.epub-dossier-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
1140
1218
|
.epub-dossier-summary { max-height: 88vh; }
|
|
1141
1219
|
.epub-zoom-hint { display: none; }
|
|
1142
1220
|
.epub-paper-folders { display: flex; gap: 4px; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--epub-line); }
|
|
@@ -3185,7 +3263,8 @@ window.__ModuleLoader__.load({
|
|
|
3185
3263
|
const [state, setState] = React.useState({ status: 'loading', folders: [], items: [], error: '' })
|
|
3186
3264
|
const [activeFolder, setActiveFolder] = React.useState('all')
|
|
3187
3265
|
const [query, setQuery] = React.useState('')
|
|
3188
|
-
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([])
|
|
3189
3268
|
const [importing, setImporting] = React.useState(false)
|
|
3190
3269
|
const [error, setError] = React.useState('')
|
|
3191
3270
|
const [dialog, setDialog] = React.useState(null)
|
|
@@ -3194,9 +3273,10 @@ window.__ModuleLoader__.load({
|
|
|
3194
3273
|
const [expandedFolders, setExpandedFolders] = React.useState(() => new Set())
|
|
3195
3274
|
const [clearConfirm, setClearConfirm] = React.useState(null)
|
|
3196
3275
|
const [skillEditor, setSkillEditor] = React.useState(null)
|
|
3197
|
-
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 })
|
|
3198
3277
|
const fileRef = React.useRef(null)
|
|
3199
3278
|
const summaryAbort = React.useRef(null)
|
|
3279
|
+
const summaryInputRef = React.useRef(null)
|
|
3200
3280
|
const searchAbort = React.useRef(null)
|
|
3201
3281
|
|
|
3202
3282
|
const load = React.useCallback(async () => {
|
|
@@ -3237,7 +3317,8 @@ window.__ModuleLoader__.load({
|
|
|
3237
3317
|
const folder = state.folders.find((entry) => entry.id === activeFolder)
|
|
3238
3318
|
const folderLabel = activeFolder === 'all' ? t('dossiers.all') : activeFolder === 'root' ? t('dossiers.root') : folder?.name ?? t('dossiers.title')
|
|
3239
3319
|
const documents = state.items.filter((item) => item.kind === 'document' && (activeFolder === 'all' || item.folderId === folderId))
|
|
3240
|
-
const shown = search.
|
|
3320
|
+
const shown = search.active ? search.results : documents
|
|
3321
|
+
const summaryLocked = summary.busy || summary.status === 'streaming' || summary.askStatus === 'streaming'
|
|
3241
3322
|
|
|
3242
3323
|
function skillsFor(id) {
|
|
3243
3324
|
return state.items.filter((item) => item.kind === 'skill' && item.folderId === id)
|
|
@@ -3342,14 +3423,16 @@ window.__ModuleLoader__.load({
|
|
|
3342
3423
|
|
|
3343
3424
|
async function runSearch(event) {
|
|
3344
3425
|
if (event !== undefined) event.preventDefault()
|
|
3345
|
-
const
|
|
3346
|
-
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 }
|
|
3347
3428
|
searchAbort.current?.abort()
|
|
3348
3429
|
const controller = new AbortController()
|
|
3349
3430
|
searchAbort.current = controller
|
|
3350
|
-
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)
|
|
3351
3434
|
try {
|
|
3352
|
-
await apiStreamSearch(`/dossiers/search/stream
|
|
3435
|
+
await apiStreamSearch(`/dossiers/search/stream?${params.toString()}`, {
|
|
3353
3436
|
signal: controller.signal,
|
|
3354
3437
|
onEvent: (streamEvent) => {
|
|
3355
3438
|
if (streamEvent.type === 'match' && streamEvent.match !== undefined) {
|
|
@@ -3406,14 +3489,17 @@ window.__ModuleLoader__.load({
|
|
|
3406
3489
|
function stopSummary() {
|
|
3407
3490
|
summaryAbort.current?.abort()
|
|
3408
3491
|
summaryAbort.current = null
|
|
3409
|
-
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
|
+
})
|
|
3410
3496
|
}
|
|
3411
3497
|
|
|
3412
3498
|
async function startSummary(options = {}) {
|
|
3413
3499
|
const controller = new AbortController()
|
|
3414
3500
|
summaryAbort.current?.abort()
|
|
3415
3501
|
summaryAbort.current = controller
|
|
3416
|
-
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 })
|
|
3417
3503
|
try {
|
|
3418
3504
|
await apiStreamJson('/dossiers/summary', {
|
|
3419
3505
|
folderId,
|
|
@@ -3422,25 +3508,113 @@ window.__ModuleLoader__.load({
|
|
|
3422
3508
|
}, {
|
|
3423
3509
|
signal: controller.signal,
|
|
3424
3510
|
onEvent: (event) => {
|
|
3425
|
-
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') {
|
|
3426
3514
|
setSummary((current) => ({ ...current, text: current.text + event.text }))
|
|
3427
3515
|
} else if (event.type === 'done') {
|
|
3428
|
-
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
|
+
})
|
|
3429
3521
|
} else if (event.type === 'error') {
|
|
3430
|
-
setSummary((current) => ({ ...current, status: 'error', error: String(event.error ?? 'AI 总结失败') }))
|
|
3522
|
+
setSummary((current) => ({ ...current, status: 'error', statusText: '', error: String(event.error ?? 'AI 总结失败') }))
|
|
3431
3523
|
}
|
|
3432
3524
|
},
|
|
3433
3525
|
})
|
|
3434
3526
|
} catch (reason) {
|
|
3435
3527
|
if (controller.signal.aborted) return
|
|
3436
|
-
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 }))
|
|
3437
3532
|
}
|
|
3438
3533
|
}
|
|
3439
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
|
+
|
|
3440
3614
|
function closeSummary() {
|
|
3441
3615
|
summaryAbort.current?.abort()
|
|
3442
3616
|
summaryAbort.current = null
|
|
3443
|
-
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 })
|
|
3444
3618
|
}
|
|
3445
3619
|
|
|
3446
3620
|
async function clearFolder() {
|
|
@@ -3449,7 +3623,7 @@ window.__ModuleLoader__.load({
|
|
|
3449
3623
|
await apiPost('/dossiers/clear', { folderId: clearConfirm.scope })
|
|
3450
3624
|
setClearConfirm(null)
|
|
3451
3625
|
stopSearch()
|
|
3452
|
-
setSearch({ busy: false, results: [], error: '', query: '' })
|
|
3626
|
+
setSearch({ busy: false, results: [], error: '', query: '', active: false })
|
|
3453
3627
|
setQuery('')
|
|
3454
3628
|
await load()
|
|
3455
3629
|
} catch (reason) {
|
|
@@ -3458,7 +3632,7 @@ window.__ModuleLoader__.load({
|
|
|
3458
3632
|
}
|
|
3459
3633
|
|
|
3460
3634
|
function summaryScopeIds() {
|
|
3461
|
-
if (search.
|
|
3635
|
+
if (search.active && search.results.length > 0) return search.results.map((entry) => entry.id)
|
|
3462
3636
|
return []
|
|
3463
3637
|
}
|
|
3464
3638
|
|
|
@@ -3481,13 +3655,13 @@ window.__ModuleLoader__.load({
|
|
|
3481
3655
|
h('div', { className: 'epub-papers-body' },
|
|
3482
3656
|
h('aside', { className: 'epub-paper-folders' },
|
|
3483
3657
|
h('div', { className: 'epub-paper-folder-top' },
|
|
3484
|
-
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))),
|
|
3485
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 })),
|
|
3486
3660
|
),
|
|
3487
3661
|
h('div', { className: 'epub-dossier-folder-group', onContextMenu: (event) => openContext(event, 'root') },
|
|
3488
3662
|
h('div', { className: cn('epub-paper-folder-wrap', activeFolder === 'root' && 'is-active') },
|
|
3489
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') ? '▾' : '▸'),
|
|
3490
|
-
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))),
|
|
3491
3665
|
h('button', { type: 'button', className: 'epub-paper-folder-rename', onClick: () => void createSkill(''), title: t('dossiers.createSkill') }, '+'),
|
|
3492
3666
|
),
|
|
3493
3667
|
expandedFolders.has('root') ? renderFolderSkills('') : null,
|
|
@@ -3496,7 +3670,7 @@ window.__ModuleLoader__.load({
|
|
|
3496
3670
|
h('div', { key: entry.id, className: 'epub-dossier-folder-group', onContextMenu: (event) => openContext(event, 'folder', { folderId: entry.id }) },
|
|
3497
3671
|
h('div', { className: cn('epub-paper-folder-wrap', activeFolder === entry.id && 'is-active') },
|
|
3498
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) ? '▾' : '▸'),
|
|
3499
|
-
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))),
|
|
3500
3674
|
h('button', { type: 'button', className: 'epub-paper-folder-rename', onClick: () => openDialog('folderRename', entry.name, entry.id), title: t('dossiers.renameFolder') }, '✎'),
|
|
3501
3675
|
h('button', { type: 'button', className: 'epub-paper-folder-rename', onClick: () => void createSkill(entry.id), title: t('dossiers.createSkill') }, '+'),
|
|
3502
3676
|
),
|
|
@@ -3508,16 +3682,26 @@ window.__ModuleLoader__.load({
|
|
|
3508
3682
|
h('div', { className: 'epub-paper-main-head' },
|
|
3509
3683
|
h('h2', null, folderLabel),
|
|
3510
3684
|
h('div', { className: 'epub-dossier-actions' },
|
|
3511
|
-
h('form', { className: 'epub-dossier-search', onSubmit: runSearch },
|
|
3512
|
-
h(
|
|
3513
|
-
|
|
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 })),
|
|
3514
3698
|
),
|
|
3515
3699
|
search.busy
|
|
3516
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 }))
|
|
3517
|
-
: search.
|
|
3701
|
+
: search.active
|
|
3518
3702
|
? h('span', { className: 'epub-dossier-search-count' }, format(t('dossiers.searchCount'), { count: search.results.length }))
|
|
3519
3703
|
: null,
|
|
3520
|
-
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')),
|
|
3521
3705
|
),
|
|
3522
3706
|
),
|
|
3523
3707
|
h('div', { className: 'epub-dossier-hint' }, t('dossiers.skillHint')),
|
|
@@ -3532,7 +3716,7 @@ window.__ModuleLoader__.load({
|
|
|
3532
3716
|
: search.busy && shown.length === 0
|
|
3533
3717
|
? h('div', { className: 'epub-paper-empty' }, h(Spinner, null))
|
|
3534
3718
|
: shown.length === 0 && !importing
|
|
3535
|
-
? 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')))
|
|
3536
3720
|
: shown.length > 0
|
|
3537
3721
|
? h('div', { className: 'epub-dossier-grid' },
|
|
3538
3722
|
shown.map((item) => h('article', {
|
|
@@ -3543,7 +3727,7 @@ window.__ModuleLoader__.load({
|
|
|
3543
3727
|
},
|
|
3544
3728
|
h('div', { className: 'epub-dossier-type' }, (item.format || 'DOC').toUpperCase()),
|
|
3545
3729
|
h('h3', null, item.title),
|
|
3546
|
-
h('p', null, search.
|
|
3730
|
+
h('p', null, search.active && item.snippet !== undefined ? item.snippet : (item.preview || t('dossiers.openHint'))),
|
|
3547
3731
|
h('div', { className: 'epub-dossier-meta' }, h('span', null, formatBytes(item.size ?? 0)), h('span', null, t('dossiers.open'))),
|
|
3548
3732
|
)),
|
|
3549
3733
|
)
|
|
@@ -3654,12 +3838,46 @@ window.__ModuleLoader__.load({
|
|
|
3654
3838
|
h('header', null,
|
|
3655
3839
|
h('div', null, h('p', { className: 'epub-eyebrow' }, 'AI DOSSIER'), h('h3', null, summary.title)),
|
|
3656
3840
|
h('div', { className: 'epub-dossier-summary-actions' },
|
|
3657
|
-
|
|
3841
|
+
summaryLocked ? h('button', { type: 'button', className: 'epub-btn', onClick: stopSummary }, t('dossiers.stop')) : null,
|
|
3658
3842
|
h('button', { type: 'button', className: 'epub-icon-btn', onClick: closeSummary, title: t('papers.close') }, h(IconX, { size: 16 })),
|
|
3659
3843
|
),
|
|
3660
3844
|
),
|
|
3661
|
-
summary.error !== ''
|
|
3662
|
-
|
|
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
|
+
),
|
|
3663
3881
|
),
|
|
3664
3882
|
)
|
|
3665
3883
|
: null,
|
package/index.js
CHANGED
|
@@ -571,6 +571,66 @@ function traditionalizeChinese(value) {
|
|
|
571
571
|
return [...text].map((character) => SIMPLIFIED_TO_TRADITIONAL[character] ?? character).join('')
|
|
572
572
|
}
|
|
573
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
|
+
|
|
574
634
|
function parseGutenbergSearch(html, sourceId = 'gutenberg', sourceName = 'Project Gutenberg', language = '') {
|
|
575
635
|
const results = []
|
|
576
636
|
const blocks = html.matchAll(/<li\b[^>]*class="[^"]*\bbooklink\b[^"]*"[^>]*>([\s\S]*?)<\/li>/gi)
|
|
@@ -2049,34 +2109,25 @@ function createHandler(ctx, config) {
|
|
|
2049
2109
|
return { deleted: true }
|
|
2050
2110
|
}
|
|
2051
2111
|
|
|
2052
|
-
async function searchDossiers(folderId,
|
|
2112
|
+
async function searchDossiers(folderId, keywords) {
|
|
2053
2113
|
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
2054
2114
|
const library = await readDossierLibrary(archiveDir)
|
|
2055
|
-
const terms = [...new Set([query, traditionalizeChinese(query)])].filter((entry) => entry.trim() !== '')
|
|
2056
|
-
const needles = terms.map((entry) => entry.toLowerCase())
|
|
2057
2115
|
const docs = library.items.filter((item) => item.kind === 'document' && (folderId === '' || item.folderId === folderId))
|
|
2058
2116
|
const matches = []
|
|
2059
2117
|
for (const item of docs) {
|
|
2060
2118
|
const text = await dossierTextForItem(item, archiveDir)
|
|
2061
|
-
const
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
if (at !== -1 && (best === -1 || at < best)) best = at
|
|
2066
|
-
}
|
|
2067
|
-
if (best === -1) continue
|
|
2068
|
-
const start = Math.max(0, best - 90)
|
|
2069
|
-
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() })
|
|
2070
2123
|
if (matches.length >= 60) break
|
|
2071
2124
|
}
|
|
2072
|
-
return { matches, total: matches.length, query }
|
|
2125
|
+
return { matches, total: matches.length, query: keywords.join(' ') }
|
|
2073
2126
|
}
|
|
2074
2127
|
|
|
2075
|
-
async function streamDossierSearch(folderId,
|
|
2128
|
+
async function streamDossierSearch(folderId, keywords, res) {
|
|
2076
2129
|
const archiveDir = await ensureArchiveDir(config, ctx)
|
|
2077
2130
|
const library = await readDossierLibrary(archiveDir)
|
|
2078
|
-
const terms = [...new Set([query, traditionalizeChinese(query)])].filter((entry) => entry.trim() !== '')
|
|
2079
|
-
const needles = terms.map((entry) => entry.toLowerCase())
|
|
2080
2131
|
const docs = library.items.filter((item) => item.kind === 'document' && (folderId === '' || item.folderId === folderId))
|
|
2081
2132
|
let closed = false
|
|
2082
2133
|
res.on('close', () => { if (!res.writableEnded) closed = true })
|
|
@@ -2094,21 +2145,16 @@ function createHandler(ctx, config) {
|
|
|
2094
2145
|
for (const item of docs) {
|
|
2095
2146
|
if (closed) break
|
|
2096
2147
|
const text = await dossierTextForItem(item, archiveDir)
|
|
2097
|
-
const
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
if (at !== -1 && (best === -1 || at < best)) best = at
|
|
2102
|
-
}
|
|
2103
|
-
if (best === -1) continue
|
|
2104
|
-
const start = Math.max(0, best - 90)
|
|
2105
|
-
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() }
|
|
2106
2152
|
total += 1
|
|
2107
2153
|
if (!(await writeSse(res, `data: ${JSON.stringify({ type: 'match', match })}\n\n`))) break
|
|
2108
2154
|
if (total >= 60) break
|
|
2109
2155
|
}
|
|
2110
2156
|
if (!closed) {
|
|
2111
|
-
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`)
|
|
2112
2158
|
res.end()
|
|
2113
2159
|
}
|
|
2114
2160
|
} catch (error) {
|
|
@@ -2155,40 +2201,68 @@ function createHandler(ctx, config) {
|
|
|
2155
2201
|
const skills = library.items.filter((item) => item.kind === 'skill' && (folderId === '' || item.folderId === folderId))
|
|
2156
2202
|
const route = resolveModelRoute(ctx, config)
|
|
2157
2203
|
if (route === undefined) throw new HttpError(503, 'NO_MODEL', '还没有选择模型')
|
|
2158
|
-
const sections = []
|
|
2159
|
-
for (const item of selected.slice(0, 40)) {
|
|
2160
|
-
const text = (await dossierTextForItem(item, archiveDir)).slice(0, 6000)
|
|
2161
|
-
if (text.trim() !== '') sections.push(`【${item.title}】\n${text}`)
|
|
2162
|
-
}
|
|
2163
|
-
if (sections.length === 0) throw new HttpError(422, 'DOSSIER_NO_TEXT', '卷宗文件没有可总结的文字')
|
|
2164
|
-
const skillText = skills
|
|
2165
|
-
.filter((item) => String(item.text ?? '').trim() !== '')
|
|
2166
|
-
.map((item) => `## ${item.title}\n${String(item.text ?? '').slice(0, 6000)}`)
|
|
2167
|
-
.join('\n\n')
|
|
2168
|
-
const system = '你是卷宗分析助手。请严格依据提供的卷宗内容作答;有 Skill 时优先遵循 Skill 中的方法与格式。不要编造卷宗中没有的事实。'
|
|
2169
|
-
const prompt = [
|
|
2170
|
-
`卷宗:${folder?.name ?? '未分类卷宗'}`,
|
|
2171
|
-
query === '' ? '请总结这个卷宗的核心内容、关键事实、争议点、时间线和待办事项。' : `请围绕以下问题总结卷宗:${query}`,
|
|
2172
|
-
skillText === '' ? '' : `\n<!-- SKILLS -->\n${skillText}\n<!-- /SKILLS -->`,
|
|
2173
|
-
`\n<!-- DOSSIERS -->\n${sections.join('\n\n')}\n<!-- /DOSSIERS -->`,
|
|
2174
|
-
].filter(Boolean).join('\n')
|
|
2175
2204
|
const llm = ctx.get('llm')
|
|
2176
2205
|
if (llm === undefined || typeof llm.stream !== 'function') throw new HttpError(503, 'NO_MODEL', '当前环境没有可用的模型服务')
|
|
2206
|
+
|
|
2177
2207
|
const controller = new AbortController()
|
|
2178
2208
|
let closed = false
|
|
2179
2209
|
res.on('close', () => { if (!res.writableEnded) { closed = true; controller.abort() } })
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
:
|
|
2185
|
-
|
|
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
|
+
})
|
|
2186
2217
|
if (typeof res.flushHeaders === 'function') res.flushHeaders()
|
|
2187
2218
|
await writeSse(res, ': connected\n\n')
|
|
2188
2219
|
const heartbeat = setInterval(() => { if (!closed) void writeSse(res, ': ping\n\n') }, 15000)
|
|
2189
2220
|
if (typeof heartbeat.unref === 'function') heartbeat.unref()
|
|
2190
|
-
|
|
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
|
+
|
|
2191
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 = ''
|
|
2192
2266
|
for await (const chunk of stream) {
|
|
2193
2267
|
if (closed) break
|
|
2194
2268
|
if (chunk.type === 'text-delta') {
|
|
@@ -2203,8 +2277,74 @@ function createHandler(ctx, config) {
|
|
|
2203
2277
|
res.end()
|
|
2204
2278
|
}
|
|
2205
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
|
+
}
|
|
2206
2341
|
if (!closed) {
|
|
2207
|
-
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`)
|
|
2208
2348
|
res.end()
|
|
2209
2349
|
}
|
|
2210
2350
|
} finally {
|
|
@@ -2906,9 +3046,9 @@ function createHandler(ctx, config) {
|
|
|
2906
3046
|
|
|
2907
3047
|
if (method === 'GET' && pathname === '/dossiers/search/stream') {
|
|
2908
3048
|
const folder = (url.searchParams.get('folder') ?? '').trim()
|
|
2909
|
-
const
|
|
2910
|
-
if (
|
|
2911
|
-
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)
|
|
2912
3052
|
return
|
|
2913
3053
|
}
|
|
2914
3054
|
|
|
@@ -2921,9 +3061,9 @@ function createHandler(ctx, config) {
|
|
|
2921
3061
|
|
|
2922
3062
|
if (method === 'GET' && pathname === '/dossiers/search') {
|
|
2923
3063
|
const folder = (url.searchParams.get('folder') ?? '').trim()
|
|
2924
|
-
const
|
|
2925
|
-
if (
|
|
2926
|
-
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))
|
|
2927
3067
|
return
|
|
2928
3068
|
}
|
|
2929
3069
|
|
|
@@ -2932,6 +3072,11 @@ function createHandler(ctx, config) {
|
|
|
2932
3072
|
return
|
|
2933
3073
|
}
|
|
2934
3074
|
|
|
3075
|
+
if (method === 'POST' && pathname === '/dossiers/ask') {
|
|
3076
|
+
await dossierAsk(req, res)
|
|
3077
|
+
return
|
|
3078
|
+
}
|
|
3079
|
+
|
|
2935
3080
|
if (method === 'POST' && pathname === '/papers/translate') {
|
|
2936
3081
|
const payload = await readJson(req)
|
|
2937
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",
|