From bc32fbbc98a1fed3c4cafcc0d0a2707ae31b6436 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 20:07:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(webui):=20=E9=98=B6=E6=AE=B5=E7=AE=A1?= =?UTF-8?q?=E9=81=93=E7=9A=84=E5=B7=A5=E5=85=B7=E6=A0=BC=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E5=B1=95=E7=A4=BA=E6=9C=80=E6=96=B0=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「工具」是循环格:一轮里可能调几十次工具/输出通道。此前每一次都追加成 chip,这一格被撑成一长条,反而看不出「现在在调什么」。改为固定一行的 滚动视口——只留最新一条,右侧给出本轮累计次数;新调用到来时旧条向上 滚出、新条滑入(morph 就地改文本不会重放 CSS 动画,故摘类 + 强制 reflow + 重加类)。并给 chip 名称加 .rt-chip-t 承接省略号,窄框不再硬切半截。 配套 TestStagePipelineToolCellShowsLatestOnly 钉住该口径。 --- internal/plugins/webui/dashboard.css | 46 ++++++++++++++++ internal/plugins/webui/dashboard.js | 72 ++++++++++++++++++++------ internal/plugins/webui/handler_test.go | 34 ++++++++++++ 3 files changed, 135 insertions(+), 17 deletions(-) diff --git a/internal/plugins/webui/dashboard.css b/internal/plugins/webui/dashboard.css index bf5f361..1a0dea4 100644 --- a/internal/plugins/webui/dashboard.css +++ b/internal/plugins/webui/dashboard.css @@ -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 继承,不因为换成 而变粗;省略号靠它生效 */ + 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); diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index 3c7d9cc..0358cc3 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -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 += '+' + (d - n) + ""; return out + ""; } + // 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 ( + '' + ico + + '' + escHtml(t.short || t.label) + "" + + (t.n > 1 ? 'x' + t.n + "" : "") + + "" + ); + } // 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 ( - '' + ico + - escHtml(t.short || t.label) + - (t.n > 1 ? 'x' + t.n + "" : "") + - "" - ); - }) - .join("") - : '' + __("无", "none") + ""; + // 「工具」格是**循环**格:一轮里可能调几十次工具/输出通道。把每一次都 + // 追加成 chip,这格会被撑成一长条,读者反而看不出「现在正在调什么」。 + // 所以它只保留**最新一条**,旧条在同一行视口里向上滚走 + // (rtFlashLatestTool 触发滑入),右侧另给本轮累计次数。 + var cls = "rt-pipe-events"; + var body; + if (!items.length) { + body = '' + __("无", "none") + ""; + } 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]) + + 'x' + total + ""; + } else { + body = items.map(rtChipEl).join(""); + } return ( '
' + '
' + RT_ICO[s.ico] + @@ -879,7 +896,7 @@ '">' + RT_ICO.loop + "" : "") + "
" + - '
' + body + "
" + + '
' + body + "
" + "
" ); }).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") { diff --git a/internal/plugins/webui/handler_test.go b/internal/plugins/webui/handler_test.go index 1061e07..54ce292 100644 --- a/internal/plugins/webui/handler_test.go +++ b/internal/plugins/webui/handler_test.go @@ -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 的调用每次都拿到完整聊天记录