dsh-compact-relay 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -818,6 +818,14 @@ window.__ModuleLoader__.load({
818
818
  const { sessionId, waitAndOpen } = props;
819
819
  const hasSession = typeof sessionId === 'string' && sessionId.length > 0;
820
820
  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 });
821
+ // 0.4 P2-3:原生自动压缩的「有效线」——优先真实触发线(thresholdTokens/window),
822
+ // 老宿主/算不出时回落 thresholdRatio(窗口比例)。
823
+ const effectiveNativeRatio = (native) => {
824
+ if (native === null || native === undefined || native.autoEnabled === false) return null;
825
+ if (typeof native.ratio === 'number' && Number.isFinite(native.ratio) && native.ratio > 0 && native.ratio < 1) return native.ratio;
826
+ if (typeof native.thresholdRatio === 'number' && Number.isFinite(native.thresholdRatio) && native.thresholdRatio > 0) return native.thresholdRatio;
827
+ return null;
828
+ };
821
829
  const [open, setOpen] = useState(false);
822
830
  const [busy, setBusy] = useState(false);
823
831
  const [hover, setHover] = useState(false);
@@ -877,6 +885,8 @@ window.__ModuleLoader__.load({
877
885
  }, []);
878
886
  // A1b:交接预览(九段草稿 + 就绪清单),只读不落盘。
879
887
  const [handoffPreview, setHandoffPreview] = useState(null);
888
+ /** 0.4 P2-1:交接预览里「约束 / 下一步」两段的编辑值(键=段标题;空值回落原文)。 */
889
+ const [handoffEditValues, setHandoffEditValues] = useState({});
880
890
  const handoffPreviewRef = useRef(null);
881
891
  const handoffPreviewAbortRef = useRef(null);
882
892
  const closeHandoffPreview = useCallback(() => {
@@ -936,13 +946,20 @@ window.__ModuleLoader__.load({
936
946
  : '/api/compact-relay-state';
937
947
 
938
948
  const applierRef = useRef(() => {});
949
+ // 0.3.7:最后一帧(SSE 帧或轮询响应)的到达时间。远程/代理链路可能让 SSE
950
+ // 「onopen 已连上、之后却一帧都不来」地静默挂住,表现为压缩后界面不刷新
951
+ // (Win 远程客户端实测:手动刷新才变)。帧静默超时后由兜底轮询补拉。
952
+ const lastFrameAtRef = useRef(0);
939
953
  const fetchState = useCallback(async () => {
940
954
  if (!hasSession) return;
941
955
  try {
942
956
  const res = await fetch(url, { cache: 'no-store' });
943
957
  if (!res.ok) return;
944
958
  const data = await res.json();
945
- if (data && typeof data === 'object') applierRef.current(data);
959
+ if (data && typeof data === 'object') {
960
+ lastFrameAtRef.current = Date.now();
961
+ applierRef.current(data);
962
+ }
946
963
  } catch {
947
964
  /* host 不在就保持上一次状态 */
948
965
  }
@@ -999,6 +1016,28 @@ window.__ModuleLoader__.load({
999
1016
  typeof data.native.thresholdRatio === 'number' && Number.isFinite(data.native.thresholdRatio)
1000
1017
  ? Math.max(0, Math.min(1, data.native.thresholdRatio))
1001
1018
  : 0.8,
1019
+ // 0.4 P2-3:真实触发线(thresholdTokens/window);宿主算不出时为 null。
1020
+ ratio:
1021
+ typeof data.native.ratio === 'number' && Number.isFinite(data.native.ratio)
1022
+ ? Math.max(0, Math.min(1, data.native.ratio))
1023
+ : null,
1024
+ thresholdTokens:
1025
+ typeof data.native.thresholdTokens === 'number' && Number.isFinite(data.native.thresholdTokens)
1026
+ ? data.native.thresholdTokens
1027
+ : null,
1028
+ contextWindow:
1029
+ typeof data.native.contextWindow === 'number' && Number.isFinite(data.native.contextWindow)
1030
+ ? data.native.contextWindow
1031
+ : null,
1032
+ headroomTokens:
1033
+ typeof data.native.headroomTokens === 'number' && Number.isFinite(data.native.headroomTokens)
1034
+ ? data.native.headroomTokens
1035
+ : null,
1036
+ overridden: data.native.overridden === true,
1037
+ route:
1038
+ data.native.route && typeof data.native.route === 'object' && typeof data.native.route.model === 'string'
1039
+ ? { provider: String(data.native.route.provider ?? ''), model: data.native.route.model }
1040
+ : null,
1002
1041
  }
1003
1042
  : null,
1004
1043
  colors: data.colors && typeof data.colors === 'object' ? data.colors : null,
@@ -1040,8 +1079,12 @@ window.__ModuleLoader__.load({
1040
1079
  if (dead) return;
1041
1080
  streamLiveRef.current = true;
1042
1081
  setStreamLive(true);
1082
+ lastFrameAtRef.current = Date.now();
1083
+ // 0.3.7:刚连上先拉一次,补齐「断开期间错过的帧」(重连场景)。
1084
+ void fetchState();
1043
1085
  };
1044
1086
  source.onmessage = (event) => {
1087
+ lastFrameAtRef.current = Date.now();
1045
1088
  try {
1046
1089
  applyState(JSON.parse(event.data));
1047
1090
  } catch {
@@ -1073,14 +1116,17 @@ window.__ModuleLoader__.load({
1073
1116
  // 兜底轮询:
1074
1117
  // - 还没就绪(会话刚打开 / 没有 agent)→ 1.5s 快轮询(修「重启后停在上下文未知」);
1075
1118
  // - 已就绪但 SSE 没连上 → 5s 轮询兜底;
1076
- // - 已就绪且 SSE 活着 → 不主动发请求,只留 5s 计时器。
1119
+ // - 已就绪且 SSE 活着 → 5s 计时器只检查「帧是否静默超时」;
1120
+ // - 0.3.7:SSE 活着但 30s 没来帧(远程/代理链路静默挂住)→ 补拉一次,
1121
+ // 修「压缩后远程客户端不刷新、要手动刷新才变」。
1077
1122
  useEffect(() => {
1078
1123
  if (!hasSession) return;
1079
1124
  let timer;
1080
1125
  let dead = false;
1081
1126
  const tick = () => {
1082
1127
  if (dead) return;
1083
- if (!readyRef.current || !streamLiveRef.current) void fetchState();
1128
+ const sseSilent = streamLiveRef.current && Date.now() - lastFrameAtRef.current > 30000;
1129
+ if (!readyRef.current || !streamLiveRef.current || sseSilent) void fetchState();
1084
1130
  timer = setTimeout(tick, readyRef.current ? 5000 : 1500);
1085
1131
  };
1086
1132
  tick();
@@ -1090,6 +1136,24 @@ window.__ModuleLoader__.load({
1090
1136
  };
1091
1137
  }, [fetchState, hasSession]);
1092
1138
 
1139
+ // 0.3.7:窗口重新可见 / 重新获得焦点 / 网络恢复时拉一次——覆盖「页面被挂起后
1140
+ // 计时器被节流、SSE 断流」的场景(远程客户端切走再切回来也会走到这里)。
1141
+ useEffect(() => {
1142
+ if (!hasSession) return;
1143
+ const refresh = () => {
1144
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
1145
+ void fetchState();
1146
+ };
1147
+ document.addEventListener('visibilitychange', refresh);
1148
+ window.addEventListener('focus', refresh);
1149
+ window.addEventListener('online', refresh);
1150
+ return () => {
1151
+ document.removeEventListener('visibilitychange', refresh);
1152
+ window.removeEventListener('focus', refresh);
1153
+ window.removeEventListener('online', refresh);
1154
+ };
1155
+ }, [fetchState, hasSession]);
1156
+
1093
1157
 
1094
1158
  // 点外面 / Esc 关闭菜单、压缩确认卡与交接单面板。
1095
1159
  useEffect(() => {
@@ -1188,7 +1252,7 @@ window.__ModuleLoader__.load({
1188
1252
  // DSH 原生自动压缩线(报警最高阈值):开着时在环上画一个「径向刻度」(不是沿环的弧),
1189
1253
  // 用紫色 + 主题底色描边——刻意不用五级配色里的颜色,保证在任何分段颜色上都看得清。
1190
1254
  // 越过原生线后:整环红色闪烁(作者 2026-10-20 反馈)。
1191
- const nativeAuto = state.native !== null && state.native.autoEnabled ? state.native.thresholdRatio : null;
1255
+ const nativeAuto = effectiveNativeRatio(state.native);
1192
1256
  const nativeValid = nativeAuto !== null && nativeAuto > 0.02 && nativeAuto < 1;
1193
1257
  const nativeCrossed = nativeValid && ratio > nativeAuto;
1194
1258
  // 断点缺口:0.9 SVG 单位(≈6.4°),折算成比例;段太短时缺口会吃掉整段,就跳过该段。
@@ -1310,11 +1374,11 @@ window.__ModuleLoader__.load({
1310
1374
  // 配合指示灯呼吸光晕,解决「指示灯太安静、新用户不知道这里有个菜单」。
1311
1375
  // 设置页「再看一次初次引导」按钮通过 window 事件重播(两个组件同页,用事件解耦)。
1312
1376
  const onboardingText = useCallback(() => {
1313
- const nativeRatio = state.native !== null && state.native.autoEnabled !== false ? state.native.thresholdRatio : null;
1377
+ const nativeRatio = effectiveNativeRatio(state.native);
1314
1378
  const nativePart =
1315
1379
  nativeRatio === null
1316
1380
  ? 'DSH 原生自动压缩已关闭'
1317
- : `DSH 原生自动压缩线在 ${(nativeRatio * 100).toFixed(0)}%,到那之前先压缩或交接更稳`;
1381
+ : `DSH 原生自动压缩线在 ${(nativeRatio * 100).toFixed(1)}%,到那之前先压缩或交接更稳`;
1318
1382
  return `这是上下文指示灯:点它压缩旧会话、锁定重要消息、交接新会话。${nativePart}。环上的小缺口是我判断「该压缩」的位置(模型判断或到线自动出现)。重要的结论/路径先锁定,压缩时才不会被压掉。`;
1319
1383
  }, [state.native]);
1320
1384
 
@@ -1437,6 +1501,7 @@ window.__ModuleLoader__.load({
1437
1501
  setPreview(null);
1438
1502
  closeDocs();
1439
1503
  closeDiff();
1504
+ setHandoffEditValues({});
1440
1505
  setHandoffPreview({ loading: true });
1441
1506
  const controller = typeof AbortController === 'function' ? new AbortController() : null;
1442
1507
  handoffPreviewAbortRef.current = controller;
@@ -1626,7 +1691,7 @@ window.__ModuleLoader__.load({
1626
1691
  }, [effectiveBusy, sessionId]);
1627
1692
 
1628
1693
  const run = useCallback(
1629
- async (action) => {
1694
+ async (action, extra) => {
1630
1695
  // A3:确认必须带预览时拿到的 planId,宿主重算不一致会以 changed 拒绝。
1631
1696
  const expected =
1632
1697
  action === 'compact' && preview !== null && preview.loading !== true && typeof preview.data?.planId === 'string'
@@ -1651,6 +1716,8 @@ window.__ModuleLoader__.load({
1651
1716
  body: JSON.stringify({
1652
1717
  action,
1653
1718
  ...(expected === undefined ? {} : { expected }),
1719
+ // 0.4 P2-1:`extra.edits` = 只含用户真正改过的「约束/下一步」两段。
1720
+ ...(extra !== undefined && extra !== null && typeof extra === 'object' ? extra : {}),
1654
1721
  ...(typeof sessionId === 'string' && sessionId.length > 0 ? { sessionId } : {}),
1655
1722
  }),
1656
1723
  ...(controller === null ? {} : { signal: controller.signal }),
@@ -1690,10 +1757,17 @@ window.__ModuleLoader__.load({
1690
1757
  state.compressHint !== null && typeof state.compressHint.ratio === 'number'
1691
1758
  ? `压缩判断点 ${(state.compressHint.ratio * 100).toFixed(0)}%`
1692
1759
  : '';
1760
+ const nativeEffective = effectiveNativeRatio(state.native);
1761
+ const nativeDetail =
1762
+ state.native !== null && state.native.thresholdTokens !== null && state.native.contextWindow !== null
1763
+ ? `(${fmt(state.native.thresholdTokens)} / ${fmt(state.native.contextWindow)}${
1764
+ state.native.route !== null ? `,${state.native.route.provider}/${state.native.route.model}` : ''
1765
+ }${state.native.overridden ? ',按路由覆盖' : ''})`
1766
+ : '';
1693
1767
  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 ? '(已越过:整环红色闪烁)' : ''
1768
+ nativeEffective !== null
1769
+ ? `原生自动压缩线 ${(nativeEffective * 100).toFixed(1)}%${nativeDetail}${
1770
+ ratio > nativeEffective ? '(已越过:整环红色闪烁)' : ''
1697
1771
  }`
1698
1772
  : 'DSH 原生自动压缩已关闭';
1699
1773
  const pressureKnown = pressureValue !== null;
@@ -2030,6 +2104,12 @@ window.__ModuleLoader__.load({
2030
2104
  const previewRange = preview?.data?.preview ?? null;
2031
2105
  // 0.3.1:范围太小时预览阶段就拦下(服务端已按 800 tokens 判定),不让用户白确认一次。
2032
2106
  const previewTooSmall = previewRange !== null && previewRange.tooSmall === true;
2107
+ // 0.4 P1-1/P1-2:建议锁定候选 + pin 命中数(宿主已算好,客户端只渲染;不自动锁)。
2108
+ const pinSuggest =
2109
+ previewRange !== null && previewRange.pinSuggest !== null && typeof previewRange.pinSuggest === 'object' && Array.isArray(previewRange.pinSuggest.items)
2110
+ ? previewRange.pinSuggest
2111
+ : null;
2112
+ const pinHits = previewRange !== null && previewRange.pinHits !== null && typeof previewRange.pinHits === 'object' ? previewRange.pinHits : null;
2033
2113
  const confirmPanel =
2034
2114
  preview === null
2035
2115
  ? null
@@ -2047,6 +2127,8 @@ window.__ModuleLoader__.load({
2047
2127
  maxWidth: 'calc(100vw - 16px)',
2048
2128
  padding: '10px 12px',
2049
2129
  borderRadius: 12,
2130
+ maxHeight: 'min(70vh, 440px)',
2131
+ overflowY: 'auto',
2050
2132
  ...panelChrome(theme),
2051
2133
  color: theme.panelFg,
2052
2134
  zIndex: 42,
@@ -2077,6 +2159,45 @@ window.__ModuleLoader__.load({
2077
2159
  h('br'),
2078
2160
  '只估算范围和容量;摘要内容确认后才生成,不在这里展示。压缩后旧会话将替换为摘要,无法还原为原文。',
2079
2161
  ),
2162
+ pinSuggest !== null && pinSuggest.items.length > 0
2163
+ ? h(
2164
+ 'div',
2165
+ {
2166
+ 'data-cr-pin-suggest': '1',
2167
+ style: {
2168
+ display: 'flex',
2169
+ flexDirection: 'column',
2170
+ gap: 2,
2171
+ padding: '6px 8px',
2172
+ borderRadius: 8,
2173
+ border: `1px solid ${theme.panelBorder}`,
2174
+ background: theme.hover,
2175
+ },
2176
+ },
2177
+ h('div', { style: { fontSize: 11, fontWeight: 700, color: theme.panelFg } }, `建议锁定 ${pinSuggest.items.length} 条(先 /pin 再压)`),
2178
+ ...pinSuggest.items.map((item) =>
2179
+ h(
2180
+ 'div',
2181
+ { key: `pin-suggest-${item.seq}`, style: { fontSize: 11, lineHeight: '16px', color: theme.subText } },
2182
+ `${item.role} · ${item.reason}:${item.snippet}`,
2183
+ ),
2184
+ ),
2185
+ h(
2186
+ 'div',
2187
+ { style: { fontSize: 10, lineHeight: '14px', color: theme.subText } },
2188
+ '只建议、不自动锁定;锁定后这些消息会原样保留(/pin list 可按序号挑)。',
2189
+ ),
2190
+ )
2191
+ : null,
2192
+ pinHits !== null && pinHits.total > 0
2193
+ ? h(
2194
+ 'div',
2195
+ { 'data-cr-pin-hits': '1', style: { fontSize: 11, lineHeight: '16px', color: theme.subText } },
2196
+ pinHits.inRange > 0
2197
+ ? `pin 命中 ${pinHits.inRange} 条:本次范围里被锁定的消息已跳过(会话共 ${pinHits.total} 条锁定)。`
2198
+ : `pin 命中 0 条:本次范围内没有锁定消息(会话共 ${pinHits.total} 条锁定,均在范围外)。`,
2199
+ )
2200
+ : null,
2080
2201
  h(
2081
2202
  'div',
2082
2203
  { style: { display: 'flex', justifyContent: 'flex-end', gap: 6 } },
@@ -2214,6 +2335,46 @@ window.__ModuleLoader__.load({
2214
2335
  : handoffDraft,
2215
2336
  ),
2216
2337
  ),
2338
+ // 0.4 P2-1:只允许编辑「约束」「下一步」两段;其余段由模型/程序生成,UI 不给入口。
2339
+ handoffData !== null && typeof handoffData === 'object' && handoffData.editable !== null && typeof handoffData.editable === 'object'
2340
+ ? h(
2341
+ 'div',
2342
+ { 'data-cr-handoff-editable': '1', style: { display: 'flex', flexDirection: 'column', gap: 6 } },
2343
+ h('div', { style: { fontSize: 11, fontWeight: 600 } }, '可编辑两段(改完点「确认交接」才生效)'),
2344
+ ...(Array.isArray(handoffData.editable.titles) ? handoffData.editable.titles : []).map((title) => {
2345
+ const original = typeof handoffData.editable.sections?.[title] === 'string' ? handoffData.editable.sections[title] : '';
2346
+ return h(
2347
+ 'label',
2348
+ { key: `handoff-edit-${title}`, style: { display: 'flex', flexDirection: 'column', gap: 3, fontSize: 11 } },
2349
+ h('span', { style: { color: theme.subText } }, `${title}(留空 = 使用原文)`),
2350
+ h('textarea', {
2351
+ 'data-cr-handoff-edit': title,
2352
+ value: typeof handoffEditValues[title] === 'string' ? handoffEditValues[title] : original,
2353
+ rows: 3,
2354
+ maxLength: Number.isInteger(handoffData.editable.maxChars) ? handoffData.editable.maxChars : 4000,
2355
+ onChange: (event) => {
2356
+ const value = event.target.value;
2357
+ setHandoffEditValues((previous) => ({ ...previous, [title]: value }));
2358
+ },
2359
+ style: {
2360
+ width: '100%',
2361
+ boxSizing: 'border-box',
2362
+ resize: 'vertical',
2363
+ fontFamily: 'inherit',
2364
+ fontSize: 11,
2365
+ lineHeight: '16px',
2366
+ color: 'inherit',
2367
+ background: 'transparent',
2368
+ border: `1px solid ${theme.panelBorder}`,
2369
+ borderRadius: 6,
2370
+ padding: '4px 6px',
2371
+ },
2372
+ }),
2373
+ );
2374
+ }),
2375
+ h('div', { style: { fontSize: 10, lineHeight: '14px', color: theme.subText } }, handoffData.editable.note ?? '程序生成的段不可改。'),
2376
+ )
2377
+ : null,
2217
2378
  h('div', { style: { fontSize: 11, color: theme.subText } }, '确认后会先压缩旧会话,再按压缩后的历史生成最终交接单;预览不落盘。'),
2218
2379
  h(
2219
2380
  'div',
@@ -2234,8 +2395,18 @@ window.__ModuleLoader__.load({
2234
2395
  type: 'button',
2235
2396
  'data-cr-handoff-run': '1',
2236
2397
  onClick: () => {
2398
+ // P2-1:只把「用户真的改过、且非空」的两段发给宿主;空值/未改 = 用重新生成的原文。
2399
+ const editable = handoffData !== null && typeof handoffData === 'object' ? handoffData.editable : null;
2400
+ const edits = {};
2401
+ if (editable !== null && typeof editable === 'object' && Array.isArray(editable.titles)) {
2402
+ for (const title of editable.titles) {
2403
+ const original = typeof editable.sections?.[title] === 'string' ? editable.sections[title].trim() : '';
2404
+ const current = typeof handoffEditValues[title] === 'string' ? handoffEditValues[title].trim() : '';
2405
+ if (current.length > 0 && current !== original) edits[title] = current;
2406
+ }
2407
+ }
2237
2408
  closeHandoffPreview();
2238
- void run('handoff');
2409
+ void run('handoff', Object.keys(edits).length > 0 ? { edits } : undefined);
2239
2410
  },
2240
2411
  style: {
2241
2412
  border: 'none',
@@ -2837,6 +3008,8 @@ window.__ModuleLoader__.load({
2837
3008
  const [native, setNative] = useState(null);
2838
3009
  /** UI-002:设置文件路径(「关于与危险操作」区块展示)。 */
2839
3010
  const [settingsPath, setSettingsPath] = useState('');
3011
+ /** 0.4 P1-3:轻账本累计数字(独立账本文件算出来的;null = 没有数据/读不到,显示引导文案)。 */
3012
+ const [ledger, setLedger] = useState(null);
2840
3013
  const [confirmNative, setConfirmNative] = useState(null); // null | 'disable' | 'enable'
2841
3014
  const [nativeBusy, setNativeBusy] = useState(false);
2842
3015
  /** UI-003:结果提示就地显示在开关旁(以前只写页面底部,用户看不到)。 */
@@ -2844,6 +3017,32 @@ window.__ModuleLoader__.load({
2844
3017
  /** 作者 2026-10-20:设置页「再看一次初次引导」的就地提示。 */
2845
3018
  const [onboardingNotice, setOnboardingNotice] = useState(null);
2846
3019
  const nativeConfirmRef = useRef(null);
3020
+ /**
3021
+ * 0.4 P0-2:诊断信息(只在本机生成;可预览/复制,插件不会把它发到任何地方)。
3022
+ * diagData 是端点返回的 {schema,version,generatedAt,markdown,json,issueUrl}。
3023
+ */
3024
+ const [diagData, setDiagData] = useState(null);
3025
+ const [diagLoading, setDiagLoading] = useState(false);
3026
+ const [diagOpen, setDiagOpen] = useState(false);
3027
+ /** 弹层顶部说明卡:只在本会话第一次生成时出现一次。 */
3028
+ const [diagIntroSeen, setDiagIntroSeen] = useState(false);
3029
+ const [diagStatus, setDiagStatus] = useState(null);
3030
+ /** 复制/打开结果的小 toast 计时器(A4:成功与失败都要有 toast 反馈)。 */
3031
+ const diagToastTimer = useRef(null);
3032
+
3033
+ /** 就地提示 + 4.2 秒后自动消失的 toast。 */
3034
+ const flashDiagStatus = useCallback((next) => {
3035
+ setDiagStatus(next);
3036
+ if (diagToastTimer.current !== null) clearTimeout(diagToastTimer.current);
3037
+ diagToastTimer.current = setTimeout(() => setDiagStatus(null), 4200);
3038
+ }, []);
3039
+
3040
+ useEffect(
3041
+ () => () => {
3042
+ if (diagToastTimer.current !== null) clearTimeout(diagToastTimer.current);
3043
+ },
3044
+ [],
3045
+ );
2847
3046
 
2848
3047
  /** 把服务端返回的 view 落到表单:同时更新「已保存」快照和设置文件错误。 */
2849
3048
  const applyLoaded = useCallback((data) => {
@@ -2853,6 +3052,7 @@ window.__ModuleLoader__.load({
2853
3052
  setNative(data.native && typeof data.native === 'object' ? data.native : null);
2854
3053
  if (typeof data.version === 'string') setVersion(data.version);
2855
3054
  if (typeof data.path === 'string') setSettingsPath(data.path);
3055
+ setLedger(data.ledger !== null && typeof data.ledger === 'object' ? data.ledger : null);
2856
3056
  }, []);
2857
3057
 
2858
3058
  const load = useCallback(async () => {
@@ -2957,6 +3157,77 @@ window.__ModuleLoader__.load({
2957
3157
  }
2958
3158
  }, [confirmNative]);
2959
3159
 
3160
+ /**
3161
+ * 0.4 P0-2:生成诊断信息。
3162
+ *
3163
+ * 唯一的网络调用是同源只读 GET(`/api/compact-relay-diagnostics`,仅此一次);
3164
+ * 产物由宿主生成(白名单 + 已脱敏),这里只负责展示/复制。
3165
+ */
3166
+ const generateDiagnostics = useCallback(
3167
+ async ({ openPreview = true } = {}) => {
3168
+ if (diagLoading) return null;
3169
+ setDiagLoading(true);
3170
+ setDiagStatus(null);
3171
+ try {
3172
+ const res = await fetch('/api/compact-relay-diagnostics', { cache: 'no-store' });
3173
+ const data = await res.json().catch(() => ({}));
3174
+ if (!res.ok || data.ok === false) throw new Error(data && data.message ? String(data.message) : '生成失败');
3175
+ setDiagData(data);
3176
+ if (openPreview) setDiagOpen(true);
3177
+ return data;
3178
+ } catch (error) {
3179
+ setDiagStatus({ error: true, text: `生成失败:${error && error.message ? error.message : '未知错误'}` });
3180
+ return null;
3181
+ } finally {
3182
+ setDiagLoading(false);
3183
+ }
3184
+ },
3185
+ [diagLoading],
3186
+ );
3187
+
3188
+ /** 「打开问题报告页」:没生成就先生成,再打开宿主拼好的短字段 URL(不碰诊断正文)。 */
3189
+ const openIssueReport = useCallback(async () => {
3190
+ let data = diagData;
3191
+ if (data === null || typeof data.issueUrl !== 'string') data = await generateDiagnostics({ openPreview: false });
3192
+ if (data === null || typeof data.issueUrl !== 'string') return;
3193
+ try {
3194
+ window.open(data.issueUrl, '_blank', 'noopener');
3195
+ } catch {
3196
+ flashDiagStatus({ error: true, text: '打开问题报告页失败:新窗口被浏览器拦截了' });
3197
+ }
3198
+ }, [diagData, generateDiagnostics, flashDiagStatus]);
3199
+
3200
+ /** 复制 Markdown / JSON:统一走 navigator.clipboard?.writeText,失败就地提示。 */
3201
+ const copyDiagnostics = useCallback(
3202
+ (format) => {
3203
+ if (diagData === null) {
3204
+ flashDiagStatus({ error: true, text: '还没有可复制的诊断信息,请先生成' });
3205
+ return;
3206
+ }
3207
+ const text =
3208
+ format === 'json' ? JSON.stringify(diagData.json, null, 2) : typeof diagData.markdown === 'string' ? diagData.markdown : '';
3209
+ if (typeof text !== 'string' || text.length === 0) {
3210
+ flashDiagStatus({ error: true, text: '还没有可复制的诊断信息,请先生成' });
3211
+ return;
3212
+ }
3213
+ const done = () => flashDiagStatus({ text: '已复制诊断信息(脱敏)' });
3214
+ const failed = () => flashDiagStatus({ error: true, text: '复制失败:浏览器不允许访问剪贴板,可手动选中预览文本' });
3215
+ try {
3216
+ const written = navigator.clipboard?.writeText(text);
3217
+ if (written !== undefined && typeof written.then === 'function') written.then(done, failed);
3218
+ else done();
3219
+ } catch {
3220
+ failed();
3221
+ }
3222
+ },
3223
+ [diagData, flashDiagStatus],
3224
+ );
3225
+
3226
+ const closeDiagnosticsPreview = useCallback(() => {
3227
+ setDiagOpen(false);
3228
+ setDiagIntroSeen(true);
3229
+ }, []);
3230
+
2960
3231
  if (form === null) {
2961
3232
  return h(
2962
3233
  'div',
@@ -2969,11 +3240,21 @@ window.__ModuleLoader__.load({
2969
3240
  const compactPercent = percent(form.thresholds.compactRatio);
2970
3241
  const handoffPercent = percent(form.thresholds.handoffRatio);
2971
3242
  const count = (value) => (Number.isFinite(value) ? value : 0);
3243
+ // 0.4 P1-3:轻账本一行累计数字(只读;数据来自宿主账本,与账本文件同源)。
3244
+ const ledgerLine = (() => {
3245
+ if (ledger === null) return '累计:暂无记录(完成一次压缩/交接后开始统计)';
3246
+ const freed = count(ledger.freedTokens);
3247
+ const freedText = freed >= 1_000_000 ? `${(freed / 1_000_000).toFixed(1)}M` : freed >= 1000 ? `${(freed / 1000).toFixed(1)}K` : String(freed);
3248
+ const avg = ledger.avgDurationMs !== null && Number.isFinite(ledger.avgDurationMs) ? `${(ledger.avgDurationMs / 1000).toFixed(1)}s` : '—';
3249
+ return `累计:已压缩 ${count(ledger.compactCount)} 次 · 交接 ${count(ledger.handoffCount)} 次 · 腾出约 ${freedText} tokens · 平均耗时 ${avg}`;
3250
+ })();
2972
3251
 
2973
3252
  const autoOnNative = native !== null && native.autoEnabled !== false;
2974
3253
  const nativePercent = native !== null && typeof native.thresholdRatio === 'number' ? Math.round(native.thresholdRatio * 100) : 80;
2975
3254
  const previewLight = paletteForScheme(form.colors, 'light');
2976
3255
  const previewDark = paletteForScheme(form.colors, 'dark');
3256
+ // 0.4 P0-2:诊断预览弹层复用指示灯那套浮层材质(15-shared.panelChrome)。
3257
+ const theme = detectTheme();
2977
3258
  const banner = settingsError === null ? null : h(
2978
3259
  'div',
2979
3260
  { 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))' } },
@@ -3448,12 +3729,60 @@ window.__ModuleLoader__.load({
3448
3729
  // 2026-10-20:0.3.1 已发布到 npm,帮助区直接给包页面入口。
3449
3730
  githubLink('npm 包页面', 'https://www.npmjs.com/package/dsh-compact-relay', ''),
3450
3731
  ),
3732
+ // 0.4 P0-2:诊断信息 —— 本机生成、白名单、已脱敏;只提供「生成/预览/复制」。
3733
+ h(
3734
+ 'div',
3735
+ { style: { marginBottom: 18, paddingTop: 12, borderTop: '1px dashed var(--dsw-alias-border-l2, rgba(127,127,127,0.25))' }, 'data-cr-diagnostics': '1' },
3736
+ h('div', { style: { fontSize: 13, fontWeight: 600, marginBottom: 4 } }, '诊断信息'),
3737
+ h('p', { style: { ...settingsHintStyle, margin: '0 0 2px' } }, '只在本机生成,不会自动外发;生成后可以先预览,再决定把哪部分粘贴到哪里。'),
3738
+ h(
3739
+ 'p',
3740
+ { style: settingsHintStyle },
3741
+ '内容只有版本、平台、配置数值、状态摘要、计数、耗时与错误码;不含聊天内容、摘要、交接单正文、文件路径、用户名、会话 id、密钥或 cookie。',
3742
+ ),
3743
+ h(
3744
+ 'div',
3745
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginTop: 8 } },
3746
+ h(
3747
+ 'button',
3748
+ {
3749
+ type: 'button',
3750
+ 'data-cr-diagnostics-generate': '1',
3751
+ style: settingsButtonStyle,
3752
+ disabled: diagLoading,
3753
+ onClick: () => void generateDiagnostics(),
3754
+ },
3755
+ diagLoading ? '生成中…' : '生成并预览',
3756
+ ),
3757
+ h(
3758
+ 'button',
3759
+ { type: 'button', 'data-cr-diagnostics-issue': '1', style: settingsButtonStyle, onClick: () => void openIssueReport() },
3760
+ '打开问题报告页',
3761
+ ),
3762
+ diagStatus === null
3763
+ ? null
3764
+ : h(
3765
+ 'span',
3766
+ {
3767
+ 'data-cr-diagnostics-status': diagStatus.error ? 'error' : 'ok',
3768
+ style: {
3769
+ fontSize: 11,
3770
+ lineHeight: '16px',
3771
+ color: diagStatus.error ? 'var(--dsw-alias-state-error-primary, #D92D20)' : 'var(--dsw-alias-state-success-primary, #15803D)',
3772
+ },
3773
+ },
3774
+ diagStatus.text,
3775
+ ),
3776
+ ),
3777
+ h('p', { style: { ...settingsHintStyle, marginTop: 6 } }, 'issue 页是公开的;粘贴前请自己再看一眼。'),
3778
+ ),
3451
3779
  ),
3452
3780
  sectionHeading('关于与危险操作'),
3453
3781
  h(
3454
3782
  'div',
3455
3783
  { 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
3784
  h('p', { style: settingsHintStyle }, `版本:${version || '—'}`),
3785
+ h('p', { style: settingsHintStyle, 'data-cr-ledger': '1' }, ledgerLine),
3457
3786
  h('p', { style: settingsHintStyle }, `设置文件:${settingsPath || '—'}`),
3458
3787
  h('p', { style: { ...settingsHintStyle, marginTop: 8 } }, '输入框左侧的上下文指示灯:看容量、提醒压缩/交接、锁住重要消息;到线了用 /bc 或 /handoff,重要内容用 /pin 锁住。'),
3459
3788
  h('p', { style: settingsHintStyle }, '遇到 bug 或想提改进:走上面「帮助」里的 GitHub Issues;也欢迎直接发邮件。'),
@@ -3470,8 +3799,151 @@ window.__ModuleLoader__.load({
3470
3799
  )
3471
3800
  : h('button', { type: 'button', style: settingsButtonStyle, disabled: saving, onClick: () => setConfirmReset(true) }, '恢复默认'),
3472
3801
  ),
3473
- )
3474
-
3802
+ ),
3803
+ // 0.4 P0-2:诊断信息预览弹层(只读等宽文本 + 复制 Markdown/JSON)。
3804
+ // 只有用户点了「生成并预览」/「打开问题报告页」才会出现;关掉后本次会话不再显示首次说明卡。
3805
+ diagOpen && diagData !== null
3806
+ ? h(
3807
+ 'div',
3808
+ {
3809
+ 'data-cr-diagnostics-modal': '1',
3810
+ style: {
3811
+ position: 'fixed',
3812
+ inset: 0,
3813
+ zIndex: 60,
3814
+ display: 'flex',
3815
+ alignItems: 'center',
3816
+ justifyContent: 'center',
3817
+ padding: 24,
3818
+ background: 'rgba(0,0,0,0.36)',
3819
+ },
3820
+ },
3821
+ h(
3822
+ 'div',
3823
+ {
3824
+ role: 'dialog',
3825
+ 'aria-modal': 'true',
3826
+ 'aria-label': '诊断信息预览(脱敏)',
3827
+ style: {
3828
+ ...panelChrome(theme),
3829
+ display: 'flex',
3830
+ flexDirection: 'column',
3831
+ width: '100%',
3832
+ maxWidth: 760,
3833
+ maxHeight: '80vh',
3834
+ borderRadius: 12,
3835
+ padding: '14px 16px',
3836
+ },
3837
+ },
3838
+ h(
3839
+ 'div',
3840
+ { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 } },
3841
+ h('div', { style: { fontSize: 13, fontWeight: 700, color: 'var(--dsw-alias-label-primary, inherit)' } }, '诊断信息预览(脱敏)'),
3842
+ h('button', { type: 'button', style: settingsButtonStyle, onClick: closeDiagnosticsPreview }, '关闭'),
3843
+ ),
3844
+ diagIntroSeen
3845
+ ? null
3846
+ : h(
3847
+ 'div',
3848
+ {
3849
+ style: {
3850
+ marginTop: 10,
3851
+ padding: '8px 10px',
3852
+ borderRadius: 8,
3853
+ border: '1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,0.25))',
3854
+ background: 'var(--dsw-alias-bg-layer-1, rgba(127,127,127,0.06))',
3855
+ },
3856
+ },
3857
+ h('div', { style: { fontSize: 12, fontWeight: 600, marginBottom: 4 } }, '第一次生成:先看三点'),
3858
+ h(
3859
+ 'p',
3860
+ { style: settingsHintStyle },
3861
+ '① 只在本机生成,插件不会自己发到任何地方;② 只有数值、枚举与错误码,不含聊天内容与文件路径;③ issue 页是公开的,粘贴前请自己再看一眼。',
3862
+ ),
3863
+ ),
3864
+ h(
3865
+ 'pre',
3866
+ {
3867
+ 'data-cr-diagnostics-preview': '1',
3868
+ 'aria-label': '诊断信息 Markdown 预览(只读)',
3869
+ style: {
3870
+ flex: 1,
3871
+ minHeight: 120,
3872
+ overflow: 'auto',
3873
+ margin: '10px 0',
3874
+ padding: 10,
3875
+ borderRadius: 8,
3876
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.2))',
3877
+ background: 'var(--dsw-alias-bg-layer-1, rgba(127,127,127,0.06))',
3878
+ color: 'inherit',
3879
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
3880
+ fontSize: 11,
3881
+ lineHeight: '16px',
3882
+ whiteSpace: 'pre-wrap',
3883
+ wordBreak: 'break-word',
3884
+ userSelect: 'text',
3885
+ },
3886
+ },
3887
+ typeof diagData.markdown === 'string' && diagData.markdown.length > 0 ? diagData.markdown : '(没有内容)',
3888
+ ),
3889
+ h(
3890
+ 'div',
3891
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
3892
+ h(
3893
+ 'button',
3894
+ { type: 'button', 'data-cr-diagnostics-copy': 'markdown', style: settingsButtonStyle, onClick: () => copyDiagnostics('markdown') },
3895
+ '复制 Markdown',
3896
+ ),
3897
+ h(
3898
+ 'button',
3899
+ { type: 'button', 'data-cr-diagnostics-copy': 'json', style: settingsButtonStyle, onClick: () => copyDiagnostics('json') },
3900
+ '复制 JSON',
3901
+ ),
3902
+ h('span', { style: settingsHintStyle }, 'issue 是公开的;粘贴前请自己再看一眼。'),
3903
+ diagStatus === null
3904
+ ? null
3905
+ : h(
3906
+ 'span',
3907
+ {
3908
+ 'data-cr-diagnostics-copy-status': diagStatus.error ? 'error' : 'ok',
3909
+ style: {
3910
+ fontSize: 11,
3911
+ lineHeight: '16px',
3912
+ color: diagStatus.error
3913
+ ? 'var(--dsw-alias-state-error-primary, #D92D20)'
3914
+ : 'var(--dsw-alias-state-success-primary, #15803D)',
3915
+ },
3916
+ },
3917
+ diagStatus.text,
3918
+ ),
3919
+ ),
3920
+ ),
3921
+ )
3922
+ : null,
3923
+ // A4:复制/打开结果的短 toast(成功与失败都提示;自动消失,不挡操作)。
3924
+ diagStatus === null
3925
+ ? null
3926
+ : h(
3927
+ 'div',
3928
+ {
3929
+ 'data-cr-diagnostics-toast': diagStatus.error ? 'error' : 'ok',
3930
+ role: 'status',
3931
+ style: {
3932
+ ...panelChrome(theme),
3933
+ position: 'fixed',
3934
+ right: 24,
3935
+ bottom: 24,
3936
+ zIndex: 70,
3937
+ maxWidth: 360,
3938
+ padding: '8px 12px',
3939
+ borderRadius: 8,
3940
+ fontSize: 12,
3941
+ lineHeight: '17px',
3942
+ color: diagStatus.error ? 'var(--dsw-alias-state-error-primary, #D92D20)' : 'var(--dsw-alias-label-primary, inherit)',
3943
+ },
3944
+ },
3945
+ diagStatus.text,
3946
+ ),
3475
3947
  );
3476
3948
  }
3477
3949