dsh-compact-relay 0.3.6 → 0.4.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/lib/client.js CHANGED
@@ -742,6 +742,59 @@ window.__ModuleLoader__.load({
742
742
  );
743
743
  };
744
744
 
745
+ /**
746
+ * 0.4.1:原生自动压缩线「配置阈值 vs 实际触发线」。
747
+ * DSH 按 token 预算触发:min(窗口×配置阈值, 窗口−输出预留−余量),
748
+ * 所以实际触发点常比配置比例更早(deepseek-flash 配置 80%、实际 67.85%)。
749
+ * autoEnabled=false 时 effective/percentText 为空(未开启),但 actualText 仍给出
750
+ * 「开启后实际会触发多少」,供关闭态文案使用。
751
+ * @param {object|null} native readState 下发的 native 字段
752
+ * @returns {{configured:number|null,configuredText:string|null,actual:number|null,actualText:string,effective:number|null,percentText:string,differs:boolean,autoOff:boolean,why:string}}
753
+ */
754
+ const nativeLineInfo = (native) => {
755
+ const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : null);
756
+ const ratioOf = (value) => {
757
+ const n = num(value);
758
+ return n !== null && n > 0 && n < 1 ? n : null;
759
+ };
760
+ const src = native === null || native === undefined ? null : native;
761
+ const configured = ratioOf(src === null ? null : src.thresholdRatio);
762
+ const actual = ratioOf(src === null ? null : src.ratio);
763
+ const autoOff = src !== null && src.autoEnabled === false;
764
+ const effective = autoOff ? null : actual !== null ? actual : configured;
765
+ const pctText = (value) => {
766
+ const p = value * 100;
767
+ return Math.abs(p - Math.round(p)) < 0.05 ? String(Math.round(p)) : p.toFixed(1);
768
+ };
769
+ const configuredText = configured === null ? null : pctText(configured);
770
+ const actualText = actual === null ? '' : pctText(actual);
771
+ const percentText = effective === null ? '' : pctText(effective);
772
+ const differs = configured !== null && effective !== null && Math.abs(effective - configured) >= 0.005;
773
+ let why = '';
774
+ if (differs) {
775
+ const overridden = src.overridden === true;
776
+ const windowTokens = num(src.contextWindow);
777
+ const reserved = num(src.reservedCompletionTokens);
778
+ const headroom = num(src.headroomTokens);
779
+ // 只有「预算分支确实更小」时,才能把差异归因为输出预留/余量。
780
+ const budgetTokens = windowTokens !== null ? windowTokens - (reserved ?? 0) - (headroom ?? 0) : null;
781
+ const budgetBinding = budgetTokens !== null && configured !== null && budgetTokens < windowTokens * configured;
782
+ const parts = [];
783
+ if (reserved !== null && reserved > 0) parts.push(`模型输出要预留 ${fmt(reserved)}`);
784
+ if (headroom !== null && headroom > 0) parts.push(`DSH 自己还要留 ${fmt(headroom)} 余量`);
785
+ if (overridden) {
786
+ why = `这个模型单独配了阈值(按路由覆盖),实际触发是 ${actualText}%(全局配置 ${configuredText}%)。`;
787
+ } else if (budgetBinding && parts.length > 0) {
788
+ why = `配置阈值 ${configuredText}% 只是理论上限;${parts.join('、')},所以实际打到 ${percentText}% 就会自动压。`;
789
+ } else if (actual !== null && configured !== null && actual > configured) {
790
+ why = `这个路由的输出预留更小,实际触发晚于配置阈值 ${configuredText}%(实际 ${actualText}%)。`;
791
+ } else {
792
+ why = `实际触发 ${actualText}% 与配置阈值 ${configuredText}% 不同,按宿主 token 预算算的。`;
793
+ }
794
+ }
795
+ return { configured, configuredText, actual, actualText, effective, percentText, differs, autoOff, why };
796
+ };
797
+
745
798
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
746
799
 
747
800
  /**
@@ -818,6 +871,14 @@ window.__ModuleLoader__.load({
818
871
  const { sessionId, waitAndOpen } = props;
819
872
  const hasSession = typeof sessionId === 'string' && sessionId.length > 0;
820
873
  const [state, setState] = useState({ advice: 'none', pressure: null, window: null, summaries: 0, colors: null, compactRange: undefined, busy: false, pins: { count: 0, seqs: [], marks: [] }, compressHint: null, native: null });
874
+ // 0.4 P2-3:原生自动压缩的「有效线」——优先真实触发线(thresholdTokens/window),
875
+ // 老宿主/算不出时回落 thresholdRatio(窗口比例)。
876
+ const effectiveNativeRatio = (native) => {
877
+ if (native === null || native === undefined || native.autoEnabled === false) return null;
878
+ if (typeof native.ratio === 'number' && Number.isFinite(native.ratio) && native.ratio > 0 && native.ratio < 1) return native.ratio;
879
+ if (typeof native.thresholdRatio === 'number' && Number.isFinite(native.thresholdRatio) && native.thresholdRatio > 0) return native.thresholdRatio;
880
+ return null;
881
+ };
821
882
  const [open, setOpen] = useState(false);
822
883
  const [busy, setBusy] = useState(false);
823
884
  const [hover, setHover] = useState(false);
@@ -877,6 +938,8 @@ window.__ModuleLoader__.load({
877
938
  }, []);
878
939
  // A1b:交接预览(九段草稿 + 就绪清单),只读不落盘。
879
940
  const [handoffPreview, setHandoffPreview] = useState(null);
941
+ /** 0.4 P2-1:交接预览里「约束 / 下一步」两段的编辑值(键=段标题;空值回落原文)。 */
942
+ const [handoffEditValues, setHandoffEditValues] = useState({});
880
943
  const handoffPreviewRef = useRef(null);
881
944
  const handoffPreviewAbortRef = useRef(null);
882
945
  const closeHandoffPreview = useCallback(() => {
@@ -936,13 +999,20 @@ window.__ModuleLoader__.load({
936
999
  : '/api/compact-relay-state';
937
1000
 
938
1001
  const applierRef = useRef(() => {});
1002
+ // 0.3.7:最后一帧(SSE 帧或轮询响应)的到达时间。远程/代理链路可能让 SSE
1003
+ // 「onopen 已连上、之后却一帧都不来」地静默挂住,表现为压缩后界面不刷新
1004
+ // (Win 远程客户端实测:手动刷新才变)。帧静默超时后由兜底轮询补拉。
1005
+ const lastFrameAtRef = useRef(0);
939
1006
  const fetchState = useCallback(async () => {
940
1007
  if (!hasSession) return;
941
1008
  try {
942
1009
  const res = await fetch(url, { cache: 'no-store' });
943
1010
  if (!res.ok) return;
944
1011
  const data = await res.json();
945
- if (data && typeof data === 'object') applierRef.current(data);
1012
+ if (data && typeof data === 'object') {
1013
+ lastFrameAtRef.current = Date.now();
1014
+ applierRef.current(data);
1015
+ }
946
1016
  } catch {
947
1017
  /* host 不在就保持上一次状态 */
948
1018
  }
@@ -999,6 +1069,33 @@ window.__ModuleLoader__.load({
999
1069
  typeof data.native.thresholdRatio === 'number' && Number.isFinite(data.native.thresholdRatio)
1000
1070
  ? Math.max(0, Math.min(1, data.native.thresholdRatio))
1001
1071
  : 0.8,
1072
+ // 0.4 P2-3:真实触发线(thresholdTokens/window);宿主算不出时为 null。
1073
+ ratio:
1074
+ typeof data.native.ratio === 'number' && Number.isFinite(data.native.ratio)
1075
+ ? Math.max(0, Math.min(1, data.native.ratio))
1076
+ : null,
1077
+ thresholdTokens:
1078
+ typeof data.native.thresholdTokens === 'number' && Number.isFinite(data.native.thresholdTokens)
1079
+ ? data.native.thresholdTokens
1080
+ : null,
1081
+ contextWindow:
1082
+ typeof data.native.contextWindow === 'number' && Number.isFinite(data.native.contextWindow)
1083
+ ? data.native.contextWindow
1084
+ : null,
1085
+ headroomTokens:
1086
+ typeof data.native.headroomTokens === 'number' && Number.isFinite(data.native.headroomTokens)
1087
+ ? data.native.headroomTokens
1088
+ : null,
1089
+ // 0.4.1:悬停卡解释「为什么实际触发比配置阈值早」要用到输出预留。
1090
+ reservedCompletionTokens:
1091
+ typeof data.native.reservedCompletionTokens === 'number' && Number.isFinite(data.native.reservedCompletionTokens)
1092
+ ? data.native.reservedCompletionTokens
1093
+ : null,
1094
+ overridden: data.native.overridden === true,
1095
+ route:
1096
+ data.native.route && typeof data.native.route === 'object' && typeof data.native.route.model === 'string'
1097
+ ? { provider: String(data.native.route.provider ?? ''), model: data.native.route.model }
1098
+ : null,
1002
1099
  }
1003
1100
  : null,
1004
1101
  colors: data.colors && typeof data.colors === 'object' ? data.colors : null,
@@ -1040,8 +1137,12 @@ window.__ModuleLoader__.load({
1040
1137
  if (dead) return;
1041
1138
  streamLiveRef.current = true;
1042
1139
  setStreamLive(true);
1140
+ lastFrameAtRef.current = Date.now();
1141
+ // 0.3.7:刚连上先拉一次,补齐「断开期间错过的帧」(重连场景)。
1142
+ void fetchState();
1043
1143
  };
1044
1144
  source.onmessage = (event) => {
1145
+ lastFrameAtRef.current = Date.now();
1045
1146
  try {
1046
1147
  applyState(JSON.parse(event.data));
1047
1148
  } catch {
@@ -1073,14 +1174,17 @@ window.__ModuleLoader__.load({
1073
1174
  // 兜底轮询:
1074
1175
  // - 还没就绪(会话刚打开 / 没有 agent)→ 1.5s 快轮询(修「重启后停在上下文未知」);
1075
1176
  // - 已就绪但 SSE 没连上 → 5s 轮询兜底;
1076
- // - 已就绪且 SSE 活着 → 不主动发请求,只留 5s 计时器。
1177
+ // - 已就绪且 SSE 活着 → 5s 计时器只检查「帧是否静默超时」;
1178
+ // - 0.3.7:SSE 活着但 30s 没来帧(远程/代理链路静默挂住)→ 补拉一次,
1179
+ // 修「压缩后远程客户端不刷新、要手动刷新才变」。
1077
1180
  useEffect(() => {
1078
1181
  if (!hasSession) return;
1079
1182
  let timer;
1080
1183
  let dead = false;
1081
1184
  const tick = () => {
1082
1185
  if (dead) return;
1083
- if (!readyRef.current || !streamLiveRef.current) void fetchState();
1186
+ const sseSilent = streamLiveRef.current && Date.now() - lastFrameAtRef.current > 30000;
1187
+ if (!readyRef.current || !streamLiveRef.current || sseSilent) void fetchState();
1084
1188
  timer = setTimeout(tick, readyRef.current ? 5000 : 1500);
1085
1189
  };
1086
1190
  tick();
@@ -1090,6 +1194,24 @@ window.__ModuleLoader__.load({
1090
1194
  };
1091
1195
  }, [fetchState, hasSession]);
1092
1196
 
1197
+ // 0.3.7:窗口重新可见 / 重新获得焦点 / 网络恢复时拉一次——覆盖「页面被挂起后
1198
+ // 计时器被节流、SSE 断流」的场景(远程客户端切走再切回来也会走到这里)。
1199
+ useEffect(() => {
1200
+ if (!hasSession) return;
1201
+ const refresh = () => {
1202
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
1203
+ void fetchState();
1204
+ };
1205
+ document.addEventListener('visibilitychange', refresh);
1206
+ window.addEventListener('focus', refresh);
1207
+ window.addEventListener('online', refresh);
1208
+ return () => {
1209
+ document.removeEventListener('visibilitychange', refresh);
1210
+ window.removeEventListener('focus', refresh);
1211
+ window.removeEventListener('online', refresh);
1212
+ };
1213
+ }, [fetchState, hasSession]);
1214
+
1093
1215
 
1094
1216
  // 点外面 / Esc 关闭菜单、压缩确认卡与交接单面板。
1095
1217
  useEffect(() => {
@@ -1188,7 +1310,7 @@ window.__ModuleLoader__.load({
1188
1310
  // DSH 原生自动压缩线(报警最高阈值):开着时在环上画一个「径向刻度」(不是沿环的弧),
1189
1311
  // 用紫色 + 主题底色描边——刻意不用五级配色里的颜色,保证在任何分段颜色上都看得清。
1190
1312
  // 越过原生线后:整环红色闪烁(作者 2026-10-20 反馈)。
1191
- const nativeAuto = state.native !== null && state.native.autoEnabled ? state.native.thresholdRatio : null;
1313
+ const nativeAuto = effectiveNativeRatio(state.native);
1192
1314
  const nativeValid = nativeAuto !== null && nativeAuto > 0.02 && nativeAuto < 1;
1193
1315
  const nativeCrossed = nativeValid && ratio > nativeAuto;
1194
1316
  // 断点缺口:0.9 SVG 单位(≈6.4°),折算成比例;段太短时缺口会吃掉整段,就跳过该段。
@@ -1310,11 +1432,11 @@ window.__ModuleLoader__.load({
1310
1432
  // 配合指示灯呼吸光晕,解决「指示灯太安静、新用户不知道这里有个菜单」。
1311
1433
  // 设置页「再看一次初次引导」按钮通过 window 事件重播(两个组件同页,用事件解耦)。
1312
1434
  const onboardingText = useCallback(() => {
1313
- const nativeRatio = state.native !== null && state.native.autoEnabled !== false ? state.native.thresholdRatio : null;
1435
+ const nativeRatio = effectiveNativeRatio(state.native);
1314
1436
  const nativePart =
1315
1437
  nativeRatio === null
1316
1438
  ? 'DSH 原生自动压缩已关闭'
1317
- : `DSH 原生自动压缩线在 ${(nativeRatio * 100).toFixed(0)}%,到那之前先压缩或交接更稳`;
1439
+ : `DSH 原生自动压缩线在 ${(nativeRatio * 100).toFixed(1)}%,到那之前先压缩或交接更稳`;
1318
1440
  return `这是上下文指示灯:点它压缩旧会话、锁定重要消息、交接新会话。${nativePart}。环上的小缺口是我判断「该压缩」的位置(模型判断或到线自动出现)。重要的结论/路径先锁定,压缩时才不会被压掉。`;
1319
1441
  }, [state.native]);
1320
1442
 
@@ -1437,6 +1559,7 @@ window.__ModuleLoader__.load({
1437
1559
  setPreview(null);
1438
1560
  closeDocs();
1439
1561
  closeDiff();
1562
+ setHandoffEditValues({});
1440
1563
  setHandoffPreview({ loading: true });
1441
1564
  const controller = typeof AbortController === 'function' ? new AbortController() : null;
1442
1565
  handoffPreviewAbortRef.current = controller;
@@ -1626,7 +1749,7 @@ window.__ModuleLoader__.load({
1626
1749
  }, [effectiveBusy, sessionId]);
1627
1750
 
1628
1751
  const run = useCallback(
1629
- async (action) => {
1752
+ async (action, extra) => {
1630
1753
  // A3:确认必须带预览时拿到的 planId,宿主重算不一致会以 changed 拒绝。
1631
1754
  const expected =
1632
1755
  action === 'compact' && preview !== null && preview.loading !== true && typeof preview.data?.planId === 'string'
@@ -1651,6 +1774,8 @@ window.__ModuleLoader__.load({
1651
1774
  body: JSON.stringify({
1652
1775
  action,
1653
1776
  ...(expected === undefined ? {} : { expected }),
1777
+ // 0.4 P2-1:`extra.edits` = 只含用户真正改过的「约束/下一步」两段。
1778
+ ...(extra !== undefined && extra !== null && typeof extra === 'object' ? extra : {}),
1654
1779
  ...(typeof sessionId === 'string' && sessionId.length > 0 ? { sessionId } : {}),
1655
1780
  }),
1656
1781
  ...(controller === null ? {} : { signal: controller.signal }),
@@ -1690,11 +1815,19 @@ window.__ModuleLoader__.load({
1690
1815
  state.compressHint !== null && typeof state.compressHint.ratio === 'number'
1691
1816
  ? `压缩判断点 ${(state.compressHint.ratio * 100).toFixed(0)}%`
1692
1817
  : '';
1818
+ const nativeEffective = effectiveNativeRatio(state.native);
1819
+ const nativeDetail =
1820
+ state.native !== null && state.native.thresholdTokens !== null && state.native.contextWindow !== null
1821
+ ? `(${fmt(state.native.thresholdTokens)} / ${fmt(state.native.contextWindow)}${
1822
+ state.native.route !== null ? `,${state.native.route.provider}/${state.native.route.model}` : ''
1823
+ }${state.native.overridden ? ',按路由覆盖' : ''})`
1824
+ : '';
1825
+ const nativeInfo = nativeLineInfo(state.native);
1693
1826
  const nativeLineText =
1694
- state.native !== null && state.native.autoEnabled !== false && typeof state.native.thresholdRatio === 'number'
1695
- ? `原生自动压缩线 ${(state.native.thresholdRatio * 100).toFixed(0)}%${
1696
- ratio > state.native.thresholdRatio ? '(已越过:整环红色闪烁)' : ''
1697
- }`
1827
+ nativeEffective !== null
1828
+ ? `原生自动压缩线 ${(nativeEffective * 100).toFixed(1)}%${nativeDetail}${
1829
+ nativeInfo.differs ? `;${nativeInfo.why}` : ''
1830
+ }${ratio > nativeEffective ? '(已越过:整环红色闪烁)' : ''}`
1698
1831
  : 'DSH 原生自动压缩已关闭';
1699
1832
  const pressureKnown = pressureValue !== null;
1700
1833
  const title =
@@ -2030,6 +2163,12 @@ window.__ModuleLoader__.load({
2030
2163
  const previewRange = preview?.data?.preview ?? null;
2031
2164
  // 0.3.1:范围太小时预览阶段就拦下(服务端已按 800 tokens 判定),不让用户白确认一次。
2032
2165
  const previewTooSmall = previewRange !== null && previewRange.tooSmall === true;
2166
+ // 0.4 P1-1/P1-2:建议锁定候选 + pin 命中数(宿主已算好,客户端只渲染;不自动锁)。
2167
+ const pinSuggest =
2168
+ previewRange !== null && previewRange.pinSuggest !== null && typeof previewRange.pinSuggest === 'object' && Array.isArray(previewRange.pinSuggest.items)
2169
+ ? previewRange.pinSuggest
2170
+ : null;
2171
+ const pinHits = previewRange !== null && previewRange.pinHits !== null && typeof previewRange.pinHits === 'object' ? previewRange.pinHits : null;
2033
2172
  const confirmPanel =
2034
2173
  preview === null
2035
2174
  ? null
@@ -2047,6 +2186,8 @@ window.__ModuleLoader__.load({
2047
2186
  maxWidth: 'calc(100vw - 16px)',
2048
2187
  padding: '10px 12px',
2049
2188
  borderRadius: 12,
2189
+ maxHeight: 'min(70vh, 440px)',
2190
+ overflowY: 'auto',
2050
2191
  ...panelChrome(theme),
2051
2192
  color: theme.panelFg,
2052
2193
  zIndex: 42,
@@ -2077,6 +2218,45 @@ window.__ModuleLoader__.load({
2077
2218
  h('br'),
2078
2219
  '只估算范围和容量;摘要内容确认后才生成,不在这里展示。压缩后旧会话将替换为摘要,无法还原为原文。',
2079
2220
  ),
2221
+ pinSuggest !== null && pinSuggest.items.length > 0
2222
+ ? h(
2223
+ 'div',
2224
+ {
2225
+ 'data-cr-pin-suggest': '1',
2226
+ style: {
2227
+ display: 'flex',
2228
+ flexDirection: 'column',
2229
+ gap: 2,
2230
+ padding: '6px 8px',
2231
+ borderRadius: 8,
2232
+ border: `1px solid ${theme.panelBorder}`,
2233
+ background: theme.hover,
2234
+ },
2235
+ },
2236
+ h('div', { style: { fontSize: 11, fontWeight: 700, color: theme.panelFg } }, `建议锁定 ${pinSuggest.items.length} 条(先 /pin 再压)`),
2237
+ ...pinSuggest.items.map((item) =>
2238
+ h(
2239
+ 'div',
2240
+ { key: `pin-suggest-${item.seq}`, style: { fontSize: 11, lineHeight: '16px', color: theme.subText } },
2241
+ `${item.role} · ${item.reason}:${item.snippet}`,
2242
+ ),
2243
+ ),
2244
+ h(
2245
+ 'div',
2246
+ { style: { fontSize: 10, lineHeight: '14px', color: theme.subText } },
2247
+ '只建议、不自动锁定;锁定后这些消息会原样保留(/pin list 可按序号挑)。',
2248
+ ),
2249
+ )
2250
+ : null,
2251
+ pinHits !== null && pinHits.total > 0
2252
+ ? h(
2253
+ 'div',
2254
+ { 'data-cr-pin-hits': '1', style: { fontSize: 11, lineHeight: '16px', color: theme.subText } },
2255
+ pinHits.inRange > 0
2256
+ ? `pin 命中 ${pinHits.inRange} 条:本次范围里被锁定的消息已跳过(会话共 ${pinHits.total} 条锁定)。`
2257
+ : `pin 命中 0 条:本次范围内没有锁定消息(会话共 ${pinHits.total} 条锁定,均在范围外)。`,
2258
+ )
2259
+ : null,
2080
2260
  h(
2081
2261
  'div',
2082
2262
  { style: { display: 'flex', justifyContent: 'flex-end', gap: 6 } },
@@ -2214,6 +2394,46 @@ window.__ModuleLoader__.load({
2214
2394
  : handoffDraft,
2215
2395
  ),
2216
2396
  ),
2397
+ // 0.4 P2-1:只允许编辑「约束」「下一步」两段;其余段由模型/程序生成,UI 不给入口。
2398
+ handoffData !== null && typeof handoffData === 'object' && handoffData.editable !== null && typeof handoffData.editable === 'object'
2399
+ ? h(
2400
+ 'div',
2401
+ { 'data-cr-handoff-editable': '1', style: { display: 'flex', flexDirection: 'column', gap: 6 } },
2402
+ h('div', { style: { fontSize: 11, fontWeight: 600 } }, '可编辑两段(改完点「确认交接」才生效)'),
2403
+ ...(Array.isArray(handoffData.editable.titles) ? handoffData.editable.titles : []).map((title) => {
2404
+ const original = typeof handoffData.editable.sections?.[title] === 'string' ? handoffData.editable.sections[title] : '';
2405
+ return h(
2406
+ 'label',
2407
+ { key: `handoff-edit-${title}`, style: { display: 'flex', flexDirection: 'column', gap: 3, fontSize: 11 } },
2408
+ h('span', { style: { color: theme.subText } }, `${title}(留空 = 使用原文)`),
2409
+ h('textarea', {
2410
+ 'data-cr-handoff-edit': title,
2411
+ value: typeof handoffEditValues[title] === 'string' ? handoffEditValues[title] : original,
2412
+ rows: 3,
2413
+ maxLength: Number.isInteger(handoffData.editable.maxChars) ? handoffData.editable.maxChars : 4000,
2414
+ onChange: (event) => {
2415
+ const value = event.target.value;
2416
+ setHandoffEditValues((previous) => ({ ...previous, [title]: value }));
2417
+ },
2418
+ style: {
2419
+ width: '100%',
2420
+ boxSizing: 'border-box',
2421
+ resize: 'vertical',
2422
+ fontFamily: 'inherit',
2423
+ fontSize: 11,
2424
+ lineHeight: '16px',
2425
+ color: 'inherit',
2426
+ background: 'transparent',
2427
+ border: `1px solid ${theme.panelBorder}`,
2428
+ borderRadius: 6,
2429
+ padding: '4px 6px',
2430
+ },
2431
+ }),
2432
+ );
2433
+ }),
2434
+ h('div', { style: { fontSize: 10, lineHeight: '14px', color: theme.subText } }, handoffData.editable.note ?? '程序生成的段不可改。'),
2435
+ )
2436
+ : null,
2217
2437
  h('div', { style: { fontSize: 11, color: theme.subText } }, '确认后会先压缩旧会话,再按压缩后的历史生成最终交接单;预览不落盘。'),
2218
2438
  h(
2219
2439
  'div',
@@ -2234,8 +2454,18 @@ window.__ModuleLoader__.load({
2234
2454
  type: 'button',
2235
2455
  'data-cr-handoff-run': '1',
2236
2456
  onClick: () => {
2457
+ // P2-1:只把「用户真的改过、且非空」的两段发给宿主;空值/未改 = 用重新生成的原文。
2458
+ const editable = handoffData !== null && typeof handoffData === 'object' ? handoffData.editable : null;
2459
+ const edits = {};
2460
+ if (editable !== null && typeof editable === 'object' && Array.isArray(editable.titles)) {
2461
+ for (const title of editable.titles) {
2462
+ const original = typeof editable.sections?.[title] === 'string' ? editable.sections[title].trim() : '';
2463
+ const current = typeof handoffEditValues[title] === 'string' ? handoffEditValues[title].trim() : '';
2464
+ if (current.length > 0 && current !== original) edits[title] = current;
2465
+ }
2466
+ }
2237
2467
  closeHandoffPreview();
2238
- void run('handoff');
2468
+ void run('handoff', Object.keys(edits).length > 0 ? { edits } : undefined);
2239
2469
  },
2240
2470
  style: {
2241
2471
  border: 'none',
@@ -2837,6 +3067,8 @@ window.__ModuleLoader__.load({
2837
3067
  const [native, setNative] = useState(null);
2838
3068
  /** UI-002:设置文件路径(「关于与危险操作」区块展示)。 */
2839
3069
  const [settingsPath, setSettingsPath] = useState('');
3070
+ /** 0.4 P1-3:轻账本累计数字(独立账本文件算出来的;null = 没有数据/读不到,显示引导文案)。 */
3071
+ const [ledger, setLedger] = useState(null);
2840
3072
  const [confirmNative, setConfirmNative] = useState(null); // null | 'disable' | 'enable'
2841
3073
  const [nativeBusy, setNativeBusy] = useState(false);
2842
3074
  /** UI-003:结果提示就地显示在开关旁(以前只写页面底部,用户看不到)。 */
@@ -2844,22 +3076,78 @@ window.__ModuleLoader__.load({
2844
3076
  /** 作者 2026-10-20:设置页「再看一次初次引导」的就地提示。 */
2845
3077
  const [onboardingNotice, setOnboardingNotice] = useState(null);
2846
3078
  const nativeConfirmRef = useRef(null);
3079
+ /**
3080
+ * 0.4 P0-2:诊断信息(只在本机生成;可预览/复制,插件不会把它发到任何地方)。
3081
+ * diagData 是端点返回的 {schema,version,generatedAt,markdown,json,issueUrl}。
3082
+ */
3083
+ const [diagData, setDiagData] = useState(null);
3084
+ const [diagLoading, setDiagLoading] = useState(false);
3085
+ const [diagOpen, setDiagOpen] = useState(false);
3086
+ /** 弹层顶部说明卡:只在本会话第一次生成时出现一次。 */
3087
+ const [diagIntroSeen, setDiagIntroSeen] = useState(false);
3088
+ const [diagStatus, setDiagStatus] = useState(null);
3089
+ /** 复制/打开结果的小 toast 计时器(A4:成功与失败都要有 toast 反馈)。 */
3090
+ const diagToastTimer = useRef(null);
3091
+
3092
+ /** 就地提示 + 4.2 秒后自动消失的 toast。 */
3093
+ const flashDiagStatus = useCallback((next) => {
3094
+ setDiagStatus(next);
3095
+ if (diagToastTimer.current !== null) clearTimeout(diagToastTimer.current);
3096
+ diagToastTimer.current = setTimeout(() => setDiagStatus(null), 4200);
3097
+ }, []);
3098
+
3099
+ useEffect(
3100
+ () => () => {
3101
+ if (diagToastTimer.current !== null) clearTimeout(diagToastTimer.current);
3102
+ },
3103
+ [],
3104
+ );
3105
+
3106
+ /** 0.4.1:设置接口只给「配置阈值」,实际触发线要从 state 接口补(同一套 readState 数据)。 */
3107
+ const nativeLineExtra = useRef(null);
3108
+ const mergeNative = useCallback((view) => {
3109
+ if (view === null || view === undefined) return null;
3110
+ const extra = nativeLineExtra.current;
3111
+ return extra === null ? view : { ...view, ...extra };
3112
+ }, []);
2847
3113
 
2848
3114
  /** 把服务端返回的 view 落到表单:同时更新「已保存」快照和设置文件错误。 */
2849
3115
  const applyLoaded = useCallback((data) => {
2850
3116
  setForm(data.config);
2851
3117
  setSavedSnapshot(JSON.stringify(data.config));
2852
3118
  setSettingsError(data.settingsError ?? null);
2853
- setNative(data.native && typeof data.native === 'object' ? data.native : null);
3119
+ setNative(mergeNative(data.native && typeof data.native === 'object' ? data.native : null));
2854
3120
  if (typeof data.version === 'string') setVersion(data.version);
2855
3121
  if (typeof data.path === 'string') setSettingsPath(data.path);
2856
- }, []);
3122
+ setLedger(data.ledger !== null && typeof data.ledger === 'object' ? data.ledger : null);
3123
+ }, [mergeNative]);
2857
3124
 
2858
3125
  const load = useCallback(async () => {
2859
3126
  try {
2860
- const res = await fetch('/api/compact-relay-settings', { cache: 'no-store' });
2861
- const data = await res.json().catch(() => ({}));
2862
- if (!res.ok || data.ok === false) throw new Error(data && data.message ? String(data.message) : '读取设置失败');
3127
+ const [settingsRes, stateRes] = await Promise.all([
3128
+ fetch('/api/compact-relay-settings', { cache: 'no-store' }),
3129
+ fetch('/api/compact-relay-state', { cache: 'no-store' }).catch(() => null),
3130
+ ]);
3131
+ const data = await settingsRes.json().catch(() => ({}));
3132
+ if (!settingsRes.ok || data.ok === false) throw new Error(data && data.message ? String(data.message) : '读取设置失败');
3133
+ // 0.4.1:补上实际触发线相关字段(配置接口没有这些,只有 state 接口有)。
3134
+ let extra = null;
3135
+ if (stateRes !== null && stateRes.ok) {
3136
+ const stateData = await stateRes.json().catch(() => ({}));
3137
+ const sn = stateData !== null && stateData.native !== null && typeof stateData.native === 'object' ? stateData.native : null;
3138
+ if (sn !== null) {
3139
+ extra = {
3140
+ ratio: typeof sn.ratio === 'number' ? sn.ratio : null,
3141
+ thresholdTokens: typeof sn.thresholdTokens === 'number' ? sn.thresholdTokens : null,
3142
+ contextWindow: typeof sn.contextWindow === 'number' ? sn.contextWindow : null,
3143
+ reservedCompletionTokens: typeof sn.reservedCompletionTokens === 'number' ? sn.reservedCompletionTokens : null,
3144
+ headroomTokens: typeof sn.headroomTokens === 'number' ? sn.headroomTokens : null,
3145
+ overridden: sn.overridden === true,
3146
+ route: sn.route !== null && typeof sn.route === 'object' ? sn.route : null,
3147
+ };
3148
+ }
3149
+ }
3150
+ nativeLineExtra.current = extra;
2863
3151
  applyLoaded(data);
2864
3152
  setStatus(null);
2865
3153
  } catch (error) {
@@ -2957,6 +3245,77 @@ window.__ModuleLoader__.load({
2957
3245
  }
2958
3246
  }, [confirmNative]);
2959
3247
 
3248
+ /**
3249
+ * 0.4 P0-2:生成诊断信息。
3250
+ *
3251
+ * 唯一的网络调用是同源只读 GET(`/api/compact-relay-diagnostics`,仅此一次);
3252
+ * 产物由宿主生成(白名单 + 已脱敏),这里只负责展示/复制。
3253
+ */
3254
+ const generateDiagnostics = useCallback(
3255
+ async ({ openPreview = true } = {}) => {
3256
+ if (diagLoading) return null;
3257
+ setDiagLoading(true);
3258
+ setDiagStatus(null);
3259
+ try {
3260
+ const res = await fetch('/api/compact-relay-diagnostics', { cache: 'no-store' });
3261
+ const data = await res.json().catch(() => ({}));
3262
+ if (!res.ok || data.ok === false) throw new Error(data && data.message ? String(data.message) : '生成失败');
3263
+ setDiagData(data);
3264
+ if (openPreview) setDiagOpen(true);
3265
+ return data;
3266
+ } catch (error) {
3267
+ setDiagStatus({ error: true, text: `生成失败:${error && error.message ? error.message : '未知错误'}` });
3268
+ return null;
3269
+ } finally {
3270
+ setDiagLoading(false);
3271
+ }
3272
+ },
3273
+ [diagLoading],
3274
+ );
3275
+
3276
+ /** 「打开问题报告页」:没生成就先生成,再打开宿主拼好的短字段 URL(不碰诊断正文)。 */
3277
+ const openIssueReport = useCallback(async () => {
3278
+ let data = diagData;
3279
+ if (data === null || typeof data.issueUrl !== 'string') data = await generateDiagnostics({ openPreview: false });
3280
+ if (data === null || typeof data.issueUrl !== 'string') return;
3281
+ try {
3282
+ window.open(data.issueUrl, '_blank', 'noopener');
3283
+ } catch {
3284
+ flashDiagStatus({ error: true, text: '打开问题报告页失败:新窗口被浏览器拦截了' });
3285
+ }
3286
+ }, [diagData, generateDiagnostics, flashDiagStatus]);
3287
+
3288
+ /** 复制 Markdown / JSON:统一走 navigator.clipboard?.writeText,失败就地提示。 */
3289
+ const copyDiagnostics = useCallback(
3290
+ (format) => {
3291
+ if (diagData === null) {
3292
+ flashDiagStatus({ error: true, text: '还没有可复制的诊断信息,请先生成' });
3293
+ return;
3294
+ }
3295
+ const text =
3296
+ format === 'json' ? JSON.stringify(diagData.json, null, 2) : typeof diagData.markdown === 'string' ? diagData.markdown : '';
3297
+ if (typeof text !== 'string' || text.length === 0) {
3298
+ flashDiagStatus({ error: true, text: '还没有可复制的诊断信息,请先生成' });
3299
+ return;
3300
+ }
3301
+ const done = () => flashDiagStatus({ text: '已复制诊断信息(脱敏)' });
3302
+ const failed = () => flashDiagStatus({ error: true, text: '复制失败:浏览器不允许访问剪贴板,可手动选中预览文本' });
3303
+ try {
3304
+ const written = navigator.clipboard?.writeText(text);
3305
+ if (written !== undefined && typeof written.then === 'function') written.then(done, failed);
3306
+ else done();
3307
+ } catch {
3308
+ failed();
3309
+ }
3310
+ },
3311
+ [diagData, flashDiagStatus],
3312
+ );
3313
+
3314
+ const closeDiagnosticsPreview = useCallback(() => {
3315
+ setDiagOpen(false);
3316
+ setDiagIntroSeen(true);
3317
+ }, []);
3318
+
2960
3319
  if (form === null) {
2961
3320
  return h(
2962
3321
  'div',
@@ -2969,11 +3328,30 @@ window.__ModuleLoader__.load({
2969
3328
  const compactPercent = percent(form.thresholds.compactRatio);
2970
3329
  const handoffPercent = percent(form.thresholds.handoffRatio);
2971
3330
  const count = (value) => (Number.isFinite(value) ? value : 0);
3331
+ // 0.4 P1-3:轻账本一行累计数字(只读;数据来自宿主账本,与账本文件同源)。
3332
+ const ledgerLine = (() => {
3333
+ if (ledger === null) return '累计:暂无记录(完成一次压缩/交接后开始统计)';
3334
+ const freed = count(ledger.freedTokens);
3335
+ const freedText = freed >= 1_000_000 ? `${(freed / 1_000_000).toFixed(1)}M` : freed >= 1000 ? `${(freed / 1000).toFixed(1)}K` : String(freed);
3336
+ const avg = ledger.avgDurationMs !== null && Number.isFinite(ledger.avgDurationMs) ? `${(ledger.avgDurationMs / 1000).toFixed(1)}s` : '—';
3337
+ return `累计:已压缩 ${count(ledger.compactCount)} 次 · 交接 ${count(ledger.handoffCount)} 次 · 腾出约 ${freedText} tokens · 平均耗时 ${avg}`;
3338
+ })();
2972
3339
 
2973
3340
  const autoOnNative = native !== null && native.autoEnabled !== false;
2974
- const nativePercent = native !== null && typeof native.thresholdRatio === 'number' ? Math.round(native.thresholdRatio * 100) : 80;
3341
+ // 0.4.1:配置阈值(thresholdRatio)只是理论上限;实际触发线由宿主按 token 预算算。
3342
+ const nativeInfo = nativeLineInfo(native);
3343
+ const nativePercent = nativeInfo.configured !== null ? Math.round(nativeInfo.configured * 100) : 80;
3344
+ const nativeCapRatio = nativeInfo.effective !== null ? nativeInfo.effective : nativeInfo.configured !== null ? nativeInfo.configured : 0.8;
3345
+ // 交接线可设上限:按实际触发线向下取整,输入框 max 与 onChange 用同一个数。
3346
+ const nativeCapPercent = Math.max(5, Math.floor(nativeCapRatio * 100 - 1));
3347
+ // 原生压缩已关闭时 effective/percentText 为空:回落到「开启后实际会触发多少」,再回落配置阈值。
3348
+ const nativeActualText = nativeInfo.percentText !== '' ? nativeInfo.percentText : nativeInfo.actualText !== '' ? nativeInfo.actualText : nativeInfo.configuredText !== null ? nativeInfo.configuredText : String(nativePercent);
3349
+ const handoffOverNative = nativeInfo.effective !== null && form !== null && typeof form.thresholds === 'object' && typeof form.thresholds.handoffRatio === 'number' && form.thresholds.handoffRatio > nativeInfo.effective + 1e-9;
3350
+ const handoffNativeWarning = handoffOverNative ? `当前交接线 ${Math.round(form.thresholds.handoffRatio * 100)}% 高于原生实际触发线 ${nativeInfo.percentText}%:DSH 会先自己压历史,这条提醒可能来不及。建议调到 ${nativeInfo.percentText}% 以下(改完立即生效)。` : '';
2975
3351
  const previewLight = paletteForScheme(form.colors, 'light');
2976
3352
  const previewDark = paletteForScheme(form.colors, 'dark');
3353
+ // 0.4 P0-2:诊断预览弹层复用指示灯那套浮层材质(15-shared.panelChrome)。
3354
+ const theme = detectTheme();
2977
3355
  const banner = settingsError === null ? null : h(
2978
3356
  'div',
2979
3357
  { style: { marginBottom: 14, padding: '8px 10px', borderRadius: 6, border: '1px solid var(--dsw-alias-state-warning-primary, #d97706)', background: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.08))' } },
@@ -3041,19 +3419,19 @@ window.__ModuleLoader__.load({
3041
3419
  settingsRow(
3042
3420
  '交接提醒线',
3043
3421
  '到这条线内圆亮红点,菜单建议「交接新会话」。',
3044
- '调高 → 更晚换会话、单会话更久;调低 → 更早换会话、单会话更短。必须大于压缩线。',
3422
+ `调高 → 更晚换会话、单会话更久;调低 → 更早换会话、单会话更短。必须大于压缩线。${handoffNativeWarning}`,
3045
3423
  h('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
3046
3424
  h('input', {
3047
3425
  type: 'number',
3048
3426
  min: 5,
3049
- max: native !== null && native.autoEnabled !== false ? Math.max(5, Math.round(native.thresholdRatio * 100) - 1) : 99,
3427
+ max: native !== null && native.autoEnabled !== false ? nativeCapPercent : 99,
3050
3428
  step: 1,
3051
3429
  value: handoffPercent,
3052
3430
  'aria-label': '交接提醒线(百分比)',
3053
3431
  style: { ...settingsInputStyle, width: 90 },
3054
3432
  onChange: (event) => {
3055
3433
  const raw = Math.max(0.05, Number(event.target.value) / 100);
3056
- const cap = native !== null && native.autoEnabled !== false ? Math.max(0.05, native.thresholdRatio - 0.01) : 0.99;
3434
+ const cap = native !== null && native.autoEnabled !== false ? nativeCapPercent / 100 : 0.99;
3057
3435
  patch(['thresholds', 'handoffRatio'], Math.min(cap, raw));
3058
3436
  },
3059
3437
  }),
@@ -3106,10 +3484,10 @@ window.__ModuleLoader__.load({
3106
3484
  native === null
3107
3485
  ? '正在读取 DSH 配置…'
3108
3486
  : autoOnNative
3109
- ? `当前开启:容量到 ${nativePercent}% 时,DSH 会自己把历史压成摘要(不读本插件的锁定)。`
3487
+ ? `当前开启:容量到 ${nativeActualText}% 时,DSH 会自己把历史压成摘要(不读本插件的锁定)。`
3110
3488
  : '当前已关闭:DSH 不再自动压历史,只有你主动压缩/交接才会动历史。',
3111
3489
  autoOnNative
3112
- ? `开启时:容量到 ${nativePercent}% DSH 会自己压历史(不读锁定);关闭后:只在你主动压缩/交接时才动历史。`
3490
+ ? `开启时:容量到 ${nativeActualText}% DSH 会自己压历史(不读锁定);关闭后:只在你主动压缩/交接时才动历史。${nativeInfo.differs ? `(${nativeInfo.why})` : ''}`
3113
3491
  : '开启后:DSH 会在容量到原生阈值时自动压历史,可能压掉锁定的消息;本插件的提醒照常。',
3114
3492
  h(
3115
3493
  'div',
@@ -3156,7 +3534,13 @@ window.__ModuleLoader__.load({
3156
3534
  }),
3157
3535
  ),
3158
3536
  h('span', { style: { fontSize: 12, fontWeight: 600, minWidth: 44 } }, native === null ? '读取中…' : nativeBusy ? '处理中…' : autoOnNative ? '已开启' : '已关闭'),
3159
- h('span', { style: settingsHintStyle }, native !== null ? `原生阈值 ${nativePercent}%` : ''),
3537
+ h('span', { style: settingsHintStyle }, native !== null
3538
+ ? (nativeInfo.autoOff
3539
+ ? (nativeInfo.actualText !== ''
3540
+ ? `原生阈值 配置 ${nativeInfo.configuredText ?? nativePercent}%(已关闭;开启后实际触发 ${nativeInfo.actualText}%)`
3541
+ : `原生阈值 配置 ${nativeInfo.configuredText ?? nativePercent}%(已关闭)`)
3542
+ : (nativeInfo.differs ? `原生阈值 配置 ${nativeInfo.configuredText}%(实际触发 ${nativeInfo.percentText}%)` : `原生阈值 ${nativeActualText}%`))
3543
+ : ''),
3160
3544
  nativeStatus === null
3161
3545
  ? null
3162
3546
  : h(
@@ -3193,9 +3577,9 @@ window.__ModuleLoader__.load({
3193
3577
  { style: { ...settingsHintStyle, marginBottom: 8 } },
3194
3578
  confirmNative === 'disable'
3195
3579
  ? `操作内容:在 DSH profile 的 cordis.patch.yml 里把 compaction-basic 的 auto 改为 false(写前备份原文件,可回滚)。` +
3196
- `改变:DSH 不再在容量到 ${nativePercent}% 时自动压缩历史;锁定的消息不会被压掉;本插件的提醒和手动压缩照常。需要重启 DSH 生效。`
3580
+ `改变:DSH 不再在容量到 ${nativeActualText}% 时自动压缩历史;锁定的消息不会被压掉;本插件的提醒和手动压缩照常。需要重启 DSH 生效。`
3197
3581
  : `操作内容:在 DSH profile 的 cordis.patch.yml 里把 compaction-basic 的 auto 改回 true(写前备份,可回滚)。` +
3198
- `改变:DSH 重新在容量到 ${nativePercent}% 时自动压历史,可能压掉锁定的消息。需要重启 DSH 生效。`,
3582
+ `改变:DSH 重新在容量到 ${nativeActualText}% 时自动压历史,可能压掉锁定的消息。需要重启 DSH 生效。`,
3199
3583
  ),
3200
3584
  h(
3201
3585
  'div',
@@ -3383,13 +3767,15 @@ window.__ModuleLoader__.load({
3383
3767
  { ratio: compactRatio + 0.08, hint: null, title: '进入压缩区', text: '琥珀:建议压缩旧会话' },
3384
3768
  { ratio: handoffRatio + 0.15, hint: null, title: '越过交接线', text: '红:建议交接新会话' },
3385
3769
  ];
3386
- if (native !== null && native.autoEnabled !== false) {
3770
+ if (native !== null && native.autoEnabled !== false && nativeInfo.effective !== null) {
3387
3771
  legend.push({
3388
- ratio: native.thresholdRatio + 0.06,
3772
+ ratio: Math.min(0.95, nativeInfo.effective + 0.06),
3389
3773
  hint: null,
3390
- nativeAt: native.thresholdRatio,
3774
+ nativeAt: nativeInfo.effective,
3391
3775
  title: '原生自动压缩线',
3392
- text: `${Math.round(native.thresholdRatio * 100)}%:DSH 会自己压历史(越过整环红色闪烁)`,
3776
+ text: nativeInfo.differs
3777
+ ? `实际 ${nativeInfo.percentText}%:DSH 会自己压历史(配置阈值 ${nativeInfo.configuredText}%,越过整环红色闪烁)`
3778
+ : `${nativeInfo.percentText}%:DSH 会自己压历史(越过整环红色闪烁)`,
3393
3779
  });
3394
3780
  }
3395
3781
  return h(
@@ -3448,12 +3834,60 @@ window.__ModuleLoader__.load({
3448
3834
  // 2026-10-20:0.3.1 已发布到 npm,帮助区直接给包页面入口。
3449
3835
  githubLink('npm 包页面', 'https://www.npmjs.com/package/dsh-compact-relay', ''),
3450
3836
  ),
3837
+ // 0.4 P0-2:诊断信息 —— 本机生成、白名单、已脱敏;只提供「生成/预览/复制」。
3838
+ h(
3839
+ 'div',
3840
+ { style: { marginBottom: 18, paddingTop: 12, borderTop: '1px dashed var(--dsw-alias-border-l2, rgba(127,127,127,0.25))' }, 'data-cr-diagnostics': '1' },
3841
+ h('div', { style: { fontSize: 13, fontWeight: 600, marginBottom: 4 } }, '诊断信息'),
3842
+ h('p', { style: { ...settingsHintStyle, margin: '0 0 2px' } }, '只在本机生成,不会自动外发;生成后可以先预览,再决定把哪部分粘贴到哪里。'),
3843
+ h(
3844
+ 'p',
3845
+ { style: settingsHintStyle },
3846
+ '内容只有版本、平台、配置数值、状态摘要、计数、耗时与错误码;不含聊天内容、摘要、交接单正文、文件路径、用户名、会话 id、密钥或 cookie。',
3847
+ ),
3848
+ h(
3849
+ 'div',
3850
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginTop: 8 } },
3851
+ h(
3852
+ 'button',
3853
+ {
3854
+ type: 'button',
3855
+ 'data-cr-diagnostics-generate': '1',
3856
+ style: settingsButtonStyle,
3857
+ disabled: diagLoading,
3858
+ onClick: () => void generateDiagnostics(),
3859
+ },
3860
+ diagLoading ? '生成中…' : '生成并预览',
3861
+ ),
3862
+ h(
3863
+ 'button',
3864
+ { type: 'button', 'data-cr-diagnostics-issue': '1', style: settingsButtonStyle, onClick: () => void openIssueReport() },
3865
+ '打开问题报告页',
3866
+ ),
3867
+ diagStatus === null
3868
+ ? null
3869
+ : h(
3870
+ 'span',
3871
+ {
3872
+ 'data-cr-diagnostics-status': diagStatus.error ? 'error' : 'ok',
3873
+ style: {
3874
+ fontSize: 11,
3875
+ lineHeight: '16px',
3876
+ color: diagStatus.error ? 'var(--dsw-alias-state-error-primary, #D92D20)' : 'var(--dsw-alias-state-success-primary, #15803D)',
3877
+ },
3878
+ },
3879
+ diagStatus.text,
3880
+ ),
3881
+ ),
3882
+ h('p', { style: { ...settingsHintStyle, marginTop: 6 } }, 'issue 页是公开的;粘贴前请自己再看一眼。'),
3883
+ ),
3451
3884
  ),
3452
3885
  sectionHeading('关于与危险操作'),
3453
3886
  h(
3454
3887
  'div',
3455
3888
  { style: { display: 'flex', flexDirection: 'column', paddingLeft: 16, borderLeft: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.2))' }, 'data-cr-group': '关于与危险操作' },
3456
3889
  h('p', { style: settingsHintStyle }, `版本:${version || '—'}`),
3890
+ h('p', { style: settingsHintStyle, 'data-cr-ledger': '1' }, ledgerLine),
3457
3891
  h('p', { style: settingsHintStyle }, `设置文件:${settingsPath || '—'}`),
3458
3892
  h('p', { style: { ...settingsHintStyle, marginTop: 8 } }, '输入框左侧的上下文指示灯:看容量、提醒压缩/交接、锁住重要消息;到线了用 /bc 或 /handoff,重要内容用 /pin 锁住。'),
3459
3893
  h('p', { style: settingsHintStyle }, '遇到 bug 或想提改进:走上面「帮助」里的 GitHub Issues;也欢迎直接发邮件。'),
@@ -3470,8 +3904,151 @@ window.__ModuleLoader__.load({
3470
3904
  )
3471
3905
  : h('button', { type: 'button', style: settingsButtonStyle, disabled: saving, onClick: () => setConfirmReset(true) }, '恢复默认'),
3472
3906
  ),
3473
- )
3474
-
3907
+ ),
3908
+ // 0.4 P0-2:诊断信息预览弹层(只读等宽文本 + 复制 Markdown/JSON)。
3909
+ // 只有用户点了「生成并预览」/「打开问题报告页」才会出现;关掉后本次会话不再显示首次说明卡。
3910
+ diagOpen && diagData !== null
3911
+ ? h(
3912
+ 'div',
3913
+ {
3914
+ 'data-cr-diagnostics-modal': '1',
3915
+ style: {
3916
+ position: 'fixed',
3917
+ inset: 0,
3918
+ zIndex: 60,
3919
+ display: 'flex',
3920
+ alignItems: 'center',
3921
+ justifyContent: 'center',
3922
+ padding: 24,
3923
+ background: 'rgba(0,0,0,0.36)',
3924
+ },
3925
+ },
3926
+ h(
3927
+ 'div',
3928
+ {
3929
+ role: 'dialog',
3930
+ 'aria-modal': 'true',
3931
+ 'aria-label': '诊断信息预览(脱敏)',
3932
+ style: {
3933
+ ...panelChrome(theme),
3934
+ display: 'flex',
3935
+ flexDirection: 'column',
3936
+ width: '100%',
3937
+ maxWidth: 760,
3938
+ maxHeight: '80vh',
3939
+ borderRadius: 12,
3940
+ padding: '14px 16px',
3941
+ },
3942
+ },
3943
+ h(
3944
+ 'div',
3945
+ { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 } },
3946
+ h('div', { style: { fontSize: 13, fontWeight: 700, color: 'var(--dsw-alias-label-primary, inherit)' } }, '诊断信息预览(脱敏)'),
3947
+ h('button', { type: 'button', style: settingsButtonStyle, onClick: closeDiagnosticsPreview }, '关闭'),
3948
+ ),
3949
+ diagIntroSeen
3950
+ ? null
3951
+ : h(
3952
+ 'div',
3953
+ {
3954
+ style: {
3955
+ marginTop: 10,
3956
+ padding: '8px 10px',
3957
+ borderRadius: 8,
3958
+ border: '1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,0.25))',
3959
+ background: 'var(--dsw-alias-bg-layer-1, rgba(127,127,127,0.06))',
3960
+ },
3961
+ },
3962
+ h('div', { style: { fontSize: 12, fontWeight: 600, marginBottom: 4 } }, '第一次生成:先看三点'),
3963
+ h(
3964
+ 'p',
3965
+ { style: settingsHintStyle },
3966
+ '① 只在本机生成,插件不会自己发到任何地方;② 只有数值、枚举与错误码,不含聊天内容与文件路径;③ issue 页是公开的,粘贴前请自己再看一眼。',
3967
+ ),
3968
+ ),
3969
+ h(
3970
+ 'pre',
3971
+ {
3972
+ 'data-cr-diagnostics-preview': '1',
3973
+ 'aria-label': '诊断信息 Markdown 预览(只读)',
3974
+ style: {
3975
+ flex: 1,
3976
+ minHeight: 120,
3977
+ overflow: 'auto',
3978
+ margin: '10px 0',
3979
+ padding: 10,
3980
+ borderRadius: 8,
3981
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.2))',
3982
+ background: 'var(--dsw-alias-bg-layer-1, rgba(127,127,127,0.06))',
3983
+ color: 'inherit',
3984
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
3985
+ fontSize: 11,
3986
+ lineHeight: '16px',
3987
+ whiteSpace: 'pre-wrap',
3988
+ wordBreak: 'break-word',
3989
+ userSelect: 'text',
3990
+ },
3991
+ },
3992
+ typeof diagData.markdown === 'string' && diagData.markdown.length > 0 ? diagData.markdown : '(没有内容)',
3993
+ ),
3994
+ h(
3995
+ 'div',
3996
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
3997
+ h(
3998
+ 'button',
3999
+ { type: 'button', 'data-cr-diagnostics-copy': 'markdown', style: settingsButtonStyle, onClick: () => copyDiagnostics('markdown') },
4000
+ '复制 Markdown',
4001
+ ),
4002
+ h(
4003
+ 'button',
4004
+ { type: 'button', 'data-cr-diagnostics-copy': 'json', style: settingsButtonStyle, onClick: () => copyDiagnostics('json') },
4005
+ '复制 JSON',
4006
+ ),
4007
+ h('span', { style: settingsHintStyle }, 'issue 是公开的;粘贴前请自己再看一眼。'),
4008
+ diagStatus === null
4009
+ ? null
4010
+ : h(
4011
+ 'span',
4012
+ {
4013
+ 'data-cr-diagnostics-copy-status': diagStatus.error ? 'error' : 'ok',
4014
+ style: {
4015
+ fontSize: 11,
4016
+ lineHeight: '16px',
4017
+ color: diagStatus.error
4018
+ ? 'var(--dsw-alias-state-error-primary, #D92D20)'
4019
+ : 'var(--dsw-alias-state-success-primary, #15803D)',
4020
+ },
4021
+ },
4022
+ diagStatus.text,
4023
+ ),
4024
+ ),
4025
+ ),
4026
+ )
4027
+ : null,
4028
+ // A4:复制/打开结果的短 toast(成功与失败都提示;自动消失,不挡操作)。
4029
+ diagStatus === null
4030
+ ? null
4031
+ : h(
4032
+ 'div',
4033
+ {
4034
+ 'data-cr-diagnostics-toast': diagStatus.error ? 'error' : 'ok',
4035
+ role: 'status',
4036
+ style: {
4037
+ ...panelChrome(theme),
4038
+ position: 'fixed',
4039
+ right: 24,
4040
+ bottom: 24,
4041
+ zIndex: 70,
4042
+ maxWidth: 360,
4043
+ padding: '8px 12px',
4044
+ borderRadius: 8,
4045
+ fontSize: 12,
4046
+ lineHeight: '17px',
4047
+ color: diagStatus.error ? 'var(--dsw-alias-state-error-primary, #D92D20)' : 'var(--dsw-alias-label-primary, inherit)',
4048
+ },
4049
+ },
4050
+ diagStatus.text,
4051
+ ),
3475
4052
  );
3476
4053
  }
3477
4054