@lemoncat7/dsh-knowledge 0.6.0-alpha.6

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/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/cordis.patch.yml +12 -0
  4. package/docs/architecture.md +107 -0
  5. package/docs/document-knowledge-design.zh-CN.md +329 -0
  6. package/docs/requirements.md +86 -0
  7. package/lib/api.d.ts +4 -0
  8. package/lib/api.d.ts.map +1 -0
  9. package/lib/api.js +497 -0
  10. package/lib/api.js.map +1 -0
  11. package/lib/client.js +420 -0
  12. package/lib/client.js.map +7 -0
  13. package/lib/config.d.ts +38 -0
  14. package/lib/config.d.ts.map +1 -0
  15. package/lib/config.js +84 -0
  16. package/lib/config.js.map +1 -0
  17. package/lib/connection.d.ts +15 -0
  18. package/lib/connection.d.ts.map +1 -0
  19. package/lib/connection.js +95 -0
  20. package/lib/connection.js.map +1 -0
  21. package/lib/control.d.ts +26 -0
  22. package/lib/control.d.ts.map +1 -0
  23. package/lib/control.js +149 -0
  24. package/lib/control.js.map +1 -0
  25. package/lib/domain.d.ts +207 -0
  26. package/lib/domain.d.ts.map +1 -0
  27. package/lib/domain.js +122 -0
  28. package/lib/domain.js.map +1 -0
  29. package/lib/extraction.d.ts +27 -0
  30. package/lib/extraction.d.ts.map +1 -0
  31. package/lib/extraction.js +367 -0
  32. package/lib/extraction.js.map +1 -0
  33. package/lib/index.d.ts +15 -0
  34. package/lib/index.d.ts.map +1 -0
  35. package/lib/index.js +198 -0
  36. package/lib/index.js.map +1 -0
  37. package/lib/local-provider.d.ts +60 -0
  38. package/lib/local-provider.d.ts.map +1 -0
  39. package/lib/local-provider.js +1096 -0
  40. package/lib/local-provider.js.map +1 -0
  41. package/lib/provider-router.d.ts +19 -0
  42. package/lib/provider-router.d.ts.map +1 -0
  43. package/lib/provider-router.js +83 -0
  44. package/lib/provider-router.js.map +1 -0
  45. package/lib/provider.d.ts +38 -0
  46. package/lib/provider.d.ts.map +1 -0
  47. package/lib/provider.js +2 -0
  48. package/lib/provider.js.map +1 -0
  49. package/lib/recall.d.ts +8 -0
  50. package/lib/recall.d.ts.map +1 -0
  51. package/lib/recall.js +76 -0
  52. package/lib/recall.js.map +1 -0
  53. package/lib/remote-provider.d.ts +51 -0
  54. package/lib/remote-provider.d.ts.map +1 -0
  55. package/lib/remote-provider.js +216 -0
  56. package/lib/remote-provider.js.map +1 -0
  57. package/lib/retrieval.d.ts +35 -0
  58. package/lib/retrieval.d.ts.map +1 -0
  59. package/lib/retrieval.js +206 -0
  60. package/lib/retrieval.js.map +1 -0
  61. package/lib/runtime.d.ts +147 -0
  62. package/lib/runtime.d.ts.map +1 -0
  63. package/lib/runtime.js +26 -0
  64. package/lib/runtime.js.map +1 -0
  65. package/lib/tools.d.ts +6 -0
  66. package/lib/tools.d.ts.map +1 -0
  67. package/lib/tools.js +99 -0
  68. package/lib/tools.js.map +1 -0
  69. package/lib/web.d.ts +3 -0
  70. package/lib/web.d.ts.map +1 -0
  71. package/lib/web.js +71 -0
  72. package/lib/web.js.map +1 -0
  73. package/package.json +79 -0
  74. package/web/app.js +1686 -0
  75. package/web/index.html +19 -0
  76. package/web/styles.css +786 -0
package/web/app.js ADDED
@@ -0,0 +1,1686 @@
1
+ const API_BASE = document.querySelector('meta[name="dsh-knowledge-api"]')?.content || '/knowledge-api/v1'
2
+ const TOKEN_KEY = 'dsh-knowledge.session-token'
3
+ const TYPES = ['preference', 'fact', 'decision', 'procedure', 'lesson']
4
+ const TYPE_LABELS = { preference: '偏好', fact: '事实', decision: '决策', procedure: '流程', lesson: '经验' }
5
+ const ACTION_LABELS = { create: '新增', update: '更新', conflict: '冲突' }
6
+ const STATUS_LABELS = { active: '生效中', archived: '已归档', pending: '待审核', approved: '已通过', rejected: '已拒绝' }
7
+ const CHANGE_LABELS = { create: '创建', update: '更新', archive: '归档', restore: '恢复' }
8
+ const WRITE_MODE_LABELS = { none: '仅召回', audit: '审核写入', direct: '直接写入' }
9
+ const TYPE_DOCUMENTS = {
10
+ preference: 'preferences.md', fact: 'facts.md', decision: 'decisions.md', procedure: 'procedures.md', lesson: 'lessons.md',
11
+ }
12
+ const DOCUMENT_LAYOUT_KEY = 'dsh-knowledge.document-layout'
13
+ const pageParams = new URLSearchParams(location.search)
14
+ const mountContext = {
15
+ sessionId: pageParams.get('sessionId')?.trim() || '',
16
+ projectId: pageParams.get('projectId')?.trim() || '',
17
+ }
18
+ const app = document.querySelector('#app')
19
+ const toastRegion = document.querySelector('#toast-region')
20
+ const savedDocumentLayout = readDocumentLayout()
21
+
22
+ const state = {
23
+ token: sessionStorage.getItem(TOKEN_KEY) || '',
24
+ view: 'overview',
25
+ menuOpen: false,
26
+ stats: null,
27
+ overview: null,
28
+ knowledgeBases: [],
29
+ mounts: [],
30
+ resolvedMounts: [],
31
+ mountContext,
32
+ mountManager: {
33
+ targetKind: mountContext.sessionId ? 'session' : 'project',
34
+ query: '',
35
+ filter: 'all',
36
+ selectedIds: new Set(),
37
+ },
38
+ entries: [],
39
+ nextCursor: null,
40
+ entryFilters: { query: '', type: '', status: 'active', projectId: '', knowledgeBaseId: '' },
41
+ documents: [],
42
+ documentView: {
43
+ knowledgeBaseId: '', documentId: '', query: '', mode: 'documents',
44
+ sidebarHidden: savedDocumentLayout.sidebarHidden,
45
+ libraryHidden: savedDocumentLayout.libraryHidden,
46
+ documentListHidden: savedDocumentLayout.documentListHidden,
47
+ sidebarWidth: savedDocumentLayout.sidebarWidth,
48
+ libraryWidth: savedDocumentLayout.libraryWidth,
49
+ documentListWidth: savedDocumentLayout.documentListWidth,
50
+ },
51
+ candidates: [],
52
+ candidateStatus: 'pending',
53
+ tokens: [],
54
+ loading: false,
55
+ error: '',
56
+ }
57
+
58
+ function readDocumentLayout() {
59
+ const fallback = {
60
+ sidebarHidden: false, libraryHidden: false, documentListHidden: false,
61
+ sidebarWidth: 236, libraryWidth: 220, documentListWidth: 280,
62
+ }
63
+ try {
64
+ const value = JSON.parse(localStorage.getItem(DOCUMENT_LAYOUT_KEY) || '{}')
65
+ return {
66
+ sidebarHidden: value.sidebarHidden === true,
67
+ libraryHidden: value.libraryHidden === true,
68
+ documentListHidden: value.documentListHidden === true,
69
+ sidebarWidth: clampNumber(value.sidebarWidth, 190, 340, fallback.sidebarWidth),
70
+ libraryWidth: clampNumber(value.libraryWidth, 170, 360, fallback.libraryWidth),
71
+ documentListWidth: clampNumber(value.documentListWidth, 210, 480, fallback.documentListWidth),
72
+ }
73
+ } catch { return fallback }
74
+ }
75
+
76
+ function saveDocumentLayout() {
77
+ try {
78
+ localStorage.setItem(DOCUMENT_LAYOUT_KEY, JSON.stringify({
79
+ sidebarHidden: state.documentView.sidebarHidden,
80
+ libraryHidden: state.documentView.libraryHidden,
81
+ documentListHidden: state.documentView.documentListHidden,
82
+ sidebarWidth: state.documentView.sidebarWidth,
83
+ libraryWidth: state.documentView.libraryWidth,
84
+ documentListWidth: state.documentView.documentListWidth,
85
+ }))
86
+ } catch {}
87
+ }
88
+
89
+ function clampNumber(value, minimum, maximum, fallback) {
90
+ const number = Number(value)
91
+ return Number.isFinite(number) ? Math.min(maximum, Math.max(minimum, Math.round(number))) : fallback
92
+ }
93
+
94
+ function element(tag, attributes = {}, ...children) {
95
+ const node = document.createElement(tag)
96
+ for (const [key, value] of Object.entries(attributes)) {
97
+ if (value === undefined || value === null || value === false) continue
98
+ if (key === 'class') node.className = value
99
+ else if (key === 'text') node.textContent = value
100
+ else if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2).toLowerCase(), value)
101
+ else if (key === 'checked' || key === 'selected' || key === 'disabled') node[key] = Boolean(value)
102
+ else node.setAttribute(key, String(value))
103
+ }
104
+ for (const child of children.flat(Infinity)) {
105
+ if (child === undefined || child === null || child === false) continue
106
+ node.append(child instanceof Node ? child : document.createTextNode(String(child)))
107
+ }
108
+ return node
109
+ }
110
+
111
+ function actionButton(label, onClick, variant = '', attributes = {}) {
112
+ return element('button', { type: 'button', class: `button ${variant}`.trim(), onClick, ...attributes }, label)
113
+ }
114
+
115
+ function paneToggleButton(pane, visible, onClick, label) {
116
+ const action = `${visible ? '隐藏' : '显示'}${label}`
117
+ return element('button', {
118
+ type: 'button', class: 'pane-toggle-button', 'data-pane': pane,
119
+ 'aria-label': action, 'aria-pressed': String(visible), title: action, onClick,
120
+ }, element('span', { class: `pane-icon pane-icon-${pane}`, 'aria-hidden': 'true' }))
121
+ }
122
+
123
+ function badge(label, variant = '') {
124
+ return element('span', { class: `badge ${variant}`.trim() }, label)
125
+ }
126
+
127
+ function showToast(message, kind = '') {
128
+ const toast = element('div', { class: `toast ${kind}`.trim(), role: kind === 'error' ? 'alert' : 'status' }, message)
129
+ toastRegion.append(toast)
130
+ window.setTimeout(() => toast.remove(), 4200)
131
+ }
132
+
133
+ async function api(path, options = {}) {
134
+ const headers = { accept: 'application/json', ...(options.body === undefined ? {} : { 'content-type': 'application/json' }) }
135
+ if (state.token) headers.authorization = `Bearer ${state.token}`
136
+ const response = await fetch(`${API_BASE}/${path.replace(/^\/+/, '')}`, {
137
+ method: options.method || 'GET',
138
+ headers,
139
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
140
+ signal: options.signal,
141
+ })
142
+ const text = await response.text()
143
+ let payload
144
+ if (text) {
145
+ try { payload = JSON.parse(text) } catch { throw new Error('服务返回了无法识别的数据') }
146
+ }
147
+ if (!response.ok) {
148
+ const error = new Error(payload?.error || `请求失败(HTTP ${response.status})`)
149
+ error.status = response.status
150
+ throw error
151
+ }
152
+ return payload
153
+ }
154
+
155
+ async function boot() {
156
+ if (!state.token) {
157
+ renderLogin()
158
+ return
159
+ }
160
+ try {
161
+ await api('entries?limit=1')
162
+ await navigate('overview')
163
+ } catch (error) {
164
+ sessionStorage.removeItem(TOKEN_KEY)
165
+ state.token = ''
166
+ renderLogin(error.status === 401 ? '令牌无效或已被撤销,请重新输入。' : '暂时无法连接知识库。')
167
+ }
168
+ }
169
+
170
+ function renderLogin(message = '') {
171
+ const tokenInput = element('input', {
172
+ class: 'input', type: 'password', name: 'token', autocomplete: 'current-password',
173
+ placeholder: '输入管理员或只读访问令牌', required: true, autofocus: true,
174
+ })
175
+ const error = element('p', { class: 'login-error', role: 'alert' }, message)
176
+ const submit = actionButton('连接知识库', () => form.requestSubmit(), 'primary')
177
+ const form = element('form', {
178
+ onSubmit: async (event) => {
179
+ event.preventDefault()
180
+ const token = tokenInput.value.trim()
181
+ if (!token) return
182
+ submit.disabled = true
183
+ submit.textContent = '正在连接…'
184
+ error.textContent = ''
185
+ state.token = token
186
+ try {
187
+ await api('entries?limit=1')
188
+ sessionStorage.setItem(TOKEN_KEY, token)
189
+ await navigate('overview')
190
+ } catch (requestError) {
191
+ state.token = ''
192
+ error.textContent = requestError.status === 401 ? '令牌无效或已被撤销。' : '连接失败,请检查服务状态后重试。'
193
+ submit.disabled = false
194
+ submit.textContent = '连接知识库'
195
+ tokenInput.focus()
196
+ }
197
+ },
198
+ },
199
+ element('div', { class: 'field' },
200
+ element('label', { for: 'login-token' }, '访问令牌'),
201
+ Object.assign(tokenInput, { id: 'login-token' }),
202
+ element('span', { class: 'field-hint' }, '令牌仅保存在当前浏览器标签页,关闭后自动清除。'),
203
+ ), error, submit)
204
+
205
+ app.replaceChildren(element('main', { class: 'login-page' }, element('section', { class: 'login-card', 'aria-labelledby': 'login-title' },
206
+ element('div', { class: 'brand-mark', 'aria-hidden': 'true' }, 'K'),
207
+ element('h1', { id: 'login-title' }, 'DSH 知识库'),
208
+ element('p', {}, '管理对话中沉淀的长期知识与 AI 提取候选。'),
209
+ form,
210
+ )))
211
+ }
212
+
213
+ function signOut() {
214
+ sessionStorage.removeItem(TOKEN_KEY)
215
+ Object.assign(state, { token: '', stats: null, overview: null, knowledgeBases: [], mounts: [], resolvedMounts: [], entries: [], documents: [], candidates: [], tokens: [] })
216
+ renderLogin()
217
+ }
218
+
219
+ async function navigate(view) {
220
+ state.view = view
221
+ state.menuOpen = false
222
+ state.loading = true
223
+ state.error = ''
224
+ renderShell()
225
+ try {
226
+ if (view === 'overview') await loadOverview()
227
+ if (view === 'bases') await loadKnowledgeBasesPage()
228
+ if (view === 'entries') await loadDocuments()
229
+ if (view === 'candidates') await loadCandidates()
230
+ if (view === 'tokens') await loadTokens()
231
+ } catch (error) {
232
+ if (error.status === 401) return signOut()
233
+ state.error = friendlyError(error)
234
+ } finally {
235
+ state.loading = false
236
+ renderShell()
237
+ }
238
+ }
239
+
240
+ async function refreshStats() {
241
+ state.stats = await api('stats')
242
+ }
243
+
244
+ async function ensureKnowledgeBases(force = false) {
245
+ if (force || state.knowledgeBases.length === 0) state.knowledgeBases = await api('knowledge-bases')
246
+ return state.knowledgeBases
247
+ }
248
+
249
+ async function loadKnowledgeBasesPage() {
250
+ const requests = [api('knowledge-bases'), api('mounts')]
251
+ if (state.mountContext.sessionId) {
252
+ const params = new URLSearchParams({ sessionId: state.mountContext.sessionId })
253
+ if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
254
+ requests.push(api(`mounts/resolve?${params}`))
255
+ }
256
+ const [bases, mounts, resolved = []] = await Promise.all(requests)
257
+ state.knowledgeBases = bases
258
+ state.mounts = mounts
259
+ state.resolvedMounts = resolved
260
+ await refreshStats()
261
+ }
262
+
263
+ async function loadOverview() {
264
+ const [stats, recent, pending, bases] = await Promise.all([
265
+ api('stats'),
266
+ api('entries?status=active&limit=6'),
267
+ api('candidates?status=pending&limit=5'),
268
+ api('knowledge-bases'),
269
+ ])
270
+ state.stats = stats
271
+ state.knowledgeBases = bases
272
+ state.overview = { recent: recent.items, pending }
273
+ }
274
+
275
+ async function loadEntries(cursor = '') {
276
+ await ensureKnowledgeBases()
277
+ const filters = state.entryFilters
278
+ let result
279
+ if (filters.query.trim() && filters.status === 'active' && !cursor) {
280
+ const params = new URLSearchParams({ q: filters.query.trim(), limit: '100' })
281
+ if (filters.projectId.trim()) params.set('projectId', filters.projectId.trim())
282
+ if (filters.type) params.append('type', filters.type)
283
+ if (filters.knowledgeBaseId) params.append('knowledgeBaseId', filters.knowledgeBaseId)
284
+ const hits = await api(`search?${params}`)
285
+ result = { items: hits.map(hit => hit.entry) }
286
+ } else {
287
+ const params = new URLSearchParams({ limit: '50', status: filters.status })
288
+ if (filters.type) params.set('type', filters.type)
289
+ if (filters.projectId.trim()) params.set('projectId', filters.projectId.trim())
290
+ if (filters.knowledgeBaseId) params.set('knowledgeBaseId', filters.knowledgeBaseId)
291
+ if (cursor) params.set('cursor', cursor)
292
+ result = await api(`entries?${params}`)
293
+ }
294
+ state.entries = cursor ? [...state.entries, ...result.items] : result.items
295
+ state.nextCursor = result.nextCursor || null
296
+ if (!state.stats) await refreshStats()
297
+ }
298
+
299
+ async function loadDocuments() {
300
+ const [bases, documents] = await Promise.all([api('knowledge-bases'), api('documents')])
301
+ state.knowledgeBases = bases
302
+ state.documents = documents
303
+ const view = state.documentView
304
+ const availableBaseIds = new Set(bases.map(base => base.id))
305
+ if (!view.knowledgeBaseId || !availableBaseIds.has(view.knowledgeBaseId)) {
306
+ view.knowledgeBaseId = state.entryFilters.knowledgeBaseId && availableBaseIds.has(state.entryFilters.knowledgeBaseId)
307
+ ? state.entryFilters.knowledgeBaseId
308
+ : bases.find(base => base.status === 'active')?.id || bases[0]?.id || ''
309
+ }
310
+ selectDefaultDocument()
311
+ if (!state.stats) await refreshStats()
312
+ }
313
+
314
+ function selectDefaultDocument() {
315
+ const view = state.documentView
316
+ const documents = state.documents.filter(document => document.knowledgeBaseId === view.knowledgeBaseId)
317
+ if (!documents.some(document => document.id === view.documentId)) {
318
+ view.documentId = documents.find(document => document.relPath === 'README.md')?.id || documents[0]?.id || ''
319
+ }
320
+ }
321
+
322
+ async function loadCandidates() {
323
+ const [candidates] = await Promise.all([
324
+ api(`candidates?status=${state.candidateStatus}&limit=100`),
325
+ ensureKnowledgeBases(),
326
+ ])
327
+ state.candidates = candidates
328
+ if (!state.stats) await refreshStats()
329
+ }
330
+
331
+ async function loadTokens() {
332
+ state.tokens = await api('tokens')
333
+ if (!state.stats) await refreshStats()
334
+ }
335
+
336
+ function renderShell() {
337
+ const titles = {
338
+ overview: ['概览', '知识库运行状态与最近活动'],
339
+ bases: ['知识库', '创建知识库,并限定项目与会话的召回和写入范围'],
340
+ entries: ['文档', '按知识库浏览自动整理的 Markdown 文档'],
341
+ candidates: ['审核', '确认 AI 提取结果后再写入知识库'],
342
+ tokens: ['访问管理', '管理其他客户端连接中央知识库的权限'],
343
+ }
344
+ const [title, subtitle] = titles[state.view]
345
+ const shell = element('div', {
346
+ class: 'app-shell', 'data-menu-open': String(state.menuOpen),
347
+ 'data-sidebar-hidden': String(state.documentView.sidebarHidden),
348
+ style: `--sidebar-width: ${state.documentView.sidebarWidth}px`,
349
+ },
350
+ renderSidebar(),
351
+ renderAppSidebarResizer(),
352
+ element('main', { class: 'main' },
353
+ element('header', { class: 'topbar' },
354
+ element('div', { class: 'topbar-title' },
355
+ actionButton('☰', () => { state.menuOpen = !state.menuOpen; renderShell() }, 'ghost mobile-menu', { 'aria-label': '打开导航菜单' }),
356
+ paneToggleButton('main', !state.documentView.sidebarHidden, () => setSidebarHidden(!state.documentView.sidebarHidden), '主导航栏'),
357
+ element('div', {}, element('h1', {}, title), element('p', {}, subtitle)),
358
+ ),
359
+ ),
360
+ element('div', { class: 'page' }, renderCurrentView()),
361
+ ),
362
+ )
363
+ if (state.menuOpen) shell.addEventListener('click', (event) => {
364
+ if (event.target === shell) { state.menuOpen = false; renderShell() }
365
+ })
366
+ app.replaceChildren(shell)
367
+ }
368
+
369
+ function renderAppSidebarResizer() {
370
+ const minimum = 190
371
+ const maximum = 340
372
+ const value = state.documentView.sidebarWidth
373
+ return element('div', {
374
+ class: 'app-sidebar-resizer', role: 'separator', tabindex: '0',
375
+ title: '拖动调整主导航栏宽度',
376
+ 'aria-label': '调整主导航栏宽度', 'aria-orientation': 'vertical',
377
+ 'aria-valuemin': minimum, 'aria-valuemax': maximum, 'aria-valuenow': value,
378
+ onPointerDown: event => startSidebarResize(event, minimum, maximum),
379
+ onKeyDown: event => resizeSidebarWithKeyboard(event, minimum, maximum),
380
+ }, element('span', { 'aria-hidden': 'true' }, '⋮'))
381
+ }
382
+
383
+ function startSidebarResize(event, minimum, maximum) {
384
+ if (event.button !== 0) return
385
+ event.preventDefault()
386
+ const handle = event.currentTarget
387
+ const shell = handle.closest('.app-shell')
388
+ if (!shell) return
389
+ const startX = event.clientX
390
+ const startWidth = state.documentView.sidebarWidth
391
+ handle.setPointerCapture?.(event.pointerId)
392
+ handle.classList.add('is-dragging')
393
+ document.body.classList.add('is-resizing-columns')
394
+ const move = moveEvent => {
395
+ const width = clampNumber(startWidth + moveEvent.clientX - startX, minimum, maximum, startWidth)
396
+ setSidebarWidth(width, shell, handle)
397
+ }
398
+ const finish = () => {
399
+ handle.classList.remove('is-dragging')
400
+ document.body.classList.remove('is-resizing-columns')
401
+ handle.removeEventListener('pointermove', move)
402
+ handle.removeEventListener('pointerup', finish)
403
+ handle.removeEventListener('pointercancel', finish)
404
+ saveDocumentLayout()
405
+ }
406
+ handle.addEventListener('pointermove', move)
407
+ handle.addEventListener('pointerup', finish)
408
+ handle.addEventListener('pointercancel', finish)
409
+ }
410
+
411
+ function resizeSidebarWithKeyboard(event, minimum, maximum) {
412
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
413
+ event.preventDefault()
414
+ const current = state.documentView.sidebarWidth
415
+ const width = event.key === 'Home' ? minimum
416
+ : event.key === 'End' ? maximum
417
+ : clampNumber(current + (event.key === 'ArrowRight' ? 16 : -16), minimum, maximum, current)
418
+ setSidebarWidth(width, event.currentTarget.closest('.app-shell'), event.currentTarget)
419
+ saveDocumentLayout()
420
+ }
421
+
422
+ function setSidebarWidth(width, shell, handle) {
423
+ state.documentView.sidebarWidth = width
424
+ shell?.style.setProperty('--sidebar-width', `${width}px`)
425
+ handle?.setAttribute('aria-valuenow', String(width))
426
+ }
427
+
428
+ function setSidebarHidden(hidden) {
429
+ state.documentView.sidebarHidden = hidden
430
+ saveDocumentLayout()
431
+ renderShell()
432
+ }
433
+
434
+ function renderSidebar() {
435
+ const pending = state.stats?.candidates.pending
436
+ const navItems = [
437
+ ['overview', '概览', '◫'], ['bases', '知识库', '▦'], ['entries', '文档', '◇'], ['candidates', '审核', '✓'], ['tokens', '访问管理', '⌁'],
438
+ ]
439
+ return element('aside', { class: 'sidebar', 'aria-label': '知识库导航' },
440
+ element('div', { class: 'brand' },
441
+ element('div', { class: 'brand-mark', 'aria-hidden': 'true' }, 'K'),
442
+ element('div', { class: 'brand-copy' }, element('strong', {}, 'DSH Knowledge'), element('span', {}, '管理控制台')),
443
+ ),
444
+ element('nav', { class: 'nav' }, navItems.map(([id, label, icon]) => element('button', {
445
+ type: 'button', class: 'nav-button', 'aria-current': state.view === id ? 'page' : undefined,
446
+ onClick: () => navigate(id),
447
+ }, element('span', { class: 'nav-icon', 'aria-hidden': 'true' }, icon), label,
448
+ id === 'candidates' && pending ? element('span', { class: 'nav-count', 'aria-label': `${pending} 条待审核` }, pending) : null))),
449
+ element('div', { class: 'sidebar-footer' },
450
+ element('div', { class: 'connection' }, element('span', { class: 'status-dot', 'aria-hidden': 'true' }), '知识库已连接'),
451
+ actionButton('退出当前会话', signOut, 'ghost small'),
452
+ ),
453
+ )
454
+ }
455
+
456
+ function renderCurrentView() {
457
+ if (state.loading) return loadingView()
458
+ if (state.error) return errorView(state.error, () => navigate(state.view))
459
+ if (state.view === 'overview') return renderOverview()
460
+ if (state.view === 'bases') return renderKnowledgeBases()
461
+ if (state.view === 'entries') return renderEntries()
462
+ if (state.view === 'candidates') return renderCandidates()
463
+ return renderTokens()
464
+ }
465
+
466
+ function loadingView() {
467
+ return element('div', { class: 'loading', role: 'status' }, element('div', {}, element('div', { class: 'spinner', 'aria-hidden': 'true' }), element('span', { class: 'visually-hidden' }, '正在加载')))
468
+ }
469
+
470
+ function errorView(message, retry) {
471
+ return element('div', { class: 'error-state', role: 'alert' }, element('p', {}, message), actionButton('重试', retry, 'small'))
472
+ }
473
+
474
+ function renderOverview() {
475
+ const stats = state.stats
476
+ const overview = state.overview
477
+ if (!stats || !overview) return loadingView()
478
+ const metrics = [
479
+ ['生效知识', stats.entries.active, `${stats.entries.archived} 条已归档`],
480
+ ['待审核候选', stats.candidates.pending, `${stats.candidates.approved} 条已通过`],
481
+ ['提取任务', stats.extractionJobs.total, `${stats.extractionJobs.failed} 个失败`],
482
+ ['知识类型', Object.values(stats.entries.byType).filter(Boolean).length, '共 5 种分类'],
483
+ ]
484
+ return element('div', {},
485
+ element('section', { class: 'metrics', 'aria-label': '知识库指标' }, metrics.map(([label, value, detail]) => element('article', { class: 'metric' },
486
+ element('div', { class: 'metric-label' }, label),
487
+ element('strong', { class: 'metric-value' }, value),
488
+ element('div', { class: 'metric-detail' }, detail),
489
+ ))),
490
+ element('div', { class: 'dashboard-grid' },
491
+ element('section', { class: 'panel', 'aria-labelledby': 'recent-title' },
492
+ element('div', { class: 'panel-header' }, element('h2', { id: 'recent-title' }, '最近更新'), actionButton('查看全部', () => navigate('entries'), 'ghost small')),
493
+ element('div', { class: 'panel-body' }, overview.recent.length ? element('div', { class: 'list' }, overview.recent.map(renderCompactEntry)) : compactEmpty('还没有已生效知识')),
494
+ ),
495
+ element('section', { class: 'panel', 'aria-labelledby': 'pending-title' },
496
+ element('div', { class: 'panel-header' }, element('h2', { id: 'pending-title' }, '等待确认'), actionButton('进入审核', () => navigate('candidates'), 'ghost small')),
497
+ element('div', { class: 'panel-body' }, overview.pending.length ? element('div', { class: 'list' }, overview.pending.map(renderCompactCandidate)) : compactEmpty('当前没有待审核候选')),
498
+ ),
499
+ ),
500
+ )
501
+ }
502
+
503
+ function renderKnowledgeBases() {
504
+ const activeBases = state.knowledgeBases.filter(base => base.status === 'active')
505
+ const archivedBases = state.knowledgeBases.filter(base => base.status === 'archived')
506
+ const contextAvailable = Boolean(state.mountContext.projectId || state.mountContext.sessionId)
507
+ return element('div', { class: 'bases-page' },
508
+ element('section', { 'aria-labelledby': 'bases-heading' },
509
+ element('div', { class: 'section-heading' },
510
+ element('div', {}, element('h2', { id: 'bases-heading' }, `知识库 · ${activeBases.length}`), element('p', {}, '每个知识库拥有独立的默认标签和提取规则。')),
511
+ actionButton('+ 创建知识库', () => openKnowledgeBaseEditor(), 'primary'),
512
+ ),
513
+ activeBases.length
514
+ ? element('div', { class: 'base-grid' }, activeBases.map(renderKnowledgeBaseCard))
515
+ : emptyState('还没有可用知识库', '先创建一个知识库,再挂载到项目或会话。', '创建知识库', () => openKnowledgeBaseEditor()),
516
+ archivedBases.length ? element('details', { class: 'archived-bases' },
517
+ element('summary', {}, `已归档知识库 · ${archivedBases.length}`),
518
+ element('div', { class: 'base-grid' }, archivedBases.map(renderKnowledgeBaseCard)),
519
+ ) : null,
520
+ ),
521
+ element('section', { class: 'mount-section', 'aria-labelledby': 'mounts-heading' },
522
+ element('div', { class: 'section-heading' }, element('div', {},
523
+ element('h2', { id: 'mounts-heading' }, '当前项目与会话挂载'),
524
+ element('p', {}, '会话默认继承项目设置;创建会话覆盖后,可独立调整或关闭。'),
525
+ )),
526
+ contextAvailable
527
+ ? element('div', { class: 'mount-context' },
528
+ state.mountContext.projectId ? contextPill('项目', state.mountContext.projectId) : contextPill('项目', '当前页面未提供'),
529
+ state.mountContext.sessionId ? contextPill('会话', state.mountContext.sessionId) : contextPill('会话', '当前页面未提供'),
530
+ )
531
+ : element('div', { class: 'context-warning' }, '请从 DSH 当前会话的左侧“知识库”入口打开,才能管理当前项目和会话的挂载。'),
532
+ contextAvailable && activeBases.length
533
+ ? renderMountManager(activeBases)
534
+ : null,
535
+ ),
536
+ )
537
+ }
538
+
539
+ function contextPill(label, value) {
540
+ return element('div', { class: 'context-pill' }, element('strong', {}, label), element('span', { title: value }, value))
541
+ }
542
+
543
+ function renderKnowledgeBaseCard(base) {
544
+ const archived = base.status === 'archived'
545
+ return element('article', { class: `base-card${archived ? ' is-archived' : ''}` },
546
+ element('div', { class: 'base-card-header' },
547
+ element('div', {}, element('h3', {}, base.name), element('small', {}, base.id === 'default' ? '系统默认库' : `ID · ${base.id}`)),
548
+ badge(archived ? '已归档' : '可用', archived ? '' : 'success'),
549
+ ),
550
+ element('div', { class: 'base-description' }, element('strong', {}, '回写匹配描述'), element('p', {}, base.description || '未设置:按通用知识库处理')),
551
+ base.defaultTags.length
552
+ ? element('div', { class: 'tag-row' }, base.defaultTags.map(tag => element('span', { class: 'tag' }, `#${tag}`)))
553
+ : element('span', { class: 'field-hint' }, '无默认标签'),
554
+ base.extractionInstructions
555
+ ? element('div', { class: 'base-instructions' }, element('strong', {}, '提取要求'), element('span', {}, base.extractionInstructions))
556
+ : null,
557
+ element('div', { class: 'base-model' },
558
+ element('strong', {}, '回写模型'),
559
+ badge(base.writebackProvider && base.writebackModel ? `${base.writebackProvider} / ${base.writebackModel}` : '跟随当前会话'),
560
+ ),
561
+ element('div', { class: 'base-card-actions' },
562
+ actionButton('查看知识', () => {
563
+ state.entryFilters.knowledgeBaseId = base.id
564
+ state.documentView.knowledgeBaseId = base.id
565
+ state.documentView.documentId = ''
566
+ state.documentView.mode = 'documents'
567
+ void navigate('entries')
568
+ }, 'ghost small'),
569
+ archived ? actionButton('恢复', () => confirmRestoreKnowledgeBase(base), 'small') : actionButton('编辑', () => openKnowledgeBaseEditor(base), 'small'),
570
+ archived ? actionButton('永久删除', () => confirmDeleteKnowledgeBase(base), 'danger small') : null,
571
+ !archived && base.id !== 'default' ? actionButton('归档', () => confirmArchiveKnowledgeBase(base), 'danger small') : null,
572
+ ),
573
+ )
574
+ }
575
+
576
+ function findExplicitMount(baseId, targetKind, targetId) {
577
+ return state.mounts.find(mount => mount.knowledgeBaseId === baseId && mount.targetKind === targetKind && mount.targetId === targetId)
578
+ }
579
+
580
+ function mountView(base, targetKind, targetId) {
581
+ const explicit = findExplicitMount(base.id, targetKind, targetId)
582
+ const inherited = targetKind === 'session' && !explicit && state.mountContext.projectId
583
+ ? findExplicitMount(base.id, 'project', state.mountContext.projectId)
584
+ : undefined
585
+ const source = explicit || (inherited?.enabled ? inherited : undefined)
586
+ let statusKey
587
+ let statusLabel
588
+ let statusVariant = ''
589
+ let detail
590
+ if (explicit && !explicit.enabled) {
591
+ statusKey = 'disabled'
592
+ statusLabel = '已关闭'
593
+ statusVariant = 'danger'
594
+ detail = '显式禁用,不会继承项目设置'
595
+ } else if (source) {
596
+ statusKey = explicit ? 'mounted' : 'inherited'
597
+ statusLabel = explicit ? '已挂载' : '继承项目'
598
+ statusVariant = explicit ? 'success' : 'accent'
599
+ detail = `${source.recallEnabled ? '召回开启' : '召回关闭'} · ${WRITE_MODE_LABELS[source.writeMode]}`
600
+ } else {
601
+ statusKey = 'unmounted'
602
+ statusLabel = '未挂载'
603
+ detail = '不召回、不提取、不回写'
604
+ }
605
+ return { explicit, inherited, source, statusKey, statusLabel, statusVariant, detail }
606
+ }
607
+
608
+ function renderMountManager(activeBases) {
609
+ const manager = state.mountManager
610
+ const availableKinds = [
611
+ state.mountContext.projectId ? ['project', '项目'] : null,
612
+ state.mountContext.sessionId ? ['session', '会话'] : null,
613
+ ].filter(Boolean)
614
+ if (!availableKinds.some(([kind]) => kind === manager.targetKind)) manager.targetKind = availableKinds[0][0]
615
+ const targetId = manager.targetKind === 'project' ? state.mountContext.projectId : state.mountContext.sessionId
616
+ const query = manager.query.trim().toLowerCase()
617
+ const rows = activeBases.map(base => ({ base, view: mountView(base, manager.targetKind, targetId) }))
618
+ const visibleRows = rows.filter(({ base, view }) => {
619
+ const model = base.writebackProvider && base.writebackModel ? `${base.writebackProvider} ${base.writebackModel}` : '跟随会话'
620
+ const searchable = `${base.name} ${base.description} ${base.defaultTags.join(' ')} ${model}`.toLowerCase()
621
+ return (!query || searchable.includes(query)) && (manager.filter === 'all' || manager.filter === view.statusKey)
622
+ })
623
+ const visibleIds = visibleRows.map(({ base }) => base.id)
624
+ const selectedCount = manager.selectedIds.size
625
+ const searchInput = element('input', {
626
+ class: 'input', type: 'search', value: manager.query,
627
+ placeholder: '搜索名称、描述、标签或模型', 'aria-label': '搜索可挂载知识库',
628
+ onInput: (event) => { manager.query = event.target.value; renderShell() },
629
+ })
630
+ const filter = selectControl('筛选挂载状态', [
631
+ { value: 'all', label: '全部状态' },
632
+ { value: 'mounted', label: '已挂载' },
633
+ { value: 'unmounted', label: '未挂载' },
634
+ ...(manager.targetKind === 'session' ? [{ value: 'inherited', label: '继承项目' }] : []),
635
+ { value: 'disabled', label: '已关闭' },
636
+ ], manager.filter, (value) => { manager.filter = value; renderShell() })
637
+ return element('div', { class: 'mount-manager' },
638
+ element('div', { class: 'mount-manager-toolbar' },
639
+ element('div', { class: 'tabs', role: 'tablist', 'aria-label': '挂载目标' }, availableKinds.map(([kind, label]) => element('button', {
640
+ type: 'button', role: 'tab', class: 'tab', 'aria-selected': String(manager.targetKind === kind),
641
+ onClick: () => {
642
+ manager.targetKind = kind
643
+ manager.filter = 'all'
644
+ manager.selectedIds.clear()
645
+ renderShell()
646
+ },
647
+ }, label))),
648
+ element('div', { class: 'search-box mount-search' }, element('span', { class: 'search-symbol', 'aria-hidden': 'true' }, '⌕'), searchInput),
649
+ filter,
650
+ ),
651
+ element('div', { class: 'mount-target-line' },
652
+ element('span', {}, manager.targetKind === 'project' ? '当前项目' : '当前会话'),
653
+ element('code', { title: targetId }, targetId),
654
+ element('span', { class: 'field-hint' }, `显示 ${visibleRows.length} / ${activeBases.length}`),
655
+ ),
656
+ visibleRows.length
657
+ ? element('div', { class: 'mount-table', role: 'list', 'aria-label': '知识库挂载列表' }, visibleRows.map(({ base, view }) => renderMountListRow(base, view, manager.targetKind, targetId)))
658
+ : emptyState('没有匹配的知识库', '调整搜索词或筛选条件。'),
659
+ element('div', { class: 'mount-bulk-bar', 'aria-live': 'polite' },
660
+ element('strong', {}, `已选择 ${selectedCount} 个`),
661
+ element('div', { class: 'mount-bulk-actions' },
662
+ actionButton('选择当前结果', () => { visibleIds.forEach(id => manager.selectedIds.add(id)); renderShell() }, 'ghost small', { disabled: visibleIds.length === 0 }),
663
+ actionButton('清除选择', () => { manager.selectedIds.clear(); renderShell() }, 'ghost small', { disabled: selectedCount === 0 }),
664
+ actionButton(manager.targetKind === 'session' ? '恢复继承' : '取消挂载', () => void bulkRemoveMounts(), 'danger small', { disabled: selectedCount === 0 }),
665
+ actionButton('批量挂载', () => openBulkMountEditor(), 'primary small', { disabled: selectedCount === 0 }),
666
+ ),
667
+ ),
668
+ )
669
+ }
670
+
671
+ function renderMountListRow(base, view, targetKind, targetId) {
672
+ const selected = state.mountManager.selectedIds.has(base.id)
673
+ const checkbox = element('input', {
674
+ type: 'checkbox', checked: selected, 'aria-label': `选择 ${base.name}`,
675
+ onChange: (event) => {
676
+ if (event.target.checked) state.mountManager.selectedIds.add(base.id)
677
+ else state.mountManager.selectedIds.delete(base.id)
678
+ renderShell()
679
+ },
680
+ })
681
+ const modelLabel = base.writebackProvider && base.writebackModel
682
+ ? `${base.writebackProvider} / ${base.writebackModel}`
683
+ : '跟随当前会话模型'
684
+ return element('article', { class: `mount-list-row${selected ? ' is-selected' : ''}`, role: 'listitem' },
685
+ element('label', { class: 'mount-select' }, checkbox, element('span', { class: 'visually-hidden' }, `选择 ${base.name}`)),
686
+ element('div', { class: 'mount-list-main' },
687
+ element('div', { class: 'mount-list-title' }, element('strong', {}, base.name), badge(view.statusLabel, view.statusVariant), badge(modelLabel)),
688
+ element('p', {}, base.description || '通用知识库'),
689
+ element('div', { class: 'mount-list-meta' }, view.detail,
690
+ view.source?.includeTags.length ? element('span', {}, ` · 包含 #${view.source.includeTags.join(' #')}`) : null,
691
+ view.source?.excludeTags.length ? element('span', {}, ` · 排除 #${view.source.excludeTags.join(' #')}`) : null,
692
+ ),
693
+ ),
694
+ actionButton(view.explicit ? '设置' : view.inherited?.enabled ? '覆盖' : '挂载', () => openMountEditor(base, targetKind, targetId, view.explicit, view.inherited), 'small'),
695
+ )
696
+ }
697
+
698
+ function renderCompactEntry(entry) {
699
+ return element('article', { class: 'list-row' },
700
+ element('div', { class: 'list-main' },
701
+ element('div', { class: 'list-title' }, element('strong', {}, entry.title), badge(TYPE_LABELS[entry.type])),
702
+ element('p', { class: 'list-summary' }, entry.body),
703
+ element('div', { class: 'list-meta' }, knowledgeBaseName(entry.knowledgeBaseId), scopeLabel(entry.scope), formatDate(entry.updatedAt)),
704
+ ),
705
+ actionButton('编辑', () => openEntryEditor(entry), 'ghost small'),
706
+ )
707
+ }
708
+
709
+ function renderCompactCandidate(candidate) {
710
+ return element('article', { class: 'list-row' },
711
+ element('div', { class: 'list-main' },
712
+ element('div', { class: 'list-title' }, element('strong', {}, candidate.draft.title), badge(ACTION_LABELS[candidate.action], candidate.action === 'conflict' ? 'warning' : 'accent')),
713
+ element('p', { class: 'list-summary' }, candidate.reason || candidate.draft.body),
714
+ element('div', { class: 'list-meta' }, knowledgeBaseName(candidate.draft.knowledgeBaseId), TYPE_LABELS[candidate.draft.type], formatDate(candidate.createdAt)),
715
+ ),
716
+ actionButton('审核', () => navigate('candidates'), 'ghost small'),
717
+ )
718
+ }
719
+
720
+ function compactEmpty(message) {
721
+ return element('div', { class: 'loading' }, message)
722
+ }
723
+
724
+ function renderEntries() {
725
+ if (state.documentView.mode === 'entries') return renderLegacyEntries()
726
+ const view = state.documentView
727
+ const query = view.query.trim().toLocaleLowerCase()
728
+ const activeBases = state.knowledgeBases.filter(base => base.status === 'active')
729
+ const selectedBase = state.knowledgeBases.find(base => base.id === view.knowledgeBaseId)
730
+ const allBaseDocuments = state.documents.filter(document => document.knowledgeBaseId === view.knowledgeBaseId)
731
+ const visibleDocuments = allBaseDocuments.filter(document => !query || [document.title, document.relPath, document.content]
732
+ .some(value => value.toLocaleLowerCase().includes(query)))
733
+ const selectedDocument = state.documents.find(document => document.id === view.documentId)
734
+ const search = element('input', {
735
+ class: 'input', type: 'search', value: view.query, placeholder: '搜索文档标题、路径或正文', 'aria-label': '搜索知识库文档',
736
+ onInput: (event) => {
737
+ view.query = event.target.value
738
+ const stillVisible = state.documents.some(document => document.id === view.documentId
739
+ && document.knowledgeBaseId === view.knowledgeBaseId
740
+ && (!view.query.trim() || [document.title, document.relPath, document.content].some(value => value.toLocaleLowerCase().includes(view.query.trim().toLocaleLowerCase()))))
741
+ if (!stillVisible) view.documentId = ''
742
+ renderShell()
743
+ document.querySelector('.document-global-search input')?.focus()
744
+ },
745
+ })
746
+ if (!view.documentId && visibleDocuments.length) view.documentId = visibleDocuments[0].id
747
+ const currentDocument = state.documents.find(document => document.id === view.documentId) || selectedDocument
748
+
749
+ return element('section', { class: 'document-page', 'aria-labelledby': 'documents-heading' },
750
+ element('div', { class: 'document-page-toolbar' },
751
+ element('div', { class: 'search-box document-global-search' }, element('span', { class: 'search-symbol', 'aria-hidden': 'true' }, '⌕'), search),
752
+ element('select', {
753
+ class: 'select compact-library-picker', 'aria-label': '选择知识库', value: view.knowledgeBaseId,
754
+ onChange: (event) => { view.knowledgeBaseId = event.target.value; view.documentId = ''; selectDefaultDocument(); renderShell() },
755
+ }, activeBases.map(base => element('option', { value: base.id, selected: base.id === view.knowledgeBaseId }, base.name))),
756
+ element('div', { class: 'tabs', role: 'tablist', 'aria-label': '知识视图' },
757
+ documentViewTab('文档', 'documents'), documentViewTab('条目管理', 'entries')),
758
+ element('div', { class: 'pane-toggle-group document-pane-toggles', role: 'group', 'aria-label': '文档边栏显示' },
759
+ paneToggleButton('library', !view.libraryHidden, () => setDocumentColumnHidden('library', !view.libraryHidden), '知识库二级栏'),
760
+ paneToggleButton('document', !view.documentListHidden, () => setDocumentColumnHidden('documentList', !view.documentListHidden), '文档列表栏'),
761
+ ),
762
+ actionButton('调整栏宽', openLayoutEditor, 'ghost small', { 'aria-label': '调整主导航、知识库和文档栏宽度' }),
763
+ actionButton('+ 新建知识', () => openEntryEditor(), 'primary'),
764
+ ),
765
+ element('div', {
766
+ class: 'document-browser',
767
+ 'data-library-hidden': String(view.libraryHidden),
768
+ 'data-document-list-hidden': String(view.documentListHidden),
769
+ style: `--library-width:${view.libraryWidth}px;--document-list-width:${view.documentListWidth}px`,
770
+ },
771
+ element('aside', { class: 'knowledge-library-column', 'aria-label': '知识库列表' },
772
+ element('header', { class: 'column-header' },
773
+ element('div', {}, element('h2', { id: 'documents-heading' }, '知识库'), element('span', {}, `${activeBases.length} 个可用`)),
774
+ ),
775
+ activeBases.length ? element('div', { class: 'library-list', role: 'listbox', tabindex: '0', onKeyDown: event => moveDocumentSelection(event, 'base') },
776
+ activeBases.map(base => {
777
+ const documentCount = state.documents.filter(document => document.knowledgeBaseId === base.id).length
778
+ const selected = base.id === view.knowledgeBaseId
779
+ return element('button', {
780
+ type: 'button', class: 'library-row', role: 'option', 'aria-selected': String(selected),
781
+ onClick: () => { view.knowledgeBaseId = base.id; view.documentId = ''; selectDefaultDocument(); renderShell() },
782
+ }, element('span', { class: 'library-dot', 'aria-hidden': 'true' }), element('span', { class: 'library-row-copy' },
783
+ element('strong', {}, base.name), element('small', {}, base.description || '通用知识库')),
784
+ element('span', { class: 'library-count', 'aria-label': `${documentCount} 篇文档` }, documentCount))
785
+ })) : compactEmpty('还没有知识库'),
786
+ element('footer', { class: 'column-footer' }, actionButton('+ 新建知识库', () => openKnowledgeBaseEditor(), 'ghost small')),
787
+ ),
788
+ renderColumnResizer('library', '调整知识库栏宽度'),
789
+ element('aside', { class: 'document-list-column', 'aria-label': '文档列表' },
790
+ element('header', { class: 'column-header' },
791
+ element('div', {}, element('h2', {}, selectedBase?.name || '文档'), element('span', {}, query ? `找到 ${visibleDocuments.length} 篇` : `${allBaseDocuments.length} 篇文档`)),
792
+ ),
793
+ visibleDocuments.length ? element('div', { class: 'document-list', role: 'listbox', tabindex: '0', onKeyDown: event => moveDocumentSelection(event, 'document') },
794
+ visibleDocuments.map(document => element('button', {
795
+ type: 'button', class: 'document-row', role: 'option', 'aria-selected': String(document.id === view.documentId),
796
+ onClick: () => { view.documentId = document.id; renderShell() },
797
+ }, element('span', { class: 'document-icon', 'aria-hidden': 'true' }, document.relPath === 'README.md' ? '▣' : '≡'),
798
+ element('span', { class: 'document-row-copy' }, element('strong', {}, document.title), element('small', {}, document.relPath)),
799
+ element('span', { class: 'document-entry-count' }, document.entryCount))))
800
+ : element('div', { class: 'document-empty' }, query ? '没有匹配的文档' : '这个知识库还没有文档'),
801
+ ),
802
+ renderColumnResizer('documentList', '调整文档栏宽度'),
803
+ renderDocumentReader(currentDocument, selectedBase),
804
+ ),
805
+ )
806
+ }
807
+
808
+ function setDocumentColumnHidden(column, hidden) {
809
+ if (column === 'library') state.documentView.libraryHidden = hidden
810
+ else state.documentView.documentListHidden = hidden
811
+ saveDocumentLayout()
812
+ renderShell()
813
+ }
814
+
815
+ function renderColumnResizer(column, label) {
816
+ const isLibrary = column === 'library'
817
+ const minimum = isLibrary ? 170 : 210
818
+ const maximum = isLibrary ? 360 : 480
819
+ const value = isLibrary ? state.documentView.libraryWidth : state.documentView.documentListWidth
820
+ return element('div', {
821
+ class: 'column-resizer', 'data-column': column, role: 'separator', tabindex: '0',
822
+ title: `${label};可拖动或使用左右方向键`,
823
+ 'aria-label': label, 'aria-orientation': 'vertical',
824
+ 'aria-valuemin': minimum, 'aria-valuemax': maximum, 'aria-valuenow': value,
825
+ onPointerDown: event => startColumnResize(event, column, minimum, maximum),
826
+ onKeyDown: event => resizeColumnWithKeyboard(event, column, minimum, maximum),
827
+ }, element('span', { 'aria-hidden': 'true' }, '⋮'))
828
+ }
829
+
830
+ function openLayoutEditor() {
831
+ const fields = [
832
+ layoutRangeField('主导航栏', state.documentView.sidebarWidth, 190, 340),
833
+ layoutRangeField('知识库二级栏', state.documentView.libraryWidth, 170, 360),
834
+ layoutRangeField('文档列表栏', state.documentView.documentListWidth, 210, 480),
835
+ ]
836
+ const visibility = [
837
+ layoutVisibilityOption('显示主导航栏', !state.documentView.sidebarHidden),
838
+ layoutVisibilityOption('显示知识库二级栏', !state.documentView.libraryHidden),
839
+ layoutVisibilityOption('显示文档列表栏', !state.documentView.documentListHidden),
840
+ ]
841
+ const reset = actionButton('恢复默认布局', () => {
842
+ const defaults = [236, 220, 280]
843
+ fields.forEach((field, index) => field.setValue(defaults[index]))
844
+ visibility.forEach(option => { option.input.checked = true })
845
+ }, 'ghost small')
846
+ const body = element('div', { class: 'layout-editor' },
847
+ element('div', { class: 'layout-visibility', 'aria-label': '边栏显示设置' }, visibility.map(option => option.wrapper)),
848
+ fields.map(field => field.wrapper),
849
+ element('div', { class: 'layout-editor-note' },
850
+ element('span', {}, '也可以直接拖动栏与栏之间的三点分隔线。窗口过窄时会自动切换为紧凑布局。'),
851
+ reset,
852
+ ),
853
+ )
854
+ openModal({
855
+ title: '调整边栏宽度',
856
+ description: '分别设置管理导航、知识库二级栏和文档列表栏。',
857
+ body,
858
+ primaryLabel: '应用布局',
859
+ onPrimary: async () => {
860
+ state.documentView.sidebarHidden = !visibility[0].input.checked
861
+ state.documentView.libraryHidden = !visibility[1].input.checked
862
+ state.documentView.documentListHidden = !visibility[2].input.checked
863
+ state.documentView.sidebarWidth = fields[0].value()
864
+ state.documentView.libraryWidth = fields[1].value()
865
+ state.documentView.documentListWidth = fields[2].value()
866
+ saveDocumentLayout()
867
+ renderShell()
868
+ showToast('边栏宽度已保存。')
869
+ return true
870
+ },
871
+ })
872
+ }
873
+
874
+ function layoutVisibilityOption(label, checked) {
875
+ const input = element('input', { type: 'checkbox', checked })
876
+ return {
877
+ input,
878
+ wrapper: element('label', { class: 'check-option' }, input, element('span', {}, element('strong', {}, label))),
879
+ }
880
+ }
881
+
882
+ function layoutRangeField(label, value, minimum, maximum) {
883
+ const output = element('output', { class: 'range-value' }, `${value}px`)
884
+ const input = element('input', {
885
+ type: 'range', min: minimum, max: maximum, step: '10', value,
886
+ 'aria-label': label,
887
+ onInput: event => { output.textContent = `${event.target.value}px` },
888
+ })
889
+ return {
890
+ wrapper: element('div', { class: 'field' },
891
+ element('div', { class: 'layout-range-label' }, element('label', {}, label), output),
892
+ element('div', { class: 'range-row' }, input),
893
+ ),
894
+ value: () => Number(input.value),
895
+ setValue: next => { input.value = String(next); output.textContent = `${next}px` },
896
+ }
897
+ }
898
+
899
+ function startColumnResize(event, column, minimum, maximum) {
900
+ if (event.button !== 0) return
901
+ event.preventDefault()
902
+ const handle = event.currentTarget
903
+ const browser = handle.closest('.document-browser')
904
+ if (!browser) return
905
+ const startX = event.clientX
906
+ const startWidth = column === 'library' ? state.documentView.libraryWidth : state.documentView.documentListWidth
907
+ handle.setPointerCapture?.(event.pointerId)
908
+ handle.classList.add('is-dragging')
909
+ document.body.classList.add('is-resizing-columns')
910
+ const move = moveEvent => {
911
+ const width = clampNumber(startWidth + moveEvent.clientX - startX, minimum, maximum, startWidth)
912
+ setColumnWidth(column, width, browser, handle)
913
+ }
914
+ const finish = () => {
915
+ handle.classList.remove('is-dragging')
916
+ document.body.classList.remove('is-resizing-columns')
917
+ handle.removeEventListener('pointermove', move)
918
+ handle.removeEventListener('pointerup', finish)
919
+ handle.removeEventListener('pointercancel', finish)
920
+ saveDocumentLayout()
921
+ }
922
+ handle.addEventListener('pointermove', move)
923
+ handle.addEventListener('pointerup', finish)
924
+ handle.addEventListener('pointercancel', finish)
925
+ }
926
+
927
+ function resizeColumnWithKeyboard(event, column, minimum, maximum) {
928
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
929
+ event.preventDefault()
930
+ const current = column === 'library' ? state.documentView.libraryWidth : state.documentView.documentListWidth
931
+ const width = event.key === 'Home' ? minimum
932
+ : event.key === 'End' ? maximum
933
+ : clampNumber(current + (event.key === 'ArrowRight' ? 16 : -16), minimum, maximum, current)
934
+ const browser = event.currentTarget.closest('.document-browser')
935
+ setColumnWidth(column, width, browser, event.currentTarget)
936
+ saveDocumentLayout()
937
+ }
938
+
939
+ function setColumnWidth(column, width, browser, handle) {
940
+ if (column === 'library') {
941
+ state.documentView.libraryWidth = width
942
+ browser?.style.setProperty('--library-width', `${width}px`)
943
+ } else {
944
+ state.documentView.documentListWidth = width
945
+ browser?.style.setProperty('--document-list-width', `${width}px`)
946
+ }
947
+ handle?.setAttribute('aria-valuenow', String(width))
948
+ }
949
+
950
+ function documentViewTab(label, mode) {
951
+ return element('button', {
952
+ type: 'button', role: 'tab', class: 'tab', 'aria-selected': String(state.documentView.mode === mode),
953
+ onClick: async () => {
954
+ state.documentView.mode = mode
955
+ if (mode === 'entries') await loadEntries()
956
+ else await loadDocuments()
957
+ renderShell()
958
+ },
959
+ }, label)
960
+ }
961
+
962
+ function moveDocumentSelection(event, kind) {
963
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Enter') return
964
+ const view = state.documentView
965
+ const values = kind === 'base'
966
+ ? state.knowledgeBases.filter(base => base.status === 'active').map(base => base.id)
967
+ : state.documents.filter(document => document.knowledgeBaseId === view.knowledgeBaseId).map(document => document.id)
968
+ const current = kind === 'base' ? view.knowledgeBaseId : view.documentId
969
+ if (event.key === 'Enter') return
970
+ event.preventDefault()
971
+ const index = Math.max(0, values.indexOf(current))
972
+ const next = event.key === 'ArrowDown' ? Math.min(values.length - 1, index + 1) : Math.max(0, index - 1)
973
+ if (!values[next]) return
974
+ if (kind === 'base') { view.knowledgeBaseId = values[next]; view.documentId = ''; selectDefaultDocument() }
975
+ else view.documentId = values[next]
976
+ renderShell()
977
+ document.querySelector(kind === 'base' ? '.library-list' : '.document-list')?.focus()
978
+ }
979
+
980
+ function renderDocumentReader(document, base) {
981
+ if (!document) return element('main', { class: 'document-reader document-reader-empty' },
982
+ element('div', {}, element('strong', {}, '选择一篇文档'), element('p', {}, '文档内容会在这里显示。')))
983
+ return element('main', { class: 'document-reader', 'aria-labelledby': 'document-reader-title' },
984
+ element('header', { class: 'reader-toolbar' },
985
+ element('div', { class: 'reader-title' }, element('span', {}, base?.name || document.knowledgeBaseId), element('strong', { id: 'document-reader-title' }, document.relPath)),
986
+ element('select', {
987
+ class: 'select mobile-document-picker', 'aria-label': '选择文档',
988
+ onChange: (event) => { state.documentView.documentId = event.target.value; renderShell() },
989
+ }, state.documents.filter(item => item.knowledgeBaseId === document.knowledgeBaseId)
990
+ .map(item => element('option', { value: item.id, selected: item.id === document.id }, item.relPath))),
991
+ element('div', { class: 'reader-actions' }, badge(`${document.entryCount} 条知识`), actionButton('添加内容', () => openEntryEditor(), 'small')),
992
+ ),
993
+ element('article', { class: 'markdown-document' }, renderMarkdown(document.content)),
994
+ element('footer', { class: 'reader-footer' },
995
+ element('span', {}, `自动整理 · ${formatDate(document.updatedAt)}`),
996
+ element('span', {}, `SHA-256 · ${document.contentHash.slice(0, 8)}`),
997
+ ),
998
+ )
999
+ }
1000
+
1001
+ function renderMarkdown(markdown) {
1002
+ const nodes = []
1003
+ const lines = markdown.replaceAll('\r\n', '\n').split('\n')
1004
+ let paragraph = []
1005
+ let list = []
1006
+ let code = []
1007
+ let inCode = false
1008
+ const flushParagraph = () => {
1009
+ if (paragraph.length) nodes.push(element('p', {}, paragraph.join(' ')))
1010
+ paragraph = []
1011
+ }
1012
+ const flushList = () => {
1013
+ if (list.length) nodes.push(element('ul', {}, list.map(item => element('li', {}, item))))
1014
+ list = []
1015
+ }
1016
+ const flushCode = () => {
1017
+ if (code.length) nodes.push(element('pre', {}, element('code', {}, code.join('\n'))))
1018
+ code = []
1019
+ }
1020
+ for (const raw of lines) {
1021
+ const line = raw.trimEnd()
1022
+ if (line.startsWith('```')) {
1023
+ flushParagraph(); flushList()
1024
+ if (inCode) flushCode()
1025
+ inCode = !inCode
1026
+ continue
1027
+ }
1028
+ if (inCode) { code.push(raw); continue }
1029
+ const heading = /^(#{1,4})\s+(.+)$/.exec(line)
1030
+ const bullet = /^[-*]\s+(.+)$/.exec(line)
1031
+ if (heading) {
1032
+ flushParagraph(); flushList()
1033
+ nodes.push(element(`h${heading[1].length}`, {}, heading[2]))
1034
+ } else if (line.startsWith('> ')) {
1035
+ flushParagraph(); flushList()
1036
+ nodes.push(element('blockquote', {}, line.slice(2)))
1037
+ } else if (bullet) {
1038
+ flushParagraph(); list.push(bullet[1])
1039
+ } else if (!line.trim()) {
1040
+ flushParagraph(); flushList()
1041
+ } else if (/^<small>.*<\/small>$/.test(line.trim())) {
1042
+ flushParagraph(); flushList()
1043
+ nodes.push(element('small', { class: 'markdown-metadata' }, line.trim().replace(/^<small>|<\/small>$/g, '')))
1044
+ } else paragraph.push(line.trim())
1045
+ }
1046
+ flushParagraph(); flushList(); flushCode()
1047
+ return nodes
1048
+ }
1049
+
1050
+ function renderLegacyEntries() {
1051
+ const queryInput = element('input', { class: 'input', type: 'search', value: state.entryFilters.query, placeholder: '搜索标题、正文或标签', 'aria-label': '搜索知识' })
1052
+ const projectInput = element('input', { class: 'input', value: state.entryFilters.projectId, placeholder: '项目路径(可选)', 'aria-label': '按项目路径筛选' })
1053
+ const applyFilters = async () => {
1054
+ state.entryFilters.query = queryInput.value
1055
+ state.entryFilters.projectId = projectInput.value
1056
+ state.loading = true
1057
+ renderShell()
1058
+ try { await loadEntries() } catch (error) { state.error = friendlyError(error) } finally { state.loading = false; renderShell() }
1059
+ }
1060
+ const toolbar = element('form', { class: 'toolbar', onSubmit: (event) => { event.preventDefault(); void applyFilters() } },
1061
+ element('div', { class: 'search-box' }, element('span', { class: 'search-symbol', 'aria-hidden': 'true' }, '⌕'), queryInput),
1062
+ selectControl('状态', [{ value: 'active', label: '生效中' }, { value: 'archived', label: '已归档' }], state.entryFilters.status, (value) => { state.entryFilters.status = value; void applyFilters() }),
1063
+ selectControl('类型', [{ value: '', label: '全部类型' }, ...TYPES.map(type => ({ value: type, label: TYPE_LABELS[type] }))], state.entryFilters.type, (value) => { state.entryFilters.type = value; void applyFilters() }),
1064
+ selectControl('知识库', [{ value: '', label: '全部知识库' }, ...state.knowledgeBases.map(base => ({ value: base.id, label: base.name }))], state.entryFilters.knowledgeBaseId, (value) => { state.entryFilters.knowledgeBaseId = value; void applyFilters() }),
1065
+ projectInput,
1066
+ actionButton('搜索', () => {}, 'primary', { type: 'submit' }),
1067
+ )
1068
+ return element('section', { 'aria-labelledby': 'entries-heading' },
1069
+ element('div', { class: 'section-heading' }, element('div', {}, element('h2', { id: 'entries-heading' }, `${STATUS_LABELS[state.entryFilters.status]} · ${state.entries.length}`), element('p', {}, '搜索范围会包含全局知识和指定项目知识'))),
1070
+ toolbar,
1071
+ state.entries.length
1072
+ ? element('div', { class: 'card-grid' }, state.entries.map(renderEntryCard))
1073
+ : emptyState('没有匹配的知识', '调整筛选条件,或者手动创建第一条知识。', '新建知识', () => openEntryEditor()),
1074
+ state.nextCursor ? element('div', { class: 'loading' }, actionButton('加载更多', async () => {
1075
+ const cursor = state.nextCursor
1076
+ state.nextCursor = null
1077
+ try { await loadEntries(cursor); renderShell() } catch (error) { showToast(friendlyError(error), 'error') }
1078
+ })) : null,
1079
+ )
1080
+ }
1081
+
1082
+ function renderEntryCard(entry) {
1083
+ const archived = entry.status === 'archived'
1084
+ return element('article', { class: 'knowledge-card' },
1085
+ element('div', { class: 'card-top' },
1086
+ element('div', {}, badge(knowledgeBaseName(entry.knowledgeBaseId)), ' ', badge(TYPE_LABELS[entry.type], 'accent'), ' ', archived ? badge('已归档') : badge('生效中', 'success')),
1087
+ element('span', { class: 'field-hint' }, `v${entry.version}`),
1088
+ ),
1089
+ element('h3', {}, entry.title),
1090
+ element('p', {}, entry.body),
1091
+ entry.tags.length ? element('div', { class: 'tag-row', 'aria-label': '标签' }, entry.tags.map(tag => element('span', { class: 'tag' }, `#${tag}`))) : null,
1092
+ element('div', { class: 'card-footer' },
1093
+ element('span', { class: 'field-hint' }, `${scopeLabel(entry.scope)} · ${formatDate(entry.updatedAt)}`),
1094
+ element('div', { class: 'card-actions' },
1095
+ actionButton('历史', () => openHistory(entry), 'ghost small'),
1096
+ actionButton(archived ? '编辑并恢复' : '编辑', () => openEntryEditor(entry), 'ghost small'),
1097
+ !archived ? actionButton('归档', () => confirmArchive(entry), 'ghost small') : actionButton('彻底删除', () => confirmDelete(entry), 'danger small'),
1098
+ ),
1099
+ ),
1100
+ )
1101
+ }
1102
+
1103
+ function renderCandidates() {
1104
+ const statuses = [['pending', '待审核'], ['approved', '已通过'], ['rejected', '已拒绝']]
1105
+ return element('section', { 'aria-labelledby': 'candidates-heading' },
1106
+ element('div', { class: 'section-heading' },
1107
+ element('div', {}, element('h2', { id: 'candidates-heading' }, 'AI 提取候选'), element('p', {}, '审核写入的结果与冲突项会在这里等待确认;直接写入的普通结果会自动生效。')),
1108
+ element('div', { class: 'tabs', role: 'tablist', 'aria-label': '候选状态' }, statuses.map(([value, label]) => element('button', {
1109
+ type: 'button', role: 'tab', class: 'tab', 'aria-selected': String(state.candidateStatus === value),
1110
+ onClick: async () => { state.candidateStatus = value; await navigate('candidates') },
1111
+ }, label, state.stats ? ` ${state.stats.candidates[value]}` : ''))),
1112
+ ),
1113
+ state.candidates.length ? element('div', { class: 'candidate-list' }, state.candidates.map(renderCandidateCard))
1114
+ : emptyState(`没有${STATUS_LABELS[state.candidateStatus]}候选`, state.candidateStatus === 'pending' ? '新的对话结束后,插件会在后台判断是否产生候选。' : '切换其他状态查看历史记录。'),
1115
+ )
1116
+ }
1117
+
1118
+ function renderCandidateCard(candidate) {
1119
+ const pending = candidate.status === 'pending'
1120
+ const targetDocument = TYPE_DOCUMENTS[candidate.draft.type] || 'README.md'
1121
+ return element('article', { class: 'candidate' },
1122
+ element('div', { class: 'candidate-header' },
1123
+ element('div', {},
1124
+ element('div', {}, badge(knowledgeBaseName(candidate.draft.knowledgeBaseId)), ' ', badge(ACTION_LABELS[candidate.action], candidate.action === 'conflict' ? 'warning' : 'accent'), ' ', badge(TYPE_LABELS[candidate.draft.type])),
1125
+ element('h3', {}, candidate.draft.title),
1126
+ ),
1127
+ badge(STATUS_LABELS[candidate.status], candidate.status === 'approved' ? 'success' : candidate.status === 'rejected' ? 'danger' : 'warning'),
1128
+ ),
1129
+ element('div', { class: 'candidate-body' },
1130
+ element('p', { class: 'candidate-content' }, candidate.draft.body),
1131
+ element('div', { class: 'candidate-reason' },
1132
+ element('strong', {}, '文档变更'),
1133
+ element('span', { class: 'candidate-target' }, `将${candidate.action === 'create' ? '追加' : '更新'}到 ${targetDocument}`),
1134
+ element('strong', {}, '模型判断依据'), candidate.reason || '未提供判断说明'),
1135
+ ),
1136
+ element('div', { class: 'candidate-footer' },
1137
+ element('small', {}, `${scopeLabel(candidate.draft.scope)} · 置信度 ${Math.round(candidate.draft.confidence * 100)}%${candidate.targetId ? ` · 目标 ${candidate.targetId}` : ''} · ${formatDate(candidate.createdAt)}`),
1138
+ pending ? element('div', {},
1139
+ actionButton('拒绝', () => reviewCandidate(candidate, 'reject'), 'danger small'),
1140
+ actionButton('编辑并通过', () => openEntryEditor(undefined, candidate), 'small'),
1141
+ actionButton('通过', () => reviewCandidate(candidate, 'approve'), 'primary small'),
1142
+ ) : null,
1143
+ ),
1144
+ )
1145
+ }
1146
+
1147
+ function renderTokens() {
1148
+ return element('section', { 'aria-labelledby': 'tokens-heading' },
1149
+ element('div', { class: 'section-heading' },
1150
+ element('div', {}, element('h2', { id: 'tokens-heading' }, '客户端访问令牌'), element('p', {}, '给其他 DSH 客户端分配最小必要权限;原始令牌只显示一次。')),
1151
+ actionButton('+ 创建令牌', openTokenCreator, 'primary'),
1152
+ ),
1153
+ element('div', { class: 'panel' }, element('div', { class: 'panel-body' },
1154
+ state.tokens.length ? element('div', { class: 'token-list' }, state.tokens.map(renderTokenRow)) : compactEmpty('还没有访问令牌'),
1155
+ )),
1156
+ )
1157
+ }
1158
+
1159
+ function renderTokenRow(token) {
1160
+ const revoked = Boolean(token.revokedAt)
1161
+ return element('article', { class: 'token-row' },
1162
+ element('div', {},
1163
+ element('strong', {}, token.name, ' ', revoked ? badge('已撤销', 'danger') : badge('有效', 'success')),
1164
+ element('div', { class: 'permissions' }, token.permissions.map(permission => badge(permission))),
1165
+ element('small', {}, `创建于 ${formatDate(token.createdAt)}${token.lastUsedAt ? ` · 最近使用 ${formatDate(token.lastUsedAt)}` : ' · 尚未使用'}`),
1166
+ ),
1167
+ revoked ? null : actionButton('撤销', () => confirmRevokeToken(token), 'danger small'),
1168
+ )
1169
+ }
1170
+
1171
+ function emptyState(title, description, actionLabel, action) {
1172
+ return element('div', { class: 'empty-state' }, element('div', {}, element('strong', {}, title), element('p', {}, description), actionLabel ? actionButton(actionLabel, action, 'primary') : null))
1173
+ }
1174
+
1175
+ function selectControl(label, options, current, onChange) {
1176
+ const select = element('select', { class: 'select', 'aria-label': label, onChange: (event) => onChange(event.target.value) },
1177
+ options.map(option => element('option', { value: option.value, selected: option.value === current }, option.label)))
1178
+ return select
1179
+ }
1180
+
1181
+ function knowledgeBaseName(id) {
1182
+ return state.knowledgeBases.find(base => base.id === id)?.name || id || '默认知识库'
1183
+ }
1184
+
1185
+ function parseTags(value) {
1186
+ return [...new Set(value.split(/[,,]/).map(tag => tag.trim().toLowerCase()).filter(Boolean))]
1187
+ }
1188
+
1189
+ function openKnowledgeBaseEditor(base) {
1190
+ const source = base || { name: '', description: '', defaultTags: [], extractionInstructions: '' }
1191
+ const form = element('form', { class: 'form-grid' })
1192
+ const name = formField('名称', 'input', source.name, { required: true, maxlength: 100, placeholder: '例如:项目规范' })
1193
+ const description = formField('回写匹配描述', 'textarea', source.description, { maxlength: 2000, placeholder: '描述什么样的对话才属于这个库。例如:只记录 dsh-knowledge 项目的架构决策和部署规范' })
1194
+ const tags = formField('默认标签', 'input', source.defaultTags.join(', '), { placeholder: 'project-rule, backend' })
1195
+ const instructions = formField('提取要求', 'textarea', source.extractionInstructions, { maxlength: 4000, placeholder: '例如:只收录已确认、可跨会话复用的项目约定' })
1196
+ const customModel = element('input', { type: 'checkbox', checked: Boolean(source.writebackProvider && source.writebackModel) })
1197
+ const provider = formField('模型提供方(Provider)', 'input', source.writebackProvider || '', { maxlength: 100, placeholder: '例如:cli' })
1198
+ const model = formField('模型名称(Model)', 'input', source.writebackModel || '', { maxlength: 200, placeholder: '例如:kimi-k2.7-code' })
1199
+ const modelHint = element('div', { class: 'field-hint span-2' }, '默认跟随当前会话模型。指定后,仅这个知识库使用专用模型回写。')
1200
+ const updateModelFields = () => {
1201
+ provider.input.disabled = !customModel.checked
1202
+ model.input.disabled = !customModel.checked
1203
+ provider.input.required = customModel.checked
1204
+ model.input.required = customModel.checked
1205
+ }
1206
+ customModel.addEventListener('change', updateModelFields)
1207
+ updateModelFields()
1208
+ for (const field of [name, description, tags, instructions]) field.wrapper.classList.add('span-2')
1209
+ form.append(
1210
+ name.wrapper, description.wrapper, tags.wrapper, instructions.wrapper,
1211
+ element('label', { class: 'check-option span-2' }, customModel, element('span', {},
1212
+ element('strong', {}, '为这个知识库指定回写模型'),
1213
+ element('small', {}, '关闭时自动使用当前会话正在使用的模型。'),
1214
+ )),
1215
+ provider.wrapper, model.wrapper, modelHint,
1216
+ )
1217
+ openModal({
1218
+ title: base ? '编辑知识库' : '创建知识库',
1219
+ description: '匹配描述先判断对话是否属于该库;提取要求再规定应该收录什么。',
1220
+ body: form,
1221
+ primaryLabel: base ? '保存修改' : '创建',
1222
+ onPrimary: async () => {
1223
+ if (!form.reportValidity()) return false
1224
+ const draft = {
1225
+ name: name.input.value.trim(),
1226
+ description: description.input.value.trim(),
1227
+ defaultTags: parseTags(tags.input.value),
1228
+ extractionInstructions: instructions.input.value.trim(),
1229
+ writebackProvider: customModel.checked ? provider.input.value.trim() : '',
1230
+ writebackModel: customModel.checked ? model.input.value.trim() : '',
1231
+ }
1232
+ if (base) await api(`knowledge-bases/${encodeURIComponent(base.id)}`, { method: 'PUT', body: { draft } })
1233
+ else await api('knowledge-bases', { method: 'POST', body: { draft } })
1234
+ showToast(base ? '知识库已更新。' : '知识库已创建,现在可以挂载到项目或会话。')
1235
+ await navigate('bases')
1236
+ return true
1237
+ },
1238
+ })
1239
+ name.input.focus()
1240
+ }
1241
+
1242
+ function openBulkMountEditor() {
1243
+ const manager = state.mountManager
1244
+ const targetId = manager.targetKind === 'project' ? state.mountContext.projectId : state.mountContext.sessionId
1245
+ const bases = state.knowledgeBases.filter(base => base.status === 'active' && manager.selectedIds.has(base.id))
1246
+ if (!targetId || bases.length === 0) return
1247
+ const recall = element('input', { type: 'checkbox', checked: true })
1248
+ const writeMode = selectField('写入方式', [
1249
+ { value: 'none', label: '仅召回(不提取、不回写)' },
1250
+ { value: 'audit', label: '审核写入(推荐)' },
1251
+ { value: 'direct', label: '直接写入(普通结果自动生效;冲突仍待审)' },
1252
+ ], 'audit')
1253
+ const includeTags = formField('必须包含的标签', 'input', '', { placeholder: '可选,逗号分隔' })
1254
+ const excludeTags = formField('排除标签', 'input', '', { placeholder: '可选,逗号分隔' })
1255
+ const form = element('form', { class: 'form-grid' },
1256
+ element('div', { class: 'selection-summary span-2' },
1257
+ element('strong', {}, `将挂载 ${bases.length} 个知识库`),
1258
+ element('p', {}, bases.slice(0, 8).map(base => base.name).join('、'), bases.length > 8 ? ` 等 ${bases.length} 个` : ''),
1259
+ ),
1260
+ element('label', { class: 'check-option' }, recall, element('span', {}, element('strong', {}, '开启召回'), element('small', {}, '回答前检索所选知识库。'))),
1261
+ writeMode.wrapper,
1262
+ includeTags.wrapper,
1263
+ excludeTags.wrapper,
1264
+ )
1265
+ openModal({
1266
+ title: `批量挂载到${manager.targetKind === 'project' ? '项目' : '会话'}`,
1267
+ description: targetId,
1268
+ body: form,
1269
+ primaryLabel: `挂载 ${bases.length} 个`,
1270
+ onPrimary: async () => {
1271
+ const include = parseTags(includeTags.input.value)
1272
+ const exclude = parseTags(excludeTags.input.value)
1273
+ if (include.some(tag => exclude.includes(tag))) {
1274
+ showToast('同一标签不能同时包含和排除。', 'error')
1275
+ return false
1276
+ }
1277
+ await api('mounts/bulk', { method: 'POST', body: {
1278
+ upserts: bases.map(base => ({
1279
+ targetKind: manager.targetKind,
1280
+ targetId,
1281
+ knowledgeBaseId: base.id,
1282
+ enabled: true,
1283
+ recallEnabled: recall.checked,
1284
+ writeMode: writeMode.input.value,
1285
+ includeTags: include,
1286
+ excludeTags: exclude,
1287
+ extractionInstructions: '',
1288
+ })),
1289
+ deleteIds: [],
1290
+ } })
1291
+ manager.selectedIds.clear()
1292
+ showToast(`已批量挂载 ${bases.length} 个知识库。`)
1293
+ await navigate('bases')
1294
+ return true
1295
+ },
1296
+ })
1297
+ }
1298
+
1299
+ async function bulkRemoveMounts() {
1300
+ const manager = state.mountManager
1301
+ const targetId = manager.targetKind === 'project' ? state.mountContext.projectId : state.mountContext.sessionId
1302
+ const explicit = state.mounts.filter(mount =>
1303
+ mount.targetKind === manager.targetKind
1304
+ && mount.targetId === targetId
1305
+ && manager.selectedIds.has(mount.knowledgeBaseId))
1306
+ if (explicit.length === 0) {
1307
+ showToast(manager.targetKind === 'session' ? '所选知识库当前没有会话覆盖,无需恢复。' : '所选知识库当前没有项目挂载。')
1308
+ return
1309
+ }
1310
+ try {
1311
+ await api('mounts/bulk', { method: 'POST', body: { upserts: [], deleteIds: explicit.map(mount => mount.id) } })
1312
+ manager.selectedIds.clear()
1313
+ showToast(manager.targetKind === 'session' ? `已恢复 ${explicit.length} 个知识库的项目继承。` : `已取消 ${explicit.length} 个项目挂载。`)
1314
+ await navigate('bases')
1315
+ } catch (error) {
1316
+ showToast(friendlyError(error), 'error')
1317
+ }
1318
+ }
1319
+
1320
+ function openMountEditor(base, targetKind, targetId, explicit, inherited) {
1321
+ const source = explicit || inherited || {
1322
+ enabled: true, recallEnabled: true, writeMode: 'audit', includeTags: [], excludeTags: [], extractionInstructions: '',
1323
+ }
1324
+ const enabled = element('input', { type: 'checkbox', checked: source.enabled })
1325
+ const recall = element('input', { type: 'checkbox', checked: source.recallEnabled })
1326
+ const writeMode = selectField('写入方式', [
1327
+ { value: 'none', label: '仅召回(不提取、不回写)' },
1328
+ { value: 'audit', label: '审核写入(先进待审核)' },
1329
+ { value: 'direct', label: '直接写入(普通结果自动生效;冲突仍待审)' },
1330
+ ], source.writeMode)
1331
+ const includeTags = formField('必须包含的标签', 'input', source.includeTags.join(', '), { placeholder: '例如:project-rule' })
1332
+ const excludeTags = formField('排除标签', 'input', source.excludeTags.join(', '), { placeholder: '例如:personal, temporary' })
1333
+ const instructions = formField('本挂载的额外提取要求', 'textarea', source.extractionInstructions, { maxlength: 4000, placeholder: '可以比知识库默认规则更严格' })
1334
+ const form = element('form', { class: 'form-grid' },
1335
+ element('label', { class: 'check-option span-2' }, enabled, element('span', {}, element('strong', {}, '启用这个挂载'), element('small', {}, '关闭后,当前范围不召回、不提取、不回写。'))),
1336
+ element('label', { class: 'check-option' }, recall, element('span', {}, element('strong', {}, '开启召回'), element('small', {}, '回答前只检索这个库。'))),
1337
+ writeMode.wrapper,
1338
+ includeTags.wrapper,
1339
+ excludeTags.wrapper,
1340
+ instructions.wrapper,
1341
+ )
1342
+ instructions.wrapper.classList.add('span-2')
1343
+ if (targetKind === 'session' && !explicit && inherited) {
1344
+ form.prepend(element('div', { class: 'context-warning span-2' }, '此会话目前继承项目配置。保存后会创建独立的会话覆盖。'))
1345
+ }
1346
+ if (explicit) {
1347
+ form.append(element('div', { class: 'mount-delete span-2' },
1348
+ element('span', { class: 'field-hint' }, targetKind === 'session' ? '删除会话覆盖后,将恢复继承项目配置。' : '删除后,该项目不再挂载此库。'),
1349
+ actionButton('删除当前配置', async () => {
1350
+ try {
1351
+ await api(`mounts/${encodeURIComponent(explicit.id)}`, { method: 'DELETE' })
1352
+ document.querySelector('.dialog-backdrop')?.remove()
1353
+ showToast(targetKind === 'session' ? '会话覆盖已删除,已恢复项目继承。' : '项目挂载已删除。')
1354
+ await navigate('bases')
1355
+ } catch (error) { showToast(friendlyError(error), 'error') }
1356
+ }, 'danger small'),
1357
+ ))
1358
+ }
1359
+ const updateAvailability = () => {
1360
+ for (const input of [recall, writeMode.input, includeTags.input, excludeTags.input, instructions.input]) input.disabled = !enabled.checked
1361
+ }
1362
+ enabled.addEventListener('change', updateAvailability)
1363
+ updateAvailability()
1364
+ openModal({
1365
+ title: `${targetKind === 'project' ? '项目' : '会话'}挂载 · ${base.name}`,
1366
+ description: targetId,
1367
+ body: form,
1368
+ primaryLabel: '保存挂载',
1369
+ onPrimary: async () => {
1370
+ const include = parseTags(includeTags.input.value)
1371
+ const exclude = parseTags(excludeTags.input.value)
1372
+ if (include.some(tag => exclude.includes(tag))) {
1373
+ showToast('同一标签不能同时包含和排除。', 'error')
1374
+ return false
1375
+ }
1376
+ await api('mounts', { method: 'POST', body: { draft: {
1377
+ targetKind, targetId, knowledgeBaseId: base.id,
1378
+ enabled: enabled.checked,
1379
+ recallEnabled: enabled.checked && recall.checked,
1380
+ writeMode: enabled.checked ? writeMode.input.value : 'none',
1381
+ includeTags: include,
1382
+ excludeTags: exclude,
1383
+ extractionInstructions: instructions.input.value.trim(),
1384
+ } } })
1385
+ showToast(enabled.checked ? '挂载设置已保存。' : '已在当前范围关闭这个知识库。')
1386
+ await navigate('bases')
1387
+ return true
1388
+ },
1389
+ })
1390
+ }
1391
+
1392
+ function confirmArchiveKnowledgeBase(base) {
1393
+ openConfirm({
1394
+ title: `归档“${base.name}”?`,
1395
+ message: '归档后所有挂载会自动关闭,其中的知识仍保留,但不再参与召回与回写。',
1396
+ confirmLabel: '确认归档', danger: true,
1397
+ onConfirm: async () => {
1398
+ await api(`knowledge-bases/${encodeURIComponent(base.id)}/archive`, { method: 'POST' })
1399
+ showToast('知识库已归档,相关挂载已关闭。')
1400
+ await navigate('bases')
1401
+ },
1402
+ })
1403
+ }
1404
+
1405
+ function confirmRestoreKnowledgeBase(base) {
1406
+ openConfirm({
1407
+ title: `恢复“${base.name}”?`,
1408
+ message: '恢复后知识库可再次挂载;之前归档时关闭的挂载不会自动重开。',
1409
+ confirmLabel: '确认恢复', danger: false,
1410
+ onConfirm: async () => {
1411
+ await api(`knowledge-bases/${encodeURIComponent(base.id)}/restore`, { method: 'POST' })
1412
+ showToast('知识库已恢复,可以重新配置挂载。')
1413
+ await navigate('bases')
1414
+ },
1415
+ })
1416
+ }
1417
+
1418
+ function confirmDeleteKnowledgeBase(base) {
1419
+ const confirmation = formField('输入知识库名称确认', 'input', '', {
1420
+ required: true, autocomplete: 'off', placeholder: base.name,
1421
+ 'aria-describedby': 'delete-base-warning',
1422
+ })
1423
+ const form = element('form', {},
1424
+ element('div', { id: 'delete-base-warning', class: 'context-warning' },
1425
+ '此操作无法撤销。知识库中的全部知识、版本历史、候选、挂载和生成文档都会永久删除。'),
1426
+ confirmation.wrapper,
1427
+ )
1428
+ confirmation.wrapper.classList.add('delete-base-confirmation')
1429
+ openModal({
1430
+ title: `永久删除“${base.name}”?`,
1431
+ description: '只有已归档知识库可以永久删除。',
1432
+ body: form,
1433
+ primaryLabel: '永久删除',
1434
+ primaryVariant: 'danger',
1435
+ onPrimary: async () => {
1436
+ if (!form.reportValidity()) return false
1437
+ if (confirmation.input.value.trim() !== base.name) {
1438
+ showToast('输入的知识库名称不匹配。', 'error')
1439
+ confirmation.input.select()
1440
+ return false
1441
+ }
1442
+ await api(`knowledge-bases/${encodeURIComponent(base.id)}`, { method: 'DELETE' })
1443
+ if (state.documentView.knowledgeBaseId === base.id) {
1444
+ state.documentView.knowledgeBaseId = ''
1445
+ state.documentView.documentId = ''
1446
+ }
1447
+ if (state.entryFilters.knowledgeBaseId === base.id) state.entryFilters.knowledgeBaseId = ''
1448
+ showToast('知识库及其全部关联数据已永久删除。')
1449
+ await navigate('bases')
1450
+ return true
1451
+ },
1452
+ })
1453
+ confirmation.input.focus()
1454
+ }
1455
+
1456
+ function openEntryEditor(entry, candidate) {
1457
+ const source = candidate?.draft || entry || {
1458
+ knowledgeBaseId: state.documentView.knowledgeBaseId || undefined,
1459
+ title: '', body: '', type: 'fact', tags: [], scope: { kind: 'global' }, confidence: .8,
1460
+ }
1461
+ const form = element('form', { class: 'form-grid' })
1462
+ const title = formField('标题', 'input', source.title, { required: true, maxlength: 200, placeholder: '一句话说明这条知识' })
1463
+ const body = formField('正文', 'textarea', source.body, { required: true, maxlength: 50000, placeholder: '写下可在未来对话中复用的内容' })
1464
+ const type = selectField('类型', TYPES.map(value => ({ value, label: TYPE_LABELS[value] })), source.type)
1465
+ const activeBases = state.knowledgeBases.filter(base => base.status === 'active')
1466
+ const selectedBaseId = source.knowledgeBaseId || activeBases[0]?.id || 'default'
1467
+ const knowledgeBase = selectField('所属知识库', activeBases.map(base => ({ value: base.id, label: base.name })), selectedBaseId)
1468
+ const scope = selectField('范围', [{ value: 'global', label: '全局' }, { value: 'project', label: '项目' }], source.scope.kind)
1469
+ const project = formField('项目 ID / 路径', 'input', source.scope.kind === 'project' ? source.scope.id : '', { placeholder: '/workspace/project' })
1470
+ const tags = formField('标签', 'input', source.tags.join(', '), { placeholder: 'docker, deployment' })
1471
+ const confidenceInput = element('input', { type: 'range', min: 0, max: 1, step: .01, value: source.confidence })
1472
+ const confidenceValue = element('span', { class: 'range-value' }, `${Math.round(source.confidence * 100)}%`)
1473
+ confidenceInput.addEventListener('input', () => { confidenceValue.textContent = `${Math.round(Number(confidenceInput.value) * 100)}%` })
1474
+ const scopeProjectField = project.wrapper
1475
+ const updateScope = () => { scopeProjectField.hidden = scope.input.value !== 'project'; project.input.required = scope.input.value === 'project' }
1476
+ scope.input.addEventListener('change', updateScope)
1477
+ updateScope()
1478
+ form.append(
1479
+ title.wrapper,
1480
+ type.wrapper,
1481
+ knowledgeBase.wrapper,
1482
+ scope.wrapper,
1483
+ scopeProjectField,
1484
+ element('div', { class: 'field span-2' }, element('label', {}, '置信度'), element('div', { class: 'range-row' }, confidenceInput, confidenceValue)),
1485
+ body.wrapper,
1486
+ tags.wrapper,
1487
+ )
1488
+ body.wrapper.classList.add('span-2')
1489
+ tags.wrapper.classList.add('span-2')
1490
+ const modeTitle = candidate ? '编辑并通过候选' : entry ? '编辑知识' : '新建知识'
1491
+ const modal = openModal({ title: modeTitle, description: candidate ? '保存后,这条候选会立即通过并写入知识库。' : '知识保存后会立即参与后续召回。', body: form, primaryLabel: candidate ? '通过并保存' : '保存', onPrimary: async () => {
1492
+ if (!form.reportValidity()) return false
1493
+ const draft = {
1494
+ knowledgeBaseId: knowledgeBase.input.value,
1495
+ title: title.input.value.trim(), body: body.input.value.trim(), type: type.input.value,
1496
+ tags: parseTags(tags.input.value),
1497
+ scope: scope.input.value === 'global' ? { kind: 'global' } : { kind: 'project', id: project.input.value.trim() },
1498
+ confidence: Number(confidenceInput.value),
1499
+ ...(source.source ? { source: source.source } : {}),
1500
+ }
1501
+ if (candidate) await api(`candidates/${encodeURIComponent(candidate.id)}/review`, { method: 'POST', body: { decision: 'approve', draft } })
1502
+ else if (entry) await api(`entries/${encodeURIComponent(entry.id)}`, { method: 'PUT', body: { draft } })
1503
+ else await api('entries', { method: 'POST', body: { draft } })
1504
+ showToast(candidate ? '候选已通过并写入知识库。' : '知识已保存。')
1505
+ state.stats = null
1506
+ await navigate(candidate ? 'candidates' : state.view)
1507
+ return true
1508
+ } })
1509
+ title.input.focus()
1510
+ return modal
1511
+ }
1512
+
1513
+ function formField(label, kind, value, attributes = {}) {
1514
+ const input = element(kind === 'textarea' ? 'textarea' : 'input', { class: kind === 'textarea' ? 'textarea' : 'input', value, ...attributes })
1515
+ if (kind === 'textarea') input.value = value
1516
+ return { input, wrapper: element('div', { class: 'field' }, element('label', {}, label), input) }
1517
+ }
1518
+
1519
+ function selectField(label, options, value) {
1520
+ const input = element('select', { class: 'select' }, options.map(option => element('option', { value: option.value, selected: option.value === value }, option.label)))
1521
+ return { input, wrapper: element('div', { class: 'field' }, element('label', {}, label), input) }
1522
+ }
1523
+
1524
+ async function reviewCandidate(candidate, decision) {
1525
+ const approve = decision === 'approve'
1526
+ openConfirm({
1527
+ title: approve ? '通过这条候选?' : '拒绝这条候选?',
1528
+ message: approve ? '通过后内容会立即写入知识库,并参与后续对话召回。' : '拒绝后会保留审核记录,但不会进入知识库。',
1529
+ confirmLabel: approve ? '确认通过' : '确认拒绝', danger: !approve,
1530
+ onConfirm: async () => {
1531
+ await api(`candidates/${encodeURIComponent(candidate.id)}/review`, { method: 'POST', body: { decision } })
1532
+ showToast(approve ? '候选已通过。' : '候选已拒绝。')
1533
+ state.stats = null
1534
+ await navigate('candidates')
1535
+ },
1536
+ })
1537
+ }
1538
+
1539
+ function confirmArchive(entry) {
1540
+ openConfirm({ title: '归档这条知识?', message: '归档后它不会再参与召回,版本历史仍会保留。', confirmLabel: '确认归档', danger: true, onConfirm: async () => {
1541
+ await api(`entries/${encodeURIComponent(entry.id)}/archive`, { method: 'POST' })
1542
+ showToast('知识已归档。')
1543
+ state.stats = null
1544
+ await navigate('entries')
1545
+ } })
1546
+ }
1547
+
1548
+ function confirmDelete(entry) {
1549
+ openConfirm({ title: '彻底删除这条知识?', message: '知识正文和全部版本历史都会永久删除,此操作无法撤销。', confirmLabel: '永久删除', danger: true, onConfirm: async () => {
1550
+ await api(`entries/${encodeURIComponent(entry.id)}`, { method: 'DELETE' })
1551
+ showToast('知识已彻底删除。')
1552
+ state.stats = null
1553
+ await navigate('entries')
1554
+ } })
1555
+ }
1556
+
1557
+ async function openHistory(entry) {
1558
+ try {
1559
+ const versions = await api(`entries/${encodeURIComponent(entry.id)}/versions`)
1560
+ const content = element('div', { class: 'history' }, versions.map(version => element('article', { class: 'history-item' },
1561
+ element('div', { class: 'history-line' }, element('span', { class: 'history-dot', 'aria-hidden': 'true' })),
1562
+ element('div', { class: 'history-content' }, element('strong', {}, `v${version.version} · ${CHANGE_LABELS[version.changeKind]}`), element('small', {}, formatDate(version.createdAt)), element('p', {}, version.snapshot.body)),
1563
+ )))
1564
+ openModal({ title: `${entry.title} · 版本历史`, description: '每次修改都会保留不可变快照。', body: content, cancelLabel: '关闭' })
1565
+ } catch (error) { showToast(friendlyError(error), 'error') }
1566
+ }
1567
+
1568
+ function openTokenCreator() {
1569
+ const name = formField('令牌名称', 'input', '', { required: true, maxlength: 100, placeholder: '例如:办公室电脑' })
1570
+ const permissions = ['read', 'propose', 'write', 'admin']
1571
+ const checkboxes = permissions.map((permission, index) => {
1572
+ const input = element('input', { type: 'checkbox', value: permission, checked: index === 0 })
1573
+ return { permission, input, node: element('label', { class: 'check-option' }, input, permission) }
1574
+ })
1575
+ const body = element('form', { class: 'form-grid' }, name.wrapper,
1576
+ element('div', { class: 'field span-2' }, element('label', {}, '权限'), element('div', { class: 'check-grid' }, checkboxes.map(item => item.node)), element('span', { class: 'field-hint' }, '普通客户端建议只授予 read + propose。')))
1577
+ name.wrapper.classList.add('span-2')
1578
+ openModal({ title: '创建客户端令牌', description: '原始令牌只会在创建成功后显示一次。', body, primaryLabel: '创建令牌', onPrimary: async () => {
1579
+ if (!body.reportValidity()) return false
1580
+ const selected = checkboxes.filter(item => item.input.checked).map(item => item.permission)
1581
+ if (!selected.length) { showToast('请至少选择一项权限。', 'error'); return false }
1582
+ const created = await api('tokens', { method: 'POST', body: { name: name.input.value.trim(), permissions: selected } })
1583
+ await loadTokens()
1584
+ renderShell()
1585
+ window.setTimeout(() => showSecret(created.token), 0)
1586
+ return true
1587
+ } })
1588
+ name.input.focus()
1589
+ }
1590
+
1591
+ function showSecret(token) {
1592
+ const code = element('code', {}, token)
1593
+ const content = element('div', { class: 'secret-box' }, element('strong', {}, '请立即复制并妥善保存'), element('p', {}, '关闭此窗口后,服务端无法再次显示原始令牌。'), element('div', { class: 'secret-value' }, code, actionButton('复制', async () => {
1594
+ try { await navigator.clipboard.writeText(token); showToast('令牌已复制。') } catch { showToast('复制失败,请手动选择令牌。', 'error') }
1595
+ }, 'small')))
1596
+ openModal({ title: '令牌创建成功', body: content, cancelLabel: '我已保存' })
1597
+ }
1598
+
1599
+ function confirmRevokeToken(token) {
1600
+ openConfirm({ title: `撤销“${token.name}”?`, message: '使用此令牌的客户端会立即失去访问权限,此操作不可撤销。', confirmLabel: '确认撤销', danger: true, onConfirm: async () => {
1601
+ await api(`tokens/${encodeURIComponent(token.id)}`, { method: 'DELETE' })
1602
+ showToast('令牌已撤销。')
1603
+ await loadTokens()
1604
+ renderShell()
1605
+ } })
1606
+ }
1607
+
1608
+ function openConfirm({ title, message, confirmLabel, danger, onConfirm }) {
1609
+ return openModal({ title, body: element('p', {}, message), primaryLabel: confirmLabel, primaryVariant: danger ? 'danger' : 'primary', onPrimary: async () => { await onConfirm(); return true } })
1610
+ }
1611
+
1612
+ function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消' }) {
1613
+ const previouslyFocused = document.activeElement
1614
+ const backdrop = element('div', { class: 'dialog-backdrop' })
1615
+ const dialog = element('section', { class: `dialog ${primaryLabel ? '' : 'narrow'}`.trim(), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dialog-title' })
1616
+ let busy = false
1617
+ const close = () => {
1618
+ if (busy) return
1619
+ document.removeEventListener('keydown', onKeyDown)
1620
+ backdrop.remove()
1621
+ if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus()
1622
+ }
1623
+ const closeButton = actionButton('×', close, 'ghost', { 'aria-label': '关闭对话框' })
1624
+ const cancel = actionButton(cancelLabel, close)
1625
+ const primary = primaryLabel ? actionButton(primaryLabel, async () => {
1626
+ if (busy) return
1627
+ busy = true
1628
+ primary.disabled = true
1629
+ cancel.disabled = true
1630
+ const original = primary.textContent
1631
+ primary.textContent = '正在处理…'
1632
+ try {
1633
+ const shouldClose = await onPrimary()
1634
+ busy = false
1635
+ if (shouldClose !== false) close()
1636
+ else { primary.disabled = false; cancel.disabled = false; primary.textContent = original }
1637
+ } catch (error) {
1638
+ busy = false
1639
+ primary.disabled = false
1640
+ cancel.disabled = false
1641
+ primary.textContent = original
1642
+ showToast(friendlyError(error), 'error')
1643
+ }
1644
+ }, primaryVariant) : null
1645
+ const onKeyDown = (event) => {
1646
+ if (event.key === 'Escape') { event.preventDefault(); close() }
1647
+ if (event.key === 'Tab') trapFocus(event, dialog)
1648
+ }
1649
+ dialog.append(
1650
+ element('header', { class: 'dialog-header' }, element('div', {}, element('h2', { id: 'dialog-title' }, title), description ? element('p', {}, description) : null), closeButton),
1651
+ element('div', { class: 'dialog-body' }, body),
1652
+ element('footer', { class: 'dialog-footer' }, cancel, primary),
1653
+ )
1654
+ backdrop.append(dialog)
1655
+ backdrop.addEventListener('mousedown', (event) => { if (event.target === backdrop) close() })
1656
+ document.body.append(backdrop)
1657
+ document.addEventListener('keydown', onKeyDown)
1658
+ window.setTimeout(() => (dialog.querySelector('input, textarea, select, button') || dialog).focus(), 0)
1659
+ return { close, dialog }
1660
+ }
1661
+
1662
+ function trapFocus(event, container) {
1663
+ const focusable = [...container.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])')]
1664
+ if (!focusable.length) return
1665
+ const first = focusable[0]
1666
+ const last = focusable.at(-1)
1667
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
1668
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
1669
+ }
1670
+
1671
+ function formatDate(value) {
1672
+ try { return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)) } catch { return value }
1673
+ }
1674
+
1675
+ function scopeLabel(scope) {
1676
+ return scope.kind === 'global' ? '全局' : `项目 · ${scope.id}`
1677
+ }
1678
+
1679
+ function friendlyError(error) {
1680
+ if (error.status === 403) return '当前令牌没有执行此操作所需的权限。'
1681
+ if (error.status === 409) return error.message || '内容已发生变化,请刷新后重试。'
1682
+ if (error.name === 'AbortError') return '请求已取消。'
1683
+ return error.message || '操作失败,请稍后重试。'
1684
+ }
1685
+
1686
+ void boot()