@lemoncat7/dsh-knowledge 1.1.5 → 1.2.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 (64) hide show
  1. package/README.md +2 -2
  2. package/docs/architecture.md +3 -3
  3. package/lib/api.d.ts +2 -0
  4. package/lib/api.d.ts.map +1 -1
  5. package/lib/api.js +41 -12
  6. package/lib/api.js.map +1 -1
  7. package/lib/atomic-file.d.ts +16 -0
  8. package/lib/atomic-file.d.ts.map +1 -0
  9. package/lib/atomic-file.js +93 -0
  10. package/lib/atomic-file.js.map +1 -0
  11. package/lib/client.js +1 -1
  12. package/lib/client.js.map +2 -2
  13. package/lib/config.d.ts.map +1 -1
  14. package/lib/config.js +2 -5
  15. package/lib/config.js.map +1 -1
  16. package/lib/connection.d.ts.map +1 -1
  17. package/lib/connection.js +5 -15
  18. package/lib/connection.js.map +1 -1
  19. package/lib/documents/projection-queue.d.ts +10 -0
  20. package/lib/documents/projection-queue.d.ts.map +1 -0
  21. package/lib/documents/projection-queue.js +21 -0
  22. package/lib/documents/projection-queue.js.map +1 -0
  23. package/lib/documents/store.d.ts +4 -2
  24. package/lib/documents/store.d.ts.map +1 -1
  25. package/lib/documents/store.js +54 -73
  26. package/lib/documents/store.js.map +1 -1
  27. package/lib/extraction.d.ts.map +1 -1
  28. package/lib/extraction.js +10 -2
  29. package/lib/extraction.js.map +1 -1
  30. package/lib/index.d.ts.map +1 -1
  31. package/lib/index.js +85 -31
  32. package/lib/index.js.map +1 -1
  33. package/lib/knowledge-merge.d.ts +8 -0
  34. package/lib/knowledge-merge.d.ts.map +1 -0
  35. package/lib/knowledge-merge.js +19 -0
  36. package/lib/knowledge-merge.js.map +1 -0
  37. package/lib/local-provider.d.ts +2 -0
  38. package/lib/local-provider.d.ts.map +1 -1
  39. package/lib/local-provider.js +100 -61
  40. package/lib/local-provider.js.map +1 -1
  41. package/lib/remote-provider.d.ts.map +1 -1
  42. package/lib/remote-provider.js +34 -2
  43. package/lib/remote-provider.js.map +1 -1
  44. package/lib/remote-url.d.ts +2 -0
  45. package/lib/remote-url.d.ts.map +1 -0
  46. package/lib/remote-url.js +14 -0
  47. package/lib/remote-url.js.map +1 -0
  48. package/lib/service-settings.d.ts.map +1 -1
  49. package/lib/service-settings.js +3 -10
  50. package/lib/service-settings.js.map +1 -1
  51. package/lib/tools.js +44 -1
  52. package/lib/tools.js.map +1 -1
  53. package/lib/web-change-review.d.ts +34 -0
  54. package/lib/web-change-review.d.ts.map +1 -0
  55. package/lib/web-change-review.js +147 -0
  56. package/lib/web-change-review.js.map +1 -0
  57. package/lib/web.d.ts.map +1 -1
  58. package/lib/web.js +1 -0
  59. package/lib/web.js.map +1 -1
  60. package/package.json +1 -1
  61. package/web/app.js +287 -81
  62. package/web/change-review.js +5 -0
  63. package/web/index.html +1 -0
  64. package/web/styles.css +100 -23
package/web/app.js CHANGED
@@ -82,6 +82,10 @@ const state = {
82
82
  }
83
83
 
84
84
  let scrollRestoreFrame = 0
85
+ let navigationController = null
86
+ let navigationRequest = 0
87
+ let navigationSave = Promise.resolve(true)
88
+ let libraryDetailRequest = 0
85
89
 
86
90
  function installHostThemeBridge() {
87
91
  if (window.parent === window) return Promise.resolve()
@@ -242,9 +246,12 @@ function badge(label, variant = '') {
242
246
  }
243
247
 
244
248
  function showToast(message, kind = '') {
245
- const toast = element('div', { class: `toast ${kind}`.trim(), role: kind === 'error' ? 'alert' : 'status' }, message)
249
+ const toast = element('div', { class: `toast ${kind}`.trim(), role: kind === 'error' ? 'alert' : 'status' },
250
+ element('span', {}, message),
251
+ kind === 'error' ? actionButton('关闭', () => toast.remove(), 'ghost small toast-close', { 'aria-label': '关闭错误提示' }) : null,
252
+ )
246
253
  toastRegion.append(toast)
247
- window.setTimeout(() => toast.remove(), 4200)
254
+ if (kind !== 'error') window.setTimeout(() => toast.remove(), 4200)
248
255
  }
249
256
 
250
257
  async function api(path, options = {}) {
@@ -342,6 +349,12 @@ function signOut() {
342
349
  }
343
350
 
344
351
  async function navigate(view) {
352
+ const request = ++navigationRequest
353
+ navigationSave = navigationSave.then(() => saveBeforeLeavingDocument(), () => saveBeforeLeavingDocument())
354
+ if (!await navigationSave || request !== navigationRequest) return
355
+ navigationController?.abort()
356
+ const controller = new AbortController()
357
+ navigationController = controller
345
358
  const previousView = state.view
346
359
  if (view === 'bases' && previousView !== 'bases' && state.knowledgeBaseView === 'detail') {
347
360
  state.knowledgeBaseView = 'libraries'
@@ -352,35 +365,51 @@ async function navigate(view) {
352
365
  state.error = ''
353
366
  renderShell()
354
367
  try {
355
- if (view === 'overview') await loadOverview()
356
- if (view === 'bases') await loadKnowledgeBasesPage()
357
- if (view === 'entries') await loadDocuments()
358
- if (view === 'candidates') await loadCandidates()
359
- if (view === 'tokens') await loadTokens()
368
+ if (view === 'overview') await loadOverview(controller.signal)
369
+ if (view === 'bases') await loadKnowledgeBasesPage(controller.signal)
370
+ if (view === 'entries') await loadDocuments(controller.signal)
371
+ if (view === 'candidates') await loadCandidates(controller.signal)
372
+ if (view === 'tokens') await loadTokens(controller.signal)
360
373
  } catch (error) {
374
+ if (controller.signal.aborted) return
361
375
  if (error.status === 401 && AUTH_MODE === 'bearer') return signOut()
362
376
  state.error = friendlyError(error)
363
377
  } finally {
378
+ if (navigationController !== controller) return
364
379
  state.loading = false
365
380
  renderShell()
366
381
  }
367
382
  }
368
383
 
369
- async function refreshStats() {
370
- state.stats = await api('stats')
384
+ async function saveBeforeLeavingDocument() {
385
+ const workspace = activeDocumentWorkspace()
386
+ const editor = workspace?.view.editor
387
+ if (!workspace || !editor?.dirty) return true
388
+ const emptyDraft = editor.isNew && !editor.title.trim() && !editor.body.trim()
389
+ if (emptyDraft) {
390
+ workspace.view.editor = null
391
+ workspace.view.documentId = ''
392
+ return true
393
+ }
394
+ return saveDocumentEditor(workspace)
371
395
  }
372
396
 
373
- async function ensureKnowledgeBases(force = false) {
374
- if (force || state.knowledgeBases.length === 0) state.knowledgeBases = await api('knowledge-bases')
397
+ async function refreshStats(signal) {
398
+ state.stats = await api('stats', { signal })
399
+ }
400
+
401
+ async function ensureKnowledgeBases(force = false, signal) {
402
+ if (force || state.knowledgeBases.length === 0) state.knowledgeBases = await api('knowledge-bases', { signal })
375
403
  return state.knowledgeBases
376
404
  }
377
405
 
378
- async function loadKnowledgeBasesPage() {
379
- const requests = [api('knowledge-bases'), api('mounts'), api('settings'), api('service')]
406
+ async function loadKnowledgeBasesPage(signal) {
407
+ const options = { signal }
408
+ const requests = [api('knowledge-bases', options), api('mounts', options), api('settings', options), api('service', options)]
380
409
  if (state.mountContext.sessionId) {
381
410
  const params = new URLSearchParams({ sessionId: state.mountContext.sessionId })
382
411
  if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
383
- requests.push(api(`mounts/resolve?${params}`))
412
+ requests.push(api(`mounts/resolve?${params}`, options))
384
413
  }
385
414
  const [bases, mounts, settings, service, resolved = []] = await Promise.all(requests)
386
415
  state.knowledgeBases = bases
@@ -388,15 +417,16 @@ async function loadKnowledgeBasesPage() {
388
417
  state.settings = settings
389
418
  state.service = service
390
419
  state.resolvedMounts = resolved
391
- await refreshStats()
420
+ await refreshStats(signal)
392
421
  }
393
422
 
394
- async function loadOverview() {
423
+ async function loadOverview(signal) {
424
+ const options = { signal }
395
425
  const [stats, recent, pending, bases] = await Promise.all([
396
- api('stats'),
397
- api('entries?status=active&limit=6'),
398
- api('candidates?status=pending&limit=5'),
399
- api('knowledge-bases'),
426
+ api('stats', options),
427
+ api('entries?status=active&limit=6', options),
428
+ api('candidates?status=pending&limit=5', options),
429
+ api('knowledge-bases', options),
400
430
  ])
401
431
  state.stats = stats
402
432
  state.knowledgeBases = bases
@@ -427,12 +457,13 @@ async function loadEntries(cursor = '') {
427
457
  if (!state.stats) await refreshStats()
428
458
  }
429
459
 
430
- async function loadDocuments() {
431
- const requests = [api('knowledge-bases'), api('documents')]
460
+ async function loadDocuments(signal) {
461
+ const options = { signal }
462
+ const requests = [api('knowledge-bases', options), api('documents', options)]
432
463
  if (state.mountContext.sessionId) {
433
464
  const params = new URLSearchParams({ sessionId: state.mountContext.sessionId })
434
465
  if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
435
- requests.push(api(`mounts/resolve?${params}`))
466
+ requests.push(api(`mounts/resolve?${params}`, options))
436
467
  }
437
468
  const [bases, documents, resolved = []] = await Promise.all(requests)
438
469
  state.knowledgeBases = bases
@@ -448,9 +479,9 @@ async function loadDocuments() {
448
479
  : documentKnowledgeBases(bases)[0]?.id || ''
449
480
  }
450
481
  selectDefaultDocument(workspace)
451
- if (view.documentId) await loadDocumentEditor(workspace, view.documentId)
482
+ if (view.documentId) await loadDocumentEditor(workspace, view.documentId, signal)
452
483
  else view.editor = null
453
- if (!state.stats) await refreshStats()
484
+ if (!state.stats) await refreshStats(signal)
454
485
  }
455
486
 
456
487
  function documentKnowledgeBases(bases = state.knowledgeBases) {
@@ -496,14 +527,19 @@ function documentWorkspaceReadOnly(workspace) {
496
527
  }
497
528
 
498
529
  async function reloadDocumentWorkspace(workspace) {
530
+ const libraryBaseId = workspace.kind === 'library' ? state.libraryDetail.knowledgeBaseId : ''
499
531
  const documents = workspace.kind === 'library'
500
- ? await api(`documents?knowledgeBaseId=${encodeURIComponent(state.libraryDetail.knowledgeBaseId)}`)
532
+ ? await api(`documents?knowledgeBaseId=${encodeURIComponent(libraryBaseId)}`)
501
533
  : await api('documents')
502
- if (workspace.kind === 'library') setDocumentWorkspaceDocuments(workspace, documents)
534
+ if (workspace.kind === 'library') {
535
+ if (state.libraryDetail.knowledgeBaseId !== libraryBaseId) return false
536
+ setDocumentWorkspaceDocuments(workspace, documents)
537
+ }
503
538
  else {
504
539
  const visibleBaseIds = new Set(documentKnowledgeBases().map(base => base.id))
505
540
  setDocumentWorkspaceDocuments(workspace, documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId)))
506
541
  }
542
+ return true
507
543
  }
508
544
 
509
545
  function selectDefaultDocument(workspace) {
@@ -515,8 +551,11 @@ function selectDefaultDocument(workspace) {
515
551
  if (view.knowledgeBaseId) view.expandedBases.add(view.knowledgeBaseId)
516
552
  }
517
553
 
518
- async function loadDocumentEditor(workspace, id) {
519
- const entry = await api(`entries/${encodeURIComponent(id)}`)
554
+ async function loadDocumentEditor(workspace, id, signal) {
555
+ const revision = (workspace.view.loadRevision || 0) + 1
556
+ workspace.view.loadRevision = revision
557
+ const entry = await api(`entries/${encodeURIComponent(id)}`, { signal })
558
+ if (workspace.view.loadRevision !== revision || workspace.view.documentId !== id) return false
520
559
  workspace.view.mode = 'preview'
521
560
  workspace.view.editor = {
522
561
  ...entry,
@@ -525,6 +564,7 @@ async function loadDocumentEditor(workspace, id) {
525
564
  isNew: false,
526
565
  saveState: '已保存',
527
566
  }
567
+ return true
528
568
  }
529
569
 
530
570
  function createBlankDocument(workspace, baseId) {
@@ -557,8 +597,7 @@ async function selectDocument(workspace, id) {
557
597
  if (editor?.dirty && !emptyDraft && !await saveDocumentEditor(workspace)) return
558
598
  workspace.view.documentId = id
559
599
  workspace.view.treeOpen = false
560
- await loadDocumentEditor(workspace, id)
561
- renderShell()
600
+ if (await loadDocumentEditor(workspace, id)) renderShell()
562
601
  }
563
602
 
564
603
  function editorDraft(editor) {
@@ -615,26 +654,29 @@ function updateEditorSaveState(label) {
615
654
  if (node) node.textContent = label
616
655
  }
617
656
 
618
- async function loadCandidates() {
657
+ async function loadCandidates(signal) {
619
658
  const [candidates] = await Promise.all([
620
- api(`candidates?status=${state.candidateStatus}&limit=100`),
621
- ensureKnowledgeBases(),
659
+ api(`candidates?status=${state.candidateStatus}&limit=100`, { signal }),
660
+ ensureKnowledgeBases(false, signal),
622
661
  ])
623
662
  state.candidates = candidates
624
663
  const targetIds = [...new Set(candidates.map(candidate => candidate.targetId).filter(Boolean))]
625
664
  const targets = await Promise.all(targetIds.map(async id => {
626
- try { return [id, await api(`entries/${encodeURIComponent(id)}`)] }
627
- catch { return [id, null] }
665
+ try { return [id, await api(`entries/${encodeURIComponent(id)}`, { signal })] }
666
+ catch (error) {
667
+ if (signal?.aborted) throw error
668
+ return [id, null]
669
+ }
628
670
  }))
629
671
  state.candidateTargets = new Map(targets)
630
- if (!state.stats) await refreshStats()
672
+ if (!state.stats) await refreshStats(signal)
631
673
  }
632
674
 
633
- async function loadTokens() {
634
- const [tokens, service] = await Promise.all([api('tokens'), api('service')])
675
+ async function loadTokens(signal) {
676
+ const [tokens, service] = await Promise.all([api('tokens', { signal }), api('service', { signal })])
635
677
  state.tokens = tokens
636
678
  state.service = service
637
- if (!state.stats) await refreshStats()
679
+ if (!state.stats) await refreshStats(signal)
638
680
  }
639
681
 
640
682
  function renderShell() {
@@ -660,7 +702,10 @@ function renderShell() {
660
702
  element('main', { class: 'main' },
661
703
  element('header', { class: 'topbar' },
662
704
  element('div', { class: 'topbar-title' },
663
- actionButton('☰', () => { state.menuOpen = !state.menuOpen; renderShell() }, 'ghost mobile-menu', { 'aria-label': '打开导航菜单' }),
705
+ actionButton('☰', () => { state.menuOpen = !state.menuOpen; renderShell() }, 'ghost mobile-menu', {
706
+ 'aria-label': state.menuOpen ? '关闭导航菜单' : '打开导航菜单',
707
+ 'aria-expanded': String(state.menuOpen),
708
+ }),
664
709
  paneToggleButton('main', !state.documentView.sidebarHidden, () => setSidebarHidden(!state.documentView.sidebarHidden), '主导航栏'),
665
710
  activeDocumentWorkspace() ? paneToggleButton('library', activeDocumentWorkspace().view.treeOpen, () => {
666
711
  const workspace = activeDocumentWorkspace()
@@ -901,6 +946,7 @@ function renderKnowledgeBases() {
901
946
 
902
947
  async function openKnowledgeBaseDocuments(base) {
903
948
  captureScrollPosition()
949
+ const request = ++libraryDetailRequest
904
950
  const detail = state.libraryDetail
905
951
  const changingBase = detail.knowledgeBaseId !== base.id
906
952
  detail.knowledgeBaseId = base.id
@@ -916,13 +962,15 @@ async function openKnowledgeBaseDocuments(base) {
916
962
  renderShell()
917
963
  const workspace = libraryDocumentWorkspace()
918
964
  try {
919
- await reloadDocumentWorkspace(workspace)
965
+ if (!await reloadDocumentWorkspace(workspace) || request !== libraryDetailRequest) return
920
966
  selectDefaultDocument(workspace)
921
967
  if (workspace.view.documentId) await loadDocumentEditor(workspace, workspace.view.documentId)
922
968
  else workspace.view.editor = null
923
969
  } catch (error) {
970
+ if (request !== libraryDetailRequest) return
924
971
  detail.error = friendlyError(error)
925
972
  } finally {
973
+ if (request !== libraryDetailRequest) return
926
974
  state.loading = false
927
975
  renderShell()
928
976
  window.requestAnimationFrame(() => document.querySelector('.library-detail-back')?.focus())
@@ -934,6 +982,7 @@ async function closeKnowledgeBaseDetail() {
934
982
  const editor = workspace.view.editor
935
983
  const emptyDraft = editor?.isNew && !editor.title.trim() && !editor.body.trim()
936
984
  if (editor?.dirty && !emptyDraft && !await saveDocumentEditor(workspace)) return
985
+ libraryDetailRequest += 1
937
986
  const baseId = state.libraryDetail.knowledgeBaseId
938
987
  state.knowledgeBaseView = 'libraries'
939
988
  state.libraryDetail.error = ''
@@ -998,6 +1047,16 @@ function contextPill(label, value) {
998
1047
  return element('div', { class: 'context-pill' }, element('strong', {}, label), element('span', { title: value }, value))
999
1048
  }
1000
1049
 
1050
+ function writebackRouteLabel(base) {
1051
+ if (state.service.writebackProvider && state.service.writebackModel) {
1052
+ return `本机覆盖 · ${state.service.writebackProvider} / ${state.service.writebackModel}`
1053
+ }
1054
+ if (base?.writebackProvider && base?.writebackModel) {
1055
+ return `知识库专用 · ${base.writebackProvider} / ${base.writebackModel}`
1056
+ }
1057
+ return '跟随当前会话模型'
1058
+ }
1059
+
1001
1060
  function renderKnowledgeBaseCard(base) {
1002
1061
  const archived = base.status === 'archived'
1003
1062
  const visibleTags = base.defaultTags.slice(0, 4)
@@ -1017,7 +1076,7 @@ function renderKnowledgeBaseCard(base) {
1017
1076
  hiddenTagCount ? element('span', { class: 'tag' }, `+${hiddenTagCount}`) : null),
1018
1077
  element('div', { class: 'base-card-meta' },
1019
1078
  element('span', {}, element('strong', {}, '回写策略'), base.writebackPolicy === 'proactive' ? '主动' : '严谨'),
1020
- element('span', {}, element('strong', {}, '回写模型'), state.service.writebackProvider && state.service.writebackModel ? `本机 · ${state.service.writebackProvider} / ${state.service.writebackModel}` : '跟随当前会话'),
1079
+ element('span', {}, element('strong', {}, '回写模型'), writebackRouteLabel(base)),
1021
1080
  base.extractionInstructions ? element('span', {}, element('strong', {}, '提取规则'), '已设置') : null,
1022
1081
  ),
1023
1082
  element('div', { class: 'base-card-actions' },
@@ -1133,9 +1192,7 @@ function renderMountListRow(base, view, targetKind, targetId) {
1133
1192
  renderShell()
1134
1193
  },
1135
1194
  })
1136
- const modelLabel = state.service.writebackProvider && state.service.writebackModel
1137
- ? `本机 · ${state.service.writebackProvider} / ${state.service.writebackModel}`
1138
- : '跟随当前会话模型'
1195
+ const modelLabel = writebackRouteLabel(base)
1139
1196
  return element('article', { class: `mount-list-row${selected ? ' is-selected' : ''}`, role: 'listitem' },
1140
1197
  element('label', { class: 'mount-select' }, checkbox, element('span', { class: 'visually-hidden' }, `选择 ${base.name}`)),
1141
1198
  element('div', { class: 'mount-list-main' },
@@ -1444,12 +1501,9 @@ function renderCandidates() {
1444
1501
 
1445
1502
  function renderCandidateCard(candidate) {
1446
1503
  const pending = candidate.status === 'pending'
1447
- const conflictTarget = candidate.action === 'conflict' && candidate.targetId
1448
- ? state.candidateTargets.get(candidate.targetId)
1449
- : null
1450
- const targetDocument = candidate.action === 'create'
1451
- ? `新文档“${candidate.draft.title}”`
1452
- : `文档“${candidate.draft.title}”`
1504
+ const target = candidate.targetId ? state.candidateTargets.get(candidate.targetId) : null
1505
+ const targetAvailable = candidate.action === 'create' || Boolean(target)
1506
+ const action = candidatePrimaryAction(candidate)
1453
1507
  return element('article', { class: 'candidate' },
1454
1508
  element('div', { class: 'candidate-header' },
1455
1509
  element('div', {},
@@ -1462,31 +1516,122 @@ function renderCandidateCard(candidate) {
1462
1516
  ),
1463
1517
  badge(STATUS_LABELS[candidate.status], candidate.status === 'approved' ? 'success' : candidate.status === 'rejected' ? 'danger' : 'warning'),
1464
1518
  ),
1465
- element('div', { class: `candidate-body${candidate.action === 'conflict' ? ' is-conflict' : ''}` },
1466
- candidate.action === 'conflict'
1467
- ? element('div', { class: 'candidate-comparison' },
1468
- element('section', {}, element('strong', {}, '当前知识'), element('pre', {}, conflictTarget?.body || '目标文档不可用,请刷新后重试。')),
1469
- element('section', {}, element('strong', {}, '候选内容'), element('pre', {}, candidate.draft.body)),
1470
- )
1471
- : element('p', { class: 'candidate-content' }, candidate.draft.body),
1519
+ element('div', { class: 'candidate-body' },
1520
+ renderCandidateDiff(candidate, target),
1472
1521
  element('div', { class: 'candidate-reason' },
1473
- element('strong', {}, '文档变更'),
1474
- element('span', { class: 'candidate-target' }, `${candidate.action === 'create' ? '创建' : '更新'}${targetDocument}`),
1475
- element('strong', {}, '模型判断依据'), candidate.reason || '未提供判断说明'),
1522
+ element('section', {},
1523
+ element('strong', {}, '写入位置'),
1524
+ element('span', { class: 'candidate-target' }, candidate.action === 'create'
1525
+ ? `创建“${candidate.draft.title}”`
1526
+ : target ? `合并到“${target.title}”` : `目标 ${candidate.targetId || '不可用'}`),
1527
+ ),
1528
+ renderCandidateMetadataChanges(candidate, target),
1529
+ element('section', {},
1530
+ element('strong', {}, '模型判断依据'),
1531
+ element('p', {}, candidate.reason || '未提供判断说明'),
1532
+ ),
1533
+ candidate.reviewNote ? element('section', {},
1534
+ element('strong', {}, '审核备注'),
1535
+ element('p', {}, candidate.reviewNote),
1536
+ ) : null,
1537
+ ),
1476
1538
  ),
1477
1539
  element('div', { class: 'candidate-footer' },
1478
1540
  element('small', {}, `${scopeLabel(candidate.draft.scope)} · 置信度 ${Math.round(candidate.draft.confidence * 100)}%${candidate.targetId ? ` · 目标 ${candidate.targetId}` : ''} · ${formatDate(candidate.createdAt)}`),
1479
- pending ? element('div', {},
1541
+ pending ? element('div', { class: 'candidate-actions' },
1480
1542
  actionButton('拒绝', () => reviewCandidate(candidate, 'reject'), 'danger small'),
1481
- actionButton('编辑并通过', () => openEntryEditor(undefined, candidate), 'small'),
1482
- candidate.action === 'conflict'
1483
- ? actionButton('合并追加', () => reviewCandidate(candidate, 'approve', 'merge'), 'primary small')
1484
- : actionButton('通过', () => reviewCandidate(candidate, 'approve'), 'primary small'),
1543
+ actionButton(action.editLabel, () => openEntryEditor(undefined, candidate), 'small', {
1544
+ disabled: !targetAvailable,
1545
+ title: targetAvailable ? action.editLabel : '目标文档不可用,无法安全编辑合并结果',
1546
+ }),
1547
+ actionButton(action.label, () => reviewCandidate(candidate, 'approve', action.resolution), 'primary small', {
1548
+ disabled: !targetAvailable,
1549
+ title: targetAvailable ? action.label : '目标文档不可用,刷新后再审核',
1550
+ }),
1485
1551
  ) : null,
1486
1552
  ),
1487
1553
  )
1488
1554
  }
1489
1555
 
1556
+ function renderCandidateDiff(candidate, target) {
1557
+ if (candidate.action !== 'create' && !target) {
1558
+ return element('section', { class: 'candidate-change candidate-change-unavailable', role: 'alert' },
1559
+ element('strong', {}, '无法生成变更预览'),
1560
+ element('p', {}, '目标文档暂时不可用。为避免盲目覆盖,刷新并确认当前版本后再审核。'),
1561
+ )
1562
+ }
1563
+ const review = window.DshKnowledgeReview.createReviewChange(candidate.action, target?.body || '', candidate.draft.body)
1564
+ const title = candidate.action === 'create' ? '新文档内容' : candidate.action === 'conflict' ? '冲突合并预览' : '文档合并预览'
1565
+ const targetLabel = candidate.action === 'create'
1566
+ ? candidate.draft.title
1567
+ : `${target.title} · 当前版本 ${target.version}`
1568
+ return element('section', { class: `candidate-change is-${candidate.action}`, 'aria-label': title },
1569
+ element('div', { class: 'candidate-change-heading' },
1570
+ element('div', {}, element('strong', {}, title), element('small', {}, targetLabel)),
1571
+ element('div', { class: 'diff-summary', 'aria-label': `新增 ${review.diff.additions} 行,删除 ${review.diff.deletions} 行,未变更 ${review.diff.unchanged} 行` },
1572
+ element('span', { class: 'diff-stat additions' }, `+${review.diff.additions}`),
1573
+ element('span', { class: 'diff-stat deletions' }, `-${review.diff.deletions}`),
1574
+ element('span', { class: 'diff-stat unchanged' }, `${review.diff.unchanged} 未变`),
1575
+ ),
1576
+ ),
1577
+ review.diff.simplified ? element('div', { class: 'diff-notice' }, '文档变更较大,已使用简化差异视图。合并内容不受影响。') : null,
1578
+ element('div', { class: 'diff-viewer', role: 'table', 'aria-label': `${title}逐行差异` },
1579
+ element('div', { class: 'diff-column-headings', role: 'row' },
1580
+ element('span', { role: 'columnheader' }, '旧'),
1581
+ element('span', { role: 'columnheader' }, '新'),
1582
+ element('span', { 'aria-hidden': 'true' }),
1583
+ element('span', { role: 'columnheader' }, '正文'),
1584
+ ),
1585
+ review.displayLines.length
1586
+ ? review.displayLines.map(renderDiffLine)
1587
+ : element('div', { class: 'diff-empty' }, '正文为空,没有可写入的差异。'),
1588
+ ),
1589
+ )
1590
+ }
1591
+
1592
+ function renderDiffLine(line) {
1593
+ if (line.kind === 'omitted') {
1594
+ return element('div', { class: 'diff-line is-omitted', role: 'row', 'aria-label': `${line.count} 行未变更,已折叠` },
1595
+ element('span', { class: 'diff-line-number', 'aria-hidden': 'true' }),
1596
+ element('span', { class: 'diff-line-number', 'aria-hidden': 'true' }),
1597
+ element('span', { class: 'diff-marker', 'aria-hidden': 'true' }, '…'),
1598
+ element('span', { class: 'diff-omitted-copy' }, `${line.count} 行未变更`),
1599
+ )
1600
+ }
1601
+ const marker = line.kind === 'add' ? '+' : line.kind === 'remove' ? '-' : ' '
1602
+ const label = line.kind === 'add'
1603
+ ? `新增第 ${line.newLine} 行`
1604
+ : line.kind === 'remove' ? `删除原第 ${line.oldLine} 行` : `未变更行 ${line.oldLine}`
1605
+ return element('div', { class: `diff-line is-${line.kind}`, role: 'row', 'aria-label': label },
1606
+ element('span', { class: 'diff-line-number', role: 'cell' }, line.oldLine || ''),
1607
+ element('span', { class: 'diff-line-number', role: 'cell' }, line.newLine || ''),
1608
+ element('span', { class: 'diff-marker', role: 'cell', 'aria-hidden': 'true' }, marker),
1609
+ element('code', { class: 'diff-code', role: 'cell' }, line.text || ' '),
1610
+ )
1611
+ }
1612
+
1613
+ function renderCandidateMetadataChanges(candidate, target) {
1614
+ if (!target || candidate.action === 'create') return null
1615
+ const changes = []
1616
+ if (target.title !== candidate.draft.title) changes.push(`标题:${target.title} → ${candidate.draft.title}`)
1617
+ const addedTags = candidate.draft.tags.filter(tag => !target.tags.includes(tag))
1618
+ if (addedTags.length) changes.push(`新增标签:${addedTags.join('、')}`)
1619
+ if (candidate.draft.confidence > target.confidence) {
1620
+ changes.push(`置信度:${Math.round(target.confidence * 100)}% → ${Math.round(candidate.draft.confidence * 100)}%`)
1621
+ }
1622
+ if (!changes.length) return null
1623
+ return element('section', { class: 'candidate-metadata' },
1624
+ element('strong', {}, '属性变化'),
1625
+ element('ul', {}, changes.map(change => element('li', {}, change))),
1626
+ )
1627
+ }
1628
+
1629
+ function candidatePrimaryAction(candidate) {
1630
+ if (candidate.action === 'create') return { label: '写入新文档', editLabel: '编辑后写入' }
1631
+ if (candidate.action === 'conflict') return { label: '确认合并', editLabel: '编辑合并内容', resolution: 'merge' }
1632
+ return { label: '合并到文档', editLabel: '编辑后合并' }
1633
+ }
1634
+
1490
1635
  function renderTokens() {
1491
1636
  const apiAddress = new URL(state.service.publicApiPrefix, location.origin).href.replace(/\/$/, '')
1492
1637
  return element('section', { 'aria-labelledby': 'tokens-heading' },
@@ -1569,7 +1714,8 @@ function parseTags(value) {
1569
1714
  return [...new Set(value.split(/[,,]/).map(tag => tag.trim().toLowerCase()).filter(Boolean))]
1570
1715
  }
1571
1716
 
1572
- function openKnowledgeBaseEditor(base) {
1717
+ async function openKnowledgeBaseEditor(base) {
1718
+ await loadModelCatalog()
1573
1719
  const source = base || { name: '', description: '', defaultTags: [], extractionInstructions: '', writebackPolicy: 'conservative' }
1574
1720
  const form = element('form', { class: 'form-grid' })
1575
1721
  const name = formField('名称', 'input', source.name, { required: true, maxlength: 100, placeholder: '例如:项目规范' })
@@ -1580,9 +1726,26 @@ function openKnowledgeBaseEditor(base) {
1580
1726
  { value: 'conservative', label: '严谨(高置信度直写)' },
1581
1727
  { value: 'proactive', label: '主动(更积极沉淀)' },
1582
1728
  ], source.writebackPolicy || 'conservative')
1729
+ const dedicatedModel = element('input', {
1730
+ type: 'checkbox',
1731
+ checked: Boolean(source.writebackProvider && source.writebackModel),
1732
+ })
1733
+ const route = modelRouteFields(source.writebackProvider || '', source.writebackModel || '')
1734
+ const routeToggle = element('label', { class: 'check-option span-2' }, dedicatedModel,
1735
+ element('span', {},
1736
+ element('strong', {}, '这个知识库使用专用回写模型'),
1737
+ element('small', {}, '未设置时先使用本机覆盖;本机也未设置时跟随当前会话模型。'),
1738
+ ))
1739
+ const syncRouteAvailability = () => {
1740
+ route.provider.input.disabled = route.model.input.disabled = !dedicatedModel.checked
1741
+ route.provider.input.required = route.model.input.required = dedicatedModel.checked
1742
+ }
1743
+ dedicatedModel.addEventListener('change', syncRouteAvailability)
1744
+ syncRouteAvailability()
1583
1745
  for (const field of [name, description, tags, instructions]) field.wrapper.classList.add('span-2')
1584
1746
  form.append(
1585
1747
  name.wrapper, description.wrapper, tags.wrapper, instructions.wrapper, policy.wrapper,
1748
+ routeToggle, route.provider.wrapper, route.model.wrapper,
1586
1749
  )
1587
1750
  openSheet({
1588
1751
  title: base ? '编辑知识库' : '创建知识库',
@@ -1597,6 +1760,10 @@ function openKnowledgeBaseEditor(base) {
1597
1760
  defaultTags: parseTags(tags.input.value),
1598
1761
  extractionInstructions: instructions.input.value.trim(),
1599
1762
  writebackPolicy: policy.input.value,
1763
+ ...(dedicatedModel.checked ? {
1764
+ writebackProvider: route.provider.input.value,
1765
+ writebackModel: route.model.input.value,
1766
+ } : {}),
1600
1767
  }
1601
1768
  if (base) await api(`knowledge-bases/${encodeURIComponent(base.id)}`, { method: 'PUT', body: { draft } })
1602
1769
  else await api('knowledge-bases', { method: 'POST', body: { draft } })
@@ -1830,7 +1997,11 @@ function confirmDeleteKnowledgeBase(base) {
1830
1997
 
1831
1998
  function openEntryEditor(entry, candidate) {
1832
1999
  const activeWorkspace = activeDocumentWorkspace()
1833
- const source = candidate?.draft || entry || {
2000
+ const candidateTarget = candidate?.targetId ? state.candidateTargets.get(candidate.targetId) : null
2001
+ const candidateReview = candidate && (candidate.action === 'create' || candidateTarget)
2002
+ ? window.DshKnowledgeReview.createReviewChange(candidate.action, candidateTarget?.body || '', candidate.draft.body)
2003
+ : null
2004
+ const source = candidate ? { ...candidate.draft, body: candidateReview?.after || candidate.draft.body } : entry || {
1834
2005
  knowledgeBaseId: activeWorkspace?.view.knowledgeBaseId || undefined,
1835
2006
  title: '', body: '', type: 'fact', tags: [], scope: { kind: 'global' }, confidence: .8,
1836
2007
  }
@@ -1863,8 +2034,16 @@ function openEntryEditor(entry, candidate) {
1863
2034
  )
1864
2035
  body.wrapper.classList.add('span-2')
1865
2036
  tags.wrapper.classList.add('span-2')
1866
- const modeTitle = candidate ? '编辑并通过候选' : entry ? '编辑知识文档' : '新建知识文档'
1867
- const modal = openSheet({ title: modeTitle, description: candidate ? '保存后,这条候选会立即通过并写入知识库。' : '文档保存后会立即参与后续召回。', body: form, primaryLabel: candidate ? '通过并保存' : '保存文档', onPrimary: async () => {
2037
+ const modeTitle = candidate
2038
+ ? candidate.action === 'create' ? '编辑新文档' : '编辑合并内容'
2039
+ : entry ? '编辑知识文档' : '新建知识文档'
2040
+ const candidateDescription = candidate?.action === 'create'
2041
+ ? '确认后将创建文档并立即参与后续召回。'
2042
+ : '正文已载入完整合并预览;保存前可以处理重复内容或冲突。'
2043
+ const primaryLabel = candidate
2044
+ ? candidate.action === 'create' ? '写入新文档' : '保存并合并'
2045
+ : '保存文档'
2046
+ const modal = openSheet({ title: modeTitle, description: candidate ? candidateDescription : '文档保存后会立即参与后续召回。', body: form, primaryLabel, onPrimary: async () => {
1868
2047
  if (!form.reportValidity()) return false
1869
2048
  const draft = {
1870
2049
  knowledgeBaseId: knowledgeBase.input.value,
@@ -1879,7 +2058,7 @@ function openEntryEditor(entry, candidate) {
1879
2058
  })
1880
2059
  else if (entry) await api(`entries/${encodeURIComponent(entry.id)}`, { method: 'PUT', body: { draft } })
1881
2060
  else await api('entries', { method: 'POST', body: { draft } })
1882
- showToast(candidate ? '候选已通过并写入知识库。' : '知识已保存。')
2061
+ showToast(candidate ? candidate.action === 'create' ? '新文档已写入。' : '候选内容已合并。' : '知识已保存。')
1883
2062
  state.stats = null
1884
2063
  await navigate(candidate ? 'candidates' : state.view)
1885
2064
  return true
@@ -1902,7 +2081,9 @@ function selectField(label, options, value) {
1902
2081
  async function loadModelCatalog() {
1903
2082
  if (state.modelCatalog !== null) return state.modelCatalog
1904
2083
  try {
1905
- const response = await fetch('/knowledge-control/v1/models', { headers: { accept: 'application/json' } })
2084
+ const response = await fetch('/knowledge-control/v1/models', {
2085
+ headers: { accept: 'application/json', 'x-dsh-knowledge-client': 'management-web' },
2086
+ })
1906
2087
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
1907
2088
  const payload = await response.json()
1908
2089
  state.modelCatalog = Array.isArray(payload.providers) ? payload.providers : []
@@ -1936,10 +2117,19 @@ function modelRouteFields(currentProvider, currentModel) {
1936
2117
 
1937
2118
  async function reviewCandidate(candidate, decision, resolution) {
1938
2119
  const approve = decision === 'approve'
2120
+ const action = candidatePrimaryAction(candidate)
2121
+ const title = approve ? `${action.label}?` : '拒绝这条候选?'
2122
+ const message = !approve
2123
+ ? '拒绝后会保留审核记录,但不会进入知识库。'
2124
+ : candidate.action === 'create'
2125
+ ? '确认后将创建新文档,并立即参与后续对话召回。'
2126
+ : candidate.action === 'conflict'
2127
+ ? '确认后将保留当前文档内容,并把候选内容合入同一文档。'
2128
+ : '确认后将按预览结果合入目标文档;若审核期间文档发生变化,会转为冲突项。'
1939
2129
  openConfirm({
1940
- title: approve ? '通过这条候选?' : '拒绝这条候选?',
1941
- message: approve ? '通过后内容会立即写入知识库,并参与后续对话召回。' : '拒绝后会保留审核记录,但不会进入知识库。',
1942
- confirmLabel: approve ? '确认通过' : '确认拒绝', danger: !approve,
2130
+ title,
2131
+ message,
2132
+ confirmLabel: approve ? action.label : '确认拒绝', danger: !approve,
1943
2133
  onConfirm: async () => {
1944
2134
  const reviewed = await api(`candidates/${encodeURIComponent(candidate.id)}/review`, {
1945
2135
  method: 'POST', body: { decision, ...(resolution ? { resolution } : {}) },
@@ -1950,7 +2140,7 @@ async function reviewCandidate(candidate, decision, resolution) {
1950
2140
  await navigate('candidates')
1951
2141
  return
1952
2142
  }
1953
- showToast(approve ? '候选已通过。' : '候选已拒绝。')
2143
+ showToast(approve ? candidate.action === 'create' ? '新文档已写入。' : '候选内容已合并。' : '候选已拒绝。')
1954
2144
  state.stats = null
1955
2145
  await navigate('candidates')
1956
2146
  },
@@ -2049,14 +2239,19 @@ function openModal({ title, description = '', body, primaryLabel, primaryVariant
2049
2239
  const backdrop = element('div', { class: `dialog-backdrop${isSheet ? ' sheet-backdrop' : ''}` })
2050
2240
  const dialog = element('section', { class: `dialog${isSheet ? ' sheet' : ''} ${primaryLabel ? '' : 'narrow'}`.trim(), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dialog-title' })
2051
2241
  let busy = false
2052
- const close = () => {
2242
+ let formDirty = false
2243
+ const close = (explicit = false) => {
2053
2244
  if (busy) return
2245
+ if (!explicit && formDirty) {
2246
+ showToast('表单有未保存的修改,请先保存,或使用“取消”放弃修改。', 'error')
2247
+ return
2248
+ }
2054
2249
  document.removeEventListener('keydown', onKeyDown)
2055
2250
  backdrop.remove()
2056
2251
  if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus()
2057
2252
  }
2058
- const closeButton = actionButton('×', close, 'ghost', { 'aria-label': '关闭对话框' })
2059
- const cancel = actionButton(cancelLabel, close)
2253
+ const closeButton = actionButton('×', () => close(), 'ghost', { 'aria-label': '关闭对话框' })
2254
+ const cancel = actionButton(cancelLabel, () => close(true))
2060
2255
  const primary = primaryLabel ? actionButton(primaryLabel, async () => {
2061
2256
  if (busy) return
2062
2257
  busy = true
@@ -2067,7 +2262,7 @@ function openModal({ title, description = '', body, primaryLabel, primaryVariant
2067
2262
  try {
2068
2263
  const shouldClose = await onPrimary()
2069
2264
  busy = false
2070
- if (shouldClose !== false) close()
2265
+ if (shouldClose !== false) close(true)
2071
2266
  else { primary.disabled = false; cancel.disabled = false; primary.textContent = original }
2072
2267
  } catch (error) {
2073
2268
  busy = false
@@ -2088,6 +2283,10 @@ function openModal({ title, description = '', body, primaryLabel, primaryVariant
2088
2283
  )
2089
2284
  backdrop.append(dialog)
2090
2285
  backdrop.addEventListener('mousedown', (event) => { if (event.target === backdrop) close() })
2286
+ if (body.matches?.('form') || body.querySelector?.('form')) {
2287
+ body.addEventListener('input', () => { formDirty = true })
2288
+ body.addEventListener('change', () => { formDirty = true })
2289
+ }
2091
2290
  document.body.append(backdrop)
2092
2291
  document.addEventListener('keydown', onKeyDown)
2093
2292
  window.setTimeout(() => (dialog.querySelector('input, textarea, select, button') || dialog).focus(), 0)
@@ -2118,4 +2317,11 @@ function friendlyError(error) {
2118
2317
  return error.message || '操作失败,请稍后重试。'
2119
2318
  }
2120
2319
 
2320
+ window.addEventListener('beforeunload', event => {
2321
+ const editor = activeDocumentWorkspace()?.view.editor
2322
+ if (!editor?.dirty) return
2323
+ event.preventDefault()
2324
+ event.returnValue = ''
2325
+ })
2326
+
2121
2327
  void installHostThemeBridge().then(() => boot())