@lemoncat7/dsh-knowledge 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +29 -1
  2. package/lib/api.d.ts.map +1 -1
  3. package/lib/api.js +15 -3
  4. package/lib/api.js.map +1 -1
  5. package/lib/client.d.ts +3 -0
  6. package/lib/client.d.ts.map +1 -1
  7. package/lib/client.js +102 -7
  8. package/lib/client.js.map +4 -4
  9. package/lib/extraction.d.ts.map +1 -1
  10. package/lib/extraction.js +1 -0
  11. package/lib/extraction.js.map +1 -1
  12. package/lib/index.d.ts.map +1 -1
  13. package/lib/index.js +14 -1
  14. package/lib/index.js.map +1 -1
  15. package/lib/notes/share-import.d.ts +2 -0
  16. package/lib/notes/share-import.d.ts.map +1 -1
  17. package/lib/notes/share-import.js +40 -5
  18. package/lib/notes/share-import.js.map +1 -1
  19. package/lib/web.js +1 -1
  20. package/lib/web.js.map +1 -1
  21. package/lib/writeback/control.d.ts +5 -0
  22. package/lib/writeback/control.d.ts.map +1 -0
  23. package/lib/writeback/control.js +39 -0
  24. package/lib/writeback/control.js.map +1 -0
  25. package/lib/writeback/live-control.d.ts +5 -0
  26. package/lib/writeback/live-control.d.ts.map +1 -0
  27. package/lib/writeback/live-control.js +63 -0
  28. package/lib/writeback/live-control.js.map +1 -0
  29. package/lib/writeback/queue.d.ts +27 -1
  30. package/lib/writeback/queue.d.ts.map +1 -1
  31. package/lib/writeback/queue.js +97 -7
  32. package/lib/writeback/queue.js.map +1 -1
  33. package/lib/writeback/status-client.d.ts +3 -0
  34. package/lib/writeback/status-client.d.ts.map +1 -1
  35. package/lib/writeback/status-client.js +30 -4
  36. package/lib/writeback/status-client.js.map +1 -1
  37. package/package.json +1 -1
  38. package/web/api-client.js +2 -1
  39. package/web/app.js +42 -15
  40. package/web/styles.css +13 -8
  41. package/web/ui-primitives.js +1 -0
  42. package/web/writeback-live.d.ts +1 -0
  43. package/web/writeback-live.js +52 -0
  44. package/web/writeback-workspace.js +76 -0
package/web/app.js CHANGED
@@ -17,6 +17,8 @@ const { installHostThemeBridge } = themeModule
17
17
  const { actionButton, badge, createToastPresenter, element, interfaceIcon, paneToggleButton } = uiModule
18
18
  const { renderMenu: renderDocumentMenu, closeMenus: closeDocumentMenus } = documentActionsModule.createDocumentMenuPresenter(uiModule)
19
19
  const readModelCatalog = modelCatalogModule.createModelCatalogLoader()
20
+ const { createWritebackWorkspace } = await import(moduleUrl('writeback-workspace'))
21
+ let writebackWorkspace
20
22
  const TOKEN_KEY = 'dsh-knowledge.session-token'
21
23
  const TYPES = ['preference', 'fact', 'decision', 'procedure', 'lesson']
22
24
  const TYPE_LABELS = { preference: '偏好', fact: '事实', decision: '决策', procedure: '流程', lesson: '经验' }
@@ -32,7 +34,7 @@ const NOTE_MAX_FILE_SIZE = 64 * 1024 * 1024
32
34
  const pageParams = new URLSearchParams(location.search)
33
35
  const initialKnowledgeBaseId = pageParams.get('knowledgeBaseId')?.trim() || ''
34
36
  const initialDocumentId = pageParams.get('documentId')?.trim() || ''
35
- const initialView = pageParams.get('view') === 'notes' ? 'notes' : 'entries'
37
+ const initialView = ['notes', 'writeback'].includes(pageParams.get('view')) ? pageParams.get('view') : 'entries'
36
38
  const initialNoteId = pageParams.get('noteId')?.trim() || ''
37
39
  const mountContext = {
38
40
  sessionId: pageParams.get('sessionId')?.trim() || '',
@@ -950,6 +952,8 @@ async function loadTokens(signal) {
950
952
  }
951
953
 
952
954
  function renderShell() {
955
+ writebackWorkspace?.dispose()
956
+ writebackWorkspace = undefined
953
957
  closeDocumentMenus()
954
958
  releaseNoteEditors()
955
959
  captureScrollPosition()
@@ -960,10 +964,11 @@ function renderShell() {
960
964
  notes: ['笔记文档', '像本地目录一样整理笔记和资料,并按需关联到知识文档'],
961
965
  shares: ['已分享', '管理只读分享链接,也可以导入别人分享的笔记和目录'],
962
966
  candidates: ['待审核', '确认 AI 提取结果后再写入知识文档'],
967
+ writeback: ['回写任务', '查看本机队列,处理失败和阻塞的回写'],
963
968
  tokens: ['访问管理', '管理其他客户端连接中央知识库的权限'],
964
969
  }
965
970
  const [title, subtitle] = titles[state.view]
966
- const viewIndexes = { overview: '00', notes: '01', shares: '02', entries: '03', candidates: '04', bases: '05', tokens: '06' }
971
+ const viewIndexes = { overview: '00', notes: '01', shares: '02', entries: '03', candidates: '04', writeback: '05', bases: '06', tokens: '07' }
967
972
  const shell = element('div', {
968
973
  class: 'app-shell', 'data-menu-open': String(state.menuOpen),
969
974
  'data-view': state.view, 'data-loading': String(state.loading),
@@ -1146,7 +1151,7 @@ function renderSidebar() {
1146
1151
  const pending = state.stats?.candidates.pending
1147
1152
  const navGroups = [
1148
1153
  ['笔记工作区', [['notes', '笔记文档'], ['shares', '已分享']]],
1149
- ['知识工作区', [['entries', '知识文档'], ['candidates', '待审核'], ['bases', '知识库与挂载']]],
1154
+ ['知识工作区', [['entries', '知识文档'], ['candidates', '待审核'], ['writeback', '回写任务'], ['bases', '知识库与挂载']]],
1150
1155
  ['连接', [['tokens', '访问管理']].filter(([id]) => id !== 'tokens' || !state.service.remote)],
1151
1156
  ].filter(([, items]) => items.length)
1152
1157
  let navIndex = 0
@@ -1172,6 +1177,10 @@ function renderSidebar() {
1172
1177
  function renderCurrentView() {
1173
1178
  if (state.loading) return loadingView(state.view, state.loadingPhase, state.loadingProgress)
1174
1179
  if (state.error) return errorView(state.error, () => navigate(state.view))
1180
+ if (state.view === 'writeback') {
1181
+ writebackWorkspace = createWritebackWorkspace({ element, actionButton, openConfirm, sessionId: mountContext.sessionId })
1182
+ return writebackWorkspace.root
1183
+ }
1175
1184
  if (state.view === 'overview') return renderOverview()
1176
1185
  if (state.view === 'bases') return renderKnowledgeBases()
1177
1186
  if (state.view === 'entries') return renderEntries()
@@ -2181,8 +2190,9 @@ function openImportNoteShare() {
2181
2190
  destination.input.disabled = true
2182
2191
  const inspectButton = actionButton('读取分享', () => { void inspect() }, 'small')
2183
2192
  const fieldError = element('p', { class: 'share-import-field-error', role: 'alert', hidden: true })
2193
+ const privateConfirmation = element('div', { class: 'share-import-note', hidden: true, role: 'status' })
2184
2194
  const preview = element('div', { class: 'share-import-preview', 'data-state': 'empty', 'aria-live': 'polite' },
2185
- element('span', { class: 'share-import-preview-mark', 'aria-hidden': 'true' }),
2195
+ element('span', { class: 'share-import-preview-mark', 'aria-hidden': 'true' }, interfaceIcon('share-import')),
2186
2196
  element('div', {}, element('strong', {}, '等待读取分享'), element('p', {}, '读取后会显示类型、文件数量与大小;导入前不会写入任何内容。')),
2187
2197
  )
2188
2198
  const destinationHint = element('span', { class: 'field-hint' }, '正在读取你的笔记目录…')
@@ -2191,11 +2201,13 @@ function openImportNoteShare() {
2191
2201
  urlField.wrapper.replaceChildren(element('label', {}, '分享链接'), inputRow, fieldError)
2192
2202
  const form = element('form', { class: 'share-import-form', onSubmit: event => { event.preventDefault(); void inspect() } },
2193
2203
  urlField.wrapper,
2204
+ privateConfirmation,
2194
2205
  preview,
2195
2206
  destination.wrapper,
2196
2207
  element('p', { class: 'share-import-note' }, '导入会创建一份独立副本;之后对方更新或停止分享,都不会改动你已经导入的内容。'),
2197
2208
  )
2198
2209
  let inspectedUrl = ''
2210
+ let confirmedPrivateUrl = ''
2199
2211
 
2200
2212
  const showFieldError = message => {
2201
2213
  fieldError.textContent = message
@@ -2215,33 +2227,54 @@ function openImportNoteShare() {
2215
2227
  )
2216
2228
  }
2217
2229
  const inspect = async () => {
2230
+ if (inspectButton.disabled) return false
2218
2231
  if (!urlField.input.reportValidity()) return false
2232
+ const requestedUrl = urlField.input.value.trim()
2233
+ urlField.input.disabled = true
2234
+ privateConfirmation.hidden = true
2219
2235
  inspectButton.disabled = true
2220
2236
  inspectButton.setAttribute('aria-busy', 'true')
2221
2237
  inspectButton.textContent = '正在读取…'
2222
2238
  showFieldError('')
2223
2239
  preview.dataset.state = 'loading'
2224
2240
  try {
2225
- const result = await api('notes/import-share/inspect', { method: 'POST', body: { url: urlField.input.value.trim() } })
2226
- inspectedUrl = urlField.input.value.trim()
2241
+ const result = await api('notes/import-share/inspect', { method: 'POST', body: { url: requestedUrl, confirmPrivateShare: confirmedPrivateUrl === requestedUrl } })
2242
+ inspectedUrl = requestedUrl
2227
2243
  renderPreview(result.manifest)
2228
2244
  return !result.manifest.truncated
2229
2245
  } catch (error) {
2230
2246
  inspectedUrl = ''
2231
2247
  preview.dataset.state = 'error'
2232
2248
  preview.replaceChildren(
2233
- element('span', { class: 'share-import-preview-mark', 'aria-hidden': 'true' }),
2249
+ element('span', { class: 'share-import-preview-mark', 'aria-hidden': 'true' }, interfaceIcon('share-import')),
2234
2250
  element('div', {}, element('strong', {}, '无法读取这个分享'), element('p', {}, '请检查链接是否完整、分享是否仍有效。')),
2235
2251
  )
2236
2252
  showFieldError(friendlyError(error))
2253
+ if (error.code === 'PRIVATE_SHARE_CONFIRMATION_REQUIRED' && error.origin === new URL(requestedUrl).origin) {
2254
+ confirmedPrivateUrl = ''
2255
+ showFieldError('')
2256
+ preview.dataset.state = 'empty'
2257
+ preview.replaceChildren(
2258
+ element('span', { class: 'share-import-preview-mark', 'aria-hidden': 'true' }, interfaceIcon('share-import')),
2259
+ element('div', {}, element('strong', {}, '等待内网访问确认'), element('p', {}, '确认后读取分享清单,不会立即导入。')),
2260
+ )
2261
+ privateConfirmation.replaceChildren(
2262
+ element('p', {}, `目标 ${error.origin} 解析到内网地址。确认后,DSH 服务端将访问此链接的分享接口;请仅允许你信任的来源。授权仅用于本次读取和导入,不会保存到白名单。`),
2263
+ actionButton('允许本次内网分享并读取', () => { confirmedPrivateUrl = requestedUrl; void inspect() }, 'small'),
2264
+ )
2265
+ privateConfirmation.hidden = false
2266
+ }
2237
2267
  return false
2238
2268
  } finally {
2239
2269
  inspectButton.disabled = false
2270
+ urlField.input.disabled = false
2240
2271
  inspectButton.removeAttribute('aria-busy')
2241
2272
  inspectButton.textContent = '读取分享'
2242
2273
  }
2243
2274
  }
2244
2275
  urlField.input.addEventListener('input', () => {
2276
+ confirmedPrivateUrl = ''
2277
+ privateConfirmation.hidden = true
2245
2278
  if (urlField.input.value.trim() !== inspectedUrl) {
2246
2279
  inspectedUrl = ''
2247
2280
  preview.dataset.state = 'empty'
@@ -2265,7 +2298,7 @@ function openImportNoteShare() {
2265
2298
  showFieldError('')
2266
2299
  try {
2267
2300
  const result = await api('notes/import-share', {
2268
- method: 'POST', body: { url: requestedUrl, parentId: destination.input.value || null },
2301
+ method: 'POST', body: { url: requestedUrl, parentId: destination.input.value || null, confirmPrivateShare: confirmedPrivateUrl === requestedUrl },
2269
2302
  })
2270
2303
  state.notes.children.clear()
2271
2304
  state.notes.loadedFolders.clear()
@@ -2747,8 +2780,7 @@ function noteInfoItem(label, value, title = '', key = '') {
2747
2780
  }
2748
2781
 
2749
2782
  function renderNoteIcon(node, large = false) {
2750
- const label = node.kind === 'folder' ? '' : noteExtension(node.name)
2751
- return element('span', { class: `note-node-icon is-${node.kind}${large ? ' is-large' : ''}`, 'data-media-kind': noteMediaKind(node), 'aria-hidden': 'true' }, label)
2783
+ return element('span', { class: `note-node-icon ${node.kind === 'folder' ? 'tree-folder-icon' : 'tree-document-icon'}${large ? ' is-large' : ''}`, 'data-media-kind': noteMediaKind(node), 'aria-hidden': 'true' })
2752
2784
  }
2753
2785
 
2754
2786
  function noteKindLabel(node) {
@@ -3559,11 +3591,6 @@ function noteMediaKind(node) {
3559
3591
  return 'file'
3560
3592
  }
3561
3593
 
3562
- function noteExtension(name) {
3563
- const extension = name.includes('.') ? name.split('.').pop() : 'FILE'
3564
- return extension.slice(0, 4).toLocaleUpperCase()
3565
- }
3566
-
3567
3594
  function shortNoteId(id) {
3568
3595
  return `${id.slice(0, 11)}…${id.slice(-5)}`
3569
3596
  }
package/web/styles.css CHANGED
@@ -955,12 +955,8 @@ html,
955
955
  .notes-search-row strong, .notes-search-row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
956
956
  .notes-search-row strong { font-size: 11px; }
957
957
  .notes-search-row small { margin-top: 1px; color: var(--text-tertiary); font-size: 10px; }
958
- .note-node-icon { position: relative; width: 18px; height: 21px; display: grid; place-items: end center; flex: none; border: 1px solid var(--border-strong); border-radius: 3px; padding-bottom: 2px; background: var(--surface-raised); color: var(--text-tertiary); font-size: 5px; font-weight: 720; letter-spacing: .02em; }
959
- .note-node-icon.is-folder { width: 20px; height: 15px; margin-top: 2px; border-radius: 3px; background: color-mix(in srgb, var(--text-tertiary) 11%, var(--surface-raised)); }
960
- .note-node-icon.is-folder::before { content: ""; position: absolute; top: -4px; left: 1px; width: 9px; height: 4px; border: 1px solid var(--border-strong); border-bottom: 0; border-radius: 3px 3px 0 0; background: inherit; }
961
- .note-node-icon.is-document, .note-node-icon[data-media-kind="text"] { border-color: color-mix(in srgb, var(--accent) 30%, var(--border-strong)); color: var(--accent); }
962
- .note-node-icon.is-large { width: 38px; height: 46px; border-radius: 6px; padding-bottom: 5px; font-size: 7px; }
963
- .note-node-icon.is-folder.is-large { width: 44px; height: 32px; }
958
+ .note-node-icon.is-large { width: 38px; height: 42px; }
959
+ .note-node-icon.tree-folder-icon.is-large { width: 40px; height: 35px; }
964
960
  .notes-content { min-width: 0; min-height: 0; overflow: auto; background: transparent; }
965
961
  .notes-content-header { position: sticky; top: 0; z-index: 2; border-bottom: 1px solid var(--separator); padding: 11px 22px 13px; background: transparent; -webkit-backdrop-filter: none; backdrop-filter: none; }
966
962
  .notes-breadcrumb { min-width: 0; display: flex; align-items: center; gap: 5px; overflow: hidden; color: var(--text-tertiary); font-size: 10px; }
@@ -1028,8 +1024,8 @@ html,
1028
1024
  .share-import-preview strong, .share-import-preview p { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1029
1025
  .share-import-preview strong { color: var(--text); font-size: 12px; }
1030
1026
  .share-import-preview p { margin: 4px 0 0; color: var(--text-tertiary); font-size: 10px; }
1031
- .share-import-preview-mark { position: relative; width: 42px; height: 32px; border: 1.5px solid var(--border-strong); border-radius: 8px; background: var(--surface-soft); }
1032
- .share-import-preview-mark::after { position: absolute; inset: 8px 12px; border: solid var(--text-tertiary); border-width: 0 1.5px 1.5px 0; content: ""; transform: rotate(45deg); }
1027
+ .share-import-preview-mark { display: grid; place-items: center; width: 42px; height: 42px; border: 1px solid var(--glass-border); border-radius: 12px; background: var(--surface-soft); color: var(--text-secondary); }
1028
+ .share-import-preview-mark > .interface-icon { width: 24px; height: 24px; }
1033
1029
  .share-import-note { margin: -4px 0 0; border-left: 2px solid var(--border-strong); padding: 5px 0 5px 10px; color: var(--text-tertiary); font-size: 10px; line-height: 1.6; }
1034
1030
  .dialog.share-import-dialog { width: min(560px, calc(100vw - 32px)); }
1035
1031
  .notes-content.is-document, .notes-content.is-file { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; }
@@ -2032,3 +2028,12 @@ html,
2032
2028
  @media (forced-colors: active) {
2033
2029
  .status-dot, .history-dot { forced-color-adjust: none; }
2034
2030
  }
2031
+ /* Durable outbox: reuse workspace controls and surfaces, no animated cards. */
2032
+ .writeback-workspace { display: grid; gap: 12px; min-width: 0; }
2033
+ .writeback-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
2034
+ .writeback-toolbar input { flex: 1 1 220px; min-width: 0; }
2035
+ .writeback-job-list { display: grid; gap: 12px; }
2036
+ .writeback-job { padding: 12px; border: 1px solid var(--border); border-radius: 12px; min-width: 0; }
2037
+ .writeback-job p { margin: 8px 0; overflow-wrap: anywhere; }
2038
+ .writeback-job-key { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 8px; }
2039
+ .writeback-job pre { white-space: pre-wrap; overflow-wrap: anywhere; }
@@ -54,6 +54,7 @@ export function interfaceIcon(name, className = 'interface-icon') {
54
54
  outline: 'M8 6h11M8 12h11M8 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01',
55
55
  history: 'M4 4v5h5M4.8 8.2A8 8 0 1 1 4 13M12 7.5V12l3 2',
56
56
  download: 'M12 3v11M8 10l4 4 4-4M5 20h14',
57
+ 'share-import': 'M13 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-2M13 3v5h5l-5-5M21 12H10m4-4-4 4 4 4',
57
58
  link: 'M9.5 14.5l5-5M8.5 17H6a5 5 0 0 1 0-10h3M15.5 7H18a5 5 0 0 1 0 10h-3',
58
59
  rename: 'M4 20l4.2-1 10.4-10.4a2.1 2.1 0 0 0-3-3L5.2 16zM14.5 6.5l3 3',
59
60
  move: 'M3 7h6l2 2h10v11H3zM12 4h8m-3-3 3 3-3 3',
@@ -0,0 +1 @@
1
+ export function subscribeWritebackChanges(client: 'conversation-web' | 'management-web', listener: () => void): () => void
@@ -0,0 +1,52 @@
1
+ // Shared by visible conversation turns; the iframe owns its own single channel.
2
+ const groups = new Map()
3
+ export function subscribeWritebackChanges(client, listener) {
4
+ let group = groups.get(client)
5
+ if (!group) {
6
+ const listeners = new Set()
7
+ let stopped = false, request, timer, revision = '', failures = 0
8
+ function emit() { for (const fn of listeners) fn() }
9
+ async function poll() {
10
+ if (stopped || document.hidden) return
11
+ const controller = new AbortController()
12
+ request = controller
13
+ try {
14
+ const response = await fetch(`/knowledge-control/v1/writeback-changes?since=${encodeURIComponent(revision)}`, {
15
+ credentials: 'same-origin', cache: 'no-store', headers: { 'x-dsh-knowledge-client': client },
16
+ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(25000)]),
17
+ })
18
+ if (!response.ok) throw new Error('notification transport unavailable')
19
+ const data = await response.json()
20
+ if (typeof data.revision !== 'string') throw new Error('invalid revision')
21
+ if (stopped || controller.signal.aborted) return
22
+ const changed = revision !== data.revision || failures > 0
23
+ revision = data.revision
24
+ failures = 0
25
+ if (changed) emit()
26
+ } catch { if (!controller.signal.aborted) failures++ }
27
+ finally {
28
+ if (request === controller) {
29
+ request = undefined
30
+ if (!stopped && !document.hidden) timer = setTimeout(poll, failures ? Math.min(30000, 1000 * 2 ** Math.min(failures, 5)) : 0)
31
+ }
32
+ }
33
+ }
34
+ function resume() {
35
+ clearTimeout(timer)
36
+ request?.abort()
37
+ request = undefined
38
+ if (!document.hidden && !stopped) { emit(); void poll() }
39
+ }
40
+ document.addEventListener('visibilitychange', resume)
41
+ window.addEventListener('online', resume)
42
+ group = { listeners, close() { stopped = true; clearTimeout(timer); request?.abort(); document.removeEventListener('visibilitychange', resume); window.removeEventListener('online', resume) } }
43
+ groups.set(client, group)
44
+ // Let the first caller attach before the initial revision arrives.
45
+ timer = setTimeout(poll, 0)
46
+ }
47
+ group.listeners.add(listener)
48
+ return () => {
49
+ group.listeners.delete(listener)
50
+ if (!group.listeners.size) { group.close(); groups.delete(client) }
51
+ }
52
+ }
@@ -0,0 +1,76 @@
1
+ import { subscribeWritebackChanges } from './writeback-live.js'
2
+ /** Local outbox UI. Poll only this view, never reload the knowledge workspace. */
3
+ export function createWritebackWorkspace({ element, actionButton, openConfirm, sessionId = '' }) {
4
+ const labels = { queued: '排队中', running: '回写中', failed: '失败', completed: '已完成', cancelled: '已取消' }
5
+ let disposed = false, timer, request, offset = 0, busy = false, previous = '', dirty = false
6
+ const message = element('p', { role: 'status' })
7
+ const list = element('div', { class: 'writeback-job-list' })
8
+ const page = element('span', {})
9
+ const filter = element('input', { class: 'input', value: sessionId, placeholder: '按会话编号筛选(可留空)', 'aria-label': '会话编号' })
10
+ const previousButton = actionButton('上一页', () => { offset = Math.max(0, offset - 50); previous = ''; void refresh() }, 'ghost small')
11
+ const nextButton = actionButton('下一页', () => { offset += 50; previous = ''; void refresh() }, 'ghost small')
12
+ const root = element('section', { class: 'writeback-workspace' },
13
+ element('p', {}, '本机回写队列。同一会话按顺序执行;网络中断会自动重试,连续失败可手动重试或取消。取消不会撤销已经写入的内容。'),
14
+ element('div', { class: 'writeback-toolbar' }, filter, actionButton('筛选', () => { sessionId = filter.value.trim(); offset = 0; previous = ''; void refresh() }, 'small'), actionButton('刷新', () => { void refresh() }, 'ghost small')),
15
+ message, list, element('div', { class: 'writeback-toolbar' }, previousButton, page, nextButton))
16
+ async function fetchJson(params, method = 'GET', signal) {
17
+ const response = await fetch(`/knowledge-control/v1/writeback-jobs?${params}`, { method, credentials: 'same-origin', headers: { 'x-dsh-knowledge-client': 'management-web' }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15000)]) : AbortSignal.timeout(15000) })
18
+ const data = await response.json().catch(() => ({}))
19
+ if (!response.ok) throw new Error(data.error || `读取回写队列失败(${response.status})`)
20
+ return data
21
+ }
22
+ async function operate(job, action) {
23
+ if (busy || disposed) return
24
+ busy = true
25
+ request?.abort()
26
+ message.textContent = action === 'cancel' ? '正在请求取消…' : '正在提交重试…'
27
+ try {
28
+ const result = await fetchJson(new URLSearchParams({ action, sourceKey: job.sourceKey }), 'POST')
29
+ message.textContent = result.summary
30
+ previous = ''
31
+ } catch (error) { message.textContent = error.message }
32
+ finally { busy = false; if (!disposed) void refresh(true) }
33
+ }
34
+ function renderJob(job) {
35
+ const actions = element('div', { class: 'writeback-toolbar' })
36
+ if (job.status === 'failed') actions.append(actionButton('重试', () => operate(job, 'retry'), 'small'))
37
+ if (['queued', 'running', 'failed'].includes(job.status)) actions.append(actionButton(job.cancelRequested ? '取消中…' : '取消回写', () => openConfirm({ title: '取消这条回写?', message: '取消后不再重试,并允许后续轮次继续。已经写入的内容不会撤销。', confirmLabel: '取消回写', danger: true, onConfirm: () => operate(job, 'cancel') }), 'ghost small', { disabled: job.cancelRequested }))
38
+ return element('article', { class: 'writeback-job' },
39
+ element('div', { class: 'writeback-toolbar' }, element('strong', {}, labels[job.status] || job.status), element('span', {}, `已尝试 ${job.attempts} 次`), actions),
40
+ element('div', { class: 'writeback-job-key', title: job.sourceKey }, job.sourceKey),
41
+ job.createdAt ? element('p', {}, '创建于 ', element('time', { datetime: new Date(job.createdAt).toISOString() }, new Date(job.createdAt).toLocaleString())) : element('p', {}, '历史任务 · 创建时间未记录'),
42
+ element('p', {}, job.summary),
43
+ job.blockedBy ? element('p', {}, `阻塞任务:${job.blockedBy}`) : null,
44
+ job.nextAttemptAt ? element('p', {}, `下次自动重试:${new Date(job.nextAttemptAt).toLocaleString()}`) : null,
45
+ job.error ? element('details', {}, element('summary', {}, '失败原因'), element('pre', {}, job.error)) : null,
46
+ ...(job.destinations || []).map(destination => element('p', {}, `${destination.knowledgeBaseName} / ${destination.documentTitle || destination.documentId || ''}`)))
47
+ }
48
+ async function refresh(preserveMessage = false) {
49
+ clearTimeout(timer)
50
+ if (disposed || document.hidden || busy) return
51
+ dirty = false
52
+ request?.abort()
53
+ const controller = new AbortController()
54
+ request = controller
55
+ try {
56
+ const data = await fetchJson(new URLSearchParams({ sessionId, offset: String(offset) }), 'GET', controller.signal)
57
+ if (disposed || request !== controller) return
58
+ const fingerprint = JSON.stringify(data)
59
+ if (fingerprint !== previous) {
60
+ list.replaceChildren(...(data.items.length ? data.items.map(renderJob) : [element('p', {}, '没有回写任务')]))
61
+ previous = fingerprint
62
+ }
63
+ page.textContent = `共 ${data.total} 条 · 第 ${Math.floor(offset / 50) + 1} 页`
64
+ previousButton.disabled = offset === 0
65
+ nextButton.disabled = offset + 50 >= data.total
66
+ if (!preserveMessage) message.textContent = '状态自动更新'
67
+ } catch (error) { if (!controller.signal.aborted && !disposed) message.textContent = `${error.message};将自动重连` }
68
+ finally { if (request === controller) { request = undefined; if (!disposed) timer = setTimeout(() => refresh(), dirty ? 0 : 5000) } }
69
+ }
70
+ function visibility() { if (document.hidden) { clearTimeout(timer); request?.abort() } else void refresh() }
71
+ document.addEventListener('visibilitychange', visibility)
72
+ window.addEventListener('online', visibility)
73
+ const unsubscribeChanges = subscribeWritebackChanges('management-web', () => { if (request || busy) dirty = true; else void refresh() })
74
+ void refresh()
75
+ return { root, dispose() { disposed = true; clearTimeout(timer); request?.abort(); unsubscribeChanges(); document.removeEventListener('visibilitychange', visibility); window.removeEventListener('online', visibility) } }
76
+ }