mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 17:42:00 +00:00
f0fd63f0b9
用户的原话是「copilot(包括问答)可以是侧边栏的效果」「显示他的简要思考节点, 使得用户知道他大致在想什么,以及用了哪些工具」。 画布右栏从「运行 / 属性 → 时间线 / 成果 / 事件」(两层嵌套 5 个视图)变成 「助手 / 属性」一层。助手栏里是一条流:你让它做什么、它做了什么、结果是什么。 Copilot 建图和跑图在协议上完全无关(SSE 操作流 vs 事件 WebSocket,不共享 seq 和 node_id),但对用户是一件事的两半,decode.ts 把两种流译成同一种 Step 之后才能排进同一条时间轴。 Copilot 不再是弹窗。弹窗的问题不是样式:每改一次图都要重开一次,关掉之后 需求文本就没了,想微调只能重打;而画布左下角那条浮条(z-30)和 Toast (z-100)、Modal(z-50)三层各自为政,本身也是重复的一份 Copilot 状态渲染。 两个都删了,输入框就在栏里,说完一句接着说下一句。右栏没删——它是发起运行 和处理审批的唯一入口,RunLauncher 原样保留,只是跑起来会自动收起给执行过程 让位。tab 用 hidden 切换而不是卸载,不然点一下「属性」看个节点再切回来, 输入框草稿和展开状态全丢。 问数据页和运行页换成同一个 AssistantStream + 同一个 decode.ts。RunPanel 里 那套平行的 EVENT_META + describe() 删了:留着两套翻译,同一次运行在不同页面 会读出不同的故事,而用户没法判断哪个是真的。同理合并了两个耗时格式化函数 (画布节点显示"0ms",右边同一步什么都不显示,两块还并排放着)。运行页列表 加了内容摘要——原本每行只有"临时图 · 1.2s · 340 tok",十条长得一模一样, 要分清哪条是哪条只能挨个点开。 跑真实运行时发现两个缺陷: 1. 后端丢事件(app/core/bus.py)。WebSocket 先读历史、再订阅,两步之间图 正好跑出来的事件既不在历史里也不在实时流里,永远丢了。而 subscribe() 是 异步生成器,真正注册还要等到第一次迭代,窗口比看上去更大。实测一次跑图 开头的 node.finished/node.started 消失,界面上表现为某个节点永远在转圈、 它的子步骤跑到了顶层。改成先 attach 占位、再读历史,重叠部分按 seq 去重。 tests/test_bus_attach.py 里有一条刻画旧行为的测试,说明这个窗口确实存在。 2. 循环里的多轮审批被算错。我在 S1 按内容去重,但三轮审批的 title 一模一样, 和 LangGraph 的重放无法区分——结果第 2、3 次真实审批被当成重放吞掉,而 三次"你驳回了/你放行了"各自成行。改成开/闭配对:开着就是同一次,闭了才是 新一次;决定折进那条审批行而不是另起一行。真实运行(驳回 → 改写 → 再驳回 → 改写 → 放行)已收进 fixtures。 验证:三层各守一件事,共 76 条断言全过。check-decode 守翻译(含新的多轮审批 和截断结果集),check-stream 守渲染,新增 check-ui 守"接起来还对不对"——真 浏览器真后端,包括「切走再切回来草稿还在」「唯一的运行入口还在」「原始事件 切得到也切得回」。另外真跑了一次带三轮审批的图和两次 Copilot 生成(60s, deepseek-v4-pro),截图逐帧看过。后端 78 个测试全过。
125 lines
4.5 KiB
Python
125 lines
4.5 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
from collections import defaultdict
|
|
from typing import AsyncIterator
|
|
|
|
from app.core.events import RunEventModel
|
|
|
|
# 单个订阅者的积压上限。慢消费者(比如卡住的浏览器标签)不应该拖垮整个 run,
|
|
# 所以队列满了就丢最旧的事件并打标记,而不是阻塞发布方。
|
|
_QUEUE_MAXSIZE = 2048
|
|
|
|
|
|
class _Subscriber:
|
|
def __init__(self) -> None:
|
|
self.queue: asyncio.Queue[RunEventModel | None] = asyncio.Queue(maxsize=_QUEUE_MAXSIZE)
|
|
self.dropped = 0
|
|
|
|
|
|
class EventBus:
|
|
"""按 run_id 分频道的内存 pub/sub。
|
|
|
|
只负责实时分发;历史事件由 DB 承担,订阅者接入时先读库补齐再接实时流,
|
|
这样刷新页面或中途接入都能看到完整时间线。
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._subs: dict[str, set[_Subscriber]] = defaultdict(set)
|
|
self._seq: dict[str, int] = defaultdict(int)
|
|
self._lock = asyncio.Lock()
|
|
|
|
def next_seq(self, run_id: str) -> int:
|
|
self._seq[run_id] += 1
|
|
return self._seq[run_id]
|
|
|
|
def set_seq(self, run_id: str, value: int) -> None:
|
|
"""恢复一个已存在的 run 时,把序号接上,保证前端排序不乱。"""
|
|
self._seq[run_id] = max(self._seq[run_id], value)
|
|
|
|
async def publish(self, event: RunEventModel) -> None:
|
|
for sub in list(self._subs.get(event.run_id, ())):
|
|
try:
|
|
sub.queue.put_nowait(event)
|
|
except asyncio.QueueFull:
|
|
with contextlib.suppress(asyncio.QueueEmpty):
|
|
sub.queue.get_nowait()
|
|
sub.dropped += 1
|
|
with contextlib.suppress(asyncio.QueueFull):
|
|
sub.queue.put_nowait(event)
|
|
|
|
async def close(self, run_id: str) -> None:
|
|
"""给所有订阅者发结束哨兵,让 WebSocket 干净地收尾。"""
|
|
for sub in list(self._subs.get(run_id, ())):
|
|
with contextlib.suppress(asyncio.QueueFull):
|
|
sub.queue.put_nowait(None)
|
|
|
|
def attach(self, run_id: str) -> "Subscription":
|
|
"""立刻占一个位,之后再慢慢消费。
|
|
|
|
必须能在读历史**之前**占位。`subscribe()` 做不到这件事:它是异步
|
|
生成器,订阅者要等到第一次迭代才真正注册——而调用方通常先读完历史
|
|
才开始迭代。这中间发出的事件既不在历史里、也不在实时流里,就这么
|
|
没了。一次跑图从发起到订阅之间正好有几毫秒,丢掉的往往是开头那
|
|
两三条 node.started/node.finished,表现为"某个节点永远在转圈、
|
|
它的子步骤跑到了顶层"。
|
|
"""
|
|
sub = _Subscriber()
|
|
self._subs[run_id].add(sub)
|
|
return Subscription(self, run_id, sub)
|
|
|
|
def _detach(self, run_id: str, sub: _Subscriber) -> None:
|
|
self._subs.get(run_id, set()).discard(sub)
|
|
if run_id in self._subs and not self._subs[run_id]:
|
|
self._subs.pop(run_id, None)
|
|
|
|
async def subscribe(self, run_id: str) -> AsyncIterator[RunEventModel]:
|
|
"""便捷入口:占位即消费。只适合不需要补历史的场景。"""
|
|
with self.attach(run_id) as sub:
|
|
async for event in sub.stream():
|
|
yield event
|
|
|
|
def subscriber_count(self, run_id: str) -> int:
|
|
return len(self._subs.get(run_id, ()))
|
|
|
|
|
|
class Subscription:
|
|
"""一个已经在收事件的订阅位。"""
|
|
|
|
def __init__(self, bus: EventBus, run_id: str, sub: _Subscriber) -> None:
|
|
self._bus = bus
|
|
self._run_id = run_id
|
|
self._sub = sub
|
|
|
|
@property
|
|
def dropped(self) -> int:
|
|
"""因为消费太慢被丢掉的条数。不是 0 就说明这条时间线有洞。"""
|
|
return self._sub.dropped
|
|
|
|
async def stream(self, after: int = 0) -> AsyncIterator[RunEventModel]:
|
|
"""吐出实时事件。
|
|
|
|
after 用来去重:占位到读完历史之间发出的事件,两边都有一份。
|
|
按 seq 过滤掉已经补过的,前端就不会看到同一步出现两次。
|
|
"""
|
|
while True:
|
|
item = await self._sub.queue.get()
|
|
if item is None:
|
|
break
|
|
if after and (item.seq or 0) <= after:
|
|
continue
|
|
yield item
|
|
|
|
def close(self) -> None:
|
|
self._bus._detach(self._run_id, self._sub)
|
|
|
|
def __enter__(self) -> "Subscription":
|
|
return self
|
|
|
|
def __exit__(self, *exc: object) -> None:
|
|
self.close()
|
|
|
|
|
|
bus = EventBus()
|