feat(webui): 阶段管道的工具格改为滚动展示最新一条调用

「工具」是循环格:一轮里可能调几十次工具/输出通道。此前每一次都追加成
chip,这一格被撑成一长条,反而看不出「现在在调什么」。改为固定一行的
滚动视口——只留最新一条,右侧给出本轮累计次数;新调用到来时旧条向上
滚出、新条滑入(morph 就地改文本不会重放 CSS 动画,故摘类 + 强制 reflow
+ 重加类)。并给 chip 名称加 .rt-chip-t 承接省略号,窄框不再硬切半截。

配套 TestStagePipelineToolCellShowsLatestOnly 钉住该口径。
This commit is contained in:
JianFeeeee
2026-09-14 20:07:29 +08:00
parent f3fa0e8f5b
commit bc32fbbc98
3 changed files with 135 additions and 17 deletions

View File

@ -2767,6 +2767,44 @@
gap: 4px;
min-height: 23px;
}
/* 「工具」格的滚动视口:固定一行高,只露最新一条。
工具调用一轮内可能几十次,全部追加会把这一格撑成长条,反而看不出
「现在在调什么」;这里让旧条向上滚出视口,留下最新那条。 */
.rt-pipe-scroll {
flex-wrap: nowrap;
height: 23px;
min-height: 23px;
overflow: hidden;
align-items: center;
}
/* 名称可缩、计数不可缩:窄框里先截断工具名,累计次数始终看得见。 */
.rt-pipe-scroll .rt-chip {
flex: 0 1 auto;
min-width: 0;
max-width: none;
}
/* rtFlashLatestTool 每来一条新调用就重加一次,重置动画时间轴。 */
.rt-chip-enter {
animation: rt-chip-scroll-in 0.3s var(--ease-out);
}
@keyframes rt-chip-scroll-in {
from {
transform: translateY(115%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.rt-scroll-count {
flex: 0 0 auto;
margin-left: auto;
font-style: normal;
font-size: 10.5px;
opacity: 0.6;
font-variant-numeric: tabular-nums;
}
/* 拓扑光点:沿 animateMotion 给的路径跑;不吃鼠标事件,跑完由 JS 摘掉。 */
.rt-spark {
pointer-events: none;
@ -2923,6 +2961,14 @@
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.rt-chip-t {
/* font-weight 继承,不因为换成 <b> 而变粗;省略号靠它生效 */
font-weight: inherit;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rt-chip-tool {
border-color: rgba(136, 192, 208, 0.42);
color: var(--frost-300);

View File

@ -15,6 +15,7 @@
pipelinePhase: "", // 当前阶段SSE stage 事件驱动总览页)
pipelineTimer: null,
stageTrail: [], // 本轮已发生的事件轨迹(工具/输出调用,供阶段管道展示循环)
toolFlash: false, // 本轮新到一条工具调用:本轮渲染后给「工具」格放一次滑入动画
healthResult: null,
starmapInit: false,
starmapLoading: false,
@ -841,6 +842,19 @@
if (d > n) out += '<b class="rt-slots-more">+' + (d - n) + "</b>";
return out + "</span>";
}
// rtChipEl 画一枚事件 chip。工具与输出通道调用各有配色名称包进
// .rt-chip-t让窄框里由它做省略号截断.rt-chip 本身是 flex 容器,
// text-overflow 对直接子文本不生效,会被硬切掉半截)。
function rtChipEl(t) {
var kind = t.kind || "stage";
var ico = kind === "output" ? RT_ICO.out : kind === "tool" ? RT_ICO.tool : "";
return (
'<span class="rt-chip rt-chip-' + kind + '" title="' + escHtml(t.label) + '">' + ico +
'<b class="rt-chip-t">' + escHtml(t.short || t.label) + "</b>" +
(t.n > 1 ? '<i class="rt-chip-n">x' + t.n + "</i>" : "") +
"</span>"
);
}
// rtPipelineHtml 画阶段管道:**五个等大的表框**,每框里是本阶段本轮发生的事。
//
// 为什么不是「小圆点 + 连接线 + 9px 小字」:那种画法在总览里几乎读不出
@ -853,22 +867,25 @@
var items = (trail || []).filter(function (t) {
return (t.g | 0) === i;
});
var body = items.length
? items
.map(function (t) {
var kind = t.kind || "stage";
var ico =
kind === "output" ? RT_ICO.out : kind === "tool" ? RT_ICO.tool : "";
return (
'<span class="rt-chip rt-chip-' + kind + '" title="' +
escHtml(t.label) + '">' + ico +
escHtml(t.short || t.label) +
(t.n > 1 ? '<i class="rt-chip-n">x' + t.n + "</i>" : "") +
"</span>"
);
})
.join("")
: '<span class="rt-chip rt-chip-none">' + __("无", "none") + "</span>";
// 「工具」格是**循环**格:一轮里可能调几十次工具/输出通道。把每一次都
// 追加成 chip这格会被撑成一长条读者反而看不出「现在正在调什么」。
// 所以它只保留**最新一条**,旧条在同一行视口里向上滚走
// rtFlashLatestTool 触发滑入),右侧另给本轮累计次数。
var cls = "rt-pipe-events";
var body;
if (!items.length) {
body = '<span class="rt-chip rt-chip-none">' + __("无", "none") + "</span>";
} else if (s.loop) {
cls += " rt-pipe-scroll";
var total = 0;
for (var k = 0; k < items.length; k++) total += items[k].n || 1;
body =
rtChipEl(items[items.length - 1]) +
'<i class="rt-scroll-count" title="' +
__("本轮工具调用累计次数", "tool calls this turn") + '">x' + total + "</i>";
} else {
body = items.map(rtChipEl).join("");
}
return (
'<div class="rt-pipe-cell' + (i === g ? " active" : "") + '">' +
'<div class="rt-pipe-head">' + RT_ICO[s.ico] +
@ -879,7 +896,7 @@
'">' + RT_ICO.loop + "</em>"
: "") +
"</div>" +
'<div class="rt-pipe-events">' + body + "</div>" +
'<div class="' + cls + '">' + body + "</div>" +
"</div>"
);
}).join("");
@ -906,6 +923,21 @@
if (arr.length > 24) arr.shift();
}
// rtFlashLatestTool 让「工具」格里最新那条做一次「向上滚入」。
//
// morph 是就地改文本:新工具到来时 chip 节点不会被替换,纯 CSS 的
// animation 因此不会自动重放。这里摘类 → 强制 reflow → 重加类,重置动画
// 时间轴。prefers-reduced-motion 由样式表统一压到 0.01ms,无需在此判断。
function rtFlashLatestTool() {
var box = document.querySelector(".rt-pipe-events.rt-pipe-scroll");
if (!box) return;
var chip = box.querySelector(".rt-chip");
if (!chip) return;
chip.classList.remove("rt-chip-enter");
void chip.offsetWidth; // 强制 reflow少了这句重加类不会重启动画
chip.classList.add("rt-chip-enter");
}
// ---- per-agent 负载 ----
//
// 负载 = 该 agent **自己的**调度器积压折算成的百分比。
@ -3686,6 +3718,7 @@
} else if (phase === "before_toolcall" && tool) {
var isOut = tool.indexOf("output_") === 0;
rtTrailPush(2, isOut ? "output" : "tool", tool, rtShortTool(tool));
state.toolFlash = true;
} else if (phase === "before_output") {
rtTrailPush(3, "stage", __("生成回复", "generate reply"), __("生成", "gen"));
} else if (phase === "after_output") {
@ -3698,6 +3731,11 @@
if (document.getElementById("rt-sec-pipe")) renderRuntime();
}, 2500);
if (document.getElementById("rt-sec-pipe")) renderRuntime();
// 渲染后再放动画morph 已经把 chip 文本更新成最新那条。
if (state.toolFlash) {
state.toolFlash = false;
rtFlashLatestTool();
}
if (phase === "pre_action") {
state.chatStage = __("AI 思考中...", "AI thinking...");
} else if (phase === "before_toolcall") {

View File

@ -1339,6 +1339,40 @@ func TestDashboardAssetsSplit(t *testing.T) {
}
}
// TestStagePipelineToolCellShowsLatestOnly 钉住总览「阶段管道」里工具格的展示口径:
// 只显示**最新一条**工具/输出调用(单行视口 + 滑入动画),而不是把本轮每一次
// 都追加成 chip —— 后者在几十次工具调用的一轮里会把这一格撑成长条,
// 反而看不出「现在正在调什么」。
//
// 前端没有 JS 测试运行器,这里按仓库既有做法(见 TestDashboardAssetsSplit
// 直接对产物做字符串钉桩:若日后有人把这条口径改回「不断追加」,用例会红。
func TestStagePipelineToolCellShowsLatestOnly(t *testing.T) {
js, err := dashboardFS.ReadFile("dashboard.js")
if err != nil {
t.Fatalf("读 dashboard.js: %v", err)
}
css, err := dashboardFS.ReadFile("dashboard.css")
if err != nil {
t.Fatalf("读 dashboard.css: %v", err)
}
sjs, scss := string(js), string(css)
for _, want := range []string{
"items[items.length - 1]", // 工具格只取末条(最新)
"rt-pipe-scroll", // 单行滚动视口
"rtFlashLatestTool", // 新调用到来时重放滑入动画
"rt-scroll-count", // 右侧本轮累计次数
} {
if !strings.Contains(sjs, want) {
t.Errorf("dashboard.js 缺 %q工具格应只滚动展示最新一条调用", want)
}
}
for _, want := range []string{".rt-pipe-scroll", "@keyframes rt-chip-scroll-in"} {
if !strings.Contains(scss, want) {
t.Errorf("dashboard.css 缺 %q工具格的滚动视口/滑入动画样式", want)
}
}
}
// TestChatHistoryDefaultIsPaged 钉住 /chat/history 的默认页大小。
//
// 原先缺省 limit=0 表示"不限制",于是任何不带 limit 的调用每次都拿到完整聊天记录