From 51d9acb2797e1da07c01bd686fbeb00c73ca5a69 Mon Sep 17 00:00:00 2001
From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com>
Date: Mon, 14 Sep 2026 16:51:41 +0800
Subject: [PATCH] =?UTF-8?q?fix(webui):=20=E6=8B=93=E6=89=91=E6=8C=89?=
=?UTF-8?q?=E5=AE=9E=E6=B5=8B=E5=AE=B9=E5=99=A8=E5=AE=BD=E5=B8=83=E5=B1=80?=
=?UTF-8?q?=20+=20=E5=AD=97=E5=8F=B7/=E6=88=AA=E6=96=AD=EF=BC=8C=E4=BF=AE?=
=?UTF-8?q?=E9=97=B4=E8=B7=9D=E5=A4=B1=E8=A1=A1=E4=B8=8E=E6=96=87=E5=AD=97?=
=?UTF-8?q?=E9=9A=BE=E8=AF=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
三处实机问题:
1) viewBox 固定 640 而容器 ~920,浏览器按 'meet' 把内容顶到左上、右侧空出一大片
—— 观感就是「间距不对」。改为 viewBox 宽 = 实测容器宽、width 用像素值,
缩放恒为 1(已用 getScreenCTM().a 验证)。
2) 文字全是 9-11px + 低对比度硬编码色(#8b90a5)→ 难读。字号提到 10.5-12.5px,
fill/font-size 改走 .tp-* 类,颜色交给 --text-primary/--text-muted 主题变量。
3) 列短的一侧原来顶在带上半、节点居中,连线又长又歪;长通道名还会溢出到邻居身上。
现在两列在带内各自居中、节点块高度参与带高计算(单行带不再把节点名压到下一条带),
长名按估算宽度截断加 …,完整名放
悬停可见。
另:rtSpark 用的 _rtEdgeIn/_rtEdgeOut 键与取值方式未变,光点动画照旧。
---
internal/plugins/webui/dashboard.css | 13 +++++
internal/plugins/webui/dashboard.js | 82 +++++++++++++++++++++-------
2 files changed, 75 insertions(+), 20 deletions(-)
diff --git a/internal/plugins/webui/dashboard.css b/internal/plugins/webui/dashboard.css
index 994eca57..5de23aae 100644
--- a/internal/plugins/webui/dashboard.css
+++ b/internal/plugins/webui/dashboard.css
@@ -2397,6 +2397,19 @@
.rt-svg text {
fill: var(--text-primary);
}
+ /* 拓扑图的文字全部走类(不用 font-size/fill 属性):
+ 1) 字号统一可读(原来 9–11px 且低对比度,实机很难看清);
+ 2) 颜色交给主题变量,切主题/配色时不用重算 SVG。
+ 长名截断由 JS 的 rtClip 负责(SVG 没有 text-overflow)。 */
+ .rt-topo-svg .tp-name { font-size: 12px; fill: var(--text-primary); }
+ .rt-topo-svg .tp-sub { font-size: 10.5px; fill: var(--text-muted); font-variant-numeric: tabular-nums; }
+ .rt-topo-svg .tp-val { font-size: 12.5px; font-weight: 700; fill: var(--text-primary); }
+ .rt-topo-svg .tp-lab { font-size: 11px; font-weight: 600; }
+ .rt-topo-svg .tp-warn { font-size: 10.5px; fill: #ffb86b; }
+ .rt-topo-svg .tp-hint { font-size: 10.5px; fill: var(--text-muted); }
+ .rt-topo-svg .tp-edge { fill: none; }
+ .rt-topo-svg .tp-edge-in { stroke-opacity: 0.45; stroke-width: 1.4; }
+ .rt-topo-svg .tp-edge-out { stroke-opacity: 0.35; stroke-width: 1.2; }
/* 第五条:排队队列(无级别)。用虚线分隔 + 中性色,与“有级别”的四条区分开——
它不是优先级,而是另一**类别**(排队 vs 中断),同一套配色会误导。 */
.rt-level.rt-level-queued {
diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js
index cccd5555..eed9a043 100644
--- a/internal/plugins/webui/dashboard.js
+++ b/internal/plugins/webui/dashboard.js
@@ -925,9 +925,41 @@
// 右边是它能写的 outputch。连线即路由;光点沿连线跑表示消息正在流动。
// 与旧版「归属框 → 单个内核盒」的差别:内核盒只有一个,看不出"这条输入到底
// 喂给了哪个子",而子 agent 才是运行态里最该看清的东西。
+ // rtTopoWidth 用**实测容器宽**当 viewBox 宽:viewBox 宽与渲染宽一致时缩放才是
+ // 1:1。此前固定 640 而容器 ~920,浏览器按 "meet" 把内容顶在左上、右侧空出一大块,
+ // 观感就是"间距不对"。
+ function rtTopoWidth() {
+ var host = document.getElementById("rt-sec-topo");
+ var w = host ? host.clientWidth : 0;
+ if (!w) {
+ var panel = document.getElementById("rt-panel");
+ w = panel ? panel.clientWidth : 0;
+ }
+ if (!w || w < 360) w = 900;
+ return Math.round(w) - 4;
+ }
+
+ // rtClip 按估算宽度截断长通道名(SVG 没有 text-overflow,只能自己量)。
+ // 全角按 1em、其余按 0.56em 估宽:只求不越界,不求像素级精确。
+ function rtClip(text, maxPx, fontPx) {
+ var s = String(text == null ? "" : text);
+ var w = 0;
+ var i = 0;
+ for (; i < s.length; i++) {
+ var cw = s.charCodeAt(i) > 0x2e80 ? fontPx : fontPx * 0.56;
+ if (w + cw > maxPx) break;
+ w += cw;
+ }
+ if (i >= s.length) return s;
+ return s.slice(0, Math.max(1, i - 1)) + "…";
+ }
+
function rtAgentTopology(agents) {
- var ROW = 26, GAP = 16, NODE_R = 19, W = 640, MAX_OUT = 8;
- var IN_X = 6, IN_W = 176, NX = 272, OUT_X = 344, OUT_W = 244;
+ var W = rtTopoWidth();
+ var ROW = 30, BOX_H = 20, GAP = 14, NODE_R = 20, MAX_OUT = 8;
+ // 三列按比例分:输入 0~32%,节点居中在 44%,输出 56%~100%,两侧留白对称。
+ var IN_X = 0, IN_W = Math.round(W * 0.32);
+ var NX = Math.round(W * 0.44), OUT_X = Math.round(W * 0.56), OUT_W = W - Math.round(W * 0.56) - 2;
var out = [];
_rtEdgeIn = {};
_rtEdgeOut = {};
@@ -942,27 +974,37 @@
}
var y = 8;
if (!agents.length) {
- out.push('' + __("暂无通道 / agent", "no channels / agents") + "");
+ out.push('' + __("暂无通道 / agent", "no channels / agents") + "");
y = 44;
}
agents.forEach(function (a) {
- var rows = Math.max(a.inputs.length, a.outputs.length, 1);
- var bandH = rows * ROW + GAP;
- var cy = y + (rows * ROW) / 2;
+ var nin = a.inputs.length;
+ var nout = Math.min(a.outputs.length, MAX_OUT);
+ var rows = Math.max(nin, nout, 1);
+ var truncated = a.outputs.length > MAX_OUT;
+ // 内容高必须**容得下节点块**(圆环 + 下方标签一行),否则单行带里节点和它的
+ // 名字会溢出到上/下一个带上("间距不对"的一个来源)。
+ var nodeBlock = NODE_R * 2 + (a.res && a.res.context_full ? 52 : 20);
+ var contentH = Math.max(rows * ROW, nodeBlock, truncated ? nout * ROW + 18 : 0);
+ var bandH = contentH + GAP;
+ var cy = y + contentH / 2;
+ // 短的一列在带内**居中**:连线短而对称,不再出现"左边挤在上半、右边铺满"的错位感。
+ var inTop = y + (contentH - nin * ROW) / 2;
+ var outTop = y + (contentH - nout * ROW) / 2;
out.push('');
out.push('');
// 输入通道 → agent
a.inputs.forEach(function (c, i) {
- var ry = y + i * ROW + ROW / 2;
- out.push('');
- out.push('' + esc(c.name) + "");
+ var ry = inTop + i * ROW + ROW / 2;
+ out.push('');
+ out.push('' + esc(rtClip(c.name, IN_W - 44, 12)) + "" + esc(c.name) + "");
if (c.capacity) {
- out.push('' + esc(c.capacity) + "");
+ out.push('' + esc(c.capacity) + "");
}
var d = pathD(IN_X + IN_W, ry, NX - NODE_R - 2, cy);
_rtEdgeIn[c.name] = d;
- out.push('');
+ out.push('');
});
// agent 节点:两圈 + 一段负载弧(stroke-dasharray 画进度)
@@ -974,30 +1016,30 @@
'" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="' + ((a.load / 100) * C).toFixed(1) + " " + C.toFixed(1) +
'" transform="rotate(-90 ' + NX + " " + cy + ')"/>',
);
- out.push('' + a.load + "");
- out.push('' + esc(a.child ? a.id : __("根 agent", "root")) + "");
+ out.push('' + a.load + "");
+ out.push('' + esc(rtClip(a.child ? a.id : __("根 agent", "root"), 140, 11)) + "");
if (a.res && a.res.context_full) {
- out.push('' + __("上下文已满", "ctx full") + "");
+ out.push('' + __("上下文已满", "ctx full") + "");
}
// agent → 输出通道
a.outputs.slice(0, MAX_OUT).forEach(function (name, i) {
- var ry = y + i * ROW + ROW / 2;
- out.push('');
- out.push('' + esc(name) + "");
+ var ry = outTop + i * ROW + ROW / 2;
+ out.push('');
+ out.push('' + esc(rtClip(name, OUT_W - 20, 12)) + "" + esc(name) + "");
var d = pathD(NX + NODE_R + 2, cy, OUT_X, ry);
_rtEdgeOut[a.id + "\u0000" + name] = d;
- out.push('');
+ out.push('');
});
if (a.outputs.length > MAX_OUT) {
- out.push('+' + (a.outputs.length - MAX_OUT) + " " + __("更多", "more") + "");
+ out.push('+' + (a.outputs.length - MAX_OUT) + " " + __("更多", "more") + "");
}
y += bandH;
});
var H = Math.max(60, y);
return (
'' + __("拓扑(圆环 = 负载)", "Topology (ring = load)") + "
" +
- '