@lemoncat7/dsh-knowledge 1.1.1 → 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,15 +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
- inspectedKnowledgeBaseId: '',
57
- mode: 'preview',
58
- treeOpen: false,
61
+ documentView: createDocumentViewState({
59
62
  sidebarHidden: savedDocumentLayout.sidebarHidden,
60
63
  sidebarWidth: savedDocumentLayout.sidebarWidth,
61
- expandedBases: new Set(),
62
- editor: null,
64
+ }),
65
+ libraryDetail: {
66
+ knowledgeBaseId: '',
67
+ documents: [],
68
+ error: '',
69
+ view: createDocumentViewState(),
63
70
  },
64
71
  candidates: [],
65
72
  candidateTargets: new Map(),
@@ -77,34 +84,45 @@ const state = {
77
84
  let scrollRestoreFrame = 0
78
85
 
79
86
  function installHostThemeBridge() {
80
- if (window.parent === window) return
87
+ if (window.parent === window) return Promise.resolve()
81
88
  const parentOrigin = referrerOrigin()
82
- window.addEventListener('message', event => {
83
- if (event.source !== window.parent) return
84
- if (parentOrigin && event.origin !== parentOrigin) return
85
- const message = event.data
86
- if (!message || message.type !== HOST_THEME_MESSAGE || message.version !== HOST_THEME_PROTOCOL_VERSION) return
87
- if (message.colorScheme !== 'light' && message.colorScheme !== 'dark') return
88
- if (!message.tokens || typeof message.tokens !== 'object' || Array.isArray(message.tokens)) return
89
-
90
- const root = document.documentElement
91
- for (const name of [...HOST_THEME_COLOR_TOKENS, ...HOST_THEME_STYLE_TOKENS]) root.style.removeProperty(name)
92
- for (const [name, value] of Object.entries(message.tokens)) {
93
- if (typeof value !== 'string' || value.length === 0 || value.length > 512) continue
94
- if (HOST_THEME_COLOR_TOKENS.has(name) && CSS.supports('color', value)) root.style.setProperty(name, value)
95
- 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()
96
96
  }
97
- root.dataset.dshHostTheme = 'true'
98
- root.style.colorScheme = message.colorScheme
99
- root.style.setProperty('--dialog-surface', message.colorScheme === 'dark' ? '#1c1c1e' : '#ffffff')
100
- document.querySelector('meta[name="color-scheme"]')?.setAttribute('content', message.colorScheme)
101
- const background = root.style.getPropertyValue('--bg')
102
- 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 || '*')
103
125
  })
104
- window.parent.postMessage({
105
- type: HOST_THEME_READY_MESSAGE,
106
- version: HOST_THEME_PROTOCOL_VERSION,
107
- }, parentOrigin || '*')
108
126
  }
109
127
 
110
128
  function referrerOrigin() {
@@ -153,12 +171,16 @@ function captureScrollPosition() {
153
171
  const key = node.getAttribute('data-scroll-key')
154
172
  if (key) regions[key] = { left: node.scrollLeft, top: node.scrollTop }
155
173
  })
156
- state.scrollPositions.set(shell.dataset.view, {
174
+ state.scrollPositions.set(shell.dataset.scrollState || shell.dataset.view, {
157
175
  window: { left: window.scrollX, top: window.scrollY },
158
176
  regions,
159
177
  })
160
178
  }
161
179
 
180
+ function currentScrollState() {
181
+ return state.view === 'bases' ? `bases:${state.knowledgeBaseView}` : state.view
182
+ }
183
+
162
184
  function restoreScrollPosition(view) {
163
185
  if (state.loading) return
164
186
  const saved = state.scrollPositions.get(view)
@@ -320,6 +342,10 @@ function signOut() {
320
342
  }
321
343
 
322
344
  async function navigate(view) {
345
+ const previousView = state.view
346
+ if (view === 'bases' && previousView !== 'bases' && state.knowledgeBaseView === 'detail') {
347
+ state.knowledgeBaseView = 'libraries'
348
+ }
323
349
  state.view = view
324
350
  state.menuOpen = false
325
351
  state.loading = true
@@ -413,15 +439,16 @@ async function loadDocuments() {
413
439
  state.resolvedMounts = resolved
414
440
  const visibleBaseIds = new Set(documentKnowledgeBases(bases).map(base => base.id))
415
441
  state.documents = documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId))
416
- const view = state.documentView
442
+ const workspace = sessionDocumentWorkspace()
443
+ const view = workspace.view
417
444
  const availableBaseIds = visibleBaseIds
418
445
  if (!view.knowledgeBaseId || !availableBaseIds.has(view.knowledgeBaseId)) {
419
446
  view.knowledgeBaseId = state.entryFilters.knowledgeBaseId && availableBaseIds.has(state.entryFilters.knowledgeBaseId)
420
447
  ? state.entryFilters.knowledgeBaseId
421
448
  : documentKnowledgeBases(bases)[0]?.id || ''
422
449
  }
423
- selectDefaultDocument()
424
- if (view.documentId) await loadDocumentEditor(view.documentId)
450
+ selectDefaultDocument(workspace)
451
+ if (view.documentId) await loadDocumentEditor(workspace, view.documentId)
425
452
  else view.editor = null
426
453
  if (!state.stats) await refreshStats()
427
454
  }
@@ -430,22 +457,68 @@ function documentKnowledgeBases(bases = state.knowledgeBases) {
430
457
  const active = bases.filter(base => base.status === 'active')
431
458
  if (!state.mountContext.sessionId) return active
432
459
  const mountedIds = new Set(state.resolvedMounts.map(mount => mount.knowledgeBaseId))
433
- return active.filter(base => mountedIds.has(base.id) || base.id === state.documentView.inspectedKnowledgeBaseId)
460
+ return active.filter(base => mountedIds.has(base.id))
461
+ }
462
+
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
434
475
  }
435
476
 
436
- function selectDefaultDocument() {
437
- const view = state.documentView
438
- const documents = state.documents.filter(document => document.knowledgeBaseId === view.knowledgeBaseId)
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)
439
512
  if (!documents.some(document => document.id === view.documentId)) {
440
513
  view.documentId = documents[0]?.id || ''
441
514
  }
442
515
  if (view.knowledgeBaseId) view.expandedBases.add(view.knowledgeBaseId)
443
516
  }
444
517
 
445
- async function loadDocumentEditor(id) {
518
+ async function loadDocumentEditor(workspace, id) {
446
519
  const entry = await api(`entries/${encodeURIComponent(id)}`)
447
- state.documentView.mode = 'preview'
448
- state.documentView.editor = {
520
+ workspace.view.mode = 'preview'
521
+ workspace.view.editor = {
449
522
  ...entry,
450
523
  tagsText: entry.tags.join(', '),
451
524
  dirty: false,
@@ -454,10 +527,10 @@ async function loadDocumentEditor(id) {
454
527
  }
455
528
  }
456
529
 
457
- function createBlankDocument(baseId) {
530
+ function createBlankDocument(workspace, baseId) {
458
531
  const base = state.knowledgeBases.find(item => item.id === baseId && item.status === 'active')
459
532
  if (!base) return showToast('请先选择一个可用知识库。', 'error')
460
- const view = state.documentView
533
+ const view = workspace.view
461
534
  view.knowledgeBaseId = base.id
462
535
  view.documentId = ''
463
536
  view.mode = 'edit'
@@ -471,20 +544,20 @@ function createBlankDocument(baseId) {
471
544
  document.querySelector('.note-title-input')?.focus()
472
545
  }
473
546
 
474
- async function startBlankDocument(baseId) {
475
- const editor = state.documentView.editor
547
+ async function startBlankDocument(workspace, baseId) {
548
+ const editor = workspace.view.editor
476
549
  const emptyDraft = editor?.isNew && !editor.title.trim() && !editor.body.trim()
477
- if (editor?.dirty && !emptyDraft && !await saveDocumentEditor()) return
478
- createBlankDocument(baseId)
550
+ if (editor?.dirty && !emptyDraft && !await saveDocumentEditor(workspace)) return
551
+ createBlankDocument(workspace, baseId)
479
552
  }
480
553
 
481
- async function selectDocument(id) {
482
- const editor = state.documentView.editor
554
+ async function selectDocument(workspace, id) {
555
+ const editor = workspace.view.editor
483
556
  const emptyDraft = editor?.isNew && !editor.title.trim() && !editor.body.trim()
484
- if (editor?.dirty && !emptyDraft && !await saveDocumentEditor()) return
485
- state.documentView.documentId = id
486
- state.documentView.treeOpen = false
487
- 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)
488
561
  renderShell()
489
562
  }
490
563
 
@@ -501,8 +574,9 @@ function editorDraft(editor) {
501
574
  }
502
575
  }
503
576
 
504
- async function saveDocumentEditor() {
505
- const editor = state.documentView.editor
577
+ async function saveDocumentEditor(workspace = activeDocumentWorkspace()) {
578
+ if (!workspace) return false
579
+ const editor = workspace.view.editor
506
580
  if (!editor || !editor.dirty) return true
507
581
  if (!editor.isNew && editor.documentState !== 'open') {
508
582
  showToast('这篇文档已经结束并封存;请先重新打开。', 'error')
@@ -524,10 +598,9 @@ async function saveDocumentEditor() {
524
598
  editor.dirty = false
525
599
  editor.updatedAt = saved.updatedAt
526
600
  editor.saveState = '已保存'
527
- state.documentView.documentId = saved.id
601
+ workspace.view.documentId = saved.id
528
602
  updateEditorSaveState(editor.saveState)
529
- const documents = await api('documents')
530
- state.documents = documents
603
+ await reloadDocumentWorkspace(workspace)
531
604
  return true
532
605
  } catch (error) {
533
606
  editor.saveState = '保存失败'
@@ -577,6 +650,8 @@ function renderShell() {
577
650
  const shell = element('div', {
578
651
  class: 'app-shell', 'data-menu-open': String(state.menuOpen),
579
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'),
580
655
  'data-sidebar-hidden': String(state.documentView.sidebarHidden),
581
656
  style: `--sidebar-width: ${state.documentView.sidebarWidth}px`,
582
657
  },
@@ -587,8 +662,10 @@ function renderShell() {
587
662
  element('div', { class: 'topbar-title' },
588
663
  actionButton('☰', () => { state.menuOpen = !state.menuOpen; renderShell() }, 'ghost mobile-menu', { 'aria-label': '打开导航菜单' }),
589
664
  paneToggleButton('main', !state.documentView.sidebarHidden, () => setSidebarHidden(!state.documentView.sidebarHidden), '主导航栏'),
590
- state.view === 'entries' ? paneToggleButton('library', state.documentView.treeOpen, () => {
591
- 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
592
669
  renderShell()
593
670
  }, '知识目录') : null,
594
671
  element('div', {}, element('h1', {}, title), element('p', {}, subtitle)),
@@ -601,7 +678,7 @@ function renderShell() {
601
678
  if (event.target === shell) { state.menuOpen = false; renderShell() }
602
679
  })
603
680
  app.replaceChildren(shell)
604
- restoreScrollPosition(state.view)
681
+ restoreScrollPosition(currentScrollState())
605
682
  }
606
683
 
607
684
  function renderAppSidebarResizer() {
@@ -752,6 +829,7 @@ function renderOverview() {
752
829
  function renderKnowledgeBases() {
753
830
  const activeBases = state.knowledgeBases.filter(base => base.status === 'active')
754
831
  const archivedBases = state.knowledgeBases.filter(base => base.status === 'archived')
832
+ if (state.knowledgeBaseView === 'detail') return renderKnowledgeBaseDetail()
755
833
  const contextAvailable = Boolean(state.mountContext.projectId || state.mountContext.sessionId)
756
834
  const query = state.knowledgeBaseQuery.trim().toLocaleLowerCase()
757
835
  const matchesQuery = base => !query || [base.name, base.description, base.defaultTags.join(' ')]
@@ -821,6 +899,82 @@ function renderKnowledgeBases() {
821
899
  )
822
900
  }
823
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
+
824
978
  async function openGlobalWritebackModelEditor() {
825
979
  await loadModelCatalog()
826
980
  const custom = element('input', { type: 'checkbox', checked: Boolean(state.service.writebackProvider && state.service.writebackModel) })
@@ -867,14 +1021,7 @@ function renderKnowledgeBaseCard(base) {
867
1021
  base.extractionInstructions ? element('span', {}, element('strong', {}, '提取规则'), '已设置') : null,
868
1022
  ),
869
1023
  element('div', { class: 'base-card-actions' },
870
- actionButton('查看知识', () => {
871
- state.entryFilters.knowledgeBaseId = base.id
872
- state.documentView.knowledgeBaseId = base.id
873
- state.documentView.inspectedKnowledgeBaseId = base.id
874
- state.documentView.documentId = ''
875
- state.documentView.expandedBases.add(base.id)
876
- void navigate('entries')
877
- }, 'ghost small'),
1024
+ actionButton('查看知识', () => { void openKnowledgeBaseDocuments(base) }, 'ghost small', { 'data-open-knowledge-base': base.id }),
878
1025
  archived ? actionButton('恢复', () => confirmRestoreKnowledgeBase(base), 'small') : actionButton('编辑', () => openKnowledgeBaseEditor(base), 'small'),
879
1026
  archived ? actionButton('永久删除', () => confirmDeleteKnowledgeBase(base), 'danger small') : null,
880
1027
  !archived && base.id !== 'default' ? actionButton('归档', () => confirmArchiveKnowledgeBase(base), 'danger small') : null,
@@ -1030,23 +1177,30 @@ function compactEmpty(message) {
1030
1177
  }
1031
1178
 
1032
1179
  function renderEntries() {
1033
- const view = state.documentView
1180
+ return renderDocumentWorkspace(sessionDocumentWorkspace(), { heading: '知识目录' })
1181
+ }
1182
+
1183
+ function renderDocumentWorkspace(workspace, options = {}) {
1184
+ const view = workspace.view
1034
1185
  const query = view.query.trim().toLocaleLowerCase()
1035
- const activeBases = documentKnowledgeBases()
1186
+ const activeBases = documentWorkspaceBases(workspace)
1187
+ const workspaceDocuments = documentWorkspaceDocuments(workspace)
1188
+ const readOnly = documentWorkspaceReadOnly(workspace)
1036
1189
  const selectedBase = activeBases.find(base => base.id === view.knowledgeBaseId)
1037
1190
  const search = element('input', {
1038
1191
  class: 'note-tree-search', type: 'search', value: view.query, placeholder: '搜索文档', 'aria-label': '搜索知识库文档',
1192
+ 'data-document-scope': workspace.kind,
1039
1193
  onInput: (event) => {
1040
1194
  view.query = event.target.value
1041
1195
  renderShell()
1042
- const input = document.querySelector('.note-tree-search')
1196
+ const input = document.querySelector(`.note-tree-search[data-document-scope="${workspace.kind}"]`)
1043
1197
  input?.focus()
1044
1198
  input?.setSelectionRange(input.value.length, input.value.length)
1045
1199
  },
1046
1200
  })
1047
1201
  const tree = activeBases.map(base => {
1048
1202
  const expanded = view.expandedBases.has(base.id) || Boolean(query)
1049
- const documents = state.documents.filter(document => document.knowledgeBaseId === base.id
1203
+ const documents = workspaceDocuments.filter(document => document.knowledgeBaseId === base.id
1050
1204
  && (!query || [document.title, document.relPath, document.content].some(value => value.toLocaleLowerCase().includes(query))))
1051
1205
  return element('section', { class: 'note-tree-group', 'data-expanded': String(expanded) },
1052
1206
  element('button', {
@@ -1065,48 +1219,50 @@ function renderEntries() {
1065
1219
  expanded ? element('div', { class: 'note-tree-documents', role: 'group', 'aria-label': `${base.name}文档` },
1066
1220
  documents.map(document => element('button', {
1067
1221
  type: 'button', class: 'note-tree-document', 'aria-current': document.id === view.documentId ? 'page' : undefined,
1068
- onClick: () => { void selectDocument(document.id) },
1222
+ onClick: () => { void selectDocument(workspace, document.id) },
1069
1223
  }, element('span', { class: 'tree-document-icon', 'aria-hidden': 'true' }), element('span', { class: 'tree-document-copy' },
1070
1224
  element('strong', {}, document.title), element('small', {}, document.relPath)),
1071
1225
  document.documentState !== 'open' ? badge(DOCUMENT_STATE_LABELS[document.documentState] || '已结束', 'success') : null)),
1072
- !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) } },
1073
1227
  element('span', { 'aria-hidden': 'true' }, '+'), '新建文档') : null,
1074
1228
  ) : null,
1075
1229
  )
1076
1230
  })
1077
1231
  return element('section', {
1078
- class: 'note-workspace', 'aria-labelledby': 'documents-heading',
1232
+ class: `note-workspace note-workspace--${workspace.kind}`, 'aria-labelledby': `${workspace.kind}-documents-heading`,
1079
1233
  'data-tree-open': String(view.treeOpen),
1080
1234
  },
1081
1235
  element('aside', { class: 'note-tree-panel', 'aria-label': '知识目录' },
1082
1236
  element('header', { class: 'note-tree-header' },
1083
- element('div', {}, element('h2', { id: 'documents-heading' }, '知识目录'), element('span', {}, `${state.documents.length} 篇文档`)),
1084
- 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,
1085
1239
  ),
1086
1240
  element('div', { class: 'note-tree-search-wrap' }, interfaceIcon('search', 'search-symbol'), search),
1087
- element('nav', { class: 'note-tree', 'data-scroll-key': 'note-tree' }, tree.length ? tree : element('div', { class: 'note-tree-empty' }, state.mountContext.sessionId ? '当前会话未挂载知识库' : '还没有知识库')),
1088
- 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,
1089
1243
  ),
1090
1244
  view.treeOpen ? element('button', {
1091
1245
  type: 'button', class: 'note-tree-scrim', 'aria-label': '关闭知识目录',
1092
1246
  onClick: () => { view.treeOpen = false; renderShell() },
1093
1247
  }) : null,
1094
- renderNoteEditor(view.editor, selectedBase),
1248
+ renderNoteEditor(workspace, view.editor, selectedBase),
1095
1249
  )
1096
1250
  }
1097
1251
 
1098
- function renderNoteEditor(editor, base) {
1252
+ function renderNoteEditor(workspace, editor, base) {
1253
+ const view = workspace.view
1254
+ const readOnly = documentWorkspaceReadOnly(workspace)
1099
1255
  if (!editor) return element('main', { class: 'note-editor note-editor-empty' },
1100
1256
  element('div', { class: 'note-empty-content' },
1101
1257
  element('span', { class: 'empty-document-mark', 'aria-hidden': 'true' }),
1102
1258
  element('h3', {}, '选择或新建一篇文档'),
1103
- element('p', {}, '文档保存后会立即参与当前知识库的搜索与召回。'),
1104
- 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,
1105
1261
  ))
1106
1262
  const saveShortcut = event => {
1107
1263
  if ((event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === 's') {
1108
1264
  event.preventDefault()
1109
- void saveDocumentEditor().then(saved => { if (saved) renderShell() })
1265
+ void saveDocumentEditor(workspace).then(saved => { if (saved) renderShell() })
1110
1266
  }
1111
1267
  }
1112
1268
  const update = (key, value) => {
@@ -1125,27 +1281,27 @@ function renderNoteEditor(editor, base) {
1125
1281
  })
1126
1282
  body.value = editor.body
1127
1283
  const finalized = !editor.isNew && editor.documentState !== 'open'
1128
- const mode = finalized ? 'preview' : state.documentView.mode === 'edit' ? 'edit' : 'preview'
1284
+ const mode = finalized || readOnly ? 'preview' : view.mode === 'edit' ? 'edit' : 'preview'
1129
1285
  if (mode === 'edit') window.requestAnimationFrame(() => resizeDocumentEditor(body))
1130
1286
  return element('main', { class: 'note-editor', 'aria-label': '文档编辑器' },
1131
1287
  element('header', { class: 'note-editor-toolbar' },
1132
1288
  element('div', { class: 'note-breadcrumb' },
1133
1289
  element('span', {}, base?.name || '知识库'),
1134
1290
  element('span', { 'aria-hidden': 'true' }, '/'),
1135
- 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)),
1136
1292
  element('div', { class: 'note-editor-actions' },
1137
- 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': '文档视图' }, [
1138
1294
  ['edit', '编辑'],
1139
1295
  ['preview', '预览'],
1140
1296
  ].map(([value, label]) => element('button', {
1141
1297
  type: 'button', role: 'tab', 'aria-selected': String(mode === value),
1142
- onClick: () => switchDocumentMode(value),
1298
+ onClick: () => switchDocumentMode(workspace, value),
1143
1299
  }, label))),
1144
1300
  element('span', { class: 'editor-save-status', role: 'status' }, editor.saveState),
1145
- finalized ? actionButton('重新打开', () => reopenDocument(editor), 'small') : null,
1146
- !editor.isNew && !finalized ? actionButton('标记结束', () => openFinalizeDocument(editor), 'small') : null,
1147
- !editor.isNew && !finalized ? actionButton('删除', () => confirmDeleteDocument(editor), 'ghost small') : null,
1148
- !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,
1149
1305
  ),
1150
1306
  ),
1151
1307
  element('div', { class: 'note-editor-scroll', 'data-scroll-key': 'note-editor' },
@@ -1166,20 +1322,20 @@ function renderNoteEditor(editor, base) {
1166
1322
  ),
1167
1323
  ),
1168
1324
  element('footer', { class: 'note-inspector' },
1169
- 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', {
1170
1326
  class: 'note-meta-select', onChange: event => update('type', event.target.value),
1171
1327
  }, TYPES.map(type => element('option', { value: type, selected: type === editor.type }, TYPE_LABELS[type])))),
1172
- 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', {
1173
1329
  value: editor.tagsText, placeholder: '用逗号分隔', onInput: event => update('tagsText', event.target.value),
1174
1330
  })),
1175
- !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,
1176
1332
  ),
1177
1333
  )
1178
1334
  }
1179
1335
 
1180
- function switchDocumentMode(mode) {
1336
+ function switchDocumentMode(workspace, mode) {
1181
1337
  if (mode !== 'edit' && mode !== 'preview') return
1182
- state.documentView.mode = mode
1338
+ workspace.view.mode = mode
1183
1339
  renderShell()
1184
1340
  if (mode === 'edit') window.requestAnimationFrame(() => document.querySelector('.note-body-editor')?.focus())
1185
1341
  }
@@ -1196,7 +1352,7 @@ function resizeDocumentEditor(editor) {
1196
1352
  editor.style.height = `${Math.max(420, editor.scrollHeight)}px`
1197
1353
  }
1198
1354
 
1199
- function openFinalizeDocument(editor) {
1355
+ function openFinalizeDocument(workspace, editor) {
1200
1356
  const form = element('form', { class: 'form-grid' })
1201
1357
  const stateField = selectField('结束状态', [
1202
1358
  { value: 'resolved', label: '已解决 — 问题已有最终结论' },
@@ -1219,9 +1375,9 @@ function openFinalizeDocument(editor) {
1219
1375
  method: 'POST',
1220
1376
  body: { state: stateField.input.value, note: note.input.value.trim() },
1221
1377
  })
1222
- state.documentView.editor = { ...saved, tagsText: saved.tags.join(', '), dirty: false, isNew: false, saveState: '已封存' }
1223
- state.documentView.mode = 'preview'
1224
- 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)
1225
1381
  renderShell()
1226
1382
  showToast(stateField.input.value === 'resolved' ? '文档已标记为已解决并封存。' : '文档已标记为收集完成并封存。')
1227
1383
  return true
@@ -1229,32 +1385,34 @@ function openFinalizeDocument(editor) {
1229
1385
  })
1230
1386
  }
1231
1387
 
1232
- function reopenDocument(editor) {
1388
+ function reopenDocument(workspace, editor) {
1233
1389
  openConfirm({
1234
1390
  title: `重新打开“${editor.title}”?`,
1235
1391
  message: '重新打开后,人工编辑、AI 回写和候选审核将恢复。',
1236
1392
  confirmLabel: '确认重新打开',
1237
1393
  onConfirm: async () => {
1238
1394
  const saved = await api(`documents/${encodeURIComponent(editor.id)}/reopen`, { method: 'POST' })
1239
- state.documentView.editor = { ...saved, tagsText: saved.tags.join(', '), dirty: false, isNew: false, saveState: '已重新打开' }
1240
- state.documents = await api('documents')
1395
+ workspace.view.editor = { ...saved, tagsText: saved.tags.join(', '), dirty: false, isNew: false, saveState: '已重新打开' }
1396
+ await reloadDocumentWorkspace(workspace)
1241
1397
  renderShell()
1242
1398
  showToast('文档已重新打开。')
1243
1399
  },
1244
1400
  })
1245
1401
  }
1246
1402
 
1247
- function confirmDeleteDocument(editor) {
1403
+ function confirmDeleteDocument(workspace, editor) {
1248
1404
  openConfirm({
1249
1405
  title: `删除“${editor.title}”?`,
1250
1406
  message: '文档和对应的召回知识会被永久删除,此操作无法撤销。',
1251
1407
  confirmLabel: '永久删除', danger: true,
1252
1408
  onConfirm: async () => {
1253
1409
  await api(`entries/${encodeURIComponent(editor.id)}`, { method: 'DELETE' })
1254
- state.documentView.documentId = ''
1255
- state.documentView.editor = null
1410
+ workspace.view.documentId = ''
1411
+ workspace.view.editor = null
1256
1412
  state.stats = null
1257
- await loadDocuments()
1413
+ await reloadDocumentWorkspace(workspace)
1414
+ selectDefaultDocument(workspace)
1415
+ if (workspace.view.documentId) await loadDocumentEditor(workspace, workspace.view.documentId)
1258
1416
  renderShell()
1259
1417
  showToast('文档已删除。')
1260
1418
  },
@@ -1647,6 +1805,12 @@ function confirmDeleteKnowledgeBase(base) {
1647
1805
  state.documentView.knowledgeBaseId = ''
1648
1806
  state.documentView.documentId = ''
1649
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
+ }
1650
1814
  if (state.entryFilters.knowledgeBaseId === base.id) state.entryFilters.knowledgeBaseId = ''
1651
1815
  showToast('知识库及其全部关联数据已永久删除。')
1652
1816
  await navigate('bases')
@@ -1657,8 +1821,9 @@ function confirmDeleteKnowledgeBase(base) {
1657
1821
  }
1658
1822
 
1659
1823
  function openEntryEditor(entry, candidate) {
1824
+ const activeWorkspace = activeDocumentWorkspace()
1660
1825
  const source = candidate?.draft || entry || {
1661
- knowledgeBaseId: state.documentView.knowledgeBaseId || undefined,
1826
+ knowledgeBaseId: activeWorkspace?.view.knowledgeBaseId || undefined,
1662
1827
  title: '', body: '', type: 'fact', tags: [], scope: { kind: 'global' }, confidence: .8,
1663
1828
  }
1664
1829
  const form = element('form', { class: 'form-grid' })
@@ -1945,5 +2110,4 @@ function friendlyError(error) {
1945
2110
  return error.message || '操作失败,请稍后重试。'
1946
2111
  }
1947
2112
 
1948
- installHostThemeBridge()
1949
- void boot()
2113
+ void installHostThemeBridge().then(() => boot())