dsh-compaction-tower 0.0.0-stage → 0.1.39
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.md +210 -3
- package/docs/compression.png +0 -0
- package/docs/config.png +0 -0
- package/docs/design/DSH-Tower-/346/217/222/344/273/266/350/256/276/350/256/241/350/247/204/346/240/274.md +723 -0
- package/docs/design/compaction.md +157 -0
- package/docs/panel-narrow.png +0 -0
- package/docs/panel.png +0 -0
- package/dsh.bundle.patch.yml +9 -0
- package/lib/client.js +841 -0
- package/lib/config-store.js +146 -0
- package/lib/config.js +323 -0
- package/lib/core.js +268 -0
- package/lib/engine.js +66 -0
- package/lib/index.js +101 -0
- package/lib/prompts.js +124 -0
- package/lib/select.js +248 -0
- package/lib/server.js +253 -0
- package/lib/state.js +192 -0
- package/lib/summarize.js +91 -0
- package/lib/tools.js +262 -0
- package/lib/tower.js +1366 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tower 的浏览器半侧:会话面板里的一个 tab,显示压缩状态并提供手动操作。
|
|
3
|
+
*
|
|
4
|
+
* 这个文件是**已构建产物**的形态(`window.__ModuleLoader__.load` + 惰性 factory),
|
|
5
|
+
* 不是源码:DSH 的客户端插件必须这样声明,宿主才知道怎么把它装进页面。
|
|
6
|
+
* 所以这里直接用 `React.createElement`,不依赖任何构建链。
|
|
7
|
+
*
|
|
8
|
+
* 数据来自宿主半侧的三条路由(同源,相对路径即可):
|
|
9
|
+
* GET /tower/state → 快照
|
|
10
|
+
* POST /tower/action → 手动蒸馏 / 提交 / 合并
|
|
11
|
+
* GET /tower/config · POST /tower/config → 配置读写
|
|
12
|
+
*
|
|
13
|
+
* 两个挂载点:会话里的 `conversation.view` tab(Tower),
|
|
14
|
+
* 插件页里插件卡片下的配置表单(`plugins.bundle.config`,键是包名)。
|
|
15
|
+
*/
|
|
16
|
+
window.__ModuleLoader__.load({
|
|
17
|
+
id: 'dsh-compaction-tower',
|
|
18
|
+
factory: (require) => {
|
|
19
|
+
const React = require('react');
|
|
20
|
+
const h = React.createElement;
|
|
21
|
+
const Fragment = React.Fragment;
|
|
22
|
+
|
|
23
|
+
const NS = 'compaction-tower';
|
|
24
|
+
const inject = ['slots'];
|
|
25
|
+
const REFRESH_MS = 3000;
|
|
26
|
+
|
|
27
|
+
// ── 样式:一次性注入,类名前缀避免与宿主冲突 ──────────────────────
|
|
28
|
+
const STYLE_ID = 'dsh-compaction-tower-style';
|
|
29
|
+
const CSS = [
|
|
30
|
+
'.tw-root{display:flex;flex-direction:column;gap:14px;padding:14px 14px 180px;font-size:13px;line-height:1.5;color:inherit;overflow:auto;height:100%}',
|
|
31
|
+
'.tw-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
32
|
+
'.tw-title{font-weight:600;font-size:14px}',
|
|
33
|
+
'.tw-muted{opacity:.62}',
|
|
34
|
+
'.tw-btn{border:1px solid rgba(127,127,127,.38);background:transparent;color:inherit;border-radius:6px;padding:3px 9px;font-size:12px;cursor:pointer}',
|
|
35
|
+
'.tw-btn:hover:not(:disabled){background:rgba(127,127,127,.14)}',
|
|
36
|
+
'.tw-btn:disabled{opacity:.42;cursor:default}',
|
|
37
|
+
'.tw-btn-primary{border-color:rgba(64,150,255,.7)}',
|
|
38
|
+
'.tw-card{border:1px solid rgba(127,127,127,.24);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:8px}',
|
|
39
|
+
'.tw-card-title{font-weight:600;font-size:12px;letter-spacing:.02em;opacity:.82;display:flex;justify-content:space-between;align-items:center;gap:8px}',
|
|
40
|
+
// 三段同色相、只走明度:实心(已用)→ 斜纹(当前→触发)→ 灰(触发之后,到不了)。
|
|
41
|
+
// 颜色用「渐变 + 半透明」写,浅色/深色两套主题都不用另配参数。
|
|
42
|
+
'.tw-bar{position:relative;height:14px;border-radius:7px;background:rgba(127,127,127,.16);overflow:hidden}',
|
|
43
|
+
'.tw-bar-real{position:absolute;top:0;bottom:0;background:linear-gradient(180deg,#33608f,#275284)}',
|
|
44
|
+
'.tw-bar-fixed{position:absolute;left:0;top:0;bottom:0;background:#15181c}',
|
|
45
|
+
'.tw-bar-stripes{position:absolute;top:0;bottom:0;background-color:rgba(140,168,205,.5);background-image:repeating-linear-gradient(135deg,rgba(255,255,255,.34) 0 3px,rgba(255,255,255,0) 3px 8px)}',
|
|
46
|
+
'.tw-bar-dead{position:absolute;top:0;bottom:0;right:0;background:rgba(127,127,127,.3)}',
|
|
47
|
+
// 越过触发线之后多出来的那一截:比实心更深,一眼看出「已经越界、下次请求就会压」。
|
|
48
|
+
'.tw-bar-over{position:absolute;top:0;bottom:0;background:linear-gradient(180deg,#1d3a58,#142a42)}',
|
|
49
|
+
'.tw-legend{display:flex;gap:12px;flex-wrap:wrap;font-size:11px;opacity:.72}',
|
|
50
|
+
'.tw-verdict{font-size:11px;opacity:.62}',
|
|
51
|
+
'.tw-table{width:100%;border-collapse:collapse;font-size:12px}',
|
|
52
|
+
'.tw-table-wrap{max-height:38vh;overflow:auto;border-radius:6px}',
|
|
53
|
+
'.tw-tree-wrap{max-height:30vh;overflow:auto}',
|
|
54
|
+
'.tw-table th{text-align:left;font-weight:600;opacity:.6;padding:3px 6px;border-bottom:1px solid rgba(127,127,127,.24)}',
|
|
55
|
+
'.tw-table td{padding:3px 6px;border-bottom:1px solid rgba(127,127,127,.12);vertical-align:top}',
|
|
56
|
+
'.tw-row{cursor:pointer}',
|
|
57
|
+
'.tw-row:hover{background:rgba(127,127,127,.09)}',
|
|
58
|
+
'.tw-row-selected{background:rgba(64,150,255,.13)}',
|
|
59
|
+
'.tw-tag{display:inline-block;padding:0 5px;border-radius:4px;font-size:11px;border:1px solid transparent}',
|
|
60
|
+
'.tw-tag-t1{background:rgba(64,150,255,.16);border-color:rgba(64,150,255,.32)}',
|
|
61
|
+
'.tw-tag-t2{background:rgba(150,110,255,.16);border-color:rgba(150,110,255,.32)}',
|
|
62
|
+
'.tw-tag-t3{background:rgba(255,140,80,.16);border-color:rgba(255,140,80,.32)}',
|
|
63
|
+
'.tw-tag-t4{background:rgba(220,80,140,.16);border-color:rgba(220,80,140,.32)}',
|
|
64
|
+
'.tw-status{font-size:11px;opacity:.78}',
|
|
65
|
+
'.tw-summary{white-space:pre-wrap;font-family:inherit;font-size:12px;opacity:.86;background:rgba(127,127,127,.08);border-radius:6px;padding:8px;margin-top:4px}',
|
|
66
|
+
'.tw-tree{list-style:none;margin:0;padding-left:14px}',
|
|
67
|
+
'.tw-tree>li{margin:2px 0}',
|
|
68
|
+
'.tw-err{color:#e05555;font-size:12px}',
|
|
69
|
+
'.tw-note{color:#2f8f5b;font-size:12px}',
|
|
70
|
+
'.tw-empty{opacity:.55;font-size:12px}',
|
|
71
|
+
// 配置表单
|
|
72
|
+
'.tw-cfg{display:flex;flex-direction:column;gap:12px;font-size:13px}',
|
|
73
|
+
'.tw-cfg-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
74
|
+
'.tw-cfg-path{font-family:ui-monospace,Consolas,monospace;font-size:11px;opacity:.55;word-break:break-all}',
|
|
75
|
+
'.tw-cfg-group{border:1px solid rgba(127,127,127,.24);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:10px}',
|
|
76
|
+
'.tw-cfg-group-title{font-weight:600;font-size:12px;opacity:.82}',
|
|
77
|
+
'.tw-cfg-field{display:grid;grid-template-columns:minmax(120px,190px) 1fr;gap:4px 12px;align-items:start}',
|
|
78
|
+
'.tw-cfg-label{font-weight:500;padding-top:3px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
|
|
79
|
+
'.tw-cfg-body{display:flex;flex-direction:column;gap:3px;min-width:0}',
|
|
80
|
+
'.tw-cfg-input{border:1px solid rgba(127,127,127,.38);background:transparent;color:inherit;border-radius:6px;padding:3px 7px;font-size:12px;font-family:inherit;width:150px;max-width:100%}',
|
|
81
|
+
'.tw-cfg-input-wide{width:100%}',
|
|
82
|
+
'.tw-cfg-select{width:auto;min-width:220px}',
|
|
83
|
+
'.tw-cfg-textarea{width:100%;min-height:96px;resize:vertical;line-height:1.5;font-family:var(--ui-mono,ui-monospace,Consolas,monospace);padding:6px 8px}',
|
|
84
|
+
'.tw-cfg-readonly{background:rgba(127,127,127,.07);cursor:default}',
|
|
85
|
+
'.tw-cfg-hint{font-size:11px;opacity:.62}',
|
|
86
|
+
'.tw-cfg-badge{font-size:10px;padding:0 4px;border-radius:3px;background:rgba(64,150,255,.18);border:1px solid rgba(64,150,255,.3)}',
|
|
87
|
+
'.tw-cfg-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
88
|
+
].join('\n');
|
|
89
|
+
|
|
90
|
+
function ensureStyle() {
|
|
91
|
+
if (typeof document === 'undefined') return () => {};
|
|
92
|
+
const existing = document.getElementById(STYLE_ID);
|
|
93
|
+
if (existing !== null) return () => {};
|
|
94
|
+
const node = document.createElement('style');
|
|
95
|
+
node.id = STYLE_ID;
|
|
96
|
+
node.textContent = CSS;
|
|
97
|
+
document.head.appendChild(node);
|
|
98
|
+
return () => node.remove();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── 数据 ────────────────────────────────────────────────────────────
|
|
102
|
+
async function loadState(sessionId) {
|
|
103
|
+
const query = sessionId === null ? '' : `?sessionId=${encodeURIComponent(sessionId)}`;
|
|
104
|
+
const response = await fetch(`/tower/state${query}`, { headers: { accept: 'application/json' } });
|
|
105
|
+
const body = await response.json();
|
|
106
|
+
if (body.ok === false) throw new Error(body.error ?? `HTTP ${response.status}`);
|
|
107
|
+
return body;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function runAction(action) {
|
|
111
|
+
const response = await fetch('/tower/action', {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: { 'content-type': 'application/json' },
|
|
114
|
+
body: JSON.stringify(action),
|
|
115
|
+
});
|
|
116
|
+
const body = await response.json();
|
|
117
|
+
if (body.ok === false) throw new Error(body.error ?? `HTTP ${response.status}`);
|
|
118
|
+
return body;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function loadConfig() {
|
|
122
|
+
const response = await fetch('/tower/config', { headers: { accept: 'application/json' } });
|
|
123
|
+
const body = await response.json();
|
|
124
|
+
if (body.ok === false) throw new Error(body.error ?? `HTTP ${response.status}`);
|
|
125
|
+
return body;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function writeConfig(payload) {
|
|
129
|
+
const response = await fetch('/tower/config', {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: { 'content-type': 'application/json' },
|
|
132
|
+
body: JSON.stringify(payload),
|
|
133
|
+
});
|
|
134
|
+
const body = await response.json();
|
|
135
|
+
if (body.ok === false) throw new Error(body.error ?? `HTTP ${response.status}`);
|
|
136
|
+
return body;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── 小工具 ──────────────────────────────────────────────────────────
|
|
140
|
+
function fmt(tokens) {
|
|
141
|
+
if (typeof tokens !== 'number' || !Number.isFinite(tokens)) return '—';
|
|
142
|
+
if (tokens < 1000) return String(Math.round(tokens));
|
|
143
|
+
if (tokens < 1000000) return `${(tokens / 1000).toFixed(1)}K`;
|
|
144
|
+
return `${(tokens / 1000000).toFixed(1)}M`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 比例转百分比:小值保留有效位,别把 0.00001 显示成 0%。 */
|
|
148
|
+
function formatPercent(ratio) {
|
|
149
|
+
const percent = ratio * 100;
|
|
150
|
+
if (percent === 0) return '0%';
|
|
151
|
+
if (percent < 0.01) return `${percent.toFixed(3)}%`;
|
|
152
|
+
if (percent < 1) return `${percent.toFixed(2)}%`;
|
|
153
|
+
return `${Math.round(percent * 10) / 10}%`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function tierTag(tier) {
|
|
157
|
+
return h('span', { className: `tw-tag tw-tag-t${Math.min(tier, 4)}` }, `T${tier}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function statusLabel(status) {
|
|
161
|
+
if (status === 'committed') return '已进入上下文';
|
|
162
|
+
if (status === 'consumed') return '已并入上层';
|
|
163
|
+
return '待提交';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 上下文占用条:三段一条,两种状态。
|
|
168
|
+
*
|
|
169
|
+
* 未越线(当前 ≤ 触发线):
|
|
170
|
+
* 实心 = 已用 | 斜纹 = 当前到触发线之间还剩的空间 | 灰 = 触发线之后(到不了)
|
|
171
|
+
*
|
|
172
|
+
* 已越线(当前 > 触发线):
|
|
173
|
+
* 实心 = 触发线以内的部分 | 更深的一截 = 越过触发线的部分(下次请求就会压缩)| 灰 = 当前之后
|
|
174
|
+
*
|
|
175
|
+
* 斜纹在越线时宽度本来就是 0(没有剩余空间了),所以那种状态改用「深一截」表达,
|
|
176
|
+
* 而不是让条上凭空少一段。
|
|
177
|
+
*/
|
|
178
|
+
/**
|
|
179
|
+
* 上一次压力判定翻译成一句人话 —— 面板上直接回答「为什么没压」。
|
|
180
|
+
* @param {object|null} pressure
|
|
181
|
+
* @returns {string}
|
|
182
|
+
*/
|
|
183
|
+
function describePressure(pressure) {
|
|
184
|
+
if (pressure === null || pressure === undefined) return '上次判定:还没跑过';
|
|
185
|
+
const text = (value) => (value === null || value === undefined ? '?' : fmt(value));
|
|
186
|
+
const times = pressure.calls === null || pressure.calls === undefined ? '' : `(第 ${pressure.calls} 次判定)`;
|
|
187
|
+
switch (pressure.kind) {
|
|
188
|
+
case 'below-trigger':
|
|
189
|
+
return `上次判定:未到触发线(内容 ${text(pressure.content ?? pressure.total)} / ${text(pressure.trigger)})${times}`;
|
|
190
|
+
case 'no-window':
|
|
191
|
+
return '上次判定:量不到上下文窗口,这次不压';
|
|
192
|
+
case 'no-range':
|
|
193
|
+
return '上次判定:可压区为空(老材料都在保护区里,或已经进了块)';
|
|
194
|
+
case 'no-block':
|
|
195
|
+
return `上次判定:没有可提交的块(账本 ${pressure.blocks ?? 0} 块,可压区 ${pressure.run ?? 0} 条)`;
|
|
196
|
+
case 'commit': {
|
|
197
|
+
// 关键:这里要报「实际成功几块」,只报计划会让人以为已经压下去了。
|
|
198
|
+
const outcome = pressure.outcome;
|
|
199
|
+
if (outcome !== null && outcome !== undefined) {
|
|
200
|
+
if (outcome.succeeded === 0) {
|
|
201
|
+
return `上次判定:计划提交 ${outcome.planned} 块,实际 0 块(${outcome.error ?? 'compactRegion 返回空'})`;
|
|
202
|
+
}
|
|
203
|
+
return `上次判定:提交 ${outcome.succeeded}/${outcome.planned} 块${outcome.error === null || outcome.error === undefined ? '' : `,中途失败:${outcome.error}`}`;
|
|
204
|
+
}
|
|
205
|
+
return `上次判定:计划提交 ${pressure.blockCount ?? 0} 块(${(pressure.blockIds ?? []).join(' ')})${pressure.projected === null || pressure.projected === undefined ? '' : ` → ${fmt(pressure.projected)}`}`;
|
|
206
|
+
}
|
|
207
|
+
default:
|
|
208
|
+
return `上次判定:${pressure.kind ?? '未知'}`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 上一次观察判定翻译成一句人话 —— 面板上回答「为什么没蒸」。
|
|
215
|
+
* @param {object|null} observation
|
|
216
|
+
* @param {object} budget
|
|
217
|
+
* @returns {string}
|
|
218
|
+
*/
|
|
219
|
+
function describeObservation(observation, budget) {
|
|
220
|
+
if (observation === null || observation === undefined) return '上次观察:还没跑过';
|
|
221
|
+
switch (observation.kind) {
|
|
222
|
+
case 'no-material':
|
|
223
|
+
return `上次观察:没有可蒸材料(不够一个单位 ${fmt(observation.unit ?? 0)} tok,或已经进了块)`;
|
|
224
|
+
case 'stocked':
|
|
225
|
+
return '上次观察:库存已够,先不蒸(防浪费)';
|
|
226
|
+
case 'no-quota':
|
|
227
|
+
return '上次观察:并发名额被下层占满';
|
|
228
|
+
case 'no-engine':
|
|
229
|
+
// 进程刚起来时宿主还没调过一次 compactIfNeeded,引擎实例自然还没露面;
|
|
230
|
+
// 第一次请求走完就有了。这是启动瞬间的正常状态,不是故障。
|
|
231
|
+
return '上次观察:等首次请求(引擎实例还没露面)';
|
|
232
|
+
case 'no-window':
|
|
233
|
+
return '上次观察:量不到上下文窗口';
|
|
234
|
+
case 'launched': {
|
|
235
|
+
const jobs = (observation.plan ?? []).map((job) => `T${job.tier}×${job.count}`).join(' ');
|
|
236
|
+
const planned = (observation.plan ?? []).reduce((sum, job) => sum + job.count, 0);
|
|
237
|
+
const material = (observation.available ?? []).reduce((sum, item) => sum + (item[1] ?? 0), 0);
|
|
238
|
+
// 发起数少于计划时,多半是材料只够这么多份 —— 直接说出来,省得以为并发没生效。
|
|
239
|
+
const short = material > 0 && material <= planned ? ` · 材料只够 ${material} 份` : '';
|
|
240
|
+
return `上次观察:发起 ${jobs || '一批'}(单位 ${observation.unit ?? '?'} tok)${short}`;
|
|
241
|
+
}
|
|
242
|
+
default:
|
|
243
|
+
return `上次观察:${observation.kind ?? '未知'}`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 「当前组装」:上下文此刻由什么拼成。
|
|
249
|
+
*
|
|
250
|
+
* 只报表层上的东西:已提交的块按层计数,其余(还没被压掉的消息)算原文;
|
|
251
|
+
* 待提交的块不在上下文里,不算。块号不写进来 —— 上面那张表点一下就有,
|
|
252
|
+
* 这行的任务是回答「谁占得多」,不是「具体是哪一块」。
|
|
253
|
+
* @param {{tiers?: Array<{tier:number,count:number}>, raw?: number}|null} composition
|
|
254
|
+
* @returns {string}
|
|
255
|
+
*/
|
|
256
|
+
function describeComposition(composition) {
|
|
257
|
+
const tiers = composition?.tiers ?? [];
|
|
258
|
+
const raw = Number(composition?.raw ?? 0);
|
|
259
|
+
if (tiers.length === 0 && raw === 0) return '当前组装:还没开始';
|
|
260
|
+
const parts = tiers.map((item) => `T${item.tier} ${item.count} 块`);
|
|
261
|
+
if (raw > 0) parts.push(tiers.length === 0 ? `原文 ${raw} 条(还没压过)` : `原文 ${raw} 条`);
|
|
262
|
+
return `当前组装:${parts.join(' + ')}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function UsageBar({ budget, pressure, observation, composition }) {
|
|
266
|
+
const limit = budget.contextWindow;
|
|
267
|
+
if (typeof limit !== 'number' || limit <= 0) {
|
|
268
|
+
return h('div', { className: 'tw-empty' }, '拿不到模型上下文窗口');
|
|
269
|
+
}
|
|
270
|
+
// 轴仍然是整个窗口:最前面那截黑色是永远压不掉的固定开销(system + 工具 schema),
|
|
271
|
+
// 后面的实心才是「内容」。触发线按内容算,所以画在 fixed + trigger 的位置上。
|
|
272
|
+
const fixed = typeof budget.fixed?.total === 'number' ? budget.fixed.total : 0;
|
|
273
|
+
const content = typeof budget.content === 'number' ? budget.content : budget.total;
|
|
274
|
+
const usable = typeof budget.usable === 'number' ? budget.usable : Math.max(1, limit - fixed);
|
|
275
|
+
const used = Math.min(limit, fixed + content);
|
|
276
|
+
const percent = Math.min(100, (used / limit) * 100);
|
|
277
|
+
const fixedPct = Math.max(0, Math.min(100, (fixed / limit) * 100));
|
|
278
|
+
const triggerAt = typeof budget.triggerTokens === 'number' ? fixed + budget.triggerTokens : null;
|
|
279
|
+
const trigger = triggerAt === null ? null : Math.min(100, (triggerAt / limit) * 100);
|
|
280
|
+
const over = trigger !== null && percent > trigger;
|
|
281
|
+
const bandFrom = trigger === null ? percent : Math.min(percent, trigger);
|
|
282
|
+
const bandWidth = over || trigger === null ? 0 : Math.max(0, trigger - bandFrom);
|
|
283
|
+
const solid = over ? Math.max(0, trigger - fixedPct) : Math.max(0, percent - fixedPct);
|
|
284
|
+
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } }, [
|
|
285
|
+
h('div', { key: 'bar', className: 'tw-bar' }, [
|
|
286
|
+
fixedPct <= 0 ? null : h('div', {
|
|
287
|
+
key: 'fixed',
|
|
288
|
+
className: 'tw-bar-fixed',
|
|
289
|
+
style: { width: `${fixedPct}%` },
|
|
290
|
+
}),
|
|
291
|
+
trigger === null ? null : h('div', {
|
|
292
|
+
key: 'dead',
|
|
293
|
+
className: 'tw-bar-dead',
|
|
294
|
+
style: { left: `${over ? percent : trigger}%` },
|
|
295
|
+
}),
|
|
296
|
+
bandWidth <= 0 ? null : h('div', {
|
|
297
|
+
key: 'stripes',
|
|
298
|
+
className: 'tw-bar-stripes',
|
|
299
|
+
style: { left: `${bandFrom}%`, width: `${bandWidth}%` },
|
|
300
|
+
}),
|
|
301
|
+
h('div', {
|
|
302
|
+
key: 'real',
|
|
303
|
+
className: 'tw-bar-real',
|
|
304
|
+
style: { left: `${fixedPct}%`, width: `${solid}%` },
|
|
305
|
+
}),
|
|
306
|
+
over ? h('div', {
|
|
307
|
+
key: 'over',
|
|
308
|
+
className: 'tw-bar-over',
|
|
309
|
+
style: { left: `${trigger}%`, width: `${percent - trigger}%` },
|
|
310
|
+
}) : null,
|
|
311
|
+
]),
|
|
312
|
+
h('div', { key: 'legend', className: 'tw-legend' }, [
|
|
313
|
+
h('span', { key: 'now' }, fixed > 0
|
|
314
|
+
? `内容 ${fmt(content)} / 可用 ${fmt(usable)} · 固定 ${fmt(fixed)}`
|
|
315
|
+
: `内容 ${fmt(content)} / 可用 ${fmt(usable)}`),
|
|
316
|
+
over
|
|
317
|
+
? h('span', { key: 't', style: { opacity: .95 } }, `已越过触发线 ${fmt(budget.triggerTokens)}(内容)`)
|
|
318
|
+
: h('span', { key: 't' }, `触发线 ${fmt(budget.triggerTokens)}(内容)`),
|
|
319
|
+
h('span', { key: 'g' }, `目标线 ${fmt(budget.targetTokens)}`),
|
|
320
|
+
]),
|
|
321
|
+
h('div', { key: 'verdict', className: 'tw-verdict' }, describePressure(pressure)),
|
|
322
|
+
h('div', { key: 'observer', className: 'tw-verdict' }, describeObservation(observation, budget)),
|
|
323
|
+
h('div', { key: 'assembly', className: 'tw-verdict' }, describeComposition(composition)),
|
|
324
|
+
]);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function BlockRows({ block, selected, onToggle }) {
|
|
328
|
+
const [open, setOpen] = React.useState(false);
|
|
329
|
+
return h(Fragment, null, [
|
|
330
|
+
h('tr', {
|
|
331
|
+
key: `${block.blockId}-row`,
|
|
332
|
+
className: `tw-row${selected ? ' tw-row-selected' : ''}`,
|
|
333
|
+
onClick: () => setOpen(!open),
|
|
334
|
+
}, [
|
|
335
|
+
h('td', {
|
|
336
|
+
key: 'sel',
|
|
337
|
+
onClick: (event) => { event.stopPropagation(); onToggle(block); },
|
|
338
|
+
}, selected ? '☑' : '☐'),
|
|
339
|
+
h('td', { key: 'id' }, h('code', null, block.blockId)),
|
|
340
|
+
h('td', { key: 'tier' }, tierTag(block.tier)),
|
|
341
|
+
h('td', { key: 'cover' }, `${block.covers.length} 条`),
|
|
342
|
+
h('td', { key: 'size' }, `${block.coveredLabel} → ${block.summaryLabel}`),
|
|
343
|
+
h('td', { key: 'status', className: 'tw-status' }, statusLabel(block.status)),
|
|
344
|
+
]),
|
|
345
|
+
open
|
|
346
|
+
? h('tr', { key: `${block.blockId}-body` }, h('td', { colSpan: 6, style: { padding: 0 } },
|
|
347
|
+
h('div', { className: 'tw-summary' }, block.summary)))
|
|
348
|
+
: null,
|
|
349
|
+
]);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function TreeView({ blocks }) {
|
|
353
|
+
if (blocks.length === 0) return h('div', { className: 'tw-empty' }, '空');
|
|
354
|
+
const byId = new Map(blocks.map((block) => [block.blockId, block]));
|
|
355
|
+
const childIds = new Set();
|
|
356
|
+
for (const block of blocks) for (const id of block.childBlockIds ?? []) childIds.add(id);
|
|
357
|
+
const roots = blocks.filter((block) => !childIds.has(block.blockId));
|
|
358
|
+
// 塔是自下而上合并出来的:一个块的 children 就是它合并掉的那些块。
|
|
359
|
+
const childrenOf = (block) => (block.childBlockIds ?? [])
|
|
360
|
+
.map((id) => byId.get(id))
|
|
361
|
+
.filter((child) => child !== undefined);
|
|
362
|
+
const render = (block) => {
|
|
363
|
+
const kids = childrenOf(block);
|
|
364
|
+
return h('li', { key: `${block.blockId}-${block.tier}` }, [
|
|
365
|
+
h('code', { key: 'id' }, block.blockId),
|
|
366
|
+
' ',
|
|
367
|
+
tierTag(block.tier),
|
|
368
|
+
h('span', { key: 'meta', className: 'tw-muted' }, ` ${block.covers.length} 条 · ${block.coveredLabel} → ${block.summaryLabel}`),
|
|
369
|
+
kids.length === 0 ? null : h('ul', { key: 'kids', className: 'tw-tree' }, kids.map((child) => render(child))),
|
|
370
|
+
]);
|
|
371
|
+
};
|
|
372
|
+
return h('ul', { className: 'tw-tree', style: { paddingLeft: 0 } }, roots.map((block) => render(block)));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* 渲染兜底。
|
|
377
|
+
*
|
|
378
|
+
* 面板里任何一处抛错,都变成一段可读的错误文本,而不是整块空白 ——
|
|
379
|
+
* 空白既查不出原因,也看不出是哪一层坏掉的。
|
|
380
|
+
*/
|
|
381
|
+
class PanelBoundary extends React.Component {
|
|
382
|
+
constructor(props) {
|
|
383
|
+
super(props);
|
|
384
|
+
this.state = { error: null };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
static getDerivedStateFromError(error) {
|
|
388
|
+
return { error };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
render() {
|
|
392
|
+
const { error } = this.state;
|
|
393
|
+
if (error !== null && error !== undefined) {
|
|
394
|
+
return h('div', { className: 'tw-root' }, [
|
|
395
|
+
h('div', { key: 'h', className: 'tw-err' }, `压缩面板渲染失败:${error?.message ?? String(error)}`),
|
|
396
|
+
h('pre', { key: 's', className: 'tw-summary', style: { whiteSpace: 'pre-wrap', fontSize: 11 } }, String(error?.stack ?? '').slice(0, 1500)),
|
|
397
|
+
]);
|
|
398
|
+
}
|
|
399
|
+
return this.props?.children ?? null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function SafeTowerView(props) {
|
|
404
|
+
return h(PanelBoundary, null, h(TowerView, props));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function SafeConfigPanel(props) {
|
|
408
|
+
return h(PanelBoundary, null, h(ConfigPanel, props));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function Panel({ state, selected, toggle, onAction, busy, error, note, showConfig, onToggleConfig }) {
|
|
412
|
+
const blocks = state.blocks ?? [];
|
|
413
|
+
const chosen = blocks.filter((block) => selected.has(block.blockId));
|
|
414
|
+
const canMerge = chosen.length >= 2
|
|
415
|
+
&& new Set(chosen.map((block) => block.tier)).size === 1
|
|
416
|
+
&& chosen.every((block) => block.status === 'committed');
|
|
417
|
+
|
|
418
|
+
const body = [
|
|
419
|
+
h('div', { key: 'usage', className: 'tw-card' }, [
|
|
420
|
+
h('div', { key: 'title', className: 'tw-card-title' }, '上下文占用'),
|
|
421
|
+
h(UsageBar, { key: 'bar', budget: state.budget ?? {}, pressure: state.pressure ?? null, observation: state.observation ?? null, composition: state.composition ?? null }),
|
|
422
|
+
]),
|
|
423
|
+
|
|
424
|
+
h('div', { key: 'blocks', className: 'tw-card' }, [
|
|
425
|
+
h('div', { key: 'title', className: 'tw-card-title' }, [
|
|
426
|
+
h('span', { key: 'a' }, `块(${blocks.length})`),
|
|
427
|
+
h('span', { key: 'b', className: 'tw-muted' }, `已进入 ${blocks.filter((b) => b.status === 'committed').length} · 已并入 ${blocks.filter((b) => b.status === 'consumed').length} · 待提交 ${blocks.filter((b) => b.status === 'dormant').length}`),
|
|
428
|
+
]),
|
|
429
|
+
blocks.length === 0
|
|
430
|
+
? h('div', { key: 'empty', className: 'tw-empty' }, '还没有块。回合结束后会自动蒸馏,也可以点「立即蒸馏」。')
|
|
431
|
+
: h('div', { key: 'wrap', className: 'tw-table-wrap' }, h('table', { className: 'tw-table' }, [
|
|
432
|
+
h('thead', { key: 'thead' }, h('tr', null, [
|
|
433
|
+
h('th', { key: 'sel', style: { width: 24 } }, ''),
|
|
434
|
+
h('th', { key: 'id' }, '块号'),
|
|
435
|
+
h('th', { key: 'tier' }, '层'),
|
|
436
|
+
h('th', { key: 'cover' }, '覆盖'),
|
|
437
|
+
h('th', { key: 'size' }, '原 → 摘要'),
|
|
438
|
+
h('th', { key: 'status' }, '状态'),
|
|
439
|
+
])),
|
|
440
|
+
h('tbody', { key: 'tbody' }, blocks.map((block) => h(BlockRows, {
|
|
441
|
+
key: block.blockId,
|
|
442
|
+
block,
|
|
443
|
+
selected: selected.has(block.blockId),
|
|
444
|
+
onToggle: toggle,
|
|
445
|
+
}))),
|
|
446
|
+
])),
|
|
447
|
+
]),
|
|
448
|
+
|
|
449
|
+
h('div', { key: 'tree', className: 'tw-card' }, [
|
|
450
|
+
h('div', { key: 'title', className: 'tw-card-title' }, '塔结构'),
|
|
451
|
+
h('div', { key: 'wrap', className: 'tw-tree-wrap' }, h(TreeView, { blocks })),
|
|
452
|
+
]),
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
return h('div', { className: 'tw-root' }, [
|
|
456
|
+
h('div', { key: 'head', className: 'tw-head' }, [
|
|
457
|
+
h('span', { key: 't', className: 'tw-title' }, showConfig ? '压缩参数' : '压缩记录'),
|
|
458
|
+
h('span', { key: 's', className: 'tw-muted' }, showConfig ? '' : (state.session?.id ?? '')),
|
|
459
|
+
h('span', { key: 'sp', style: { flex: 1 } }),
|
|
460
|
+
showConfig ? null : h('button', { key: 'distill', className: 'tw-btn', disabled: busy, onClick: () => onAction({ kind: 'distill' }) }, '立即蒸馏'),
|
|
461
|
+
showConfig ? null : h('button', { key: 'commit', className: 'tw-btn tw-btn-primary', disabled: busy, onClick: () => onAction({ kind: 'commit' }) }, '立即提交'),
|
|
462
|
+
showConfig ? null : h('button', {
|
|
463
|
+
key: 'merge',
|
|
464
|
+
className: 'tw-btn',
|
|
465
|
+
disabled: busy || !canMerge,
|
|
466
|
+
title: canMerge ? '' : '勾选两个同层且已进入上下文的块',
|
|
467
|
+
onClick: () => onAction({ kind: 'merge', blockIds: chosen.map((block) => block.blockId) }),
|
|
468
|
+
}, '合并选中'),
|
|
469
|
+
h('button', {
|
|
470
|
+
key: 'config',
|
|
471
|
+
className: 'tw-btn',
|
|
472
|
+
title: showConfig ? '回到压缩记录' : '打开压缩参数',
|
|
473
|
+
onClick: () => onToggleConfig(),
|
|
474
|
+
}, showConfig ? '返回' : '设置'),
|
|
475
|
+
]),
|
|
476
|
+
error === null ? null : h('div', { key: 'err', className: 'tw-err' }, error),
|
|
477
|
+
note === null ? null : h('div', { key: 'note', className: 'tw-note' }, note),
|
|
478
|
+
...(showConfig ? [h(ConfigPanel, { key: 'config', embedded: true })] : body),
|
|
479
|
+
]);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── 配置表单(挂在插件页的 plugins.bundle.config 槽上) ──────────────
|
|
483
|
+
function ConfigField({ field, value, options, dirty, presetText, busy, onChange, onReset }) {
|
|
484
|
+
const isBool = field.type === 'bool';
|
|
485
|
+
const isText = field.type === 'text';
|
|
486
|
+
const isSelect = field.type === 'select';
|
|
487
|
+
const isTextarea = field.type === 'textarea';
|
|
488
|
+
// 选了预设时,文本框换成「只读展示那段预设」;只有自定义档才可编辑。
|
|
489
|
+
const showingPreset = isTextarea && typeof presetText === 'string';
|
|
490
|
+
const blocked = busy;
|
|
491
|
+
const percent = field.type === 'ratio' && typeof value === 'number' && Number.isFinite(value)
|
|
492
|
+
? `(= ${formatPercent(value)})`
|
|
493
|
+
: '';
|
|
494
|
+
const current = showingPreset ? presetText : (value === undefined || value === null ? '' : String(value));
|
|
495
|
+
// 目录里没有当前值(例如旧的手填值、或 provider 暂时不可用)也要能看见,别默默显示成第一项。
|
|
496
|
+
const choices = (options ?? []).slice();
|
|
497
|
+
if (isSelect && choices.every((option) => option.value !== current)) {
|
|
498
|
+
choices.push({ value: current, label: current === '' ? '跟随当前会话' : `${current}(目录里没有)` });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const control = isBool
|
|
502
|
+
? h('input', {
|
|
503
|
+
type: 'checkbox',
|
|
504
|
+
checked: value === true,
|
|
505
|
+
disabled: blocked,
|
|
506
|
+
onChange: (event) => onChange(event.target.checked),
|
|
507
|
+
})
|
|
508
|
+
: isSelect
|
|
509
|
+
? h('select', {
|
|
510
|
+
className: 'tw-cfg-input tw-cfg-select',
|
|
511
|
+
value: current,
|
|
512
|
+
disabled: blocked,
|
|
513
|
+
onChange: (event) => onChange(event.target.value),
|
|
514
|
+
}, choices.map((option) => h('option', { key: option.value, value: option.value }, option.label)))
|
|
515
|
+
: isTextarea
|
|
516
|
+
? h('textarea', {
|
|
517
|
+
className: `tw-cfg-input tw-cfg-textarea${showingPreset ? ' tw-cfg-readonly' : ''}`,
|
|
518
|
+
rows: field.rows ?? 6,
|
|
519
|
+
value: current,
|
|
520
|
+
readOnly: showingPreset,
|
|
521
|
+
disabled: blocked,
|
|
522
|
+
placeholder: field.placeholder ?? '',
|
|
523
|
+
onChange: (event) => onChange(event.target.value),
|
|
524
|
+
})
|
|
525
|
+
: h('input', {
|
|
526
|
+
className: `tw-cfg-input${isText ? ' tw-cfg-input-wide' : ''}`,
|
|
527
|
+
type: isText ? 'text' : 'number',
|
|
528
|
+
value: current,
|
|
529
|
+
min: field.min,
|
|
530
|
+
max: field.max,
|
|
531
|
+
step: field.step,
|
|
532
|
+
disabled: blocked,
|
|
533
|
+
onChange: (event) => onChange(event.target.value),
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
return h('div', { className: 'tw-cfg-field', 'data-field': field.key }, [
|
|
537
|
+
h('div', { key: 'label', className: 'tw-cfg-label' }, [
|
|
538
|
+
field.label,
|
|
539
|
+
showingPreset ? h('span', { key: 'preset', className: 'tw-cfg-badge' }, '预设(只读)') : null,
|
|
540
|
+
dirty ? h('span', { key: 'badge', className: 'tw-cfg-badge' }, '未保存') : null,
|
|
541
|
+
field.override && !dirty && !showingPreset ? h('span', { key: 'ov', className: 'tw-cfg-badge' }, '已自定义') : null,
|
|
542
|
+
]),
|
|
543
|
+
h('div', { key: 'body', className: 'tw-cfg-body' }, [
|
|
544
|
+
h('div', { key: 'row', style: { display: 'flex', alignItems: isTextarea ? 'flex-start' : 'center', gap: 8, flexWrap: 'wrap' } }, [
|
|
545
|
+
control,
|
|
546
|
+
h('span', { key: 'unit', className: 'tw-muted', style: { fontSize: 11 } },
|
|
547
|
+
[percent, field.unit ?? ''].filter(Boolean).join(' ')),
|
|
548
|
+
field.override && !showingPreset
|
|
549
|
+
? h('button', {
|
|
550
|
+
key: 'reset',
|
|
551
|
+
className: 'tw-btn',
|
|
552
|
+
style: { padding: '1px 6px', fontSize: 11 },
|
|
553
|
+
disabled: blocked,
|
|
554
|
+
onClick: () => onReset(field.key),
|
|
555
|
+
}, '恢复默认')
|
|
556
|
+
: null,
|
|
557
|
+
]),
|
|
558
|
+
h('div', { key: 'hint', className: 'tw-cfg-hint' }, field.hint ?? ''),
|
|
559
|
+
]),
|
|
560
|
+
]);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function ConfigPanel(props) {
|
|
564
|
+
// 嵌在「压缩」面板里时,外层已经有标题和返回按钮,这里不再重复一行标题。
|
|
565
|
+
const embedded = props?.embedded === true;
|
|
566
|
+
const [data, setData] = React.useState(null);
|
|
567
|
+
const [draft, setDraft] = React.useState({});
|
|
568
|
+
const [error, setError] = React.useState(null);
|
|
569
|
+
const [note, setNote] = React.useState(null);
|
|
570
|
+
const [busy, setBusy] = React.useState(false);
|
|
571
|
+
|
|
572
|
+
const refresh = React.useCallback(() => {
|
|
573
|
+
loadConfig()
|
|
574
|
+
.then((next) => { setData(next); setDraft({}); setError(null); })
|
|
575
|
+
.catch((cause) => setError(String(cause?.message ?? cause)));
|
|
576
|
+
}, []);
|
|
577
|
+
|
|
578
|
+
React.useEffect(() => { refresh(); }, [refresh]);
|
|
579
|
+
|
|
580
|
+
React.useEffect(() => {
|
|
581
|
+
if (note === null) return undefined;
|
|
582
|
+
const timer = setTimeout(() => setNote(null), 8000);
|
|
583
|
+
return () => clearTimeout(timer);
|
|
584
|
+
}, [note]);
|
|
585
|
+
|
|
586
|
+
const fields = React.useMemo(
|
|
587
|
+
() => (data?.groups ?? []).flatMap((group) => group.fields.map((field) => ({ ...field, group: group.title }))),
|
|
588
|
+
[data],
|
|
589
|
+
);
|
|
590
|
+
const byKey = React.useMemo(() => new Map(fields.map((field) => [field.key, field])), [fields]);
|
|
591
|
+
const valueOf = (field) => (Object.hasOwn(draft, field.key) ? draft[field.key] : field.value);
|
|
592
|
+
|
|
593
|
+
if (data === null) {
|
|
594
|
+
return h('div', { className: 'tw-cfg' }, error === null ? '加载中…' : h('div', { className: 'tw-err' }, error));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const dirty = Object.keys(draft).length > 0;
|
|
598
|
+
|
|
599
|
+
// 模型下拉的候选来自宿主的模型目录(服务端汇总后随配置一起下发)。
|
|
600
|
+
const catalog = data.catalog ?? { providers: [], models: [] };
|
|
601
|
+
const optionValue = (key) => {
|
|
602
|
+
const field = byKey.get(key);
|
|
603
|
+
if (field === undefined) return '';
|
|
604
|
+
const raw = Object.hasOwn(draft, key) ? draft[key] : field.value;
|
|
605
|
+
return raw === undefined || raw === null ? '' : String(raw);
|
|
606
|
+
};
|
|
607
|
+
const optionsFor = (field) => {
|
|
608
|
+
// 固定候选项(例如提示词三档)直接来自字段元数据。
|
|
609
|
+
if (Array.isArray(field.options)) return field.options.map((option) => ({ value: option.value, label: option.label }));
|
|
610
|
+
if (field.source === 'provider') {
|
|
611
|
+
return [
|
|
612
|
+
{ value: '', label: '跟随当前会话' },
|
|
613
|
+
...(catalog.providers ?? []).map((provider) => ({
|
|
614
|
+
value: provider.id,
|
|
615
|
+
label: provider.name === provider.id ? provider.id : `${provider.name}(${provider.id})`,
|
|
616
|
+
})),
|
|
617
|
+
];
|
|
618
|
+
}
|
|
619
|
+
const provider = field.pairKey === undefined ? '' : optionValue(field.pairKey);
|
|
620
|
+
const models = (catalog.models ?? []).filter((model) => provider === '' || model.provider === provider);
|
|
621
|
+
return [
|
|
622
|
+
{ value: '', label: '跟随当前会话' },
|
|
623
|
+
...models.map((model) => ({
|
|
624
|
+
value: model.id,
|
|
625
|
+
label: provider === ''
|
|
626
|
+
? `${model.provider} / ${model.name === model.id ? model.id : model.name}`
|
|
627
|
+
: (model.name === model.id ? model.id : model.name),
|
|
628
|
+
})),
|
|
629
|
+
];
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* 这个输入框此刻该以只读方式展示哪段预设正文。
|
|
634
|
+
* 返回字符串 = 只读展示它;返回 null = 可编辑(自定义档,或本来就不是提示词字段)。
|
|
635
|
+
*/
|
|
636
|
+
const presetTextFor = (field) => {
|
|
637
|
+
if (field.modeKey === undefined || field.presetFor === undefined) return null;
|
|
638
|
+
const presetKey = field.presetFor[optionValue(field.modeKey)];
|
|
639
|
+
if (presetKey === undefined) return null;
|
|
640
|
+
const text = data.presets?.[presetKey];
|
|
641
|
+
return typeof text === 'string' ? text : null;
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const change = (field, value) => {
|
|
645
|
+
// 从预设切到「自定义」时,把刚才只读展示的那段正文复制进草稿,
|
|
646
|
+
// 用户是在一份已知可用的文本上改,而不是对着空白框重写。
|
|
647
|
+
const prefill = {};
|
|
648
|
+
if (field.targetKey !== undefined && value === 'custom') {
|
|
649
|
+
const target = byKey.get(field.targetKey);
|
|
650
|
+
const presetKey = target?.presetFor?.[optionValue(field.key)];
|
|
651
|
+
const text = presetKey === undefined ? '' : (data.presets?.[presetKey] ?? '');
|
|
652
|
+
if (text !== '' && optionValue(field.targetKey) === '') prefill[field.targetKey] = text;
|
|
653
|
+
}
|
|
654
|
+
setDraft((previous) => {
|
|
655
|
+
const next = { ...previous, [field.key]: value, ...prefill };
|
|
656
|
+
if (field.source === 'provider') {
|
|
657
|
+
// 换了 provider,旧的 model 未必还在目录里:退回「跟随会话」,让用户重选。
|
|
658
|
+
for (const other of fields) if (other.pairKey === field.key) next[other.key] = '';
|
|
659
|
+
} else if (field.source === 'model' && field.pairKey !== undefined && value !== '') {
|
|
660
|
+
// 从「全部模型」里挑,就把 provider 一起定下来,避免存出半截路由。
|
|
661
|
+
const picked = (catalog.models ?? []).find((model) => model.id === value);
|
|
662
|
+
if (picked !== undefined) next[field.pairKey] = picked.provider;
|
|
663
|
+
}
|
|
664
|
+
return next;
|
|
665
|
+
});
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const save = () => {
|
|
669
|
+
const patch = {};
|
|
670
|
+
for (const [key, raw] of Object.entries(draft)) {
|
|
671
|
+
const field = byKey.get(key);
|
|
672
|
+
if (field === undefined) continue;
|
|
673
|
+
if (field.type === 'bool') patch[key] = raw === true;
|
|
674
|
+
else if (field.type === 'text' || field.type === 'select' || field.type === 'textarea') patch[key] = String(raw ?? '');
|
|
675
|
+
else {
|
|
676
|
+
const numeric = typeof raw === 'number' ? raw : Number(String(raw).trim());
|
|
677
|
+
if (!Number.isFinite(numeric)) {
|
|
678
|
+
setError(`「${field.label}」不是有效数字`);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
patch[key] = numeric;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
setBusy(true);
|
|
685
|
+
setError(null);
|
|
686
|
+
writeConfig({ patch })
|
|
687
|
+
.then((next) => {
|
|
688
|
+
setData(next);
|
|
689
|
+
setDraft({});
|
|
690
|
+
setNote('已保存,立即生效');
|
|
691
|
+
})
|
|
692
|
+
.catch((cause) => setError(String(cause?.message ?? cause)))
|
|
693
|
+
.finally(() => setBusy(false));
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
const resetOne = (key) => {
|
|
697
|
+
setBusy(true);
|
|
698
|
+
setError(null);
|
|
699
|
+
writeConfig({ reset: [key] })
|
|
700
|
+
.then((next) => {
|
|
701
|
+
setData(next);
|
|
702
|
+
setDraft((previous) => {
|
|
703
|
+
const copy = { ...previous };
|
|
704
|
+
delete copy[key];
|
|
705
|
+
return copy;
|
|
706
|
+
});
|
|
707
|
+
setNote('已恢复 profile 里的值');
|
|
708
|
+
})
|
|
709
|
+
.catch((cause) => setError(String(cause?.message ?? cause)))
|
|
710
|
+
.finally(() => setBusy(false));
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
return h('div', { className: 'tw-cfg' }, [
|
|
714
|
+
h('div', { key: 'head', className: 'tw-cfg-head' }, [
|
|
715
|
+
h('span', { key: 't', className: 'tw-title' }, embedded ? '' : '压缩参数'),
|
|
716
|
+
h('span', { key: 'sp', style: { flex: 1 } }),
|
|
717
|
+
h('button', {
|
|
718
|
+
key: 'save',
|
|
719
|
+
className: 'tw-btn tw-btn-primary',
|
|
720
|
+
disabled: busy || !dirty,
|
|
721
|
+
onClick: save,
|
|
722
|
+
}, busy ? '保存中…' : dirty ? `保存 ${Object.keys(draft).length} 项` : '保存'),
|
|
723
|
+
h('button', {
|
|
724
|
+
key: 'reload',
|
|
725
|
+
className: 'tw-btn',
|
|
726
|
+
disabled: busy,
|
|
727
|
+
onClick: refresh,
|
|
728
|
+
}, '重新读取'),
|
|
729
|
+
]),
|
|
730
|
+
h('div', { key: 'path', className: 'tw-cfg-path' }, `覆盖层写入 ${data.path}`),
|
|
731
|
+
error === null ? null : h('div', { key: 'err', className: 'tw-err' }, error),
|
|
732
|
+
note === null ? null : h('div', { key: 'note', className: 'tw-note' }, note),
|
|
733
|
+
data.problem === undefined || data.problem === null
|
|
734
|
+
? null
|
|
735
|
+
: h('div', { key: 'problem', className: 'tw-err' }, data.problem),
|
|
736
|
+
...(data.groups ?? []).map((group) => h('div', { key: group.title, className: 'tw-cfg-group' }, [
|
|
737
|
+
h('div', { key: 'title', className: 'tw-cfg-group-title' }, group.title),
|
|
738
|
+
...group.fields.map((field) => h(ConfigField, {
|
|
739
|
+
key: field.key,
|
|
740
|
+
field,
|
|
741
|
+
options: optionsFor(field),
|
|
742
|
+
value: valueOf(field),
|
|
743
|
+
dirty: Object.hasOwn(draft, field.key),
|
|
744
|
+
presetText: presetTextFor(field),
|
|
745
|
+
busy,
|
|
746
|
+
onChange: (value) => change(field, value),
|
|
747
|
+
onReset: resetOne,
|
|
748
|
+
})),
|
|
749
|
+
])),
|
|
750
|
+
h('div', { key: 'foot', className: 'tw-cfg-hint' },
|
|
751
|
+
'优先级:这里写的值 > profile 里 cordis.patch.yml 的 config > 内置默认。保存后立刻生效,不需要重启。'),
|
|
752
|
+
]);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function TowerView(props) {
|
|
756
|
+
const hostSessionId = typeof props?.sessionId === 'string' && props.sessionId.length > 0 ? props.sessionId : null;
|
|
757
|
+
const [state, setState] = React.useState(null);
|
|
758
|
+
const [error, setError] = React.useState(null);
|
|
759
|
+
const [busy, setBusy] = React.useState(false);
|
|
760
|
+
const [selected, setSelected] = React.useState(() => new Set());
|
|
761
|
+
const [note, setNote] = React.useState(null);
|
|
762
|
+
/** 面板内切换:压缩记录 ⟷ 压缩参数(右上角「设置」) */
|
|
763
|
+
const [showConfig, setShowConfig] = React.useState(false);
|
|
764
|
+
|
|
765
|
+
const refresh = React.useCallback(() => {
|
|
766
|
+
loadState(hostSessionId)
|
|
767
|
+
.then((next) => { setState(next); setError(null); })
|
|
768
|
+
.catch((cause) => setError(String(cause?.message ?? cause)));
|
|
769
|
+
}, [hostSessionId]);
|
|
770
|
+
|
|
771
|
+
React.useEffect(() => {
|
|
772
|
+
refresh();
|
|
773
|
+
const timer = setInterval(refresh, REFRESH_MS);
|
|
774
|
+
return () => clearInterval(timer);
|
|
775
|
+
}, [refresh]);
|
|
776
|
+
|
|
777
|
+
// 提示只用来回一句「已排队」,过一会儿自己消失,避免看起来像状态。
|
|
778
|
+
React.useEffect(() => {
|
|
779
|
+
if (note === null) return undefined;
|
|
780
|
+
const timer = setTimeout(() => setNote(null), 20000);
|
|
781
|
+
return () => clearTimeout(timer);
|
|
782
|
+
}, [note]);
|
|
783
|
+
|
|
784
|
+
const toggle = React.useCallback((block) => {
|
|
785
|
+
setSelected((previous) => {
|
|
786
|
+
const next = new Set(previous);
|
|
787
|
+
if (next.has(block.blockId)) next.delete(block.blockId);
|
|
788
|
+
else next.add(block.blockId);
|
|
789
|
+
return next;
|
|
790
|
+
});
|
|
791
|
+
}, []);
|
|
792
|
+
|
|
793
|
+
const onAction = React.useCallback((action) => {
|
|
794
|
+
setBusy(true);
|
|
795
|
+
setNote(null);
|
|
796
|
+
runAction({ ...action, sessionId: state?.session?.id ?? hostSessionId ?? undefined })
|
|
797
|
+
.then((result) => {
|
|
798
|
+
setSelected(new Set());
|
|
799
|
+
setNote(typeof result?.message === 'string' ? result.message : null);
|
|
800
|
+
refresh();
|
|
801
|
+
})
|
|
802
|
+
.catch((cause) => setError(String(cause?.message ?? cause)))
|
|
803
|
+
.finally(() => setBusy(false));
|
|
804
|
+
}, [state, hostSessionId, refresh]);
|
|
805
|
+
|
|
806
|
+
// 钩子必须全部在早退之前:第一次渲染 state 还是 null,若这一行放在 return 之后,
|
|
807
|
+
// 数据到位后的第二次渲染就会多出一个钩子,React 直接报 #310(Rendered more hooks…)。
|
|
808
|
+
const onToggleConfig = React.useCallback(() => setShowConfig((previous) => !previous), []);
|
|
809
|
+
|
|
810
|
+
if (state === null) {
|
|
811
|
+
return h('div', { className: 'tw-root' }, error === null ? '加载中…' : h('div', { className: 'tw-err' }, error));
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return h(Panel, { state, selected, toggle, onAction, busy, error, note, showConfig, onToggleConfig });
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function apply(ctx) {
|
|
818
|
+
ctx.effect(() => ensureStyle(), `${NS}: styles`);
|
|
819
|
+
// `conversation.view` 的 owner props 里没有 sessionId(只有 inspectCall / viewRequest /
|
|
820
|
+
// openView / completeViewRequest),所以用注册项自带的 inject(sessionId) 把会话号
|
|
821
|
+
// 补进组件 props —— 官方 trajectory 视图就是这么拿的。
|
|
822
|
+
ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
|
823
|
+
name: 'conversation.view',
|
|
824
|
+
id: 'tower',
|
|
825
|
+
order: 40,
|
|
826
|
+
label: () => '压缩',
|
|
827
|
+
inject: (sessionId) => ({ sessionId }),
|
|
828
|
+
}, SafeTowerView));
|
|
829
|
+
|
|
830
|
+
// 插件页的配置表单:keyed 槽,键是 profile 里列的 bundle 包名。
|
|
831
|
+
ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
|
|
832
|
+
name: 'plugins.bundle.config',
|
|
833
|
+
key: 'dsh-compaction-tower',
|
|
834
|
+
label: () => '压缩参数',
|
|
835
|
+
inject: () => ({}),
|
|
836
|
+
}, SafeConfigPanel));
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return { apply, inject, TowerView, ConfigPanel };
|
|
840
|
+
},
|
|
841
|
+
});
|