@lemoncat7/dsh-knowledge 2.2.9 → 2.2.13-next.12

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 (77) hide show
  1. package/README.md +3 -3
  2. package/docs/architecture.md +6 -2
  3. package/lib/api.d.ts.map +1 -1
  4. package/lib/api.js +42 -0
  5. package/lib/api.js.map +1 -1
  6. package/lib/client.js +22 -22
  7. package/lib/client.js.map +2 -2
  8. package/lib/domain.d.ts +15 -0
  9. package/lib/domain.d.ts.map +1 -1
  10. package/lib/domain.js.map +1 -1
  11. package/lib/local-provider.d.ts +11 -2
  12. package/lib/local-provider.d.ts.map +1 -1
  13. package/lib/local-provider.js +71 -0
  14. package/lib/local-provider.js.map +1 -1
  15. package/lib/management-proxy.js +1 -1
  16. package/lib/management-proxy.js.map +1 -1
  17. package/lib/notes/domain.d.ts +10 -0
  18. package/lib/notes/domain.d.ts.map +1 -1
  19. package/lib/notes/domain.js.map +1 -1
  20. package/lib/notes/store.d.ts +15 -1
  21. package/lib/notes/store.d.ts.map +1 -1
  22. package/lib/notes/store.js +277 -28
  23. package/lib/notes/store.js.map +1 -1
  24. package/lib/provider.d.ts +10 -2
  25. package/lib/provider.d.ts.map +1 -1
  26. package/lib/remote-provider.d.ts +10 -2
  27. package/lib/remote-provider.d.ts.map +1 -1
  28. package/lib/remote-provider.js +27 -0
  29. package/lib/remote-provider.js.map +1 -1
  30. package/lib/theme-bridge.js +7 -7
  31. package/lib/theme-bridge.js.map +1 -1
  32. package/lib/tool-authorization.js +11 -16
  33. package/lib/tool-authorization.js.map +1 -1
  34. package/lib/web-note-editor-chrome.d.ts +17 -0
  35. package/lib/web-note-editor-chrome.d.ts.map +1 -0
  36. package/lib/web-note-editor-chrome.js +38 -0
  37. package/lib/web-note-editor-chrome.js.map +1 -0
  38. package/lib/web-note-editor-find.d.ts +17 -0
  39. package/lib/web-note-editor-find.d.ts.map +1 -0
  40. package/lib/web-note-editor-find.js +225 -0
  41. package/lib/web-note-editor-find.js.map +1 -0
  42. package/lib/web-note-editor-outline.d.ts +15 -0
  43. package/lib/web-note-editor-outline.d.ts.map +1 -0
  44. package/lib/web-note-editor-outline.js +169 -0
  45. package/lib/web-note-editor-outline.js.map +1 -0
  46. package/lib/web-note-editor-search.d.ts +30 -0
  47. package/lib/web-note-editor-search.d.ts.map +1 -0
  48. package/lib/web-note-editor-search.js +143 -0
  49. package/lib/web-note-editor-search.js.map +1 -0
  50. package/lib/web-note-editor-selection.d.ts +15 -0
  51. package/lib/web-note-editor-selection.d.ts.map +1 -0
  52. package/lib/web-note-editor-selection.js +255 -0
  53. package/lib/web-note-editor-selection.js.map +1 -0
  54. package/lib/web-note-editor.d.ts +7 -0
  55. package/lib/web-note-editor.d.ts.map +1 -1
  56. package/lib/web-note-editor.js +36 -6
  57. package/lib/web-note-editor.js.map +1 -1
  58. package/lib/web-note-history.d.ts +28 -0
  59. package/lib/web-note-history.d.ts.map +1 -0
  60. package/lib/web-note-history.js +163 -0
  61. package/lib/web-note-history.js.map +1 -0
  62. package/lib/web-workspace-effects.d.ts.map +1 -1
  63. package/lib/web-workspace-effects.js +1 -2
  64. package/lib/web-workspace-effects.js.map +1 -1
  65. package/lib/web.d.ts.map +1 -1
  66. package/lib/web.js +4 -0
  67. package/lib/web.js.map +1 -1
  68. package/package.json +1 -1
  69. package/web/api-client.js +94 -0
  70. package/web/app.js +344 -235
  71. package/web/host-theme.js +63 -0
  72. package/web/index.html +1 -0
  73. package/web/note-editor.js +70 -70
  74. package/web/note-history.js +1 -0
  75. package/web/styles.css +374 -154
  76. package/web/ui-primitives.js +77 -0
  77. package/web/workspace-effects.js +1 -1
package/web/app.js CHANGED
@@ -2,16 +2,15 @@ const API_BASE = document.querySelector('meta[name="dsh-knowledge-api"]')?.conte
2
2
  const AUTH_MODE = document.querySelector('meta[name="dsh-knowledge-auth-mode"]')?.content || 'bearer'
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
- const HOST_THEME_MESSAGE = '@lemoncat7/dsh-knowledge/host-theme'
6
- const HOST_THEME_READY_MESSAGE = '@lemoncat7/dsh-knowledge/host-theme-ready'
7
- const HOST_THEME_PROTOCOL_VERSION = 1
8
- const HOST_THEME_COLOR_TOKENS = new Set([
9
- '--bg', '--surface', '--surface-raised', '--surface-soft', '--surface-hover', '--dialog-surface',
10
- '--text', '--text-secondary', '--text-tertiary', '--border', '--border-strong',
11
- '--accent', '--accent-hover', '--accent-soft', '--on-accent',
12
- '--success', '--success-soft', '--warning', '--warning-soft', '--danger', '--danger-soft',
5
+ const moduleUrl = name => `./${name}.js${ASSET_VERSION ? `?v=${encodeURIComponent(ASSET_VERSION)}` : ''}`
6
+ const [apiModule, themeModule, uiModule] = await Promise.all([
7
+ import(moduleUrl('api-client')),
8
+ import(moduleUrl('host-theme')),
9
+ import(moduleUrl('ui-primitives')),
13
10
  ])
14
- const HOST_THEME_STYLE_TOKENS = new Set(['--shadow'])
11
+ const { createApiClient } = apiModule
12
+ const { installHostThemeBridge } = themeModule
13
+ const { actionButton, badge, createToastPresenter, element, interfaceIcon, paneToggleButton } = uiModule
15
14
  const TOKEN_KEY = 'dsh-knowledge.session-token'
16
15
  const TYPES = ['preference', 'fact', 'decision', 'procedure', 'lesson']
17
16
  const TYPE_LABELS = { preference: '偏好', fact: '事实', decision: '决策', procedure: '流程', lesson: '经验' }
@@ -33,6 +32,7 @@ const mountContext = {
33
32
  }
34
33
  const app = document.querySelector('#app')
35
34
  const toastRegion = document.querySelector('#toast-region')
35
+ const showToast = createToastPresenter(toastRegion)
36
36
  const savedDocumentLayout = readDocumentLayout()
37
37
 
38
38
  function createDocumentViewState(overrides = {}) {
@@ -81,6 +81,7 @@ const state = {
81
81
  candidates: [],
82
82
  candidateTargets: new Map(),
83
83
  candidateStatus: 'pending',
84
+ candidateBatchRunning: false,
84
85
  settings: { writebackPolicy: 'conservative', updatedAt: '' },
85
86
  modelCatalog: null,
86
87
  settingsSaving: false,
@@ -120,62 +121,10 @@ let noteSelectionRequest = 0
120
121
  let knowledgeDocumentDrag = null
121
122
  let movingDocumentId = ''
122
123
 
123
- function installHostThemeBridge() {
124
- if (window.parent === window) return Promise.resolve()
125
- const parentOrigin = referrerOrigin()
126
- return new Promise(resolve => {
127
- let initialThemeSettled = false
128
- const settleInitialTheme = () => {
129
- if (initialThemeSettled) return
130
- initialThemeSettled = true
131
- window.clearTimeout(fallback)
132
- resolve()
133
- }
134
- const fallback = window.setTimeout(settleInitialTheme, 160)
135
- window.addEventListener('message', event => {
136
- if (event.source !== window.parent) return
137
- if (parentOrigin && event.origin !== parentOrigin) return
138
- const message = event.data
139
- if (!message || message.type !== HOST_THEME_MESSAGE || message.version !== HOST_THEME_PROTOCOL_VERSION) return
140
- if (message.colorScheme !== 'light' && message.colorScheme !== 'dark') return
141
- if (!message.tokens || typeof message.tokens !== 'object' || Array.isArray(message.tokens)) return
142
-
143
- const root = document.documentElement
144
- for (const name of [...HOST_THEME_COLOR_TOKENS, ...HOST_THEME_STYLE_TOKENS]) root.style.removeProperty(name)
145
- for (const [name, value] of Object.entries(message.tokens)) {
146
- if (typeof value !== 'string' || value.length === 0 || value.length > 512) continue
147
- if (HOST_THEME_COLOR_TOKENS.has(name) && CSS.supports('color', value)) root.style.setProperty(name, value)
148
- else if (HOST_THEME_STYLE_TOKENS.has(name) && CSS.supports('box-shadow', value)) root.style.setProperty(name, value)
149
- }
150
- root.dataset.dshHostTheme = 'true'
151
- root.dataset.colorScheme = message.colorScheme
152
- root.style.colorScheme = message.colorScheme
153
- document.querySelector('meta[name="color-scheme"]')?.setAttribute('content', message.colorScheme)
154
- const background = root.style.getPropertyValue('--bg')
155
- if (background) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', background)
156
- settleInitialTheme()
157
- })
158
- window.parent.postMessage({
159
- type: HOST_THEME_READY_MESSAGE,
160
- version: HOST_THEME_PROTOCOL_VERSION,
161
- }, parentOrigin || '*')
162
- })
163
- }
164
-
165
- function referrerOrigin() {
166
- if (!document.referrer) return ''
167
- try {
168
- const origin = new URL(document.referrer).origin
169
- return origin === 'null' ? '' : origin
170
- } catch {
171
- return ''
172
- }
173
- }
174
-
175
124
  function readDocumentLayout() {
176
125
  const fallback = {
177
126
  sidebarHidden: false,
178
- sidebarWidth: 236,
127
+ sidebarWidth: 214,
179
128
  }
180
129
  try {
181
130
  const value = JSON.parse(localStorage.getItem(DOCUMENT_LAYOUT_KEY) || '{}')
@@ -235,153 +184,15 @@ function restoreScrollPosition(view) {
235
184
  })
236
185
  }
237
186
 
238
- function element(tag, attributes = {}, ...children) {
239
- const node = document.createElement(tag)
240
- for (const [key, value] of Object.entries(attributes)) {
241
- if (value === undefined || value === null || value === false) continue
242
- if (key === 'class') node.className = value
243
- else if (key === 'text') node.textContent = value
244
- else if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2).toLowerCase(), value)
245
- else if (key === 'checked' || key === 'selected' || key === 'disabled') node[key] = Boolean(value)
246
- else node.setAttribute(key, String(value))
247
- }
248
- for (const child of children.flat(Infinity)) {
249
- if (child === undefined || child === null || child === false) continue
250
- node.append(child instanceof Node ? child : document.createTextNode(String(child)))
251
- }
252
- return node
253
- }
254
-
255
187
  function refreshWorkspaceEffects(root = document) {
256
188
  window.DshKnowledgeEffects?.refresh(root)
257
189
  }
258
190
 
259
- function actionButton(label, onClick, variant = '', attributes = {}) {
260
- return element('button', { type: 'button', class: `button ${variant}`.trim(), onClick, ...attributes }, label)
261
- }
262
-
263
- function paneToggleButton(pane, visible, onClick, label) {
264
- const action = `${visible ? '隐藏' : '显示'}${label}`
265
- return element('button', {
266
- type: 'button', class: 'pane-toggle-button', 'data-pane': pane,
267
- 'aria-label': action, 'aria-pressed': String(visible), title: action, onClick,
268
- }, element('span', { class: `pane-icon pane-icon-${pane}`, 'aria-hidden': 'true' }))
269
- }
270
-
271
- function interfaceIcon(name, className = 'interface-icon') {
272
- const paths = {
273
- search: 'M10.8 4.5a6.3 6.3 0 1 0 0 12.6 6.3 6.3 0 0 0 0-12.6Zm4.6 11 4.1 4',
274
- }
275
- return element('svg', {
276
- class: className, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
277
- 'stroke-width': '1.8', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true',
278
- }, element('path', { d: paths[name] }))
279
- }
280
-
281
- function badge(label, variant = '') {
282
- return element('span', { class: `badge ${variant}`.trim() }, label)
283
- }
284
-
285
- function showToast(message, kind = '') {
286
- const toast = element('div', { class: `toast ${kind}`.trim(), role: kind === 'error' ? 'alert' : 'status' },
287
- element('span', {}, message),
288
- kind === 'error' ? actionButton('关闭', () => toast.remove(), 'ghost small toast-close', { 'aria-label': '关闭错误提示' }) : null,
289
- )
290
- toastRegion.append(toast)
291
- if (kind !== 'error') window.setTimeout(() => toast.remove(), 4200)
292
- }
293
-
294
- async function api(path, options = {}) {
295
- const headers = { accept: 'application/json', ...(options.body === undefined ? {} : { 'content-type': 'application/json' }) }
296
- if (AUTH_MODE === 'same-origin') headers['x-dsh-knowledge-client'] = 'management-web'
297
- if (state.token) headers.authorization = `Bearer ${state.token}`
298
- const response = await fetch(`${API_BASE}/${path.replace(/^\/+/, '')}`, {
299
- method: options.method || 'GET',
300
- headers,
301
- body: options.body === undefined ? undefined : JSON.stringify(options.body),
302
- signal: options.signal,
303
- })
304
- const text = await response.text()
305
- let payload
306
- if (text) {
307
- try { payload = JSON.parse(text) } catch { throw new Error('服务返回了无法识别的数据') }
308
- }
309
- if (!response.ok) {
310
- const error = new Error(payload?.error || `请求失败(HTTP ${response.status})`)
311
- error.status = response.status
312
- throw error
313
- }
314
- return payload
315
- }
316
-
317
- async function binaryRequest(path, options = {}) {
318
- const headers = { accept: options.accept || 'application/json' }
319
- if (AUTH_MODE === 'same-origin') headers['x-dsh-knowledge-client'] = 'management-web'
320
- if (state.token) headers.authorization = `Bearer ${state.token}`
321
- if (options.contentType) headers['content-type'] = options.contentType
322
- const response = await fetch(`${API_BASE}/${path.replace(/^\/+/, '')}`, {
323
- method: options.method || 'GET',
324
- headers,
325
- body: options.body,
326
- signal: options.signal,
327
- })
328
- if (options.responseType === 'blob') {
329
- if (!response.ok) throw await responseError(response)
330
- return response.blob()
331
- }
332
- const text = await response.text()
333
- let payload
334
- if (text) {
335
- try { payload = JSON.parse(text) } catch { throw new Error('服务返回了无法识别的数据') }
336
- }
337
- if (!response.ok) {
338
- const error = new Error(payload?.error || `请求失败(HTTP ${response.status})`)
339
- error.status = response.status
340
- throw error
341
- }
342
- return payload
343
- }
344
-
345
- function binaryUploadRequest(path, body, options = {}) {
346
- return new Promise((resolve, reject) => {
347
- const request = new XMLHttpRequest()
348
- request.open(options.method || 'POST', `${API_BASE}/${path.replace(/^\/+/, '')}`)
349
- request.responseType = 'text'
350
- request.setRequestHeader('accept', 'application/json')
351
- if (AUTH_MODE === 'same-origin') request.setRequestHeader('x-dsh-knowledge-client', 'management-web')
352
- if (state.token) request.setRequestHeader('authorization', `Bearer ${state.token}`)
353
- if (options.contentType) request.setRequestHeader('content-type', options.contentType)
354
- request.upload.addEventListener('progress', event => {
355
- if (event.lengthComputable) options.onProgress?.(event.loaded, event.total)
356
- })
357
- request.addEventListener('load', () => {
358
- let payload
359
- if (request.responseText) {
360
- try { payload = JSON.parse(request.responseText) }
361
- catch { return reject(new Error('服务返回了无法识别的数据')) }
362
- }
363
- if (request.status < 200 || request.status >= 300) {
364
- const error = new Error(payload?.error || `请求失败(HTTP ${request.status})`)
365
- error.status = request.status
366
- reject(error)
367
- return
368
- }
369
- resolve(payload)
370
- })
371
- request.addEventListener('error', () => reject(new Error('上传连接中断,请检查网络后重试')))
372
- request.addEventListener('abort', () => reject(new DOMException('上传已取消', 'AbortError')))
373
- request.send(body)
374
- })
375
- }
376
-
377
- async function responseError(response) {
378
- let message = `请求失败(HTTP ${response.status})`
379
- try {
380
- const payload = await response.json()
381
- if (payload?.error) message = payload.error
382
- } catch {}
383
- return Object.assign(new Error(message), { status: response.status })
384
- }
191
+ const { api, binaryRequest, binaryUploadRequest } = createApiClient({
192
+ apiBase: API_BASE,
193
+ authMode: AUTH_MODE,
194
+ getToken: () => state.token,
195
+ })
385
196
 
386
197
  async function boot() {
387
198
  if (AUTH_MODE === 'same-origin') {
@@ -2222,8 +2033,8 @@ function openNoteAgentGuide() {
2222
2033
  element('section', { class: 'notes-agent-guide-rule' },
2223
2034
  element('span', { class: 'notes-agent-guide-index', 'aria-hidden': 'true' }, '01'),
2224
2035
  element('div', {},
2225
- element('h3', {}, '在当前消息里明确授权'),
2226
- element('p', {}, '写清动作、对象和位置,并明确使用“笔记文档”“笔记目录”或“笔记工作区”。为了避免误写,不能只依赖上一轮说过的授权。'))),
2036
+ element('h3', {}, '在当前消息里指定笔记文档'),
2037
+ element('p', {}, '只要当前消息明确提到“笔记文档”“笔记目录”或“笔记工作区”,会话就可以按你的要求查看和维护;无需套用固定句式。为了避免误操作,删除仍需明确说出删除,授权也不会从上一轮延续。'))),
2227
2038
  element('section', { class: 'notes-agent-guide-rule' },
2228
2039
  element('span', { class: 'notes-agent-guide-index', 'aria-hidden': 'true' }, '02'),
2229
2040
  element('div', {},
@@ -2233,7 +2044,7 @@ function openNoteAgentGuide() {
2233
2044
  element('section', { class: 'notes-agent-examples' },
2234
2045
  element('h3', {}, '可以直接这样说'),
2235
2046
  element('ul', {}, examples)),
2236
- element('p', { class: 'notes-agent-guide-note' }, '只说“新建 Markdown”或“创建本地目录”不会获得笔记写入权限;请明确指出这是笔记工作区中的文档或目录。'))
2047
+ element('p', { class: 'notes-agent-guide-note' }, '只说“新建 Markdown”或“创建本地目录”仍不会获得笔记权限;在当前消息中带上“笔记文档”即可。'))
2237
2048
  return openSheet({
2238
2049
  title: '让会话整理笔记',
2239
2050
  description: '会话可以操作笔记工作区,但每次写入都需要当前用户消息明确授权。',
@@ -2366,9 +2177,10 @@ function renderNoteFolderContent(folder) {
2366
2177
  renderNoteIcon(node, true),
2367
2178
  element('span', {}, element('strong', {}, node.name), element('small', {}, noteKindLabel(node))),
2368
2179
  ),
2369
- element('time', { datetime: node.updatedAt }, formatDate(node.updatedAt)),
2370
- element('span', { class: 'notes-file-size' }, node.kind === 'folder' ? '' : formatBytes(node.size)),
2180
+ element('time', { datetime: node.updatedAt, 'data-label': '更新' }, formatDate(node.updatedAt)),
2181
+ element('span', { class: 'notes-file-size', 'data-label': node.kind === 'folder' ? '类型' : '大小' }, node.kind === 'folder' ? '目录' : formatBytes(node.size)),
2371
2182
  element('div', { class: 'notes-file-actions' },
2183
+ node.kind !== 'folder' ? noteDownloadButton(node, 'ghost tiny') : null,
2372
2184
  actionButton('重命名', () => openRenameNoteNode(node), 'ghost tiny'),
2373
2185
  actionButton('复制', () => { void copyNoteNode(node) }, 'ghost tiny'),
2374
2186
  actionButton('删除', () => { void confirmDeleteNoteNode(node) }, 'ghost tiny danger-text'),
@@ -2411,10 +2223,7 @@ function renderEditableNote(node) {
2411
2223
  ? element('div', { class: 'notes-live-editor', role: 'status', 'aria-label': `正在打开 ${node.name}` },
2412
2224
  element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
2413
2225
  : createPlainTextNoteEditor(node)
2414
- if (markdown) mountMarkdownNoteEditor(editor, node)
2415
- else mountPlainTextNoteEditor(editor, node)
2416
- return element('main', { class: `notes-content is-document${markdown ? '' : ' has-line-numbers'}` },
2417
- renderNoteFileToolbar(node, { editable: true }),
2226
+ const scrollHost = element('div', { class: 'notes-document-scroll', 'data-scroll-key': `notes-document:${node.id}` },
2418
2227
  element('h1', {
2419
2228
  class: 'notes-document-title', contenteditable: 'plaintext-only', spellcheck: 'false',
2420
2229
  'aria-label': `修改 ${node.name} 的标题`, title: '点击修改标题',
@@ -2426,6 +2235,17 @@ function renderEditableNote(node) {
2426
2235
  }, title),
2427
2236
  editor,
2428
2237
  )
2238
+ const outlineHost = markdown ? element('aside', { id: 'dsh-note-outline', class: 'notes-editor-outline', 'aria-label': '文档大纲', 'aria-hidden': 'true' }) : null
2239
+ const editorFrame = markdown
2240
+ ? element('div', { class: 'notes-editor-frame', 'data-outline-open': 'false', 'data-find-open': 'false' }, scrollHost, outlineHost)
2241
+ : scrollHost
2242
+ if (markdown) mountMarkdownNoteEditor(editor, node, editorFrame, scrollHost, outlineHost)
2243
+ else mountPlainTextNoteEditor(editor, node)
2244
+ return element('main', { class: `notes-content is-document${markdown ? '' : ' has-line-numbers'}` },
2245
+ renderNoteFileToolbar(node, { editable: true, enhanced: markdown }),
2246
+ editorFrame,
2247
+ renderNoteFileStatusbar(node),
2248
+ )
2429
2249
  }
2430
2250
 
2431
2251
  function editableNoteTitle(node) {
@@ -2478,8 +2298,11 @@ function updateNoteDraft(value) {
2478
2298
  syncNoteEditorChrome()
2479
2299
  }
2480
2300
 
2481
- function mountMarkdownNoteEditor(host, node) {
2301
+ function mountMarkdownNoteEditor(host, node, frame, scrollHost, outlineHost) {
2482
2302
  mountMarkdownEditor(host, {
2303
+ frame,
2304
+ scrollHost,
2305
+ outlineHost,
2483
2306
  markdown: state.notes.draft,
2484
2307
  label: `编辑 ${node.name}`,
2485
2308
  isCurrent: () => state.notes.selectedNode?.id === node.id,
@@ -2525,6 +2348,13 @@ function mountMarkdownEditor(host, options) {
2525
2348
  host.removeAttribute('role')
2526
2349
  markdownEditorHandle = runtime.createMarkdownEditor({
2527
2350
  host,
2351
+ ...(options.frame ? {
2352
+ frame: options.frame,
2353
+ scrollHost: options.scrollHost,
2354
+ outlineHost: options.outlineHost,
2355
+ findButton: host.closest('.notes-content')?.querySelector('[data-note-find]') || null,
2356
+ outlineButton: host.closest('.notes-content')?.querySelector('[data-note-outline]') || null,
2357
+ } : {}),
2528
2358
  markdown: options.markdown,
2529
2359
  label: options.label,
2530
2360
  onChange: options.onChange,
@@ -2600,12 +2430,13 @@ function renderNoteFile(node) {
2600
2430
  renderNoteIcon(node, true),
2601
2431
  element('h2', {}, node.name),
2602
2432
  element('p', {}, '该格式不能在浏览器中直接编辑,可以下载后使用本地应用打开。'),
2603
- actionButton('下载文件', () => { void downloadNoteFile(node) }, 'primary small'),
2433
+ noteDownloadButton(node, 'primary small', '下载文件'),
2604
2434
  )
2605
2435
  }
2606
2436
  return element('main', { class: 'notes-content is-file' },
2607
2437
  renderNoteFileToolbar(node),
2608
2438
  element('section', { class: `notes-inline-viewer is-${previewKind}`, 'aria-label': `${node.name}内容` }, preview),
2439
+ renderNoteFileStatusbar(node),
2609
2440
  )
2610
2441
  }
2611
2442
 
@@ -2613,19 +2444,89 @@ function renderNoteFileToolbar(node, options = {}) {
2613
2444
  return element('header', { class: 'notes-document-toolbar' },
2614
2445
  element('div', { class: 'notes-toolbar-leading' },
2615
2446
  renderNoteDocumentBreadcrumb(node),
2616
- element('div', { class: 'notes-file-info', 'aria-label': '文件信息' },
2617
- noteInfoItem('编号', shortNoteId(node.id), node.id),
2618
- noteInfoItem('类型', node.kind === 'document' ? 'Markdown' : node.mediaType || '未知'),
2619
- noteInfoItem('大小', formatBytes(node.size), '', 'size'),
2620
- noteInfoItem('更新', formatDate(node.updatedAt), '', 'updated'),
2621
- ),
2622
2447
  ),
2623
2448
  element('div', { class: 'notes-document-actions' },
2624
2449
  options.editable ? element('span', { class: 'notes-save-state', role: 'status', 'data-note-save-state': '', 'data-dirty': String(state.notes.dirty) }, state.notes.dirty ? '未保存' : '已保存') : null,
2625
- node.kind === 'file' ? actionButton('下载', () => { void downloadNoteFile(node) }, 'ghost small') : null,
2626
- actionButton('复制引用', () => { void copyNoteReference(node) }, 'ghost small'),
2627
- actionButton('重命名', () => openRenameNoteNode(node), 'ghost small'),
2628
- options.editable ? actionButton('保存', () => { void saveNoteDocument() }, 'primary small', { 'data-note-save': '', disabled: !state.notes.dirty }) : null,
2450
+ options.enhanced ? actionButton('查找', () => markdownEditorHandle?.openFind(), 'ghost small', { 'data-note-find': '', 'data-note-mobile-overflow': '', 'aria-keyshortcuts': 'Control+F Meta+F', 'aria-pressed': 'false' }) : null,
2451
+ options.enhanced ? actionButton([
2452
+ interfaceIcon('outline', 'notes-toolbar-action-icon'),
2453
+ element('span', {}, '大纲'),
2454
+ ], () => markdownEditorHandle?.toggleOutline(), 'ghost small notes-outline-action', { 'data-note-outline': '', 'aria-label': '打开标题大纲', 'aria-controls': 'dsh-note-outline', 'aria-expanded': 'false', 'aria-pressed': 'false' }) : null,
2455
+ options.editable ? actionButton('历史', () => { void openNoteHistory(node) }, 'ghost small', { 'data-note-history': '', 'data-note-mobile-overflow': '' }) : null,
2456
+ noteDownloadButton(node, 'ghost small', '下载', { 'data-note-mobile-overflow': '' }),
2457
+ actionButton('复制引用', () => { void copyNoteReference(node) }, 'ghost small', { 'data-note-mobile-overflow': '' }),
2458
+ actionButton('重命名', () => openRenameNoteNode(node), 'ghost small', { 'data-note-mobile-overflow': '' }),
2459
+ options.editable ? actionButton('保存', () => { void saveNoteDocument() }, 'primary small', { 'data-note-save': '', 'data-note-mobile-overflow': '', disabled: !state.notes.dirty }) : null,
2460
+ renderNoteFileOverflowMenu(node, options),
2461
+ ),
2462
+ )
2463
+ }
2464
+
2465
+ function renderNoteFileOverflowMenu(node, options) {
2466
+ const details = element('details', {
2467
+ class: 'notes-document-more',
2468
+ onKeyDown: event => {
2469
+ if (event.key !== 'Escape') return
2470
+ event.preventDefault()
2471
+ details.open = false
2472
+ summary.focus()
2473
+ },
2474
+ onFocusOut: () => {
2475
+ window.setTimeout(() => {
2476
+ if (!details.contains(document.activeElement)) details.open = false
2477
+ }, 0)
2478
+ },
2479
+ })
2480
+ const summary = element('summary', { class: 'button ghost small', title: '更多操作', 'aria-label': `打开 ${node.name} 的更多操作` },
2481
+ interfaceIcon('more', 'notes-document-more-icon'),
2482
+ )
2483
+ details.addEventListener('toggle', () => {
2484
+ summary.setAttribute('aria-expanded', String(details.open))
2485
+ summary.setAttribute('aria-label', `${details.open ? '关闭' : '打开'} ${node.name} 的更多操作`)
2486
+ })
2487
+ const closeThen = action => event => {
2488
+ details.open = false
2489
+ action(event)
2490
+ }
2491
+ const menuItems = []
2492
+ if (options.editable && state.notes.dirty) {
2493
+ menuItems.push(noteMenuAction('保存修改', 'save', closeThen(() => { void saveNoteDocument() })))
2494
+ menuItems.push(noteMenuDivider())
2495
+ }
2496
+ if (options.enhanced) {
2497
+ menuItems.push(noteMenuAction('查找', 'search', closeThen(() => markdownEditorHandle?.openFind())))
2498
+ }
2499
+ if (options.editable) menuItems.push(noteMenuAction('页面历史', 'history', closeThen(() => { void openNoteHistory(node) })))
2500
+ if (options.enhanced || options.editable) menuItems.push(noteMenuDivider())
2501
+ menuItems.push(
2502
+ noteMenuAction('下载', 'download', closeThen(event => { void downloadNoteFile(node, event.currentTarget) })),
2503
+ noteMenuAction('复制引用', 'link', closeThen(() => { void copyNoteReference(node) })),
2504
+ noteMenuAction('重命名', 'rename', closeThen(() => openRenameNoteNode(node))),
2505
+ )
2506
+ const menu = element('div', { class: 'notes-document-more-menu', role: 'menu', 'aria-label': `${node.name} 的更多操作` }, menuItems)
2507
+ summary.setAttribute('aria-expanded', 'false')
2508
+ details.append(summary, menu)
2509
+ return details
2510
+ }
2511
+
2512
+ function noteMenuAction(label, icon, onClick, attributes = {}) {
2513
+ return actionButton([
2514
+ interfaceIcon(icon, 'notes-document-menu-icon'),
2515
+ element('span', { class: 'notes-document-menu-label' }, label),
2516
+ ], onClick, 'ghost small notes-document-menu-item', { role: 'menuitem', ...attributes })
2517
+ }
2518
+
2519
+ function noteMenuDivider() {
2520
+ return element('div', { class: 'notes-document-menu-divider', role: 'separator' })
2521
+ }
2522
+
2523
+ function renderNoteFileStatusbar(node) {
2524
+ return element('footer', { class: 'notes-document-statusbar', 'aria-label': '文档信息' },
2525
+ element('div', { class: 'notes-file-info' },
2526
+ noteInfoItem('编号', shortNoteId(node.id), node.id, 'id'),
2527
+ noteInfoItem('类型', node.kind === 'document' ? 'Markdown' : node.mediaType || '未知'),
2528
+ noteInfoItem('大小', formatBytes(node.size), '', 'size'),
2529
+ noteInfoItem('更新', formatDate(node.updatedAt), '', 'updated'),
2629
2530
  ),
2630
2531
  )
2631
2532
  }
@@ -3153,14 +3054,132 @@ async function saveNoteDocument() {
3153
3054
  }
3154
3055
  }
3155
3056
 
3156
- async function downloadNoteFile(node) {
3057
+ async function openNoteHistory(node) {
3058
+ if (state.notes.selectedNode?.id === node.id && state.notes.dirty && !await saveNoteDocument()) return
3059
+ try {
3060
+ const current = state.notes.selectedNode?.id === node.id ? state.notes.selectedNode : await api(`notes/${encodeURIComponent(node.id)}`)
3061
+ if (!current?.editable) throw new Error('只有可编辑的笔记文档支持页面历史。')
3062
+ const versions = await api(`notes/${encodeURIComponent(node.id)}/versions?limit=100`)
3063
+ if (!versions.length) {
3064
+ showToast('这个文档还没有可查看的保存记录。')
3065
+ return
3066
+ }
3067
+ let modal
3068
+ const view = window.DshKnowledgeNoteHistory.createNoteHistoryView({
3069
+ versions,
3070
+ currentVersion: current.version,
3071
+ currentContent: state.notes.selectedNode?.id === node.id ? state.notes.content : '',
3072
+ loadContent: async (version, signal) => {
3073
+ const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/versions/${version.version}/content`, {
3074
+ responseType: 'blob', accept: version.mediaType || 'text/plain', signal,
3075
+ })
3076
+ return blob.text()
3077
+ },
3078
+ renderPreview: content => renderHistoricalNotePreview(content, isMarkdownNote(current)),
3079
+ renderDiff: renderNoteHistoryDiff,
3080
+ formatDate,
3081
+ formatBytes,
3082
+ onRestore: async (version, content) => {
3083
+ const updated = await api(`notes/${encodeURIComponent(node.id)}/versions/${version.version}/restore`, {
3084
+ method: 'POST', body: { expectedVersion: current.version },
3085
+ })
3086
+ if (state.notes.selectedNode?.id === node.id) {
3087
+ state.notes.selectedNode = updated
3088
+ state.notes.content = content
3089
+ state.notes.draft = content
3090
+ state.notes.dirty = false
3091
+ await loadNoteChildren(updated.parentId, true)
3092
+ }
3093
+ modal?.close(true)
3094
+ renderShell()
3095
+ showToast(`已将版本 ${version.version} 恢复为新的版本 ${updated.version}。`)
3096
+ },
3097
+ onError: error => showToast(friendlyError(error), 'error'),
3098
+ })
3099
+ modal = openModal({
3100
+ title: `${current.name} · 页面历史`,
3101
+ description: '每次内容保存都会形成不可变快照;恢复历史不会删除当前版本。',
3102
+ body: view.element,
3103
+ cancelLabel: '关闭',
3104
+ onClose: () => view.destroy(),
3105
+ })
3106
+ modal.dialog.classList.add('note-history-dialog')
3107
+ modal.dialog.classList.remove('narrow')
3108
+ } catch (error) {
3109
+ showToast(friendlyError(error), 'error')
3110
+ }
3111
+ }
3112
+
3113
+ function renderHistoricalNotePreview(content, markdown) {
3114
+ if (!markdown) return element('pre', { class: 'note-history-plain-preview', role: 'document' }, content || '(空文档)')
3115
+ const rendered = renderMarkdownPreview(content).cloneNode(true)
3116
+ rendered.classList.add('note-history-markdown-preview')
3117
+ rendered.querySelectorAll('a').forEach(link => {
3118
+ link.removeAttribute('href')
3119
+ link.removeAttribute('role')
3120
+ link.removeAttribute('tabindex')
3121
+ link.removeAttribute('title')
3122
+ })
3123
+ return rendered
3124
+ }
3125
+
3126
+ function renderNoteHistoryDiff(historical, current) {
3127
+ const diff = window.DshKnowledgeReview.createLineDiff(historical, current)
3128
+ const lines = window.DshKnowledgeReview.compactDiffLines(diff.lines, 3)
3129
+ return element('section', { class: 'note-history-diff', 'aria-label': '历史版本与当前版本的逐行差异' },
3130
+ element('div', { class: 'note-history-diff-summary' },
3131
+ element('strong', {}, '历史版本 → 当前版本'),
3132
+ element('div', { class: 'diff-summary', 'aria-label': `新增 ${diff.additions} 行,删除 ${diff.deletions} 行` },
3133
+ element('span', { class: 'diff-stat additions' }, `+${diff.additions}`),
3134
+ element('span', { class: 'diff-stat deletions' }, `-${diff.deletions}`),
3135
+ ),
3136
+ ),
3137
+ diff.simplified ? element('div', { class: 'diff-notice' }, '内容较长,已使用有界的简化差异视图。') : null,
3138
+ element('div', { class: 'diff-viewer', role: 'table' },
3139
+ element('div', { class: 'diff-column-headings', role: 'row' },
3140
+ element('span', { role: 'columnheader' }, '旧'),
3141
+ element('span', { role: 'columnheader' }, '新'),
3142
+ element('span', { 'aria-hidden': 'true' }),
3143
+ element('span', { role: 'columnheader' }, '正文'),
3144
+ ),
3145
+ lines.length ? lines.map(renderDiffLine) : element('div', { class: 'diff-empty' }, '所选版本与当前内容相同。'),
3146
+ ),
3147
+ )
3148
+ }
3149
+
3150
+ function noteDownloadButton(node, variant = 'ghost small', label = '下载', attributes = {}) {
3151
+ return actionButton(label, event => { void downloadNoteFile(node, event.currentTarget) }, variant, {
3152
+ 'aria-label': `下载 ${node.name}`,
3153
+ title: `下载 ${node.name}`,
3154
+ ...attributes,
3155
+ })
3156
+ }
3157
+
3158
+ async function downloadNoteFile(node, button) {
3159
+ const originalLabel = button?.textContent || ''
3160
+ if (button) {
3161
+ button.disabled = true
3162
+ button.setAttribute('aria-busy', 'true')
3163
+ button.textContent = '下载中'
3164
+ }
3157
3165
  try {
3158
- const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content?download=1`, { responseType: 'blob', accept: node.mediaType || 'application/octet-stream' })
3166
+ if (state.notes.selectedNode?.id === node.id && state.notes.dirty && !await saveNoteDocument()) return
3167
+ const current = state.notes.selectedNode?.id === node.id ? state.notes.selectedNode : node
3168
+ const blob = await binaryRequest(`notes/${encodeURIComponent(current.id)}/content?download=1`, { responseType: 'blob', accept: current.mediaType || 'application/octet-stream' })
3159
3169
  const url = URL.createObjectURL(blob)
3160
- const anchor = element('a', { href: url, download: node.name })
3170
+ const anchor = element('a', { href: url, download: current.name })
3161
3171
  document.body.append(anchor); anchor.click(); anchor.remove()
3162
3172
  window.setTimeout(() => URL.revokeObjectURL(url), 1000)
3163
- } catch (error) { showToast(friendlyError(error), 'error') }
3173
+ showToast(`已下载 ${current.name}。`)
3174
+ } catch (error) {
3175
+ showToast(`下载失败:${friendlyError(error)}`, 'error')
3176
+ } finally {
3177
+ if (button?.isConnected) {
3178
+ button.disabled = false
3179
+ button.removeAttribute('aria-busy')
3180
+ button.textContent = originalLabel
3181
+ }
3182
+ }
3164
3183
  }
3165
3184
 
3166
3185
  async function copyNoteReference(node) {
@@ -3274,13 +3293,21 @@ function formatBytes(value) {
3274
3293
 
3275
3294
  function renderCandidates() {
3276
3295
  const statuses = [['pending', '待审核'], ['approved', '已通过'], ['rejected', '已拒绝']]
3277
- return element('section', { 'aria-labelledby': 'candidates-heading' },
3296
+ const pendingCount = state.stats?.candidates.pending ?? state.candidates.filter(candidate => candidate.status === 'pending').length
3297
+ return element('section', { class: 'candidate-page', 'aria-labelledby': 'candidates-heading' },
3278
3298
  element('div', { class: 'section-heading' },
3279
3299
  element('div', {}, element('h2', { id: 'candidates-heading' }, 'AI 提取候选'), element('p', {}, '审核写入、模型推断、低置信度结果和冲突项会在这里等待确认;只有高置信度且证据明确的结果才能直接写入。')),
3280
- element('div', { class: 'tabs', role: 'tablist', 'aria-label': '候选状态' }, statuses.map(([value, label]) => element('button', {
3281
- type: 'button', role: 'tab', class: 'tab', 'aria-selected': String(state.candidateStatus === value),
3282
- onClick: async () => { state.candidateStatus = value; await navigate('candidates') },
3283
- }, label, state.stats ? ` ${state.stats.candidates[value]}` : ''))),
3300
+ element('div', { class: 'candidate-heading-actions' },
3301
+ state.candidateStatus === 'pending' ? actionButton(state.candidateBatchRunning ? '正在通过…' : '一键通过', openBulkApprove, 'primary', {
3302
+ disabled: state.candidateBatchRunning || pendingCount === 0,
3303
+ title: pendingCount === 0 ? '当前没有待审核候选' : '分批通过新增与可安全合并项,冲突项会保留',
3304
+ 'aria-busy': String(state.candidateBatchRunning),
3305
+ }) : null,
3306
+ element('div', { class: 'tabs', role: 'tablist', 'aria-label': '候选状态' }, statuses.map(([value, label]) => element('button', {
3307
+ type: 'button', role: 'tab', class: 'tab', 'aria-selected': String(state.candidateStatus === value),
3308
+ onClick: async () => { state.candidateStatus = value; await navigate('candidates') },
3309
+ }, label, state.stats ? ` ${state.stats.candidates[value]}` : ''))),
3310
+ ),
3284
3311
  ),
3285
3312
  state.candidates.length ? element('div', { class: 'candidate-list' }, state.candidates.map(renderCandidateCard))
3286
3313
  : emptyState(`没有${STATUS_LABELS[state.candidateStatus]}候选`, state.candidateStatus === 'pending' ? '新的对话结束后,插件会在后台判断是否产生候选。' : '切换其他状态查看历史记录。'),
@@ -3953,6 +3980,88 @@ async function reviewCandidate(candidate, decision, resolution) {
3953
3980
  })
3954
3981
  }
3955
3982
 
3983
+ function openBulkApprove() {
3984
+ if (state.candidateBatchRunning) return
3985
+ const pendingCount = state.stats?.candidates.pending ?? state.candidates.length
3986
+ const summary = element('strong', {}, `准备处理 ${pendingCount} 条待审核候选`)
3987
+ const detail = element('p', {}, '开始后会按创建时间分批处理,并实时显示结果。')
3988
+ const metrics = element('div', { class: 'bulk-review-metrics' })
3989
+ const status = element('div', {
3990
+ class: 'bulk-review-status', role: 'status', 'aria-live': 'polite', 'aria-atomic': 'true', hidden: true,
3991
+ }, summary, detail, metrics)
3992
+ const body = element('div', { class: 'bulk-review-confirm' },
3993
+ element('p', {}, '将分批通过待审核中的新增与可安全合并项。冲突项、目标不可用项,以及审核期间发生变化的候选会保留,供你逐条处理。'),
3994
+ element('div', { class: 'bulk-review-safety' },
3995
+ element('strong', {}, '不会自动处理冲突'),
3996
+ element('span', {}, '同一文档的候选会按时间顺序写入,避免并发覆盖。'),
3997
+ ),
3998
+ status,
3999
+ )
4000
+ openModal({
4001
+ title: '一键通过待审核候选?',
4002
+ description: '适合集中处理积压候选;操作完成后无法批量撤销。',
4003
+ body,
4004
+ primaryLabel: '开始一键通过',
4005
+ onPrimary: async () => {
4006
+ state.candidateBatchRunning = true
4007
+ status.hidden = false
4008
+ const excluded = new Set()
4009
+ const totals = { approved: 0, deferred: 0, failed: 0, selected: 0, remainingReviewable: pendingCount, remainingManual: 0 }
4010
+ const updateProgress = (message = '正在按顺序审核候选…') => {
4011
+ summary.textContent = message
4012
+ detail.textContent = `已检查 ${totals.selected} 条;每批最多 25 条。`
4013
+ metrics.replaceChildren(
4014
+ bulkReviewMetric('已通过', totals.approved, 'success'),
4015
+ bulkReviewMetric('转为冲突', totals.deferred, 'warning'),
4016
+ bulkReviewMetric('失败保留', totals.failed, totals.failed ? 'danger' : ''),
4017
+ bulkReviewMetric('剩余可处理', totals.remainingReviewable),
4018
+ )
4019
+ }
4020
+ updateProgress()
4021
+ try {
4022
+ for (let pass = 0; pass < 1000; pass += 1) {
4023
+ const result = await api('candidates/bulk-review', {
4024
+ method: 'POST', body: { limit: 25, excludeIds: [...excluded] },
4025
+ })
4026
+ totals.selected += result.selected
4027
+ totals.approved += result.approved
4028
+ totals.deferred += result.deferred
4029
+ totals.failed += result.failed.length
4030
+ totals.remainingReviewable = result.remainingReviewable
4031
+ totals.remainingManual = result.remainingManual
4032
+ result.failed.forEach(item => excluded.add(item.id))
4033
+ updateProgress()
4034
+ if (result.remainingReviewable === 0) break
4035
+ if (result.selected === 0) throw new Error('批量审核没有继续推进;剩余候选已保留,请刷新后重试。')
4036
+ }
4037
+ if (totals.remainingReviewable > 0) throw new Error('待审核候选过多,本次处理已达到安全上限,请再次执行。')
4038
+ summary.textContent = totals.approved > 0 ? `已通过 ${totals.approved} 条候选` : '没有可自动通过的候选'
4039
+ detail.textContent = totals.remainingManual > 0
4040
+ ? `仍有 ${totals.remainingManual} 条冲突或失败项保留,需逐条确认。`
4041
+ : '待审核队列已处理完成。'
4042
+ state.stats = null
4043
+ await loadCandidates()
4044
+ state.candidateBatchRunning = false
4045
+ renderShell()
4046
+ const parts = [`已通过 ${totals.approved} 条`]
4047
+ if (totals.deferred) parts.push(`${totals.deferred} 条转为冲突`)
4048
+ if (totals.failed) parts.push(`${totals.failed} 条失败并保留`)
4049
+ if (totals.remainingManual) parts.push(`${totals.remainingManual} 条待人工处理`)
4050
+ showToast(`${parts.join(',')}。`, totals.failed ? 'error' : '')
4051
+ return true
4052
+ } finally {
4053
+ state.candidateBatchRunning = false
4054
+ }
4055
+ },
4056
+ })
4057
+ }
4058
+
4059
+ function bulkReviewMetric(label, value, variant = '') {
4060
+ return element('span', { class: `bulk-review-metric ${variant}`.trim() },
4061
+ element('small', {}, label), element('strong', {}, String(value)),
4062
+ )
4063
+ }
4064
+
3956
4065
  function confirmArchive(entry) {
3957
4066
  openConfirm({ title: '归档这条知识?', message: '归档后它不会再参与召回,版本历史仍会保留。', confirmLabel: '确认归档', danger: true, onConfirm: async () => {
3958
4067
  await api(`entries/${encodeURIComponent(entry.id)}/archive`, { method: 'POST' })