diff --git a/internal/plugins/webui/dashboard.css b/internal/plugins/webui/dashboard.css index 994eca5..5de23aa 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 cccd555..eed9a04 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)") + "
" + - '
' + + '
' + out.join("") + "
" );