mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:31:58 +00:00
运行和 Copilot 分成两层;顺带修了一个会丢事件的时序缺陷
用户:「点击右上角运行后,会跳到流程;然后下方就又有 Copilot 对话框,把 结果和 Copilot 又放在一块儿了」。说得对,而且我上一版的理由是错的。 我当时把建图和跑图排进同一条时间轴,说它们是"一件事的两半"。它们在时间上 先后发生,但不是同一种东西——最直接的证据在我自己的代码里:运行那一轮的 question 只能填 undefined。一个没有提问的"对话轮次",本来就不是对话。 更实际的问题是那个输入框:它贴在运行结果下面,暗示你可以回复这次运行。 但你不能——在那儿打字改的是画布上的图。这是个假的可供性。两者的生命周期 也对不上:Copilot 的轮次会累积,运行永远只有最新一次。 现在右栏是一个常驻的家(Copilot 对话)加两层临时盖上来的东西(节点属性、 运行过程)。运行那一层没有输入框,有自己的头部和停止按钮;对话那一层底部 留一条运行进度,点一下过去。 --- 顺着这条线查下去,发现一个更严重的问题:**后端在丢事件**。 浏览器侧连跑 5 次,2 次丢了中间一条。抓 WebSocket 原始帧才看清:所有帧的 replay 都是 false——历史读回来是空的,全部走实时;丢的是 run.started, 它在客户端订阅之前就广播了,而那时还没落库,所以历史里也没有。 根因是 _emit 的顺序:先广播、后落库。订阅者的完整性靠"先订阅 → 读历史 → 接实时"拼起来,它需要这条保证——提交早于我这次 SELECT 的,在历史里;提交 晚于我这次 SELECT 的,广播也晚于我订阅,在实时流里。后半句只有在"提交先于 广播"时才成立。交换顺序后,6 次 WebSocket 探测 + 5 次浏览器运行全部无缺口。 (上一轮我修过这个文件的另一个窗口——先读历史后订阅。那个修复是对的,但 只堵住了两步之间的缝,堵不住订阅建立之前的那一段。) tests/test_emit_order.py 守这条不变式:广播的那一刻,这条事件必须已经能从 另一个 session 查到。去掉修复后它确实失败。 --- 还修了三处: - 停在人工介入时,"在不在等人"原先取自审批列表,而那是 4 秒轮询一次的; 中断后会有几秒钟界面说"这次运行已结束",而它正等着你点通过。改成从事件 推导(isAwaitingHuman),即时且和显示的内容一致。 - 新增 tests/conftest.py 把整个测试会话关进临时目录。test_emit_order 第一版 直接用 SessionLocal 往开发库里写,几行假运行排到了运行列表最前面。已清掉 那 12 行。这该是全局保障,不是只修一个文件。 - 开发期把 store 挂到 window.__studio,好让 playwright 直接读状态——"事件 收全了没有"在界面上看不出来,只能问 store。已确认不进生产包。
This commit is contained in:
@@ -91,7 +91,16 @@ class RunManager:
|
||||
node_id=node_id,
|
||||
data=data or {},
|
||||
)
|
||||
await bus.publish(event)
|
||||
# 先落库,再广播。顺序反过来会漏事件,而且是结构性的漏:
|
||||
#
|
||||
# 订阅者的完整性靠"读历史 + 接实时"两段拼起来。它先订阅、再读历史,
|
||||
# 于是需要这条保证——**提交早于我这次 SELECT 的,在历史里;提交晚于
|
||||
# 我这次 SELECT 的,广播也晚于我订阅,在实时流里**。这条保证只有在
|
||||
# "提交先于广播"时才成立。
|
||||
#
|
||||
# 先广播的话,一条在订阅建立之前广播、却在读历史之后才提交的事件,
|
||||
# 两边都拿不到。实测跑一次 25ms 的图,六条事件里的 run.started 有
|
||||
# 一半几率就这么没了——界面表现为某个节点永远在转圈。
|
||||
if persist and event.type not in _EPHEMERAL:
|
||||
async with SessionLocal() as session:
|
||||
session.add(
|
||||
@@ -108,6 +117,7 @@ class RunManager:
|
||||
update(Run).where(Run.id == run_id).values(last_seq=event.seq)
|
||||
)
|
||||
await session.commit()
|
||||
await bus.publish(event)
|
||||
|
||||
async def note(
|
||||
self, run_id: str, event_type: EventType | str, *, node_id: str | None = None,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""把整个测试会话关进一个临时目录。
|
||||
|
||||
不这么做的话,任何一个直接用 SessionLocal 的测试都会往开发库
|
||||
(data/agentlab.db)里写行——写进去的 run 会出现在运行列表里,和真实运行
|
||||
混在一起,而且带着测试的假数据。这不是假想:test_emit_order 第一版就这么
|
||||
干了,几行 emit-order-* 的假运行直接排在了列表最前面。
|
||||
|
||||
必须在 app.* 被 import 之前设好环境变量:settings 是模块级单例,engine 在
|
||||
app.db.base 导入时就按 db_url 建好了。conftest 早于测试模块加载,所以这里
|
||||
是唯一还来得及的地方。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
_TMP = Path(tempfile.mkdtemp(prefix="agentlab-tests-"))
|
||||
os.environ["AGENTLAB_DATA_DIR"] = str(_TMP)
|
||||
# 别把真实的 key 带进测试环境
|
||||
os.environ.setdefault("AGENTLAB_SECRET_KEY", "test-only-not-a-real-key")
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _prepare_schema():
|
||||
"""建表。测试库是全新的临时文件,什么都没有。"""
|
||||
import asyncio
|
||||
|
||||
from app.db.base import Base, engine
|
||||
|
||||
async def create() -> None:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(create())
|
||||
yield
|
||||
|
||||
|
||||
def pytest_report_header(config) -> str:
|
||||
return f"测试数据目录:{_TMP}"
|
||||
@@ -0,0 +1,111 @@
|
||||
"""事件的落库与广播顺序。
|
||||
|
||||
订阅者的完整性靠"先订阅 → 读历史 → 接实时"三步拼起来,它需要这条保证:
|
||||
|
||||
提交早于我这次 SELECT 的,在历史里;
|
||||
提交晚于我这次 SELECT 的,广播也晚于我订阅,在实时流里。
|
||||
|
||||
后半句只有在"提交先于广播"时才成立。反过来(先广播后落库)会漏掉这样一条
|
||||
事件:在订阅建立之前广播、却在读历史之后才提交——两边都拿不到。
|
||||
|
||||
这不是理论。实测跑一次 25 毫秒的图,六条事件里的 run.started 有一半几率
|
||||
就这么没了,界面上表现为某个节点永远在转圈、它的子步骤跑到了顶层。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.events import EventType
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import Run, RunEvent
|
||||
from app.engine import runner as runner_mod
|
||||
from app.engine.runner import run_manager
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_is_in_db_before_it_is_broadcast(monkeypatch) -> None:
|
||||
"""广播的那一刻,这条事件必须已经能从库里查到。"""
|
||||
run_id = f"emit-order-1-{uuid.uuid4().hex[:8]}"
|
||||
async with SessionLocal() as session:
|
||||
session.add(Run(id=run_id, status="running", graph={}, input={}))
|
||||
await session.commit()
|
||||
|
||||
visible_at_publish: list[bool] = []
|
||||
original = runner_mod.bus.publish
|
||||
|
||||
async def spy(event):
|
||||
# 用一个独立 session 查,模拟另一个连接(WebSocket 的历史读就是这样)
|
||||
async with SessionLocal() as s:
|
||||
row = (
|
||||
await s.execute(
|
||||
select(RunEvent).where(
|
||||
RunEvent.run_id == event.run_id, RunEvent.seq == event.seq
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
visible_at_publish.append(row is not None)
|
||||
await original(event)
|
||||
|
||||
monkeypatch.setattr(runner_mod.bus, "publish", spy)
|
||||
|
||||
await run_manager._emit(run_id, EventType.RUN_STARTED, data={"nodes": 3})
|
||||
await run_manager._emit(run_id, EventType.NODE_STARTED, node_id="a", data={})
|
||||
|
||||
assert visible_at_publish == [True, True], (
|
||||
"广播时还查不到这条事件——说明是先广播后落库,"
|
||||
"订阅者会漏掉在它订阅之前广播、在它读历史之后提交的那些"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ephemeral_events_still_broadcast(monkeypatch) -> None:
|
||||
"""不落库的流式增量照样要广播出去,不能因为跳过落库就一起跳过广播。"""
|
||||
run_id = f"emit-order-2-{uuid.uuid4().hex[:8]}"
|
||||
async with SessionLocal() as session:
|
||||
session.add(Run(id=run_id, status="running", graph={}, input={}))
|
||||
await session.commit()
|
||||
|
||||
published: list[str] = []
|
||||
bus_mod = runner_mod.bus
|
||||
original = bus_mod.publish
|
||||
|
||||
async def spy(event):
|
||||
published.append(str(event.type))
|
||||
await original(event)
|
||||
|
||||
monkeypatch.setattr(bus_mod, "publish", spy)
|
||||
await run_manager._emit(run_id, EventType.LLM_TOKEN, node_id="a", data={"delta": "x"})
|
||||
|
||||
assert published == ["llm.token"]
|
||||
async with SessionLocal() as s:
|
||||
rows = (
|
||||
await s.execute(select(RunEvent).where(RunEvent.run_id == run_id))
|
||||
).scalars().all()
|
||||
assert rows == [], "llm.token 是流式增量,不该落库"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seq_is_monotonic_across_emits() -> None:
|
||||
"""序号按发出顺序递增——前端排序和去重都建立在这上面。"""
|
||||
run_id = f"emit-order-3-{uuid.uuid4().hex[:8]}"
|
||||
async with SessionLocal() as session:
|
||||
session.add(Run(id=run_id, status="running", graph={}, input={}))
|
||||
await session.commit()
|
||||
|
||||
for i in range(5):
|
||||
await run_manager._emit(run_id, EventType.LOG, data={"message": str(i)})
|
||||
|
||||
async with SessionLocal() as s:
|
||||
seqs = [
|
||||
e.seq
|
||||
for e in (
|
||||
await s.execute(
|
||||
select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq)
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
assert seqs == sorted(seqs) and len(set(seqs)) == len(seqs)
|
||||
@@ -3,8 +3,9 @@ import { ChevronLeft, Hand } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useCatalog } from '../store/catalog'
|
||||
import { useStudio } from '../store/studio'
|
||||
import { decodeRun, type Step } from '../run/decode'
|
||||
import { decodeRun } from '../run/decode'
|
||||
import { Spinner } from '../components/ui'
|
||||
import { lastMeaningful } from '../run/AssistantPanel'
|
||||
import { Inspector } from './Inspector'
|
||||
|
||||
/**
|
||||
@@ -70,8 +71,7 @@ function LiveStrip() {
|
||||
if (!streaming && !waiting) return null
|
||||
|
||||
// 只取最后一步说给人听,不在这里重演整条流——这是一条提示,不是第二个时间线
|
||||
const steps = decodeRun(events)
|
||||
const last = flattenLast(steps)
|
||||
const last = lastMeaningful(decodeRun(events))
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -90,11 +90,3 @@ function LiveStrip() {
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** 最后一条有意义的步骤:优先取最深的子步骤,那才是"此刻在干什么"。 */
|
||||
function flattenLast(steps: Step[]): Step | undefined {
|
||||
const flat: Step[] = []
|
||||
const walk = (list: Step[]) => list.forEach((s) => { flat.push(s); if (s.children) walk(s.children) })
|
||||
walk(steps)
|
||||
return [...flat].reverse().find((s) => s.kind !== 'lifecycle') ?? flat[flat.length - 1]
|
||||
}
|
||||
|
||||
+217
-116
@@ -1,160 +1,261 @@
|
||||
import { useMemo, useRef, useState, useEffect } from 'react'
|
||||
import { Code2, Eraser } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Code2, Eraser, Hand, Play, Square } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useCatalog } from '../store/catalog'
|
||||
import { useStudio } from '../store/studio'
|
||||
import { Spinner } from '../components/ui'
|
||||
import { AssistantStream, type StreamTurn } from './AssistantStream'
|
||||
import { Composer } from './Composer'
|
||||
import { decodeCopilot, decodeRun, formatDuration } from './decode'
|
||||
import {
|
||||
decodeCopilot, decodeRun, formatDuration, isAwaitingHuman, type Step,
|
||||
} from './decode'
|
||||
import { ApprovalCard } from './RunPanel'
|
||||
|
||||
/**
|
||||
* 画布右栏:助手。
|
||||
* 画布右栏。
|
||||
*
|
||||
* 这一栏现在只有一件事——和 Copilot 说话,以及看它和运行做了什么。属性不再
|
||||
* 和它平级挤在两个 tab 里:选中一个节点会滑出属性面板盖在上面,关掉就回到
|
||||
* 原处。之前是切 tab,而 tab 是互斥的——跑图跑到一半点开一个节点看配置,
|
||||
* 整条执行过程就从眼前消失了,回来还得重新找位置。
|
||||
* 一个常驻的家(和 Copilot 说话),两层临时盖上来的东西(节点属性、运行过程)。
|
||||
* 三者不是平级的 tab——tab 互斥,会把下面那层卸载掉,草稿和滚动位置全丢。
|
||||
*
|
||||
* 两种体量:没说过话时输入框是主体(竖直居中、带例句),有对话之后它退到
|
||||
* 底部,上面让给过程。
|
||||
* 运行为什么不留在对话流里:
|
||||
*
|
||||
* 之前把 Copilot 建图和跑图排进了同一条时间轴,理由是"它们是一件事的两半"。
|
||||
* 那个说法站不住。它们在时间上先后发生,但不是同一种东西——最直接的证据是
|
||||
* 运行那一轮的 question 只能填 undefined:一个没有提问的"对话轮次",本来就
|
||||
* 不是对话。
|
||||
*
|
||||
* 更实际的问题是输入框:它贴在运行结果下面,暗示你可以回复这次运行。但你
|
||||
* 不能——在那儿打字改的是画布上的图,不是回答运行。这是个假的可供性,
|
||||
* 而且两者的生命周期也对不上:Copilot 的轮次会累积,运行永远只有最新一次。
|
||||
*
|
||||
* 所以运行有自己的一层,没有输入框;对话那一层留一条进度条,点一下过去。
|
||||
*/
|
||||
export function AssistantPanel() {
|
||||
const run = useStudio((s) => s.run)
|
||||
const events = useStudio((s) => s.events)
|
||||
const streaming = useStudio((s) => s.streaming)
|
||||
const copilot = useStudio((s) => s.copilot)
|
||||
const copilotTurns = useStudio((s) => s.copilotTurns)
|
||||
const attachRun = useStudio((s) => s.attachRun)
|
||||
const clearCopilot = useStudio((s) => s.clearCopilot)
|
||||
const approvals = useCatalog((s) => s.approvals)
|
||||
const pending = approvals.filter((a) => a.run_id === run?.id && a.status === 'pending')
|
||||
const [raw, setRaw] = useState(false)
|
||||
const [view, setView] = useState<'chat' | 'run'>('chat')
|
||||
|
||||
const turns = useMemo<StreamTurn[]>(() => {
|
||||
const list: StreamTurn[] = copilotTurns.map((t) => ({
|
||||
id: t.id,
|
||||
question: t.instruction,
|
||||
phase: t.phase === 'running' ? 'running' : t.phase === 'error' ? 'error' : 'done',
|
||||
// 生成期间不能只给一个孤零零的转圈图标。具体阶段从 copilot 实时状态里取
|
||||
status: t.phase === 'running' ? (copilot.lastOp || '正在生成流程…')
|
||||
: t.phase === 'done' ? '流程已更新到画布' : '',
|
||||
steps: decodeCopilot(t.ops),
|
||||
output: t.explanation ? { 说明: t.explanation } : null,
|
||||
error: t.error,
|
||||
}))
|
||||
if (run) {
|
||||
const finished = [...events].reverse().find((e) => e.type === 'run.finished')
|
||||
const failedEvent = [...events].reverse().find((e) => e.type === 'run.failed')
|
||||
const waiting = pending.length > 0
|
||||
list.push({
|
||||
id: run.id,
|
||||
// 运行的"问题"是输入表单,已经在工具栏那边填过了
|
||||
question: undefined,
|
||||
phase: failedEvent ? 'error'
|
||||
: waiting ? 'waiting'
|
||||
: streaming ? 'running'
|
||||
: finished ? 'done' : 'running',
|
||||
status: streaming ? '正在执行…' : waiting ? '等待你的确认' : finished ? '完成' : '',
|
||||
steps: decodeRun(events),
|
||||
// run 对象是启动时的快照,成果在 run.finished 事件里
|
||||
output: finished?.data?.output ?? run.output ?? null,
|
||||
error: failedEvent ? String(failedEvent.data?.error ?? '运行失败') : run.error || undefined,
|
||||
runId: run.id,
|
||||
runClass: run.run_class,
|
||||
})
|
||||
}
|
||||
return list
|
||||
}, [copilotTurns, run, events, streaming, pending.length, copilot.lastOp])
|
||||
// 发起一次新运行就把这一层推到前面——那是此刻唯一在发生的事
|
||||
useEffect(() => {
|
||||
if (run?.id) setView('run')
|
||||
}, [run?.id])
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col">
|
||||
{view === 'run' && run
|
||||
? <RunView onBack={() => setView('chat')} />
|
||||
: <ChatView hasRun={!!run} onOpenRun={() => setView('run')} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 对话:和 Copilot 说话的地方
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function ChatView({ hasRun, onOpenRun }: { hasRun: boolean; onOpenRun: () => void }) {
|
||||
const copilotTurns = useStudio((s) => s.copilotTurns)
|
||||
const copilot = useStudio((s) => s.copilot)
|
||||
const clearCopilot = useStudio((s) => s.clearCopilot)
|
||||
|
||||
const turns = useMemo<StreamTurn[]>(() => copilotTurns.map((t) => ({
|
||||
id: t.id,
|
||||
question: t.instruction,
|
||||
phase: t.phase === 'running' ? 'running' : t.phase === 'error' ? 'error' : 'done',
|
||||
// 生成期间不能只给一个孤零零的转圈图标,具体阶段从实时状态里取
|
||||
status: t.phase === 'running' ? (copilot.lastOp || '正在生成流程…')
|
||||
: t.phase === 'done' ? '流程已更新到画布' : '',
|
||||
steps: decodeCopilot(t.ops),
|
||||
output: t.explanation ? { 说明: t.explanation } : null,
|
||||
error: t.error,
|
||||
})), [copilotTurns, copilot.lastOp])
|
||||
|
||||
const empty = !turns.length
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<>
|
||||
{!empty && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-2.5 py-1.5">
|
||||
<span className="text-[11px] font-semibold">助手</span>
|
||||
{streaming && (
|
||||
<span className="flex items-center gap-1 text-[10px]" style={{ color: 'var(--accent)' }}>
|
||||
<Spinner size={9} /> 执行中
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] font-semibold">Copilot</span>
|
||||
<span className="flex-1" />
|
||||
{run && (
|
||||
<span className="mono text-[9.5px] text-faint" title={run.id}>
|
||||
#{run.id.slice(0, 6)}
|
||||
</span>
|
||||
)}
|
||||
{/* 原始事件是排查用的开发者视图,不该占一个常驻 tab——但也不能删,
|
||||
翻译层出问题时它是唯一能对照的东西 */}
|
||||
<button
|
||||
className={clsx('rounded p-1 transition-colors hover:bg-hover',
|
||||
raw ? 'text-[var(--accent)]' : 'text-faint')}
|
||||
title={raw ? '回到助手视图' : `看原始事件(${events.length} 条)`}
|
||||
onClick={() => setRaw((v) => !v)}
|
||||
>
|
||||
<Code2 size={12} />
|
||||
<button className="rounded p-1 text-faint transition-colors hover:bg-hover"
|
||||
title="清空对话(不影响画布)" onClick={clearCopilot}>
|
||||
<Eraser size={12} />
|
||||
</button>
|
||||
{!!copilotTurns.length && (
|
||||
<button className="rounded p-1 text-faint transition-colors hover:bg-hover"
|
||||
title="清空助手记录(不影响画布)" onClick={clearCopilot}>
|
||||
<Eraser size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{empty ? (
|
||||
// 没说过话时输入框就是这一栏的主体,竖直居中。之前它是个 38px 高的
|
||||
// 框挤在最底下,比上面任何一块都不起眼——"没地方引导写问题"说的就是它
|
||||
// 没说过话时输入框就是这一栏的主体,竖直居中
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-center overflow-y-auto py-6">
|
||||
<Composer hero />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="min-h-0 flex-1">
|
||||
{raw ? <RawEvents /> : (
|
||||
<AssistantStream
|
||||
turns={turns}
|
||||
dense
|
||||
// 审批卡插在它所属的那一轮里,而不是浮在整栏顶上:
|
||||
// "等你确认"和确认的按钮之间隔着整条执行过程,是两件事
|
||||
approvalsFor={(t) => (
|
||||
t.runId && pending.length ? (
|
||||
<div className="fade-up mt-2 overflow-hidden rounded-lg border"
|
||||
style={{ borderColor: 'var(--warn)' }}>
|
||||
{pending.map((a) => (
|
||||
<ApprovalCard key={a.id} approval={a}
|
||||
onResolved={(runId) => attachRun(runId)} />
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<AssistantStream turns={turns} dense />
|
||||
</div>
|
||||
<Composer hero={false} />
|
||||
{run && <RunFooter />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasRun && <RunStrip onClick={onOpenRun} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 对话下面那条运行进度。
|
||||
*
|
||||
* 运行搬去自己那一层之后,这条是它在对话视图里留下的唯一痕迹——没有它,
|
||||
* 点了运行再切回对话,这次运行在眼前就彻底没了。
|
||||
*/
|
||||
function RunStrip({ onClick }: { onClick: () => void }) {
|
||||
const run = useStudio((s) => s.run)
|
||||
const events = useStudio((s) => s.events)
|
||||
const streaming = useStudio((s) => s.streaming)
|
||||
if (!run) return null
|
||||
|
||||
const steps = decodeRun(events)
|
||||
const waiting = isAwaitingHuman(steps)
|
||||
const last = lastMeaningful(steps)
|
||||
const failed = events.some((e) => e.type === 'run.failed')
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex w-full shrink-0 items-center gap-2 border-t px-2.5 py-1.5 text-left text-[10.5px] transition-colors hover:bg-hover"
|
||||
style={waiting ? { background: 'color-mix(in srgb, var(--warn) 10%, transparent)' } : undefined}
|
||||
title="看这次运行的完整过程" onClick={onClick}
|
||||
>
|
||||
{waiting ? <Hand size={11} className="shrink-0" style={{ color: 'var(--warn)' }} />
|
||||
: streaming ? <Spinner size={10} />
|
||||
: <Play size={10} className="shrink-0 text-faint" fill="currentColor" />}
|
||||
<span className={clsx('min-w-0 flex-1 truncate',
|
||||
waiting && 'text-[var(--warn)]', failed && 'text-[var(--err)]')}>
|
||||
{waiting ? '等你确认'
|
||||
: failed ? '运行失败'
|
||||
: streaming ? (last?.title || '正在执行…')
|
||||
: '这次运行已结束'}
|
||||
</span>
|
||||
<ChevronRight size={11} className="shrink-0 text-faint" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 运行:过程和结果,没有输入框
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function RunView({ onBack }: { onBack: () => void }) {
|
||||
const run = useStudio((s) => s.run)!
|
||||
const events = useStudio((s) => s.events)
|
||||
const streaming = useStudio((s) => s.streaming)
|
||||
const attachRun = useStudio((s) => s.attachRun)
|
||||
const stopRun = useStudio((s) => s.stopRun)
|
||||
const approvals = useCatalog((s) => s.approvals)
|
||||
const pending = approvals.filter((a) => a.run_id === run.id && a.status === 'pending')
|
||||
const [raw, setRaw] = useState(false)
|
||||
|
||||
const turns = useMemo<StreamTurn[]>(() => {
|
||||
const finished = [...events].reverse().find((e) => e.type === 'run.finished')
|
||||
const failedEvent = [...events].reverse().find((e) => e.type === 'run.failed')
|
||||
const steps = decodeRun(events)
|
||||
// 从事件推,不查审批列表——那是 4 秒轮询一次的,中断后会有几秒钟
|
||||
// 这里说"完成",而实际上它正等着你点通过
|
||||
const waiting = isAwaitingHuman(steps)
|
||||
return [{
|
||||
id: run.id,
|
||||
// 运行没有"提问"——它的输入是工具栏那张表单。硬塞一个问题气泡
|
||||
// 只会让它看起来像一次对话,而它不是
|
||||
question: undefined,
|
||||
phase: failedEvent ? 'error'
|
||||
: waiting ? 'waiting'
|
||||
: streaming ? 'running'
|
||||
: finished ? 'done' : 'running',
|
||||
status: streaming ? '正在执行…' : waiting ? '等待你的确认' : finished ? '完成' : '',
|
||||
steps,
|
||||
// run 对象是启动时的快照,成果在 run.finished 事件里
|
||||
output: finished?.data?.output ?? run.output ?? null,
|
||||
error: failedEvent ? String(failedEvent.data?.error ?? '运行失败') : run.error || undefined,
|
||||
runId: run.id,
|
||||
runClass: run.run_class,
|
||||
}]
|
||||
}, [run, events, streaming])
|
||||
|
||||
return (
|
||||
<div className="sheet-in flex h-full min-h-0 flex-col">
|
||||
<div className="flex shrink-0 items-center gap-1.5 border-b px-2 py-1.5">
|
||||
<button
|
||||
className="flex items-center gap-0.5 rounded-md px-1 py-1 text-faint transition-colors hover:bg-hover hover:text-fg"
|
||||
title="回到 Copilot 对话" onClick={onBack}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
<span className="text-[10px]">Copilot</span>
|
||||
</button>
|
||||
<span className="mx-0.5 h-3.5 w-px shrink-0" style={{ background: 'var(--border)' }} />
|
||||
<span className="text-[11px] font-semibold">运行</span>
|
||||
<span className="mono text-[9.5px] text-faint" title={run.id}>#{run.id.slice(0, 6)}</span>
|
||||
<span className="flex-1" />
|
||||
{streaming && (
|
||||
<button className="rounded p-1 transition-colors hover:bg-hover"
|
||||
style={{ color: 'var(--err)' }} title="停止这次运行"
|
||||
onClick={() => void stopRun()}>
|
||||
<Square size={11} fill="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
{/* 原始事件是排查用的开发者视图,不该占一个常驻 tab——但也不能删,
|
||||
翻译层出问题时它是唯一能对照的东西 */}
|
||||
<button
|
||||
className={clsx('rounded p-1 transition-colors hover:bg-hover',
|
||||
raw ? 'text-[var(--accent)]' : 'text-faint')}
|
||||
title={raw ? '回到可读视图' : `看原始事件(${events.length} 条)`}
|
||||
onClick={() => setRaw((v) => !v)}
|
||||
>
|
||||
<Code2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{raw ? <RawEvents /> : (
|
||||
<AssistantStream
|
||||
turns={turns}
|
||||
dense
|
||||
// 审批卡插在它所属的那一轮里,而不是浮在整栏顶上:
|
||||
// 要确认的那件事和确认按钮之间不该隔着整条执行过程
|
||||
approvalsFor={() => (pending.length ? (
|
||||
<div className="fade-up mt-2 overflow-hidden rounded-lg border"
|
||||
style={{ borderColor: 'var(--warn)' }}>
|
||||
{pending.map((a) => (
|
||||
<ApprovalCard key={a.id} approval={a}
|
||||
onResolved={(runId) => attachRun(runId)} />
|
||||
))}
|
||||
</div>
|
||||
) : null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 border-t px-2.5 py-1 text-[9.5px] text-faint">
|
||||
{run.run_class === 'formal'
|
||||
? <span className="chip" style={{ color: 'var(--ok)', borderColor: 'var(--ok)' }}>正式 v{run.version}</span>
|
||||
: <span className="chip">探索</span>}
|
||||
<span className="flex-1" />
|
||||
{!!run.usage?.total_tokens && <span>{run.usage.total_tokens} tok</span>}
|
||||
{run.usage?.cost_usd ? <span>${Number(run.usage.cost_usd).toFixed(4)}</span> : null}
|
||||
{run.usage?.duration_ms ? <span>{formatDuration(run.usage.duration_ms)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RunFooter() {
|
||||
const run = useStudio((s) => s.run)!
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 border-t px-2.5 py-1 text-[9.5px] text-faint">
|
||||
{run.run_class === 'formal'
|
||||
? <span className="chip" style={{ color: 'var(--ok)', borderColor: 'var(--ok)' }}>正式 v{run.version}</span>
|
||||
: <span className="chip">探索</span>}
|
||||
<span className="flex-1" />
|
||||
{!!run.usage?.total_tokens && <span>{run.usage.total_tokens} tok</span>}
|
||||
{run.usage?.cost_usd ? <span>${Number(run.usage.cost_usd).toFixed(4)}</span> : null}
|
||||
{run.usage?.duration_ms ? <span>{formatDuration(run.usage.duration_ms)}</span> : null}
|
||||
</div>
|
||||
)
|
||||
/** 最后一条有意义的步骤:跳过生命周期行,那才是"此刻在干什么"。 */
|
||||
export function lastMeaningful(steps: Step[]): Step | undefined {
|
||||
const flat: Step[] = []
|
||||
const walk = (list: Step[]) => list.forEach((s) => {
|
||||
flat.push(s)
|
||||
if (s.children) walk(s.children)
|
||||
})
|
||||
walk(steps)
|
||||
return [...flat].reverse().find((s) => s.kind !== 'lifecycle') ?? flat[flat.length - 1]
|
||||
}
|
||||
|
||||
/** 原始事件。翻译层出问题时用来对照,平时收着。 */
|
||||
|
||||
@@ -96,6 +96,19 @@ export function formatDuration(ms?: number): string {
|
||||
return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* 这次运行是不是正停在人工介入上。
|
||||
*
|
||||
* 从步骤推,不要去查审批列表——那是 4 秒轮询一次的,而 run.interrupted
|
||||
* 事件是即时到的。用列表的话,中断后会有几秒钟界面说"这次运行已结束",
|
||||
* 而实际上它正等着你点通过。
|
||||
*/
|
||||
export function isAwaitingHuman(steps: Step[]): boolean {
|
||||
return steps.some(function walk(s): boolean {
|
||||
return s.status === 'waiting' || (s.children?.some(walk) ?? false)
|
||||
})
|
||||
}
|
||||
|
||||
/** run 到终态时,所有还挂着"进行中"的生命周期行都要收尾。 */
|
||||
function closeLifecycles(out: Step[], status: StepStatus) {
|
||||
out.forEach((s) => {
|
||||
|
||||
@@ -675,3 +675,9 @@ export const useStudio = create<StudioState>((set, get) => ({
|
||||
set({ events, runtime, activeEdges })
|
||||
},
|
||||
}))
|
||||
|
||||
// 开发期把 store 挂到 window 上,好让 playwright 直接读状态做断言——
|
||||
// 界面上看不出"事件收全了没有",只能问 store。生产构建里去掉。
|
||||
if (import.meta.env.DEV) {
|
||||
;(window as any).__studio = useStudio
|
||||
}
|
||||
|
||||
@@ -110,6 +110,13 @@ console.log('\n=== 停在审批时的状态 ===')
|
||||
const after = mod.decodeRun(fixtures.human)
|
||||
const doneHost = flatten(after).find((s) => s.kind === 'node' && s.nodeId === 'h')
|
||||
check('恢复后回到正常状态', doneHost?.status === 'done', doneHost?.status)
|
||||
|
||||
// "在不在等人"必须从事件推。查审批列表的话要等 4 秒轮询,中断后那几秒
|
||||
// 界面会说"这次运行已结束",而它其实正等着你点通过
|
||||
check('从事件就能判断正在等人', mod.isAwaitingHuman(mod.decodeRun(upto)) === true)
|
||||
check('跑完了就不再说在等人', mod.isAwaitingHuman(after) === false)
|
||||
check('压根没有人工节点的运行也不误报',
|
||||
mod.isAwaitingHuman(mod.decodeRun(fixtures.db)) === false)
|
||||
}
|
||||
|
||||
console.log('\n=== 循环里的多轮审批 ===')
|
||||
|
||||
Reference in New Issue
Block a user