dsh-compact-relay 0.4.0 → 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/CHANGELOG.md +39 -0
- package/README.en.md +14 -4
- package/README.md +15 -5
- package/ROADMAP.md +16 -5
- package/TUTORIAL.md +7 -5
- package/lib/actions/settings.js +20 -12
- package/lib/adapters/http.js +3 -1
- package/lib/client.js +880 -61
- package/lib/core/handoff-guard.js +219 -0
- package/package.json +3 -2
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 '—';
|
|
@@ -742,6 +961,59 @@ window.__ModuleLoader__.load({
|
|
|
742
961
|
);
|
|
743
962
|
};
|
|
744
963
|
|
|
964
|
+
/**
|
|
965
|
+
* 0.4.1:原生自动压缩线「配置阈值 vs 实际触发线」。
|
|
966
|
+
* DSH 按 token 预算触发:min(窗口×配置阈值, 窗口−输出预留−余量),
|
|
967
|
+
* 所以实际触发点常比配置比例更早(deepseek-flash 配置 80%、实际 67.85%)。
|
|
968
|
+
* autoEnabled=false 时 effective/percentText 为空(未开启),但 actualText 仍给出
|
|
969
|
+
* 「开启后实际会触发多少」,供关闭态文案使用。
|
|
970
|
+
* @param {object|null} native readState 下发的 native 字段
|
|
971
|
+
* @returns {{configured:number|null,configuredText:string|null,actual:number|null,actualText:string,effective:number|null,percentText:string,differs:boolean,autoOff:boolean,why:string}}
|
|
972
|
+
*/
|
|
973
|
+
const nativeLineInfo = (native) => {
|
|
974
|
+
const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : null);
|
|
975
|
+
const ratioOf = (value) => {
|
|
976
|
+
const n = num(value);
|
|
977
|
+
return n !== null && n > 0 && n < 1 ? n : null;
|
|
978
|
+
};
|
|
979
|
+
const src = native === null || native === undefined ? null : native;
|
|
980
|
+
const configured = ratioOf(src === null ? null : src.thresholdRatio);
|
|
981
|
+
const actual = ratioOf(src === null ? null : src.ratio);
|
|
982
|
+
const autoOff = src !== null && src.autoEnabled === false;
|
|
983
|
+
const effective = autoOff ? null : actual !== null ? actual : configured;
|
|
984
|
+
const pctText = (value) => {
|
|
985
|
+
const p = value * 100;
|
|
986
|
+
return Math.abs(p - Math.round(p)) < 0.05 ? String(Math.round(p)) : p.toFixed(1);
|
|
987
|
+
};
|
|
988
|
+
const configuredText = configured === null ? null : pctText(configured);
|
|
989
|
+
const actualText = actual === null ? '' : pctText(actual);
|
|
990
|
+
const percentText = effective === null ? '' : pctText(effective);
|
|
991
|
+
const differs = configured !== null && effective !== null && Math.abs(effective - configured) >= 0.005;
|
|
992
|
+
let why = '';
|
|
993
|
+
if (differs) {
|
|
994
|
+
const overridden = src.overridden === true;
|
|
995
|
+
const windowTokens = num(src.contextWindow);
|
|
996
|
+
const reserved = num(src.reservedCompletionTokens);
|
|
997
|
+
const headroom = num(src.headroomTokens);
|
|
998
|
+
// 只有「预算分支确实更小」时,才能把差异归因为输出预留/余量。
|
|
999
|
+
const budgetTokens = windowTokens !== null ? windowTokens - (reserved ?? 0) - (headroom ?? 0) : null;
|
|
1000
|
+
const budgetBinding = budgetTokens !== null && configured !== null && budgetTokens < windowTokens * configured;
|
|
1001
|
+
const parts = [];
|
|
1002
|
+
if (reserved !== null && reserved > 0) parts.push(`模型输出要预留 ${fmt(reserved)}`);
|
|
1003
|
+
if (headroom !== null && headroom > 0) parts.push(`DSH 自己还要留 ${fmt(headroom)} 余量`);
|
|
1004
|
+
if (overridden) {
|
|
1005
|
+
why = `这个模型单独配了阈值(按路由覆盖),实际触发是 ${actualText}%(全局配置 ${configuredText}%)。`;
|
|
1006
|
+
} else if (budgetBinding && parts.length > 0) {
|
|
1007
|
+
why = `配置阈值 ${configuredText}% 只是理论上限;${parts.join('、')},所以实际打到 ${percentText}% 就会自动压。`;
|
|
1008
|
+
} else if (actual !== null && configured !== null && actual > configured) {
|
|
1009
|
+
why = `这个路由的输出预留更小,实际触发晚于配置阈值 ${configuredText}%(实际 ${actualText}%)。`;
|
|
1010
|
+
} else {
|
|
1011
|
+
why = `实际触发 ${actualText}% 与配置阈值 ${configuredText}% 不同,按宿主 token 预算算的。`;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return { configured, configuredText, actual, actualText, effective, percentText, differs, autoOff, why };
|
|
1015
|
+
};
|
|
1016
|
+
|
|
745
1017
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
746
1018
|
|
|
747
1019
|
/**
|
|
@@ -1033,6 +1305,11 @@ window.__ModuleLoader__.load({
|
|
|
1033
1305
|
typeof data.native.headroomTokens === 'number' && Number.isFinite(data.native.headroomTokens)
|
|
1034
1306
|
? data.native.headroomTokens
|
|
1035
1307
|
: null,
|
|
1308
|
+
// 0.4.1:悬停卡解释「为什么实际触发比配置阈值早」要用到输出预留。
|
|
1309
|
+
reservedCompletionTokens:
|
|
1310
|
+
typeof data.native.reservedCompletionTokens === 'number' && Number.isFinite(data.native.reservedCompletionTokens)
|
|
1311
|
+
? data.native.reservedCompletionTokens
|
|
1312
|
+
: null,
|
|
1036
1313
|
overridden: data.native.overridden === true,
|
|
1037
1314
|
route:
|
|
1038
1315
|
data.native.route && typeof data.native.route === 'object' && typeof data.native.route.model === 'string'
|
|
@@ -1249,6 +1526,22 @@ window.__ModuleLoader__.load({
|
|
|
1249
1526
|
// 0.3.1:模型判断的「这里适合压缩」位置 → 断点1(优先于阈值);失效的 hint 由 host 过滤。
|
|
1250
1527
|
const hintRatio = state.compressHint && typeof state.compressHint.ratio === 'number' ? state.compressHint.ratio : null;
|
|
1251
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
|
+
: '';
|
|
1252
1545
|
// DSH 原生自动压缩线(报警最高阈值):开着时在环上画一个「径向刻度」(不是沿环的弧),
|
|
1253
1546
|
// 用紫色 + 主题底色描边——刻意不用五级配色里的颜色,保证在任何分段颜色上都看得清。
|
|
1254
1547
|
// 越过原生线后:整环红色闪烁(作者 2026-10-20 反馈)。
|
|
@@ -1316,6 +1609,39 @@ window.__ModuleLoader__.load({
|
|
|
1316
1609
|
}),
|
|
1317
1610
|
);
|
|
1318
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
|
+
}
|
|
1319
1645
|
// 平时给中心一点可见的灰点;交接时红点(外环已分段,红点强调该交接)。
|
|
1320
1646
|
const dotColor = adviseHandoff ? palette.handoff : palette.idle;
|
|
1321
1647
|
// 锁定消息在环上的块(方案 A:按消息在历史里的相对位置)。这是「保护内容」的信号,
|
|
@@ -1430,6 +1756,29 @@ window.__ModuleLoader__.load({
|
|
|
1430
1756
|
return () => window.removeEventListener('compact-relay:replay-onboarding', onReplay);
|
|
1431
1757
|
}, [playOnboarding]);
|
|
1432
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
|
+
|
|
1433
1782
|
// 引导光晕:默认 4 次呼吸后自动停(手动重播时按传入时长)。
|
|
1434
1783
|
useEffect(() => {
|
|
1435
1784
|
if (!onboarding) return undefined;
|
|
@@ -1764,11 +2113,12 @@ window.__ModuleLoader__.load({
|
|
|
1764
2113
|
state.native.route !== null ? `,${state.native.route.provider}/${state.native.route.model}` : ''
|
|
1765
2114
|
}${state.native.overridden ? ',按路由覆盖' : ''})`
|
|
1766
2115
|
: '';
|
|
2116
|
+
const nativeInfo = nativeLineInfo(state.native);
|
|
1767
2117
|
const nativeLineText =
|
|
1768
2118
|
nativeEffective !== null
|
|
1769
2119
|
? `原生自动压缩线 ${(nativeEffective * 100).toFixed(1)}%${nativeDetail}${
|
|
1770
|
-
|
|
1771
|
-
}`
|
|
2120
|
+
nativeInfo.differs ? `;${nativeInfo.why}` : ''
|
|
2121
|
+
}${ratio > nativeEffective ? '(已越过:整环红色闪烁)' : ''}`
|
|
1772
2122
|
: 'DSH 原生自动压缩已关闭';
|
|
1773
2123
|
const pressureKnown = pressureValue !== null;
|
|
1774
2124
|
const title =
|
|
@@ -1778,6 +2128,7 @@ window.__ModuleLoader__.load({
|
|
|
1778
2128
|
`,已压缩 ${state.summaries} 次|${adviceText}` +
|
|
1779
2129
|
(compressHintText !== '' ? `|${compressHintText}` : '') +
|
|
1780
2130
|
(nativeLineText !== '' ? `|${nativeLineText}` : '') +
|
|
2131
|
+
(handoffWarnText !== '' ? `|${handoffWarnText}` : '') +
|
|
1781
2132
|
(pins.count > 0 ? `|已锁定 ${pins.count} 条` : '') +
|
|
1782
2133
|
(effectiveBusy ? '|处理中…' : '');
|
|
1783
2134
|
|
|
@@ -1837,6 +2188,10 @@ window.__ModuleLoader__.load({
|
|
|
1837
2188
|
{ style: { color: theme.subText } },
|
|
1838
2189
|
`已压缩 ${state.summaries} 次 · ${adviceText}${compressHintText !== '' ? ` · ${compressHintText}` : ''}${nativeLineText !== '' ? ` · ${nativeLineText}` : ''}${pins.count > 0 ? ` · 已锁定 ${pins.count} 条` : ''}${effectiveBusy ? ' · 处理中…' : ''}`,
|
|
1839
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,
|
|
1840
2195
|
)
|
|
1841
2196
|
: null;
|
|
1842
2197
|
|
|
@@ -1859,6 +2214,8 @@ window.__ModuleLoader__.load({
|
|
|
1859
2214
|
}),
|
|
1860
2215
|
// 0.3.1:DSH 原生自动压缩线(刻度/危险区)。
|
|
1861
2216
|
...nativeNodes,
|
|
2217
|
+
// 0.4.2:交接线已晚的警示标记(交接刻度旁的小短线 + 小点,不改分段逻辑)。
|
|
2218
|
+
...handoffWarnNodes,
|
|
1862
2219
|
// 0.3.1:锁定消息在环上的块(品红,按历史位置)。
|
|
1863
2220
|
...pinBlocks,
|
|
1864
2221
|
);
|
|
@@ -2074,6 +2431,8 @@ window.__ModuleLoader__.load({
|
|
|
2074
2431
|
onClick: () => {
|
|
2075
2432
|
void startHandoffPreview();
|
|
2076
2433
|
},
|
|
2434
|
+
// 0.4.2(P1):交接线晚于原生压缩时,菜单项旁直接标出来(只告警,仍可点)。
|
|
2435
|
+
...(handoffLate ? { note: handoffWarnText, title: handoffGuardInfo.message } : {}),
|
|
2077
2436
|
}
|
|
2078
2437
|
: { disabled: true, note: '会话刚打开,请稍候' },
|
|
2079
2438
|
),
|
|
@@ -2926,6 +3285,43 @@ window.__ModuleLoader__.load({
|
|
|
2926
3285
|
cursor: 'pointer',
|
|
2927
3286
|
};
|
|
2928
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
|
+
|
|
2929
3325
|
/** 不可变地设置对象里的某个路径。 */
|
|
2930
3326
|
function setIn(object, path, value) {
|
|
2931
3327
|
if (path.length === 1) return { ...object, [path[0]]: value };
|
|
@@ -3011,6 +3407,21 @@ window.__ModuleLoader__.load({
|
|
|
3011
3407
|
/** 0.4 P1-3:轻账本累计数字(独立账本文件算出来的;null = 没有数据/读不到,显示引导文案)。 */
|
|
3012
3408
|
const [ledger, setLedger] = useState(null);
|
|
3013
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({});
|
|
3014
3425
|
const [nativeBusy, setNativeBusy] = useState(false);
|
|
3015
3426
|
/** UI-003:结果提示就地显示在开关旁(以前只写页面底部,用户看不到)。 */
|
|
3016
3427
|
const [nativeStatus, setNativeStatus] = useState(null);
|
|
@@ -3044,22 +3455,53 @@ window.__ModuleLoader__.load({
|
|
|
3044
3455
|
[],
|
|
3045
3456
|
);
|
|
3046
3457
|
|
|
3458
|
+
/** 0.4.1:设置接口只给「配置阈值」,实际触发线要从 state 接口补(同一套 readState 数据)。 */
|
|
3459
|
+
const nativeLineExtra = useRef(null);
|
|
3460
|
+
const mergeNative = useCallback((view) => {
|
|
3461
|
+
if (view === null || view === undefined) return null;
|
|
3462
|
+
const extra = nativeLineExtra.current;
|
|
3463
|
+
return extra === null ? view : { ...view, ...extra };
|
|
3464
|
+
}, []);
|
|
3465
|
+
|
|
3047
3466
|
/** 把服务端返回的 view 落到表单:同时更新「已保存」快照和设置文件错误。 */
|
|
3048
3467
|
const applyLoaded = useCallback((data) => {
|
|
3049
3468
|
setForm(data.config);
|
|
3050
3469
|
setSavedSnapshot(JSON.stringify(data.config));
|
|
3470
|
+
// 服务端回来的配置是新的权威值:丢掉输入草稿,输入框重新按表单派生(加载/保存成功都走这里)。
|
|
3471
|
+
setPercentDrafts({});
|
|
3051
3472
|
setSettingsError(data.settingsError ?? null);
|
|
3052
|
-
setNative(data.native && typeof data.native === 'object' ? data.native : null);
|
|
3473
|
+
setNative(mergeNative(data.native && typeof data.native === 'object' ? data.native : null));
|
|
3053
3474
|
if (typeof data.version === 'string') setVersion(data.version);
|
|
3054
3475
|
if (typeof data.path === 'string') setSettingsPath(data.path);
|
|
3055
3476
|
setLedger(data.ledger !== null && typeof data.ledger === 'object' ? data.ledger : null);
|
|
3056
|
-
}, []);
|
|
3477
|
+
}, [mergeNative]);
|
|
3057
3478
|
|
|
3058
3479
|
const load = useCallback(async () => {
|
|
3059
3480
|
try {
|
|
3060
|
-
const
|
|
3061
|
-
|
|
3062
|
-
|
|
3481
|
+
const [settingsRes, stateRes] = await Promise.all([
|
|
3482
|
+
fetch('/api/compact-relay-settings', { cache: 'no-store' }),
|
|
3483
|
+
fetch('/api/compact-relay-state', { cache: 'no-store' }).catch(() => null),
|
|
3484
|
+
]);
|
|
3485
|
+
const data = await settingsRes.json().catch(() => ({}));
|
|
3486
|
+
if (!settingsRes.ok || data.ok === false) throw new Error(data && data.message ? String(data.message) : '读取设置失败');
|
|
3487
|
+
// 0.4.1:补上实际触发线相关字段(配置接口没有这些,只有 state 接口有)。
|
|
3488
|
+
let extra = null;
|
|
3489
|
+
if (stateRes !== null && stateRes.ok) {
|
|
3490
|
+
const stateData = await stateRes.json().catch(() => ({}));
|
|
3491
|
+
const sn = stateData !== null && stateData.native !== null && typeof stateData.native === 'object' ? stateData.native : null;
|
|
3492
|
+
if (sn !== null) {
|
|
3493
|
+
extra = {
|
|
3494
|
+
ratio: typeof sn.ratio === 'number' ? sn.ratio : null,
|
|
3495
|
+
thresholdTokens: typeof sn.thresholdTokens === 'number' ? sn.thresholdTokens : null,
|
|
3496
|
+
contextWindow: typeof sn.contextWindow === 'number' ? sn.contextWindow : null,
|
|
3497
|
+
reservedCompletionTokens: typeof sn.reservedCompletionTokens === 'number' ? sn.reservedCompletionTokens : null,
|
|
3498
|
+
headroomTokens: typeof sn.headroomTokens === 'number' ? sn.headroomTokens : null,
|
|
3499
|
+
overridden: sn.overridden === true,
|
|
3500
|
+
route: sn.route !== null && typeof sn.route === 'object' ? sn.route : null,
|
|
3501
|
+
};
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
nativeLineExtra.current = extra;
|
|
3063
3505
|
applyLoaded(data);
|
|
3064
3506
|
setStatus(null);
|
|
3065
3507
|
} catch (error) {
|
|
@@ -3074,32 +3516,122 @@ window.__ModuleLoader__.load({
|
|
|
3074
3516
|
const patch = (path, value) => {
|
|
3075
3517
|
setForm((prev) => (prev === null ? prev : setIn(prev, path, value)));
|
|
3076
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);
|
|
3077
3591
|
};
|
|
3078
3592
|
|
|
3079
3593
|
/** 表单与已保存快照不一致 → 有未保存修改。 */
|
|
3080
3594
|
const dirty = form !== null && savedSnapshot !== null && JSON.stringify(form) !== savedSnapshot;
|
|
3081
3595
|
|
|
3082
|
-
|
|
3596
|
+
/**
|
|
3597
|
+
* 保存设置。0.4.2:body 带上 `nativeRatio`(当前模型实际触发线),
|
|
3598
|
+
* 服务端据此按「实际线 −5pp」同口径校验交接线;算不出/原生关闭时不带(服务端兜底)。
|
|
3599
|
+
* @param {object} [override] 「按实际线重算」用:一次性覆盖表单(不依赖 setState 的异步生效)
|
|
3600
|
+
*/
|
|
3601
|
+
const save = useCallback(async (override) => {
|
|
3083
3602
|
if (form === null || saving) return;
|
|
3603
|
+
const payload = override !== undefined && override !== null ? override : form;
|
|
3084
3604
|
setSaving(true);
|
|
3085
3605
|
setStatus(null);
|
|
3086
3606
|
setConfirmReset(false);
|
|
3087
3607
|
try {
|
|
3608
|
+
const info = nativeLineInfo(native);
|
|
3609
|
+
const nativeRatio = native !== null && native.autoEnabled !== false && info.actual !== null ? info.actual : null;
|
|
3088
3610
|
const res = await fetch('/api/compact-relay-settings', {
|
|
3089
3611
|
method: 'POST',
|
|
3090
3612
|
headers: { 'content-type': 'application/json' },
|
|
3091
|
-
body: JSON.stringify(
|
|
3613
|
+
body: JSON.stringify(nativeRatio === null ? payload : { ...payload, nativeRatio }),
|
|
3092
3614
|
});
|
|
3093
3615
|
const data = await res.json().catch(() => ({}));
|
|
3094
|
-
if (!res.ok || data.ok === false)
|
|
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
|
+
}
|
|
3095
3623
|
applyLoaded(data);
|
|
3096
3624
|
setStatus({ text: '已保存(立即生效,不需要重启)' });
|
|
3097
3625
|
} catch (error) {
|
|
3098
|
-
setStatus({
|
|
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
|
+
});
|
|
3099
3631
|
} finally {
|
|
3100
3632
|
setSaving(false);
|
|
3101
3633
|
}
|
|
3102
|
-
}, [form, saving, applyLoaded]);
|
|
3634
|
+
}, [form, saving, applyLoaded, native]);
|
|
3103
3635
|
|
|
3104
3636
|
/** 恢复默认 = 删除用户设置文件;先二次确认,避免误点丢掉配色。 */
|
|
3105
3637
|
const reset = useCallback(async () => {
|
|
@@ -3124,6 +3656,19 @@ window.__ModuleLoader__.load({
|
|
|
3124
3656
|
}
|
|
3125
3657
|
}, [saving, applyLoaded]);
|
|
3126
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
|
+
|
|
3127
3672
|
/** 0.3.1:关闭/恢复 DSH 原生自动压缩(写 profile 配置,备份后重启生效)。 */
|
|
3128
3673
|
const applyNative = useCallback(
|
|
3129
3674
|
async (enabled) => {
|
|
@@ -3238,7 +3783,6 @@ window.__ModuleLoader__.load({
|
|
|
3238
3783
|
|
|
3239
3784
|
const percent = (ratio) => Math.round(ratio * 100);
|
|
3240
3785
|
const compactPercent = percent(form.thresholds.compactRatio);
|
|
3241
|
-
const handoffPercent = percent(form.thresholds.handoffRatio);
|
|
3242
3786
|
const count = (value) => (Number.isFinite(value) ? value : 0);
|
|
3243
3787
|
// 0.4 P1-3:轻账本一行累计数字(只读;数据来自宿主账本,与账本文件同源)。
|
|
3244
3788
|
const ledgerLine = (() => {
|
|
@@ -3250,7 +3794,45 @@ window.__ModuleLoader__.load({
|
|
|
3250
3794
|
})();
|
|
3251
3795
|
|
|
3252
3796
|
const autoOnNative = native !== null && native.autoEnabled !== false;
|
|
3253
|
-
|
|
3797
|
+
// 0.4.1:配置阈值(thresholdRatio)只是理论上限;实际触发线由宿主按 token 预算算。
|
|
3798
|
+
const nativeInfo = nativeLineInfo(native);
|
|
3799
|
+
const nativePercent = nativeInfo.configured !== null ? Math.round(nativeInfo.configured * 100) : 80;
|
|
3800
|
+
// 原生压缩已关闭时 effective/percentText 为空:回落到「开启后实际会触发多少」,再回落配置阈值。
|
|
3801
|
+
const nativeActualText = nativeInfo.percentText !== '' ? nativeInfo.percentText : nativeInfo.actualText !== '' ? nativeInfo.actualText : nativeInfo.configuredText !== null ? nativeInfo.configuredText : String(nativePercent);
|
|
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
|
+
: '';
|
|
3254
3836
|
const previewLight = paletteForScheme(form.colors, 'light');
|
|
3255
3837
|
const previewDark = paletteForScheme(form.colors, 'dark');
|
|
3256
3838
|
// 0.4 P0-2:诊断预览弹层复用指示灯那套浮层材质(15-shared.panelChrome)。
|
|
@@ -3279,7 +3861,7 @@ window.__ModuleLoader__.load({
|
|
|
3279
3861
|
|
|
3280
3862
|
return h(
|
|
3281
3863
|
'div',
|
|
3282
|
-
{ style: { display: 'flex', flexDirection: 'column', maxWidth: 680, padding: '4px 0
|
|
3864
|
+
{ style: { display: 'flex', flexDirection: 'column', maxWidth: 680, padding: '4px 0 8px' } },
|
|
3283
3865
|
h('div', { style: { fontSize: 14, fontWeight: 600, marginBottom: 4 } }, '压缩接力'),
|
|
3284
3866
|
h(
|
|
3285
3867
|
'p',
|
|
@@ -3300,8 +3882,19 @@ window.__ModuleLoader__.load({
|
|
|
3300
3882
|
'div',
|
|
3301
3883
|
{ style: { display: 'flex', alignItems: 'center', gap: 8 } },
|
|
3302
3884
|
h('input', {
|
|
3303
|
-
type: 'number',
|
|
3304
|
-
|
|
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'),
|
|
3305
3898
|
}),
|
|
3306
3899
|
h('span', { style: settingsHintStyle }, '% 窗口'),
|
|
3307
3900
|
),
|
|
@@ -3310,10 +3903,20 @@ window.__ModuleLoader__.load({
|
|
|
3310
3903
|
'压缩提醒线',
|
|
3311
3904
|
'指示灯到这条线开始变琥珀色,菜单也会建议「压缩旧会话」。',
|
|
3312
3905
|
'调高 → 更晚提醒、压得更少但上下文更长;调低 → 更早提醒、压得更勤。',
|
|
3313
|
-
h('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
|
|
3906
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
|
|
3314
3907
|
h('input', {
|
|
3315
|
-
type: 'number',
|
|
3316
|
-
|
|
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'),
|
|
3317
3920
|
}),
|
|
3318
3921
|
h('span', { style: settingsHintStyle }, '% 窗口'),
|
|
3319
3922
|
h('span', { style: settingsHintStyle }, `(1M 窗口 ≈ ${Math.round(1000000 * form.thresholds.compactRatio / 1000)}K)`),
|
|
@@ -3323,25 +3926,125 @@ window.__ModuleLoader__.load({
|
|
|
3323
3926
|
'交接提醒线',
|
|
3324
3927
|
'到这条线内圆亮红点,菜单建议「交接新会话」。',
|
|
3325
3928
|
'调高 → 更晚换会话、单会话更久;调低 → 更早换会话、单会话更短。必须大于压缩线。',
|
|
3326
|
-
h(
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
'
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
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,
|
|
3343
3997
|
),
|
|
3344
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
|
+
),
|
|
3345
4048
|
),
|
|
3346
4049
|
sectionHeading('压缩行为'),
|
|
3347
4050
|
h(
|
|
@@ -3387,10 +4090,10 @@ window.__ModuleLoader__.load({
|
|
|
3387
4090
|
native === null
|
|
3388
4091
|
? '正在读取 DSH 配置…'
|
|
3389
4092
|
: autoOnNative
|
|
3390
|
-
? `当前开启:容量到 ${
|
|
4093
|
+
? `当前开启:容量到 ${nativeActualText}% 时,DSH 会自己把历史压成摘要(不读本插件的锁定)。`
|
|
3391
4094
|
: '当前已关闭:DSH 不再自动压历史,只有你主动压缩/交接才会动历史。',
|
|
3392
4095
|
autoOnNative
|
|
3393
|
-
? `开启时:容量到 ${
|
|
4096
|
+
? `开启时:容量到 ${nativeActualText}% DSH 会自己压历史(不读锁定);关闭后:只在你主动压缩/交接时才动历史。${nativeInfo.differs ? `(${nativeInfo.why})` : ''}`
|
|
3394
4097
|
: '开启后:DSH 会在容量到原生阈值时自动压历史,可能压掉锁定的消息;本插件的提醒照常。',
|
|
3395
4098
|
h(
|
|
3396
4099
|
'div',
|
|
@@ -3437,7 +4140,13 @@ window.__ModuleLoader__.load({
|
|
|
3437
4140
|
}),
|
|
3438
4141
|
),
|
|
3439
4142
|
h('span', { style: { fontSize: 12, fontWeight: 600, minWidth: 44 } }, native === null ? '读取中…' : nativeBusy ? '处理中…' : autoOnNative ? '已开启' : '已关闭'),
|
|
3440
|
-
h('span', { style: settingsHintStyle }, native !== null
|
|
4143
|
+
h('span', { style: settingsHintStyle }, native !== null
|
|
4144
|
+
? (nativeInfo.autoOff
|
|
4145
|
+
? (nativeInfo.actualText !== ''
|
|
4146
|
+
? `原生阈值 配置 ${nativeInfo.configuredText ?? nativePercent}%(已关闭;开启后实际触发 ${nativeInfo.actualText}%)`
|
|
4147
|
+
: `原生阈值 配置 ${nativeInfo.configuredText ?? nativePercent}%(已关闭)`)
|
|
4148
|
+
: (nativeInfo.differs ? `原生阈值 配置 ${nativeInfo.configuredText}%(实际触发 ${nativeInfo.percentText}%)` : `原生阈值 ${nativeActualText}%`))
|
|
4149
|
+
: ''),
|
|
3441
4150
|
nativeStatus === null
|
|
3442
4151
|
? null
|
|
3443
4152
|
: h(
|
|
@@ -3474,9 +4183,9 @@ window.__ModuleLoader__.load({
|
|
|
3474
4183
|
{ style: { ...settingsHintStyle, marginBottom: 8 } },
|
|
3475
4184
|
confirmNative === 'disable'
|
|
3476
4185
|
? `操作内容:在 DSH profile 的 cordis.patch.yml 里把 compaction-basic 的 auto 改为 false(写前备份原文件,可回滚)。` +
|
|
3477
|
-
`改变:DSH 不再在容量到 ${
|
|
4186
|
+
`改变:DSH 不再在容量到 ${nativeActualText}% 时自动压缩历史;锁定的消息不会被压掉;本插件的提醒和手动压缩照常。需要重启 DSH 生效。`
|
|
3478
4187
|
: `操作内容:在 DSH profile 的 cordis.patch.yml 里把 compaction-basic 的 auto 改回 true(写前备份,可回滚)。` +
|
|
3479
|
-
`改变:DSH 重新在容量到 ${
|
|
4188
|
+
`改变:DSH 重新在容量到 ${nativeActualText}% 时自动压历史,可能压掉锁定的消息。需要重启 DSH 生效。`,
|
|
3480
4189
|
),
|
|
3481
4190
|
h(
|
|
3482
4191
|
'div',
|
|
@@ -3583,12 +4292,6 @@ window.__ModuleLoader__.load({
|
|
|
3583
4292
|
'换成更醒目的反差色 → 锁定位置一眼可见;换成接近默认色 → 更安静(但可能看不清标记)。',
|
|
3584
4293
|
colorField(form.colors.base.pin, (value) => patch(['colors', 'base', 'pin'], value), '锁定颜色'),
|
|
3585
4294
|
),
|
|
3586
|
-
h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, marginTop: 16, flexWrap: 'wrap' } },
|
|
3587
|
-
h('button', { type: 'button', style: { ...settingsButtonStyle, background: 'var(--dsw-alias-button-primary, #2563eb)', color: '#fff', border: 'none' }, disabled: saving, onClick: () => void save() }, saving ? '保存中…' : dirty ? '保存修改' : '保存'),
|
|
3588
|
-
dirty ? h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-state-warning-primary, #b45309)' } }, '有未保存的修改') : null,
|
|
3589
|
-
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,
|
|
3590
|
-
h('span', { style: settingsHintStyle }, '改完点这里保存;恢复默认在页面最底部。'),
|
|
3591
|
-
),
|
|
3592
4295
|
),
|
|
3593
4296
|
sectionHeading('帮助'),
|
|
3594
4297
|
h(
|
|
@@ -3664,13 +4367,15 @@ window.__ModuleLoader__.load({
|
|
|
3664
4367
|
{ ratio: compactRatio + 0.08, hint: null, title: '进入压缩区', text: '琥珀:建议压缩旧会话' },
|
|
3665
4368
|
{ ratio: handoffRatio + 0.15, hint: null, title: '越过交接线', text: '红:建议交接新会话' },
|
|
3666
4369
|
];
|
|
3667
|
-
if (native !== null && native.autoEnabled !== false) {
|
|
4370
|
+
if (native !== null && native.autoEnabled !== false && nativeInfo.effective !== null) {
|
|
3668
4371
|
legend.push({
|
|
3669
|
-
ratio:
|
|
4372
|
+
ratio: Math.min(0.95, nativeInfo.effective + 0.06),
|
|
3670
4373
|
hint: null,
|
|
3671
|
-
nativeAt:
|
|
4374
|
+
nativeAt: nativeInfo.effective,
|
|
3672
4375
|
title: '原生自动压缩线',
|
|
3673
|
-
text:
|
|
4376
|
+
text: nativeInfo.differs
|
|
4377
|
+
? `实际 ${nativeInfo.percentText}%:DSH 会自己压历史(配置阈值 ${nativeInfo.configuredText}%,越过整环红色闪烁)`
|
|
4378
|
+
: `${nativeInfo.percentText}%:DSH 会自己压历史(越过整环红色闪烁)`,
|
|
3674
4379
|
});
|
|
3675
4380
|
}
|
|
3676
4381
|
return h(
|
|
@@ -3789,16 +4494,130 @@ window.__ModuleLoader__.load({
|
|
|
3789
4494
|
h('p', { style: settingsHintStyle }, '联系方式:cfanzx@136.com(合作、安全问题,以及不便公开讨论的事项)。'),
|
|
3790
4495
|
h('p', { style: settingsHintStyle }, 'npm 包页面:https://www.npmjs.com/package/dsh-compact-relay'),
|
|
3791
4496
|
h('p', { style: settingsHintStyle }, '致谢:DeepSeek Harness;设置栏实现参考了 dsh-plugin-memory;也谢谢帮忙测试的朋友们。'),
|
|
3792
|
-
h(
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
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,
|
|
3801
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,
|
|
3802
4621
|
),
|
|
3803
4622
|
// 0.4 P0-2:诊断信息预览弹层(只读等宽文本 + 复制 Markdown/JSON)。
|
|
3804
4623
|
// 只有用户点了「生成并预览」/「打开问题报告页」才会出现;关掉后本次会话不再显示首次说明卡。
|