@lemoncat7/dsh-knowledge 1.1.5 → 2.0.0

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 (128) hide show
  1. package/README.md +24 -4
  2. package/docs/architecture.md +32 -6
  3. package/docs/requirements.md +10 -0
  4. package/lib/api.d.ts +2 -0
  5. package/lib/api.d.ts.map +1 -1
  6. package/lib/api.js +235 -13
  7. package/lib/api.js.map +1 -1
  8. package/lib/atomic-file.d.ts +16 -0
  9. package/lib/atomic-file.d.ts.map +1 -0
  10. package/lib/atomic-file.js +92 -0
  11. package/lib/atomic-file.js.map +1 -0
  12. package/lib/client.d.ts.map +1 -1
  13. package/lib/client.js +38 -35
  14. package/lib/client.js.map +2 -2
  15. package/lib/config.d.ts.map +1 -1
  16. package/lib/config.js +2 -5
  17. package/lib/config.js.map +1 -1
  18. package/lib/connection.d.ts.map +1 -1
  19. package/lib/connection.js +5 -15
  20. package/lib/connection.js.map +1 -1
  21. package/lib/content-safety.d.ts +12 -0
  22. package/lib/content-safety.d.ts.map +1 -0
  23. package/lib/content-safety.js +38 -0
  24. package/lib/content-safety.js.map +1 -0
  25. package/lib/documents/projection-queue.d.ts +10 -0
  26. package/lib/documents/projection-queue.d.ts.map +1 -0
  27. package/lib/documents/projection-queue.js +21 -0
  28. package/lib/documents/projection-queue.js.map +1 -0
  29. package/lib/documents/store.d.ts +4 -2
  30. package/lib/documents/store.d.ts.map +1 -1
  31. package/lib/documents/store.js +54 -73
  32. package/lib/documents/store.js.map +1 -1
  33. package/lib/domain.d.ts +13 -0
  34. package/lib/domain.d.ts.map +1 -1
  35. package/lib/domain.js.map +1 -1
  36. package/lib/extraction.d.ts.map +1 -1
  37. package/lib/extraction.js +18 -4
  38. package/lib/extraction.js.map +1 -1
  39. package/lib/index.d.ts +1 -0
  40. package/lib/index.d.ts.map +1 -1
  41. package/lib/index.js +91 -32
  42. package/lib/index.js.map +1 -1
  43. package/lib/knowledge-merge.d.ts +8 -0
  44. package/lib/knowledge-merge.d.ts.map +1 -0
  45. package/lib/knowledge-merge.js +19 -0
  46. package/lib/knowledge-merge.js.map +1 -0
  47. package/lib/local-provider.d.ts +26 -1
  48. package/lib/local-provider.d.ts.map +1 -1
  49. package/lib/local-provider.js +402 -63
  50. package/lib/local-provider.js.map +1 -1
  51. package/lib/management-proxy.d.ts.map +1 -1
  52. package/lib/management-proxy.js +22 -13
  53. package/lib/management-proxy.js.map +1 -1
  54. package/lib/note-reference-handle.d.ts +15 -0
  55. package/lib/note-reference-handle.d.ts.map +1 -0
  56. package/lib/note-reference-handle.js +54 -0
  57. package/lib/note-reference-handle.js.map +1 -0
  58. package/lib/note-reference-tools.d.ts +6 -0
  59. package/lib/note-reference-tools.d.ts.map +1 -0
  60. package/lib/note-reference-tools.js +119 -0
  61. package/lib/note-reference-tools.js.map +1 -0
  62. package/lib/note-tools.d.ts +6 -0
  63. package/lib/note-tools.d.ts.map +1 -0
  64. package/lib/note-tools.js +283 -0
  65. package/lib/note-tools.js.map +1 -0
  66. package/lib/notes/domain.d.ts +44 -0
  67. package/lib/notes/domain.d.ts.map +1 -0
  68. package/lib/notes/domain.js +38 -0
  69. package/lib/notes/domain.js.map +1 -0
  70. package/lib/notes/store.d.ts +39 -0
  71. package/lib/notes/store.d.ts.map +1 -0
  72. package/lib/notes/store.js +407 -0
  73. package/lib/notes/store.js.map +1 -0
  74. package/lib/provider.d.ts +17 -0
  75. package/lib/provider.d.ts.map +1 -1
  76. package/lib/remote-provider.d.ts +19 -0
  77. package/lib/remote-provider.d.ts.map +1 -1
  78. package/lib/remote-provider.js +172 -14
  79. package/lib/remote-provider.js.map +1 -1
  80. package/lib/remote-url.d.ts +2 -0
  81. package/lib/remote-url.d.ts.map +1 -0
  82. package/lib/remote-url.js +14 -0
  83. package/lib/remote-url.js.map +1 -0
  84. package/lib/runtime.d.ts +1 -0
  85. package/lib/runtime.d.ts.map +1 -1
  86. package/lib/runtime.js.map +1 -1
  87. package/lib/service-settings.d.ts.map +1 -1
  88. package/lib/service-settings.js +3 -10
  89. package/lib/service-settings.js.map +1 -1
  90. package/lib/theme-bridge.js +12 -12
  91. package/lib/theme-bridge.js.map +1 -1
  92. package/lib/tool-authorization.d.ts +14 -0
  93. package/lib/tool-authorization.d.ts.map +1 -0
  94. package/lib/tool-authorization.js +124 -0
  95. package/lib/tool-authorization.js.map +1 -0
  96. package/lib/tool-input.d.ts +6 -0
  97. package/lib/tool-input.d.ts.map +1 -0
  98. package/lib/tool-input.js +27 -0
  99. package/lib/tool-input.js.map +1 -0
  100. package/lib/tools.d.ts +2 -1
  101. package/lib/tools.d.ts.map +1 -1
  102. package/lib/tools.js +70 -177
  103. package/lib/tools.js.map +1 -1
  104. package/lib/tracking.d.ts +29 -0
  105. package/lib/tracking.d.ts.map +1 -0
  106. package/lib/tracking.js +141 -0
  107. package/lib/tracking.js.map +1 -0
  108. package/lib/web-change-review.d.ts +34 -0
  109. package/lib/web-change-review.d.ts.map +1 -0
  110. package/lib/web-change-review.js +147 -0
  111. package/lib/web-change-review.js.map +1 -0
  112. package/lib/web-markdown-preview.d.ts.map +1 -1
  113. package/lib/web-markdown-preview.js +10 -0
  114. package/lib/web-markdown-preview.js.map +1 -1
  115. package/lib/web-note-editor.d.ts +44 -0
  116. package/lib/web-note-editor.d.ts.map +1 -0
  117. package/lib/web-note-editor.js +118 -0
  118. package/lib/web-note-editor.js.map +1 -0
  119. package/lib/web.d.ts.map +1 -1
  120. package/lib/web.js +3 -1
  121. package/lib/web.js.map +1 -1
  122. package/package.json +12 -1
  123. package/web/app.js +1888 -154
  124. package/web/change-review.js +5 -0
  125. package/web/index.html +2 -0
  126. package/web/markdown-preview.js +8 -8
  127. package/web/note-editor.js +220 -0
  128. package/web/styles.css +599 -170
package/web/app.js CHANGED
@@ -1,5 +1,7 @@
1
1
  const API_BASE = document.querySelector('meta[name="dsh-knowledge-api"]')?.content || '/knowledge-api/v1'
2
2
  const AUTH_MODE = document.querySelector('meta[name="dsh-knowledge-auth-mode"]')?.content || 'bearer'
3
+ const WEB_PATH = document.querySelector('meta[name="dsh-knowledge-web"]')?.content || '/knowledge'
4
+ const ASSET_VERSION = document.querySelector('meta[name="dsh-knowledge-asset-version"]')?.content || ''
3
5
  const HOST_THEME_MESSAGE = '@lemoncat7/dsh-knowledge/host-theme'
4
6
  const HOST_THEME_READY_MESSAGE = '@lemoncat7/dsh-knowledge/host-theme-ready'
5
7
  const HOST_THEME_PROTOCOL_VERSION = 1
@@ -20,6 +22,7 @@ const WRITE_MODE_LABELS = { none: '仅召回', audit: '审核写入', direct: '
20
22
  const EVIDENCE_LABELS = { explicit: '用户明确', verified: '结果已验证', inferred: '模型推断' }
21
23
  const DOCUMENT_STATE_LABELS = { open: '进行中', resolved: '已解决', complete: '已收集完成' }
22
24
  const DOCUMENT_LAYOUT_KEY = 'dsh-knowledge.document-layout'
25
+ const NOTE_MAX_FILE_SIZE = 64 * 1024 * 1024
23
26
  const pageParams = new URLSearchParams(location.search)
24
27
  const mountContext = {
25
28
  sessionId: pageParams.get('sessionId')?.trim() || '',
@@ -32,7 +35,9 @@ const savedDocumentLayout = readDocumentLayout()
32
35
  function createDocumentViewState(overrides = {}) {
33
36
  return {
34
37
  knowledgeBaseId: '', documentId: '', query: '', mode: 'preview', treeOpen: false,
35
- expandedBases: new Set(), editor: null, ...overrides,
38
+ expandedBases: new Set(), documentPages: new Map(),
39
+ searchResults: [], searchNextCursor: '', searchTotal: 0, searchLoading: false, searchError: '', searchRequest: 0,
40
+ editor: null, editorLoading: false, loadingDocumentId: '', ...overrides,
36
41
  }
37
42
  }
38
43
 
@@ -75,13 +80,35 @@ const state = {
75
80
  modelCatalog: null,
76
81
  settingsSaving: false,
77
82
  tokens: [],
83
+ notes: {
84
+ children: new Map(), loadedFolders: new Set(), expandedFolders: new Set(),
85
+ selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [],
86
+ content: '', draft: '', dirty: false, assetUrl: '', query: '', searchResults: [],
87
+ transfer: null, loadingNodeId: '',
88
+ },
78
89
  service: { publicApiEnabled: false, publicApiPrefix: '/knowledge-api/v1', remote: false },
79
90
  scrollPositions: new Map(),
80
91
  loading: false,
92
+ loadingPhase: '',
93
+ loadingProgress: 0,
81
94
  error: '',
82
95
  }
83
96
 
84
97
  let scrollRestoreFrame = 0
98
+ let navigationController = null
99
+ let navigationRequest = 0
100
+ let navigationSave = Promise.resolve(true)
101
+ let libraryDetailRequest = 0
102
+ let documentSearchTimer = 0
103
+ let noteSearchTimer = 0
104
+ let markdownEditorHandle = null
105
+ let noteEditorLoader = null
106
+ let markdownEditorMountRequest = 0
107
+ let plainTextEditorHandle = null
108
+ let plainTextEditorMountRequest = 0
109
+ let noteTransferFrame = 0
110
+ let noteTransferSequence = 0
111
+ let noteSelectionRequest = 0
85
112
 
86
113
  function installHostThemeBridge() {
87
114
  if (window.parent === window) return Promise.resolve()
@@ -242,9 +269,12 @@ function badge(label, variant = '') {
242
269
  }
243
270
 
244
271
  function showToast(message, kind = '') {
245
- const toast = element('div', { class: `toast ${kind}`.trim(), role: kind === 'error' ? 'alert' : 'status' }, message)
272
+ const toast = element('div', { class: `toast ${kind}`.trim(), role: kind === 'error' ? 'alert' : 'status' },
273
+ element('span', {}, message),
274
+ kind === 'error' ? actionButton('关闭', () => toast.remove(), 'ghost small toast-close', { 'aria-label': '关闭错误提示' }) : null,
275
+ )
246
276
  toastRegion.append(toast)
247
- window.setTimeout(() => toast.remove(), 4200)
277
+ if (kind !== 'error') window.setTimeout(() => toast.remove(), 4200)
248
278
  }
249
279
 
250
280
  async function api(path, options = {}) {
@@ -270,11 +300,83 @@ async function api(path, options = {}) {
270
300
  return payload
271
301
  }
272
302
 
303
+ async function binaryRequest(path, options = {}) {
304
+ const headers = { accept: options.accept || 'application/json' }
305
+ if (AUTH_MODE === 'same-origin') headers['x-dsh-knowledge-client'] = 'management-web'
306
+ if (state.token) headers.authorization = `Bearer ${state.token}`
307
+ if (options.contentType) headers['content-type'] = options.contentType
308
+ const response = await fetch(`${API_BASE}/${path.replace(/^\/+/, '')}`, {
309
+ method: options.method || 'GET',
310
+ headers,
311
+ body: options.body,
312
+ signal: options.signal,
313
+ })
314
+ if (options.responseType === 'blob') {
315
+ if (!response.ok) throw await responseError(response)
316
+ return response.blob()
317
+ }
318
+ const text = await response.text()
319
+ let payload
320
+ if (text) {
321
+ try { payload = JSON.parse(text) } catch { throw new Error('服务返回了无法识别的数据') }
322
+ }
323
+ if (!response.ok) {
324
+ const error = new Error(payload?.error || `请求失败(HTTP ${response.status})`)
325
+ error.status = response.status
326
+ throw error
327
+ }
328
+ return payload
329
+ }
330
+
331
+ function binaryUploadRequest(path, body, options = {}) {
332
+ return new Promise((resolve, reject) => {
333
+ const request = new XMLHttpRequest()
334
+ request.open(options.method || 'POST', `${API_BASE}/${path.replace(/^\/+/, '')}`)
335
+ request.responseType = 'text'
336
+ request.setRequestHeader('accept', 'application/json')
337
+ if (AUTH_MODE === 'same-origin') request.setRequestHeader('x-dsh-knowledge-client', 'management-web')
338
+ if (state.token) request.setRequestHeader('authorization', `Bearer ${state.token}`)
339
+ if (options.contentType) request.setRequestHeader('content-type', options.contentType)
340
+ request.upload.addEventListener('progress', event => {
341
+ if (event.lengthComputable) options.onProgress?.(event.loaded, event.total)
342
+ })
343
+ request.addEventListener('load', () => {
344
+ let payload
345
+ if (request.responseText) {
346
+ try { payload = JSON.parse(request.responseText) }
347
+ catch { return reject(new Error('服务返回了无法识别的数据')) }
348
+ }
349
+ if (request.status < 200 || request.status >= 300) {
350
+ const error = new Error(payload?.error || `请求失败(HTTP ${request.status})`)
351
+ error.status = request.status
352
+ reject(error)
353
+ return
354
+ }
355
+ resolve(payload)
356
+ })
357
+ request.addEventListener('error', () => reject(new Error('上传连接中断,请检查网络后重试')))
358
+ request.addEventListener('abort', () => reject(new DOMException('上传已取消', 'AbortError')))
359
+ request.send(body)
360
+ })
361
+ }
362
+
363
+ async function responseError(response) {
364
+ let message = `请求失败(HTTP ${response.status})`
365
+ try {
366
+ const payload = await response.json()
367
+ if (payload?.error) message = payload.error
368
+ } catch {}
369
+ return Object.assign(new Error(message), { status: response.status })
370
+ }
371
+
273
372
  async function boot() {
274
373
  if (AUTH_MODE === 'same-origin') {
275
374
  state.token = ''
276
- try { state.service = await api('service') } catch {}
277
- await navigate('entries')
375
+ await Promise.all([
376
+ api('service').then(service => { state.service = service }).catch(() => {}),
377
+ navigate('entries'),
378
+ ])
379
+ renderShell()
278
380
  return
279
381
  }
280
382
  if (!state.token) {
@@ -335,13 +437,22 @@ function renderLogin(message = '') {
335
437
  }
336
438
 
337
439
  function signOut() {
440
+ releaseNoteEditors()
441
+ releaseNoteAsset()
338
442
  sessionStorage.removeItem(TOKEN_KEY)
339
443
  Object.assign(state, { token: '', stats: null, overview: null, knowledgeBases: [], mounts: [], resolvedMounts: [], entries: [], documents: [], candidates: [], candidateTargets: new Map(), settings: { writebackPolicy: 'conservative', updatedAt: '' }, tokens: [] })
444
+ state.notes = { children: new Map(), loadedFolders: new Set(), expandedFolders: new Set(), selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [], content: '', draft: '', dirty: false, assetUrl: '', query: '', searchResults: [], transfer: null, loadingNodeId: '' }
340
445
  if (AUTH_MODE === 'same-origin') void boot()
341
446
  else renderLogin()
342
447
  }
343
448
 
344
449
  async function navigate(view) {
450
+ const request = ++navigationRequest
451
+ navigationSave = navigationSave.then(() => saveBeforeNavigation(), () => saveBeforeNavigation())
452
+ if (!await navigationSave || request !== navigationRequest) return
453
+ navigationController?.abort()
454
+ const controller = new AbortController()
455
+ navigationController = controller
345
456
  const previousView = state.view
346
457
  if (view === 'bases' && previousView !== 'bases' && state.knowledgeBaseView === 'detail') {
347
458
  state.knowledgeBaseView = 'libraries'
@@ -349,38 +460,78 @@ async function navigate(view) {
349
460
  state.view = view
350
461
  state.menuOpen = false
351
462
  state.loading = true
463
+ state.loadingPhase = loadingPhaseForView(view)
464
+ state.loadingProgress = .12
352
465
  state.error = ''
353
466
  renderShell()
354
467
  try {
355
- if (view === 'overview') await loadOverview()
356
- if (view === 'bases') await loadKnowledgeBasesPage()
357
- if (view === 'entries') await loadDocuments()
358
- if (view === 'candidates') await loadCandidates()
359
- if (view === 'tokens') await loadTokens()
468
+ if (view === 'overview') await loadOverview(controller.signal)
469
+ if (view === 'bases') await loadKnowledgeBasesPage(controller.signal)
470
+ if (view === 'entries') await loadDocuments(controller.signal, (label, progress) => updateLoadingPhase(request, label, progress))
471
+ if (view === 'candidates') await loadCandidates(controller.signal)
472
+ if (view === 'notes') await loadNotes(controller.signal, (label, progress) => updateLoadingPhase(request, label, progress))
473
+ if (view === 'tokens') await loadTokens(controller.signal)
360
474
  } catch (error) {
475
+ if (controller.signal.aborted) return
361
476
  if (error.status === 401 && AUTH_MODE === 'bearer') return signOut()
362
477
  state.error = friendlyError(error)
363
478
  } finally {
479
+ if (navigationController !== controller) return
364
480
  state.loading = false
481
+ state.loadingPhase = ''
482
+ state.loadingProgress = 1
365
483
  renderShell()
366
484
  }
367
485
  }
368
486
 
369
- async function refreshStats() {
370
- state.stats = await api('stats')
487
+ function loadingPhaseForView(view) {
488
+ return ({
489
+ overview: '正在汇总知识活动', bases: '正在读取知识库配置', entries: '正在读取知识目录',
490
+ notes: '正在读取笔记目录', candidates: '正在准备审核队列', tokens: '正在读取访问权限',
491
+ })[view] || '正在准备工作区'
492
+ }
493
+
494
+ function updateLoadingPhase(request, label, progress) {
495
+ if (request !== navigationRequest || !state.loading) return
496
+ state.loadingPhase = label
497
+ state.loadingProgress = Math.max(0, Math.min(1, progress))
498
+ renderShell()
499
+ }
500
+
501
+ async function saveBeforeNavigation() {
502
+ if (state.view === 'notes' && state.notes.dirty) return saveNoteDocument()
503
+ return saveBeforeLeavingDocument()
504
+ }
505
+
506
+ async function saveBeforeLeavingDocument() {
507
+ const workspace = activeDocumentWorkspace()
508
+ const editor = workspace?.view.editor
509
+ if (!workspace || !editor?.dirty) return true
510
+ const emptyDraft = editor.isNew && !editor.title.trim() && !editor.body.trim()
511
+ if (emptyDraft) {
512
+ workspace.view.editor = null
513
+ workspace.view.documentId = ''
514
+ return true
515
+ }
516
+ return saveDocumentEditor(workspace)
371
517
  }
372
518
 
373
- async function ensureKnowledgeBases(force = false) {
374
- if (force || state.knowledgeBases.length === 0) state.knowledgeBases = await api('knowledge-bases')
519
+ async function refreshStats(signal) {
520
+ state.stats = await api('stats', { signal })
521
+ }
522
+
523
+ async function ensureKnowledgeBases(force = false, signal) {
524
+ if (force || state.knowledgeBases.length === 0) state.knowledgeBases = await api('knowledge-bases', { signal })
375
525
  return state.knowledgeBases
376
526
  }
377
527
 
378
- async function loadKnowledgeBasesPage() {
379
- const requests = [api('knowledge-bases'), api('mounts'), api('settings'), api('service')]
528
+ async function loadKnowledgeBasesPage(signal) {
529
+ const options = { signal }
530
+ const requests = [api('knowledge-bases', options), api('mounts', options), api('settings', options), api('service', options)]
380
531
  if (state.mountContext.sessionId) {
381
532
  const params = new URLSearchParams({ sessionId: state.mountContext.sessionId })
382
533
  if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
383
- requests.push(api(`mounts/resolve?${params}`))
534
+ requests.push(api(`mounts/resolve?${params}`, options))
384
535
  }
385
536
  const [bases, mounts, settings, service, resolved = []] = await Promise.all(requests)
386
537
  state.knowledgeBases = bases
@@ -388,15 +539,16 @@ async function loadKnowledgeBasesPage() {
388
539
  state.settings = settings
389
540
  state.service = service
390
541
  state.resolvedMounts = resolved
391
- await refreshStats()
542
+ await refreshStats(signal)
392
543
  }
393
544
 
394
- async function loadOverview() {
545
+ async function loadOverview(signal) {
546
+ const options = { signal }
395
547
  const [stats, recent, pending, bases] = await Promise.all([
396
- api('stats'),
397
- api('entries?status=active&limit=6'),
398
- api('candidates?status=pending&limit=5'),
399
- api('knowledge-bases'),
548
+ api('stats', options),
549
+ api('entries?status=active&limit=6', options),
550
+ api('candidates?status=pending&limit=5', options),
551
+ api('knowledge-bases', options),
400
552
  ])
401
553
  state.stats = stats
402
554
  state.knowledgeBases = bases
@@ -427,18 +579,26 @@ async function loadEntries(cursor = '') {
427
579
  if (!state.stats) await refreshStats()
428
580
  }
429
581
 
430
- async function loadDocuments() {
431
- const requests = [api('knowledge-bases'), api('documents')]
582
+ async function loadDocuments(signal, onPhase = () => {}) {
583
+ onPhase('正在读取可用知识库', .24)
584
+ const options = { signal }
585
+ const requests = [api('knowledge-bases', options)]
432
586
  if (state.mountContext.sessionId) {
433
587
  const params = new URLSearchParams({ sessionId: state.mountContext.sessionId })
434
588
  if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
435
- requests.push(api(`mounts/resolve?${params}`))
589
+ requests.push(api(`mounts/resolve?${params}`, options))
436
590
  }
437
- const [bases, documents, resolved = []] = await Promise.all(requests)
591
+ const [results, stats] = await Promise.all([
592
+ Promise.all(requests),
593
+ state.stats ? Promise.resolve(state.stats) : api('stats', options),
594
+ ])
595
+ const [bases, resolved = []] = results
438
596
  state.knowledgeBases = bases
439
597
  state.resolvedMounts = resolved
598
+ state.stats = stats
599
+ onPhase('正在建立文档索引', .56)
440
600
  const visibleBaseIds = new Set(documentKnowledgeBases(bases).map(base => base.id))
441
- state.documents = documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId))
601
+ state.documents = state.documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId))
442
602
  const workspace = sessionDocumentWorkspace()
443
603
  const view = workspace.view
444
604
  const availableBaseIds = visibleBaseIds
@@ -447,10 +607,30 @@ async function loadDocuments() {
447
607
  ? state.entryFilters.knowledgeBaseId
448
608
  : documentKnowledgeBases(bases)[0]?.id || ''
449
609
  }
610
+ if (view.knowledgeBaseId) {
611
+ view.expandedBases.add(view.knowledgeBaseId)
612
+ await loadDocumentPage(workspace, view.knowledgeBaseId, { signal })
613
+ }
450
614
  selectDefaultDocument(workspace)
451
- if (view.documentId) await loadDocumentEditor(workspace, view.documentId)
615
+ if (view.documentId) {
616
+ onPhase('正在打开最近文档', .82)
617
+ await loadDocumentEditor(workspace, view.documentId, signal)
618
+ }
452
619
  else view.editor = null
453
- if (!state.stats) await refreshStats()
620
+ }
621
+
622
+ async function loadNotes(signal, onPhase = () => {}) {
623
+ onPhase('正在读取顶层目录', .36)
624
+ state.notes.children.set('root', await api('notes?limit=500', { signal }))
625
+ state.notes.loadedFolders.add('root')
626
+ if (state.notes.selectedId) {
627
+ onPhase('正在恢复上次打开的笔记', .74)
628
+ const selected = state.notes.selectedNode?.id === state.notes.selectedId
629
+ ? state.notes.selectedNode
630
+ : await api(`notes/${encodeURIComponent(state.notes.selectedId)}`, { signal }).catch(() => null)
631
+ if (!selected) clearNoteSelection()
632
+ else state.notes.selectedNode = selected
633
+ }
454
634
  }
455
635
 
456
636
  function documentKnowledgeBases(bases = state.knowledgeBases) {
@@ -483,6 +663,139 @@ function setDocumentWorkspaceDocuments(workspace, documents) {
483
663
  else state.documents = documents
484
664
  }
485
665
 
666
+ function documentPageState(workspace, knowledgeBaseId) {
667
+ const pages = workspace.view.documentPages
668
+ if (!pages.has(knowledgeBaseId)) {
669
+ pages.set(knowledgeBaseId, { loaded: false, loading: false, nextCursor: '', total: 0, error: '' })
670
+ }
671
+ return pages.get(knowledgeBaseId)
672
+ }
673
+
674
+ function mergeDocumentSummaries(workspace, items, knowledgeBaseId, reset) {
675
+ const current = documentWorkspaceDocuments(workspace)
676
+ const retained = reset ? current.filter(document => document.knowledgeBaseId !== knowledgeBaseId) : current
677
+ const merged = new Map(retained.map(document => [document.id, document]))
678
+ for (const item of items) merged.set(item.id, item)
679
+ setDocumentWorkspaceDocuments(workspace, [...merged.values()])
680
+ }
681
+
682
+ async function loadDocumentPage(workspace, knowledgeBaseId, options = {}) {
683
+ if (!knowledgeBaseId) return
684
+ const page = documentPageState(workspace, knowledgeBaseId)
685
+ if (page.loading || (!options.reset && !options.append && page.loaded)) return
686
+ if (options.append && !page.nextCursor) return
687
+ page.loading = true
688
+ page.error = ''
689
+ const cursor = options.append ? page.nextCursor : ''
690
+ try {
691
+ const params = new URLSearchParams({ knowledgeBaseId, limit: '60' })
692
+ if (cursor) params.set('cursor', cursor)
693
+ const result = await api(`document-index?${params}`, { signal: options.signal })
694
+ mergeDocumentSummaries(workspace, result.items, knowledgeBaseId, !options.append)
695
+ page.loaded = true
696
+ page.nextCursor = result.nextCursor || ''
697
+ page.total = result.total
698
+ } catch (error) {
699
+ if (error.name === 'AbortError') throw error
700
+ page.error = friendlyError(error)
701
+ if (options.throwOnError) throw error
702
+ } finally {
703
+ page.loading = false
704
+ }
705
+ }
706
+
707
+ async function toggleDocumentBase(workspace, baseId) {
708
+ const view = workspace.view
709
+ view.knowledgeBaseId = baseId
710
+ if (view.expandedBases.has(baseId)) {
711
+ view.expandedBases.delete(baseId)
712
+ renderShell()
713
+ return
714
+ }
715
+ view.expandedBases.add(baseId)
716
+ const page = documentPageState(workspace, baseId)
717
+ if (!page.loaded) page.loading = true
718
+ renderShell()
719
+ if (!page.loaded) {
720
+ page.loading = false
721
+ await loadDocumentPage(workspace, baseId)
722
+ renderShell()
723
+ }
724
+ }
725
+
726
+ function resetDocumentSearch(view) {
727
+ view.searchRequest += 1
728
+ view.searchResults = []
729
+ view.searchNextCursor = ''
730
+ view.searchTotal = 0
731
+ view.searchLoading = false
732
+ view.searchError = ''
733
+ }
734
+
735
+ function scheduleDocumentSearch(workspace) {
736
+ window.clearTimeout(documentSearchTimer)
737
+ const view = workspace.view
738
+ if (!view.query.trim()) {
739
+ resetDocumentSearch(view)
740
+ renderShell()
741
+ return
742
+ }
743
+ view.searchLoading = true
744
+ view.searchError = ''
745
+ renderShell()
746
+ documentSearchTimer = window.setTimeout(() => { void loadDocumentSearch(workspace, true) }, 220)
747
+ }
748
+
749
+ function renderDocumentSearchState(workspace) {
750
+ renderShell()
751
+ window.requestAnimationFrame(() => {
752
+ const input = document.querySelector(`.note-tree-search[data-document-scope="${workspace.kind}"]`)
753
+ input?.focus()
754
+ input?.setSelectionRange(input.value.length, input.value.length)
755
+ })
756
+ }
757
+
758
+ async function loadDocumentSearch(workspace, reset = false) {
759
+ const view = workspace.view
760
+ const query = view.query.trim()
761
+ if (!query) return resetDocumentSearch(view)
762
+ const request = ++view.searchRequest
763
+ view.searchLoading = true
764
+ view.searchError = ''
765
+ const params = new URLSearchParams({ q: query, limit: '80' })
766
+ const bases = documentWorkspaceBases(workspace)
767
+ if (bases.length === 0) {
768
+ resetDocumentSearch(view)
769
+ return renderShell()
770
+ }
771
+ if (workspace.kind === 'library') {
772
+ params.append('knowledgeBaseId', state.libraryDetail.knowledgeBaseId)
773
+ } else if (state.mountContext.sessionId) {
774
+ params.set('sessionId', state.mountContext.sessionId)
775
+ if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
776
+ } else {
777
+ params.set('active', '1')
778
+ }
779
+ if (!reset && view.searchNextCursor) params.set('cursor', view.searchNextCursor)
780
+ try {
781
+ const result = await api(`document-index?${params}`)
782
+ if (request !== view.searchRequest || query !== view.query.trim()) return
783
+ const merged = new Map((reset ? [] : view.searchResults).map(document => [document.id, document]))
784
+ for (const item of result.items) merged.set(item.id, item)
785
+ view.searchResults = [...merged.values()]
786
+ view.searchNextCursor = result.nextCursor || ''
787
+ view.searchTotal = result.total
788
+ } catch (error) {
789
+ if (request !== view.searchRequest) return
790
+ view.searchError = friendlyError(error)
791
+ } finally {
792
+ if (request === view.searchRequest) {
793
+ view.searchLoading = false
794
+ renderDocumentSearchState(workspace)
795
+ }
796
+ }
797
+ }
798
+
486
799
  function documentWorkspaceBases(workspace) {
487
800
  if (workspace.kind === 'library') {
488
801
  const base = state.knowledgeBases.find(item => item.id === state.libraryDetail.knowledgeBaseId)
@@ -496,14 +809,21 @@ function documentWorkspaceReadOnly(workspace) {
496
809
  }
497
810
 
498
811
  async function reloadDocumentWorkspace(workspace) {
499
- const documents = workspace.kind === 'library'
500
- ? await api(`documents?knowledgeBaseId=${encodeURIComponent(state.libraryDetail.knowledgeBaseId)}`)
501
- : await api('documents')
502
- if (workspace.kind === 'library') setDocumentWorkspaceDocuments(workspace, documents)
503
- else {
504
- const visibleBaseIds = new Set(documentKnowledgeBases().map(base => base.id))
505
- setDocumentWorkspaceDocuments(workspace, documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId)))
812
+ const baseId = workspace.kind === 'library' ? state.libraryDetail.knowledgeBaseId : workspace.view.knowledgeBaseId
813
+ if (!baseId) return true
814
+ await loadDocumentPage(workspace, baseId, { reset: true, throwOnError: true })
815
+ if (workspace.kind === 'library' && state.libraryDetail.knowledgeBaseId !== baseId) return false
816
+ const selectedId = workspace.view.documentId
817
+ if (selectedId && !documentWorkspaceDocuments(workspace).some(document => document.id === selectedId)) {
818
+ const document = await api(`documents/${encodeURIComponent(selectedId)}`)
819
+ mergeDocumentSummaries(workspace, [documentSummary(document)], baseId, false)
506
820
  }
821
+ return true
822
+ }
823
+
824
+ function documentSummary(document) {
825
+ const { content: _content, ...summary } = document
826
+ return summary
507
827
  }
508
828
 
509
829
  function selectDefaultDocument(workspace) {
@@ -515,16 +835,24 @@ function selectDefaultDocument(workspace) {
515
835
  if (view.knowledgeBaseId) view.expandedBases.add(view.knowledgeBaseId)
516
836
  }
517
837
 
518
- async function loadDocumentEditor(workspace, id) {
519
- const entry = await api(`entries/${encodeURIComponent(id)}`)
838
+ async function loadDocumentEditor(workspace, id, signal) {
839
+ const revision = (workspace.view.loadRevision || 0) + 1
840
+ workspace.view.loadRevision = revision
841
+ const [entry, noteReferences] = await Promise.all([
842
+ api(`entries/${encodeURIComponent(id)}`, { signal }),
843
+ api(`entries/${encodeURIComponent(id)}/note-references`, { signal }),
844
+ ])
845
+ if (workspace.view.loadRevision !== revision || workspace.view.documentId !== id) return false
520
846
  workspace.view.mode = 'preview'
521
847
  workspace.view.editor = {
522
848
  ...entry,
849
+ noteReferences,
523
850
  tagsText: entry.tags.join(', '),
524
851
  dirty: false,
525
852
  isNew: false,
526
853
  saveState: '已保存',
527
854
  }
855
+ return true
528
856
  }
529
857
 
530
858
  function createBlankDocument(workspace, baseId) {
@@ -538,7 +866,7 @@ function createBlankDocument(workspace, baseId) {
538
866
  view.expandedBases.add(base.id)
539
867
  view.editor = {
540
868
  id: '', knowledgeBaseId: base.id, title: '', body: '', type: 'fact', tags: [], tagsText: '',
541
- scope: { kind: 'global' }, confidence: .8, dirty: true, isNew: true, saveState: '新文档',
869
+ scope: { kind: 'global' }, confidence: .8, noteReferences: [], dirty: true, isNew: true, saveState: '新文档',
542
870
  }
543
871
  renderShell()
544
872
  document.querySelector('.note-title-input')?.focus()
@@ -557,8 +885,21 @@ async function selectDocument(workspace, id) {
557
885
  if (editor?.dirty && !emptyDraft && !await saveDocumentEditor(workspace)) return
558
886
  workspace.view.documentId = id
559
887
  workspace.view.treeOpen = false
560
- await loadDocumentEditor(workspace, id)
888
+ workspace.view.editor = null
889
+ workspace.view.editorLoading = true
890
+ workspace.view.loadingDocumentId = id
561
891
  renderShell()
892
+ try {
893
+ await loadDocumentEditor(workspace, id)
894
+ } catch (error) {
895
+ if (workspace.view.documentId === id) showToast(friendlyError(error), 'error')
896
+ } finally {
897
+ if (workspace.view.loadingDocumentId === id) {
898
+ workspace.view.editorLoading = false
899
+ workspace.view.loadingDocumentId = ''
900
+ renderShell()
901
+ }
902
+ }
562
903
  }
563
904
 
564
905
  function editorDraft(editor) {
@@ -615,38 +956,44 @@ function updateEditorSaveState(label) {
615
956
  if (node) node.textContent = label
616
957
  }
617
958
 
618
- async function loadCandidates() {
959
+ async function loadCandidates(signal) {
619
960
  const [candidates] = await Promise.all([
620
- api(`candidates?status=${state.candidateStatus}&limit=100`),
621
- ensureKnowledgeBases(),
961
+ api(`candidates?status=${state.candidateStatus}&limit=100`, { signal }),
962
+ ensureKnowledgeBases(false, signal),
622
963
  ])
623
964
  state.candidates = candidates
624
965
  const targetIds = [...new Set(candidates.map(candidate => candidate.targetId).filter(Boolean))]
625
966
  const targets = await Promise.all(targetIds.map(async id => {
626
- try { return [id, await api(`entries/${encodeURIComponent(id)}`)] }
627
- catch { return [id, null] }
967
+ try { return [id, await api(`entries/${encodeURIComponent(id)}`, { signal })] }
968
+ catch (error) {
969
+ if (signal?.aborted) throw error
970
+ return [id, null]
971
+ }
628
972
  }))
629
973
  state.candidateTargets = new Map(targets)
630
- if (!state.stats) await refreshStats()
974
+ if (!state.stats) await refreshStats(signal)
631
975
  }
632
976
 
633
- async function loadTokens() {
634
- const [tokens, service] = await Promise.all([api('tokens'), api('service')])
977
+ async function loadTokens(signal) {
978
+ const [tokens, service] = await Promise.all([api('tokens', { signal }), api('service', { signal })])
635
979
  state.tokens = tokens
636
980
  state.service = service
637
- if (!state.stats) await refreshStats()
981
+ if (!state.stats) await refreshStats(signal)
638
982
  }
639
983
 
640
984
  function renderShell() {
985
+ releaseNoteEditors()
641
986
  captureScrollPosition()
642
987
  const titles = {
643
988
  overview: ['概览', '知识库运行状态与最近活动'],
644
989
  bases: ['知识库与挂载', '管理知识目录,并限定项目与会话的召回和写入范围'],
645
990
  entries: ['知识文档', '在知识目录中阅读、整理和维护 Markdown 文档'],
991
+ notes: ['笔记文档', '像本地目录一样整理笔记和资料,并按需关联到知识文档'],
646
992
  candidates: ['待审核', '确认 AI 提取结果后再写入知识文档'],
647
993
  tokens: ['访问管理', '管理其他客户端连接中央知识库的权限'],
648
994
  }
649
995
  const [title, subtitle] = titles[state.view]
996
+ const viewIndexes = { overview: '00', notes: '01', entries: '02', candidates: '03', bases: '04', tokens: '05' }
650
997
  const shell = element('div', {
651
998
  class: 'app-shell', 'data-menu-open': String(state.menuOpen),
652
999
  'data-view': state.view, 'data-loading': String(state.loading),
@@ -660,7 +1007,10 @@ function renderShell() {
660
1007
  element('main', { class: 'main' },
661
1008
  element('header', { class: 'topbar' },
662
1009
  element('div', { class: 'topbar-title' },
663
- actionButton('☰', () => { state.menuOpen = !state.menuOpen; renderShell() }, 'ghost mobile-menu', { 'aria-label': '打开导航菜单' }),
1010
+ actionButton('☰', () => { state.menuOpen = !state.menuOpen; renderShell() }, 'ghost mobile-menu', {
1011
+ 'aria-label': state.menuOpen ? '关闭导航菜单' : '打开导航菜单',
1012
+ 'aria-expanded': String(state.menuOpen),
1013
+ }),
664
1014
  paneToggleButton('main', !state.documentView.sidebarHidden, () => setSidebarHidden(!state.documentView.sidebarHidden), '主导航栏'),
665
1015
  activeDocumentWorkspace() ? paneToggleButton('library', activeDocumentWorkspace().view.treeOpen, () => {
666
1016
  const workspace = activeDocumentWorkspace()
@@ -668,10 +1018,17 @@ function renderShell() {
668
1018
  workspace.view.treeOpen = !workspace.view.treeOpen
669
1019
  renderShell()
670
1020
  }, '知识目录') : null,
671
- element('div', {}, element('h1', {}, title), element('p', {}, subtitle)),
1021
+ element('div', { class: 'topbar-heading' },
1022
+ element('span', { class: 'topbar-kicker' }, `KNOWLEDGE / ${viewIndexes[state.view] || '00'}`),
1023
+ element('h1', {}, title),
1024
+ element('p', {}, subtitle)),
672
1025
  ),
1026
+ state.loading ? element('div', {
1027
+ class: 'route-progress', role: 'progressbar', 'aria-label': state.loadingPhase || '正在加载',
1028
+ 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(Math.round(state.loadingProgress * 100)),
1029
+ }, element('span', { style: `--route-progress: ${state.loadingProgress}` })) : null,
673
1030
  ),
674
- element('div', { class: 'page' }, renderCurrentView()),
1031
+ element('div', { class: 'page' }, element('div', { class: 'view-stage', 'data-view-stage': state.view }, renderCurrentView())),
675
1032
  ),
676
1033
  )
677
1034
  if (state.menuOpen) shell.addEventListener('click', (event) => {
@@ -749,11 +1106,14 @@ function setSidebarHidden(hidden) {
749
1106
  function renderSidebar() {
750
1107
  const pending = state.stats?.candidates.pending
751
1108
  const navGroups = [
752
- ['知识工作区', [['entries', '文档'], ['candidates', '待审核'], ['bases', '知识库与挂载']]],
1109
+ ['笔记工作区', [['notes', '笔记文档']]],
1110
+ ['知识工作区', [['entries', '知识文档'], ['candidates', '待审核'], ['bases', '知识库与挂载']]],
753
1111
  ['连接', [['tokens', '访问管理']].filter(([id]) => id !== 'tokens' || !state.service.remote)],
754
1112
  ].filter(([, items]) => items.length)
1113
+ let navIndex = 0
755
1114
  return element('aside', { class: 'sidebar', 'aria-label': '知识库导航' },
756
1115
  element('div', { class: 'brand' },
1116
+ element('span', { class: 'brand-emblem', 'aria-hidden': 'true' }, element('span', {})),
757
1117
  element('div', { class: 'brand-copy' }, element('span', {}, 'DSH Knowledge'), element('strong', {}, '知识库')),
758
1118
  ),
759
1119
  element('nav', { class: 'nav' }, navGroups.map(([group, items]) => element('div', { class: 'nav-group' },
@@ -761,7 +1121,7 @@ function renderSidebar() {
761
1121
  items.map(([id, label]) => element('button', {
762
1122
  type: 'button', class: 'nav-button', 'aria-current': state.view === id ? 'page' : undefined,
763
1123
  onClick: () => navigate(id),
764
- }, element('span', { class: 'nav-label' }, label),
1124
+ }, element('span', { class: 'nav-index', 'aria-hidden': 'true' }, String(++navIndex).padStart(2, '0')), element('span', { class: 'nav-label' }, label),
765
1125
  id === 'candidates' && pending ? element('span', { class: 'nav-count', 'aria-label': `${pending} 条待审核` }, pending) : null))))),
766
1126
  element('div', { class: 'sidebar-footer' },
767
1127
  element('div', { class: 'connection' }, element('span', { class: 'status-dot', 'aria-hidden': 'true' }), state.service.remote ? '中央知识库已连接' : '本地知识库已连接'),
@@ -771,26 +1131,38 @@ function renderSidebar() {
771
1131
  }
772
1132
 
773
1133
  function renderCurrentView() {
774
- if (state.loading) return loadingView()
1134
+ if (state.loading) return loadingView(state.view, state.loadingPhase, state.loadingProgress)
775
1135
  if (state.error) return errorView(state.error, () => navigate(state.view))
776
1136
  if (state.view === 'overview') return renderOverview()
777
1137
  if (state.view === 'bases') return renderKnowledgeBases()
778
1138
  if (state.view === 'entries') return renderEntries()
1139
+ if (state.view === 'notes') return renderNotes()
779
1140
  if (state.view === 'candidates') return renderCandidates()
780
1141
  return renderTokens()
781
1142
  }
782
1143
 
783
- function loadingView() {
784
- return element('div', { class: 'loading-skeleton', role: 'status', 'aria-label': '正在加载' },
785
- element('span', { class: 'visually-hidden' }, '正在加载'),
786
- element('div', { class: 'skeleton-line skeleton-title', 'aria-hidden': 'true' }),
787
- element('div', { class: 'skeleton-line skeleton-copy', 'aria-hidden': 'true' }),
788
- element('div', { class: 'skeleton-grid', 'aria-hidden': 'true' },
789
- element('div', { class: 'skeleton-block' }),
790
- element('div', { class: 'skeleton-block' }),
791
- element('div', { class: 'skeleton-block' }),
792
- ),
1144
+ function loadingView(view = state.view, phase = '正在加载', progress = 0) {
1145
+ const label = phase || loadingPhaseForView(view)
1146
+ const header = element('div', { class: 'skeleton-heading' },
1147
+ element('div', {}, element('div', { class: 'skeleton-line skeleton-title', 'aria-hidden': 'true' }), element('div', { class: 'skeleton-line skeleton-copy', 'aria-hidden': 'true' })),
1148
+ element('span', { class: 'skeleton-phase' }, label),
793
1149
  )
1150
+ const content = view === 'entries'
1151
+ ? element('div', { class: 'skeleton-workspace skeleton-workspace-documents', 'aria-hidden': 'true' },
1152
+ element('div', { class: 'skeleton-pane skeleton-pane-tree' }, ...Array.from({ length: 7 }, (_, index) => element('div', { class: `skeleton-line skeleton-tree-row is-${index % 3}` }))),
1153
+ element('div', { class: 'skeleton-pane skeleton-pane-paper' }, element('div', { class: 'skeleton-line skeleton-paper-title' }), ...Array.from({ length: 8 }, (_, index) => element('div', { class: `skeleton-line skeleton-paper-row is-${index % 4}` }))),
1154
+ )
1155
+ : view === 'notes'
1156
+ ? element('div', { class: 'skeleton-workspace skeleton-workspace-notes', 'aria-hidden': 'true' },
1157
+ element('div', { class: 'skeleton-pane skeleton-pane-tree' }, ...Array.from({ length: 8 }, (_, index) => element('div', { class: `skeleton-line skeleton-tree-row is-${index % 3}` }))),
1158
+ element('div', { class: 'skeleton-pane skeleton-pane-list' }, ...Array.from({ length: 6 }, () => element('div', { class: 'skeleton-list-row' }, element('span', { class: 'skeleton-block' }), element('span', { class: 'skeleton-line' })))),
1159
+ )
1160
+ : element('div', { class: 'skeleton-grid', 'aria-hidden': 'true' },
1161
+ element('div', { class: 'skeleton-block' }), element('div', { class: 'skeleton-block' }), element('div', { class: 'skeleton-block' }))
1162
+ return element('div', {
1163
+ class: `loading-skeleton loading-skeleton--${view}`, role: 'status', 'aria-label': label,
1164
+ style: `--loading-progress: ${progress}`,
1165
+ }, element('span', { class: 'visually-hidden' }, label), header, content)
794
1166
  }
795
1167
 
796
1168
  function errorView(message, retry) {
@@ -901,6 +1273,7 @@ function renderKnowledgeBases() {
901
1273
 
902
1274
  async function openKnowledgeBaseDocuments(base) {
903
1275
  captureScrollPosition()
1276
+ const request = ++libraryDetailRequest
904
1277
  const detail = state.libraryDetail
905
1278
  const changingBase = detail.knowledgeBaseId !== base.id
906
1279
  detail.knowledgeBaseId = base.id
@@ -916,13 +1289,15 @@ async function openKnowledgeBaseDocuments(base) {
916
1289
  renderShell()
917
1290
  const workspace = libraryDocumentWorkspace()
918
1291
  try {
919
- await reloadDocumentWorkspace(workspace)
1292
+ if (!await reloadDocumentWorkspace(workspace) || request !== libraryDetailRequest) return
920
1293
  selectDefaultDocument(workspace)
921
1294
  if (workspace.view.documentId) await loadDocumentEditor(workspace, workspace.view.documentId)
922
1295
  else workspace.view.editor = null
923
1296
  } catch (error) {
1297
+ if (request !== libraryDetailRequest) return
924
1298
  detail.error = friendlyError(error)
925
1299
  } finally {
1300
+ if (request !== libraryDetailRequest) return
926
1301
  state.loading = false
927
1302
  renderShell()
928
1303
  window.requestAnimationFrame(() => document.querySelector('.library-detail-back')?.focus())
@@ -934,6 +1309,7 @@ async function closeKnowledgeBaseDetail() {
934
1309
  const editor = workspace.view.editor
935
1310
  const emptyDraft = editor?.isNew && !editor.title.trim() && !editor.body.trim()
936
1311
  if (editor?.dirty && !emptyDraft && !await saveDocumentEditor(workspace)) return
1312
+ libraryDetailRequest += 1
937
1313
  const baseId = state.libraryDetail.knowledgeBaseId
938
1314
  state.knowledgeBaseView = 'libraries'
939
1315
  state.libraryDetail.error = ''
@@ -998,6 +1374,16 @@ function contextPill(label, value) {
998
1374
  return element('div', { class: 'context-pill' }, element('strong', {}, label), element('span', { title: value }, value))
999
1375
  }
1000
1376
 
1377
+ function writebackRouteLabel(base) {
1378
+ if (state.service.writebackProvider && state.service.writebackModel) {
1379
+ return `本机覆盖 · ${state.service.writebackProvider} / ${state.service.writebackModel}`
1380
+ }
1381
+ if (base?.writebackProvider && base?.writebackModel) {
1382
+ return `知识库专用 · ${base.writebackProvider} / ${base.writebackModel}`
1383
+ }
1384
+ return '跟随当前会话模型'
1385
+ }
1386
+
1001
1387
  function renderKnowledgeBaseCard(base) {
1002
1388
  const archived = base.status === 'archived'
1003
1389
  const visibleTags = base.defaultTags.slice(0, 4)
@@ -1017,7 +1403,7 @@ function renderKnowledgeBaseCard(base) {
1017
1403
  hiddenTagCount ? element('span', { class: 'tag' }, `+${hiddenTagCount}`) : null),
1018
1404
  element('div', { class: 'base-card-meta' },
1019
1405
  element('span', {}, element('strong', {}, '回写策略'), base.writebackPolicy === 'proactive' ? '主动' : '严谨'),
1020
- element('span', {}, element('strong', {}, '回写模型'), state.service.writebackProvider && state.service.writebackModel ? `本机 · ${state.service.writebackProvider} / ${state.service.writebackModel}` : '跟随当前会话'),
1406
+ element('span', {}, element('strong', {}, '回写模型'), writebackRouteLabel(base)),
1021
1407
  base.extractionInstructions ? element('span', {}, element('strong', {}, '提取规则'), '已设置') : null,
1022
1408
  ),
1023
1409
  element('div', { class: 'base-card-actions' },
@@ -1133,9 +1519,7 @@ function renderMountListRow(base, view, targetKind, targetId) {
1133
1519
  renderShell()
1134
1520
  },
1135
1521
  })
1136
- const modelLabel = state.service.writebackProvider && state.service.writebackModel
1137
- ? `本机 · ${state.service.writebackProvider} / ${state.service.writebackModel}`
1138
- : '跟随当前会话模型'
1522
+ const modelLabel = writebackRouteLabel(base)
1139
1523
  return element('article', { class: `mount-list-row${selected ? ' is-selected' : ''}`, role: 'listitem' },
1140
1524
  element('label', { class: 'mount-select' }, checkbox, element('span', { class: 'visually-hidden' }, `选择 ${base.name}`)),
1141
1525
  element('div', { class: 'mount-list-main' },
@@ -1192,53 +1576,77 @@ function renderDocumentWorkspace(workspace, options = {}) {
1192
1576
  'data-document-scope': workspace.kind,
1193
1577
  onInput: (event) => {
1194
1578
  view.query = event.target.value
1195
- renderShell()
1196
- const input = document.querySelector(`.note-tree-search[data-document-scope="${workspace.kind}"]`)
1197
- input?.focus()
1198
- input?.setSelectionRange(input.value.length, input.value.length)
1579
+ scheduleDocumentSearch(workspace)
1580
+ window.requestAnimationFrame(() => {
1581
+ const input = document.querySelector(`.note-tree-search[data-document-scope="${workspace.kind}"]`)
1582
+ input?.focus()
1583
+ input?.setSelectionRange(input.value.length, input.value.length)
1584
+ })
1199
1585
  },
1200
1586
  })
1201
- const tree = activeBases.map(base => {
1587
+ const visibleBases = query
1588
+ ? activeBases.filter(base => view.searchResults.some(document => document.knowledgeBaseId === base.id))
1589
+ : activeBases
1590
+ const tree = visibleBases.map(base => {
1202
1591
  const expanded = view.expandedBases.has(base.id) || Boolean(query)
1203
- const documents = workspaceDocuments.filter(document => document.knowledgeBaseId === base.id
1204
- && (!query || [document.title, document.relPath, document.content].some(value => value.toLocaleLowerCase().includes(query))))
1592
+ const page = documentPageState(workspace, base.id)
1593
+ const documents = (query ? view.searchResults : workspaceDocuments).filter(document => document.knowledgeBaseId === base.id)
1205
1594
  return element('section', { class: 'note-tree-group', 'data-expanded': String(expanded) },
1206
1595
  element('button', {
1207
1596
  type: 'button', class: 'note-tree-base', 'aria-expanded': String(expanded),
1208
1597
  onClick: () => {
1209
- view.knowledgeBaseId = base.id
1210
- if (expanded && !query) view.expandedBases.delete(base.id)
1211
- else view.expandedBases.add(base.id)
1212
- renderShell()
1598
+ if (query) { view.knowledgeBaseId = base.id; return renderShell() }
1599
+ void toggleDocumentBase(workspace, base.id)
1213
1600
  },
1214
1601
  },
1215
1602
  element('span', { class: 'tree-disclosure', 'aria-hidden': 'true' }),
1216
1603
  element('span', { class: 'tree-folder-icon', 'aria-hidden': 'true' }),
1217
1604
  element('span', { class: 'tree-base-name' }, base.name),
1218
- element('span', { class: 'tree-count' }, documents.length)),
1605
+ element('span', { class: 'tree-count' }, query ? documents.length : page.loaded ? page.total : '—')),
1219
1606
  expanded ? element('div', { class: 'note-tree-documents', role: 'group', 'aria-label': `${base.name}文档` },
1607
+ !query && page.loading && documents.length === 0 ? element('div', { class: 'note-tree-status', role: 'status' }, '正在读取目录…') : null,
1608
+ !query && page.error ? element('div', { class: 'note-tree-status is-error' },
1609
+ element('span', {}, page.error),
1610
+ actionButton('重试', () => { void loadDocumentPage(workspace, base.id, { reset: true }).then(renderShell) }, 'ghost small')) : null,
1220
1611
  documents.map(document => element('button', {
1221
1612
  type: 'button', class: 'note-tree-document', 'aria-current': document.id === view.documentId ? 'page' : undefined,
1222
- onClick: () => { void selectDocument(workspace, document.id) },
1613
+ onClick: () => { view.knowledgeBaseId = base.id; void selectDocument(workspace, document.id) },
1223
1614
  }, element('span', { class: 'tree-document-icon', 'aria-hidden': 'true' }), element('span', { class: 'tree-document-copy' },
1224
1615
  element('strong', {}, document.title), element('small', {}, document.relPath)),
1225
1616
  document.documentState !== 'open' ? badge(DOCUMENT_STATE_LABELS[document.documentState] || '已结束', 'success') : null)),
1617
+ !query && page.nextCursor ? element('button', {
1618
+ type: 'button', class: 'note-tree-more', disabled: page.loading,
1619
+ onClick: () => { void loadDocumentPage(workspace, base.id, { append: true }).then(renderShell) },
1620
+ }, page.loading ? '正在加载…' : `继续加载(已显示 ${documents.length} / ${page.total})`) : null,
1621
+ !query && page.loaded && documents.length === 0 ? element('div', { class: 'note-tree-status' }, '这个知识库还没有文档。') : null,
1226
1622
  !query && !readOnly ? element('button', { type: 'button', class: 'note-tree-new', onClick: () => { void startBlankDocument(workspace, base.id) } },
1227
1623
  element('span', { 'aria-hidden': 'true' }, '+'), '新建文档') : null,
1228
1624
  ) : null,
1229
1625
  )
1230
1626
  })
1627
+ const treeContent = query
1628
+ ? [
1629
+ view.searchLoading && view.searchResults.length === 0 ? element('div', { class: 'note-tree-status', role: 'status' }, '正在搜索文档…') : null,
1630
+ view.searchError ? element('div', { class: 'note-tree-status is-error' }, view.searchError) : null,
1631
+ ...tree,
1632
+ !view.searchLoading && !view.searchError && view.searchResults.length === 0 ? element('div', { class: 'note-tree-empty' }, '没有匹配的文档') : null,
1633
+ view.searchNextCursor ? element('button', {
1634
+ type: 'button', class: 'note-tree-more search-more', disabled: view.searchLoading,
1635
+ onClick: () => { void loadDocumentSearch(workspace, false) },
1636
+ }, view.searchLoading ? '正在加载…' : `继续加载搜索结果(${view.searchResults.length} / ${view.searchTotal})`) : null,
1637
+ ]
1638
+ : tree.length ? tree : [element('div', { class: 'note-tree-empty' }, workspace.kind === 'session' && state.mountContext.sessionId ? '当前会话未挂载知识库' : '还没有知识库')]
1231
1639
  return element('section', {
1232
1640
  class: `note-workspace note-workspace--${workspace.kind}`, 'aria-labelledby': `${workspace.kind}-documents-heading`,
1233
1641
  'data-tree-open': String(view.treeOpen),
1234
1642
  },
1235
1643
  element('aside', { class: 'note-tree-panel', 'aria-label': '知识目录' },
1236
1644
  element('header', { class: 'note-tree-header' },
1237
- element('div', {}, element('h2', { id: `${workspace.kind}-documents-heading` }, options.heading || '知识目录'), element('span', {}, `${workspaceDocuments.length} 篇文档`)),
1645
+ element('div', {}, element('h2', { id: `${workspace.kind}-documents-heading` }, options.heading || '知识目录'), element('span', {}, query ? `${view.searchTotal} 条搜索结果` : `${workspaceDocuments.length} 篇已加载`)),
1238
1646
  !readOnly ? actionButton('+', () => { void startBlankDocument(workspace, view.knowledgeBaseId || activeBases[0]?.id) }, 'ghost note-add-button', { 'aria-label': '新建文档', title: '新建文档' }) : null,
1239
1647
  ),
1240
1648
  element('div', { class: 'note-tree-search-wrap' }, interfaceIcon('search', 'search-symbol'), search),
1241
- element('nav', { class: 'note-tree', 'data-scroll-key': `${workspace.kind}-note-tree` }, tree.length ? tree : element('div', { class: 'note-tree-empty' }, workspace.kind === 'session' && state.mountContext.sessionId ? '当前会话未挂载知识库' : '还没有知识库')),
1649
+ element('nav', { class: 'note-tree', 'data-scroll-key': `${workspace.kind}-note-tree` }, treeContent),
1242
1650
  workspace.kind === 'session' ? element('footer', { class: 'note-tree-footer' }, actionButton('新建知识库', () => openKnowledgeBaseEditor(), 'ghost small')) : null,
1243
1651
  ),
1244
1652
  view.treeOpen ? element('button', {
@@ -1252,6 +1660,7 @@ function renderDocumentWorkspace(workspace, options = {}) {
1252
1660
  function renderNoteEditor(workspace, editor, base) {
1253
1661
  const view = workspace.view
1254
1662
  const readOnly = documentWorkspaceReadOnly(workspace)
1663
+ if (view.editorLoading) return renderDocumentEditorLoading(base)
1255
1664
  if (!editor) return element('main', { class: 'note-editor note-editor-empty' },
1256
1665
  element('div', { class: 'note-empty-content' },
1257
1666
  element('span', { class: 'empty-document-mark', 'aria-hidden': 'true' }),
@@ -1275,28 +1684,29 @@ function renderNoteEditor(workspace, editor, base) {
1275
1684
  class: 'note-title-input', value: editor.title, maxlength: 200, placeholder: '无标题文档', 'aria-label': '文档标题',
1276
1685
  onInput: event => update('title', event.target.value), onKeyDown: saveShortcut,
1277
1686
  })
1278
- const body = element('textarea', {
1279
- class: 'note-body-editor', maxlength: 50000, placeholder: '从这里开始记录…', 'aria-label': '文档正文',
1280
- onInput: event => { update('body', event.target.value); resizeDocumentEditor(event.target) }, onKeyDown: saveShortcut,
1281
- })
1282
- body.value = editor.body
1283
1687
  const finalized = !editor.isNew && editor.documentState !== 'open'
1284
- const mode = finalized || readOnly ? 'preview' : view.mode === 'edit' ? 'edit' : 'preview'
1285
- if (mode === 'edit') window.requestAnimationFrame(() => resizeDocumentEditor(body))
1688
+ const editable = !finalized && !readOnly
1689
+ const body = editable
1690
+ ? element('div', { class: 'notes-live-editor knowledge-live-editor', role: 'status', 'aria-label': '正在打开知识文档正文' },
1691
+ element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
1692
+ : renderMarkdownPreview(editor.body)
1693
+ if (editable) mountMarkdownEditor(body, {
1694
+ markdown: editor.body,
1695
+ label: '编辑知识文档正文',
1696
+ onChange: value => update('body', value),
1697
+ onSave: () => { void saveDocumentEditor(workspace).then(saved => { if (saved) renderShell() }) },
1698
+ })
1286
1699
  return element('main', { class: 'note-editor', 'aria-label': '文档编辑器' },
1287
1700
  element('header', { class: 'note-editor-toolbar' },
1288
1701
  element('div', { class: 'note-breadcrumb' },
1289
1702
  element('span', {}, base?.name || '知识库'),
1290
1703
  element('span', { 'aria-hidden': 'true' }, '/'),
1291
- element('strong', {}, editor.isNew ? '新文档' : documentWorkspaceDocuments(workspace).find(item => item.id === editor.id)?.relPath || editor.title)),
1704
+ element('strong', {}, editor.isNew ? '新文档' : documentWorkspaceDocuments(workspace).find(item => item.id === editor.id)?.relPath || editor.title),
1705
+ element('span', { class: 'note-toolbar-meta' },
1706
+ element('span', {}, editor.isNew ? '尚未保存' : `更新于 ${formatDate(editor.updatedAt)}`),
1707
+ element('span', {}, editor.scope.kind === 'global' ? '全局知识' : `项目 ${editor.scope.id}`))),
1292
1708
  element('div', { class: 'note-editor-actions' },
1293
- finalized ? badge(DOCUMENT_STATE_LABELS[editor.documentState] || '已结束', 'success') : readOnly ? badge('只读') : element('div', { class: 'note-mode-switch', role: 'tablist', 'aria-label': '文档视图' }, [
1294
- ['edit', '编辑'],
1295
- ['preview', '预览'],
1296
- ].map(([value, label]) => element('button', {
1297
- type: 'button', role: 'tab', 'aria-selected': String(mode === value),
1298
- onClick: () => switchDocumentMode(workspace, value),
1299
- }, label))),
1709
+ finalized ? badge(DOCUMENT_STATE_LABELS[editor.documentState] || '已结束', 'success') : readOnly ? badge('只读') : null,
1300
1710
  element('span', { class: 'editor-save-status', role: 'status' }, editor.saveState),
1301
1711
  finalized && !readOnly ? actionButton('重新打开', () => reopenDocument(workspace, editor), 'small') : null,
1302
1712
  !readOnly && !editor.isNew && !finalized ? actionButton('标记结束', () => openFinalizeDocument(workspace, editor), 'small') : null,
@@ -1313,14 +1723,12 @@ function renderNoteEditor(workspace, editor, base) {
1313
1723
  editor.finalizationNote ? element('p', {}, editor.finalizationNote) : null,
1314
1724
  editor.finalizedAt ? element('small', {}, `结束于 ${formatDate(editor.finalizedAt)}`) : null,
1315
1725
  ) : null,
1316
- element('article', { class: `note-paper is-${mode}`, 'data-document-mode': mode },
1317
- mode === 'edit' ? title : element('h1', { class: 'note-preview-title' }, editor.title || '无标题文档'),
1318
- element('div', { class: 'note-document-meta' },
1319
- element('span', {}, editor.isNew ? '尚未保存' : `更新于 ${formatDate(editor.updatedAt)}`),
1320
- element('span', {}, editor.scope.kind === 'global' ? '全局知识' : `项目 · ${editor.scope.id}`)),
1321
- mode === 'edit' ? body : renderMarkdownPreview(editor.body),
1726
+ element('article', { class: `note-paper is-${editable ? 'edit' : 'preview'}`, 'data-document-mode': editable ? 'edit' : 'preview' },
1727
+ editable ? title : element('h1', { class: 'note-preview-title' }, editor.title || '无标题文档'),
1728
+ body,
1322
1729
  ),
1323
1730
  ),
1731
+ renderDocumentReferenceBar(workspace, editor, editable),
1324
1732
  element('footer', { class: 'note-inspector' },
1325
1733
  finalized || readOnly ? element('span', { class: 'note-format-hint' }, readOnly ? '知识库已归档 · 只读' : '只读封存 · 重新打开后才能编辑') : element('label', {}, element('span', {}, '类型'), element('select', {
1326
1734
  class: 'note-meta-select', onChange: event => update('type', event.target.value),
@@ -1328,16 +1736,109 @@ function renderNoteEditor(workspace, editor, base) {
1328
1736
  finalized || readOnly ? null : element('label', { class: 'note-tags-field' }, element('span', {}, '标签'), element('input', {
1329
1737
  value: editor.tagsText, placeholder: '用逗号分隔', onInput: event => update('tagsText', event.target.value),
1330
1738
  })),
1331
- !finalized && !readOnly ? element('span', { class: 'note-format-hint' }, mode === 'edit' ? 'Markdown · Ctrl/⌘ S 保存' : 'Markdown 预览') : null,
1739
+ editable ? element('span', { class: 'note-format-hint' }, 'Markdown · Ctrl/⌘ S 保存') : null,
1332
1740
  ),
1333
1741
  )
1334
1742
  }
1335
1743
 
1336
- function switchDocumentMode(workspace, mode) {
1337
- if (mode !== 'edit' && mode !== 'preview') return
1338
- workspace.view.mode = mode
1339
- renderShell()
1340
- if (mode === 'edit') window.requestAnimationFrame(() => document.querySelector('.note-body-editor')?.focus())
1744
+ function renderDocumentEditorLoading(base) {
1745
+ return element('main', { class: 'note-editor note-editor-loading', role: 'status', 'aria-label': '正在打开知识文档' },
1746
+ element('header', { class: 'note-editor-toolbar' },
1747
+ element('div', { class: 'note-breadcrumb' }, element('span', {}, base?.name || '知识库'), element('span', { 'aria-hidden': 'true' }, '/'), element('strong', {}, '正在打开文档')),
1748
+ element('span', { class: 'editor-load-label' }, '按需加载正文与关联笔记'),
1749
+ ),
1750
+ element('div', { class: 'note-editor-scroll' }, element('article', { class: 'note-paper editor-paper-skeleton', 'aria-hidden': 'true' },
1751
+ element('div', { class: 'skeleton-line skeleton-paper-title' }),
1752
+ ...Array.from({ length: 9 }, (_, index) => element('div', { class: `skeleton-line skeleton-paper-row is-${index % 4}` })),
1753
+ )),
1754
+ )
1755
+ }
1756
+
1757
+ function renderDocumentReferenceBar(workspace, editor, editable) {
1758
+ const references = editor.noteReferences || []
1759
+ return element('section', { class: 'document-reference-bar', 'aria-label': '关联笔记' },
1760
+ element('div', { class: 'document-reference-heading' },
1761
+ element('span', { class: 'document-reference-mark', 'aria-hidden': 'true' }),
1762
+ element('strong', {}, '关联笔记'),
1763
+ element('small', {}, references.length ? `${references.length} 项` : '未关联')),
1764
+ element('div', { class: 'document-reference-list' }, references.length
1765
+ ? references.map(reference => element('span', { class: 'document-reference-chip' },
1766
+ element('button', {
1767
+ type: 'button', class: 'document-reference-open', title: `打开 ${reference.note.name}`,
1768
+ onClick: () => { void openNoteReference(reference.note.id) },
1769
+ }, renderNoteIcon(reference.note), element('span', {}, reference.note.name)),
1770
+ editable ? element('button', {
1771
+ type: 'button', class: 'document-reference-remove', 'aria-label': `移除 ${reference.note.name} 的关联`, title: '移除关联',
1772
+ onClick: () => { void removeDocumentNoteReference(workspace, editor, reference) },
1773
+ }, '×') : null,
1774
+ ))
1775
+ : element('span', { class: 'document-reference-empty' }, editor.isNew ? '保存文档后可以关联笔记或资料文件' : '这篇知识还没有关联笔记')),
1776
+ editable && !editor.isNew ? actionButton('+ 添加', () => { void openDocumentNoteReferencePicker(workspace, editor) }, 'ghost small document-reference-add') : null,
1777
+ )
1778
+ }
1779
+
1780
+ async function openDocumentNoteReferencePicker(workspace, editor) {
1781
+ const search = element('input', {
1782
+ class: 'input', type: 'search', placeholder: '搜索笔记文档或文件', 'aria-label': '搜索可关联的笔记',
1783
+ })
1784
+ const results = element('div', { class: 'note-picker-results', 'aria-live': 'polite' })
1785
+ let modal
1786
+ let request = 0
1787
+ const paint = async () => {
1788
+ const current = ++request
1789
+ const query = search.value.trim()
1790
+ if (!query) {
1791
+ results.replaceChildren(element('div', { class: 'note-picker-empty' }, '输入名称以搜索可关联的笔记文档和文件。'))
1792
+ return
1793
+ }
1794
+ results.replaceChildren(element('div', { class: 'note-picker-empty' }, '正在搜索…'))
1795
+ try {
1796
+ const linked = new Set((editor.noteReferences || []).map(reference => reference.note.id))
1797
+ const visible = (await api(`notes?q=${encodeURIComponent(query)}&limit=100`)).filter(node => node.kind !== 'folder' && !linked.has(node.id))
1798
+ if (current !== request) return
1799
+ results.replaceChildren(...(visible.length
1800
+ ? visible.map(node => element('button', {
1801
+ type: 'button', class: 'note-picker-row',
1802
+ onClick: async event => {
1803
+ event.currentTarget.disabled = true
1804
+ try {
1805
+ const reference = await api(`entries/${encodeURIComponent(editor.id)}/note-references`, { method: 'POST', body: { noteId: node.id } })
1806
+ editor.noteReferences = [...(editor.noteReferences || []), reference]
1807
+ modal.close(true)
1808
+ renderShell()
1809
+ showToast('已关联笔记。')
1810
+ } catch (error) {
1811
+ event.currentTarget.disabled = false
1812
+ showToast(friendlyError(error), 'error')
1813
+ }
1814
+ },
1815
+ },
1816
+ renderNoteIcon(node),
1817
+ element('span', {}, element('strong', {}, node.name), element('small', {}, `${node.kind === 'document' ? '笔记文档' : formatBytes(node.size)} ${shortNoteId(node.id)}`))))
1818
+ : [element('div', { class: 'note-picker-empty' }, '没有匹配的笔记文档或文件。')]))
1819
+ } catch (error) {
1820
+ if (current !== request) return
1821
+ results.replaceChildren(element('div', { class: 'note-picker-empty is-error' }, friendlyError(error)))
1822
+ }
1823
+ }
1824
+ let timer = 0
1825
+ search.addEventListener('input', () => { window.clearTimeout(timer); timer = window.setTimeout(() => void paint(), 180) })
1826
+ modal = openSheet({
1827
+ title: '添加关联笔记',
1828
+ description: '关联保存在文档正文之外;笔记移动或改名后仍然有效。',
1829
+ body: element('div', { class: 'note-picker' }, search, results),
1830
+ cancelLabel: '取消',
1831
+ })
1832
+ search.focus()
1833
+ }
1834
+
1835
+ async function removeDocumentNoteReference(workspace, editor, reference) {
1836
+ try {
1837
+ await api(`entries/${encodeURIComponent(editor.id)}/note-references/${encodeURIComponent(reference.note.id)}`, { method: 'DELETE' })
1838
+ editor.noteReferences = (editor.noteReferences || []).filter(item => item.note.id !== reference.note.id)
1839
+ if (workspace.view.editor === editor) renderShell()
1840
+ showToast('已移除关联。')
1841
+ } catch (error) { showToast(friendlyError(error), 'error') }
1341
1842
  }
1342
1843
 
1343
1844
  function renderMarkdownPreview(markdown) {
@@ -1352,12 +1853,30 @@ function renderMarkdownPreview(markdown) {
1352
1853
  table.replaceWith(scroller)
1353
1854
  scroller.append(table)
1354
1855
  })
1856
+ preview.querySelectorAll('[data-note-id]').forEach(link => {
1857
+ link.setAttribute('role', 'button')
1858
+ link.setAttribute('tabindex', '0')
1859
+ link.setAttribute('title', '打开笔记文档')
1860
+ const open = event => {
1861
+ if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') return
1862
+ event.preventDefault()
1863
+ void openNoteReference(link.dataset.noteId)
1864
+ }
1865
+ link.addEventListener('click', open)
1866
+ link.addEventListener('keydown', open)
1867
+ })
1355
1868
  return preview
1356
1869
  }
1357
1870
 
1358
- function resizeDocumentEditor(editor) {
1359
- editor.style.height = 'auto'
1360
- editor.style.height = `${Math.max(420, editor.scrollHeight)}px`
1871
+ async function openNoteReference(id) {
1872
+ try {
1873
+ if (!await saveBeforeLeavingDocument()) return
1874
+ const node = await api(`notes/${encodeURIComponent(id)}`)
1875
+ state.view = 'notes'
1876
+ state.loading = false
1877
+ await selectNoteNode(node)
1878
+ renderShell()
1879
+ } catch (error) { showToast(friendlyError(error), 'error') }
1361
1880
  }
1362
1881
 
1363
1882
  function openFinalizeDocument(workspace, editor) {
@@ -1427,6 +1946,1068 @@ function confirmDeleteDocument(workspace, editor) {
1427
1946
  })
1428
1947
  }
1429
1948
 
1949
+ function renderNotes() {
1950
+ const fileInput = element('input', {
1951
+ class: 'visually-hidden', type: 'file', multiple: true, tabindex: '-1',
1952
+ onChange: event => { void uploadNoteFiles([...event.target.files], state.notes.currentFolderId); event.target.value = '' },
1953
+ })
1954
+ const rootNodes = state.notes.children.get('root') || []
1955
+ const tree = state.notes.query.trim()
1956
+ ? renderNoteSearchResults()
1957
+ : renderNoteTreeBranch(null)
1958
+ const workspace = element('section', {
1959
+ class: 'notes-workspace', 'data-drop-active': 'false',
1960
+ onDragEnter: noteWorkspaceDragEnter, onDragOver: noteWorkspaceDragEnter,
1961
+ onDragLeave: noteWorkspaceDragLeave,
1962
+ onDrop: event => {
1963
+ clearNoteDragState()
1964
+ if (!hasDragType(event, 'Files')) return
1965
+ event.preventDefault()
1966
+ void importDroppedNotes(event.dataTransfer, state.notes.currentFolderId)
1967
+ },
1968
+ },
1969
+ element('aside', { class: 'notes-browser', 'aria-label': '笔记目录' },
1970
+ element('header', { class: 'notes-browser-header' },
1971
+ element('div', {}, element('h2', {}, '目录'), element('span', {}, `${rootNodes.length} 个根目录项目`)),
1972
+ element('div', { class: 'notes-browser-actions' },
1973
+ actionButton('新建', () => openCreateNoteDocument(), 'primary small'),
1974
+ actionButton('目录', () => openCreateNoteFolder(), 'small'),
1975
+ actionButton('导入', () => fileInput.click(), 'ghost small'),
1976
+ fileInput,
1977
+ ),
1978
+ ),
1979
+ element('div', { class: 'notes-search' }, element('input', {
1980
+ class: 'input', type: 'search', value: state.notes.query, placeholder: '搜索笔记文档', 'aria-label': '搜索笔记文档',
1981
+ onInput: event => scheduleNoteSearch(event.target.value),
1982
+ })),
1983
+ element('div', {
1984
+ class: 'notes-tree', role: 'tree', 'data-scroll-key': 'notes-tree',
1985
+ onDragOver: event => {
1986
+ if (hasDragType(event, 'application/x-dsh-note-id') || hasDragType(event, 'Files')) event.preventDefault()
1987
+ },
1988
+ onDrop: event => { if (event.target === event.currentTarget) void dropNoteNode(event, null) },
1989
+ }, tree),
1990
+ ),
1991
+ renderNoteContent(),
1992
+ element('div', { class: 'notes-drop-overlay', 'aria-hidden': 'true' },
1993
+ element('strong', {}, '放开以导入当前目录'),
1994
+ element('span', {}, '支持文件和完整目录,原有目录层级会被保留。')),
1995
+ renderNoteTransfer(),
1996
+ )
1997
+ return element('section', { class: 'notes-page', 'aria-label': '笔记工作区' }, workspace)
1998
+ }
1999
+
2000
+ function renderNoteTransfer() {
2001
+ const transfer = state.notes.transfer
2002
+ if (!transfer) return null
2003
+ const dismissible = transfer.phase === 'complete' || transfer.phase === 'error'
2004
+ return element('section', {
2005
+ class: 'notes-transfer', 'data-note-transfer': transfer.id, 'data-state': transfer.phase,
2006
+ role: transfer.phase === 'error' ? 'alert' : 'status', 'aria-live': 'polite',
2007
+ },
2008
+ element('div', { class: 'notes-transfer-heading' },
2009
+ element('span', { class: 'notes-transfer-mark', 'aria-hidden': 'true' }),
2010
+ element('strong', { class: 'notes-transfer-title' }, noteTransferTitle(transfer)),
2011
+ dismissible ? actionButton('关闭', dismissNoteTransfer, 'ghost tiny notes-transfer-close', { 'aria-label': '关闭导入进度' }) : null,
2012
+ ),
2013
+ element('div', { class: 'notes-transfer-detail', title: transfer.currentName || '' }, noteTransferDetail(transfer)),
2014
+ element('div', {
2015
+ class: 'notes-transfer-track', role: 'progressbar',
2016
+ 'aria-label': '笔记导入进度', 'aria-valuemin': '0', 'aria-valuemax': '100',
2017
+ 'aria-valuenow': transfer.phase === 'scanning' ? undefined : String(noteTransferPercent(transfer)),
2018
+ }, element('span', { class: 'notes-transfer-fill' })),
2019
+ element('div', { class: 'notes-transfer-meta' },
2020
+ element('span', { class: 'notes-transfer-count' }, noteTransferCount(transfer)),
2021
+ element('span', { class: 'notes-transfer-percent' }, transfer.phase === 'scanning' ? '正在整理' : `${noteTransferPercent(transfer)}%`),
2022
+ ),
2023
+ )
2024
+ }
2025
+
2026
+ function renderNoteTreeBranch(parentId, depth = 0) {
2027
+ const key = parentId || 'root'
2028
+ const nodes = state.notes.children.get(key) || []
2029
+ if (!nodes.length && depth === 0) {
2030
+ return element('div', { class: 'notes-tree-empty' }, element('strong', {}, '还没有笔记'), element('span', {}, '新建目录或文档开始整理。'))
2031
+ }
2032
+ return element('div', { class: 'notes-tree-branch', role: depth ? 'group' : undefined }, nodes.map(node => {
2033
+ const selected = state.notes.selectedId === node.id
2034
+ const expanded = node.kind === 'folder' && state.notes.expandedFolders.has(node.id)
2035
+ const row = element('div', {
2036
+ class: 'notes-tree-item', 'data-kind': node.kind, 'data-selected': String(selected),
2037
+ 'data-note-id': node.id,
2038
+ draggable: 'true',
2039
+ onDragStart: event => {
2040
+ event.dataTransfer.effectAllowed = 'move'
2041
+ event.dataTransfer.setData('application/x-dsh-note-id', node.id)
2042
+ event.currentTarget.dataset.dragging = 'true'
2043
+ },
2044
+ onDragEnd: () => clearNoteDragState(),
2045
+ onDragEnter: event => activateNoteFolderDropTarget(event, node),
2046
+ onDragOver: event => activateNoteFolderDropTarget(event, node),
2047
+ onDragLeave: event => { if (!event.currentTarget.contains(event.relatedTarget)) event.currentTarget.dataset.dropTarget = 'false' },
2048
+ onDrop: event => { if (node.kind === 'folder') void dropNoteNode(event, node.id) },
2049
+ },
2050
+ element('button', {
2051
+ type: 'button', class: 'notes-tree-row', role: 'treeitem',
2052
+ 'aria-selected': String(selected), 'aria-expanded': node.kind === 'folder' ? String(expanded) : undefined,
2053
+ onClick: () => { void selectNoteNode(node, { toggleFolder: node.kind === 'folder' }) },
2054
+ },
2055
+ node.kind === 'folder' ? element('span', { class: 'notes-tree-chevron', 'aria-hidden': 'true' }) : element('span', { class: 'notes-tree-spacer' }),
2056
+ renderNoteIcon(node),
2057
+ element('span', { class: 'notes-tree-name', title: node.name }, node.name),
2058
+ ),
2059
+ element('div', { class: 'notes-row-actions' },
2060
+ actionButton('重命名', event => { event.stopPropagation(); openRenameNoteNode(node) }, 'ghost tiny'),
2061
+ actionButton('复制', event => { event.stopPropagation(); void copyNoteNode(node) }, 'ghost tiny'),
2062
+ actionButton('删除', event => { event.stopPropagation(); void confirmDeleteNoteNode(node) }, 'ghost tiny danger-text'),
2063
+ ),
2064
+ )
2065
+ const children = expanded
2066
+ ? state.notes.loadedFolders.has(node.id)
2067
+ ? renderNoteTreeBranch(node.id, depth + 1)
2068
+ : element('div', { class: 'notes-tree-loading', role: 'status' }, '正在读取…')
2069
+ : null
2070
+ return element('div', { class: 'notes-tree-node' }, row, children)
2071
+ }))
2072
+ }
2073
+
2074
+ function renderNoteSearchResults() {
2075
+ if (!state.notes.searchResults.length) return element('div', { class: 'notes-tree-empty' }, '没有匹配的笔记文档。')
2076
+ return element('div', { class: 'notes-search-results' }, state.notes.searchResults.map(node => element('button', {
2077
+ type: 'button', class: 'notes-search-row', onClick: () => { void selectNoteNode(node) },
2078
+ }, renderNoteIcon(node), element('span', {}, element('strong', {}, node.name), element('small', {}, noteKindLabel(node))))))
2079
+ }
2080
+
2081
+ function renderNoteContent() {
2082
+ const node = state.notes.selectedNode
2083
+ if (!node) return renderNoteFolderContent(null)
2084
+ if (state.notes.loadingNodeId === node.id) return renderNoteSelectionLoading(node)
2085
+ if (node.kind === 'folder') return renderNoteFolderContent(node)
2086
+ if (node.kind === 'document') return renderNoteDocument(node)
2087
+ return renderNoteFile(node)
2088
+ }
2089
+
2090
+ function renderNoteSelectionLoading(node) {
2091
+ return element('main', { class: `notes-content notes-selection-loading is-${node.kind}`, role: 'status', 'aria-label': `正在打开 ${node.name}` },
2092
+ element('header', { class: 'notes-content-header' },
2093
+ element('div', { class: 'notes-breadcrumb' }, element('span', {}, '笔记文档'), element('span', { 'aria-hidden': 'true' }, '/'), element('strong', {}, node.name)),
2094
+ element('div', { class: 'notes-content-title' }, element('div', {}, element('h2', {}, node.name), element('p', {}, node.kind === 'folder' ? '正在读取目录内容' : '正在按需读取文档内容'))),
2095
+ ),
2096
+ element('div', { class: 'notes-selection-skeleton', 'aria-hidden': 'true' },
2097
+ element('div', { class: 'skeleton-line skeleton-paper-title' }),
2098
+ ...Array.from({ length: node.kind === 'folder' ? 6 : 9 }, (_, index) => element('div', { class: `skeleton-line skeleton-paper-row is-${index % 4}` })),
2099
+ ),
2100
+ )
2101
+ }
2102
+
2103
+ function renderNoteFolderContent(folder) {
2104
+ const parentId = folder?.id || null
2105
+ const children = state.notes.children.get(parentId || 'root') || []
2106
+ return element('main', {
2107
+ class: 'notes-content is-folder',
2108
+ onDragOver: event => event.preventDefault(),
2109
+ onDrop: event => void dropNoteNode(event, parentId),
2110
+ },
2111
+ renderNoteContentHeader(folder),
2112
+ children.length
2113
+ ? element('div', { class: 'notes-file-list', role: 'list' }, children.map(node => element('div', {
2114
+ class: 'notes-file-row', role: 'listitem', draggable: 'true',
2115
+ onDragStart: event => { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('application/x-dsh-note-id', node.id) },
2116
+ onDblClick: () => { void selectNoteNode(node, { toggleFolder: node.kind === 'folder' }) },
2117
+ },
2118
+ element('button', { type: 'button', class: 'notes-file-main', onClick: () => { void selectNoteNode(node) } },
2119
+ renderNoteIcon(node, true),
2120
+ element('span', {}, element('strong', {}, node.name), element('small', {}, noteKindLabel(node))),
2121
+ ),
2122
+ element('time', { datetime: node.updatedAt }, formatDate(node.updatedAt)),
2123
+ element('span', { class: 'notes-file-size' }, node.kind === 'folder' ? '' : formatBytes(node.size)),
2124
+ element('div', { class: 'notes-file-actions' },
2125
+ actionButton('重命名', () => openRenameNoteNode(node), 'ghost tiny'),
2126
+ actionButton('复制', () => { void copyNoteNode(node) }, 'ghost tiny'),
2127
+ actionButton('删除', () => { void confirmDeleteNoteNode(node) }, 'ghost tiny danger-text'),
2128
+ ),
2129
+ )))
2130
+ : element('div', { class: 'notes-folder-empty' },
2131
+ element('span', { class: 'notes-empty-folder-mark', 'aria-hidden': 'true' }),
2132
+ element('h3', {}, '这个目录还是空的'),
2133
+ element('p', {}, '可以新建笔记文档、建立子目录,或把本地文件拖到这里。'),
2134
+ element('div', {}, actionButton('新建文档', () => openCreateNoteDocument(parentId), 'primary small'), actionButton('新建目录', () => openCreateNoteFolder(parentId), 'small')),
2135
+ ),
2136
+ )
2137
+ }
2138
+
2139
+ function renderNoteContentHeader(folder) {
2140
+ const path = folder ? [...state.notes.breadcrumbs] : []
2141
+ return element('header', { class: 'notes-content-header' },
2142
+ element('nav', { class: 'notes-breadcrumb', 'aria-label': '当前位置' },
2143
+ element('button', { type: 'button', onClick: () => { void openNoteRoot() } }, '笔记文档'),
2144
+ path.map(node => element('span', {}, element('span', { 'aria-hidden': 'true' }, '/'), element('button', { type: 'button', onClick: () => { void selectNoteNode(node) } }, node.name))),
2145
+ ),
2146
+ element('div', { class: 'notes-content-title' },
2147
+ element('div', {}, element('h2', {}, folder?.name || '全部笔记'), element('p', {}, folder ? `${(state.notes.children.get(folder.id) || []).length} 个项目` : '你的独立笔记与资料目录')),
2148
+ element('div', { class: 'notes-content-actions' },
2149
+ actionButton('新建文档', () => openCreateNoteDocument(folder?.id || null), 'primary small'),
2150
+ actionButton('新建目录', () => openCreateNoteFolder(folder?.id || null), 'small'),
2151
+ ),
2152
+ ),
2153
+ )
2154
+ }
2155
+
2156
+ function renderNoteDocument(node) {
2157
+ return renderEditableNote(node)
2158
+ }
2159
+
2160
+ function renderEditableNote(node) {
2161
+ const markdown = isMarkdownNote(node)
2162
+ const title = editableNoteTitle(node)
2163
+ const editor = markdown
2164
+ ? element('div', { class: 'notes-live-editor', role: 'status', 'aria-label': `正在打开 ${node.name}` },
2165
+ element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
2166
+ : createPlainTextNoteEditor(node)
2167
+ if (markdown) mountMarkdownNoteEditor(editor, node)
2168
+ else mountPlainTextNoteEditor(editor, node)
2169
+ return element('main', { class: `notes-content is-document${markdown ? '' : ' has-line-numbers'}` },
2170
+ renderNoteFileToolbar(node, { editable: true }),
2171
+ element('h1', {
2172
+ class: 'notes-document-title', contenteditable: 'plaintext-only', spellcheck: 'false',
2173
+ 'aria-label': `修改 ${node.name} 的标题`, title: '点击修改标题',
2174
+ onBlur: event => { void saveEditableNoteTitle(event.currentTarget, node) },
2175
+ onKeyDown: event => {
2176
+ if (event.key === 'Enter') { event.preventDefault(); event.currentTarget.blur() }
2177
+ if (event.key === 'Escape') { event.preventDefault(); event.currentTarget.textContent = title; event.currentTarget.blur() }
2178
+ },
2179
+ }, title),
2180
+ editor,
2181
+ )
2182
+ }
2183
+
2184
+ function editableNoteTitle(node) {
2185
+ return node.kind === 'document' ? node.name.replace(/\.md$/i, '') : node.name
2186
+ }
2187
+
2188
+ async function saveEditableNoteTitle(editor, node) {
2189
+ const title = readPlainTextEditor(editor).replace(/\s+/g, ' ').trim()
2190
+ const name = node.kind === 'document' && title ? `${title.replace(/\.md$/i, '')}.md` : title
2191
+ if (!name) {
2192
+ editor.textContent = editableNoteTitle(node)
2193
+ showToast('标题不能为空。', 'error')
2194
+ return
2195
+ }
2196
+ if (name === node.name) {
2197
+ editor.textContent = editableNoteTitle(node)
2198
+ return
2199
+ }
2200
+ editor.setAttribute('contenteditable', 'false')
2201
+ try {
2202
+ const updated = await api(`notes/${encodeURIComponent(node.id)}`, { method: 'PATCH', body: { name } })
2203
+ if (state.notes.selectedNode?.id === node.id) state.notes.selectedNode = updated
2204
+ editor.textContent = editableNoteTitle(updated)
2205
+ const breadcrumb = document.querySelector('.notes-document-toolbar .notes-breadcrumb strong')
2206
+ if (breadcrumb) breadcrumb.textContent = updated.name
2207
+ await loadNoteChildren(updated.parentId, true).catch(() => {})
2208
+ const treeName = document.querySelector(`.notes-tree-item[data-note-id="${updated.id}"] .notes-tree-name`)
2209
+ if (treeName) treeName.textContent = updated.name
2210
+ showToast('标题已更新。')
2211
+ } catch (error) {
2212
+ editor.textContent = editableNoteTitle(node)
2213
+ showToast(friendlyError(error), 'error')
2214
+ } finally {
2215
+ if (editor.isConnected) editor.setAttribute('contenteditable', 'plaintext-only')
2216
+ }
2217
+ }
2218
+
2219
+ function createPlainTextNoteEditor(node) {
2220
+ return element('div', { class: 'notes-plain-editor', role: 'status', 'aria-label': `正在打开 ${node.name}` },
2221
+ element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
2222
+ }
2223
+
2224
+ function readPlainTextEditor(editor) {
2225
+ return editor.innerText.replace(/\r\n?/g, '\n')
2226
+ }
2227
+
2228
+ function updateNoteDraft(value) {
2229
+ state.notes.draft = value
2230
+ state.notes.dirty = state.notes.draft !== state.notes.content
2231
+ syncNoteEditorChrome()
2232
+ }
2233
+
2234
+ function mountMarkdownNoteEditor(host, node) {
2235
+ mountMarkdownEditor(host, {
2236
+ markdown: state.notes.draft,
2237
+ label: `编辑 ${node.name}`,
2238
+ isCurrent: () => state.notes.selectedNode?.id === node.id,
2239
+ onChange: updateNoteDraft,
2240
+ onSave: () => { void saveNoteDocument() },
2241
+ })
2242
+ }
2243
+
2244
+ function mountPlainTextNoteEditor(host, node) {
2245
+ const request = ++plainTextEditorMountRequest
2246
+ window.requestAnimationFrame(async () => {
2247
+ if (!host.isConnected || state.notes.selectedNode?.id !== node.id || request !== plainTextEditorMountRequest) return
2248
+ try {
2249
+ const runtime = await loadMarkdownNoteEditor()
2250
+ if (!host.isConnected || state.notes.selectedNode?.id !== node.id || request !== plainTextEditorMountRequest) return
2251
+ host.replaceChildren()
2252
+ host.removeAttribute('role')
2253
+ plainTextEditorHandle = runtime.createPlainTextEditor({
2254
+ host,
2255
+ text: state.notes.draft,
2256
+ label: `编辑 ${node.name}`,
2257
+ onChange: updateNoteDraft,
2258
+ onSave: () => { void saveNoteDocument() },
2259
+ })
2260
+ } catch (error) {
2261
+ if (!host.isConnected || request !== plainTextEditorMountRequest) return
2262
+ host.replaceChildren(element('div', { class: 'notes-editor-error', role: 'alert' },
2263
+ element('strong', {}, '无法打开纯文本编辑器'),
2264
+ element('span', {}, friendlyError(error)),
2265
+ actionButton('重试', () => { noteEditorLoader = null; host.replaceChildren(); mountPlainTextNoteEditor(host, node) }, 'small')))
2266
+ }
2267
+ })
2268
+ }
2269
+
2270
+ function mountMarkdownEditor(host, options) {
2271
+ const request = ++markdownEditorMountRequest
2272
+ window.requestAnimationFrame(async () => {
2273
+ if (!host.isConnected || options.isCurrent?.() === false || request !== markdownEditorMountRequest) return
2274
+ try {
2275
+ const runtime = await loadMarkdownNoteEditor()
2276
+ if (!host.isConnected || options.isCurrent?.() === false || request !== markdownEditorMountRequest) return
2277
+ host.replaceChildren()
2278
+ host.removeAttribute('role')
2279
+ markdownEditorHandle = runtime.createMarkdownEditor({
2280
+ host,
2281
+ markdown: options.markdown,
2282
+ label: options.label,
2283
+ onChange: options.onChange,
2284
+ onSave: options.onSave,
2285
+ })
2286
+ } catch (error) {
2287
+ if (!host.isConnected || request !== markdownEditorMountRequest) return
2288
+ host.replaceChildren(element('div', { class: 'notes-editor-error', role: 'alert' },
2289
+ element('strong', {}, '无法打开 Markdown 编辑器'),
2290
+ element('span', {}, friendlyError(error)),
2291
+ actionButton('重试', () => { noteEditorLoader = null; host.replaceChildren(); mountMarkdownEditor(host, options) }, 'small')))
2292
+ }
2293
+ })
2294
+ }
2295
+
2296
+ function loadMarkdownNoteEditor() {
2297
+ if (window.DshKnowledgeNoteEditor?.createMarkdownEditor) return Promise.resolve(window.DshKnowledgeNoteEditor)
2298
+ if (noteEditorLoader) return noteEditorLoader
2299
+ noteEditorLoader = new Promise((resolve, reject) => {
2300
+ const script = document.createElement('script')
2301
+ const suffix = ASSET_VERSION ? `?v=${encodeURIComponent(ASSET_VERSION)}` : ''
2302
+ script.src = `${WEB_PATH.replace(/\/$/, '')}/note-editor.js${suffix}`
2303
+ script.async = true
2304
+ script.onload = () => window.DshKnowledgeNoteEditor?.createMarkdownEditor
2305
+ ? resolve(window.DshKnowledgeNoteEditor)
2306
+ : reject(new Error('Markdown 编辑器模块无效'))
2307
+ script.onerror = () => { script.remove(); reject(new Error('Markdown 编辑器加载失败')) }
2308
+ document.head.append(script)
2309
+ }).catch(error => {
2310
+ noteEditorLoader = null
2311
+ throw error
2312
+ })
2313
+ return noteEditorLoader
2314
+ }
2315
+
2316
+ function releaseNoteEditors() {
2317
+ markdownEditorMountRequest += 1
2318
+ plainTextEditorMountRequest += 1
2319
+ markdownEditorHandle?.destroy()
2320
+ plainTextEditorHandle?.destroy()
2321
+ markdownEditorHandle = null
2322
+ plainTextEditorHandle = null
2323
+ }
2324
+
2325
+ function syncNoteEditorChrome() {
2326
+ const status = document.querySelector('[data-note-save-state]')
2327
+ if (status) {
2328
+ status.textContent = state.notes.dirty ? '未保存' : '已保存'
2329
+ status.dataset.dirty = String(state.notes.dirty)
2330
+ }
2331
+ const save = document.querySelector('[data-note-save]')
2332
+ if (save) save.disabled = !state.notes.dirty
2333
+ }
2334
+
2335
+ function renderNoteDocumentBreadcrumb(node) {
2336
+ return element('nav', { class: 'notes-breadcrumb', 'aria-label': '当前位置' },
2337
+ element('button', { type: 'button', onClick: () => { void openNoteRoot() } }, '笔记文档'),
2338
+ state.notes.breadcrumbs.map(parent => element('span', {}, element('span', { 'aria-hidden': 'true' }, '/'), element('button', { type: 'button', onClick: () => { void selectNoteNode(parent) } }, parent.name))),
2339
+ element('span', {}, element('span', { 'aria-hidden': 'true' }, '/'), element('strong', {}, node.name)),
2340
+ )
2341
+ }
2342
+
2343
+ function renderNoteFile(node) {
2344
+ if (node.editable) return renderEditableNote(node)
2345
+ const previewKind = noteFilePreviewKind(node)
2346
+ let preview
2347
+ if (previewKind === 'image' && state.notes.assetUrl) {
2348
+ preview = element('img', { class: 'notes-inline-image', src: state.notes.assetUrl, alt: node.name })
2349
+ } else if (previewKind === 'pdf' && state.notes.assetUrl) {
2350
+ preview = element('iframe', { class: 'notes-inline-frame', src: state.notes.assetUrl, title: node.name })
2351
+ } else {
2352
+ preview = element('div', { class: 'notes-inline-unavailable' },
2353
+ renderNoteIcon(node, true),
2354
+ element('h2', {}, node.name),
2355
+ element('p', {}, '该格式不能在浏览器中直接编辑,可以下载后使用本地应用打开。'),
2356
+ actionButton('下载文件', () => { void downloadNoteFile(node) }, 'primary small'),
2357
+ )
2358
+ }
2359
+ return element('main', { class: 'notes-content is-file' },
2360
+ renderNoteFileToolbar(node),
2361
+ element('section', { class: `notes-inline-viewer is-${previewKind}`, 'aria-label': `${node.name}内容` }, preview),
2362
+ )
2363
+ }
2364
+
2365
+ function renderNoteFileToolbar(node, options = {}) {
2366
+ return element('header', { class: 'notes-document-toolbar' },
2367
+ element('div', { class: 'notes-toolbar-leading' },
2368
+ renderNoteDocumentBreadcrumb(node),
2369
+ element('div', { class: 'notes-file-info', 'aria-label': '文件信息' },
2370
+ noteInfoItem('编号', shortNoteId(node.id), node.id),
2371
+ noteInfoItem('类型', node.kind === 'document' ? 'Markdown' : node.mediaType || '未知'),
2372
+ noteInfoItem('大小', formatBytes(node.size), '', 'size'),
2373
+ noteInfoItem('更新', formatDate(node.updatedAt), '', 'updated'),
2374
+ ),
2375
+ ),
2376
+ element('div', { class: 'notes-document-actions' },
2377
+ options.editable ? element('span', { class: 'notes-save-state', role: 'status', 'data-note-save-state': '', 'data-dirty': String(state.notes.dirty) }, state.notes.dirty ? '未保存' : '已保存') : null,
2378
+ node.kind === 'file' ? actionButton('下载', () => { void downloadNoteFile(node) }, 'ghost small') : null,
2379
+ actionButton('复制引用', () => { void copyNoteReference(node) }, 'ghost small'),
2380
+ actionButton('重命名', () => openRenameNoteNode(node), 'ghost small'),
2381
+ options.editable ? actionButton('保存', () => { void saveNoteDocument() }, 'primary small', { 'data-note-save': '', disabled: !state.notes.dirty }) : null,
2382
+ ),
2383
+ )
2384
+ }
2385
+
2386
+ function noteInfoItem(label, value, title = '', key = '') {
2387
+ return element('span', { class: 'notes-file-info-item', title: title || value, ...(key ? { 'data-note-info': key } : {}) }, element('strong', {}, label), element('span', {}, value))
2388
+ }
2389
+
2390
+ function renderNoteIcon(node, large = false) {
2391
+ const label = node.kind === 'folder' ? '' : noteExtension(node.name)
2392
+ return element('span', { class: `note-node-icon is-${node.kind}${large ? ' is-large' : ''}`, 'data-media-kind': noteMediaKind(node), 'aria-hidden': 'true' }, label)
2393
+ }
2394
+
2395
+ function noteKindLabel(node) {
2396
+ if (node.kind === 'folder') return '目录'
2397
+ if (node.kind === 'document') return 'Markdown 笔记'
2398
+ return `${node.mediaType || '文件'} ${formatBytes(node.size)}`
2399
+ }
2400
+
2401
+ function isMarkdownNote(node) {
2402
+ return node.kind === 'document' || node.mediaType === 'text/markdown' || /\.(md|markdown)$/i.test(node.name)
2403
+ }
2404
+
2405
+ function noteFilePreviewKind(node) {
2406
+ if (node.mediaType?.startsWith('image/')) return 'image'
2407
+ if (node.mediaType === 'application/pdf' || /\.pdf$/i.test(node.name)) return 'pdf'
2408
+ return 'unavailable'
2409
+ }
2410
+
2411
+ async function selectNoteNode(node, options = {}) {
2412
+ if (state.notes.dirty && !await saveNoteDocument()) return
2413
+ const request = ++noteSelectionRequest
2414
+ releaseNoteAsset()
2415
+ state.notes.selectedId = node.id
2416
+ state.notes.selectedNode = node
2417
+ Object.assign(state.notes, { content: '', draft: '', dirty: false })
2418
+ state.notes.loadingNodeId = node.id
2419
+ renderShell()
2420
+ try {
2421
+ const breadcrumbs = await loadNoteBreadcrumbs(node)
2422
+ if (request !== noteSelectionRequest || state.notes.selectedId !== node.id) return
2423
+ state.notes.breadcrumbs = breadcrumbs
2424
+ if (node.kind === 'folder') {
2425
+ Object.assign(state.notes, { content: '', draft: '', dirty: false })
2426
+ state.notes.currentFolderId = node.id
2427
+ if (options.toggleFolder) {
2428
+ if (state.notes.expandedFolders.has(node.id)) state.notes.expandedFolders.delete(node.id)
2429
+ else state.notes.expandedFolders.add(node.id)
2430
+ }
2431
+ await loadNoteChildren(node.id)
2432
+ } else if (node.editable) {
2433
+ const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, { responseType: 'blob', accept: node.mediaType || 'text/plain' })
2434
+ const content = await blob.text()
2435
+ if (request !== noteSelectionRequest || state.notes.selectedId !== node.id) return
2436
+ state.notes.content = content
2437
+ state.notes.draft = content
2438
+ state.notes.dirty = false
2439
+ state.notes.currentFolderId = node.parentId
2440
+ } else {
2441
+ state.notes.content = ''
2442
+ state.notes.draft = ''
2443
+ state.notes.dirty = false
2444
+ state.notes.currentFolderId = node.parentId
2445
+ if (noteFilePreviewKind(node) !== 'unavailable') {
2446
+ const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, { responseType: 'blob', accept: node.mediaType || 'application/octet-stream' })
2447
+ if (request !== noteSelectionRequest || state.notes.selectedId !== node.id) return
2448
+ state.notes.assetUrl = URL.createObjectURL(blob)
2449
+ }
2450
+ }
2451
+ } catch (error) {
2452
+ if (request === noteSelectionRequest) showToast(friendlyError(error), 'error')
2453
+ } finally {
2454
+ if (request === noteSelectionRequest && state.notes.loadingNodeId === node.id) {
2455
+ state.notes.loadingNodeId = ''
2456
+ renderShell()
2457
+ }
2458
+ }
2459
+ }
2460
+
2461
+ async function loadNoteChildren(parentId, force = false) {
2462
+ const key = parentId || 'root'
2463
+ if (!force && state.notes.loadedFolders.has(key)) return state.notes.children.get(key) || []
2464
+ const params = new URLSearchParams({ limit: '500' })
2465
+ if (parentId) params.set('parentId', parentId)
2466
+ const nodes = await api(`notes?${params}`)
2467
+ state.notes.children.set(key, nodes)
2468
+ state.notes.loadedFolders.add(key)
2469
+ return nodes
2470
+ }
2471
+
2472
+ async function loadNoteBreadcrumbs(node) {
2473
+ const parents = []
2474
+ let parentId = node.parentId
2475
+ while (parentId) {
2476
+ const parent = findCachedNote(parentId) || await api(`notes/${encodeURIComponent(parentId)}`)
2477
+ parents.unshift(parent)
2478
+ parentId = parent.parentId
2479
+ }
2480
+ return node.kind === 'folder' ? [...parents, node] : parents
2481
+ }
2482
+
2483
+ function findCachedNote(id) {
2484
+ for (const nodes of state.notes.children.values()) {
2485
+ const found = nodes.find(node => node.id === id)
2486
+ if (found) return found
2487
+ }
2488
+ return state.notes.selectedNode?.id === id ? state.notes.selectedNode : null
2489
+ }
2490
+
2491
+ async function openNoteRoot() {
2492
+ if (state.notes.dirty && !await saveNoteDocument()) return
2493
+ noteSelectionRequest += 1
2494
+ clearNoteSelection()
2495
+ await loadNoteChildren(null)
2496
+ renderShell()
2497
+ }
2498
+
2499
+ function clearNoteSelection() {
2500
+ releaseNoteAsset()
2501
+ Object.assign(state.notes, { selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [], content: '', draft: '', dirty: false, loadingNodeId: '' })
2502
+ }
2503
+
2504
+ function releaseNoteAsset() {
2505
+ if (!state.notes?.assetUrl) return
2506
+ URL.revokeObjectURL(state.notes.assetUrl)
2507
+ state.notes.assetUrl = ''
2508
+ }
2509
+
2510
+ function scheduleNoteSearch(value) {
2511
+ state.notes.query = value
2512
+ window.clearTimeout(noteSearchTimer)
2513
+ noteSearchTimer = window.setTimeout(async () => {
2514
+ try {
2515
+ state.notes.searchResults = value.trim() ? await api(`notes?q=${encodeURIComponent(value.trim())}&limit=200`) : []
2516
+ renderShell()
2517
+ } catch (error) { showToast(friendlyError(error), 'error') }
2518
+ }, 180)
2519
+ }
2520
+
2521
+ function openCreateNoteFolder(parentId = state.notes.currentFolderId) {
2522
+ const field = formField('目录名称', 'text', '', { maxlength: 255, placeholder: '例如:项目资料' })
2523
+ const form = element('form', { class: 'form-grid' }, field.wrapper)
2524
+ openSheet({
2525
+ title: '新建目录', description: '目录可以继续包含子目录、笔记文档和任意文件。', body: form,
2526
+ primaryLabel: '创建目录', onPrimary: async () => {
2527
+ const node = await api('notes/folders', { method: 'POST', body: { name: field.input.value, parentId } })
2528
+ await loadNoteChildren(parentId, true)
2529
+ if (parentId) state.notes.expandedFolders.add(parentId)
2530
+ await selectNoteNode(node)
2531
+ return true
2532
+ },
2533
+ })
2534
+ field.input.focus()
2535
+ }
2536
+
2537
+ function openCreateNoteDocument(parentId = state.notes.currentFolderId) {
2538
+ const field = formField('文档名称', 'text', '', { maxlength: 255, placeholder: '例如:部署记录' })
2539
+ const form = element('form', { class: 'form-grid' }, field.wrapper)
2540
+ openSheet({
2541
+ title: '新建笔记文档', description: '文档使用 Markdown 编辑,默认不会被 AI 检索或回写。', body: form,
2542
+ primaryLabel: '创建并编辑', onPrimary: async () => {
2543
+ const node = await api('notes/documents', { method: 'POST', body: { name: field.input.value, parentId } })
2544
+ await loadNoteChildren(parentId, true)
2545
+ if (parentId) state.notes.expandedFolders.add(parentId)
2546
+ await selectNoteNode(node)
2547
+ return true
2548
+ },
2549
+ })
2550
+ field.input.focus()
2551
+ }
2552
+
2553
+ function openRenameNoteNode(node) {
2554
+ const field = formField('名称', 'text', node.name, { maxlength: 255 })
2555
+ openSheet({
2556
+ title: '重命名', description: '知识文档中的 @ 引用使用稳定编号,不会因改名失效。', body: element('form', { class: 'form-grid' }, field.wrapper),
2557
+ primaryLabel: '保存名称', onPrimary: async () => {
2558
+ const updated = await api(`notes/${encodeURIComponent(node.id)}`, { method: 'PATCH', body: { name: field.input.value } })
2559
+ await loadNoteChildren(node.parentId, true)
2560
+ if (state.notes.selectedId === node.id) await selectNoteNode(updated)
2561
+ else renderShell()
2562
+ showToast('名称已更新。')
2563
+ return true
2564
+ },
2565
+ })
2566
+ field.input.select()
2567
+ }
2568
+
2569
+ async function copyNoteNode(node) {
2570
+ try {
2571
+ const copy = await api(`notes/${encodeURIComponent(node.id)}/copy`, { method: 'POST', body: {} })
2572
+ await loadNoteChildren(node.parentId, true)
2573
+ renderShell()
2574
+ showToast(`已创建“${copy.name}”。`)
2575
+ } catch (error) { showToast(friendlyError(error), 'error') }
2576
+ }
2577
+
2578
+ async function dropNoteNode(event, parentId) {
2579
+ event.preventDefault()
2580
+ event.stopPropagation()
2581
+ clearNoteDragState()
2582
+ const id = event.dataTransfer.getData('application/x-dsh-note-id')
2583
+ if (!id) {
2584
+ if (hasDragType(event, 'Files')) await importDroppedNotes(event.dataTransfer, parentId)
2585
+ return
2586
+ }
2587
+ const node = findCachedNote(id)
2588
+ if (!node || id === parentId || node.parentId === parentId) return
2589
+ try {
2590
+ await api(`notes/${encodeURIComponent(id)}`, { method: 'PATCH', body: { parentId } })
2591
+ await Promise.all([loadNoteChildren(node.parentId, true), loadNoteChildren(parentId, true)])
2592
+ if (state.notes.selectedId === id) state.notes.selectedNode = await api(`notes/${encodeURIComponent(id)}`)
2593
+ renderShell()
2594
+ showToast('已移动到目标目录。')
2595
+ } catch (error) { showToast(friendlyError(error), 'error') }
2596
+ }
2597
+
2598
+ async function uploadNoteFiles(files, parentId) {
2599
+ const summary = createNoteImportSummary()
2600
+ const plans = files.flatMap(file => createNoteFilePlan(file, summary))
2601
+ if (!plans.length) return showToast('没有可上传的文件,单文件上限为 64 MiB。', 'error')
2602
+ const transfer = beginNoteTransfer('uploading')
2603
+ if (!transfer) return
2604
+ prepareNoteTransfer(transfer, summary)
2605
+ try {
2606
+ await importNotePlans(plans, parentId, transfer)
2607
+ await loadNoteChildren(parentId, true)
2608
+ renderShell()
2609
+ completeNoteTransfer(transfer)
2610
+ showNoteImportResult(summary)
2611
+ } catch (error) {
2612
+ if (transfer.completedItems) await loadNoteChildren(parentId, true).catch(() => {})
2613
+ renderShell()
2614
+ failNoteTransfer(transfer, error)
2615
+ showToast(friendlyError(error), 'error')
2616
+ }
2617
+ }
2618
+
2619
+ async function importDroppedNotes(dataTransfer, parentId) {
2620
+ const payload = captureNoteDropPayload(dataTransfer)
2621
+ if (!payload.entries.length) return uploadNoteFiles(payload.files, parentId)
2622
+ const transfer = beginNoteTransfer('scanning')
2623
+ if (!transfer) return
2624
+ const summary = createNoteImportSummary()
2625
+ try {
2626
+ const plans = []
2627
+ for (const entry of payload.entries) {
2628
+ const plan = await collectNoteEntry(entry, summary, transfer)
2629
+ if (plan) plans.push(plan)
2630
+ }
2631
+ if (!plans.length) {
2632
+ dismissNoteTransfer()
2633
+ showToast('没有可上传的内容,单文件上限为 64 MiB。', 'error')
2634
+ return
2635
+ }
2636
+ prepareNoteTransfer(transfer, summary)
2637
+ await importNotePlans(plans, parentId, transfer)
2638
+ await loadNoteChildren(parentId, true)
2639
+ if (parentId) state.notes.expandedFolders.add(parentId)
2640
+ renderShell()
2641
+ completeNoteTransfer(transfer)
2642
+ showNoteImportResult(summary)
2643
+ } catch (error) {
2644
+ await loadNoteChildren(parentId, true).catch(() => {})
2645
+ renderShell()
2646
+ failNoteTransfer(transfer, error)
2647
+ showToast(friendlyError(error), 'error')
2648
+ }
2649
+ }
2650
+
2651
+ function createNoteImportSummary() {
2652
+ return { files: 0, folders: 0, skipped: 0, bytes: 0 }
2653
+ }
2654
+
2655
+ function createNoteFilePlan(file, summary) {
2656
+ if (file.size > NOTE_MAX_FILE_SIZE) {
2657
+ summary.skipped += 1
2658
+ return []
2659
+ }
2660
+ summary.files += 1
2661
+ summary.bytes += file.size
2662
+ return [{ kind: 'file', name: file.name, file }]
2663
+ }
2664
+
2665
+ function captureNoteDropPayload(dataTransfer) {
2666
+ const files = Array.from(dataTransfer?.files || [])
2667
+ const entries = Array.from(dataTransfer?.items || []).flatMap(item => {
2668
+ if (item.kind !== 'file' || typeof item.webkitGetAsEntry !== 'function') return []
2669
+ try {
2670
+ const entry = item.webkitGetAsEntry()
2671
+ return entry ? [entry] : []
2672
+ } catch {
2673
+ return []
2674
+ }
2675
+ })
2676
+ return { entries, files }
2677
+ }
2678
+
2679
+ async function collectNoteEntry(entry, summary, transfer) {
2680
+ transfer.currentName = entry.name || ''
2681
+ transfer.scannedItems += 1
2682
+ scheduleNoteTransferSync()
2683
+ if (entry.isDirectory) {
2684
+ summary.folders += 1
2685
+ const children = []
2686
+ for (const child of await readNoteDirectoryEntries(entry)) {
2687
+ const plan = await collectNoteEntry(child, summary, transfer)
2688
+ if (plan) children.push(plan)
2689
+ }
2690
+ return { kind: 'folder', name: entry.name, children }
2691
+ }
2692
+ if (!entry.isFile) return null
2693
+ const file = await readNoteFileEntry(entry)
2694
+ return createNoteFilePlan(file, summary)[0] || null
2695
+ }
2696
+
2697
+ async function importNotePlans(plans, parentId, transfer) {
2698
+ for (const plan of plans) {
2699
+ transfer.currentName = plan.name
2700
+ scheduleNoteTransferSync()
2701
+ if (plan.kind === 'folder') {
2702
+ const folder = await api('notes/folders', { method: 'POST', body: { name: plan.name, parentId } })
2703
+ transfer.completedItems += 1
2704
+ scheduleNoteTransferSync()
2705
+ await importNotePlans(plan.children, folder.id, transfer)
2706
+ continue
2707
+ }
2708
+ const settledBytes = transfer.loadedBytes
2709
+ await uploadNoteFile(plan.file, parentId, loaded => {
2710
+ transfer.loadedBytes = settledBytes + Math.min(loaded, plan.file.size)
2711
+ scheduleNoteTransferSync()
2712
+ })
2713
+ transfer.loadedBytes = settledBytes + plan.file.size
2714
+ transfer.completedItems += 1
2715
+ scheduleNoteTransferSync()
2716
+ }
2717
+ }
2718
+
2719
+ async function readNoteDirectoryEntries(entry) {
2720
+ const reader = entry.createReader()
2721
+ const entries = []
2722
+ while (true) {
2723
+ const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject))
2724
+ if (!batch.length) return entries
2725
+ entries.push(...batch)
2726
+ }
2727
+ }
2728
+
2729
+ function readNoteFileEntry(entry) {
2730
+ return new Promise((resolve, reject) => entry.file(resolve, reject))
2731
+ }
2732
+
2733
+ async function uploadNoteFile(file, parentId, onProgress) {
2734
+ const params = new URLSearchParams({ name: file.name })
2735
+ if (parentId) params.set('parentId', parentId)
2736
+ return binaryUploadRequest(`notes/files?${params}`, file, {
2737
+ method: 'POST', contentType: file.type || 'application/octet-stream', onProgress,
2738
+ })
2739
+ }
2740
+
2741
+ function beginNoteTransfer(phase) {
2742
+ if (state.notes.transfer && ['scanning', 'uploading'].includes(state.notes.transfer.phase)) {
2743
+ showToast('已有导入任务正在进行,请稍候。', 'error')
2744
+ return null
2745
+ }
2746
+ const transfer = {
2747
+ id: String(++noteTransferSequence), phase, currentName: '', error: '',
2748
+ scannedItems: 0, completedItems: 0, totalItems: 0,
2749
+ loadedBytes: 0, totalBytes: 0, files: 0, folders: 0, skipped: 0,
2750
+ }
2751
+ state.notes.transfer = transfer
2752
+ refreshNoteTransferPanel()
2753
+ return transfer
2754
+ }
2755
+
2756
+ function prepareNoteTransfer(transfer, summary) {
2757
+ transfer.phase = 'uploading'
2758
+ transfer.currentName = ''
2759
+ transfer.totalItems = summary.files + summary.folders
2760
+ transfer.totalBytes = summary.bytes
2761
+ transfer.files = summary.files
2762
+ transfer.folders = summary.folders
2763
+ transfer.skipped = summary.skipped
2764
+ scheduleNoteTransferSync()
2765
+ }
2766
+
2767
+ function completeNoteTransfer(transfer) {
2768
+ if (state.notes.transfer !== transfer) return
2769
+ transfer.phase = 'complete'
2770
+ transfer.currentName = ''
2771
+ transfer.completedItems = transfer.totalItems
2772
+ transfer.loadedBytes = transfer.totalBytes
2773
+ refreshNoteTransferPanel()
2774
+ window.setTimeout(() => {
2775
+ if (state.notes.transfer === transfer && transfer.phase === 'complete') dismissNoteTransfer()
2776
+ }, 2600)
2777
+ }
2778
+
2779
+ function failNoteTransfer(transfer, error) {
2780
+ if (state.notes.transfer !== transfer) return
2781
+ transfer.phase = 'error'
2782
+ transfer.error = friendlyError(error)
2783
+ refreshNoteTransferPanel()
2784
+ }
2785
+
2786
+ function dismissNoteTransfer() {
2787
+ state.notes.transfer = null
2788
+ document.querySelector('.notes-transfer')?.remove()
2789
+ }
2790
+
2791
+ function refreshNoteTransferPanel() {
2792
+ const current = document.querySelector('.notes-transfer')
2793
+ const panel = renderNoteTransfer()
2794
+ if (!panel) {
2795
+ current?.remove()
2796
+ return
2797
+ }
2798
+ if (current) current.replaceWith(panel)
2799
+ else document.querySelector('.notes-workspace')?.append(panel)
2800
+ syncNoteTransferPanel()
2801
+ }
2802
+
2803
+ function scheduleNoteTransferSync() {
2804
+ if (noteTransferFrame) return
2805
+ noteTransferFrame = requestAnimationFrame(() => {
2806
+ noteTransferFrame = 0
2807
+ syncNoteTransferPanel()
2808
+ })
2809
+ }
2810
+
2811
+ function syncNoteTransferPanel() {
2812
+ const transfer = state.notes.transfer
2813
+ const panel = document.querySelector('.notes-transfer')
2814
+ if (!transfer || !panel || panel.dataset.noteTransfer !== transfer.id) return
2815
+ const percent = noteTransferPercent(transfer)
2816
+ panel.dataset.state = transfer.phase
2817
+ panel.style.setProperty('--notes-transfer-progress', String(percent / 100))
2818
+ const title = panel.querySelector('.notes-transfer-title')
2819
+ const detail = panel.querySelector('.notes-transfer-detail')
2820
+ const count = panel.querySelector('.notes-transfer-count')
2821
+ const percentLabel = panel.querySelector('.notes-transfer-percent')
2822
+ const progress = panel.querySelector('.notes-transfer-track')
2823
+ if (title) title.textContent = noteTransferTitle(transfer)
2824
+ if (detail) {
2825
+ detail.textContent = noteTransferDetail(transfer)
2826
+ detail.title = transfer.currentName || ''
2827
+ }
2828
+ if (count) count.textContent = noteTransferCount(transfer)
2829
+ if (percentLabel) percentLabel.textContent = transfer.phase === 'scanning' ? '正在整理' : `${percent}%`
2830
+ if (progress) {
2831
+ if (transfer.phase === 'scanning') progress.removeAttribute('aria-valuenow')
2832
+ else progress.setAttribute('aria-valuenow', String(percent))
2833
+ }
2834
+ }
2835
+
2836
+ function noteTransferPercent(transfer) {
2837
+ if (transfer.phase === 'complete') return 100
2838
+ const value = transfer.totalBytes > 0
2839
+ ? transfer.loadedBytes / transfer.totalBytes
2840
+ : transfer.totalItems > 0 ? transfer.completedItems / transfer.totalItems : 0
2841
+ return Math.min(transfer.phase === 'uploading' ? 99 : 100, Math.max(0, Math.round(value * 100)))
2842
+ }
2843
+
2844
+ function noteTransferTitle(transfer) {
2845
+ if (transfer.phase === 'scanning') return '正在整理导入内容'
2846
+ if (transfer.phase === 'complete') return '导入完成'
2847
+ if (transfer.phase === 'error') return '导入未完成'
2848
+ return '正在导入笔记'
2849
+ }
2850
+
2851
+ function noteTransferDetail(transfer) {
2852
+ if (transfer.phase === 'error') return transfer.error || '导入过程中发生错误'
2853
+ if (transfer.phase === 'complete') return `${transfer.files} 个文件、${transfer.folders} 个目录已写入`
2854
+ if (transfer.phase === 'scanning') return transfer.currentName || '正在读取文件和目录…'
2855
+ return transfer.currentName || '准备写入…'
2856
+ }
2857
+
2858
+ function noteTransferCount(transfer) {
2859
+ if (transfer.phase === 'scanning') return `已读取 ${transfer.scannedItems} 项`
2860
+ const count = `${transfer.completedItems} / ${transfer.totalItems} 项`
2861
+ const bytes = transfer.totalBytes ? ` · ${formatBytes(transfer.loadedBytes)} / ${formatBytes(transfer.totalBytes)}` : ''
2862
+ const skipped = transfer.skipped ? ` · 跳过 ${transfer.skipped}` : ''
2863
+ return `${count}${bytes}${skipped}`
2864
+ }
2865
+
2866
+ function showNoteImportResult(summary) {
2867
+ const added = [summary.folders ? `${summary.folders} 个目录` : '', summary.files ? `${summary.files} 个文件` : ''].filter(Boolean).join('、')
2868
+ const skipped = summary.skipped ? `,跳过 ${summary.skipped} 个超过 64 MiB 的文件` : ''
2869
+ showToast(`已添加${added ? ` ${added}` : ''}${skipped}。`)
2870
+ }
2871
+
2872
+ async function saveNoteDocument() {
2873
+ const node = state.notes.selectedNode
2874
+ if (!node || !node.editable || !state.notes.dirty) return true
2875
+ try {
2876
+ const updated = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, {
2877
+ method: 'PUT', body: new Blob([state.notes.draft], { type: node.mediaType || 'text/plain' }), contentType: node.mediaType || 'text/plain',
2878
+ })
2879
+ state.notes.selectedNode = updated
2880
+ state.notes.content = state.notes.draft
2881
+ state.notes.dirty = false
2882
+ await loadNoteChildren(node.parentId, true)
2883
+ const size = document.querySelector('[data-note-info="size"] > span')
2884
+ const updatedAt = document.querySelector('[data-note-info="updated"] > span')
2885
+ if (size) size.textContent = formatBytes(updated.size)
2886
+ if (updatedAt) updatedAt.textContent = formatDate(updated.updatedAt)
2887
+ syncNoteEditorChrome()
2888
+ showToast('文件已保存。')
2889
+ return true
2890
+ } catch (error) {
2891
+ showToast(friendlyError(error), 'error')
2892
+ return false
2893
+ }
2894
+ }
2895
+
2896
+ async function downloadNoteFile(node) {
2897
+ try {
2898
+ const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content?download=1`, { responseType: 'blob', accept: node.mediaType || 'application/octet-stream' })
2899
+ const url = URL.createObjectURL(blob)
2900
+ const anchor = element('a', { href: url, download: node.name })
2901
+ document.body.append(anchor); anchor.click(); anchor.remove()
2902
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000)
2903
+ } catch (error) { showToast(friendlyError(error), 'error') }
2904
+ }
2905
+
2906
+ async function copyNoteReference(node) {
2907
+ try { await navigator.clipboard.writeText(noteReference(node)); showToast('笔记引用已复制。') }
2908
+ catch { showToast('复制失败,请手动复制文档编号。', 'error') }
2909
+ }
2910
+
2911
+ async function confirmDeleteNoteNode(node) {
2912
+ try {
2913
+ const references = await api(`notes/${encodeURIComponent(node.id)}/references`)
2914
+ if (references.length) {
2915
+ const names = references.slice(0, 4).map(item => `“${item.documentTitle}”`).join('、')
2916
+ return openModal({
2917
+ title: '仍被知识文档引用', description: node.name,
2918
+ body: element('p', {}, `${names}${references.length > 4 ? `等 ${references.length} 篇文档` : ''}仍在引用这里的内容。请先移除引用,再删除。`),
2919
+ cancelLabel: '知道了',
2920
+ })
2921
+ }
2922
+ openConfirm({
2923
+ title: `删除“${node.name}”?`,
2924
+ message: node.kind === 'folder' ? '目录及其全部子目录和文件会被永久删除。' : '该文档会被永久删除,此操作无法撤销。',
2925
+ confirmLabel: '永久删除', danger: true,
2926
+ onConfirm: async () => {
2927
+ await api(`notes/${encodeURIComponent(node.id)}`, { method: 'DELETE' })
2928
+ await loadNoteChildren(node.parentId, true)
2929
+ if (state.notes.selectedId === node.id || state.notes.breadcrumbs.some(parent => parent.id === node.id)) clearNoteSelection()
2930
+ renderShell(); showToast('已删除。')
2931
+ },
2932
+ })
2933
+ } catch (error) { showToast(friendlyError(error), 'error') }
2934
+ }
2935
+
2936
+ function noteWorkspaceDragEnter(event) {
2937
+ if (!hasDragType(event, 'Files')) return
2938
+ event.preventDefault()
2939
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
2940
+ event.currentTarget.dataset.dropActive = 'true'
2941
+ }
2942
+
2943
+ function noteWorkspaceDragLeave(event) {
2944
+ if (event.currentTarget.contains(event.relatedTarget)) return
2945
+ clearNoteDragState()
2946
+ }
2947
+
2948
+ function activateNoteFolderDropTarget(event, node) {
2949
+ if (node.kind !== 'folder') return
2950
+ const hasFiles = hasDragType(event, 'Files')
2951
+ if (!hasFiles && !hasDragType(event, 'application/x-dsh-note-id')) return
2952
+ event.preventDefault()
2953
+ event.stopPropagation()
2954
+ if (event.dataTransfer) event.dataTransfer.dropEffect = hasFiles ? 'copy' : 'move'
2955
+ const workspace = document.querySelector('.notes-workspace')
2956
+ if (workspace) workspace.dataset.dropActive = 'false'
2957
+ document.querySelectorAll('.notes-tree-item[data-drop-target="true"]').forEach(target => {
2958
+ if (target !== event.currentTarget) target.dataset.dropTarget = 'false'
2959
+ })
2960
+ event.currentTarget.dataset.dropTarget = 'true'
2961
+ }
2962
+
2963
+ function hasDragType(event, type) {
2964
+ return Array.from(event.dataTransfer?.types || []).includes(type)
2965
+ }
2966
+
2967
+ function clearNoteDragState() {
2968
+ const workspace = document.querySelector('.notes-workspace')
2969
+ if (workspace) workspace.dataset.dropActive = 'false'
2970
+ document.querySelectorAll('.notes-tree-item[data-drop-target="true"]').forEach(node => { node.dataset.dropTarget = 'false' })
2971
+ document.querySelectorAll('.notes-tree-item[data-dragging="true"]').forEach(node => { node.dataset.dragging = 'false' })
2972
+ }
2973
+
2974
+ function installNoteDragRecovery() {
2975
+ window.addEventListener('drop', clearNoteDragState, true)
2976
+ window.addEventListener('dragend', clearNoteDragState, true)
2977
+ window.addEventListener('blur', clearNoteDragState)
2978
+ document.addEventListener('dragleave', event => {
2979
+ if (event.relatedTarget === null) clearNoteDragState()
2980
+ }, true)
2981
+ }
2982
+
2983
+ function noteReference(node) {
2984
+ const label = node.name.replaceAll('\\', '\\\\').replaceAll('[', '\\[').replaceAll(']', '\\]')
2985
+ return `@[${label}](note://${node.id})`
2986
+ }
2987
+
2988
+ function noteMediaKind(node) {
2989
+ if (node.kind === 'folder') return 'folder'
2990
+ if (node.kind === 'document' || node.mediaType?.startsWith('text/')) return 'text'
2991
+ if (node.mediaType?.startsWith('image/')) return 'image'
2992
+ if (node.mediaType === 'application/pdf') return 'pdf'
2993
+ return 'file'
2994
+ }
2995
+
2996
+ function noteExtension(name) {
2997
+ const extension = name.includes('.') ? name.split('.').pop() : 'FILE'
2998
+ return extension.slice(0, 4).toLocaleUpperCase()
2999
+ }
3000
+
3001
+ function shortNoteId(id) {
3002
+ return `${id.slice(0, 11)}…${id.slice(-5)}`
3003
+ }
3004
+
3005
+ function formatBytes(value) {
3006
+ if (value < 1024) return `${value} B`
3007
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KiB`
3008
+ return `${(value / 1024 / 1024).toFixed(value < 10 * 1024 * 1024 ? 1 : 0)} MiB`
3009
+ }
3010
+
1430
3011
  function renderCandidates() {
1431
3012
  const statuses = [['pending', '待审核'], ['approved', '已通过'], ['rejected', '已拒绝']]
1432
3013
  return element('section', { 'aria-labelledby': 'candidates-heading' },
@@ -1444,12 +3025,9 @@ function renderCandidates() {
1444
3025
 
1445
3026
  function renderCandidateCard(candidate) {
1446
3027
  const pending = candidate.status === 'pending'
1447
- const conflictTarget = candidate.action === 'conflict' && candidate.targetId
1448
- ? state.candidateTargets.get(candidate.targetId)
1449
- : null
1450
- const targetDocument = candidate.action === 'create'
1451
- ? `新文档“${candidate.draft.title}”`
1452
- : `文档“${candidate.draft.title}”`
3028
+ const target = candidate.targetId ? state.candidateTargets.get(candidate.targetId) : null
3029
+ const targetAvailable = candidate.action === 'create' || Boolean(target)
3030
+ const action = candidatePrimaryAction(candidate)
1453
3031
  return element('article', { class: 'candidate' },
1454
3032
  element('div', { class: 'candidate-header' },
1455
3033
  element('div', {},
@@ -1462,31 +3040,122 @@ function renderCandidateCard(candidate) {
1462
3040
  ),
1463
3041
  badge(STATUS_LABELS[candidate.status], candidate.status === 'approved' ? 'success' : candidate.status === 'rejected' ? 'danger' : 'warning'),
1464
3042
  ),
1465
- element('div', { class: `candidate-body${candidate.action === 'conflict' ? ' is-conflict' : ''}` },
1466
- candidate.action === 'conflict'
1467
- ? element('div', { class: 'candidate-comparison' },
1468
- element('section', {}, element('strong', {}, '当前知识'), element('pre', {}, conflictTarget?.body || '目标文档不可用,请刷新后重试。')),
1469
- element('section', {}, element('strong', {}, '候选内容'), element('pre', {}, candidate.draft.body)),
1470
- )
1471
- : element('p', { class: 'candidate-content' }, candidate.draft.body),
3043
+ element('div', { class: 'candidate-body' },
3044
+ renderCandidateDiff(candidate, target),
1472
3045
  element('div', { class: 'candidate-reason' },
1473
- element('strong', {}, '文档变更'),
1474
- element('span', { class: 'candidate-target' }, `${candidate.action === 'create' ? '创建' : '更新'}${targetDocument}`),
1475
- element('strong', {}, '模型判断依据'), candidate.reason || '未提供判断说明'),
3046
+ element('section', {},
3047
+ element('strong', {}, '写入位置'),
3048
+ element('span', { class: 'candidate-target' }, candidate.action === 'create'
3049
+ ? `创建“${candidate.draft.title}”`
3050
+ : target ? `合并到“${target.title}”` : `目标 ${candidate.targetId || '不可用'}`),
3051
+ ),
3052
+ renderCandidateMetadataChanges(candidate, target),
3053
+ element('section', {},
3054
+ element('strong', {}, '模型判断依据'),
3055
+ element('p', {}, candidate.reason || '未提供判断说明'),
3056
+ ),
3057
+ candidate.reviewNote ? element('section', {},
3058
+ element('strong', {}, '审核备注'),
3059
+ element('p', {}, candidate.reviewNote),
3060
+ ) : null,
3061
+ ),
1476
3062
  ),
1477
3063
  element('div', { class: 'candidate-footer' },
1478
3064
  element('small', {}, `${scopeLabel(candidate.draft.scope)} · 置信度 ${Math.round(candidate.draft.confidence * 100)}%${candidate.targetId ? ` · 目标 ${candidate.targetId}` : ''} · ${formatDate(candidate.createdAt)}`),
1479
- pending ? element('div', {},
3065
+ pending ? element('div', { class: 'candidate-actions' },
1480
3066
  actionButton('拒绝', () => reviewCandidate(candidate, 'reject'), 'danger small'),
1481
- actionButton('编辑并通过', () => openEntryEditor(undefined, candidate), 'small'),
1482
- candidate.action === 'conflict'
1483
- ? actionButton('合并追加', () => reviewCandidate(candidate, 'approve', 'merge'), 'primary small')
1484
- : actionButton('通过', () => reviewCandidate(candidate, 'approve'), 'primary small'),
3067
+ actionButton(action.editLabel, () => openEntryEditor(undefined, candidate), 'small', {
3068
+ disabled: !targetAvailable,
3069
+ title: targetAvailable ? action.editLabel : '目标文档不可用,无法安全编辑合并结果',
3070
+ }),
3071
+ actionButton(action.label, () => reviewCandidate(candidate, 'approve', action.resolution), 'primary small', {
3072
+ disabled: !targetAvailable,
3073
+ title: targetAvailable ? action.label : '目标文档不可用,刷新后再审核',
3074
+ }),
1485
3075
  ) : null,
1486
3076
  ),
1487
3077
  )
1488
3078
  }
1489
3079
 
3080
+ function renderCandidateDiff(candidate, target) {
3081
+ if (candidate.action !== 'create' && !target) {
3082
+ return element('section', { class: 'candidate-change candidate-change-unavailable', role: 'alert' },
3083
+ element('strong', {}, '无法生成变更预览'),
3084
+ element('p', {}, '目标文档暂时不可用。为避免盲目覆盖,刷新并确认当前版本后再审核。'),
3085
+ )
3086
+ }
3087
+ const review = window.DshKnowledgeReview.createReviewChange(candidate.action, target?.body || '', candidate.draft.body)
3088
+ const title = candidate.action === 'create' ? '新文档内容' : candidate.action === 'conflict' ? '冲突合并预览' : '文档合并预览'
3089
+ const targetLabel = candidate.action === 'create'
3090
+ ? candidate.draft.title
3091
+ : `${target.title} · 当前版本 ${target.version}`
3092
+ return element('section', { class: `candidate-change is-${candidate.action}`, 'aria-label': title },
3093
+ element('div', { class: 'candidate-change-heading' },
3094
+ element('div', {}, element('strong', {}, title), element('small', {}, targetLabel)),
3095
+ element('div', { class: 'diff-summary', 'aria-label': `新增 ${review.diff.additions} 行,删除 ${review.diff.deletions} 行,未变更 ${review.diff.unchanged} 行` },
3096
+ element('span', { class: 'diff-stat additions' }, `+${review.diff.additions}`),
3097
+ element('span', { class: 'diff-stat deletions' }, `-${review.diff.deletions}`),
3098
+ element('span', { class: 'diff-stat unchanged' }, `${review.diff.unchanged} 未变`),
3099
+ ),
3100
+ ),
3101
+ review.diff.simplified ? element('div', { class: 'diff-notice' }, '文档变更较大,已使用简化差异视图。合并内容不受影响。') : null,
3102
+ element('div', { class: 'diff-viewer', role: 'table', 'aria-label': `${title}逐行差异` },
3103
+ element('div', { class: 'diff-column-headings', role: 'row' },
3104
+ element('span', { role: 'columnheader' }, '旧'),
3105
+ element('span', { role: 'columnheader' }, '新'),
3106
+ element('span', { 'aria-hidden': 'true' }),
3107
+ element('span', { role: 'columnheader' }, '正文'),
3108
+ ),
3109
+ review.displayLines.length
3110
+ ? review.displayLines.map(renderDiffLine)
3111
+ : element('div', { class: 'diff-empty' }, '正文为空,没有可写入的差异。'),
3112
+ ),
3113
+ )
3114
+ }
3115
+
3116
+ function renderDiffLine(line) {
3117
+ if (line.kind === 'omitted') {
3118
+ return element('div', { class: 'diff-line is-omitted', role: 'row', 'aria-label': `${line.count} 行未变更,已折叠` },
3119
+ element('span', { class: 'diff-line-number', 'aria-hidden': 'true' }),
3120
+ element('span', { class: 'diff-line-number', 'aria-hidden': 'true' }),
3121
+ element('span', { class: 'diff-marker', 'aria-hidden': 'true' }, '…'),
3122
+ element('span', { class: 'diff-omitted-copy' }, `${line.count} 行未变更`),
3123
+ )
3124
+ }
3125
+ const marker = line.kind === 'add' ? '+' : line.kind === 'remove' ? '-' : ' '
3126
+ const label = line.kind === 'add'
3127
+ ? `新增第 ${line.newLine} 行`
3128
+ : line.kind === 'remove' ? `删除原第 ${line.oldLine} 行` : `未变更行 ${line.oldLine}`
3129
+ return element('div', { class: `diff-line is-${line.kind}`, role: 'row', 'aria-label': label },
3130
+ element('span', { class: 'diff-line-number', role: 'cell' }, line.oldLine || ''),
3131
+ element('span', { class: 'diff-line-number', role: 'cell' }, line.newLine || ''),
3132
+ element('span', { class: 'diff-marker', role: 'cell', 'aria-hidden': 'true' }, marker),
3133
+ element('code', { class: 'diff-code', role: 'cell' }, line.text || ' '),
3134
+ )
3135
+ }
3136
+
3137
+ function renderCandidateMetadataChanges(candidate, target) {
3138
+ if (!target || candidate.action === 'create') return null
3139
+ const changes = []
3140
+ if (target.title !== candidate.draft.title) changes.push(`标题:${target.title} → ${candidate.draft.title}`)
3141
+ const addedTags = candidate.draft.tags.filter(tag => !target.tags.includes(tag))
3142
+ if (addedTags.length) changes.push(`新增标签:${addedTags.join('、')}`)
3143
+ if (candidate.draft.confidence > target.confidence) {
3144
+ changes.push(`置信度:${Math.round(target.confidence * 100)}% → ${Math.round(candidate.draft.confidence * 100)}%`)
3145
+ }
3146
+ if (!changes.length) return null
3147
+ return element('section', { class: 'candidate-metadata' },
3148
+ element('strong', {}, '属性变化'),
3149
+ element('ul', {}, changes.map(change => element('li', {}, change))),
3150
+ )
3151
+ }
3152
+
3153
+ function candidatePrimaryAction(candidate) {
3154
+ if (candidate.action === 'create') return { label: '写入新文档', editLabel: '编辑后写入' }
3155
+ if (candidate.action === 'conflict') return { label: '确认合并', editLabel: '编辑合并内容', resolution: 'merge' }
3156
+ return { label: '合并到文档', editLabel: '编辑后合并' }
3157
+ }
3158
+
1490
3159
  function renderTokens() {
1491
3160
  const apiAddress = new URL(state.service.publicApiPrefix, location.origin).href.replace(/\/$/, '')
1492
3161
  return element('section', { 'aria-labelledby': 'tokens-heading' },
@@ -1569,7 +3238,8 @@ function parseTags(value) {
1569
3238
  return [...new Set(value.split(/[,,]/).map(tag => tag.trim().toLowerCase()).filter(Boolean))]
1570
3239
  }
1571
3240
 
1572
- function openKnowledgeBaseEditor(base) {
3241
+ async function openKnowledgeBaseEditor(base) {
3242
+ await loadModelCatalog()
1573
3243
  const source = base || { name: '', description: '', defaultTags: [], extractionInstructions: '', writebackPolicy: 'conservative' }
1574
3244
  const form = element('form', { class: 'form-grid' })
1575
3245
  const name = formField('名称', 'input', source.name, { required: true, maxlength: 100, placeholder: '例如:项目规范' })
@@ -1580,9 +3250,26 @@ function openKnowledgeBaseEditor(base) {
1580
3250
  { value: 'conservative', label: '严谨(高置信度直写)' },
1581
3251
  { value: 'proactive', label: '主动(更积极沉淀)' },
1582
3252
  ], source.writebackPolicy || 'conservative')
3253
+ const dedicatedModel = element('input', {
3254
+ type: 'checkbox',
3255
+ checked: Boolean(source.writebackProvider && source.writebackModel),
3256
+ })
3257
+ const route = modelRouteFields(source.writebackProvider || '', source.writebackModel || '')
3258
+ const routeToggle = element('label', { class: 'check-option span-2' }, dedicatedModel,
3259
+ element('span', {},
3260
+ element('strong', {}, '这个知识库使用专用回写模型'),
3261
+ element('small', {}, '未设置时先使用本机覆盖;本机也未设置时跟随当前会话模型。'),
3262
+ ))
3263
+ const syncRouteAvailability = () => {
3264
+ route.provider.input.disabled = route.model.input.disabled = !dedicatedModel.checked
3265
+ route.provider.input.required = route.model.input.required = dedicatedModel.checked
3266
+ }
3267
+ dedicatedModel.addEventListener('change', syncRouteAvailability)
3268
+ syncRouteAvailability()
1583
3269
  for (const field of [name, description, tags, instructions]) field.wrapper.classList.add('span-2')
1584
3270
  form.append(
1585
3271
  name.wrapper, description.wrapper, tags.wrapper, instructions.wrapper, policy.wrapper,
3272
+ routeToggle, route.provider.wrapper, route.model.wrapper,
1586
3273
  )
1587
3274
  openSheet({
1588
3275
  title: base ? '编辑知识库' : '创建知识库',
@@ -1597,6 +3284,10 @@ function openKnowledgeBaseEditor(base) {
1597
3284
  defaultTags: parseTags(tags.input.value),
1598
3285
  extractionInstructions: instructions.input.value.trim(),
1599
3286
  writebackPolicy: policy.input.value,
3287
+ ...(dedicatedModel.checked ? {
3288
+ writebackProvider: route.provider.input.value,
3289
+ writebackModel: route.model.input.value,
3290
+ } : {}),
1600
3291
  }
1601
3292
  if (base) await api(`knowledge-bases/${encodeURIComponent(base.id)}`, { method: 'PUT', body: { draft } })
1602
3293
  else await api('knowledge-bases', { method: 'POST', body: { draft } })
@@ -1830,7 +3521,11 @@ function confirmDeleteKnowledgeBase(base) {
1830
3521
 
1831
3522
  function openEntryEditor(entry, candidate) {
1832
3523
  const activeWorkspace = activeDocumentWorkspace()
1833
- const source = candidate?.draft || entry || {
3524
+ const candidateTarget = candidate?.targetId ? state.candidateTargets.get(candidate.targetId) : null
3525
+ const candidateReview = candidate && (candidate.action === 'create' || candidateTarget)
3526
+ ? window.DshKnowledgeReview.createReviewChange(candidate.action, candidateTarget?.body || '', candidate.draft.body)
3527
+ : null
3528
+ const source = candidate ? { ...candidate.draft, body: candidateReview?.after || candidate.draft.body } : entry || {
1834
3529
  knowledgeBaseId: activeWorkspace?.view.knowledgeBaseId || undefined,
1835
3530
  title: '', body: '', type: 'fact', tags: [], scope: { kind: 'global' }, confidence: .8,
1836
3531
  }
@@ -1863,8 +3558,16 @@ function openEntryEditor(entry, candidate) {
1863
3558
  )
1864
3559
  body.wrapper.classList.add('span-2')
1865
3560
  tags.wrapper.classList.add('span-2')
1866
- const modeTitle = candidate ? '编辑并通过候选' : entry ? '编辑知识文档' : '新建知识文档'
1867
- const modal = openSheet({ title: modeTitle, description: candidate ? '保存后,这条候选会立即通过并写入知识库。' : '文档保存后会立即参与后续召回。', body: form, primaryLabel: candidate ? '通过并保存' : '保存文档', onPrimary: async () => {
3561
+ const modeTitle = candidate
3562
+ ? candidate.action === 'create' ? '编辑新文档' : '编辑合并内容'
3563
+ : entry ? '编辑知识文档' : '新建知识文档'
3564
+ const candidateDescription = candidate?.action === 'create'
3565
+ ? '确认后将创建文档并立即参与后续召回。'
3566
+ : '正文已载入完整合并预览;保存前可以处理重复内容或冲突。'
3567
+ const primaryLabel = candidate
3568
+ ? candidate.action === 'create' ? '写入新文档' : '保存并合并'
3569
+ : '保存文档'
3570
+ const modal = openSheet({ title: modeTitle, description: candidate ? candidateDescription : '文档保存后会立即参与后续召回。', body: form, primaryLabel, onPrimary: async () => {
1868
3571
  if (!form.reportValidity()) return false
1869
3572
  const draft = {
1870
3573
  knowledgeBaseId: knowledgeBase.input.value,
@@ -1879,7 +3582,7 @@ function openEntryEditor(entry, candidate) {
1879
3582
  })
1880
3583
  else if (entry) await api(`entries/${encodeURIComponent(entry.id)}`, { method: 'PUT', body: { draft } })
1881
3584
  else await api('entries', { method: 'POST', body: { draft } })
1882
- showToast(candidate ? '候选已通过并写入知识库。' : '知识已保存。')
3585
+ showToast(candidate ? candidate.action === 'create' ? '新文档已写入。' : '候选内容已合并。' : '知识已保存。')
1883
3586
  state.stats = null
1884
3587
  await navigate(candidate ? 'candidates' : state.view)
1885
3588
  return true
@@ -1902,7 +3605,9 @@ function selectField(label, options, value) {
1902
3605
  async function loadModelCatalog() {
1903
3606
  if (state.modelCatalog !== null) return state.modelCatalog
1904
3607
  try {
1905
- const response = await fetch('/knowledge-control/v1/models', { headers: { accept: 'application/json' } })
3608
+ const response = await fetch('/knowledge-control/v1/models', {
3609
+ headers: { accept: 'application/json', 'x-dsh-knowledge-client': 'management-web' },
3610
+ })
1906
3611
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
1907
3612
  const payload = await response.json()
1908
3613
  state.modelCatalog = Array.isArray(payload.providers) ? payload.providers : []
@@ -1936,10 +3641,19 @@ function modelRouteFields(currentProvider, currentModel) {
1936
3641
 
1937
3642
  async function reviewCandidate(candidate, decision, resolution) {
1938
3643
  const approve = decision === 'approve'
3644
+ const action = candidatePrimaryAction(candidate)
3645
+ const title = approve ? `${action.label}?` : '拒绝这条候选?'
3646
+ const message = !approve
3647
+ ? '拒绝后会保留审核记录,但不会进入知识库。'
3648
+ : candidate.action === 'create'
3649
+ ? '确认后将创建新文档,并立即参与后续对话召回。'
3650
+ : candidate.action === 'conflict'
3651
+ ? '确认后将保留当前文档内容,并把候选内容合入同一文档。'
3652
+ : '确认后将按预览结果合入目标文档;若审核期间文档发生变化,会转为冲突项。'
1939
3653
  openConfirm({
1940
- title: approve ? '通过这条候选?' : '拒绝这条候选?',
1941
- message: approve ? '通过后内容会立即写入知识库,并参与后续对话召回。' : '拒绝后会保留审核记录,但不会进入知识库。',
1942
- confirmLabel: approve ? '确认通过' : '确认拒绝', danger: !approve,
3654
+ title,
3655
+ message,
3656
+ confirmLabel: approve ? action.label : '确认拒绝', danger: !approve,
1943
3657
  onConfirm: async () => {
1944
3658
  const reviewed = await api(`candidates/${encodeURIComponent(candidate.id)}/review`, {
1945
3659
  method: 'POST', body: { decision, ...(resolution ? { resolution } : {}) },
@@ -1950,7 +3664,7 @@ async function reviewCandidate(candidate, decision, resolution) {
1950
3664
  await navigate('candidates')
1951
3665
  return
1952
3666
  }
1953
- showToast(approve ? '候选已通过。' : '候选已拒绝。')
3667
+ showToast(approve ? candidate.action === 'create' ? '新文档已写入。' : '候选内容已合并。' : '候选已拒绝。')
1954
3668
  state.stats = null
1955
3669
  await navigate('candidates')
1956
3670
  },
@@ -1994,7 +3708,7 @@ function openTokenCreator() {
1994
3708
  return { permission, input, node: element('label', { class: 'check-option' }, input, permission) }
1995
3709
  })
1996
3710
  const body = element('form', { class: 'form-grid' }, name.wrapper,
1997
- element('div', { class: 'field span-2' }, element('label', {}, '权限'), element('div', { class: 'check-grid' }, checkboxes.map(item => item.node)), element('span', { class: 'field-hint' }, '普通客户端建议只授予 read + propose。')))
3711
+ element('div', { class: 'field span-2' }, element('label', {}, '权限'), element('div', { class: 'check-grid' }, checkboxes.map(item => item.node)), element('span', { class: 'field-hint' }, '普通客户端建议只授予 read + propose。write 是当前中央服务的全局写权限,同时允许直接写入、管理知识库、挂载和笔记;仅在确实需要时授予。')))
1998
3712
  name.wrapper.classList.add('span-2')
1999
3713
  openModal({ title: '创建客户端令牌', description: '原始令牌只会在创建成功后显示一次。', body, primaryLabel: '创建令牌', onPrimary: async () => {
2000
3714
  if (!body.reportValidity()) return false
@@ -2043,20 +3757,28 @@ function openSheet(options) {
2043
3757
  return openModal({ ...options, presentation: 'sheet' })
2044
3758
  }
2045
3759
 
2046
- function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消', presentation = 'modal' }) {
3760
+ function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消', presentation = 'modal', onClose }) {
2047
3761
  const previouslyFocused = document.activeElement
2048
3762
  const isSheet = presentation === 'sheet'
2049
3763
  const backdrop = element('div', { class: `dialog-backdrop${isSheet ? ' sheet-backdrop' : ''}` })
2050
3764
  const dialog = element('section', { class: `dialog${isSheet ? ' sheet' : ''} ${primaryLabel ? '' : 'narrow'}`.trim(), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dialog-title' })
2051
3765
  let busy = false
2052
- const close = () => {
2053
- if (busy) return
3766
+ let formDirty = false
3767
+ let closed = false
3768
+ const close = (explicit = false) => {
3769
+ if (busy || closed) return
3770
+ if (!explicit && formDirty) {
3771
+ showToast('表单有未保存的修改,请先保存,或使用“取消”放弃修改。', 'error')
3772
+ return
3773
+ }
2054
3774
  document.removeEventListener('keydown', onKeyDown)
3775
+ closed = true
2055
3776
  backdrop.remove()
3777
+ onClose?.()
2056
3778
  if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus()
2057
3779
  }
2058
- const closeButton = actionButton('×', close, 'ghost', { 'aria-label': '关闭对话框' })
2059
- const cancel = actionButton(cancelLabel, close)
3780
+ const closeButton = actionButton('×', () => close(), 'ghost', { 'aria-label': '关闭对话框' })
3781
+ const cancel = actionButton(cancelLabel, () => close(true))
2060
3782
  const primary = primaryLabel ? actionButton(primaryLabel, async () => {
2061
3783
  if (busy) return
2062
3784
  busy = true
@@ -2067,7 +3789,7 @@ function openModal({ title, description = '', body, primaryLabel, primaryVariant
2067
3789
  try {
2068
3790
  const shouldClose = await onPrimary()
2069
3791
  busy = false
2070
- if (shouldClose !== false) close()
3792
+ if (shouldClose !== false) close(true)
2071
3793
  else { primary.disabled = false; cancel.disabled = false; primary.textContent = original }
2072
3794
  } catch (error) {
2073
3795
  busy = false
@@ -2088,6 +3810,10 @@ function openModal({ title, description = '', body, primaryLabel, primaryVariant
2088
3810
  )
2089
3811
  backdrop.append(dialog)
2090
3812
  backdrop.addEventListener('mousedown', (event) => { if (event.target === backdrop) close() })
3813
+ if (body.matches?.('form') || body.querySelector?.('form')) {
3814
+ body.addEventListener('input', () => { formDirty = true })
3815
+ body.addEventListener('change', () => { formDirty = true })
3816
+ }
2091
3817
  document.body.append(backdrop)
2092
3818
  document.addEventListener('keydown', onKeyDown)
2093
3819
  window.setTimeout(() => (dialog.querySelector('input, textarea, select, button') || dialog).focus(), 0)
@@ -2118,4 +3844,12 @@ function friendlyError(error) {
2118
3844
  return error.message || '操作失败,请稍后重试。'
2119
3845
  }
2120
3846
 
3847
+ window.addEventListener('beforeunload', event => {
3848
+ const editor = activeDocumentWorkspace()?.view.editor
3849
+ if (!editor?.dirty && !state.notes.dirty) return
3850
+ event.preventDefault()
3851
+ event.returnValue = ''
3852
+ })
3853
+
3854
+ installNoteDragRecovery()
2121
3855
  void installHostThemeBridge().then(() => boot())