diff --git a/internal/plugins/cli/plugin.go b/internal/plugins/cli/plugin.go index 199c23e..71a108c 100644 --- a/internal/plugins/cli/plugin.go +++ b/internal/plugins/cli/plugin.go @@ -348,8 +348,11 @@ func (p *Plugin) cmdInterrupt(conn net.Conn, parts []string, s *sdk.PluginSDK) { if alias := strings.TrimSpace(strings.TrimPrefix(line2(parts), "/interrupt")); alias != "" { msg = alias } + // PriorityL3(交互):/stop 是人在终端上当场下的指令,属于"需要及时处理", + // 不该用默认的 L1(后台)——那样它会被排在其它后台注入后面,停得不及时。 s.InjectInterrupt(cliSource, cliChannel, "text", map[string]interface{}{ - "content": msg, + "content": msg, + "priority": sdk.PriorityL3, }) writeLine(conn, map[string]interface{}{ "type": "response", diff --git a/internal/plugins/webui/dashboard.css b/internal/plugins/webui/dashboard.css index e829b4c..994eca5 100644 --- a/internal/plugins/webui/dashboard.css +++ b/internal/plugins/webui/dashboard.css @@ -2811,3 +2811,40 @@ font-size: 11px; color: var(--text-muted); } + + /* 阶段管道的循环标记与「本轮轨迹」chip 序列。 + 轨迹用不同配色区分:普通工具(⚙)与 output_* 输出通道调用(⇥)。 */ + .rt-loop { + font-style: normal; + color: var(--accent); + font-weight: 700; + } + .rt-trail { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 2px 0 8px; + } + .rt-chip { + max-width: 170px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px; + padding: 2px 7px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border-color); + color: var(--text-secondary); + } + .rt-chip-tool { + border-color: rgba(136, 192, 208, 0.42); + color: var(--frost-300); + } + .rt-chip-output { + border-color: var(--accent); + color: var(--accent); + } + .rt-chip-stage { + opacity: 0.7; + } diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index bc045a4..cccd555 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -12,8 +12,9 @@ messages: [], chatLoading: false, chatStage: "", - pipelinePhase: "", // 当前阶段(SSE stage 事件驱动总览页的滑块) + pipelinePhase: "", // 当前阶段(SSE stage 事件驱动总览页) pipelineTimer: null, + stageTrail: [], // 本轮已发生的事件轨迹(工具/输出调用,供阶段管道展示循环) healthResult: null, starmapInit: false, starmapLoading: false, @@ -591,10 +592,10 @@ // 四级语义直接照抄内核(internal/agent/core/scheduler.go 的 Level 定义), // 别自己起名字——前端叫法一旦和内核不一致,看板就成了误导。 var RT_LEVELS = [ - { lv: 4, name: "L4 内核独占", cls: "rt-lv-4" }, - { lv: 3, name: "L3 交互", cls: "rt-lv-3" }, - { lv: 2, name: "L2 消息", cls: "rt-lv-2" }, - { lv: 1, name: "L1 后台", cls: "rt-lv-1" }, + { lv: 4, name: "L4", desc: "内核独占", cls: "rt-lv-4" }, + { lv: 3, name: "L3", desc: "交互", cls: "rt-lv-3" }, + { lv: 2, name: "L2", desc: "消息", cls: "rt-lv-2" }, + { lv: 1, name: "L1", desc: "后台", cls: "rt-lv-1" }, ]; var RT_CAP_NAMES = { 1: "text", 2: "file", 4: "image", 8: "audio", 16: "structured" }; @@ -773,37 +774,74 @@ // // 为什么放进总览:阶段管道回答"这一轮走到哪一步",总览其余图形回答"积压了多少", // 两者合起来才是运行态。此前它只是对话页一个 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" }, + // 阶段管道:**循环流程 + 本轮轨迹**,不是单向滑块。 + // + // 一轮里工具调用会反复回到「行动后」再接下一个工具(post_action → + // before_toolcall → after_toolcall → post_action …),还可能中途多次 + // 输出,所以线性滑块本身就是错的表述。这里画成 + // 输入 → 行动 ⇄(工具) → 输出 → 结束 + // (工具那格带循环标记),下面再用一排 chip 记下**本轮真实发生过什么**: + // 普通工具与 output_* 输出通道调用用不同配色区分开来。 + var RT_PIPE_GROUPS = [ + { zh: "输入", en: "in" }, + { zh: "行动", en: "act" }, + { zh: "工具", en: "tool" }, + { zh: "输出", en: "out" }, + { zh: "结束", en: "done" }, ]; - - 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) + ""; + function rtPhaseGroup(phase) { + switch (phase) { + case "on_input": + return 0; + case "pre_action": + case "post_action": + return 1; + case "before_toolcall": + case "after_toolcall": + return 2; + case "before_output": + return 3; + case "after_output": + return 4; + } + return -1; + } + // rtShortTool 把 `qq_get_message` / `output_send__qq` 压成尾段短名。 + function rtShortTool(name) { + var n = String(name || ""); + var i = n.lastIndexOf("__"); + if (i >= 0) n = n.slice(i + 2); + return n.length > 14 ? n.slice(0, 13) + "…" : n; + } + function rtPipelineHtml(phase, trail) { + var g = rtPhaseGroup(phase); + var nodes = RT_PIPE_GROUPS.map(function (s, i) { + var cls = "rt-pipe-node" + (i === g ? " active" : ""); + return ( + '' + + __(s.zh, s.en) + + (i === 2 ? ' ' : "") + + "" + ); + }).join(""); + var chips = (trail || []).map(function (t) { + var kind = t.kind || "stage"; + return ( + '' + + (kind === "output" ? "⇥ " : kind === "tool" ? "⚙ " : "") + + escHtml(t.short || t.label) + "" + ); }).join(""); return ( '
' + __("阶段管道", "Stage pipeline") + - (idx < 0 ? " " + __("(空闲)", "(idle)") : "") + + (g < 0 ? " " + __("(空闲)", "(idle)") : "") + "
" + - '
' + - '' + - '' + + '
' + '
' + nodes + "
" + - "
" + "
" + + (chips ? '
' + chips + "
" : "") ); } @@ -958,7 +996,7 @@ }); var H = Math.max(60, y); return ( - '
' + __("Agent 拓扑(通道 → agent → 通道;圆环 = 负载)", "Agent topology (channel → agent → channel; ring = load)") + "
" + + '
' + __("拓扑(圆环 = 负载)", "Topology (ring = load)") + "
" + '
' + out.join("") + "
" @@ -1014,7 +1052,10 @@ // 数据签名(**不含 uptime**——它每秒都变,带上就等于没缓存):整体没变就整块跳过。 // 签名里必须带 pipelinePhase:否则 SSE 把阶段推到下一格时, // 数据没变 → 早退 → 滑块不动,只能等下一次 /runtime 轮询才追上。 - var sig = JSON.stringify([sc, residents, channels, inputs, state.pipelinePhase]); + var sig = JSON.stringify([ + sc, residents, channels, inputs, state.pipelinePhase, + (state.stageTrail || []).map(function (t) { return t.kind + ":" + (t.short || t.label); }).join(","), + ]); if (sig === _rtSig) return; _rtSig = sig; @@ -1052,31 +1093,25 @@ // ---- 段 1:四个数字块 ---- var h = '
'; - h += rtTile(ready, __("排队任务", "Ready queue"), "", ready ? Math.min(100, ready * 20) : 0, ready > 0); - h += rtTile(pending, __("待处理中断", "Pending interrupts"), "", pending ? Math.min(100, pending * 25) : 0, pending > 0, pending > 0); - h += rtTile(stack + "/" + maxStack, __("中断栈", "Interrupt stack"), "", (stack / (maxStack || 4)) * 100, stack > 0); + h += rtTile(ready, __("排队", "Ready"), "", ready ? Math.min(100, ready * 20) : 0, ready > 0); + h += rtTile(pending, __("中断", "Pending"), "", pending ? Math.min(100, pending * 25) : 0, pending > 0, pending > 0); + h += rtTile(stack + "/" + maxStack, __("栈", "Stack"), "", (stack / (maxStack || 4)) * 100, stack > 0); var rFull = residents.filter(function (r) { return r.context_full; }).length; - h += rtTile(residents.length, __("驻留子 Agent", "Resident agents"), rFull ? rFull + __(" 满", " full") : "", residents.length ? Math.min(100, residents.length * 20) : 0, residents.length > 0, rFull > 0); + h += rtTile(residents.length, __("子代理", "Subagents"), rFull ? rFull + __("满", " full") : "", residents.length ? Math.min(100, residents.length * 20) : 0, residents.length > 0, rFull > 0); h += "
"; - // 抢占/背压计数(设计 §11.6 E4 的按级别口径) - h += '
' + __("累计", "totals") + ": " + - __("入队", "enqueued") + " " + (sc.enqueued || 0) + " · " + - __("执行", "executed") + " " + (sc.executed || 0) + " · " + - __("抢占", "preempted") + " " + (sc.preempted || 0) + " · " + - __("挂起/恢复", "susp/res") + " " + (sc.suspended || 0) + "/" + (sc.resumed || 0) + " · " + - __("拒绝", "rejected") + " " + (sc.rejected || 0) + " · " + - __("背压", "backpressure") + " " + (sc.backpressure || 0) + "
"; + // 「累计:入队 … 执行 … 抢占 …」那一整行纯文字被去掉了:它对"现在忙不忙" + // 没有帮助,却占掉一整行,是总览里最大的一坨文字。 put("tiles", h); // ---- 段 2:阶段管道(滑块,SSE stage 事件驱动)---- - put("pipe", rtPipelineHtml(state.pipelinePhase)); + put("pipe", rtPipelineHtml(state.pipelinePhase, state.stageTrail)); // ---- 段 3:队列(四级中断 + 一条排队)---- // // 设计是「四条中断队列(L1–L4)+ 一条排队队列」共五个,所以必须画五行: // 只画四条会让「排队输入」这条线在运行态里凭空消失,而它正是 // 「不需要及时处理」的那一半输入。排队队列**无级别**,故用不同配色 + 虚线。 - h = '
' + __("队列(四级中断 + 排队)", "Queues (4 interrupt levels + queued)") + "
"; + h = '
' + __("队列", "Queues") + "
"; var maxQ = Math.max(1, ready, q[1] || 0, q[2] || 0, q[3] || 0, q[4] || 0); var maxReg = 1; var maxPre = 1; @@ -1090,7 +1125,7 @@ var reg = byLv[L.lv] || 0; var pre = preLv[L.lv] || 0; h += - '
' + L.name + "" + + '
' + L.name + "" + '' + @@ -1101,8 +1136,8 @@ }); h += "
"; // 第五条:排队队列(无级别,纯 FIFO) - h += '
' + - __("排队(无级别)", "queued (no level)") + "" + + h += '
' + + __("排队", "queued") + "" + '' + '' + ready + " · FIFO
"; @@ -1115,7 +1150,7 @@ put("levels", h); // ---- 段 4:中断栈 ---- - h = '
' + __("中断栈", "Interrupt stack") + "
"; + h = '
' + __("栈", "Stack") + "
"; // 深度先给一条进度条("压了几层 / 上限几层"一眼可读),下面再列具体帧。 h += rtSlider(stack, maxStack, __("深度", "depth"), stack + " / " + maxStack); if (frames.length) { @@ -3487,6 +3522,21 @@ console.log("[SSE] stage event", phase, tool); // 驱动总览页的"阶段管道"滑块:滑到当前阶段;一轮跑完(或 2.5s 无新 // 事件)自动回到空闲,避免留下一个永远停在 after_output 的假状态。 + // 阶段轨迹:本轮真实发生过什么,交给阶段管道画成 chip 序列。 + // 工具调用会反复出现(多轮 toolcall),output_* 单独配色。 + if (phase === "on_input") { + state.stageTrail = [{ kind: "stage", label: __("输入", "input"), short: __("输入", "input") }]; + } else if (phase === "before_toolcall" && tool) { + state.stageTrail.push({ + kind: tool.indexOf("output_") === 0 ? "output" : "tool", + label: tool, + short: rtShortTool(tool), + }); + if (state.stageTrail.length > 24) state.stageTrail.shift(); + } else if (phase === "after_output") { + state.stageTrail.push({ kind: "stage", label: __("完成", "done"), short: __("完成", "done") }); + if (state.stageTrail.length > 24) state.stageTrail.shift(); + } state.pipelinePhase = phase; if (state.pipelineTimer) clearTimeout(state.pipelineTimer); state.pipelineTimer = setTimeout(function () { diff --git a/third_party/homeagent-sdk/example/qq/plugin.go b/third_party/homeagent-sdk/example/qq/plugin.go index 5168d76..d7b5ca0 100644 --- a/third_party/homeagent-sdk/example/qq/plugin.go +++ b/third_party/homeagent-sdk/example/qq/plugin.go @@ -165,7 +165,7 @@ type Plugin struct { batchMax time.Duration // 一批最长等多久(防持续刷屏时永远不投) // injectHook 仅供测试:非 nil 时 injectInterrupt 走它而不是真实 SDK。 - injectHook func(text string) + injectHook func(text, level string) } // pendingBatch 是一批待投递的消息(同一会话、同一发送者、短时间内的连续消息)。 @@ -1356,7 +1356,7 @@ func (p *Plugin) enqueueInterrupt(msgType string, userID, groupID, messageID int return } if p.batchWindow <= 0 { - p.injectInterrupt(single) + p.injectInterrupt(single, p.interruptLevel(owner)) return } key := qqBatchKey(msgType, groupID, userID) @@ -1392,14 +1392,32 @@ func (p *Plugin) enqueueInterrupt(msgType string, userID, groupID, messageID int p.batchMu.Unlock() } -// injectInterrupt 投递一条中断提示(NoMemory:HTTP 侧来的不是对话内容; -// Priority L1:QQ 消息是低级别中断,既非实时工作也非紧急工作,完全可等)。 -func (p *Plugin) injectInterrupt(text string) { +// interruptLevel 决定一条 QQ 消息的中断级别。 +// +// - Bot 所有者/管理员的消息 → **L2**(一般提醒); +// - 其他人的消息 → L1(后台,完全可等)。 +// +// 为什么不能一律 L1:L1 之间可以随时互相抢占、也可以被任何更高一级打断, +// 于是「老板发的话」会被路人的闲聊挤到后面,甚至对方持续刷屏时一直排在队尾。 +// 为什么也不该给 L3:L3 是时钟/终端那类"需要及时处理"的实时工作,QQ 是异步 +// 消息,抬到 L3 会反过来打断真正实时的事情。 +func (p *Plugin) interruptLevel(owner bool) string { + if owner { + return sdk.PriorityL2 + } + return sdk.PriorityL1 +} + +// injectInterrupt 投递一条中断提示(NoMemory:HTTP 侧来的不是对话内容)。 +func (p *Plugin) injectInterrupt(text, level string) { if text == "" { return } + if level == "" { + level = sdk.PriorityL1 + } if p.injectHook != nil { - p.injectHook(text) + p.injectHook(text, level) return } if p.sdk == nil { @@ -1407,7 +1425,7 @@ func (p *Plugin) injectInterrupt(text string) { } p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{ NoMemory: true, - Priority: sdk.PriorityL1, + Priority: level, }) } @@ -1424,7 +1442,8 @@ func (p *Plugin) flushBatch(key string) { if len(b.msgIDs) > 1 { text = p.buildBatchInterrupt(b) } - p.injectInterrupt(text) + // 一批里只要有一条来自 Bot 所有者,整批按 L2 投递(不因混入路人消息而降低)。 + p.injectInterrupt(text, p.interruptLevel(b.owner)) } // flushAllBatches 停机前把未到点的批次立刻投出去(best effort)。 diff --git a/third_party/homeagent-sdk/example/qq/plugin_test.go b/third_party/homeagent-sdk/example/qq/plugin_test.go index 04828b1..205214e 100644 --- a/third_party/homeagent-sdk/example/qq/plugin_test.go +++ b/third_party/homeagent-sdk/example/qq/plugin_test.go @@ -242,7 +242,7 @@ func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) { // collectInterrupts 用注入钩子收集中断文本(避免测试依赖真实 SDK)。 func collectInterrupts(p *Plugin) *[]string { got := []string{} - p.injectHook = func(s string) { got = append(got, s) } + p.injectHook = func(s, _ string) { got = append(got, s) } return &got } @@ -312,3 +312,25 @@ func TestSingleMessageKeepsOriginalText(t *testing.T) { t.Fatalf("单条消息应沿用原文(含所有者前缀),实际 %#v", *got) } } + +// Bot 所有者/管理员的消息给 L2,普通人的给 L1 —— 否则所有者的话会被路人 +// 的 L1 闲聊抢占/挤到队尾。 +func TestOwnerMessagesGetHigherInterruptLevel(t *testing.T) { + p := newPermissionTestPlugin(t) + got := []string{} + p.injectHook = func(text, level string) { got = append(got, text+"|"+level) } + p.batchWindow = 20 * time.Millisecond + p.batchMax = time.Second + + p.enqueueInterrupt("private", 1, 0, 1, "owner", "owner-msg", true, false) + p.enqueueInterrupt("private", 2, 0, 2, "someone", "other-msg", false, false) + time.Sleep(120 * time.Millisecond) + + joined := strings.Join(got, ",") + if !strings.Contains(joined, "owner-msg|L2") { + t.Fatalf("所有者消息应为 L2,实际 %q", joined) + } + if !strings.Contains(joined, "other-msg|L1") { + t.Fatalf("普通人消息应为 L1,实际 %q", joined) + } +}