dsh-superinput 0.7.0 → 0.12.1

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/client.js CHANGED
@@ -24,7 +24,7 @@ function createCostClient(require) {
24
24
  const ENDPOINT = '/api/dsh-cost'
25
25
  const POLL_MS = 15000
26
26
 
27
- /** 客户端调试通道:关键锚定/装配事件上报服务端 debug log(真机取证)。 */
27
+ /* 客户端调试通道:关键锚定/装配事件上报服务端 debug log(真机取证)。 */
28
28
  function clientDebug(message) {
29
29
  try {
30
30
  void fetch(`${ENDPOINT}/client-debug`, {
@@ -35,6 +35,108 @@ function createCostClient(require) {
35
35
  } catch { /* ignore */ }
36
36
  }
37
37
 
38
+ /* -- 双语(zh / en):localStorage 持久化,默认跟随系统 ------------- */
39
+
40
+ const LANG_KEY = 'dsh-cost-lang'
41
+ const STRINGS = {
42
+ zh: {
43
+ sectionLabel: '费用',
44
+ today: '今日', thisMonth: '本月', total: '累计',
45
+ callsUnit: '次调用', callTimes: ' 次',
46
+ noRecords: '暂无记录', noCallsToday: '今日暂无调用',
47
+ peakBadge: '峰时计价', offPeakBadge: '谷时计价',
48
+ peakNow: '峰时', offPeakNow: '谷时',
49
+ unmatchedModels: '个模型按默认档计费',
50
+ exportJson: '导出 JSON', exportCsv: '导出 CSV',
51
+ exportJsonTitle: '导出全部费用记录(JSON)', exportCsvTitle: '导出日汇总(CSV)',
52
+ syncPrices: '同步官方价格', syncPricesTitle: '从官方定价页同步现行价格(峰谷两档,历史账目不受影响)',
53
+ refreshRate: '刷新汇率', refreshRateTitle: '立即刷新 USD→CNY 汇率(平时每小时自动刷新)',
54
+ priceSynced: '价格已同步', rateRefreshed: '汇率已刷新', failed: '失败',
55
+ monthForecast: '月底预计', spent: '已花', dailyAvg: '日均', daysLeft: (n) => `剩 ${n} 天`, overBudget: '超出预算',
56
+ forecastNeedDays: (n) => `预测需要至少 3 天完整数据(当前 ${n} 天)`,
57
+ budget: '预算', budgetPeriodDay: '今日', budgetPeriodMonth: '本月', budgetPeriodAll: '累计',
58
+ turnCost: '费用', turnCostTitle: '本轮费用明细', turnCostPop: '本轮费用', turnEmpty: '该回合暂无用量记录',
59
+ costDetail: '费用明细', sessionCost: '本对话费用', officialBalance: '官方余额', recentCalls: '最近调用',
60
+ srcChat: '对话', srcEnhance: '提示词增强', srcFollowup: '跟进建议',
61
+ todaySidebar: '今日', loading: '加载中…', readFailed: '读取失败', gotIt: '知道了',
62
+ peakSoonPeak: (time) => `即将进入峰时计价(${time})`, peakSoonOff: (time) => `即将进入谷时计价(${time})`,
63
+ budgetOverTitle: 'dsh 费用预算已超限',
64
+ budgetOverBody: (label, used, amount, pct) => `${label}费用 ¥${used},已达预算 ¥${amount} 的 ${pct}%。`,
65
+ bucketInput: '输入', bucketOutput: '输出', bucketCacheRead: '缓存读取', bucketCacheWrite: '缓存写入', bucketReasoning: '推理',
66
+ heatTitle: '用量热力图', heatCost: '费用', heatCalls: '调用', heatTokens: 'Token',
67
+ heatLess: '少', heatMore: '多', heatEmpty: '暂无用量数据',
68
+ plansTitle: 'Coding Plan 配额', plansHint: '粘贴各家订阅的 API Key 查询剩余额度;Key 只保存在本机 settings.json,不会回显或上传。',
69
+ plansSave: '保存', plansSaved: '已保存', plansRefresh: '刷新', plansRefreshAll: '全部刷新',
70
+ plansConfigured: '已配置', plansError: '查询失败',
71
+ winFiveHour: '5 小时', winWeekly: '本周', winMonthly: '本月', winBalance: '余额',
72
+ winCredits: '额度', winPlan: '套餐', winCompensation: '补偿', winCurrent: '当前',
73
+ winResets: '重置', winUnlimited: '不限量',
74
+ planAnthropic: 'Anthropic (Claude Pro/Max)', planZai: 'Z.ai / 智谱 GLM Coding Plan',
75
+ planMinimax: 'MiniMax Token Plan', planKimi: 'Kimi Code / Moonshot',
76
+ planOpenrouter: 'OpenRouter', planSiliconflow: 'SiliconFlow 硅基流动',
77
+ planCommandcode: 'CommandCode', planClinepass: 'Cline Pass',
78
+ langHint: '设置分区标题等宿主注册项在刷新窗口后生效',
79
+ },
80
+ en: {
81
+ sectionLabel: 'Cost',
82
+ today: 'Today', thisMonth: 'This month', total: 'Total',
83
+ callsUnit: 'calls', callTimes: ' calls',
84
+ noRecords: 'No records yet', noCallsToday: 'No calls today',
85
+ peakBadge: 'Peak pricing', offPeakBadge: 'Off-peak pricing',
86
+ peakNow: 'peak', offPeakNow: 'off-peak',
87
+ unmatchedModels: ' models billed at default tier',
88
+ exportJson: 'Export JSON', exportCsv: 'Export CSV',
89
+ exportJsonTitle: 'Export all cost records (JSON)', exportCsvTitle: 'Export daily summary (CSV)',
90
+ syncPrices: 'Sync official prices', syncPricesTitle: 'Sync current prices from the official pricing page (peak/off-peak; history untouched)',
91
+ refreshRate: 'Refresh rate', refreshRateTitle: 'Refresh the USD→CNY rate now (auto hourly otherwise)',
92
+ priceSynced: 'Prices synced', rateRefreshed: 'Rate refreshed', failed: 'Failed',
93
+ monthForecast: 'Month-end forecast', spent: 'Spent ', dailyAvg: 'daily avg ', daysLeft: (n) => `${n} days left`, overBudget: 'over budget',
94
+ forecastNeedDays: (n) => `Forecast needs at least 3 full days of data (currently ${n})`,
95
+ budget: 'Budget', budgetPeriodDay: 'Daily', budgetPeriodMonth: 'Monthly', budgetPeriodAll: 'All time',
96
+ turnCost: 'Cost', turnCostTitle: 'Turn cost details', turnCostPop: 'This turn', turnEmpty: 'No usage recorded for this turn',
97
+ costDetail: 'Cost details', sessionCost: 'This conversation', officialBalance: 'Official balance', recentCalls: 'Recent calls',
98
+ srcChat: 'Chat', srcEnhance: 'Prompt enhancer', srcFollowup: 'Follow-ups',
99
+ todaySidebar: 'Today', loading: 'Loading…', readFailed: 'Failed to load', gotIt: 'Got it',
100
+ peakSoonPeak: (time) => `Peak pricing starts at ${time}`, peakSoonOff: (time) => `Off-peak pricing starts at ${time}`,
101
+ budgetOverTitle: 'dsh cost budget exceeded',
102
+ budgetOverBody: (label, used, amount, pct) => `${label} cost ¥${used} — ${pct}% of the ¥${amount} budget.`,
103
+ bucketInput: 'Input', bucketOutput: 'Output', bucketCacheRead: 'Cache read', bucketCacheWrite: 'Cache write', bucketReasoning: 'Reasoning',
104
+ heatTitle: 'Usage heatmap', heatCost: 'Cost', heatCalls: 'Calls', heatTokens: 'Tokens',
105
+ heatLess: 'Less', heatMore: 'More', heatEmpty: 'No usage data yet',
106
+ plansTitle: 'Coding Plan quotas', plansHint: 'Paste an API key per subscription to query remaining quota. Keys stay in the local settings.json — never echoed, never uploaded.',
107
+ plansSave: 'Save', plansSaved: 'Saved', plansRefresh: 'Refresh', plansRefreshAll: 'Refresh all',
108
+ plansConfigured: 'configured', plansError: 'Query failed',
109
+ winFiveHour: '5-hour', winWeekly: 'This week', winMonthly: 'Monthly', winBalance: 'Balance',
110
+ winCredits: 'Credits', winPlan: 'Plan', winCompensation: 'Bonus', winCurrent: 'Current',
111
+ winResets: 'Resets', winUnlimited: 'Unlimited',
112
+ planAnthropic: 'Anthropic (Claude Pro/Max)', planZai: 'Z.ai / Zhipu GLM Coding Plan',
113
+ planMinimax: 'MiniMax Token Plan', planKimi: 'Kimi Code / Moonshot',
114
+ planOpenrouter: 'OpenRouter', planSiliconflow: 'SiliconFlow',
115
+ planCommandcode: 'CommandCode', planClinepass: 'Cline Pass',
116
+ langHint: 'Host-registered labels (e.g. the settings section title) apply after a window reload',
117
+ },
118
+ }
119
+ let lang = 'zh'
120
+ try {
121
+ const saved = localStorage.getItem(LANG_KEY)
122
+ lang = saved === 'zh' || saved === 'en'
123
+ ? saved
124
+ : (navigator.language ?? 'zh').toLowerCase().startsWith('zh') ? 'zh' : 'en'
125
+ } catch { /* 无 localStorage:保持跟随系统默认 */ }
126
+ const t = (key) => STRINGS[lang][key] ?? STRINGS.zh[key] ?? key
127
+ /** 切语言:写 localStorage 并广播一次,让所有 useCostState 订阅组件重渲染。 */
128
+ function setLang(value) {
129
+ const next = value === 'en' || value === 'zh'
130
+ ? value
131
+ : ((navigator.language ?? 'zh').toLowerCase().startsWith('zh') ? 'zh' : 'en')
132
+ if (next === lang) return
133
+ lang = next
134
+ try { localStorage.setItem(LANG_KEY, lang) } catch { /* ignore */ }
135
+ for (const listener of listeners) {
136
+ try { listener(latestState) } catch { /* 单订阅者异常不断链 */ }
137
+ }
138
+ }
139
+
38
140
  /* 官方 token 配色(compaction-control / cost-meter 同基准),无自创色。 */
39
141
  const CSS = `
40
142
  .dsh-cost-inline {
@@ -223,6 +325,43 @@ body[data-ds-dark-theme] .dsh-cost-pop {
223
325
  .dsh-cost-fill { height: 100%; border-radius: 3px; background: var(--dsw-alias-brand-primary); }
224
326
  .dsh-cost-fill.warn { background: var(--dsw-alias-state-warn-primary); }
225
327
  .dsh-cost-fill.over { background: var(--dsw-alias-state-error-primary); }
328
+ /* 用量热力图(GitHub 贡献图样式;色阶用官方品牌色 color-mix) */
329
+ .dsh-cost-heat { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 10px; background: var(--dsw-alias-bg-layer-1, transparent); }
330
+ .dsh-cost-heat-head { display: flex; align-items: center; gap: 8px; }
331
+ .dsh-cost-heat-title { font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-tertiary); flex: 1; }
332
+ .dsh-cost-heat-toggle { display: flex; gap: 2px; }
333
+ .dsh-cost-heat-toggle button { border: none; background: transparent; color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 18px; padding: 1px 8px; border-radius: 4px; cursor: pointer; }
334
+ .dsh-cost-heat-toggle button[aria-pressed="true"] { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.2)); color: var(--dsw-alias-label-primary); }
335
+ .dsh-cost-heat-months { display: flex; gap: 3px; margin-left: 18px; height: 11px; font-size: 10px; line-height: 11px; color: var(--dsw-alias-label-tertiary); }
336
+ .dsh-cost-heat-months span { width: 11px; flex: none; overflow: visible; white-space: nowrap; }
337
+ .dsh-cost-heat-weeks { display: flex; flex-direction: column; width: 14px; flex: none; font-size: 10px; line-height: 11px; color: var(--dsw-alias-label-tertiary); }
338
+ .dsh-cost-heat-grid { display: flex; gap: 3px; overflow-x: auto; height: 95px; align-items: flex-start; }
339
+ .dsh-cost-heat-col { display: flex; flex-direction: column; gap: 3px; flex: none; }
340
+ .dsh-cost-heat-cell { width: 11px; height: 11px; border-radius: 2.5px; background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.22)); flex: none; }
341
+ .dsh-cost-heat-cell.blank { visibility: hidden; }
342
+ .dsh-cost-heat-legend { display: flex; align-items: center; gap: 3px; font-size: 11px; color: var(--dsw-alias-label-tertiary); }
343
+ .dsh-cost-heat-legend .dsh-cost-heat-cell { width: 9px; height: 9px; }
344
+ /* Coding Plan 配额面板 */
345
+ .dsh-cost-plans { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 10px; background: var(--dsw-alias-bg-layer-1, transparent); }
346
+ .dsh-cost-plans-head { display: flex; align-items: center; gap: 8px; }
347
+ .dsh-cost-plans-title { font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-primary); flex: 1; cursor: pointer; user-select: none; }
348
+ .dsh-cost-plans-caret { font-size: 10px; color: var(--dsw-alias-label-tertiary); flex: none; width: 12px; }
349
+ .dsh-cost-plans-hint { font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); }
350
+ .dsh-cost-plan { display: flex; flex-direction: column; gap: 5px; padding: 8px 10px; border-radius: 8px; background: var(--dsw-alias-bg-layer-2, transparent); }
351
+ .dsh-cost-plan-row { display: flex; align-items: center; gap: 8px; }
352
+ .dsh-cost-plan-name { font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-primary); flex: 1 1 40%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
353
+ .dsh-cost-plan-input { flex: 2 1 180px; min-width: 0; height: 24px; padding: 0 8px; border: 1px solid var(--dsw-alias-border-l1, transparent); border-radius: 6px; background: var(--dsw-alias-bg-layer-1, transparent); color: var(--dsw-alias-label-primary); font-size: 12px; outline: none; }
354
+ .dsh-cost-plan-input:focus { border-color: var(--dsw-alias-brand-primary); }
355
+ .dsh-cost-plan-btn { flex: none; border: none; border-radius: 6px; background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.2)); color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 20px; padding: 0 10px; cursor: pointer; }
356
+ .dsh-cost-plan-btn:hover { color: var(--dsw-alias-label-primary); }
357
+ .dsh-cost-plan-btn[disabled] { opacity: 0.45; cursor: default; }
358
+ .dsh-cost-plan-win { display: flex; align-items: center; gap: 8px; font-size: 11.5px; line-height: 18px; font-variant-numeric: tabular-nums; }
359
+ .dsh-cost-plan-win .dsh-cost-plan-winname { flex: none; width: 68px; color: var(--dsw-alias-label-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
360
+ .dsh-cost-plan-win .dsh-cost-plan-bar { flex: 1; height: 4px; border-radius: 2px; background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.2)); overflow: hidden; }
361
+ .dsh-cost-plan-win .dsh-cost-plan-fill { height: 100%; border-radius: 2px; background: var(--dsw-alias-brand-primary); }
362
+ .dsh-cost-plan-win .dsh-cost-plan-fill.hot { background: var(--dsw-alias-state-warn-primary); }
363
+ .dsh-cost-plan-win .dsh-cost-plan-pct { flex: none; min-width: 88px; text-align: right; color: var(--dsw-alias-label-tertiary); }
364
+ .dsh-cost-plan-err { font-size: 11.5px; line-height: 18px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; }
226
365
  `
227
366
 
228
367
  function ensureStyle() {
@@ -250,11 +389,11 @@ body[data-ds-dark-theme] .dsh-cost-pop {
250
389
  if (budgetNoticeKey === key) return
251
390
  budgetNoticeKey = key
252
391
  if (typeof Notification === 'undefined') return
253
- const periodLabel = budget.period === 'day' ? '今日' : budget.period === 'month' ? '本月' : '累计'
392
+ const periodLabel = t(budget.period === 'day' ? 'budgetPeriodDay' : budget.period === 'month' ? 'budgetPeriodMonth' : 'budgetPeriodAll')
254
393
  const fire = () => {
255
394
  try {
256
- const n = new Notification('dsh 费用预算已超限', {
257
- body: `${periodLabel}费用 ¥${Number(budget.used).toFixed(2)},已达预算 ¥${Number(budget.amount).toFixed(2)} 的 ${(budget.usedPct * 100).toFixed(0)}%。`,
395
+ const n = new Notification(t('budgetOverTitle'), {
396
+ body: t('budgetOverBody')(periodLabel, Number(budget.used).toFixed(2), Number(budget.amount).toFixed(2), (budget.usedPct * 100).toFixed(0)),
258
397
  })
259
398
  n.onclick = () => { window.focus?.(); n.close() }
260
399
  } catch { /* 通知不可用:UI 超限变色兜底 */ }
@@ -488,8 +627,8 @@ body[data-ds-dark-theme] .dsh-cost-pop {
488
627
  if (!state) return null
489
628
  const today = state.today
490
629
  const title = today
491
- ? `${today.calls} 次调用 · token ${(tokensOf(today) / 1000).toFixed(1)}k · ${state.peak?.isPeak ? '峰时' : '谷时'}`
492
- : '今日暂无调用'
630
+ ? `${today.calls} ${t('callsUnit')} · token ${(tokensOf(today) / 1000).toFixed(1)}k · ${state.peak?.isPeak ? t('peakNow') : t('offPeakNow')}`
631
+ : t('noCallsToday')
493
632
  const overBudget = state.budget && state.budget.usedPct >= 0.8
494
633
  return h('button', {
495
634
  type: 'button',
@@ -497,13 +636,14 @@ body[data-ds-dark-theme] .dsh-cost-pop {
497
636
  title,
498
637
  onClick: () => { void pollOnce() },
499
638
  },
500
- h('span', null, '今日'),
639
+ h('span', null, t('todaySidebar')),
501
640
  h('span', { className: 'dsh-cost-sidebar-value' }, moneyOf(state, today?.cost ?? 0)))
502
641
  }
503
642
 
504
643
  /* -- 回合边界感知(turnTail 槽位,隐形数据载体) -------------------- */
505
644
 
506
- const TURN_BUCKET_NAMES = { input: '输入', output: '输出', cacheRead: '缓存读取', cacheWrite: '缓存写入', reasoning: '推理' }
645
+ const TURN_BUCKET_KEYS = { input: 'bucketInput', output: 'bucketOutput', cacheRead: 'bucketCacheRead', cacheWrite: 'bucketCacheWrite', reasoning: 'bucketReasoning' }
646
+ const turnBucketName = (bucket) => t(TURN_BUCKET_KEYS[bucket] ?? bucket)
507
647
 
508
648
  /**
509
649
  * 用户要求去掉回合尾部的可见费用行(0.6.1);此组件退化为隐形载体:
@@ -541,7 +681,7 @@ body[data-ds-dark-theme] .dsh-cost-pop {
541
681
  if (lastAlertedSwitch === nextSwitchAtMs) return
542
682
  lastAlertedSwitch = nextSwitchAtMs
543
683
  const time = new Date(nextSwitchAtMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
544
- const text = state.peak.nextIsPeak ? `即将进入峰时计价(${time})` : `即将进入谷时计价(${time})`
684
+ const text = state.peak.nextIsPeak ? t('peakSoonPeak')(time) : t('peakSoonOff')(time)
545
685
  setMessage(text)
546
686
  setVisible(true)
547
687
  if (alerts.notify) {
@@ -551,7 +691,7 @@ body[data-ds-dark-theme] .dsh-cost-pop {
551
691
  if (!visible) return null
552
692
  return createPortal(h('div', { className: 'dsh-cost-alert' },
553
693
  h('span', null, message),
554
- h('button', { type: 'button', onClick: () => setVisible(false) }, '知道了')), document.body)
694
+ h('button', { type: 'button', onClick: () => setVisible(false) }, t('gotIt'))), document.body)
555
695
  }
556
696
 
557
697
  /* -- 动作行费用按钮:锚定在官方「用量」按钮旁,弹卡同官方样式 ------- */
@@ -590,7 +730,7 @@ body[data-ds-dark-theme] .dsh-cost-pop {
590
730
  if (!from || !to) return
591
731
  const pop = document.createElement('div')
592
732
  pop.className = 'dsh-cost-pop'
593
- pop.textContent = '加载中…'
733
+ pop.textContent = t('loading')
594
734
  document.body.appendChild(pop)
595
735
  usageAnchors.pop = pop
596
736
  usageAnchors.anchor = anchor
@@ -608,7 +748,7 @@ body[data-ds-dark-theme] .dsh-cost-pop {
608
748
  const head = document.createElement('div')
609
749
  head.className = 'dsh-cost-pop-head'
610
750
  const title = document.createElement('span')
611
- title.textContent = '本轮费用'
751
+ title.textContent = t('turnCostPop')
612
752
  const total = document.createElement('span')
613
753
  total.className = 'dsh-cost-pop-num'
614
754
  total.textContent = value.found ? moneyOf(latestState, value.cost) : '—'
@@ -619,7 +759,7 @@ body[data-ds-dark-theme] .dsh-cost-pop {
619
759
  const line = document.createElement('div')
620
760
  line.className = 'dsh-cost-pop-row'
621
761
  const label = document.createElement('span')
622
- label.textContent = `${String(row.model ?? '').replace(/^deepseek-/, '')} · ${TURN_BUCKET_NAMES[row.bucket] ?? row.bucket}`
762
+ label.textContent = `${String(row.model ?? '').replace(/^deepseek-/, '')} · ${turnBucketName(row.bucket)}`
623
763
  const numEl = document.createElement('span')
624
764
  numEl.className = 'dsh-cost-pop-num'
625
765
  numEl.textContent = `${Number(row.tokens).toLocaleString()} tok · ${moneyOf(latestState, row.cost)}`
@@ -630,7 +770,7 @@ body[data-ds-dark-theme] .dsh-cost-pop {
630
770
  if (!value.found) {
631
771
  const empty = document.createElement('div')
632
772
  empty.className = 'dsh-cost-pop-row'
633
- empty.textContent = '该回合暂无用量记录'
773
+ empty.textContent = t('turnEmpty')
634
774
  pop.appendChild(empty)
635
775
  }
636
776
  })
@@ -652,7 +792,7 @@ body[data-ds-dark-theme] .dsh-cost-pop {
652
792
  const anchor = document.createElement('button')
653
793
  anchor.className = 'dsh-cost-usage-anchor'
654
794
  anchor.type = 'button'
655
- anchor.textContent = '费用 —'
795
+ anchor.textContent = `${t('turnCost')} —`
656
796
  const from = details.getAttribute('data-turn-from') ?? ''
657
797
  const to = details.getAttribute('data-turn-to') ?? ''
658
798
  anchor.dataset.turnFrom = from
@@ -673,10 +813,10 @@ body[data-ds-dark-theme] .dsh-cost-pop {
673
813
  .then((res) => (res.ok ? res.json() : null))
674
814
  .then((value) => {
675
815
  if (value?.found && anchor.isConnected) {
676
- anchor.textContent = `费用 ${moneyOf(latestState, value.cost)}`
677
- anchor.title = '本轮费用明细'
816
+ anchor.textContent = `${t('turnCost')} ${moneyOf(latestState, value.cost)}`
817
+ anchor.title = t('turnCostTitle')
678
818
  } else if (anchor.isConnected) {
679
- anchor.textContent = '费用 —'
819
+ anchor.textContent = `${t('turnCost')} —`
680
820
  }
681
821
  })
682
822
  .catch(() => {})
@@ -707,10 +847,10 @@ body[data-ds-dark-theme] .dsh-cost-pop {
707
847
  if (!budget) return null
708
848
  const pct = Math.min(100, Math.max(0, budget.usedPct * 100))
709
849
  const tier = budget.usedPct >= 1 ? 'over' : budget.usedPct >= 0.8 ? 'warn' : ''
710
- const periodLabel = budget.period === 'day' ? '今日' : budget.period === 'month' ? '本月' : '累计'
850
+ const periodLabel = t(budget.period === 'day' ? 'budgetPeriodDay' : budget.period === 'month' ? 'budgetPeriodMonth' : 'budgetPeriodAll')
711
851
  return h('div', { className: 'dsh-cost-budget' },
712
852
  h('div', { className: 'dsh-cost-budget-head' },
713
- h('span', null, `预算(${periodLabel})`),
853
+ h('span', null, `${t('budget')} · ${periodLabel}`),
714
854
  h('span', null, `¥${Number(budget.used).toFixed(2)} / ¥${Number(budget.amount).toFixed(2)} · ${(budget.usedPct * 100).toFixed(0)}%`)),
715
855
  h('div', { className: 'dsh-cost-bar' }, h('div', { className: `dsh-cost-fill ${tier}`, style: { width: `${pct}%` } })))
716
856
  }
@@ -751,7 +891,8 @@ body[data-ds-dark-theme] .dsh-cost-pop {
751
891
 
752
892
  /* -- 状态行费用 pill:累计费用,点开看每笔调用(含提示词增强) ------ */
753
893
 
754
- const SOURCE_LABEL = { chat: '对话', enhance: '提示词增强', followup: '跟进建议' }
894
+ const SOURCE_KEYS = { chat: 'srcChat', enhance: 'srcEnhance', followup: 'srcFollowup' }
895
+ const sourceLabel = (key) => t(SOURCE_KEYS[key] ?? key)
755
896
  let overviewPop = null
756
897
 
757
898
  function closeOverviewPop(event) {
@@ -772,7 +913,7 @@ body[data-ds-dark-theme] .dsh-cost-pop {
772
913
  const head = document.createElement('div')
773
914
  head.className = 'dsh-cost-pop-head'
774
915
  const title = document.createElement('span')
775
- title.textContent = '本对话费用'
916
+ title.textContent = t('sessionCost')
776
917
  const sum = document.createElement('span')
777
918
  sum.textContent = moneyOf(state, data.total?.cost ?? 0)
778
919
  head.append(title, sum)
@@ -783,7 +924,7 @@ body[data-ds-dark-theme] .dsh-cost-pop {
783
924
  balRow.className = 'dsh-cost-src'
784
925
  const balName = document.createElement('span')
785
926
  balName.className = 'name'
786
- balName.textContent = '官方余额'
927
+ balName.textContent = t('officialBalance')
787
928
  const balNum = document.createElement('span')
788
929
  balNum.className = 'num'
789
930
  balNum.textContent = '…'
@@ -809,10 +950,10 @@ body[data-ds-dark-theme] .dsh-cost-pop {
809
950
  row.className = 'dsh-cost-src'
810
951
  const name = document.createElement('span')
811
952
  name.className = 'name'
812
- name.textContent = SOURCE_LABEL[key] ?? key
953
+ name.textContent = sourceLabel(key)
813
954
  const cnt = document.createElement('span')
814
955
  cnt.className = 'cnt'
815
- cnt.textContent = entry.calls + ' 次'
956
+ cnt.textContent = `${entry.calls}${t('callTimes')}`
816
957
  const num = document.createElement('span')
817
958
  num.className = 'num'
818
959
  num.textContent = moneyOf(state, entry.cost)
@@ -824,7 +965,7 @@ body[data-ds-dark-theme] .dsh-cost-pop {
824
965
  sep.className = 'dsh-cost-pop-head'
825
966
  sep.style.marginTop = '8px'
826
967
  const sepTitle = document.createElement('span')
827
- sepTitle.textContent = '最近调用'
968
+ sepTitle.textContent = t('recentCalls')
828
969
  sep.appendChild(sepTitle)
829
970
  box.appendChild(sep)
830
971
 
@@ -833,10 +974,10 @@ body[data-ds-dark-theme] .dsh-cost-pop {
833
974
  row.className = 'dsh-cost-src'
834
975
  const time = document.createElement('span')
835
976
  time.className = 'cnt'
836
- time.textContent = new Date(call.at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
977
+ time.textContent = new Date(call.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
837
978
  const name = document.createElement('span')
838
979
  name.className = 'name'
839
- name.textContent = (SOURCE_LABEL[call.source] ?? call.source) + ' · ' + (call.model || '—')
980
+ name.textContent = sourceLabel(call.source) + ' · ' + (call.model || '—')
840
981
  const num = document.createElement('span')
841
982
  num.className = 'num'
842
983
  num.textContent = moneyOf(state, call.cost)
@@ -917,7 +1058,7 @@ body[data-ds-dark-theme] .dsh-cost-pop {
917
1058
  type: 'button',
918
1059
  className: 'dsh-cost-pill',
919
1060
  'aria-expanded': open ? 'true' : 'false',
920
- title: '费用明细',
1061
+ title: t('costDetail'),
921
1062
  // 数值全部来自 dsh 的状态行规范:font-size 12px / line-height 20px /
922
1063
  // 文字色 --dsw-alias-label-tertiary(暗色 rgb(173,178,184)) /
923
1064
  // padding 1px 8px / 圆角 --dsw-radius-sm(8px)。不用 inherit,宿主那层
@@ -948,6 +1089,254 @@ body[data-ds-dark-theme] .dsh-cost-pop {
948
1089
  }, amount))
949
1090
  }
950
1091
 
1092
+ /* -- 用量热力图(GitHub 贡献图样式:周列 × 星期行,官方品牌色色阶) -- */
1093
+
1094
+ const HEAT_LEVEL_MIX = [0, 28, 52, 76, 100]
1095
+ const heatLevelColor = (level) => level > 0
1096
+ ? `color-mix(in srgb, var(--dsw-alias-brand-primary) ${HEAT_LEVEL_MIX[level]}%, transparent)`
1097
+ : ''
1098
+
1099
+ function HeatmapCard() {
1100
+ useCostState() // 轮询触发重渲染,让热力图跟着账本更新
1101
+ const [data, setData] = React.useState(null)
1102
+ const [metric, setMetric] = React.useState(() => {
1103
+ try { return localStorage.getItem('dsh-cost-heat-metric') === 'calls' || localStorage.getItem('dsh-cost-heat-metric') === 'tokens' ? localStorage.getItem('dsh-cost-heat-metric') : 'cost' } catch { return 'cost' }
1104
+ })
1105
+ React.useEffect(() => {
1106
+ let alive = true
1107
+ let lastAt = 0
1108
+ const load = (force) => {
1109
+ const now = Date.now()
1110
+ if (!force && now - lastAt < 60_000) return
1111
+ lastAt = now
1112
+ fetch(`${ENDPOINT}/heatmap?days=182`, { cache: 'no-store' })
1113
+ .then((res) => (res.ok ? res.json() : null))
1114
+ .then((value) => { if (alive && value && Array.isArray(value.days)) setData(value) })
1115
+ .catch(() => {})
1116
+ }
1117
+ load(true)
1118
+ const timerId = setInterval(() => load(false), POLL_MS * 4)
1119
+ return () => { alive = false; clearInterval(timerId) }
1120
+ }, [])
1121
+ const pickMetric = (value) => {
1122
+ setMetric(value)
1123
+ try { localStorage.setItem('dsh-cost-heat-metric', value) } catch { /* ignore */ }
1124
+ }
1125
+ if (!data || !Array.isArray(data.days) || data.days.length === 0) {
1126
+ return h('div', { className: 'dsh-cost-heat' },
1127
+ h('div', { className: 'dsh-cost-heat-head' }, h('span', { className: 'dsh-cost-heat-title' }, t('heatTitle'))),
1128
+ h('div', { className: 'dsh-cost-empty' }, t('heatEmpty')))
1129
+ }
1130
+ const state = latestState
1131
+ const metricOf = (day) => (metric === 'calls' ? day.calls ?? 0 : metric === 'tokens' ? day.tokens ?? 0 : day.cost ?? 0)
1132
+ const metricText = (day) => metric === 'calls'
1133
+ ? `${day.calls ?? 0} ${t('callsUnit')}`
1134
+ : metric === 'tokens' ? `${(day.tokens ?? 0).toLocaleString()} tok` : moneyOf(state, day.cost ?? 0)
1135
+ const byDate = new Map(data.days.map((day) => [day.date, day]))
1136
+ // 182 天窗口对齐周一(0=周日 → getDay()==0 时前推 6 天)。
1137
+ const end = new Date()
1138
+ end.setHours(12, 0, 0, 0)
1139
+ const start = new Date(end.getTime() - 181 * 86400000)
1140
+ start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
1141
+ const columns = []
1142
+ let max = 0
1143
+ const cursor = new Date(start)
1144
+ while (cursor <= end) {
1145
+ const col = []
1146
+ for (let i = 0; i < 7 && cursor <= end; i += 1) {
1147
+ const key = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`
1148
+ const day = byDate.get(key) ?? { date: key, cost: 0, calls: 0, tokens: 0 }
1149
+ max = Math.max(max, metricOf(day))
1150
+ col.push(day)
1151
+ cursor.setDate(cursor.getDate() + 1)
1152
+ }
1153
+ columns.push(col)
1154
+ // 补齐当前周剩余(未来日期 → 空白占位,保持 7 行对齐)。
1155
+ while (col.length < 7) col.push(null)
1156
+ }
1157
+ const levelOf = (value) => {
1158
+ if (!(value > 0) || !(max > 0)) return 0
1159
+ const ratio = value / max
1160
+ return ratio > 0.75 ? 4 : ratio > 0.5 ? 3 : ratio > 0.25 ? 2 : 1
1161
+ }
1162
+ const weekLabels = lang === 'zh' ? ['一', '三', '五'] : ['M', 'W', 'F']
1163
+ const months = columns.map((col, index) => {
1164
+ const first = col.find(Boolean)
1165
+ if (!first) return ''
1166
+ const month = Number(first.date.slice(5, 7))
1167
+ const prev = index > 0 ? columns[index - 1].find(Boolean) : null
1168
+ return prev && Number(prev.date.slice(5, 7)) === month ? '' : String(month)
1169
+ })
1170
+ return h('div', { className: 'dsh-cost-heat' },
1171
+ h('div', { className: 'dsh-cost-heat-head' },
1172
+ h('span', { className: 'dsh-cost-heat-title' }, t('heatTitle')),
1173
+ h('div', { className: 'dsh-cost-heat-toggle' },
1174
+ ['cost', 'calls', 'tokens'].map((value) => h('button', {
1175
+ type: 'button',
1176
+ key: value,
1177
+ 'aria-pressed': metric === value ? 'true' : 'false',
1178
+ onClick: () => pickMetric(value),
1179
+ }, value === 'cost' ? t('heatCost') : value === 'calls' ? t('heatCalls') : t('heatTokens'))))),
1180
+ h('div', { className: 'dsh-cost-heat-months' },
1181
+ months.map((label, index) => h('span', { key: index }, label))),
1182
+ h('div', { style: { display: 'flex', gap: '4px' } },
1183
+ h('div', { className: 'dsh-cost-heat-weeks' },
1184
+ [0, 2, 4].map((row, idx) => h('span', {
1185
+ key: row,
1186
+ style: { display: 'block', height: '11px', lineHeight: '11px', marginTop: idx === 0 ? '0' : '17px' },
1187
+ }, weekLabels[idx]))),
1188
+ h('div', { className: 'dsh-cost-heat-grid' },
1189
+ columns.map((col, colIndex) => h('div', { className: 'dsh-cost-heat-col', key: col[0]?.date ?? colIndex },
1190
+ col.map((day, rowIndex) => {
1191
+ if (!day) return h('div', { className: 'dsh-cost-heat-cell blank', key: `blank-${rowIndex}` })
1192
+ const level = levelOf(metricOf(day))
1193
+ return h('div', {
1194
+ className: 'dsh-cost-heat-cell',
1195
+ key: day.date,
1196
+ style: level > 0 ? { background: heatLevelColor(level) } : undefined,
1197
+ title: `${day.date} · ${metricText(day)}`,
1198
+ })
1199
+ }))))),
1200
+ h('div', { className: 'dsh-cost-heat-legend' },
1201
+ t('heatLess'),
1202
+ HEAT_LEVEL_MIX.map((_, level) => h('div', {
1203
+ className: 'dsh-cost-heat-cell',
1204
+ key: level,
1205
+ style: level > 0 ? { background: heatLevelColor(level) } : undefined,
1206
+ })),
1207
+ t('heatMore')))
1208
+ }
1209
+
1210
+ /* -- Coding Plan 配额面板(订阅额度适配器,Key 只存本机) ---------- */
1211
+
1212
+ const CODING_PLAN_LIST = ['anthropic', 'zai', 'kimi', 'minimax', 'openrouter', 'siliconflow', 'commandcode', 'clinepass']
1213
+ const CODING_PLAN_LABEL_KEYS = {
1214
+ anthropic: 'planAnthropic', zai: 'planZai', minimax: 'planMinimax', kimi: 'planKimi',
1215
+ openrouter: 'planOpenrouter', siliconflow: 'planSiliconflow', commandcode: 'planCommandcode', clinepass: 'planClinepass',
1216
+ }
1217
+ const PLAN_WINDOW_KEYS = {
1218
+ fiveHour: 'winFiveHour', '5h': 'winFiveHour', weekly: 'winWeekly', '7d': 'winWeekly',
1219
+ monthly: 'winMonthly', month: 'winMonthly', balance: 'winBalance', credits: 'winCredits',
1220
+ plan: 'winPlan', compensation: 'winCompensation', current: 'winCurrent',
1221
+ }
1222
+ const planWindowName = (name) => t(PLAN_WINDOW_KEYS[name] ?? name)
1223
+
1224
+ function CodingPlansCard() {
1225
+ const [open, setOpen] = React.useState(() => {
1226
+ try { return localStorage.getItem('dsh-cost-plans-open') === '1' } catch { return false }
1227
+ })
1228
+ const [providers, setProviders] = React.useState(null)
1229
+ const [drafts, setDrafts] = React.useState({})
1230
+ const [notes, setNotes] = React.useState({})
1231
+ const load = React.useCallback(() => {
1232
+ fetch(`${ENDPOINT}/coding-plans`, { cache: 'no-store' })
1233
+ .then((res) => (res.ok ? res.json() : null))
1234
+ .then((value) => { if (value && typeof value.providers === 'object') setProviders(value.providers) })
1235
+ .catch(() => {})
1236
+ }, [])
1237
+ React.useEffect(() => {
1238
+ if (!open) return undefined
1239
+ load()
1240
+ const timerId = setInterval(load, 60_000)
1241
+ return () => clearInterval(timerId)
1242
+ }, [open, load])
1243
+ const toggle = () => {
1244
+ const next = !open
1245
+ setOpen(next)
1246
+ try { localStorage.setItem('dsh-cost-plans-open', next ? '1' : '0') } catch { /* ignore */ }
1247
+ }
1248
+ const save = async (id) => {
1249
+ const key = String(drafts[id] ?? '').trim()
1250
+ if (!key) return
1251
+ try {
1252
+ await fetch(`${ENDPOINT}/coding-plans/config`, {
1253
+ method: 'POST',
1254
+ headers: { 'content-type': 'application/json' },
1255
+ body: JSON.stringify({ provider: id, key }),
1256
+ })
1257
+ setNotes((m) => ({ ...m, [id]: t('plansSaved') }))
1258
+ setDrafts((m) => ({ ...m, [id]: '' }))
1259
+ await fetch(`${ENDPOINT}/coding-plans/refresh`, {
1260
+ method: 'POST',
1261
+ headers: { 'content-type': 'application/json' },
1262
+ body: JSON.stringify({ provider: id }),
1263
+ })
1264
+ } catch { /* 下轮轮询兜底 */ }
1265
+ load()
1266
+ }
1267
+ const refreshAll = async () => {
1268
+ try {
1269
+ await fetch(`${ENDPOINT}/coding-plans/refresh`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
1270
+ } catch { /* ignore */ }
1271
+ load()
1272
+ }
1273
+ const resetAtText = (iso) => {
1274
+ const ms = Date.parse(iso)
1275
+ return Number.isFinite(ms)
1276
+ ? ` · ${t('winResets')} ${new Date(ms).toLocaleString([], { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}`
1277
+ : ''
1278
+ }
1279
+ return h('div', { className: 'dsh-cost-plans' },
1280
+ h('div', { className: 'dsh-cost-plans-head' },
1281
+ h('span', { className: 'dsh-cost-plans-caret' }, open ? '▾' : '▸'),
1282
+ h('span', { className: 'dsh-cost-plans-title', onClick: toggle }, t('plansTitle')),
1283
+ open && providers != null
1284
+ ? h('button', { type: 'button', className: 'dsh-cost-plan-btn', onClick: () => { void refreshAll() } }, t('plansRefreshAll'))
1285
+ : null),
1286
+ open
1287
+ ? h('div', { className: 'dsh-cost-plans-hint' }, t('plansHint'))
1288
+ : null,
1289
+ open
1290
+ ? CODING_PLAN_LIST.map((id) => {
1291
+ const info = providers?.[id] ?? { hasKey: false }
1292
+ const windows = info.windows ?? null
1293
+ return h('div', { className: 'dsh-cost-plan', key: id },
1294
+ h('div', { className: 'dsh-cost-plan-row' },
1295
+ h('span', { className: 'dsh-cost-plan-name', title: t(CODING_PLAN_LABEL_KEYS[id]) }, t(CODING_PLAN_LABEL_KEYS[id])),
1296
+ h('input', {
1297
+ className: 'dsh-cost-plan-input',
1298
+ type: 'password',
1299
+ autoComplete: 'off',
1300
+ placeholder: info.hasKey ? `••••••(${t('plansConfigured')})` : 'API Key',
1301
+ value: drafts[id] ?? '',
1302
+ onChange: (event) => setDrafts((m) => ({ ...m, [id]: event.target.value })),
1303
+ onKeyDown: (event) => { if (event.key === 'Enter') void save(id) },
1304
+ }),
1305
+ h('button', {
1306
+ type: 'button',
1307
+ className: 'dsh-cost-plan-btn',
1308
+ disabled: !String(drafts[id] ?? '').trim(),
1309
+ onClick: () => { void save(id) },
1310
+ }, t('plansSave')),
1311
+ notes[id]
1312
+ ? h('span', { className: 'dsh-cost-plan-btn', style: { background: 'transparent', cursor: 'default' } }, notes[id])
1313
+ : null),
1314
+ !info.hasKey
1315
+ ? null
1316
+ : info.error
1317
+ ? h('div', { className: 'dsh-cost-plan-err' }, `${t('plansError')}: ${info.error}`)
1318
+ : windows && Object.keys(windows).length > 0
1319
+ ? Object.entries(windows).map(([name, win]) => {
1320
+ const bar = win.unlimited
1321
+ ? null
1322
+ : h('div', { className: 'dsh-cost-plan-bar' },
1323
+ h('div', {
1324
+ className: 'dsh-cost-plan-fill' + (Number(win.percent) >= 80 ? ' hot' : ''),
1325
+ style: { width: `${Math.min(100, Math.max(0, Number(win.percent) || 0))}%` },
1326
+ }))
1327
+ const pct = win.unlimited
1328
+ ? t('winUnlimited')
1329
+ : `${(Number(win.percent) || 0).toFixed(1)}%${resetAtText(win.resetsAt)}`
1330
+ return h('div', { className: 'dsh-cost-plan-win', key: name },
1331
+ h('span', { className: 'dsh-cost-plan-winname', title: name }, planWindowName(name)),
1332
+ bar ?? h('span', { className: 'dsh-cost-plan-winname', style: { flex: 1 } }, String(win.text ?? '')),
1333
+ h('span', { className: 'dsh-cost-plan-pct' }, pct))
1334
+ })
1335
+ : h('div', { className: 'dsh-cost-plan-err' }, '…'))
1336
+ })
1337
+ : null)
1338
+ }
1339
+
951
1340
  function CostSection() {
952
1341
  const state = useCostState()
953
1342
  const forecast = useForecast()
@@ -959,40 +1348,40 @@ body[data-ds-dark-theme] .dsh-cost-pop {
959
1348
  const forecastCard = forecast && forecast.basis === 'recent'
960
1349
  ? h('div', { className: 'dsh-cost-forecast', 'data-over': String(!!overBudget) },
961
1350
  h('div', { className: 'dsh-cost-forecast-head' },
962
- h('span', { className: 'dsh-cost-card-title' }, '月底预计'),
1351
+ h('span', { className: 'dsh-cost-card-title' }, t('monthForecast')),
963
1352
  h('span', { className: 'dsh-cost-forecast-value' }, moneyOf(state, forecast.projected))),
964
1353
  h('div', { className: 'dsh-cost-forecast-sub' },
965
- `已花 ${moneyOf(state, forecast.monthCost)} · 日均 ${moneyOf(state, forecast.avgDaily)} · 剩 ${forecast.daysLeft} 天${overBudget ? ' · 超出预算' : ''}`))
1354
+ `${t('spent')}${moneyOf(state, forecast.monthCost)} · ${t('dailyAvg')}${moneyOf(state, forecast.avgDaily)} · ${t('daysLeft')(forecast.daysLeft)}${overBudget ? ` · ${t('overBudget')}` : ''}`))
966
1355
  : forecast && forecast.basis === 'insufficient'
967
1356
  ? h('div', { className: 'dsh-cost-forecast' },
968
1357
  h('div', { className: 'dsh-cost-forecast-sub' },
969
- `预测需要至少 3 天完整数据(当前 ${forecast.sampleDays ?? 0} 天)`))
1358
+ t('forecastNeedDays')(forecast.sampleDays ?? 0)))
970
1359
  : null
971
1360
  const cards = h('div', { className: 'dsh-cost-cards' },
972
- h(CostCard, { title: '今日', row: state?.today, state }),
973
- h(CostCard, { title: '本月', row: state?.month, state }),
974
- h(CostCard, { title: '累计', row: state?.total, state }))
1361
+ h(CostCard, { title: t('today'), row: state?.today, state }),
1362
+ h(CostCard, { title: t('thisMonth'), row: state?.month, state }),
1363
+ h(CostCard, { title: t('total'), row: state?.total, state }))
975
1364
  const recent = Array.isArray(state?.recent) && state.recent.length > 0
976
1365
  ? h('div', { className: 'dsh-cost-table' },
977
1366
  state.recent.slice().reverse().map((day) => h('div', { className: 'dsh-cost-row', key: day.date },
978
1367
  h('span', { className: 'dsh-cost-dim' }, day.date.slice(5)),
979
- h('span', { className: 'dsh-cost-grow' }, `${day.calls} 次调用`),
1368
+ h('span', { className: 'dsh-cost-grow' }, `${day.calls} ${t('callsUnit')}`),
980
1369
  h('span', { className: 'dsh-cost-num' }, moneyOf(state, day.cost)))))
981
- : h('div', { className: 'dsh-cost-table' }, h('div', { className: 'dsh-cost-empty' }, '暂无记录'))
1370
+ : h('div', { className: 'dsh-cost-table' }, h('div', { className: 'dsh-cost-empty' }, t('noRecords')))
982
1371
  const sessions = Array.isArray(state?.sessions) && state.sessions.length > 0
983
1372
  ? h('div', { className: 'dsh-cost-table' },
984
1373
  state.sessions.slice(0, 10).map((session) => h('div', { className: 'dsh-cost-row', key: `${session.date}-${session.id}` },
985
1374
  h('span', { className: 'dsh-cost-dim' }, new Date(session.at || session.date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })),
986
1375
  h('span', { className: 'dsh-cost-grow' }, String(session.id).replace(/^session-/, '').slice(0, 8)),
987
- h('span', { className: 'dsh-cost-grow' }, `${session.calls} 次`),
1376
+ h('span', { className: 'dsh-cost-grow' }, `${session.calls}${t('callTimes')}`),
988
1377
  h('span', { className: 'dsh-cost-num' }, moneyOf(state, session.cost)))))
989
1378
  : null
990
1379
  const footerBits = []
991
1380
  if (state?.peak) {
992
- footerBits.push(h('span', { className: 'dsh-cost-badge', key: 'peak' }, state.peak.isPeak ? '峰时计价' : '谷时计价'))
1381
+ footerBits.push(h('span', { className: 'dsh-cost-badge', key: 'peak' }, state.peak.isPeak ? t('peakBadge') : t('offPeakBadge')))
993
1382
  }
994
1383
  if (Array.isArray(state?.unmatched) && state.unmatched.length > 0) {
995
- footerBits.push(h('span', { className: 'dsh-cost-badge', key: 'unmatched', title: state.unmatched.join(', ') }, `${state.unmatched.length} 个模型按默认档计费`))
1384
+ footerBits.push(h('span', { className: 'dsh-cost-badge', key: 'unmatched', title: state.unmatched.join(', ') }, `${state.unmatched.length} ${t('unmatchedModels')}`))
996
1385
  }
997
1386
  const downloadExport = (format) => {
998
1387
  const link = document.createElement('a')
@@ -1002,8 +1391,8 @@ body[data-ds-dark-theme] .dsh-cost-pop {
1002
1391
  link.click()
1003
1392
  link.remove()
1004
1393
  }
1005
- footerBits.push(h('button', { className: 'dsh-cost-badge action', key: 'export-json', title: '导出全部费用记录(JSON)', onClick: () => downloadExport('json') }, '导出 JSON'))
1006
- footerBits.push(h('button', { className: 'dsh-cost-badge action', key: 'export-csv', title: '导出日汇总(CSV)', onClick: () => downloadExport('csv') }, '导出 CSV'))
1394
+ footerBits.push(h('button', { className: 'dsh-cost-badge action', key: 'export-json', title: t('exportJsonTitle'), onClick: () => downloadExport('json') }, t('exportJson')))
1395
+ footerBits.push(h('button', { className: 'dsh-cost-badge action', key: 'export-csv', title: t('exportCsvTitle'), onClick: () => downloadExport('csv') }, t('exportCsv')))
1007
1396
  // 官方价格同步 + 汇率刷新(点击后按钮短暂显示结果,再自动复原)。
1008
1397
  const [syncNote, setSyncNote] = React.useState('')
1009
1398
  React.useEffect(() => {
@@ -1016,22 +1405,35 @@ body[data-ds-dark-theme] .dsh-cost-pop {
1016
1405
  const res = await fetch(`${ENDPOINT}/${endpoint}`, { method: 'POST' })
1017
1406
  const data = await res.json().catch(() => null)
1018
1407
  if (res.ok && data?.ok) setSyncNote(okLabel)
1019
- else setSyncNote(`失败:${data?.error ?? res.status}`)
1408
+ else setSyncNote(`${t('failed')}: ${data?.error ?? res.status}`)
1020
1409
  if (res.ok) void pollOnce()
1021
- } catch (error) { setSyncNote(`失败:${String(error?.message ?? error).slice(0, 60)}`) }
1410
+ } catch (error) { setSyncNote(`${t('failed')}: ${String(error?.message ?? error).slice(0, 60)}`) }
1022
1411
  }
1023
1412
  footerBits.push(h('button', {
1024
1413
  className: 'dsh-cost-badge action',
1025
1414
  key: 'sync-prices',
1026
- title: '从官方定价页同步现行价格(峰谷两档,历史账目不受影响)',
1027
- onClick: () => { void postSync('fetch-prices', '价格已同步') },
1028
- }, '同步官方价格'))
1415
+ title: t('syncPricesTitle'),
1416
+ onClick: () => { void postSync('fetch-prices', t('priceSynced')) },
1417
+ }, t('syncPrices')))
1029
1418
  footerBits.push(h('button', {
1030
1419
  className: 'dsh-cost-badge action',
1031
1420
  key: 'sync-rate',
1032
- title: '立即刷新 USD→CNY 汇率(平时每小时自动刷新)',
1033
- onClick: () => { void postSync('fetch-rate', '汇率已刷新') },
1034
- }, '刷新汇率'))
1421
+ title: t('refreshRateTitle'),
1422
+ onClick: () => { void postSync('fetch-rate', t('rateRefreshed')) },
1423
+ }, t('refreshRate')))
1424
+ // 界面语言:中文 / English 两枚徽标按钮,立即生效(宿主注册项刷新后生效)。
1425
+ const langButtons = [
1426
+ ['zh', '中文'],
1427
+ ['en', 'English'],
1428
+ ].map(([value, label]) => h('button', {
1429
+ className: 'dsh-cost-badge action',
1430
+ key: `lang-${value}`,
1431
+ 'aria-pressed': lang === value ? 'true' : 'false',
1432
+ title: t('langHint'),
1433
+ style: lang === value ? { color: 'var(--dsw-alias-label-primary)', fontWeight: 500 } : undefined,
1434
+ onClick: () => setLang(value),
1435
+ }, label))
1436
+ footerBits.push(h('span', { className: 'dsh-cost-badge', key: 'lang-group', style: { gap: '6px' } }, langButtons))
1035
1437
  if (syncNote) {
1036
1438
  footerBits.push(h('span', { className: 'dsh-cost-badge', key: 'sync-note' }, syncNote))
1037
1439
  }
@@ -1039,10 +1441,12 @@ body[data-ds-dark-theme] .dsh-cost-pop {
1039
1441
  h(BudgetCard, { state }),
1040
1442
  cards,
1041
1443
  forecastCard,
1444
+ h(HeatmapCard, null),
1042
1445
  recent,
1043
1446
  sessions,
1044
1447
  h(RecentCalls, { state }),
1045
- h('div', { style: { display: 'flex', gap: '6px' } }, footerBits))
1448
+ h(CodingPlansCard, null),
1449
+ h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap' } }, footerBits))
1046
1450
  }
1047
1451
 
1048
1452
  /* -- 装配 ---------------------------------------------------------- */
@@ -1055,7 +1459,7 @@ body[data-ds-dark-theme] .dsh-cost-pop {
1055
1459
  startSessionAnchor()
1056
1460
  startUsageAnchors()
1057
1461
  sessionWatchers.add(renderSessionAnchor)
1058
- console.info('[dsh-cost] client 0.8.0 active')
1462
+ console.info('[dsh-cost] client 0.10.0 active')
1059
1463
  // 只当 sessionId 感知器(渲染 null),费用本体锚定在宿主状态行末尾。
1060
1464
  ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
1061
1465
  name: 'conversation.input.dock',
@@ -1089,7 +1493,7 @@ body[data-ds-dark-theme] .dsh-cost-pop {
1089
1493
  name: 'settings.section',
1090
1494
  id: 'dsh-cost',
1091
1495
  order: 30,
1092
- label: '费用',
1496
+ label: t('sectionLabel'),
1093
1497
  }, CostSection))
1094
1498
  },
1095
1499
  }
@@ -4329,171 +4733,1197 @@ svg[viewBox="0 0 640 40"] { visibility: hidden !important; }
4329
4733
  }
4330
4734
  }
4331
4735
 
4332
- /* dsh-superinput · prompts 模块客户端:模板库入口 + 面板。 */
4333
- function createPromptsClient(require) {
4334
- const React = require('react')
4335
- const { createPortal } = require('react-dom')
4336
- const h = React.createElement
4736
+ /* 由 scripts/build-suite.mjs 从 dsh-compaction-control/client.js 机械生成;勿手改。 */
4737
+ /**
4738
+ * dsh-compaction-control 的 web 客户端部分。
4739
+ *
4740
+ * 在输入栏右侧区(conversation.input.right,list 槽位,渲染在模型选择器
4741
+ * 左边)放一颗和内置 ContextMeter 同款几何的上下文占用圆环:
4742
+ *
4743
+ * - 数据走官方会话投影:useProjection('contextPressure') /
4744
+ * 'contextBreakdown'(token-meter 注册,session 标准 props 自动下发),
4745
+ * 不碰内部 RPC。
4746
+ * - 点开面板 = 内置面板的三段构成(系统提示/工具/对话)+ 两个新能力:
4747
+ * 1. 「立即压缩」→ POST /api/dsh-compaction-control/compact,
4748
+ * 服务端 ctx.compaction.compactNow(agent, signal)(官方 /compact
4749
+ * 的编程入口,等价免打字)。
4750
+ * 2. 自动压缩调节(auto 开关 / 触发阈值 / 保留比例)→ POST
4751
+ * /state,服务端写 profile 用户补丁层,重启后生效。
4752
+ * - hideBuiltIn(默认开):运行时把官方占用环按钮 visibility:hidden。
4753
+ * 内置圆环是 InputBar 硬编码(不在槽位里),没有官方关闭开关;识别
4754
+ * 特征收严(0.2.2)= dialog 按钮 + svg 带圆 + 文本恰为「NN%」——
4755
+ * 0.2.1 的宽泛特征在真机命中了 dock 里的未知按钮,文本白名单把误伤
4756
+ * 半径收到最小。MutationObserver 在宿主重挂时补隐藏。
4757
+ * - 落位(0.2.5 定稿):圆环直接作为 conversation.input.right 槽位内容
4758
+ * 渲染——已装 app.asar 的 InputBar 源码实锤 standardControls 里
4759
+ * renderSlot("conversation.input.right") 与 renderSlot("conversation.
4760
+ * input.model")(模型选择器)是 JSX 兄弟节点且前者在前,槽位位置即
4761
+ * 「模型选择器紧左侧」,官方契约保证,零坐标手术。0.1.6 起四轮方案
4762
+ * 全废:fixed 不占布局流必压按钮;「发送左侧最靠右下拉」会把状态行
4763
+ * 的官方环/费用按钮误当选择器(不同行的按钮在窗口坐标里也分左右);
4764
+ * 剪贴板所在 composer.dock 实际渲染在会话状态行。另:本环永远渲染
4765
+ * ——0.2.1 的「hideBuiltIn=false 时退场」门已拆(持久化值恰为 false
4766
+ * 曾把环卸成空壳占位);该开关现在只管官方环的隐藏。占用比读取改
4767
+ * `||`(projectedTokens 可为合法 0,`??` 不会回落 pressureTokens,
4768
+ * 真机出现过 0% 环而官方 17%)。
4769
+ *
4770
+ * 诊断:boot/register/crash 上报服务端 /diag,落
4771
+ * ~/.dsh/compaction-control-debug.log;window.__dshCompactionControl 供检查。
4772
+ */
4337
4773
 
4338
- const STYLE_ID = 'dsh-superinput-prompts-style'
4339
- const ENDPOINT = '/api/dsh-superinput/prompts'
4774
+ function createCompactionClient(require) {
4775
+ const React = require('react')
4776
+ const h = React.createElement
4777
+ const { useEffect, useRef, useState } = React
4340
4778
 
4341
- const CSS = `
4342
- .dsh-cp-btn {
4343
- display: inline-flex;
4344
- align-items: center;
4345
- border: none !important;
4346
- background: transparent !important;
4347
- box-shadow: none !important;
4348
- color: var(--dsw-alias-label-secondary, rgba(232, 238, 250, 0.72)) !important;
4349
- font-size: 12px;
4350
- line-height: 20px;
4351
- padding: 1px 8px;
4352
- border-radius: var(--dsw-radius-sm, 6px);
4353
- cursor: default;
4354
- user-select: none;
4779
+ // react shim 没有 createPortal;必须从 react-dom 取(内置组件同款做法)。
4780
+ let createPortal = null
4781
+ try {
4782
+ const ReactDOM = require('react-dom')
4783
+ if (typeof ReactDOM?.createPortal === 'function') createPortal = ReactDOM.createPortal
4784
+ } catch { createPortal = null }
4785
+
4786
+ const ENDPOINT = '/api/dsh-compaction-control'
4787
+ const VERSION = '0.2.5'
4788
+ /** 官方环几何 14px viewBox / 2 单位描边;纯图标无百分比文字撑视觉重量,渲染尺寸放大到 18px(描边随比例 ≈2.6px)。 */
4789
+ const RADIUS = 5.5
4790
+ const CIRCUMFERENCE = 2 * Math.PI * RADIUS
4791
+ const RING_SIZE = 18
4792
+ /** 官方面板宽度:min(264px, 100vw - 24px)。 */
4793
+ const PANEL_WIDTH = 264
4794
+
4795
+ /** 全部对齐官方 ContextMeter/菜单面板 tokens(ContextMeter.module.css 同源)。 */
4796
+ const CSS = `
4797
+ .dsh-cc-host { display: inline-flex; align-items: center; flex: none; margin: 0 2px; }
4798
+ .dsh-cc-root { display: inline-flex; }
4799
+ .dsh-cc-pending .dsh-cc-trigger { opacity: 0.65; }
4800
+ .dsh-cc-pending .dsh-cc-trigger:hover,
4801
+ .dsh-cc-pending .dsh-cc-trigger[aria-expanded='true'] { opacity: 1; }
4802
+ .dsh-cc-trigger {
4803
+ display: inline-flex; align-items: center; justify-content: center;
4804
+ flex: none; height: 28px; padding: 0 6px;
4805
+ border: none; border-radius: var(--dsw-radius-sm, 8px);
4806
+ background: transparent;
4807
+ color: var(--dsw-alias-label-secondary, rgba(232, 238, 250, 0.72));
4808
+ font-family: inherit; line-height: 20px;
4809
+ cursor: pointer;
4355
4810
  }
4356
- .dsh-cp-btn:hover { background: var(--dsw-alias-interactive-bg-hover) !important; color: var(--dsw-alias-label-primary) !important; }
4357
- .dsh-cp-panel {
4358
- position: fixed;
4359
- z-index: 1100;
4360
- box-sizing: border-box;
4361
- border: 0;
4362
- border-radius: var(--dsw-radius-lg, 12px);
4363
- background: var(--dsw-specific-menu, rgba(67, 69, 74, 0.45));
4364
- backdrop-filter: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
4365
- -webkit-backdrop-filter: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
4366
- width: min(380px, 100vw - 24px);
4367
- --dsw-elevation-stroke-color: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
4368
- box-shadow: var(--dsw-elevation-prominent, 0 0 0 0.5px var(--dsw-elevation-stroke-color), 0 3px 8px 0 rgba(0, 0, 0, 0.04), 0 0 20px 0 rgba(0, 0, 0, 0.05));
4369
- padding: 10px;
4370
- display: flex;
4371
- flex-direction: column;
4372
- gap: 8px;
4811
+ .dsh-cc-trigger:hover, .dsh-cc-trigger[aria-expanded='true'] {
4812
+ background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08));
4813
+ color: var(--dsw-alias-label-secondary, rgba(232, 238, 250, 0.72));
4373
4814
  }
4374
- .dsh-cp-head { display: flex; gap: 6px; align-items: center; }
4375
- .dsh-cp-search {
4376
- flex: 1 1 auto; min-width: 0; box-sizing: border-box;
4377
- padding: 6px 10px; font-size: 12px; line-height: 18px;
4378
- color: var(--dsw-alias-label-primary);
4379
- background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.06));
4380
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12));
4381
- border-radius: var(--dsw-radius-sm, 6px);
4382
- outline: none;
4815
+ .dsh-cc-ring-track { fill: none; stroke: var(--dsw-alias-border-l3, rgba(255, 255, 255, 0.18)); stroke-width: 2; }
4816
+ .dsh-cc-ring-fill { fill: none; stroke: var(--dsw-alias-label-secondary, rgba(232, 238, 250, 0.72)); stroke-width: 2; stroke-linecap: round; transition: stroke-dasharray 200ms ease; }
4817
+
4818
+ .dsh-cc-panel {
4819
+ position: fixed; z-index: 1100; box-sizing: border-box;
4820
+ width: min(264px, calc(100vw - 24px)); max-height: 70vh; overflow-y: auto;
4821
+ padding: 12px;
4822
+ border: 0; border-radius: 14px;
4823
+ /* 底色写死 0.94,不用 --dsw-specific-menu:Windows 默认值是 rgba(67,69,74,.45),
4824
+ 那是给带 MenuSurface 底衬的官方菜单用的;插件 portal 到 body 没有底衬,
4825
+ 45% 一旦 backdrop-filter 失效就发灰糊在背景上。0.94 取自 dsh 的 macOS 档。 */
4826
+ background: rgba(248, 249, 250, 0.94);
4827
+ backdrop-filter: blur(40px) saturate(150%);
4828
+ -webkit-backdrop-filter: blur(40px) saturate(150%);
4829
+ box-shadow:
4830
+ 0 0 0 0.5px rgba(0, 0, 0, 0.07),
4831
+ 0 3px 8px 0 rgba(0, 0, 0, 0.04),
4832
+ 0 0 20px 0 rgba(0, 0, 0, 0.05);
4833
+ font-size: 12px; line-height: 20px;
4834
+ color: rgba(28, 30, 34, 0.86);
4835
+ cursor: default;
4836
+ animation: dsh-cc-in 140ms ease-out both;
4383
4837
  }
4384
- .dsh-cp-search:focus { border-color: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.2)); }
4385
- .dsh-cp-new {
4386
- flex: 0 0 auto; width: 26px; height: 26px;
4387
- display: inline-flex; align-items: center; justify-content: center;
4388
- font-size: 16px; line-height: 1;
4389
- color: var(--dsw-alias-label-secondary);
4390
- background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.06));
4391
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12));
4392
- border-radius: var(--dsw-radius-sm, 6px);
4393
- cursor: pointer;
4838
+ body[data-ds-dark-theme] .dsh-cc-panel {
4839
+ background: rgba(48, 49, 54, 0.94);
4840
+ box-shadow:
4841
+ 0 0 0 0.5px rgba(255, 255, 255, 0.10),
4842
+ 0 3px 8px 0 rgba(0, 0, 0, 0.04),
4843
+ 0 0 20px 0 rgba(0, 0, 0, 0.05);
4844
+ color: rgba(232, 238, 250, 0.72);
4394
4845
  }
4395
- .dsh-cp-new:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
4396
- .dsh-cp-list { display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow-y: auto; }
4397
- .dsh-cp-group { font-size: 11px; line-height: 18px; color: var(--dsw-alias-label-tertiary); padding: 4px 6px 2px; }
4398
- .dsh-cp-row { display: flex; align-items: flex-start; gap: 6px; padding: 6px; border-radius: var(--dsw-radius-sm, 6px); }
4399
- .dsh-cp-row:hover { background: var(--dsw-alias-interactive-bg-hover); }
4400
- .dsh-cp-row-main { flex: 1 1 auto; min-width: 0; cursor: pointer; display: flex; flex-direction: column; gap: 2px; }
4401
- .dsh-cp-title { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4402
- .dsh-cp-uses { color: var(--dsw-alias-label-tertiary); font-size: 10px; margin-left: 4px; }
4403
- .dsh-cp-preview { font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4404
- .dsh-cp-vars { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
4405
- .dsh-cp-var { font-size: 10px; line-height: 14px; padding: 0 4px; border-radius: 3px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.08)); }
4406
- .dsh-cp-row-actions { flex: 0 0 auto; display: flex; gap: 2px; }
4407
- .dsh-cp-mini {
4408
- width: 20px; height: 20px; padding: 0;
4409
- display: inline-flex; align-items: center; justify-content: center;
4410
- font-size: 11px; line-height: 1;
4411
- color: var(--dsw-alias-label-tertiary);
4412
- background: transparent; border: none; border-radius: 4px; cursor: pointer;
4846
+ /* 只动 opacity:展开方向由内联 transform 控制,keyframes 不能碰 transform */
4847
+ @keyframes dsh-cc-in { from { opacity: 0; } to { opacity: 1; } }
4848
+ @media (prefers-reduced-motion: reduce) { .dsh-cc-panel { animation: none; } }
4849
+
4850
+ .dsh-cc-head { display: flex; align-items: center; gap: 6px; }
4851
+ .dsh-cc-head-label { color: var(--dsw-alias-label-tertiary, rgba(232, 238, 250, 0.55)); white-space: nowrap; }
4852
+ .dsh-cc-head-pct { font-weight: 500; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary, rgba(232, 238, 250, 0.9)); }
4853
+ .dsh-cc-head-fig { margin-left: auto; font-weight: 500; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary, rgba(232, 238, 250, 0.9)); }
4854
+
4855
+ .dsh-cc-bar { display: flex; gap: 1px; margin: 8px 0 8px; height: 4px; border-radius: 999px; background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08)); overflow: hidden; }
4856
+ .dsh-cc-bar-seg { flex: none; min-width: 2px; height: 100%; border-radius: 1px; background: var(--dsh-cc-tint, var(--dsw-alias-label-tertiary)); }
4857
+ .dsh-cc-tint-system { --dsh-cc-tint: var(--dsw-static-neutral-bluish-400, #9db2c8); }
4858
+ .dsh-cc-tint-tools { --dsh-cc-tint: rgb(167, 139, 250); }
4859
+ .dsh-cc-tint-messages { --dsh-cc-tint: var(--dsw-static-blue-450, #5b8def); }
4860
+
4861
+ .dsh-cc-rows { margin: 4px 0 0; }
4862
+ .dsh-cc-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 1px 0; }
4863
+ .dsh-cc-row dt { color: var(--dsw-alias-label-secondary, rgba(232, 238, 250, 0.72)); }
4864
+ .dsh-cc-row dd { margin: 0; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary, rgba(232, 238, 250, 0.9)); }
4865
+ .dsh-cc-dot { display: inline-block; margin-right: 6px; width: 8px; height: 8px; border-radius: 2px; background: var(--dsh-cc-tint); vertical-align: baseline; }
4866
+
4867
+ .dsh-cc-section { border-top: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08)); margin-top: 8px; padding-top: 6px; }
4868
+
4869
+ .dsh-cc-disclose {
4870
+ display: flex; align-items: center; justify-content: space-between;
4871
+ width: 100%; height: 28px; padding: 0 4px 0 8px;
4872
+ border: none; border-radius: var(--dsw-radius-sm, 8px);
4873
+ background: transparent;
4874
+ color: var(--dsw-alias-label-secondary, rgba(232, 238, 250, 0.72));
4875
+ font-size: 13px; line-height: 20px; font-weight: 400;
4876
+ cursor: pointer;
4413
4877
  }
4414
- .dsh-cp-mini:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.08)); }
4415
- .dsh-cp-mini.danger:hover { color: #f87171; }
4416
- .dsh-cp-mini.active { color: #3a83f7; }
4417
- .dsh-cp-empty { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); padding: 12px 6px; text-align: center; }
4418
- .dsh-cp-editor { display: flex; flex-direction: column; gap: 6px; }
4419
- .dsh-cp-input, .dsh-cp-textarea {
4420
- box-sizing: border-box; width: 100%;
4421
- padding: 6px 10px; font-size: 12px; line-height: 18px;
4422
- color: var(--dsw-alias-label-primary);
4423
- background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.06));
4424
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12));
4425
- border-radius: var(--dsw-radius-sm, 6px);
4426
- outline: none; font-family: inherit; resize: vertical;
4878
+ .dsh-cc-disclose:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08)); }
4879
+ .dsh-cc-caret { color: var(--dsw-alias-label-caption, rgba(232, 238, 250, 0.45)); font-size: 12px; }
4880
+
4881
+ .dsh-cc-compact-btn {
4882
+ width: 100%; height: 24px;
4883
+ border: 1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12));
4884
+ border-radius: var(--dsw-radius-sm, 8px);
4885
+ background: transparent;
4886
+ color: var(--dsw-alias-label-primary, rgba(232, 238, 250, 0.9));
4887
+ font-size: 12px; line-height: 20px;
4888
+ cursor: pointer;
4889
+ transition: background 120ms ease;
4427
4890
  }
4428
- .dsh-cp-textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
4429
- .dsh-cp-actions { display: flex; justify-content: flex-end; gap: 6px; }
4430
- .dsh-cp-action {
4431
- padding: 4px 12px; font-size: 12px; line-height: 18px;
4432
- color: var(--dsw-alias-label-secondary);
4433
- background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.06));
4434
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12));
4435
- border-radius: var(--dsw-radius-sm, 6px);
4891
+ .dsh-cc-compact-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08)); }
4892
+ .dsh-cc-compact-btn[disabled] { opacity: 0.5; cursor: default; }
4893
+
4894
+ .dsh-cc-fields { margin-top: 4px; }
4895
+ .dsh-cc-btnrow { display: flex; gap: 6px; margin-top: 4px; }
4896
+ .dsh-cc-save {
4897
+ flex: 1; height: 24px;
4898
+ border: 1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12));
4899
+ border-radius: var(--dsw-radius-sm, 8px);
4900
+ background: transparent;
4901
+ color: var(--dsw-alias-label-primary, rgba(232, 238, 250, 0.9));
4902
+ font-size: 12px; line-height: 20px;
4436
4903
  cursor: pointer;
4904
+ transition: background 120ms ease;
4437
4905
  }
4438
- .dsh-cp-action:hover { color: var(--dsw-alias-label-primary); }
4439
- .dsh-cp-action.primary { color: #fff; background: #3a83f7; border-color: transparent; }
4440
- .dsh-cp-action.primary:hover { background: #4f8ef9; }
4441
- .dsh-cp-selmenu { display: flex; flex-direction: row; align-items: center; gap: 2px; padding: 3px; border-radius: var(--dsw-radius-lg, 12px);
4442
- background: var(--dsw-specific-menu, rgba(67, 69, 74, 0.85));
4443
- backdrop-filter: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
4444
- --dsw-elevation-stroke-color: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
4445
- box-shadow: var(--dsw-elevation-prominent, 0 0 0 0.5px var(--dsw-elevation-stroke-color), 0 3px 8px 0 rgba(0, 0, 0, 0.1));
4906
+ .dsh-cc-save:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08)); }
4907
+ .dsh-cc-save[disabled] { opacity: 0.5; cursor: default; }
4908
+ /* 恢复默认:官方菜单行样式(ghost 行,hover 背景无描边) */
4909
+ .dsh-cc-reset {
4910
+ flex: 1; height: 24px; padding: 0 4px;
4911
+ border: none; border-radius: var(--dsw-radius-sm, 8px);
4912
+ background: transparent;
4913
+ color: var(--dsw-alias-label-secondary, rgba(232, 238, 250, 0.72));
4914
+ font-size: 12px; line-height: 20px; font-weight: 400;
4915
+ cursor: pointer;
4446
4916
  }
4447
- .dsh-cp-selitem { border: none; background: transparent; text-align: left; padding: 5px 10px; font-size: 12px; line-height: 18px;
4448
- color: var(--dsw-alias-label-primary, rgba(232, 238, 250, 0.92)); border-radius: var(--dsw-radius-sm, 6px); cursor: pointer; white-space: nowrap; }
4449
- .dsh-cp-selitem:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.07)); }
4450
- .dsh-cp-toast { padding: 5px 12px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-primary, #fff);
4451
- background: rgba(50, 50, 54, 0.95); border-radius: 8px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); }
4917
+ .dsh-cc-reset:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08)); }
4918
+ .dsh-cc-reset[disabled] { opacity: 0.5; cursor: default; }
4919
+
4920
+ .dsh-cc-field { display: flex; align-items: center; gap: 8px; padding: 1px 0; font-size: 12px; line-height: 20px; }
4921
+ .dsh-cc-field-label { flex: none; width: 56px; color: var(--dsw-alias-label-tertiary, rgba(232, 238, 250, 0.55)); }
4922
+ .dsh-cc-field-value { margin-left: auto; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary, rgba(232, 238, 250, 0.9)); min-width: 30px; text-align: right; }
4923
+ .dsh-cc-slider { flex: 1; height: 2px; accent-color: var(--dsw-static-blue-450, #5b8def); cursor: pointer; }
4924
+ .dsh-cc-check { accent-color: var(--dsw-static-blue-450, #5b8def); width: 13px; height: 13px; cursor: pointer; }
4925
+ .dsh-cc-hint { font-size: 11px; line-height: 16px; color: rgb(234, 179, 8); margin: 2px 0 4px; display: none; }
4926
+ .dsh-cc-hint-show { display: block; }
4927
+
4928
+ .dsh-cc-flash { margin-top: 6px; font-size: 11px; line-height: 16px; border-radius: var(--dsw-radius-sm, 8px); padding: 4px 8px; display: none; }
4929
+ .dsh-cc-flash-ok { display: block; background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08)); color: var(--dsw-alias-label-secondary, rgba(232, 238, 250, 0.72)); }
4930
+ .dsh-cc-flash-err { display: block; background: rgba(239, 68, 68, 0.12); color: #fca5a5; }
4452
4931
  `
4453
4932
 
4454
- function ensureStyle() {
4455
- if (typeof document === 'undefined') return
4456
- if (document.getElementById(STYLE_ID)) return
4457
- const style = document.createElement('style')
4458
- style.id = STYLE_ID
4459
- style.textContent = CSS
4460
- document.head.appendChild(style)
4461
- }
4462
-
4463
- async function api(path, body) {
4464
- try {
4465
- const res = await fetch(`${ENDPOINT}${path}`, body
4466
- ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
4467
- : { cache: 'no-store' })
4468
- return res.ok ? res.json() : null
4469
- } catch { return null }
4470
- }
4471
-
4472
- /** 异常留痕:走 prompts /diag(0.4.0 起套件统一通道,落 superinput-debug.log)。 */
4473
- function report(message) {
4474
- try {
4475
- void fetch('/api/dsh-superinput/prompts/diag', {
4476
- method: 'POST',
4477
- headers: { 'content-type': 'application/json' },
4478
- body: JSON.stringify({ message: `prompts: ${String(message).slice(0, 300)}` }),
4479
- }).catch(() => {})
4480
- } catch { /* ignore */ }
4481
- }
4933
+ const STYLE_ID = 'dsh-compaction-control-style'
4482
4934
 
4483
- /** 找出正文里的 {{变量}},去重保序。 */
4484
- function varsOf(text) {
4485
- const found = []
4486
- const re = /\{\{\s*([a-zA-Z_$][\w$]*)\s*\}\}/g
4487
- let match
4488
- while ((match = re.exec(String(text ?? '')))) {
4489
- if (!found.includes(match[1])) found.push(match[1])
4935
+ function ensureStyle() {
4936
+ if (document.getElementById(STYLE_ID)) return
4937
+ const style = document.createElement('style')
4938
+ style.id = STYLE_ID
4939
+ style.textContent = CSS
4940
+ document.head.appendChild(style)
4490
4941
  }
4491
- return found
4492
- }
4493
4942
 
4494
- /** 内置动态变量:同步可取的自动填值;取不到的(如剪贴板)留空手填/异步预填。 */
4495
- function autoValue(name) {
4496
- const now = new Date()
4943
+ const report = (phase, extra) => {
4944
+ try {
4945
+ fetch(`${ENDPOINT}/diag`, {
4946
+ method: 'POST',
4947
+ headers: { 'content-type': 'application/json' },
4948
+ body: JSON.stringify({ line: `${phase} ${JSON.stringify(extra ?? {})}`.slice(0, 1800) }),
4949
+ }).catch(() => {})
4950
+ } catch { /* ignore */ }
4951
+ }
4952
+
4953
+ /** 与内置 ContextMeter 同款紧凑数字(K / M)。 */
4954
+ function formatTokens(value) {
4955
+ if (value < 1000) return String(value)
4956
+ const scale = (n) => (n >= 100 ? String(Math.round(n)) : String(Math.round(n * 10) / 10))
4957
+ if (value < 1000000) return `${scale(value / 1000)}K`
4958
+ return `${scale(value / 1000000)}M`
4959
+ }
4960
+
4961
+ /** contextPressure → 占用(与 ui-conversation 的 contextOccupancy 同算法)。
4962
+ * 用 || 而非 ??:projectedTokens 可为合法 0(未投影出增量),此时必须
4963
+ * 回落 pressureTokens——`??` 会把 0 当有效值挡住回落,真机出现过官方
4964
+ * 17% 而本环 0% 的空圈。 */
4965
+ function occupancy(pressure) {
4966
+ const used = pressure?.projectedTokens || pressure?.pressureTokens
4967
+ if (used === undefined || pressure?.contextWindow === undefined) return null
4968
+ return {
4969
+ percent: Math.min(100, Math.round((used / pressure.contextWindow) * 100)),
4970
+ usedTokens: used,
4971
+ contextWindow: pressure.contextWindow,
4972
+ }
4973
+ }
4974
+
4975
+ /**
4976
+ * 官方占用环按钮的识别特征(0.2.2 收严):dialog 按钮 + svg 带圆 +
4977
+ * 文本恰为「NN%」。0.2.1 的「全文 dialog + 双 circle」宽泛特征在真机
4978
+ * 命中了 dock 里一个未知按钮(correct-trace mode:"meter" 坐标 965,796),
4979
+ * 宽泛特征必然误伤,文本白名单把爆炸半径收到最小;若官方环文本形态
4980
+ * 不同则不隐藏(无害降级,官方环照常显示)。
4981
+ */
4982
+ function findBuiltInMeterButtons() {
4983
+ const found = []
4984
+ for (const btn of document.querySelectorAll('button[aria-haspopup="dialog"]')) {
4985
+ if (btn.classList.contains('dsh-cc-trigger')) continue
4986
+ if (!/^\d+%$/.test((btn.textContent ?? '').trim())) continue
4987
+ const svg = btn.querySelector('svg')
4988
+ if (svg && svg.querySelectorAll('circle').length >= 1) found.push(btn)
4989
+ }
4990
+ return found
4991
+ }
4992
+
4993
+ let hideObserver = null
4994
+ let hideRefcount = 0
4995
+ // visibility:hidden 而非 display:none:官方环占位保留,状态行 flex 不
4996
+ // 会把费用/用量按钮挤过来盖住我们的环。
4997
+ let knownMeters = new Set()
4998
+ function hideBuiltInOnce(force) {
4999
+ if (!force && [...knownMeters].every((b) => b.isConnected && b.style.visibility === 'hidden')) return
5000
+ knownMeters = new Set(findBuiltInMeterButtons())
5001
+ for (const btn of knownMeters) btn.style.visibility = 'hidden'
5002
+ }
5003
+ function acquireDockHide() {
5004
+ hideRefcount += 1
5005
+ hideBuiltInOnce(true)
5006
+ if (!hideObserver) {
5007
+ hideObserver = new MutationObserver(() => hideBuiltInOnce(false))
5008
+ hideObserver.observe(document.body, { childList: true, subtree: true })
5009
+ }
5010
+ }
5011
+ function releaseDockHide() {
5012
+ hideRefcount = Math.max(0, hideRefcount - 1)
5013
+ if (hideRefcount === 0) {
5014
+ if (hideObserver) { hideObserver.disconnect(); hideObserver = null }
5015
+ for (const btn of knownMeters) btn.style.visibility = ''
5016
+ knownMeters = new Set()
5017
+ }
5018
+ }
5019
+
5020
+ class QuietBoundary extends React.Component {
5021
+ constructor(props) { super(props); this.state = { error: null } }
5022
+ static getDerivedStateFromError(error) { return { error } }
5023
+ componentDidCatch(error) { report('crash', { message: String(error?.message ?? error) }) }
5024
+ render() { return this.state.error ? null : this.props.children }
5025
+ }
5026
+
5027
+ // 官方命令通道:dock 槽位组件的标准 props 带 inputActions(followup
5028
+ // 同款)。「立即压缩」优先把 /compact 落进草稿再 submit——命令在
5029
+ // agent 宿主进程执行,不受本进程 webServer 无 agents 服务的影响。
5030
+ let commandBridge = null
5031
+
5032
+ function CommandBridge(props) {
5033
+ useEffect(() => {
5034
+ commandBridge = props.inputActions ?? null
5035
+ return () => { if (commandBridge === props.inputActions) commandBridge = null }
5036
+ }, [props.inputActions])
5037
+ return null
5038
+ }
5039
+
5040
+ /** 走官方 /compact 命令(草稿→提交),返回是否已受理。 */
5041
+ const compactViaCommand = () => {
5042
+ if (!commandBridge) return false
5043
+ try {
5044
+ commandBridge.setDraft('/compact')
5045
+ window.setTimeout(() => {
5046
+ try { commandBridge.submit() } catch (err) { report('submit-error', { message: String(err?.message ?? err) }) }
5047
+ }, 250)
5048
+ return true
5049
+ } catch (err) {
5050
+ report('bridge-error', { message: String(err?.message ?? err) })
5051
+ return false
5052
+ }
5053
+ }
5054
+
5055
+ function RingSvg({ percent }) {
5056
+ return h('svg', { viewBox: '0 0 14 14', width: RING_SIZE, height: RING_SIZE, 'aria-hidden': true },
5057
+ h('circle', { className: 'dsh-cc-ring-track', cx: 7, cy: 7, r: RADIUS }),
5058
+ h('circle', {
5059
+ className: 'dsh-cc-ring-fill',
5060
+ cx: 7, cy: 7, r: RADIUS,
5061
+ strokeDasharray: `${(CIRCUMFERENCE * percent) / 100} ${CIRCUMFERENCE}`,
5062
+ transform: 'rotate(-90 7 7)',
5063
+ }))
5064
+ }
5065
+
5066
+ function Seat(props) {
5067
+ const useProjection = props.useProjection
5068
+ const sessionId = props.sessionId
5069
+ const pressure = useProjection('contextPressure')
5070
+ const breakdown = useProjection('contextBreakdown')
5071
+
5072
+ const [open, setOpen] = useState(false)
5073
+ const [pos, setPos] = useState(null)
5074
+ const [settings, setSettings] = useState(null)
5075
+ const [draft, setDraft] = useState(null)
5076
+ const [busy, setBusy] = useState(false)
5077
+ const [saving, setSaving] = useState(false)
5078
+ const [resetting, setResetting] = useState(false)
5079
+ const [expanded, setExpanded] = useState(false)
5080
+ const [flash, setFlash] = useState(null)
5081
+
5082
+ const rootRef = useRef(null)
5083
+ const panelRef = useRef(null)
5084
+
5085
+ // 圆环常驻:新会话/空会话还没有 contextPressure 时用 0% 占位,
5086
+ // 避免「模型选择器左侧的环莫名消失」;有数据后自动切回真实占用。
5087
+ const measured = occupancy(pressure)
5088
+ const context = measured ?? { percent: 0, usedTokens: 0, contextWindow: undefined }
5089
+
5090
+ // 挂载取证:0.2.5 起圆环是 conversation.input.right 槽位的直接内容,
5091
+ // 位置由官方 InputBar JSX 兄弟顺序保证(紧在模型选择器 renderSlot
5092
+ // 之前),不再有任何坐标手术;这条 trace 只为确认槽位真的渲染了。
5093
+ const mountReported = useRef(false)
5094
+ useEffect(() => {
5095
+ if (mountReported.current || context === null) return
5096
+ mountReported.current = true
5097
+ const rect = rootRef.current?.getBoundingClientRect()
5098
+ report('in-slot-mount', rect
5099
+ ? { left: Math.round(rect.left), top: Math.round(rect.top), w: Math.round(rect.width), h: Math.round(rect.height) }
5100
+ : {})
5101
+ })
5102
+
5103
+ // 挂载即拉一次服务端状态:hideBuiltIn 要在用户打开面板前就生效。
5104
+ useEffect(() => {
5105
+ let alive = true
5106
+ fetch(`${ENDPOINT}/state`)
5107
+ .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))))
5108
+ .then((data) => {
5109
+ if (!alive || !data?.state) return
5110
+ setSettings(data)
5111
+ setDraft({
5112
+ auto: data.state.auto,
5113
+ thresholdRatio: data.state.thresholdRatio,
5114
+ retainRatio: data.state.retainRatio,
5115
+ hideBuiltIn: data.state.hideBuiltIn,
5116
+ checkpoint: data.state.checkpoint,
5117
+ })
5118
+ })
5119
+ .catch((err) => report('state-error', { message: String(err?.message ?? err) }))
5120
+ return () => { alive = false }
5121
+ }, [])
5122
+
5123
+ // 压缩前自动落档布防:占用率上穿「阈值-5%」的升沿 POST 一次 arm=true,
5124
+ // 回落(压缩完成/新会话)时 arm=false。服务端在下一次 pre-step 注入
5125
+ // 落档指令,agent 自己把项目状态写进工作区 checkpoint.md。
5126
+ const armRef = useRef(false)
5127
+ useEffect(() => {
5128
+ if (context === null || !sessionId) return
5129
+ const thresholdPct = Math.round((draft?.thresholdRatio ?? 0.8) * 100)
5130
+ const above = context.percent >= thresholdPct - 5
5131
+ if (above === armRef.current) return
5132
+ armRef.current = above
5133
+ void fetch(`${ENDPOINT}/checkpoint-arm`, {
5134
+ method: 'POST',
5135
+ headers: { 'content-type': 'application/json' },
5136
+ body: JSON.stringify({ sessionId: String(sessionId), arm: above }),
5137
+ }).then((res) => (res.ok ? res.json() : null))
5138
+ .then((data) => { if (data && data.armed) report('checkpoint-armed', { sessionId, percent: context.percent }) })
5139
+ .catch((err) => report('checkpoint-arm-error', { message: String(err?.message ?? err) }))
5140
+ }, [context?.percent, sessionId, draft?.thresholdRatio])
5141
+
5142
+ // 隐藏官方环的生命周期跟 draft.hideBuiltIn 走(面板里可切换)。
5143
+ // 未加载(undefined)也按默认隐藏——任何加载失败场景都不出现双环。
5144
+ useEffect(() => {
5145
+ if (draft?.hideBuiltIn === false) return undefined
5146
+ acquireDockHide()
5147
+ return () => releaseDockHide()
5148
+ }, [draft?.hideBuiltIn])
5149
+
5150
+ // 面板定位 + 外点关闭 + Esc。默认向上展开(translateY(-100%));
5151
+ // 上方空间不足 460px 时才向下。外点关闭 + Esc。
5152
+ useEffect(() => {
5153
+ if (!open) return undefined
5154
+ const rect = rootRef.current?.getBoundingClientRect()
5155
+ if (rect) {
5156
+ const left = Math.max(8, Math.min(rect.right - PANEL_WIDTH, window.innerWidth - PANEL_WIDTH - 8))
5157
+ const openUp = rect.top > 460
5158
+ setPos({
5159
+ left,
5160
+ top: openUp ? rect.top - 8 : rect.bottom + 8,
5161
+ transform: openUp ? 'translateY(-100%)' : 'none',
5162
+ })
5163
+ }
5164
+ const onPointerDown = (e) => {
5165
+ const t = e.target
5166
+ if (rootRef.current?.contains(t) || panelRef.current?.contains(t)) return
5167
+ setOpen(false)
5168
+ }
5169
+ const onKeyDown = (e) => { if (e.key === 'Escape') setOpen(false) }
5170
+ document.addEventListener('pointerdown', onPointerDown, true)
5171
+ document.addEventListener('keydown', onKeyDown)
5172
+ return () => {
5173
+ document.removeEventListener('pointerdown', onPointerDown, true)
5174
+ document.removeEventListener('keydown', onKeyDown)
5175
+ }
5176
+ }, [open])
5177
+
5178
+ // flash 3.5s 自动消散。
5179
+ useEffect(() => {
5180
+ if (!flash) return undefined
5181
+ const timer = setTimeout(() => setFlash(null), 3500)
5182
+ return () => clearTimeout(timer)
5183
+ }, [flash])
5184
+
5185
+ // 本环永远渲染:0.2.1 的「hideBuiltIn=false 时退场」门已拆——持久化
5186
+ // 值恰为 false 曾把环卸成空壳占位。该开关只管官方环的隐藏。
5187
+ if (createPortal === null) return null
5188
+
5189
+ const breakdownTotal = breakdown === undefined
5190
+ ? 0
5191
+ : breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens
5192
+ const rows = [
5193
+ { key: 'systemTokens', label: '系统提示', cls: 'dsh-cc-tint-system', value: breakdown?.systemTokens },
5194
+ { key: 'toolsTokens', label: '工具定义', cls: 'dsh-cc-tint-tools', value: breakdown?.toolsTokens },
5195
+ { key: 'messageTokens', label: '对话历史', cls: 'dsh-cc-tint-messages', value: breakdown?.messageTokens },
5196
+ ]
5197
+ const segments = breakdownTotal > 0
5198
+ ? rows
5199
+ .map((row) => ({ ...row, width: (context.percent * row.value) / breakdownTotal }))
5200
+ .filter((row) => row.width > 0)
5201
+ : [{ key: 'total', cls: '', width: context.percent }]
5202
+
5203
+ const onCompact = async () => {
5204
+ if (busy) return
5205
+ // 优先官方命令通道:结果以官方 chip 形式出现在对话流里。
5206
+ if (compactViaCommand()) {
5207
+ setFlash({ kind: 'ok', text: '已执行 /compact,结果见对话流' })
5208
+ return
5209
+ }
5210
+ setBusy(true)
5211
+ setFlash(null)
5212
+ try {
5213
+ const res = await fetch(`${ENDPOINT}/compact`, {
5214
+ method: 'POST',
5215
+ headers: { 'content-type': 'application/json' },
5216
+ body: JSON.stringify({ sessionId: String(sessionId) }),
5217
+ })
5218
+ const data = await res.json().catch(() => ({}))
5219
+ if (!res.ok || !data.ok) {
5220
+ setFlash({ kind: 'err', text: data?.error ?? `压缩失败(HTTP ${res.status})` })
5221
+ } else if (data.empty) {
5222
+ setFlash({ kind: 'ok', text: data.message ?? '暂无可压缩的历史' })
5223
+ } else {
5224
+ setFlash({ kind: 'ok', text: `已压缩 ${data.shadowed} 条历史(约 ${formatTokens(data.tokens ?? 0)} tokens)` })
5225
+ }
5226
+ } catch (err) {
5227
+ setFlash({ kind: 'err', text: `请求失败:${String(err?.message ?? err)}` })
5228
+ } finally {
5229
+ setBusy(false)
5230
+ }
5231
+ }
5232
+
5233
+ const onSave = async () => {
5234
+ if (saving || !draft) return
5235
+ if (draft.retainRatio >= draft.thresholdRatio) {
5236
+ setFlash({ kind: 'err', text: '保留比例必须小于触发阈值' })
5237
+ return
5238
+ }
5239
+ setSaving(true)
5240
+ setFlash(null)
5241
+ try {
5242
+ const res = await fetch(`${ENDPOINT}/state`, {
5243
+ method: 'POST',
5244
+ headers: { 'content-type': 'application/json' },
5245
+ body: JSON.stringify(draft),
5246
+ })
5247
+ const data = await res.json().catch(() => ({}))
5248
+ if (!res.ok || !data.ok) {
5249
+ setFlash({ kind: 'err', text: data?.error ?? `保存失败(HTTP ${res.status})` })
5250
+ } else {
5251
+ setSettings({ ...settings, state: data.state, profiles: data.profiles })
5252
+ setFlash({ kind: 'ok', text: '已保存,重启 dsh 后生效' })
5253
+ }
5254
+ } catch (err) {
5255
+ setFlash({ kind: 'err', text: `请求失败:${String(err?.message ?? err)}` })
5256
+ } finally {
5257
+ setSaving(false)
5258
+ }
5259
+ }
5260
+
5261
+ const onReset = async () => {
5262
+ if (resetting) return
5263
+ setResetting(true)
5264
+ setFlash(null)
5265
+ try {
5266
+ const res = await fetch(`${ENDPOINT}/reset`, { method: 'POST', headers: { 'content-type': 'application/json' } })
5267
+ const data = await res.json().catch(() => ({}))
5268
+ if (!res.ok || !data.ok) {
5269
+ setFlash({ kind: 'err', text: data?.error ?? `恢复失败(HTTP ${res.status})` })
5270
+ } else {
5271
+ setSettings({ ...settings, state: data.state, profiles: data.profiles })
5272
+ setDraft({
5273
+ auto: data.state.auto,
5274
+ thresholdRatio: data.state.thresholdRatio,
5275
+ retainRatio: data.state.retainRatio,
5276
+ hideBuiltIn: data.state.hideBuiltIn,
5277
+ })
5278
+ setExpanded(false)
5279
+ setFlash({ kind: 'ok', text: '已恢复默认,重启 dsh 后生效' })
5280
+ }
5281
+ } catch (err) {
5282
+ setFlash({ kind: 'err', text: `请求失败:${String(err?.message ?? err)}` })
5283
+ } finally {
5284
+ setResetting(false)
5285
+ }
5286
+ }
5287
+
5288
+ const panel = h('div', {
5289
+ ref: panelRef,
5290
+ className: 'dsh-cc-panel',
5291
+ role: 'dialog',
5292
+ 'aria-label': '上下文压缩控制',
5293
+ style: pos ? { left: pos.left, top: pos.top, transform: pos.transform } : { visibility: 'hidden', left: 0, top: 0 },
5294
+ },
5295
+ h('div', { className: 'dsh-cc-head' },
5296
+ h('span', { className: 'dsh-cc-head-label' }, '上下文已用'),
5297
+ h('span', { className: 'dsh-cc-head-pct' }, `${context.percent}%`),
5298
+ h('span', { className: 'dsh-cc-head-fig' },
5299
+ measured ? `~${formatTokens(context.usedTokens)} / ${formatTokens(context.contextWindow)}` : '当前会话暂无上下文数据')),
5300
+ h('div', { className: 'dsh-cc-bar' },
5301
+ segments.map((seg) => h('div', { key: seg.key, className: `dsh-cc-bar-seg ${seg.cls}`, style: { width: `${seg.width}%` } }))),
5302
+ breakdownTotal > 0
5303
+ ? h('dl', { className: 'dsh-cc-rows' },
5304
+ rows.map((row) => h('div', { key: row.key, className: 'dsh-cc-row' },
5305
+ h('dt', null,
5306
+ h('span', { className: `dsh-cc-dot ${row.cls}`, 'aria-hidden': true }),
5307
+ row.label),
5308
+ h('dd', null, `~${formatTokens(row.value ?? 0)}`))))
5309
+ : null,
5310
+
5311
+ h('div', { className: 'dsh-cc-section' },
5312
+ h('button', { type: 'button', className: 'dsh-cc-compact-btn', disabled: busy, onClick: onCompact },
5313
+ busy ? '正在压缩…' : '立即压缩当前会话')),
5314
+
5315
+ h('div', { className: 'dsh-cc-section' },
5316
+ h('button', {
5317
+ type: 'button',
5318
+ className: 'dsh-cc-disclose',
5319
+ 'aria-expanded': expanded,
5320
+ onClick: () => setExpanded(!expanded),
5321
+ },
5322
+ h('span', null, '自定义压缩'),
5323
+ h('span', { className: 'dsh-cc-caret' }, expanded ? '▾' : '▸')),
5324
+ expanded ? h('div', { className: 'dsh-cc-fields' },
5325
+ h('div', { className: 'dsh-cc-field' },
5326
+ h('span', { className: 'dsh-cc-field-label' }, '触发阈值'),
5327
+ h('input', {
5328
+ type: 'range', className: 'dsh-cc-slider', min: 50, max: 95, step: 1,
5329
+ value: Math.round((draft?.thresholdRatio ?? 0.8) * 100),
5330
+ onChange: (e) => setDraft({ ...draft, thresholdRatio: Number(e.target.value) / 100 }),
5331
+ }),
5332
+ h('span', { className: 'dsh-cc-field-value' }, `${Math.round((draft?.thresholdRatio ?? 0.8) * 100)}%`)),
5333
+ h('div', { className: 'dsh-cc-field' },
5334
+ h('span', { className: 'dsh-cc-field-label' }, '保留比例'),
5335
+ h('input', {
5336
+ type: 'range', className: 'dsh-cc-slider', min: 4, max: 40, step: 1,
5337
+ value: Math.round((draft?.retainRatio ?? 0.16) * 100),
5338
+ onChange: (e) => setDraft({ ...draft, retainRatio: Number(e.target.value) / 100 }),
5339
+ }),
5340
+ h('span', { className: 'dsh-cc-field-value' }, `${Math.round((draft?.retainRatio ?? 0.16) * 100)}%`)),
5341
+ h('div', { className: `dsh-cc-hint ${(draft?.retainRatio ?? 0) >= (draft?.thresholdRatio ?? 1) ? 'dsh-cc-hint-show' : ''}` },
5342
+ '保留比例需小于触发阈值'),
5343
+ h('div', { className: 'dsh-cc-field' },
5344
+ h('span', { className: 'dsh-cc-field-label' }, '官方圆环'),
5345
+ h('input', {
5346
+ type: 'checkbox', className: 'dsh-cc-check',
5347
+ checked: draft?.hideBuiltIn ?? true,
5348
+ onChange: (e) => setDraft({ ...draft, hideBuiltIn: e.target.checked }),
5349
+ }),
5350
+ h('span', { className: 'dsh-cc-field-value' }, draft?.hideBuiltIn === false ? '显示' : '已隐藏')),
5351
+ h('div', { className: 'dsh-cc-field' },
5352
+ h('span', { className: 'dsh-cc-field-label' }, '压缩前落档'),
5353
+ h('input', {
5354
+ type: 'checkbox', className: 'dsh-cc-check',
5355
+ checked: draft?.checkpoint ?? true,
5356
+ onChange: (e) => setDraft({ ...draft, checkpoint: e.target.checked }),
5357
+ }),
5358
+ h('span', { className: 'dsh-cc-field-value' }, draft?.checkpoint === false ? '关闭' : '写 checkpoint.md')),
5359
+ h('div', { className: 'dsh-cc-btnrow' },
5360
+ h('button', { type: 'button', className: 'dsh-cc-save', disabled: saving || !draft, onClick: onSave },
5361
+ saving ? '保存中…' : '保存设置'),
5362
+ h('button', { type: 'button', className: 'dsh-cc-reset', disabled: resetting, onClick: onReset },
5363
+ resetting ? '恢复中…' : '恢复默认')))
5364
+ : null),
5365
+
5366
+ flash ? h('div', { className: `dsh-cc-flash dsh-cc-flash-${flash.kind}` }, flash.text) : null)
5367
+
5368
+ return h('span', { ref: rootRef, className: measured ? 'dsh-cc-root' : 'dsh-cc-root dsh-cc-pending' },
5369
+ h('button', {
5370
+ type: 'button',
5371
+ className: 'dsh-cc-trigger',
5372
+ 'aria-label': measured ? `上下文已用 ${context.percent}%,打开压缩控制` : '上下文数据加载中,打开压缩控制',
5373
+ 'aria-haspopup': 'dialog',
5374
+ 'aria-expanded': open,
5375
+ title: measured ? `上下文已用 ${context.percent}%` : '当前会话暂无上下文数据',
5376
+ onClick: () => setOpen(!open),
5377
+ },
5378
+ h(RingSvg, { percent: context.percent })),
5379
+ open ? createPortal(panel, document.body) : null)
5380
+ }
5381
+
5382
+ return {
5383
+ inject: ['slots'],
5384
+ apply(ctx) {
5385
+ ensureStyle()
5386
+ console.info(`[dsh-compaction-control] client ${VERSION} loading`)
5387
+ report('boot', {
5388
+ slots: typeof ctx.slots,
5389
+ portal: createPortal ? 'react-dom' : 'missing',
5390
+ })
5391
+ window.__dshCompactionControl = { version: VERSION }
5392
+
5393
+ ctx.inject(['slots'], (scope) => {
5394
+ const gate = Promise.race([
5395
+ fetch(`${ENDPOINT}/enabled`)
5396
+ .then((res) => (res.ok ? res.json() : { enabled: true }))
5397
+ .catch(() => ({ enabled: true })),
5398
+ new Promise((resolve) => setTimeout(() => resolve({ enabled: true }), 2500)),
5399
+ ])
5400
+
5401
+ gate.then(({ enabled }) => {
5402
+ if (enabled === false) {
5403
+ report('skip-disabled', {})
5404
+ return
5405
+ }
5406
+ // list 槽位:order 越小越靠前,0 让圆环落在模型选择器左边第一位。
5407
+ scope.slots.inject('conversation.input.right', () => scope.slots.register({
5408
+ name: 'conversation.input.right',
5409
+ id: 'dsh-compaction-control',
5410
+ order: 0,
5411
+ }, (props) => h(QuietBoundary, null, h(Seat, props))))
5412
+ // 隐形桥接:只为拿 inputActions(setDraft/submit),不渲染任何 UI。
5413
+ scope.slots.inject('conversation.input.dock', () => scope.slots.register({
5414
+ name: 'conversation.input.dock',
5415
+ id: 'dsh-compaction-control-bridge',
5416
+ order: 90,
5417
+ }, (props) => h(QuietBoundary, null, h(CommandBridge, props))))
5418
+ report('register', {})
5419
+ }).catch((err) => report('gate-error', { message: String(err?.message ?? err) }))
5420
+ })
5421
+ },
5422
+ }
5423
+ }
5424
+
5425
+ /* dsh-superinput · prompts 模块客户端:模板库入口 + 面板。 */
5426
+ function createPromptsClient(require) {
5427
+ const React = require('react')
5428
+ const { createPortal } = require('react-dom')
5429
+ const h = React.createElement
5430
+
5431
+ const STYLE_ID = 'dsh-superinput-prompts-style'
5432
+ const ENDPOINT = '/api/dsh-superinput/prompts'
5433
+
5434
+ /* 划选引用:官方 reference chip + Trae 规格。
5435
+ * CHIP_MARK 是不可见分隔符(U+2063),作为标签前缀用于 CSS 精确选中
5436
+ * 「我们插入的 chip」而不波及官方 @file chip;视觉与文本内容无影响。 */
5437
+ const SELECTION_SOURCE = 'superinput-selection'
5438
+ const SELECTION_RULE = 'dsh-superinput-selection'
5439
+ const CHIP_MARK = '\u2063'
5440
+ const CONTEXT_MAX = 8
5441
+ const CONTEXT_TEXT_MAX = 4000
5442
+ const CONTEXT_COMMENT_MAX = 600
5443
+ const STORAGE_KEY = 'dsh-superinput-selection-items'
5444
+
5445
+ const CSS = `
5446
+ .dsh-cp-btn {
5447
+ display: inline-flex;
5448
+ align-items: center;
5449
+ border: none !important;
5450
+ background: transparent !important;
5451
+ box-shadow: none !important;
5452
+ color: var(--dsw-alias-label-secondary, rgba(232, 238, 250, 0.72)) !important;
5453
+ font-size: 12px;
5454
+ line-height: 20px;
5455
+ padding: 1px 8px;
5456
+ border-radius: var(--dsw-radius-sm, 6px);
5457
+ cursor: default;
5458
+ user-select: none;
5459
+ }
5460
+ .dsh-cp-btn:hover { background: var(--dsw-alias-interactive-bg-hover) !important; color: var(--dsw-alias-label-primary) !important; }
5461
+ .dsh-cp-panel {
5462
+ position: fixed;
5463
+ z-index: 1100;
5464
+ box-sizing: border-box;
5465
+ border: 0;
5466
+ border-radius: var(--dsw-radius-lg, 12px);
5467
+ background: var(--dsw-specific-menu, rgba(67, 69, 74, 0.45));
5468
+ backdrop-filter: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
5469
+ -webkit-backdrop-filter: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
5470
+ width: min(380px, 100vw - 24px);
5471
+ --dsw-elevation-stroke-color: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
5472
+ box-shadow: var(--dsw-elevation-prominent, 0 0 0 0.5px var(--dsw-elevation-stroke-color), 0 3px 8px 0 rgba(0, 0, 0, 0.04), 0 0 20px 0 rgba(0, 0, 0, 0.05));
5473
+ padding: 10px;
5474
+ display: flex;
5475
+ flex-direction: column;
5476
+ gap: 8px;
5477
+ }
5478
+ .dsh-cp-head { display: flex; gap: 6px; align-items: center; }
5479
+ .dsh-cp-search {
5480
+ flex: 1 1 auto; min-width: 0; box-sizing: border-box;
5481
+ padding: 6px 10px; font-size: 12px; line-height: 18px;
5482
+ color: var(--dsw-alias-label-primary);
5483
+ background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.06));
5484
+ border: 0.5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12));
5485
+ border-radius: var(--dsw-radius-sm, 6px);
5486
+ outline: none;
5487
+ }
5488
+ .dsh-cp-search:focus { border-color: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.2)); }
5489
+ .dsh-cp-new {
5490
+ flex: 0 0 auto; width: 26px; height: 26px;
5491
+ display: inline-flex; align-items: center; justify-content: center;
5492
+ font-size: 16px; line-height: 1;
5493
+ color: var(--dsw-alias-label-secondary);
5494
+ background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.06));
5495
+ border: 0.5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12));
5496
+ border-radius: var(--dsw-radius-sm, 6px);
5497
+ cursor: pointer;
5498
+ }
5499
+ .dsh-cp-new:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
5500
+ .dsh-cp-list { display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow-y: auto; }
5501
+ .dsh-cp-group { font-size: 11px; line-height: 18px; color: var(--dsw-alias-label-tertiary); padding: 4px 6px 2px; }
5502
+ .dsh-cp-row { display: flex; align-items: flex-start; gap: 6px; padding: 6px; border-radius: var(--dsw-radius-sm, 6px); }
5503
+ .dsh-cp-row:hover { background: var(--dsw-alias-interactive-bg-hover); }
5504
+ .dsh-cp-row-main { flex: 1 1 auto; min-width: 0; cursor: pointer; display: flex; flex-direction: column; gap: 2px; }
5505
+ .dsh-cp-title { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5506
+ .dsh-cp-uses { color: var(--dsw-alias-label-tertiary); font-size: 10px; margin-left: 4px; }
5507
+ .dsh-cp-preview { font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5508
+ .dsh-cp-vars { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
5509
+ .dsh-cp-var { font-size: 10px; line-height: 14px; padding: 0 4px; border-radius: 3px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.08)); }
5510
+ .dsh-cp-row-actions { flex: 0 0 auto; display: flex; gap: 2px; }
5511
+ .dsh-cp-mini {
5512
+ width: 20px; height: 20px; padding: 0;
5513
+ display: inline-flex; align-items: center; justify-content: center;
5514
+ font-size: 11px; line-height: 1;
5515
+ color: var(--dsw-alias-label-tertiary);
5516
+ background: transparent; border: none; border-radius: 4px; cursor: pointer;
5517
+ }
5518
+ .dsh-cp-mini:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.08)); }
5519
+ .dsh-cp-mini.danger:hover { color: #f87171; }
5520
+ .dsh-cp-mini.active { color: #3a83f7; }
5521
+ .dsh-cp-empty { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); padding: 12px 6px; text-align: center; }
5522
+ .dsh-cp-editor { display: flex; flex-direction: column; gap: 6px; }
5523
+ .dsh-cp-input, .dsh-cp-textarea {
5524
+ box-sizing: border-box; width: 100%;
5525
+ padding: 6px 10px; font-size: 12px; line-height: 18px;
5526
+ color: var(--dsw-alias-label-primary);
5527
+ background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.06));
5528
+ border: 0.5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12));
5529
+ border-radius: var(--dsw-radius-sm, 6px);
5530
+ outline: none; font-family: inherit; resize: vertical;
5531
+ }
5532
+ .dsh-cp-textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
5533
+ .dsh-cp-actions { display: flex; justify-content: flex-end; gap: 6px; }
5534
+ .dsh-cp-action {
5535
+ padding: 4px 12px; font-size: 12px; line-height: 18px;
5536
+ color: var(--dsw-alias-label-secondary);
5537
+ background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.06));
5538
+ border: 0.5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12));
5539
+ border-radius: var(--dsw-radius-sm, 6px);
5540
+ cursor: pointer;
5541
+ }
5542
+ .dsh-cp-action:hover { color: var(--dsw-alias-label-primary); }
5543
+ .dsh-cp-action.primary { color: #fff; background: #3a83f7; border-color: transparent; }
5544
+ .dsh-cp-action.primary:hover { background: #4f8ef9; }
5545
+ /* 四选项横条:完全官方样式——令牌与几何照抄官方 QuoteSelection 按钮
5546
+ (.U4oBDW_action:floating-fill 底 / 999px 胶囊 / panel 阴影 / xs-13 字), */
5547
+ .dsh-cp-selmenu { display: flex; flex-direction: row; align-items: center; gap: 2px; padding: 3px; border-radius: 999px;
5548
+ background: var(--dsw-alias-button-floating-fill, rgba(116, 126, 148, .24));
5549
+ --dsw-elevation-stroke-color: var(--dsw-alias-border-l3, rgba(255, 255, 255, .1));
5550
+ box-shadow: var(--dsw-elevation-panel, 0 0 0 .5px var(--dsw-elevation-stroke-color), 0 2px 6px rgba(0, 0, 0, .12));
5551
+ }
5552
+ .dsh-cp-selitem { border: none; background: transparent; text-align: center; padding: 5px 10px; font-size: 13px; line-height: 18px;
5553
+ color: var(--dsw-alias-label-primary); border-radius: 999px; cursor: pointer; white-space: nowrap;
5554
+ font-family: var(--dsw-font-family, inherit); letter-spacing: -.02em; }
5555
+ .dsh-cp-selitem:hover, .dsh-cp-selitem:focus-visible { background: var(--dsw-alias-button-floating-hover, rgba(116, 126, 148, .36)); }
5556
+ /* 官方浮动「引用文本」pill 由横条里的同名功能取代(同一 insertQuote API),隐藏避免双浮层 */
5557
+ [class*="U4oBDW_action"] { display: none !important; }
5558
+ .dsh-cp-toast { padding: 5px 12px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-primary, #fff);
5559
+ background: rgba(50, 50, 54, 0.95); border-radius: 8px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); }
5560
+ /* ---- 划选引用 chip:Trae comment-mention 规格(ai-modules-chat/index.css)----
5561
+ 规格:高 22px / 圆角 --dsw-radius-sm(8) / business 浅色底 + business 主色文字 /
5562
+ 12px 500 / max-width 240;左图标在 hover 时换成 ×(Trae remove-mention-icon
5563
+ 同义)。颜色全部走 dsh 设计令牌,明暗主题各自生效,不再写死 Trae 的灰/蓝。 */
5564
+ [contenteditable] span[title^="${CHIP_MARK}"] {
5565
+ box-sizing: border-box;
5566
+ display: inline-flex;
5567
+ align-items: baseline;
5568
+ gap: 4px;
5569
+ max-width: min(80%, 300px);
5570
+ padding: 0;
5571
+ border: none;
5572
+ border-radius: 0;
5573
+ background: none;
5574
+ color: var(--dsw-alias-state-business-primary, #4176e6);
5575
+ font-family: var(--dsw-font-family, inherit);
5576
+ font-size: 14px;
5577
+ line-height: 20px;
5578
+ vertical-align: baseline;
5579
+ cursor: pointer;
5580
+ overflow: hidden;
5581
+ transition: background .14s ease;
5582
+ }
5583
+ [contenteditable] span[title^="${CHIP_MARK}"]:hover {
5584
+ color: var(--dsw-alias-state-business-primary, #4176e6);
5585
+ }
5586
+ /* 隐藏宿主默认前缀(@ / file 图标),换官方同款 ❝ 引用标记(hover 变 ×) */
5587
+ [contenteditable] span[title^="${CHIP_MARK}"] > span:first-child { display: none; }
5588
+ [contenteditable] span[title^="${CHIP_MARK}"]::before {
5589
+ content: '';
5590
+ flex: 0 0 14px;
5591
+ width: 14px;
5592
+ height: 14px;
5593
+ align-self: center;
5594
+ margin-right: 2px;
5595
+ background: currentColor;
5596
+ -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M6.6 3.2C4.5 4.3 3 6.3 3 8.6c0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4L6.6 3.2Zm7 0c-2.1 1.1-3.6 3.1-3.6 5.4 0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4l-.8-1.9Z' fill='black'/></svg>") center / 14px 14px no-repeat;
5597
+ mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M6.6 3.2C4.5 4.3 3 6.3 3 8.6c0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4L6.6 3.2Zm7 0c-2.1 1.1-3.6 3.1-3.6 5.4 0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4l-.8-1.9Z' fill='black'/></svg>") center / 14px 14px no-repeat;
5598
+ }
5599
+ [contenteditable] span[title^="${CHIP_MARK}"]:hover::before {
5600
+ content: '×';
5601
+ background: none;
5602
+ -webkit-mask: none;
5603
+ mask: none;
5604
+ color: var(--dsw-alias-label-primary, currentColor);
5605
+ font-size: 14px;
5606
+ line-height: 14px;
5607
+ text-align: center;
5608
+ }
5609
+ /* 消息正文里的同款 chip(user-text 规则节点;不在 contenteditable 里) */
5610
+ .dsh-si-msgchip { box-sizing: border-box; display: inline-flex; align-items: baseline; gap: 4px; max-width: min(80%, 300px);
5611
+ padding: 0; border: none; border-radius: 0; background: none;
5612
+ color: var(--dsw-alias-state-business-primary, #4176e6);
5613
+ font-family: var(--dsw-font-family, inherit); font-size: 14px; line-height: 20px;
5614
+ vertical-align: baseline; overflow: hidden; user-select: none; }
5615
+ .dsh-si-msgchip.interactive { cursor: pointer; }
5616
+ .dsh-si-msgchip-icon { flex: 0 0 14px; width: 14px; height: 14px; margin-right: 2px; opacity: .8; background: currentColor;
5617
+ -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M6.6 3.2C4.5 4.3 3 6.3 3 8.6c0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4L6.6 3.2Zm7 0c-2.1 1.1-3.6 3.1-3.6 5.4 0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4l-.8-1.9Z' fill='black'/></svg>") center / 14px 14px no-repeat;
5618
+ mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M6.6 3.2C4.5 4.3 3 6.3 3 8.6c0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4L6.6 3.2Zm7 0c-2.1 1.1-3.6 3.1-3.6 5.4 0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4l-.8-1.9Z' fill='black'/></svg>") center / 14px 14px no-repeat; }
5619
+ .dsh-si-msgchip-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5620
+ /* 详情弹窗:dsh 原生 Dialog 观感(layer-1 表面 / 16 圆角 / 突出投影 / 标准按钮) */
5621
+ .dsh-si-mask { position: fixed; inset: 0; z-index: 1200; display: flex; align-items: center; justify-content: center;
5622
+ padding: 24px; background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, .24)); }
5623
+ .dsh-si-dialog { box-sizing: border-box; width: min(480px, 100%); max-height: min(600px, 82vh); display: flex; flex-direction: column; gap: 12px;
5624
+ padding: 16px; border-radius: var(--dsw-radius-lg, 16px); color: var(--dsw-alias-label-primary, #e8eefa); overflow-y: auto;
5625
+ font-family: var(--dsw-font-family, inherit);
5626
+ background: var(--dsw-alias-bg-layer-1, #232324);
5627
+ border: .5px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .08));
5628
+ --dsw-elevation-stroke-color: var(--dsw-alias-border-l4, rgba(255, 255, 255, .2));
5629
+ box-shadow: var(--dsw-elevation-prominent, 0 0 0 .5px var(--dsw-elevation-stroke-color), 0 8px 28px rgba(0, 0, 0, .28)); }
5630
+ .dsh-si-dialog-head { display: flex; align-items: center; gap: 8px; }
5631
+ .dsh-si-dialog-title { flex: 1 1 auto; min-width: 0; font-size: 16px; font-weight: 500; line-height: 24px;
5632
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5633
+ .dsh-si-dialog-x { flex: 0 0 auto; width: 24px; height: 24px; padding: 0; border: 0; border-radius: var(--dsw-radius-xs, 4px); cursor: pointer;
5634
+ background: transparent; color: var(--dsw-alias-label-tertiary, #8a9096); font-size: 15px; line-height: 1; }
5635
+ .dsh-si-dialog-x:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .08)); }
5636
+ .dsh-si-dialog-quote { box-sizing: border-box; flex: 1 1 auto; min-height: 40px; max-height: 240px; overflow: auto; padding: 10px 12px; border-radius: var(--dsw-radius-sm, 8px);
5637
+ background: var(--dsw-alias-bg-module-platform, rgba(255, 255, 255, .06));
5638
+ border-left: 2px solid var(--dsw-alias-state-business-primary, #3a83f7);
5639
+ font-size: 14px; line-height: 22px; color: var(--dsw-alias-label-secondary); white-space: pre-wrap; overflow-wrap: anywhere; }
5640
+ .dsh-si-dialog-label { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary, #8a9096); }
5641
+ .dsh-si-dialog-detail { font-size: 14px; line-height: 22px; color: var(--dsw-alias-label-secondary); white-space: pre-wrap; overflow-wrap: anywhere; }
5642
+ .dsh-si-dialog-comment { box-sizing: border-box; width: 100%; min-height: 64px; max-height: 160px; resize: vertical; padding: 8px 10px;
5643
+ font-size: 14px; line-height: 22px; font-family: inherit; color: var(--dsw-alias-label-primary);
5644
+ background: var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, .06)); border: .5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, .12));
5645
+ border-radius: var(--dsw-radius-sm, 8px); outline: none; }
5646
+ .dsh-si-dialog-comment:focus {
5647
+ border-color: var(--dsw-alias-state-business-primary, #3a83f7);
5648
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary, #3a83f7) 16%, transparent);
5649
+ }
5650
+ .dsh-si-dialog-foot { display: flex; align-items: center; gap: 8px; }
5651
+ .dsh-si-dialog-spacer { flex: 1 1 auto; }
5652
+ .dsh-si-dialog-btn { height: 30px; padding: 0 14px; font-size: 13px; font-weight: 500; line-height: 20px; cursor: pointer;
5653
+ color: var(--dsw-alias-label-primary); background: var(--dsw-alias-button-elevated-fill, transparent);
5654
+ border: .5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, .12)); border-radius: var(--dsw-radius-sm, 8px); }
5655
+ .dsh-si-dialog-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .08)); }
5656
+ .dsh-si-dialog-btn.ghost { background: transparent; border-color: transparent; color: var(--dsw-alias-label-secondary); }
5657
+ .dsh-si-dialog-btn.ghost:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .08)); }
5658
+ .dsh-si-dialog-btn.primary { color: var(--dsw-alias-label-primary-foreground, #fff);
5659
+ background: var(--dsw-alias-button-primary-fill, #3a83f7); border-color: transparent; }
5660
+ .dsh-si-dialog-btn.primary:hover { background: var(--dsw-alias-button-primary-hover, #4f8ef9); }
5661
+ .dsh-si-dialog-btn.danger { background: transparent; border-color: transparent; color: var(--dsw-alias-state-error-primary, #f87171); }
5662
+ .dsh-si-dialog-btn.danger:hover { background: var(--dsw-alias-interactive-bg-hover-danger, rgba(248, 113, 113, .12)); }
5663
+ /* 划选评论编辑器:照抄 Trae CommentEditorCard(solo-lite 7920 包源码)——
5664
+ previewCommentCardContainer(菜单底/1px 中性描边/radius 8/双层阴影)内嵌
5665
+ commentEditorCard(320px/padding 8/gap 4 列布局);无标题无预览无取消钮,
5666
+ 单行 = 透明自增 textarea(13px/20px,max-height 140)+ ✓ 主按钮;Enter 提交、
5667
+ Shift+Enter 换行、Esc/点外取消。 */
5668
+ .dsh-si-comment-editor { position: fixed; z-index: 1300; box-sizing: border-box; width: 320px; padding: 8px;
5669
+ display: flex; flex-direction: column; gap: 4px;
5670
+ border-radius: var(--dsw-radius-sm, 8px); color: var(--dsw-alias-label-primary);
5671
+ font-family: var(--dsw-font-family, inherit); background: var(--dsw-specific-menu, #202124);
5672
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .1));
5673
+ /* specific-menu 是半透明底:必须配官方毛玻璃,否则透出下层文字(官方弹卡成对规则) */
5674
+ backdrop-filter: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
5675
+ -webkit-backdrop-filter: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
5676
+ box-shadow: 0 2px 6px rgba(0, 0, 0, .19), 0 .5px rgba(255, 255, 255, .06), inset 0 .5px rgba(255, 255, 255, .06);
5677
+ overflow: hidden;
5678
+ transition: width .25s cubic-bezier(.4, 0, .2, 1), height .25s cubic-bezier(.4, 0, .2, 1);
5679
+ visibility: hidden; }
5680
+ .dsh-si-comment-row { display: flex; align-items: center; gap: 8px; }
5681
+ .dsh-si-comment-input { flex: 1; min-width: 0; box-sizing: border-box; background: none; border: none; outline: none; resize: none;
5682
+ color: var(--dsw-alias-label-primary); font-family: var(--dsw-font-family, inherit);
5683
+ font-size: 13px; font-weight: 400; line-height: 20px; max-height: 140px;
5684
+ padding: 0 8px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-border-l2, rgba(255, 255, 255, .16)) transparent; }
5685
+ .dsh-si-comment-input::placeholder { color: var(--dsw-alias-label-tertiary); }
5686
+ .dsh-si-comment-submit { flex: 0 0 auto; width: 24px; height: 24px; padding: 0; display: inline-flex; align-items: center; justify-content: center;
5687
+ border: none; border-radius: 6px; cursor: pointer;
5688
+ background: var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, .12)); color: var(--dsw-alias-label-primary); }
5689
+ .dsh-si-comment-submit:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .2)); }
5690
+ .dsh-si-comment-submit:disabled { opacity: .5; cursor: default; }
5691
+ /* 点击标签跳源时对源元素的闪烁高亮。 */
5692
+ .dsh-cp-flash { outline: 2px solid var(--dsw-alias-state-business-primary, #3a83f7) !important; outline-offset: 1px; border-radius: 3px; transition: outline-color .3s ease; }
5693
+ `
5694
+
5695
+ function ensureStyle() {
5696
+ if (typeof document === 'undefined') return
5697
+ if (document.getElementById(STYLE_ID)) return
5698
+ const style = document.createElement('style')
5699
+ style.id = STYLE_ID
5700
+ style.textContent = CSS
5701
+ document.head.appendChild(style)
5702
+ }
5703
+
5704
+ async function api(path, body) {
5705
+ try {
5706
+ const res = await fetch(`${ENDPOINT}${path}`, body
5707
+ ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
5708
+ : { cache: 'no-store' })
5709
+ return res.ok ? res.json() : null
5710
+ } catch { return null }
5711
+ }
5712
+
5713
+ /** 异常留痕:走 prompts /diag(0.4.0 起套件统一通道,落 superinput-debug.log)。 */
5714
+ function report(message) {
5715
+ try {
5716
+ void fetch('/api/dsh-superinput/prompts/diag', {
5717
+ method: 'POST',
5718
+ headers: { 'content-type': 'application/json' },
5719
+ body: JSON.stringify({ message: `prompts: ${String(message).slice(0, 300)}` }),
5720
+ }).catch(() => {})
5721
+ } catch { /* ignore */ }
5722
+ }
5723
+
5724
+ /* ------------------------------------------------------------------ */
5725
+ /* 划选引用:输入框内联 chip(官方 reference source)+ /context 同步 */
5726
+ /* ------------------------------------------------------------------ */
5727
+
5728
+ const selectionItems = new Map() // id -> item(发送后归档,供消息正文的 chip 渲染)
5729
+ const selectionSyncTimers = new Map()
5730
+ const selectionActions = new Map() // sessionId -> InputActions
5731
+ const selectionDetailSubs = new Set()
5732
+ let selectionDetailRequest = null
5733
+ let selectionSessions = null // ClientSessions 服务(取每会话 binding.ctx 发输入事件)
5734
+ let selectionIdSeq = 0
5735
+ let selectionStorageTimer = null
5736
+
5737
+ const tokenOf = (id) => `[选段#${id}]`
5738
+ const previewOf = (text, max = 220) => String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
5739
+
5740
+ /** 选区矩形兜底:菜单给的 rect 失效时退回锚点元素/默认位置。 */
5741
+ const normalizeSelectionRect = (rect, anchorEl) => {
5742
+ const finite = (n) => typeof n === 'number' && Number.isFinite(n)
5743
+ if (rect && finite(rect.left) && finite(rect.top) && finite(rect.right) && finite(rect.bottom)) return rect
5744
+ const el = anchorEl instanceof Element ? anchorEl : null
5745
+ if (el) {
5746
+ const r = el.getBoundingClientRect()
5747
+ return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height }
5748
+ }
5749
+ return { left: 16, top: 80, right: 16, bottom: 80, width: 0, height: 0 }
5750
+ }
5751
+
5752
+ /** chip 标签:Trae 是 `N. 选段文本 · 评论`(评论内联在标签里)。
5753
+ * 有评论时把选段压到 ~14 字(Trae 的 text-content 也只占 50%),
5754
+ * 否则 240px 的 chip 会把评论整段挤没。 */
5755
+ const chipTextOf = (seq, text, comment) => {
5756
+ const quote = String(text ?? '').replace(/\s+/g, ' ').trim()
5757
+ const note = String(comment ?? '').replace(/\s+/g, ' ').trim()
5758
+ if (!note) return `${seq}. ${quote}`
5759
+ const head = quote.length > 14 ? `${quote.slice(0, 14)}…` : quote
5760
+ return `${seq}. ${head} · ${note.slice(0, 40)}`
5761
+ }
5762
+ const chipLabelOf = (item) => `${CHIP_MARK}${chipTextOf(item.seq, item.text, item.comment)}`
5763
+
5764
+ /**
5765
+ * 某条 chip 在 detect 坐标里的跨度。occurrences 的 offset 是 clipboard
5766
+ * 坐标(chip 展开成 clipboardText),detect 坐标里每条 chip 只占 1 个
5767
+ * U+FFFC——把前面所有 chip 的 (length-1) 扣掉即得。
5768
+ */
5769
+ function chipSpanOf(occurrences, ref, draftRev) {
5770
+ const own = (occurrences ?? []).find((occ) => occ.source === SELECTION_SOURCE && occ.ref === ref)
5771
+ if (!own) return null
5772
+ let start = own.offset
5773
+ for (const occ of occurrences) if (occ.offset < own.offset) start -= occ.length - 1
5774
+ return { start, end: start + 1, draftRev }
5775
+ }
5776
+
5777
+ /** 当前草稿里真实存在的条目,按草稿里的先后顺序(Trae 的编号顺序)。 */
5778
+ function liveSelectionItems(sessionId, occurrences) {
5779
+ if (!sessionId) return []
5780
+ const seen = new Set()
5781
+ const live = []
5782
+ for (const occ of occurrences ?? []) {
5783
+ if (occ.source !== SELECTION_SOURCE || seen.has(occ.ref)) continue
5784
+ seen.add(occ.ref)
5785
+ const item = selectionItems.get(occ.ref)
5786
+ if (item && item.sessionId === sessionId) live.push(item)
5787
+ }
5788
+ return live
5789
+ }
5790
+
5791
+ async function postContext(sessionId, items) {
5792
+ if (!sessionId) return
5793
+ try {
5794
+ await fetch(`${ENDPOINT}/context`, {
5795
+ method: 'POST',
5796
+ headers: { 'content-type': 'application/json' },
5797
+ body: JSON.stringify({
5798
+ sessionId,
5799
+ items: items.map(({ text, comment }) => ({
5800
+ text: String(text ?? '').slice(0, CONTEXT_TEXT_MAX),
5801
+ comment: String(comment ?? '').slice(0, CONTEXT_COMMENT_MAX),
5802
+ })),
5803
+ }),
5804
+ keepalive: true,
5805
+ })
5806
+ } catch { /* 网络失败时服务端保留上一份,不打断输入 */ }
5807
+ }
5808
+
5809
+ /** 服务端 /context 同步:150ms 防抖;发送意图用 immediate flush。 */
5810
+ function syncSelectionContext(sessionId, items, immediate) {
5811
+ if (!sessionId) return
5812
+ const previous = selectionSyncTimers.get(sessionId)
5813
+ if (previous) clearTimeout(previous)
5814
+ const send = () => {
5815
+ selectionSyncTimers.delete(sessionId)
5816
+ void postContext(sessionId, items)
5817
+ }
5818
+ if (immediate) send()
5819
+ else selectionSyncTimers.set(sessionId, setTimeout(send, 150))
5820
+ }
5821
+
5822
+ /** 落 sessionStorage:消息正文的 chip 在刷新后仍有文本/评论可渲染。 */
5823
+ function persistSelectionItems() {
5824
+ if (typeof sessionStorage === 'undefined' || selectionStorageTimer) return
5825
+ selectionStorageTimer = setTimeout(() => {
5826
+ selectionStorageTimer = null
5827
+ try {
5828
+ const snapshot = [...selectionItems.values()].slice(-120).map((item) => ({
5829
+ id: item.id,
5830
+ sessionId: item.sessionId,
5831
+ seq: item.seq,
5832
+ text: item.text,
5833
+ comment: item.comment,
5834
+ sent: item.sent === true,
5835
+ at: item.at,
5836
+ }))
5837
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot))
5838
+ } catch { /* 配额/隐私模式:忽略 */ }
5839
+ }, 400)
5840
+ }
5841
+
5842
+ function loadSelectionItems() {
5843
+ if (typeof sessionStorage === 'undefined') return
5844
+ try {
5845
+ const saved = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '[]')
5846
+ if (!Array.isArray(saved)) return
5847
+ for (const raw of saved) {
5848
+ if (!raw?.id || !raw?.text) continue
5849
+ const id = String(raw.id)
5850
+ if (selectionItems.has(id)) continue
5851
+ const sessionId = String(raw.sessionId ?? '')
5852
+ const seq = Number(raw.seq) || 0
5853
+ selectionItems.set(id, {
5854
+ id, sessionId, seq,
5855
+ text: String(raw.text),
5856
+ comment: String(raw.comment ?? ''),
5857
+ anchor: null,
5858
+ sent: raw.sent === true,
5859
+ archived: true, // 历史条目:chip 已不在草稿里,只用于消息正文展示
5860
+ at: Number(raw.at) || Date.now(),
5861
+ })
5862
+ }
5863
+ } catch { /* 存档损坏:忽略 */ }
5864
+ }
5865
+
5866
+ /** 走会话作用域的 slash/input-insert-reference 事件把 chip 插进官方编辑器。 */
5867
+ function applySelectionInsert(sessionId, item, span) {
5868
+ if (!span) return false
5869
+ const binding = selectionSessions?.binding?.(sessionId)
5870
+ const actx = binding?.ctx
5871
+ if (!actx) return false
5872
+ const request = {
5873
+ reference: {
5874
+ source: SELECTION_SOURCE,
5875
+ ref: item.id,
5876
+ label: chipLabelOf(item),
5877
+ clipboardText: tokenOf(item.id),
5878
+ },
5879
+ span,
5880
+ }
5881
+ try {
5882
+ if (typeof actx.bail === 'function') {
5883
+ try { return actx.bail(actx, 'slash/input-insert-reference', request) === true }
5884
+ catch { /* 有些版本只接受 (name, payload) */ }
5885
+ try { return actx.bail('slash/input-insert-reference', request) === true }
5886
+ catch { return false }
5887
+ }
5888
+ if (typeof actx.emit === 'function') {
5889
+ actx.emit(actx, 'slash/input-insert-reference', request)
5890
+ return true
5891
+ }
5892
+ } catch { return false }
5893
+ return false
5894
+ }
5895
+
5896
+ /** 删除一条 chip:用 insertText('') 替换它的 detect 跨度(空文本即删除)。 */
5897
+ function applySelectionRemove(sessionId, item, span) {
5898
+ if (!span) return false
5899
+ const actions = selectionActions.get(sessionId)
5900
+ if (typeof actions?.insertText !== 'function') return false
5901
+ try { return actions.insertText('', span) === true } catch { return false }
5902
+ }
5903
+
5904
+ function requestSelectionDetail(sessionId, id, focus) {
5905
+ selectionDetailRequest = { sessionId, id, focus: focus === true }
5906
+ for (const listener of selectionDetailSubs) {
5907
+ try { listener() } catch { /* ignore */ }
5908
+ }
5909
+ }
5910
+
5911
+ loadSelectionItems()
5912
+
5913
+ /** 找出正文里的 {{变量}},去重保序。 */
5914
+ function varsOf(text) {
5915
+ const found = []
5916
+ const re = /\{\{\s*([a-zA-Z_$][\w$]*)\s*\}\}/g
5917
+ let match
5918
+ while ((match = re.exec(String(text ?? '')))) {
5919
+ if (!found.includes(match[1])) found.push(match[1])
5920
+ }
5921
+ return found
5922
+ }
5923
+
5924
+ /** 内置动态变量:同步可取的自动填值;取不到的(如剪贴板)留空手填/异步预填。 */
5925
+ function autoValue(name) {
5926
+ const now = new Date()
4497
5927
  if (name === '日期') return now.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
4498
5928
  if (name === '时间') return now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
4499
5929
  if (name === '星期') return now.toLocaleDateString('zh-CN', { weekday: 'long' })
@@ -4568,84 +5998,53 @@ function createPromptsClient(require) {
4568
5998
  if (hideTimer) clearTimeout(hideTimer)
4569
5999
  hideTimer = setTimeout(hide, 1800)
4570
6000
  }
4571
- const copySel = async (text) => {
4572
- hide()
4573
- try {
4574
- await navigator.clipboard?.writeText(text)
4575
- toast('已复制')
4576
- } catch {
4577
- toast('复制失败')
4578
- }
4579
- }
4580
-
4581
- /** 把选中文字追加进输入框草稿(草稿桥是 enhancer 客户端挂的全局)。 */
4582
- const addToDraft = (text) => {
4583
- hide()
4584
- try {
4585
- const set = window.__dshSuperinputSetDraft
4586
- if (!set) { toast('草稿桥未就绪,刷新后重试'); return }
4587
- const current = String(window.__dshSuperinputDraft?.() ?? '')
4588
- set(current ? `${current}\n${text}` : text)
4589
- toast('已添加到输入框')
4590
- } catch {
4591
- toast('添加失败')
4592
- }
4593
- }
4594
-
4595
- const save = async (text) => {
4596
- const title = text.replace(/\s+/g, ' ').trim().slice(0, 40)
4597
- hide()
4598
- try {
4599
- const res = await fetch('/api/dsh-superinput/prompts/save', {
4600
- method: 'POST',
4601
- headers: { 'content-type': 'application/json' },
4602
- body: JSON.stringify({ title, body: text }),
4603
- })
4604
- const data = res.ok ? await res.json() : null
4605
- toast(data?.ok ? '已存为模板' : '保存失败')
4606
- } catch {
4607
- toast('保存失败')
4608
- }
4609
- }
6001
+ // 评论编辑器在 React 侧保存后要提示,复用这个文档级 toast。
6002
+ window.__dshSuperinputToast = toast
4610
6003
 
4611
- // 选区变化驱动(dblclick 选词在 mouseup 之后才成形,selectionchange 才是可靠信号)
6004
+ /* 四选项横条(用户拍板恢复 0.8.x 形态):复制 / 引用文本(官方
6005
+ * insertQuote 机制)/ 添加评论(Trae 评论卡 → chip)/ 存为模板。
6006
+ * 官方自己的浮动「引用文本」pill 用 CSS 隐藏——横条里的「引用文本」
6007
+ * 调同一个官方 API,避免双浮层。 */
4612
6008
  let showTimer = 0
4613
6009
  document.addEventListener('selectionchange', () => {
4614
6010
  try {
4615
6011
  if (showTimer) { clearTimeout(showTimer); showTimer = 0 }
4616
6012
  const selection = window.getSelection?.()
4617
6013
  const text = String(selection ?? '').trim()
4618
- if (!text || text.length < 4 || !selection.rangeCount) { hide(); return }
6014
+ if (!text || !selection.rangeCount) { hide(); return }
4619
6015
  showTimer = setTimeout(() => {
4620
6016
  try {
4621
6017
  const sel = window.getSelection?.()
4622
6018
  const current = String(sel ?? '').trim()
4623
- if (!current || current.length < 4 || !sel.rangeCount) { hide(); return }
6019
+ if (!current || !sel.rangeCount) { hide(); return }
4624
6020
  const node = sel.anchorNode
4625
6021
  const el = node instanceof Element ? node : node?.parentElement
4626
- // 只收对话正文:跳过输入区和我们自己的弹层
6022
+ // 只收对话正文:跳过输入区、我们自己的弹层,以及侧栏/设置等非对话区域
4627
6023
  if (!el || el.closest('[data-dsh-superinput-portal]') || el.closest('[contenteditable="true"], textarea, input')) { hide(); return }
6024
+ if (!el.closest('[data-chat-flow], [data-slot="conversation.chat.flow"]')) { hide(); return }
4628
6025
  const rect = sel.getRangeAt(0).getBoundingClientRect()
4629
6026
  if (!rect || (rect.width === 0 && rect.height === 0)) { hide(); return }
4630
6027
  const container = ensureHost()
4631
- // 横排菜单(复制 / 添加到对话 / 存为模板 ≈210px):左缘对齐选区,右侧防溢出
4632
- container.style.left = `${Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - 226))}px`
6028
+ // 横排四项 ≈320px:左缘对齐选区,右侧防溢出
6029
+ container.style.left = `${Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - 330))}px`
4633
6030
  container.style.top = `${Math.max(8, rect.top - 44)}px`
4634
6031
  container.style.transform = ''
4635
6032
  container.style.display = 'block'
4636
6033
  const menu = document.createElement('div')
4637
6034
  menu.className = 'dsh-cp-selmenu'
4638
- menu.addEventListener('mousedown', (e) => e.stopPropagation())
6035
+ // 阻止默认聚焦/清选区:否则 mousedown 触发 selectionchange,菜单会在 click 前被隐藏
6036
+ menu.addEventListener('mousedown', (e) => { e.stopPropagation(); e.preventDefault() })
4639
6037
  const mkItem = (label, action) => {
4640
6038
  const b = document.createElement('button')
4641
6039
  b.className = 'dsh-cp-selitem'
4642
6040
  b.textContent = label
4643
- b.addEventListener('click', () => { void action(current) })
6041
+ b.addEventListener('click', () => { void action(current, el, rect) })
4644
6042
  return b
4645
6043
  }
4646
6044
  menu.append(
4647
6045
  mkItem('复制', copySel),
4648
- mkItem('添加到对话', addToDraft),
6046
+ mkItem('引用文本', quoteToComposer),
6047
+ mkItem('添加评论', (text2) => openComment(text2, el, rect)),
4649
6048
  mkItem('存为模板', save))
4650
6049
  container.replaceChildren(menu)
4651
6050
  } catch { /* ignore */ }
@@ -4659,6 +6058,128 @@ function createPromptsClient(require) {
4659
6058
  document.addEventListener('selectionchange', () => {
4660
6059
  if (!String(window.getSelection?.() ?? '').trim()) hide()
4661
6060
  })
6061
+
6062
+ const copySel = async (text) => {
6063
+ hide()
6064
+ if (!navigator.clipboard?.writeText) { toast('当前环境不支持复制'); return }
6065
+ try {
6066
+ await navigator.clipboard.writeText(text)
6067
+ toast('已复制')
6068
+ } catch {
6069
+ toast('复制失败')
6070
+ }
6071
+ }
6072
+
6073
+ /** 官方「引用文本」同款:inputActions.insertQuote 插 Markdown blockquote。
6074
+ * plain 模式限制下返回 false 时,退回草稿桥手写 `> ` 前缀(发送后官方
6075
+ * quoteRule 一样渲染成引用样式)。 */
6076
+ const quoteToComposer = (text) => {
6077
+ hide()
6078
+ const insert = window.__dshSuperinputInsertQuote
6079
+ if (typeof insert === 'function' && insert(text) === true) { toast('已引用到输入框'); return }
6080
+ try {
6081
+ const set = window.__dshSuperinputSetDraft
6082
+ if (!set) { toast('输入框桥未就绪,刷新后重试'); return }
6083
+ const block = text.split('\n').map((line) => `> ${line}`).join('\n')
6084
+ const current = String(window.__dshSuperinputDraft?.() ?? '')
6085
+ set(current ? `${current}\n${block}` : block)
6086
+ toast('已引用到输入框')
6087
+ } catch {
6088
+ toast('引用失败')
6089
+ }
6090
+ }
6091
+
6092
+ /** Trae 的「添加评论」:在选区旁弹评论卡,保存后才插 chip(带评论)。
6093
+ * rect 是选区矩形,React 侧用它把卡片锚在选区附近。 */
6094
+ const openComment = (text, anchorEl, rect) => {
6095
+ hide()
6096
+ const open = window.__dshSuperinputCommentContext
6097
+ if (typeof open !== 'function') { toast('评论编辑器未就绪,刷新后重试'); return }
6098
+ let status = 'failed'
6099
+ try {
6100
+ status = open(text, anchorEl, {
6101
+ left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom,
6102
+ width: rect.width, height: rect.height,
6103
+ })
6104
+ } catch { status = 'failed' }
6105
+ if (status === 'full') toast(`最多添加 ${CONTEXT_MAX} 段对话内容`)
6106
+ else if (status === 'empty') toast('选段为空')
6107
+ else if (status === 'failed') toast('打开评论编辑器失败')
6108
+ }
6109
+
6110
+ const save = async (text) => {
6111
+ const title = text.replace(/\s+/g, ' ').trim().slice(0, 40)
6112
+ hide()
6113
+ try {
6114
+ const res = await fetch('/api/dsh-superinput/prompts/save', {
6115
+ method: 'POST',
6116
+ headers: { 'content-type': 'application/json' },
6117
+ body: JSON.stringify({ title, body: text }),
6118
+ })
6119
+ const data = res.ok ? await res.json() : null
6120
+ toast(data?.ok ? '已存为模板' : '保存失败')
6121
+ } catch {
6122
+ toast('保存失败')
6123
+ }
6124
+ }
6125
+
6126
+ /* Trae dragDropMention 等价:把对话里划选的文字拖进输入框,落成引用 chip。
6127
+ * dragstart 时对选区打自定义 dataTransfer 标记 + stash 文本;dragover 放行
6128
+ * composer;drop 拦截宿主的纯文本插入,走 __dshSuperinputDropContext 插 chip。 */
6129
+ let dragSelection = null // { text, anchorEl }
6130
+ document.addEventListener('dragstart', (event) => {
6131
+ try {
6132
+ const selection = window.getSelection?.()
6133
+ const text = String(selection ?? '').trim()
6134
+ const node = selection?.anchorNode
6135
+ const el = node instanceof Element ? node : node?.parentElement
6136
+ if (!text || !el?.closest?.('[data-chat-flow], [data-slot="conversation.chat.flow"]')
6137
+ || el.closest?.('[contenteditable="true"], textarea, input')) { dragSelection = null; return }
6138
+ dragSelection = { text: text.slice(0, CONTEXT_TEXT_MAX), anchorEl: el }
6139
+ try { event.dataTransfer?.setData('application/x-dsh-selection', '1') } catch { /* 标记失败只影响识别 */ }
6140
+ } catch { dragSelection = null }
6141
+ }, true)
6142
+ document.addEventListener('dragover', (event) => {
6143
+ try {
6144
+ if (!dragSelection) return
6145
+ const target = event.target
6146
+ if (!(target instanceof Element) || !target.closest?.('[contenteditable="true"]')) return
6147
+ event.preventDefault()
6148
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
6149
+ } catch { /* ignore */ }
6150
+ }, true)
6151
+ document.addEventListener('drop', (event) => {
6152
+ try {
6153
+ if (!dragSelection) return
6154
+ const target = event.target
6155
+ if (!(target instanceof Element) || !target.closest?.('[contenteditable="true"]')) return
6156
+ event.preventDefault()
6157
+ event.stopPropagation()
6158
+ const { text, anchorEl } = dragSelection
6159
+ dragSelection = null
6160
+ // 尽力把光标挪到落点:宿主 captureInsertion 读到哪就是哪。
6161
+ try {
6162
+ const range = typeof document.caretRangeFromPoint === 'function'
6163
+ ? document.caretRangeFromPoint(event.clientX, event.clientY)
6164
+ : null
6165
+ if (range) {
6166
+ window.getSelection()?.removeAllRanges()
6167
+ window.getSelection()?.addRange(range)
6168
+ }
6169
+ } catch { /* 定位失败就落原 caret */ }
6170
+ const insert = window.__dshSuperinputDropContext ?? window.__dshSuperinputAddContext
6171
+ if (typeof insert === 'function') {
6172
+ const status = insert(text, anchorEl)
6173
+ const toast = window.__dshSuperinputToast
6174
+ if (typeof toast === 'function') {
6175
+ if (status === 'added') toast('已添加到对话')
6176
+ else if (status === 'dup') toast('该选段已在列表中')
6177
+ else if (status === 'full') toast(`最多添加 ${CONTEXT_MAX} 段对话内容`)
6178
+ }
6179
+ }
6180
+ } catch { /* ignore */ }
6181
+ }, true)
6182
+ document.addEventListener('dragend', () => { dragSelection = null }, true)
4662
6183
  }
4663
6184
 
4664
6185
  function PromptsPanel({ anchor, onClose, onSyncSlash }) {
@@ -4954,6 +6475,525 @@ function createPromptsClient(require) {
4954
6475
  render() { return this.state.crashed ? null : this.props.children }
4955
6476
  }
4956
6477
 
6478
+ /** 详情弹窗:Trae 点 chip 打开的**只读** Dialog(引用全文 + 评论 + 跳转原文)。
6479
+ * 评论不在这个弹窗里编辑——Trae 的评论在选区旁的 previewCommentCard 输入;
6480
+ * 这里只展示结果(与 ai-modules-chat 的 mention-info-modal 同构)。 */
6481
+ function SelectionDetail({ title, text, comment, onNavigate, onClose }) {
6482
+ const maskRef = React.useRef(null)
6483
+
6484
+ /* 桌面壳(Electron)里 body 会变成 0×0 的 fixed 包含块:`inset: 0` 的遮罩
6485
+ * 缩在左下角,弹窗被裁掉。打开后量一次遮罩实际矩形,若没有铺满视口,
6486
+ * 就按测量偏移把遮罩校正回视口原点并铺满——不依赖宿主沿用哪种 portal。 */
6487
+ React.useEffect(() => {
6488
+ const mask = maskRef.current
6489
+ if (!mask || typeof window === 'undefined') return undefined
6490
+ let corrected = false
6491
+ const fix = () => {
6492
+ const vw = window.innerWidth
6493
+ const vh = window.innerHeight
6494
+ if (!corrected) {
6495
+ const rect = mask.getBoundingClientRect()
6496
+ if (rect.width >= vw * 0.8 && rect.height >= vh * 0.8) return
6497
+ corrected = true
6498
+ mask.style.position = 'fixed'
6499
+ mask.style.inset = 'auto'
6500
+ mask.style.left = `${-rect.left}px`
6501
+ mask.style.top = `${-rect.top}px`
6502
+ mask.style.display = 'flex'
6503
+ mask.style.alignItems = 'center'
6504
+ mask.style.justifyContent = 'center'
6505
+ report(`selection dialog: corrected mask ${Math.round(rect.width)}x${Math.round(rect.height)}@${Math.round(rect.left)},${Math.round(rect.top)} to viewport ${vw}x${vh}`)
6506
+ }
6507
+ mask.style.width = `${vw}px`
6508
+ mask.style.height = `${vh}px`
6509
+ mask.style.boxSizing = 'border-box'
6510
+ }
6511
+ fix()
6512
+ window.addEventListener('resize', fix)
6513
+ return () => window.removeEventListener('resize', fix)
6514
+ }, [])
6515
+
6516
+ return h('div', {
6517
+ ref: maskRef,
6518
+ className: 'dsh-si-mask',
6519
+ onMouseDown: (event) => { event.stopPropagation(); onClose?.() },
6520
+ },
6521
+ h('div', {
6522
+ className: 'dsh-si-dialog',
6523
+ onMouseDown: (event) => event.stopPropagation(),
6524
+ onClick: (event) => event.stopPropagation(),
6525
+ },
6526
+ h('div', { className: 'dsh-si-dialog-head' },
6527
+ h('span', { className: 'dsh-si-dialog-title' }, title),
6528
+ h('button', { className: 'dsh-si-dialog-x', 'aria-label': '关闭', onClick: () => onClose?.() }, '×')),
6529
+ h('div', { className: 'dsh-si-dialog-quote' }, text),
6530
+ comment ? h(React.Fragment, null,
6531
+ h('div', { className: 'dsh-si-dialog-label' }, '评论'),
6532
+ h('div', { className: 'dsh-si-dialog-detail' }, comment)) : null,
6533
+ h('div', { className: 'dsh-si-dialog-foot' },
6534
+ onNavigate ? h('button', { className: 'dsh-si-dialog-btn', onClick: onNavigate }, '跳转原文') : null,
6535
+ h('span', { className: 'dsh-si-dialog-spacer' }),
6536
+ h('button', { className: 'dsh-si-dialog-btn ghost', onClick: () => onClose?.() }, '关闭')))
6537
+ )
6538
+ }
6539
+
6540
+ /** 划选评论编辑器:照抄 Trae CommentEditorCard——无标题/无预览/无取消钮,
6541
+ * 单行 = 透明自增 textarea + ✓ 按钮;Enter 提交、Shift+Enter 换行、
6542
+ * Esc/点外取消;IME 组合期不提交。锚在选区旁,不遮罩。 */
6543
+ function SelectionCommentEditor({ draft, onSave, onCancel }) {
6544
+ const [value, setValue] = React.useState(draft.comment ?? '')
6545
+ const cardRef = React.useRef(null)
6546
+ const inputRef = React.useRef(null)
6547
+ const valueRef = React.useRef(value)
6548
+ React.useEffect(() => { valueRef.current = value }, [value])
6549
+
6550
+ // textarea 自增高(Trae 同款:height auto → scrollHeight,上限 140)
6551
+ React.useEffect(() => {
6552
+ const el = inputRef.current
6553
+ if (!el) return
6554
+ el.style.height = 'auto'
6555
+ el.style.height = `${Math.min(el.scrollHeight, 140)}px`
6556
+ }, [value])
6557
+
6558
+ React.useEffect(() => {
6559
+ const card = cardRef.current
6560
+ if (!card || typeof window === 'undefined') return
6561
+ const vw = window.innerWidth
6562
+ const vh = window.innerHeight
6563
+ const margin = 8
6564
+ const width = Math.min(320, vw - margin * 2)
6565
+ card.style.width = `${width}px`
6566
+ const height = card.getBoundingClientRect().height || 44
6567
+ const rect = draft.rect ?? {}
6568
+ let left = Math.min(Math.max(margin, (rect.left ?? 16) + 20), Math.max(margin, vw - width - margin))
6569
+ let top = (rect.bottom ?? 80) + 8
6570
+ if (top + height > vh - margin) top = Math.max(margin, (rect.top ?? 80) - height - 8)
6571
+ // 先放到期望位置;桌面壳若把 fixed 包含块变成 0×0,再按实际偏差校正。
6572
+ card.style.visibility = 'hidden'
6573
+ card.style.left = `${left}px`
6574
+ card.style.top = `${top}px`
6575
+ const actual = card.getBoundingClientRect()
6576
+ const dx = actual.left - left
6577
+ const dy = actual.top - top
6578
+ if (Math.abs(dx) > 1) card.style.left = `${left - dx}px`
6579
+ if (Math.abs(dy) > 1) card.style.top = `${top - dy}px`
6580
+ card.style.visibility = 'visible'
6581
+ const textarea = card.querySelector('textarea')
6582
+ if (textarea) {
6583
+ textarea.focus()
6584
+ textarea.setSelectionRange?.(textarea.value.length, textarea.value.length)
6585
+ }
6586
+ }, [draft])
6587
+
6588
+ React.useEffect(() => {
6589
+ const onDown = (event) => {
6590
+ if (cardRef.current?.contains(event.target)) return
6591
+ onCancel?.()
6592
+ }
6593
+ document.addEventListener('mousedown', onDown, true)
6594
+ return () => document.removeEventListener('mousedown', onDown, true)
6595
+ }, [onCancel])
6596
+
6597
+ const submit = () => {
6598
+ if (!value.trim()) return
6599
+ onSave?.(value)
6600
+ }
6601
+
6602
+ return createPortal(h('div', {
6603
+ ref: cardRef,
6604
+ className: 'dsh-si-comment-editor',
6605
+ onMouseDown: (event) => event.stopPropagation(),
6606
+ onClick: (event) => event.stopPropagation(),
6607
+ },
6608
+ h('div', { className: 'dsh-si-comment-row' },
6609
+ h('textarea', {
6610
+ ref: inputRef,
6611
+ className: 'dsh-si-comment-input',
6612
+ rows: 1,
6613
+ value,
6614
+ placeholder: '输入你的评论...',
6615
+ 'aria-label': '输入你的评论...',
6616
+ onInput: (event) => setValue(event.target.value),
6617
+ onKeyDown: (event) => {
6618
+ if (event.nativeEvent?.isComposing || event.nativeEvent?.keyCode === 229) return
6619
+ event.stopPropagation()
6620
+ if (event.key === 'Escape') { event.preventDefault(); onCancel?.() }
6621
+ else if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
6622
+ event.preventDefault()
6623
+ submit()
6624
+ }
6625
+ },
6626
+ }),
6627
+ h('button', {
6628
+ className: 'dsh-si-comment-submit',
6629
+ title: draft.comment ? '保存' : '评论到对话',
6630
+ 'aria-label': draft.comment ? '保存' : '评论到对话',
6631
+ disabled: !value.trim(),
6632
+ onClick: submit,
6633
+ },
6634
+ h('svg', { width: 14, height: 14, viewBox: '0 0 16 16', 'aria-hidden': true },
6635
+ h('path', {
6636
+ d: 'M13.5 4.5L6.5 11.5L2.5 7.5',
6637
+ fill: 'none', stroke: 'currentColor', 'stroke-width': '1.8',
6638
+ 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
6639
+ }))))
6640
+ ), document.body)
6641
+ }
6642
+
6643
+ /** 划选引用桥:把「添加到对话」插成输入框内的官方 reference chip。
6644
+ * Trae 的引用是编辑器里的内联节点(conversation-selection-mention-symbol),
6645
+ * dsh 对外只开放 reference chip 这一条内联通道——这里注册自定义 source,
6646
+ * 用 slash/input-insert-reference 落 chip,并按 Trae 规格重着色。
6647
+ * 输入框上方不再有独立 pill 行。 */
6648
+ function SelectionContextBridge({ session, useInput, inputActions }) {
6649
+ const sessionId = session?.sessionId === undefined ? '' : String(session.sessionId)
6650
+ const draftRev = useInput((state) => state?.draftRev ?? 0)
6651
+ const occurrences = useInput((state) => state?.occurrences)
6652
+ const phase = useInput((state) => state?.phase)
6653
+ const snapshotRef = React.useRef({ draftRev, occurrences })
6654
+ const actionsRef = React.useRef(inputActions)
6655
+ const previousPhase = React.useRef(phase)
6656
+ const [, setVersion] = React.useState(0)
6657
+ const [dialog, setDialog] = React.useState(null)
6658
+ const [editor, setEditor] = React.useState(null)
6659
+ const bump = () => setVersion((value) => value + 1)
6660
+
6661
+ React.useEffect(() => {
6662
+ snapshotRef.current = { draftRev, occurrences }
6663
+ actionsRef.current = inputActions
6664
+ }, [draftRev, occurrences, inputActions])
6665
+
6666
+ React.useEffect(() => {
6667
+ if (!sessionId || !inputActions) return undefined
6668
+ selectionActions.set(sessionId, inputActions)
6669
+ return () => {
6670
+ if (selectionActions.get(sessionId) === inputActions) selectionActions.delete(sessionId)
6671
+ }
6672
+ }, [sessionId, inputActions])
6673
+
6674
+ /** 发送意图:把当前 chip 立即同步给服务端(pre-step 读取),并标记发送。 */
6675
+ const flushForSend = () => {
6676
+ if (!sessionId) return
6677
+ const live = liveSelectionItems(sessionId, snapshotRef.current.occurrences)
6678
+ if (live.length === 0) return
6679
+ syncSelectionContext(sessionId, live, true)
6680
+ for (const item of live) item.sent = true
6681
+ persistSelectionItems()
6682
+ }
6683
+
6684
+ /** 插入/更新一条选段 chip;comment === undefined 表示「不改已有评论」。 */
6685
+ const insertSelection = (text, anchorEl, comment) => {
6686
+ const clean = String(text ?? '').trim().slice(0, CONTEXT_TEXT_MAX)
6687
+ if (!clean) return { status: 'empty' }
6688
+ const live = liveSelectionItems(sessionId, snapshotRef.current.occurrences)
6689
+ const existing = live.find((entry) => entry.text === clean)
6690
+ if (existing) {
6691
+ if (comment !== undefined) {
6692
+ const next = String(comment ?? '').slice(0, CONTEXT_COMMENT_MAX).trim()
6693
+ if (next !== (existing.comment ?? '')) {
6694
+ existing.comment = next
6695
+ const span = chipSpanOf(snapshotRef.current.occurrences, existing.id, snapshotRef.current.draftRev)
6696
+ if (span) applySelectionInsert(sessionId, existing, span)
6697
+ bump()
6698
+ syncSelectionContext(sessionId, liveSelectionItems(sessionId, snapshotRef.current.occurrences), false)
6699
+ persistSelectionItems()
6700
+ }
6701
+ }
6702
+ return { status: 'dup', id: existing.id, item: existing }
6703
+ }
6704
+ if (live.length >= CONTEXT_MAX) return { status: 'full' }
6705
+ const actions = actionsRef.current
6706
+ if (typeof actions?.captureInsertion !== 'function') return { status: 'failed' }
6707
+ let span
6708
+ try {
6709
+ const insertion = actions.captureInsertion()
6710
+ // 折叠到选区末尾:只插入、不替换用户已选中的草稿文字。
6711
+ span = { start: insertion.end, end: insertion.end, draftRev: insertion.draftRev }
6712
+ } catch { return { status: 'failed' } }
6713
+ // 显示序号按当前输入框里的条目数重排(Trae 每个草稿从 1 开始)。
6714
+ const seq = live.reduce((max, entry) => Math.max(max, entry.seq ?? 0), 0) + 1
6715
+ const id = `${Date.now().toString(36)}${(++selectionIdSeq).toString(36)}`
6716
+ const item = {
6717
+ id, sessionId, seq,
6718
+ text: clean,
6719
+ comment: comment === undefined ? '' : String(comment ?? '').slice(0, CONTEXT_COMMENT_MAX).trim(),
6720
+ anchor: anchorEl instanceof Element ? new WeakRef(anchorEl) : null,
6721
+ sent: false,
6722
+ archived: false,
6723
+ at: Date.now(),
6724
+ }
6725
+ if (!applySelectionInsert(sessionId, item, span)) return { status: 'failed' }
6726
+ selectionItems.set(id, item)
6727
+ bump()
6728
+ syncSelectionContext(sessionId, [...live, item], false)
6729
+ persistSelectionItems()
6730
+ return { status: 'added', id, item }
6731
+ }
6732
+
6733
+ /** 删除一条选段:chip + 条目 + 服务端同步(hover × / 编辑器移除共用)。 */
6734
+ const dropSelectionItem = (item) => {
6735
+ if (!item) return
6736
+ const span = chipSpanOf(snapshotRef.current.occurrences, item.id, snapshotRef.current.draftRev)
6737
+ if (span) applySelectionRemove(item.sessionId, item, span)
6738
+ selectionItems.delete(item.id)
6739
+ bump()
6740
+ syncSelectionContext(item.sessionId, liveSelectionItems(item.sessionId, snapshotRef.current.occurrences), false)
6741
+ persistSelectionItems()
6742
+ }
6743
+
6744
+ React.useEffect(() => {
6745
+ const was = previousPhase.current
6746
+ previousPhase.current = phase
6747
+ if (was !== 'submitting' && phase === 'submitting') flushForSend()
6748
+ }, [phase, sessionId])
6749
+
6750
+ // 发送按钮 / Enter 比 phase 更早、更可靠;window capture 先于宿主监听。
6751
+ React.useEffect(() => {
6752
+ if (!sessionId) return undefined
6753
+ const onClick = (event) => {
6754
+ const target = event.target
6755
+ if (target?.closest?.('button[aria-label*="发送消息"], button[aria-label*="Send"], button[aria-label*="send"]')) flushForSend()
6756
+ }
6757
+ const onKey = (event) => {
6758
+ if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return
6759
+ if (!event.target?.closest?.('[contenteditable="true"]')) return
6760
+ flushForSend()
6761
+ }
6762
+ window.addEventListener('click', onClick, true)
6763
+ window.addEventListener('keydown', onKey, true)
6764
+ return () => {
6765
+ window.removeEventListener('click', onClick, true)
6766
+ window.removeEventListener('keydown', onKey, true)
6767
+ }
6768
+ }, [sessionId])
6769
+
6770
+ // 草稿里的 chip 被键盘删掉 / 发送后清空:以 occurrences 为真相修剪条目。
6771
+ React.useEffect(() => {
6772
+ if (!sessionId) return
6773
+ const refs = new Set()
6774
+ for (const occ of occurrences ?? []) if (occ.source === SELECTION_SOURCE) refs.add(occ.ref)
6775
+ let changed = false
6776
+ for (const [id, item] of selectionItems) {
6777
+ if (item.sessionId !== sessionId) continue
6778
+ if (item.archived) {
6779
+ // 草稿恢复出的历史 chip:重新激活,参与同步
6780
+ if (refs.has(id)) { item.archived = false; item.sent = false; changed = true }
6781
+ continue
6782
+ }
6783
+ if (refs.has(id)) continue
6784
+ if (item.sent) { item.archived = true; changed = true; continue }
6785
+ selectionItems.delete(id)
6786
+ changed = true
6787
+ }
6788
+ if (changed) bump()
6789
+ syncSelectionContext(sessionId, liveSelectionItems(sessionId, occurrences), false)
6790
+ persistSelectionItems()
6791
+ }, [occurrences, sessionId])
6792
+
6793
+ // 详情弹窗请求(chip 点击 → source.openReference;添加成功后也会主动打开)。
6794
+ React.useEffect(() => {
6795
+ if (!sessionId) return undefined
6796
+ const listener = () => {
6797
+ const request = selectionDetailRequest
6798
+ if (!request || request.sessionId !== sessionId) return
6799
+ selectionDetailRequest = null
6800
+ const item = selectionItems.get(request.id)
6801
+ if (!item) return
6802
+ setEditor(null)
6803
+ setDialog({ id: request.id })
6804
+ }
6805
+ selectionDetailSubs.add(listener)
6806
+ listener()
6807
+ return () => { selectionDetailSubs.delete(listener) }
6808
+ }, [sessionId])
6809
+
6810
+ // Trae 的两个入口(previewCommentCard):
6811
+ // - 添加到对话:先插 chip,再把选区旁的卡片原位切成评论编辑器(addThenComment);
6812
+ // - 评论到对话:先弹评论卡片,保存后才插 chip(带评论)。
6813
+ React.useEffect(() => {
6814
+ if (!sessionId) return undefined
6815
+ const add = (text, anchorEl, rect) => {
6816
+ const result = insertSelection(text, anchorEl, undefined)
6817
+ if (result.status === 'added' || result.status === 'dup') {
6818
+ const item = result.item ?? (result.id ? selectionItems.get(result.id) : null)
6819
+ if (item) {
6820
+ // Trae addThenComment:chips 已经进输入框,卡片原位切换为评论编辑。
6821
+ setDialog(null)
6822
+ setEditor({
6823
+ id: item.id,
6824
+ text: item.text,
6825
+ comment: item.comment ?? '',
6826
+ anchorEl: anchorEl instanceof Element ? anchorEl : null,
6827
+ rect: normalizeSelectionRect(rect, anchorEl),
6828
+ })
6829
+ }
6830
+ }
6831
+ return result.status
6832
+ }
6833
+ const openComment = (text, anchorEl, rect) => {
6834
+ const clean = String(text ?? '').trim().slice(0, CONTEXT_TEXT_MAX)
6835
+ if (!clean) return 'empty'
6836
+ const live = liveSelectionItems(sessionId, snapshotRef.current.occurrences)
6837
+ const existing = live.find((entry) => entry.text === clean)
6838
+ if (!existing && live.length >= CONTEXT_MAX) return 'full'
6839
+ setDialog(null)
6840
+ setEditor({
6841
+ id: existing?.id ?? null,
6842
+ text: clean,
6843
+ comment: existing?.comment ?? '',
6844
+ anchorEl: anchorEl instanceof Element ? anchorEl : null,
6845
+ rect: normalizeSelectionRect(rect, anchorEl),
6846
+ })
6847
+ return 'opened'
6848
+ }
6849
+ window.__dshSuperinputAddContext = add
6850
+ window.__dshSuperinputCommentContext = openComment
6851
+ // Trae dragDropMention 同义:拖放进来的引用只插 chip,不弹评论卡
6852
+ //(拖拽是快速引用手势;要评论走划选菜单的「评论到对话」)。
6853
+ window.__dshSuperinputDropContext = (text, anchorEl) => insertSelection(text, anchorEl, undefined)
6854
+ return () => {
6855
+ if (window.__dshSuperinputAddContext === add) delete window.__dshSuperinputAddContext
6856
+ if (window.__dshSuperinputCommentContext === openComment) delete window.__dshSuperinputCommentContext
6857
+ if (window.__dshSuperinputDropContext === insertSelection) delete window.__dshSuperinputDropContext
6858
+ }
6859
+ }, [sessionId])
6860
+
6861
+ // chip 悬停时左侧图标变 ×(Trae remove-mention-icon);点左侧 20px 移除。
6862
+ React.useEffect(() => {
6863
+ if (!sessionId) return undefined
6864
+ const onClick = (event) => {
6865
+ const target = event.target
6866
+ const chip = target?.closest?.('span[title]')
6867
+ if (!chip || !chip.closest?.('[contenteditable="true"]')) return
6868
+ if (!String(chip.title || '').startsWith(CHIP_MARK)) return
6869
+ const rect = chip.getBoundingClientRect()
6870
+ if (event.clientX > rect.left + 20 || !chip.matches(':hover')) return
6871
+ const label = String(chip.title)
6872
+ const item = [...selectionItems.values()].find((entry) => entry.sessionId === sessionId && chipLabelOf(entry) === label)
6873
+ if (!item) return
6874
+ event.preventDefault()
6875
+ event.stopPropagation()
6876
+ dropSelectionItem(item)
6877
+ }
6878
+ document.addEventListener('click', onClick, true)
6879
+ return () => document.removeEventListener('click', onClick, true)
6880
+ }, [sessionId])
6881
+
6882
+ const navigateTo = (item) => {
6883
+ try {
6884
+ const el = item.anchor?.deref?.()
6885
+ if (el?.isConnected) {
6886
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
6887
+ el.classList.add('dsh-cp-flash')
6888
+ setTimeout(() => el.classList.remove('dsh-cp-flash'), 1600)
6889
+ }
6890
+ } catch { /* 锚点失效:静默降级 */ }
6891
+ }
6892
+
6893
+ /** 评论卡片保存:已有条目更新评论,新条目插 chip(带评论)。 */
6894
+ const saveEditorComment = (comment) => {
6895
+ const draft = editor
6896
+ if (!draft) return
6897
+ const next = String(comment ?? '').slice(0, CONTEXT_COMMENT_MAX).trim()
6898
+ const existing = draft.id ? selectionItems.get(draft.id) : null
6899
+ if (existing) {
6900
+ if (next !== (existing.comment ?? '')) {
6901
+ existing.comment = next
6902
+ const span = chipSpanOf(snapshotRef.current.occurrences, existing.id, snapshotRef.current.draftRev)
6903
+ if (span) applySelectionInsert(existing.sessionId, existing, span)
6904
+ bump()
6905
+ syncSelectionContext(existing.sessionId, liveSelectionItems(existing.sessionId, snapshotRef.current.occurrences), false)
6906
+ persistSelectionItems()
6907
+ window.__dshSuperinputToast?.('已保存评论')
6908
+ }
6909
+ } else {
6910
+ const result = insertSelection(draft.text, draft.anchorEl, next)
6911
+ if (result.status === 'added' || result.status === 'dup') window.__dshSuperinputToast?.('已添加到对话')
6912
+ else if (result.status === 'full') window.__dshSuperinputToast?.(`最多添加 ${CONTEXT_MAX} 段对话内容`)
6913
+ else if (result.status === 'failed') window.__dshSuperinputToast?.('保存失败')
6914
+ }
6915
+ setEditor(null)
6916
+ }
6917
+
6918
+ const dialogItem = dialog ? selectionItems.get(dialog.id) : null
6919
+ const editorNode = editor ? h(SelectionCommentEditor, {
6920
+ key: `comment:${editor.id ?? 'new'}:${editor.text}`,
6921
+ draft: editor,
6922
+ onSave: saveEditorComment,
6923
+ onCancel: () => setEditor(null),
6924
+ }) : null
6925
+ const detailNode = dialogItem ? createPortal(h(SelectionDetail, {
6926
+ title: `${dialogItem.seq}. ${previewOf(dialogItem.text, 48)}`,
6927
+ text: dialogItem.text,
6928
+ comment: dialogItem.comment,
6929
+ onNavigate: dialogItem.anchor ? () => navigateTo(dialogItem) : null,
6930
+ onClose: () => setDialog(null),
6931
+ }), document.body) : null
6932
+ if (!editorNode && !detailNode) return null
6933
+ return h(React.Fragment, null, editorNode, detailNode)
6934
+ }
6935
+
6936
+ /** 消息正文的选段 chip:user-text 规则把 token 换成同款标签(Trae 消息里的
6937
+ * mention chip 同义)。刷新后条目由 sessionStorage 恢复。 */
6938
+ const selectionUserTextRule = {
6939
+ id: SELECTION_RULE,
6940
+ order: 5,
6941
+ match(text, from) {
6942
+ const re = /\[选段#([0-9a-z]+)\]/g
6943
+ re.lastIndex = from
6944
+ const found = re.exec(text)
6945
+ if (!found) return undefined
6946
+ const item = selectionItems.get(found[1])
6947
+ if (!item) return undefined
6948
+ return {
6949
+ id: SELECTION_RULE,
6950
+ start: found.index,
6951
+ end: found.index + found[0].length,
6952
+ data: { id: item.id, seq: item.seq, text: item.text, comment: item.comment },
6953
+ }
6954
+ },
6955
+ }
6956
+
6957
+ function SelectionMessageChip({ node, interactive }) {
6958
+ const data = node?.data ?? {}
6959
+ const [open, setOpen] = React.useState(false)
6960
+ const label = chipTextOf(data.seq, data.text, data.comment)
6961
+ return h(React.Fragment, null,
6962
+ h('span', {
6963
+ className: `dsh-si-msgchip${interactive ? ' interactive' : ''}`,
6964
+ title: data.text,
6965
+ onClick: interactive ? () => setOpen(true) : undefined,
6966
+ },
6967
+ h('span', { className: 'dsh-si-msgchip-icon', 'aria-hidden': true }),
6968
+ h('span', { className: 'dsh-si-msgchip-label' }, label)),
6969
+ open ? createPortal(h(SelectionDetail, {
6970
+ title: label,
6971
+ text: data.text,
6972
+ comment: data.comment,
6973
+ onClose: () => setOpen(false),
6974
+ }), document.body) : null)
6975
+ }
6976
+
6977
+ /** 草稿桥:划选「添加评论」、模板插入、/ 命令插入、横条「引用文本」共用。
6978
+ * 挂 conversation.input.dock——该槽位会注入 useInput / inputActions;
6979
+ * composer.dock 不注入这些 props。片段模块移除时桥被一并删掉,这里补回。 */
6980
+ function PromptsDraftBridge({ useInput, inputActions }) {
6981
+ const draft = useInput((state) => state?.draft)
6982
+ React.useEffect(() => {
6983
+ window.__dshSuperinputDraft = () => (typeof draft === 'string' ? draft : '')
6984
+ if (typeof inputActions?.setDraft === 'function') {
6985
+ window.__dshSuperinputSetDraft = (value) => {
6986
+ try { inputActions.setDraft(value) } catch { /* ignore */ }
6987
+ }
6988
+ }
6989
+ // 官方「引用文本」同款 API(0.2.2-rc.1 起可用):plain 模式下插 blockquote
6990
+ window.__dshSuperinputInsertQuote = (text) => {
6991
+ try { return typeof inputActions?.insertQuote === 'function' && inputActions.insertQuote(text) === true } catch { return false }
6992
+ }
6993
+ }, [draft, inputActions])
6994
+ return null
6995
+ }
6996
+
4957
6997
  function PromptsButton({ onSyncSlash }) {
4958
6998
  const [open, setOpen] = React.useState(false)
4959
6999
  const ref = React.useRef(null)
@@ -4967,7 +7007,7 @@ function createPromptsClient(require) {
4967
7007
  }
4968
7008
 
4969
7009
  return {
4970
- inject: ['slots'],
7010
+ inject: ['slots', 'sessions', 'uiConversation'],
4971
7011
  apply(ctx) {
4972
7012
  ensureStyle()
4973
7013
  installSelectionSaver()
@@ -5024,6 +7064,70 @@ function createPromptsClient(require) {
5024
7064
  id: 'dsh-superinput-prompts-btn',
5025
7065
  order: 4,
5026
7066
  }, (props) => h(PromptsBoundary, null, h(PromptsButton, { ...props, onSyncSlash: syncSlash }))))
7067
+
7068
+ /* 划选引用的官方 input source:codec 决定 chip 的粘贴文本与送模文本;
7069
+ * candidates 恒空——只是借 @ 触发器的注册位拿到一个合法的自定义
7070
+ * source(官方 chip 插入/序列化按 source 名路由),不往 @ 菜单加条目。 */
7071
+ const selectionSource = {
7072
+ trigger: '@',
7073
+ name: SELECTION_SOURCE,
7074
+ showGroupTitle: false,
7075
+ async candidates() { return [] },
7076
+ onPick() { return undefined },
7077
+ openReference(session, reference) {
7078
+ const targetSession = session?.sessionId === undefined ? '' : String(session.sessionId)
7079
+ const ref = String(reference?.ref ?? '')
7080
+ if (!targetSession || !selectionItems.has(ref)) return false
7081
+ requestSelectionDetail(targetSession, ref, false)
7082
+ return true
7083
+ },
7084
+ codec: {
7085
+ clipboardText: (ref) => tokenOf(ref),
7086
+ serialize: async (ref) => tokenOf(ref),
7087
+ },
7088
+ }
7089
+ ctx.inject(['inputTriggers'], (scope) => {
7090
+ try {
7091
+ const service = scope.get('inputTriggers')
7092
+ ctx.effect(() => service.registerSource(selectionSource), 'dsh-superinput: selection source')
7093
+ } catch (error) {
7094
+ report(`selection source register failed: ${error?.message ?? error}`)
7095
+ }
7096
+ })
7097
+ ctx.inject(['sessions'], (scope) => {
7098
+ selectionSessions = scope.get('sessions')
7099
+ })
7100
+
7101
+ // 消息正文里的同款 chip:user-text 规则 + 节点渲染槽位。
7102
+ ctx.inject(['uiConversation'], (scope) => {
7103
+ try {
7104
+ const ui = scope.get('uiConversation')
7105
+ if (typeof ui?.userText?.register === 'function') {
7106
+ ctx.effect(() => ui.userText.register(selectionUserTextRule), 'dsh-superinput: selection user-text rule')
7107
+ }
7108
+ } catch (error) {
7109
+ report(`selection user-text rule failed: ${error?.message ?? error}`)
7110
+ }
7111
+ })
7112
+ ctx.slots.inject('conversation.user.text.node', () => ctx.slots.register({
7113
+ name: 'conversation.user.text.node',
7114
+ key: SELECTION_RULE,
7115
+ priority: 20,
7116
+ }, SelectionMessageChip))
7117
+
7118
+ // 草稿桥挂 input.dock(该槽位注入 useInput / inputActions)
7119
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
7120
+ name: 'conversation.input.dock',
7121
+ id: 'dsh-superinput-prompts-bridge',
7122
+ order: 96,
7123
+ }, PromptsDraftBridge))
7124
+
7125
+ // 划选引用桥:chip 已内联在编辑器里,这个槽位只提供入口与详情弹窗。
7126
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
7127
+ name: 'conversation.input.dock',
7128
+ id: 'dsh-superinput-selection-context',
7129
+ order: 97,
7130
+ }, (props) => h(PromptsBoundary, null, h(SelectionContextBridge, props))))
5027
7131
  },
5028
7132
  }
5029
7133
  }
@@ -5258,6 +7362,7 @@ window.__ModuleLoader__.load({
5258
7362
  ['followup', createFollowupClient],
5259
7363
  ['voice', createVoiceClient],
5260
7364
  ['prompts', createPromptsClient],
7365
+ ['compaction', createCompactionClient],
5261
7366
  ['hub', createHubClient],
5262
7367
  ]
5263
7368
  // 模块异常上报:console 不落盘,失败必须走 prompts /diag 留痕。