@lemoncat7/dsh-knowledge 1.0.10 → 1.0.13

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.
package/web/app.js CHANGED
@@ -61,8 +61,10 @@ const state = {
61
61
  editor: null,
62
62
  },
63
63
  candidates: [],
64
+ candidateTargets: new Map(),
64
65
  candidateStatus: 'pending',
65
66
  settings: { writebackPolicy: 'conservative', updatedAt: '' },
67
+ modelCatalog: null,
66
68
  settingsSaving: false,
67
69
  tokens: [],
68
70
  service: { publicApiEnabled: false, publicApiPrefix: '/knowledge-api/v1', remote: false },
@@ -93,6 +95,7 @@ function installHostThemeBridge() {
93
95
  }
94
96
  root.dataset.dshHostTheme = 'true'
95
97
  root.style.colorScheme = message.colorScheme
98
+ root.style.setProperty('--dialog-surface', message.colorScheme === 'dark' ? '#1c1c1e' : '#ffffff')
96
99
  document.querySelector('meta[name="color-scheme"]')?.setAttribute('content', message.colorScheme)
97
100
  const background = root.style.getPropertyValue('--bg')
98
101
  if (background) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', background)
@@ -310,7 +313,7 @@ function renderLogin(message = '') {
310
313
 
311
314
  function signOut() {
312
315
  sessionStorage.removeItem(TOKEN_KEY)
313
- Object.assign(state, { token: '', stats: null, overview: null, knowledgeBases: [], mounts: [], resolvedMounts: [], entries: [], documents: [], candidates: [], settings: { writebackPolicy: 'conservative', updatedAt: '' }, tokens: [] })
316
+ Object.assign(state, { token: '', stats: null, overview: null, knowledgeBases: [], mounts: [], resolvedMounts: [], entries: [], documents: [], candidates: [], candidateTargets: new Map(), settings: { writebackPolicy: 'conservative', updatedAt: '' }, tokens: [] })
314
317
  if (AUTH_MODE === 'same-origin') void boot()
315
318
  else renderLogin()
316
319
  }
@@ -543,6 +546,12 @@ async function loadCandidates() {
543
546
  ensureKnowledgeBases(),
544
547
  ])
545
548
  state.candidates = candidates
549
+ const targetIds = [...new Set(candidates.map(candidate => candidate.targetId).filter(Boolean))]
550
+ const targets = await Promise.all(targetIds.map(async id => {
551
+ try { return [id, await api(`entries/${encodeURIComponent(id)}`)] }
552
+ catch { return [id, null] }
553
+ }))
554
+ state.candidateTargets = new Map(targets)
546
555
  if (!state.stats) await refreshStats()
547
556
  }
548
557
 
@@ -756,11 +765,11 @@ function renderKnowledgeBases() {
756
765
  type: 'button', role: 'tab', class: 'tab', 'aria-selected': String(state.knowledgeBaseView === id),
757
766
  onClick: () => { state.knowledgeBaseView = id; renderShell() },
758
767
  }, element('span', {}, label), element('span', { class: 'tab-count' }, count)))),
759
- renderWritebackPolicyControl(),
768
+ actionButton('默认回写模型', () => openGlobalWritebackModelEditor(), 'ghost small'),
760
769
  ),
761
- element('p', {}, state.settings.writebackPolicy === 'conservative'
762
- ? '严谨模式只保留明确或已验证的长期知识,宁可漏记也不堆积噪声。'
763
- : '主动模式会更积极地沉淀可复用内容,仍会排除敏感信息和临时输出。'),
770
+ element('p', {}, state.settings.writebackProvider && state.settings.writebackModel
771
+ ? `未单独指定模型的知识库使用 ${state.settings.writebackProvider} / ${state.settings.writebackModel}。`
772
+ : '未单独指定模型的知识库跟随当前会话模型。'),
764
773
  )
765
774
  return element('div', { class: 'bases-page' },
766
775
  switcher,
@@ -810,41 +819,23 @@ function renderKnowledgeBases() {
810
819
  )
811
820
  }
812
821
 
813
- function renderWritebackPolicyControl() {
814
- const policies = [
815
- ['conservative', '严谨'],
816
- ['proactive', '主动'],
817
- ]
818
- return element('div', {
819
- class: 'writeback-policy-control',
820
- 'aria-label': '全局回写策略',
821
- 'aria-busy': String(state.settingsSaving),
822
- },
823
- element('span', { class: 'writeback-policy-label' }, '回写策略'),
824
- element('div', { class: 'writeback-policy-segments', role: 'radiogroup', 'aria-label': '选择全局回写策略' }, policies.map(([value, label]) => element('button', {
825
- type: 'button', role: 'radio', class: 'writeback-policy-option',
826
- 'aria-checked': String(state.settings.writebackPolicy === value),
827
- disabled: state.settingsSaving,
828
- onClick: () => void updateWritebackPolicy(value),
829
- }, label))))
830
- }
831
-
832
- async function updateWritebackPolicy(writebackPolicy) {
833
- if (state.settingsSaving || state.settings.writebackPolicy === writebackPolicy) return
834
- const previous = state.settings
835
- state.settings = { ...state.settings, writebackPolicy }
836
- state.settingsSaving = true
837
- renderShell()
838
- try {
839
- state.settings = await api('settings', { method: 'PUT', body: { patch: { writebackPolicy } } })
840
- showToast(`全局回写策略已切换为${writebackPolicy === 'conservative' ? '严谨' : '主动'}模式`)
841
- } catch (error) {
842
- state.settings = previous
843
- showToast(friendlyError(error), 'error')
844
- } finally {
845
- state.settingsSaving = false
846
- renderShell()
847
- }
822
+ async function openGlobalWritebackModelEditor() {
823
+ await loadModelCatalog()
824
+ const custom = element('input', { type: 'checkbox', checked: Boolean(state.settings.writebackProvider && state.settings.writebackModel) })
825
+ const route = modelRouteFields(state.settings.writebackProvider || '', state.settings.writebackModel || '')
826
+ const form = element('form', { class: 'form-grid' },
827
+ element('label', { class: 'check-option span-2' }, custom, element('span', {}, element('strong', {}, '使用全局默认模型'), element('small', {}, '关闭后,未单独配置的知识库跟随当前会话模型。'))),
828
+ route.provider.wrapper, route.model.wrapper,
829
+ )
830
+ const sync = () => { route.provider.input.disabled = route.model.input.disabled = !custom.checked; route.provider.input.required = route.model.input.required = custom.checked }
831
+ custom.addEventListener('change', sync); sync()
832
+ openSheet({ title: '默认回写模型', description: '知识库自己的模型优先于这里;这里留空则跟随当前会话。', body: form, primaryLabel: '保存', onPrimary: async () => {
833
+ if (!form.reportValidity()) return false
834
+ state.settings = await api('settings', { method: 'PUT', body: { patch: custom.checked
835
+ ? { writebackProvider: route.provider.input.value, writebackModel: route.model.input.value }
836
+ : { writebackProvider: null, writebackModel: null } } })
837
+ showToast('默认回写模型已更新。'); renderShell(); return true
838
+ } })
848
839
  }
849
840
 
850
841
  function contextPill(label, value) {
@@ -869,7 +860,8 @@ function renderKnowledgeBaseCard(base) {
869
860
  : element('span', { class: 'tag is-empty' }, '无默认标签'),
870
861
  hiddenTagCount ? element('span', { class: 'tag' }, `+${hiddenTagCount}`) : null),
871
862
  element('div', { class: 'base-card-meta' },
872
- element('span', {}, element('strong', {}, '回写模型'), base.writebackProvider && base.writebackModel ? `${base.writebackProvider} / ${base.writebackModel}` : '跟随当前会话'),
863
+ element('span', {}, element('strong', {}, '回写策略'), base.writebackPolicy === 'proactive' ? '主动' : '严谨'),
864
+ element('span', {}, element('strong', {}, '回写模型'), base.writebackProvider && base.writebackModel ? `${base.writebackProvider} / ${base.writebackModel}` : state.settings.writebackProvider && state.settings.writebackModel ? `默认 · ${state.settings.writebackProvider} / ${state.settings.writebackModel}` : '跟随当前会话'),
873
865
  base.extractionInstructions ? element('span', {}, element('strong', {}, '提取规则'), '已设置') : null,
874
866
  ),
875
867
  element('div', { class: 'base-card-actions' },
@@ -1283,6 +1275,9 @@ function renderCandidates() {
1283
1275
 
1284
1276
  function renderCandidateCard(candidate) {
1285
1277
  const pending = candidate.status === 'pending'
1278
+ const conflictTarget = candidate.action === 'conflict' && candidate.targetId
1279
+ ? state.candidateTargets.get(candidate.targetId)
1280
+ : null
1286
1281
  const targetDocument = candidate.action === 'create'
1287
1282
  ? `新文档“${candidate.draft.title}”`
1288
1283
  : `文档“${candidate.draft.title}”`
@@ -1298,8 +1293,13 @@ function renderCandidateCard(candidate) {
1298
1293
  ),
1299
1294
  badge(STATUS_LABELS[candidate.status], candidate.status === 'approved' ? 'success' : candidate.status === 'rejected' ? 'danger' : 'warning'),
1300
1295
  ),
1301
- element('div', { class: 'candidate-body' },
1302
- element('p', { class: 'candidate-content' }, candidate.draft.body),
1296
+ element('div', { class: `candidate-body${candidate.action === 'conflict' ? ' is-conflict' : ''}` },
1297
+ candidate.action === 'conflict'
1298
+ ? element('div', { class: 'candidate-comparison' },
1299
+ element('section', {}, element('strong', {}, '当前知识'), element('pre', {}, conflictTarget?.body || '目标文档不可用,请刷新后重试。')),
1300
+ element('section', {}, element('strong', {}, '候选内容'), element('pre', {}, candidate.draft.body)),
1301
+ )
1302
+ : element('p', { class: 'candidate-content' }, candidate.draft.body),
1303
1303
  element('div', { class: 'candidate-reason' },
1304
1304
  element('strong', {}, '文档变更'),
1305
1305
  element('span', { class: 'candidate-target' }, `${candidate.action === 'create' ? '创建' : '更新'}${targetDocument}`),
@@ -1310,7 +1310,9 @@ function renderCandidateCard(candidate) {
1310
1310
  pending ? element('div', {},
1311
1311
  actionButton('拒绝', () => reviewCandidate(candidate, 'reject'), 'danger small'),
1312
1312
  actionButton('编辑并通过', () => openEntryEditor(undefined, candidate), 'small'),
1313
- actionButton('通过', () => reviewCandidate(candidate, 'approve'), 'primary small'),
1313
+ candidate.action === 'conflict'
1314
+ ? actionButton('合并追加', () => reviewCandidate(candidate, 'approve', 'merge'), 'primary small')
1315
+ : actionButton('通过', () => reviewCandidate(candidate, 'approve'), 'primary small'),
1314
1316
  ) : null,
1315
1317
  ),
1316
1318
  )
@@ -1398,33 +1400,37 @@ function parseTags(value) {
1398
1400
  return [...new Set(value.split(/[,,]/).map(tag => tag.trim().toLowerCase()).filter(Boolean))]
1399
1401
  }
1400
1402
 
1401
- function openKnowledgeBaseEditor(base) {
1402
- const source = base || { name: '', description: '', defaultTags: [], extractionInstructions: '' }
1403
+ async function openKnowledgeBaseEditor(base) {
1404
+ await loadModelCatalog()
1405
+ const source = base || { name: '', description: '', defaultTags: [], extractionInstructions: '', writebackPolicy: 'conservative' }
1403
1406
  const form = element('form', { class: 'form-grid' })
1404
1407
  const name = formField('名称', 'input', source.name, { required: true, maxlength: 100, placeholder: '例如:项目规范' })
1405
1408
  const description = formField('回写匹配描述', 'textarea', source.description, { maxlength: 2000, placeholder: '描述什么样的对话才属于这个库。例如:只记录 dsh-knowledge 项目的架构决策和部署规范' })
1406
1409
  const tags = formField('默认标签', 'input', source.defaultTags.join(', '), { placeholder: 'project-rule, backend' })
1407
1410
  const instructions = formField('提取要求', 'textarea', source.extractionInstructions, { maxlength: 4000, placeholder: '例如:只收录已确认、可跨会话复用的项目约定' })
1411
+ const policy = selectField('回写策略', [
1412
+ { value: 'conservative', label: '严谨(高置信度直写)' },
1413
+ { value: 'proactive', label: '主动(更积极沉淀)' },
1414
+ ], source.writebackPolicy || 'conservative')
1408
1415
  const customModel = element('input', { type: 'checkbox', checked: Boolean(source.writebackProvider && source.writebackModel) })
1409
- const provider = formField('模型提供方(Provider)', 'input', source.writebackProvider || '', { maxlength: 100, placeholder: '例如:cli' })
1410
- const model = formField('模型名称(Model)', 'input', source.writebackModel || '', { maxlength: 200, placeholder: '例如:kimi-k2.7-code' })
1416
+ const route = modelRouteFields(source.writebackProvider || '', source.writebackModel || '')
1411
1417
  const modelHint = element('div', { class: 'field-hint span-2' }, '默认跟随当前会话模型。指定后,仅这个知识库使用专用模型回写。')
1412
1418
  const updateModelFields = () => {
1413
- provider.input.disabled = !customModel.checked
1414
- model.input.disabled = !customModel.checked
1415
- provider.input.required = customModel.checked
1416
- model.input.required = customModel.checked
1419
+ route.provider.input.disabled = !customModel.checked
1420
+ route.model.input.disabled = !customModel.checked
1421
+ route.provider.input.required = customModel.checked
1422
+ route.model.input.required = customModel.checked
1417
1423
  }
1418
1424
  customModel.addEventListener('change', updateModelFields)
1419
1425
  updateModelFields()
1420
1426
  for (const field of [name, description, tags, instructions]) field.wrapper.classList.add('span-2')
1421
1427
  form.append(
1422
- name.wrapper, description.wrapper, tags.wrapper, instructions.wrapper,
1428
+ name.wrapper, description.wrapper, tags.wrapper, instructions.wrapper, policy.wrapper,
1423
1429
  element('label', { class: 'check-option span-2' }, customModel, element('span', {},
1424
1430
  element('strong', {}, '为这个知识库指定回写模型'),
1425
1431
  element('small', {}, '关闭时自动使用当前会话正在使用的模型。'),
1426
1432
  )),
1427
- provider.wrapper, model.wrapper, modelHint,
1433
+ route.provider.wrapper, route.model.wrapper, modelHint,
1428
1434
  )
1429
1435
  openSheet({
1430
1436
  title: base ? '编辑知识库' : '创建知识库',
@@ -1438,8 +1444,9 @@ function openKnowledgeBaseEditor(base) {
1438
1444
  description: description.input.value.trim(),
1439
1445
  defaultTags: parseTags(tags.input.value),
1440
1446
  extractionInstructions: instructions.input.value.trim(),
1441
- writebackProvider: customModel.checked ? provider.input.value.trim() : '',
1442
- writebackModel: customModel.checked ? model.input.value.trim() : '',
1447
+ writebackPolicy: policy.input.value,
1448
+ writebackProvider: customModel.checked ? route.provider.input.value : '',
1449
+ writebackModel: customModel.checked ? route.model.input.value : '',
1443
1450
  }
1444
1451
  if (base) await api(`knowledge-bases/${encodeURIComponent(base.id)}`, { method: 'PUT', body: { draft } })
1445
1452
  else await api('knowledge-bases', { method: 'POST', body: { draft } })
@@ -1710,7 +1717,9 @@ function openEntryEditor(entry, candidate) {
1710
1717
  confidence: Number(confidenceInput.value),
1711
1718
  ...(source.source ? { source: source.source } : {}),
1712
1719
  }
1713
- if (candidate) await api(`candidates/${encodeURIComponent(candidate.id)}/review`, { method: 'POST', body: { decision: 'approve', draft } })
1720
+ if (candidate) await api(`candidates/${encodeURIComponent(candidate.id)}/review`, {
1721
+ method: 'POST', body: { decision: 'approve', draft, ...(candidate.action === 'conflict' ? { resolution: 'merge' } : {}) },
1722
+ })
1714
1723
  else if (entry) await api(`entries/${encodeURIComponent(entry.id)}`, { method: 'PUT', body: { draft } })
1715
1724
  else await api('entries', { method: 'POST', body: { draft } })
1716
1725
  showToast(candidate ? '候选已通过并写入知识库。' : '知识已保存。')
@@ -1733,14 +1742,57 @@ function selectField(label, options, value) {
1733
1742
  return { input, wrapper: element('div', { class: 'field' }, element('label', {}, label), input) }
1734
1743
  }
1735
1744
 
1736
- async function reviewCandidate(candidate, decision) {
1745
+ async function loadModelCatalog() {
1746
+ if (state.modelCatalog !== null) return state.modelCatalog
1747
+ try {
1748
+ const response = await fetch('/knowledge-control/v1/models', { headers: { accept: 'application/json' } })
1749
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
1750
+ const payload = await response.json()
1751
+ state.modelCatalog = Array.isArray(payload.providers) ? payload.providers : []
1752
+ } catch (error) {
1753
+ state.modelCatalog = []
1754
+ showToast(`无法读取当前 DSH 的模型目录:${error instanceof Error ? error.message : String(error)}`, 'error')
1755
+ }
1756
+ return state.modelCatalog
1757
+ }
1758
+
1759
+ function modelRouteFields(currentProvider, currentModel) {
1760
+ const providers = [...(state.modelCatalog || [])]
1761
+ if (currentProvider && !providers.some(item => item.id === currentProvider)) {
1762
+ providers.push({ id: currentProvider, name: `${currentProvider}(当前配置)`, models: [] })
1763
+ }
1764
+ const provider = selectField('模型提供方', providers.map(item => ({ value: item.id, label: item.name || item.id })), currentProvider || providers[0]?.id || '')
1765
+ const model = selectField('回写模型', [], '')
1766
+ const refreshModels = preferred => {
1767
+ const selected = providers.find(item => item.id === provider.input.value)
1768
+ const models = [...(selected?.models || [])]
1769
+ if (preferred && !models.some(item => item.id === preferred)) models.push({ id: preferred, name: `${preferred}(当前配置)` })
1770
+ model.input.replaceChildren(...models.map(item => element('option', {
1771
+ value: item.id, selected: item.id === preferred,
1772
+ }, item.name && item.name !== item.id ? `${item.name} · ${item.id}` : item.id)))
1773
+ if (preferred && models.some(item => item.id === preferred)) model.input.value = preferred
1774
+ }
1775
+ provider.input.addEventListener('change', () => refreshModels(''))
1776
+ refreshModels(currentModel)
1777
+ return { provider, model }
1778
+ }
1779
+
1780
+ async function reviewCandidate(candidate, decision, resolution) {
1737
1781
  const approve = decision === 'approve'
1738
1782
  openConfirm({
1739
1783
  title: approve ? '通过这条候选?' : '拒绝这条候选?',
1740
1784
  message: approve ? '通过后内容会立即写入知识库,并参与后续对话召回。' : '拒绝后会保留审核记录,但不会进入知识库。',
1741
1785
  confirmLabel: approve ? '确认通过' : '确认拒绝', danger: !approve,
1742
1786
  onConfirm: async () => {
1743
- await api(`candidates/${encodeURIComponent(candidate.id)}/review`, { method: 'POST', body: { decision } })
1787
+ const reviewed = await api(`candidates/${encodeURIComponent(candidate.id)}/review`, {
1788
+ method: 'POST', body: { decision, ...(resolution ? { resolution } : {}) },
1789
+ })
1790
+ if (approve && reviewed.status === 'pending' && reviewed.action === 'conflict') {
1791
+ showToast('审核期间知识已变化,候选已转为冲突项,请比较后重新确认。')
1792
+ state.stats = null
1793
+ await navigate('candidates')
1794
+ return
1795
+ }
1744
1796
  showToast(approve ? '候选已通过。' : '候选已拒绝。')
1745
1797
  state.stats = null
1746
1798
  await navigate('candidates')
package/web/styles.css CHANGED
@@ -7,6 +7,7 @@
7
7
  --surface-raised: #ffffff;
8
8
  --surface-soft: #f0f0f3;
9
9
  --surface-hover: #f5f5f8;
10
+ --dialog-surface: #ffffff;
10
11
  --text: #1d1d1f;
11
12
  --text-secondary: #4c4c50;
12
13
  --text-tertiary: #6e6e73;
@@ -34,6 +35,7 @@
34
35
  --surface-raised: #242426;
35
36
  --surface-soft: #2c2c2e;
36
37
  --surface-hover: #303033;
38
+ --dialog-surface: #1c1c1e;
37
39
  --text: #f5f5f7;
38
40
  --text-secondary: #c7c7cc;
39
41
  --text-tertiary: #98989d;
@@ -106,7 +108,7 @@ button:disabled { cursor: not-allowed; opacity: .56; }
106
108
  border: 1px solid var(--border);
107
109
  border-radius: 20px;
108
110
  padding: 32px;
109
- background: var(--surface-raised);
111
+ background: var(--dialog-surface);
110
112
  box-shadow: var(--shadow);
111
113
  }
112
114
 
@@ -127,11 +129,11 @@ button:disabled { cursor: not-allowed; opacity: .56; }
127
129
  .login-card > p { margin: 0 0 24px; color: var(--text-secondary); }
128
130
 
129
131
  .field { display: grid; gap: 7px; }
130
- .field + .field { margin-top: 16px; }
131
132
  .field label { color: var(--text-secondary); font-size: 13px; font-weight: 600; }
132
133
  .field-hint { color: var(--text-tertiary); font-size: 12px; }
133
134
 
134
135
  .input, .select, .textarea {
136
+ box-sizing: border-box;
135
137
  width: 100%;
136
138
  border: 1px solid var(--border-strong);
137
139
  border-radius: 9px;
@@ -141,7 +143,7 @@ button:disabled { cursor: not-allowed; opacity: .56; }
141
143
  transition: border-color .15s ease, box-shadow .15s ease;
142
144
  }
143
145
 
144
- .input, .select { min-height: 40px; }
146
+ .input, .select { height: 40px; min-height: 40px; font: inherit; line-height: 20px; }
145
147
  .textarea { min-height: 132px; resize: vertical; }
146
148
  .input::placeholder, .textarea::placeholder { color: var(--text-tertiary); }
147
149
  .input:focus, .select:focus, .textarea:focus { border-color: var(--accent); }
@@ -169,6 +171,7 @@ button:disabled { cursor: not-allowed; opacity: .56; }
169
171
  .button.ghost { border-color: transparent; background: transparent; color: var(--text-secondary); }
170
172
  .button.small { min-height: 32px; padding: 5px 10px; font-size: 12px; }
171
173
  .login-card .button { width: 100%; margin-top: 20px; }
174
+ .login-card .field + .field { margin-top: 16px; }
172
175
 
173
176
  .app-shell { min-height: 100vh; min-height: 100dvh; display: grid; grid-template-columns: var(--sidebar-width) 6px minmax(0, 1fr); }
174
177
 
@@ -392,41 +395,6 @@ button:disabled { cursor: not-allowed; opacity: .56; }
392
395
  .workspace-switcher p { max-width: 56ch; margin: 0; color: var(--text-tertiary); font-size: 12px; text-align: right; }
393
396
  .workspace-tabs { flex: none; }
394
397
  .workspace-tabs .tab { display: inline-flex; align-items: center; gap: 7px; }
395
- .writeback-policy-control {
396
- min-height: 36px;
397
- display: inline-flex;
398
- align-items: center;
399
- gap: 7px;
400
- border-left: 1px solid var(--border);
401
- padding-left: 10px;
402
- }
403
- .writeback-policy-label { color: var(--text-tertiary); font-size: 11px; font-weight: 600; white-space: nowrap; }
404
- .writeback-policy-segments {
405
- display: inline-flex;
406
- gap: 2px;
407
- border: 1px solid var(--border);
408
- border-radius: 9px;
409
- padding: 2px;
410
- background: color-mix(in srgb, var(--surface-soft) 72%, transparent);
411
- }
412
- .writeback-policy-option {
413
- min-width: 48px;
414
- min-height: 30px;
415
- border: 0;
416
- border-radius: 7px;
417
- padding: 5px 10px;
418
- background: transparent;
419
- color: var(--text-tertiary);
420
- font-size: 11px;
421
- font-weight: 620;
422
- transition: background .14s ease, color .14s ease, box-shadow .14s ease;
423
- }
424
- .writeback-policy-option:hover:not(:disabled) { background: var(--surface-hover); color: var(--text); }
425
- .writeback-policy-option[aria-checked="true"] {
426
- background: var(--surface-raised);
427
- color: var(--text);
428
- box-shadow: 0 1px 4px rgb(20 25 35 / 10%);
429
- }
430
398
  .tab-count { min-width: 20px; border-radius: 6px; padding: 1px 6px; background: color-mix(in srgb, var(--text-tertiary) 10%, transparent); color: var(--text-tertiary); font-size: 10px; font-variant-numeric: tabular-nums; }
431
399
  .tab[aria-selected="true"] .tab-count { background: var(--accent-soft); color: var(--accent); }
432
400
  .library-management, .mount-section { min-width: 0; }
@@ -678,6 +646,11 @@ button:disabled { cursor: not-allowed; opacity: .56; }
678
646
  .candidate-header h3 { margin: 8px 0 0; font-size: 15px; }
679
647
  .candidate-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, .55fr); gap: 20px; padding: 0 18px 17px; }
680
648
  .candidate-content { margin: 0; color: var(--text-secondary); white-space: pre-wrap; }
649
+ .candidate-body.is-conflict { grid-template-columns: 1fr; }
650
+ .candidate-comparison { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
651
+ .candidate-comparison section { min-width: 0; border: 1px solid var(--border); border-radius: 9px; padding: 12px; background: var(--surface-soft); }
652
+ .candidate-comparison strong { display: block; margin-bottom: 8px; color: var(--text); font-size: 12px; }
653
+ .candidate-comparison pre { overflow: auto; max-height: 280px; margin: 0; color: var(--text-secondary); font: inherit; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
681
654
  .candidate-reason { border-left: 2px solid var(--border-strong); padding-left: 13px; color: var(--text-secondary); font-size: 12px; }
682
655
  .candidate-reason strong { display: block; margin-bottom: 5px; color: var(--text-tertiary); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
683
656
  .candidate-target { display: block; margin-bottom: 13px; border-radius: 7px; padding: 7px 9px; background: var(--accent-soft); color: var(--accent); font-weight: 600; }
@@ -727,7 +700,7 @@ button:disabled { cursor: not-allowed; opacity: .56; }
727
700
  flex-direction: column;
728
701
  border: 1px solid var(--border-strong);
729
702
  border-radius: 18px;
730
- background: var(--surface-raised);
703
+ background: var(--dialog-surface);
731
704
  box-shadow: 0 28px 80px rgb(0 0 0 / 24%);
732
705
  }
733
706
  .dialog.narrow { width: min(100%, 460px); }
@@ -752,7 +725,7 @@ button:disabled { cursor: not-allowed; opacity: .56; }
752
725
  .dialog-header p { margin: 4px 0 0; color: var(--text-tertiary); font-size: 12px; }
753
726
  .dialog-body { overflow: auto; padding: 20px; }
754
727
  .dialog-footer { display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--border); padding: 13px 20px; }
755
- .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
728
+ .form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 16px; }
756
729
  .form-grid .span-2 { grid-column: 1 / -1; }
757
730
  .check-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
758
731
  .check-option { display: flex; align-items: center; gap: 8px; border: 1px solid var(--border); border-radius: 8px; padding: 9px; }
@@ -805,6 +778,7 @@ button:disabled { cursor: not-allowed; opacity: .56; }
805
778
  }
806
779
 
807
780
  @media (max-width: 760px) {
781
+ .candidate-comparison { grid-template-columns: 1fr; }
808
782
  :root { --sidebar-width: 0px; }
809
783
  .app-shell { display: block; }
810
784
  .app-sidebar-resizer { display: none; }
@@ -890,7 +864,6 @@ button:disabled { cursor: not-allowed; opacity: .56; }
890
864
  .topbar-actions .button.primary::before { content: "+"; font-size: 18px; }
891
865
  .workspace-tabs { width: 100%; }
892
866
  .workspace-tabs .tab { min-width: 0; flex: 1; justify-content: center; padding-inline: 8px; }
893
- .writeback-policy-control { width: 100%; justify-content: space-between; border-left: 0; padding-left: 0; }
894
867
  .document-toolbar-actions { width: 100%; }
895
868
  .document-toolbar-actions .button.primary { margin-left: auto; }
896
869
  .form-grid { grid-template-columns: 1fr; }