dsh-compact-relay 0.4.1 → 0.4.2

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
@@ -724,6 +724,225 @@ window.__ModuleLoader__.load({
724
724
  })
725
725
  .filter((node) => node !== null);
726
726
  }
727
+ /**
728
+ * 交接线保命判定 —— 纯函数,零依赖(0.4.2)。
729
+ *
730
+ * 背景(0.4.1 遗留的三个洞,见 docs/0.4.2-implementation-plan.md):
731
+ * 1. 交接提醒线仍是配置值(默认 60%),换到实际触发线更低的模型(如 55%)时,
732
+ * DSH 会先自己压历史,dcr 的交接提醒来不及;
733
+ * 2. 服务端保存校验只看配置阈值(80%),手改设置文件能设到 70%;
734
+ * 3. 旧值不跟随当前模型,除设置页一行 warning 外没有其它可见提示。
735
+ *
736
+ * 0.4.2 规则(范围定稿):
737
+ * 交接提醒线 ≤ 实际触发线 − 5pp(HANDOFF_MARGIN_PP),向下取整到整数百分位。
738
+ * - 实际触发线来自 state.native.ratio(按当前路由模型实时算,见 core/native-line.js);
739
+ * - 实际线算不出时:运行时不告警、不阻断;服务端保存退回配置阈值防呆(0.4.1 行为);
740
+ * - DSH 原生自动压缩关闭时不存在「被抢先压缩」的截止线,不判定;
741
+ * - 只告警 / 拒绝,绝不静默改写用户配置。
742
+ *
743
+ * 本模块只做判定与文案,不做 IO:服务端 `lib/actions/settings.js` 直接 import,
744
+ * 客户端由 `scripts/build-client.mjs` 的 `/* @inline core/handoff-guard.js *\/`
745
+ * 内联进 `lib/client.js`(同一份判定,避免 UI 与后台两套口径)。
746
+ */
747
+
748
+ /** 交接线相对实际触发线至少留 5 个百分点(1M 窗口 ≈ 50K tokens 缓冲)。 */
749
+ const HANDOFF_MARGIN_PP = 5;
750
+
751
+ /** 实际线算不出、退回配置阈值防呆时的余量(0.4.1 口径:最高 = 配置阈值 −1pp)。 */
752
+ const FALLBACK_MARGIN_PP = 1;
753
+
754
+ /** cap 的绝对上限(避免算出 96%+ 这种没有意义的上限)。 */
755
+ const HANDOFF_LIMIT_DEFAULT = 0.95;
756
+
757
+ const EPS = 1e-9;
758
+
759
+ /**
760
+ * 合法窗口比例:必须是 0<r<1 的有限数。
761
+ * @param {unknown} value
762
+ * @returns {number|null}
763
+ */
764
+ function ratioOf(value) {
765
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 && value < 1 ? value : null;
766
+ }
767
+
768
+ /**
769
+ * 比例 → 百分数数字(一位小数,整数不带小数):0.678 → 67.8,0.62 → 62。
770
+ * @param {number|null} ratio
771
+ * @returns {number|null}
772
+ */
773
+ function percentOf(ratio) {
774
+ if (ratio === null) return null;
775
+ const percent = ratio * 100;
776
+ return Math.abs(percent - Math.round(percent)) < 0.05 ? Math.round(percent) : Number(percent.toFixed(1));
777
+ }
778
+
779
+ /**
780
+ * 比例 → 百分数文本(用于文案):0.678 → '67.8',0.62 → '62'。
781
+ * @param {number|null} ratio
782
+ * @returns {string}
783
+ */
784
+ function percentText(ratio) {
785
+ if (ratio === null) return '';
786
+ const percent = ratio * 100;
787
+ return Math.abs(percent - Math.round(percent)) < 0.05 ? String(Math.round(percent)) : percent.toFixed(1);
788
+ }
789
+
790
+ /**
791
+ * 只算交接线上限:参考线 − marginPp,向下取整到整数百分位,再夹到 limit。
792
+ *
793
+ * 浮点安全:`0.62 − 0.05` 在二进制里可能是 0.5700000000000001 / 0.5699999999999999,
794
+ * 直接 floor 会把 57 变成 56,所以加 EPS 再 floor。
795
+ *
796
+ * @param {number|null} referenceRatio 参考线(实际触发线或配置阈值),非法时返回 null
797
+ * @param {{marginPp?:number, limit?:number}} [options]
798
+ * @returns {number|null} 例如 0.62;参考线太小(减完 ≤0)时返回 null
799
+ */
800
+ function handoffCapOf(referenceRatio, { marginPp = HANDOFF_MARGIN_PP, limit = HANDOFF_LIMIT_DEFAULT } = {}) {
801
+ const reference = ratioOf(referenceRatio);
802
+ if (reference === null) return null;
803
+ const margin = (Number.isFinite(marginPp) ? marginPp : HANDOFF_MARGIN_PP) / 100;
804
+ const max = Number.isFinite(limit) && limit > 0 && limit < 1 ? limit : HANDOFF_LIMIT_DEFAULT;
805
+ // 向下取整到整数百分位(例如 62.8 → 0.62),不是四舍五入。
806
+ const cap = Math.floor((reference - margin) * 100 + EPS) / 100;
807
+ if (!(cap > 0)) return null;
808
+ return Math.min(cap, max);
809
+ }
810
+
811
+ /** 「重算目标 ≤ 压缩线」时的补充提示(保持「交接线 > 压缩线」现有约束)。 */
812
+ function compactConflictText(capPercent, compactRatio) {
813
+ return `按实际线重算目标 ${capPercent}% 不高于压缩线 ${percentText(compactRatio)}%,请先把压缩线降到 ${capPercent}% 以下。`;
814
+ }
815
+
816
+ /**
817
+ * 判定「当前交接线是否会在 DSH 原生压缩之后」。
818
+ *
819
+ * @param {object} [input]
820
+ * @param {number|null} [input.actualRatio] 当前模型实际触发线(state.native.ratio);算不出/关闭时 null
821
+ * @param {number|null} [input.configuredRatio] 配置阈值(native.thresholdRatio),兜底用
822
+ * @param {number|null} [input.handoffRatio] 当前交接提醒线(thresholds.handoffRatio)
823
+ * @param {number|null} [input.compactRatio] 压缩提醒线(thresholds.compactRatio),判重算冲突
824
+ * @param {boolean} [input.autoEnabled] DSH 原生自动压缩是否开启(false → 不判定,交接线无截止线)
825
+ * @param {number} [input.limit] cap 绝对上限,默认 0.95
826
+ * @param {'none'|'configured'} [input.fallback]
827
+ * 'none'(运行时默认):实际线算不出就不判定(不告警、不阻断);
828
+ * 'configured'(服务端保存默认):实际线算不出时按配置阈值 −1pp 防呆(0.4.1 行为)。
829
+ * @returns {{
830
+ * source:'actual'|'configured'|null,
831
+ * actualRatio:number|null, actualPercent:number|null,
832
+ * configuredRatio:number|null, configuredPercent:number|null,
833
+ * handoffRatio:number|null, handoffPercent:number|null,
834
+ * compactRatio:number|null, compactPercent:number|null,
835
+ * cap:number|null, capPercent:number|null,
836
+ * ok:boolean, severity:'ok'|'warn'|'late'|'unknown',
837
+ * reason:'ok'|'late'|'margin'|'fallback'|'no-reference'|'invalid-handoff'|'no-cap',
838
+ * message:string, target:number|null, compactConflict:boolean
839
+ * }}
840
+ */
841
+ function handoffGuard({
842
+ actualRatio = null,
843
+ configuredRatio = null,
844
+ handoffRatio = null,
845
+ compactRatio = null,
846
+ autoEnabled = true,
847
+ limit = HANDOFF_LIMIT_DEFAULT,
848
+ fallback = 'none',
849
+ } = {}) {
850
+ const actual = ratioOf(actualRatio);
851
+ const configured = ratioOf(configuredRatio);
852
+ const handoff = ratioOf(handoffRatio);
853
+ const compact = ratioOf(compactRatio);
854
+ const neutral = {
855
+ source: null,
856
+ actualRatio: actual,
857
+ actualPercent: percentOf(actual),
858
+ configuredRatio: configured,
859
+ configuredPercent: percentOf(configured),
860
+ handoffRatio: handoff,
861
+ handoffPercent: percentOf(handoff),
862
+ compactRatio: compact,
863
+ compactPercent: percentOf(compact),
864
+ cap: null,
865
+ capPercent: null,
866
+ ok: true,
867
+ severity: 'unknown',
868
+ reason: 'no-reference',
869
+ message: '',
870
+ target: null,
871
+ compactConflict: false,
872
+ };
873
+ // 原生自动压缩关着:DSH 不会自己压,不存在「提醒来不及」的截止线。
874
+ if (autoEnabled === false) return neutral;
875
+
876
+ /** 带 cap 的结果骨架(cap 已是整数百分位,×100 即整数)。 */
877
+ const withCap = (source, cap) => {
878
+ const capPercent = cap === null ? null : Math.round(cap * 100);
879
+ return {
880
+ ...neutral,
881
+ source,
882
+ cap,
883
+ capPercent,
884
+ target: source === 'actual' ? cap : null,
885
+ compactConflict: cap !== null && compact !== null && cap <= compact + EPS,
886
+ };
887
+ };
888
+
889
+ // 1) 实际触发线可用:唯一口径(−5pp),不再叠加「配置阈值 −1pp」。
890
+ if (actual !== null) {
891
+ const cap = handoffCapOf(actual, { limit });
892
+ if (cap === null) return { ...withCap('actual', null), reason: 'no-cap' };
893
+ const base = withCap('actual', cap);
894
+ if (handoff === null) return { ...base, reason: 'invalid-handoff' };
895
+ const conflict = base.compactConflict ? compactConflictText(base.capPercent, compact) : '';
896
+ // 等于实际触发线也算「晚」:DSH 会在同一位置自己压历史,提醒来不及。
897
+ if (handoff > actual - EPS) {
898
+ return {
899
+ ...base,
900
+ ok: false,
901
+ severity: 'late',
902
+ reason: 'late',
903
+ message:
904
+ `交接线已晚:当前模型实际触发 ${percentText(actual)}%,交接线 ${percentText(handoff)}% 会在原生压缩之后` +
905
+ `(DSH 会先压历史);最高可设 ${base.capPercent}%。${conflict}`,
906
+ };
907
+ }
908
+ if (handoff > cap + EPS) {
909
+ return {
910
+ ...base,
911
+ ok: false,
912
+ severity: 'warn',
913
+ reason: 'margin',
914
+ message:
915
+ `交接线偏晚:当前模型实际触发 ${percentText(actual)}%,交接线 ${percentText(handoff)}% ` +
916
+ `距原生压缩不足 ${HANDOFF_MARGIN_PP} 个百分点;最高可设 ${base.capPercent}%。${conflict}`,
917
+ };
918
+ }
919
+ return { ...base, ok: true, severity: 'ok', reason: 'ok' };
920
+ }
921
+
922
+ // 2) 实际线算不出 + 服务端保存:退回配置阈值防呆(0.4.1 行为,兼容旧客户端)。
923
+ if (fallback === 'configured' && configured !== null) {
924
+ const cap = handoffCapOf(configured, { marginPp: FALLBACK_MARGIN_PP, limit });
925
+ if (cap === null) return { ...withCap('configured', null), reason: 'no-cap' };
926
+ const base = withCap('configured', cap);
927
+ if (handoff === null) return { ...base, reason: 'invalid-handoff' };
928
+ if (handoff > cap + EPS) {
929
+ return {
930
+ ...base,
931
+ ok: false,
932
+ severity: 'warn',
933
+ reason: 'fallback',
934
+ message:
935
+ `「交接提醒线」必须低于 DSH 原生自动压缩线(配置阈值 ${percentText(configured)}%),` +
936
+ `否则原生压缩会先动手;最高只能设到 ${base.capPercent}%。` +
937
+ '想设更高请先在设置里关闭「DSH 原生自动压缩」。',
938
+ };
939
+ }
940
+ return { ...base, ok: true, severity: 'ok', reason: 'ok' };
941
+ }
942
+
943
+ // 3) 运行时且算不出:不告警、不阻断。
944
+ return neutral;
945
+ }
727
946
 
728
947
  const fmt = (n) => {
729
948
  if (n == null) return '—';
@@ -1307,6 +1526,22 @@ window.__ModuleLoader__.load({
1307
1526
  // 0.3.1:模型判断的「这里适合压缩」位置 → 断点1(优先于阈值);失效的 hint 由 host 过滤。
1308
1527
  const hintRatio = state.compressHint && typeof state.compressHint.ratio === 'number' ? state.compressHint.ratio : null;
1309
1528
  const ring = ringSegments(ratio, compactRatio, handoffRatio, hintRatio);
1529
+ /**
1530
+ * 0.4.2(P0-1):交接线是否已晚 —— 硬上限 = 实际触发线 −5pp(与后台 save() 同一份判定)。
1531
+ * 只在宿主算出实际触发线(native.ratio)且原生自动压缩开启时判定;
1532
+ * 算不出时按范围定稿「不告警、不阻断」。
1533
+ */
1534
+ const handoffGuardInfo = handoffGuard({
1535
+ actualRatio: state.native === null ? null : state.native.ratio,
1536
+ configuredRatio: state.native === null ? null : state.native.thresholdRatio,
1537
+ handoffRatio,
1538
+ compactRatio,
1539
+ autoEnabled: state.native === null ? true : state.native.autoEnabled !== false,
1540
+ });
1541
+ const handoffLate = handoffGuardInfo.source === 'actual' && handoffGuardInfo.ok === false;
1542
+ const handoffWarnText = handoffLate
1543
+ ? `⚠ 交接线已晚:实际触发 ${handoffGuardInfo.actualPercent}%,交接线 ${handoffGuardInfo.handoffPercent}% 会在原生压缩之后(最高 ${handoffGuardInfo.capPercent}%)`
1544
+ : '';
1310
1545
  // DSH 原生自动压缩线(报警最高阈值):开着时在环上画一个「径向刻度」(不是沿环的弧),
1311
1546
  // 用紫色 + 主题底色描边——刻意不用五级配色里的颜色,保证在任何分段颜色上都看得清。
1312
1547
  // 越过原生线后:整环红色闪烁(作者 2026-10-20 反馈)。
@@ -1374,6 +1609,39 @@ window.__ModuleLoader__.load({
1374
1609
  }),
1375
1610
  );
1376
1611
  }
1612
+ // 0.4.2(P1):交接线已晚时,在环上「交接刻度」旁加一个小警示(径向短线 + 小点)。
1613
+ // 只加节点,不改环的分段/断点逻辑(范围定稿:A/B/C 画法不并进 0.4.2)。
1614
+ const handoffWarnNodes = [];
1615
+ if (handoffLate) {
1616
+ const tick = nativeTickAt(handoffRatio); // 复用同一套径向几何,位置换成交接线
1617
+ const warnAngle = handoffRatio * Math.PI * 2 - Math.PI / 2;
1618
+ handoffWarnNodes.push(
1619
+ h('line', {
1620
+ key: 'handoff-warn-casing',
1621
+ ...tick,
1622
+ stroke: theme.dark ? '#26262c' : '#ffffff',
1623
+ strokeWidth: 4.4,
1624
+ strokeLinecap: 'round',
1625
+ opacity: 0.92,
1626
+ }),
1627
+ h('line', {
1628
+ key: 'handoff-warn',
1629
+ ...tick,
1630
+ stroke: palette.handoff,
1631
+ strokeWidth: 2.2,
1632
+ strokeLinecap: 'round',
1633
+ 'data-cr-handoff-warn': '1',
1634
+ }),
1635
+ h('circle', {
1636
+ key: 'handoff-warn-dot',
1637
+ cx: Number((10 + 9.4 * Math.cos(warnAngle)).toFixed(3)),
1638
+ cy: Number((10 + 9.4 * Math.sin(warnAngle)).toFixed(3)),
1639
+ r: 1.05,
1640
+ fill: palette.handoff,
1641
+ 'data-cr-handoff-warn-dot': '1',
1642
+ }),
1643
+ );
1644
+ }
1377
1645
  // 平时给中心一点可见的灰点;交接时红点(外环已分段,红点强调该交接)。
1378
1646
  const dotColor = adviseHandoff ? palette.handoff : palette.idle;
1379
1647
  // 锁定消息在环上的块(方案 A:按消息在历史里的相对位置)。这是「保护内容」的信号,
@@ -1488,6 +1756,29 @@ window.__ModuleLoader__.load({
1488
1756
  return () => window.removeEventListener('compact-relay:replay-onboarding', onReplay);
1489
1757
  }, [playOnboarding]);
1490
1758
 
1759
+ // 0.4.2(P1):换模型后首次发现「交接线已晚」弹一次提示(同路由不重复打扰)。
1760
+ // 只在「观察到路由变化」(localStorage 记着上一次的 provider/model)时弹;
1761
+ // 首次见到某路由不弹(不是换模型导致的),设置页有常驻提示兜底。
1762
+ useEffect(() => {
1763
+ if (state.native === null || state.native.route === null) return;
1764
+ const key = `${state.native.route.provider || ''}/${state.native.route.model}`;
1765
+ let previous = null;
1766
+ try {
1767
+ previous = window.localStorage.getItem('dsh.compact-relay.route.v1');
1768
+ } catch {
1769
+ previous = null; // 隐私模式:不记、不弹,避免反复打扰
1770
+ return;
1771
+ }
1772
+ if (previous === key) return;
1773
+ try {
1774
+ window.localStorage.setItem('dsh.compact-relay.route.v1', key);
1775
+ } catch {
1776
+ /* ignore */
1777
+ }
1778
+ if (previous === null || previous === '' || !handoffLate) return;
1779
+ showToast(`已切换到 ${state.native.route.model}:${handoffWarnText.replace('⚠ ', '')}。可在「设置 → 压缩接力」一键重算。`, 10000);
1780
+ }, [state.native, handoffLate, handoffWarnText, showToast]);
1781
+
1491
1782
  // 引导光晕:默认 4 次呼吸后自动停(手动重播时按传入时长)。
1492
1783
  useEffect(() => {
1493
1784
  if (!onboarding) return undefined;
@@ -1837,6 +2128,7 @@ window.__ModuleLoader__.load({
1837
2128
  `,已压缩 ${state.summaries} 次|${adviceText}` +
1838
2129
  (compressHintText !== '' ? `|${compressHintText}` : '') +
1839
2130
  (nativeLineText !== '' ? `|${nativeLineText}` : '') +
2131
+ (handoffWarnText !== '' ? `|${handoffWarnText}` : '') +
1840
2132
  (pins.count > 0 ? `|已锁定 ${pins.count} 条` : '') +
1841
2133
  (effectiveBusy ? '|处理中…' : '');
1842
2134
 
@@ -1896,6 +2188,10 @@ window.__ModuleLoader__.load({
1896
2188
  { style: { color: theme.subText } },
1897
2189
  `已压缩 ${state.summaries} 次 · ${adviceText}${compressHintText !== '' ? ` · ${compressHintText}` : ''}${nativeLineText !== '' ? ` · ${nativeLineText}` : ''}${pins.count > 0 ? ` · 已锁定 ${pins.count} 条` : ''}${effectiveBusy ? ' · 处理中…' : ''}`,
1898
2190
  ),
2191
+ // 0.4.2:交接线晚于原生压缩时,悬停卡里给一行显式警示(只告警,不改设置)。
2192
+ handoffWarnText !== ''
2193
+ ? h('div', { 'data-cr-handoff-warn-text': '1', style: { marginTop: 2, color: palette.handoff } }, handoffWarnText)
2194
+ : null,
1899
2195
  )
1900
2196
  : null;
1901
2197
 
@@ -1918,6 +2214,8 @@ window.__ModuleLoader__.load({
1918
2214
  }),
1919
2215
  // 0.3.1:DSH 原生自动压缩线(刻度/危险区)。
1920
2216
  ...nativeNodes,
2217
+ // 0.4.2:交接线已晚的警示标记(交接刻度旁的小短线 + 小点,不改分段逻辑)。
2218
+ ...handoffWarnNodes,
1921
2219
  // 0.3.1:锁定消息在环上的块(品红,按历史位置)。
1922
2220
  ...pinBlocks,
1923
2221
  );
@@ -2133,6 +2431,8 @@ window.__ModuleLoader__.load({
2133
2431
  onClick: () => {
2134
2432
  void startHandoffPreview();
2135
2433
  },
2434
+ // 0.4.2(P1):交接线晚于原生压缩时,菜单项旁直接标出来(只告警,仍可点)。
2435
+ ...(handoffLate ? { note: handoffWarnText, title: handoffGuardInfo.message } : {}),
2136
2436
  }
2137
2437
  : { disabled: true, note: '会话刚打开,请稍候' },
2138
2438
  ),
@@ -2985,6 +3285,43 @@ window.__ModuleLoader__.load({
2985
3285
  cursor: 'pointer',
2986
3286
  };
2987
3287
 
3288
+ /** 百分比上限徽标:「最高 62%」——让上限一眼可见,但不用它夹紧输入。 */
3289
+ const percentCapChipStyle = {
3290
+ display: 'inline-flex',
3291
+ alignItems: 'center',
3292
+ height: 20,
3293
+ padding: '0 8px',
3294
+ borderRadius: 999,
3295
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.28))',
3296
+ background: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.08))',
3297
+ color: 'var(--dsw-alias-label-secondary, rgba(127,127,127,0.95))',
3298
+ fontSize: 11,
3299
+ lineHeight: '20px',
3300
+ whiteSpace: 'nowrap',
3301
+ };
3302
+
3303
+ /**
3304
+ * 行内提示块(告警 / 失败):淡色底 + 同色描边,深浅主题都靠 DSH 状态 token 自适应。
3305
+ * 红色只用在真正的失败上,且用 7% 透明底而不是整块红,避免刺眼。
3306
+ * @param {'warning'|'error'} kind
3307
+ */
3308
+ function alertBoxStyle(kind) {
3309
+ const warning = kind === 'warning';
3310
+ return {
3311
+ display: 'flex',
3312
+ flexDirection: 'column',
3313
+ gap: 2,
3314
+ padding: '8px 10px',
3315
+ borderRadius: 8,
3316
+ border: `1px solid ${warning ? 'var(--dsw-alias-state-warning-primary, #d97706)' : 'var(--dsw-alias-state-error-primary, #D92D20)'}`,
3317
+ background: warning ? 'rgba(217, 119, 6, 0.08)' : 'rgba(217, 45, 32, 0.07)',
3318
+ color: warning ? 'var(--dsw-alias-state-warning-primary, #b45309)' : 'var(--dsw-alias-state-error-primary, #D92D20)',
3319
+ fontSize: 12,
3320
+ lineHeight: '17px',
3321
+ fontWeight: 600,
3322
+ };
3323
+ }
3324
+
2988
3325
  /** 不可变地设置对象里的某个路径。 */
2989
3326
  function setIn(object, path, value) {
2990
3327
  if (path.length === 1) return { ...object, [path[0]]: value };
@@ -3070,6 +3407,21 @@ window.__ModuleLoader__.load({
3070
3407
  /** 0.4 P1-3:轻账本累计数字(独立账本文件算出来的;null = 没有数据/读不到,显示引导文案)。 */
3071
3408
  const [ledger, setLedger] = useState(null);
3072
3409
  const [confirmNative, setConfirmNative] = useState(null); // null | 'disable' | 'enable'
3410
+ /** 0.4.2:按实际线重算交接线(先二次确认,{ratio,percent})。 */
3411
+ const [confirmRecalc, setConfirmRecalc] = useState(null);
3412
+ /**
3413
+ * 0.4.2 修复(真机 bug「输入 80 他直接改成 99」):百分比输入框的**本地草稿**。
3414
+ *
3415
+ * 根因:输入框的 value 以前是派生值 `Math.round(ratio*100)`(受控、无草稿),
3416
+ * 每次按键都经过 `Math.min(0.99, …)` 夹紧再四舍五入回写。于是「60 后面按 8」这种
3417
+ * 中间态(608)会被夹成 0.99 → 显示 99,用户输入被静默吃掉,保存再报错。
3418
+ *
3419
+ * 现在:按键只记草稿(用户输入什么就显示什么,除非非数字),表单同步原样写入
3420
+ * `value/100`(不夹紧);失焦/加载/保存成功时丢弃草稿,回到表单的派生值。
3421
+ * 超限一律留给服务端拒绝(guard.message 给目标值),不在客户端改写。
3422
+ * @type {[Record<string,string>, Function]}
3423
+ */
3424
+ const [percentDrafts, setPercentDrafts] = useState({});
3073
3425
  const [nativeBusy, setNativeBusy] = useState(false);
3074
3426
  /** UI-003:结果提示就地显示在开关旁(以前只写页面底部,用户看不到)。 */
3075
3427
  const [nativeStatus, setNativeStatus] = useState(null);
@@ -3115,6 +3467,8 @@ window.__ModuleLoader__.load({
3115
3467
  const applyLoaded = useCallback((data) => {
3116
3468
  setForm(data.config);
3117
3469
  setSavedSnapshot(JSON.stringify(data.config));
3470
+ // 服务端回来的配置是新的权威值:丢掉输入草稿,输入框重新按表单派生(加载/保存成功都走这里)。
3471
+ setPercentDrafts({});
3118
3472
  setSettingsError(data.settingsError ?? null);
3119
3473
  setNative(mergeNative(data.native && typeof data.native === 'object' ? data.native : null));
3120
3474
  if (typeof data.version === 'string') setVersion(data.version);
@@ -3162,32 +3516,122 @@ window.__ModuleLoader__.load({
3162
3516
  const patch = (path, value) => {
3163
3517
  setForm((prev) => (prev === null ? prev : setIn(prev, path, value)));
3164
3518
  setConfirmReset(false);
3519
+ setConfirmRecalc(null);
3520
+ // 0.4.2 修复:一改值就清掉上一次的保存结果/失败提示,避免旧错误一直挂着像没生效。
3521
+ setStatus(null);
3522
+ };
3523
+
3524
+ /* ---------------- 百分比输入:草稿优先,绝不静默改写 ---------------- */
3525
+
3526
+ /**
3527
+ * 输入框显示值:有草稿就用草稿(用户打什么显示什么),否则按表单比例派生整数百分位。
3528
+ * @param {'minRatio'|'compactRatio'|'handoffRatio'} key
3529
+ * @param {number} ratio
3530
+ * @returns {string}
3531
+ */
3532
+ const percentText = (key, ratio) => {
3533
+ const draft = percentDrafts[key];
3534
+ return typeof draft === 'string' ? draft : String(Math.round(ratio * 100));
3535
+ };
3536
+
3537
+ /**
3538
+ * 百分比输入变化:先记草稿,再把「用户输入的数字 / 100」原样写进表单。
3539
+ * - 空串 = 用户正在删旧值准备重打:只留草稿,不动表单(以前空串会被夹成 5%,用户根本打不出新值);
3540
+ * - 非数字(type=number 下浏览器给空串或字母被吞):只显示,不动表单;
3541
+ * - 其余:不夹紧、不四舍五入,超限交给保存时服务端拒绝并给目标值。
3542
+ * @param {'minRatio'|'compactRatio'|'handoffRatio'} key
3543
+ * @param {string} text
3544
+ */
3545
+ const changePercent = (key, text) => {
3546
+ const raw = typeof text === 'string' ? text : '';
3547
+ setPercentDrafts((prev) => ({ ...prev, [key]: raw }));
3548
+ if (raw.trim() === '') {
3549
+ setStatus(null);
3550
+ setConfirmReset(false);
3551
+ setConfirmRecalc(null);
3552
+ return;
3553
+ }
3554
+ const value = Number(raw);
3555
+ if (!Number.isFinite(value)) return;
3556
+ patch(['thresholds', key], value / 100);
3557
+ };
3558
+
3559
+ /** 丢弃某个草稿,显示回到表单派生值(失焦时用;不会改表单)。 */
3560
+ const restorePercentDraft = (key) => {
3561
+ setPercentDrafts((prev) => {
3562
+ if (!(key in prev)) return prev;
3563
+ const next = { ...prev };
3564
+ delete next[key];
3565
+ return next;
3566
+ });
3567
+ };
3568
+
3569
+ /**
3570
+ * 聚焦/点击即全选:阈值框只有 2–3 位数,点进去直接打字就是「替换」,
3571
+ * 不会出现「60 后面接着打 80 变成 6080」这种叠加(真机上「输入 80 变成 99」的一半原因)。
3572
+ *
3573
+ * 为什么还要 setTimeout(0) 再选一次:点进输入框时,浏览器会在 focus 事件之后
3574
+ * 才把光标落到鼠标位置(把刚做的选区冲掉)。下一轮事件循环再选一次 = 打字一定是替换。
3575
+ */
3576
+ const selectOnFocus = (event) => {
3577
+ const el = event === null || event === undefined ? null : event.target;
3578
+ if (el === null || el === undefined || typeof el.select !== 'function') return;
3579
+ try {
3580
+ el.select();
3581
+ } catch {
3582
+ /* 个别浏览器不允许对 number 输入做选区:忽略,不影响输入 */
3583
+ }
3584
+ setTimeout(() => {
3585
+ try {
3586
+ if (el.ownerDocument !== null && el.ownerDocument !== undefined && el.ownerDocument.activeElement === el) el.select();
3587
+ } catch {
3588
+ /* 元素已卸载:忽略 */
3589
+ }
3590
+ }, 0);
3165
3591
  };
3166
3592
 
3167
3593
  /** 表单与已保存快照不一致 → 有未保存修改。 */
3168
3594
  const dirty = form !== null && savedSnapshot !== null && JSON.stringify(form) !== savedSnapshot;
3169
3595
 
3170
- const save = useCallback(async () => {
3596
+ /**
3597
+ * 保存设置。0.4.2:body 带上 `nativeRatio`(当前模型实际触发线),
3598
+ * 服务端据此按「实际线 −5pp」同口径校验交接线;算不出/原生关闭时不带(服务端兜底)。
3599
+ * @param {object} [override] 「按实际线重算」用:一次性覆盖表单(不依赖 setState 的异步生效)
3600
+ */
3601
+ const save = useCallback(async (override) => {
3171
3602
  if (form === null || saving) return;
3603
+ const payload = override !== undefined && override !== null ? override : form;
3172
3604
  setSaving(true);
3173
3605
  setStatus(null);
3174
3606
  setConfirmReset(false);
3175
3607
  try {
3608
+ const info = nativeLineInfo(native);
3609
+ const nativeRatio = native !== null && native.autoEnabled !== false && info.actual !== null ? info.actual : null;
3176
3610
  const res = await fetch('/api/compact-relay-settings', {
3177
3611
  method: 'POST',
3178
3612
  headers: { 'content-type': 'application/json' },
3179
- body: JSON.stringify(form),
3613
+ body: JSON.stringify(nativeRatio === null ? payload : { ...payload, nativeRatio }),
3180
3614
  });
3181
3615
  const data = await res.json().catch(() => ({}));
3182
- if (!res.ok || data.ok === false) throw new Error(data && data.message ? String(data.message) : '保存失败');
3616
+ if (!res.ok || data.ok === false) {
3617
+ // 0.4.2 修复:服务端会带 field(交接线校验失败时是 thresholds.handoffRatio),
3618
+ // 带出来才能在交接线行内就地标红,而不是只在页面底部写一行。
3619
+ const failure = new Error(data && data.message ? String(data.message) : '保存失败');
3620
+ if (data !== null && typeof data === 'object' && typeof data.field === 'string') failure.field = data.field;
3621
+ throw failure;
3622
+ }
3183
3623
  applyLoaded(data);
3184
3624
  setStatus({ text: '已保存(立即生效,不需要重启)' });
3185
3625
  } catch (error) {
3186
- setStatus({ error: true, text: `保存失败:${error && error.message ? error.message : '未知错误'}` });
3626
+ setStatus({
3627
+ error: true,
3628
+ text: `保存失败:${error && error.message ? error.message : '未知错误'}`,
3629
+ field: error !== null && typeof error === 'object' && typeof error.field === 'string' ? error.field : null,
3630
+ });
3187
3631
  } finally {
3188
3632
  setSaving(false);
3189
3633
  }
3190
- }, [form, saving, applyLoaded]);
3634
+ }, [form, saving, applyLoaded, native]);
3191
3635
 
3192
3636
  /** 恢复默认 = 删除用户设置文件;先二次确认,避免误点丢掉配色。 */
3193
3637
  const reset = useCallback(async () => {
@@ -3212,6 +3656,19 @@ window.__ModuleLoader__.load({
3212
3656
  }
3213
3657
  }, [saving, applyLoaded]);
3214
3658
 
3659
+ /**
3660
+ * 0.4.2:「按实际线重算」——目标 = 实际触发线 −5pp(整数百分位,guard 已算好)。
3661
+ * 二次确认后一次性保存(不走 patch + save 的异步状态,避免发出旧表单)。
3662
+ */
3663
+ const applyRecalc = useCallback(async () => {
3664
+ if (form === null || confirmRecalc === null || saving) return;
3665
+ const next = setIn(form, ['thresholds', 'handoffRatio'], confirmRecalc.ratio);
3666
+ setConfirmRecalc(null);
3667
+ // 目标值是 guard 算出来的权威值:丢掉输入草稿,避免输入框还显示用户手打的旧数字。
3668
+ setPercentDrafts({});
3669
+ await save(next);
3670
+ }, [form, confirmRecalc, saving, save]);
3671
+
3215
3672
  /** 0.3.1:关闭/恢复 DSH 原生自动压缩(写 profile 配置,备份后重启生效)。 */
3216
3673
  const applyNative = useCallback(
3217
3674
  async (enabled) => {
@@ -3326,7 +3783,6 @@ window.__ModuleLoader__.load({
3326
3783
 
3327
3784
  const percent = (ratio) => Math.round(ratio * 100);
3328
3785
  const compactPercent = percent(form.thresholds.compactRatio);
3329
- const handoffPercent = percent(form.thresholds.handoffRatio);
3330
3786
  const count = (value) => (Number.isFinite(value) ? value : 0);
3331
3787
  // 0.4 P1-3:轻账本一行累计数字(只读;数据来自宿主账本,与账本文件同源)。
3332
3788
  const ledgerLine = (() => {
@@ -3341,13 +3797,42 @@ window.__ModuleLoader__.load({
3341
3797
  // 0.4.1:配置阈值(thresholdRatio)只是理论上限;实际触发线由宿主按 token 预算算。
3342
3798
  const nativeInfo = nativeLineInfo(native);
3343
3799
  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
3800
  // 原生压缩已关闭时 effective/percentText 为空:回落到「开启后实际会触发多少」,再回落配置阈值。
3348
3801
  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}% 以下(改完立即生效)。` : '';
3802
+ /**
3803
+ * 0.4.2(P0-1/P1):交接线硬上限 = 实际触发线 −5pp(整数百分位),与后台 save() 同一份判定。
3804
+ * 实际线算不出时退回配置阈值 −1pp 兜底(与 0.4.1 一致,仅限制输入上限,不告警)。
3805
+ * 注意:这里读 nativeInfo.actual(真实触发线),不能用 effective(autoOff/算不出时它会回落配置阈值)。
3806
+ */
3807
+ const handoffGuardInfo = handoffGuard({
3808
+ actualRatio: nativeInfo.actual,
3809
+ configuredRatio: nativeInfo.configured,
3810
+ handoffRatio: form.thresholds.handoffRatio,
3811
+ compactRatio: form.thresholds.compactRatio,
3812
+ autoEnabled: autoOnNative,
3813
+ fallback: 'configured',
3814
+ });
3815
+ const handoffCapPercent = handoffGuardInfo.capPercent;
3816
+ const handoffLate = handoffGuardInfo.source === 'actual' && handoffGuardInfo.ok === false;
3817
+ const handoffCapHint =
3818
+ handoffGuardInfo.source === 'actual'
3819
+ ? `当前模型实际触发 ${nativeInfo.actualText}%,最高可设 ${handoffCapPercent}%(实际触发线 −5pp)。`
3820
+ : autoOnNative && handoffCapPercent !== null
3821
+ ? `实际触发线未知(会话未就绪或算不出),暂按配置阈值最高 ${handoffCapPercent}%。`
3822
+ : '';
3823
+ /**
3824
+ * 0.4.2 修复(真机反馈):交接线被服务端拒绝时,错误文案就地显示在交接线行内。
3825
+ * 文案沿用 guard.message(含实际线与目标值),去掉「保存失败:」前缀避免重复。
3826
+ */
3827
+ const handoffSaveError =
3828
+ status !== null && status.error && status.field === 'thresholds.handoffRatio'
3829
+ ? String(status.text).replace(/^保存失败:/, '')
3830
+ : null;
3831
+ // 重算目标 ≤ 压缩线时(保持「交接线 > 压缩线」现有约束)点按钮直接拒绝并提示先降压缩线。
3832
+ const handoffRecalcConflict =
3833
+ handoffGuardInfo.compactConflict && handoffCapPercent !== null
3834
+ ? `按实际线重算目标 ${handoffCapPercent}% 不高于压缩线 ${compactPercent}%,请先把压缩线降到 ${handoffCapPercent}% 以下。`
3835
+ : '';
3351
3836
  const previewLight = paletteForScheme(form.colors, 'light');
3352
3837
  const previewDark = paletteForScheme(form.colors, 'dark');
3353
3838
  // 0.4 P0-2:诊断预览弹层复用指示灯那套浮层材质(15-shared.panelChrome)。
@@ -3376,7 +3861,7 @@ window.__ModuleLoader__.load({
3376
3861
 
3377
3862
  return h(
3378
3863
  'div',
3379
- { style: { display: 'flex', flexDirection: 'column', maxWidth: 680, padding: '4px 0 20px' } },
3864
+ { style: { display: 'flex', flexDirection: 'column', maxWidth: 680, padding: '4px 0 8px' } },
3380
3865
  h('div', { style: { fontSize: 14, fontWeight: 600, marginBottom: 4 } }, '压缩接力'),
3381
3866
  h(
3382
3867
  'p',
@@ -3397,8 +3882,19 @@ window.__ModuleLoader__.load({
3397
3882
  'div',
3398
3883
  { style: { display: 'flex', alignItems: 'center', gap: 8 } },
3399
3884
  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))),
3885
+ type: 'number',
3886
+ inputMode: 'numeric',
3887
+ min: 1,
3888
+ max: 60,
3889
+ step: 1,
3890
+ value: percentText('minRatio', form.thresholds.minRatio),
3891
+ 'aria-label': '最小压缩线(百分比)',
3892
+ style: { ...settingsInputStyle, width: 90, textAlign: 'right', fontVariantNumeric: 'tabular-nums' },
3893
+ onFocus: selectOnFocus,
3894
+ onClick: selectOnFocus,
3895
+ // 0.4.2 修复:不再按键就夹紧/四舍五入(以前清空会立刻变 5%),草稿优先,越界交服务端拒绝。
3896
+ onChange: (event) => changePercent('minRatio', event.target.value),
3897
+ onBlur: () => restorePercentDraft('minRatio'),
3402
3898
  }),
3403
3899
  h('span', { style: settingsHintStyle }, '% 窗口'),
3404
3900
  ),
@@ -3407,10 +3903,20 @@ window.__ModuleLoader__.load({
3407
3903
  '压缩提醒线',
3408
3904
  '指示灯到这条线开始变琥珀色,菜单也会建议「压缩旧会话」。',
3409
3905
  '调高 → 更晚提醒、压得更少但上下文更长;调低 → 更早提醒、压得更勤。',
3410
- h('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
3906
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
3411
3907
  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))),
3908
+ type: 'number',
3909
+ inputMode: 'numeric',
3910
+ min: 5,
3911
+ max: 95,
3912
+ step: 1,
3913
+ value: percentText('compactRatio', form.thresholds.compactRatio),
3914
+ 'aria-label': '压缩提醒线(百分比)',
3915
+ style: { ...settingsInputStyle, width: 90, textAlign: 'right', fontVariantNumeric: 'tabular-nums' },
3916
+ onFocus: selectOnFocus,
3917
+ onClick: selectOnFocus,
3918
+ onChange: (event) => changePercent('compactRatio', event.target.value),
3919
+ onBlur: () => restorePercentDraft('compactRatio'),
3414
3920
  }),
3415
3921
  h('span', { style: settingsHintStyle }, '% 窗口'),
3416
3922
  h('span', { style: settingsHintStyle }, `(1M 窗口 ≈ ${Math.round(1000000 * form.thresholds.compactRatio / 1000)}K)`),
@@ -3419,26 +3925,126 @@ window.__ModuleLoader__.load({
3419
3925
  settingsRow(
3420
3926
  '交接提醒线',
3421
3927
  '到这条线内圆亮红点,菜单建议「交接新会话」。',
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)`),
3928
+ '调高 → 更晚换会话、单会话更久;调低 → 更早换会话、单会话更短。必须大于压缩线。',
3929
+ h(
3930
+ 'div',
3931
+ { 'data-cr-handoff-field': '1', style: { display: 'flex', flexDirection: 'column', gap: 8 } },
3932
+ // 控制行:输入框 + 单位 + 上限徽标(上限只做提示,不用来夹紧输入)
3933
+ h(
3934
+ 'div',
3935
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
3936
+ h('input', {
3937
+ type: 'number',
3938
+ inputMode: 'numeric',
3939
+ min: 5,
3940
+ // 0.4.2 修复:max 用真实上限(如 62),只作浏览器提示;不再靠代码夹紧改写用户输入。
3941
+ max: handoffCapPercent !== null ? handoffCapPercent : 100,
3942
+ step: 1,
3943
+ value: percentText('handoffRatio', form.thresholds.handoffRatio),
3944
+ 'aria-label': '交接提醒线(百分比)',
3945
+ 'aria-describedby': 'cr-handoff-cap-hint',
3946
+ style: { ...settingsInputStyle, width: 90, textAlign: 'right', fontVariantNumeric: 'tabular-nums' },
3947
+ onFocus: selectOnFocus,
3948
+ onClick: selectOnFocus,
3949
+ // 0.4.2 修复(真机 bug「输入 80 变成 99」):输入原样保留,不夹紧/不取整/不替用户改。
3950
+ onChange: (event) => changePercent('handoffRatio', event.target.value),
3951
+ onBlur: () => restorePercentDraft('handoffRatio'),
3952
+ }),
3953
+ h('span', { style: settingsHintStyle }, '% 窗口'),
3954
+ handoffCapPercent === null
3955
+ ? null
3956
+ : h('span', {
3957
+ 'data-cr-handoff-cap-chip': '1',
3958
+ title: `超过 ${handoffCapPercent}% 保存会被拒绝(会告诉你目标值);这里不会自动改写你的输入。`,
3959
+ style: percentCapChipStyle,
3960
+ }, `最高 ${handoffCapPercent}%`),
3961
+ ),
3962
+ // 体量估算单独一行:和上限、告警挤在同一排会显得拥挤。
3963
+ h('div', { style: settingsHintStyle }, `(1M 窗口 ≈ ${Math.round(1000000 * form.thresholds.handoffRatio / 1000)}K)`),
3964
+ handoffCapHint === '' ? null : h('div', { 'data-cr-handoff-cap': '1', id: 'cr-handoff-cap-hint', style: settingsHintStyle }, handoffCapHint),
3965
+ // 超限告警:保存前就可见(amber),保存被拒后由红色失败块接棒(两者不同时出现)。
3966
+ handoffLate && handoffSaveError === null
3967
+ ? h('div', { 'data-cr-handoff-late': '1', role: 'status', style: alertBoxStyle('warning') }, `⚠ ${handoffGuardInfo.message}`)
3968
+ : null,
3969
+ // 保存失败(服务端 guard.message):就地在交接线行内显示,滚动到页面任何位置都不用回头找。
3970
+ handoffSaveError === null
3971
+ ? null
3972
+ : h('div', { 'data-cr-handoff-error': '1', role: 'alert', style: alertBoxStyle('error') }, handoffSaveError),
3973
+ // 违规时给「按实际线重算」:目标 = 实际触发线 −5pp(整数百分位),保存前二次确认。
3974
+ // 0.4.2 修复:从 11px 灰色小字里挪到独立动作行(右对齐),和告警同属一块可视区,不再跟单位/估算挤在一排。
3975
+ handoffLate && handoffCapPercent !== null
3976
+ ? h(
3977
+ 'div',
3978
+ { style: { display: 'flex', justifyContent: 'flex-end' } },
3979
+ h('button', {
3980
+ type: 'button',
3981
+ 'data-cr-recalc': '1',
3982
+ disabled: saving,
3983
+ title: `按当前模型实际触发线 ${nativeInfo.actualText}% 重算:最高可设 ${handoffCapPercent}%`,
3984
+ style: { ...settingsButtonStyle, height: 28, fontSize: 12, padding: '0 12px', whiteSpace: 'nowrap', cursor: saving ? 'wait' : 'pointer' },
3985
+ onClick: () => {
3986
+ if (handoffRecalcConflict !== '') {
3987
+ setConfirmRecalc(null);
3988
+ setStatus({ error: true, text: handoffRecalcConflict, field: 'thresholds.handoffRatio' });
3989
+ return;
3990
+ }
3991
+ setStatus(null);
3992
+ setConfirmRecalc({ ratio: handoffGuardInfo.target, percent: handoffCapPercent });
3993
+ },
3994
+ }, `按实际线重算(${handoffCapPercent}%)`),
3995
+ )
3996
+ : null,
3440
3997
  ),
3441
3998
  ),
3999
+ confirmRecalc === null
4000
+ ? null
4001
+ : h(
4002
+ 'div',
4003
+ {
4004
+ 'data-cr-recalc-confirm': '1',
4005
+ style: {
4006
+ margin: '0 0 14px',
4007
+ padding: '10px 12px',
4008
+ borderRadius: 8,
4009
+ border: '1px solid var(--dsw-alias-state-warning-primary, #d97706)',
4010
+ background: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.06))',
4011
+ },
4012
+ },
4013
+ h('div', { style: { fontSize: 12, fontWeight: 600, marginBottom: 4 } }, `确认把「交接提醒线」改为 ${confirmRecalc.percent}%?`),
4014
+ h(
4015
+ 'div',
4016
+ { style: { ...settingsHintStyle, marginBottom: 8 } },
4017
+ `当前模型实际触发 ${nativeInfo.actualText}%,按「实际线 −5pp」向下取整到 ${confirmRecalc.percent}%;` +
4018
+ '点「确认重算」会立即保存生效(不改压缩线、不动其它设置)。',
4019
+ ),
4020
+ h(
4021
+ 'div',
4022
+ { style: { display: 'flex', gap: 8 } },
4023
+ h(
4024
+ 'button',
4025
+ {
4026
+ type: 'button',
4027
+ disabled: saving,
4028
+ 'data-cr-recalc-ok': '1',
4029
+ style: { padding: '4px 12px', borderRadius: 6, border: '1px solid #d97706', background: '#d97706', color: '#fff', cursor: saving ? 'wait' : 'pointer', fontSize: 12 },
4030
+ onClick: () => {
4031
+ void applyRecalc();
4032
+ },
4033
+ },
4034
+ saving ? '保存中…' : '确认重算',
4035
+ ),
4036
+ h(
4037
+ 'button',
4038
+ {
4039
+ type: 'button',
4040
+ disabled: saving,
4041
+ 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 },
4042
+ onClick: () => setConfirmRecalc(null),
4043
+ },
4044
+ '取消',
4045
+ ),
4046
+ ),
4047
+ ),
3442
4048
  ),
3443
4049
  sectionHeading('压缩行为'),
3444
4050
  h(
@@ -3686,12 +4292,6 @@ window.__ModuleLoader__.load({
3686
4292
  '换成更醒目的反差色 → 锁定位置一眼可见;换成接近默认色 → 更安静(但可能看不清标记)。',
3687
4293
  colorField(form.colors.base.pin, (value) => patch(['colors', 'base', 'pin'], value), '锁定颜色'),
3688
4294
  ),
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
4295
  ),
3696
4296
  sectionHeading('帮助'),
3697
4297
  h(
@@ -3894,16 +4494,130 @@ window.__ModuleLoader__.load({
3894
4494
  h('p', { style: settingsHintStyle }, '联系方式:cfanzx@136.com(合作、安全问题,以及不便公开讨论的事项)。'),
3895
4495
  h('p', { style: settingsHintStyle }, 'npm 包页面:https://www.npmjs.com/package/dsh-compact-relay'),
3896
4496
  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) }, '恢复默认'),
4497
+ h(
4498
+ 'p',
4499
+ { style: { ...settingsHintStyle, marginTop: 20, paddingTop: 14, borderTop: '1px dashed var(--dsw-alias-border-l2, rgba(127,127,127,0.25))' } },
4500
+ '危险操作:恢复默认会删除你的设置文件。入口在页面底部常驻的保存工具条里,点一次后还需要再确认一次(不会误删)。',
4501
+ ),
4502
+ ),
4503
+ /**
4504
+ * 0.4.2 修复(真机反馈「保存按键太靠下了」):设置页很长,保存/恢复默认改成
4505
+ * 粘性底部工具条 —— 页面滚到哪都固定在视口底部,不用滚到页尾才能点保存。
4506
+ *
4507
+ * 为什么用 sticky 而不是 fixed:它是流内元素,天然只占页面末尾那一格,
4508
+ * 不会盖住最后一块内容;滚动中固定在底部时给宿主弹层留出 12px 内边距 +
4509
+ * 安全区 padding,避免贴边/遮挡。
4510
+ *
4511
+ * 0.4.2 视觉打磨:按钮组靠左、状态靠右(marginLeft:auto 对齐,不再错位),
4512
+ * 状态做成淡色小胶囊(失败红 / 成功绿 / 无状态时占位提示,行高不跳);
4513
+ * 底色/投影用现有 DSH token + theme.dark 兜底,深色浅色都不糊。
4514
+ */
4515
+ h(
4516
+ 'div',
4517
+ {
4518
+ 'data-cr-savebar': '1',
4519
+ style: {
4520
+ position: 'sticky',
4521
+ bottom: 0,
4522
+ zIndex: 20,
4523
+ marginTop: 20,
4524
+ marginLeft: -8,
4525
+ marginRight: -8,
4526
+ padding: '8px 10px calc(8px + env(safe-area-inset-bottom, 0px))',
4527
+ borderTop: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.22))',
4528
+ background: `var(--dsw-alias-bg-layer-1, ${theme.dark ? 'rgba(32,33,36,0.94)' : 'rgba(255,255,255,0.94)'})`,
4529
+ backdropFilter: 'var(--dsw-menu-backdrop-filter, blur(10px))',
4530
+ WebkitBackdropFilter: 'var(--dsw-menu-backdrop-filter, blur(10px))',
4531
+ boxShadow: theme.dark ? '0 -6px 18px rgba(0,0,0,0.42)' : '0 -6px 18px rgba(0,0,0,0.08)',
4532
+ display: 'flex',
4533
+ flexDirection: 'column',
4534
+ gap: 6,
4535
+ },
4536
+ },
4537
+ h(
4538
+ 'div',
4539
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
4540
+ h(
4541
+ 'button',
4542
+ {
4543
+ type: 'button',
4544
+ 'data-cr-save': '1',
4545
+ disabled: saving,
4546
+ style: {
4547
+ ...settingsButtonStyle,
4548
+ height: 30,
4549
+ padding: '0 16px',
4550
+ fontWeight: 600,
4551
+ background: 'var(--dsw-alias-button-primary, #2563eb)',
4552
+ color: '#fff',
4553
+ border: 'none',
4554
+ cursor: saving ? 'wait' : 'pointer',
4555
+ boxShadow: '0 1px 2px rgba(0,0,0,0.12)',
4556
+ },
4557
+ onClick: () => void save(),
4558
+ },
4559
+ saving ? '保存中…' : dirty ? '保存修改' : '保存',
4560
+ ),
4561
+ h(
4562
+ 'button',
4563
+ {
4564
+ type: 'button',
4565
+ 'data-cr-reset': '1',
4566
+ disabled: saving,
4567
+ style: { ...settingsButtonStyle, height: 30, cursor: saving ? 'wait' : 'pointer' },
4568
+ onClick: () => setConfirmReset(true),
4569
+ },
4570
+ '恢复默认',
4571
+ ),
4572
+ dirty
4573
+ ? h('span', { 'data-cr-dirty': '1', style: { fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary, #b45309)' } }, '有未保存的修改')
4574
+ : h('span', { 'data-cr-dirty': '0', style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))' } }, '已是最新'),
4575
+ // 没有状态时右侧放提示占位:按钮行高度稳定,不会因为状态出现/消失而跳。
4576
+ status === null
4577
+ ? h('span', { style: { ...settingsHintStyle, marginLeft: 'auto', textAlign: 'right' } }, '改完随时点「保存」,不用滚到页尾。')
4578
+ : null,
3906
4579
  ),
4580
+ // 状态单独一行(失败文案可能两行):淡色胶囊 + 状态色文字,条内也看得清,且不把按钮挤歪。
4581
+ status === null
4582
+ ? null
4583
+ : h(
4584
+ 'div',
4585
+ {
4586
+ 'data-cr-save-status': status.error ? 'error' : 'ok',
4587
+ role: status.error ? 'alert' : 'status',
4588
+ style: {
4589
+ padding: '4px 8px',
4590
+ borderRadius: 6,
4591
+ background: status.error ? 'rgba(217, 45, 32, 0.10)' : 'rgba(21, 128, 61, 0.10)',
4592
+ color: status.error
4593
+ ? 'var(--dsw-alias-state-error-primary, #D92D20)'
4594
+ : 'var(--dsw-alias-state-success-primary, #15803D)',
4595
+ fontSize: 11,
4596
+ lineHeight: '16px',
4597
+ fontWeight: status.error ? 600 : 500,
4598
+ },
4599
+ },
4600
+ status.text,
4601
+ ),
4602
+ confirmReset
4603
+ ? h(
4604
+ 'div',
4605
+ {
4606
+ 'data-cr-reset-confirm': '1',
4607
+ style: {
4608
+ display: 'flex',
4609
+ alignItems: 'center',
4610
+ gap: 8,
4611
+ flexWrap: 'wrap',
4612
+ paddingTop: 6,
4613
+ borderTop: '1px dashed var(--dsw-alias-border-l2, rgba(127,127,127,0.2))',
4614
+ },
4615
+ },
4616
+ h('span', { style: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-state-error-primary, #d92d20)' } }, '确定恢复默认?(会删除你的设置文件)'),
4617
+ h('button', { type: 'button', style: settingsButtonStyle, disabled: saving, onClick: () => void reset() }, '确定恢复'),
4618
+ h('button', { type: 'button', style: settingsButtonStyle, disabled: saving, onClick: () => setConfirmReset(false) }, '取消'),
4619
+ )
4620
+ : null,
3907
4621
  ),
3908
4622
  // 0.4 P0-2:诊断信息预览弹层(只读等宽文本 + 复制 Markdown/JSON)。
3909
4623
  // 只有用户点了「生成并预览」/「打开问题报告页」才会出现;关掉后本次会话不再显示首次说明卡。