@lemoncat7/dsh-knowledge 1.2.0 → 2.1.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 (104) hide show
  1. package/README.md +22 -2
  2. package/docs/architecture.md +30 -4
  3. package/docs/requirements.md +10 -0
  4. package/lib/api.d.ts.map +1 -1
  5. package/lib/api.js +194 -1
  6. package/lib/api.js.map +1 -1
  7. package/lib/atomic-file.d.ts +1 -1
  8. package/lib/atomic-file.d.ts.map +1 -1
  9. package/lib/atomic-file.js +0 -1
  10. package/lib/atomic-file.js.map +1 -1
  11. package/lib/client.d.ts.map +1 -1
  12. package/lib/client.js +48 -82
  13. package/lib/client.js.map +2 -2
  14. package/lib/content-safety.d.ts +12 -0
  15. package/lib/content-safety.d.ts.map +1 -0
  16. package/lib/content-safety.js +38 -0
  17. package/lib/content-safety.js.map +1 -0
  18. package/lib/domain.d.ts +13 -0
  19. package/lib/domain.d.ts.map +1 -1
  20. package/lib/domain.js.map +1 -1
  21. package/lib/extraction.d.ts.map +1 -1
  22. package/lib/extraction.js +8 -2
  23. package/lib/extraction.js.map +1 -1
  24. package/lib/index.d.ts +1 -0
  25. package/lib/index.d.ts.map +1 -1
  26. package/lib/index.js +6 -1
  27. package/lib/index.js.map +1 -1
  28. package/lib/local-provider.d.ts +24 -1
  29. package/lib/local-provider.d.ts.map +1 -1
  30. package/lib/local-provider.js +302 -2
  31. package/lib/local-provider.js.map +1 -1
  32. package/lib/management-proxy.d.ts.map +1 -1
  33. package/lib/management-proxy.js +22 -13
  34. package/lib/management-proxy.js.map +1 -1
  35. package/lib/note-reference-handle.d.ts +15 -0
  36. package/lib/note-reference-handle.d.ts.map +1 -0
  37. package/lib/note-reference-handle.js +54 -0
  38. package/lib/note-reference-handle.js.map +1 -0
  39. package/lib/note-reference-tools.d.ts +6 -0
  40. package/lib/note-reference-tools.d.ts.map +1 -0
  41. package/lib/note-reference-tools.js +119 -0
  42. package/lib/note-reference-tools.js.map +1 -0
  43. package/lib/note-tools.d.ts +6 -0
  44. package/lib/note-tools.d.ts.map +1 -0
  45. package/lib/note-tools.js +283 -0
  46. package/lib/note-tools.js.map +1 -0
  47. package/lib/notes/domain.d.ts +44 -0
  48. package/lib/notes/domain.d.ts.map +1 -0
  49. package/lib/notes/domain.js +38 -0
  50. package/lib/notes/domain.js.map +1 -0
  51. package/lib/notes/store.d.ts +39 -0
  52. package/lib/notes/store.d.ts.map +1 -0
  53. package/lib/notes/store.js +407 -0
  54. package/lib/notes/store.js.map +1 -0
  55. package/lib/provider.d.ts +17 -0
  56. package/lib/provider.d.ts.map +1 -1
  57. package/lib/remote-provider.d.ts +19 -0
  58. package/lib/remote-provider.d.ts.map +1 -1
  59. package/lib/remote-provider.js +139 -13
  60. package/lib/remote-provider.js.map +1 -1
  61. package/lib/runtime.d.ts +1 -0
  62. package/lib/runtime.d.ts.map +1 -1
  63. package/lib/runtime.js.map +1 -1
  64. package/lib/theme-bridge.d.ts +2 -2
  65. package/lib/theme-bridge.d.ts.map +1 -1
  66. package/lib/theme-bridge.js +24 -57
  67. package/lib/theme-bridge.js.map +1 -1
  68. package/lib/tool-authorization.d.ts +14 -0
  69. package/lib/tool-authorization.d.ts.map +1 -0
  70. package/lib/tool-authorization.js +124 -0
  71. package/lib/tool-authorization.js.map +1 -0
  72. package/lib/tool-input.d.ts +6 -0
  73. package/lib/tool-input.d.ts.map +1 -0
  74. package/lib/tool-input.js +27 -0
  75. package/lib/tool-input.js.map +1 -0
  76. package/lib/tools.d.ts +2 -1
  77. package/lib/tools.d.ts.map +1 -1
  78. package/lib/tools.js +29 -179
  79. package/lib/tools.js.map +1 -1
  80. package/lib/tracking.d.ts +29 -0
  81. package/lib/tracking.d.ts.map +1 -0
  82. package/lib/tracking.js +141 -0
  83. package/lib/tracking.js.map +1 -0
  84. package/lib/web-markdown-preview.d.ts.map +1 -1
  85. package/lib/web-markdown-preview.js +10 -0
  86. package/lib/web-markdown-preview.js.map +1 -1
  87. package/lib/web-note-editor.d.ts +44 -0
  88. package/lib/web-note-editor.d.ts.map +1 -0
  89. package/lib/web-note-editor.js +118 -0
  90. package/lib/web-note-editor.js.map +1 -0
  91. package/lib/web-workspace-effects.d.ts +13 -0
  92. package/lib/web-workspace-effects.d.ts.map +1 -0
  93. package/lib/web-workspace-effects.js +340 -0
  94. package/lib/web-workspace-effects.js.map +1 -0
  95. package/lib/web.d.ts.map +1 -1
  96. package/lib/web.js +3 -1
  97. package/lib/web.js.map +1 -1
  98. package/package.json +12 -1
  99. package/web/app.js +1670 -93
  100. package/web/index.html +2 -0
  101. package/web/markdown-preview.js +8 -8
  102. package/web/note-editor.js +220 -0
  103. package/web/styles.css +845 -182
  104. package/web/workspace-effects.js +16 -0
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,9 +80,17 @@ 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
 
@@ -86,6 +99,16 @@ let navigationController = null
86
99
  let navigationRequest = 0
87
100
  let navigationSave = Promise.resolve(true)
88
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
89
112
 
90
113
  function installHostThemeBridge() {
91
114
  if (window.parent === window) return Promise.resolve()
@@ -219,6 +242,10 @@ function element(tag, attributes = {}, ...children) {
219
242
  return node
220
243
  }
221
244
 
245
+ function refreshWorkspaceEffects(root = document) {
246
+ window.DshKnowledgeEffects?.refresh(root)
247
+ }
248
+
222
249
  function actionButton(label, onClick, variant = '', attributes = {}) {
223
250
  return element('button', { type: 'button', class: `button ${variant}`.trim(), onClick, ...attributes }, label)
224
251
  }
@@ -277,11 +304,83 @@ async function api(path, options = {}) {
277
304
  return payload
278
305
  }
279
306
 
307
+ async function binaryRequest(path, options = {}) {
308
+ const headers = { accept: options.accept || 'application/json' }
309
+ if (AUTH_MODE === 'same-origin') headers['x-dsh-knowledge-client'] = 'management-web'
310
+ if (state.token) headers.authorization = `Bearer ${state.token}`
311
+ if (options.contentType) headers['content-type'] = options.contentType
312
+ const response = await fetch(`${API_BASE}/${path.replace(/^\/+/, '')}`, {
313
+ method: options.method || 'GET',
314
+ headers,
315
+ body: options.body,
316
+ signal: options.signal,
317
+ })
318
+ if (options.responseType === 'blob') {
319
+ if (!response.ok) throw await responseError(response)
320
+ return response.blob()
321
+ }
322
+ const text = await response.text()
323
+ let payload
324
+ if (text) {
325
+ try { payload = JSON.parse(text) } catch { throw new Error('服务返回了无法识别的数据') }
326
+ }
327
+ if (!response.ok) {
328
+ const error = new Error(payload?.error || `请求失败(HTTP ${response.status})`)
329
+ error.status = response.status
330
+ throw error
331
+ }
332
+ return payload
333
+ }
334
+
335
+ function binaryUploadRequest(path, body, options = {}) {
336
+ return new Promise((resolve, reject) => {
337
+ const request = new XMLHttpRequest()
338
+ request.open(options.method || 'POST', `${API_BASE}/${path.replace(/^\/+/, '')}`)
339
+ request.responseType = 'text'
340
+ request.setRequestHeader('accept', 'application/json')
341
+ if (AUTH_MODE === 'same-origin') request.setRequestHeader('x-dsh-knowledge-client', 'management-web')
342
+ if (state.token) request.setRequestHeader('authorization', `Bearer ${state.token}`)
343
+ if (options.contentType) request.setRequestHeader('content-type', options.contentType)
344
+ request.upload.addEventListener('progress', event => {
345
+ if (event.lengthComputable) options.onProgress?.(event.loaded, event.total)
346
+ })
347
+ request.addEventListener('load', () => {
348
+ let payload
349
+ if (request.responseText) {
350
+ try { payload = JSON.parse(request.responseText) }
351
+ catch { return reject(new Error('服务返回了无法识别的数据')) }
352
+ }
353
+ if (request.status < 200 || request.status >= 300) {
354
+ const error = new Error(payload?.error || `请求失败(HTTP ${request.status})`)
355
+ error.status = request.status
356
+ reject(error)
357
+ return
358
+ }
359
+ resolve(payload)
360
+ })
361
+ request.addEventListener('error', () => reject(new Error('上传连接中断,请检查网络后重试')))
362
+ request.addEventListener('abort', () => reject(new DOMException('上传已取消', 'AbortError')))
363
+ request.send(body)
364
+ })
365
+ }
366
+
367
+ async function responseError(response) {
368
+ let message = `请求失败(HTTP ${response.status})`
369
+ try {
370
+ const payload = await response.json()
371
+ if (payload?.error) message = payload.error
372
+ } catch {}
373
+ return Object.assign(new Error(message), { status: response.status })
374
+ }
375
+
280
376
  async function boot() {
281
377
  if (AUTH_MODE === 'same-origin') {
282
378
  state.token = ''
283
- try { state.service = await api('service') } catch {}
284
- await navigate('entries')
379
+ await Promise.all([
380
+ api('service').then(service => { state.service = service }).catch(() => {}),
381
+ navigate('entries'),
382
+ ])
383
+ renderShell()
285
384
  return
286
385
  }
287
386
  if (!state.token) {
@@ -339,18 +438,22 @@ function renderLogin(message = '') {
339
438
  element('p', {}, '管理对话中沉淀的长期知识与 AI 提取候选。'),
340
439
  form,
341
440
  )))
441
+ refreshWorkspaceEffects(app)
342
442
  }
343
443
 
344
444
  function signOut() {
445
+ releaseNoteEditors()
446
+ releaseNoteAsset()
345
447
  sessionStorage.removeItem(TOKEN_KEY)
346
448
  Object.assign(state, { token: '', stats: null, overview: null, knowledgeBases: [], mounts: [], resolvedMounts: [], entries: [], documents: [], candidates: [], candidateTargets: new Map(), settings: { writebackPolicy: 'conservative', updatedAt: '' }, tokens: [] })
449
+ 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: '' }
347
450
  if (AUTH_MODE === 'same-origin') void boot()
348
451
  else renderLogin()
349
452
  }
350
453
 
351
454
  async function navigate(view) {
352
455
  const request = ++navigationRequest
353
- navigationSave = navigationSave.then(() => saveBeforeLeavingDocument(), () => saveBeforeLeavingDocument())
456
+ navigationSave = navigationSave.then(() => saveBeforeNavigation(), () => saveBeforeNavigation())
354
457
  if (!await navigationSave || request !== navigationRequest) return
355
458
  navigationController?.abort()
356
459
  const controller = new AbortController()
@@ -362,13 +465,16 @@ async function navigate(view) {
362
465
  state.view = view
363
466
  state.menuOpen = false
364
467
  state.loading = true
468
+ state.loadingPhase = loadingPhaseForView(view)
469
+ state.loadingProgress = .12
365
470
  state.error = ''
366
471
  renderShell()
367
472
  try {
368
473
  if (view === 'overview') await loadOverview(controller.signal)
369
474
  if (view === 'bases') await loadKnowledgeBasesPage(controller.signal)
370
- if (view === 'entries') await loadDocuments(controller.signal)
475
+ if (view === 'entries') await loadDocuments(controller.signal, (label, progress) => updateLoadingPhase(request, label, progress))
371
476
  if (view === 'candidates') await loadCandidates(controller.signal)
477
+ if (view === 'notes') await loadNotes(controller.signal, (label, progress) => updateLoadingPhase(request, label, progress))
372
478
  if (view === 'tokens') await loadTokens(controller.signal)
373
479
  } catch (error) {
374
480
  if (controller.signal.aborted) return
@@ -377,10 +483,31 @@ async function navigate(view) {
377
483
  } finally {
378
484
  if (navigationController !== controller) return
379
485
  state.loading = false
486
+ state.loadingPhase = ''
487
+ state.loadingProgress = 1
380
488
  renderShell()
381
489
  }
382
490
  }
383
491
 
492
+ function loadingPhaseForView(view) {
493
+ return ({
494
+ overview: '正在汇总知识活动', bases: '正在读取知识库配置', entries: '正在读取知识目录',
495
+ notes: '正在读取笔记目录', candidates: '正在准备审核队列', tokens: '正在读取访问权限',
496
+ })[view] || '正在准备工作区'
497
+ }
498
+
499
+ function updateLoadingPhase(request, label, progress) {
500
+ if (request !== navigationRequest || !state.loading) return
501
+ state.loadingPhase = label
502
+ state.loadingProgress = Math.max(0, Math.min(1, progress))
503
+ renderShell()
504
+ }
505
+
506
+ async function saveBeforeNavigation() {
507
+ if (state.view === 'notes' && state.notes.dirty) return saveNoteDocument()
508
+ return saveBeforeLeavingDocument()
509
+ }
510
+
384
511
  async function saveBeforeLeavingDocument() {
385
512
  const workspace = activeDocumentWorkspace()
386
513
  const editor = workspace?.view.editor
@@ -457,19 +584,26 @@ async function loadEntries(cursor = '') {
457
584
  if (!state.stats) await refreshStats()
458
585
  }
459
586
 
460
- async function loadDocuments(signal) {
587
+ async function loadDocuments(signal, onPhase = () => {}) {
588
+ onPhase('正在读取可用知识库', .24)
461
589
  const options = { signal }
462
- const requests = [api('knowledge-bases', options), api('documents', options)]
590
+ const requests = [api('knowledge-bases', options)]
463
591
  if (state.mountContext.sessionId) {
464
592
  const params = new URLSearchParams({ sessionId: state.mountContext.sessionId })
465
593
  if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
466
594
  requests.push(api(`mounts/resolve?${params}`, options))
467
595
  }
468
- const [bases, documents, resolved = []] = await Promise.all(requests)
596
+ const [results, stats] = await Promise.all([
597
+ Promise.all(requests),
598
+ state.stats ? Promise.resolve(state.stats) : api('stats', options),
599
+ ])
600
+ const [bases, resolved = []] = results
469
601
  state.knowledgeBases = bases
470
602
  state.resolvedMounts = resolved
603
+ state.stats = stats
604
+ onPhase('正在建立文档索引', .56)
471
605
  const visibleBaseIds = new Set(documentKnowledgeBases(bases).map(base => base.id))
472
- state.documents = documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId))
606
+ state.documents = state.documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId))
473
607
  const workspace = sessionDocumentWorkspace()
474
608
  const view = workspace.view
475
609
  const availableBaseIds = visibleBaseIds
@@ -478,10 +612,30 @@ async function loadDocuments(signal) {
478
612
  ? state.entryFilters.knowledgeBaseId
479
613
  : documentKnowledgeBases(bases)[0]?.id || ''
480
614
  }
615
+ if (view.knowledgeBaseId) {
616
+ view.expandedBases.add(view.knowledgeBaseId)
617
+ await loadDocumentPage(workspace, view.knowledgeBaseId, { signal })
618
+ }
481
619
  selectDefaultDocument(workspace)
482
- if (view.documentId) await loadDocumentEditor(workspace, view.documentId, signal)
620
+ if (view.documentId) {
621
+ onPhase('正在打开最近文档', .82)
622
+ await loadDocumentEditor(workspace, view.documentId, signal)
623
+ }
483
624
  else view.editor = null
484
- if (!state.stats) await refreshStats(signal)
625
+ }
626
+
627
+ async function loadNotes(signal, onPhase = () => {}) {
628
+ onPhase('正在读取顶层目录', .36)
629
+ state.notes.children.set('root', await api('notes?limit=500', { signal }))
630
+ state.notes.loadedFolders.add('root')
631
+ if (state.notes.selectedId) {
632
+ onPhase('正在恢复上次打开的笔记', .74)
633
+ const selected = state.notes.selectedNode?.id === state.notes.selectedId
634
+ ? state.notes.selectedNode
635
+ : await api(`notes/${encodeURIComponent(state.notes.selectedId)}`, { signal }).catch(() => null)
636
+ if (!selected) clearNoteSelection()
637
+ else state.notes.selectedNode = selected
638
+ }
485
639
  }
486
640
 
487
641
  function documentKnowledgeBases(bases = state.knowledgeBases) {
@@ -514,6 +668,139 @@ function setDocumentWorkspaceDocuments(workspace, documents) {
514
668
  else state.documents = documents
515
669
  }
516
670
 
671
+ function documentPageState(workspace, knowledgeBaseId) {
672
+ const pages = workspace.view.documentPages
673
+ if (!pages.has(knowledgeBaseId)) {
674
+ pages.set(knowledgeBaseId, { loaded: false, loading: false, nextCursor: '', total: 0, error: '' })
675
+ }
676
+ return pages.get(knowledgeBaseId)
677
+ }
678
+
679
+ function mergeDocumentSummaries(workspace, items, knowledgeBaseId, reset) {
680
+ const current = documentWorkspaceDocuments(workspace)
681
+ const retained = reset ? current.filter(document => document.knowledgeBaseId !== knowledgeBaseId) : current
682
+ const merged = new Map(retained.map(document => [document.id, document]))
683
+ for (const item of items) merged.set(item.id, item)
684
+ setDocumentWorkspaceDocuments(workspace, [...merged.values()])
685
+ }
686
+
687
+ async function loadDocumentPage(workspace, knowledgeBaseId, options = {}) {
688
+ if (!knowledgeBaseId) return
689
+ const page = documentPageState(workspace, knowledgeBaseId)
690
+ if (page.loading || (!options.reset && !options.append && page.loaded)) return
691
+ if (options.append && !page.nextCursor) return
692
+ page.loading = true
693
+ page.error = ''
694
+ const cursor = options.append ? page.nextCursor : ''
695
+ try {
696
+ const params = new URLSearchParams({ knowledgeBaseId, limit: '60' })
697
+ if (cursor) params.set('cursor', cursor)
698
+ const result = await api(`document-index?${params}`, { signal: options.signal })
699
+ mergeDocumentSummaries(workspace, result.items, knowledgeBaseId, !options.append)
700
+ page.loaded = true
701
+ page.nextCursor = result.nextCursor || ''
702
+ page.total = result.total
703
+ } catch (error) {
704
+ if (error.name === 'AbortError') throw error
705
+ page.error = friendlyError(error)
706
+ if (options.throwOnError) throw error
707
+ } finally {
708
+ page.loading = false
709
+ }
710
+ }
711
+
712
+ async function toggleDocumentBase(workspace, baseId) {
713
+ const view = workspace.view
714
+ view.knowledgeBaseId = baseId
715
+ if (view.expandedBases.has(baseId)) {
716
+ view.expandedBases.delete(baseId)
717
+ renderShell()
718
+ return
719
+ }
720
+ view.expandedBases.add(baseId)
721
+ const page = documentPageState(workspace, baseId)
722
+ if (!page.loaded) page.loading = true
723
+ renderShell()
724
+ if (!page.loaded) {
725
+ page.loading = false
726
+ await loadDocumentPage(workspace, baseId)
727
+ renderShell()
728
+ }
729
+ }
730
+
731
+ function resetDocumentSearch(view) {
732
+ view.searchRequest += 1
733
+ view.searchResults = []
734
+ view.searchNextCursor = ''
735
+ view.searchTotal = 0
736
+ view.searchLoading = false
737
+ view.searchError = ''
738
+ }
739
+
740
+ function scheduleDocumentSearch(workspace) {
741
+ window.clearTimeout(documentSearchTimer)
742
+ const view = workspace.view
743
+ if (!view.query.trim()) {
744
+ resetDocumentSearch(view)
745
+ renderShell()
746
+ return
747
+ }
748
+ view.searchLoading = true
749
+ view.searchError = ''
750
+ renderShell()
751
+ documentSearchTimer = window.setTimeout(() => { void loadDocumentSearch(workspace, true) }, 220)
752
+ }
753
+
754
+ function renderDocumentSearchState(workspace) {
755
+ renderShell()
756
+ window.requestAnimationFrame(() => {
757
+ const input = document.querySelector(`.note-tree-search[data-document-scope="${workspace.kind}"]`)
758
+ input?.focus()
759
+ input?.setSelectionRange(input.value.length, input.value.length)
760
+ })
761
+ }
762
+
763
+ async function loadDocumentSearch(workspace, reset = false) {
764
+ const view = workspace.view
765
+ const query = view.query.trim()
766
+ if (!query) return resetDocumentSearch(view)
767
+ const request = ++view.searchRequest
768
+ view.searchLoading = true
769
+ view.searchError = ''
770
+ const params = new URLSearchParams({ q: query, limit: '80' })
771
+ const bases = documentWorkspaceBases(workspace)
772
+ if (bases.length === 0) {
773
+ resetDocumentSearch(view)
774
+ return renderShell()
775
+ }
776
+ if (workspace.kind === 'library') {
777
+ params.append('knowledgeBaseId', state.libraryDetail.knowledgeBaseId)
778
+ } else if (state.mountContext.sessionId) {
779
+ params.set('sessionId', state.mountContext.sessionId)
780
+ if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
781
+ } else {
782
+ params.set('active', '1')
783
+ }
784
+ if (!reset && view.searchNextCursor) params.set('cursor', view.searchNextCursor)
785
+ try {
786
+ const result = await api(`document-index?${params}`)
787
+ if (request !== view.searchRequest || query !== view.query.trim()) return
788
+ const merged = new Map((reset ? [] : view.searchResults).map(document => [document.id, document]))
789
+ for (const item of result.items) merged.set(item.id, item)
790
+ view.searchResults = [...merged.values()]
791
+ view.searchNextCursor = result.nextCursor || ''
792
+ view.searchTotal = result.total
793
+ } catch (error) {
794
+ if (request !== view.searchRequest) return
795
+ view.searchError = friendlyError(error)
796
+ } finally {
797
+ if (request === view.searchRequest) {
798
+ view.searchLoading = false
799
+ renderDocumentSearchState(workspace)
800
+ }
801
+ }
802
+ }
803
+
517
804
  function documentWorkspaceBases(workspace) {
518
805
  if (workspace.kind === 'library') {
519
806
  const base = state.knowledgeBases.find(item => item.id === state.libraryDetail.knowledgeBaseId)
@@ -527,21 +814,23 @@ function documentWorkspaceReadOnly(workspace) {
527
814
  }
528
815
 
529
816
  async function reloadDocumentWorkspace(workspace) {
530
- const libraryBaseId = workspace.kind === 'library' ? state.libraryDetail.knowledgeBaseId : ''
531
- const documents = workspace.kind === 'library'
532
- ? await api(`documents?knowledgeBaseId=${encodeURIComponent(libraryBaseId)}`)
533
- : await api('documents')
534
- if (workspace.kind === 'library') {
535
- if (state.libraryDetail.knowledgeBaseId !== libraryBaseId) return false
536
- setDocumentWorkspaceDocuments(workspace, documents)
537
- }
538
- else {
539
- const visibleBaseIds = new Set(documentKnowledgeBases().map(base => base.id))
540
- setDocumentWorkspaceDocuments(workspace, documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId)))
817
+ const baseId = workspace.kind === 'library' ? state.libraryDetail.knowledgeBaseId : workspace.view.knowledgeBaseId
818
+ if (!baseId) return true
819
+ await loadDocumentPage(workspace, baseId, { reset: true, throwOnError: true })
820
+ if (workspace.kind === 'library' && state.libraryDetail.knowledgeBaseId !== baseId) return false
821
+ const selectedId = workspace.view.documentId
822
+ if (selectedId && !documentWorkspaceDocuments(workspace).some(document => document.id === selectedId)) {
823
+ const document = await api(`documents/${encodeURIComponent(selectedId)}`)
824
+ mergeDocumentSummaries(workspace, [documentSummary(document)], baseId, false)
541
825
  }
542
826
  return true
543
827
  }
544
828
 
829
+ function documentSummary(document) {
830
+ const { content: _content, ...summary } = document
831
+ return summary
832
+ }
833
+
545
834
  function selectDefaultDocument(workspace) {
546
835
  const view = workspace.view
547
836
  const documents = documentWorkspaceDocuments(workspace).filter(document => document.knowledgeBaseId === view.knowledgeBaseId)
@@ -554,11 +843,15 @@ function selectDefaultDocument(workspace) {
554
843
  async function loadDocumentEditor(workspace, id, signal) {
555
844
  const revision = (workspace.view.loadRevision || 0) + 1
556
845
  workspace.view.loadRevision = revision
557
- const entry = await api(`entries/${encodeURIComponent(id)}`, { signal })
846
+ const [entry, noteReferences] = await Promise.all([
847
+ api(`entries/${encodeURIComponent(id)}`, { signal }),
848
+ api(`entries/${encodeURIComponent(id)}/note-references`, { signal }),
849
+ ])
558
850
  if (workspace.view.loadRevision !== revision || workspace.view.documentId !== id) return false
559
851
  workspace.view.mode = 'preview'
560
852
  workspace.view.editor = {
561
853
  ...entry,
854
+ noteReferences,
562
855
  tagsText: entry.tags.join(', '),
563
856
  dirty: false,
564
857
  isNew: false,
@@ -578,7 +871,7 @@ function createBlankDocument(workspace, baseId) {
578
871
  view.expandedBases.add(base.id)
579
872
  view.editor = {
580
873
  id: '', knowledgeBaseId: base.id, title: '', body: '', type: 'fact', tags: [], tagsText: '',
581
- scope: { kind: 'global' }, confidence: .8, dirty: true, isNew: true, saveState: '新文档',
874
+ scope: { kind: 'global' }, confidence: .8, noteReferences: [], dirty: true, isNew: true, saveState: '新文档',
582
875
  }
583
876
  renderShell()
584
877
  document.querySelector('.note-title-input')?.focus()
@@ -597,7 +890,21 @@ async function selectDocument(workspace, id) {
597
890
  if (editor?.dirty && !emptyDraft && !await saveDocumentEditor(workspace)) return
598
891
  workspace.view.documentId = id
599
892
  workspace.view.treeOpen = false
600
- if (await loadDocumentEditor(workspace, id)) renderShell()
893
+ workspace.view.editor = null
894
+ workspace.view.editorLoading = true
895
+ workspace.view.loadingDocumentId = id
896
+ renderShell()
897
+ try {
898
+ await loadDocumentEditor(workspace, id)
899
+ } catch (error) {
900
+ if (workspace.view.documentId === id) showToast(friendlyError(error), 'error')
901
+ } finally {
902
+ if (workspace.view.loadingDocumentId === id) {
903
+ workspace.view.editorLoading = false
904
+ workspace.view.loadingDocumentId = ''
905
+ renderShell()
906
+ }
907
+ }
601
908
  }
602
909
 
603
910
  function editorDraft(editor) {
@@ -680,15 +987,18 @@ async function loadTokens(signal) {
680
987
  }
681
988
 
682
989
  function renderShell() {
990
+ releaseNoteEditors()
683
991
  captureScrollPosition()
684
992
  const titles = {
685
993
  overview: ['概览', '知识库运行状态与最近活动'],
686
994
  bases: ['知识库与挂载', '管理知识目录,并限定项目与会话的召回和写入范围'],
687
995
  entries: ['知识文档', '在知识目录中阅读、整理和维护 Markdown 文档'],
996
+ notes: ['笔记文档', '像本地目录一样整理笔记和资料,并按需关联到知识文档'],
688
997
  candidates: ['待审核', '确认 AI 提取结果后再写入知识文档'],
689
998
  tokens: ['访问管理', '管理其他客户端连接中央知识库的权限'],
690
999
  }
691
1000
  const [title, subtitle] = titles[state.view]
1001
+ const viewIndexes = { overview: '00', notes: '01', entries: '02', candidates: '03', bases: '04', tokens: '05' }
692
1002
  const shell = element('div', {
693
1003
  class: 'app-shell', 'data-menu-open': String(state.menuOpen),
694
1004
  'data-view': state.view, 'data-loading': String(state.loading),
@@ -713,16 +1023,25 @@ function renderShell() {
713
1023
  workspace.view.treeOpen = !workspace.view.treeOpen
714
1024
  renderShell()
715
1025
  }, '知识目录') : null,
716
- element('div', {}, element('h1', {}, title), element('p', {}, subtitle)),
1026
+ element('div', { class: 'topbar-heading' },
1027
+ element('span', { class: 'topbar-kicker' }, `KNOWLEDGE / ${viewIndexes[state.view] || '00'}`),
1028
+ element('h1', {}, title),
1029
+ element('p', {}, subtitle)),
717
1030
  ),
1031
+ state.loading ? element('div', {
1032
+ class: 'route-progress', role: 'progressbar', 'aria-label': state.loadingPhase || '正在加载',
1033
+ 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(Math.round(state.loadingProgress * 100)),
1034
+ }, element('span', { style: `--route-progress: ${state.loadingProgress}` })) : null,
718
1035
  ),
719
- element('div', { class: 'page' }, renderCurrentView()),
1036
+ element('div', { class: 'page' }, element('div', { class: 'view-stage', 'data-view-stage': state.view }, renderCurrentView())),
720
1037
  ),
721
1038
  )
722
1039
  if (state.menuOpen) shell.addEventListener('click', (event) => {
723
1040
  if (event.target === shell) { state.menuOpen = false; renderShell() }
724
1041
  })
725
1042
  app.replaceChildren(shell)
1043
+ applySidebarVisibility(shell, state.documentView.sidebarHidden)
1044
+ refreshWorkspaceEffects(shell)
726
1045
  restoreScrollPosition(currentScrollState())
727
1046
  }
728
1047
 
@@ -788,17 +1107,46 @@ function setSidebarWidth(width, shell, handle) {
788
1107
  function setSidebarHidden(hidden) {
789
1108
  state.documentView.sidebarHidden = hidden
790
1109
  saveDocumentLayout()
791
- renderShell()
1110
+ const shell = app.querySelector('.app-shell')
1111
+ if (shell) applySidebarVisibility(shell, hidden)
1112
+ }
1113
+
1114
+ function applySidebarVisibility(shell, hidden) {
1115
+ shell.dataset.sidebarHidden = String(hidden)
1116
+ const sidebar = shell.querySelector(':scope > .sidebar')
1117
+ const resizer = shell.querySelector(':scope > .app-sidebar-resizer')
1118
+ const toggle = shell.querySelector('.pane-toggle-button[data-pane="main"]')
1119
+ sidebar?.toggleAttribute('inert', hidden)
1120
+ resizer?.toggleAttribute('inert', hidden)
1121
+ if (sidebar) {
1122
+ if (hidden) sidebar.setAttribute('aria-hidden', 'true')
1123
+ else sidebar.removeAttribute('aria-hidden')
1124
+ }
1125
+ if (resizer) {
1126
+ resizer.tabIndex = hidden ? -1 : 0
1127
+ if (hidden) resizer.setAttribute('aria-hidden', 'true')
1128
+ else resizer.removeAttribute('aria-hidden')
1129
+ }
1130
+ if (toggle) {
1131
+ const visible = !hidden
1132
+ const action = `${visible ? '隐藏' : '显示'}主导航栏`
1133
+ toggle.setAttribute('aria-pressed', String(visible))
1134
+ toggle.setAttribute('aria-label', action)
1135
+ toggle.title = action
1136
+ }
792
1137
  }
793
1138
 
794
1139
  function renderSidebar() {
795
1140
  const pending = state.stats?.candidates.pending
796
1141
  const navGroups = [
797
- ['知识工作区', [['entries', '文档'], ['candidates', '待审核'], ['bases', '知识库与挂载']]],
1142
+ ['笔记工作区', [['notes', '笔记文档']]],
1143
+ ['知识工作区', [['entries', '知识文档'], ['candidates', '待审核'], ['bases', '知识库与挂载']]],
798
1144
  ['连接', [['tokens', '访问管理']].filter(([id]) => id !== 'tokens' || !state.service.remote)],
799
1145
  ].filter(([, items]) => items.length)
1146
+ let navIndex = 0
800
1147
  return element('aside', { class: 'sidebar', 'aria-label': '知识库导航' },
801
1148
  element('div', { class: 'brand' },
1149
+ element('span', { class: 'brand-emblem', 'aria-hidden': 'true' }, element('span', {})),
802
1150
  element('div', { class: 'brand-copy' }, element('span', {}, 'DSH Knowledge'), element('strong', {}, '知识库')),
803
1151
  ),
804
1152
  element('nav', { class: 'nav' }, navGroups.map(([group, items]) => element('div', { class: 'nav-group' },
@@ -806,7 +1154,7 @@ function renderSidebar() {
806
1154
  items.map(([id, label]) => element('button', {
807
1155
  type: 'button', class: 'nav-button', 'aria-current': state.view === id ? 'page' : undefined,
808
1156
  onClick: () => navigate(id),
809
- }, element('span', { class: 'nav-label' }, label),
1157
+ }, element('span', { class: 'nav-index', 'aria-hidden': 'true' }, String(++navIndex).padStart(2, '0')), element('span', { class: 'nav-label' }, label),
810
1158
  id === 'candidates' && pending ? element('span', { class: 'nav-count', 'aria-label': `${pending} 条待审核` }, pending) : null))))),
811
1159
  element('div', { class: 'sidebar-footer' },
812
1160
  element('div', { class: 'connection' }, element('span', { class: 'status-dot', 'aria-hidden': 'true' }), state.service.remote ? '中央知识库已连接' : '本地知识库已连接'),
@@ -816,26 +1164,38 @@ function renderSidebar() {
816
1164
  }
817
1165
 
818
1166
  function renderCurrentView() {
819
- if (state.loading) return loadingView()
1167
+ if (state.loading) return loadingView(state.view, state.loadingPhase, state.loadingProgress)
820
1168
  if (state.error) return errorView(state.error, () => navigate(state.view))
821
1169
  if (state.view === 'overview') return renderOverview()
822
1170
  if (state.view === 'bases') return renderKnowledgeBases()
823
1171
  if (state.view === 'entries') return renderEntries()
1172
+ if (state.view === 'notes') return renderNotes()
824
1173
  if (state.view === 'candidates') return renderCandidates()
825
1174
  return renderTokens()
826
1175
  }
827
1176
 
828
- function loadingView() {
829
- return element('div', { class: 'loading-skeleton', role: 'status', 'aria-label': '正在加载' },
830
- element('span', { class: 'visually-hidden' }, '正在加载'),
831
- element('div', { class: 'skeleton-line skeleton-title', 'aria-hidden': 'true' }),
832
- element('div', { class: 'skeleton-line skeleton-copy', 'aria-hidden': 'true' }),
833
- element('div', { class: 'skeleton-grid', 'aria-hidden': 'true' },
834
- element('div', { class: 'skeleton-block' }),
835
- element('div', { class: 'skeleton-block' }),
836
- element('div', { class: 'skeleton-block' }),
837
- ),
1177
+ function loadingView(view = state.view, phase = '正在加载', progress = 0) {
1178
+ const label = phase || loadingPhaseForView(view)
1179
+ const header = element('div', { class: 'skeleton-heading' },
1180
+ element('div', {}, element('div', { class: 'skeleton-line skeleton-title', 'aria-hidden': 'true' }), element('div', { class: 'skeleton-line skeleton-copy', 'aria-hidden': 'true' })),
1181
+ element('span', { class: 'skeleton-phase' }, label),
838
1182
  )
1183
+ const content = view === 'entries'
1184
+ ? element('div', { class: 'skeleton-workspace skeleton-workspace-documents', 'aria-hidden': 'true' },
1185
+ element('div', { class: 'skeleton-pane skeleton-pane-tree' }, ...Array.from({ length: 7 }, (_, index) => element('div', { class: `skeleton-line skeleton-tree-row is-${index % 3}` }))),
1186
+ 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}` }))),
1187
+ )
1188
+ : view === 'notes'
1189
+ ? element('div', { class: 'skeleton-workspace skeleton-workspace-notes', 'aria-hidden': 'true' },
1190
+ element('div', { class: 'skeleton-pane skeleton-pane-tree' }, ...Array.from({ length: 8 }, (_, index) => element('div', { class: `skeleton-line skeleton-tree-row is-${index % 3}` }))),
1191
+ 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' })))),
1192
+ )
1193
+ : element('div', { class: 'skeleton-grid', 'aria-hidden': 'true' },
1194
+ element('div', { class: 'skeleton-block' }), element('div', { class: 'skeleton-block' }), element('div', { class: 'skeleton-block' }))
1195
+ return element('div', {
1196
+ class: `loading-skeleton loading-skeleton--${view}`, role: 'status', 'aria-label': label,
1197
+ style: `--loading-progress: ${progress}`,
1198
+ }, element('span', { class: 'visually-hidden' }, label), header, content)
839
1199
  }
840
1200
 
841
1201
  function errorView(message, retry) {
@@ -1061,7 +1421,10 @@ function renderKnowledgeBaseCard(base) {
1061
1421
  const archived = base.status === 'archived'
1062
1422
  const visibleTags = base.defaultTags.slice(0, 4)
1063
1423
  const hiddenTagCount = Math.max(0, base.defaultTags.length - visibleTags.length)
1064
- return element('article', { class: `base-card${archived ? ' is-archived' : ''}` },
1424
+ return element('article', {
1425
+ class: `base-card${archived ? ' is-archived' : ''}`,
1426
+ 'data-knowledge-motion-key': `knowledge-library:${base.id}`,
1427
+ },
1065
1428
  element('div', { class: 'base-card-header' },
1066
1429
  element('div', { class: 'base-card-identity' },
1067
1430
  element('span', { class: 'base-symbol', 'aria-hidden': 'true' }, base.name.trim().slice(0, 1).toLocaleUpperCase() || 'K'),
@@ -1193,7 +1556,11 @@ function renderMountListRow(base, view, targetKind, targetId) {
1193
1556
  },
1194
1557
  })
1195
1558
  const modelLabel = writebackRouteLabel(base)
1196
- return element('article', { class: `mount-list-row${selected ? ' is-selected' : ''}`, role: 'listitem' },
1559
+ return element('article', {
1560
+ class: `mount-list-row${selected ? ' is-selected' : ''}`,
1561
+ role: 'listitem',
1562
+ 'data-knowledge-motion-key': `knowledge-mount:${targetKind}:${base.id}`,
1563
+ },
1197
1564
  element('label', { class: 'mount-select' }, checkbox, element('span', { class: 'visually-hidden' }, `选择 ${base.name}`)),
1198
1565
  element('div', { class: 'mount-list-main' },
1199
1566
  element('div', { class: 'mount-list-title' }, element('strong', {}, base.name), badge(view.statusLabel, view.statusVariant), badge(modelLabel)),
@@ -1249,53 +1616,82 @@ function renderDocumentWorkspace(workspace, options = {}) {
1249
1616
  'data-document-scope': workspace.kind,
1250
1617
  onInput: (event) => {
1251
1618
  view.query = event.target.value
1252
- renderShell()
1253
- const input = document.querySelector(`.note-tree-search[data-document-scope="${workspace.kind}"]`)
1254
- input?.focus()
1255
- input?.setSelectionRange(input.value.length, input.value.length)
1619
+ scheduleDocumentSearch(workspace)
1620
+ window.requestAnimationFrame(() => {
1621
+ const input = document.querySelector(`.note-tree-search[data-document-scope="${workspace.kind}"]`)
1622
+ input?.focus()
1623
+ input?.setSelectionRange(input.value.length, input.value.length)
1624
+ })
1256
1625
  },
1257
1626
  })
1258
- const tree = activeBases.map(base => {
1627
+ const visibleBases = query
1628
+ ? activeBases.filter(base => view.searchResults.some(document => document.knowledgeBaseId === base.id))
1629
+ : activeBases
1630
+ const tree = visibleBases.map(base => {
1259
1631
  const expanded = view.expandedBases.has(base.id) || Boolean(query)
1260
- const documents = workspaceDocuments.filter(document => document.knowledgeBaseId === base.id
1261
- && (!query || [document.title, document.relPath, document.content].some(value => value.toLocaleLowerCase().includes(query))))
1262
- return element('section', { class: 'note-tree-group', 'data-expanded': String(expanded) },
1632
+ const page = documentPageState(workspace, base.id)
1633
+ const documents = (query ? view.searchResults : workspaceDocuments).filter(document => document.knowledgeBaseId === base.id)
1634
+ return element('section', {
1635
+ class: 'note-tree-group', 'data-expanded': String(expanded),
1636
+ },
1263
1637
  element('button', {
1264
1638
  type: 'button', class: 'note-tree-base', 'aria-expanded': String(expanded),
1639
+ 'data-knowledge-motion-key': `knowledge-base:${workspace.kind}:${base.id}`,
1265
1640
  onClick: () => {
1266
- view.knowledgeBaseId = base.id
1267
- if (expanded && !query) view.expandedBases.delete(base.id)
1268
- else view.expandedBases.add(base.id)
1269
- renderShell()
1641
+ if (query) { view.knowledgeBaseId = base.id; return renderShell() }
1642
+ void toggleDocumentBase(workspace, base.id)
1270
1643
  },
1271
1644
  },
1272
1645
  element('span', { class: 'tree-disclosure', 'aria-hidden': 'true' }),
1273
1646
  element('span', { class: 'tree-folder-icon', 'aria-hidden': 'true' }),
1274
1647
  element('span', { class: 'tree-base-name' }, base.name),
1275
- element('span', { class: 'tree-count' }, documents.length)),
1648
+ element('span', { class: 'tree-count' }, query ? documents.length : page.loaded ? page.total : '—')),
1276
1649
  expanded ? element('div', { class: 'note-tree-documents', role: 'group', 'aria-label': `${base.name}文档` },
1650
+ !query && page.loading && documents.length === 0 ? element('div', { class: 'note-tree-status', role: 'status' }, '正在读取目录…') : null,
1651
+ !query && page.error ? element('div', { class: 'note-tree-status is-error' },
1652
+ element('span', {}, page.error),
1653
+ actionButton('重试', () => { void loadDocumentPage(workspace, base.id, { reset: true }).then(renderShell) }, 'ghost small')) : null,
1277
1654
  documents.map(document => element('button', {
1278
1655
  type: 'button', class: 'note-tree-document', 'aria-current': document.id === view.documentId ? 'page' : undefined,
1279
- onClick: () => { void selectDocument(workspace, document.id) },
1656
+ 'data-document-id': document.id,
1657
+ 'data-knowledge-motion-key': `knowledge-document:${workspace.kind}:${document.id}`,
1658
+ onClick: () => { view.knowledgeBaseId = base.id; void selectDocument(workspace, document.id) },
1280
1659
  }, element('span', { class: 'tree-document-icon', 'aria-hidden': 'true' }), element('span', { class: 'tree-document-copy' },
1281
1660
  element('strong', {}, document.title), element('small', {}, document.relPath)),
1282
1661
  document.documentState !== 'open' ? badge(DOCUMENT_STATE_LABELS[document.documentState] || '已结束', 'success') : null)),
1662
+ !query && page.nextCursor ? element('button', {
1663
+ type: 'button', class: 'note-tree-more', disabled: page.loading,
1664
+ onClick: () => { void loadDocumentPage(workspace, base.id, { append: true }).then(renderShell) },
1665
+ }, page.loading ? '正在加载…' : `继续加载(已显示 ${documents.length} / ${page.total})`) : null,
1666
+ !query && page.loaded && documents.length === 0 ? element('div', { class: 'note-tree-status' }, '这个知识库还没有文档。') : null,
1283
1667
  !query && !readOnly ? element('button', { type: 'button', class: 'note-tree-new', onClick: () => { void startBlankDocument(workspace, base.id) } },
1284
1668
  element('span', { 'aria-hidden': 'true' }, '+'), '新建文档') : null,
1285
1669
  ) : null,
1286
1670
  )
1287
1671
  })
1672
+ const treeContent = query
1673
+ ? [
1674
+ view.searchLoading && view.searchResults.length === 0 ? element('div', { class: 'note-tree-status', role: 'status' }, '正在搜索文档…') : null,
1675
+ view.searchError ? element('div', { class: 'note-tree-status is-error' }, view.searchError) : null,
1676
+ ...tree,
1677
+ !view.searchLoading && !view.searchError && view.searchResults.length === 0 ? element('div', { class: 'note-tree-empty' }, '没有匹配的文档') : null,
1678
+ view.searchNextCursor ? element('button', {
1679
+ type: 'button', class: 'note-tree-more search-more', disabled: view.searchLoading,
1680
+ onClick: () => { void loadDocumentSearch(workspace, false) },
1681
+ }, view.searchLoading ? '正在加载…' : `继续加载搜索结果(${view.searchResults.length} / ${view.searchTotal})`) : null,
1682
+ ]
1683
+ : tree.length ? tree : [element('div', { class: 'note-tree-empty' }, workspace.kind === 'session' && state.mountContext.sessionId ? '当前会话未挂载知识库' : '还没有知识库')]
1288
1684
  return element('section', {
1289
1685
  class: `note-workspace note-workspace--${workspace.kind}`, 'aria-labelledby': `${workspace.kind}-documents-heading`,
1290
1686
  'data-tree-open': String(view.treeOpen),
1291
1687
  },
1292
1688
  element('aside', { class: 'note-tree-panel', 'aria-label': '知识目录' },
1293
1689
  element('header', { class: 'note-tree-header' },
1294
- element('div', {}, element('h2', { id: `${workspace.kind}-documents-heading` }, options.heading || '知识目录'), element('span', {}, `${workspaceDocuments.length} 篇文档`)),
1690
+ element('div', {}, element('h2', { id: `${workspace.kind}-documents-heading` }, options.heading || '知识目录'), element('span', {}, query ? `${view.searchTotal} 条搜索结果` : `${workspaceDocuments.length} 篇已加载`)),
1295
1691
  !readOnly ? actionButton('+', () => { void startBlankDocument(workspace, view.knowledgeBaseId || activeBases[0]?.id) }, 'ghost note-add-button', { 'aria-label': '新建文档', title: '新建文档' }) : null,
1296
1692
  ),
1297
1693
  element('div', { class: 'note-tree-search-wrap' }, interfaceIcon('search', 'search-symbol'), search),
1298
- 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 ? '当前会话未挂载知识库' : '还没有知识库')),
1694
+ element('nav', { class: 'note-tree', 'data-scroll-key': `${workspace.kind}-note-tree` }, treeContent),
1299
1695
  workspace.kind === 'session' ? element('footer', { class: 'note-tree-footer' }, actionButton('新建知识库', () => openKnowledgeBaseEditor(), 'ghost small')) : null,
1300
1696
  ),
1301
1697
  view.treeOpen ? element('button', {
@@ -1309,6 +1705,7 @@ function renderDocumentWorkspace(workspace, options = {}) {
1309
1705
  function renderNoteEditor(workspace, editor, base) {
1310
1706
  const view = workspace.view
1311
1707
  const readOnly = documentWorkspaceReadOnly(workspace)
1708
+ if (view.editorLoading) return renderDocumentEditorLoading(base)
1312
1709
  if (!editor) return element('main', { class: 'note-editor note-editor-empty' },
1313
1710
  element('div', { class: 'note-empty-content' },
1314
1711
  element('span', { class: 'empty-document-mark', 'aria-hidden': 'true' }),
@@ -1332,28 +1729,29 @@ function renderNoteEditor(workspace, editor, base) {
1332
1729
  class: 'note-title-input', value: editor.title, maxlength: 200, placeholder: '无标题文档', 'aria-label': '文档标题',
1333
1730
  onInput: event => update('title', event.target.value), onKeyDown: saveShortcut,
1334
1731
  })
1335
- const body = element('textarea', {
1336
- class: 'note-body-editor', maxlength: 50000, placeholder: '从这里开始记录…', 'aria-label': '文档正文',
1337
- onInput: event => { update('body', event.target.value); resizeDocumentEditor(event.target) }, onKeyDown: saveShortcut,
1338
- })
1339
- body.value = editor.body
1340
1732
  const finalized = !editor.isNew && editor.documentState !== 'open'
1341
- const mode = finalized || readOnly ? 'preview' : view.mode === 'edit' ? 'edit' : 'preview'
1342
- if (mode === 'edit') window.requestAnimationFrame(() => resizeDocumentEditor(body))
1733
+ const editable = !finalized && !readOnly
1734
+ const body = editable
1735
+ ? element('div', { class: 'notes-live-editor knowledge-live-editor', role: 'status', 'aria-label': '正在打开知识文档正文' },
1736
+ element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
1737
+ : renderMarkdownPreview(editor.body)
1738
+ if (editable) mountMarkdownEditor(body, {
1739
+ markdown: editor.body,
1740
+ label: '编辑知识文档正文',
1741
+ onChange: value => update('body', value),
1742
+ onSave: () => { void saveDocumentEditor(workspace).then(saved => { if (saved) renderShell() }) },
1743
+ })
1343
1744
  return element('main', { class: 'note-editor', 'aria-label': '文档编辑器' },
1344
1745
  element('header', { class: 'note-editor-toolbar' },
1345
1746
  element('div', { class: 'note-breadcrumb' },
1346
1747
  element('span', {}, base?.name || '知识库'),
1347
1748
  element('span', { 'aria-hidden': 'true' }, '/'),
1348
- element('strong', {}, editor.isNew ? '新文档' : documentWorkspaceDocuments(workspace).find(item => item.id === editor.id)?.relPath || editor.title)),
1749
+ element('strong', {}, editor.isNew ? '新文档' : documentWorkspaceDocuments(workspace).find(item => item.id === editor.id)?.relPath || editor.title),
1750
+ element('span', { class: 'note-toolbar-meta' },
1751
+ element('span', {}, editor.isNew ? '尚未保存' : `更新于 ${formatDate(editor.updatedAt)}`),
1752
+ element('span', {}, editor.scope.kind === 'global' ? '全局知识' : `项目 ${editor.scope.id}`))),
1349
1753
  element('div', { class: 'note-editor-actions' },
1350
- finalized ? badge(DOCUMENT_STATE_LABELS[editor.documentState] || '已结束', 'success') : readOnly ? badge('只读') : element('div', { class: 'note-mode-switch', role: 'tablist', 'aria-label': '文档视图' }, [
1351
- ['edit', '编辑'],
1352
- ['preview', '预览'],
1353
- ].map(([value, label]) => element('button', {
1354
- type: 'button', role: 'tab', 'aria-selected': String(mode === value),
1355
- onClick: () => switchDocumentMode(workspace, value),
1356
- }, label))),
1754
+ finalized ? badge(DOCUMENT_STATE_LABELS[editor.documentState] || '已结束', 'success') : readOnly ? badge('只读') : null,
1357
1755
  element('span', { class: 'editor-save-status', role: 'status' }, editor.saveState),
1358
1756
  finalized && !readOnly ? actionButton('重新打开', () => reopenDocument(workspace, editor), 'small') : null,
1359
1757
  !readOnly && !editor.isNew && !finalized ? actionButton('标记结束', () => openFinalizeDocument(workspace, editor), 'small') : null,
@@ -1370,14 +1768,12 @@ function renderNoteEditor(workspace, editor, base) {
1370
1768
  editor.finalizationNote ? element('p', {}, editor.finalizationNote) : null,
1371
1769
  editor.finalizedAt ? element('small', {}, `结束于 ${formatDate(editor.finalizedAt)}`) : null,
1372
1770
  ) : null,
1373
- element('article', { class: `note-paper is-${mode}`, 'data-document-mode': mode },
1374
- mode === 'edit' ? title : element('h1', { class: 'note-preview-title' }, editor.title || '无标题文档'),
1375
- element('div', { class: 'note-document-meta' },
1376
- element('span', {}, editor.isNew ? '尚未保存' : `更新于 ${formatDate(editor.updatedAt)}`),
1377
- element('span', {}, editor.scope.kind === 'global' ? '全局知识' : `项目 · ${editor.scope.id}`)),
1378
- mode === 'edit' ? body : renderMarkdownPreview(editor.body),
1771
+ element('article', { class: `note-paper is-${editable ? 'edit' : 'preview'}`, 'data-document-mode': editable ? 'edit' : 'preview' },
1772
+ editable ? title : element('h1', { class: 'note-preview-title' }, editor.title || '无标题文档'),
1773
+ body,
1379
1774
  ),
1380
1775
  ),
1776
+ renderDocumentReferenceBar(workspace, editor, editable),
1381
1777
  element('footer', { class: 'note-inspector' },
1382
1778
  finalized || readOnly ? element('span', { class: 'note-format-hint' }, readOnly ? '知识库已归档 · 只读' : '只读封存 · 重新打开后才能编辑') : element('label', {}, element('span', {}, '类型'), element('select', {
1383
1779
  class: 'note-meta-select', onChange: event => update('type', event.target.value),
@@ -1385,16 +1781,109 @@ function renderNoteEditor(workspace, editor, base) {
1385
1781
  finalized || readOnly ? null : element('label', { class: 'note-tags-field' }, element('span', {}, '标签'), element('input', {
1386
1782
  value: editor.tagsText, placeholder: '用逗号分隔', onInput: event => update('tagsText', event.target.value),
1387
1783
  })),
1388
- !finalized && !readOnly ? element('span', { class: 'note-format-hint' }, mode === 'edit' ? 'Markdown · Ctrl/⌘ S 保存' : 'Markdown 预览') : null,
1784
+ editable ? element('span', { class: 'note-format-hint' }, 'Markdown · Ctrl/⌘ S 保存') : null,
1389
1785
  ),
1390
1786
  )
1391
1787
  }
1392
1788
 
1393
- function switchDocumentMode(workspace, mode) {
1394
- if (mode !== 'edit' && mode !== 'preview') return
1395
- workspace.view.mode = mode
1396
- renderShell()
1397
- if (mode === 'edit') window.requestAnimationFrame(() => document.querySelector('.note-body-editor')?.focus())
1789
+ function renderDocumentEditorLoading(base) {
1790
+ return element('main', { class: 'note-editor note-editor-loading', role: 'status', 'aria-label': '正在打开知识文档' },
1791
+ element('header', { class: 'note-editor-toolbar' },
1792
+ element('div', { class: 'note-breadcrumb' }, element('span', {}, base?.name || '知识库'), element('span', { 'aria-hidden': 'true' }, '/'), element('strong', {}, '正在打开文档')),
1793
+ element('span', { class: 'editor-load-label' }, '按需加载正文与关联笔记'),
1794
+ ),
1795
+ element('div', { class: 'note-editor-scroll' }, element('article', { class: 'note-paper editor-paper-skeleton', 'aria-hidden': 'true' },
1796
+ element('div', { class: 'skeleton-line skeleton-paper-title' }),
1797
+ ...Array.from({ length: 9 }, (_, index) => element('div', { class: `skeleton-line skeleton-paper-row is-${index % 4}` })),
1798
+ )),
1799
+ )
1800
+ }
1801
+
1802
+ function renderDocumentReferenceBar(workspace, editor, editable) {
1803
+ const references = editor.noteReferences || []
1804
+ return element('section', { class: 'document-reference-bar', 'aria-label': '关联笔记' },
1805
+ element('div', { class: 'document-reference-heading' },
1806
+ element('span', { class: 'document-reference-mark', 'aria-hidden': 'true' }),
1807
+ element('strong', {}, '关联笔记'),
1808
+ element('small', {}, references.length ? `${references.length} 项` : '未关联')),
1809
+ element('div', { class: 'document-reference-list' }, references.length
1810
+ ? references.map(reference => element('span', { class: 'document-reference-chip' },
1811
+ element('button', {
1812
+ type: 'button', class: 'document-reference-open', title: `打开 ${reference.note.name}`,
1813
+ onClick: () => { void openNoteReference(reference.note.id) },
1814
+ }, renderNoteIcon(reference.note), element('span', {}, reference.note.name)),
1815
+ editable ? element('button', {
1816
+ type: 'button', class: 'document-reference-remove', 'aria-label': `移除 ${reference.note.name} 的关联`, title: '移除关联',
1817
+ onClick: () => { void removeDocumentNoteReference(workspace, editor, reference) },
1818
+ }, '×') : null,
1819
+ ))
1820
+ : element('span', { class: 'document-reference-empty' }, editor.isNew ? '保存文档后可以关联笔记或资料文件' : '这篇知识还没有关联笔记')),
1821
+ editable && !editor.isNew ? actionButton('+ 添加', () => { void openDocumentNoteReferencePicker(workspace, editor) }, 'ghost small document-reference-add') : null,
1822
+ )
1823
+ }
1824
+
1825
+ async function openDocumentNoteReferencePicker(workspace, editor) {
1826
+ const search = element('input', {
1827
+ class: 'input', type: 'search', placeholder: '搜索笔记文档或文件', 'aria-label': '搜索可关联的笔记',
1828
+ })
1829
+ const results = element('div', { class: 'note-picker-results', 'aria-live': 'polite' })
1830
+ let modal
1831
+ let request = 0
1832
+ const paint = async () => {
1833
+ const current = ++request
1834
+ const query = search.value.trim()
1835
+ if (!query) {
1836
+ results.replaceChildren(element('div', { class: 'note-picker-empty' }, '输入名称以搜索可关联的笔记文档和文件。'))
1837
+ return
1838
+ }
1839
+ results.replaceChildren(element('div', { class: 'note-picker-empty' }, '正在搜索…'))
1840
+ try {
1841
+ const linked = new Set((editor.noteReferences || []).map(reference => reference.note.id))
1842
+ const visible = (await api(`notes?q=${encodeURIComponent(query)}&limit=100`)).filter(node => node.kind !== 'folder' && !linked.has(node.id))
1843
+ if (current !== request) return
1844
+ results.replaceChildren(...(visible.length
1845
+ ? visible.map(node => element('button', {
1846
+ type: 'button', class: 'note-picker-row',
1847
+ onClick: async event => {
1848
+ event.currentTarget.disabled = true
1849
+ try {
1850
+ const reference = await api(`entries/${encodeURIComponent(editor.id)}/note-references`, { method: 'POST', body: { noteId: node.id } })
1851
+ editor.noteReferences = [...(editor.noteReferences || []), reference]
1852
+ modal.close(true)
1853
+ renderShell()
1854
+ showToast('已关联笔记。')
1855
+ } catch (error) {
1856
+ event.currentTarget.disabled = false
1857
+ showToast(friendlyError(error), 'error')
1858
+ }
1859
+ },
1860
+ },
1861
+ renderNoteIcon(node),
1862
+ element('span', {}, element('strong', {}, node.name), element('small', {}, `${node.kind === 'document' ? '笔记文档' : formatBytes(node.size)} ${shortNoteId(node.id)}`))))
1863
+ : [element('div', { class: 'note-picker-empty' }, '没有匹配的笔记文档或文件。')]))
1864
+ } catch (error) {
1865
+ if (current !== request) return
1866
+ results.replaceChildren(element('div', { class: 'note-picker-empty is-error' }, friendlyError(error)))
1867
+ }
1868
+ }
1869
+ let timer = 0
1870
+ search.addEventListener('input', () => { window.clearTimeout(timer); timer = window.setTimeout(() => void paint(), 180) })
1871
+ modal = openSheet({
1872
+ title: '添加关联笔记',
1873
+ description: '关联保存在文档正文之外;笔记移动或改名后仍然有效。',
1874
+ body: element('div', { class: 'note-picker' }, search, results),
1875
+ cancelLabel: '取消',
1876
+ })
1877
+ search.focus()
1878
+ }
1879
+
1880
+ async function removeDocumentNoteReference(workspace, editor, reference) {
1881
+ try {
1882
+ await api(`entries/${encodeURIComponent(editor.id)}/note-references/${encodeURIComponent(reference.note.id)}`, { method: 'DELETE' })
1883
+ editor.noteReferences = (editor.noteReferences || []).filter(item => item.note.id !== reference.note.id)
1884
+ if (workspace.view.editor === editor) renderShell()
1885
+ showToast('已移除关联。')
1886
+ } catch (error) { showToast(friendlyError(error), 'error') }
1398
1887
  }
1399
1888
 
1400
1889
  function renderMarkdownPreview(markdown) {
@@ -1409,12 +1898,30 @@ function renderMarkdownPreview(markdown) {
1409
1898
  table.replaceWith(scroller)
1410
1899
  scroller.append(table)
1411
1900
  })
1901
+ preview.querySelectorAll('[data-note-id]').forEach(link => {
1902
+ link.setAttribute('role', 'button')
1903
+ link.setAttribute('tabindex', '0')
1904
+ link.setAttribute('title', '打开笔记文档')
1905
+ const open = event => {
1906
+ if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') return
1907
+ event.preventDefault()
1908
+ void openNoteReference(link.dataset.noteId)
1909
+ }
1910
+ link.addEventListener('click', open)
1911
+ link.addEventListener('keydown', open)
1912
+ })
1412
1913
  return preview
1413
1914
  }
1414
1915
 
1415
- function resizeDocumentEditor(editor) {
1416
- editor.style.height = 'auto'
1417
- editor.style.height = `${Math.max(420, editor.scrollHeight)}px`
1916
+ async function openNoteReference(id) {
1917
+ try {
1918
+ if (!await saveBeforeLeavingDocument()) return
1919
+ const node = await api(`notes/${encodeURIComponent(id)}`)
1920
+ state.view = 'notes'
1921
+ state.loading = false
1922
+ await selectNoteNode(node)
1923
+ renderShell()
1924
+ } catch (error) { showToast(friendlyError(error), 'error') }
1418
1925
  }
1419
1926
 
1420
1927
  function openFinalizeDocument(workspace, editor) {
@@ -1484,6 +1991,1070 @@ function confirmDeleteDocument(workspace, editor) {
1484
1991
  })
1485
1992
  }
1486
1993
 
1994
+ function renderNotes() {
1995
+ const fileInput = element('input', {
1996
+ class: 'visually-hidden', type: 'file', multiple: true, tabindex: '-1',
1997
+ onChange: event => { void uploadNoteFiles([...event.target.files], state.notes.currentFolderId); event.target.value = '' },
1998
+ })
1999
+ const rootNodes = state.notes.children.get('root') || []
2000
+ const tree = state.notes.query.trim()
2001
+ ? renderNoteSearchResults()
2002
+ : renderNoteTreeBranch(null)
2003
+ const workspace = element('section', {
2004
+ class: 'notes-workspace', 'data-drop-active': 'false',
2005
+ onDragEnter: noteWorkspaceDragEnter, onDragOver: noteWorkspaceDragEnter,
2006
+ onDragLeave: noteWorkspaceDragLeave,
2007
+ onDrop: event => {
2008
+ clearNoteDragState()
2009
+ if (!hasDragType(event, 'Files')) return
2010
+ event.preventDefault()
2011
+ void importDroppedNotes(event.dataTransfer, state.notes.currentFolderId)
2012
+ },
2013
+ },
2014
+ element('aside', { class: 'notes-browser', 'aria-label': '笔记目录' },
2015
+ element('header', { class: 'notes-browser-header' },
2016
+ element('div', {}, element('h2', {}, '目录'), element('span', {}, `${rootNodes.length} 个根目录项目`)),
2017
+ element('div', { class: 'notes-browser-actions' },
2018
+ actionButton('新建', () => openCreateNoteDocument(), 'primary small'),
2019
+ actionButton('目录', () => openCreateNoteFolder(), 'small'),
2020
+ actionButton('导入', () => fileInput.click(), 'ghost small'),
2021
+ fileInput,
2022
+ ),
2023
+ ),
2024
+ element('div', { class: 'notes-search' }, element('input', {
2025
+ class: 'input', type: 'search', value: state.notes.query, placeholder: '搜索笔记文档', 'aria-label': '搜索笔记文档',
2026
+ onInput: event => scheduleNoteSearch(event.target.value),
2027
+ })),
2028
+ element('div', {
2029
+ class: 'notes-tree', role: 'tree', 'data-scroll-key': 'notes-tree',
2030
+ onDragOver: event => {
2031
+ if (hasDragType(event, 'application/x-dsh-note-id') || hasDragType(event, 'Files')) event.preventDefault()
2032
+ },
2033
+ onDrop: event => { if (event.target === event.currentTarget) void dropNoteNode(event, null) },
2034
+ }, tree),
2035
+ ),
2036
+ renderNoteContent(),
2037
+ element('div', { class: 'notes-drop-overlay', 'aria-hidden': 'true' },
2038
+ element('strong', {}, '放开以导入当前目录'),
2039
+ element('span', {}, '支持文件和完整目录,原有目录层级会被保留。')),
2040
+ renderNoteTransfer(),
2041
+ )
2042
+ return element('section', { class: 'notes-page', 'aria-label': '笔记工作区' }, workspace)
2043
+ }
2044
+
2045
+ function renderNoteTransfer() {
2046
+ const transfer = state.notes.transfer
2047
+ if (!transfer) return null
2048
+ const dismissible = transfer.phase === 'complete' || transfer.phase === 'error'
2049
+ return element('section', {
2050
+ class: 'notes-transfer', 'data-note-transfer': transfer.id, 'data-state': transfer.phase,
2051
+ role: transfer.phase === 'error' ? 'alert' : 'status', 'aria-live': 'polite',
2052
+ },
2053
+ element('div', { class: 'notes-transfer-heading' },
2054
+ element('span', { class: 'notes-transfer-mark', 'aria-hidden': 'true' }),
2055
+ element('strong', { class: 'notes-transfer-title' }, noteTransferTitle(transfer)),
2056
+ dismissible ? actionButton('关闭', dismissNoteTransfer, 'ghost tiny notes-transfer-close', { 'aria-label': '关闭导入进度' }) : null,
2057
+ ),
2058
+ element('div', { class: 'notes-transfer-detail', title: transfer.currentName || '' }, noteTransferDetail(transfer)),
2059
+ element('div', {
2060
+ class: 'notes-transfer-track', role: 'progressbar',
2061
+ 'aria-label': '笔记导入进度', 'aria-valuemin': '0', 'aria-valuemax': '100',
2062
+ 'aria-valuenow': transfer.phase === 'scanning' ? undefined : String(noteTransferPercent(transfer)),
2063
+ }, element('span', { class: 'notes-transfer-fill' })),
2064
+ element('div', { class: 'notes-transfer-meta' },
2065
+ element('span', { class: 'notes-transfer-count' }, noteTransferCount(transfer)),
2066
+ element('span', { class: 'notes-transfer-percent' }, transfer.phase === 'scanning' ? '正在整理' : `${noteTransferPercent(transfer)}%`),
2067
+ ),
2068
+ )
2069
+ }
2070
+
2071
+ function renderNoteTreeBranch(parentId, depth = 0) {
2072
+ const key = parentId || 'root'
2073
+ const nodes = state.notes.children.get(key) || []
2074
+ if (!nodes.length && depth === 0) {
2075
+ return element('div', { class: 'notes-tree-empty' }, element('strong', {}, '还没有笔记'), element('span', {}, '新建目录或文档开始整理。'))
2076
+ }
2077
+ return element('div', { class: 'notes-tree-branch', role: depth ? 'group' : undefined }, nodes.map(node => {
2078
+ const selected = state.notes.selectedId === node.id
2079
+ const expanded = node.kind === 'folder' && state.notes.expandedFolders.has(node.id)
2080
+ const row = element('div', {
2081
+ class: 'notes-tree-item', 'data-kind': node.kind, 'data-selected': String(selected),
2082
+ 'data-note-id': node.id,
2083
+ 'data-knowledge-motion-key': `note:${node.id}`,
2084
+ draggable: 'true',
2085
+ onDragStart: event => {
2086
+ event.dataTransfer.effectAllowed = 'move'
2087
+ event.dataTransfer.setData('application/x-dsh-note-id', node.id)
2088
+ event.currentTarget.dataset.dragging = 'true'
2089
+ },
2090
+ onDragEnd: () => clearNoteDragState(),
2091
+ onDragEnter: event => activateNoteFolderDropTarget(event, node),
2092
+ onDragOver: event => activateNoteFolderDropTarget(event, node),
2093
+ onDragLeave: event => { if (!event.currentTarget.contains(event.relatedTarget)) event.currentTarget.dataset.dropTarget = 'false' },
2094
+ onDrop: event => { if (node.kind === 'folder') void dropNoteNode(event, node.id) },
2095
+ },
2096
+ element('button', {
2097
+ type: 'button', class: 'notes-tree-row', role: 'treeitem',
2098
+ 'aria-selected': String(selected), 'aria-expanded': node.kind === 'folder' ? String(expanded) : undefined,
2099
+ onClick: () => { void selectNoteNode(node, { toggleFolder: node.kind === 'folder' }) },
2100
+ },
2101
+ node.kind === 'folder' ? element('span', { class: 'notes-tree-chevron', 'aria-hidden': 'true' }) : element('span', { class: 'notes-tree-spacer' }),
2102
+ renderNoteIcon(node),
2103
+ element('span', { class: 'notes-tree-name', title: node.name }, node.name),
2104
+ ),
2105
+ element('div', { class: 'notes-row-actions' },
2106
+ actionButton('重命名', event => { event.stopPropagation(); openRenameNoteNode(node) }, 'ghost tiny'),
2107
+ actionButton('复制', event => { event.stopPropagation(); void copyNoteNode(node) }, 'ghost tiny'),
2108
+ actionButton('删除', event => { event.stopPropagation(); void confirmDeleteNoteNode(node) }, 'ghost tiny danger-text'),
2109
+ ),
2110
+ )
2111
+ const children = expanded
2112
+ ? state.notes.loadedFolders.has(node.id)
2113
+ ? renderNoteTreeBranch(node.id, depth + 1)
2114
+ : element('div', { class: 'notes-tree-loading', role: 'status' }, '正在读取…')
2115
+ : null
2116
+ return element('div', { class: 'notes-tree-node' }, row, children)
2117
+ }))
2118
+ }
2119
+
2120
+ function renderNoteSearchResults() {
2121
+ if (!state.notes.searchResults.length) return element('div', { class: 'notes-tree-empty' }, '没有匹配的笔记文档。')
2122
+ return element('div', { class: 'notes-search-results' }, state.notes.searchResults.map(node => element('button', {
2123
+ type: 'button', class: 'notes-search-row', 'data-knowledge-motion-key': `note-search:${node.id}`,
2124
+ onClick: () => { void selectNoteNode(node) },
2125
+ }, renderNoteIcon(node), element('span', {}, element('strong', {}, node.name), element('small', {}, noteKindLabel(node))))))
2126
+ }
2127
+
2128
+ function renderNoteContent() {
2129
+ const node = state.notes.selectedNode
2130
+ if (!node) return renderNoteFolderContent(null)
2131
+ if (state.notes.loadingNodeId === node.id) return renderNoteSelectionLoading(node)
2132
+ if (node.kind === 'folder') return renderNoteFolderContent(node)
2133
+ if (node.kind === 'document') return renderNoteDocument(node)
2134
+ return renderNoteFile(node)
2135
+ }
2136
+
2137
+ function renderNoteSelectionLoading(node) {
2138
+ return element('main', { class: `notes-content notes-selection-loading is-${node.kind}`, role: 'status', 'aria-label': `正在打开 ${node.name}` },
2139
+ element('header', { class: 'notes-content-header' },
2140
+ element('div', { class: 'notes-breadcrumb' }, element('span', {}, '笔记文档'), element('span', { 'aria-hidden': 'true' }, '/'), element('strong', {}, node.name)),
2141
+ element('div', { class: 'notes-content-title' }, element('div', {}, element('h2', {}, node.name), element('p', {}, node.kind === 'folder' ? '正在读取目录内容' : '正在按需读取文档内容'))),
2142
+ ),
2143
+ element('div', { class: 'notes-selection-skeleton', 'aria-hidden': 'true' },
2144
+ element('div', { class: 'skeleton-line skeleton-paper-title' }),
2145
+ ...Array.from({ length: node.kind === 'folder' ? 6 : 9 }, (_, index) => element('div', { class: `skeleton-line skeleton-paper-row is-${index % 4}` })),
2146
+ ),
2147
+ )
2148
+ }
2149
+
2150
+ function renderNoteFolderContent(folder) {
2151
+ const parentId = folder?.id || null
2152
+ const children = state.notes.children.get(parentId || 'root') || []
2153
+ return element('main', {
2154
+ class: 'notes-content is-folder',
2155
+ onDragOver: event => event.preventDefault(),
2156
+ onDrop: event => void dropNoteNode(event, parentId),
2157
+ },
2158
+ renderNoteContentHeader(folder),
2159
+ children.length
2160
+ ? element('div', { class: 'notes-file-list', role: 'list' }, children.map(node => element('div', {
2161
+ class: 'notes-file-row', role: 'listitem', draggable: 'true',
2162
+ onDragStart: event => { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('application/x-dsh-note-id', node.id) },
2163
+ onDblClick: () => { void selectNoteNode(node, { toggleFolder: node.kind === 'folder' }) },
2164
+ },
2165
+ element('button', { type: 'button', class: 'notes-file-main', onClick: () => { void selectNoteNode(node) } },
2166
+ renderNoteIcon(node, true),
2167
+ element('span', {}, element('strong', {}, node.name), element('small', {}, noteKindLabel(node))),
2168
+ ),
2169
+ element('time', { datetime: node.updatedAt }, formatDate(node.updatedAt)),
2170
+ element('span', { class: 'notes-file-size' }, node.kind === 'folder' ? '' : formatBytes(node.size)),
2171
+ element('div', { class: 'notes-file-actions' },
2172
+ actionButton('重命名', () => openRenameNoteNode(node), 'ghost tiny'),
2173
+ actionButton('复制', () => { void copyNoteNode(node) }, 'ghost tiny'),
2174
+ actionButton('删除', () => { void confirmDeleteNoteNode(node) }, 'ghost tiny danger-text'),
2175
+ ),
2176
+ )))
2177
+ : element('div', { class: 'notes-folder-empty' },
2178
+ element('span', { class: 'notes-empty-folder-mark', 'aria-hidden': 'true' }),
2179
+ element('h3', {}, '这个目录还是空的'),
2180
+ element('p', {}, '可以新建笔记文档、建立子目录,或把本地文件拖到这里。'),
2181
+ element('div', {}, actionButton('新建文档', () => openCreateNoteDocument(parentId), 'primary small'), actionButton('新建目录', () => openCreateNoteFolder(parentId), 'small')),
2182
+ ),
2183
+ )
2184
+ }
2185
+
2186
+ function renderNoteContentHeader(folder) {
2187
+ const path = folder ? [...state.notes.breadcrumbs] : []
2188
+ return element('header', { class: 'notes-content-header' },
2189
+ element('nav', { class: 'notes-breadcrumb', 'aria-label': '当前位置' },
2190
+ element('button', { type: 'button', onClick: () => { void openNoteRoot() } }, '笔记文档'),
2191
+ path.map(node => element('span', {}, element('span', { 'aria-hidden': 'true' }, '/'), element('button', { type: 'button', onClick: () => { void selectNoteNode(node) } }, node.name))),
2192
+ ),
2193
+ element('div', { class: 'notes-content-title' },
2194
+ element('div', {}, element('h2', {}, folder?.name || '全部笔记'), element('p', {}, folder ? `${(state.notes.children.get(folder.id) || []).length} 个项目` : '你的独立笔记与资料目录')),
2195
+ element('div', { class: 'notes-content-actions' },
2196
+ actionButton('新建文档', () => openCreateNoteDocument(folder?.id || null), 'primary small'),
2197
+ actionButton('新建目录', () => openCreateNoteFolder(folder?.id || null), 'small'),
2198
+ ),
2199
+ ),
2200
+ )
2201
+ }
2202
+
2203
+ function renderNoteDocument(node) {
2204
+ return renderEditableNote(node)
2205
+ }
2206
+
2207
+ function renderEditableNote(node) {
2208
+ const markdown = isMarkdownNote(node)
2209
+ const title = editableNoteTitle(node)
2210
+ const editor = markdown
2211
+ ? element('div', { class: 'notes-live-editor', role: 'status', 'aria-label': `正在打开 ${node.name}` },
2212
+ element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
2213
+ : createPlainTextNoteEditor(node)
2214
+ if (markdown) mountMarkdownNoteEditor(editor, node)
2215
+ else mountPlainTextNoteEditor(editor, node)
2216
+ return element('main', { class: `notes-content is-document${markdown ? '' : ' has-line-numbers'}` },
2217
+ renderNoteFileToolbar(node, { editable: true }),
2218
+ element('h1', {
2219
+ class: 'notes-document-title', contenteditable: 'plaintext-only', spellcheck: 'false',
2220
+ 'aria-label': `修改 ${node.name} 的标题`, title: '点击修改标题',
2221
+ onBlur: event => { void saveEditableNoteTitle(event.currentTarget, node) },
2222
+ onKeyDown: event => {
2223
+ if (event.key === 'Enter') { event.preventDefault(); event.currentTarget.blur() }
2224
+ if (event.key === 'Escape') { event.preventDefault(); event.currentTarget.textContent = title; event.currentTarget.blur() }
2225
+ },
2226
+ }, title),
2227
+ editor,
2228
+ )
2229
+ }
2230
+
2231
+ function editableNoteTitle(node) {
2232
+ return node.kind === 'document' ? node.name.replace(/\.md$/i, '') : node.name
2233
+ }
2234
+
2235
+ async function saveEditableNoteTitle(editor, node) {
2236
+ const title = readPlainTextEditor(editor).replace(/\s+/g, ' ').trim()
2237
+ const name = node.kind === 'document' && title ? `${title.replace(/\.md$/i, '')}.md` : title
2238
+ if (!name) {
2239
+ editor.textContent = editableNoteTitle(node)
2240
+ showToast('标题不能为空。', 'error')
2241
+ return
2242
+ }
2243
+ if (name === node.name) {
2244
+ editor.textContent = editableNoteTitle(node)
2245
+ return
2246
+ }
2247
+ editor.setAttribute('contenteditable', 'false')
2248
+ try {
2249
+ const updated = await api(`notes/${encodeURIComponent(node.id)}`, { method: 'PATCH', body: { name } })
2250
+ if (state.notes.selectedNode?.id === node.id) state.notes.selectedNode = updated
2251
+ editor.textContent = editableNoteTitle(updated)
2252
+ const breadcrumb = document.querySelector('.notes-document-toolbar .notes-breadcrumb strong')
2253
+ if (breadcrumb) breadcrumb.textContent = updated.name
2254
+ await loadNoteChildren(updated.parentId, true).catch(() => {})
2255
+ const treeName = document.querySelector(`.notes-tree-item[data-note-id="${updated.id}"] .notes-tree-name`)
2256
+ if (treeName) treeName.textContent = updated.name
2257
+ showToast('标题已更新。')
2258
+ } catch (error) {
2259
+ editor.textContent = editableNoteTitle(node)
2260
+ showToast(friendlyError(error), 'error')
2261
+ } finally {
2262
+ if (editor.isConnected) editor.setAttribute('contenteditable', 'plaintext-only')
2263
+ }
2264
+ }
2265
+
2266
+ function createPlainTextNoteEditor(node) {
2267
+ return element('div', { class: 'notes-plain-editor', role: 'status', 'aria-label': `正在打开 ${node.name}` },
2268
+ element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
2269
+ }
2270
+
2271
+ function readPlainTextEditor(editor) {
2272
+ return editor.innerText.replace(/\r\n?/g, '\n')
2273
+ }
2274
+
2275
+ function updateNoteDraft(value) {
2276
+ state.notes.draft = value
2277
+ state.notes.dirty = state.notes.draft !== state.notes.content
2278
+ syncNoteEditorChrome()
2279
+ }
2280
+
2281
+ function mountMarkdownNoteEditor(host, node) {
2282
+ mountMarkdownEditor(host, {
2283
+ markdown: state.notes.draft,
2284
+ label: `编辑 ${node.name}`,
2285
+ isCurrent: () => state.notes.selectedNode?.id === node.id,
2286
+ onChange: updateNoteDraft,
2287
+ onSave: () => { void saveNoteDocument() },
2288
+ })
2289
+ }
2290
+
2291
+ function mountPlainTextNoteEditor(host, node) {
2292
+ const request = ++plainTextEditorMountRequest
2293
+ window.requestAnimationFrame(async () => {
2294
+ if (!host.isConnected || state.notes.selectedNode?.id !== node.id || request !== plainTextEditorMountRequest) return
2295
+ try {
2296
+ const runtime = await loadMarkdownNoteEditor()
2297
+ if (!host.isConnected || state.notes.selectedNode?.id !== node.id || request !== plainTextEditorMountRequest) return
2298
+ host.replaceChildren()
2299
+ host.removeAttribute('role')
2300
+ plainTextEditorHandle = runtime.createPlainTextEditor({
2301
+ host,
2302
+ text: state.notes.draft,
2303
+ label: `编辑 ${node.name}`,
2304
+ onChange: updateNoteDraft,
2305
+ onSave: () => { void saveNoteDocument() },
2306
+ })
2307
+ } catch (error) {
2308
+ if (!host.isConnected || request !== plainTextEditorMountRequest) return
2309
+ host.replaceChildren(element('div', { class: 'notes-editor-error', role: 'alert' },
2310
+ element('strong', {}, '无法打开纯文本编辑器'),
2311
+ element('span', {}, friendlyError(error)),
2312
+ actionButton('重试', () => { noteEditorLoader = null; host.replaceChildren(); mountPlainTextNoteEditor(host, node) }, 'small')))
2313
+ }
2314
+ })
2315
+ }
2316
+
2317
+ function mountMarkdownEditor(host, options) {
2318
+ const request = ++markdownEditorMountRequest
2319
+ window.requestAnimationFrame(async () => {
2320
+ if (!host.isConnected || options.isCurrent?.() === false || request !== markdownEditorMountRequest) return
2321
+ try {
2322
+ const runtime = await loadMarkdownNoteEditor()
2323
+ if (!host.isConnected || options.isCurrent?.() === false || request !== markdownEditorMountRequest) return
2324
+ host.replaceChildren()
2325
+ host.removeAttribute('role')
2326
+ markdownEditorHandle = runtime.createMarkdownEditor({
2327
+ host,
2328
+ markdown: options.markdown,
2329
+ label: options.label,
2330
+ onChange: options.onChange,
2331
+ onSave: options.onSave,
2332
+ })
2333
+ } catch (error) {
2334
+ if (!host.isConnected || request !== markdownEditorMountRequest) return
2335
+ host.replaceChildren(element('div', { class: 'notes-editor-error', role: 'alert' },
2336
+ element('strong', {}, '无法打开 Markdown 编辑器'),
2337
+ element('span', {}, friendlyError(error)),
2338
+ actionButton('重试', () => { noteEditorLoader = null; host.replaceChildren(); mountMarkdownEditor(host, options) }, 'small')))
2339
+ }
2340
+ })
2341
+ }
2342
+
2343
+ function loadMarkdownNoteEditor() {
2344
+ if (window.DshKnowledgeNoteEditor?.createMarkdownEditor) return Promise.resolve(window.DshKnowledgeNoteEditor)
2345
+ if (noteEditorLoader) return noteEditorLoader
2346
+ noteEditorLoader = new Promise((resolve, reject) => {
2347
+ const script = document.createElement('script')
2348
+ const suffix = ASSET_VERSION ? `?v=${encodeURIComponent(ASSET_VERSION)}` : ''
2349
+ script.src = `${WEB_PATH.replace(/\/$/, '')}/note-editor.js${suffix}`
2350
+ script.async = true
2351
+ script.onload = () => window.DshKnowledgeNoteEditor?.createMarkdownEditor
2352
+ ? resolve(window.DshKnowledgeNoteEditor)
2353
+ : reject(new Error('Markdown 编辑器模块无效'))
2354
+ script.onerror = () => { script.remove(); reject(new Error('Markdown 编辑器加载失败')) }
2355
+ document.head.append(script)
2356
+ }).catch(error => {
2357
+ noteEditorLoader = null
2358
+ throw error
2359
+ })
2360
+ return noteEditorLoader
2361
+ }
2362
+
2363
+ function releaseNoteEditors() {
2364
+ markdownEditorMountRequest += 1
2365
+ plainTextEditorMountRequest += 1
2366
+ markdownEditorHandle?.destroy()
2367
+ plainTextEditorHandle?.destroy()
2368
+ markdownEditorHandle = null
2369
+ plainTextEditorHandle = null
2370
+ }
2371
+
2372
+ function syncNoteEditorChrome() {
2373
+ const status = document.querySelector('[data-note-save-state]')
2374
+ if (status) {
2375
+ status.textContent = state.notes.dirty ? '未保存' : '已保存'
2376
+ status.dataset.dirty = String(state.notes.dirty)
2377
+ }
2378
+ const save = document.querySelector('[data-note-save]')
2379
+ if (save) save.disabled = !state.notes.dirty
2380
+ }
2381
+
2382
+ function renderNoteDocumentBreadcrumb(node) {
2383
+ return element('nav', { class: 'notes-breadcrumb', 'aria-label': '当前位置' },
2384
+ element('button', { type: 'button', onClick: () => { void openNoteRoot() } }, '笔记文档'),
2385
+ state.notes.breadcrumbs.map(parent => element('span', {}, element('span', { 'aria-hidden': 'true' }, '/'), element('button', { type: 'button', onClick: () => { void selectNoteNode(parent) } }, parent.name))),
2386
+ element('span', {}, element('span', { 'aria-hidden': 'true' }, '/'), element('strong', {}, node.name)),
2387
+ )
2388
+ }
2389
+
2390
+ function renderNoteFile(node) {
2391
+ if (node.editable) return renderEditableNote(node)
2392
+ const previewKind = noteFilePreviewKind(node)
2393
+ let preview
2394
+ if (previewKind === 'image' && state.notes.assetUrl) {
2395
+ preview = element('img', { class: 'notes-inline-image', src: state.notes.assetUrl, alt: node.name })
2396
+ } else if (previewKind === 'pdf' && state.notes.assetUrl) {
2397
+ preview = element('iframe', { class: 'notes-inline-frame', src: state.notes.assetUrl, title: node.name })
2398
+ } else {
2399
+ preview = element('div', { class: 'notes-inline-unavailable' },
2400
+ renderNoteIcon(node, true),
2401
+ element('h2', {}, node.name),
2402
+ element('p', {}, '该格式不能在浏览器中直接编辑,可以下载后使用本地应用打开。'),
2403
+ actionButton('下载文件', () => { void downloadNoteFile(node) }, 'primary small'),
2404
+ )
2405
+ }
2406
+ return element('main', { class: 'notes-content is-file' },
2407
+ renderNoteFileToolbar(node),
2408
+ element('section', { class: `notes-inline-viewer is-${previewKind}`, 'aria-label': `${node.name}内容` }, preview),
2409
+ )
2410
+ }
2411
+
2412
+ function renderNoteFileToolbar(node, options = {}) {
2413
+ return element('header', { class: 'notes-document-toolbar' },
2414
+ element('div', { class: 'notes-toolbar-leading' },
2415
+ renderNoteDocumentBreadcrumb(node),
2416
+ element('div', { class: 'notes-file-info', 'aria-label': '文件信息' },
2417
+ noteInfoItem('编号', shortNoteId(node.id), node.id),
2418
+ noteInfoItem('类型', node.kind === 'document' ? 'Markdown' : node.mediaType || '未知'),
2419
+ noteInfoItem('大小', formatBytes(node.size), '', 'size'),
2420
+ noteInfoItem('更新', formatDate(node.updatedAt), '', 'updated'),
2421
+ ),
2422
+ ),
2423
+ element('div', { class: 'notes-document-actions' },
2424
+ options.editable ? element('span', { class: 'notes-save-state', role: 'status', 'data-note-save-state': '', 'data-dirty': String(state.notes.dirty) }, state.notes.dirty ? '未保存' : '已保存') : null,
2425
+ node.kind === 'file' ? actionButton('下载', () => { void downloadNoteFile(node) }, 'ghost small') : null,
2426
+ actionButton('复制引用', () => { void copyNoteReference(node) }, 'ghost small'),
2427
+ actionButton('重命名', () => openRenameNoteNode(node), 'ghost small'),
2428
+ options.editable ? actionButton('保存', () => { void saveNoteDocument() }, 'primary small', { 'data-note-save': '', disabled: !state.notes.dirty }) : null,
2429
+ ),
2430
+ )
2431
+ }
2432
+
2433
+ function noteInfoItem(label, value, title = '', key = '') {
2434
+ return element('span', { class: 'notes-file-info-item', title: title || value, ...(key ? { 'data-note-info': key } : {}) }, element('strong', {}, label), element('span', {}, value))
2435
+ }
2436
+
2437
+ function renderNoteIcon(node, large = false) {
2438
+ const label = node.kind === 'folder' ? '' : noteExtension(node.name)
2439
+ return element('span', { class: `note-node-icon is-${node.kind}${large ? ' is-large' : ''}`, 'data-media-kind': noteMediaKind(node), 'aria-hidden': 'true' }, label)
2440
+ }
2441
+
2442
+ function noteKindLabel(node) {
2443
+ if (node.kind === 'folder') return '目录'
2444
+ if (node.kind === 'document') return 'Markdown 笔记'
2445
+ return `${node.mediaType || '文件'} ${formatBytes(node.size)}`
2446
+ }
2447
+
2448
+ function isMarkdownNote(node) {
2449
+ return node.kind === 'document' || node.mediaType === 'text/markdown' || /\.(md|markdown)$/i.test(node.name)
2450
+ }
2451
+
2452
+ function noteFilePreviewKind(node) {
2453
+ if (node.mediaType?.startsWith('image/')) return 'image'
2454
+ if (node.mediaType === 'application/pdf' || /\.pdf$/i.test(node.name)) return 'pdf'
2455
+ return 'unavailable'
2456
+ }
2457
+
2458
+ async function selectNoteNode(node, options = {}) {
2459
+ if (state.notes.dirty && !await saveNoteDocument()) return
2460
+ const request = ++noteSelectionRequest
2461
+ releaseNoteAsset()
2462
+ state.notes.selectedId = node.id
2463
+ state.notes.selectedNode = node
2464
+ Object.assign(state.notes, { content: '', draft: '', dirty: false })
2465
+ state.notes.loadingNodeId = node.id
2466
+ renderShell()
2467
+ try {
2468
+ const breadcrumbs = await loadNoteBreadcrumbs(node)
2469
+ if (request !== noteSelectionRequest || state.notes.selectedId !== node.id) return
2470
+ state.notes.breadcrumbs = breadcrumbs
2471
+ if (node.kind === 'folder') {
2472
+ Object.assign(state.notes, { content: '', draft: '', dirty: false })
2473
+ state.notes.currentFolderId = node.id
2474
+ if (options.toggleFolder) {
2475
+ if (state.notes.expandedFolders.has(node.id)) state.notes.expandedFolders.delete(node.id)
2476
+ else state.notes.expandedFolders.add(node.id)
2477
+ }
2478
+ await loadNoteChildren(node.id)
2479
+ } else if (node.editable) {
2480
+ const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, { responseType: 'blob', accept: node.mediaType || 'text/plain' })
2481
+ const content = await blob.text()
2482
+ if (request !== noteSelectionRequest || state.notes.selectedId !== node.id) return
2483
+ state.notes.content = content
2484
+ state.notes.draft = content
2485
+ state.notes.dirty = false
2486
+ state.notes.currentFolderId = node.parentId
2487
+ } else {
2488
+ state.notes.content = ''
2489
+ state.notes.draft = ''
2490
+ state.notes.dirty = false
2491
+ state.notes.currentFolderId = node.parentId
2492
+ if (noteFilePreviewKind(node) !== 'unavailable') {
2493
+ const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, { responseType: 'blob', accept: node.mediaType || 'application/octet-stream' })
2494
+ if (request !== noteSelectionRequest || state.notes.selectedId !== node.id) return
2495
+ state.notes.assetUrl = URL.createObjectURL(blob)
2496
+ }
2497
+ }
2498
+ } catch (error) {
2499
+ if (request === noteSelectionRequest) showToast(friendlyError(error), 'error')
2500
+ } finally {
2501
+ if (request === noteSelectionRequest && state.notes.loadingNodeId === node.id) {
2502
+ state.notes.loadingNodeId = ''
2503
+ renderShell()
2504
+ }
2505
+ }
2506
+ }
2507
+
2508
+ async function loadNoteChildren(parentId, force = false) {
2509
+ const key = parentId || 'root'
2510
+ if (!force && state.notes.loadedFolders.has(key)) return state.notes.children.get(key) || []
2511
+ const params = new URLSearchParams({ limit: '500' })
2512
+ if (parentId) params.set('parentId', parentId)
2513
+ const nodes = await api(`notes?${params}`)
2514
+ state.notes.children.set(key, nodes)
2515
+ state.notes.loadedFolders.add(key)
2516
+ return nodes
2517
+ }
2518
+
2519
+ async function loadNoteBreadcrumbs(node) {
2520
+ const parents = []
2521
+ let parentId = node.parentId
2522
+ while (parentId) {
2523
+ const parent = findCachedNote(parentId) || await api(`notes/${encodeURIComponent(parentId)}`)
2524
+ parents.unshift(parent)
2525
+ parentId = parent.parentId
2526
+ }
2527
+ return node.kind === 'folder' ? [...parents, node] : parents
2528
+ }
2529
+
2530
+ function findCachedNote(id) {
2531
+ for (const nodes of state.notes.children.values()) {
2532
+ const found = nodes.find(node => node.id === id)
2533
+ if (found) return found
2534
+ }
2535
+ return state.notes.selectedNode?.id === id ? state.notes.selectedNode : null
2536
+ }
2537
+
2538
+ async function openNoteRoot() {
2539
+ if (state.notes.dirty && !await saveNoteDocument()) return
2540
+ noteSelectionRequest += 1
2541
+ clearNoteSelection()
2542
+ await loadNoteChildren(null)
2543
+ renderShell()
2544
+ }
2545
+
2546
+ function clearNoteSelection() {
2547
+ releaseNoteAsset()
2548
+ Object.assign(state.notes, { selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [], content: '', draft: '', dirty: false, loadingNodeId: '' })
2549
+ }
2550
+
2551
+ function releaseNoteAsset() {
2552
+ if (!state.notes?.assetUrl) return
2553
+ URL.revokeObjectURL(state.notes.assetUrl)
2554
+ state.notes.assetUrl = ''
2555
+ }
2556
+
2557
+ function scheduleNoteSearch(value) {
2558
+ state.notes.query = value
2559
+ window.clearTimeout(noteSearchTimer)
2560
+ noteSearchTimer = window.setTimeout(async () => {
2561
+ try {
2562
+ state.notes.searchResults = value.trim() ? await api(`notes?q=${encodeURIComponent(value.trim())}&limit=200`) : []
2563
+ renderShell()
2564
+ } catch (error) { showToast(friendlyError(error), 'error') }
2565
+ }, 180)
2566
+ }
2567
+
2568
+ function openCreateNoteFolder(parentId = state.notes.currentFolderId) {
2569
+ const field = formField('目录名称', 'text', '', { maxlength: 255, placeholder: '例如:项目资料' })
2570
+ const form = element('form', { class: 'form-grid' }, field.wrapper)
2571
+ openSheet({
2572
+ title: '新建目录', description: '目录可以继续包含子目录、笔记文档和任意文件。', body: form,
2573
+ primaryLabel: '创建目录', onPrimary: async () => {
2574
+ const node = await api('notes/folders', { method: 'POST', body: { name: field.input.value, parentId } })
2575
+ await loadNoteChildren(parentId, true)
2576
+ if (parentId) state.notes.expandedFolders.add(parentId)
2577
+ await selectNoteNode(node)
2578
+ return true
2579
+ },
2580
+ })
2581
+ field.input.focus()
2582
+ }
2583
+
2584
+ function openCreateNoteDocument(parentId = state.notes.currentFolderId) {
2585
+ const field = formField('文档名称', 'text', '', { maxlength: 255, placeholder: '例如:部署记录' })
2586
+ const form = element('form', { class: 'form-grid' }, field.wrapper)
2587
+ openSheet({
2588
+ title: '新建笔记文档', description: '文档使用 Markdown 编辑,默认不会被 AI 检索或回写。', body: form,
2589
+ primaryLabel: '创建并编辑', onPrimary: async () => {
2590
+ const node = await api('notes/documents', { method: 'POST', body: { name: field.input.value, parentId } })
2591
+ await loadNoteChildren(parentId, true)
2592
+ if (parentId) state.notes.expandedFolders.add(parentId)
2593
+ await selectNoteNode(node)
2594
+ return true
2595
+ },
2596
+ })
2597
+ field.input.focus()
2598
+ }
2599
+
2600
+ function openRenameNoteNode(node) {
2601
+ const field = formField('名称', 'text', node.name, { maxlength: 255 })
2602
+ openSheet({
2603
+ title: '重命名', description: '知识文档中的 @ 引用使用稳定编号,不会因改名失效。', body: element('form', { class: 'form-grid' }, field.wrapper),
2604
+ primaryLabel: '保存名称', onPrimary: async () => {
2605
+ const updated = await api(`notes/${encodeURIComponent(node.id)}`, { method: 'PATCH', body: { name: field.input.value } })
2606
+ await loadNoteChildren(node.parentId, true)
2607
+ if (state.notes.selectedId === node.id) await selectNoteNode(updated)
2608
+ else renderShell()
2609
+ showToast('名称已更新。')
2610
+ return true
2611
+ },
2612
+ })
2613
+ field.input.select()
2614
+ }
2615
+
2616
+ async function copyNoteNode(node) {
2617
+ try {
2618
+ const copy = await api(`notes/${encodeURIComponent(node.id)}/copy`, { method: 'POST', body: {} })
2619
+ await loadNoteChildren(node.parentId, true)
2620
+ renderShell()
2621
+ showToast(`已创建“${copy.name}”。`)
2622
+ } catch (error) { showToast(friendlyError(error), 'error') }
2623
+ }
2624
+
2625
+ async function dropNoteNode(event, parentId) {
2626
+ event.preventDefault()
2627
+ event.stopPropagation()
2628
+ clearNoteDragState()
2629
+ const id = event.dataTransfer.getData('application/x-dsh-note-id')
2630
+ if (!id) {
2631
+ if (hasDragType(event, 'Files')) await importDroppedNotes(event.dataTransfer, parentId)
2632
+ return
2633
+ }
2634
+ const node = findCachedNote(id)
2635
+ if (!node || id === parentId || node.parentId === parentId) return
2636
+ try {
2637
+ await api(`notes/${encodeURIComponent(id)}`, { method: 'PATCH', body: { parentId } })
2638
+ await Promise.all([loadNoteChildren(node.parentId, true), loadNoteChildren(parentId, true)])
2639
+ if (state.notes.selectedId === id) state.notes.selectedNode = await api(`notes/${encodeURIComponent(id)}`)
2640
+ renderShell()
2641
+ showToast('已移动到目标目录。')
2642
+ } catch (error) { showToast(friendlyError(error), 'error') }
2643
+ }
2644
+
2645
+ async function uploadNoteFiles(files, parentId) {
2646
+ const summary = createNoteImportSummary()
2647
+ const plans = files.flatMap(file => createNoteFilePlan(file, summary))
2648
+ if (!plans.length) return showToast('没有可上传的文件,单文件上限为 64 MiB。', 'error')
2649
+ const transfer = beginNoteTransfer('uploading')
2650
+ if (!transfer) return
2651
+ prepareNoteTransfer(transfer, summary)
2652
+ try {
2653
+ await importNotePlans(plans, parentId, transfer)
2654
+ await loadNoteChildren(parentId, true)
2655
+ renderShell()
2656
+ completeNoteTransfer(transfer)
2657
+ showNoteImportResult(summary)
2658
+ } catch (error) {
2659
+ if (transfer.completedItems) await loadNoteChildren(parentId, true).catch(() => {})
2660
+ renderShell()
2661
+ failNoteTransfer(transfer, error)
2662
+ showToast(friendlyError(error), 'error')
2663
+ }
2664
+ }
2665
+
2666
+ async function importDroppedNotes(dataTransfer, parentId) {
2667
+ const payload = captureNoteDropPayload(dataTransfer)
2668
+ if (!payload.entries.length) return uploadNoteFiles(payload.files, parentId)
2669
+ const transfer = beginNoteTransfer('scanning')
2670
+ if (!transfer) return
2671
+ const summary = createNoteImportSummary()
2672
+ try {
2673
+ const plans = []
2674
+ for (const entry of payload.entries) {
2675
+ const plan = await collectNoteEntry(entry, summary, transfer)
2676
+ if (plan) plans.push(plan)
2677
+ }
2678
+ if (!plans.length) {
2679
+ dismissNoteTransfer()
2680
+ showToast('没有可上传的内容,单文件上限为 64 MiB。', 'error')
2681
+ return
2682
+ }
2683
+ prepareNoteTransfer(transfer, summary)
2684
+ await importNotePlans(plans, parentId, transfer)
2685
+ await loadNoteChildren(parentId, true)
2686
+ if (parentId) state.notes.expandedFolders.add(parentId)
2687
+ renderShell()
2688
+ completeNoteTransfer(transfer)
2689
+ showNoteImportResult(summary)
2690
+ } catch (error) {
2691
+ await loadNoteChildren(parentId, true).catch(() => {})
2692
+ renderShell()
2693
+ failNoteTransfer(transfer, error)
2694
+ showToast(friendlyError(error), 'error')
2695
+ }
2696
+ }
2697
+
2698
+ function createNoteImportSummary() {
2699
+ return { files: 0, folders: 0, skipped: 0, bytes: 0 }
2700
+ }
2701
+
2702
+ function createNoteFilePlan(file, summary) {
2703
+ if (file.size > NOTE_MAX_FILE_SIZE) {
2704
+ summary.skipped += 1
2705
+ return []
2706
+ }
2707
+ summary.files += 1
2708
+ summary.bytes += file.size
2709
+ return [{ kind: 'file', name: file.name, file }]
2710
+ }
2711
+
2712
+ function captureNoteDropPayload(dataTransfer) {
2713
+ const files = Array.from(dataTransfer?.files || [])
2714
+ const entries = Array.from(dataTransfer?.items || []).flatMap(item => {
2715
+ if (item.kind !== 'file' || typeof item.webkitGetAsEntry !== 'function') return []
2716
+ try {
2717
+ const entry = item.webkitGetAsEntry()
2718
+ return entry ? [entry] : []
2719
+ } catch {
2720
+ return []
2721
+ }
2722
+ })
2723
+ return { entries, files }
2724
+ }
2725
+
2726
+ async function collectNoteEntry(entry, summary, transfer) {
2727
+ transfer.currentName = entry.name || ''
2728
+ transfer.scannedItems += 1
2729
+ scheduleNoteTransferSync()
2730
+ if (entry.isDirectory) {
2731
+ summary.folders += 1
2732
+ const children = []
2733
+ for (const child of await readNoteDirectoryEntries(entry)) {
2734
+ const plan = await collectNoteEntry(child, summary, transfer)
2735
+ if (plan) children.push(plan)
2736
+ }
2737
+ return { kind: 'folder', name: entry.name, children }
2738
+ }
2739
+ if (!entry.isFile) return null
2740
+ const file = await readNoteFileEntry(entry)
2741
+ return createNoteFilePlan(file, summary)[0] || null
2742
+ }
2743
+
2744
+ async function importNotePlans(plans, parentId, transfer) {
2745
+ for (const plan of plans) {
2746
+ transfer.currentName = plan.name
2747
+ scheduleNoteTransferSync()
2748
+ if (plan.kind === 'folder') {
2749
+ const folder = await api('notes/folders', { method: 'POST', body: { name: plan.name, parentId } })
2750
+ transfer.completedItems += 1
2751
+ scheduleNoteTransferSync()
2752
+ await importNotePlans(plan.children, folder.id, transfer)
2753
+ continue
2754
+ }
2755
+ const settledBytes = transfer.loadedBytes
2756
+ await uploadNoteFile(plan.file, parentId, loaded => {
2757
+ transfer.loadedBytes = settledBytes + Math.min(loaded, plan.file.size)
2758
+ scheduleNoteTransferSync()
2759
+ })
2760
+ transfer.loadedBytes = settledBytes + plan.file.size
2761
+ transfer.completedItems += 1
2762
+ scheduleNoteTransferSync()
2763
+ }
2764
+ }
2765
+
2766
+ async function readNoteDirectoryEntries(entry) {
2767
+ const reader = entry.createReader()
2768
+ const entries = []
2769
+ while (true) {
2770
+ const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject))
2771
+ if (!batch.length) return entries
2772
+ entries.push(...batch)
2773
+ }
2774
+ }
2775
+
2776
+ function readNoteFileEntry(entry) {
2777
+ return new Promise((resolve, reject) => entry.file(resolve, reject))
2778
+ }
2779
+
2780
+ async function uploadNoteFile(file, parentId, onProgress) {
2781
+ const params = new URLSearchParams({ name: file.name })
2782
+ if (parentId) params.set('parentId', parentId)
2783
+ return binaryUploadRequest(`notes/files?${params}`, file, {
2784
+ method: 'POST', contentType: file.type || 'application/octet-stream', onProgress,
2785
+ })
2786
+ }
2787
+
2788
+ function beginNoteTransfer(phase) {
2789
+ if (state.notes.transfer && ['scanning', 'uploading'].includes(state.notes.transfer.phase)) {
2790
+ showToast('已有导入任务正在进行,请稍候。', 'error')
2791
+ return null
2792
+ }
2793
+ const transfer = {
2794
+ id: String(++noteTransferSequence), phase, currentName: '', error: '',
2795
+ scannedItems: 0, completedItems: 0, totalItems: 0,
2796
+ loadedBytes: 0, totalBytes: 0, files: 0, folders: 0, skipped: 0,
2797
+ }
2798
+ state.notes.transfer = transfer
2799
+ refreshNoteTransferPanel()
2800
+ return transfer
2801
+ }
2802
+
2803
+ function prepareNoteTransfer(transfer, summary) {
2804
+ transfer.phase = 'uploading'
2805
+ transfer.currentName = ''
2806
+ transfer.totalItems = summary.files + summary.folders
2807
+ transfer.totalBytes = summary.bytes
2808
+ transfer.files = summary.files
2809
+ transfer.folders = summary.folders
2810
+ transfer.skipped = summary.skipped
2811
+ scheduleNoteTransferSync()
2812
+ }
2813
+
2814
+ function completeNoteTransfer(transfer) {
2815
+ if (state.notes.transfer !== transfer) return
2816
+ transfer.phase = 'complete'
2817
+ transfer.currentName = ''
2818
+ transfer.completedItems = transfer.totalItems
2819
+ transfer.loadedBytes = transfer.totalBytes
2820
+ refreshNoteTransferPanel()
2821
+ window.setTimeout(() => {
2822
+ if (state.notes.transfer === transfer && transfer.phase === 'complete') dismissNoteTransfer()
2823
+ }, 2600)
2824
+ }
2825
+
2826
+ function failNoteTransfer(transfer, error) {
2827
+ if (state.notes.transfer !== transfer) return
2828
+ transfer.phase = 'error'
2829
+ transfer.error = friendlyError(error)
2830
+ refreshNoteTransferPanel()
2831
+ }
2832
+
2833
+ function dismissNoteTransfer() {
2834
+ state.notes.transfer = null
2835
+ document.querySelector('.notes-transfer')?.remove()
2836
+ }
2837
+
2838
+ function refreshNoteTransferPanel() {
2839
+ const current = document.querySelector('.notes-transfer')
2840
+ const panel = renderNoteTransfer()
2841
+ if (!panel) {
2842
+ current?.remove()
2843
+ return
2844
+ }
2845
+ if (current) current.replaceWith(panel)
2846
+ else document.querySelector('.notes-workspace')?.append(panel)
2847
+ syncNoteTransferPanel()
2848
+ }
2849
+
2850
+ function scheduleNoteTransferSync() {
2851
+ if (noteTransferFrame) return
2852
+ noteTransferFrame = requestAnimationFrame(() => {
2853
+ noteTransferFrame = 0
2854
+ syncNoteTransferPanel()
2855
+ })
2856
+ }
2857
+
2858
+ function syncNoteTransferPanel() {
2859
+ const transfer = state.notes.transfer
2860
+ const panel = document.querySelector('.notes-transfer')
2861
+ if (!transfer || !panel || panel.dataset.noteTransfer !== transfer.id) return
2862
+ const percent = noteTransferPercent(transfer)
2863
+ panel.dataset.state = transfer.phase
2864
+ panel.style.setProperty('--notes-transfer-progress', String(percent / 100))
2865
+ const title = panel.querySelector('.notes-transfer-title')
2866
+ const detail = panel.querySelector('.notes-transfer-detail')
2867
+ const count = panel.querySelector('.notes-transfer-count')
2868
+ const percentLabel = panel.querySelector('.notes-transfer-percent')
2869
+ const progress = panel.querySelector('.notes-transfer-track')
2870
+ if (title) title.textContent = noteTransferTitle(transfer)
2871
+ if (detail) {
2872
+ detail.textContent = noteTransferDetail(transfer)
2873
+ detail.title = transfer.currentName || ''
2874
+ }
2875
+ if (count) count.textContent = noteTransferCount(transfer)
2876
+ if (percentLabel) percentLabel.textContent = transfer.phase === 'scanning' ? '正在整理' : `${percent}%`
2877
+ if (progress) {
2878
+ if (transfer.phase === 'scanning') progress.removeAttribute('aria-valuenow')
2879
+ else progress.setAttribute('aria-valuenow', String(percent))
2880
+ }
2881
+ }
2882
+
2883
+ function noteTransferPercent(transfer) {
2884
+ if (transfer.phase === 'complete') return 100
2885
+ const value = transfer.totalBytes > 0
2886
+ ? transfer.loadedBytes / transfer.totalBytes
2887
+ : transfer.totalItems > 0 ? transfer.completedItems / transfer.totalItems : 0
2888
+ return Math.min(transfer.phase === 'uploading' ? 99 : 100, Math.max(0, Math.round(value * 100)))
2889
+ }
2890
+
2891
+ function noteTransferTitle(transfer) {
2892
+ if (transfer.phase === 'scanning') return '正在整理导入内容'
2893
+ if (transfer.phase === 'complete') return '导入完成'
2894
+ if (transfer.phase === 'error') return '导入未完成'
2895
+ return '正在导入笔记'
2896
+ }
2897
+
2898
+ function noteTransferDetail(transfer) {
2899
+ if (transfer.phase === 'error') return transfer.error || '导入过程中发生错误'
2900
+ if (transfer.phase === 'complete') return `${transfer.files} 个文件、${transfer.folders} 个目录已写入`
2901
+ if (transfer.phase === 'scanning') return transfer.currentName || '正在读取文件和目录…'
2902
+ return transfer.currentName || '准备写入…'
2903
+ }
2904
+
2905
+ function noteTransferCount(transfer) {
2906
+ if (transfer.phase === 'scanning') return `已读取 ${transfer.scannedItems} 项`
2907
+ const count = `${transfer.completedItems} / ${transfer.totalItems} 项`
2908
+ const bytes = transfer.totalBytes ? ` · ${formatBytes(transfer.loadedBytes)} / ${formatBytes(transfer.totalBytes)}` : ''
2909
+ const skipped = transfer.skipped ? ` · 跳过 ${transfer.skipped}` : ''
2910
+ return `${count}${bytes}${skipped}`
2911
+ }
2912
+
2913
+ function showNoteImportResult(summary) {
2914
+ const added = [summary.folders ? `${summary.folders} 个目录` : '', summary.files ? `${summary.files} 个文件` : ''].filter(Boolean).join('、')
2915
+ const skipped = summary.skipped ? `,跳过 ${summary.skipped} 个超过 64 MiB 的文件` : ''
2916
+ showToast(`已添加${added ? ` ${added}` : ''}${skipped}。`)
2917
+ }
2918
+
2919
+ async function saveNoteDocument() {
2920
+ const node = state.notes.selectedNode
2921
+ if (!node || !node.editable || !state.notes.dirty) return true
2922
+ try {
2923
+ const updated = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, {
2924
+ method: 'PUT', body: new Blob([state.notes.draft], { type: node.mediaType || 'text/plain' }), contentType: node.mediaType || 'text/plain',
2925
+ })
2926
+ state.notes.selectedNode = updated
2927
+ state.notes.content = state.notes.draft
2928
+ state.notes.dirty = false
2929
+ await loadNoteChildren(node.parentId, true)
2930
+ const size = document.querySelector('[data-note-info="size"] > span')
2931
+ const updatedAt = document.querySelector('[data-note-info="updated"] > span')
2932
+ if (size) size.textContent = formatBytes(updated.size)
2933
+ if (updatedAt) updatedAt.textContent = formatDate(updated.updatedAt)
2934
+ syncNoteEditorChrome()
2935
+ showToast('文件已保存。')
2936
+ return true
2937
+ } catch (error) {
2938
+ showToast(friendlyError(error), 'error')
2939
+ return false
2940
+ }
2941
+ }
2942
+
2943
+ async function downloadNoteFile(node) {
2944
+ try {
2945
+ const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content?download=1`, { responseType: 'blob', accept: node.mediaType || 'application/octet-stream' })
2946
+ const url = URL.createObjectURL(blob)
2947
+ const anchor = element('a', { href: url, download: node.name })
2948
+ document.body.append(anchor); anchor.click(); anchor.remove()
2949
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000)
2950
+ } catch (error) { showToast(friendlyError(error), 'error') }
2951
+ }
2952
+
2953
+ async function copyNoteReference(node) {
2954
+ try { await navigator.clipboard.writeText(noteReference(node)); showToast('笔记引用已复制。') }
2955
+ catch { showToast('复制失败,请手动复制文档编号。', 'error') }
2956
+ }
2957
+
2958
+ async function confirmDeleteNoteNode(node) {
2959
+ try {
2960
+ const references = await api(`notes/${encodeURIComponent(node.id)}/references`)
2961
+ if (references.length) {
2962
+ const names = references.slice(0, 4).map(item => `“${item.documentTitle}”`).join('、')
2963
+ return openModal({
2964
+ title: '仍被知识文档引用', description: node.name,
2965
+ body: element('p', {}, `${names}${references.length > 4 ? `等 ${references.length} 篇文档` : ''}仍在引用这里的内容。请先移除引用,再删除。`),
2966
+ cancelLabel: '知道了',
2967
+ })
2968
+ }
2969
+ openConfirm({
2970
+ title: `删除“${node.name}”?`,
2971
+ message: node.kind === 'folder' ? '目录及其全部子目录和文件会被永久删除。' : '该文档会被永久删除,此操作无法撤销。',
2972
+ confirmLabel: '永久删除', danger: true,
2973
+ onConfirm: async () => {
2974
+ await api(`notes/${encodeURIComponent(node.id)}`, { method: 'DELETE' })
2975
+ await loadNoteChildren(node.parentId, true)
2976
+ if (state.notes.selectedId === node.id || state.notes.breadcrumbs.some(parent => parent.id === node.id)) clearNoteSelection()
2977
+ renderShell(); showToast('已删除。')
2978
+ },
2979
+ })
2980
+ } catch (error) { showToast(friendlyError(error), 'error') }
2981
+ }
2982
+
2983
+ function noteWorkspaceDragEnter(event) {
2984
+ if (!hasDragType(event, 'Files')) return
2985
+ event.preventDefault()
2986
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
2987
+ event.currentTarget.dataset.dropActive = 'true'
2988
+ }
2989
+
2990
+ function noteWorkspaceDragLeave(event) {
2991
+ if (event.currentTarget.contains(event.relatedTarget)) return
2992
+ clearNoteDragState()
2993
+ }
2994
+
2995
+ function activateNoteFolderDropTarget(event, node) {
2996
+ if (node.kind !== 'folder') return
2997
+ const hasFiles = hasDragType(event, 'Files')
2998
+ if (!hasFiles && !hasDragType(event, 'application/x-dsh-note-id')) return
2999
+ event.preventDefault()
3000
+ event.stopPropagation()
3001
+ if (event.dataTransfer) event.dataTransfer.dropEffect = hasFiles ? 'copy' : 'move'
3002
+ const workspace = document.querySelector('.notes-workspace')
3003
+ if (workspace) workspace.dataset.dropActive = 'false'
3004
+ document.querySelectorAll('.notes-tree-item[data-drop-target="true"]').forEach(target => {
3005
+ if (target !== event.currentTarget) target.dataset.dropTarget = 'false'
3006
+ })
3007
+ event.currentTarget.dataset.dropTarget = 'true'
3008
+ }
3009
+
3010
+ function hasDragType(event, type) {
3011
+ return Array.from(event.dataTransfer?.types || []).includes(type)
3012
+ }
3013
+
3014
+ function clearNoteDragState() {
3015
+ const workspace = document.querySelector('.notes-workspace')
3016
+ if (workspace) workspace.dataset.dropActive = 'false'
3017
+ document.querySelectorAll('.notes-tree-item[data-drop-target="true"]').forEach(node => { node.dataset.dropTarget = 'false' })
3018
+ document.querySelectorAll('.notes-tree-item[data-dragging="true"]').forEach(node => { node.dataset.dragging = 'false' })
3019
+ }
3020
+
3021
+ function installNoteDragRecovery() {
3022
+ window.addEventListener('drop', clearNoteDragState, true)
3023
+ window.addEventListener('dragend', clearNoteDragState, true)
3024
+ window.addEventListener('blur', clearNoteDragState)
3025
+ document.addEventListener('dragleave', event => {
3026
+ if (event.relatedTarget === null) clearNoteDragState()
3027
+ }, true)
3028
+ }
3029
+
3030
+ function noteReference(node) {
3031
+ const label = node.name.replaceAll('\\', '\\\\').replaceAll('[', '\\[').replaceAll(']', '\\]')
3032
+ return `@[${label}](note://${node.id})`
3033
+ }
3034
+
3035
+ function noteMediaKind(node) {
3036
+ if (node.kind === 'folder') return 'folder'
3037
+ if (node.kind === 'document' || node.mediaType?.startsWith('text/')) return 'text'
3038
+ if (node.mediaType?.startsWith('image/')) return 'image'
3039
+ if (node.mediaType === 'application/pdf') return 'pdf'
3040
+ return 'file'
3041
+ }
3042
+
3043
+ function noteExtension(name) {
3044
+ const extension = name.includes('.') ? name.split('.').pop() : 'FILE'
3045
+ return extension.slice(0, 4).toLocaleUpperCase()
3046
+ }
3047
+
3048
+ function shortNoteId(id) {
3049
+ return `${id.slice(0, 11)}…${id.slice(-5)}`
3050
+ }
3051
+
3052
+ function formatBytes(value) {
3053
+ if (value < 1024) return `${value} B`
3054
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KiB`
3055
+ return `${(value / 1024 / 1024).toFixed(value < 10 * 1024 * 1024 ? 1 : 0)} MiB`
3056
+ }
3057
+
1487
3058
  function renderCandidates() {
1488
3059
  const statuses = [['pending', '待审核'], ['approved', '已通过'], ['rejected', '已拒绝']]
1489
3060
  return element('section', { 'aria-labelledby': 'candidates-heading' },
@@ -2184,7 +3755,7 @@ function openTokenCreator() {
2184
3755
  return { permission, input, node: element('label', { class: 'check-option' }, input, permission) }
2185
3756
  })
2186
3757
  const body = element('form', { class: 'form-grid' }, name.wrapper,
2187
- 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。')))
3758
+ 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 是当前中央服务的全局写权限,同时允许直接写入、管理知识库、挂载和笔记;仅在确实需要时授予。')))
2188
3759
  name.wrapper.classList.add('span-2')
2189
3760
  openModal({ title: '创建客户端令牌', description: '原始令牌只会在创建成功后显示一次。', body, primaryLabel: '创建令牌', onPrimary: async () => {
2190
3761
  if (!body.reportValidity()) return false
@@ -2233,21 +3804,25 @@ function openSheet(options) {
2233
3804
  return openModal({ ...options, presentation: 'sheet' })
2234
3805
  }
2235
3806
 
2236
- function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消', presentation = 'modal' }) {
3807
+ function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消', presentation = 'modal', onClose }) {
2237
3808
  const previouslyFocused = document.activeElement
2238
3809
  const isSheet = presentation === 'sheet'
2239
3810
  const backdrop = element('div', { class: `dialog-backdrop${isSheet ? ' sheet-backdrop' : ''}` })
2240
3811
  const dialog = element('section', { class: `dialog${isSheet ? ' sheet' : ''} ${primaryLabel ? '' : 'narrow'}`.trim(), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dialog-title' })
2241
3812
  let busy = false
2242
3813
  let formDirty = false
3814
+ let closed = false
2243
3815
  const close = (explicit = false) => {
2244
- if (busy) return
3816
+ if (busy || closed) return
2245
3817
  if (!explicit && formDirty) {
2246
3818
  showToast('表单有未保存的修改,请先保存,或使用“取消”放弃修改。', 'error')
2247
3819
  return
2248
3820
  }
2249
3821
  document.removeEventListener('keydown', onKeyDown)
3822
+ closed = true
2250
3823
  backdrop.remove()
3824
+ refreshWorkspaceEffects()
3825
+ onClose?.()
2251
3826
  if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus()
2252
3827
  }
2253
3828
  const closeButton = actionButton('×', () => close(), 'ghost', { 'aria-label': '关闭对话框' })
@@ -2288,6 +3863,7 @@ function openModal({ title, description = '', body, primaryLabel, primaryVariant
2288
3863
  body.addEventListener('change', () => { formDirty = true })
2289
3864
  }
2290
3865
  document.body.append(backdrop)
3866
+ refreshWorkspaceEffects(backdrop)
2291
3867
  document.addEventListener('keydown', onKeyDown)
2292
3868
  window.setTimeout(() => (dialog.querySelector('input, textarea, select, button') || dialog).focus(), 0)
2293
3869
  return { close, dialog }
@@ -2319,9 +3895,10 @@ function friendlyError(error) {
2319
3895
 
2320
3896
  window.addEventListener('beforeunload', event => {
2321
3897
  const editor = activeDocumentWorkspace()?.view.editor
2322
- if (!editor?.dirty) return
3898
+ if (!editor?.dirty && !state.notes.dirty) return
2323
3899
  event.preventDefault()
2324
3900
  event.returnValue = ''
2325
3901
  })
2326
3902
 
3903
+ installNoteDragRecovery()
2327
3904
  void installHostThemeBridge().then(() => boot())