@falling-ts/dsh-force-compact 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.cn.md +170 -0
- package/README.md +479 -0
- package/cordis.patch.yml +13 -0
- package/index.js +550 -0
- package/package.json +54 -0
- package/src/core/crashnet.js +215 -0
- package/src/core/log.js +346 -0
- package/src/core/pairing.js +188 -0
- package/src/core/policy.js +35 -0
- package/src/core/projected.js +139 -0
- package/src/core/settings.js +475 -0
- package/src/core/ui-signal.js +271 -0
- package/src/engine/backend.js +143 -0
- package/src/engine/builtin.js +1336 -0
- package/src/engine/checkpoint.js +206 -0
- package/src/engine/region.js +579 -0
- package/src/engine/summarizer.js +818 -0
- package/src/hooks/command.js +164 -0
- package/src/hooks/guard.js +706 -0
- package/src/hooks/idle.js +136 -0
- package/src/hooks/wire-rewrite.js +151 -0
- package/web/client.js +807 -0
- package/web/swish.css +188 -0
package/web/client.js
ADDED
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* force-compact 设置分区的浏览器半部(settings.section)。
|
|
3
|
+
*
|
|
4
|
+
* 这是一个闭包工厂 artifact:调用 window.__ModuleLoader__.load({ id, factory }),
|
|
5
|
+
* factory(require) 通过注入的 require 解析外部模块(这里只有基线 react),并返回
|
|
6
|
+
* 插件面 { name, inject, apply }。宿主半部(根 index.js)与本文件是同一 package
|
|
7
|
+
* 的两个面:宿主半部由 main 入口加载,本文件由 exports["./client"] 导出,经
|
|
8
|
+
* dsh.client 声明被 client module 系统自动组成并服务(/plugins/<id>/client.js)。
|
|
9
|
+
*
|
|
10
|
+
* 该分区通过 settingsScope 读写宿主侧 falling-ts-force-compact 设置命名空间
|
|
11
|
+
* (disableThinking / autoThresholdTokens / retainLatestTokens /
|
|
12
|
+
* turnEndForceCompactionEnabled),并在设置页左侧菜单注册 "强制压缩" 分区。
|
|
13
|
+
* 纯展示 + 写回,不引入 timer、内存态存储或额外订阅。
|
|
14
|
+
*/
|
|
15
|
+
window.__ModuleLoader__.load({
|
|
16
|
+
id: "@falling-ts/dsh-force-compact",
|
|
17
|
+
factory: (require) => {
|
|
18
|
+
var module = { exports: {} };
|
|
19
|
+
var exports = module.exports;
|
|
20
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
21
|
+
const React = require("react");
|
|
22
|
+
const h = React.createElement;
|
|
23
|
+
// 基线外部(web 平台预载):把 settingsScope 镜像成 uSES 安全的 SnapshotStore。
|
|
24
|
+
const { createSnapshotStore } = require("@deepseek-ai/dsh-client-runtime/client");
|
|
25
|
+
|
|
26
|
+
/** 该分区拥有的文案命名空间。 */
|
|
27
|
+
const NS = "settings.forceCompact";
|
|
28
|
+
/** 宿主侧 force-compact 设置命名空间(settings.get 读取的键)。 */
|
|
29
|
+
const NS_SETTINGS = "falling-ts-force-compact";
|
|
30
|
+
|
|
31
|
+
const zh = {
|
|
32
|
+
nav: "强制压缩",
|
|
33
|
+
intro: "控制 force-compact 插件的压缩行为(强制压缩配置)。改动在 $DSH_HOME/settings.yaml 的 falling-ts-force-compact 段生效。",
|
|
34
|
+
disableThinking: "压缩时关闭思考",
|
|
35
|
+
disableThinkingHint: "为 true 时每次模型请求携带 reasoningEffort: off,关闭思考以节省 token。",
|
|
36
|
+
autoThresholdTokens: "自动压缩阈值(tokens)",
|
|
37
|
+
autoThresholdTokensHint: "会话总上下文 tokens ≥ 该值时,agent/pre-step 阈值门禁触发强制压缩。最小 32000;若填低于此值会自动重置为 32000。",
|
|
38
|
+
retainLatestTokens: "保留最新上下文(tokens)",
|
|
39
|
+
retainLatestTokensHint: "自动/强制压缩时,从会话最新条目往前累加 token(按官方 tokenMeter 逐节点计数),直到 ≥ 该值停止;该截点之前的所有条目一次性发往大模型做摘要(原条目被遮蔽/跳过),保留的尾部逐字保留。默认 8000;最小 8000,若填低于此值会自动重置为 8000。",
|
|
40
|
+
turnEndForceCompaction: "回合结束强制压缩",
|
|
41
|
+
turnEndForceCompactionHint: "为 true 时,agent 转入 idle(一轮结束)时执行一轮结束压缩。",
|
|
42
|
+
debug: "详细日志(debug)",
|
|
43
|
+
debugHint: "为 true 时,每次模型请求/步骤的关键观察行都会写入 logFile(默认 ~/.dsh/logs/dsh-force-compact.log)。生产环境可设为 false 减少噪音。",
|
|
44
|
+
logFile: "日志文件路径",
|
|
45
|
+
logFileHint: "详细日志的目标文件路径(leading ~ 展开为用户家目录)。修改后下次启动生效。",
|
|
46
|
+
logFilePlaceholder: "~/.dsh/logs/dsh-force-compact.log",
|
|
47
|
+
compactionMode: "压缩服务解析模式",
|
|
48
|
+
compactionModeHint: "realm:优先从当前 Agent 域查找 compaction 服务,再回落全局。global:直接使用全局 compaction 服务(需后端已挂到 root realm)。",
|
|
49
|
+
modeRealm: "realm(域优先)",
|
|
50
|
+
modeGlobal: "global(全局)",
|
|
51
|
+
builtinEnabled: "内置压缩引擎",
|
|
52
|
+
builtinEnabledHint: "官方 compaction 服务不可达时(例如标准 preset 将其隔离进 isolate 组),启用插件自研的内置压缩引擎作为后备。默认开启。设为 false 严格只走官方。",
|
|
53
|
+
maxSummaryTokens: "最大摘要数(tokens)",
|
|
54
|
+
maxSummaryTokensHint: "插件自身摘要 LLM 调用的 maxTokens 上限(默认 1024,1024–200000),防止摘要长度失控;收缩门禁另行保证提交的摘要比被遮蔽区间小。最小 1024,若填低于此值会自动重置为 1024。",
|
|
55
|
+
unavailable: "设置不可用",
|
|
56
|
+
loading: "加载中…",
|
|
57
|
+
notWritable: "(当前为只读/内存模式,改动仅本进程生效)",
|
|
58
|
+
onWord: "开",
|
|
59
|
+
offWord: "关",
|
|
60
|
+
};
|
|
61
|
+
const en = {
|
|
62
|
+
nav: "Force Compact",
|
|
63
|
+
intro: "Control how the force-compact plugin compacts. Changes land under the falling-ts-force-compact section of $DSH_HOME/settings.yaml.",
|
|
64
|
+
disableThinking: "Disable thinking during compaction",
|
|
65
|
+
disableThinkingHint: "When true, every model request carries reasoningEffort: off to save tokens.",
|
|
66
|
+
autoThresholdTokens: "Auto-compaction threshold (tokens)",
|
|
67
|
+
autoThresholdTokensHint: "When the session's total context tokens ≥ this value, the agent/pre-step threshold gate force-compacts. Minimum 32000; values below are clamped back to 32000.",
|
|
68
|
+
retainLatestTokens: "Retain latest context (tokens)",
|
|
69
|
+
retainLatestTokensHint: "When auto/forced compaction fires, walk backward from the LATEST surface entry accumulating per-node tokens (the official tokenMeter's prices) until the running sum REACHES OR EXCEEDS this budget; everything before that cutoff is sent to the summarizer in ONE batch (its entries become shadowed/skipped in derived history), and the retained tail stays VERBATIM. Default 8000; minimum 8000 — values below are clamped back to 8000.",
|
|
70
|
+
turnEndForceCompaction: "Force-compaction at turn end",
|
|
71
|
+
turnEndForceCompactionHint: "When true, run a turn-end compaction when the agent becomes idle.",
|
|
72
|
+
debug: "Verbose logging (debug)",
|
|
73
|
+
debugHint: "When true, per-request/step observation lines are appended to logFile (default ~/.dsh/logs/dsh-force-compact.log). Turn off in production to reduce noise.",
|
|
74
|
+
logFile: "Log file path",
|
|
75
|
+
logFileHint: "Destination for verbose logs (leading ~ expands to the user home). Takes effect on the next restart.",
|
|
76
|
+
logFilePlaceholder: "~/.dsh/logs/dsh-force-compact.log",
|
|
77
|
+
compactionMode: "Compaction-service resolution mode",
|
|
78
|
+
compactionModeHint: "realm: locate the compaction service in the current Agent's realm first, falling back to global. global: use the global compaction service directly (requires the backend to be mounted at the root realm).",
|
|
79
|
+
modeRealm: "realm (realm-first)",
|
|
80
|
+
modeGlobal: "global (global)",
|
|
81
|
+
builtinEnabled: "Built-in compaction engine",
|
|
82
|
+
builtinEnabledHint: "Fallback to this plugin's own self-contained engine when the official compaction service is unreachable (e.g. standard-preset realm isolation). Defaults on. Set false to strictly use only the official backend.",
|
|
83
|
+
maxSummaryTokens: "Max summary size (tokens)",
|
|
84
|
+
maxSummaryTokensHint: "maxTokens ceiling on the plugin's own summarization LLM call (default 1024, range 1024–200000). Prevents runaway summaries; the shrink gate separately guarantees the committed summary is smaller than the span it replaces. Minimum 1024 — values below are clamped back to 1024.",
|
|
85
|
+
unavailable: "Settings unavailable",
|
|
86
|
+
loading: "Loading…",
|
|
87
|
+
notWritable: "(read-only / memory mode; changes are process-local)",
|
|
88
|
+
onWord: "on",
|
|
89
|
+
offWord: "off",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** 必需服务(cordis fiber inject)。settingsScope 由 ui-settings 提供。 */
|
|
93
|
+
const inject = ["slots", "locale", "settingsScope"];
|
|
94
|
+
|
|
95
|
+
// ── 视觉设计 ----------------------------------------------------------------
|
|
96
|
+
// 参照通用设置分区(如「语言」)的排版:扁平、无背景色、行间细分隔线、
|
|
97
|
+
// 紧凑纵向节奏;三栏网格 label(定宽一列)· control(右对齐一列)· hint,
|
|
98
|
+
// label 与 hint 同列同字体纵向对齐,control 靠右,数值输入框等宽中性描边。
|
|
99
|
+
const divider = "rgba(0,0,0,0.08)";
|
|
100
|
+
const hintColor = "rgba(0,0,0,0.45)";
|
|
101
|
+
const mutedColor = "rgba(0,0,0,0.55)";
|
|
102
|
+
const gridCols = "172px 140px minmax(0,1fr)";
|
|
103
|
+
const wrapStyle = { padding: "4px 0" };
|
|
104
|
+
const titleStyle = { margin: "2px 0 2px", fontSize: 15, lineHeight: 1.4 };
|
|
105
|
+
const introStyle = { margin: "0 0 6px", color: hintColor, lineHeight: 1.65, fontSize: 13, maxWidth: 680 };
|
|
106
|
+
const rowStyle = { display: "grid", gridTemplateColumns: gridCols, columnGap: 16, rowGap: 5, padding: "13px 0", borderBottom: "1px solid " + divider, alignItems: "center" };
|
|
107
|
+
const lastRowStyle = { ...rowStyle, borderBottom: "none" };
|
|
108
|
+
const labelStyle = { fontSize: 13.5, fontWeight: 500, lineHeight: 1.35 };
|
|
109
|
+
const controlStyle = { display: "flex", justifyContent: "flex-end", alignItems: "center" };
|
|
110
|
+
const hintStyle = { gridColumn: "1 / 3", gridRow: 3, color: hintColor, fontSize: 12, lineHeight: 1.55 };
|
|
111
|
+
const inputStyle = { width: 128, textAlign: "right", padding: "5px 10px", boxSizing: "border-box", border: "1px solid rgba(0,0,0,0.22)", borderRadius: 6, fontVariantNumeric: "tabular-nums", backgroundColor: "transparent", outline: "none", fontSize: 13 };
|
|
112
|
+
// 精致的 Switch:更缓动的位移动画(cubic-bezier),开态用品牌蓝→亮青渐变
|
|
113
|
+
// + 轻微外发光,滑块白色带双层阴影;悬停时外圈高亮提示可点。
|
|
114
|
+
const brandGrad = "linear-gradient(90deg,#2f6bff 0%,#3d8bff 100%)";
|
|
115
|
+
const springEase = "transform .22s cubic-bezier(.34,1.4,.64,1), box-shadow .22s ease";
|
|
116
|
+
const switchOuter = (disabled) => ({
|
|
117
|
+
position: "relative",
|
|
118
|
+
display: "inline-block",
|
|
119
|
+
padding: 4,
|
|
120
|
+
borderRadius: 999,
|
|
121
|
+
cursor: disabled ? "default" : "pointer",
|
|
122
|
+
transition: "box-shadow .18s ease, background .18s ease",
|
|
123
|
+
outline: "none",
|
|
124
|
+
});
|
|
125
|
+
const switchTrack = (on, hovered, disabled) => ({
|
|
126
|
+
position: "relative",
|
|
127
|
+
display: "block",
|
|
128
|
+
width: 40,
|
|
129
|
+
height: 22,
|
|
130
|
+
borderRadius: 999,
|
|
131
|
+
transition: "background .22s ease, box-shadow .22s ease",
|
|
132
|
+
background: on ? brandGrad : (hovered && !disabled ? "rgba(0,0,0,0.24)" : "rgba(0,0,0,0.16)"),
|
|
133
|
+
boxShadow: on ? "inset 0 0 0 1px rgba(255,255,255,0.12), 0 0 10px rgba(47,107,255,0.35)" : "inset 0 1px 2px rgba(0,0,0,0.12)",
|
|
134
|
+
opacity: disabled ? 0.5 : 1,
|
|
135
|
+
});
|
|
136
|
+
// 不做 scale(同滑块理由:scale+translate 叠加会漂移)。悬停/开态只用阴影+边框表达。
|
|
137
|
+
const switchKnob = (on, hovered, disabled) => ({
|
|
138
|
+
position: "absolute",
|
|
139
|
+
top: 3,
|
|
140
|
+
left: 3,
|
|
141
|
+
width: 16,
|
|
142
|
+
height: 16,
|
|
143
|
+
borderRadius: "50%",
|
|
144
|
+
backgroundColor: "#fff",
|
|
145
|
+
boxShadow: (hovered && !disabled)
|
|
146
|
+
? "0 1px 3px rgba(0,0,0,0.4), 0 0 0 0.5px rgba(0,0,0,0.08)"
|
|
147
|
+
: "0 1px 2px rgba(0,0,0,0.35), 0 0 0 0.5px rgba(0,0,0,0.06)",
|
|
148
|
+
transform: on ? "translateX(18px)" : "translateX(0px)",
|
|
149
|
+
transition: springEase,
|
|
150
|
+
pointerEvents: "none",
|
|
151
|
+
});
|
|
152
|
+
const disabledHintStyle = { marginTop: 12, color: hintColor, fontSize: 12.5, marginBottom: 8 };
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 精致的 Switch(布尔控件)。外层 button 负责更大的可点热区与 hover 高亮,
|
|
156
|
+
* 内层绘制 track 渐变 + 白色滑块位移动画。支持键盘(Enter/Space 切换)。
|
|
157
|
+
* @param props - { on: boolean, disabled: boolean, onChange(next:boolean) }
|
|
158
|
+
*/
|
|
159
|
+
function SwitchButton(props) {
|
|
160
|
+
const { on, disabled, onChange } = props;
|
|
161
|
+
const [hovered, setHovered] = React.useState(false);
|
|
162
|
+
return h("button", {
|
|
163
|
+
type: "button",
|
|
164
|
+
role: "switch",
|
|
165
|
+
"aria-checked": !!on,
|
|
166
|
+
disabled: !!disabled,
|
|
167
|
+
onMouseEnter: () => setHovered(true),
|
|
168
|
+
onMouseLeave: () => setHovered(false),
|
|
169
|
+
onClick: () => { if (!disabled) onChange(!on); },
|
|
170
|
+
onKeyDown: (e) => {
|
|
171
|
+
if ((e.key === "Enter" || e.key === " ") && !disabled) { e.preventDefault(); onChange(!on); }
|
|
172
|
+
},
|
|
173
|
+
style: switchOuter(disabled),
|
|
174
|
+
tabIndex: disabled ? -1 : 0,
|
|
175
|
+
},
|
|
176
|
+
h("span", { style: switchTrack(on, hovered, disabled) },
|
|
177
|
+
h("span", { style: switchKnob(on, hovered, disabled) })));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 字符串草稿编辑:类似 useDraftNumber,但针对自由文本(这里是 logFile 路径)。
|
|
182
|
+
* 途中随便打都不写回;失焦/回车时才一次性写回(trim 后空串视为清空)。
|
|
183
|
+
* @returns [draftValue, handlers]
|
|
184
|
+
*/
|
|
185
|
+
function useDraftText(key, currentValue, update) {
|
|
186
|
+
const [buf, setBuf] = React.useState(currentValue === undefined ? "" : String(currentValue));
|
|
187
|
+
const focusedRef = React.useRef(false);
|
|
188
|
+
React.useEffect(() => {
|
|
189
|
+
if (!focusedRef.current) {
|
|
190
|
+
setBuf(currentValue === undefined ? "" : String(currentValue));
|
|
191
|
+
}
|
|
192
|
+
}, [currentValue]);
|
|
193
|
+
const commit = () => {
|
|
194
|
+
focusedRef.current = false;
|
|
195
|
+
const trimmed = buf.trim();
|
|
196
|
+
if (trimmed === "") { update(key, undefined); setBuf(""); return; }
|
|
197
|
+
update(key, trimmed);
|
|
198
|
+
};
|
|
199
|
+
const handlers = {
|
|
200
|
+
onFocus: () => { focusedRef.current = true; },
|
|
201
|
+
onChange: (e) => setBuf(e.target.value),
|
|
202
|
+
onBlur: commit,
|
|
203
|
+
onKeyDown: (e) => { if (e.key === "Enter") { e.currentTarget.blur(); } },
|
|
204
|
+
};
|
|
205
|
+
return [buf, handlers];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 比例范围常量:自动/强制压缩最早比例均 0.01–1。 */
|
|
209
|
+
const RATIO_MIN = 0.01;
|
|
210
|
+
const RATIO_MAX = 1;
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 两选项分段控制器(segmented control):两个 pill 按钮左右排列,选中项
|
|
215
|
+
* 反白高亮、未选中呈浅灰边框。用于 compactionMode(realm / global)。
|
|
216
|
+
* @param props - { value, options: [{id,label}], onChange(id) }
|
|
217
|
+
*/
|
|
218
|
+
function SegmentedPicker(props) {
|
|
219
|
+
const { value, options, onChange, disabled } = props;
|
|
220
|
+
const selected = (id) => value === id;
|
|
221
|
+
const btnBase = {
|
|
222
|
+
appearance: "none", border: "none", padding: 0, background: "none",
|
|
223
|
+
cursor: disabled ? "default" : "pointer", fontSize: 12.5, lineHeight: 1.4,
|
|
224
|
+
};
|
|
225
|
+
const pills = options.map((o) => {
|
|
226
|
+
const sel = selected(o.id);
|
|
227
|
+
const style = {
|
|
228
|
+
...btnBase,
|
|
229
|
+
padding: "5px 12px",
|
|
230
|
+
borderRadius: 999,
|
|
231
|
+
border: sel ? "1px solid rgba(47,107,255,0.7)" : "1px solid rgba(0,0,0,0.18)",
|
|
232
|
+
background: sel ? "linear-gradient(90deg,#2f6bff 0%,#3d8bff 100%)" : "transparent",
|
|
233
|
+
color: sel ? "#ffffff" : "rgba(0,0,0,0.65)",
|
|
234
|
+
boxShadow: sel ? "0 0 8px rgba(47,107,255,0.35)" : "none",
|
|
235
|
+
opacity: disabled ? 0.5 : 1,
|
|
236
|
+
transition: "color .18s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease",
|
|
237
|
+
minWidth: 84,
|
|
238
|
+
textAlign: "center",
|
|
239
|
+
whiteSpace: "nowrap",
|
|
240
|
+
};
|
|
241
|
+
return h("button", {
|
|
242
|
+
type: "button",
|
|
243
|
+
key: o.id,
|
|
244
|
+
role: "radio",
|
|
245
|
+
"aria-checked": sel,
|
|
246
|
+
disabled: disabled,
|
|
247
|
+
style,
|
|
248
|
+
onClick: () => { if (!disabled) onChange(o.id); },
|
|
249
|
+
onKeyDown: (e) => { if (e.key === "Enter" && !disabled) { e.preventDefault(); onChange(o.id); } },
|
|
250
|
+
tabIndex: sel ? 0 : -1,
|
|
251
|
+
}, o.label);
|
|
252
|
+
});
|
|
253
|
+
return h("div", { role: "radiogroup", style: { display: "inline-flex", gap: 8 } }, pills);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 数字草稿编辑:用本地 buffer 缓存输入,途中随便打(含空串、小数点、负号)
|
|
258
|
+
* 都不写回;失焦/回车时把合法数值一次性写回(clamp 到 [min,max]),非法则
|
|
259
|
+
* 还原为当前值。彻底解决“每次击键都被当作最终值立刻写回导致输不进”。
|
|
260
|
+
* @returns [draftValue, handlers] —— draftValue 供 input.value;handlers 供 input。
|
|
261
|
+
*/
|
|
262
|
+
function useDraftNumber(key, currentValue, opts, update) {
|
|
263
|
+
const [buf, setBuf] = React.useState(currentValue === undefined ? "" : String(currentValue));
|
|
264
|
+
// 外部值变化(例如别的入口改了)且本框未在编辑时,同步缓冲区。
|
|
265
|
+
const focusedRef = React.useRef(false);
|
|
266
|
+
React.useEffect(() => {
|
|
267
|
+
if (!focusedRef.current) {
|
|
268
|
+
setBuf(currentValue === undefined ? "" : String(currentValue));
|
|
269
|
+
}
|
|
270
|
+
}, [currentValue]);
|
|
271
|
+
const clamp = (n) => {
|
|
272
|
+
let x = n;
|
|
273
|
+
if (opts.min !== undefined && x < opts.min) x = opts.min;
|
|
274
|
+
if (opts.max !== undefined && x > opts.max) x = opts.max;
|
|
275
|
+
return x;
|
|
276
|
+
};
|
|
277
|
+
const commit = () => {
|
|
278
|
+
focusedRef.current = false;
|
|
279
|
+
const trimmed = buf.trim();
|
|
280
|
+
if (trimmed === "") { update(key, undefined); setBuf(""); return; }
|
|
281
|
+
const n = Number(trimmed);
|
|
282
|
+
if (Number.isNaN(n)) { setBuf(currentValue === undefined ? "" : String(currentValue)); return; }
|
|
283
|
+
const c = clamp(n);
|
|
284
|
+
update(key, c);
|
|
285
|
+
setBuf(String(c));
|
|
286
|
+
};
|
|
287
|
+
const handlers = {
|
|
288
|
+
onFocus: () => { focusedRef.current = true; },
|
|
289
|
+
onChange: (e) => setBuf(e.target.value),
|
|
290
|
+
onBlur: commit,
|
|
291
|
+
onKeyDown: (e) => { if (e.key === "Enter") { e.currentTarget.blur(); } },
|
|
292
|
+
inputMode: "decimal",
|
|
293
|
+
};
|
|
294
|
+
return [buf, handlers];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 在 `useDraftNumber` 之上再套一层「硬性 floor」:commit 时把数值先 clamp
|
|
299
|
+
* 到 [hardFloor, opts.max](忽略 opts.min,改用传入的硬下界),并把钳位后的
|
|
300
|
+
* 值同时写回 store——即使另一入口绕过表单写入 sub-floor 值,下一次 blur
|
|
301
|
+
* 也会把它拉回 floor。用于带最小值的 token 尺度参数。
|
|
302
|
+
* @param {string} key
|
|
303
|
+
* @param {*} currentValue 当前 store 中的值。
|
|
304
|
+
* @param {{ step?: number, min?: number, max?: number }} opts 步进与上下界提示。
|
|
305
|
+
* @param {(k:string,v:any)=>void} update 底层写回函数。
|
|
306
|
+
* @param {number} hardFloor 不可逾越的下限(提交时向上 clamp)。
|
|
307
|
+
* @returns [draftValue, handlers]
|
|
308
|
+
*/
|
|
309
|
+
function useDraftNumberClamped(key, currentValue, opts, update, hardFloor) {
|
|
310
|
+
const [buf, setBuf] = React.useState(currentValue === undefined ? "" : String(currentValue));
|
|
311
|
+
const focusedRef = React.useRef(false);
|
|
312
|
+
React.useEffect(() => {
|
|
313
|
+
// External drift (another tab / programmatic write) re-syncs the buffer
|
|
314
|
+
// ONLY when we are not mid-edit, mirroring the unclamped twin.
|
|
315
|
+
if (!focusedRef.current) {
|
|
316
|
+
setBuf(currentValue === undefined ? "" : String(currentValue));
|
|
317
|
+
}
|
|
318
|
+
}, [currentValue, hardFloor]);
|
|
319
|
+
const clampToFloor = (n) => {
|
|
320
|
+
let x = n;
|
|
321
|
+
if (x < hardFloor) x = hardFloor;
|
|
322
|
+
if (opts.max !== undefined && x > opts.max) x = opts.max;
|
|
323
|
+
return x;
|
|
324
|
+
};
|
|
325
|
+
const commit = () => {
|
|
326
|
+
focusedRef.current = false;
|
|
327
|
+
const trimmed = buf.trim();
|
|
328
|
+
if (trimmed === "") { update(key, undefined); setBuf(""); return; }
|
|
329
|
+
const n = Number(trimmed);
|
|
330
|
+
if (Number.isNaN(n)) { setBuf(currentValue === undefined ? "" : String(currentValue)); return; }
|
|
331
|
+
const c = clampToFloor(n);
|
|
332
|
+
update(key, c);
|
|
333
|
+
setBuf(String(c));
|
|
334
|
+
};
|
|
335
|
+
const handlers = {
|
|
336
|
+
onFocus: () => { focusedRef.current = true; },
|
|
337
|
+
onChange: (e) => setBuf(e.target.value),
|
|
338
|
+
onBlur: commit,
|
|
339
|
+
onKeyDown: (e) => { if (e.key === "Enter") { e.currentTarget.blur(); } },
|
|
340
|
+
inputMode: "decimal",
|
|
341
|
+
};
|
|
342
|
+
return [buf, handlers];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** 比例滑块的几何与外观常量。 */
|
|
346
|
+
const SLIDER_W = 200; // 轨道宽度(px)
|
|
347
|
+
const SLIDER_H = 4; // 轨道条高度
|
|
348
|
+
const KNOB_D = 18; // 旋钮直径
|
|
349
|
+
const SLIDER_BOX_H = KNOB_D + 8; // 命中盒高度
|
|
350
|
+
const KNOB_TRAVEL = SLIDER_W - KNOB_D; // 旋钮左缘位移上限(保持圆钮落在轨道内)
|
|
351
|
+
const TRACK_TOP = (SLIDER_BOX_H - SLIDER_H) / 2; // 轨道条垂直居中
|
|
352
|
+
const KNOB_TOP = (SLIDER_BOX_H - KNOB_D) / 2; // 旋钮垂直居中
|
|
353
|
+
const sliderFill = (pct) => ({
|
|
354
|
+
position: "absolute",
|
|
355
|
+
left: 0,
|
|
356
|
+
top: TRACK_TOP,
|
|
357
|
+
height: SLIDER_H,
|
|
358
|
+
borderRadius: 999,
|
|
359
|
+
background: "linear-gradient(90deg,rgba(47,107,255,0.5) 0%,#2f6bff 100%)",
|
|
360
|
+
width: (KNOB_D / 2) + KNOB_TRAVEL * pct, // 从轨道中线到旋钮中心的填充
|
|
361
|
+
transition: "width .05s linear",
|
|
362
|
+
});
|
|
363
|
+
// 注意:不做 scale 放大——scale 与 translateX 作为独立变换叠加会在悬停/拖动时
|
|
364
|
+
// 沿 X 额外推移圆钮导致“漂浮”。只用颜色/阴影表达状态,位置恒稳。
|
|
365
|
+
const sliderKnob = (pct, dragging, hovered) => ({
|
|
366
|
+
position: "absolute",
|
|
367
|
+
top: KNOB_TOP,
|
|
368
|
+
left: 0,
|
|
369
|
+
width: KNOB_D,
|
|
370
|
+
height: KNOB_D,
|
|
371
|
+
borderRadius: "50%",
|
|
372
|
+
backgroundColor: "#fff",
|
|
373
|
+
border: "2px solid " + (dragging ? "#2f6bff" : "#3b74ff"),
|
|
374
|
+
boxShadow: dragging
|
|
375
|
+
? "0 3px 10px rgba(47,107,255,0.5)"
|
|
376
|
+
: (hovered ? "0 2px 6px rgba(47,107,255,0.4)" : "0 1px 3px rgba(0,0,0,0.3)"),
|
|
377
|
+
transform: "translateX(" + (KNOB_TRAVEL * pct) + "px)",
|
|
378
|
+
transition: "transform .06s linear, box-shadow .15s ease, border-color .15s ease",
|
|
379
|
+
cursor: "inherit",
|
|
380
|
+
pointerEvents: "none",
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
/** 把比例 0.01–1 换算成归一化占比 0–1,反之亦然。 */
|
|
384
|
+
function ratioToNorm(v) { return Math.max(0, Math.min(1, (v - RATIO_MIN) / (RATIO_MAX - RATIO_MIN))); }
|
|
385
|
+
function normToRatio(p) { return RATIO_MIN + p * (RATIO_MAX - RATIO_MIN); }
|
|
386
|
+
function round2(n) { return Math.round(n * 100) / 100; }
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* 比例拖动滑块:把 0.01–1 的比例映射为一条可拖动的横向滑轨。
|
|
390
|
+
* 指针事件(pointerdown/move/up,window 级监听跨出边界仍跟手)驱动,
|
|
391
|
+
* 拖动途中每帧即时预览、抬起时一次性写回(round2);另支持方向键 ±0.01。
|
|
392
|
+
* @returns { trackRef, val, pct, dragging, trackNode }
|
|
393
|
+
*/
|
|
394
|
+
function useDragRatio(key, currentVal, update) {
|
|
395
|
+
const trackRef = React.useRef(null);
|
|
396
|
+
const [livePct, setLivePct] = React.useState(undefined);
|
|
397
|
+
const [dragging, setDragging] = React.useState(false);
|
|
398
|
+
const [hovered, setHovered] = React.useState(false);
|
|
399
|
+
|
|
400
|
+
const shownVal = livePct !== undefined ? normToRatio(livePct) : (currentVal == null ? RATIO_MIN : currentVal);
|
|
401
|
+
const pct = livePct !== undefined ? livePct : ratioToNorm(shownVal);
|
|
402
|
+
|
|
403
|
+
function xToNorm(clientX) {
|
|
404
|
+
const el = trackRef.current;
|
|
405
|
+
if (!el) return null;
|
|
406
|
+
const r = el.getBoundingClientRect();
|
|
407
|
+
if (r.width <= 0) return null;
|
|
408
|
+
const x = Math.max(0, Math.min(r.width, clientX - r.left));
|
|
409
|
+
return x / r.width;
|
|
410
|
+
}
|
|
411
|
+
function beginDrag(e) {
|
|
412
|
+
if (typeof e.preventDefault === "function") e.preventDefault();
|
|
413
|
+
setDragging(true);
|
|
414
|
+
const p0 = xToNorm(e.clientX);
|
|
415
|
+
if (p0 != null) setLivePct(p0);
|
|
416
|
+
const onMove = (ev) => { const m = xToNorm(ev.clientX); if (m != null) setLivePct(m); };
|
|
417
|
+
const onUp = (ev) => {
|
|
418
|
+
window.removeEventListener("pointermove", onMove);
|
|
419
|
+
window.removeEventListener("pointerup", onUp);
|
|
420
|
+
const fin = xToNorm(ev.clientX);
|
|
421
|
+
if (fin != null) update(key, round2(normToRatio(fin)));
|
|
422
|
+
setDragging(false);
|
|
423
|
+
setLivePct(undefined);
|
|
424
|
+
};
|
|
425
|
+
window.addEventListener("pointermove", onMove);
|
|
426
|
+
window.addEventListener("pointerup", onUp);
|
|
427
|
+
}
|
|
428
|
+
function handleKey(e) {
|
|
429
|
+
const base = currentVal == null ? RATIO_MIN : currentVal;
|
|
430
|
+
if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
|
|
431
|
+
e.preventDefault();
|
|
432
|
+
update(key, round2(Math.max(RATIO_MIN, base - 0.01)));
|
|
433
|
+
} else if (e.key === "ArrowRight" || e.key === "ArrowUp") {
|
|
434
|
+
e.preventDefault();
|
|
435
|
+
update(key, round2(Math.min(RATIO_MAX, base + 0.01)));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const trackNode = h("div", {
|
|
440
|
+
ref: trackRef,
|
|
441
|
+
onPointerDown: beginDrag,
|
|
442
|
+
onPointerEnter: () => setHovered(true),
|
|
443
|
+
onPointerLeave: () => setHovered(false),
|
|
444
|
+
onKeyDown: handleKey,
|
|
445
|
+
tabIndex: 0,
|
|
446
|
+
role: "slider",
|
|
447
|
+
"aria-valuemin": RATIO_MIN,
|
|
448
|
+
"aria-valuemax": RATIO_MAX,
|
|
449
|
+
"aria-valuenow": shownVal,
|
|
450
|
+
style: {
|
|
451
|
+
position: "relative",
|
|
452
|
+
width: SLIDER_W,
|
|
453
|
+
height: KNOB_D + 8,
|
|
454
|
+
display: "inline-block",
|
|
455
|
+
cursor: dragging ? "grabbing" : "pointer",
|
|
456
|
+
touchAction: "none",
|
|
457
|
+
userSelect: "none",
|
|
458
|
+
outline: "none",
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
h("div", { style: { position: "absolute", left: 0, right: 0, top: TRACK_TOP, height: SLIDER_H, borderRadius: 999, background: "rgba(0,0,0,0.14)" } }),
|
|
462
|
+
h("span", { style: sliderFill(pct) }),
|
|
463
|
+
h("span", { style: sliderKnob(pct, dragging, hovered) }));
|
|
464
|
+
|
|
465
|
+
return { trackRef, val: shownVal, pct, dragging, trackNode };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* 渲染 force-compact 设置分区的内容列。
|
|
470
|
+
* @param props - 组合后的槽 props:useForceCompact(hooks 分区的可观察源)、update(写回)、t(文案)。
|
|
471
|
+
* @returns 分区内容,或在设置不可用/加载中时的占位。
|
|
472
|
+
*/
|
|
473
|
+
function ForceCompactSection(props) {
|
|
474
|
+
const { useForceCompact, update, t } = props;
|
|
475
|
+
const snap = useForceCompact((s) => s);
|
|
476
|
+
const value = snap.value;
|
|
477
|
+
const valOrUndef = (k) => (value && k in value ? value[k] : undefined);
|
|
478
|
+
// 阈值用草稿数字框;「保留最新 tokens」也用草稿数字框(绝对 token 值,非
|
|
479
|
+
// 比例)。所有 hook 都在组件顶部、任何条件返回之前无条件调用,保证 React
|
|
480
|
+
// hooks 顺序恒定(状态切换不改变 hook 数)。
|
|
481
|
+
// 三个 token 尺度参数均带硬性下界:schema 与表单两侧同设同一 floor,
|
|
482
|
+
// 表单提交时再做一次运行时 clamp(防键盘直接键入 sub-floor 值)。
|
|
483
|
+
const thOpt = { step: 1000, min: 32000, max: 1000000 };
|
|
484
|
+
const [thBuf, thHandlers] = useDraftNumberClamped("autoThresholdTokens", valOrUndef("autoThresholdTokens"), thOpt, update, 32000);
|
|
485
|
+
// retainLatestTokens:整 token 值(step 512),范围 8000–1_000_000。
|
|
486
|
+
const rtOpt = { step: 512, min: 8000, max: 1000000 };
|
|
487
|
+
const [rtBuf, rtHandlers] = useDraftNumberClamped("retainLatestTokens", valOrUndef("retainLatestTokens"), rtOpt, update, 8000);
|
|
488
|
+
// 新增三项的可观察源(同样放在顶部无条件调用,保持 hooks 顺序稳定)。
|
|
489
|
+
const lfOpt = { placeholder: t("logFilePlaceholder") };
|
|
490
|
+
const [lfBuf, lfHandlers] = useDraftText("logFile", valOrUndef("logFile"), update);
|
|
491
|
+
// maxSummaryTokens: 数字框,1024–200000,默认 1024。
|
|
492
|
+
const msOpt = { step: 64, min: 1024, max: 200000 };
|
|
493
|
+
const [msBuf, msHandlers] = useDraftNumberClamped("maxSummaryTokens", valOrUndef("maxSummaryTokens"), msOpt, update, 1024);
|
|
494
|
+
const modeOptions = [
|
|
495
|
+
{ id: "realm", label: t("modeRealm") },
|
|
496
|
+
{ id: "global", label: t("modeGlobal") },
|
|
497
|
+
];
|
|
498
|
+
|
|
499
|
+
const phStyle = { color: hintColor, fontSize: 13, lineHeight: 1.6, margin: "4px 0 0" };
|
|
500
|
+
if (snap.status === "unavailable") {
|
|
501
|
+
return h("div", { style: wrapStyle }, h("h2", { style: titleStyle }, t("nav")), h("p", { style: phStyle }, t("unavailable")));
|
|
502
|
+
}
|
|
503
|
+
if (snap.status === "loading") {
|
|
504
|
+
return h("div", { style: wrapStyle }, h("h2", { style: titleStyle }, t("nav")), h("p", { style: introStyle }, t("intro")), h("p", { style: phStyle }, t("loading")));
|
|
505
|
+
}
|
|
506
|
+
// status === "ready"
|
|
507
|
+
if (value === undefined) {
|
|
508
|
+
return h("div", { style: wrapStyle }, h("h2", { style: titleStyle }, t("nav")), h("p", { style: introStyle }, t("intro")), h("p", { style: phStyle }, t("loading")));
|
|
509
|
+
}
|
|
510
|
+
const disabled = !snap.writable;
|
|
511
|
+
|
|
512
|
+
function labelCell(labelKey) {
|
|
513
|
+
return h("span", { style: labelStyle }, t(labelKey));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function hintCell(hintKey) {
|
|
517
|
+
return h("span", { style: hintStyle }, t(hintKey));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function booleanRow(key, labelKey, hintKey, isLast) {
|
|
521
|
+
return h("div", { key: key, style: isLast ? lastRowStyle : rowStyle },
|
|
522
|
+
labelCell(labelKey),
|
|
523
|
+
h("span", { style: controlStyle },
|
|
524
|
+
h(SwitchButton, { on: !!value[key], disabled: disabled, onChange: (nv) => update(key, nv) })),
|
|
525
|
+
hintCell(hintKey));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function numberRow(key, labelKey, hintKey, buf, handlers, opts, isLast) {
|
|
529
|
+
return h("div", { key: key, style: isLast ? lastRowStyle : rowStyle },
|
|
530
|
+
labelCell(labelKey),
|
|
531
|
+
h("span", { style: controlStyle },
|
|
532
|
+
h("input", {
|
|
533
|
+
type: "number",
|
|
534
|
+
value: buf,
|
|
535
|
+
disabled: disabled,
|
|
536
|
+
step: opts.step,
|
|
537
|
+
min: opts.min,
|
|
538
|
+
max: opts.max,
|
|
539
|
+
style: inputStyle,
|
|
540
|
+
...handlers,
|
|
541
|
+
})),
|
|
542
|
+
hintCell(hintKey));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// 比例行:左 label(同列宽),中拖动滑块 + 实时百分比,下排 hint。
|
|
546
|
+
// 单独定义三栏模板(中间留给 200px 滑轨),避免共用 140px 窄列造成溢出。
|
|
547
|
+
const ratioGrid = "172px auto minmax(0,1fr)";
|
|
548
|
+
const ratioRowBase = { display: "grid", gridTemplateColumns: ratioGrid, columnGap: 16, rowGap: 5, padding: "13px 0", borderBottom: "1px solid " + divider, alignItems: "start" };
|
|
549
|
+
const sliderValueStyle = { width: 44, textAlign: "right", fontVariantNumeric: "tabular-nums", fontSize: 13, color: mutedColor, flexShrink: 0 };
|
|
550
|
+
function ratioRow(labelKey, hintKey, slider, isLast) {
|
|
551
|
+
const base = { ...ratioRowBase, ...(isLast ? { borderBottom: "none" } : {}) };
|
|
552
|
+
return h("div", { style: base },
|
|
553
|
+
h("span", { style: { ...labelStyle, paddingTop: 11 } }, t(labelKey)),
|
|
554
|
+
h("span", { style: { display: "inline-flex", alignItems: "center", gap: 14 } },
|
|
555
|
+
slider.trackNode,
|
|
556
|
+
h("span", { style: sliderValueStyle }, (Math.round(slider.val * 100) / 100) + "")),
|
|
557
|
+
h("span", { style: { gridColumn: "1 / 4", color: hintColor, fontSize: 12, lineHeight: 1.55 } }, t(hintKey)));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// logFile 文字行:左 label · 中 textarea-ish input · 下排 hint。
|
|
561
|
+
const textRowBase = { display: "grid", gridTemplateColumns: "172px 220px minmax(0,1fr)", columnGap: 16, rowGap: 5, padding: "13px 0", borderBottom: "1px solid " + divider, alignItems: "center" };
|
|
562
|
+
function textRow(labelKey, hintKey, buf, handlers, opts, isLast) {
|
|
563
|
+
const base = { ...textRowBase, ...(isLast ? { borderBottom: "none" } : {}) };
|
|
564
|
+
return h("div", { style: base },
|
|
565
|
+
h("span", { style: labelStyle }, t(labelKey)),
|
|
566
|
+
h("input", {
|
|
567
|
+
type: "text",
|
|
568
|
+
value: buf,
|
|
569
|
+
disabled: disabled,
|
|
570
|
+
placeholder: opts.placeholder || "",
|
|
571
|
+
style: { ...inputStyle, width: 200, textAlign: "left", fontFamily: "var(--mono-font, monospace)", fontSize: 12.5 },
|
|
572
|
+
...handlers,
|
|
573
|
+
}),
|
|
574
|
+
h("span", { style: { gridColumn: "1 / 3", gridRow: 3, color: hintColor, fontSize: 12, lineHeight: 1.55 } }, t(hintKey)));
|
|
575
|
+
}
|
|
576
|
+
// compactionMode 行:左 label · 中 SegmentedPicker(realm|global) · 下排 hint。
|
|
577
|
+
function modeRow(isLast) {
|
|
578
|
+
const base = { display: "grid", gridTemplateColumns: "172px 260px minmax(0,1fr)", columnGap: 16, rowGap: 5, padding: "13px 0", borderBottom: "1px solid " + divider, alignItems: "center" };
|
|
579
|
+
const b2 = { ...base, ...(isLast ? { borderBottom: "none" } : {}) };
|
|
580
|
+
const modeValue = valOrUndef("compactionMode");
|
|
581
|
+
return h("div", { style: b2 },
|
|
582
|
+
h("span", { style: labelStyle }, t("compactionMode")),
|
|
583
|
+
h("span", { style: { display: "inline-flex", alignItems: "center" } },
|
|
584
|
+
h(SegmentedPicker, { value: modeValue, options: modeOptions, onChange: (id) => update("compactionMode", id), disabled: disabled })),
|
|
585
|
+
h("span", { style: { gridColumn: "1 / 3", gridRow: 3, color: hintColor, fontSize: 12, lineHeight: 1.55 } }, t("compactionModeHint")));
|
|
586
|
+
}
|
|
587
|
+
return h("div", { style: wrapStyle },
|
|
588
|
+
h("h2", { style: titleStyle }, t("nav")),
|
|
589
|
+
h("p", { style: introStyle }, t("intro")),
|
|
590
|
+
h("div", null,
|
|
591
|
+
booleanRow("disableThinking", "disableThinking", "disableThinkingHint", false),
|
|
592
|
+
numberRow("autoThresholdTokens", "autoThresholdTokens", "autoThresholdTokensHint", thBuf, thHandlers, thOpt, false),
|
|
593
|
+
numberRow("retainLatestTokens", "retainLatestTokens", "retainLatestTokensHint", rtBuf, rtHandlers, rtOpt, false),
|
|
594
|
+
booleanRow("turnEndForceCompactionEnabled", "turnEndForceCompaction", "turnEndForceCompactionHint", false),
|
|
595
|
+
booleanRow("debug", "debug", "debugHint", false),
|
|
596
|
+
textRow("logFile", "logFileHint", lfBuf, lfHandlers, lfOpt, false),
|
|
597
|
+
modeRow(false),
|
|
598
|
+
booleanRow("builtinEnabled", "builtinEnabled", "builtinEnabledHint", false),
|
|
599
|
+
numberRow("maxSummaryTokens", "maxSummaryTokens", "maxSummaryTokensHint", msBuf, msHandlers, msOpt, true)),
|
|
600
|
+
disabled ? h("p", { style: disabledHintStyle }, t("notWritable")) : null);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* 「Deep diving…」指示器的实时贴皮器(live UI 徽章)。
|
|
605
|
+
*
|
|
606
|
+
* 宿主半部(core/ui-signal.js)在三个时机改写本命名空间的 liveUi 字段:
|
|
607
|
+
* • 每次出站 LLM 调用开始时——写入 20×20 随机工作态(phase/text/color);
|
|
608
|
+
* • 任意一次强制压缩开始前——固定红色「[强制压缩中>>>]」;
|
|
609
|
+
* • 压缩成功后——固定绿色「[压缩完成!]」。
|
|
610
|
+
* 本函数把该字段的最新值贴到对话区那个 `<div role="status" aria-live="polite">`
|
|
611
|
+
* (官方 `TurnStatus` 组件,"Deep diving…" 所在处)的**第一个文本节点**上,
|
|
612
|
+
* 并按 phase 着色。这是一次**瞬时 DOM 覆盖**:React 的下一帧重绘会自行还原
|
|
613
|
+
* (这正是文档 turn-status-deep-diving-rendering.md §四「临时改文案」描述的
|
|
614
|
+
* 机制),而下一轮 liveUi 变化又会再次贴上来——净效果就是跟随宿主相位持续
|
|
615
|
+
* 显示,无需本地 timer、也无需 MutationObserver 追帧:宿主每次改写都经由
|
|
616
|
+
* settingsScope 镜像到达这里,天然成为我们的"事件时钟"。
|
|
617
|
+
*
|
|
618
|
+
* 定位策略:全页面可能存在多个 role=status 节点(多标签/多会话并存时,每个
|
|
619
|
+
* 打开的 running 会话各有一个 TurnStatus)。我们取**全部**命中的节点逐一贴
|
|
620
|
+
* 上——装饰性的、幂等的、零侵入(不改 className/结构,只动第一个文本子节点
|
|
621
|
+
* 的 nodeValue + 父节点的相位标记属性)。找不到节点则静默 no-op(当前没有
|
|
622
|
+
* running 会话 → 没有指示器 → 无可贴目标,这是预期行为而非错误)。
|
|
623
|
+
*
|
|
624
|
+
* 着色原理:官方 `TurnStatus` 的可见颜色来自 shimmer——
|
|
625
|
+
* `background: linear-gradient` + `background-clip:text` +
|
|
626
|
+
* `color/-webkit-text-fill-color: transparent`(ChatView.module.css
|
|
627
|
+
* .turnStatus)。因此 **inline `style.color` 对它无效**(填充恒为透明,
|
|
628
|
+
* 看到的是背景渐变,不是 color)。唯一可行的覆盖方式是一条高于普通类规则
|
|
629
|
+
* 的选择器(`[role="status"][data-fc-phase]` + `!important`)同时中和四个
|
|
630
|
+
* 属性:`background:none`(撤掉渐变本体)+ `animation:none`(停掉扫光,
|
|
631
|
+
* prefers-reduced-motion 分支下两者皆静态,一并覆盖)+ `color` /
|
|
632
|
+
* `-webkit-text-fill-color` 设为相位色。样式表经 ensurePhaseStyleSheet
|
|
633
|
+
* 按需注入**一次**(head 下首个 <style>,key 前缀 `dsh-fc-` 保证重复插入
|
|
634
|
+
* 时浏览器按内容复用而不产生重复规则)。选择器以 `data-fc-phase="<phase>"`
|
|
635
|
+
* 精确匹配相位值(非存在性通配),使不同相位的多个 status 节点各自命中各自
|
|
636
|
+
* 的规则、互不干扰;phase 消失(React 重建节点、新 TurnStatus 挂载)后
|
|
637
|
+
* 属性不复存在 → 规则不再命中 → 官方 shimmer 自然恢复。
|
|
638
|
+
*
|
|
639
|
+
* @param liveUi - 宿主写入的 { phase, text, color };缺省/null 时 no-op。
|
|
640
|
+
*/
|
|
641
|
+
// ── 扫光配色表:20 个工作态颜色 + 2 个钉住颜色(compressing 红 / done 绿)──
|
|
642
|
+
// 每条对应 web/swish.css 里的 .falling-ts-swish-NN(@keyframes falling-ts-swish-NN),
|
|
643
|
+
// 色值单一事实源在 src/core/ui-signal.js 的 WORKING_COLORS(20 项)与
|
|
644
|
+
// PINNED_COLORS(2 项),此处仅做"hex → 编号"映射,不做任何样式计算。
|
|
645
|
+
const SWISH_HEXES = [
|
|
646
|
+
"#4f9cf9","#5b8def","#6a5bff","#8b5cf6","#a855f7",
|
|
647
|
+
"#c45bf9","#db6bd4","#e86bb0","#f06b8b","#f76b5b",
|
|
648
|
+
"#fb8c5b","#fca95b","#fdc35b","#d8e05b","#aede5b",
|
|
649
|
+
"#7ee083","#5be0a0","#5becd8","#5bcdf9","#7ba8f9",
|
|
650
|
+
"#ff4d4f","#52c41a",
|
|
651
|
+
];
|
|
652
|
+
const SWISH_CLASS_PREFIX = "falling-ts-swish-";
|
|
653
|
+
function swishClassForColor(hex) {
|
|
654
|
+
const norm = String(hex || "").trim().toLowerCase();
|
|
655
|
+
const idx = SWISH_HEXES.findIndex(c => c.toLowerCase() === norm);
|
|
656
|
+
// 未识别色 → 回落到第一档(最冷蓝),避免误贴错误相位色
|
|
657
|
+
const slot = idx >= 0 ? idx : 0;
|
|
658
|
+
return SWISH_CLASS_PREFIX + String(slot).padStart(2, "0");
|
|
659
|
+
}
|
|
660
|
+
function paintTurnStatus(liveUi) {
|
|
661
|
+
if (typeof document === "undefined") return;
|
|
662
|
+
if (!liveUi || typeof liveUi.text !== "string" || liveUi.text.length === 0) return;
|
|
663
|
+
const color = typeof liveUi.color === "string" && liveUi.color !== "" ? liveUi.color : null;
|
|
664
|
+
const nodes = document.querySelectorAll('[role="status"][aria-live="polite"]');
|
|
665
|
+
if (nodes.length === 0) return; // 没有 running 会话 → 没有 TurnStatus
|
|
666
|
+
const targetCls = color !== null ? swishClassForColor(color) : null;
|
|
667
|
+
for (const node of nodes) {
|
|
668
|
+
let textChild = null;
|
|
669
|
+
for (const child of node.childNodes) {
|
|
670
|
+
if (child.nodeType === Node.TEXT_NODE && child.nodeValue.trim() !== "") { textChild = child; break; }
|
|
671
|
+
}
|
|
672
|
+
if (textChild === null) continue;
|
|
673
|
+
textChild.nodeValue = liveUi.text;
|
|
674
|
+
// class 化:先清掉所有上一轮的 swish class,再贴本次(target 为空时只清不加)。
|
|
675
|
+
for (const cls of [...node.classList]) {
|
|
676
|
+
if (cls.startsWith(SWISH_CLASS_PREFIX)) node.classList.remove(cls);
|
|
677
|
+
}
|
|
678
|
+
// 相位色载体属性:与 swish class 同步贴/撤(`data-fc-bg` 仅在有相位色时
|
|
679
|
+
// 存在,供 CSS 里的文本强调规则取色;属性消失时强调规则失配、自然回落
|
|
680
|
+
// 官方 caption 灰)。
|
|
681
|
+
if (color !== null) {
|
|
682
|
+
node.setAttribute("data-fc-bg", color);
|
|
683
|
+
} else {
|
|
684
|
+
node.removeAttribute("data-fc-bg");
|
|
685
|
+
}
|
|
686
|
+
if (targetCls) node.classList.add(targetCls);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* 把 web/swish.css 的全部内容内联注入到 <head>(一次性,幂等)。
|
|
692
|
+
*
|
|
693
|
+
* 为什么走内联而不是 <link>:宿主 registry 只暴露已知 artifact(client.js 等),
|
|
694
|
+
* 插件新增的同名 .css 不会出现在 /plugins/<id>/ 下(实测 404),也没有别的
|
|
695
|
+
* 合法入口把静态 CSS 资产带进 DOM。plain-JS 插件无构建步骤的约束决定了唯一
|
|
696
|
+
* 可行路径就是把 CSS 文本直接内联成 <style> 注入。
|
|
697
|
+
*
|
|
698
|
+
* 同步纪律:此字符串必须与仓库内的 web/swish.css **语义一致**——后者是给人读的
|
|
699
|
+
* 可读版本(带注释、格式化),前者是给浏览器执行的紧凑版本;两者不是逐字镜像。
|
|
700
|
+
* 改动任意一边都要同步另一边。审查 checklist:
|
|
701
|
+
* ① 22 段 @keyframes + 22 个 class + 1 段文本强调规则,数量逐一对得上;
|
|
702
|
+
* ② 所有 var() 引用、渐变色值、selector 逐字一致;
|
|
703
|
+
* ③ 每段规则都带 !important。
|
|
704
|
+
* 扫光渐变骨架为**相位色底**(<HEX> 0%/40%/60%/100% 四点全相位色),
|
|
705
|
+
* 中央 50% 是**白色扫光带**(var(--dsw-fc-swish-band, #ffffff))——与官方
|
|
706
|
+
* .turnStatus 的"深色底 + 浅色扫光带"机制同构(官方:deepseek-500 底 +
|
|
707
|
+
* deepseek-200 带),文字填充(-webkit-background-clip:text 继承自官方哈希类)
|
|
708
|
+
* 即相位色本体,不再是白底上一条窄色缝(旧形态为品牌蓝
|
|
709
|
+
* --dsw-static-deepseek-500 白底 + 50% 窄色缝,2026 版已废弃)。
|
|
710
|
+
*
|
|
711
|
+
* 文本强调:.turnStatusClock(TurnStatus 唯一独立着色的子元素——时长数字,官方
|
|
712
|
+
* 用灰色别名 --dsw-alias-label-caption 着色)随 data-fc-bg 属性取当前相位色;
|
|
713
|
+
* 该属性仅在有相位色时存在(paintTurnStatus 同步贴/撤),属性消失时选择器失配、
|
|
714
|
+
* 官方 caption 灰自然恢复。详见 web/swish.css 头注释与「文本颜色强调」节。
|
|
715
|
+
*/
|
|
716
|
+
const SWISH_CSS = "@keyframes falling-ts-swish-00{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-00{animation-name:falling-ts-swish-00!important;background-image:linear-gradient(90deg,#4f9cf9 0%,#4f9cf9 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#4f9cf9 60%,#4f9cf9 100%)!important}@keyframes falling-ts-swish-01{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-01{animation-name:falling-ts-swish-01!important;background-image:linear-gradient(90deg,#5b8def 0%,#5b8def 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#5b8def 60%,#5b8def 100%)!important}@keyframes falling-ts-swish-02{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-02{animation-name:falling-ts-swish-02!important;background-image:linear-gradient(90deg,#6a5bff 0%,#6a5bff 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#6a5bff 60%,#6a5bff 100%)!important}@keyframes falling-ts-swish-03{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-03{animation-name:falling-ts-swish-03!important;background-image:linear-gradient(90deg,#8b5cf6 0%,#8b5cf6 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#8b5cf6 60%,#8b5cf6 100%)!important}@keyframes falling-ts-swish-04{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-04{animation-name:falling-ts-swish-04!important;background-image:linear-gradient(90deg,#a855f7 0%,#a855f7 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#a855f7 60%,#a855f7 100%)!important}@keyframes falling-ts-swish-05{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-05{animation-name:falling-ts-swish-05!important;background-image:linear-gradient(90deg,#c45bf9 0%,#c45bf9 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#c45bf9 60%,#c45bf9 100%)!important}@keyframes falling-ts-swish-06{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-06{animation-name:falling-ts-swish-06!important;background-image:linear-gradient(90deg,#db6bd4 0%,#db6bd4 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#db6bd4 60%,#db6bd4 100%)!important}@keyframes falling-ts-swish-07{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-07{animation-name:falling-ts-swish-07!important;background-image:linear-gradient(90deg,#e86bb0 0%,#e86bb0 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#e86bb0 60%,#e86bb0 100%)!important}@keyframes falling-ts-swish-08{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-08{animation-name:falling-ts-swish-08!important;background-image:linear-gradient(90deg,#f06b8b 0%,#f06b8b 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#f06b8b 60%,#f06b8b 100%)!important}@keyframes falling-ts-swish-09{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-09{animation-name:falling-ts-swish-09!important;background-image:linear-gradient(90deg,#f76b5b 0%,#f76b5b 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#f76b5b 60%,#f76b5b 100%)!important}@keyframes falling-ts-swish-10{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-10{animation-name:falling-ts-swish-10!important;background-image:linear-gradient(90deg,#fb8c5b 0%,#fb8c5b 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#fb8c5b 60%,#fb8c5b 100%)!important}@keyframes falling-ts-swish-11{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-11{animation-name:falling-ts-swish-11!important;background-image:linear-gradient(90deg,#fca95b 0%,#fca95b 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#fca95b 60%,#fca95b 100%)!important}@keyframes falling-ts-swish-12{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-12{animation-name:falling-ts-swish-12!important;background-image:linear-gradient(90deg,#fdc35b 0%,#fdc35b 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#fdc35b 60%,#fdc35b 100%)!important}@keyframes falling-ts-swish-13{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-13{animation-name:falling-ts-swish-13!important;background-image:linear-gradient(90deg,#d8e05b 0%,#d8e05b 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#d8e05b 60%,#d8e05b 100%)!important}@keyframes falling-ts-swish-14{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-14{animation-name:falling-ts-swish-14!important;background-image:linear-gradient(90deg,#aede5b 0%,#aede5b 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#aede5b 60%,#aede5b 100%)!important}@keyframes falling-ts-swish-15{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-15{animation-name:falling-ts-swish-15!important;background-image:linear-gradient(90deg,#7ee083 0%,#7ee083 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#7ee083 60%,#7ee083 100%)!important}@keyframes falling-ts-swish-16{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-16{animation-name:falling-ts-swish-16!important;background-image:linear-gradient(90deg,#5be0a0 0%,#5be0a0 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#5be0a0 60%,#5be0a0 100%)!important}@keyframes falling-ts-swish-17{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-17{animation-name:falling-ts-swish-17!important;background-image:linear-gradient(90deg,#5becd8 0%,#5becd8 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#5becd8 60%,#5becd8 100%)!important}@keyframes falling-ts-swish-18{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-18{animation-name:falling-ts-swish-18!important;background-image:linear-gradient(90deg,#5bcdf9 0%,#5bcdf9 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#5bcdf9 60%,#5bcdf9 100%)!important}@keyframes falling-ts-swish-19{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-19{animation-name:falling-ts-swish-19!important;background-image:linear-gradient(90deg,#7ba8f9 0%,#7ba8f9 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#7ba8f9 60%,#7ba8f9 100%)!important}@keyframes falling-ts-swish-20{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-20{animation-name:falling-ts-swish-20!important;background-image:linear-gradient(90deg,#ff4d4f 0%,#ff4d4f 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#ff4d4f 60%,#ff4d4f 100%)!important}@keyframes falling-ts-swish-21{from{background-position:100% 0}to{background-position:0 0}}.falling-ts-swish-21{animation-name:falling-ts-swish-21!important;background-image:linear-gradient(90deg,#52c41a 0%,#52c41a 40%,var(--dsw-fc-swish-band,#ffffff) 50%,#52c41a 60%,#52c41a 100%)!important}.turnStatus[data-fc-bg] .turnStatusClock,[data-fc-bg] span:last-of-type{color:attr(data-fc-bg)!important;-webkit-text-fill-color:attr(data-fc-bg)!important}";
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* 确保 <style id="falling-ts-swish-inline"> 已挂在 <head>(幂等,至多一次)。
|
|
720
|
+
* @returns void
|
|
721
|
+
*/
|
|
722
|
+
function ensureSwishStylesheetInlined() {
|
|
723
|
+
if (typeof document === "undefined") return;
|
|
724
|
+
if (document.getElementById("falling-ts-swish-inline")) return;
|
|
725
|
+
const el = document.createElement("style");
|
|
726
|
+
el.id = "falling-ts-swish-inline";
|
|
727
|
+
el.textContent = SWISH_CSS;
|
|
728
|
+
document.head.appendChild(el);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* 注册文案字典、绑定设置命名空间、把分区挂到 settings.section。
|
|
733
|
+
* @param ctx - client 根上下文。
|
|
734
|
+
*/
|
|
735
|
+
function apply(ctx) {
|
|
736
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "force-compact: dictionaries");
|
|
737
|
+
const t = ctx.locale.bind(NS);
|
|
738
|
+
const scope = ctx.settingsScope.bind({ namespace: NS_SETTINGS });
|
|
739
|
+
// 挂载 swish 调色板样式表(首次 apply 时执行一次;effect 登记便于 fiber 卸载时清理)。
|
|
740
|
+
ctx.effect(() => ensureSwishStylesheetInlined(), "force-compact: swish stylesheets");
|
|
741
|
+
// 把 settingsScope 镜像成 uSES 安全的 SnapshotStore(hooks 分区的可观察源)。
|
|
742
|
+
const store = createSnapshotStore({ status: "loading", value: undefined, writable: false });
|
|
743
|
+
const derive = () => {
|
|
744
|
+
// SAFETY ENVELOPE: derive runs BOTH as a direct call and as the
|
|
745
|
+
// scope.subscribe callback — a throwing `scope.getSnapshot()` or an
|
|
746
|
+
// unexpected snapshot shape must not escape out of the subscription
|
|
747
|
+
// dispatch (which would strand the whole settings panel). Any anomaly
|
|
748
|
+
// degrades to leaving the previous snapshot in place (store untouched).
|
|
749
|
+
try {
|
|
750
|
+
const s = scope.getSnapshot();
|
|
751
|
+
if (s === undefined || s === null || typeof s !== "object") return;
|
|
752
|
+
store.update((d) => {
|
|
753
|
+
d.status = s.status;
|
|
754
|
+
d.value = s.value;
|
|
755
|
+
d.writable = s.writable;
|
|
756
|
+
});
|
|
757
|
+
// ── Live UI 徽章(详见上方 paintTurnStatus 文档)──
|
|
758
|
+
// 每次命名空间快照翻转(含宿主写入 liveUi 瞬间),顺路把最新 liveUi
|
|
759
|
+
// 贴到对话区 TurnStatus DOM 上。这一步搭车在已有 scope.subscribe
|
|
760
|
+
// 回调上不新增任何订阅机制 / timer / 组件,符合 client 端 AGENTS.md
|
|
761
|
+
// 红线。幂等纯装饰:贴不上(无 running 会话/无 DOM 锚点)即静默跳过。
|
|
762
|
+
const liveUi = (typeof s.value === "object" && s.value !== null) ? s.value.liveUi : undefined;
|
|
763
|
+
if (s.status === "ready" && typeof liveUi === "object" && liveUi !== null) {
|
|
764
|
+
paintTurnStatus(liveUi);
|
|
765
|
+
}
|
|
766
|
+
} catch {
|
|
767
|
+
// Never let a cosmetic derive take down the settings panel.
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
// 关键:settingsScope 的快照自带权威状态枚举 'loading'|'ready'|'unavailable'
|
|
771
|
+
// (见 ui-settings 的 SettingsScopeController.derive:命名空间未出现时置
|
|
772
|
+
// 'unavailable',并非 'loading')。derive 直接透传该枚举,绝不按 mode 二次
|
|
773
|
+
// 映射——否则会把这个 loopback 实例上 mode='host' 的 'unavailable' 误判为
|
|
774
|
+
// 'loading',让面板在命名空间未被宿主注册期间永久停留在“加载中…”。
|
|
775
|
+
//
|
|
776
|
+
// 宿主侧 settings 命名空间是惰性安装的(插件 apply 时一次性尽力注册;若彼时
|
|
777
|
+
// settings 服务尚未挂载,则由首个 agent/* 事件补装)。一旦命名空间真正出现,
|
|
778
|
+
// 客户端 settingsScope 背后共享的 SettingsDescribeMirror 会收到 settings/
|
|
779
|
+
// document-updated 广播并重新 mirror.load(),随后 derive() 读到 status='ready'
|
|
780
|
+
// 自动渲染出对齐好的表单——无需本分区额外维护 timer 或轮询。
|
|
781
|
+
const unsub = scope.subscribe(derive);
|
|
782
|
+
derive();
|
|
783
|
+
// unsub 本身就是一个 disposer(解绑 scope 监听器)。必须把它作为 effect 的
|
|
784
|
+
// 返回值交给 ctx.effect,由 fiber 在本插件卸载/重跑时运行它以解除订阅;
|
|
785
|
+
// 直接把 unsub 当作用法的“执行体”(ctx.effect(unsub,...))会令 fiber 立即
|
|
786
|
+
// 调用 unsub() 并把它的 void 返回值当第二个 effect 收集——既提前解绑了本次
|
|
787
|
+
// 订阅,又不登记任何清理项,属错误用法。
|
|
788
|
+
ctx.effect(() => unsub, "force-compact: scope subscription");
|
|
789
|
+
const injected = () => ({
|
|
790
|
+
hooks: { forceCompact: store },
|
|
791
|
+
t: t,
|
|
792
|
+
update: (field, value2) => scope.set(field, value2),
|
|
793
|
+
});
|
|
794
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
795
|
+
name: "settings.section",
|
|
796
|
+
id: "force-compact",
|
|
797
|
+
order: 30,
|
|
798
|
+
label: () => t("nav"),
|
|
799
|
+
inject: injected,
|
|
800
|
+
}, ForceCompactSection));
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
exports.apply = apply;
|
|
804
|
+
exports.inject = inject;
|
|
805
|
+
return module.exports;
|
|
806
|
+
}
|
|
807
|
+
});
|