@lemoncat7/dsh-knowledge 1.1.0 → 1.1.2

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.
package/web/app.js CHANGED
@@ -29,6 +29,13 @@ const app = document.querySelector('#app')
29
29
  const toastRegion = document.querySelector('#toast-region')
30
30
  const savedDocumentLayout = readDocumentLayout()
31
31
 
32
+ function createDocumentViewState(overrides = {}) {
33
+ return {
34
+ knowledgeBaseId: '', documentId: '', query: '', mode: 'preview', treeOpen: false,
35
+ expandedBases: new Set(), editor: null, ...overrides,
36
+ }
37
+ }
38
+
32
39
  const state = {
33
40
  token: sessionStorage.getItem(TOKEN_KEY) || '',
34
41
  view: 'entries',
@@ -51,14 +58,15 @@ const state = {
51
58
  nextCursor: null,
52
59
  entryFilters: { query: '', type: '', status: 'active', projectId: '', knowledgeBaseId: '' },
53
60
  documents: [],
54
- documentView: {
55
- knowledgeBaseId: '', documentId: '', query: '',
56
- mode: 'preview',
57
- treeOpen: false,
61
+ documentView: createDocumentViewState({
58
62
  sidebarHidden: savedDocumentLayout.sidebarHidden,
59
63
  sidebarWidth: savedDocumentLayout.sidebarWidth,
60
- expandedBases: new Set(),
61
- editor: null,
64
+ }),
65
+ libraryDetail: {
66
+ knowledgeBaseId: '',
67
+ documents: [],
68
+ error: '',
69
+ view: createDocumentViewState(),
62
70
  },
63
71
  candidates: [],
64
72
  candidateTargets: new Map(),
@@ -76,34 +84,45 @@ const state = {
76
84
  let scrollRestoreFrame = 0
77
85
 
78
86
  function installHostThemeBridge() {
79
- if (window.parent === window) return
87
+ if (window.parent === window) return Promise.resolve()
80
88
  const parentOrigin = referrerOrigin()
81
- window.addEventListener('message', event => {
82
- if (event.source !== window.parent) return
83
- if (parentOrigin && event.origin !== parentOrigin) return
84
- const message = event.data
85
- if (!message || message.type !== HOST_THEME_MESSAGE || message.version !== HOST_THEME_PROTOCOL_VERSION) return
86
- if (message.colorScheme !== 'light' && message.colorScheme !== 'dark') return
87
- if (!message.tokens || typeof message.tokens !== 'object' || Array.isArray(message.tokens)) return
88
-
89
- const root = document.documentElement
90
- for (const name of [...HOST_THEME_COLOR_TOKENS, ...HOST_THEME_STYLE_TOKENS]) root.style.removeProperty(name)
91
- for (const [name, value] of Object.entries(message.tokens)) {
92
- if (typeof value !== 'string' || value.length === 0 || value.length > 512) continue
93
- if (HOST_THEME_COLOR_TOKENS.has(name) && CSS.supports('color', value)) root.style.setProperty(name, value)
94
- else if (HOST_THEME_STYLE_TOKENS.has(name) && CSS.supports('box-shadow', value)) root.style.setProperty(name, value)
89
+ return new Promise(resolve => {
90
+ let initialThemeSettled = false
91
+ const settleInitialTheme = () => {
92
+ if (initialThemeSettled) return
93
+ initialThemeSettled = true
94
+ window.clearTimeout(fallback)
95
+ resolve()
95
96
  }
96
- root.dataset.dshHostTheme = 'true'
97
- root.style.colorScheme = message.colorScheme
98
- root.style.setProperty('--dialog-surface', message.colorScheme === 'dark' ? '#1c1c1e' : '#ffffff')
99
- document.querySelector('meta[name="color-scheme"]')?.setAttribute('content', message.colorScheme)
100
- const background = root.style.getPropertyValue('--bg')
101
- if (background) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', background)
97
+ const fallback = window.setTimeout(settleInitialTheme, 160)
98
+ window.addEventListener('message', event => {
99
+ if (event.source !== window.parent) return
100
+ if (parentOrigin && event.origin !== parentOrigin) return
101
+ const message = event.data
102
+ if (!message || message.type !== HOST_THEME_MESSAGE || message.version !== HOST_THEME_PROTOCOL_VERSION) return
103
+ if (message.colorScheme !== 'light' && message.colorScheme !== 'dark') return
104
+ if (!message.tokens || typeof message.tokens !== 'object' || Array.isArray(message.tokens)) return
105
+
106
+ const root = document.documentElement
107
+ for (const name of [...HOST_THEME_COLOR_TOKENS, ...HOST_THEME_STYLE_TOKENS]) root.style.removeProperty(name)
108
+ for (const [name, value] of Object.entries(message.tokens)) {
109
+ if (typeof value !== 'string' || value.length === 0 || value.length > 512) continue
110
+ if (HOST_THEME_COLOR_TOKENS.has(name) && CSS.supports('color', value)) root.style.setProperty(name, value)
111
+ else if (HOST_THEME_STYLE_TOKENS.has(name) && CSS.supports('box-shadow', value)) root.style.setProperty(name, value)
112
+ }
113
+ root.dataset.dshHostTheme = 'true'
114
+ root.style.colorScheme = message.colorScheme
115
+ root.style.setProperty('--dialog-surface', root.style.getPropertyValue('--surface-raised') || root.style.getPropertyValue('--surface'))
116
+ document.querySelector('meta[name="color-scheme"]')?.setAttribute('content', message.colorScheme)
117
+ const background = root.style.getPropertyValue('--bg')
118
+ if (background) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', background)
119
+ settleInitialTheme()
120
+ })
121
+ window.parent.postMessage({
122
+ type: HOST_THEME_READY_MESSAGE,
123
+ version: HOST_THEME_PROTOCOL_VERSION,
124
+ }, parentOrigin || '*')
102
125
  })
103
- window.parent.postMessage({
104
- type: HOST_THEME_READY_MESSAGE,
105
- version: HOST_THEME_PROTOCOL_VERSION,
106
- }, parentOrigin || '*')
107
126
  }
108
127
 
109
128
  function referrerOrigin() {
@@ -152,12 +171,16 @@ function captureScrollPosition() {
152
171
  const key = node.getAttribute('data-scroll-key')
153
172
  if (key) regions[key] = { left: node.scrollLeft, top: node.scrollTop }
154
173
  })
155
- state.scrollPositions.set(shell.dataset.view, {
174
+ state.scrollPositions.set(shell.dataset.scrollState || shell.dataset.view, {
156
175
  window: { left: window.scrollX, top: window.scrollY },
157
176
  regions,
158
177
  })
159
178
  }
160
179
 
180
+ function currentScrollState() {
181
+ return state.view === 'bases' ? `bases:${state.knowledgeBaseView}` : state.view
182
+ }
183
+
161
184
  function restoreScrollPosition(view) {
162
185
  if (state.loading) return
163
186
  const saved = state.scrollPositions.get(view)
@@ -319,6 +342,10 @@ function signOut() {
319
342
  }
320
343
 
321
344
  async function navigate(view) {
345
+ const previousView = state.view
346
+ if (view === 'bases' && previousView !== 'bases' && state.knowledgeBaseView === 'detail') {
347
+ state.knowledgeBaseView = 'libraries'
348
+ }
322
349
  state.view = view
323
350
  state.menuOpen = false
324
351
  state.loading = true
@@ -412,15 +439,16 @@ async function loadDocuments() {
412
439
  state.resolvedMounts = resolved
413
440
  const visibleBaseIds = new Set(documentKnowledgeBases(bases).map(base => base.id))
414
441
  state.documents = documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId))
415
- const view = state.documentView
442
+ const workspace = sessionDocumentWorkspace()
443
+ const view = workspace.view
416
444
  const availableBaseIds = visibleBaseIds
417
445
  if (!view.knowledgeBaseId || !availableBaseIds.has(view.knowledgeBaseId)) {
418
446
  view.knowledgeBaseId = state.entryFilters.knowledgeBaseId && availableBaseIds.has(state.entryFilters.knowledgeBaseId)
419
447
  ? state.entryFilters.knowledgeBaseId
420
448
  : documentKnowledgeBases(bases)[0]?.id || ''
421
449
  }
422
- selectDefaultDocument()
423
- if (view.documentId) await loadDocumentEditor(view.documentId)
450
+ selectDefaultDocument(workspace)
451
+ if (view.documentId) await loadDocumentEditor(workspace, view.documentId)
424
452
  else view.editor = null
425
453
  if (!state.stats) await refreshStats()
426
454
  }
@@ -432,19 +460,65 @@ function documentKnowledgeBases(bases = state.knowledgeBases) {
432
460
  return active.filter(base => mountedIds.has(base.id))
433
461
  }
434
462
 
435
- function selectDefaultDocument() {
436
- const view = state.documentView
437
- const documents = state.documents.filter(document => document.knowledgeBaseId === view.knowledgeBaseId)
463
+ function sessionDocumentWorkspace() {
464
+ return { kind: 'session', view: state.documentView }
465
+ }
466
+
467
+ function libraryDocumentWorkspace() {
468
+ return { kind: 'library', view: state.libraryDetail.view }
469
+ }
470
+
471
+ function activeDocumentWorkspace() {
472
+ if (state.view === 'entries') return sessionDocumentWorkspace()
473
+ if (state.view === 'bases' && state.knowledgeBaseView === 'detail') return libraryDocumentWorkspace()
474
+ return null
475
+ }
476
+
477
+ function documentWorkspaceDocuments(workspace) {
478
+ return workspace.kind === 'library' ? state.libraryDetail.documents : state.documents
479
+ }
480
+
481
+ function setDocumentWorkspaceDocuments(workspace, documents) {
482
+ if (workspace.kind === 'library') state.libraryDetail.documents = documents
483
+ else state.documents = documents
484
+ }
485
+
486
+ function documentWorkspaceBases(workspace) {
487
+ if (workspace.kind === 'library') {
488
+ const base = state.knowledgeBases.find(item => item.id === state.libraryDetail.knowledgeBaseId)
489
+ return base ? [base] : []
490
+ }
491
+ return documentKnowledgeBases()
492
+ }
493
+
494
+ function documentWorkspaceReadOnly(workspace) {
495
+ return workspace.kind === 'library' && documentWorkspaceBases(workspace)[0]?.status === 'archived'
496
+ }
497
+
498
+ async function reloadDocumentWorkspace(workspace) {
499
+ const documents = workspace.kind === 'library'
500
+ ? await api(`documents?knowledgeBaseId=${encodeURIComponent(state.libraryDetail.knowledgeBaseId)}`)
501
+ : await api('documents')
502
+ if (workspace.kind === 'library') setDocumentWorkspaceDocuments(workspace, documents)
503
+ else {
504
+ const visibleBaseIds = new Set(documentKnowledgeBases().map(base => base.id))
505
+ setDocumentWorkspaceDocuments(workspace, documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId)))
506
+ }
507
+ }
508
+
509
+ function selectDefaultDocument(workspace) {
510
+ const view = workspace.view
511
+ const documents = documentWorkspaceDocuments(workspace).filter(document => document.knowledgeBaseId === view.knowledgeBaseId)
438
512
  if (!documents.some(document => document.id === view.documentId)) {
439
513
  view.documentId = documents[0]?.id || ''
440
514
  }
441
515
  if (view.knowledgeBaseId) view.expandedBases.add(view.knowledgeBaseId)
442
516
  }
443
517
 
444
- async function loadDocumentEditor(id) {
518
+ async function loadDocumentEditor(workspace, id) {
445
519
  const entry = await api(`entries/${encodeURIComponent(id)}`)
446
- state.documentView.mode = 'preview'
447
- state.documentView.editor = {
520
+ workspace.view.mode = 'preview'
521
+ workspace.view.editor = {
448
522
  ...entry,
449
523
  tagsText: entry.tags.join(', '),
450
524
  dirty: false,
@@ -453,10 +527,10 @@ async function loadDocumentEditor(id) {
453
527
  }
454
528
  }
455
529
 
456
- function createBlankDocument(baseId) {
530
+ function createBlankDocument(workspace, baseId) {
457
531
  const base = state.knowledgeBases.find(item => item.id === baseId && item.status === 'active')
458
532
  if (!base) return showToast('请先选择一个可用知识库。', 'error')
459
- const view = state.documentView
533
+ const view = workspace.view
460
534
  view.knowledgeBaseId = base.id
461
535
  view.documentId = ''
462
536
  view.mode = 'edit'
@@ -470,20 +544,20 @@ function createBlankDocument(baseId) {
470
544
  document.querySelector('.note-title-input')?.focus()
471
545
  }
472
546
 
473
- async function startBlankDocument(baseId) {
474
- const editor = state.documentView.editor
547
+ async function startBlankDocument(workspace, baseId) {
548
+ const editor = workspace.view.editor
475
549
  const emptyDraft = editor?.isNew && !editor.title.trim() && !editor.body.trim()
476
- if (editor?.dirty && !emptyDraft && !await saveDocumentEditor()) return
477
- createBlankDocument(baseId)
550
+ if (editor?.dirty && !emptyDraft && !await saveDocumentEditor(workspace)) return
551
+ createBlankDocument(workspace, baseId)
478
552
  }
479
553
 
480
- async function selectDocument(id) {
481
- const editor = state.documentView.editor
554
+ async function selectDocument(workspace, id) {
555
+ const editor = workspace.view.editor
482
556
  const emptyDraft = editor?.isNew && !editor.title.trim() && !editor.body.trim()
483
- if (editor?.dirty && !emptyDraft && !await saveDocumentEditor()) return
484
- state.documentView.documentId = id
485
- state.documentView.treeOpen = false
486
- await loadDocumentEditor(id)
557
+ if (editor?.dirty && !emptyDraft && !await saveDocumentEditor(workspace)) return
558
+ workspace.view.documentId = id
559
+ workspace.view.treeOpen = false
560
+ await loadDocumentEditor(workspace, id)
487
561
  renderShell()
488
562
  }
489
563
 
@@ -500,8 +574,9 @@ function editorDraft(editor) {
500
574
  }
501
575
  }
502
576
 
503
- async function saveDocumentEditor() {
504
- const editor = state.documentView.editor
577
+ async function saveDocumentEditor(workspace = activeDocumentWorkspace()) {
578
+ if (!workspace) return false
579
+ const editor = workspace.view.editor
505
580
  if (!editor || !editor.dirty) return true
506
581
  if (!editor.isNew && editor.documentState !== 'open') {
507
582
  showToast('这篇文档已经结束并封存;请先重新打开。', 'error')
@@ -523,10 +598,9 @@ async function saveDocumentEditor() {
523
598
  editor.dirty = false
524
599
  editor.updatedAt = saved.updatedAt
525
600
  editor.saveState = '已保存'
526
- state.documentView.documentId = saved.id
601
+ workspace.view.documentId = saved.id
527
602
  updateEditorSaveState(editor.saveState)
528
- const documents = await api('documents')
529
- state.documents = documents
603
+ await reloadDocumentWorkspace(workspace)
530
604
  return true
531
605
  } catch (error) {
532
606
  editor.saveState = '保存失败'
@@ -576,6 +650,8 @@ function renderShell() {
576
650
  const shell = element('div', {
577
651
  class: 'app-shell', 'data-menu-open': String(state.menuOpen),
578
652
  'data-view': state.view, 'data-loading': String(state.loading),
653
+ 'data-scroll-state': currentScrollState(),
654
+ 'data-base-detail': String(state.view === 'bases' && state.knowledgeBaseView === 'detail'),
579
655
  'data-sidebar-hidden': String(state.documentView.sidebarHidden),
580
656
  style: `--sidebar-width: ${state.documentView.sidebarWidth}px`,
581
657
  },
@@ -586,8 +662,10 @@ function renderShell() {
586
662
  element('div', { class: 'topbar-title' },
587
663
  actionButton('☰', () => { state.menuOpen = !state.menuOpen; renderShell() }, 'ghost mobile-menu', { 'aria-label': '打开导航菜单' }),
588
664
  paneToggleButton('main', !state.documentView.sidebarHidden, () => setSidebarHidden(!state.documentView.sidebarHidden), '主导航栏'),
589
- state.view === 'entries' ? paneToggleButton('library', state.documentView.treeOpen, () => {
590
- state.documentView.treeOpen = !state.documentView.treeOpen
665
+ activeDocumentWorkspace() ? paneToggleButton('library', activeDocumentWorkspace().view.treeOpen, () => {
666
+ const workspace = activeDocumentWorkspace()
667
+ if (!workspace) return
668
+ workspace.view.treeOpen = !workspace.view.treeOpen
591
669
  renderShell()
592
670
  }, '知识目录') : null,
593
671
  element('div', {}, element('h1', {}, title), element('p', {}, subtitle)),
@@ -600,7 +678,7 @@ function renderShell() {
600
678
  if (event.target === shell) { state.menuOpen = false; renderShell() }
601
679
  })
602
680
  app.replaceChildren(shell)
603
- restoreScrollPosition(state.view)
681
+ restoreScrollPosition(currentScrollState())
604
682
  }
605
683
 
606
684
  function renderAppSidebarResizer() {
@@ -751,6 +829,7 @@ function renderOverview() {
751
829
  function renderKnowledgeBases() {
752
830
  const activeBases = state.knowledgeBases.filter(base => base.status === 'active')
753
831
  const archivedBases = state.knowledgeBases.filter(base => base.status === 'archived')
832
+ if (state.knowledgeBaseView === 'detail') return renderKnowledgeBaseDetail()
754
833
  const contextAvailable = Boolean(state.mountContext.projectId || state.mountContext.sessionId)
755
834
  const query = state.knowledgeBaseQuery.trim().toLocaleLowerCase()
756
835
  const matchesQuery = base => !query || [base.name, base.description, base.defaultTags.join(' ')]
@@ -820,6 +899,82 @@ function renderKnowledgeBases() {
820
899
  )
821
900
  }
822
901
 
902
+ async function openKnowledgeBaseDocuments(base) {
903
+ captureScrollPosition()
904
+ const detail = state.libraryDetail
905
+ const changingBase = detail.knowledgeBaseId !== base.id
906
+ detail.knowledgeBaseId = base.id
907
+ if (changingBase) {
908
+ detail.documents = []
909
+ detail.view = createDocumentViewState({ knowledgeBaseId: base.id })
910
+ } else {
911
+ detail.view.knowledgeBaseId = base.id
912
+ }
913
+ state.knowledgeBaseView = 'detail'
914
+ state.loading = true
915
+ detail.error = ''
916
+ renderShell()
917
+ const workspace = libraryDocumentWorkspace()
918
+ try {
919
+ await reloadDocumentWorkspace(workspace)
920
+ selectDefaultDocument(workspace)
921
+ if (workspace.view.documentId) await loadDocumentEditor(workspace, workspace.view.documentId)
922
+ else workspace.view.editor = null
923
+ } catch (error) {
924
+ detail.error = friendlyError(error)
925
+ } finally {
926
+ state.loading = false
927
+ renderShell()
928
+ window.requestAnimationFrame(() => document.querySelector('.library-detail-back')?.focus())
929
+ }
930
+ }
931
+
932
+ async function closeKnowledgeBaseDetail() {
933
+ const workspace = libraryDocumentWorkspace()
934
+ const editor = workspace.view.editor
935
+ const emptyDraft = editor?.isNew && !editor.title.trim() && !editor.body.trim()
936
+ if (editor?.dirty && !emptyDraft && !await saveDocumentEditor(workspace)) return
937
+ const baseId = state.libraryDetail.knowledgeBaseId
938
+ state.knowledgeBaseView = 'libraries'
939
+ state.libraryDetail.error = ''
940
+ renderShell()
941
+ window.requestAnimationFrame(() => document.querySelector(`[data-open-knowledge-base="${CSS.escape(baseId)}"]`)?.focus())
942
+ }
943
+
944
+ function renderKnowledgeBaseDetail() {
945
+ const base = state.knowledgeBases.find(item => item.id === state.libraryDetail.knowledgeBaseId)
946
+ if (!base) return emptyState('知识库不可用', '它可能已被删除,请返回知识库列表重新选择。', '返回我的知识库', () => { state.knowledgeBaseView = 'libraries'; renderShell() })
947
+ const workspace = libraryDocumentWorkspace()
948
+ const archived = base.status === 'archived'
949
+ const mountStatus = state.mountContext.sessionId
950
+ ? mountView(base, 'session', state.mountContext.sessionId)
951
+ : state.mountContext.projectId ? mountView(base, 'project', state.mountContext.projectId) : null
952
+ return element('section', { class: 'library-detail', 'aria-labelledby': 'library-detail-title' },
953
+ element('header', { class: 'library-detail-header' },
954
+ actionButton('我的知识库', () => { void closeKnowledgeBaseDetail() }, 'ghost small library-detail-back', { 'aria-label': '返回我的知识库' }),
955
+ element('div', { class: 'library-detail-identity' },
956
+ element('span', { class: 'base-symbol', 'aria-hidden': 'true' }, base.name.trim().slice(0, 1).toLocaleUpperCase() || 'K'),
957
+ element('div', {},
958
+ element('div', { class: 'library-detail-title-line' },
959
+ element('h2', { id: 'library-detail-title' }, base.name),
960
+ badge(archived ? '已归档' : '可用', archived ? '' : 'success'),
961
+ mountStatus ? badge(mountStatus.statusLabel, mountStatus.statusVariant) : badge('未选择挂载范围'),
962
+ ),
963
+ element('p', {}, base.description || '通用知识库,尚未设置匹配描述。'),
964
+ ),
965
+ ),
966
+ element('div', { class: 'library-detail-actions' },
967
+ archived ? actionButton('恢复知识库', () => confirmRestoreKnowledgeBase(base), 'small') : actionButton('编辑知识库', () => openKnowledgeBaseEditor(base), 'small'),
968
+ !archived ? actionButton('+ 新建文档', () => { void startBlankDocument(workspace, base.id) }, 'primary small') : null,
969
+ ),
970
+ ),
971
+ archived ? element('div', { class: 'library-detail-notice', role: 'status' }, '这个知识库已经归档。文档仍可阅读,但恢复知识库后才能新建或修改。') : null,
972
+ state.libraryDetail.error
973
+ ? errorView(state.libraryDetail.error, () => { void openKnowledgeBaseDocuments(base) })
974
+ : renderDocumentWorkspace(workspace, { heading: base.name, singleBase: true }),
975
+ )
976
+ }
977
+
823
978
  async function openGlobalWritebackModelEditor() {
824
979
  await loadModelCatalog()
825
980
  const custom = element('input', { type: 'checkbox', checked: Boolean(state.service.writebackProvider && state.service.writebackModel) })
@@ -866,12 +1021,7 @@ function renderKnowledgeBaseCard(base) {
866
1021
  base.extractionInstructions ? element('span', {}, element('strong', {}, '提取规则'), '已设置') : null,
867
1022
  ),
868
1023
  element('div', { class: 'base-card-actions' },
869
- actionButton('查看知识', () => {
870
- state.entryFilters.knowledgeBaseId = base.id
871
- state.documentView.knowledgeBaseId = base.id
872
- state.documentView.documentId = ''
873
- void navigate('entries')
874
- }, 'ghost small'),
1024
+ actionButton('查看知识', () => { void openKnowledgeBaseDocuments(base) }, 'ghost small', { 'data-open-knowledge-base': base.id }),
875
1025
  archived ? actionButton('恢复', () => confirmRestoreKnowledgeBase(base), 'small') : actionButton('编辑', () => openKnowledgeBaseEditor(base), 'small'),
876
1026
  archived ? actionButton('永久删除', () => confirmDeleteKnowledgeBase(base), 'danger small') : null,
877
1027
  !archived && base.id !== 'default' ? actionButton('归档', () => confirmArchiveKnowledgeBase(base), 'danger small') : null,
@@ -1027,23 +1177,30 @@ function compactEmpty(message) {
1027
1177
  }
1028
1178
 
1029
1179
  function renderEntries() {
1030
- const view = state.documentView
1180
+ return renderDocumentWorkspace(sessionDocumentWorkspace(), { heading: '知识目录' })
1181
+ }
1182
+
1183
+ function renderDocumentWorkspace(workspace, options = {}) {
1184
+ const view = workspace.view
1031
1185
  const query = view.query.trim().toLocaleLowerCase()
1032
- const activeBases = documentKnowledgeBases()
1186
+ const activeBases = documentWorkspaceBases(workspace)
1187
+ const workspaceDocuments = documentWorkspaceDocuments(workspace)
1188
+ const readOnly = documentWorkspaceReadOnly(workspace)
1033
1189
  const selectedBase = activeBases.find(base => base.id === view.knowledgeBaseId)
1034
1190
  const search = element('input', {
1035
1191
  class: 'note-tree-search', type: 'search', value: view.query, placeholder: '搜索文档', 'aria-label': '搜索知识库文档',
1192
+ 'data-document-scope': workspace.kind,
1036
1193
  onInput: (event) => {
1037
1194
  view.query = event.target.value
1038
1195
  renderShell()
1039
- const input = document.querySelector('.note-tree-search')
1196
+ const input = document.querySelector(`.note-tree-search[data-document-scope="${workspace.kind}"]`)
1040
1197
  input?.focus()
1041
1198
  input?.setSelectionRange(input.value.length, input.value.length)
1042
1199
  },
1043
1200
  })
1044
1201
  const tree = activeBases.map(base => {
1045
1202
  const expanded = view.expandedBases.has(base.id) || Boolean(query)
1046
- const documents = state.documents.filter(document => document.knowledgeBaseId === base.id
1203
+ const documents = workspaceDocuments.filter(document => document.knowledgeBaseId === base.id
1047
1204
  && (!query || [document.title, document.relPath, document.content].some(value => value.toLocaleLowerCase().includes(query))))
1048
1205
  return element('section', { class: 'note-tree-group', 'data-expanded': String(expanded) },
1049
1206
  element('button', {
@@ -1062,48 +1219,50 @@ function renderEntries() {
1062
1219
  expanded ? element('div', { class: 'note-tree-documents', role: 'group', 'aria-label': `${base.name}文档` },
1063
1220
  documents.map(document => element('button', {
1064
1221
  type: 'button', class: 'note-tree-document', 'aria-current': document.id === view.documentId ? 'page' : undefined,
1065
- onClick: () => { void selectDocument(document.id) },
1222
+ onClick: () => { void selectDocument(workspace, document.id) },
1066
1223
  }, element('span', { class: 'tree-document-icon', 'aria-hidden': 'true' }), element('span', { class: 'tree-document-copy' },
1067
1224
  element('strong', {}, document.title), element('small', {}, document.relPath)),
1068
1225
  document.documentState !== 'open' ? badge(DOCUMENT_STATE_LABELS[document.documentState] || '已结束', 'success') : null)),
1069
- !query ? element('button', { type: 'button', class: 'note-tree-new', onClick: () => { void startBlankDocument(base.id) } },
1226
+ !query && !readOnly ? element('button', { type: 'button', class: 'note-tree-new', onClick: () => { void startBlankDocument(workspace, base.id) } },
1070
1227
  element('span', { 'aria-hidden': 'true' }, '+'), '新建文档') : null,
1071
1228
  ) : null,
1072
1229
  )
1073
1230
  })
1074
1231
  return element('section', {
1075
- class: 'note-workspace', 'aria-labelledby': 'documents-heading',
1232
+ class: `note-workspace note-workspace--${workspace.kind}`, 'aria-labelledby': `${workspace.kind}-documents-heading`,
1076
1233
  'data-tree-open': String(view.treeOpen),
1077
1234
  },
1078
1235
  element('aside', { class: 'note-tree-panel', 'aria-label': '知识目录' },
1079
1236
  element('header', { class: 'note-tree-header' },
1080
- element('div', {}, element('h2', { id: 'documents-heading' }, '知识目录'), element('span', {}, `${state.documents.length} 篇文档`)),
1081
- actionButton('+', () => { void startBlankDocument(view.knowledgeBaseId || activeBases[0]?.id) }, 'ghost note-add-button', { 'aria-label': '新建文档', title: '新建文档' }),
1237
+ element('div', {}, element('h2', { id: `${workspace.kind}-documents-heading` }, options.heading || '知识目录'), element('span', {}, `${workspaceDocuments.length} 篇文档`)),
1238
+ !readOnly ? actionButton('+', () => { void startBlankDocument(workspace, view.knowledgeBaseId || activeBases[0]?.id) }, 'ghost note-add-button', { 'aria-label': '新建文档', title: '新建文档' }) : null,
1082
1239
  ),
1083
1240
  element('div', { class: 'note-tree-search-wrap' }, interfaceIcon('search', 'search-symbol'), search),
1084
- element('nav', { class: 'note-tree', 'data-scroll-key': 'note-tree' }, tree.length ? tree : element('div', { class: 'note-tree-empty' }, state.mountContext.sessionId ? '当前会话未挂载知识库' : '还没有知识库')),
1085
- element('footer', { class: 'note-tree-footer' }, actionButton('新建知识库', () => openKnowledgeBaseEditor(), 'ghost small')),
1241
+ element('nav', { class: 'note-tree', 'data-scroll-key': `${workspace.kind}-note-tree` }, tree.length ? tree : element('div', { class: 'note-tree-empty' }, workspace.kind === 'session' && state.mountContext.sessionId ? '当前会话未挂载知识库' : '还没有知识库')),
1242
+ workspace.kind === 'session' ? element('footer', { class: 'note-tree-footer' }, actionButton('新建知识库', () => openKnowledgeBaseEditor(), 'ghost small')) : null,
1086
1243
  ),
1087
1244
  view.treeOpen ? element('button', {
1088
1245
  type: 'button', class: 'note-tree-scrim', 'aria-label': '关闭知识目录',
1089
1246
  onClick: () => { view.treeOpen = false; renderShell() },
1090
1247
  }) : null,
1091
- renderNoteEditor(view.editor, selectedBase),
1248
+ renderNoteEditor(workspace, view.editor, selectedBase),
1092
1249
  )
1093
1250
  }
1094
1251
 
1095
- function renderNoteEditor(editor, base) {
1252
+ function renderNoteEditor(workspace, editor, base) {
1253
+ const view = workspace.view
1254
+ const readOnly = documentWorkspaceReadOnly(workspace)
1096
1255
  if (!editor) return element('main', { class: 'note-editor note-editor-empty' },
1097
1256
  element('div', { class: 'note-empty-content' },
1098
1257
  element('span', { class: 'empty-document-mark', 'aria-hidden': 'true' }),
1099
1258
  element('h3', {}, '选择或新建一篇文档'),
1100
- element('p', {}, '文档保存后会立即参与当前知识库的搜索与召回。'),
1101
- actionButton('新建文档', () => { void startBlankDocument(state.documentView.knowledgeBaseId || state.knowledgeBases.find(item => item.status === 'active')?.id) }, 'primary'),
1259
+ element('p', {}, readOnly ? '这个知识库已归档,当前只能阅读已有文档。' : '文档保存后会立即参与当前知识库的搜索与召回。'),
1260
+ !readOnly ? actionButton('新建文档', () => { void startBlankDocument(workspace, view.knowledgeBaseId || state.knowledgeBases.find(item => item.status === 'active')?.id) }, 'primary') : null,
1102
1261
  ))
1103
1262
  const saveShortcut = event => {
1104
1263
  if ((event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === 's') {
1105
1264
  event.preventDefault()
1106
- void saveDocumentEditor().then(saved => { if (saved) renderShell() })
1265
+ void saveDocumentEditor(workspace).then(saved => { if (saved) renderShell() })
1107
1266
  }
1108
1267
  }
1109
1268
  const update = (key, value) => {
@@ -1122,27 +1281,27 @@ function renderNoteEditor(editor, base) {
1122
1281
  })
1123
1282
  body.value = editor.body
1124
1283
  const finalized = !editor.isNew && editor.documentState !== 'open'
1125
- const mode = finalized ? 'preview' : state.documentView.mode === 'edit' ? 'edit' : 'preview'
1284
+ const mode = finalized || readOnly ? 'preview' : view.mode === 'edit' ? 'edit' : 'preview'
1126
1285
  if (mode === 'edit') window.requestAnimationFrame(() => resizeDocumentEditor(body))
1127
1286
  return element('main', { class: 'note-editor', 'aria-label': '文档编辑器' },
1128
1287
  element('header', { class: 'note-editor-toolbar' },
1129
1288
  element('div', { class: 'note-breadcrumb' },
1130
1289
  element('span', {}, base?.name || '知识库'),
1131
1290
  element('span', { 'aria-hidden': 'true' }, '/'),
1132
- element('strong', {}, editor.isNew ? '新文档' : state.documents.find(item => item.id === editor.id)?.relPath || editor.title)),
1291
+ element('strong', {}, editor.isNew ? '新文档' : documentWorkspaceDocuments(workspace).find(item => item.id === editor.id)?.relPath || editor.title)),
1133
1292
  element('div', { class: 'note-editor-actions' },
1134
- finalized ? badge(DOCUMENT_STATE_LABELS[editor.documentState] || '已结束', 'success') : element('div', { class: 'note-mode-switch', role: 'tablist', 'aria-label': '文档视图' }, [
1293
+ finalized ? badge(DOCUMENT_STATE_LABELS[editor.documentState] || '已结束', 'success') : readOnly ? badge('只读') : element('div', { class: 'note-mode-switch', role: 'tablist', 'aria-label': '文档视图' }, [
1135
1294
  ['edit', '编辑'],
1136
1295
  ['preview', '预览'],
1137
1296
  ].map(([value, label]) => element('button', {
1138
1297
  type: 'button', role: 'tab', 'aria-selected': String(mode === value),
1139
- onClick: () => switchDocumentMode(value),
1298
+ onClick: () => switchDocumentMode(workspace, value),
1140
1299
  }, label))),
1141
1300
  element('span', { class: 'editor-save-status', role: 'status' }, editor.saveState),
1142
- finalized ? actionButton('重新打开', () => reopenDocument(editor), 'small') : null,
1143
- !editor.isNew && !finalized ? actionButton('标记结束', () => openFinalizeDocument(editor), 'small') : null,
1144
- !editor.isNew && !finalized ? actionButton('删除', () => confirmDeleteDocument(editor), 'ghost small') : null,
1145
- !finalized ? actionButton(editor.isNew ? '创建文档' : '保存', () => { void saveDocumentEditor().then(saved => { if (saved) renderShell() }) }, 'primary small') : null,
1301
+ finalized && !readOnly ? actionButton('重新打开', () => reopenDocument(workspace, editor), 'small') : null,
1302
+ !readOnly && !editor.isNew && !finalized ? actionButton('标记结束', () => openFinalizeDocument(workspace, editor), 'small') : null,
1303
+ !readOnly && !editor.isNew && !finalized ? actionButton('删除', () => confirmDeleteDocument(workspace, editor), 'ghost small') : null,
1304
+ !readOnly && !finalized ? actionButton(editor.isNew ? '创建文档' : '保存', () => { void saveDocumentEditor(workspace).then(saved => { if (saved) renderShell() }) }, 'primary small') : null,
1146
1305
  ),
1147
1306
  ),
1148
1307
  element('div', { class: 'note-editor-scroll', 'data-scroll-key': 'note-editor' },
@@ -1163,20 +1322,20 @@ function renderNoteEditor(editor, base) {
1163
1322
  ),
1164
1323
  ),
1165
1324
  element('footer', { class: 'note-inspector' },
1166
- finalized ? element('span', { class: 'note-format-hint' }, '只读封存 · 重新打开后才能编辑') : element('label', {}, element('span', {}, '类型'), element('select', {
1325
+ finalized || readOnly ? element('span', { class: 'note-format-hint' }, readOnly ? '知识库已归档 · 只读' : '只读封存 · 重新打开后才能编辑') : element('label', {}, element('span', {}, '类型'), element('select', {
1167
1326
  class: 'note-meta-select', onChange: event => update('type', event.target.value),
1168
1327
  }, TYPES.map(type => element('option', { value: type, selected: type === editor.type }, TYPE_LABELS[type])))),
1169
- finalized ? null : element('label', { class: 'note-tags-field' }, element('span', {}, '标签'), element('input', {
1328
+ finalized || readOnly ? null : element('label', { class: 'note-tags-field' }, element('span', {}, '标签'), element('input', {
1170
1329
  value: editor.tagsText, placeholder: '用逗号分隔', onInput: event => update('tagsText', event.target.value),
1171
1330
  })),
1172
- !finalized ? element('span', { class: 'note-format-hint' }, mode === 'edit' ? 'Markdown · Ctrl/⌘ S 保存' : 'Markdown 预览') : null,
1331
+ !finalized && !readOnly ? element('span', { class: 'note-format-hint' }, mode === 'edit' ? 'Markdown · Ctrl/⌘ S 保存' : 'Markdown 预览') : null,
1173
1332
  ),
1174
1333
  )
1175
1334
  }
1176
1335
 
1177
- function switchDocumentMode(mode) {
1336
+ function switchDocumentMode(workspace, mode) {
1178
1337
  if (mode !== 'edit' && mode !== 'preview') return
1179
- state.documentView.mode = mode
1338
+ workspace.view.mode = mode
1180
1339
  renderShell()
1181
1340
  if (mode === 'edit') window.requestAnimationFrame(() => document.querySelector('.note-body-editor')?.focus())
1182
1341
  }
@@ -1193,7 +1352,7 @@ function resizeDocumentEditor(editor) {
1193
1352
  editor.style.height = `${Math.max(420, editor.scrollHeight)}px`
1194
1353
  }
1195
1354
 
1196
- function openFinalizeDocument(editor) {
1355
+ function openFinalizeDocument(workspace, editor) {
1197
1356
  const form = element('form', { class: 'form-grid' })
1198
1357
  const stateField = selectField('结束状态', [
1199
1358
  { value: 'resolved', label: '已解决 — 问题已有最终结论' },
@@ -1216,9 +1375,9 @@ function openFinalizeDocument(editor) {
1216
1375
  method: 'POST',
1217
1376
  body: { state: stateField.input.value, note: note.input.value.trim() },
1218
1377
  })
1219
- state.documentView.editor = { ...saved, tagsText: saved.tags.join(', '), dirty: false, isNew: false, saveState: '已封存' }
1220
- state.documentView.mode = 'preview'
1221
- state.documents = await api('documents')
1378
+ workspace.view.editor = { ...saved, tagsText: saved.tags.join(', '), dirty: false, isNew: false, saveState: '已封存' }
1379
+ workspace.view.mode = 'preview'
1380
+ await reloadDocumentWorkspace(workspace)
1222
1381
  renderShell()
1223
1382
  showToast(stateField.input.value === 'resolved' ? '文档已标记为已解决并封存。' : '文档已标记为收集完成并封存。')
1224
1383
  return true
@@ -1226,32 +1385,34 @@ function openFinalizeDocument(editor) {
1226
1385
  })
1227
1386
  }
1228
1387
 
1229
- function reopenDocument(editor) {
1388
+ function reopenDocument(workspace, editor) {
1230
1389
  openConfirm({
1231
1390
  title: `重新打开“${editor.title}”?`,
1232
1391
  message: '重新打开后,人工编辑、AI 回写和候选审核将恢复。',
1233
1392
  confirmLabel: '确认重新打开',
1234
1393
  onConfirm: async () => {
1235
1394
  const saved = await api(`documents/${encodeURIComponent(editor.id)}/reopen`, { method: 'POST' })
1236
- state.documentView.editor = { ...saved, tagsText: saved.tags.join(', '), dirty: false, isNew: false, saveState: '已重新打开' }
1237
- state.documents = await api('documents')
1395
+ workspace.view.editor = { ...saved, tagsText: saved.tags.join(', '), dirty: false, isNew: false, saveState: '已重新打开' }
1396
+ await reloadDocumentWorkspace(workspace)
1238
1397
  renderShell()
1239
1398
  showToast('文档已重新打开。')
1240
1399
  },
1241
1400
  })
1242
1401
  }
1243
1402
 
1244
- function confirmDeleteDocument(editor) {
1403
+ function confirmDeleteDocument(workspace, editor) {
1245
1404
  openConfirm({
1246
1405
  title: `删除“${editor.title}”?`,
1247
1406
  message: '文档和对应的召回知识会被永久删除,此操作无法撤销。',
1248
1407
  confirmLabel: '永久删除', danger: true,
1249
1408
  onConfirm: async () => {
1250
1409
  await api(`entries/${encodeURIComponent(editor.id)}`, { method: 'DELETE' })
1251
- state.documentView.documentId = ''
1252
- state.documentView.editor = null
1410
+ workspace.view.documentId = ''
1411
+ workspace.view.editor = null
1253
1412
  state.stats = null
1254
- await loadDocuments()
1413
+ await reloadDocumentWorkspace(workspace)
1414
+ selectDefaultDocument(workspace)
1415
+ if (workspace.view.documentId) await loadDocumentEditor(workspace, workspace.view.documentId)
1255
1416
  renderShell()
1256
1417
  showToast('文档已删除。')
1257
1418
  },
@@ -1644,6 +1805,12 @@ function confirmDeleteKnowledgeBase(base) {
1644
1805
  state.documentView.knowledgeBaseId = ''
1645
1806
  state.documentView.documentId = ''
1646
1807
  }
1808
+ if (state.libraryDetail.knowledgeBaseId === base.id) {
1809
+ state.libraryDetail.knowledgeBaseId = ''
1810
+ state.libraryDetail.documents = []
1811
+ state.libraryDetail.view = createDocumentViewState()
1812
+ state.knowledgeBaseView = 'libraries'
1813
+ }
1647
1814
  if (state.entryFilters.knowledgeBaseId === base.id) state.entryFilters.knowledgeBaseId = ''
1648
1815
  showToast('知识库及其全部关联数据已永久删除。')
1649
1816
  await navigate('bases')
@@ -1654,8 +1821,9 @@ function confirmDeleteKnowledgeBase(base) {
1654
1821
  }
1655
1822
 
1656
1823
  function openEntryEditor(entry, candidate) {
1824
+ const activeWorkspace = activeDocumentWorkspace()
1657
1825
  const source = candidate?.draft || entry || {
1658
- knowledgeBaseId: state.documentView.knowledgeBaseId || undefined,
1826
+ knowledgeBaseId: activeWorkspace?.view.knowledgeBaseId || undefined,
1659
1827
  title: '', body: '', type: 'fact', tags: [], scope: { kind: 'global' }, confidence: .8,
1660
1828
  }
1661
1829
  const form = element('form', { class: 'form-grid' })
@@ -1942,5 +2110,4 @@ function friendlyError(error) {
1942
2110
  return error.message || '操作失败,请稍后重试。'
1943
2111
  }
1944
2112
 
1945
- installHostThemeBridge()
1946
- void boot()
2113
+ void installHostThemeBridge().then(() => boot())