From e4d69fa140d4794190547aab5a00a711cc77979f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 15:33:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(webui):=20=E6=80=BB=E8=A7=88=E6=94=B9?= =?UTF-8?q?=E7=89=88=20=E2=80=94=E2=80=94=20=E9=98=B6=E6=AE=B5=E7=AE=A1?= =?UTF-8?q?=E9=81=93=E6=BB=91=E5=9D=97=20/=20per-agent=20=E8=B4=9F?= =?UTF-8?q?=E8=BD=BD=E7=8E=AF=20/=20=E9=80=9A=E9=81=93=E2=86=92agent=20?= =?UTF-8?q?=E6=8B=93=E6=89=91=E4=B8=8E=E5=85=89=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 总览页此前是一堆数字与文字块,看不出「这一轮走到哪、谁忙、消息从哪进哪出」。 本次把运行态面板改成以图形为主: - 阶段管道:七阶段滑块,由 SSE stage 事件驱动,当前阶段高亮、滑块滑过去; 一轮结束(after_output 或 2.5s 无事件)自动回到空闲,不做假动画。 - 队列与中断栈:沿用五条进度条(L1–L4 + 排队),中断栈补一条深度进度条。 - Agent 拓扑:改成「每 agent 一条横带」——左 inputch、中 agent 节点(圆环 = 负载)、 右 outputch,连线即路由;删掉旧的「归属框 + 单个内核盒」画法(看得出哪个子接了哪条输入)。 - 光点动画:channel_input(新增轻量 SSE 事件)沿 inputch→agent 连线跑; agent_output 沿 agent→outputch 连线跑。用 SMIL animateMotion,不需要 rAF 循环。 - 负载:由该 agent **自己的**调度器积压(排队 / 四级中断 / 中断栈)按级别加权折算, 环形图展示。为此把驻留子的调度器积压透出到状态面(SDK 纯追加字段)。 后端:sdk.ResidentStatus / core.ResidentInfo 增加子 agent 调度器积压四项; WebUI SSE 增加 channel_input 轻量事件(只带通道名与 agent id,不带正文)。 顺带收口对话区视觉(页签改分段控件、消息间距/气泡区分、输入区分隔线)。 --- internal/agent/core/resident.go | 18 ++ internal/agent/core/status.go | 5 + internal/plugins/webui/dashboard.css | 166 +++++++++-- internal/plugins/webui/dashboard.js | 374 +++++++++++++++++-------- internal/plugins/webui/handler_chat.go | 23 ++ internal/sdk/status.go | 9 + 6 files changed, 454 insertions(+), 141 deletions(-) diff --git a/internal/agent/core/resident.go b/internal/agent/core/resident.go index 77b6d9b..156ba94 100644 --- a/internal/agent/core/resident.go +++ b/internal/agent/core/resident.go @@ -61,6 +61,17 @@ type ResidentInfo struct { CreatedAt time.Time `json:"created_at"` TableSize int `json:"table_size"` Table []InputchRecord `json:"table,omitempty"` + + // Sched* 是这个驻留子**自己的**输入调度器积压摘要(排队 / 待处理中断 / + // 中断栈 / 四级中断队列)。 + // + // 为什么必须单列:每个驻留子是独立 agent,跑自己的事件循环与调度器 + // (见 agent.go 的 newScheduler)。KernelStatus.Scheduler 只是**根**那份, + // 拿它代表全部子会让界面把「某个子堵死」显示成「一切正常」。 + SchedReady int `json:"sched_ready"` + SchedPending int `json:"sched_pending"` + SchedStack int `json:"sched_stack"` + SchedQueues [5]int `json:"sched_queues"` } type residentChild struct { @@ -527,10 +538,17 @@ func (rc *residentChild) info() ResidentInfo { state, full := rc.state, rc.state == "contextfull" rc.mu.Unlock() table := rc.agent.inputchTableSnapshot() + // 子自己的调度器积压(per-agent 负载展示用)。schedulerStatus 只读快照, + // 每次状态轮询为每个子算一次,成本可忽略(驻留子数量是个位数)。 + sc := rc.agent.schedulerStatus() info := ResidentInfo{ ID: rc.id, State: state, InputChs: append([]string(nil), rc.inputChs...), AllowedOutputs: append([]string(nil), rc.allowed...), ContextFull: full, CreatedAt: rc.createdAt, TableSize: len(table), + SchedReady: sc.ReadyQueueDepth, + SchedPending: sc.PendingInterrupts, + SchedStack: sc.SuspendStack, + SchedQueues: sc.InterruptQueues, // Rounds = 子**已执行的轮次数**(调度器的执行计数,单调不减)。 // // 此前这里根本没填这个字段 ⇒ 父看到的永远是 `轮次=0`,与"处理表已有 N 条" diff --git a/internal/agent/core/status.go b/internal/agent/core/status.go index ff612b3..e855d4a 100644 --- a/internal/agent/core/status.go +++ b/internal/agent/core/status.go @@ -312,6 +312,11 @@ func residentStatuses(list []ResidentInfo) []ResidentStatus { InputChs: r.InputChs, AllowedOutputs: r.AllowedOutputs, InputChTable: r.TableSize, + // 子自己的调度器积压:per-agent 负载图靠这四项,缺了就只能画根。 + ReadyQueueDepth: r.SchedReady, + PendingInterrupts: r.SchedPending, + SuspendStack: r.SchedStack, + InterruptQueues: r.SchedQueues, } if !r.CreatedAt.IsZero() { st.CreatedAt = r.CreatedAt.Format(time.RFC3339) diff --git a/internal/plugins/webui/dashboard.css b/internal/plugins/webui/dashboard.css index 982a809..4cb5144 100644 --- a/internal/plugins/webui/dashboard.css +++ b/internal/plugins/webui/dashboard.css @@ -895,8 +895,8 @@ select { background: var(--bg-input); border: 1px solid var(--border-color); - border-radius: var(--radius-sm); - padding: 8px 12px; + border-radius: var(--radius-md); + padding: 9px 12px; color: var(--text-primary); font-size: 13px; width: 100%; @@ -1216,20 +1216,29 @@ min-height: 60vh; } .chat-tabs { - display: flex; - gap: 4px; + /* 分段控件(segmented control)而不是一排药丸 chip: + 四个页签是同一组互斥选项,「一个容器 + 一个高亮块」比「四个各自带边框的胶囊」 + 更准确地表达了这层关系,也少四道描边噪声。 */ + display: inline-flex; + gap: 3px; + padding: 3px; flex-wrap: wrap; - border-bottom: 1px solid var(--border-color); - padding-bottom: 10px; + align-self: flex-start; + border-radius: var(--radius-md); + background: var(--bg-input); + border: 1px solid var(--border-color); } .chat-tabs span { padding: 6px 14px; - font-size: 13px; + font-size: 12.5px; + font-weight: 500; cursor: pointer; color: var(--text-muted); - border-radius: var(--radius-pill); - border: 1px solid transparent; - transition: all 0.15s; + border-radius: 7px; + border: none; + transition: + background var(--dur-micro) var(--ease-out), + color var(--dur-micro) var(--ease-out); } .chat-tabs span:hover { color: var(--text-primary); @@ -1238,7 +1247,8 @@ .chat-tabs span.active { color: var(--accent); background: var(--accent-bg); - border-color: rgba(255, 127, 172, 0.35); + font-weight: 600; + box-shadow: inset 0 0 0 1px rgba(255, 127, 172, 0.28); } .chat-panel { display: none; @@ -1274,17 +1284,19 @@ .chat-messages { flex: 1; overflow-y: auto; - padding: 18px 18px 26px; + padding: 20px 20px 28px; margin-bottom: 0; display: flex; flex-direction: column; - gap: 6px; + /* 消息间距从 6px 拉到 14px:原来每条贴在一起,一屏十几条像一堵墙, + 分不清哪句是哪句。 */ + gap: 14px; min-height: 0; } .msg { display: flex; - gap: 8px; - margin-bottom: 2px; + gap: 10px; + margin-bottom: 0; align-items: flex-start; max-width: 100%; } @@ -1300,8 +1312,8 @@ max-width: 90%; } .msg-avatar { - width: 28px; - height: 28px; + width: 30px; + height: 30px; border-radius: 50%; overflow: hidden; display: flex; @@ -1309,6 +1321,8 @@ justify-content: center; font-size: 12px; flex-shrink: 0; + /* 细描边把头像从同色背景里“拓”出来,否则暗色下就是一团 */ + box-shadow: 0 0 0 1px var(--glass-border); } .msg-avatar img { width: 100%; @@ -1337,7 +1351,10 @@ } .msg-content { min-width: 0; - flex: 1; + flex: 1 1 auto; + /* 行长上限:整屏宽的一行文字没人读得下去(~78ch 是舒适区), + 也让「同一侧连续多条」自然堆成一列而不是横铺开。 */ + max-width: min(78ch, 86%); } .msg-content .msg-bubble + .msg-bubble { margin-top: 6px; @@ -1367,25 +1384,27 @@ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28); } .msg-bubble { - padding: 9px 14px; - border-radius: var(--radius-md); - font-size: 15px; - line-height: 1.62; + padding: 10px 14px; + border-radius: 12px; + font-size: 14.5px; + line-height: 1.65; word-break: break-word; position: relative; border: 1px solid transparent; transition: box-shadow var(--dur-normal) var(--ease-out); } .msg-user .msg-bubble { - background: var(--msg-bubble-bg); - color: var(--msg-bubble-color); - border-bottom-right-radius: 4px; - border-color: var(--msg-bubble-border); + /* 用户与助手必须**看得出不一样**:原来两侧同底色、只差一个圆角, + 整段对话读下来分不清谁说的。用户侧用 accent 渲染,助手侧保持中性面。 */ + background: var(--msg-user-bg); + color: var(--text-primary); + border-bottom-right-radius: 5px; + border-color: rgba(255, 127, 172, 0.26); } .msg-assistant .msg-bubble { background: var(--msg-bubble-bg); color: var(--msg-bubble-color); - border-bottom-left-radius: 4px; + border-bottom-left-radius: 5px; border-color: var(--msg-bubble-border); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); } @@ -1817,7 +1836,11 @@ display: flex; gap: 8px; flex-shrink: 0; - padding-top: 10px; + padding-top: 14px; + margin-top: 4px; + /* 输入区与消息流之间加一道分隔:否则最后一条消息与输入框糊在一起, + 看不出“消息到头了”。 */ + border-top: 1px solid var(--border-color); } .chat-input-row input { flex: 1; @@ -2519,3 +2542,90 @@ margin: 12px 0 6px; text-transform: uppercase; } + + /* ===== 阶段管道滑块(总览)===== + 七个阶段各一格,滑块停在当前阶段;空闲时整条降透明度,不做假动画。 + 滑块用绝对定位 + left 过渡实现"滑过去"(内联 left 由 rtPipelineHtml 给)。 */ + .rt-pipe { + position: relative; + padding: 4px 0 2px; + margin: 2px 0 8px; + transition: opacity 0.3s var(--ease-out); + } + .rt-pipe-idle { + opacity: 0.5; + } + .rt-pipe-track { + position: absolute; + left: 22px; + right: 22px; + top: 9px; + height: 2px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + } + .rt-pipe-nodes { + position: relative; + display: flex; + justify-content: space-between; + } + .rt-pipe-node { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + width: 44px; + } + .rt-pipe-node > i { + width: 11px; + height: 11px; + border-radius: 50%; + background: var(--bg-input); + border: 2px solid var(--border-color); + position: relative; + z-index: 1; + transition: + background 0.25s var(--ease-out), + border-color 0.25s var(--ease-out), + box-shadow 0.25s var(--ease-out); + } + .rt-pipe-node > b { + font-size: 9px; + font-weight: 500; + color: var(--text-muted); + white-space: nowrap; + } + .rt-pipe-node.done > i { + background: var(--frost-300); + border-color: var(--frost-300); + } + .rt-pipe-node.active > i { + background: var(--sakura-400); + border-color: var(--sakura-400); + box-shadow: 0 0 0 4px rgba(255, 127, 172, 0.18); + } + .rt-pipe-node.active > b { + color: var(--text-primary); + font-weight: 600; + } + .rt-pipe-knob { + position: absolute; + top: 4px; + width: 11px; + height: 11px; + margin-left: -5.5px; + border-radius: 50%; + background: #fff; + box-shadow: 0 0 10px rgba(255, 127, 172, 0.85); + transition: left 0.45s var(--ease-out); + z-index: 2; + } + .rt-pipe-idle .rt-pipe-knob { + box-shadow: none; + opacity: 0.5; + } + /* 拓扑光点:沿 animateMotion 给的路径跑;不吃鼠标事件,跑完由 JS 摘掉。 */ + .rt-spark { + pointer-events: none; + filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.8)); + } diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index 0baea14..5e91e46 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -12,6 +12,8 @@ messages: [], chatLoading: false, chatStage: "", + pipelinePhase: "", // 当前阶段(SSE stage 事件驱动总览页的滑块) + pipelineTimer: null, healthResult: null, starmapInit: false, starmapLoading: false, @@ -570,9 +572,9 @@ // 数据来自 /api/v1/runtime(内核 internal/sdk 暴露的 KernelStatus 子集), // 每 3 秒刷一次——运行态要"实时",而 /kernel 是 30KB 级的全量状态,不适合秒级轮询。 // - // 四个数字块回答"现在忙不忙":排队任务、待处理中断、中断栈深度、驻留子数量; - // 下面的四级条形回答"堵在哪一级";中断栈图回答"谁打断了谁"; - // 通道拓扑回答"消息从哪儿进、能往哪儿出"。 + // 面板从上到下:四个数字块回答"现在忙不忙";阶段管道滑块回答"这一轮走到哪"; + // 五条队列条形 + 中断栈回答"堵在哪一级/压了几层";最后的分带拓扑回答 + // "哪条输入喂给哪个 agent、哪个 agent 往哪条输出写、各自负载多高"。 // 四级语义直接照抄内核(internal/agent/core/scheduler.go 的 Level 定义), // 别自己起名字——前端叫法一旦和内核不一致,看板就成了误导。 var RT_LEVELS = [ @@ -691,119 +693,237 @@ }); } - // rtTopologySvg 把「通道 + 归属 + 路由」画成**一张图**: - // 左侧按归属框出输入通道 → 汇集母线 → 内核 → 输出母线 → 右侧输出通道。 - // 连线即路由;归属是容器与配色;容量是节点里的细条;输出能力是彩色圆点。 - // 为什么合进拓扑而不是单开一项:通道属于谁是**拓扑的一部分** - // (左边这些输入口分别被谁接管),拆两张表反而看不出关系。 - function rtTopologySvg(groups, channels) { - var ROW = 24, HEAD = 18, GPAD = 8, GAP = 10; - var W = 660; - var LX = 6, LW = 226; - var KX = 292, KW = 76; - var RX = 408, RW = 246; - var BUS_IN = 262, BUS_OUT = 396; - var top = 6; + // ---- 阶段管道滑块 ---- + // + // 七阶段与内核 sdk.Stage 一一对应(顺序即执行顺序),由 SSE `stage` 事件驱动: + // 哪个阶段在执行,滑块就滑到哪一格。空闲时整条管道降透明度,不做假动画。 + // + // 为什么放进总览:阶段管道回答"这一轮走到哪一步",总览其余图形回答"积压了多少", + // 两者合起来才是运行态。此前它只是对话页一个 10px 的角标,等于看不见。 + var RT_PIPE = [ + { k: "on_input", zh: "输入", en: "input" }, + { k: "pre_action", zh: "行动前", en: "pre-action" }, + { k: "post_action", zh: "行动后", en: "post-action" }, + { k: "before_toolcall", zh: "工具前", en: "pre-tool" }, + { k: "after_toolcall", zh: "工具后", en: "post-tool" }, + { k: "before_output", zh: "输出前", en: "pre-output" }, + { k: "after_output", zh: "输出后", en: "post-output" }, + ]; + + function rtPipelineHtml(phase) { + var idx = -1; + for (var i = 0; i < RT_PIPE.length; i++) if (RT_PIPE[i].k === phase) idx = i; + var n = RT_PIPE.length; + var pct = idx < 0 ? 0 : idx / (n - 1); + var nodes = RT_PIPE.map(function (s, i) { + var cls = "rt-pipe-node"; + if (i === idx) cls += " active"; + else if (idx >= 0 && i < idx) cls += " done"; + return '' + __(s.zh, s.en) + ""; + }).join(""); + return ( + '
' + + __("阶段管道", "Stage pipeline") + + (idx < 0 ? " " + __("(空闲)", "(idle)") : "") + + "
" + + '
' + + '' + + '' + + '
' + nodes + "
" + + "
" + ); + } + + // ---- per-agent 负载 ---- + // + // 负载 = 该 agent **自己的**调度器积压折算成的百分比。 + // + // 为什么按级别加权:四级中断里 L4 是内核独占、L3 是交互,堵在 L4 一条比堵在 + // L1 五条更严重;中断栈深度意味着有现场被压着。权重是启发式的("满载"没有 + // 硬定义),因此函数做成饱和式 backlog/(backlog+K):单调、上界 100、不越界。 + // context_full 单独加分:子上下文满了以后每轮都要压缩,本身就是高负载。 + function rtLoadPct(sc, ctxFull) { + sc = sc || {}; + var q = sc.interrupt_queues || [0, 0, 0, 0, 0]; + var backlog = + (sc.ready_queue_depth || 0) + + (sc.pending_interrupts || 0) * 1.5 + + (sc.suspend_stack || 0) * 2 + + (q[1] || 0) * 1 + + (q[2] || 0) * 1.2 + + (q[3] || 0) * 1.5 + + (q[4] || 0) * 2; + var pct = 100 * (backlog / (backlog + 8)); + if (ctxFull) pct += 15; + return Math.max(0, Math.min(100, Math.round(pct))); + } + + // rtAgents 把「inputch 归属 + 驻留子 + 各自的调度器积压」整理成每个 agent 一行。 + // 根 agent 的积压来自 rt.scheduler;驻留子的来自它自己的 ResidentStatus + // (见 internal/sdk/status.go 的 ReadyQueueDepth 等四项)。 + function rtAgents(rt) { + var rootID = rt.agent_id || ""; + var groups = rtOwnerGroups(rt.input_channels || [], rt.residents || [], rootID); + var outChans = (rt.channels || []).filter(function (c) { + return c.direction === "out" || c.direction === "io"; + }); + return groups.map(function (g) { + var id = g.owner || rootID; + var outputs = []; + var seen = {}; + function add(name) { + if (!name || seen[name]) return; + seen[name] = 1; + outputs.push(name); + } + if (g.child) { + // 驻留子:优先用父显式授权的输出;没有授权登记时退回它自己 inputch 的 + // 默认回程(`output` 字段)。 + ((g.res && g.res.allowed_outputs) || []).forEach(add); + if (!outputs.length) g.list.forEach(function (c) { add(c.output); }); + } else { + // 根 agent 可以写任何输出通道;上限交给渲染侧截断。 + outChans.forEach(function (c) { add(c.name); }); + } + var load = g.child + ? rtLoadPct( + { + ready_queue_depth: g.res && g.res.ready_queue_depth, + pending_interrupts: g.res && g.res.pending_interrupts, + suspend_stack: g.res && g.res.suspend_stack, + interrupt_queues: (g.res && g.res.interrupt_queues) || [], + }, + g.res && g.res.context_full, + ) + : rtLoadPct(rt.scheduler || {}, false); + return { + id: id, child: g.child, label: g.label, color: g.color, + inputs: g.list, outputs: outputs, load: load, res: g.res || null, + }; + }); + } + + // 连线路径表:通道名 → path d。光点动画靠它把「哪个通道」翻成一条曲线。 + var _rtEdgeIn = {}; + var _rtEdgeOut = {}; + var RT_SVGNS = "http://www.w3.org/2000/svg"; + + // rtAgentTopology 画「通道 → agent → 通道」的分带拓扑。 + // + // 每个 agent 一条横带:左边是归它的 inputch,中间是它自己(圆环 = 负载), + // 右边是它能写的 outputch。连线即路由;光点沿连线跑表示消息正在流动。 + // 与旧版「归属框 → 单个内核盒」的差别:内核盒只有一个,看不出"这条输入到底 + // 喂给了哪个子",而子 agent 才是运行态里最该看清的东西。 + 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 out = []; + _rtEdgeIn = {}; + _rtEdgeOut = {}; function esc(s) { return String(s == null ? "" : s).replace(/[<>&]/g, function (m) { return m === "<" ? "<" : m === ">" ? ">" : "&"; }); } - - // ---- 左:归属容器 + 通道行 ---- - var y = top; - var inRows = []; - groups.forEach(function (g) { - var boxH = HEAD + g.list.length * ROW + GPAD; - out.push( - '" - ); - out.push( - '' + - esc((g.child ? "▸ " : "◆ ") + g.label) + - (g.res ? " " + __("轮次", "rounds") + " " + (g.res.rounds || 0) : "") + - (g.res && g.res.context_full ? " " + __("上下文已满", "ctx full") : "") + - "" - ); - g.list.forEach(function (c, i) { - var ry = y + HEAD + i * ROW; - inRows.push({ y: ry + ROW / 2, color: g.color }); - out.push('' + esc(c.name) + ""); - var cap = c.capacity || 0; - var tx = LX + LW - 76; - out.push(''); - if (cap > 0) { - var wpx = Math.max(3, Math.min(46, (46 * Math.min(cap, 64)) / 64)); - out.push(''); - // 只有**非默认**容量才写数字:默认容量用空轨表达, - // 否则整张图会排满十几行「默认」,与“少文字”背道而驰。 - out.push('' + esc(cap) + ""); - } - }); - y += boxH + GAP; - }); - var leftBottom = Math.max(y - GAP, top + 40); - - // ---- 右:输出通道 ---- - var outs = (channels || []).filter(function (c) { - return c.direction === "out" || c.direction === "io"; - }); - var outRows = []; - var oy = top; - var CAPS = [[1, "#88c0d0"], [2, "#a3be8c"], [4, "#ebcb8b"], [8, "#d08770"], [16, "#b48ead"]]; - outs.forEach(function (c) { - outRows.push({ y: oy + ROW / 2 }); - out.push(''); - out.push('' + esc(c.name) + ""); - var cx = RX + RW - 76; - CAPS.forEach(function (b) { - if ((c.output_caps || 0) & b[0]) { - out.push(''); - } - cx += 14; - }); - oy += ROW; - }); - var rightBottom = Math.max(oy, top + 40); - - var H = Math.max(leftBottom, rightBottom) + 10; - var ky = Math.round((Math.min(leftBottom, rightBottom) + Math.max(leftBottom, rightBottom)) / 2) - 14; - - // ---- 输入母线 ---- - if (inRows.length) { - var iy0 = inRows[0].y; - var iy1 = inRows[inRows.length - 1].y; - out.push(''); - inRows.forEach(function (r) { - out.push(''); - }); - out.push(''); + function pathD(x1, y1, x2, y2) { + var mx = (x1 + x2) / 2; + return "M" + x1 + " " + y1 + " C" + mx + " " + y1 + " " + mx + " " + y2 + " " + x2 + " " + y2; } - // ---- 内核 ---- - out.push(''); - out.push('' + __("内核", "Kernel") + ""); - // ---- 输出母线 ---- - if (outRows.length) { - var oy0 = outRows[0].y; - var oy1 = outRows[outRows.length - 1].y; - out.push(''); - outRows.forEach(function (r) { - out.push(''); - }); - out.push(''); + var y = 8; + if (!agents.length) { + 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; + 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) + ""); + if (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(''); + }); + + // agent 节点:两圈 + 一段负载弧(stroke-dasharray 画进度) + var C = 2 * Math.PI * (NODE_R - 3); + out.push(''); + out.push(''); + out.push( + '', + ); + out.push('' + a.load + ""); + out.push('' + esc(a.child ? a.id : __("根 agent", "root")) + ""); + if (a.res && a.res.context_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 d = pathD(NX + NODE_R + 2, cy, OUT_X, ry); + _rtEdgeOut[a.id + "\u0000" + name] = d; + out.push(''); + }); + if (a.outputs.length > MAX_OUT) { + out.push('+' + (a.outputs.length - MAX_OUT) + " " + __("更多", "more") + ""); + } + y += bandH; + }); + var H = Math.max(60, y); return ( - '
' + __("通道拓扑(连线即路由;左框 = 归属)", "Channel topology (edges = routing; boxes = owner)") + "
" + - '
' + + '
' + __("Agent 拓扑(通道 → agent → 通道;圆环 = 负载)", "Agent topology (channel → agent → channel; ring = load)") + "
" + + '
' + out.join("") + "
" ); } + // rtSpark 让一个光点沿一条连线跑一趟。 + // + // 用 SMIL 而不是 CSS offset-path / rAF:它由浏览器自己按 + // SVG 用户坐标跑,不需要前端维护动画循环,也没有 path() 在缩放 viewBox 下 + // 的坐标换算问题。跑完把节点摘掉,避免 DOM 累积。 + function rtSpark(pathD, color) { + if (!pathD) return; + var svg = document.getElementById("rt-topo-svg"); + if (!svg) return; + var c = document.createElementNS(RT_SVGNS, "circle"); + c.setAttribute("r", "3.2"); + c.setAttribute("fill", color || "#ffffff"); + c.setAttribute("class", "rt-spark"); + var am = document.createElementNS(RT_SVGNS, "animateMotion"); + am.setAttribute("dur", "0.85s"); + am.setAttribute("path", pathD); + am.setAttribute("fill", "freeze"); + am.setAttribute("begin", "0s"); + c.appendChild(am); + svg.appendChild(c); + setTimeout(function () { if (c.parentNode) c.parentNode.removeChild(c); }, 950); + } + // 输入:某条 inputch 来消息了(SSE channel_input)。 + function rtSparkInput(source) { + rtSpark(_rtEdgeIn[source], "#88c0d0"); + } + // 输出:某个 agent 往某条 outputch 写了东西(SSE agent_output)。 + function rtSparkOutput(agentID, channel) { + if (!channel || !agentID) return; + rtSpark(_rtEdgeOut[agentID + "\u0000" + channel], "#ff7fac"); + } + function renderRuntime() { var el = document.getElementById("rt-panel"); if (!el) return; @@ -819,7 +939,9 @@ var inputs = rt.input_channels || []; // 数据签名(**不含 uptime**——它每秒都变,带上就等于没缓存):整体没变就整块跳过。 - var sig = JSON.stringify([sc, residents, channels, inputs]); + // 签名里必须带 pipelinePhase:否则 SSE 把阶段推到下一格时, + // 数据没变 → 早退 → 滑块不动,只能等下一次 /runtime 轮询才追上。 + var sig = JSON.stringify([sc, residents, channels, inputs, state.pipelinePhase]); if (sig === _rtSig) return; _rtSig = sig; @@ -832,6 +954,7 @@ el.innerHTML = '

' + __("运行态", "Runtime") + "

" + '
' + + '
' + '
' + '
' + '
' + @@ -871,7 +994,10 @@ __("背压", "backpressure") + " " + (sc.backpressure || 0) + "
"; put("tiles", h); - // ---- 段 2:队列(四级中断 + 一条排队)---- + // ---- 段 2:阶段管道(滑块,SSE stage 事件驱动)---- + put("pipe", rtPipelineHtml(state.pipelinePhase)); + + // ---- 段 3:队列(四级中断 + 一条排队)---- // // 设计是「四条中断队列(L1–L4)+ 一条排队队列」共五个,所以必须画五行: // 只画四条会让「排队输入」这条线在运行态里凭空消失,而它正是 @@ -914,8 +1040,10 @@ } put("levels", h); - // ---- 段 3:中断栈 ---- - h = '
' + __("中断栈(栈顶在上)", "Interrupt stack (top first)") + "
"; + // ---- 段 4:中断栈 ---- + h = '
' + __("中断栈", "Interrupt stack") + "
"; + // 深度先给一条进度条("压了几层 / 上限几层"一眼可读),下面再列具体帧。 + h += rtSlider(stack, maxStack, __("深度", "depth"), stack + " / " + maxStack); if (frames.length) { h += '
'; frames.forEach(function (f, i) { @@ -934,15 +1062,12 @@ } put("stack", h); - // ---- 段 4:拓扑(通道 + 归属 + 路由 画在同一张图上)---- + // ---- 段 5:Agent 拓扑 ---- // - // 归属不再单开一项:通道属于谁是**拓扑的一部分**(左边这些输入口 - // 分别被谁接管),拆成两张表反而看不出关系。整张图用 SVG 画, - // 文字只保留通道名与分组名,其余信息全部用图形编码: - // 归属=容器/配色、容量=节点内细条、输出能力=彩色圆点、路由=连线。 - var rootID = rt.agent_id || ""; - var ownerGroups = rtOwnerGroups(inputs, residents, rootID); - put("topo", rtTopologySvg(ownerGroups, channels)); + // 每个 agent 一条横带:左是归它的 inputch、中间是它自己(圆环 = 负载)、 + // 右是它能写的 outputch,连线即路由。归属、路由、容量、负载全部画进 + // 同一张 SVG,不再单开"通道分配"一节——那本来就是这个拓扑的一部分。 + put("topo", rtAgentTopology(rtAgents(rt))); } // loadRuntime 拉运行态小快照并就地重绘面板(约 2KB,可秒级轮询)。 @@ -2901,6 +3026,9 @@ try { var ev = JSON.parse(e.data); var p = ev.payload || {}; + // 光点:agent → 输出通道。总览页拓扑靠它做"消息出去了"的动画; + // 其它页没有该 SVG,rtSparkOutput 查不到元素就是 no-op。 + if (p.channel) rtSparkOutput(ev.source || "", p.channel); console.log( "[SSE] agent_output received", p.content ? p.content.substring(0, 50) : "(empty)", @@ -3145,6 +3273,15 @@ var tool = p.tool || ""; if (p.channel === "_consolidation_") return; console.log("[SSE] stage event", phase, tool); + // 驱动总览页的"阶段管道"滑块:滑到当前阶段;一轮跑完(或 2.5s 无新 + // 事件)自动回到空闲,避免留下一个永远停在 after_output 的假状态。 + state.pipelinePhase = phase; + if (state.pipelineTimer) clearTimeout(state.pipelineTimer); + state.pipelineTimer = setTimeout(function () { + state.pipelinePhase = ""; + if (document.getElementById("rt-sec-pipe")) renderRuntime(); + }, 2500); + if (document.getElementById("rt-sec-pipe")) renderRuntime(); if (phase === "pre_action") { state.chatStage = __("AI 思考中...", "AI thinking..."); } else if (phase === "before_toolcall") { @@ -3166,6 +3303,17 @@ console.error("[SSE] stage error", ex); } }); + // channel_input:内核在输入进来时另发的一条轻量事件(只带通道名与 agent + // id,不带正文)。总览页拓扑靠它画"光点进入 agent"。 + es.addEventListener("channel_input", function (e) { + try { + var d = JSON.parse(e.data); + var src = d.source || ""; + if (src) rtSparkInput(src); + } catch (ex) { + console.error("[SSE] channel_input error", ex); + } + }); es.onopen = function () { console.log("[SSE] connection opened"); }; diff --git a/internal/plugins/webui/handler_chat.go b/internal/plugins/webui/handler_chat.go index 01d9e65..43c3dd0 100644 --- a/internal/plugins/webui/handler_chat.go +++ b/internal/plugins/webui/handler_chat.go @@ -771,6 +771,29 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) { }) unsubs = append(unsubs, unsub) } + + // channel_input:把「哪条输入通道刚进来一条消息、由哪个 agent 接手」单独推一条 + // **轻量**事件,供总览页拓扑画「光点进入 agent」的动画。 + // + // 为什么不直接把 raw_input 放进 subTypes:那条事件的 payload 带整条输入正文 + // (用户消息,可能几 KB),而拓扑只需要 `source`(通道名)与发出者的 agent id。 + // 全量转发会让每次用户说话都在 SSE 上多背一份正文。 + unsubInput := h.sdk.Subscribe(sdk.EventRawInput, func(evt *sdk.Event) { + src, _ := evt.Payload["source"].(string) + if src == "" { + return + } + data, _ := json.Marshal(map[string]interface{}{ + "type": "channel_input", + "source": src, + "agent": evt.Source, + "timestamp": evt.Timestamp, + }) + seq++ + id := fmt.Sprintf("%d-%d", evt.Timestamp, seq) + sendSSE(writeCh, id, "channel_input", string(data)) + }) + unsubs = append(unsubs, unsubInput) defer func() { for _, unsub := range unsubs { unsub() diff --git a/internal/sdk/status.go b/internal/sdk/status.go index 79d31f5..e83e8d1 100644 --- a/internal/sdk/status.go +++ b/internal/sdk/status.go @@ -121,6 +121,15 @@ type ResidentStatus struct { AllowedOutputs []string `json:"allowed_outputs,omitempty"` InputChTable int `json:"input_ch_table"` CreatedAt string `json:"created_at,omitempty"` + + // 以下四项是该驻留子**自己的**调度器积压摘要,用于 per-agent 负载环形图。 + // + // 根 agent 的积压看 KernelStatus.Scheduler;每个驻留子是独立 agent、 + // 各跑各的调度器,必须分别给,否则「哪个子忙」在界面无从判断。 + ReadyQueueDepth int `json:"ready_queue_depth"` + PendingInterrupts int `json:"pending_interrupts"` + SuspendStack int `json:"suspend_stack"` + InterruptQueues [5]int `json:"interrupt_queues"` } // SchedulerTask 是任务的最小标识(不暴露帧内容)。