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.
Files changed (4) hide show
  1. package/README.md +4 -4
  2. package/client.js +247 -29
  3. package/index.js +202 -57
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # AI Book Shelf / AI 书架
2
2
 
3
3
  [![GitHub](https://img.shields.io/badge/GitHub-0avatar0%2Fdsh--ai--book--shelf-181717?logo=github)](https://github.com/0avatar0/dsh-ai-book-shelf)
4
- [![Release](https://img.shields.io/badge/release-v2.6.15-2ea44f)](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.15)
4
+ [![Release](https://img.shields.io/badge/release-v2.7.1-2ea44f)](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.7.1)
5
5
  [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
6
 
7
7
  A persistent DSH Web UI plugin that adds a full-page multi-format reader to the
@@ -23,9 +23,9 @@ files and AI summaries.
23
23
 
24
24
  ## Install
25
25
 
26
- 1. Download `dsh-ai-book-shelf-2.6.15.tgz` from the
27
- [latest release](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.15),
28
- or install from npm with `dsh-ai-book-shelf@2.6.15`.
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.query !== '' ? search.results : documents
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 value = query.trim()
3346
- if (value === '') { stopSearch(); setSearch({ busy: false, results: [], error: '', query: '' }); return }
3426
+ const keywords = [query, ...extraKeywords].map((entry) => entry.trim()).filter((entry) => entry !== '')
3427
+ if (keywords.length === 0) { stopSearch(); setSearch({ busy: false, results: [], error: '', query: '', active: false }); return }
3347
3428
  searchAbort.current?.abort()
3348
3429
  const controller = new AbortController()
3349
3430
  searchAbort.current = controller
3350
- setSearch({ busy: true, results: [], error: '', query: value })
3431
+ setSearch({ busy: true, results: [], error: '', query: keywords.join(' '), active: true })
3432
+ const params = new URLSearchParams({ folder: folderId })
3433
+ for (const keyword of keywords) params.append('q', keyword)
3351
3434
  try {
3352
- await apiStreamSearch(`/dossiers/search/stream?folder=${encodeURIComponent(folderId)}&q=${encodeURIComponent(value)}`, {
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) => ({ ...current, status: current.status === 'streaming' ? 'idle' : current.status }))
3492
+ setSummary((current) => {
3493
+ const turns = current.turns.map((turn, index) => index === current.turns.length - 1 && turn.role === 'assistant' && turn.status === 'streaming' ? { ...turn, status: 'idle' } : turn)
3494
+ return { ...current, turns, busy: false, status: current.status === 'streaming' ? 'idle' : current.status, askStatus: current.askStatus === 'streaming' ? 'idle' : current.askStatus, statusText: '' }
3495
+ })
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 === 'delta' && typeof event.text === 'string') {
3511
+ if (event.type === 'status' && typeof event.text === 'string') {
3512
+ setSummary((current) => ({ ...current, statusText: event.text }))
3513
+ } else if (event.type === 'delta' && typeof event.text === 'string') {
3426
3514
  setSummary((current) => ({ ...current, text: current.text + event.text }))
3427
3515
  } else if (event.type === 'done') {
3428
- setSummary((current) => ({ ...current, status: 'done', text: event.text ?? current.text }))
3516
+ setSummary((current) => {
3517
+ const text = event.text ?? current.text
3518
+ if (String(text).trim() === '') return { ...current, status: 'error', statusText: '', error: t('dossiers.summaryEmpty') }
3519
+ return { ...current, status: 'done', statusText: '', text }
3520
+ })
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.query !== '' && search.results.length > 0) return search.results.map((entry) => entry.id)
3635
+ if (search.active && search.results.length > 0) return search.results.map((entry) => entry.id)
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(IconSearch, { size: 14 }),
3513
- h('input', { value: query, onChange: (event) => setQuery(event.target.value), placeholder: t('dossiers.searchPlaceholder'), 'aria-label': t('dossiers.searchPlaceholder'), type: 'search' }),
3685
+ h('form', { className: 'epub-dossier-search-group', onSubmit: runSearch },
3686
+ h('div', { className: 'epub-dossier-search' },
3687
+ h(IconSearch, { size: 14 }),
3688
+ h('input', { value: query, onChange: (event) => setQuery(event.target.value), placeholder: t('dossiers.searchPlaceholder'), 'aria-label': t('dossiers.searchPlaceholder'), type: 'search' }),
3689
+ ),
3690
+ extraKeywords.map((value, index) =>
3691
+ h('div', { className: 'epub-dossier-search', key: `keyword-${index}` },
3692
+ h(IconSearch, { size: 14 }),
3693
+ h('input', { value, onChange: (event) => setExtraKeywords((current) => current.map((entry, i) => i === index ? event.target.value : entry)), placeholder: t('dossiers.extraKeyword'), 'aria-label': t('dossiers.extraKeyword'), type: 'search' }),
3694
+ h('button', { type: 'button', className: 'epub-icon-btn', onClick: () => setExtraKeywords((current) => current.filter((_, i) => i !== index)), title: t('dossiers.removeKeyword') }, h(IconX, { size: 12 })),
3695
+ ),
3696
+ ),
3697
+ h('button', { type: 'button', className: 'epub-icon-btn', onClick: () => setExtraKeywords((current) => [...current, '']), title: t('dossiers.addKeyword') }, h(IconPlus, { size: 14 })),
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.query !== ''
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.query, title: search.query === '' ? t('dossiers.summaryTitle') : t('dossiers.summarySearchTitle') }), disabled: search.busy || (documents.length === 0 && search.results.length === 0) }, h(IconSparkles, { size: 12 }), t('dossiers.summary')),
3704
+ h('button', { type: 'button', className: 'epub-btn epub-btn-primary epub-dossier-summary-btn', onClick: () => void startSummary({ ids: summaryScopeIds(), query: search.active ? search.query : '', title: search.active ? t('dossiers.summarySearchTitle') : t('dossiers.summaryTitle') }), disabled: search.busy || (documents.length === 0 && search.results.length === 0) }, h(IconSparkles, { size: 12 }), t('dossiers.summary')),
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.query !== '' ? t('dossiers.searchEmpty') : t('dossiers.empty')), h('p', null, search.query !== '' ? t('dossiers.searchEmptyHint') : t('dossiers.emptyHint')))
3719
+ ? h('div', { className: 'epub-paper-empty' }, h(IconFolderPlus, { size: 30 }), h('h3', null, search.active ? t('dossiers.searchEmpty') : t('dossiers.empty')), h('p', null, search.active ? t('dossiers.searchEmptyHint') : t('dossiers.emptyHint')))
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.query !== '' && item.snippet !== undefined ? item.snippet : (item.preview || t('dossiers.openHint'))),
3730
+ h('p', null, search.active && item.snippet !== undefined ? item.snippet : (item.preview || t('dossiers.openHint'))),
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
- summary.status === 'streaming' ? h('button', { type: 'button', className: 'epub-btn', onClick: stopSummary }, t('dossiers.stop')) : null,
3841
+ summaryLocked ? h('button', { type: 'button', className: 'epub-btn', onClick: stopSummary }, t('dossiers.stop')) : null,
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 !== '' ? h('p', { className: 'epub-dossier-summary-error' }, summary.error) : null,
3662
- h('div', { className: 'epub-dossier-summary-body' }, summary.status === 'streaming' && summary.text === '' ? h(Spinner, null) : summary.text),
3845
+ summary.error !== ''
3846
+ ? h('div', { className: 'epub-dossier-summary-error-block' },
3847
+ h('p', null, summary.error),
3848
+ h('button', { type: 'button', className: 'epub-btn', onClick: retrySummary }, t('dossiers.retry')),
3849
+ )
3850
+ : null,
3851
+ h('div', { className: 'epub-dossier-summary-body', onMouseUp: captureSummarySelection },
3852
+ summary.statusText !== '' ? h('div', { className: 'epub-dossier-summary-status' }, h(Spinner, null), h('span', null, summary.statusText)) : null,
3853
+ summary.status === 'streaming' && summary.text === '' ? null : summary.text,
3854
+ summary.turns.map((turn, index) => h('div', { key: `turn-${index}`, className: cn('epub-dossier-turn', turn.role === 'user' ? 'is-user' : 'is-assistant') },
3855
+ h('span', { className: 'epub-dossier-turn-label' }, turn.role === 'user' ? t('dossiers.followUpLabel') : 'AI'),
3856
+ h('div', null, turn.text === '' && turn.status === 'streaming' ? h(Spinner, null) : turn.text),
3857
+ )),
3858
+ ),
3859
+ summaryLocked
3860
+ ? h('div', { className: 'epub-dossier-followup epub-dossier-followup-locked' }, h(Spinner, null), h('span', null, t('dossiers.followUpLocked')))
3861
+ : h('div', { className: 'epub-dossier-followup' },
3862
+ summary.selection !== ''
3863
+ ? h('div', { className: 'epub-dossier-selection-chip' },
3864
+ h('span', null, `${t('dossiers.selectedSummary')}:${summary.selection.slice(0, 80)}${summary.selection.length > 80 ? '…' : ''}`),
3865
+ h('button', { type: 'button', onClick: () => setSummary((current) => ({ ...current, selection: '' })), title: t('dossiers.clearSelection') }, '×'),
3866
+ )
3867
+ : null,
3868
+ h('div', { className: 'epub-dossier-followup-row' },
3869
+ h('input', {
3870
+ ref: summaryInputRef,
3871
+ value: summary.question,
3872
+ onChange: (event) => setSummary((current) => ({ ...current, question: event.target.value })),
3873
+ onKeyDown: (event) => { if (event.key === 'Enter') { event.preventDefault(); void askSummary() } },
3874
+ placeholder: summary.selection === '' ? t('dossiers.followUpPlaceholder') : t('dossiers.followUpSelectedPlaceholder'),
3875
+ disabled: summaryLocked,
3876
+ }),
3877
+ h('button', { type: 'button', className: 'epub-btn epub-btn-primary', onClick: () => void askSummary(), disabled: summaryLocked || (summary.question.trim() === '' && summary.selection === '') }, t('dossiers.send')),
3878
+ ),
3879
+ h('p', { className: 'epub-dossier-followup-hint' }, t('dossiers.followUpHint')),
3880
+ ),
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, query) {
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 lower = text.toLowerCase()
2062
- let best = -1
2063
- for (const needle of needles) {
2064
- const at = lower.indexOf(needle)
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, query, res) {
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 lower = text.toLowerCase()
2098
- let best = -1
2099
- for (const needle of needles) {
2100
- const at = lower.indexOf(needle)
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
- const request = { system, messages: [{ role: 'user', content: [{ type: 'text', text: prompt.slice(0, 60000) }] }], signal: controller.signal }
2181
- const call = { provider: route.provider, model: route.model, temperature: 0.2, maxTokens: 1800 }
2182
- const stream = typeof llm.prepareCall === 'function'
2183
- ? await (async () => { const prepared = await llm.prepareCall(call, controller.signal); return prepared.stream({ ...prepared.config, ...request }) })()
2184
- : llm.stream({ ...call, ...request })
2185
- res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
2210
+ res.writeHead(200, {
2211
+ 'Content-Type': 'text/event-stream; charset=utf-8',
2212
+ 'Cache-Control': 'no-cache, no-transform',
2213
+ Connection: 'keep-alive',
2214
+ 'X-Accel-Buffering': 'no',
2215
+ 'X-Content-Type-Options': 'nosniff',
2216
+ })
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
- let full = ''
2221
+
2222
+ const status = async (text) => {
2223
+ if (!closed) await writeSse(res, `data: ${JSON.stringify({ type: 'status', text })}\n\n`)
2224
+ }
2225
+ const fail = async (error) => {
2226
+ if (!closed) {
2227
+ await writeSse(res, `event: error\ndata: ${JSON.stringify({ type: 'error', error: publicErrorMessage(error) })}\n\n`)
2228
+ res.end()
2229
+ }
2230
+ }
2231
+
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, `event: error\ndata: ${JSON.stringify({ error: publicErrorMessage(error) })}\n\n`)
2342
+ await writeSse(res, `data: ${JSON.stringify({ type: 'done', text: full })}\n\n`)
2343
+ res.end()
2344
+ }
2345
+ } catch (error) {
2346
+ if (!closed) {
2347
+ await writeSse(res, `event: error\ndata: ${JSON.stringify({ type: 'error', error: publicErrorMessage(error) })}\n\n`)
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 query = (url.searchParams.get('q') ?? '').trim()
2910
- if (query === '') throw new HttpError(400, 'EMPTY_QUERY', '搜索内容不能为空')
2911
- await streamDossierSearch(folder, query, res)
3049
+ const keywords = url.searchParams.getAll('q').map((entry) => entry.trim()).filter((entry) => entry !== '')
3050
+ if (keywords.length === 0) throw new HttpError(400, 'EMPTY_QUERY', '请输入至少一个关键词')
3051
+ await streamDossierSearch(folder, keywords, res)
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 query = (url.searchParams.get('q') ?? '').trim()
2925
- if (query === '') throw new HttpError(400, 'EMPTY_QUERY', '搜索内容不能为空')
2926
- sendJson(res, 200, await searchDossiers(folder, query))
3064
+ const keywords = url.searchParams.getAll('q').map((entry) => entry.trim()).filter((entry) => entry !== '')
3065
+ if (keywords.length === 0) throw new HttpError(400, 'EMPTY_QUERY', '请输入至少一个关键词')
3066
+ sendJson(res, 200, await searchDossiers(folder, keywords))
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.6.15",
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",