@lemoncat7/dsh-knowledge 1.2.0 → 2.0.0

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