dsh-compact-relay 0.4.1 → 0.5.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
@@ -2,6 +2,7 @@
2
2
  * dsh-compact-relay —— 浏览器半(DSH client bundle)。
3
3
  *
4
4
  * 职责:输入框左侧指示灯 / 悬停卡 / 菜单 / 压缩确认卡 / 交接单面板 / toast,
5
+ * 输入框上方接力条(0.5.0:已接力自 … + 下拉菜单),
5
6
  * 以及 /handoff 命令卡(命令成功后自动切到新会话)。
6
7
  * 数据:GET /api/compact-relay-state、SSE /api/compact-relay-stream;
7
8
  * 动作:POST /api/compact-relay-action;交接单:GET /api/compact-relay-handoffs。
@@ -724,6 +725,225 @@ window.__ModuleLoader__.load({
724
725
  })
725
726
  .filter((node) => node !== null);
726
727
  }
728
+ /**
729
+ * 交接线保命判定 —— 纯函数,零依赖(0.4.2)。
730
+ *
731
+ * 背景(0.4.1 遗留的三个洞,见 docs/0.4.2-implementation-plan.md):
732
+ * 1. 交接提醒线仍是配置值(默认 60%),换到实际触发线更低的模型(如 55%)时,
733
+ * DSH 会先自己压历史,dcr 的交接提醒来不及;
734
+ * 2. 服务端保存校验只看配置阈值(80%),手改设置文件能设到 70%;
735
+ * 3. 旧值不跟随当前模型,除设置页一行 warning 外没有其它可见提示。
736
+ *
737
+ * 0.4.2 规则(范围定稿):
738
+ * 交接提醒线 ≤ 实际触发线 − 5pp(HANDOFF_MARGIN_PP),向下取整到整数百分位。
739
+ * - 实际触发线来自 state.native.ratio(按当前路由模型实时算,见 core/native-line.js);
740
+ * - 实际线算不出时:运行时不告警、不阻断;服务端保存退回配置阈值防呆(0.4.1 行为);
741
+ * - DSH 原生自动压缩关闭时不存在「被抢先压缩」的截止线,不判定;
742
+ * - 只告警 / 拒绝,绝不静默改写用户配置。
743
+ *
744
+ * 本模块只做判定与文案,不做 IO:服务端 `lib/actions/settings.js` 直接 import,
745
+ * 客户端由 `scripts/build-client.mjs` 的 `/* @inline core/handoff-guard.js *\/`
746
+ * 内联进 `lib/client.js`(同一份判定,避免 UI 与后台两套口径)。
747
+ */
748
+
749
+ /** 交接线相对实际触发线至少留 5 个百分点(1M 窗口 ≈ 50K tokens 缓冲)。 */
750
+ const HANDOFF_MARGIN_PP = 5;
751
+
752
+ /** 实际线算不出、退回配置阈值防呆时的余量(0.4.1 口径:最高 = 配置阈值 −1pp)。 */
753
+ const FALLBACK_MARGIN_PP = 1;
754
+
755
+ /** cap 的绝对上限(避免算出 96%+ 这种没有意义的上限)。 */
756
+ const HANDOFF_LIMIT_DEFAULT = 0.95;
757
+
758
+ const EPS = 1e-9;
759
+
760
+ /**
761
+ * 合法窗口比例:必须是 0<r<1 的有限数。
762
+ * @param {unknown} value
763
+ * @returns {number|null}
764
+ */
765
+ function ratioOf(value) {
766
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 && value < 1 ? value : null;
767
+ }
768
+
769
+ /**
770
+ * 比例 → 百分数数字(一位小数,整数不带小数):0.678 → 67.8,0.62 → 62。
771
+ * @param {number|null} ratio
772
+ * @returns {number|null}
773
+ */
774
+ function percentOf(ratio) {
775
+ if (ratio === null) return null;
776
+ const percent = ratio * 100;
777
+ return Math.abs(percent - Math.round(percent)) < 0.05 ? Math.round(percent) : Number(percent.toFixed(1));
778
+ }
779
+
780
+ /**
781
+ * 比例 → 百分数文本(用于文案):0.678 → '67.8',0.62 → '62'。
782
+ * @param {number|null} ratio
783
+ * @returns {string}
784
+ */
785
+ function percentText(ratio) {
786
+ if (ratio === null) return '';
787
+ const percent = ratio * 100;
788
+ return Math.abs(percent - Math.round(percent)) < 0.05 ? String(Math.round(percent)) : percent.toFixed(1);
789
+ }
790
+
791
+ /**
792
+ * 只算交接线上限:参考线 − marginPp,向下取整到整数百分位,再夹到 limit。
793
+ *
794
+ * 浮点安全:`0.62 − 0.05` 在二进制里可能是 0.5700000000000001 / 0.5699999999999999,
795
+ * 直接 floor 会把 57 变成 56,所以加 EPS 再 floor。
796
+ *
797
+ * @param {number|null} referenceRatio 参考线(实际触发线或配置阈值),非法时返回 null
798
+ * @param {{marginPp?:number, limit?:number}} [options]
799
+ * @returns {number|null} 例如 0.62;参考线太小(减完 ≤0)时返回 null
800
+ */
801
+ function handoffCapOf(referenceRatio, { marginPp = HANDOFF_MARGIN_PP, limit = HANDOFF_LIMIT_DEFAULT } = {}) {
802
+ const reference = ratioOf(referenceRatio);
803
+ if (reference === null) return null;
804
+ const margin = (Number.isFinite(marginPp) ? marginPp : HANDOFF_MARGIN_PP) / 100;
805
+ const max = Number.isFinite(limit) && limit > 0 && limit < 1 ? limit : HANDOFF_LIMIT_DEFAULT;
806
+ // 向下取整到整数百分位(例如 62.8 → 0.62),不是四舍五入。
807
+ const cap = Math.floor((reference - margin) * 100 + EPS) / 100;
808
+ if (!(cap > 0)) return null;
809
+ return Math.min(cap, max);
810
+ }
811
+
812
+ /** 「重算目标 ≤ 压缩线」时的补充提示(保持「交接线 > 压缩线」现有约束)。 */
813
+ function compactConflictText(capPercent, compactRatio) {
814
+ return `按实际线重算目标 ${capPercent}% 不高于压缩线 ${percentText(compactRatio)}%,请先把压缩线降到 ${capPercent}% 以下。`;
815
+ }
816
+
817
+ /**
818
+ * 判定「当前交接线是否会在 DSH 原生压缩之后」。
819
+ *
820
+ * @param {object} [input]
821
+ * @param {number|null} [input.actualRatio] 当前模型实际触发线(state.native.ratio);算不出/关闭时 null
822
+ * @param {number|null} [input.configuredRatio] 配置阈值(native.thresholdRatio),兜底用
823
+ * @param {number|null} [input.handoffRatio] 当前交接提醒线(thresholds.handoffRatio)
824
+ * @param {number|null} [input.compactRatio] 压缩提醒线(thresholds.compactRatio),判重算冲突
825
+ * @param {boolean} [input.autoEnabled] DSH 原生自动压缩是否开启(false → 不判定,交接线无截止线)
826
+ * @param {number} [input.limit] cap 绝对上限,默认 0.95
827
+ * @param {'none'|'configured'} [input.fallback]
828
+ * 'none'(运行时默认):实际线算不出就不判定(不告警、不阻断);
829
+ * 'configured'(服务端保存默认):实际线算不出时按配置阈值 −1pp 防呆(0.4.1 行为)。
830
+ * @returns {{
831
+ * source:'actual'|'configured'|null,
832
+ * actualRatio:number|null, actualPercent:number|null,
833
+ * configuredRatio:number|null, configuredPercent:number|null,
834
+ * handoffRatio:number|null, handoffPercent:number|null,
835
+ * compactRatio:number|null, compactPercent:number|null,
836
+ * cap:number|null, capPercent:number|null,
837
+ * ok:boolean, severity:'ok'|'warn'|'late'|'unknown',
838
+ * reason:'ok'|'late'|'margin'|'fallback'|'no-reference'|'invalid-handoff'|'no-cap',
839
+ * message:string, target:number|null, compactConflict:boolean
840
+ * }}
841
+ */
842
+ function handoffGuard({
843
+ actualRatio = null,
844
+ configuredRatio = null,
845
+ handoffRatio = null,
846
+ compactRatio = null,
847
+ autoEnabled = true,
848
+ limit = HANDOFF_LIMIT_DEFAULT,
849
+ fallback = 'none',
850
+ } = {}) {
851
+ const actual = ratioOf(actualRatio);
852
+ const configured = ratioOf(configuredRatio);
853
+ const handoff = ratioOf(handoffRatio);
854
+ const compact = ratioOf(compactRatio);
855
+ const neutral = {
856
+ source: null,
857
+ actualRatio: actual,
858
+ actualPercent: percentOf(actual),
859
+ configuredRatio: configured,
860
+ configuredPercent: percentOf(configured),
861
+ handoffRatio: handoff,
862
+ handoffPercent: percentOf(handoff),
863
+ compactRatio: compact,
864
+ compactPercent: percentOf(compact),
865
+ cap: null,
866
+ capPercent: null,
867
+ ok: true,
868
+ severity: 'unknown',
869
+ reason: 'no-reference',
870
+ message: '',
871
+ target: null,
872
+ compactConflict: false,
873
+ };
874
+ // 原生自动压缩关着:DSH 不会自己压,不存在「提醒来不及」的截止线。
875
+ if (autoEnabled === false) return neutral;
876
+
877
+ /** 带 cap 的结果骨架(cap 已是整数百分位,×100 即整数)。 */
878
+ const withCap = (source, cap) => {
879
+ const capPercent = cap === null ? null : Math.round(cap * 100);
880
+ return {
881
+ ...neutral,
882
+ source,
883
+ cap,
884
+ capPercent,
885
+ target: source === 'actual' ? cap : null,
886
+ compactConflict: cap !== null && compact !== null && cap <= compact + EPS,
887
+ };
888
+ };
889
+
890
+ // 1) 实际触发线可用:唯一口径(−5pp),不再叠加「配置阈值 −1pp」。
891
+ if (actual !== null) {
892
+ const cap = handoffCapOf(actual, { limit });
893
+ if (cap === null) return { ...withCap('actual', null), reason: 'no-cap' };
894
+ const base = withCap('actual', cap);
895
+ if (handoff === null) return { ...base, reason: 'invalid-handoff' };
896
+ const conflict = base.compactConflict ? compactConflictText(base.capPercent, compact) : '';
897
+ // 等于实际触发线也算「晚」:DSH 会在同一位置自己压历史,提醒来不及。
898
+ if (handoff > actual - EPS) {
899
+ return {
900
+ ...base,
901
+ ok: false,
902
+ severity: 'late',
903
+ reason: 'late',
904
+ message:
905
+ `交接线已晚:当前模型实际触发 ${percentText(actual)}%,交接线 ${percentText(handoff)}% 会在原生压缩之后` +
906
+ `(DSH 会先压历史);最高可设 ${base.capPercent}%。${conflict}`,
907
+ };
908
+ }
909
+ if (handoff > cap + EPS) {
910
+ return {
911
+ ...base,
912
+ ok: false,
913
+ severity: 'warn',
914
+ reason: 'margin',
915
+ message:
916
+ `交接线偏晚:当前模型实际触发 ${percentText(actual)}%,交接线 ${percentText(handoff)}% ` +
917
+ `距原生压缩不足 ${HANDOFF_MARGIN_PP} 个百分点;最高可设 ${base.capPercent}%。${conflict}`,
918
+ };
919
+ }
920
+ return { ...base, ok: true, severity: 'ok', reason: 'ok' };
921
+ }
922
+
923
+ // 2) 实际线算不出 + 服务端保存:退回配置阈值防呆(0.4.1 行为,兼容旧客户端)。
924
+ if (fallback === 'configured' && configured !== null) {
925
+ const cap = handoffCapOf(configured, { marginPp: FALLBACK_MARGIN_PP, limit });
926
+ if (cap === null) return { ...withCap('configured', null), reason: 'no-cap' };
927
+ const base = withCap('configured', cap);
928
+ if (handoff === null) return { ...base, reason: 'invalid-handoff' };
929
+ if (handoff > cap + EPS) {
930
+ return {
931
+ ...base,
932
+ ok: false,
933
+ severity: 'warn',
934
+ reason: 'fallback',
935
+ message:
936
+ `「交接提醒线」必须低于 DSH 原生自动压缩线(配置阈值 ${percentText(configured)}%),` +
937
+ `否则原生压缩会先动手;最高只能设到 ${base.capPercent}%。` +
938
+ '想设更高请先在设置里关闭「DSH 原生自动压缩」。',
939
+ };
940
+ }
941
+ return { ...base, ok: true, severity: 'ok', reason: 'ok' };
942
+ }
943
+
944
+ // 3) 运行时且算不出:不告警、不阻断。
945
+ return neutral;
946
+ }
727
947
 
728
948
  const fmt = (n) => {
729
949
  if (n == null) return '—';
@@ -1307,6 +1527,22 @@ window.__ModuleLoader__.load({
1307
1527
  // 0.3.1:模型判断的「这里适合压缩」位置 → 断点1(优先于阈值);失效的 hint 由 host 过滤。
1308
1528
  const hintRatio = state.compressHint && typeof state.compressHint.ratio === 'number' ? state.compressHint.ratio : null;
1309
1529
  const ring = ringSegments(ratio, compactRatio, handoffRatio, hintRatio);
1530
+ /**
1531
+ * 0.4.2(P0-1):交接线是否已晚 —— 硬上限 = 实际触发线 −5pp(与后台 save() 同一份判定)。
1532
+ * 只在宿主算出实际触发线(native.ratio)且原生自动压缩开启时判定;
1533
+ * 算不出时按范围定稿「不告警、不阻断」。
1534
+ */
1535
+ const handoffGuardInfo = handoffGuard({
1536
+ actualRatio: state.native === null ? null : state.native.ratio,
1537
+ configuredRatio: state.native === null ? null : state.native.thresholdRatio,
1538
+ handoffRatio,
1539
+ compactRatio,
1540
+ autoEnabled: state.native === null ? true : state.native.autoEnabled !== false,
1541
+ });
1542
+ const handoffLate = handoffGuardInfo.source === 'actual' && handoffGuardInfo.ok === false;
1543
+ const handoffWarnText = handoffLate
1544
+ ? `⚠ 交接线已晚:实际触发 ${handoffGuardInfo.actualPercent}%,交接线 ${handoffGuardInfo.handoffPercent}% 会在原生压缩之后(最高 ${handoffGuardInfo.capPercent}%)`
1545
+ : '';
1310
1546
  // DSH 原生自动压缩线(报警最高阈值):开着时在环上画一个「径向刻度」(不是沿环的弧),
1311
1547
  // 用紫色 + 主题底色描边——刻意不用五级配色里的颜色,保证在任何分段颜色上都看得清。
1312
1548
  // 越过原生线后:整环红色闪烁(作者 2026-10-20 反馈)。
@@ -1374,6 +1610,39 @@ window.__ModuleLoader__.load({
1374
1610
  }),
1375
1611
  );
1376
1612
  }
1613
+ // 0.4.2(P1):交接线已晚时,在环上「交接刻度」旁加一个小警示(径向短线 + 小点)。
1614
+ // 只加节点,不改环的分段/断点逻辑(范围定稿:A/B/C 画法不并进 0.4.2)。
1615
+ const handoffWarnNodes = [];
1616
+ if (handoffLate) {
1617
+ const tick = nativeTickAt(handoffRatio); // 复用同一套径向几何,位置换成交接线
1618
+ const warnAngle = handoffRatio * Math.PI * 2 - Math.PI / 2;
1619
+ handoffWarnNodes.push(
1620
+ h('line', {
1621
+ key: 'handoff-warn-casing',
1622
+ ...tick,
1623
+ stroke: theme.dark ? '#26262c' : '#ffffff',
1624
+ strokeWidth: 4.4,
1625
+ strokeLinecap: 'round',
1626
+ opacity: 0.92,
1627
+ }),
1628
+ h('line', {
1629
+ key: 'handoff-warn',
1630
+ ...tick,
1631
+ stroke: palette.handoff,
1632
+ strokeWidth: 2.2,
1633
+ strokeLinecap: 'round',
1634
+ 'data-cr-handoff-warn': '1',
1635
+ }),
1636
+ h('circle', {
1637
+ key: 'handoff-warn-dot',
1638
+ cx: Number((10 + 9.4 * Math.cos(warnAngle)).toFixed(3)),
1639
+ cy: Number((10 + 9.4 * Math.sin(warnAngle)).toFixed(3)),
1640
+ r: 1.05,
1641
+ fill: palette.handoff,
1642
+ 'data-cr-handoff-warn-dot': '1',
1643
+ }),
1644
+ );
1645
+ }
1377
1646
  // 平时给中心一点可见的灰点;交接时红点(外环已分段,红点强调该交接)。
1378
1647
  const dotColor = adviseHandoff ? palette.handoff : palette.idle;
1379
1648
  // 锁定消息在环上的块(方案 A:按消息在历史里的相对位置)。这是「保护内容」的信号,
@@ -1488,6 +1757,29 @@ window.__ModuleLoader__.load({
1488
1757
  return () => window.removeEventListener('compact-relay:replay-onboarding', onReplay);
1489
1758
  }, [playOnboarding]);
1490
1759
 
1760
+ // 0.4.2(P1):换模型后首次发现「交接线已晚」弹一次提示(同路由不重复打扰)。
1761
+ // 只在「观察到路由变化」(localStorage 记着上一次的 provider/model)时弹;
1762
+ // 首次见到某路由不弹(不是换模型导致的),设置页有常驻提示兜底。
1763
+ useEffect(() => {
1764
+ if (state.native === null || state.native.route === null) return;
1765
+ const key = `${state.native.route.provider || ''}/${state.native.route.model}`;
1766
+ let previous = null;
1767
+ try {
1768
+ previous = window.localStorage.getItem('dsh.compact-relay.route.v1');
1769
+ } catch {
1770
+ previous = null; // 隐私模式:不记、不弹,避免反复打扰
1771
+ return;
1772
+ }
1773
+ if (previous === key) return;
1774
+ try {
1775
+ window.localStorage.setItem('dsh.compact-relay.route.v1', key);
1776
+ } catch {
1777
+ /* ignore */
1778
+ }
1779
+ if (previous === null || previous === '' || !handoffLate) return;
1780
+ showToast(`已切换到 ${state.native.route.model}:${handoffWarnText.replace('⚠ ', '')}。可在「设置 → 压缩接力」一键重算。`, 10000);
1781
+ }, [state.native, handoffLate, handoffWarnText, showToast]);
1782
+
1491
1783
  // 引导光晕:默认 4 次呼吸后自动停(手动重播时按传入时长)。
1492
1784
  useEffect(() => {
1493
1785
  if (!onboarding) return undefined;
@@ -1837,6 +2129,7 @@ window.__ModuleLoader__.load({
1837
2129
  `,已压缩 ${state.summaries} 次|${adviceText}` +
1838
2130
  (compressHintText !== '' ? `|${compressHintText}` : '') +
1839
2131
  (nativeLineText !== '' ? `|${nativeLineText}` : '') +
2132
+ (handoffWarnText !== '' ? `|${handoffWarnText}` : '') +
1840
2133
  (pins.count > 0 ? `|已锁定 ${pins.count} 条` : '') +
1841
2134
  (effectiveBusy ? '|处理中…' : '');
1842
2135
 
@@ -1896,6 +2189,10 @@ window.__ModuleLoader__.load({
1896
2189
  { style: { color: theme.subText } },
1897
2190
  `已压缩 ${state.summaries} 次 · ${adviceText}${compressHintText !== '' ? ` · ${compressHintText}` : ''}${nativeLineText !== '' ? ` · ${nativeLineText}` : ''}${pins.count > 0 ? ` · 已锁定 ${pins.count} 条` : ''}${effectiveBusy ? ' · 处理中…' : ''}`,
1898
2191
  ),
2192
+ // 0.4.2:交接线晚于原生压缩时,悬停卡里给一行显式警示(只告警,不改设置)。
2193
+ handoffWarnText !== ''
2194
+ ? h('div', { 'data-cr-handoff-warn-text': '1', style: { marginTop: 2, color: palette.handoff } }, handoffWarnText)
2195
+ : null,
1899
2196
  )
1900
2197
  : null;
1901
2198
 
@@ -1918,6 +2215,8 @@ window.__ModuleLoader__.load({
1918
2215
  }),
1919
2216
  // 0.3.1:DSH 原生自动压缩线(刻度/危险区)。
1920
2217
  ...nativeNodes,
2218
+ // 0.4.2:交接线已晚的警示标记(交接刻度旁的小短线 + 小点,不改分段逻辑)。
2219
+ ...handoffWarnNodes,
1921
2220
  // 0.3.1:锁定消息在环上的块(品红,按历史位置)。
1922
2221
  ...pinBlocks,
1923
2222
  );
@@ -2133,6 +2432,8 @@ window.__ModuleLoader__.load({
2133
2432
  onClick: () => {
2134
2433
  void startHandoffPreview();
2135
2434
  },
2435
+ // 0.4.2(P1):交接线晚于原生压缩时,菜单项旁直接标出来(只告警,仍可点)。
2436
+ ...(handoffLate ? { note: handoffWarnText, title: handoffGuardInfo.message } : {}),
2136
2437
  }
2137
2438
  : { disabled: true, note: '会话刚打开,请稍候' },
2138
2439
  ),
@@ -2841,114 +3142,878 @@ window.__ModuleLoader__.load({
2841
3142
  );
2842
3143
  }
2843
3144
 
2844
- /** 交接结果的一行式展示(放在聊天流里的 /handoff 命令卡)。 */
2845
- function handoffRow(text, tone, action) {
3145
+ /* ---------------- 0.5.0 接力条(v1):输入框上方一行「已接力自 …」 ---------------- */
3146
+
3147
+ /**
3148
+ * 0.5.0 接力条 v1 —— 只做「一行摘要 + 向上弹出的下拉菜单」。
3149
+ *
3150
+ * 位置:`conversation.input.dock`(list / session scope;宿主文档原文
3151
+ * “Full-width entries above the composer card”,即输入卡上方通栏)。
3152
+ * 显示:`readState.chain` 非 null,且这条记录是**本会话的入站记录**
3153
+ * (childId === sessionId)。出站记录(本会话交接给了下一代)不显示
3154
+ * 「已接力自」;老宿主/没有 chain 字段时组件返回 null,不占高度。
3155
+ * 数据:GET `/api/compact-relay-state?sessionId=…`。会话刚打开、宿主还没装日志
3156
+ * 时短重试几次(拿到本会话的 sessionId 后就停,不做常驻轮询/SSE)。
3157
+ * 子会话的 relay record 不带 path(F3 只写链号),缺 path 时再只读一次父
3158
+ * 会话状态,用父记录里的 path 打开交接单(childId/ordinal 校验)。
3159
+ * 交互:点击这一行 → fixed 定位、向上弹出的菜单(不被输入区裁切):
3160
+ * 查看交接单 / 回到原会话 / 路线图(即将上线,占位禁用)。
3161
+ */
3162
+
3163
+ /**
3164
+ * readState.chain → 接力条可用的最小结构;无效时返回 null(不猜、不补默认值)。
3165
+ *
3166
+ * 只接受 ordinal 为正整数的链;baseTitle/parentId/path 缺了就保持 null,
3167
+ * 由 UI 决定禁用或回落文案。老宿主的响应里没有 chain 字段 → null → 不渲染。
3168
+ *
3169
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3170
+ * @param {string|undefined} sessionId 当前会话 id(dock 的 session scope 参数)
3171
+ * @returns {{ordinal:number, baseTitle:string|null, parentId:string|null, childId:string|null, scheme:'handoff-n'|'history-n'|null, path:string|null}|null}
3172
+ */
3173
+ function relayBarChainOf(data, sessionId) {
3174
+ const chain = relayChainProjectionOf(data);
3175
+ if (chain === null) return null;
3176
+ // 出站记录(本会话把接力棒交给下一代):它不是「已接力自」,v1 不显示。
3177
+ // childId 缺失时(旧链路记录形态)不据此隐藏,遵守「chain 非 null 就显示」。
3178
+ if (chain.childId !== null && typeof sessionId === 'string' && sessionId.length > 0 && chain.childId !== sessionId) return null;
3179
+ return chain;
3180
+ }
3181
+
3182
+ /**
3183
+ * 只做字段投影,不做「是不是本会话入站记录」的判断。
3184
+ *
3185
+ * 单独拆出来是给「父记录补 path」用:父会话里的同一条记录是出站的
3186
+ * (childId=本会话),用 relayBarChainOf 会被入站判断过滤掉,但它的 path
3187
+ * 恰恰是子会话要打开的那份交接单。
3188
+ *
3189
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3190
+ * @returns {{ordinal:number, baseTitle:string|null, parentId:string|null, childId:string|null, scheme:'handoff-n'|'history-n'|null, path:string|null}|null}
3191
+ */
3192
+ function relayChainProjectionOf(data) {
3193
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3194
+ const chain = data.chain;
3195
+ if (chain === null || typeof chain !== 'object') return null;
3196
+ if (!Number.isInteger(chain.ordinal) || chain.ordinal <= 0) return null;
3197
+ const text = (value) => (typeof value === 'string' && value.length > 0 ? value : null);
3198
+ return {
3199
+ ordinal: chain.ordinal,
3200
+ baseTitle: text(chain.baseTitle),
3201
+ parentId: text(chain.parentId),
3202
+ childId: text(chain.childId),
3203
+ scheme: chain.scheme === 'handoff-n' || chain.scheme === 'history-n' ? chain.scheme : null,
3204
+ path: text(chain.path),
3205
+ };
3206
+ }
3207
+
3208
+ /**
3209
+ * 0.5.0:旧会话是否已被交接流程自动归档。
3210
+ *
3211
+ * 宿主 `readState` 在链有父会话时下发 `parentArchived`(boolean;服务不可用为 null)。
3212
+ * 老宿主没有这个字段 → null(客户端按「可尝试恢复」处理,恢复调用本身幂等)。
3213
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3214
+ * @returns {boolean|null}
3215
+ */
3216
+ function relayBarParentArchivedOf(data) {
3217
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3218
+ return typeof data.parentArchived === 'boolean' ? data.parentArchived : null;
3219
+ }
3220
+
3221
+ /**
3222
+ * 接力条摘要:新链「历史 N:原名」,旧链「交接第 N 次:原名」。
3223
+ * @param {object} chain relayBarChainOf 的结果
3224
+ * @returns {string}
3225
+ */
3226
+ function relayBarLabelOf(chain) {
3227
+ const baseTitle = chain.baseTitle === null ? '会话' : chain.baseTitle;
3228
+ return chain.scheme === 'history-n' ? `历史 ${chain.ordinal}:${baseTitle}` : `交接第 ${chain.ordinal} 次:${baseTitle}`;
3229
+ }
3230
+
3231
+ /**
3232
+ * 交接单绝对路径 → `/api/compact-relay-handoffs?file=` 只接受的 handoff-*.md 文件名。
3233
+ * 服务端只认文件名(不是全路径),这里取 basename 并做同一形态校验;不合法 → null。
3234
+ * @param {string|null} path
3235
+ * @returns {string|null}
3236
+ */
3237
+ function relayBarDocNameOf(path) {
3238
+ if (typeof path !== 'string' || path.length === 0) return null;
3239
+ const name = path.split(/[\\/]/).pop();
3240
+ return typeof name === 'string' && /^handoff-[0-9A-Za-z][0-9A-Za-z.\-_]*\.md$/.test(name) ? name : null;
3241
+ }
3242
+
3243
+ /** 接力条菜单里的一行(和指示灯菜单同一套视觉,但更轻)。 */
3244
+ function relayBarItem(label, sub, key, action, meta = {}) {
3245
+ const disabled = meta.disabled === true;
2846
3246
  return h(
2847
- 'div',
3247
+ 'button',
2848
3248
  {
3249
+ type: 'button',
3250
+ key,
3251
+ role: 'menuitem',
3252
+ 'data-cr-relay-action': key,
3253
+ disabled,
3254
+ title: meta.title ?? (disabled ? sub : undefined),
3255
+ onClick: () => {
3256
+ if (disabled || typeof action !== 'function') return;
3257
+ action();
3258
+ },
2849
3259
  style: {
2850
3260
  display: 'flex',
2851
3261
  alignItems: 'center',
2852
- gap: 8,
2853
- padding: '4px 2px',
2854
- fontSize: 12,
2855
- color: tone === 'error' ? COLORS.error : COLORS.subText,
3262
+ gap: 10,
3263
+ width: '100%',
3264
+ minHeight: 32,
3265
+ padding: '5px 10px',
3266
+ border: 'none',
3267
+ borderRadius: 8,
3268
+ background: 'transparent',
3269
+ textAlign: 'left',
3270
+ cursor: disabled ? 'default' : 'pointer',
3271
+ color: 'var(--dsw-alias-label-primary, inherit)',
3272
+ opacity: disabled ? 0.55 : 1,
3273
+ font: 'inherit',
3274
+ },
3275
+ onMouseEnter: (event) => {
3276
+ if (!disabled) event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.10))';
3277
+ },
3278
+ onMouseLeave: (event) => {
3279
+ event.currentTarget.style.background = 'transparent';
2856
3280
  },
2857
3281
  },
2858
- h('span', { style: { flex: 1, minWidth: 0 } }, text),
2859
- action
2860
- ? h(
2861
- 'button',
2862
- {
2863
- type: 'button',
2864
- onClick: action,
2865
- style: {
2866
- flex: 'none',
2867
- border: 'none',
2868
- borderRadius: 6,
2869
- padding: '2px 8px',
2870
- background: COLORS.hover,
2871
- color: 'inherit',
2872
- fontSize: 11,
2873
- cursor: 'pointer',
2874
- },
2875
- },
2876
- '重试打开',
2877
- )
2878
- : null,
3282
+ h(
3283
+ 'span',
3284
+ { style: { display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minWidth: 0 } },
3285
+ h('span', { style: { fontSize: 12, fontWeight: 500 } }, label),
3286
+ sub !== null
3287
+ ? h(
3288
+ 'span',
3289
+ { style: { fontSize: 10, lineHeight: '14px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))' } },
3290
+ sub,
3291
+ )
3292
+ : null,
3293
+ ),
3294
+ h(
3295
+ 'span',
3296
+ { style: { flex: 'none', fontSize: 11, color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))', whiteSpace: 'nowrap' } },
3297
+ meta.right ?? (disabled ? '' : '›'),
3298
+ ),
2879
3299
  );
2880
3300
  }
2881
3301
 
2882
3302
  /**
2883
- * /handoff 命令卡:命令成功后自动切到新会话。
2884
- * 这个槽把命令的通用卡片替换成我们的卡片(只有 /handoff 这一个 key)。
3303
+ * 接力条本体(`conversation.input.dock`,输入卡上方一行)。
3304
+ *
3305
+ * @param {object} props ctx.slots.register 注入的 props
3306
+ * @param {string} [props.sessionId] 当前会话 id(session scope)
3307
+ * @param {(sessionId:string)=>unknown} [props.openSession] 切到某个会话(ctx.uiWorkspace.openSession)
3308
+ * @param {(sessionId:string)=>Promise<unknown>|undefined} [props.unarchiveSession] 恢复归档会话
3309
+ * (ctx.uiWorkspace.unarchiveSession;老宿主没有 → 走本插件 HTTP recover 兜底)
2885
3310
  */
2886
- function HandoffCard({ node, waitAndOpen }) {
2887
- const outcome = node !== null && typeof node === 'object' ? node.outcome : undefined;
2888
- const outcomeText = outcome !== null && typeof outcome === 'object' && typeof outcome.text === 'string' ? outcome.text : '';
2889
- // A5:优先用命令结果里的结构化 childId;旧宿主没带字段时再从文本 #child= 兜底。
2890
- const childId =
2891
- outcome !== null && typeof outcome === 'object' && typeof outcome.childId === 'string' && outcome.childId.length > 0
2892
- ? outcome.childId
2893
- : extractChildId(outcomeText);
2894
- // P2-C1:区分 opening / opened / failed;刷新后只有 localStorage='1' 才算已打开,
2895
- // 'opening'(上次没走完)要重新尝试,不能直接渲染成功态。
2896
- const [status, setStatus] = useState(() => {
2897
- if (outcome === undefined) return 'running';
2898
- if (outcome !== null && outcome.kind === 'error') return 'error';
2899
- if (childId === null) return 'done';
2900
- return wasOpened(childId) ? 'done' : 'opening';
2901
- });
2902
- const [detail, setDetail] = useState(null);
3311
+ function CompactRelayBar(props) {
3312
+ const sessionId = props === null || props === undefined ? undefined : props.sessionId;
3313
+ const openSession = props === null || props === undefined ? undefined : props.openSession;
3314
+ const unarchiveSession = props === null || props === undefined ? undefined : props.unarchiveSession;
3315
+ const hasSession = typeof sessionId === 'string' && sessionId.length > 0;
3316
+ const [chain, setChain] = useState(null);
3317
+ // null = 宿主没给归档状态(老宿主);true/false = readState.parentArchived。
3318
+ const [parentArchived, setParentArchived] = useState(null);
3319
+ const [open, setOpen] = useState(false);
3320
+ const [doc, setDoc] = useState(null);
3321
+ const [notice, setNotice] = useState(null);
3322
+ const [anchor, setAnchor] = useState(null);
3323
+ const theme = useAutoTheme();
3324
+ const rootRef = useRef(null);
3325
+ const triggerRef = useRef(null);
3326
+ const menuRef = useRef(null);
3327
+ const docRequestRef = useRef(0);
3328
+ const docAbortRef = useRef(null);
2903
3329
 
2904
- const openChild = useCallback(() => {
2905
- if (childId === null) return;
2906
- setStatus('opening');
2907
- setDetail(null);
2908
- waitAndOpen(childId).then(
2909
- () => {
2910
- markOpened(childId, '1');
2911
- setStatus('done');
2912
- },
2913
- (error) => {
2914
- clearOpened(childId);
2915
- setStatus('failed');
2916
- setDetail(String(error && error.message ? error.message : error));
2917
- },
2918
- );
2919
- }, [childId, waitAndOpen]);
3330
+ /** 取消未完成的交接单读取;同时让晚到的响应失效(requestId 对不上)。 */
3331
+ const abortDoc = useCallback(() => {
3332
+ docRequestRef.current += 1;
3333
+ if (docAbortRef.current !== null) {
3334
+ try {
3335
+ docAbortRef.current.abort();
3336
+ } catch {
3337
+ /* ignore */
3338
+ }
3339
+ docAbortRef.current = null;
3340
+ }
3341
+ }, []);
2920
3342
 
3343
+ const closeMenu = useCallback(() => {
3344
+ abortDoc();
3345
+ setOpen(false);
3346
+ setDoc(null);
3347
+ setNotice(null);
3348
+ }, [abortDoc]);
3349
+
3350
+ // 从交接单详情回到菜单列表:取消读取,避免「返回后又被晚到的响应撑开」。
3351
+ const backToMenu = useCallback(() => {
3352
+ abortDoc();
3353
+ setDoc(null);
3354
+ }, [abortDoc]);
3355
+
3356
+ // 读链状态。会话刚打开时宿主可能还没把日志装进内存(readState.sessionId 为
3357
+ // null),这时要重试几次;一旦宿主确认「就是这个会话且没有 chain」就停,
3358
+ // 普通会话不会无限打接口。fetch 失败/老宿主没有 chain → setChain(null) → 不渲染。
2921
3359
  useEffect(() => {
2922
- if (outcome === undefined || outcome === null || outcome.kind !== 'success' || childId === null) return;
2923
- if (wasOpened(childId)) {
2924
- setStatus('done');
2925
- return;
3360
+ // 换会话/没有会话时先清掉上一条链(dock 组件可能被复用而不是重挂)。
3361
+ setChain(null);
3362
+ setParentArchived(null);
3363
+ setOpen(false);
3364
+ setDoc(null);
3365
+ setNotice(null);
3366
+ if (!hasSession) return undefined;
3367
+ let dead = false;
3368
+ let timer = 0;
3369
+ let attempts = 0;
3370
+ const url = `/api/compact-relay-state?sessionId=${encodeURIComponent(sessionId)}`;
3371
+ const schedule = () => {
3372
+ if (dead) return;
3373
+ attempts += 1;
3374
+ if (attempts > 12) return;
3375
+ timer = setTimeout(() => {
3376
+ void load();
3377
+ }, Math.min(500 * attempts, 3000));
3378
+ };
3379
+ async function loadParentPath(localChain) {
3380
+ try {
3381
+ const res = await fetch(`/api/compact-relay-state?sessionId=${encodeURIComponent(localChain.parentId)}`, {
3382
+ cache: 'no-store',
3383
+ });
3384
+ if (dead || !res.ok) return;
3385
+ const parentData = await res.json().catch(() => null);
3386
+ if (dead) return;
3387
+ const parentChain = relayChainProjectionOf(parentData);
3388
+ if (
3389
+ parentChain === null ||
3390
+ parentChain.childId !== sessionId ||
3391
+ parentChain.ordinal !== localChain.ordinal ||
3392
+ parentChain.path === null
3393
+ ) {
3394
+ return;
3395
+ }
3396
+ setChain((previous) => (previous === null ? previous : { ...previous, path: parentChain.path }));
3397
+ } catch {
3398
+ /* 父会话读不到(未加载/已归档):保持「暂无交接单」,不影响接力条本身 */
3399
+ }
2926
3400
  }
2927
- markOpened(childId, 'opening');
2928
- let alive = true;
2929
- waitAndOpen(childId).then(
2930
- () => {
2931
- markOpened(childId, '1');
2932
- if (alive) setStatus('done');
2933
- },
2934
- (error) => {
2935
- clearOpened(childId);
2936
- if (!alive) return;
2937
- setStatus('failed');
2938
- setDetail(String(error && error.message ? error.message : error));
2939
- },
2940
- );
3401
+ async function load() {
3402
+ try {
3403
+ const res = await fetch(url, { cache: 'no-store' });
3404
+ if (dead) return;
3405
+ if (!res.ok) {
3406
+ // 4xx(鉴权/端点不存在)等下去也不会变;5xx/网络抖动才重试。
3407
+ if (res.status >= 400 && res.status < 500) return;
3408
+ schedule();
3409
+ return;
3410
+ }
3411
+ const data = await res.json().catch(() => null);
3412
+ if (dead) return;
3413
+ if (data !== null && typeof data === 'object' && data.ok === false) return;
3414
+ const parsed = relayBarChainOf(data, sessionId);
3415
+ if (parsed !== null) {
3416
+ setChain(parsed);
3417
+ setParentArchived(relayBarParentArchivedOf(data));
3418
+ // F3 的子会话记录不带 path(交接单路径只写在父记录里):向父会话读回
3419
+ // 同一条链,childId 指回本会话且链号一致时才采用它的 path。只读、失败
3420
+ // 就保持「暂无交接单」;不碰宿主逻辑,也不改交接流程。
3421
+ if (parsed.path === null && parsed.parentId !== null) void loadParentPath(parsed);
3422
+ return;
3423
+ }
3424
+ // sessionId 对不上/为 null → 宿主还没加载本会话,继续等;对得上 → 确定没有链。
3425
+ const loaded =
3426
+ data !== null && typeof data === 'object' && typeof data.sessionId === 'string' && data.sessionId === sessionId;
3427
+ if (loaded) {
3428
+ setChain(null);
3429
+ setParentArchived(null);
3430
+ } else schedule();
3431
+ } catch {
3432
+ /* 宿主不在/网络失败:保持「没有链」—— 不显示,不占高度;稍后再试 */
3433
+ schedule();
3434
+ }
3435
+ }
3436
+ void load();
2941
3437
  return () => {
2942
- alive = false;
3438
+ dead = true;
3439
+ if (timer !== 0) clearTimeout(timer);
2943
3440
  };
2944
- }, [childId, outcome, waitAndOpen]);
3441
+ }, [hasSession, sessionId]);
2945
3442
 
2946
- if (status === 'running') return handoffRow('正在交接:压缩旧会话 → 生成交接单 → 打开新会话…');
2947
- if (status === 'error') return handoffRow(outcomeText || '交接失败(详见日志)', 'error');
2948
- if (childId === null) return handoffRow(outcomeText || '交接完成');
2949
- if (status === 'failed') {
2950
- return handoffRow(`新会话已建好(${childId}),自动切换失败:${detail || '未知原因'}`, 'error', openChild);
2951
- }
3443
+ // 菜单打开时按这一行的真实位置重算锚点(滚动/缩放不漂)。
3444
+ useEffect(() => {
3445
+ if (!open) return undefined;
3446
+ const update = () => {
3447
+ const root = rootRef.current;
3448
+ const el = root === null || root === undefined ? null : root.firstElementChild;
3449
+ if (el === null || el === undefined || typeof el.getBoundingClientRect !== 'function') return;
3450
+ const rect = el.getBoundingClientRect();
3451
+ setAnchor({
3452
+ left: Math.round(rect.left),
3453
+ right: Math.round(rect.right),
3454
+ top: Math.round(rect.top),
3455
+ bottom: Math.round(rect.bottom),
3456
+ });
3457
+ };
3458
+ update();
3459
+ window.addEventListener('scroll', update, true);
3460
+ window.addEventListener('resize', update);
3461
+ return () => {
3462
+ window.removeEventListener('scroll', update, true);
3463
+ window.removeEventListener('resize', update);
3464
+ };
3465
+ }, [open]);
3466
+
3467
+ // 点外面 / Esc 关闭菜单与交接单查看。
3468
+ useEffect(() => {
3469
+ if (!open) return undefined;
3470
+ const inside = (target) => {
3471
+ const root = rootRef.current;
3472
+ const menu = menuRef.current;
3473
+ return (root !== null && root !== undefined && root.contains(target)) || (menu !== null && menu !== undefined && menu.contains(target));
3474
+ };
3475
+ const onDown = (event) => {
3476
+ if (inside(event.target)) return;
3477
+ closeMenu();
3478
+ };
3479
+ const onKey = (event) => {
3480
+ if (event.key !== 'Escape') return;
3481
+ closeMenu();
3482
+ const trigger = triggerRef.current;
3483
+ if (trigger !== null && trigger !== undefined && typeof trigger.focus === 'function') {
3484
+ window.setTimeout(() => trigger.focus(), 0);
3485
+ }
3486
+ };
3487
+ document.addEventListener('mousedown', onDown);
3488
+ document.addEventListener('keydown', onKey);
3489
+ return () => {
3490
+ document.removeEventListener('mousedown', onDown);
3491
+ document.removeEventListener('keydown', onKey);
3492
+ };
3493
+ }, [open, closeMenu]);
3494
+
3495
+ // 打开后把焦点送进第一个可操作项(键盘用户不用再 Tab 找)。
3496
+ useEffect(() => {
3497
+ if (!open || doc !== null) return;
3498
+ const menu = menuRef.current;
3499
+ if (menu === null || menu === undefined || typeof menu.querySelector !== 'function') return;
3500
+ const first = menu.querySelector('button:not(:disabled)');
3501
+ if (first !== null && first !== undefined && typeof first.focus === 'function') first.focus();
3502
+ }, [open, doc]);
3503
+
3504
+ const toggleMenu = useCallback((event) => {
3505
+ const current = event === null || event === undefined ? null : event.currentTarget;
3506
+ if (current !== null && current !== undefined && typeof current.getBoundingClientRect === 'function') {
3507
+ const rect = current.getBoundingClientRect();
3508
+ setAnchor({
3509
+ left: Math.round(rect.left),
3510
+ right: Math.round(rect.right),
3511
+ top: Math.round(rect.top),
3512
+ bottom: Math.round(rect.bottom),
3513
+ });
3514
+ }
3515
+ abortDoc();
3516
+ setNotice(null);
3517
+ setDoc(null);
3518
+ setOpen((value) => !value);
3519
+ }, [abortDoc]);
3520
+
3521
+ const loadDoc = useCallback(async (name) => {
3522
+ if (typeof name !== 'string' || name.length === 0) return;
3523
+ if (docAbortRef.current !== null) {
3524
+ try {
3525
+ docAbortRef.current.abort();
3526
+ } catch {
3527
+ /* ignore */
3528
+ }
3529
+ }
3530
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
3531
+ docAbortRef.current = controller;
3532
+ const requestId = ++docRequestRef.current;
3533
+ setDoc({ loading: true, name });
3534
+ setNotice(null);
3535
+ try {
3536
+ const res = await fetch(`/api/compact-relay-handoffs?file=${encodeURIComponent(name)}`, {
3537
+ cache: 'no-store',
3538
+ ...(controller === null ? {} : { signal: controller.signal }),
3539
+ });
3540
+ const data = await res.json().catch(() => ({}));
3541
+ if (requestId !== docRequestRef.current) return;
3542
+ if (!res.ok || data.ok === false) throw new Error(data && data.message ? String(data.message) : '读取失败');
3543
+ setDoc({
3544
+ loading: false,
3545
+ name,
3546
+ content: typeof data.content === 'string' ? data.content : '',
3547
+ path: typeof data.path === 'string' ? data.path : null,
3548
+ });
3549
+ } catch (error) {
3550
+ if (requestId !== docRequestRef.current || (error && error.name === 'AbortError')) return;
3551
+ setDoc({
3552
+ loading: false,
3553
+ name,
3554
+ error: '读取失败:' + (error && error.message ? error.message : '未知错误'),
3555
+ });
3556
+ } finally {
3557
+ if (requestId === docRequestRef.current) docAbortRef.current = null;
3558
+ }
3559
+ }, []);
3560
+
3561
+ /** 直接切到父会话(不碰归档状态);成功发起返回 true。 */
3562
+ const openParentNow = useCallback(() => {
3563
+ if (chain === null || chain.parentId === null) return false;
3564
+ if (typeof openSession !== 'function') {
3565
+ setNotice('这个宿主不支持切换会话,无法自动打开原会话。');
3566
+ return false;
3567
+ }
3568
+ try {
3569
+ const result = openSession(chain.parentId);
3570
+ if (result !== null && result !== undefined && typeof result.then === 'function') {
3571
+ result.catch((error) => {
3572
+ setNotice('打不开原会话:' + (error && error.message ? error.message : '未知错误'));
3573
+ setOpen(true);
3574
+ });
3575
+ }
3576
+ return true;
3577
+ } catch (error) {
3578
+ setNotice('打不开原会话:' + (error && error.message ? error.message : '未知错误'));
3579
+ return false;
3580
+ }
3581
+ }, [chain, openSession]);
3582
+
3583
+ /**
3584
+ * 0.5.0:恢复归档的父会话并切过去。
3585
+ *
3586
+ * 优先用宿主 Client 侧的 `ctx.uiWorkspace.unarchiveSession`(与侧栏「取消归档」
3587
+ * 同一条接缝,会同步更新本地归档集合);老宿主没有时走本插件 HTTP
3588
+ * `action:'recover'`(服务端 workspaceRegistry.unarchiveSession),成功后同样尝试切换。
3589
+ * 恢复本身幂等:父会话没归档时宿主 no-op,仍然可以直接打开。
3590
+ */
3591
+ const restoreParent = useCallback(() => {
3592
+ setNotice(null);
3593
+ if (chain === null || chain.parentId === null) return undefined;
3594
+ const target = chain.parentId;
3595
+ return (async () => {
3596
+ try {
3597
+ if (typeof unarchiveSession === 'function') {
3598
+ await unarchiveSession(target);
3599
+ } else {
3600
+ const res = await fetch('/api/compact-relay-action', {
3601
+ method: 'POST',
3602
+ headers: { 'content-type': 'application/json' },
3603
+ cache: 'no-store',
3604
+ body: JSON.stringify({ action: 'recover', sessionId: target }),
3605
+ });
3606
+ const data = await res.json().catch(() => null);
3607
+ if (!res.ok || data === null || data.ok === false) {
3608
+ throw new Error(data !== null && typeof data.message === 'string' ? data.message : '恢复接口不可用');
3609
+ }
3610
+ }
3611
+ setParentArchived(false);
3612
+ if (typeof openSession !== 'function') {
3613
+ setNotice('旧会话已恢复,但这个宿主不支持自动切换;请从侧栏打开。');
3614
+ setOpen(true);
3615
+ return;
3616
+ }
3617
+ const result = openSession(target);
3618
+ if (result !== null && result !== undefined && typeof result.then === 'function') {
3619
+ result.catch((error) => {
3620
+ setNotice('已恢复旧会话,但自动切换失败:' + (error && error.message ? error.message : '未知错误'));
3621
+ setOpen(true);
3622
+ });
3623
+ }
3624
+ setOpen(false);
3625
+ setDoc(null);
3626
+ } catch (error) {
3627
+ setNotice('恢复旧会话失败:' + (error && error.message ? error.message : '未知错误'));
3628
+ setOpen(true);
3629
+ }
3630
+ })();
3631
+ }, [chain, openSession, unarchiveSession]);
3632
+
3633
+ const goParent = useCallback(() => {
3634
+ setNotice(null);
3635
+ if (chain === null || chain.parentId === null) return;
3636
+ // 已归档 → 先恢复再打开(否则宿主会拒绝打开归档会话)。
3637
+ if (parentArchived === true) {
3638
+ void restoreParent();
3639
+ return;
3640
+ }
3641
+ if (openParentNow()) {
3642
+ setOpen(false);
3643
+ setDoc(null);
3644
+ }
3645
+ }, [chain, openParentNow, parentArchived, restoreParent]);
3646
+
3647
+ // 无链 → 什么都不渲染(不占 composerStack 的高度)。
3648
+ if (chain === null) return null;
3649
+
3650
+ const label = relayBarLabelOf(chain);
3651
+ const docName = relayBarDocNameOf(chain.path);
3652
+ const hasParent = chain.parentId !== null;
3653
+ // 归档状态已知且未归档 → 不需要恢复入口;true(已归档)或 null(老宿主不知道)
3654
+ // 都显示——恢复调用幂等,宁可多一个入口,也不让用户的旧会话找不回来。
3655
+ const showRestore = hasParent && parentArchived !== false;
3656
+ const vw = typeof window === 'undefined' ? 1280 : window.innerWidth;
3657
+ const vh = typeof window === 'undefined' ? 900 : window.innerHeight;
3658
+ const menuWidth = Math.min(doc === null ? 272 : 420, Math.max(240, vw - 16));
3659
+ const estimatedHeight = doc === null ? 168 : 360;
3660
+ const anchorTop = anchor === null ? vh - 96 : anchor.top;
3661
+ const anchorBottom = anchor === null ? vh - 40 : anchor.bottom;
3662
+ const openUp = anchorTop >= Math.min(estimatedHeight, vh - 16) + 12;
3663
+ const left = Math.max(8, Math.min(anchor === null ? 8 : anchor.left, Math.max(8, vw - menuWidth - 8)));
3664
+ const position = openUp
3665
+ ? { left, bottom: Math.max(8, vh - anchorTop + 6) }
3666
+ : { left, top: Math.min(Math.max(8, anchorBottom + 6), Math.max(8, vh - Math.min(estimatedHeight, vh - 16) - 8)) };
3667
+
3668
+ const docPanel =
3669
+ doc === null
3670
+ ? null
3671
+ : h(
3672
+ 'div',
3673
+ { role: 'dialog', 'aria-label': '交接单', 'data-cr-relay-doc': '1', style: { display: 'flex', flexDirection: 'column', minHeight: 0 } },
3674
+ h(
3675
+ 'div',
3676
+ {
3677
+ style: {
3678
+ display: 'flex',
3679
+ alignItems: 'center',
3680
+ gap: 8,
3681
+ padding: '7px 10px',
3682
+ borderBottom: `1px solid ${theme.panelBorder}`,
3683
+ fontSize: 12,
3684
+ fontWeight: 600,
3685
+ },
3686
+ },
3687
+ h('span', { style: { flex: 1, minWidth: 0 } }, '交接单'),
3688
+ h(
3689
+ 'button',
3690
+ {
3691
+ type: 'button',
3692
+ 'data-cr-relay-doc-back': '1',
3693
+ onClick: backToMenu,
3694
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 6, padding: '2px 8px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
3695
+ },
3696
+ '← 返回',
3697
+ ),
3698
+ h(
3699
+ 'button',
3700
+ {
3701
+ type: 'button',
3702
+ 'data-cr-relay-doc-close': '1',
3703
+ onClick: closeMenu,
3704
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 6, padding: '2px 8px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
3705
+ },
3706
+ '关闭',
3707
+ ),
3708
+ ),
3709
+ doc.loading === true
3710
+ ? h('div', { style: { padding: '10px 12px', fontSize: 11, color: theme.subText } }, '读取中…')
3711
+ : doc.error !== undefined && doc.error !== null
3712
+ ? h('div', { style: { padding: '10px 12px', fontSize: 11, color: COLORS.error } }, String(doc.error))
3713
+ : h(
3714
+ 'div',
3715
+ { style: { overflowY: 'auto', overflowX: 'hidden', padding: '8px 12px', fontSize: 11, lineHeight: '16px', maxHeight: 'min(52vh, 320px)', wordBreak: 'break-word' } },
3716
+ renderMarkdownLite(doc.content ?? ''),
3717
+ ),
3718
+ );
3719
+
3720
+ const menu =
3721
+ open === true
3722
+ ? h(
3723
+ 'div',
3724
+ {
3725
+ ref: menuRef,
3726
+ role: 'menu',
3727
+ 'aria-label': '接力菜单',
3728
+ 'data-cr-relay-menu': '1',
3729
+ style: {
3730
+ position: 'fixed',
3731
+ ...position,
3732
+ width: menuWidth,
3733
+ maxWidth: 'calc(100vw - 16px)',
3734
+ maxHeight: 'min(65vh, 460px)',
3735
+ display: 'flex',
3736
+ flexDirection: 'column',
3737
+ overflow: 'hidden',
3738
+ padding: doc === null ? 5 : 0,
3739
+ borderRadius: 12,
3740
+ ...panelChrome(theme),
3741
+ color: theme.panelFg,
3742
+ // 顶层:高于指示灯菜单/确认卡(39–44),避免被输入区或其它浮层盖住。
3743
+ zIndex: 60,
3744
+ },
3745
+ },
3746
+ notice !== null
3747
+ ? h(
3748
+ 'div',
3749
+ { 'data-cr-relay-notice': '1', style: { padding: '7px 10px', fontSize: 11, lineHeight: '15px', color: 'var(--dsw-alias-state-error-primary, #D92D20)' } },
3750
+ notice,
3751
+ )
3752
+ : null,
3753
+ docPanel ??
3754
+ h(
3755
+ 'div',
3756
+ { style: { display: 'flex', flexDirection: 'column' } },
3757
+ relayBarItem('查看交接单', docName === null ? '这一链没有记录交接单路径' : '打开本次交接的九段交接单', 'doc', () => {
3758
+ void loadDoc(docName);
3759
+ }, {
3760
+ disabled: docName === null,
3761
+ // 需求文案:无 path 时明确提示「暂无交接单」。
3762
+ right: docName === null ? '暂无交接单' : '›',
3763
+ title: docName === null ? '暂无交接单:这条接力记录里没有交接单路径(旧记录,或父会话已不在)' : '打开交接单',
3764
+ }),
3765
+ relayBarItem('回到原会话', hasParent ? '切到上一个会话,继续看那边历史' : '这条记录没有父会话 id', 'parent', goParent, {
3766
+ disabled: !hasParent,
3767
+ right: hasParent ? '›' : '不可用',
3768
+ title: hasParent ? `打开原会话 ${chain.parentId}` : undefined,
3769
+ }),
3770
+ showRestore
3771
+ ? relayBarItem(
3772
+ '恢复旧会话',
3773
+ parentArchived === true
3774
+ ? '旧会话已归档:恢复后切回上一代'
3775
+ : '宿主没提供归档状态;恢复调用是幂等的,仍可直接尝试',
3776
+ 'unarchive',
3777
+ () => {
3778
+ void restoreParent();
3779
+ },
3780
+ {
3781
+ right: parentArchived === true ? '已归档' : '恢复',
3782
+ title: `从归档中恢复旧会话 ${chain.parentId}`,
3783
+ },
3784
+ )
3785
+ : null,
3786
+ relayBarItem('路线图(即将上线)', '跨会话时间线,0.6 之后', 'roadmap', null, {
3787
+ disabled: true,
3788
+ right: '即将上线',
3789
+ }),
3790
+ ),
3791
+ )
3792
+ : null;
3793
+
3794
+ return h(
3795
+ 'div',
3796
+ {
3797
+ ref: rootRef,
3798
+ 'data-cr-relay-root': '1',
3799
+ style: {
3800
+ boxSizing: 'border-box',
3801
+ flex: 'none',
3802
+ // 与宿主 TodoPanel(同一 dock 槽)一致:两侧留出 composer 的边距,
3803
+ // 宽度跟着输入卡走,避免通栏压到侧栏。
3804
+ width: 'calc(100% - var(--dsh-composer-side-clearance, 16px) * 2 - var(--dsh-composer-dock-inset, 8px) * 4)',
3805
+ maxWidth: 'calc(var(--dsh-composer-card-max-width, 920px) - var(--dsh-composer-dock-inset, 8px) * 4)',
3806
+ margin: '0 auto',
3807
+ },
3808
+ },
3809
+ // 卡片外观放在内层:backdrop-filter 会给 fixed 子元素造 containing block,
3810
+ // 菜单必须留在这一层之外,才能相对视口定位(不被输入区裁切)。
3811
+ h(
3812
+ 'div',
3813
+ {
3814
+ 'data-cr-relay-card': '1',
3815
+ style: {
3816
+ borderRadius: 'var(--dsw-radius-lg, 12px)',
3817
+ border: '1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.24))',
3818
+ // 底色/字体全部走 DSH 主题 token:切深浅色不需要重挂组件;老宿主用 theme 兜底。
3819
+ background: `var(--dsw-specific-menu, ${theme.panelBg})`,
3820
+ backdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
3821
+ WebkitBackdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
3822
+ boxShadow: 'var(--dsw-elevation-panel, 0 1px 2px rgba(0,0,0,0.06))',
3823
+ color: 'var(--dsw-alias-label-primary, inherit)',
3824
+ overflow: 'hidden',
3825
+ },
3826
+ },
3827
+ h(
3828
+ 'button',
3829
+ {
3830
+ type: 'button',
3831
+ ref: triggerRef,
3832
+ 'data-cr-relay-bar': '1',
3833
+ 'data-cr-relay-ordinal': String(chain.ordinal),
3834
+ 'data-cr-relay-scheme': chain.scheme ?? '',
3835
+ 'data-cr-relay-parent': chain.parentId ?? '',
3836
+ 'data-cr-relay-path': chain.path ?? '',
3837
+ // 空串 = 宿主没给归档状态(老宿主),'true'/'false' = readState.parentArchived。
3838
+ 'data-cr-relay-parent-archived': parentArchived === null ? '' : String(parentArchived),
3839
+ 'aria-haspopup': 'menu',
3840
+ 'aria-expanded': open,
3841
+ 'aria-label': `已接力自 ${label},打开接力菜单`,
3842
+ onClick: toggleMenu,
3843
+ style: {
3844
+ display: 'flex',
3845
+ alignItems: 'center',
3846
+ gap: 8,
3847
+ width: '100%',
3848
+ minHeight: 30,
3849
+ padding: '3px 10px',
3850
+ border: 'none',
3851
+ borderRadius: 'inherit',
3852
+ background: 'transparent',
3853
+ color: 'inherit',
3854
+ font: 'inherit',
3855
+ textAlign: 'left',
3856
+ cursor: 'pointer',
3857
+ },
3858
+ onMouseEnter: (event) => {
3859
+ event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.10))';
3860
+ },
3861
+ onMouseLeave: (event) => {
3862
+ event.currentTarget.style.background = 'transparent';
3863
+ },
3864
+ },
3865
+ h(
3866
+ 'span',
3867
+ { style: { flex: 'none', fontSize: 12, lineHeight: '20px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))' } },
3868
+ // 末尾留一个空格:flex gap 负责视觉间距,文本内容(读屏/可访问性树)也要读得通。
3869
+ '已接力自 ',
3870
+ ),
3871
+ h(
3872
+ 'span',
3873
+ {
3874
+ style: {
3875
+ flex: 1,
3876
+ minWidth: 0,
3877
+ fontSize: 13,
3878
+ fontWeight: 500,
3879
+ lineHeight: '20px',
3880
+ color: 'var(--dsw-alias-label-primary, inherit)',
3881
+ overflow: 'hidden',
3882
+ textOverflow: 'ellipsis',
3883
+ whiteSpace: 'nowrap',
3884
+ },
3885
+ },
3886
+ label,
3887
+ ),
3888
+ h(
3889
+ 'span',
3890
+ {
3891
+ 'aria-hidden': true,
3892
+ style: {
3893
+ flex: 'none',
3894
+ fontSize: 10,
3895
+ lineHeight: 1,
3896
+ color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))',
3897
+ transform: open ? 'rotate(180deg)' : 'none',
3898
+ transition: 'transform 120ms ease',
3899
+ },
3900
+ },
3901
+ '▾',
3902
+ ),
3903
+ ),
3904
+ ),
3905
+ menu,
3906
+ );
3907
+ }
3908
+
3909
+ /** 交接结果的一行式展示(放在聊天流里的 /handoff 命令卡)。 */
3910
+ function handoffRow(text, tone, action) {
3911
+ return h(
3912
+ 'div',
3913
+ {
3914
+ style: {
3915
+ display: 'flex',
3916
+ alignItems: 'center',
3917
+ gap: 8,
3918
+ padding: '4px 2px',
3919
+ fontSize: 12,
3920
+ color: tone === 'error' ? COLORS.error : COLORS.subText,
3921
+ },
3922
+ },
3923
+ h('span', { style: { flex: 1, minWidth: 0 } }, text),
3924
+ action
3925
+ ? h(
3926
+ 'button',
3927
+ {
3928
+ type: 'button',
3929
+ onClick: action,
3930
+ style: {
3931
+ flex: 'none',
3932
+ border: 'none',
3933
+ borderRadius: 6,
3934
+ padding: '2px 8px',
3935
+ background: COLORS.hover,
3936
+ color: 'inherit',
3937
+ fontSize: 11,
3938
+ cursor: 'pointer',
3939
+ },
3940
+ },
3941
+ '重试打开',
3942
+ )
3943
+ : null,
3944
+ );
3945
+ }
3946
+
3947
+ /**
3948
+ * /handoff 命令卡:命令成功后自动切到新会话。
3949
+ * 这个槽把命令的通用卡片替换成我们的卡片(只有 /handoff 这一个 key)。
3950
+ */
3951
+ function HandoffCard({ node, waitAndOpen }) {
3952
+ const outcome = node !== null && typeof node === 'object' ? node.outcome : undefined;
3953
+ const outcomeText = outcome !== null && typeof outcome === 'object' && typeof outcome.text === 'string' ? outcome.text : '';
3954
+ // A5:优先用命令结果里的结构化 childId;旧宿主没带字段时再从文本 #child= 兜底。
3955
+ const childId =
3956
+ outcome !== null && typeof outcome === 'object' && typeof outcome.childId === 'string' && outcome.childId.length > 0
3957
+ ? outcome.childId
3958
+ : extractChildId(outcomeText);
3959
+ // P2-C1:区分 opening / opened / failed;刷新后只有 localStorage='1' 才算已打开,
3960
+ // 'opening'(上次没走完)要重新尝试,不能直接渲染成功态。
3961
+ const [status, setStatus] = useState(() => {
3962
+ if (outcome === undefined) return 'running';
3963
+ if (outcome !== null && outcome.kind === 'error') return 'error';
3964
+ if (childId === null) return 'done';
3965
+ return wasOpened(childId) ? 'done' : 'opening';
3966
+ });
3967
+ const [detail, setDetail] = useState(null);
3968
+
3969
+ const openChild = useCallback(() => {
3970
+ if (childId === null) return;
3971
+ setStatus('opening');
3972
+ setDetail(null);
3973
+ waitAndOpen(childId).then(
3974
+ () => {
3975
+ markOpened(childId, '1');
3976
+ setStatus('done');
3977
+ },
3978
+ (error) => {
3979
+ clearOpened(childId);
3980
+ setStatus('failed');
3981
+ setDetail(String(error && error.message ? error.message : error));
3982
+ },
3983
+ );
3984
+ }, [childId, waitAndOpen]);
3985
+
3986
+ useEffect(() => {
3987
+ if (outcome === undefined || outcome === null || outcome.kind !== 'success' || childId === null) return;
3988
+ if (wasOpened(childId)) {
3989
+ setStatus('done');
3990
+ return;
3991
+ }
3992
+ markOpened(childId, 'opening');
3993
+ let alive = true;
3994
+ waitAndOpen(childId).then(
3995
+ () => {
3996
+ markOpened(childId, '1');
3997
+ if (alive) setStatus('done');
3998
+ },
3999
+ (error) => {
4000
+ clearOpened(childId);
4001
+ if (!alive) return;
4002
+ setStatus('failed');
4003
+ setDetail(String(error && error.message ? error.message : error));
4004
+ },
4005
+ );
4006
+ return () => {
4007
+ alive = false;
4008
+ };
4009
+ }, [childId, outcome, waitAndOpen]);
4010
+
4011
+ if (status === 'running') return handoffRow('正在交接:压缩旧会话 → 生成交接单 → 打开新会话…');
4012
+ if (status === 'error') return handoffRow(outcomeText || '交接失败(详见日志)', 'error');
4013
+ if (childId === null) return handoffRow(outcomeText || '交接完成');
4014
+ if (status === 'failed') {
4015
+ return handoffRow(`新会话已建好(${childId}),自动切换失败:${detail || '未知原因'}`, 'error', openChild);
4016
+ }
2952
4017
  if (status === 'opening') return handoffRow(`交接完成,正在打开新会话(${childId})…`);
2953
4018
  return handoffRow('交接完成:已切到新会话(点指示灯 → 查看交接单 可回看或复制)。');
2954
4019
  }
@@ -2985,6 +4050,43 @@ window.__ModuleLoader__.load({
2985
4050
  cursor: 'pointer',
2986
4051
  };
2987
4052
 
4053
+ /** 百分比上限徽标:「最高 62%」——让上限一眼可见,但不用它夹紧输入。 */
4054
+ const percentCapChipStyle = {
4055
+ display: 'inline-flex',
4056
+ alignItems: 'center',
4057
+ height: 20,
4058
+ padding: '0 8px',
4059
+ borderRadius: 999,
4060
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.28))',
4061
+ background: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.08))',
4062
+ color: 'var(--dsw-alias-label-secondary, rgba(127,127,127,0.95))',
4063
+ fontSize: 11,
4064
+ lineHeight: '20px',
4065
+ whiteSpace: 'nowrap',
4066
+ };
4067
+
4068
+ /**
4069
+ * 行内提示块(告警 / 失败):淡色底 + 同色描边,深浅主题都靠 DSH 状态 token 自适应。
4070
+ * 红色只用在真正的失败上,且用 7% 透明底而不是整块红,避免刺眼。
4071
+ * @param {'warning'|'error'} kind
4072
+ */
4073
+ function alertBoxStyle(kind) {
4074
+ const warning = kind === 'warning';
4075
+ return {
4076
+ display: 'flex',
4077
+ flexDirection: 'column',
4078
+ gap: 2,
4079
+ padding: '8px 10px',
4080
+ borderRadius: 8,
4081
+ border: `1px solid ${warning ? 'var(--dsw-alias-state-warning-primary, #d97706)' : 'var(--dsw-alias-state-error-primary, #D92D20)'}`,
4082
+ background: warning ? 'rgba(217, 119, 6, 0.08)' : 'rgba(217, 45, 32, 0.07)',
4083
+ color: warning ? 'var(--dsw-alias-state-warning-primary, #b45309)' : 'var(--dsw-alias-state-error-primary, #D92D20)',
4084
+ fontSize: 12,
4085
+ lineHeight: '17px',
4086
+ fontWeight: 600,
4087
+ };
4088
+ }
4089
+
2988
4090
  /** 不可变地设置对象里的某个路径。 */
2989
4091
  function setIn(object, path, value) {
2990
4092
  if (path.length === 1) return { ...object, [path[0]]: value };
@@ -3070,6 +4172,21 @@ window.__ModuleLoader__.load({
3070
4172
  /** 0.4 P1-3:轻账本累计数字(独立账本文件算出来的;null = 没有数据/读不到,显示引导文案)。 */
3071
4173
  const [ledger, setLedger] = useState(null);
3072
4174
  const [confirmNative, setConfirmNative] = useState(null); // null | 'disable' | 'enable'
4175
+ /** 0.4.2:按实际线重算交接线(先二次确认,{ratio,percent})。 */
4176
+ const [confirmRecalc, setConfirmRecalc] = useState(null);
4177
+ /**
4178
+ * 0.4.2 修复(真机 bug「输入 80 他直接改成 99」):百分比输入框的**本地草稿**。
4179
+ *
4180
+ * 根因:输入框的 value 以前是派生值 `Math.round(ratio*100)`(受控、无草稿),
4181
+ * 每次按键都经过 `Math.min(0.99, …)` 夹紧再四舍五入回写。于是「60 后面按 8」这种
4182
+ * 中间态(608)会被夹成 0.99 → 显示 99,用户输入被静默吃掉,保存再报错。
4183
+ *
4184
+ * 现在:按键只记草稿(用户输入什么就显示什么,除非非数字),表单同步原样写入
4185
+ * `value/100`(不夹紧);失焦/加载/保存成功时丢弃草稿,回到表单的派生值。
4186
+ * 超限一律留给服务端拒绝(guard.message 给目标值),不在客户端改写。
4187
+ * @type {[Record<string,string>, Function]}
4188
+ */
4189
+ const [percentDrafts, setPercentDrafts] = useState({});
3073
4190
  const [nativeBusy, setNativeBusy] = useState(false);
3074
4191
  /** UI-003:结果提示就地显示在开关旁(以前只写页面底部,用户看不到)。 */
3075
4192
  const [nativeStatus, setNativeStatus] = useState(null);
@@ -3115,6 +4232,8 @@ window.__ModuleLoader__.load({
3115
4232
  const applyLoaded = useCallback((data) => {
3116
4233
  setForm(data.config);
3117
4234
  setSavedSnapshot(JSON.stringify(data.config));
4235
+ // 服务端回来的配置是新的权威值:丢掉输入草稿,输入框重新按表单派生(加载/保存成功都走这里)。
4236
+ setPercentDrafts({});
3118
4237
  setSettingsError(data.settingsError ?? null);
3119
4238
  setNative(mergeNative(data.native && typeof data.native === 'object' ? data.native : null));
3120
4239
  if (typeof data.version === 'string') setVersion(data.version);
@@ -3162,32 +4281,122 @@ window.__ModuleLoader__.load({
3162
4281
  const patch = (path, value) => {
3163
4282
  setForm((prev) => (prev === null ? prev : setIn(prev, path, value)));
3164
4283
  setConfirmReset(false);
4284
+ setConfirmRecalc(null);
4285
+ // 0.4.2 修复:一改值就清掉上一次的保存结果/失败提示,避免旧错误一直挂着像没生效。
4286
+ setStatus(null);
4287
+ };
4288
+
4289
+ /* ---------------- 百分比输入:草稿优先,绝不静默改写 ---------------- */
4290
+
4291
+ /**
4292
+ * 输入框显示值:有草稿就用草稿(用户打什么显示什么),否则按表单比例派生整数百分位。
4293
+ * @param {'minRatio'|'compactRatio'|'handoffRatio'} key
4294
+ * @param {number} ratio
4295
+ * @returns {string}
4296
+ */
4297
+ const percentText = (key, ratio) => {
4298
+ const draft = percentDrafts[key];
4299
+ return typeof draft === 'string' ? draft : String(Math.round(ratio * 100));
4300
+ };
4301
+
4302
+ /**
4303
+ * 百分比输入变化:先记草稿,再把「用户输入的数字 / 100」原样写进表单。
4304
+ * - 空串 = 用户正在删旧值准备重打:只留草稿,不动表单(以前空串会被夹成 5%,用户根本打不出新值);
4305
+ * - 非数字(type=number 下浏览器给空串或字母被吞):只显示,不动表单;
4306
+ * - 其余:不夹紧、不四舍五入,超限交给保存时服务端拒绝并给目标值。
4307
+ * @param {'minRatio'|'compactRatio'|'handoffRatio'} key
4308
+ * @param {string} text
4309
+ */
4310
+ const changePercent = (key, text) => {
4311
+ const raw = typeof text === 'string' ? text : '';
4312
+ setPercentDrafts((prev) => ({ ...prev, [key]: raw }));
4313
+ if (raw.trim() === '') {
4314
+ setStatus(null);
4315
+ setConfirmReset(false);
4316
+ setConfirmRecalc(null);
4317
+ return;
4318
+ }
4319
+ const value = Number(raw);
4320
+ if (!Number.isFinite(value)) return;
4321
+ patch(['thresholds', key], value / 100);
4322
+ };
4323
+
4324
+ /** 丢弃某个草稿,显示回到表单派生值(失焦时用;不会改表单)。 */
4325
+ const restorePercentDraft = (key) => {
4326
+ setPercentDrafts((prev) => {
4327
+ if (!(key in prev)) return prev;
4328
+ const next = { ...prev };
4329
+ delete next[key];
4330
+ return next;
4331
+ });
4332
+ };
4333
+
4334
+ /**
4335
+ * 聚焦/点击即全选:阈值框只有 2–3 位数,点进去直接打字就是「替换」,
4336
+ * 不会出现「60 后面接着打 80 变成 6080」这种叠加(真机上「输入 80 变成 99」的一半原因)。
4337
+ *
4338
+ * 为什么还要 setTimeout(0) 再选一次:点进输入框时,浏览器会在 focus 事件之后
4339
+ * 才把光标落到鼠标位置(把刚做的选区冲掉)。下一轮事件循环再选一次 = 打字一定是替换。
4340
+ */
4341
+ const selectOnFocus = (event) => {
4342
+ const el = event === null || event === undefined ? null : event.target;
4343
+ if (el === null || el === undefined || typeof el.select !== 'function') return;
4344
+ try {
4345
+ el.select();
4346
+ } catch {
4347
+ /* 个别浏览器不允许对 number 输入做选区:忽略,不影响输入 */
4348
+ }
4349
+ setTimeout(() => {
4350
+ try {
4351
+ if (el.ownerDocument !== null && el.ownerDocument !== undefined && el.ownerDocument.activeElement === el) el.select();
4352
+ } catch {
4353
+ /* 元素已卸载:忽略 */
4354
+ }
4355
+ }, 0);
3165
4356
  };
3166
4357
 
3167
4358
  /** 表单与已保存快照不一致 → 有未保存修改。 */
3168
4359
  const dirty = form !== null && savedSnapshot !== null && JSON.stringify(form) !== savedSnapshot;
3169
4360
 
3170
- const save = useCallback(async () => {
4361
+ /**
4362
+ * 保存设置。0.4.2:body 带上 `nativeRatio`(当前模型实际触发线),
4363
+ * 服务端据此按「实际线 −5pp」同口径校验交接线;算不出/原生关闭时不带(服务端兜底)。
4364
+ * @param {object} [override] 「按实际线重算」用:一次性覆盖表单(不依赖 setState 的异步生效)
4365
+ */
4366
+ const save = useCallback(async (override) => {
3171
4367
  if (form === null || saving) return;
4368
+ const payload = override !== undefined && override !== null ? override : form;
3172
4369
  setSaving(true);
3173
4370
  setStatus(null);
3174
4371
  setConfirmReset(false);
3175
4372
  try {
4373
+ const info = nativeLineInfo(native);
4374
+ const nativeRatio = native !== null && native.autoEnabled !== false && info.actual !== null ? info.actual : null;
3176
4375
  const res = await fetch('/api/compact-relay-settings', {
3177
4376
  method: 'POST',
3178
4377
  headers: { 'content-type': 'application/json' },
3179
- body: JSON.stringify(form),
4378
+ body: JSON.stringify(nativeRatio === null ? payload : { ...payload, nativeRatio }),
3180
4379
  });
3181
4380
  const data = await res.json().catch(() => ({}));
3182
- if (!res.ok || data.ok === false) throw new Error(data && data.message ? String(data.message) : '保存失败');
4381
+ if (!res.ok || data.ok === false) {
4382
+ // 0.4.2 修复:服务端会带 field(交接线校验失败时是 thresholds.handoffRatio),
4383
+ // 带出来才能在交接线行内就地标红,而不是只在页面底部写一行。
4384
+ const failure = new Error(data && data.message ? String(data.message) : '保存失败');
4385
+ if (data !== null && typeof data === 'object' && typeof data.field === 'string') failure.field = data.field;
4386
+ throw failure;
4387
+ }
3183
4388
  applyLoaded(data);
3184
4389
  setStatus({ text: '已保存(立即生效,不需要重启)' });
3185
4390
  } catch (error) {
3186
- setStatus({ error: true, text: `保存失败:${error && error.message ? error.message : '未知错误'}` });
4391
+ setStatus({
4392
+ error: true,
4393
+ text: `保存失败:${error && error.message ? error.message : '未知错误'}`,
4394
+ field: error !== null && typeof error === 'object' && typeof error.field === 'string' ? error.field : null,
4395
+ });
3187
4396
  } finally {
3188
4397
  setSaving(false);
3189
4398
  }
3190
- }, [form, saving, applyLoaded]);
4399
+ }, [form, saving, applyLoaded, native]);
3191
4400
 
3192
4401
  /** 恢复默认 = 删除用户设置文件;先二次确认,避免误点丢掉配色。 */
3193
4402
  const reset = useCallback(async () => {
@@ -3212,6 +4421,19 @@ window.__ModuleLoader__.load({
3212
4421
  }
3213
4422
  }, [saving, applyLoaded]);
3214
4423
 
4424
+ /**
4425
+ * 0.4.2:「按实际线重算」——目标 = 实际触发线 −5pp(整数百分位,guard 已算好)。
4426
+ * 二次确认后一次性保存(不走 patch + save 的异步状态,避免发出旧表单)。
4427
+ */
4428
+ const applyRecalc = useCallback(async () => {
4429
+ if (form === null || confirmRecalc === null || saving) return;
4430
+ const next = setIn(form, ['thresholds', 'handoffRatio'], confirmRecalc.ratio);
4431
+ setConfirmRecalc(null);
4432
+ // 目标值是 guard 算出来的权威值:丢掉输入草稿,避免输入框还显示用户手打的旧数字。
4433
+ setPercentDrafts({});
4434
+ await save(next);
4435
+ }, [form, confirmRecalc, saving, save]);
4436
+
3215
4437
  /** 0.3.1:关闭/恢复 DSH 原生自动压缩(写 profile 配置,备份后重启生效)。 */
3216
4438
  const applyNative = useCallback(
3217
4439
  async (enabled) => {
@@ -3326,7 +4548,6 @@ window.__ModuleLoader__.load({
3326
4548
 
3327
4549
  const percent = (ratio) => Math.round(ratio * 100);
3328
4550
  const compactPercent = percent(form.thresholds.compactRatio);
3329
- const handoffPercent = percent(form.thresholds.handoffRatio);
3330
4551
  const count = (value) => (Number.isFinite(value) ? value : 0);
3331
4552
  // 0.4 P1-3:轻账本一行累计数字(只读;数据来自宿主账本,与账本文件同源)。
3332
4553
  const ledgerLine = (() => {
@@ -3341,13 +4562,42 @@ window.__ModuleLoader__.load({
3341
4562
  // 0.4.1:配置阈值(thresholdRatio)只是理论上限;实际触发线由宿主按 token 预算算。
3342
4563
  const nativeInfo = nativeLineInfo(native);
3343
4564
  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
4565
  // 原生压缩已关闭时 effective/percentText 为空:回落到「开启后实际会触发多少」,再回落配置阈值。
3348
4566
  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}% 以下(改完立即生效)。` : '';
4567
+ /**
4568
+ * 0.4.2(P0-1/P1):交接线硬上限 = 实际触发线 −5pp(整数百分位),与后台 save() 同一份判定。
4569
+ * 实际线算不出时退回配置阈值 −1pp 兜底(与 0.4.1 一致,仅限制输入上限,不告警)。
4570
+ * 注意:这里读 nativeInfo.actual(真实触发线),不能用 effective(autoOff/算不出时它会回落配置阈值)。
4571
+ */
4572
+ const handoffGuardInfo = handoffGuard({
4573
+ actualRatio: nativeInfo.actual,
4574
+ configuredRatio: nativeInfo.configured,
4575
+ handoffRatio: form.thresholds.handoffRatio,
4576
+ compactRatio: form.thresholds.compactRatio,
4577
+ autoEnabled: autoOnNative,
4578
+ fallback: 'configured',
4579
+ });
4580
+ const handoffCapPercent = handoffGuardInfo.capPercent;
4581
+ const handoffLate = handoffGuardInfo.source === 'actual' && handoffGuardInfo.ok === false;
4582
+ const handoffCapHint =
4583
+ handoffGuardInfo.source === 'actual'
4584
+ ? `当前模型实际触发 ${nativeInfo.actualText}%,最高可设 ${handoffCapPercent}%(实际触发线 −5pp)。`
4585
+ : autoOnNative && handoffCapPercent !== null
4586
+ ? `实际触发线未知(会话未就绪或算不出),暂按配置阈值最高 ${handoffCapPercent}%。`
4587
+ : '';
4588
+ /**
4589
+ * 0.4.2 修复(真机反馈):交接线被服务端拒绝时,错误文案就地显示在交接线行内。
4590
+ * 文案沿用 guard.message(含实际线与目标值),去掉「保存失败:」前缀避免重复。
4591
+ */
4592
+ const handoffSaveError =
4593
+ status !== null && status.error && status.field === 'thresholds.handoffRatio'
4594
+ ? String(status.text).replace(/^保存失败:/, '')
4595
+ : null;
4596
+ // 重算目标 ≤ 压缩线时(保持「交接线 > 压缩线」现有约束)点按钮直接拒绝并提示先降压缩线。
4597
+ const handoffRecalcConflict =
4598
+ handoffGuardInfo.compactConflict && handoffCapPercent !== null
4599
+ ? `按实际线重算目标 ${handoffCapPercent}% 不高于压缩线 ${compactPercent}%,请先把压缩线降到 ${handoffCapPercent}% 以下。`
4600
+ : '';
3351
4601
  const previewLight = paletteForScheme(form.colors, 'light');
3352
4602
  const previewDark = paletteForScheme(form.colors, 'dark');
3353
4603
  // 0.4 P0-2:诊断预览弹层复用指示灯那套浮层材质(15-shared.panelChrome)。
@@ -3376,7 +4626,7 @@ window.__ModuleLoader__.load({
3376
4626
 
3377
4627
  return h(
3378
4628
  'div',
3379
- { style: { display: 'flex', flexDirection: 'column', maxWidth: 680, padding: '4px 0 20px' } },
4629
+ { style: { display: 'flex', flexDirection: 'column', maxWidth: 680, padding: '4px 0 8px' } },
3380
4630
  h('div', { style: { fontSize: 14, fontWeight: 600, marginBottom: 4 } }, '压缩接力'),
3381
4631
  h(
3382
4632
  'p',
@@ -3397,8 +4647,19 @@ window.__ModuleLoader__.load({
3397
4647
  'div',
3398
4648
  { style: { display: 'flex', alignItems: 'center', gap: 8 } },
3399
4649
  h('input', {
3400
- type: 'number', min: 1, max: 60, step: 1, value: Math.round(form.thresholds.minRatio * 100), 'aria-label': '最小压缩线(百分比)', style: { ...settingsInputStyle, width: 90 },
3401
- onChange: (event) => patch(['thresholds', 'minRatio'], Math.min(0.6, Math.max(0.01, Number(event.target.value) / 100))),
4650
+ type: 'number',
4651
+ inputMode: 'numeric',
4652
+ min: 1,
4653
+ max: 60,
4654
+ step: 1,
4655
+ value: percentText('minRatio', form.thresholds.minRatio),
4656
+ 'aria-label': '最小压缩线(百分比)',
4657
+ style: { ...settingsInputStyle, width: 90, textAlign: 'right', fontVariantNumeric: 'tabular-nums' },
4658
+ onFocus: selectOnFocus,
4659
+ onClick: selectOnFocus,
4660
+ // 0.4.2 修复:不再按键就夹紧/四舍五入(以前清空会立刻变 5%),草稿优先,越界交服务端拒绝。
4661
+ onChange: (event) => changePercent('minRatio', event.target.value),
4662
+ onBlur: () => restorePercentDraft('minRatio'),
3402
4663
  }),
3403
4664
  h('span', { style: settingsHintStyle }, '% 窗口'),
3404
4665
  ),
@@ -3407,10 +4668,20 @@ window.__ModuleLoader__.load({
3407
4668
  '压缩提醒线',
3408
4669
  '指示灯到这条线开始变琥珀色,菜单也会建议「压缩旧会话」。',
3409
4670
  '调高 → 更晚提醒、压得更少但上下文更长;调低 → 更早提醒、压得更勤。',
3410
- h('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
4671
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
3411
4672
  h('input', {
3412
- type: 'number', min: 5, max: 95, step: 1, value: compactPercent, 'aria-label': '压缩提醒线(百分比)', style: { ...settingsInputStyle, width: 90 },
3413
- onChange: (event) => patch(['thresholds', 'compactRatio'], Math.min(0.95, Math.max(0.05, Number(event.target.value) / 100))),
4673
+ type: 'number',
4674
+ inputMode: 'numeric',
4675
+ min: 5,
4676
+ max: 95,
4677
+ step: 1,
4678
+ value: percentText('compactRatio', form.thresholds.compactRatio),
4679
+ 'aria-label': '压缩提醒线(百分比)',
4680
+ style: { ...settingsInputStyle, width: 90, textAlign: 'right', fontVariantNumeric: 'tabular-nums' },
4681
+ onFocus: selectOnFocus,
4682
+ onClick: selectOnFocus,
4683
+ onChange: (event) => changePercent('compactRatio', event.target.value),
4684
+ onBlur: () => restorePercentDraft('compactRatio'),
3414
4685
  }),
3415
4686
  h('span', { style: settingsHintStyle }, '% 窗口'),
3416
4687
  h('span', { style: settingsHintStyle }, `(1M 窗口 ≈ ${Math.round(1000000 * form.thresholds.compactRatio / 1000)}K)`),
@@ -3419,26 +4690,126 @@ window.__ModuleLoader__.load({
3419
4690
  settingsRow(
3420
4691
  '交接提醒线',
3421
4692
  '到这条线内圆亮红点,菜单建议「交接新会话」。',
3422
- `调高 → 更晚换会话、单会话更久;调低 → 更早换会话、单会话更短。必须大于压缩线。${handoffNativeWarning}`,
3423
- h('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
3424
- h('input', {
3425
- type: 'number',
3426
- min: 5,
3427
- max: native !== null && native.autoEnabled !== false ? nativeCapPercent : 99,
3428
- step: 1,
3429
- value: handoffPercent,
3430
- 'aria-label': '交接提醒线(百分比)',
3431
- style: { ...settingsInputStyle, width: 90 },
3432
- onChange: (event) => {
3433
- const raw = Math.max(0.05, Number(event.target.value) / 100);
3434
- const cap = native !== null && native.autoEnabled !== false ? nativeCapPercent / 100 : 0.99;
3435
- patch(['thresholds', 'handoffRatio'], Math.min(cap, raw));
3436
- },
3437
- }),
3438
- h('span', { style: settingsHintStyle }, '% 窗口'),
3439
- h('span', { style: settingsHintStyle }, `(1M 窗口 ≈ ${Math.round(1000000 * form.thresholds.handoffRatio / 1000)}K)`),
4693
+ '调高 → 更晚换会话、单会话更久;调低 → 更早换会话、单会话更短。必须大于压缩线。',
4694
+ h(
4695
+ 'div',
4696
+ { 'data-cr-handoff-field': '1', style: { display: 'flex', flexDirection: 'column', gap: 8 } },
4697
+ // 控制行:输入框 + 单位 + 上限徽标(上限只做提示,不用来夹紧输入)
4698
+ h(
4699
+ 'div',
4700
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
4701
+ h('input', {
4702
+ type: 'number',
4703
+ inputMode: 'numeric',
4704
+ min: 5,
4705
+ // 0.4.2 修复:max 用真实上限(如 62),只作浏览器提示;不再靠代码夹紧改写用户输入。
4706
+ max: handoffCapPercent !== null ? handoffCapPercent : 100,
4707
+ step: 1,
4708
+ value: percentText('handoffRatio', form.thresholds.handoffRatio),
4709
+ 'aria-label': '交接提醒线(百分比)',
4710
+ 'aria-describedby': 'cr-handoff-cap-hint',
4711
+ style: { ...settingsInputStyle, width: 90, textAlign: 'right', fontVariantNumeric: 'tabular-nums' },
4712
+ onFocus: selectOnFocus,
4713
+ onClick: selectOnFocus,
4714
+ // 0.4.2 修复(真机 bug「输入 80 变成 99」):输入原样保留,不夹紧/不取整/不替用户改。
4715
+ onChange: (event) => changePercent('handoffRatio', event.target.value),
4716
+ onBlur: () => restorePercentDraft('handoffRatio'),
4717
+ }),
4718
+ h('span', { style: settingsHintStyle }, '% 窗口'),
4719
+ handoffCapPercent === null
4720
+ ? null
4721
+ : h('span', {
4722
+ 'data-cr-handoff-cap-chip': '1',
4723
+ title: `超过 ${handoffCapPercent}% 保存会被拒绝(会告诉你目标值);这里不会自动改写你的输入。`,
4724
+ style: percentCapChipStyle,
4725
+ }, `最高 ${handoffCapPercent}%`),
4726
+ ),
4727
+ // 体量估算单独一行:和上限、告警挤在同一排会显得拥挤。
4728
+ h('div', { style: settingsHintStyle }, `(1M 窗口 ≈ ${Math.round(1000000 * form.thresholds.handoffRatio / 1000)}K)`),
4729
+ handoffCapHint === '' ? null : h('div', { 'data-cr-handoff-cap': '1', id: 'cr-handoff-cap-hint', style: settingsHintStyle }, handoffCapHint),
4730
+ // 超限告警:保存前就可见(amber),保存被拒后由红色失败块接棒(两者不同时出现)。
4731
+ handoffLate && handoffSaveError === null
4732
+ ? h('div', { 'data-cr-handoff-late': '1', role: 'status', style: alertBoxStyle('warning') }, `⚠ ${handoffGuardInfo.message}`)
4733
+ : null,
4734
+ // 保存失败(服务端 guard.message):就地在交接线行内显示,滚动到页面任何位置都不用回头找。
4735
+ handoffSaveError === null
4736
+ ? null
4737
+ : h('div', { 'data-cr-handoff-error': '1', role: 'alert', style: alertBoxStyle('error') }, handoffSaveError),
4738
+ // 违规时给「按实际线重算」:目标 = 实际触发线 −5pp(整数百分位),保存前二次确认。
4739
+ // 0.4.2 修复:从 11px 灰色小字里挪到独立动作行(右对齐),和告警同属一块可视区,不再跟单位/估算挤在一排。
4740
+ handoffLate && handoffCapPercent !== null
4741
+ ? h(
4742
+ 'div',
4743
+ { style: { display: 'flex', justifyContent: 'flex-end' } },
4744
+ h('button', {
4745
+ type: 'button',
4746
+ 'data-cr-recalc': '1',
4747
+ disabled: saving,
4748
+ title: `按当前模型实际触发线 ${nativeInfo.actualText}% 重算:最高可设 ${handoffCapPercent}%`,
4749
+ style: { ...settingsButtonStyle, height: 28, fontSize: 12, padding: '0 12px', whiteSpace: 'nowrap', cursor: saving ? 'wait' : 'pointer' },
4750
+ onClick: () => {
4751
+ if (handoffRecalcConflict !== '') {
4752
+ setConfirmRecalc(null);
4753
+ setStatus({ error: true, text: handoffRecalcConflict, field: 'thresholds.handoffRatio' });
4754
+ return;
4755
+ }
4756
+ setStatus(null);
4757
+ setConfirmRecalc({ ratio: handoffGuardInfo.target, percent: handoffCapPercent });
4758
+ },
4759
+ }, `按实际线重算(${handoffCapPercent}%)`),
4760
+ )
4761
+ : null,
3440
4762
  ),
3441
4763
  ),
4764
+ confirmRecalc === null
4765
+ ? null
4766
+ : h(
4767
+ 'div',
4768
+ {
4769
+ 'data-cr-recalc-confirm': '1',
4770
+ style: {
4771
+ margin: '0 0 14px',
4772
+ padding: '10px 12px',
4773
+ borderRadius: 8,
4774
+ border: '1px solid var(--dsw-alias-state-warning-primary, #d97706)',
4775
+ background: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.06))',
4776
+ },
4777
+ },
4778
+ h('div', { style: { fontSize: 12, fontWeight: 600, marginBottom: 4 } }, `确认把「交接提醒线」改为 ${confirmRecalc.percent}%?`),
4779
+ h(
4780
+ 'div',
4781
+ { style: { ...settingsHintStyle, marginBottom: 8 } },
4782
+ `当前模型实际触发 ${nativeInfo.actualText}%,按「实际线 −5pp」向下取整到 ${confirmRecalc.percent}%;` +
4783
+ '点「确认重算」会立即保存生效(不改压缩线、不动其它设置)。',
4784
+ ),
4785
+ h(
4786
+ 'div',
4787
+ { style: { display: 'flex', gap: 8 } },
4788
+ h(
4789
+ 'button',
4790
+ {
4791
+ type: 'button',
4792
+ disabled: saving,
4793
+ 'data-cr-recalc-ok': '1',
4794
+ style: { padding: '4px 12px', borderRadius: 6, border: '1px solid #d97706', background: '#d97706', color: '#fff', cursor: saving ? 'wait' : 'pointer', fontSize: 12 },
4795
+ onClick: () => {
4796
+ void applyRecalc();
4797
+ },
4798
+ },
4799
+ saving ? '保存中…' : '确认重算',
4800
+ ),
4801
+ h(
4802
+ 'button',
4803
+ {
4804
+ type: 'button',
4805
+ disabled: saving,
4806
+ style: { padding: '4px 12px', borderRadius: 6, border: '1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,0.35))', background: 'transparent', color: 'var(--dsw-alias-label-primary, inherit)', cursor: 'pointer', fontSize: 12 },
4807
+ onClick: () => setConfirmRecalc(null),
4808
+ },
4809
+ '取消',
4810
+ ),
4811
+ ),
4812
+ ),
3442
4813
  ),
3443
4814
  sectionHeading('压缩行为'),
3444
4815
  h(
@@ -3686,12 +5057,6 @@ window.__ModuleLoader__.load({
3686
5057
  '换成更醒目的反差色 → 锁定位置一眼可见;换成接近默认色 → 更安静(但可能看不清标记)。',
3687
5058
  colorField(form.colors.base.pin, (value) => patch(['colors', 'base', 'pin'], value), '锁定颜色'),
3688
5059
  ),
3689
- h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, marginTop: 16, flexWrap: 'wrap' } },
3690
- h('button', { type: 'button', style: { ...settingsButtonStyle, background: 'var(--dsw-alias-button-primary, #2563eb)', color: '#fff', border: 'none' }, disabled: saving, onClick: () => void save() }, saving ? '保存中…' : dirty ? '保存修改' : '保存'),
3691
- dirty ? h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-state-warning-primary, #b45309)' } }, '有未保存的修改') : null,
3692
- status !== null ? h('span', { style: { fontSize: 11, color: status.error ? 'var(--dsw-alias-state-error-primary, #d92d20)' : 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))' } }, status.text) : null,
3693
- h('span', { style: settingsHintStyle }, '改完点这里保存;恢复默认在页面最底部。'),
3694
- ),
3695
5060
  ),
3696
5061
  sectionHeading('帮助'),
3697
5062
  h(
@@ -3894,16 +5259,130 @@ window.__ModuleLoader__.load({
3894
5259
  h('p', { style: settingsHintStyle }, '联系方式:cfanzx@136.com(合作、安全问题,以及不便公开讨论的事项)。'),
3895
5260
  h('p', { style: settingsHintStyle }, 'npm 包页面:https://www.npmjs.com/package/dsh-compact-relay'),
3896
5261
  h('p', { style: settingsHintStyle }, '致谢:DeepSeek Harness;设置栏实现参考了 dsh-plugin-memory;也谢谢帮忙测试的朋友们。'),
3897
- h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, marginTop: 20, paddingTop: 14, borderTop: '1px dashed var(--dsw-alias-border-l2, rgba(127,127,127,0.25))', flexWrap: 'wrap' } },
3898
- h('span', { style: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-state-error-primary, #d92d20)' } }, '危险操作'),
3899
- confirmReset
3900
- ? h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 6 } },
3901
- h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-state-error-primary, #d92d20)' } }, '确定恢复默认?(会删除你的设置文件)'),
3902
- h('button', { type: 'button', style: settingsButtonStyle, disabled: saving, onClick: () => void reset() }, '确定恢复'),
3903
- h('button', { type: 'button', style: settingsButtonStyle, disabled: saving, onClick: () => setConfirmReset(false) }, '取消'),
3904
- )
3905
- : h('button', { type: 'button', style: settingsButtonStyle, disabled: saving, onClick: () => setConfirmReset(true) }, '恢复默认'),
5262
+ h(
5263
+ 'p',
5264
+ { style: { ...settingsHintStyle, marginTop: 20, paddingTop: 14, borderTop: '1px dashed var(--dsw-alias-border-l2, rgba(127,127,127,0.25))' } },
5265
+ '危险操作:恢复默认会删除你的设置文件。入口在页面底部常驻的保存工具条里,点一次后还需要再确认一次(不会误删)。',
5266
+ ),
5267
+ ),
5268
+ /**
5269
+ * 0.4.2 修复(真机反馈「保存按键太靠下了」):设置页很长,保存/恢复默认改成
5270
+ * 粘性底部工具条 —— 页面滚到哪都固定在视口底部,不用滚到页尾才能点保存。
5271
+ *
5272
+ * 为什么用 sticky 而不是 fixed:它是流内元素,天然只占页面末尾那一格,
5273
+ * 不会盖住最后一块内容;滚动中固定在底部时给宿主弹层留出 12px 内边距 +
5274
+ * 安全区 padding,避免贴边/遮挡。
5275
+ *
5276
+ * 0.4.2 视觉打磨:按钮组靠左、状态靠右(marginLeft:auto 对齐,不再错位),
5277
+ * 状态做成淡色小胶囊(失败红 / 成功绿 / 无状态时占位提示,行高不跳);
5278
+ * 底色/投影用现有 DSH token + theme.dark 兜底,深色浅色都不糊。
5279
+ */
5280
+ h(
5281
+ 'div',
5282
+ {
5283
+ 'data-cr-savebar': '1',
5284
+ style: {
5285
+ position: 'sticky',
5286
+ bottom: 0,
5287
+ zIndex: 20,
5288
+ marginTop: 20,
5289
+ marginLeft: -8,
5290
+ marginRight: -8,
5291
+ padding: '8px 10px calc(8px + env(safe-area-inset-bottom, 0px))',
5292
+ borderTop: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.22))',
5293
+ background: `var(--dsw-alias-bg-layer-1, ${theme.dark ? 'rgba(32,33,36,0.94)' : 'rgba(255,255,255,0.94)'})`,
5294
+ backdropFilter: 'var(--dsw-menu-backdrop-filter, blur(10px))',
5295
+ WebkitBackdropFilter: 'var(--dsw-menu-backdrop-filter, blur(10px))',
5296
+ boxShadow: theme.dark ? '0 -6px 18px rgba(0,0,0,0.42)' : '0 -6px 18px rgba(0,0,0,0.08)',
5297
+ display: 'flex',
5298
+ flexDirection: 'column',
5299
+ gap: 6,
5300
+ },
5301
+ },
5302
+ h(
5303
+ 'div',
5304
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
5305
+ h(
5306
+ 'button',
5307
+ {
5308
+ type: 'button',
5309
+ 'data-cr-save': '1',
5310
+ disabled: saving,
5311
+ style: {
5312
+ ...settingsButtonStyle,
5313
+ height: 30,
5314
+ padding: '0 16px',
5315
+ fontWeight: 600,
5316
+ background: 'var(--dsw-alias-button-primary, #2563eb)',
5317
+ color: '#fff',
5318
+ border: 'none',
5319
+ cursor: saving ? 'wait' : 'pointer',
5320
+ boxShadow: '0 1px 2px rgba(0,0,0,0.12)',
5321
+ },
5322
+ onClick: () => void save(),
5323
+ },
5324
+ saving ? '保存中…' : dirty ? '保存修改' : '保存',
5325
+ ),
5326
+ h(
5327
+ 'button',
5328
+ {
5329
+ type: 'button',
5330
+ 'data-cr-reset': '1',
5331
+ disabled: saving,
5332
+ style: { ...settingsButtonStyle, height: 30, cursor: saving ? 'wait' : 'pointer' },
5333
+ onClick: () => setConfirmReset(true),
5334
+ },
5335
+ '恢复默认',
5336
+ ),
5337
+ dirty
5338
+ ? h('span', { 'data-cr-dirty': '1', style: { fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary, #b45309)' } }, '有未保存的修改')
5339
+ : h('span', { 'data-cr-dirty': '0', style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))' } }, '已是最新'),
5340
+ // 没有状态时右侧放提示占位:按钮行高度稳定,不会因为状态出现/消失而跳。
5341
+ status === null
5342
+ ? h('span', { style: { ...settingsHintStyle, marginLeft: 'auto', textAlign: 'right' } }, '改完随时点「保存」,不用滚到页尾。')
5343
+ : null,
3906
5344
  ),
5345
+ // 状态单独一行(失败文案可能两行):淡色胶囊 + 状态色文字,条内也看得清,且不把按钮挤歪。
5346
+ status === null
5347
+ ? null
5348
+ : h(
5349
+ 'div',
5350
+ {
5351
+ 'data-cr-save-status': status.error ? 'error' : 'ok',
5352
+ role: status.error ? 'alert' : 'status',
5353
+ style: {
5354
+ padding: '4px 8px',
5355
+ borderRadius: 6,
5356
+ background: status.error ? 'rgba(217, 45, 32, 0.10)' : 'rgba(21, 128, 61, 0.10)',
5357
+ color: status.error
5358
+ ? 'var(--dsw-alias-state-error-primary, #D92D20)'
5359
+ : 'var(--dsw-alias-state-success-primary, #15803D)',
5360
+ fontSize: 11,
5361
+ lineHeight: '16px',
5362
+ fontWeight: status.error ? 600 : 500,
5363
+ },
5364
+ },
5365
+ status.text,
5366
+ ),
5367
+ confirmReset
5368
+ ? h(
5369
+ 'div',
5370
+ {
5371
+ 'data-cr-reset-confirm': '1',
5372
+ style: {
5373
+ display: 'flex',
5374
+ alignItems: 'center',
5375
+ gap: 8,
5376
+ flexWrap: 'wrap',
5377
+ paddingTop: 6,
5378
+ borderTop: '1px dashed var(--dsw-alias-border-l2, rgba(127,127,127,0.2))',
5379
+ },
5380
+ },
5381
+ h('span', { style: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-state-error-primary, #d92d20)' } }, '确定恢复默认?(会删除你的设置文件)'),
5382
+ h('button', { type: 'button', style: settingsButtonStyle, disabled: saving, onClick: () => void reset() }, '确定恢复'),
5383
+ h('button', { type: 'button', style: settingsButtonStyle, disabled: saving, onClick: () => setConfirmReset(false) }, '取消'),
5384
+ )
5385
+ : null,
3907
5386
  ),
3908
5387
  // 0.4 P0-2:诊断信息预览弹层(只读等宽文本 + 复制 Markdown/JSON)。
3909
5388
  // 只有用户点了「生成并预览」/「打开问题报告页」才会出现;关掉后本次会话不再显示首次说明卡。
@@ -4113,6 +5592,30 @@ window.__ModuleLoader__.load({
4113
5592
  ),
4114
5593
  );
4115
5594
 
5595
+ // 0.5.0 接力条 v1:输入卡上方通栏一行「已接力自 …」+ 向上弹出的下拉菜单。
5596
+ // 组件内部再判断 chain:无链/老宿主没有 chain 字段 → 返回 null,不占高度。
5597
+ ctx.slots.inject('conversation.input.dock', () =>
5598
+ ctx.slots.register(
5599
+ {
5600
+ name: 'conversation.input.dock',
5601
+ id: 'compact-relay',
5602
+ // 宿主 TodoPanel 是 0、queue 是 20;接力条夹在中间(输入卡正上方)。
5603
+ order: 10,
5604
+ inject: (sessionId) => ({
5605
+ sessionId,
5606
+ openSession: (targetSessionId) => ctx.uiWorkspace.openSession(targetSessionId),
5607
+ // 0.5.0:恢复归档旧会话(与侧栏「取消归档」同一条 Client 接缝)。
5608
+ // 老宿主没有这个方法时传 undefined,组件会退回 HTTP recover。
5609
+ unarchiveSession:
5610
+ typeof ctx.uiWorkspace?.unarchiveSession === 'function'
5611
+ ? (targetSessionId) => ctx.uiWorkspace.unarchiveSession(targetSessionId)
5612
+ : undefined,
5613
+ }),
5614
+ },
5615
+ CompactRelayBar,
5616
+ ),
5617
+ );
5618
+
4116
5619
  // /handoff 命令卡:成功后自动切换(key 必须等于命令名)。
4117
5620
  ctx.slots.inject('conversation.chat.commandview', () =>
4118
5621
  ctx.slots.register(