@lemoncat7/dsh-knowledge 2.3.2 → 2.3.4

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 (86) hide show
  1. package/README.md +1 -1
  2. package/docs/ui-maintenance.md +97 -0
  3. package/lib/activity-control.d.ts +6 -0
  4. package/lib/activity-control.d.ts.map +1 -0
  5. package/lib/activity-control.js +118 -0
  6. package/lib/activity-control.js.map +1 -0
  7. package/lib/client.d.ts +17 -0
  8. package/lib/client.d.ts.map +1 -1
  9. package/lib/client.js +4030 -196
  10. package/lib/client.js.map +4 -4
  11. package/lib/design-tokens.d.ts +12 -0
  12. package/lib/design-tokens.d.ts.map +1 -0
  13. package/lib/design-tokens.js +58 -0
  14. package/lib/design-tokens.js.map +1 -0
  15. package/lib/index.d.ts.map +1 -1
  16. package/lib/index.js +3 -0
  17. package/lib/index.js.map +1 -1
  18. package/lib/knowledge-activity-api.d.ts +35 -0
  19. package/lib/knowledge-activity-api.d.ts.map +1 -0
  20. package/lib/knowledge-activity-api.js +115 -0
  21. package/lib/knowledge-activity-api.js.map +1 -0
  22. package/lib/knowledge-activity-controller.d.ts +20 -0
  23. package/lib/knowledge-activity-controller.d.ts.map +1 -0
  24. package/lib/knowledge-activity-controller.js +138 -0
  25. package/lib/knowledge-activity-controller.js.map +1 -0
  26. package/lib/knowledge-activity-notes.d.ts +9 -0
  27. package/lib/knowledge-activity-notes.d.ts.map +1 -0
  28. package/lib/knowledge-activity-notes.js +150 -0
  29. package/lib/knowledge-activity-notes.js.map +1 -0
  30. package/lib/knowledge-activity-panel.d.ts +8 -0
  31. package/lib/knowledge-activity-panel.d.ts.map +1 -0
  32. package/lib/knowledge-activity-panel.js +245 -0
  33. package/lib/knowledge-activity-panel.js.map +1 -0
  34. package/lib/knowledge-activity-presentation.d.ts +14 -0
  35. package/lib/knowledge-activity-presentation.d.ts.map +1 -0
  36. package/lib/knowledge-activity-presentation.js +77 -0
  37. package/lib/knowledge-activity-presentation.js.map +1 -0
  38. package/lib/knowledge-activity-state.d.ts +17 -0
  39. package/lib/knowledge-activity-state.d.ts.map +1 -0
  40. package/lib/knowledge-activity-state.js +14 -0
  41. package/lib/knowledge-activity-state.js.map +1 -0
  42. package/lib/latest-request.d.ts +7 -0
  43. package/lib/latest-request.d.ts.map +1 -0
  44. package/lib/latest-request.js +14 -0
  45. package/lib/latest-request.js.map +1 -0
  46. package/lib/local-provider.d.ts +0 -2
  47. package/lib/local-provider.d.ts.map +1 -1
  48. package/lib/local-provider.js +2 -323
  49. package/lib/local-provider.js.map +1 -1
  50. package/lib/notes/share-page-styles.d.ts +2 -0
  51. package/lib/notes/share-page-styles.d.ts.map +1 -0
  52. package/lib/notes/share-page-styles.js +11 -0
  53. package/lib/notes/share-page-styles.js.map +1 -0
  54. package/lib/notes/share-page.d.ts.map +1 -1
  55. package/lib/notes/share-page.js +1 -8
  56. package/lib/notes/share-page.js.map +1 -1
  57. package/lib/storage/migrations.d.ts +5 -0
  58. package/lib/storage/migrations.d.ts.map +1 -0
  59. package/lib/storage/migrations.js +325 -0
  60. package/lib/storage/migrations.js.map +1 -0
  61. package/lib/theme-bridge.d.ts +9 -7
  62. package/lib/theme-bridge.d.ts.map +1 -1
  63. package/lib/theme-bridge.js +3 -23
  64. package/lib/theme-bridge.js.map +1 -1
  65. package/lib/web-note-editor-outline.js +2 -2
  66. package/lib/web-note-editor-outline.js.map +1 -1
  67. package/lib/web-note-editor-selection.d.ts.map +1 -1
  68. package/lib/web-note-editor-selection.js +51 -16
  69. package/lib/web-note-editor-selection.js.map +1 -1
  70. package/lib/web-note-editor.d.ts.map +1 -1
  71. package/lib/web-note-editor.js +15 -1
  72. package/lib/web-note-editor.js.map +1 -1
  73. package/lib/web.d.ts.map +1 -1
  74. package/lib/web.js +4 -0
  75. package/lib/web.js.map +1 -1
  76. package/package.json +4 -1
  77. package/web/app.js +111 -191
  78. package/web/design-tokens.css +1 -0
  79. package/web/dialogs.js +94 -0
  80. package/web/document-actions.js +68 -0
  81. package/web/index.html +1 -0
  82. package/web/model-catalog.js +22 -0
  83. package/web/note-editor.js +57 -57
  84. package/web/select-control.js +193 -0
  85. package/web/styles.css +162 -203
  86. package/web/ui-primitives.js +5 -0
package/web/app.js CHANGED
@@ -3,14 +3,20 @@ const AUTH_MODE = document.querySelector('meta[name="dsh-knowledge-auth-mode"]')
3
3
  const WEB_PATH = document.querySelector('meta[name="dsh-knowledge-web"]')?.content || '/knowledge'
4
4
  const ASSET_VERSION = document.querySelector('meta[name="dsh-knowledge-asset-version"]')?.content || ''
5
5
  const moduleUrl = name => `./${name}.js${ASSET_VERSION ? `?v=${encodeURIComponent(ASSET_VERSION)}` : ''}`
6
- const [apiModule, themeModule, uiModule] = await Promise.all([
6
+ const [apiModule, themeModule, uiModule, modelCatalogModule, dialogModule, selectModule, documentActionsModule] = await Promise.all([
7
7
  import(moduleUrl('api-client')),
8
8
  import(moduleUrl('host-theme')),
9
9
  import(moduleUrl('ui-primitives')),
10
+ import(moduleUrl('model-catalog')),
11
+ import(moduleUrl('dialogs')),
12
+ import(moduleUrl('select-control')),
13
+ import(moduleUrl('document-actions')),
10
14
  ])
11
15
  const { createApiClient } = apiModule
12
16
  const { installHostThemeBridge } = themeModule
13
17
  const { actionButton, badge, createToastPresenter, element, interfaceIcon, paneToggleButton } = uiModule
18
+ const { renderMenu: renderDocumentMenu, closeMenus: closeDocumentMenus } = documentActionsModule.createDocumentMenuPresenter(uiModule)
19
+ const readModelCatalog = modelCatalogModule.createModelCatalogLoader()
14
20
  const TOKEN_KEY = 'dsh-knowledge.session-token'
15
21
  const TYPES = ['preference', 'fact', 'decision', 'procedure', 'lesson']
16
22
  const TYPE_LABELS = { preference: '偏好', fact: '事实', decision: '决策', procedure: '流程', lesson: '经验' }
@@ -26,6 +32,8 @@ const NOTE_MAX_FILE_SIZE = 64 * 1024 * 1024
26
32
  const pageParams = new URLSearchParams(location.search)
27
33
  const initialKnowledgeBaseId = pageParams.get('knowledgeBaseId')?.trim() || ''
28
34
  const initialDocumentId = pageParams.get('documentId')?.trim() || ''
35
+ const initialView = pageParams.get('view') === 'notes' ? 'notes' : 'entries'
36
+ const initialNoteId = pageParams.get('noteId')?.trim() || ''
29
37
  const mountContext = {
30
38
  sessionId: pageParams.get('sessionId')?.trim() || '',
31
39
  projectId: pageParams.get('projectId')?.trim() || '',
@@ -33,6 +41,8 @@ const mountContext = {
33
41
  const app = document.querySelector('#app')
34
42
  const toastRegion = document.querySelector('#toast-region')
35
43
  const showToast = createToastPresenter(toastRegion)
44
+ const { openConfirm, openSheet, openModal } = dialogModule.createDialogPresenter({ element, actionButton, interfaceIcon, showToast, friendlyError })
45
+ selectModule.installSelectControls()
36
46
  const savedDocumentLayout = readDocumentLayout()
37
47
 
38
48
  function createDocumentViewState(overrides = {}) {
@@ -46,7 +56,7 @@ function createDocumentViewState(overrides = {}) {
46
56
 
47
57
  const state = {
48
58
  token: sessionStorage.getItem(TOKEN_KEY) || '',
49
- view: 'entries',
59
+ view: initialView,
50
60
  menuOpen: false,
51
61
  stats: null,
52
62
  overview: null,
@@ -88,9 +98,9 @@ const state = {
88
98
  tokens: [],
89
99
  notes: {
90
100
  children: new Map(), loadedFolders: new Set(), expandedFolders: new Set(),
91
- selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [],
101
+ selectedId: initialNoteId, selectedNode: null, currentFolderId: null, breadcrumbs: [],
92
102
  content: '', draft: '', dirty: false, assetUrl: '', query: '', searchResults: [],
93
- transfer: null, loadingNodeId: '', shares: [], shareError: '',
103
+ transfer: null, loadingNodeId: '', shares: [], shareError: '', browserOpen: false,
94
104
  },
95
105
  service: { publicApiEnabled: false, publicApiPrefix: '/knowledge-api/v1', remote: false },
96
106
  scrollPositions: new Map(),
@@ -195,7 +205,7 @@ async function boot() {
195
205
  state.token = ''
196
206
  await Promise.all([
197
207
  api('service').then(service => { state.service = service }).catch(() => {}),
198
- navigate('entries'),
208
+ navigate(initialView),
199
209
  ])
200
210
  renderShell()
201
211
  return
@@ -206,7 +216,7 @@ async function boot() {
206
216
  }
207
217
  try {
208
218
  await api('entries?limit=1')
209
- await navigate('entries')
219
+ await navigate(initialView)
210
220
  } catch (error) {
211
221
  sessionStorage.removeItem(TOKEN_KEY)
212
222
  state.token = ''
@@ -233,7 +243,7 @@ function renderLogin(message = '') {
233
243
  try {
234
244
  await api('entries?limit=1')
235
245
  sessionStorage.setItem(TOKEN_KEY, token)
236
- await navigate('entries')
246
+ await navigate(initialView)
237
247
  } catch (requestError) {
238
248
  state.token = ''
239
249
  error.textContent = requestError.status === 401 ? '令牌无效或已被撤销。' : '连接失败,请检查服务状态后重试。'
@@ -263,7 +273,7 @@ function signOut() {
263
273
  releaseNoteAsset()
264
274
  sessionStorage.removeItem(TOKEN_KEY)
265
275
  Object.assign(state, { token: '', stats: null, overview: null, knowledgeBases: [], mounts: [], resolvedMounts: [], entries: [], documents: [], candidates: [], candidateTargets: new Map(), settings: { writebackPolicy: 'conservative', updatedAt: '' }, tokens: [] })
266
- state.notes = { children: new Map(), loadedFolders: new Set(), expandedFolders: new Set(), selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [], content: '', draft: '', dirty: false, assetUrl: '', query: '', searchResults: [], transfer: null, loadingNodeId: '', shares: [], shareError: '' }
276
+ state.notes = { children: new Map(), loadedFolders: new Set(), expandedFolders: new Set(), selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [], content: '', draft: '', dirty: false, assetUrl: '', query: '', searchResults: [], transfer: null, loadingNodeId: '', shares: [], shareError: '', browserOpen: false }
267
277
  if (AUTH_MODE === 'same-origin') void boot()
268
278
  else renderLogin()
269
279
  }
@@ -333,7 +343,10 @@ function updateLoadingPhase(request, label, progress) {
333
343
  if (request !== navigationRequest || !state.loading) return
334
344
  state.loadingPhase = label
335
345
  state.loadingProgress = Math.max(0, Math.min(1, progress))
336
- renderShell()
346
+ const progressNode = document.querySelector('.route-progress')
347
+ progressNode?.setAttribute('aria-label', label)
348
+ progressNode?.setAttribute('aria-valuenow', String(Math.round(state.loadingProgress * 100)))
349
+ progressNode?.firstElementChild?.style.setProperty('--route-progress', String(state.loadingProgress))
337
350
  }
338
351
 
339
352
  async function saveBeforeNavigation() {
@@ -484,7 +497,7 @@ async function loadNotes(signal, onPhase = () => {}) {
484
497
  ? state.notes.selectedNode
485
498
  : await api(`notes/${encodeURIComponent(state.notes.selectedId)}`, { signal }).catch(() => null)
486
499
  if (!selected) clearNoteSelection()
487
- else state.notes.selectedNode = selected
500
+ else await selectNoteNode(selected)
488
501
  }
489
502
  }
490
503
 
@@ -937,6 +950,7 @@ async function loadTokens(signal) {
937
950
  }
938
951
 
939
952
  function renderShell() {
953
+ closeDocumentMenus()
940
954
  releaseNoteEditors()
941
955
  captureScrollPosition()
942
956
  const titles = {
@@ -960,25 +974,20 @@ function renderShell() {
960
974
  },
961
975
  renderSidebar(),
962
976
  renderAppSidebarResizer(),
963
- state.menuOpen ? element('button', {
964
- type: 'button', class: 'app-sidebar-scrim',
977
+ element('button', {
978
+ type: 'button', class: 'app-sidebar-scrim', hidden: !state.menuOpen,
965
979
  'aria-label': '关闭导航菜单',
966
- onClick: () => { state.menuOpen = false; renderShell() },
967
- }) : null,
980
+ onClick: () => setMenuOpen(false),
981
+ }),
968
982
  element('main', { class: 'main' },
969
983
  element('header', { class: 'topbar' },
970
984
  element('div', { class: 'topbar-title' },
971
- actionButton('☰', () => { state.menuOpen = !state.menuOpen; renderShell() }, 'ghost mobile-menu', {
985
+ actionButton(interfaceIcon('menu'), () => setMenuOpen(!state.menuOpen), 'ghost mobile-menu', {
972
986
  'aria-label': state.menuOpen ? '关闭导航菜单' : '打开导航菜单',
973
987
  'aria-expanded': String(state.menuOpen),
974
988
  }),
975
989
  paneToggleButton('main', !state.documentView.sidebarHidden, () => setSidebarHidden(!state.documentView.sidebarHidden), '主导航栏'),
976
- activeDocumentWorkspace() ? paneToggleButton('library', activeDocumentWorkspace().view.treeOpen, () => {
977
- const workspace = activeDocumentWorkspace()
978
- if (!workspace) return
979
- workspace.view.treeOpen = !workspace.view.treeOpen
980
- renderShell()
981
- }, '知识目录') : null,
990
+ renderContextPaneToggle(),
982
991
  element('div', { class: 'topbar-heading' },
983
992
  element('span', { class: 'topbar-kicker' }, `KNOWLEDGE / ${viewIndexes[state.view] || '00'}`),
984
993
  element('h1', {}, title),
@@ -997,6 +1006,21 @@ function renderShell() {
997
1006
  restoreScrollPosition(currentScrollState())
998
1007
  }
999
1008
 
1009
+ function renderContextPaneToggle() {
1010
+ if (state.view === 'notes') {
1011
+ return paneToggleButton('library', state.notes.browserOpen, () => {
1012
+ state.notes.browserOpen = !state.notes.browserOpen
1013
+ renderShell()
1014
+ }, '笔记目录')
1015
+ }
1016
+ const workspace = activeDocumentWorkspace()
1017
+ if (!workspace) return null
1018
+ return paneToggleButton('library', workspace.view.treeOpen, () => {
1019
+ workspace.view.treeOpen = !workspace.view.treeOpen
1020
+ renderShell()
1021
+ }, '知识目录')
1022
+ }
1023
+
1000
1024
  function renderAppSidebarResizer() {
1001
1025
  const minimum = 190
1002
1026
  const maximum = 340
@@ -1019,24 +1043,38 @@ function startSidebarResize(event, minimum, maximum) {
1019
1043
  if (!shell) return
1020
1044
  const startX = event.clientX
1021
1045
  const startWidth = state.documentView.sidebarWidth
1046
+ let pendingWidth = startWidth
1047
+ let frame = 0
1022
1048
  handle.setPointerCapture?.(event.pointerId)
1023
1049
  handle.classList.add('is-dragging')
1024
1050
  document.body.classList.add('is-resizing-columns')
1025
1051
  const move = moveEvent => {
1026
- const width = clampNumber(startWidth + moveEvent.clientX - startX, minimum, maximum, startWidth)
1027
- setSidebarWidth(width, shell, handle)
1052
+ if (moveEvent.pointerId !== event.pointerId) return
1053
+ pendingWidth = clampNumber(startWidth + moveEvent.clientX - startX, minimum, maximum, startWidth)
1054
+ if (!frame) frame = window.requestAnimationFrame(() => {
1055
+ frame = 0
1056
+ if (shell.isConnected) setSidebarWidth(pendingWidth, shell, handle)
1057
+ })
1028
1058
  }
1029
- const finish = () => {
1059
+ const finish = endEvent => {
1060
+ if (endEvent.pointerId !== event.pointerId) return
1061
+ if (frame) window.cancelAnimationFrame(frame)
1062
+ frame = 0
1063
+ if (endEvent.type === 'pointerup') pendingWidth = clampNumber(startWidth + endEvent.clientX - startX, minimum, maximum, startWidth)
1064
+ if (shell.isConnected) setSidebarWidth(pendingWidth, shell, handle)
1030
1065
  handle.classList.remove('is-dragging')
1031
1066
  document.body.classList.remove('is-resizing-columns')
1032
1067
  handle.removeEventListener('pointermove', move)
1033
1068
  handle.removeEventListener('pointerup', finish)
1034
1069
  handle.removeEventListener('pointercancel', finish)
1070
+ handle.removeEventListener('lostpointercapture', finish)
1071
+ if (handle.hasPointerCapture?.(event.pointerId)) handle.releasePointerCapture(event.pointerId)
1035
1072
  saveDocumentLayout()
1036
1073
  }
1037
1074
  handle.addEventListener('pointermove', move)
1038
1075
  handle.addEventListener('pointerup', finish)
1039
1076
  handle.addEventListener('pointercancel', finish)
1077
+ handle.addEventListener('lostpointercapture', finish)
1040
1078
  }
1041
1079
 
1042
1080
  function resizeSidebarWithKeyboard(event, minimum, maximum) {
@@ -1056,6 +1094,18 @@ function setSidebarWidth(width, shell, handle) {
1056
1094
  handle?.setAttribute('aria-valuenow', String(width))
1057
1095
  }
1058
1096
 
1097
+ function setMenuOpen(open) {
1098
+ state.menuOpen = open
1099
+ const shell = app.querySelector('.app-shell')
1100
+ if (!shell) return
1101
+ shell.dataset.menuOpen = String(open)
1102
+ shell.querySelector('.app-sidebar-scrim')?.toggleAttribute('hidden', !open)
1103
+ const toggle = shell.querySelector('.mobile-menu')
1104
+ toggle?.setAttribute('aria-expanded', String(open))
1105
+ toggle?.setAttribute('aria-label', open ? '关闭导航菜单' : '打开导航菜单')
1106
+ applySidebarVisibility(shell, state.documentView.sidebarHidden)
1107
+ }
1108
+
1059
1109
  function setSidebarHidden(hidden) {
1060
1110
  state.documentView.sidebarHidden = hidden
1061
1111
  saveDocumentLayout()
@@ -1069,7 +1119,7 @@ function applySidebarVisibility(shell, hidden) {
1069
1119
  const resizer = shell.querySelector(':scope > .app-sidebar-resizer')
1070
1120
  const main = shell.querySelector(':scope > .main')
1071
1121
  const toggle = shell.querySelector('.pane-toggle-button[data-pane="main"]')
1072
- const compact = window.matchMedia('(max-width: 760px)').matches
1122
+ const compact = window.matchMedia('(max-width: 1120px), (hover: none) and (pointer: coarse) and (max-width: 1400px)').matches
1073
1123
  const sidebarUnavailable = compact ? !state.menuOpen : hidden
1074
1124
  sidebar?.toggleAttribute('inert', sidebarUnavailable)
1075
1125
  resizer?.toggleAttribute('inert', hidden)
@@ -1722,13 +1772,16 @@ function renderNoteEditor(workspace, editor, base) {
1722
1772
  element('div', { class: 'note-editor-actions' },
1723
1773
  finalized ? badge(DOCUMENT_STATE_LABELS[editor.documentState] || '已结束', 'success') : readOnly ? badge('只读') : null,
1724
1774
  element('span', { class: 'editor-save-status', role: 'status' }, editor.saveState),
1725
- !readOnly && !editor.isNew && documentWorkspaceBases(workspace).some(item => item.id !== editor.knowledgeBaseId)
1726
- ? actionButton('移动到…', () => openMoveKnowledgeDocument(workspace, editor), 'ghost small')
1727
- : null,
1728
1775
  finalized && !readOnly ? actionButton('重新打开', () => reopenDocument(workspace, editor), 'small') : null,
1729
- !readOnly && !editor.isNew && !finalized ? actionButton('标记结束', () => openFinalizeDocument(workspace, editor), 'small') : null,
1730
- !readOnly && !editor.isNew && !finalized ? actionButton('删除', () => confirmDeleteDocument(workspace, editor), 'ghost small') : null,
1731
1776
  !readOnly && !finalized ? actionButton(editor.isNew ? '创建文档' : '保存', () => { void saveDocumentEditor(workspace).then(saved => { if (saved) renderShell() }) }, 'primary small') : null,
1777
+ !readOnly && !editor.isNew ? renderDocumentMenu(editor.title || '知识文档', [
1778
+ [
1779
+ ...(documentWorkspaceBases(workspace).some(item => item.id !== editor.knowledgeBaseId)
1780
+ ? [{ label: '移动到…', icon: 'move', run: () => openMoveKnowledgeDocument(workspace, editor) }] : []),
1781
+ ...(!finalized ? [{ label: '标记结束', icon: 'check', run: () => openFinalizeDocument(workspace, editor) }] : []),
1782
+ ],
1783
+ !finalized ? [{ label: '删除文档', icon: 'trash', danger: true, run: () => confirmDeleteDocument(workspace, editor) }] : [],
1784
+ ]) : null,
1732
1785
  ),
1733
1786
  ),
1734
1787
  element('div', { class: 'note-editor-scroll', 'data-scroll-key': 'note-editor' },
@@ -1972,6 +2025,7 @@ function renderNotes() {
1972
2025
  const tree = state.notes.query.trim() ? renderNoteSearchResults() : renderNoteTreeBranch(null)
1973
2026
  const workspace = element('section', {
1974
2027
  class: 'notes-workspace', 'data-drop-active': 'false',
2028
+ 'data-browser-open': String(state.notes.browserOpen),
1975
2029
  onDragEnter: noteWorkspaceDragEnter, onDragOver: noteWorkspaceDragEnter,
1976
2030
  onDragLeave: noteWorkspaceDragLeave,
1977
2031
  onDrop: event => {
@@ -1981,6 +2035,10 @@ function renderNotes() {
1981
2035
  void importDroppedNotes(event.dataTransfer, state.notes.currentFolderId)
1982
2036
  },
1983
2037
  },
2038
+ state.notes.browserOpen ? element('button', {
2039
+ type: 'button', class: 'notes-browser-scrim', 'aria-label': '关闭笔记目录',
2040
+ onClick: () => { state.notes.browserOpen = false; renderShell() },
2041
+ }) : null,
1984
2042
  element('aside', { class: 'notes-browser', 'aria-label': '笔记目录' },
1985
2043
  element('header', { class: 'notes-browser-header' },
1986
2044
  element('div', {}, element('h2', {}, '目录'), element('span', {}, `${rootNodes.length} 个根目录项目`)),
@@ -2643,86 +2701,34 @@ function renderNoteFile(node) {
2643
2701
 
2644
2702
  function renderNoteFileToolbar(node, options = {}) {
2645
2703
  return element('header', { class: 'notes-document-toolbar' },
2646
- element('div', { class: 'notes-toolbar-leading' },
2647
- renderNoteDocumentBreadcrumb(node),
2648
- ),
2704
+ element('div', { class: 'notes-toolbar-leading' }, renderNoteDocumentBreadcrumb(node)),
2649
2705
  element('div', { class: 'notes-document-actions' },
2650
2706
  options.editable ? element('span', { class: 'notes-save-state', role: 'status', 'data-note-save-state': '', 'data-dirty': String(state.notes.dirty) }, state.notes.dirty ? '未保存' : '已保存') : null,
2651
- options.enhanced ? actionButton('查找', () => markdownEditorHandle?.openFind(), 'ghost small', { 'data-note-find': '', 'data-note-mobile-overflow': '', 'aria-keyshortcuts': 'Control+F Meta+F', 'aria-pressed': 'false' }) : null,
2652
2707
  options.enhanced ? actionButton([
2653
- interfaceIcon('outline', 'notes-toolbar-action-icon'),
2654
- element('span', {}, '大纲'),
2655
- ], () => markdownEditorHandle?.toggleOutline(), 'ghost small notes-outline-action', { 'data-note-outline': '', 'aria-label': '打开标题大纲', 'aria-controls': 'dsh-note-outline', 'aria-expanded': 'false', 'aria-pressed': 'false' }) : null,
2656
- options.editable ? actionButton('历史', () => { void openNoteHistory(node) }, 'ghost small', { 'data-note-history': '', 'data-note-mobile-overflow': '' }) : null,
2657
- noteDownloadButton(node, 'ghost small', '下载', { 'data-note-mobile-overflow': '' }),
2658
- actionButton(noteShareForNode(node.id) ? '分享中' : '分享', () => { void openNoteShare(node) }, 'ghost small', { 'data-note-mobile-overflow': '' }),
2659
- actionButton('复制引用', () => { void copyNoteReference(node) }, 'ghost small', { 'data-note-mobile-overflow': '' }),
2660
- actionButton('重命名', () => openRenameNoteNode(node), 'ghost small', { 'data-note-mobile-overflow': '' }),
2661
- options.editable ? actionButton('保存', () => { void saveNoteDocument() }, 'primary small', { 'data-note-save': '', 'data-note-mobile-overflow': '', disabled: !state.notes.dirty }) : null,
2708
+ interfaceIcon('outline', 'notes-toolbar-action-icon'), element('span', {}, '大纲'),
2709
+ ], () => markdownEditorHandle?.toggleOutline(), 'ghost small notes-outline-action', { 'data-note-outline': '', 'aria-label': '打开标题大纲', title: '标题大纲', 'aria-controls': 'dsh-note-outline', 'aria-expanded': 'false', 'aria-pressed': 'false' }) : null,
2710
+ options.editable ? actionButton('保存', () => { void saveNoteDocument() }, 'primary small', { 'data-note-save': '', disabled: !state.notes.dirty, 'aria-keyshortcuts': 'Control+S Meta+S' }) : null,
2662
2711
  renderNoteFileOverflowMenu(node, options),
2663
2712
  ),
2664
2713
  )
2665
2714
  }
2666
2715
 
2667
2716
  function renderNoteFileOverflowMenu(node, options) {
2668
- const details = element('details', {
2669
- class: 'notes-document-more',
2670
- onKeyDown: event => {
2671
- if (event.key !== 'Escape') return
2672
- event.preventDefault()
2673
- details.open = false
2674
- summary.focus()
2675
- },
2676
- onFocusOut: () => {
2677
- window.setTimeout(() => {
2678
- if (!details.contains(document.activeElement)) details.open = false
2679
- }, 0)
2680
- },
2681
- })
2682
- const summary = element('summary', { class: 'button ghost small', title: '更多操作', 'aria-label': `打开 ${node.name} 的更多操作` },
2683
- interfaceIcon('more', 'notes-document-more-icon'),
2684
- )
2685
- details.addEventListener('toggle', () => {
2686
- summary.setAttribute('aria-expanded', String(details.open))
2687
- summary.setAttribute('aria-label', `${details.open ? '关闭' : '打开'} ${node.name} 的更多操作`)
2688
- })
2689
- const closeThen = action => event => {
2690
- details.open = false
2691
- action(event)
2692
- }
2693
- const menuItems = []
2694
- if (options.editable && state.notes.dirty) {
2695
- menuItems.push(noteMenuAction('保存修改', 'save', closeThen(() => { void saveNoteDocument() })))
2696
- menuItems.push(noteMenuDivider())
2697
- }
2698
- if (options.enhanced) {
2699
- menuItems.push(noteMenuAction('查找', 'search', closeThen(() => markdownEditorHandle?.openFind())))
2700
- }
2701
- if (options.editable) menuItems.push(noteMenuAction('页面历史', 'history', closeThen(() => { void openNoteHistory(node) })))
2702
- if (options.enhanced || options.editable) menuItems.push(noteMenuDivider())
2703
- menuItems.push(
2704
- noteMenuAction('下载', 'download', closeThen(event => { void downloadNoteFile(node, event.currentTarget) })),
2705
- noteMenuAction(noteShareForNode(node.id) ? '管理分享' : '创建分享', 'link', closeThen(() => { void openNoteShare(node) })),
2706
- noteMenuAction('复制引用', 'link', closeThen(() => { void copyNoteReference(node) })),
2707
- noteMenuAction('重命名', 'rename', closeThen(() => openRenameNoteNode(node))),
2708
- noteMenuDivider(),
2709
- noteMenuAction('查看已分享', 'outline', closeThen(() => { void navigate('shares') })),
2710
- )
2711
- const menu = element('div', { class: 'notes-document-more-menu', role: 'menu', 'aria-label': `${node.name} 的更多操作` }, menuItems)
2712
- summary.setAttribute('aria-expanded', 'false')
2713
- details.append(summary, menu)
2714
- return details
2715
- }
2716
-
2717
- function noteMenuAction(label, icon, onClick, attributes = {}) {
2718
- return actionButton([
2719
- interfaceIcon(icon, 'notes-document-menu-icon'),
2720
- element('span', { class: 'notes-document-menu-label' }, label),
2721
- ], onClick, 'ghost small notes-document-menu-item', { role: 'menuitem', ...attributes })
2722
- }
2723
-
2724
- function noteMenuDivider() {
2725
- return element('div', { class: 'notes-document-menu-divider', role: 'separator' })
2717
+ return renderDocumentMenu(node.name, [
2718
+ [
2719
+ ...(options.enhanced ? [{ label: '查找', icon: 'search', run: () => markdownEditorHandle?.openFind(), attributes: { 'data-note-find': '', 'aria-keyshortcuts': 'Control+F Meta+F' } }] : []),
2720
+ ...(options.editable ? [{ label: '页面历史', icon: 'history', run: () => { void openNoteHistory(node) }, attributes: { 'data-note-history': '' } }] : []),
2721
+ ],
2722
+ [
2723
+ { label: '下载', icon: 'download', run: event => { void downloadNoteFile(node, event.currentTarget) } },
2724
+ { label: noteShareForNode(node.id) ? '管理分享' : '创建分享', icon: 'link', run: () => { void openNoteShare(node) } },
2725
+ { label: '复制引用', icon: 'link', run: () => { void copyNoteReference(node) } },
2726
+ ],
2727
+ [
2728
+ { label: '重命名', icon: 'rename', run: () => openRenameNoteNode(node) },
2729
+ { label: '查看已分享', icon: 'outline', run: () => { void navigate('shares') } },
2730
+ ],
2731
+ ])
2726
2732
  }
2727
2733
 
2728
2734
  function renderNoteFileStatusbar(node) {
@@ -2767,6 +2773,7 @@ async function selectNoteNode(node, options = {}) {
2767
2773
  releaseNoteAsset()
2768
2774
  state.notes.selectedId = node.id
2769
2775
  state.notes.selectedNode = node
2776
+ state.notes.browserOpen = false
2770
2777
  Object.assign(state.notes, { content: '', draft: '', dirty: false })
2771
2778
  state.notes.loadingNodeId = node.id
2772
2779
  renderShell()
@@ -4184,19 +4191,12 @@ function selectField(label, options, value) {
4184
4191
  }
4185
4192
 
4186
4193
  async function loadModelCatalog() {
4187
- if (state.modelCatalog !== null) return state.modelCatalog
4188
4194
  try {
4189
- const response = await fetch('/knowledge-control/v1/models', {
4190
- headers: { accept: 'application/json', 'x-dsh-knowledge-client': 'management-web' },
4191
- })
4192
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
4193
- const payload = await response.json()
4194
- state.modelCatalog = Array.isArray(payload.providers) ? payload.providers : []
4195
+ state.modelCatalog = await readModelCatalog()
4195
4196
  } catch (error) {
4196
- state.modelCatalog = []
4197
- showToast(`无法读取当前 DSH 的模型目录:${error instanceof Error ? error.message : String(error)}`, 'error')
4197
+ showToast(`无法读取当前 DSH 的模型目录:${error.message},重新打开可重试。`, 'error')
4198
4198
  }
4199
- return state.modelCatalog
4199
+ return state.modelCatalog || []
4200
4200
  }
4201
4201
 
4202
4202
  function modelRouteFields(currentProvider, currentModel) {
@@ -4416,86 +4416,6 @@ function confirmDeleteToken(token) {
4416
4416
  } })
4417
4417
  }
4418
4418
 
4419
- function openConfirm({ title, message, confirmLabel, danger, onConfirm }) {
4420
- return openModal({ title, body: element('p', {}, message), primaryLabel: confirmLabel, primaryVariant: danger ? 'danger' : 'primary', onPrimary: async () => { await onConfirm(); return true } })
4421
- }
4422
-
4423
- function openSheet(options) {
4424
- return openModal({ ...options, presentation: 'sheet' })
4425
- }
4426
-
4427
- function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消', presentation = 'modal', className = '', onClose }) {
4428
- const previouslyFocused = document.activeElement
4429
- const isSheet = presentation === 'sheet'
4430
- const backdrop = element('div', { class: `dialog-backdrop${isSheet ? ' sheet-backdrop' : ''}` })
4431
- const dialog = element('section', { class: `dialog${isSheet ? ' sheet' : ''} ${primaryLabel ? '' : 'narrow'} ${className}`.trim(), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dialog-title' })
4432
- let busy = false
4433
- let formDirty = false
4434
- let closed = false
4435
- const close = (explicit = false) => {
4436
- if (busy || closed) return
4437
- if (!explicit && formDirty) {
4438
- showToast('表单有未保存的修改,请先保存,或使用“取消”放弃修改。', 'error')
4439
- return
4440
- }
4441
- document.removeEventListener('keydown', onKeyDown)
4442
- closed = true
4443
- backdrop.remove()
4444
- onClose?.()
4445
- if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus()
4446
- }
4447
- const closeButton = actionButton('×', () => close(), 'ghost', { 'aria-label': '关闭对话框' })
4448
- const cancel = actionButton(cancelLabel, () => close(true))
4449
- const primary = primaryLabel ? actionButton(primaryLabel, async () => {
4450
- if (busy) return
4451
- busy = true
4452
- primary.disabled = true
4453
- cancel.disabled = true
4454
- const original = primary.textContent
4455
- primary.textContent = '正在处理…'
4456
- try {
4457
- const shouldClose = await onPrimary()
4458
- busy = false
4459
- if (shouldClose !== false) close(true)
4460
- else { primary.disabled = false; cancel.disabled = false; primary.textContent = original }
4461
- } catch (error) {
4462
- busy = false
4463
- primary.disabled = false
4464
- cancel.disabled = false
4465
- primary.textContent = original
4466
- showToast(friendlyError(error), 'error')
4467
- }
4468
- }, primaryVariant) : null
4469
- const onKeyDown = (event) => {
4470
- if (event.key === 'Escape') { event.preventDefault(); close() }
4471
- if (event.key === 'Tab') trapFocus(event, dialog)
4472
- }
4473
- dialog.append(
4474
- element('header', { class: 'dialog-header' }, element('div', {}, element('h2', { id: 'dialog-title' }, title), description ? element('p', {}, description) : null), closeButton),
4475
- element('div', { class: 'dialog-body' }, body),
4476
- element('footer', { class: 'dialog-footer' }, cancel, primary),
4477
- )
4478
- backdrop.append(dialog)
4479
- backdrop.addEventListener('mousedown', (event) => { if (event.target === backdrop) close() })
4480
- if (body.matches?.('form') || body.querySelector?.('form')) {
4481
- body.addEventListener('input', () => { formDirty = true })
4482
- body.addEventListener('change', () => { formDirty = true })
4483
- }
4484
- document.body.append(backdrop)
4485
- document.addEventListener('keydown', onKeyDown)
4486
- window.setTimeout(() => (dialog.querySelector('input, textarea, select, button') || dialog).focus(), 0)
4487
- return { close, dialog }
4488
- }
4489
-
4490
- function trapFocus(event, container) {
4491
- const focusable = [...container.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])')]
4492
- if (!focusable.length) return
4493
- const first = focusable[0]
4494
- const last = focusable.at(-1)
4495
- if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
4496
- else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
4497
- }
4498
-
4499
4419
  function formatDate(value) {
4500
4420
  try { return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)) } catch { return value }
4501
4421
  }
@@ -0,0 +1 @@
1
+ :root{--font-ui:ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei UI", Arial, sans-serif;--font-reading:var(--font-ui);--font-mono:"SFMono-Regular", "Cascadia Code", Menlo, Consolas, monospace;--bg:#ebebeb;--surface:#f4f4f4;--surface-raised:#fafafa;--surface-soft:#eeeeef;--surface-hover:rgb(118 118 128 / 9%);--dialog-surface:#f4f4f4;--menu-surface:rgb(232 234 236 / 98%);--menu-surface-solid:#e8eaec;--document-surface:rgb(218 222 226 / 32%);--selection-background:#b8c8d6;--selection-text:#17212b;--activity-surface:rgba(255, 255, 255, 0.14);--text:#1d1d1f;--text-secondary:#515154;--text-tertiary:#6e6e73;--border:rgb(60 60 67 / 14%);--border-strong:rgb(60 60 67 / 24%);--accent:#3a3a3c;--accent-hover:#1d1d1f;--accent-soft:#e2e2e5;--on-accent:#ffffff;--success:#248a3d;--success-soft:#e8f5eb;--warning:#c93400;--warning-soft:#fff1e8;--danger:#d70015;--danger-soft:#ffebed;--shadow:0 24px 64px rgb(0 0 0 / 20%);--knowledge-embedded-surface:rgba(255, 255, 255, 0.08);--knowledge-embedded-filter:saturate(.65) contrast(1.015) blur(24px);--knowledge-embedded-control:rgba(255, 255, 255, 0.27);}@media(prefers-color-scheme:dark){:root:not([data-color-scheme]){--bg:#101719;--surface:#182022;--surface-raised:#20292b;--surface-soft:rgb(184 204 205 / 6%);--surface-hover:rgb(105 182 186 / 9%);--dialog-surface:#20292b;--menu-surface:rgb(29 38 40 / 98%);--menu-surface-solid:#1d2628;--document-surface:rgb(130 138 146 / 6%);--selection-background:#455b68;--selection-text:#ffffff;--activity-surface:rgb(29 36 38 / 24%);--text:#e3eaeb;--text-secondary:#bbc9cc;--text-tertiary:#95a7ab;--border:rgb(184 204 205 / 10%);--border-strong:rgb(184 204 205 / 18%);--accent:#69b6ba;--accent-hover:#8aced0;--accent-soft:rgb(105 182 186 / 13%);--on-accent:#101819;--success:#30d158;--success-soft:rgb(48 209 88 / 13%);--warning:#ff9f0a;--warning-soft:rgb(255 159 10 / 13%);--danger:#ff453a;--danger-soft:rgb(255 69 58 / 14%);--shadow:0 28px 72px rgb(0 0 0 / 42%);--knowledge-embedded-surface:rgb(16 23 25 / 18%);--knowledge-embedded-filter:saturate(.55) contrast(1.02) blur(24px);--knowledge-embedded-control:rgb(39 50 53 / 72%);}}:root[data-color-scheme="dark"]{--bg:#101719;--surface:#182022;--surface-raised:#20292b;--surface-soft:rgb(184 204 205 / 6%);--surface-hover:rgb(105 182 186 / 9%);--dialog-surface:#20292b;--menu-surface:rgb(29 38 40 / 98%);--menu-surface-solid:#1d2628;--document-surface:rgb(130 138 146 / 6%);--selection-background:#455b68;--selection-text:#ffffff;--activity-surface:rgb(29 36 38 / 24%);--text:#e3eaeb;--text-secondary:#bbc9cc;--text-tertiary:#95a7ab;--border:rgb(184 204 205 / 10%);--border-strong:rgb(184 204 205 / 18%);--accent:#69b6ba;--accent-hover:#8aced0;--accent-soft:rgb(105 182 186 / 13%);--on-accent:#101819;--success:#30d158;--success-soft:rgb(48 209 88 / 13%);--warning:#ff9f0a;--warning-soft:rgb(255 159 10 / 13%);--danger:#ff453a;--danger-soft:rgb(255 69 58 / 14%);--shadow:0 28px 72px rgb(0 0 0 / 42%);--knowledge-embedded-surface:rgb(16 23 25 / 18%);--knowledge-embedded-filter:saturate(.55) contrast(1.02) blur(24px);--knowledge-embedded-control:rgb(39 50 53 / 72%);}
package/web/dialogs.js ADDED
@@ -0,0 +1,94 @@
1
+ /** Shared modal lifecycle, keyboard handling and dirty-form protection. */
2
+ export function createDialogPresenter({ element, actionButton, interfaceIcon, showToast, friendlyError }) {
3
+ let sequence = 0
4
+ const stack = []
5
+ function openConfirm({ title, message, confirmLabel, danger, onConfirm }) {
6
+ return openModal({ title, body: element('p', {}, message), primaryLabel: confirmLabel, primaryVariant: danger ? 'danger' : 'primary', onPrimary: async () => { await onConfirm(); return true } })
7
+ }
8
+
9
+ function openSheet(options) {
10
+ return openModal({ ...options, presentation: 'sheet' })
11
+ }
12
+
13
+ function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消', presentation = 'modal', className = '', onClose }) {
14
+ const previouslyFocused = document.activeElement
15
+ const isSheet = presentation === 'sheet'
16
+ const titleId = `knowledge-dialog-title-${++sequence}`
17
+ const backdrop = element('div', { class: `dialog-backdrop${isSheet ? ' sheet-backdrop' : ''}` })
18
+ const dialog = element('section', { class: `dialog${isSheet ? ' sheet' : ''} ${primaryLabel ? '' : 'narrow'} ${className}`.trim(), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': titleId, tabindex: '-1' })
19
+ let busy = false
20
+ let formDirty = false
21
+ let closed = false
22
+ const close = (explicit = false) => {
23
+ if (busy || closed) return
24
+ if (!explicit && formDirty) {
25
+ showToast('表单有未保存的修改,请先保存,或使用“取消”放弃修改。', 'error')
26
+ return
27
+ }
28
+ document.removeEventListener('keydown', onKeyDown)
29
+ closed = true
30
+ stack.splice(stack.indexOf(dialog), 1)
31
+ backdrop.remove()
32
+ onClose?.()
33
+ if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus()
34
+ }
35
+ const closeButton = actionButton(interfaceIcon('close'), () => close(), 'ghost', { 'aria-label': '关闭对话框' })
36
+ const cancel = actionButton(cancelLabel, () => close(true))
37
+ const primary = primaryLabel ? actionButton(primaryLabel, async () => {
38
+ if (busy) return
39
+ busy = true
40
+ primary.disabled = true
41
+ cancel.disabled = true
42
+ const original = primary.textContent
43
+ primary.textContent = '正在处理…'
44
+ try {
45
+ const shouldClose = await onPrimary()
46
+ busy = false
47
+ if (shouldClose !== false) close(true)
48
+ else { primary.disabled = false; cancel.disabled = false; primary.textContent = original }
49
+ } catch (error) {
50
+ busy = false
51
+ primary.disabled = false
52
+ cancel.disabled = false
53
+ primary.textContent = original
54
+ showToast(friendlyError(error), 'error')
55
+ }
56
+ }, primaryVariant) : null
57
+ const onKeyDown = (event) => {
58
+ if (stack.at(-1) !== dialog || event.defaultPrevented) return
59
+ if (event.key === 'Escape') { event.preventDefault(); close() }
60
+ if (event.key === 'Tab') trapFocus(event, dialog)
61
+ }
62
+ dialog.append(
63
+ element('header', { class: 'dialog-header' }, element('div', {}, element('h2', { id: titleId }, title), description ? element('p', {}, description) : null), closeButton),
64
+ element('div', { class: 'dialog-body' }, body),
65
+ element('footer', { class: 'dialog-footer' }, cancel, primary),
66
+ )
67
+ backdrop.append(dialog)
68
+ backdrop.addEventListener('mousedown', (event) => { if (event.target === backdrop) close() })
69
+ if (body.matches?.('form') || body.querySelector?.('form')) {
70
+ body.addEventListener('input', () => { formDirty = true })
71
+ body.addEventListener('change', () => { formDirty = true })
72
+ }
73
+ document.body.append(backdrop)
74
+ stack.push(dialog)
75
+ document.addEventListener('keydown', onKeyDown)
76
+ window.setTimeout(() => { if (!closed && stack.at(-1) === dialog) (focusableElements(dialog)[0] || dialog).focus() }, 0)
77
+ return { close, dialog }
78
+ }
79
+
80
+ function trapFocus(event, container) {
81
+ const focusable = focusableElements(container)
82
+ if (!focusable.length) return
83
+ const first = focusable[0]
84
+ const last = focusable.at(-1)
85
+ if (event.shiftKey && (document.activeElement === first || !container.contains(document.activeElement))) { event.preventDefault(); last.focus() }
86
+ else if (!event.shiftKey && (document.activeElement === last || !container.contains(document.activeElement))) { event.preventDefault(); first.focus() }
87
+ }
88
+
89
+ function focusableElements(container) {
90
+ return [...container.querySelectorAll('button, input, select, textarea, a[href], [tabindex]')]
91
+ .filter(node => !node.disabled && node.tabIndex >= 0 && !node.closest('[hidden], [inert], [aria-hidden="true"]') && node.getClientRects().length > 0)
92
+ }
93
+ return { openConfirm, openSheet, openModal }
94
+ }