@lemoncat7/dsh-knowledge 0.7.3 → 1.0.1

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 (70) hide show
  1. package/README.md +16 -15
  2. package/cordis.patch.yml +0 -1
  3. package/docs/architecture.md +37 -19
  4. package/docs/document-knowledge-design.zh-CN.md +2 -2
  5. package/docs/requirements.md +9 -5
  6. package/lib/api.js +5 -1
  7. package/lib/api.js.map +1 -1
  8. package/lib/client.d.ts +6 -0
  9. package/lib/client.d.ts.map +1 -0
  10. package/lib/client.js +224 -171
  11. package/lib/client.js.map +3 -3
  12. package/lib/config.d.ts +2 -0
  13. package/lib/config.d.ts.map +1 -1
  14. package/lib/config.js +5 -3
  15. package/lib/config.js.map +1 -1
  16. package/lib/documents/markdown.d.ts +33 -0
  17. package/lib/documents/markdown.d.ts.map +1 -0
  18. package/lib/documents/markdown.js +91 -0
  19. package/lib/documents/markdown.js.map +1 -0
  20. package/lib/documents/path.d.ts +4 -0
  21. package/lib/documents/path.d.ts.map +1 -0
  22. package/lib/documents/path.js +11 -0
  23. package/lib/documents/path.js.map +1 -0
  24. package/lib/documents/store.d.ts +32 -0
  25. package/lib/documents/store.d.ts.map +1 -0
  26. package/lib/documents/store.js +272 -0
  27. package/lib/documents/store.js.map +1 -0
  28. package/lib/domain.d.ts +2 -0
  29. package/lib/domain.d.ts.map +1 -1
  30. package/lib/domain.js +6 -0
  31. package/lib/domain.js.map +1 -1
  32. package/lib/extraction.d.ts.map +1 -1
  33. package/lib/extraction.js +210 -73
  34. package/lib/extraction.js.map +1 -1
  35. package/lib/index.d.ts +1 -1
  36. package/lib/index.d.ts.map +1 -1
  37. package/lib/index.js +9 -6
  38. package/lib/index.js.map +1 -1
  39. package/lib/local-provider.d.ts +2 -0
  40. package/lib/local-provider.d.ts.map +1 -1
  41. package/lib/local-provider.js +145 -79
  42. package/lib/local-provider.js.map +1 -1
  43. package/lib/recall.d.ts +7 -2
  44. package/lib/recall.d.ts.map +1 -1
  45. package/lib/recall.js +46 -30
  46. package/lib/recall.js.map +1 -1
  47. package/lib/retrieval.d.ts +14 -4
  48. package/lib/retrieval.d.ts.map +1 -1
  49. package/lib/retrieval.js +133 -25
  50. package/lib/retrieval.js.map +1 -1
  51. package/lib/theme-bridge.d.ts +23 -0
  52. package/lib/theme-bridge.d.ts.map +1 -0
  53. package/lib/theme-bridge.js +72 -0
  54. package/lib/theme-bridge.js.map +1 -0
  55. package/lib/tools.d.ts.map +1 -1
  56. package/lib/tools.js +165 -7
  57. package/lib/tools.js.map +1 -1
  58. package/lib/web-markdown-preview.d.ts +3 -0
  59. package/lib/web-markdown-preview.d.ts.map +1 -0
  60. package/lib/web-markdown-preview.js +24 -0
  61. package/lib/web-markdown-preview.js.map +1 -0
  62. package/lib/web.d.ts +1 -1
  63. package/lib/web.d.ts.map +1 -1
  64. package/lib/web.js +21 -10
  65. package/lib/web.js.map +1 -1
  66. package/package.json +16 -3
  67. package/web/app.js +338 -359
  68. package/web/index.html +1 -0
  69. package/web/markdown-preview.js +72 -0
  70. package/web/styles.css +177 -200
package/web/app.js CHANGED
@@ -1,5 +1,15 @@
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 HOST_THEME_MESSAGE = '@lemoncat7/dsh-knowledge/host-theme'
4
+ const HOST_THEME_READY_MESSAGE = '@lemoncat7/dsh-knowledge/host-theme-ready'
5
+ const HOST_THEME_PROTOCOL_VERSION = 1
6
+ const HOST_THEME_COLOR_TOKENS = new Set([
7
+ '--bg', '--surface', '--surface-raised', '--surface-soft', '--surface-hover',
8
+ '--text', '--text-secondary', '--text-tertiary', '--border', '--border-strong',
9
+ '--accent', '--accent-hover', '--accent-soft', '--on-accent',
10
+ '--success', '--success-soft', '--warning', '--warning-soft', '--danger', '--danger-soft',
11
+ ])
12
+ const HOST_THEME_STYLE_TOKENS = new Set(['--shadow'])
3
13
  const TOKEN_KEY = 'dsh-knowledge.session-token'
4
14
  const TYPES = ['preference', 'fact', 'decision', 'procedure', 'lesson']
5
15
  const TYPE_LABELS = { preference: '偏好', fact: '事实', decision: '决策', procedure: '流程', lesson: '经验' }
@@ -7,20 +17,8 @@ const ACTION_LABELS = { create: '新增', update: '更新', conflict: '冲突' }
7
17
  const STATUS_LABELS = { active: '生效中', archived: '已归档', pending: '待审核', approved: '已通过', rejected: '已拒绝' }
8
18
  const CHANGE_LABELS = { create: '创建', update: '更新', archive: '归档', restore: '恢复' }
9
19
  const WRITE_MODE_LABELS = { none: '仅召回', audit: '审核写入', direct: '直接写入' }
10
- const TYPE_DOCUMENTS = {
11
- preference: 'preferences.md', fact: 'facts.md', decision: 'decisions.md', procedure: 'procedures.md', lesson: 'lessons.md',
12
- }
20
+ const EVIDENCE_LABELS = { explicit: '用户明确', verified: '结果已验证', inferred: '模型推断' }
13
21
  const DOCUMENT_LAYOUT_KEY = 'dsh-knowledge.document-layout'
14
- const DOCUMENT_COLUMN_LAYOUTS = Object.freeze({
15
- library: {
16
- widthKey: 'libraryWidth', hiddenKey: 'libraryHidden', cssVariable: '--library-width',
17
- minimum: 170, maximum: 360, controls: 'knowledge-library-column',
18
- },
19
- documentList: {
20
- widthKey: 'documentListWidth', hiddenKey: 'documentListHidden', cssVariable: '--document-list-width',
21
- minimum: 210, maximum: 480, controls: 'document-list-column',
22
- },
23
- })
24
22
  const pageParams = new URLSearchParams(location.search)
25
23
  const mountContext = {
26
24
  sessionId: pageParams.get('sessionId')?.trim() || '',
@@ -32,7 +30,7 @@ const savedDocumentLayout = readDocumentLayout()
32
30
 
33
31
  const state = {
34
32
  token: sessionStorage.getItem(TOKEN_KEY) || '',
35
- view: 'overview',
33
+ view: 'entries',
36
34
  menuOpen: false,
37
35
  stats: null,
38
36
  overview: null,
@@ -53,13 +51,13 @@ const state = {
53
51
  entryFilters: { query: '', type: '', status: 'active', projectId: '', knowledgeBaseId: '' },
54
52
  documents: [],
55
53
  documentView: {
56
- knowledgeBaseId: '', documentId: '', query: '', mode: 'documents',
54
+ knowledgeBaseId: '', documentId: '', query: '',
55
+ mode: 'preview',
56
+ treeOpen: false,
57
57
  sidebarHidden: savedDocumentLayout.sidebarHidden,
58
- libraryHidden: savedDocumentLayout.libraryHidden,
59
- documentListHidden: savedDocumentLayout.documentListHidden,
60
58
  sidebarWidth: savedDocumentLayout.sidebarWidth,
61
- libraryWidth: savedDocumentLayout.libraryWidth,
62
- documentListWidth: savedDocumentLayout.documentListWidth,
59
+ expandedBases: new Set(),
60
+ editor: null,
63
61
  },
64
62
  candidates: [],
65
63
  candidateStatus: 'pending',
@@ -74,20 +72,56 @@ const state = {
74
72
 
75
73
  let scrollRestoreFrame = 0
76
74
 
75
+ function installHostThemeBridge() {
76
+ if (window.parent === window) return
77
+ const parentOrigin = referrerOrigin()
78
+ window.addEventListener('message', event => {
79
+ if (event.source !== window.parent) return
80
+ if (parentOrigin && event.origin !== parentOrigin) return
81
+ const message = event.data
82
+ if (!message || message.type !== HOST_THEME_MESSAGE || message.version !== HOST_THEME_PROTOCOL_VERSION) return
83
+ if (message.colorScheme !== 'light' && message.colorScheme !== 'dark') return
84
+ if (!message.tokens || typeof message.tokens !== 'object' || Array.isArray(message.tokens)) return
85
+
86
+ const root = document.documentElement
87
+ for (const name of [...HOST_THEME_COLOR_TOKENS, ...HOST_THEME_STYLE_TOKENS]) root.style.removeProperty(name)
88
+ for (const [name, value] of Object.entries(message.tokens)) {
89
+ if (typeof value !== 'string' || value.length === 0 || value.length > 512) continue
90
+ if (HOST_THEME_COLOR_TOKENS.has(name) && CSS.supports('color', value)) root.style.setProperty(name, value)
91
+ else if (HOST_THEME_STYLE_TOKENS.has(name) && CSS.supports('box-shadow', value)) root.style.setProperty(name, value)
92
+ }
93
+ root.dataset.dshHostTheme = 'true'
94
+ root.style.colorScheme = message.colorScheme
95
+ document.querySelector('meta[name="color-scheme"]')?.setAttribute('content', message.colorScheme)
96
+ const background = root.style.getPropertyValue('--bg')
97
+ if (background) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', background)
98
+ })
99
+ window.parent.postMessage({
100
+ type: HOST_THEME_READY_MESSAGE,
101
+ version: HOST_THEME_PROTOCOL_VERSION,
102
+ }, parentOrigin || '*')
103
+ }
104
+
105
+ function referrerOrigin() {
106
+ if (!document.referrer) return ''
107
+ try {
108
+ const origin = new URL(document.referrer).origin
109
+ return origin === 'null' ? '' : origin
110
+ } catch {
111
+ return ''
112
+ }
113
+ }
114
+
77
115
  function readDocumentLayout() {
78
116
  const fallback = {
79
- sidebarHidden: false, libraryHidden: false, documentListHidden: false,
80
- sidebarWidth: 236, libraryWidth: 220, documentListWidth: 280,
117
+ sidebarHidden: false,
118
+ sidebarWidth: 236,
81
119
  }
82
120
  try {
83
121
  const value = JSON.parse(localStorage.getItem(DOCUMENT_LAYOUT_KEY) || '{}')
84
122
  return {
85
123
  sidebarHidden: value.sidebarHidden === true,
86
- libraryHidden: value.libraryHidden === true,
87
- documentListHidden: value.documentListHidden === true,
88
124
  sidebarWidth: clampNumber(value.sidebarWidth, 190, 340, fallback.sidebarWidth),
89
- libraryWidth: clampNumber(value.libraryWidth, 170, 360, fallback.libraryWidth),
90
- documentListWidth: clampNumber(value.documentListWidth, 210, 480, fallback.documentListWidth),
91
125
  }
92
126
  } catch { return fallback }
93
127
  }
@@ -96,11 +130,7 @@ function saveDocumentLayout() {
96
130
  try {
97
131
  localStorage.setItem(DOCUMENT_LAYOUT_KEY, JSON.stringify({
98
132
  sidebarHidden: state.documentView.sidebarHidden,
99
- libraryHidden: state.documentView.libraryHidden,
100
- documentListHidden: state.documentView.documentListHidden,
101
133
  sidebarWidth: state.documentView.sidebarWidth,
102
- libraryWidth: state.documentView.libraryWidth,
103
- documentListWidth: state.documentView.documentListWidth,
104
134
  }))
105
135
  } catch {}
106
136
  }
@@ -217,7 +247,7 @@ async function boot() {
217
247
  if (AUTH_MODE === 'same-origin') {
218
248
  state.token = ''
219
249
  try { state.service = await api('service') } catch {}
220
- await navigate('overview')
250
+ await navigate('entries')
221
251
  return
222
252
  }
223
253
  if (!state.token) {
@@ -226,7 +256,7 @@ async function boot() {
226
256
  }
227
257
  try {
228
258
  await api('entries?limit=1')
229
- await navigate('overview')
259
+ await navigate('entries')
230
260
  } catch (error) {
231
261
  sessionStorage.removeItem(TOKEN_KEY)
232
262
  state.token = ''
@@ -253,7 +283,7 @@ function renderLogin(message = '') {
253
283
  try {
254
284
  await api('entries?limit=1')
255
285
  sessionStorage.setItem(TOKEN_KEY, token)
256
- await navigate('overview')
286
+ await navigate('entries')
257
287
  } catch (requestError) {
258
288
  state.token = ''
259
289
  error.textContent = requestError.status === 401 ? '令牌无效或已被撤销。' : '连接失败,请检查服务状态后重试。'
@@ -377,6 +407,8 @@ async function loadDocuments() {
377
407
  : bases.find(base => base.status === 'active')?.id || bases[0]?.id || ''
378
408
  }
379
409
  selectDefaultDocument()
410
+ if (view.documentId) await loadDocumentEditor(view.documentId)
411
+ else view.editor = null
380
412
  if (!state.stats) await refreshStats()
381
413
  }
382
414
 
@@ -384,10 +416,107 @@ function selectDefaultDocument() {
384
416
  const view = state.documentView
385
417
  const documents = state.documents.filter(document => document.knowledgeBaseId === view.knowledgeBaseId)
386
418
  if (!documents.some(document => document.id === view.documentId)) {
387
- view.documentId = documents.find(document => document.relPath === 'README.md')?.id || documents[0]?.id || ''
419
+ view.documentId = documents[0]?.id || ''
420
+ }
421
+ if (view.knowledgeBaseId) view.expandedBases.add(view.knowledgeBaseId)
422
+ }
423
+
424
+ async function loadDocumentEditor(id) {
425
+ const entry = await api(`entries/${encodeURIComponent(id)}`)
426
+ state.documentView.mode = 'preview'
427
+ state.documentView.editor = {
428
+ ...entry,
429
+ tagsText: entry.tags.join(', '),
430
+ dirty: false,
431
+ isNew: false,
432
+ saveState: '已保存',
433
+ }
434
+ }
435
+
436
+ function createBlankDocument(baseId) {
437
+ const base = state.knowledgeBases.find(item => item.id === baseId && item.status === 'active')
438
+ if (!base) return showToast('请先选择一个可用知识库。', 'error')
439
+ const view = state.documentView
440
+ view.knowledgeBaseId = base.id
441
+ view.documentId = ''
442
+ view.mode = 'edit'
443
+ view.treeOpen = false
444
+ view.expandedBases.add(base.id)
445
+ view.editor = {
446
+ id: '', knowledgeBaseId: base.id, title: '', body: '', type: 'fact', tags: [], tagsText: '',
447
+ scope: { kind: 'global' }, confidence: .8, dirty: true, isNew: true, saveState: '新文档',
448
+ }
449
+ renderShell()
450
+ document.querySelector('.note-title-input')?.focus()
451
+ }
452
+
453
+ async function startBlankDocument(baseId) {
454
+ const editor = state.documentView.editor
455
+ const emptyDraft = editor?.isNew && !editor.title.trim() && !editor.body.trim()
456
+ if (editor?.dirty && !emptyDraft && !await saveDocumentEditor()) return
457
+ createBlankDocument(baseId)
458
+ }
459
+
460
+ async function selectDocument(id) {
461
+ const editor = state.documentView.editor
462
+ const emptyDraft = editor?.isNew && !editor.title.trim() && !editor.body.trim()
463
+ if (editor?.dirty && !emptyDraft && !await saveDocumentEditor()) return
464
+ state.documentView.documentId = id
465
+ state.documentView.treeOpen = false
466
+ await loadDocumentEditor(id)
467
+ renderShell()
468
+ }
469
+
470
+ function editorDraft(editor) {
471
+ return {
472
+ knowledgeBaseId: editor.knowledgeBaseId,
473
+ title: editor.title.trim(),
474
+ body: editor.body.trim(),
475
+ type: editor.type,
476
+ tags: parseTags(editor.tagsText),
477
+ scope: editor.scope,
478
+ confidence: editor.confidence,
479
+ ...(editor.source ? { source: editor.source } : {}),
480
+ }
481
+ }
482
+
483
+ async function saveDocumentEditor() {
484
+ const editor = state.documentView.editor
485
+ if (!editor || !editor.dirty) return true
486
+ if (!editor.title.trim() || !editor.body.trim()) {
487
+ showToast('标题和正文填写完整后才能保存。', 'error')
488
+ return false
489
+ }
490
+ editor.saveState = '正在保存…'
491
+ updateEditorSaveState(editor.saveState)
492
+ try {
493
+ const draft = editorDraft(editor)
494
+ const saved = editor.isNew
495
+ ? await api('entries', { method: 'POST', body: { draft } })
496
+ : await api(`entries/${encodeURIComponent(editor.id)}`, { method: 'PUT', body: { draft } })
497
+ editor.id = saved.id
498
+ editor.isNew = false
499
+ editor.dirty = false
500
+ editor.updatedAt = saved.updatedAt
501
+ editor.saveState = '已保存'
502
+ state.documentView.documentId = saved.id
503
+ updateEditorSaveState(editor.saveState)
504
+ const documents = await api('documents')
505
+ state.documents = documents
506
+ return true
507
+ } catch (error) {
508
+ editor.saveState = '保存失败'
509
+ updateEditorSaveState(editor.saveState)
510
+ showToast(friendlyError(error), 'error')
511
+ return false
388
512
  }
389
513
  }
390
514
 
515
+ function updateEditorSaveState(label) {
516
+ const node = document.querySelector('.editor-save-status')
517
+ if (node) node.textContent = label
518
+ }
519
+
391
520
  async function loadCandidates() {
392
521
  const [candidates] = await Promise.all([
393
522
  api(`candidates?status=${state.candidateStatus}&limit=100`),
@@ -408,9 +537,9 @@ function renderShell() {
408
537
  captureScrollPosition()
409
538
  const titles = {
410
539
  overview: ['概览', '知识库运行状态与最近活动'],
411
- bases: ['知识库', '创建知识库,并限定项目与会话的召回和写入范围'],
412
- entries: ['文档', '按知识库浏览自动整理的 Markdown 文档'],
413
- candidates: ['审核', '确认 AI 提取结果后再写入知识库'],
540
+ bases: ['知识库与挂载', '管理知识目录,并限定项目与会话的召回和写入范围'],
541
+ entries: ['知识文档', '在知识目录中阅读、整理和维护 Markdown 文档'],
542
+ candidates: ['待审核', '确认 AI 提取结果后再写入知识文档'],
414
543
  tokens: ['访问管理', '管理其他客户端连接中央知识库的权限'],
415
544
  }
416
545
  const [title, subtitle] = titles[state.view]
@@ -427,6 +556,10 @@ function renderShell() {
427
556
  element('div', { class: 'topbar-title' },
428
557
  actionButton('☰', () => { state.menuOpen = !state.menuOpen; renderShell() }, 'ghost mobile-menu', { 'aria-label': '打开导航菜单' }),
429
558
  paneToggleButton('main', !state.documentView.sidebarHidden, () => setSidebarHidden(!state.documentView.sidebarHidden), '主导航栏'),
559
+ state.view === 'entries' ? paneToggleButton('library', state.documentView.treeOpen, () => {
560
+ state.documentView.treeOpen = !state.documentView.treeOpen
561
+ renderShell()
562
+ }, '知识目录') : null,
430
563
  element('div', {}, element('h1', {}, title), element('p', {}, subtitle)),
431
564
  ),
432
565
  ),
@@ -508,8 +641,8 @@ function setSidebarHidden(hidden) {
508
641
  function renderSidebar() {
509
642
  const pending = state.stats?.candidates.pending
510
643
  const navGroups = [
511
- ['工作区', [['overview', '概览'], ['bases', '知识库'], ['entries', '文档'], ['candidates', '审核']]],
512
- ['服务', [['tokens', '访问管理']].filter(([id]) => id !== 'tokens' || !state.service.remote)],
644
+ ['知识工作区', [['entries', '文档'], ['candidates', '待审核'], ['bases', '知识库与挂载']]],
645
+ ['连接', [['tokens', '访问管理']].filter(([id]) => id !== 'tokens' || !state.service.remote)],
513
646
  ].filter(([, items]) => items.length)
514
647
  return element('aside', { class: 'sidebar', 'aria-label': '知识库导航' },
515
648
  element('div', { class: 'brand' },
@@ -724,7 +857,6 @@ function renderKnowledgeBaseCard(base) {
724
857
  state.entryFilters.knowledgeBaseId = base.id
725
858
  state.documentView.knowledgeBaseId = base.id
726
859
  state.documentView.documentId = ''
727
- state.documentView.mode = 'documents'
728
860
  void navigate('entries')
729
861
  }, 'ghost small'),
730
862
  archived ? actionButton('恢复', () => confirmRestoreKnowledgeBase(base), 'small') : actionButton('编辑', () => openKnowledgeBaseEditor(base), 'small'),
@@ -883,347 +1015,182 @@ function compactEmpty(message) {
883
1015
  }
884
1016
 
885
1017
  function renderEntries() {
886
- if (state.documentView.mode === 'entries') return renderLegacyEntries()
887
1018
  const view = state.documentView
888
1019
  const query = view.query.trim().toLocaleLowerCase()
889
1020
  const activeBases = state.knowledgeBases.filter(base => base.status === 'active')
890
- const selectedBase = state.knowledgeBases.find(base => base.id === view.knowledgeBaseId)
891
- const allBaseDocuments = state.documents.filter(document => document.knowledgeBaseId === view.knowledgeBaseId)
892
- const visibleDocuments = allBaseDocuments.filter(document => !query || [document.title, document.relPath, document.content]
893
- .some(value => value.toLocaleLowerCase().includes(query)))
894
- const selectedDocument = state.documents.find(document => document.id === view.documentId)
1021
+ const selectedBase = activeBases.find(base => base.id === view.knowledgeBaseId)
895
1022
  const search = element('input', {
896
- class: 'input', type: 'search', value: view.query, placeholder: '搜索文档标题、路径或正文', 'aria-label': '搜索知识库文档',
1023
+ class: 'note-tree-search', type: 'search', value: view.query, placeholder: '搜索文档', 'aria-label': '搜索知识库文档',
897
1024
  onInput: (event) => {
898
1025
  view.query = event.target.value
899
- const stillVisible = state.documents.some(document => document.id === view.documentId
900
- && document.knowledgeBaseId === view.knowledgeBaseId
901
- && (!view.query.trim() || [document.title, document.relPath, document.content].some(value => value.toLocaleLowerCase().includes(view.query.trim().toLocaleLowerCase()))))
902
- if (!stillVisible) view.documentId = ''
903
1026
  renderShell()
904
- document.querySelector('.document-global-search input')?.focus()
1027
+ const input = document.querySelector('.note-tree-search')
1028
+ input?.focus()
1029
+ input?.setSelectionRange(input.value.length, input.value.length)
905
1030
  },
906
1031
  })
907
- if (!view.documentId && visibleDocuments.length) view.documentId = visibleDocuments[0].id
908
- const currentDocument = state.documents.find(document => document.id === view.documentId) || selectedDocument
909
-
910
- return element('section', { class: 'document-page', 'aria-labelledby': 'documents-heading' },
911
- element('div', { class: 'document-page-toolbar' },
912
- renderDocumentModeTabs(),
913
- element('div', { class: 'search-box document-global-search' }, interfaceIcon('search', 'search-symbol'), search),
914
- element('select', {
915
- class: 'select compact-library-picker', 'aria-label': '选择知识库', value: view.knowledgeBaseId,
916
- onChange: (event) => { view.knowledgeBaseId = event.target.value; view.documentId = ''; selectDefaultDocument(); renderShell() },
917
- }, activeBases.map(base => element('option', { value: base.id, selected: base.id === view.knowledgeBaseId }, base.name))),
918
- element('div', { class: 'document-toolbar-actions' },
919
- renderDocumentColumnControls(),
920
- actionButton('+ 新建知识', () => openEntryEditor(), 'primary'),
921
- ),
922
- ),
923
- element('div', {
924
- class: 'document-browser',
925
- 'data-library-hidden': String(view.libraryHidden),
926
- 'data-document-list-hidden': String(view.documentListHidden),
927
- style: `--library-width:${view.libraryWidth}px;--document-list-width:${view.documentListWidth}px`,
928
- },
929
- element('aside', { id: 'knowledge-library-column', class: 'knowledge-library-column', 'aria-label': '知识库列表' },
930
- element('header', { class: 'column-header' },
931
- element('div', {}, element('h2', { id: 'documents-heading' }, '知识库'), element('span', {}, `${activeBases.length} 个可用`)),
932
- ),
933
- activeBases.length ? element('div', { class: 'library-list', role: 'listbox', tabindex: '0', 'data-scroll-key': 'library-list', onKeyDown: event => moveDocumentSelection(event, 'base') },
934
- activeBases.map(base => {
935
- const documentCount = state.documents.filter(document => document.knowledgeBaseId === base.id).length
936
- const selected = base.id === view.knowledgeBaseId
937
- return element('button', {
938
- type: 'button', class: 'library-row', role: 'option', 'aria-selected': String(selected),
939
- onClick: () => { view.knowledgeBaseId = base.id; view.documentId = ''; selectDefaultDocument(); renderShell() },
940
- }, element('span', { class: 'library-dot', 'aria-hidden': 'true' }), element('span', { class: 'library-row-copy' },
941
- element('strong', {}, base.name), element('small', {}, base.description || '通用知识库')),
942
- element('span', { class: 'library-count', 'aria-label': `${documentCount} 篇文档` }, documentCount))
943
- })) : compactEmpty('还没有知识库'),
944
- element('footer', { class: 'column-footer' }, actionButton('+ 新建知识库', () => openKnowledgeBaseEditor(), 'ghost small')),
945
- ),
946
- renderColumnResizer('library', '调整知识库栏宽度'),
947
- element('aside', { id: 'document-list-column', class: 'document-list-column', 'aria-label': '文档列表' },
948
- element('header', { class: 'column-header' },
949
- element('div', {}, element('h2', {}, selectedBase?.name || '文档'), element('span', {}, query ? `找到 ${visibleDocuments.length} 篇` : `${allBaseDocuments.length} 篇文档`)),
950
- ),
951
- visibleDocuments.length ? element('div', { class: 'document-list', role: 'listbox', tabindex: '0', 'data-scroll-key': 'document-list', onKeyDown: event => moveDocumentSelection(event, 'document') },
952
- visibleDocuments.map(document => element('button', {
953
- type: 'button', class: 'document-row', role: 'option', 'aria-selected': String(document.id === view.documentId),
954
- onClick: () => { view.documentId = document.id; renderShell() },
955
- }, element('span', { class: 'document-icon', 'aria-hidden': 'true' }, document.relPath === 'README.md' ? '▣' : '≡'),
956
- element('span', { class: 'document-row-copy' }, element('strong', {}, document.title), element('small', {}, document.relPath)),
957
- element('span', { class: 'document-entry-count' }, document.entryCount))))
958
- : element('div', { class: 'document-empty' }, query ? '没有匹配的文档' : '这个知识库还没有文档'),
1032
+ const tree = activeBases.map(base => {
1033
+ const expanded = view.expandedBases.has(base.id) || Boolean(query)
1034
+ const documents = state.documents.filter(document => document.knowledgeBaseId === base.id
1035
+ && (!query || [document.title, document.relPath, document.content].some(value => value.toLocaleLowerCase().includes(query))))
1036
+ return element('section', { class: 'note-tree-group', 'data-expanded': String(expanded) },
1037
+ element('button', {
1038
+ type: 'button', class: 'note-tree-base', 'aria-expanded': String(expanded),
1039
+ onClick: () => {
1040
+ view.knowledgeBaseId = base.id
1041
+ if (expanded && !query) view.expandedBases.delete(base.id)
1042
+ else view.expandedBases.add(base.id)
1043
+ renderShell()
1044
+ },
1045
+ },
1046
+ element('span', { class: 'tree-disclosure', 'aria-hidden': 'true' }),
1047
+ element('span', { class: 'tree-folder-icon', 'aria-hidden': 'true' }),
1048
+ element('span', { class: 'tree-base-name' }, base.name),
1049
+ element('span', { class: 'tree-count' }, documents.length)),
1050
+ expanded ? element('div', { class: 'note-tree-documents', role: 'group', 'aria-label': `${base.name}文档` },
1051
+ documents.map(document => element('button', {
1052
+ type: 'button', class: 'note-tree-document', 'aria-current': document.id === view.documentId ? 'page' : undefined,
1053
+ onClick: () => { void selectDocument(document.id) },
1054
+ }, element('span', { class: 'tree-document-icon', 'aria-hidden': 'true' }), element('span', { class: 'tree-document-copy' },
1055
+ element('strong', {}, document.title), element('small', {}, document.relPath)))),
1056
+ !query ? element('button', { type: 'button', class: 'note-tree-new', onClick: () => { void startBlankDocument(base.id) } },
1057
+ element('span', { 'aria-hidden': 'true' }, '+'), '新建文档') : null,
1058
+ ) : null,
1059
+ )
1060
+ })
1061
+ return element('section', {
1062
+ class: 'note-workspace', 'aria-labelledby': 'documents-heading',
1063
+ 'data-tree-open': String(view.treeOpen),
1064
+ },
1065
+ element('aside', { class: 'note-tree-panel', 'aria-label': '知识目录' },
1066
+ element('header', { class: 'note-tree-header' },
1067
+ element('div', {}, element('h2', { id: 'documents-heading' }, '知识目录'), element('span', {}, `${state.documents.length} 篇文档`)),
1068
+ actionButton('+', () => { void startBlankDocument(view.knowledgeBaseId || activeBases[0]?.id) }, 'ghost note-add-button', { 'aria-label': '新建文档', title: '新建文档' }),
959
1069
  ),
960
- renderColumnResizer('documentList', '调整文档栏宽度'),
961
- renderDocumentReader(currentDocument, selectedBase),
1070
+ element('div', { class: 'note-tree-search-wrap' }, interfaceIcon('search', 'search-symbol'), search),
1071
+ element('nav', { class: 'note-tree', 'data-scroll-key': 'note-tree' }, tree.length ? tree : element('div', { class: 'note-tree-empty' }, '还没有知识库')),
1072
+ element('footer', { class: 'note-tree-footer' }, actionButton('新建知识库', () => openKnowledgeBaseEditor(), 'ghost small')),
962
1073
  ),
1074
+ view.treeOpen ? element('button', {
1075
+ type: 'button', class: 'note-tree-scrim', 'aria-label': '关闭知识目录',
1076
+ onClick: () => { view.treeOpen = false; renderShell() },
1077
+ }) : null,
1078
+ renderNoteEditor(view.editor, selectedBase),
963
1079
  )
964
1080
  }
965
1081
 
966
- function setDocumentColumnHidden(column, hidden) {
967
- if (column === 'library') state.documentView.libraryHidden = hidden
968
- else state.documentView.documentListHidden = hidden
969
- saveDocumentLayout()
970
- renderShell()
971
- }
972
-
973
- function renderDocumentColumnControls() {
974
- return element('div', { class: 'document-column-controls', role: 'group', 'aria-label': '显示文档栏位' },
975
- element('span', { class: 'document-column-controls-label', 'aria-hidden': 'true' }, '显示'),
976
- documentColumnControl('library', '知识库栏'),
977
- documentColumnControl('documentList', '文档栏'),
978
- )
979
- }
980
-
981
- function documentColumnControl(column, label) {
982
- const layout = DOCUMENT_COLUMN_LAYOUTS[column]
983
- const visible = !state.documentView[layout.hiddenKey]
984
- const action = `${visible ? '收起' : '展开'}${label}`
985
- return element('button', {
986
- type: 'button', class: 'document-column-control', 'data-column': column,
987
- 'aria-label': action, 'aria-pressed': String(visible), title: action,
988
- onClick: () => setDocumentColumnHidden(column, visible),
989
- }, label)
990
- }
991
-
992
- function renderColumnResizer(column, label) {
993
- const layout = DOCUMENT_COLUMN_LAYOUTS[column]
994
- const hidden = state.documentView[layout.hiddenKey]
995
- if (hidden) return null
996
- const value = state.documentView[layout.widthKey]
997
- return element('div', {
998
- class: 'column-resizer', 'data-column': column, role: 'separator', tabindex: '0',
999
- title: `${label};可拖动或使用左右方向键`,
1000
- 'aria-label': label, 'aria-controls': layout.controls, 'aria-orientation': 'vertical',
1001
- 'aria-valuemin': layout.minimum, 'aria-valuemax': layout.maximum, 'aria-valuenow': value,
1002
- 'aria-valuetext': `${value} 像素`,
1003
- onPointerDown: event => startColumnResize(event, column),
1004
- onKeyDown: event => resizeColumnWithKeyboard(event, column),
1005
- }, element('span', { 'aria-hidden': 'true' }, '⋮'))
1006
- }
1007
-
1008
- function startColumnResize(event, column) {
1009
- if (event.button !== 0) return
1010
- event.preventDefault()
1011
- const layout = DOCUMENT_COLUMN_LAYOUTS[column]
1012
- const handle = event.currentTarget
1013
- const browser = handle.closest('.document-browser')
1014
- if (!browser) return
1015
- const startX = event.clientX
1016
- const startWidth = state.documentView[layout.widthKey]
1017
- handle.setPointerCapture?.(event.pointerId)
1018
- handle.classList.add('is-dragging')
1019
- document.body.classList.add('is-resizing-columns')
1020
- const move = moveEvent => {
1021
- const width = clampNumber(startWidth + moveEvent.clientX - startX, layout.minimum, layout.maximum, startWidth)
1022
- setDocumentColumnWidth(column, width, browser, handle)
1082
+ function renderNoteEditor(editor, base) {
1083
+ if (!editor) return element('main', { class: 'note-editor note-editor-empty' },
1084
+ element('div', { class: 'note-empty-content' },
1085
+ element('span', { class: 'empty-document-mark', 'aria-hidden': 'true' }),
1086
+ element('h3', {}, '选择或新建一篇文档'),
1087
+ element('p', {}, '文档保存后会立即参与当前知识库的搜索与召回。'),
1088
+ actionButton('新建文档', () => { void startBlankDocument(state.documentView.knowledgeBaseId || state.knowledgeBases.find(item => item.status === 'active')?.id) }, 'primary'),
1089
+ ))
1090
+ const saveShortcut = event => {
1091
+ if ((event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === 's') {
1092
+ event.preventDefault()
1093
+ void saveDocumentEditor().then(saved => { if (saved) renderShell() })
1094
+ }
1023
1095
  }
1024
- const finish = () => {
1025
- handle.classList.remove('is-dragging')
1026
- document.body.classList.remove('is-resizing-columns')
1027
- handle.removeEventListener('pointermove', move)
1028
- handle.removeEventListener('pointerup', finish)
1029
- handle.removeEventListener('pointercancel', finish)
1030
- saveDocumentLayout()
1096
+ const update = (key, value) => {
1097
+ editor[key] = value
1098
+ editor.dirty = true
1099
+ editor.saveState = '未保存'
1100
+ updateEditorSaveState(editor.saveState)
1031
1101
  }
1032
- handle.addEventListener('pointermove', move)
1033
- handle.addEventListener('pointerup', finish)
1034
- handle.addEventListener('pointercancel', finish)
1035
- }
1036
-
1037
- function resizeColumnWithKeyboard(event, column) {
1038
- if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
1039
- event.preventDefault()
1040
- const layout = DOCUMENT_COLUMN_LAYOUTS[column]
1041
- const current = state.documentView[layout.widthKey]
1042
- const browser = event.currentTarget.closest('.document-browser')
1043
- const width = event.key === 'Home' ? layout.minimum
1044
- : event.key === 'End' ? layout.maximum
1045
- : clampNumber(current + (event.key === 'ArrowRight' ? 16 : -16), layout.minimum, layout.maximum, current)
1046
- setDocumentColumnWidth(column, width, browser, event.currentTarget)
1047
- saveDocumentLayout()
1048
- }
1049
-
1050
- function setDocumentColumnWidth(column, width, browser, handle) {
1051
- const layout = DOCUMENT_COLUMN_LAYOUTS[column]
1052
- state.documentView[layout.widthKey] = width
1053
- browser?.style.setProperty(layout.cssVariable, `${width}px`)
1054
- handle?.setAttribute('aria-valuenow', String(width))
1055
- handle?.setAttribute('aria-valuetext', `${width} 像素`)
1056
- }
1057
-
1058
- function renderDocumentModeTabs() {
1059
- return element('div', { class: 'tabs', role: 'tablist', 'aria-label': '知识视图' },
1060
- documentViewTab('文档', 'documents'),
1061
- documentViewTab('条目管理', 'entries'),
1102
+ const title = element('input', {
1103
+ class: 'note-title-input', value: editor.title, maxlength: 200, placeholder: '无标题文档', 'aria-label': '文档标题',
1104
+ onInput: event => update('title', event.target.value), onKeyDown: saveShortcut,
1105
+ })
1106
+ const body = element('textarea', {
1107
+ class: 'note-body-editor', maxlength: 50000, placeholder: '从这里开始记录…', 'aria-label': '文档正文',
1108
+ onInput: event => { update('body', event.target.value); resizeDocumentEditor(event.target) }, onKeyDown: saveShortcut,
1109
+ })
1110
+ body.value = editor.body
1111
+ const mode = state.documentView.mode === 'edit' ? 'edit' : 'preview'
1112
+ if (mode === 'edit') window.requestAnimationFrame(() => resizeDocumentEditor(body))
1113
+ return element('main', { class: 'note-editor', 'aria-label': '文档编辑器' },
1114
+ element('header', { class: 'note-editor-toolbar' },
1115
+ element('div', { class: 'note-breadcrumb' },
1116
+ element('span', {}, base?.name || '知识库'),
1117
+ element('span', { 'aria-hidden': 'true' }, '/'),
1118
+ element('strong', {}, editor.isNew ? '新文档' : state.documents.find(item => item.id === editor.id)?.relPath || editor.title)),
1119
+ element('div', { class: 'note-editor-actions' },
1120
+ element('div', { class: 'note-mode-switch', role: 'tablist', 'aria-label': '文档视图' }, [
1121
+ ['edit', '编辑'],
1122
+ ['preview', '预览'],
1123
+ ].map(([value, label]) => element('button', {
1124
+ type: 'button', role: 'tab', 'aria-selected': String(mode === value),
1125
+ onClick: () => switchDocumentMode(value),
1126
+ }, label))),
1127
+ element('span', { class: 'editor-save-status', role: 'status' }, editor.saveState),
1128
+ !editor.isNew ? actionButton('删除', () => confirmDeleteDocument(editor), 'ghost small') : null,
1129
+ actionButton(editor.isNew ? '创建文档' : '保存', () => { void saveDocumentEditor().then(saved => { if (saved) renderShell() }) }, 'primary small'),
1130
+ ),
1131
+ ),
1132
+ element('div', { class: 'note-editor-scroll', 'data-scroll-key': 'note-editor' },
1133
+ element('article', { class: `note-paper is-${mode}`, 'data-document-mode': mode },
1134
+ mode === 'edit' ? title : element('h1', { class: 'note-preview-title' }, editor.title || '无标题文档'),
1135
+ element('div', { class: 'note-document-meta' },
1136
+ element('span', {}, editor.isNew ? '尚未保存' : `更新于 ${formatDate(editor.updatedAt)}`),
1137
+ element('span', {}, editor.scope.kind === 'global' ? '全局知识' : `项目 · ${editor.scope.id}`)),
1138
+ mode === 'edit' ? body : renderMarkdownPreview(editor.body),
1139
+ ),
1140
+ ),
1141
+ element('footer', { class: 'note-inspector' },
1142
+ element('label', {}, element('span', {}, '类型'), element('select', {
1143
+ class: 'note-meta-select', onChange: event => update('type', event.target.value),
1144
+ }, TYPES.map(type => element('option', { value: type, selected: type === editor.type }, TYPE_LABELS[type])))),
1145
+ element('label', { class: 'note-tags-field' }, element('span', {}, '标签'), element('input', {
1146
+ value: editor.tagsText, placeholder: '用逗号分隔', onInput: event => update('tagsText', event.target.value),
1147
+ })),
1148
+ element('span', { class: 'note-format-hint' }, mode === 'edit' ? 'Markdown · Ctrl/⌘ S 保存' : 'Markdown 预览'),
1149
+ ),
1062
1150
  )
1063
1151
  }
1064
1152
 
1065
- function documentViewTab(label, mode) {
1066
- return element('button', {
1067
- type: 'button', role: 'tab', class: 'tab', 'aria-selected': String(state.documentView.mode === mode),
1068
- onClick: async () => {
1069
- state.documentView.mode = mode
1070
- if (mode === 'entries') await loadEntries()
1071
- else await loadDocuments()
1072
- renderShell()
1073
- },
1074
- }, label)
1075
- }
1076
-
1077
- function moveDocumentSelection(event, kind) {
1078
- if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Enter') return
1079
- const view = state.documentView
1080
- const values = kind === 'base'
1081
- ? state.knowledgeBases.filter(base => base.status === 'active').map(base => base.id)
1082
- : state.documents.filter(document => document.knowledgeBaseId === view.knowledgeBaseId).map(document => document.id)
1083
- const current = kind === 'base' ? view.knowledgeBaseId : view.documentId
1084
- if (event.key === 'Enter') return
1085
- event.preventDefault()
1086
- const index = Math.max(0, values.indexOf(current))
1087
- const next = event.key === 'ArrowDown' ? Math.min(values.length - 1, index + 1) : Math.max(0, index - 1)
1088
- if (!values[next]) return
1089
- if (kind === 'base') { view.knowledgeBaseId = values[next]; view.documentId = ''; selectDefaultDocument() }
1090
- else view.documentId = values[next]
1153
+ function switchDocumentMode(mode) {
1154
+ if (mode !== 'edit' && mode !== 'preview') return
1155
+ state.documentView.mode = mode
1091
1156
  renderShell()
1092
- document.querySelector(kind === 'base' ? '.library-list' : '.document-list')?.focus()
1093
- }
1094
-
1095
- function renderDocumentReader(document, base) {
1096
- if (!document) return element('main', { class: 'document-reader document-reader-empty' },
1097
- element('div', {}, element('strong', {}, '选择一篇文档'), element('p', {}, '文档内容会在这里显示。')))
1098
- return element('main', { class: 'document-reader', 'aria-labelledby': 'document-reader-title' },
1099
- element('header', { class: 'reader-toolbar' },
1100
- element('div', { class: 'reader-title' }, element('span', {}, base?.name || document.knowledgeBaseId), element('strong', { id: 'document-reader-title' }, document.relPath)),
1101
- element('select', {
1102
- class: 'select mobile-document-picker', 'aria-label': '选择文档',
1103
- onChange: (event) => { state.documentView.documentId = event.target.value; renderShell() },
1104
- }, state.documents.filter(item => item.knowledgeBaseId === document.knowledgeBaseId)
1105
- .map(item => element('option', { value: item.id, selected: item.id === document.id }, item.relPath))),
1106
- element('div', { class: 'reader-actions' }, badge(`${document.entryCount} 条知识`), actionButton('添加内容', () => openEntryEditor(), 'small')),
1107
- ),
1108
- element('article', { class: 'markdown-document', 'data-scroll-key': 'document-reader' }, renderMarkdown(document.content)),
1109
- element('footer', { class: 'reader-footer' },
1110
- element('span', {}, `自动整理 · ${formatDate(document.updatedAt)}`),
1111
- element('span', {}, `SHA-256 · ${document.contentHash.slice(0, 8)}`),
1112
- ),
1113
- )
1157
+ if (mode === 'edit') window.requestAnimationFrame(() => document.querySelector('.note-body-editor')?.focus())
1114
1158
  }
1115
1159
 
1116
- function renderMarkdown(markdown) {
1117
- const nodes = []
1118
- const lines = markdown.replaceAll('\r\n', '\n').split('\n')
1119
- let paragraph = []
1120
- let list = []
1121
- let code = []
1122
- let inCode = false
1123
- const flushParagraph = () => {
1124
- if (paragraph.length) nodes.push(element('p', {}, paragraph.join(' ')))
1125
- paragraph = []
1126
- }
1127
- const flushList = () => {
1128
- if (list.length) nodes.push(element('ul', {}, list.map(item => element('li', {}, item))))
1129
- list = []
1130
- }
1131
- const flushCode = () => {
1132
- if (code.length) nodes.push(element('pre', {}, element('code', {}, code.join('\n'))))
1133
- code = []
1134
- }
1135
- for (const raw of lines) {
1136
- const line = raw.trimEnd()
1137
- if (line.startsWith('```')) {
1138
- flushParagraph(); flushList()
1139
- if (inCode) flushCode()
1140
- inCode = !inCode
1141
- continue
1142
- }
1143
- if (inCode) { code.push(raw); continue }
1144
- const heading = /^(#{1,4})\s+(.+)$/.exec(line)
1145
- const bullet = /^[-*]\s+(.+)$/.exec(line)
1146
- if (heading) {
1147
- flushParagraph(); flushList()
1148
- nodes.push(element(`h${heading[1].length}`, {}, heading[2]))
1149
- } else if (line.startsWith('> ')) {
1150
- flushParagraph(); flushList()
1151
- nodes.push(element('blockquote', {}, line.slice(2)))
1152
- } else if (bullet) {
1153
- flushParagraph(); list.push(bullet[1])
1154
- } else if (!line.trim()) {
1155
- flushParagraph(); flushList()
1156
- } else if (/^<small>.*<\/small>$/.test(line.trim())) {
1157
- flushParagraph(); flushList()
1158
- nodes.push(element('small', { class: 'markdown-metadata' }, line.trim().replace(/^<small>|<\/small>$/g, '')))
1159
- } else paragraph.push(line.trim())
1160
- }
1161
- flushParagraph(); flushList(); flushCode()
1162
- return nodes
1160
+ function renderMarkdownPreview(markdown) {
1161
+ if (!markdown.trim()) return element('div', { class: 'markdown-preview is-empty', role: 'document' }, '暂无正文')
1162
+ const preview = element('div', { class: 'markdown-preview', role: 'document' })
1163
+ preview.innerHTML = window.DshKnowledgeMarkdown.renderMarkdown(markdown)
1164
+ return preview
1163
1165
  }
1164
1166
 
1165
- function renderLegacyEntries() {
1166
- const queryInput = element('input', { class: 'input', type: 'search', value: state.entryFilters.query, placeholder: '搜索标题、正文或标签', 'aria-label': '搜索知识' })
1167
- const projectInput = element('input', { class: 'input', value: state.entryFilters.projectId, placeholder: '项目路径(可选)', 'aria-label': '按项目路径筛选' })
1168
- const applyFilters = async () => {
1169
- state.entryFilters.query = queryInput.value
1170
- state.entryFilters.projectId = projectInput.value
1171
- state.loading = true
1172
- renderShell()
1173
- try { await loadEntries() } catch (error) { state.error = friendlyError(error) } finally { state.loading = false; renderShell() }
1174
- }
1175
- const toolbar = element('form', { class: 'toolbar', onSubmit: (event) => { event.preventDefault(); void applyFilters() } },
1176
- element('div', { class: 'search-box' }, element('span', { class: 'search-symbol', 'aria-hidden': 'true' }, '⌕'), queryInput),
1177
- selectControl('状态', [{ value: 'active', label: '生效中' }, { value: 'archived', label: '已归档' }], state.entryFilters.status, (value) => { state.entryFilters.status = value; void applyFilters() }),
1178
- selectControl('类型', [{ value: '', label: '全部类型' }, ...TYPES.map(type => ({ value: type, label: TYPE_LABELS[type] }))], state.entryFilters.type, (value) => { state.entryFilters.type = value; void applyFilters() }),
1179
- selectControl('知识库', [{ value: '', label: '全部知识库' }, ...state.knowledgeBases.map(base => ({ value: base.id, label: base.name }))], state.entryFilters.knowledgeBaseId, (value) => { state.entryFilters.knowledgeBaseId = value; void applyFilters() }),
1180
- projectInput,
1181
- actionButton('搜索', () => {}, 'primary', { type: 'submit' }),
1182
- )
1183
- return element('section', { class: 'document-page legacy-entries-page', 'aria-labelledby': 'entries-heading' },
1184
- element('div', { class: 'document-page-toolbar legacy-entries-toolbar' },
1185
- renderDocumentModeTabs(),
1186
- element('div', { class: 'document-toolbar-actions' }, actionButton('+ 新建知识', () => openEntryEditor(), 'primary')),
1187
- ),
1188
- element('div', { class: 'section-heading' }, element('div', {}, element('h2', { id: 'entries-heading' }, `${STATUS_LABELS[state.entryFilters.status]} · ${state.entries.length}`), element('p', {}, '搜索范围会包含全局知识和指定项目知识'))),
1189
- toolbar,
1190
- state.entries.length
1191
- ? element('div', { class: 'card-grid' }, state.entries.map(renderEntryCard))
1192
- : emptyState('没有匹配的知识', '调整筛选条件,或者手动创建第一条知识。', '新建知识', () => openEntryEditor()),
1193
- state.nextCursor ? element('div', { class: 'loading' }, actionButton('加载更多', async () => {
1194
- const cursor = state.nextCursor
1195
- state.nextCursor = null
1196
- try { await loadEntries(cursor); renderShell() } catch (error) { showToast(friendlyError(error), 'error') }
1197
- })) : null,
1198
- )
1167
+ function resizeDocumentEditor(editor) {
1168
+ editor.style.height = 'auto'
1169
+ editor.style.height = `${Math.max(420, editor.scrollHeight)}px`
1199
1170
  }
1200
1171
 
1201
- function renderEntryCard(entry) {
1202
- const archived = entry.status === 'archived'
1203
- return element('article', { class: 'knowledge-card' },
1204
- element('div', { class: 'card-top' },
1205
- element('div', {}, badge(knowledgeBaseName(entry.knowledgeBaseId)), ' ', badge(TYPE_LABELS[entry.type], 'accent'), ' ', archived ? badge('已归档') : badge('生效中', 'success')),
1206
- element('span', { class: 'field-hint' }, `v${entry.version}`),
1207
- ),
1208
- element('h3', {}, entry.title),
1209
- element('p', {}, entry.body),
1210
- entry.tags.length ? element('div', { class: 'tag-row', 'aria-label': '标签' }, entry.tags.map(tag => element('span', { class: 'tag' }, `#${tag}`))) : null,
1211
- element('div', { class: 'card-footer' },
1212
- element('span', { class: 'field-hint' }, `${scopeLabel(entry.scope)} · ${formatDate(entry.updatedAt)}`),
1213
- element('div', { class: 'card-actions' },
1214
- actionButton('历史', () => openHistory(entry), 'ghost small'),
1215
- actionButton(archived ? '编辑并恢复' : '编辑', () => openEntryEditor(entry), 'ghost small'),
1216
- !archived ? actionButton('归档', () => confirmArchive(entry), 'ghost small') : actionButton('彻底删除', () => confirmDelete(entry), 'danger small'),
1217
- ),
1218
- ),
1219
- )
1172
+ function confirmDeleteDocument(editor) {
1173
+ openConfirm({
1174
+ title: `删除“${editor.title}”?`,
1175
+ message: '文档和对应的召回知识会被永久删除,此操作无法撤销。',
1176
+ confirmLabel: '永久删除', danger: true,
1177
+ onConfirm: async () => {
1178
+ await api(`entries/${encodeURIComponent(editor.id)}`, { method: 'DELETE' })
1179
+ state.documentView.documentId = ''
1180
+ state.documentView.editor = null
1181
+ state.stats = null
1182
+ await loadDocuments()
1183
+ renderShell()
1184
+ showToast('文档已删除。')
1185
+ },
1186
+ })
1220
1187
  }
1221
1188
 
1222
1189
  function renderCandidates() {
1223
1190
  const statuses = [['pending', '待审核'], ['approved', '已通过'], ['rejected', '已拒绝']]
1224
1191
  return element('section', { 'aria-labelledby': 'candidates-heading' },
1225
1192
  element('div', { class: 'section-heading' },
1226
- element('div', {}, element('h2', { id: 'candidates-heading' }, 'AI 提取候选'), element('p', {}, '审核写入的结果与冲突项会在这里等待确认;直接写入的普通结果会自动生效。')),
1193
+ element('div', {}, element('h2', { id: 'candidates-heading' }, 'AI 提取候选'), element('p', {}, '审核写入、模型推断、低置信度结果和冲突项会在这里等待确认;只有高置信度且证据明确的结果才能直接写入。')),
1227
1194
  element('div', { class: 'tabs', role: 'tablist', 'aria-label': '候选状态' }, statuses.map(([value, label]) => element('button', {
1228
1195
  type: 'button', role: 'tab', class: 'tab', 'aria-selected': String(state.candidateStatus === value),
1229
1196
  onClick: async () => { state.candidateStatus = value; await navigate('candidates') },
@@ -1236,11 +1203,17 @@ function renderCandidates() {
1236
1203
 
1237
1204
  function renderCandidateCard(candidate) {
1238
1205
  const pending = candidate.status === 'pending'
1239
- const targetDocument = TYPE_DOCUMENTS[candidate.draft.type] || 'README.md'
1206
+ const targetDocument = candidate.action === 'create'
1207
+ ? `新文档“${candidate.draft.title}”`
1208
+ : `文档“${candidate.draft.title}”`
1240
1209
  return element('article', { class: 'candidate' },
1241
1210
  element('div', { class: 'candidate-header' },
1242
1211
  element('div', {},
1243
- element('div', {}, badge(knowledgeBaseName(candidate.draft.knowledgeBaseId)), ' ', badge(ACTION_LABELS[candidate.action], candidate.action === 'conflict' ? 'warning' : 'accent'), ' ', badge(TYPE_LABELS[candidate.draft.type])),
1212
+ element('div', {},
1213
+ badge(knowledgeBaseName(candidate.draft.knowledgeBaseId)), ' ',
1214
+ badge(ACTION_LABELS[candidate.action], candidate.action === 'conflict' ? 'warning' : 'accent'), ' ',
1215
+ badge(TYPE_LABELS[candidate.draft.type]), ' ',
1216
+ candidate.draft.source?.evidence ? badge(EVIDENCE_LABELS[candidate.draft.source.evidence] || candidate.draft.source.evidence) : null),
1244
1217
  element('h3', {}, candidate.draft.title),
1245
1218
  ),
1246
1219
  badge(STATUS_LABELS[candidate.status], candidate.status === 'approved' ? 'success' : candidate.status === 'rejected' ? 'danger' : 'warning'),
@@ -1249,7 +1222,7 @@ function renderCandidateCard(candidate) {
1249
1222
  element('p', { class: 'candidate-content' }, candidate.draft.body),
1250
1223
  element('div', { class: 'candidate-reason' },
1251
1224
  element('strong', {}, '文档变更'),
1252
- element('span', { class: 'candidate-target' }, `将${candidate.action === 'create' ? '追加' : '更新'}${targetDocument}`),
1225
+ element('span', { class: 'candidate-target' }, `${candidate.action === 'create' ? '创建' : '更新'}${targetDocument}`),
1253
1226
  element('strong', {}, '模型判断依据'), candidate.reason || '未提供判断说明'),
1254
1227
  ),
1255
1228
  element('div', { class: 'candidate-footer' },
@@ -1373,7 +1346,7 @@ function openKnowledgeBaseEditor(base) {
1373
1346
  )),
1374
1347
  provider.wrapper, model.wrapper, modelHint,
1375
1348
  )
1376
- openModal({
1349
+ openSheet({
1377
1350
  title: base ? '编辑知识库' : '创建知识库',
1378
1351
  description: '匹配描述先判断对话是否属于该库;提取要求再规定应该收录什么。',
1379
1352
  body: form,
@@ -1421,7 +1394,7 @@ function openBulkMountEditor() {
1421
1394
  includeTags.wrapper,
1422
1395
  excludeTags.wrapper,
1423
1396
  )
1424
- openModal({
1397
+ openSheet({
1425
1398
  title: `批量挂载到${manager.targetKind === 'project' ? '项目' : '会话'}`,
1426
1399
  description: targetId,
1427
1400
  body: form,
@@ -1520,7 +1493,7 @@ function openMountEditor(base, targetKind, targetId, explicit, inherited) {
1520
1493
  }
1521
1494
  enabled.addEventListener('change', updateAvailability)
1522
1495
  updateAvailability()
1523
- openModal({
1496
+ openSheet({
1524
1497
  title: `${targetKind === 'project' ? '项目' : '会话'}挂载 · ${base.name}`,
1525
1498
  description: targetId,
1526
1499
  body: form,
@@ -1646,8 +1619,8 @@ function openEntryEditor(entry, candidate) {
1646
1619
  )
1647
1620
  body.wrapper.classList.add('span-2')
1648
1621
  tags.wrapper.classList.add('span-2')
1649
- const modeTitle = candidate ? '编辑并通过候选' : entry ? '编辑知识' : '新建知识'
1650
- const modal = openModal({ title: modeTitle, description: candidate ? '保存后,这条候选会立即通过并写入知识库。' : '知识保存后会立即参与后续召回。', body: form, primaryLabel: candidate ? '通过并保存' : '保存', onPrimary: async () => {
1622
+ const modeTitle = candidate ? '编辑并通过候选' : entry ? '编辑知识文档' : '新建知识文档'
1623
+ const modal = openSheet({ title: modeTitle, description: candidate ? '保存后,这条候选会立即通过并写入知识库。' : '文档保存后会立即参与后续召回。', body: form, primaryLabel: candidate ? '通过并保存' : '保存文档', onPrimary: async () => {
1651
1624
  if (!form.reportValidity()) return false
1652
1625
  const draft = {
1653
1626
  knowledgeBaseId: knowledgeBase.input.value,
@@ -1777,10 +1750,15 @@ function openConfirm({ title, message, confirmLabel, danger, onConfirm }) {
1777
1750
  return openModal({ title, body: element('p', {}, message), primaryLabel: confirmLabel, primaryVariant: danger ? 'danger' : 'primary', onPrimary: async () => { await onConfirm(); return true } })
1778
1751
  }
1779
1752
 
1780
- function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消' }) {
1753
+ function openSheet(options) {
1754
+ return openModal({ ...options, presentation: 'sheet' })
1755
+ }
1756
+
1757
+ function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消', presentation = 'modal' }) {
1781
1758
  const previouslyFocused = document.activeElement
1782
- const backdrop = element('div', { class: 'dialog-backdrop' })
1783
- const dialog = element('section', { class: `dialog ${primaryLabel ? '' : 'narrow'}`.trim(), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dialog-title' })
1759
+ const isSheet = presentation === 'sheet'
1760
+ const backdrop = element('div', { class: `dialog-backdrop${isSheet ? ' sheet-backdrop' : ''}` })
1761
+ const dialog = element('section', { class: `dialog${isSheet ? ' sheet' : ''} ${primaryLabel ? '' : 'narrow'}`.trim(), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dialog-title' })
1784
1762
  let busy = false
1785
1763
  const close = () => {
1786
1764
  if (busy) return
@@ -1851,4 +1829,5 @@ function friendlyError(error) {
1851
1829
  return error.message || '操作失败,请稍后重试。'
1852
1830
  }
1853
1831
 
1832
+ installHostThemeBridge()
1854
1833
  void boot()