dsh-ai-book-shelf 2.6.4 → 2.6.9

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 +147 -22
  3. package/index.js +113 -5
  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.4-2ea44f)](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.4)
4
+ [![Release](https://img.shields.io/badge/release-v2.6.9-2ea44f)](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.9)
5
5
  [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
6
 
7
7
  A persistent DSH Web UI plugin that adds a full-page multi-format reader to the
@@ -11,9 +11,9 @@ comparison.
11
11
 
12
12
  ## Install
13
13
 
14
- 1. Download `dsh-ai-book-shelf-2.6.4.tgz` from the
15
- [latest release](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.4),
16
- or install from npm with `dsh-ai-book-shelf@2.6.4`.
14
+ 1. Download `dsh-ai-book-shelf-2.6.9.tgz` from the
15
+ [latest release](https://github.com/0avatar0/dsh-ai-book-shelf/releases/tag/v2.6.9),
16
+ or install from npm with `dsh-ai-book-shelf@2.6.9`.
17
17
  2. In DSH open **Plugins → Add plugin** and choose the downloaded tgz, or paste
18
18
  the npm package spec.
19
19
  3. Reload the plugin and open **AI 书架 / AI Book Shelf** in the sidebar.
package/client.js CHANGED
@@ -149,6 +149,13 @@ window.__ModuleLoader__.load({
149
149
  'dossiers.summaryTitle': 'AI 卷宗总结',
150
150
  'dossiers.summarySearchTitle': 'AI 总结搜索结果',
151
151
  'dossiers.summarizing': '正在分析卷宗…',
152
+ 'dossiers.searching': '搜索中…',
153
+ 'dossiers.stopSearch': '停止搜索',
154
+ 'dossiers.searchCount': '找到 {count} 个',
155
+ 'dossiers.clear': '清空卷宗',
156
+ 'dossiers.clearTitle': '清空「{name}」卷宗?',
157
+ 'dossiers.clearHint': '将把当前范围内的 {count} 个卷宗文件移到回收目录;文件夹和 Skill 会保留。',
158
+ 'dossiers.clearConfirm': '确认清空',
152
159
  'dossiers.open': '打开阅读',
153
160
  'dossiers.openHint': '点击打开阅读',
154
161
  'dossiers.openFailed': '卷宗打开失败',
@@ -390,6 +397,13 @@ window.__ModuleLoader__.load({
390
397
  'dossiers.summaryTitle': 'AI dossier summary',
391
398
  'dossiers.summarySearchTitle': 'AI search summary',
392
399
  'dossiers.summarizing': 'Analyzing dossiers…',
400
+ 'dossiers.searching': 'Searching…',
401
+ 'dossiers.stopSearch': 'Stop search',
402
+ 'dossiers.searchCount': '{count} found',
403
+ 'dossiers.clear': 'Clear dossiers',
404
+ 'dossiers.clearTitle': 'Clear "{name}"?',
405
+ 'dossiers.clearHint': 'Move {count} dossier files in the current scope to the trash; folders and skills are kept.',
406
+ 'dossiers.clearConfirm': 'Clear',
393
407
  'dossiers.open': 'Open',
394
408
  'dossiers.openHint': 'Click to open',
395
409
  'dossiers.openFailed': 'Could not open dossier',
@@ -983,10 +997,12 @@ window.__ModuleLoader__.load({
983
997
  .epub-dialog-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; background: rgba(0,0,0,.35); }
984
998
  .epub-dialog { width: min(380px, calc(100% - 32px)); padding: 16px; border: 1px solid var(--epub-line-strong); border-radius: 12px; background: var(--epub-surface); box-shadow: 0 20px 50px -18px rgba(0,0,0,.6); }
985
999
  .epub-dialog h3 { margin: 0 0 10px; font-size: 13px; }
1000
+ .epub-dialog-message { margin: 0 0 12px; color: var(--epub-muted); font-size: 10.5px; line-height: 1.6; }
986
1001
  .epub-dialog-input { width: 100%; height: 34px; padding: 0 9px; border: 1px solid var(--epub-line); border-radius: 7px; outline: 0; background: var(--dsw-alias-bg-base); color: var(--epub-text); font-size: 11px; }
987
1002
  .epub-dialog-input:focus { border-color: color-mix(in srgb, var(--epub-accent) 45%, var(--epub-line)); }
988
1003
  .epub-dialog-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 12px; }
989
1004
  .epub-dossier-actions { display: flex; align-items: center; gap: 7px; margin-left: auto; }
1005
+ .epub-dossier-summary-btn { height: 30px; padding: 0 8px; font-size: 10px; }
990
1006
  .epub-dossier-search { display: flex; align-items: center; gap: 5px; height: 30px; padding: 0 8px; border: 1px solid var(--epub-line); border-radius: 7px; background: var(--dsw-alias-bg-base); color: var(--epub-muted); }
991
1007
  .epub-dossier-search input { width: 190px; border: 0; outline: 0; background: transparent; color: var(--epub-text); font-size: 10px; }
992
1008
  .epub-dossier-hint { margin: 0 0 10px; color: var(--epub-muted); font-size: 9px; line-height: 1.5; }
@@ -1002,7 +1018,14 @@ window.__ModuleLoader__.load({
1002
1018
  .epub-dossier-skill-add { display: grid; place-items: center; width: 20px; height: 20px; border: 0; border-radius: 4px; background: transparent; color: var(--epub-muted); font-size: 14px; line-height: 1; }
1003
1019
  .epub-dossier-skill-add:hover { color: var(--epub-text); background: var(--epub-surface-2); }
1004
1020
  .epub-dossier-skill-empty { margin: 3px 0 0; color: var(--epub-muted); font-size: 8.5px; line-height: 1.5; }
1005
- .epub-dossier-skill.is-empty { color: var(--epub-muted); font-style: italic; }
1021
+ .epub-dossier-skill.is-empty { color: var(--epub-muted); font-style: italic; font-size: 8px; }
1022
+ .epub-dossier-importing { display: flex; align-items: center; gap: 7px; margin: 0 0 9px; padding: 7px 9px; border: 1px solid var(--epub-line); border-radius: 7px; background: var(--epub-surface); color: var(--epub-muted); font-size: 9.5px; }
1023
+ .epub-dossier-stop { color: var(--epub-danger); border: 1px solid color-mix(in srgb, var(--epub-danger) 26%, var(--epub-line)); }
1024
+ .epub-dossier-stop:hover { background: color-mix(in srgb, var(--epub-danger) 8%, transparent); }
1025
+ .epub-dossier-searching { display: inline-flex; align-items: center; gap: 5px; color: var(--epub-muted); font-size: 9px; white-space: nowrap; }
1026
+ .epub-dossier-search-count { color: var(--epub-muted); font-size: 9px; white-space: nowrap; }
1027
+ .epub-btn-danger { border-color: color-mix(in srgb, var(--epub-danger) 28%, var(--epub-line)); color: var(--epub-danger); background: transparent; }
1028
+ .epub-btn-danger:hover { background: color-mix(in srgb, var(--epub-danger) 8%, transparent); }
1006
1029
  .epub-skill-editor { display: flex; flex-direction: column; width: min(620px, 100%); max-height: min(82vh, 680px); border: 1px solid var(--epub-line-strong); border-radius: 13px; background: var(--epub-surface); box-shadow: 0 22px 60px -18px rgba(0,0,0,.6); }
1007
1030
  .epub-skill-editor header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; padding: 14px 16px 0; }
1008
1031
  .epub-skill-editor h3 { margin: 2px 0 0; font-size: 15px; }
@@ -1657,6 +1680,13 @@ window.__ModuleLoader__.load({
1657
1680
  return iconBase(props ?? {}, [h('path', { key: 'a', d: 'M12 5v14M5 12h14' })])
1658
1681
  }
1659
1682
 
1683
+ function IconStopCircle(props) {
1684
+ return iconBase(props ?? {}, [
1685
+ h('circle', { key: 'a', cx: 12, cy: 12, r: 8.5 }),
1686
+ h('rect', { key: 'b', x: 9.2, y: 9.2, width: 5.6, height: 5.6, rx: 1, fill: 'currentColor', stroke: 'none' }),
1687
+ ])
1688
+ }
1689
+
1660
1690
  function IconFolderPlus(props) {
1661
1691
  return iconBase(props ?? {}, [
1662
1692
  h('path', { key: 'a', d: 'M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z' }),
@@ -3022,10 +3052,12 @@ window.__ModuleLoader__.load({
3022
3052
  const [contextMenu, setContextMenu] = React.useState(null)
3023
3053
  const [importFolderId, setImportFolderId] = React.useState(null)
3024
3054
  const [expandedFolders, setExpandedFolders] = React.useState(() => new Set())
3055
+ const [clearConfirm, setClearConfirm] = React.useState(null)
3025
3056
  const [skillEditor, setSkillEditor] = React.useState(null)
3026
3057
  const [summary, setSummary] = React.useState({ open: false, status: 'idle', text: '', title: '', error: '' })
3027
3058
  const fileRef = React.useRef(null)
3028
3059
  const summaryAbort = React.useRef(null)
3060
+ const searchAbort = React.useRef(null)
3029
3061
 
3030
3062
  const load = React.useCallback(async () => {
3031
3063
  setState((current) => ({ ...current, status: current.items.length === 0 ? 'loading' : current.status, error: '' }))
@@ -3051,11 +3083,21 @@ window.__ModuleLoader__.load({
3051
3083
  setExpandedFolders((current) => new Set(current).add(activeFolder))
3052
3084
  }, [activeFolder])
3053
3085
 
3086
+ React.useEffect(() => {
3087
+ searchAbort.current?.abort()
3088
+ searchAbort.current = null
3089
+ }, [activeFolder])
3090
+
3091
+ React.useEffect(() => () => {
3092
+ searchAbort.current?.abort()
3093
+ summaryAbort.current?.abort()
3094
+ }, [])
3095
+
3054
3096
  const folderId = activeFolder === 'all' || activeFolder === 'root' ? '' : activeFolder
3055
3097
  const folder = state.folders.find((entry) => entry.id === activeFolder)
3056
3098
  const folderLabel = activeFolder === 'all' ? t('dossiers.all') : activeFolder === 'root' ? t('dossiers.root') : folder?.name ?? t('dossiers.title')
3057
3099
  const documents = state.items.filter((item) => item.kind === 'document' && (activeFolder === 'all' || item.folderId === folderId))
3058
- const shown = search.query !== '' && search.results.length > 0 ? search.results : documents
3100
+ const shown = search.query !== '' ? search.results : documents
3059
3101
 
3060
3102
  function skillsFor(id) {
3061
3103
  return state.items.filter((item) => item.kind === 'skill' && item.folderId === id)
@@ -3127,10 +3169,18 @@ window.__ModuleLoader__.load({
3127
3169
  try {
3128
3170
  for (const file of list) {
3129
3171
  try {
3130
- await apiUpload('/dossiers/import', file, {
3172
+ const data = await apiUpload('/dossiers/import', file, {
3131
3173
  'x-dossier-name': encodeURIComponent(file.name),
3132
3174
  'x-dossier-folder': targetFolder,
3133
3175
  })
3176
+ if (data?.item !== undefined) {
3177
+ setState((current) => ({
3178
+ status: 'ready',
3179
+ error: '',
3180
+ folders: current.folders,
3181
+ items: [...current.items.filter((entry) => entry.id !== data.item.id), data.item],
3182
+ }))
3183
+ }
3134
3184
  } catch (reason) {
3135
3185
  failures.push(`${file.name}:${reason instanceof Error ? reason.message : String(reason)}`)
3136
3186
  }
@@ -3144,16 +3194,42 @@ window.__ModuleLoader__.load({
3144
3194
  }
3145
3195
  }
3146
3196
 
3197
+ function stopSearch() {
3198
+ searchAbort.current?.abort()
3199
+ searchAbort.current = null
3200
+ setSearch((current) => ({ ...current, busy: false }))
3201
+ }
3202
+
3147
3203
  async function runSearch(event) {
3148
3204
  if (event !== undefined) event.preventDefault()
3149
3205
  const value = query.trim()
3150
- if (value === '') { setSearch({ busy: false, results: [], error: '', query: '' }); return }
3151
- setSearch((current) => ({ ...current, busy: true, error: '', query: value }))
3206
+ if (value === '') { stopSearch(); setSearch({ busy: false, results: [], error: '', query: '' }); return }
3207
+ searchAbort.current?.abort()
3208
+ const controller = new AbortController()
3209
+ searchAbort.current = controller
3210
+ setSearch({ busy: true, results: [], error: '', query: value })
3152
3211
  try {
3153
- const data = await apiGet(`/dossiers/search?folder=${encodeURIComponent(folderId)}&q=${encodeURIComponent(value)}`)
3154
- setSearch({ busy: false, results: data.matches ?? [], error: '', query: value })
3212
+ await apiStreamSearch(`/dossiers/search/stream?folder=${encodeURIComponent(folderId)}&q=${encodeURIComponent(value)}`, {
3213
+ signal: controller.signal,
3214
+ onEvent: (streamEvent) => {
3215
+ if (streamEvent.type === 'match' && streamEvent.match !== undefined) {
3216
+ setSearch((current) => ({ ...current, results: [...current.results, streamEvent.match] }))
3217
+ } else if (streamEvent.type === 'done') {
3218
+ setSearch((current) => ({ ...current, busy: false }))
3219
+ } else if (streamEvent.type === 'error') {
3220
+ setSearch((current) => ({ ...current, busy: false, error: String(streamEvent.error ?? '搜索失败') }))
3221
+ }
3222
+ },
3223
+ })
3224
+ setSearch((current) => ({ ...current, busy: false }))
3155
3225
  } catch (reason) {
3156
- setSearch({ busy: false, results: [], error: reason instanceof Error ? reason.message : String(reason), query: value })
3226
+ if (controller.signal.aborted) {
3227
+ setSearch((current) => ({ ...current, busy: false }))
3228
+ return
3229
+ }
3230
+ setSearch((current) => ({ ...current, busy: false, error: reason instanceof Error ? reason.message : String(reason) }))
3231
+ } finally {
3232
+ if (searchAbort.current === controller) searchAbort.current = null
3157
3233
  }
3158
3234
  }
3159
3235
 
@@ -3227,6 +3303,20 @@ window.__ModuleLoader__.load({
3227
3303
  setSummary({ open: false, status: 'idle', text: '', title: '', error: '' })
3228
3304
  }
3229
3305
 
3306
+ async function clearFolder() {
3307
+ if (clearConfirm === null) return
3308
+ try {
3309
+ await apiPost('/dossiers/clear', { folderId: clearConfirm.scope })
3310
+ setClearConfirm(null)
3311
+ stopSearch()
3312
+ setSearch({ busy: false, results: [], error: '', query: '' })
3313
+ setQuery('')
3314
+ await load()
3315
+ } catch (reason) {
3316
+ setError(reason instanceof Error ? reason.message : String(reason))
3317
+ }
3318
+ }
3319
+
3230
3320
  function summaryScopeIds() {
3231
3321
  if (search.query !== '' && search.results.length > 0) return search.results.map((entry) => entry.id)
3232
3322
  return []
@@ -3254,7 +3344,7 @@ window.__ModuleLoader__.load({
3254
3344
  h('button', { type: 'button', className: cn('epub-paper-folder', activeFolder === 'all' && 'is-active'), onClick: () => { setActiveFolder('all'); setSearch({ busy: false, results: [], error: '', query: '' }); setQuery('') } }, t('dossiers.all'), h('span', null, String(state.items.filter((item) => item.kind === 'document').length))),
3255
3345
  h('button', { type: 'button', className: 'epub-paper-folder-add', onClick: () => openDialog('folder'), title: t('dossiers.newFolder'), 'aria-label': t('dossiers.newFolder') }, h(IconFolderPlus, { size: 15 })),
3256
3346
  ),
3257
- h('div', { className: 'epub-dossier-folder-group' },
3347
+ h('div', { className: 'epub-dossier-folder-group', onContextMenu: (event) => openContext(event, 'root') },
3258
3348
  h('div', { className: cn('epub-paper-folder-wrap', activeFolder === 'root' && 'is-active') },
3259
3349
  h('button', { type: 'button', className: 'epub-dossier-expand', onClick: () => toggleFolder('root'), title: expandedFolders.has('root') ? t('dossiers.collapse') : t('dossiers.expand'), 'aria-label': expandedFolders.has('root') ? t('dossiers.collapse') : t('dossiers.expand') }, expandedFolders.has('root') ? '▾' : '▸'),
3260
3350
  h('button', { type: 'button', className: cn('epub-paper-folder', activeFolder === 'root' && 'is-active'), onClick: () => { setActiveFolder('root'); setSearch({ busy: false, results: [], error: '', query: '' }); setQuery('') } }, t('dossiers.root'), h('span', null, String(state.items.filter((item) => item.kind === 'document' && item.folderId === '').length))),
@@ -3282,20 +3372,29 @@ window.__ModuleLoader__.load({
3282
3372
  h(IconSearch, { size: 14 }),
3283
3373
  h('input', { value: query, onChange: (event) => setQuery(event.target.value), placeholder: t('dossiers.searchPlaceholder'), 'aria-label': t('dossiers.searchPlaceholder'), type: 'search' }),
3284
3374
  ),
3285
- h('button', { type: 'button', className: 'epub-btn epub-btn-primary', onClick: () => void startSummary({ ids: summaryScopeIds(), query: search.query, title: search.query === '' ? t('dossiers.summaryTitle') : t('dossiers.summarySearchTitle') }), disabled: documents.length === 0 && search.results.length === 0 }, h(IconSparkles, { size: 13 }), t('dossiers.summary')),
3375
+ search.busy
3376
+ ? h('button', { type: 'button', className: 'epub-icon-btn epub-dossier-stop', onClick: stopSearch, title: t('dossiers.stopSearch'), 'aria-label': t('dossiers.stopSearch') }, h(IconStopCircle, { size: 16 }))
3377
+ : search.query !== ''
3378
+ ? h('span', { className: 'epub-dossier-search-count' }, format(t('dossiers.searchCount'), { count: search.results.length }))
3379
+ : null,
3380
+ h('button', { type: 'button', className: 'epub-btn epub-btn-primary epub-dossier-summary-btn', onClick: () => void startSummary({ ids: summaryScopeIds(), query: search.query, title: search.query === '' ? t('dossiers.summaryTitle') : t('dossiers.summarySearchTitle') }), disabled: search.busy || (documents.length === 0 && search.results.length === 0) }, h(IconSparkles, { size: 12 }), t('dossiers.summary')),
3286
3381
  ),
3287
3382
  ),
3288
3383
  h('div', { className: 'epub-dossier-hint' }, t('dossiers.skillHint')),
3289
3384
  search.error !== '' ? h('div', { className: 'epub-dossier-empty' }, search.error) : null,
3290
3385
  importing
3291
- ? h('div', { className: 'epub-paper-empty' }, h(Spinner, null), h('p', null, t('dossiers.importing')))
3292
- : state.status === 'loading'
3293
- ? h('div', { className: 'epub-paper-empty' }, h(Spinner, null), h('p', null, t('papers.importing')))
3294
- : state.status === 'error'
3295
- ? h(ErrorBanner, { title: t('dossiers.importFailed'), message: state.error, actionLabel: t('papers.retry'), onAction: () => void load() })
3296
- : shown.length === 0
3297
- ? h('div', { className: 'epub-paper-empty' }, h(IconFolderPlus, { size: 30 }), h('h3', null, search.query !== '' ? t('dossiers.searchEmpty') : t('dossiers.empty')), h('p', null, search.query !== '' ? t('dossiers.searchEmptyHint') : t('dossiers.emptyHint')))
3298
- : h('div', { className: 'epub-dossier-grid' },
3386
+ ? h('div', { className: 'epub-dossier-importing' }, h(Spinner, null), h('span', null, t('dossiers.importing')))
3387
+ : null,
3388
+ state.status === 'loading' && state.items.length === 0
3389
+ ? h('div', { className: 'epub-paper-empty' }, h(Spinner, null), h('p', null, t('papers.importing')))
3390
+ : state.status === 'error'
3391
+ ? h(ErrorBanner, { title: t('dossiers.importFailed'), message: state.error, actionLabel: t('papers.retry'), onAction: () => void load() })
3392
+ : search.busy && shown.length === 0
3393
+ ? h('div', { className: 'epub-paper-empty' }, h(Spinner, null))
3394
+ : shown.length === 0 && !importing
3395
+ ? h('div', { className: 'epub-paper-empty' }, h(IconFolderPlus, { size: 30 }), h('h3', null, search.query !== '' ? t('dossiers.searchEmpty') : t('dossiers.empty')), h('p', null, search.query !== '' ? t('dossiers.searchEmptyHint') : t('dossiers.emptyHint')))
3396
+ : shown.length > 0
3397
+ ? h('div', { className: 'epub-dossier-grid' },
3299
3398
  shown.map((item) => h('article', {
3300
3399
  key: item.id,
3301
3400
  className: 'epub-dossier-card',
@@ -3307,20 +3406,34 @@ window.__ModuleLoader__.load({
3307
3406
  h('p', null, search.query !== '' && item.snippet !== undefined ? item.snippet : (item.preview || t('dossiers.openHint'))),
3308
3407
  h('div', { className: 'epub-dossier-meta' }, h('span', null, formatBytes(item.size ?? 0)), h('span', null, t('dossiers.open'))),
3309
3408
  )),
3310
- ),
3409
+ )
3410
+ : null,
3311
3411
  ),
3312
3412
  ),
3313
3413
  contextMenu !== null
3314
3414
  ? h('div', { className: 'epub-context-menu', style: { left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }, onClick: (event) => event.stopPropagation() },
3315
- contextMenu.kind === 'library' || contextMenu.kind === 'folder'
3415
+ contextMenu.kind === 'library' || contextMenu.kind === 'folder' || contextMenu.kind === 'root'
3316
3416
  ? h('button', { type: 'button', className: 'epub-context-item', onClick: () => { setContextMenu(null); openDialog('folder') } }, t('dossiers.newFolder'))
3317
3417
  : null,
3318
- contextMenu.kind === 'folder'
3319
- ? h('button', { type: 'button', className: 'epub-context-item', onClick: () => { const id = contextMenu.folderId; setContextMenu(null); setActiveFolder(id); setImportFolderId(id); fileRef.current?.click() } }, t('dossiers.importHere'))
3418
+ contextMenu.kind === 'folder' || contextMenu.kind === 'root'
3419
+ ? h('button', { type: 'button', className: 'epub-context-item', onClick: () => { const id = contextMenu.kind === 'root' ? '' : contextMenu.folderId; setContextMenu(null); setActiveFolder(id === '' ? 'root' : id); setImportFolderId(id); fileRef.current?.click() } }, t('dossiers.importHere'))
3320
3420
  : null,
3321
3421
  contextMenu.kind === 'folder'
3322
3422
  ? h('button', { type: 'button', className: 'epub-context-item', onClick: () => { const entry = state.folders.find((value) => value.id === contextMenu.folderId); setContextMenu(null); if (entry !== undefined) openDialog('folderRename', entry.name, entry.id) } }, t('dossiers.renameFolder'))
3323
3423
  : null,
3424
+ contextMenu.kind === 'folder' || contextMenu.kind === 'root'
3425
+ ? h('button', {
3426
+ type: 'button',
3427
+ className: 'epub-context-item is-danger',
3428
+ onClick: () => {
3429
+ const id = contextMenu.kind === 'root' ? '' : contextMenu.folderId
3430
+ const entry = contextMenu.kind === 'folder' ? state.folders.find((value) => value.id === id) : undefined
3431
+ const count = state.items.filter((item) => item.kind === 'document' && item.folderId === id).length
3432
+ setContextMenu(null)
3433
+ setClearConfirm({ scope: id, count, label: entry?.name ?? t('dossiers.root') })
3434
+ },
3435
+ }, t('dossiers.clear'))
3436
+ : null,
3324
3437
  contextMenu.kind === 'item'
3325
3438
  ? (() => {
3326
3439
  const item = state.items.find((entry) => entry.id === contextMenu.itemId)
@@ -3362,6 +3475,18 @@ window.__ModuleLoader__.load({
3362
3475
  ),
3363
3476
  )
3364
3477
  : null,
3478
+ clearConfirm !== null
3479
+ ? h('div', { className: 'epub-dialog-backdrop', onMouseDown: (event) => { if (event.target === event.currentTarget) setClearConfirm(null) } },
3480
+ h('div', { className: 'epub-dialog' },
3481
+ h('h3', null, format(t('dossiers.clearTitle'), { name: clearConfirm.label })),
3482
+ h('p', { className: 'epub-dialog-message' }, format(t('dossiers.clearHint'), { count: clearConfirm.count })),
3483
+ h('div', { className: 'epub-dialog-actions' },
3484
+ h('button', { type: 'button', className: 'epub-btn', onClick: () => setClearConfirm(null) }, t('papers.cancel')),
3485
+ h('button', { type: 'button', className: 'epub-btn epub-btn-danger', onClick: () => void clearFolder() }, t('dossiers.clearConfirm')),
3486
+ ),
3487
+ ),
3488
+ )
3489
+ : null,
3365
3490
  skillEditor !== null
3366
3491
  ? h('div', { className: 'epub-dialog-backdrop', onMouseDown: (event) => { if (event.target === event.currentTarget) setSkillEditor(null) } },
3367
3492
  h('section', { className: 'epub-skill-editor' },
package/index.js CHANGED
@@ -1359,6 +1359,7 @@ function createHandler(ctx, config) {
1359
1359
  const store = new BookStore(MAX_OPEN_BOOKS)
1360
1360
  let catalog = new Map()
1361
1361
  const searchCache = new Map()
1362
+ const dossierTextCache = new Map()
1362
1363
  let annotationQueue = Promise.resolve()
1363
1364
 
1364
1365
  function queueAnnotation(operation) {
@@ -1811,6 +1812,7 @@ function createHandler(ctx, config) {
1811
1812
  const root = dossierRootFor(archiveDir)
1812
1813
  await fs.mkdir(path.join(root, 'files'), { recursive: true })
1813
1814
  await fs.mkdir(path.join(root, 'skills'), { recursive: true })
1815
+ await fs.mkdir(path.join(root, 'index'), { recursive: true })
1814
1816
  return root
1815
1817
  }
1816
1818
 
@@ -1895,8 +1897,25 @@ function createHandler(ctx, config) {
1895
1897
  }
1896
1898
 
1897
1899
  async function dossierTextForItem(item, archiveDir) {
1900
+ if (item.kind === 'skill') return String(item.text ?? '')
1901
+ const cached = dossierTextCache.get(item.id)
1902
+ if (cached !== undefined) return cached
1903
+ const root = await ensureDossierDirs(archiveDir)
1904
+ const indexPath = item.indexPath || path.join(root, 'index', `${item.id}.txt`)
1898
1905
  try {
1899
- return dossierPlainText(await loadDossierBook(item, archiveDir))
1906
+ const text = await fs.readFile(indexPath, 'utf8')
1907
+ dossierTextCache.set(item.id, text)
1908
+ item.indexPath = indexPath
1909
+ return text
1910
+ } catch (error) {
1911
+ if (error?.code !== 'ENOENT') return ''
1912
+ }
1913
+ try {
1914
+ const text = dossierPlainText(await loadDossierBook(item, archiveDir))
1915
+ await fs.writeFile(indexPath, text, 'utf8')
1916
+ item.indexPath = indexPath
1917
+ dossierTextCache.set(item.id, text)
1918
+ return text
1900
1919
  } catch {
1901
1920
  return ''
1902
1921
  }
@@ -1949,7 +1968,10 @@ function createHandler(ctx, config) {
1949
1968
  book.annotationKey = `sha1-${digest}`
1950
1969
  store.put(book)
1951
1970
  const library = await readDossierLibrary(archiveDir)
1952
- const item = { id, kind: 'document', title: book.title && book.title !== '未命名书籍' ? book.title : path.basename(name, extension), path: target, folderId, addedAt: Date.now(), size: buffer.length, format: book.format ?? path.extname(name).slice(1).toUpperCase(), pageCount: book.paperPages?.length ?? 0 }
1971
+ const indexPath = path.join(root, 'index', `${id}.txt`)
1972
+ await fs.writeFile(indexPath, text, 'utf8')
1973
+ dossierTextCache.set(id, text)
1974
+ const item = { id, kind: 'document', title: book.title && book.title !== '未命名书籍' ? book.title : path.basename(name, extension), path: target, indexPath, folderId, addedAt: Date.now(), size: buffer.length, format: book.format ?? path.extname(name).slice(1).toUpperCase(), pageCount: book.paperPages?.length ?? 0 }
1953
1975
  library.items = library.items.filter((entry) => entry.id !== id)
1954
1976
  library.items.push(item)
1955
1977
  await writeDossierLibrary(archiveDir, library)
@@ -1967,6 +1989,8 @@ function createHandler(ctx, config) {
1967
1989
  if (typeof item.path === 'string' && item.path !== '') {
1968
1990
  try { await fs.rename(item.path, path.join(trash, `${Date.now()}-${path.basename(item.path)}`)) } catch { /* already gone */ }
1969
1991
  }
1992
+ try { await fs.unlink(path.join(root, 'index', `${item.id}.txt`)) } catch { /* no index */ }
1993
+ dossierTextCache.delete(item.id)
1970
1994
  library.items = library.items.filter((entry) => entry.id !== id)
1971
1995
  store.remove(id)
1972
1996
  await writeDossierLibrary(archiveDir, library)
@@ -1996,6 +2020,75 @@ function createHandler(ctx, config) {
1996
2020
  return { matches, total: matches.length, query }
1997
2021
  }
1998
2022
 
2023
+ async function streamDossierSearch(folderId, query, res) {
2024
+ const archiveDir = await ensureArchiveDir(config, ctx)
2025
+ const library = await readDossierLibrary(archiveDir)
2026
+ const terms = [...new Set([query, traditionalizeChinese(query)])].filter((entry) => entry.trim() !== '')
2027
+ const needles = terms.map((entry) => entry.toLowerCase())
2028
+ const docs = library.items.filter((item) => item.kind === 'document' && (folderId === '' || item.folderId === folderId))
2029
+ let closed = false
2030
+ res.on('close', () => { if (!res.writableEnded) closed = true })
2031
+ res.writeHead(200, {
2032
+ 'Content-Type': 'text/event-stream; charset=utf-8',
2033
+ 'Cache-Control': 'no-cache, no-transform',
2034
+ Connection: 'keep-alive',
2035
+ 'X-Accel-Buffering': 'no',
2036
+ 'X-Content-Type-Options': 'nosniff',
2037
+ })
2038
+ if (typeof res.flushHeaders === 'function') res.flushHeaders()
2039
+ await writeSse(res, ': connected\n\n')
2040
+ let total = 0
2041
+ try {
2042
+ for (const item of docs) {
2043
+ if (closed) break
2044
+ const text = await dossierTextForItem(item, archiveDir)
2045
+ const lower = text.toLowerCase()
2046
+ let best = -1
2047
+ for (const needle of needles) {
2048
+ const at = lower.indexOf(needle)
2049
+ if (at !== -1 && (best === -1 || at < best)) best = at
2050
+ }
2051
+ if (best === -1) continue
2052
+ const start = Math.max(0, best - 90)
2053
+ const match = { id: item.id, title: item.title, folderId: item.folderId ?? '', snippet: text.slice(start, best + 180).replace(/\s+/g, ' ').trim() }
2054
+ total += 1
2055
+ if (!(await writeSse(res, `data: ${JSON.stringify({ type: 'match', match })}\n\n`))) break
2056
+ if (total >= 60) break
2057
+ }
2058
+ if (!closed) {
2059
+ await writeSse(res, `data: ${JSON.stringify({ type: 'done', total, query })}\n\n`)
2060
+ res.end()
2061
+ }
2062
+ } catch (error) {
2063
+ if (!closed) {
2064
+ await writeSse(res, `event: error\ndata: ${JSON.stringify({ error: publicErrorMessage(error) })}\n\n`)
2065
+ res.end()
2066
+ }
2067
+ }
2068
+ }
2069
+
2070
+ async function clearDossiers(scope) {
2071
+ const archiveDir = await ensureArchiveDir(config, ctx)
2072
+ const library = await readDossierLibrary(archiveDir)
2073
+ const targets = library.items.filter((item) => item.kind === 'document' && (scope === 'all' || item.folderId === scope))
2074
+ if (targets.length === 0) return { cleared: 0 }
2075
+ const root = await ensureDossierDirs(archiveDir)
2076
+ const trash = path.join(root, 'trash')
2077
+ await fs.mkdir(trash, { recursive: true })
2078
+ for (const item of targets) {
2079
+ if (typeof item.path === 'string' && item.path !== '') {
2080
+ try { await fs.rename(item.path, path.join(trash, `${Date.now()}-${path.basename(item.path)}`)) } catch { /* already gone */ }
2081
+ }
2082
+ try { await fs.unlink(path.join(root, 'index', `${item.id}.txt`)) } catch { /* no index */ }
2083
+ dossierTextCache.delete(item.id)
2084
+ store.remove(item.id)
2085
+ }
2086
+ const ids = new Set(targets.map((item) => item.id))
2087
+ library.items = library.items.filter((item) => !ids.has(item.id))
2088
+ await writeDossierLibrary(archiveDir, library)
2089
+ return { cleared: targets.length }
2090
+ }
2091
+
1999
2092
  async function dossierSummary(req, res) {
2000
2093
  const payload = await readJson(req)
2001
2094
  const folderId = typeof payload.folderId === 'string' ? payload.folderId : ''
@@ -2011,8 +2104,8 @@ function createHandler(ctx, config) {
2011
2104
  const route = resolveModelRoute(ctx, config)
2012
2105
  if (route === undefined) throw new HttpError(503, 'NO_MODEL', '还没有选择模型')
2013
2106
  const sections = []
2014
- for (const item of selected.slice(0, 30)) {
2015
- const text = (await dossierTextForItem(item, archiveDir)).slice(0, 12000)
2107
+ for (const item of selected.slice(0, 40)) {
2108
+ const text = (await dossierTextForItem(item, archiveDir)).slice(0, 6000)
2016
2109
  if (text.trim() !== '') sections.push(`【${item.title}】\n${text}`)
2017
2110
  }
2018
2111
  if (sections.length === 0) throw new HttpError(422, 'DOSSIER_NO_TEXT', '卷宗文件没有可总结的文字')
@@ -2032,7 +2125,7 @@ function createHandler(ctx, config) {
2032
2125
  const controller = new AbortController()
2033
2126
  let closed = false
2034
2127
  res.on('close', () => { if (!res.writableEnded) { closed = true; controller.abort() } })
2035
- const request = { system, messages: [{ role: 'user', content: [{ type: 'text', text: prompt.slice(0, 120000) }] }], signal: controller.signal }
2128
+ const request = { system, messages: [{ role: 'user', content: [{ type: 'text', text: prompt.slice(0, 60000) }] }], signal: controller.signal }
2036
2129
  const call = { provider: route.provider, model: route.model, temperature: 0.2, maxTokens: 1800 }
2037
2130
  const stream = typeof llm.prepareCall === 'function'
2038
2131
  ? await (async () => { const prepared = await llm.prepareCall(call, controller.signal); return prepared.stream({ ...prepared.config, ...request }) })()
@@ -2695,6 +2788,21 @@ function createHandler(ctx, config) {
2695
2788
  return
2696
2789
  }
2697
2790
 
2791
+ if (method === 'GET' && pathname === '/dossiers/search/stream') {
2792
+ const folder = (url.searchParams.get('folder') ?? '').trim()
2793
+ const query = (url.searchParams.get('q') ?? '').trim()
2794
+ if (query === '') throw new HttpError(400, 'EMPTY_QUERY', '搜索内容不能为空')
2795
+ await streamDossierSearch(folder, query, res)
2796
+ return
2797
+ }
2798
+
2799
+ if (method === 'POST' && pathname === '/dossiers/clear') {
2800
+ const payload = await readJson(req)
2801
+ const scope = typeof payload.folderId === 'string' ? payload.folderId : ''
2802
+ sendJson(res, 200, await clearDossiers(scope))
2803
+ return
2804
+ }
2805
+
2698
2806
  if (method === 'GET' && pathname === '/dossiers/search') {
2699
2807
  const folder = (url.searchParams.get('folder') ?? '').trim()
2700
2808
  const query = (url.searchParams.get('q') ?? '').trim()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ai-book-shelf",
3
- "version": "2.6.4",
3
+ "version": "2.6.9",
4
4
  "description": "AI Book Shelf: read books and papers in Harness, search full text, explain and discuss with AI, translate papers and compare bilingual text.",
5
5
  "keywords": [
6
6
  "dsh-plugin",