@lemoncat7/dsh-knowledge 0.7.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) 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 +210 -170
  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/constants.d.ts +3 -0
  17. package/lib/constants.d.ts.map +1 -0
  18. package/lib/constants.js +3 -0
  19. package/lib/constants.js.map +1 -0
  20. package/lib/documents/markdown.d.ts +33 -0
  21. package/lib/documents/markdown.d.ts.map +1 -0
  22. package/lib/documents/markdown.js +91 -0
  23. package/lib/documents/markdown.js.map +1 -0
  24. package/lib/documents/path.d.ts +4 -0
  25. package/lib/documents/path.d.ts.map +1 -0
  26. package/lib/documents/path.js +11 -0
  27. package/lib/documents/path.js.map +1 -0
  28. package/lib/documents/store.d.ts +32 -0
  29. package/lib/documents/store.d.ts.map +1 -0
  30. package/lib/documents/store.js +272 -0
  31. package/lib/documents/store.js.map +1 -0
  32. package/lib/domain.d.ts +2 -0
  33. package/lib/domain.d.ts.map +1 -1
  34. package/lib/domain.js +6 -0
  35. package/lib/domain.js.map +1 -1
  36. package/lib/extraction.d.ts.map +1 -1
  37. package/lib/extraction.js +210 -73
  38. package/lib/extraction.js.map +1 -1
  39. package/lib/index.d.ts +3 -2
  40. package/lib/index.d.ts.map +1 -1
  41. package/lib/index.js +9 -4
  42. package/lib/index.js.map +1 -1
  43. package/lib/local-provider.d.ts +2 -0
  44. package/lib/local-provider.d.ts.map +1 -1
  45. package/lib/local-provider.js +145 -79
  46. package/lib/local-provider.js.map +1 -1
  47. package/lib/recall.d.ts +7 -2
  48. package/lib/recall.d.ts.map +1 -1
  49. package/lib/recall.js +46 -30
  50. package/lib/recall.js.map +1 -1
  51. package/lib/retrieval.d.ts +14 -4
  52. package/lib/retrieval.d.ts.map +1 -1
  53. package/lib/retrieval.js +133 -25
  54. package/lib/retrieval.js.map +1 -1
  55. package/lib/runtime.d.ts +5 -0
  56. package/lib/runtime.d.ts.map +1 -1
  57. package/lib/runtime.js.map +1 -1
  58. package/lib/theme-bridge.d.ts +23 -0
  59. package/lib/theme-bridge.d.ts.map +1 -0
  60. package/lib/theme-bridge.js +72 -0
  61. package/lib/theme-bridge.js.map +1 -0
  62. package/lib/tools.d.ts.map +1 -1
  63. package/lib/tools.js +165 -7
  64. package/lib/tools.js.map +1 -1
  65. package/lib/web-markdown-preview.d.ts +3 -0
  66. package/lib/web-markdown-preview.d.ts.map +1 -0
  67. package/lib/web-markdown-preview.js +24 -0
  68. package/lib/web-markdown-preview.js.map +1 -0
  69. package/lib/web.d.ts.map +1 -1
  70. package/lib/web.js +1 -0
  71. package/lib/web.js.map +1 -1
  72. package/package.json +16 -3
  73. package/web/app.js +326 -359
  74. package/web/index.html +1 -0
  75. package/web/markdown-preview.js +72 -0
  76. 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,44 @@ const state = {
74
72
 
75
73
  let scrollRestoreFrame = 0
76
74
 
75
+ function installHostThemeBridge() {
76
+ if (window.parent === window) return
77
+ window.addEventListener('message', event => {
78
+ if (event.source !== window.parent || event.origin !== window.location.origin) return
79
+ const message = event.data
80
+ if (!message || message.type !== HOST_THEME_MESSAGE || message.version !== HOST_THEME_PROTOCOL_VERSION) return
81
+ if (message.colorScheme !== 'light' && message.colorScheme !== 'dark') return
82
+ if (!message.tokens || typeof message.tokens !== 'object' || Array.isArray(message.tokens)) return
83
+
84
+ const root = document.documentElement
85
+ for (const name of [...HOST_THEME_COLOR_TOKENS, ...HOST_THEME_STYLE_TOKENS]) root.style.removeProperty(name)
86
+ for (const [name, value] of Object.entries(message.tokens)) {
87
+ if (typeof value !== 'string' || value.length === 0 || value.length > 512) continue
88
+ if (HOST_THEME_COLOR_TOKENS.has(name) && CSS.supports('color', value)) root.style.setProperty(name, value)
89
+ else if (HOST_THEME_STYLE_TOKENS.has(name) && CSS.supports('box-shadow', value)) root.style.setProperty(name, value)
90
+ }
91
+ root.dataset.dshHostTheme = 'true'
92
+ root.style.colorScheme = message.colorScheme
93
+ document.querySelector('meta[name="color-scheme"]')?.setAttribute('content', message.colorScheme)
94
+ const background = root.style.getPropertyValue('--bg')
95
+ if (background) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', background)
96
+ })
97
+ window.parent.postMessage({
98
+ type: HOST_THEME_READY_MESSAGE,
99
+ version: HOST_THEME_PROTOCOL_VERSION,
100
+ }, window.location.origin)
101
+ }
102
+
77
103
  function readDocumentLayout() {
78
104
  const fallback = {
79
- sidebarHidden: false, libraryHidden: false, documentListHidden: false,
80
- sidebarWidth: 236, libraryWidth: 220, documentListWidth: 280,
105
+ sidebarHidden: false,
106
+ sidebarWidth: 236,
81
107
  }
82
108
  try {
83
109
  const value = JSON.parse(localStorage.getItem(DOCUMENT_LAYOUT_KEY) || '{}')
84
110
  return {
85
111
  sidebarHidden: value.sidebarHidden === true,
86
- libraryHidden: value.libraryHidden === true,
87
- documentListHidden: value.documentListHidden === true,
88
112
  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
113
  }
92
114
  } catch { return fallback }
93
115
  }
@@ -96,11 +118,7 @@ function saveDocumentLayout() {
96
118
  try {
97
119
  localStorage.setItem(DOCUMENT_LAYOUT_KEY, JSON.stringify({
98
120
  sidebarHidden: state.documentView.sidebarHidden,
99
- libraryHidden: state.documentView.libraryHidden,
100
- documentListHidden: state.documentView.documentListHidden,
101
121
  sidebarWidth: state.documentView.sidebarWidth,
102
- libraryWidth: state.documentView.libraryWidth,
103
- documentListWidth: state.documentView.documentListWidth,
104
122
  }))
105
123
  } catch {}
106
124
  }
@@ -217,7 +235,7 @@ async function boot() {
217
235
  if (AUTH_MODE === 'same-origin') {
218
236
  state.token = ''
219
237
  try { state.service = await api('service') } catch {}
220
- await navigate('overview')
238
+ await navigate('entries')
221
239
  return
222
240
  }
223
241
  if (!state.token) {
@@ -226,7 +244,7 @@ async function boot() {
226
244
  }
227
245
  try {
228
246
  await api('entries?limit=1')
229
- await navigate('overview')
247
+ await navigate('entries')
230
248
  } catch (error) {
231
249
  sessionStorage.removeItem(TOKEN_KEY)
232
250
  state.token = ''
@@ -253,7 +271,7 @@ function renderLogin(message = '') {
253
271
  try {
254
272
  await api('entries?limit=1')
255
273
  sessionStorage.setItem(TOKEN_KEY, token)
256
- await navigate('overview')
274
+ await navigate('entries')
257
275
  } catch (requestError) {
258
276
  state.token = ''
259
277
  error.textContent = requestError.status === 401 ? '令牌无效或已被撤销。' : '连接失败,请检查服务状态后重试。'
@@ -377,6 +395,8 @@ async function loadDocuments() {
377
395
  : bases.find(base => base.status === 'active')?.id || bases[0]?.id || ''
378
396
  }
379
397
  selectDefaultDocument()
398
+ if (view.documentId) await loadDocumentEditor(view.documentId)
399
+ else view.editor = null
380
400
  if (!state.stats) await refreshStats()
381
401
  }
382
402
 
@@ -384,10 +404,107 @@ function selectDefaultDocument() {
384
404
  const view = state.documentView
385
405
  const documents = state.documents.filter(document => document.knowledgeBaseId === view.knowledgeBaseId)
386
406
  if (!documents.some(document => document.id === view.documentId)) {
387
- view.documentId = documents.find(document => document.relPath === 'README.md')?.id || documents[0]?.id || ''
407
+ view.documentId = documents[0]?.id || ''
408
+ }
409
+ if (view.knowledgeBaseId) view.expandedBases.add(view.knowledgeBaseId)
410
+ }
411
+
412
+ async function loadDocumentEditor(id) {
413
+ const entry = await api(`entries/${encodeURIComponent(id)}`)
414
+ state.documentView.mode = 'preview'
415
+ state.documentView.editor = {
416
+ ...entry,
417
+ tagsText: entry.tags.join(', '),
418
+ dirty: false,
419
+ isNew: false,
420
+ saveState: '已保存',
421
+ }
422
+ }
423
+
424
+ function createBlankDocument(baseId) {
425
+ const base = state.knowledgeBases.find(item => item.id === baseId && item.status === 'active')
426
+ if (!base) return showToast('请先选择一个可用知识库。', 'error')
427
+ const view = state.documentView
428
+ view.knowledgeBaseId = base.id
429
+ view.documentId = ''
430
+ view.mode = 'edit'
431
+ view.treeOpen = false
432
+ view.expandedBases.add(base.id)
433
+ view.editor = {
434
+ id: '', knowledgeBaseId: base.id, title: '', body: '', type: 'fact', tags: [], tagsText: '',
435
+ scope: { kind: 'global' }, confidence: .8, dirty: true, isNew: true, saveState: '新文档',
436
+ }
437
+ renderShell()
438
+ document.querySelector('.note-title-input')?.focus()
439
+ }
440
+
441
+ async function startBlankDocument(baseId) {
442
+ const editor = state.documentView.editor
443
+ const emptyDraft = editor?.isNew && !editor.title.trim() && !editor.body.trim()
444
+ if (editor?.dirty && !emptyDraft && !await saveDocumentEditor()) return
445
+ createBlankDocument(baseId)
446
+ }
447
+
448
+ async function selectDocument(id) {
449
+ const editor = state.documentView.editor
450
+ const emptyDraft = editor?.isNew && !editor.title.trim() && !editor.body.trim()
451
+ if (editor?.dirty && !emptyDraft && !await saveDocumentEditor()) return
452
+ state.documentView.documentId = id
453
+ state.documentView.treeOpen = false
454
+ await loadDocumentEditor(id)
455
+ renderShell()
456
+ }
457
+
458
+ function editorDraft(editor) {
459
+ return {
460
+ knowledgeBaseId: editor.knowledgeBaseId,
461
+ title: editor.title.trim(),
462
+ body: editor.body.trim(),
463
+ type: editor.type,
464
+ tags: parseTags(editor.tagsText),
465
+ scope: editor.scope,
466
+ confidence: editor.confidence,
467
+ ...(editor.source ? { source: editor.source } : {}),
388
468
  }
389
469
  }
390
470
 
471
+ async function saveDocumentEditor() {
472
+ const editor = state.documentView.editor
473
+ if (!editor || !editor.dirty) return true
474
+ if (!editor.title.trim() || !editor.body.trim()) {
475
+ showToast('标题和正文填写完整后才能保存。', 'error')
476
+ return false
477
+ }
478
+ editor.saveState = '正在保存…'
479
+ updateEditorSaveState(editor.saveState)
480
+ try {
481
+ const draft = editorDraft(editor)
482
+ const saved = editor.isNew
483
+ ? await api('entries', { method: 'POST', body: { draft } })
484
+ : await api(`entries/${encodeURIComponent(editor.id)}`, { method: 'PUT', body: { draft } })
485
+ editor.id = saved.id
486
+ editor.isNew = false
487
+ editor.dirty = false
488
+ editor.updatedAt = saved.updatedAt
489
+ editor.saveState = '已保存'
490
+ state.documentView.documentId = saved.id
491
+ updateEditorSaveState(editor.saveState)
492
+ const documents = await api('documents')
493
+ state.documents = documents
494
+ return true
495
+ } catch (error) {
496
+ editor.saveState = '保存失败'
497
+ updateEditorSaveState(editor.saveState)
498
+ showToast(friendlyError(error), 'error')
499
+ return false
500
+ }
501
+ }
502
+
503
+ function updateEditorSaveState(label) {
504
+ const node = document.querySelector('.editor-save-status')
505
+ if (node) node.textContent = label
506
+ }
507
+
391
508
  async function loadCandidates() {
392
509
  const [candidates] = await Promise.all([
393
510
  api(`candidates?status=${state.candidateStatus}&limit=100`),
@@ -408,9 +525,9 @@ function renderShell() {
408
525
  captureScrollPosition()
409
526
  const titles = {
410
527
  overview: ['概览', '知识库运行状态与最近活动'],
411
- bases: ['知识库', '创建知识库,并限定项目与会话的召回和写入范围'],
412
- entries: ['文档', '按知识库浏览自动整理的 Markdown 文档'],
413
- candidates: ['审核', '确认 AI 提取结果后再写入知识库'],
528
+ bases: ['知识库与挂载', '管理知识目录,并限定项目与会话的召回和写入范围'],
529
+ entries: ['知识文档', '在知识目录中阅读、整理和维护 Markdown 文档'],
530
+ candidates: ['待审核', '确认 AI 提取结果后再写入知识文档'],
414
531
  tokens: ['访问管理', '管理其他客户端连接中央知识库的权限'],
415
532
  }
416
533
  const [title, subtitle] = titles[state.view]
@@ -427,6 +544,10 @@ function renderShell() {
427
544
  element('div', { class: 'topbar-title' },
428
545
  actionButton('☰', () => { state.menuOpen = !state.menuOpen; renderShell() }, 'ghost mobile-menu', { 'aria-label': '打开导航菜单' }),
429
546
  paneToggleButton('main', !state.documentView.sidebarHidden, () => setSidebarHidden(!state.documentView.sidebarHidden), '主导航栏'),
547
+ state.view === 'entries' ? paneToggleButton('library', state.documentView.treeOpen, () => {
548
+ state.documentView.treeOpen = !state.documentView.treeOpen
549
+ renderShell()
550
+ }, '知识目录') : null,
430
551
  element('div', {}, element('h1', {}, title), element('p', {}, subtitle)),
431
552
  ),
432
553
  ),
@@ -508,8 +629,8 @@ function setSidebarHidden(hidden) {
508
629
  function renderSidebar() {
509
630
  const pending = state.stats?.candidates.pending
510
631
  const navGroups = [
511
- ['工作区', [['overview', '概览'], ['bases', '知识库'], ['entries', '文档'], ['candidates', '审核']]],
512
- ['服务', [['tokens', '访问管理']].filter(([id]) => id !== 'tokens' || !state.service.remote)],
632
+ ['知识工作区', [['entries', '文档'], ['candidates', '待审核'], ['bases', '知识库与挂载']]],
633
+ ['连接', [['tokens', '访问管理']].filter(([id]) => id !== 'tokens' || !state.service.remote)],
513
634
  ].filter(([, items]) => items.length)
514
635
  return element('aside', { class: 'sidebar', 'aria-label': '知识库导航' },
515
636
  element('div', { class: 'brand' },
@@ -724,7 +845,6 @@ function renderKnowledgeBaseCard(base) {
724
845
  state.entryFilters.knowledgeBaseId = base.id
725
846
  state.documentView.knowledgeBaseId = base.id
726
847
  state.documentView.documentId = ''
727
- state.documentView.mode = 'documents'
728
848
  void navigate('entries')
729
849
  }, 'ghost small'),
730
850
  archived ? actionButton('恢复', () => confirmRestoreKnowledgeBase(base), 'small') : actionButton('编辑', () => openKnowledgeBaseEditor(base), 'small'),
@@ -883,347 +1003,182 @@ function compactEmpty(message) {
883
1003
  }
884
1004
 
885
1005
  function renderEntries() {
886
- if (state.documentView.mode === 'entries') return renderLegacyEntries()
887
1006
  const view = state.documentView
888
1007
  const query = view.query.trim().toLocaleLowerCase()
889
1008
  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)
1009
+ const selectedBase = activeBases.find(base => base.id === view.knowledgeBaseId)
895
1010
  const search = element('input', {
896
- class: 'input', type: 'search', value: view.query, placeholder: '搜索文档标题、路径或正文', 'aria-label': '搜索知识库文档',
1011
+ class: 'note-tree-search', type: 'search', value: view.query, placeholder: '搜索文档', 'aria-label': '搜索知识库文档',
897
1012
  onInput: (event) => {
898
1013
  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
1014
  renderShell()
904
- document.querySelector('.document-global-search input')?.focus()
1015
+ const input = document.querySelector('.note-tree-search')
1016
+ input?.focus()
1017
+ input?.setSelectionRange(input.value.length, input.value.length)
905
1018
  },
906
1019
  })
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 ? '没有匹配的文档' : '这个知识库还没有文档'),
1020
+ const tree = activeBases.map(base => {
1021
+ const expanded = view.expandedBases.has(base.id) || Boolean(query)
1022
+ const documents = state.documents.filter(document => document.knowledgeBaseId === base.id
1023
+ && (!query || [document.title, document.relPath, document.content].some(value => value.toLocaleLowerCase().includes(query))))
1024
+ return element('section', { class: 'note-tree-group', 'data-expanded': String(expanded) },
1025
+ element('button', {
1026
+ type: 'button', class: 'note-tree-base', 'aria-expanded': String(expanded),
1027
+ onClick: () => {
1028
+ view.knowledgeBaseId = base.id
1029
+ if (expanded && !query) view.expandedBases.delete(base.id)
1030
+ else view.expandedBases.add(base.id)
1031
+ renderShell()
1032
+ },
1033
+ },
1034
+ element('span', { class: 'tree-disclosure', 'aria-hidden': 'true' }),
1035
+ element('span', { class: 'tree-folder-icon', 'aria-hidden': 'true' }),
1036
+ element('span', { class: 'tree-base-name' }, base.name),
1037
+ element('span', { class: 'tree-count' }, documents.length)),
1038
+ expanded ? element('div', { class: 'note-tree-documents', role: 'group', 'aria-label': `${base.name}文档` },
1039
+ documents.map(document => element('button', {
1040
+ type: 'button', class: 'note-tree-document', 'aria-current': document.id === view.documentId ? 'page' : undefined,
1041
+ onClick: () => { void selectDocument(document.id) },
1042
+ }, element('span', { class: 'tree-document-icon', 'aria-hidden': 'true' }), element('span', { class: 'tree-document-copy' },
1043
+ element('strong', {}, document.title), element('small', {}, document.relPath)))),
1044
+ !query ? element('button', { type: 'button', class: 'note-tree-new', onClick: () => { void startBlankDocument(base.id) } },
1045
+ element('span', { 'aria-hidden': 'true' }, '+'), '新建文档') : null,
1046
+ ) : null,
1047
+ )
1048
+ })
1049
+ return element('section', {
1050
+ class: 'note-workspace', 'aria-labelledby': 'documents-heading',
1051
+ 'data-tree-open': String(view.treeOpen),
1052
+ },
1053
+ element('aside', { class: 'note-tree-panel', 'aria-label': '知识目录' },
1054
+ element('header', { class: 'note-tree-header' },
1055
+ element('div', {}, element('h2', { id: 'documents-heading' }, '知识目录'), element('span', {}, `${state.documents.length} 篇文档`)),
1056
+ actionButton('+', () => { void startBlankDocument(view.knowledgeBaseId || activeBases[0]?.id) }, 'ghost note-add-button', { 'aria-label': '新建文档', title: '新建文档' }),
959
1057
  ),
960
- renderColumnResizer('documentList', '调整文档栏宽度'),
961
- renderDocumentReader(currentDocument, selectedBase),
1058
+ element('div', { class: 'note-tree-search-wrap' }, interfaceIcon('search', 'search-symbol'), search),
1059
+ element('nav', { class: 'note-tree', 'data-scroll-key': 'note-tree' }, tree.length ? tree : element('div', { class: 'note-tree-empty' }, '还没有知识库')),
1060
+ element('footer', { class: 'note-tree-footer' }, actionButton('新建知识库', () => openKnowledgeBaseEditor(), 'ghost small')),
962
1061
  ),
1062
+ view.treeOpen ? element('button', {
1063
+ type: 'button', class: 'note-tree-scrim', 'aria-label': '关闭知识目录',
1064
+ onClick: () => { view.treeOpen = false; renderShell() },
1065
+ }) : null,
1066
+ renderNoteEditor(view.editor, selectedBase),
963
1067
  )
964
1068
  }
965
1069
 
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)
1070
+ function renderNoteEditor(editor, base) {
1071
+ if (!editor) return element('main', { class: 'note-editor note-editor-empty' },
1072
+ element('div', { class: 'note-empty-content' },
1073
+ element('span', { class: 'empty-document-mark', 'aria-hidden': 'true' }),
1074
+ element('h3', {}, '选择或新建一篇文档'),
1075
+ element('p', {}, '文档保存后会立即参与当前知识库的搜索与召回。'),
1076
+ actionButton('新建文档', () => { void startBlankDocument(state.documentView.knowledgeBaseId || state.knowledgeBases.find(item => item.status === 'active')?.id) }, 'primary'),
1077
+ ))
1078
+ const saveShortcut = event => {
1079
+ if ((event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === 's') {
1080
+ event.preventDefault()
1081
+ void saveDocumentEditor().then(saved => { if (saved) renderShell() })
1082
+ }
1023
1083
  }
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()
1084
+ const update = (key, value) => {
1085
+ editor[key] = value
1086
+ editor.dirty = true
1087
+ editor.saveState = '未保存'
1088
+ updateEditorSaveState(editor.saveState)
1031
1089
  }
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'),
1090
+ const title = element('input', {
1091
+ class: 'note-title-input', value: editor.title, maxlength: 200, placeholder: '无标题文档', 'aria-label': '文档标题',
1092
+ onInput: event => update('title', event.target.value), onKeyDown: saveShortcut,
1093
+ })
1094
+ const body = element('textarea', {
1095
+ class: 'note-body-editor', maxlength: 50000, placeholder: '从这里开始记录…', 'aria-label': '文档正文',
1096
+ onInput: event => { update('body', event.target.value); resizeDocumentEditor(event.target) }, onKeyDown: saveShortcut,
1097
+ })
1098
+ body.value = editor.body
1099
+ const mode = state.documentView.mode === 'edit' ? 'edit' : 'preview'
1100
+ if (mode === 'edit') window.requestAnimationFrame(() => resizeDocumentEditor(body))
1101
+ return element('main', { class: 'note-editor', 'aria-label': '文档编辑器' },
1102
+ element('header', { class: 'note-editor-toolbar' },
1103
+ element('div', { class: 'note-breadcrumb' },
1104
+ element('span', {}, base?.name || '知识库'),
1105
+ element('span', { 'aria-hidden': 'true' }, '/'),
1106
+ element('strong', {}, editor.isNew ? '新文档' : state.documents.find(item => item.id === editor.id)?.relPath || editor.title)),
1107
+ element('div', { class: 'note-editor-actions' },
1108
+ element('div', { class: 'note-mode-switch', role: 'tablist', 'aria-label': '文档视图' }, [
1109
+ ['edit', '编辑'],
1110
+ ['preview', '预览'],
1111
+ ].map(([value, label]) => element('button', {
1112
+ type: 'button', role: 'tab', 'aria-selected': String(mode === value),
1113
+ onClick: () => switchDocumentMode(value),
1114
+ }, label))),
1115
+ element('span', { class: 'editor-save-status', role: 'status' }, editor.saveState),
1116
+ !editor.isNew ? actionButton('删除', () => confirmDeleteDocument(editor), 'ghost small') : null,
1117
+ actionButton(editor.isNew ? '创建文档' : '保存', () => { void saveDocumentEditor().then(saved => { if (saved) renderShell() }) }, 'primary small'),
1118
+ ),
1119
+ ),
1120
+ element('div', { class: 'note-editor-scroll', 'data-scroll-key': 'note-editor' },
1121
+ element('article', { class: `note-paper is-${mode}`, 'data-document-mode': mode },
1122
+ mode === 'edit' ? title : element('h1', { class: 'note-preview-title' }, editor.title || '无标题文档'),
1123
+ element('div', { class: 'note-document-meta' },
1124
+ element('span', {}, editor.isNew ? '尚未保存' : `更新于 ${formatDate(editor.updatedAt)}`),
1125
+ element('span', {}, editor.scope.kind === 'global' ? '全局知识' : `项目 · ${editor.scope.id}`)),
1126
+ mode === 'edit' ? body : renderMarkdownPreview(editor.body),
1127
+ ),
1128
+ ),
1129
+ element('footer', { class: 'note-inspector' },
1130
+ element('label', {}, element('span', {}, '类型'), element('select', {
1131
+ class: 'note-meta-select', onChange: event => update('type', event.target.value),
1132
+ }, TYPES.map(type => element('option', { value: type, selected: type === editor.type }, TYPE_LABELS[type])))),
1133
+ element('label', { class: 'note-tags-field' }, element('span', {}, '标签'), element('input', {
1134
+ value: editor.tagsText, placeholder: '用逗号分隔', onInput: event => update('tagsText', event.target.value),
1135
+ })),
1136
+ element('span', { class: 'note-format-hint' }, mode === 'edit' ? 'Markdown · Ctrl/⌘ S 保存' : 'Markdown 预览'),
1137
+ ),
1062
1138
  )
1063
1139
  }
1064
1140
 
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]
1141
+ function switchDocumentMode(mode) {
1142
+ if (mode !== 'edit' && mode !== 'preview') return
1143
+ state.documentView.mode = mode
1091
1144
  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
- )
1145
+ if (mode === 'edit') window.requestAnimationFrame(() => document.querySelector('.note-body-editor')?.focus())
1114
1146
  }
1115
1147
 
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
1148
+ function renderMarkdownPreview(markdown) {
1149
+ if (!markdown.trim()) return element('div', { class: 'markdown-preview is-empty', role: 'document' }, '暂无正文')
1150
+ const preview = element('div', { class: 'markdown-preview', role: 'document' })
1151
+ preview.innerHTML = window.DshKnowledgeMarkdown.renderMarkdown(markdown)
1152
+ return preview
1163
1153
  }
1164
1154
 
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
- )
1155
+ function resizeDocumentEditor(editor) {
1156
+ editor.style.height = 'auto'
1157
+ editor.style.height = `${Math.max(420, editor.scrollHeight)}px`
1199
1158
  }
1200
1159
 
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
- )
1160
+ function confirmDeleteDocument(editor) {
1161
+ openConfirm({
1162
+ title: `删除“${editor.title}”?`,
1163
+ message: '文档和对应的召回知识会被永久删除,此操作无法撤销。',
1164
+ confirmLabel: '永久删除', danger: true,
1165
+ onConfirm: async () => {
1166
+ await api(`entries/${encodeURIComponent(editor.id)}`, { method: 'DELETE' })
1167
+ state.documentView.documentId = ''
1168
+ state.documentView.editor = null
1169
+ state.stats = null
1170
+ await loadDocuments()
1171
+ renderShell()
1172
+ showToast('文档已删除。')
1173
+ },
1174
+ })
1220
1175
  }
1221
1176
 
1222
1177
  function renderCandidates() {
1223
1178
  const statuses = [['pending', '待审核'], ['approved', '已通过'], ['rejected', '已拒绝']]
1224
1179
  return element('section', { 'aria-labelledby': 'candidates-heading' },
1225
1180
  element('div', { class: 'section-heading' },
1226
- element('div', {}, element('h2', { id: 'candidates-heading' }, 'AI 提取候选'), element('p', {}, '审核写入的结果与冲突项会在这里等待确认;直接写入的普通结果会自动生效。')),
1181
+ element('div', {}, element('h2', { id: 'candidates-heading' }, 'AI 提取候选'), element('p', {}, '审核写入、模型推断、低置信度结果和冲突项会在这里等待确认;只有高置信度且证据明确的结果才能直接写入。')),
1227
1182
  element('div', { class: 'tabs', role: 'tablist', 'aria-label': '候选状态' }, statuses.map(([value, label]) => element('button', {
1228
1183
  type: 'button', role: 'tab', class: 'tab', 'aria-selected': String(state.candidateStatus === value),
1229
1184
  onClick: async () => { state.candidateStatus = value; await navigate('candidates') },
@@ -1236,11 +1191,17 @@ function renderCandidates() {
1236
1191
 
1237
1192
  function renderCandidateCard(candidate) {
1238
1193
  const pending = candidate.status === 'pending'
1239
- const targetDocument = TYPE_DOCUMENTS[candidate.draft.type] || 'README.md'
1194
+ const targetDocument = candidate.action === 'create'
1195
+ ? `新文档“${candidate.draft.title}”`
1196
+ : `文档“${candidate.draft.title}”`
1240
1197
  return element('article', { class: 'candidate' },
1241
1198
  element('div', { class: 'candidate-header' },
1242
1199
  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])),
1200
+ element('div', {},
1201
+ badge(knowledgeBaseName(candidate.draft.knowledgeBaseId)), ' ',
1202
+ badge(ACTION_LABELS[candidate.action], candidate.action === 'conflict' ? 'warning' : 'accent'), ' ',
1203
+ badge(TYPE_LABELS[candidate.draft.type]), ' ',
1204
+ candidate.draft.source?.evidence ? badge(EVIDENCE_LABELS[candidate.draft.source.evidence] || candidate.draft.source.evidence) : null),
1244
1205
  element('h3', {}, candidate.draft.title),
1245
1206
  ),
1246
1207
  badge(STATUS_LABELS[candidate.status], candidate.status === 'approved' ? 'success' : candidate.status === 'rejected' ? 'danger' : 'warning'),
@@ -1249,7 +1210,7 @@ function renderCandidateCard(candidate) {
1249
1210
  element('p', { class: 'candidate-content' }, candidate.draft.body),
1250
1211
  element('div', { class: 'candidate-reason' },
1251
1212
  element('strong', {}, '文档变更'),
1252
- element('span', { class: 'candidate-target' }, `将${candidate.action === 'create' ? '追加' : '更新'}${targetDocument}`),
1213
+ element('span', { class: 'candidate-target' }, `${candidate.action === 'create' ? '创建' : '更新'}${targetDocument}`),
1253
1214
  element('strong', {}, '模型判断依据'), candidate.reason || '未提供判断说明'),
1254
1215
  ),
1255
1216
  element('div', { class: 'candidate-footer' },
@@ -1373,7 +1334,7 @@ function openKnowledgeBaseEditor(base) {
1373
1334
  )),
1374
1335
  provider.wrapper, model.wrapper, modelHint,
1375
1336
  )
1376
- openModal({
1337
+ openSheet({
1377
1338
  title: base ? '编辑知识库' : '创建知识库',
1378
1339
  description: '匹配描述先判断对话是否属于该库;提取要求再规定应该收录什么。',
1379
1340
  body: form,
@@ -1421,7 +1382,7 @@ function openBulkMountEditor() {
1421
1382
  includeTags.wrapper,
1422
1383
  excludeTags.wrapper,
1423
1384
  )
1424
- openModal({
1385
+ openSheet({
1425
1386
  title: `批量挂载到${manager.targetKind === 'project' ? '项目' : '会话'}`,
1426
1387
  description: targetId,
1427
1388
  body: form,
@@ -1520,7 +1481,7 @@ function openMountEditor(base, targetKind, targetId, explicit, inherited) {
1520
1481
  }
1521
1482
  enabled.addEventListener('change', updateAvailability)
1522
1483
  updateAvailability()
1523
- openModal({
1484
+ openSheet({
1524
1485
  title: `${targetKind === 'project' ? '项目' : '会话'}挂载 · ${base.name}`,
1525
1486
  description: targetId,
1526
1487
  body: form,
@@ -1646,8 +1607,8 @@ function openEntryEditor(entry, candidate) {
1646
1607
  )
1647
1608
  body.wrapper.classList.add('span-2')
1648
1609
  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 () => {
1610
+ const modeTitle = candidate ? '编辑并通过候选' : entry ? '编辑知识文档' : '新建知识文档'
1611
+ const modal = openSheet({ title: modeTitle, description: candidate ? '保存后,这条候选会立即通过并写入知识库。' : '文档保存后会立即参与后续召回。', body: form, primaryLabel: candidate ? '通过并保存' : '保存文档', onPrimary: async () => {
1651
1612
  if (!form.reportValidity()) return false
1652
1613
  const draft = {
1653
1614
  knowledgeBaseId: knowledgeBase.input.value,
@@ -1777,10 +1738,15 @@ function openConfirm({ title, message, confirmLabel, danger, onConfirm }) {
1777
1738
  return openModal({ title, body: element('p', {}, message), primaryLabel: confirmLabel, primaryVariant: danger ? 'danger' : 'primary', onPrimary: async () => { await onConfirm(); return true } })
1778
1739
  }
1779
1740
 
1780
- function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消' }) {
1741
+ function openSheet(options) {
1742
+ return openModal({ ...options, presentation: 'sheet' })
1743
+ }
1744
+
1745
+ function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消', presentation = 'modal' }) {
1781
1746
  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' })
1747
+ const isSheet = presentation === 'sheet'
1748
+ const backdrop = element('div', { class: `dialog-backdrop${isSheet ? ' sheet-backdrop' : ''}` })
1749
+ const dialog = element('section', { class: `dialog${isSheet ? ' sheet' : ''} ${primaryLabel ? '' : 'narrow'}`.trim(), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dialog-title' })
1784
1750
  let busy = false
1785
1751
  const close = () => {
1786
1752
  if (busy) return
@@ -1851,4 +1817,5 @@ function friendlyError(error) {
1851
1817
  return error.message || '操作失败,请稍后重试。'
1852
1818
  }
1853
1819
 
1820
+ installHostThemeBridge()
1854
1821
  void boot()