UI 重构:Copilot 进侧栏,三个页面共用一条助手流

用户的原话是「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 个测试全过。
This commit is contained in:
ThearchyHelios
2026-09-17 18:29:16 +08:00
parent c1c13fad41
commit f0fd63f0b9
18 changed files with 3001 additions and 1012 deletions
+11 -2
View File
@@ -332,7 +332,13 @@ async def stream_run(websocket: WebSocket, run_id: str) -> None:
await websocket.accept()
after = int(websocket.query_params.get("after", 0) or 0)
# 先占订阅位,再读历史。反过来的话,两步之间图正好跑出来的事件既不在
# 历史里、也不在实时流里——永远丢了。实测就是这样:一次跑图的开头
# 两三条 node.started/node.finished 消失,界面上表现为某个节点永远
# 在转圈、它的子步骤跑到了顶层。
subscription = bus.attach(run_id)
try:
last_seq = after
async with SessionLocal() as session:
rows = await session.execute(
select(RunEvent)
@@ -340,6 +346,7 @@ async def stream_run(websocket: WebSocket, run_id: str) -> None:
.order_by(RunEvent.seq)
)
for event in rows.scalars():
last_seq = max(last_seq, event.seq or 0)
await websocket.send_json(
{
"seq": event.seq,
@@ -358,8 +365,8 @@ async def stream_run(websocket: WebSocket, run_id: str) -> None:
await websocket.close()
return
stream = bus.subscribe(run_id)
pump = asyncio.create_task(_pump(websocket, stream))
# 占位期间攒下的那几条历史里也有,按 seq 滤掉,否则前端同一步收两遍
pump = asyncio.create_task(_pump(websocket, subscription.stream(after=last_seq)))
try:
# 客户端断开时读操作会抛异常,用它来结束这条连接
while True:
@@ -375,6 +382,8 @@ async def stream_run(websocket: WebSocket, run_id: str) -> None:
except Exception: # noqa: BLE001
with contextlib.suppress(Exception):
await websocket.close()
finally:
subscription.close()
async def _pump(websocket: WebSocket, stream: Any) -> None:
+60 -14
View File
@@ -55,24 +55,70 @@ class EventBus:
with contextlib.suppress(asyncio.QueueFull):
sub.queue.put_nowait(None)
async def subscribe(self, run_id: str) -> AsyncIterator[RunEventModel]:
def attach(self, run_id: str) -> "Subscription":
"""立刻占一个位,之后再慢慢消费。
必须能在读历史**之前**占位。`subscribe()` 做不到这件事:它是异步
生成器,订阅者要等到第一次迭代才真正注册——而调用方通常先读完历史
才开始迭代。这中间发出的事件既不在历史里、也不在实时流里,就这么
没了。一次跑图从发起到订阅之间正好有几毫秒,丢掉的往往是开头那
两三条 node.started/node.finished,表现为"某个节点永远在转圈、
它的子步骤跑到了顶层"。
"""
sub = _Subscriber()
async with self._lock:
self._subs[run_id].add(sub)
try:
while True:
item = await sub.queue.get()
if item is None:
break
yield item
finally:
async with self._lock:
self._subs[run_id].discard(sub)
if not self._subs[run_id]:
self._subs.pop(run_id, None)
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()
+144
View File
@@ -0,0 +1,144 @@
"""事件总线的订阅时序。
这里守的是一个只在**并发**下才出现的缺陷:先读历史、再订阅,两步之间发出的
事件既不在历史里、也不在实时流里,永远丢了。串行地测"发一条收一条"永远
测不出来——必须在"读历史"的那一刻往总线里灌事件。
真实表现:一次跑图开头两三条 node.started/node.finished 消失,界面上某个节点
永远在转圈,它的子步骤跑到了顶层。
"""
from __future__ import annotations
import asyncio
import pytest
from app.core.bus import EventBus
from app.core.events import RunEventModel
def _event(run_id: str, seq: int, type_: str = "node.started") -> RunEventModel:
return RunEventModel(run_id=run_id, seq=seq, type=type_, node_id=None, data={})
@pytest.mark.asyncio
async def test_attach_before_history_loses_nothing() -> None:
bus = EventBus()
run_id = "r1"
got: list[int] = []
with bus.attach(run_id) as sub:
# 占位之后、开始消费之前发出的事件要留在队列里等着,不能丢
await bus.publish(_event(run_id, 1))
await bus.publish(_event(run_id, 2))
async def drain() -> None:
async for event in sub.stream():
got.append(event.seq)
task = asyncio.create_task(drain())
await asyncio.sleep(0)
await bus.publish(_event(run_id, 3))
await bus.close(run_id)
await asyncio.wait_for(task, timeout=2)
assert got == [1, 2, 3]
@pytest.mark.asyncio
async def test_after_filter_dedups_overlap() -> None:
"""占位到读完历史之间的事件两边都有一份,按 seq 滤掉重复的那份。"""
bus = EventBus()
run_id = "r2"
got: list[int] = []
with bus.attach(run_id) as sub:
for seq in (1, 2, 3):
await bus.publish(_event(run_id, seq))
# 假设补历史时读到了 seq<=2,那实时流里只该再吐 3
async def drain() -> None:
async for event in sub.stream(after=2):
got.append(event.seq)
task = asyncio.create_task(drain())
await asyncio.sleep(0)
await bus.close(run_id)
await asyncio.wait_for(task, timeout=2)
assert got == [3]
@pytest.mark.asyncio
async def test_detach_removes_subscriber() -> None:
bus = EventBus()
run_id = "r3"
sub = bus.attach(run_id)
assert bus.subscriber_count(run_id) == 1
sub.close()
assert bus.subscriber_count(run_id) == 0
@pytest.mark.asyncio
async def test_subscribe_registers_late() -> None:
"""刻画 subscribe() 的行为,说明 attach 为什么必须存在。
subscribe() 是异步生成器:拿到它并不等于订阅上了,要到第一次迭代才真正
注册。调用方"先拿生成器、再读历史、最后开始迭代"是最自然的写法,而那
恰好让整个读历史的窗口都处于无人接收的状态。
"""
bus = EventBus()
run_id = "r5"
gen = bus.subscribe(run_id)
assert bus.subscriber_count(run_id) == 0, "拿到生成器还不算订阅"
# 这条发出去时没人收——旧代码丢事件就是丢在这个窗口里
await bus.publish(_event(run_id, 1))
got: list[int] = []
async def drain() -> None:
async for event in gen:
got.append(event.seq)
task = asyncio.create_task(drain())
await asyncio.sleep(0.01)
assert bus.subscriber_count(run_id) == 1, "迭代之后才注册上"
await bus.publish(_event(run_id, 2))
await bus.close(run_id)
await asyncio.wait_for(task, timeout=2)
assert got == [2], "seq=1 确实丢了;attach 就是为了消掉这个窗口"
@pytest.mark.asyncio
async def test_concurrent_publish_during_history_read() -> None:
"""把真实时序演一遍:占位 → 慢慢读历史(期间图还在跑)→ 开始消费。"""
bus = EventBus()
run_id = "r4"
history: list[int] = []
live: list[int] = []
with bus.attach(run_id) as sub:
async def keep_running() -> None:
# 图不会等前端读完历史
for seq in range(1, 6):
await bus.publish(_event(run_id, seq))
await asyncio.sleep(0.005)
await bus.close(run_id)
runner = asyncio.create_task(keep_running())
# "读历史"——只读到前两条,剩下的还没落库
await asyncio.sleep(0.012)
history.extend([1, 2])
async def drain() -> None:
async for event in sub.stream(after=max(history)):
live.append(event.seq)
await asyncio.wait_for(asyncio.gather(runner, drain()), timeout=3)
# 3/4/5 一条都不能少——这正是修复前丢掉的那些
assert live == [3, 4, 5], f"历史={history} 实时={live}"
+5 -7
View File
@@ -3,6 +3,7 @@ import { Handle, Position, type NodeProps } from '@xyflow/react'
import { AlertTriangle, Check, Hand, Loader2, Sparkles, Wrench } from 'lucide-react'
import clsx from 'clsx'
import { NODE_DEFS, sourceHandles } from './nodeDefs'
import { formatDuration } from '../run/decode'
import { useStudio, type FlowNode } from '../store/studio'
import type { NodeType } from '../types'
@@ -149,7 +150,7 @@ function NodeCardImpl({ id, data, selected }: NodeProps<FlowNode>) {
{(runtime?.durationMs != null || runtime?.error) && (
<div className="flex items-center gap-2 border-t px-2.5 py-1">
{runtime.durationMs != null && (
<span className="text-[10px] text-faint">{formatMs(runtime.durationMs)}</span>
<span className="text-[10px] text-faint">{formatDuration(runtime.durationMs)}</span>
)}
{runtime.error && (
<span className="truncate text-[10px] text-[var(--err)]" title={runtime.error}>
@@ -185,10 +186,7 @@ function NodeCardImpl({ id, data, selected }: NodeProps<FlowNode>) {
)
}
export function formatMs(ms: number): string {
if (ms < 1000) return `${ms}ms`
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`
}
// 耗时格式化原本在这里又写了一份(formatMs),和 decode.ts 的规则不一样:
// 画布节点上显示"0ms",右边助手栏里同一步什么都不显示。两块并排放着,
// 用户看到的是两套说法。统一用 decode.ts 那份。
export const NodeCard = memo(NodeCardImpl)
+1
View File
@@ -26,6 +26,7 @@ const CASES: { key: Bucket; question: string; phase: StreamTurn['phase']; status
{ key: 'human', question: '把这批客户的信用额度调高 10%', phase: 'waiting', status: '等待你的确认' },
{ key: 'issue', question: '出一份本月经营分析', phase: 'done', status: '完成' },
{ key: 'failed', question: '查一下不存在的那张表', phase: 'error', status: '' },
{ key: 'loop_approve', question: '写条上线公告,我过一眼再发', phase: 'done', status: '完成' },
]
function buildTurns(): StreamTurn[] {
+92 -219
View File
@@ -1,14 +1,13 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ChevronRight, Database, MessageSquare, Send, Square, Table2, Trash2, Wrench, XCircle,
} from 'lucide-react'
import clsx from 'clsx'
import { useEffect, useMemo, useState } from 'react'
import { Database, MessageSquare, Send, Square, Trash2 } from 'lucide-react'
import { api } from '../api/client'
import { ApprovalCard } from '../run/RunPanel'
import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantStream'
import { decodeCopilot, decodeRun } from '../run/decode'
import { useCatalog } from '../store/catalog'
import { useChat, type ChatTurn } from '../store/chat'
import { useStudio, toFlow } from '../store/studio'
import { Empty, Spinner, useToast } from '../components/ui'
import { useStudio } from '../store/studio'
import { useToast } from '../components/ui'
/**
* 对话式入口。
@@ -19,27 +18,29 @@ import { Empty, Spinner, useToast } from '../components/ui'
*
* 所以这一页把图藏起来:你问,它自己接数据源、建流程、跑完、给结论。图仍然
* 真实存在(可以展开看,也能拿到画布里继续改),只是不再是必经之路。
*
* 这里和画布右栏用的是同一个 AssistantStream、同一个 decode.ts——差别只有
* dense 一个开关。两边各写一套的话,同一次运行在两个页面会讲出不同的故事。
*/
export function ChatPage() {
const { turns, busy, ask, stop, clear } = useChat()
const [draft, setDraft] = useState('')
const bottomRef = useRef<HTMLDivElement>(null)
const sources = useDataSources()
const setGraph = useStudio((s) => s.setGraph)
const toast = useToast()
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [turns.length, turns[turns.length - 1]?.phase])
const send = () => {
const q = draft.trim()
const send = (text?: string) => {
const q = (text ?? draft).trim()
if (!q || busy) return
ask(q)
setDraft('')
}
const streamTurns = useMemo(() => turns.map(toStreamTurn), [turns])
return (
<div className="flex h-full flex-col">
<header className="flex items-center gap-2 border-b px-4 py-2">
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-2">
<MessageSquare size={14} style={{ color: 'var(--accent)' }} />
<span className="text-[13px] font-semibold">问数据</span>
<span className="text-[11px] text-faint">
@@ -53,220 +54,93 @@ export function ChatPage() {
)}
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{!turns.length ? (
<StarterHints sources={sources} onPick={(q) => { setDraft(q); }} />
) : (
<div className="mx-auto max-w-3xl space-y-5">
{turns.map((turn) => <Turn key={turn.id} turn={turn} />)}
<AssistantStream
turns={streamTurns}
empty={<StarterHints sources={sources} onPick={send} />}
approvalsFor={(t) => <Approvals turnId={t.id} runId={t.runId} />}
onOpenGraph={(graph) => {
setGraph(graph)
toast('已放到画布,切到「编排」继续改', 'ok')
}}
footer={
<div className="shrink-0 border-t p-3">
<div className="mx-auto flex max-w-3xl items-end gap-2">
<textarea
className="field flex-1 resize-none"
rows={2}
value={draft}
placeholder={
sources.length
? `问点什么,比如「${sources[0].description || sources[0].name} 里有多少数据」`
: '还没有接入数据源,先去「设置 → 数据源」加一个'
}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Enter 发送,Shift+Enter 换行——对话框的通用约定
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() }
}}
/>
{busy ? (
<button className="btn btn-danger h-9" onClick={stop}>
<Square size={12} /> 停止
</button>
) : (
<button className="btn btn-primary h-9" onClick={() => send()}
disabled={!draft.trim()}>
<Send size={12} /> 发送
</button>
)}
</div>
</div>
)}
<div ref={bottomRef} />
</div>
<div className="border-t p-3">
<div className="mx-auto flex max-w-3xl items-end gap-2">
<textarea
className="field flex-1 resize-none"
rows={2}
value={draft}
placeholder={
sources.length
? `问点什么,比如「${sources[0].description || sources[0].name} 里有多少数据」`
: '还没有接入数据源,先去「设置 → 数据源」加一个'
}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Enter 发送,Shift+Enter 换行——对话框的通用约定
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() }
}}
/>
{busy ? (
<button className="btn btn-danger h-9" onClick={stop}>
<Square size={12} /> 停止
</button>
) : (
<button className="btn btn-primary h-9" onClick={send} disabled={!draft.trim()}>
<Send size={12} /> 发送
</button>
)}
</div>
</div>
}
/>
</div>
)
}
// -------------------------------------------------------------------------
function Turn({ turn }: { turn: ChatTurn }) {
/**
* 一轮对话 → 一条助手流。
*
* 建图和跑图是两套完全独立的协议(SSE 操作流 vs 事件 WebSocket,不共享
* seq 和 node_id),但对用户是同一件事的两半:先想清楚怎么做,再去做。
* 拼成一条步骤序列,看到的才是一个连续的过程。
*/
function toStreamTurn(turn: ChatTurn): StreamTurn {
const running = ['planning', 'building', 'running'].includes(turn.phase)
return (
<div className="fade-up">
{/* 用户的问题 */}
<div className="mb-2 flex justify-end">
<div className="max-w-[80%] rounded-lg rounded-br-sm px-3 py-2 text-[12.5px]"
style={{ background: 'var(--accent)', color: '#fff' }}>
{turn.question}
</div>
</div>
{/* 回应 */}
<div className="rounded-lg border bg-panel p-3">
<div className="flex items-center gap-2 text-[11.5px]">
{running && <Spinner size={12} />}
{turn.phase === 'error' && <XCircle size={12} className="text-[var(--err)]" />}
<span className={clsx(turn.phase === 'error' && 'text-[var(--err)]')}>
{turn.phase === 'error' ? turn.error : turn.status}
</span>
{turn.run && (
<span className="mono text-[10px] text-faint">#{turn.run.id.slice(0, 8)}</span>
)}
</div>
{/* 模型在想什么——等待期间唯一能看的东西 */}
{running && turn.thinking && (
<div className="mt-2 max-h-24 overflow-y-auto whitespace-pre-wrap rounded bg-bg px-2 py-1.5 text-[10.5px] leading-relaxed text-dim">
{turn.thinking}
</div>
)}
{/* 干了什么:查了哪张表、跑了什么 SQL */}
{!!turn.steps.length && (
<div className="mt-2 space-y-1">
{turn.steps.map((s, i) => <Step key={i} step={s} />)}
</div>
)}
{/* 停在人工介入:就地处理,不用切去别的页面 */}
{turn.phase === 'waiting' && turn.run && <Approvals turn={turn} />}
{/* 成果 */}
{turn.output && <Output output={turn.output} />}
{/* 生成的图:默认藏起来,想看再展开 */}
{turn.graph && <GraphPeek turn={turn} />}
</div>
</div>
)
return {
id: turn.id,
question: turn.question,
phase: turn.phase === 'waiting' ? 'waiting'
: turn.phase === 'error' ? 'error'
: running ? 'running' : 'done',
status: turn.status,
// 建图的步骤在前,跑图的在后——这正是发生的顺序
steps: [...decodeCopilot(turn.ops), ...decodeRun(turn.events)],
thinking: turn.thinking,
output: turn.output,
error: turn.error,
runId: turn.run?.id,
runClass: turn.run?.run_class,
graph: turn.graph,
graphNote: turn.explanation,
}
}
function Step({ step }: { step: { icon: string; text: string; detail?: string } }) {
const [open, setOpen] = useState(false)
const Icon = step.icon === 'db' ? Database
: step.icon === 'schema' ? Table2
: step.icon === 'error' ? XCircle : Wrench
const color = step.icon === 'error' ? 'var(--err)' : 'var(--text-faint)'
return (
<div className="text-[11px]">
<button
className="flex w-full items-start gap-1.5 text-left hover:text-dim"
style={{ color }}
onClick={() => step.detail && setOpen((v) => !v)}
>
<Icon size={11} className="mt-[2px] shrink-0" />
<span className="min-w-0 flex-1">{step.text}</span>
{step.detail && (
<ChevronRight size={10} className="mt-[2px] shrink-0"
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
)}
</button>
{open && step.detail && (
<pre className="mono mt-1 max-h-32 overflow-auto rounded bg-bg px-2 py-1.5 text-[10px] leading-relaxed text-dim whitespace-pre-wrap">
{step.detail}
</pre>
)}
</div>
)
}
function Approvals({ turn }: { turn: ChatTurn }) {
function Approvals({ turnId, runId }: { turnId: string; runId?: string }) {
const approvals = useCatalog((s) => s.approvals)
const reattach = useChat((s) => s.reattach)
const pending = approvals.filter((a) => a.run_id === turn.run?.id && a.status === 'pending')
const pending = approvals.filter((a) => a.run_id === runId && a.status === 'pending')
if (!pending.length) return null
if (!runId || !pending.length) return null
return (
<div className="mt-2 overflow-hidden rounded border" style={{ borderColor: 'var(--warn)' }}>
{pending.map((a) => (
<ApprovalCard key={a.id} approval={a} onResolved={() => reattach(turn.id)} />
<ApprovalCard key={a.id} approval={a} onResolved={() => reattach(turnId)} />
))}
</div>
)
}
function Output({ output }: { output: Record<string, any> }) {
const entries = useMemo(
() => Object.entries(output).filter(([k]) => !k.startsWith('_')),
[output],
)
if (!entries.length) return null
return (
<div className="mt-2 space-y-2 border-t pt-2">
{entries.map(([key, value]) => (
<div key={key}>
{entries.length > 1 && (
<div className="mb-0.5 text-[10.5px] font-semibold text-faint">{key}</div>
)}
<div className="whitespace-pre-wrap text-[12.5px] leading-relaxed">
{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
</div>
</div>
))}
</div>
)
}
function GraphPeek({ turn }: { turn: ChatTurn }) {
const [open, setOpen] = useState(false)
const setGraph = useStudio((s) => s.setGraph)
const toast = useToast()
const nodes = turn.graph?.nodes ?? []
return (
<div className="mt-2 border-t pt-2">
<div className="flex items-center gap-2">
<button className="flex items-center gap-1 text-[10.5px] text-faint hover:text-dim"
onClick={() => setOpen((v) => !v)}>
<ChevronRight size={10}
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
它是怎么做的({nodes.length} 步)
</button>
<span className="flex-1" />
<button
className="btn btn-sm btn-ghost text-[10.5px]"
title="把这张图放到画布上继续改"
onClick={() => {
if (!turn.graph) return
setGraph(turn.graph)
toast('已放到画布,切到「编排」继续改', 'ok')
}}
>
在画布里打开
</button>
</div>
{open && (
<div className="mt-1.5 space-y-1">
{turn.explanation && (
<div className="rounded bg-bg px-2 py-1.5 text-[11px] leading-relaxed text-dim">
{turn.explanation}
</div>
)}
{nodes.map((n: any, i: number) => (
<div key={n.id} className="flex items-center gap-2 text-[10.5px] text-faint">
<span className="mono w-4 text-right">{i + 1}</span>
<span className="chip">{n.type}</span>
<span className="truncate">{n.data?.label || n.id}</span>
</div>
))}
</div>
)}
</div>
)
}
// -------------------------------------------------------------------------
function StarterHints({ sources, onPick }: {
@@ -275,7 +149,7 @@ function StarterHints({ sources, onPick }: {
}) {
if (!sources.length) {
return (
<Empty
<StreamEmpty
icon={<Database size={22} />}
title="还没有接入数据源"
hint="去「设置 → 数据源」加一个数据库,然后就能直接问它问题了"
@@ -293,13 +167,12 @@ function StarterHints({ sources, onPick }: {
}).slice(0, 4)
return (
<div className="mx-auto max-w-3xl">
<Empty
icon={<MessageSquare size={22} />}
title="问点什么"
hint="它会自己接数据源、写查询、跑完给结论——你不用碰画布"
/>
<div className="mt-3 space-y-1.5">
<StreamEmpty
icon={<MessageSquare size={22} />}
title="问点什么"
hint="它会自己接数据源、写查询、跑完给结论——你不用碰画布"
>
<div className="space-y-1.5">
{suggestions.map((q) => (
<button key={q}
className="w-full rounded-lg border px-3 py-2 text-left text-[12px] hover:bg-hover"
@@ -308,7 +181,7 @@ function StarterHints({ sources, onPick }: {
</button>
))}
</div>
</div>
</StreamEmpty>
)
}
+116 -69
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'
import { GitFork, History, RefreshCw, Trash2 } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { Code2, GitFork, History, RefreshCw, Trash2 } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../api/client'
import { formatMs } from '../canvas/NodeCard'
import { Empty, Spinner, StatusDot, useToast } from '../components/ui'
import { ApprovalCard } from '../run/RunPanel'
import { AssistantStream, type StreamTurn } from '../run/AssistantStream'
import { decodeRun, formatDuration, summarizeRun } from '../run/decode'
import { useCatalog } from '../store/catalog'
import type { Run, RunEvent } from '../types'
@@ -16,6 +17,7 @@ export function RunsPage() {
const [events, setEvents] = useState<RunEvent[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('')
const [raw, setRaw] = useState(false)
// 导航上的待办徽标指向这一页,所以这页必须能处理待办
const approvals = useCatalog((s) => s.approvals)
const pendingHere = approvals.filter(
@@ -31,59 +33,92 @@ export function RunsPage() {
const open = async (run: Run) => {
setSelected(run)
setEvents([])
setEvents(await api.runs.events(run.id).catch(() => []))
}
const shown = runs.filter(
(r) => !filter || r.workflow_name.includes(filter) || r.status === filter,
(r) => !filter || r.workflow_name?.includes(filter) || r.status === filter
|| summarizeRun(r.input, r.output).includes(filter),
)
// 详情走和画布助手栏、问数据页同一个解码器和同一个组件:三处各写一套的话,
// 同一次运行会被讲成三个不同的故事,而用户没法判断哪个是真的
const turn = useMemo<StreamTurn[]>(() => {
if (!selected) return []
const finished = [...events].reverse().find((e) => e.type === 'run.finished')
const failedEvent = [...events].reverse().find((e) => e.type === 'run.failed')
return [{
id: selected.id,
phase: failedEvent || selected.status === 'failed' ? 'error'
: pendingHere.length ? 'waiting'
: selected.status === 'running' ? 'running' : 'done',
status: STATUS_TEXT[selected.status] ?? selected.status,
steps: decodeRun(events),
// run 对象是启动时的快照,成果在 run.finished 事件里;历史记录两者
// 都已落库,取事件那份更接近"当时真的输出了什么"
output: finished?.data?.output ?? selected.output ?? null,
error: failedEvent ? String(failedEvent.data?.error ?? '') : selected.error || undefined,
runClass: selected.run_class,
}]
}, [selected, events, pendingHere.length])
return (
<div className="flex h-full">
<div className="flex w-[380px] shrink-0 flex-col border-r">
<div className="flex items-center gap-2 border-b p-2">
<input className="field" placeholder="按名称或状态筛选…" value={filter}
<input className="field" placeholder="按名称、状态或内容筛选…" value={filter}
onChange={(e) => setFilter(e.target.value)} />
<button className="btn btn-sm" onClick={load}><RefreshCw size={11} /></button>
</div>
<div className="flex-1 overflow-y-auto">
{loading && <div className="p-4"><Spinner /></div>}
{!loading && !shown.length && <Empty icon={<History size={22} />} title="还没有运行记录" />}
{shown.map((run) => (
<button
key={run.id}
onClick={() => open(run)}
className={clsx(
'flex w-full flex-col gap-1 border-b px-3 py-2 text-left hover:bg-hover',
selected?.id === run.id && 'bg-hover',
)}
>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-[12px]">{run.workflow_name || '临时图'}</span>
{run.run_class === 'formal' && (
<span className="chip" style={{ color: 'var(--ok)', borderColor: 'var(--ok)' }}>正式</span>
{shown.map((run) => {
// 一行只有"临时图 · 1.2s · 340 tok"的话,十条运行长得一模一样,
// 要分清哪条是哪条只能挨个点开
const summary = summarizeRun(run.input, run.output)
return (
<button
key={run.id}
onClick={() => open(run)}
className={clsx(
'flex w-full flex-col gap-0.5 border-b px-3 py-2 text-left hover:bg-hover',
selected?.id === run.id && 'bg-hover',
)}
{(run.output as any)?._issuance?.tier === 'withheld' && (
<span className="chip" style={{ color: 'var(--err)' }}>不予出具</span>
>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-[12px]">
{run.workflow_name || '临时图'}
</span>
{run.run_class === 'formal' && (
<span className="chip" style={{ color: 'var(--ok)', borderColor: 'var(--ok)' }}>正式</span>
)}
{(run.output as any)?._issuance?.tier === 'withheld' && (
<span className="chip" style={{ color: 'var(--err)' }}>不予出具</span>
)}
<StatusDot status={run.status} />
</div>
{summary && (
<div className="truncate text-[11px] text-dim" title={summary}>{summary}</div>
)}
<StatusDot status={run.status} />
</div>
<div className="flex gap-2 text-[10px] text-faint">
<span>{run.created_at ? new Date(run.created_at).toLocaleString('zh-CN') : ''}</span>
{run.usage?.duration_ms ? <span>{formatMs(run.usage.duration_ms)}</span> : null}
{run.usage?.total_tokens ? <span>{run.usage.total_tokens} tok</span> : null}
{run.usage?.cost_usd ? <span>${Number(run.usage.cost_usd).toFixed(4)}</span> : null}
</div>
</button>
))}
<div className="flex gap-2 text-[10px] text-faint">
<span>{run.created_at ? new Date(run.created_at).toLocaleString('zh-CN') : ''}</span>
{run.usage?.duration_ms ? <span>{formatDuration(run.usage.duration_ms)}</span> : null}
{run.usage?.total_tokens ? <span>{run.usage.total_tokens} tok</span> : null}
{run.usage?.cost_usd ? <span>${Number(run.usage.cost_usd).toFixed(4)}</span> : null}
</div>
</button>
)
})}
</div>
</div>
<div className="min-w-0 flex-1 overflow-y-auto">
<div className="flex min-w-0 flex-1 flex-col">
{!selected && <Empty title="选一条运行记录" hint="左侧点击可以查看完整的执行轨迹、输入输出和用量。" />}
{selected && (
<div className="p-4">
<div className="mb-3 flex items-start gap-3">
<>
<div className="flex shrink-0 items-start gap-3 border-b px-4 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-semibold">
{selected.workflow_name || '临时图'}
@@ -91,7 +126,7 @@ export function RunsPage() {
? <span className="chip" style={{ color: 'var(--ok)', borderColor: 'var(--ok)' }}>正式 v{selected.version}</span>
: <span className="chip">探索性</span>}
</div>
<div className="mono text-[10.5px] text-faint">
<div className="mono truncate text-[10.5px] text-faint">
{selected.id}
{selected.version_hash && ` · 版本 ${selected.version_hash.slice(0, 10)}`}
{selected.manifest_hash && ` · 清单 ${selected.manifest_hash.slice(0, 10)}`}
@@ -99,6 +134,13 @@ export function RunsPage() {
</div>
</div>
<StatusDot status={selected.status} />
<button
className={clsx('btn btn-sm btn-ghost', raw && 'text-[var(--accent)]')}
title={raw ? '回到可读视图' : `看原始事件(${events.length} 条)`}
onClick={() => setRaw((v) => !v)}
>
<Code2 size={11} />
</button>
{selected.run_class !== 'formal' && (
<button
className="btn btn-sm"
@@ -131,53 +173,58 @@ export function RunsPage() {
而这里以前只有只读的事件列表——刷新页面后那些卡在审批上的
运行在整个界面里没有任何入口可以推进。 */}
{pendingHere.length > 0 && (
<div className="mb-3 overflow-hidden rounded-lg border" style={{ borderColor: 'var(--warn)' }}>
{pendingHere.map((a) => <ApprovalCard key={a.id} approval={a} />)}
<div className="shrink-0 border-b" style={{ borderColor: 'var(--warn)' }}>
{pendingHere.map((a) => (
<ApprovalCard key={a.id} approval={a} onResolved={() => open(selected)} />
))}
</div>
)}
{selected.error && (
<pre className="mono mb-3 whitespace-pre-wrap rounded border p-2 text-[11px] text-[var(--err)]"
style={{ borderColor: 'var(--err)' }}>
{selected.error}
</pre>
)}
<Block title="输入" data={selected.input} />
<Block title="成果" data={selected.output} />
<Block title="用量" data={selected.usage} />
<div className="mb-1.5 mt-4 text-[11px] font-semibold uppercase tracking-wide text-faint">
执行轨迹({events.length} 条事件)
<div className="min-h-0 flex-1">
{raw ? <RawEvents events={events} /> : <AssistantStream turns={turn} />}
</div>
<div className="rounded border">
{events.map((e) => (
<div key={e.seq} className="flex gap-2 border-b px-2 py-1 text-[10.5px] last:border-0">
<span className="w-10 shrink-0 text-faint">#{e.seq}</span>
<span className="w-32 shrink-0 text-[var(--accent)]">{e.type}</span>
<span className="w-24 shrink-0 truncate text-dim">{e.node_id ?? ''}</span>
<span className="min-w-0 flex-1 break-all text-faint">
{JSON.stringify(e.data).slice(0, 240)}
</span>
</div>
))}
{!events.length && <div className="p-3 text-center text-[11px] text-faint">没有事件记录</div>}
<div className="flex shrink-0 items-center gap-3 border-t px-4 py-1.5 text-[10px] text-faint">
<span>{events.length} 条事件</span>
{selected.usage?.total_tokens ? <span>{selected.usage.total_tokens} tok</span> : null}
{selected.usage?.cost_usd ? <span>${Number(selected.usage.cost_usd).toFixed(4)}</span> : null}
{selected.usage?.duration_ms ? <span>{formatDuration(selected.usage.duration_ms)}</span> : null}
<span className="flex-1" />
{selected.input && !!Object.keys(selected.input).length && (
<span className="truncate" title={JSON.stringify(selected.input, null, 2)}>
输入:{summarizeRun(selected.input)}
</span>
)}
</div>
</div>
</>
)}
</div>
</div>
)
}
function Block({ title, data }: { title: string; data: any }) {
if (!data || !Object.keys(data).length) return null
const STATUS_TEXT: Record<string, string> = {
succeeded: '完成', failed: '失败', running: '执行中',
interrupted: '等待人工介入', cancelled: '已取消', queued: '排队中',
}
/** 原始事件。翻译层出问题时用来对照。 */
function RawEvents({ events }: { events: RunEvent[] }) {
if (!events.length) {
return <div className="p-3 text-center text-[11px] text-faint">没有事件记录</div>
}
return (
<div className="mb-3">
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-faint">{title}</div>
<pre className="mono max-h-64 overflow-auto whitespace-pre-wrap break-words rounded border bg-panel p-2 text-[11px] leading-relaxed">
{JSON.stringify(data, null, 2)}
</pre>
<div className="h-full overflow-y-auto">
{events.map((e) => (
<div key={e.seq} className="flex gap-2 border-b px-3 py-1 text-[10.5px] last:border-0">
<span className="w-10 shrink-0 text-faint">#{e.seq}</span>
<span className="w-32 shrink-0 text-[var(--accent)]">{e.type}</span>
<span className="w-24 shrink-0 truncate text-dim">{e.node_id ?? ''}</span>
<span className="min-w-0 flex-1 break-all text-faint">
{JSON.stringify(e.data).slice(0, 240)}
</span>
</div>
))}
</div>
)
}
+34 -235
View File
@@ -1,16 +1,16 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
AlertTriangle, Check, ChevronDown, ChevronRight, Copy, LayoutGrid, Plus, Save, ShieldCheck,
Sparkles, Trash2, Wand2,
AlertTriangle, Check, ChevronDown, Copy, LayoutGrid, Plus, Save, ShieldCheck,
Trash2, Wand2,
} from 'lucide-react'
import clsx from 'clsx'
import { api } from '../api/client'
import { FlowCanvas } from '../canvas/FlowCanvas'
import { Inspector } from '../canvas/Inspector'
import { Palette } from '../canvas/Palette'
import { RunPanel } from '../run/RunPanel'
import { AssistantPanel } from '../run/AssistantPanel'
import { useStudio, toGraph } from '../store/studio'
import { useCatalog, modelOptions } from '../store/catalog'
import { useCatalog } from '../store/catalog'
import { Empty, Modal, Spinner, Tabs, useToast } from '../components/ui'
import type { Workflow } from '../types'
@@ -26,9 +26,9 @@ export function StudioPage() {
const [tab, setTab] = useState<'inspect' | 'run'>('run')
const [picker, setPicker] = useState(false)
const [copilot, setCopilot] = useState(false)
const [saving, setSaving] = useState(false)
const [publishing, setPublishing] = useState(false)
const copilotActive = useStudio((s) => s.copilot.active)
// 首次进来自动打开第一张图
useEffect(() => {
@@ -39,9 +39,10 @@ export function StudioPage() {
useEffect(() => {
if (selectedId) setTab('inspect')
}, [selectedId])
// 开始跑图或开始生成时切回助手栏,否则进展发生在一个看不见的 tab 里
useEffect(() => {
if (streaming) setTab('run')
}, [streaming])
if (streaming || copilotActive) setTab('run')
}, [streaming, copilotActive])
const doSave = useCallback(async () => {
if (!workflow) return
@@ -136,7 +137,18 @@ export function StudioPage() {
<div className="flex-1" />
<button className="btn" onClick={() => setCopilot(true)} title="用自然语言生成或修改工作流">
{/* Copilot 不再是弹窗:它就在右栏里,这个按钮只负责把人送过去。
弹窗的问题是每改一次图都要重开一次,而且关掉之后需求文本就没了 */}
<button
className="btn"
title="用自然语言生成或修改工作流"
onClick={() => {
setTab('run')
// 等这一帧渲染完输入框才在 DOM 里
requestAnimationFrame(() =>
window.dispatchEvent(new Event('agentlab:focus-copilot')))
}}
>
<Wand2 size={12} /> Copilot
</button>
<button className="btn" onClick={relayout} title="自动排版"><LayoutGrid size={12} /></button>
@@ -156,22 +168,26 @@ export function StudioPage() {
</aside>
<main className="relative min-w-0 flex-1">
<FlowCanvas />
<CopilotStatusBar />
</main>
<aside className="flex w-[360px] shrink-0 flex-col border-l bg-panel">
<Tabs
tabs={[{ key: 'run', label: '运行' }, { key: 'inspect', label: '属性' }]}
tabs={[{ key: 'run', label: '助手' }, { key: 'inspect', label: '属性' }]}
active={tab}
onChange={(k) => setTab(k as any)}
/>
<div className="min-h-0 flex-1">
{tab === 'inspect' ? <Inspector /> : <RunPanel />}
{/* 两个都挂着、用 hidden 切换,而不是三元卸载其中一个。
助手栏里有输入框草稿、展开状态、滚动位置——点一下"属性"看个
节点再切回来全没了,等于逼人别用属性页 */}
<div className={clsx('min-h-0 flex-1', tab !== 'run' && 'hidden')}>
<AssistantPanel />
</div>
<div className={clsx('min-h-0 flex-1 overflow-y-auto', tab !== 'inspect' && 'hidden')}>
<Inspector />
</div>
</aside>
</div>
<WorkflowPicker open={picker} onClose={() => setPicker(false)} />
<CopilotModal open={copilot} onClose={() => setCopilot(false)} />
{publishing && workflow && (
<PublishModal workflow={workflow} onClose={() => setPublishing(false)}
onDone={() => { setPublishing(false); void refresh() }} />
@@ -368,224 +384,7 @@ function PublishModal({ workflow, onClose, onDone }: {
)
}
function CopilotModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const nodes = useStudio((s) => s.nodes)
const runCopilot = useStudio((s) => s.runCopilot)
const providers = useCatalog((s) => s.providers)
const [instruction, setInstruction] = useState('')
const [useBase, setUseBase] = useState(true)
const [model, setModel] = useState<string>('')
const [effective, setEffective] = useState<string>('')
// Copilot 的模型是独立设置:它写的是编排本身,值得和工作流节点分开选
useEffect(() => {
if (!open) return
void api.copilot.getModel().then((m) => {
setModel(m.model ?? '')
setEffective(m.effective_model ?? '')
}).catch(() => undefined)
}, [open])
const pickModel = (value: string) => {
setModel(value)
// 选了就记住,下次打开还是它
void api.copilot.setModel({ model: value || null })
.then((m) => setEffective(m.effective_model ?? ''))
.catch(() => undefined)
}
const generate = () => {
if (!instruction.trim()) return
runCopilot(instruction, useBase && nodes.length > 0, model || undefined)
onClose()
}
const options = modelOptions(providers)
const groups = [...new Set(options.map((o) => o.group))]
const examples = [
'读取用户上传的问题,先查知识库,查到就基于资料回答并标注出处,查不到就联网搜索',
'把一段长文本拆成要点,逐条用模型打分,低分的让模型重写一次,最后汇总成表格',
'写代码分析数据,在沙箱里跑,出错就把报错喂回去让模型修,最多修三次',
]
return (
<Modal
open={open}
onClose={onClose}
title={<span className="flex items-center gap-1.5"><Sparkles size={14} /> Copilot 生成工作流</span>}
width={620}
footer={
<>
<button className="btn" onClick={onClose}>关闭</button>
<button className="btn btn-primary" onClick={generate} disabled={!instruction.trim()}>
<Wand2 size={12} /> 生成到画布
</button>
</>
}
>
<label className="label">描述你想要的工作流</label>
<textarea
className="field"
rows={4}
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
placeholder="用大白话说清楚:输入是什么、中间要做哪几步、遇到什么情况走不同分支、最后要什么结果"
/>
<div className="mt-2 flex flex-wrap gap-1.5">
{examples.map((ex) => (
<button key={ex} className="chip hover:border-[var(--accent)]" onClick={() => setInstruction(ex)}>
{ex.slice(0, 26)}…
</button>
))}
</div>
<div className="mt-3">
<label className="label">Copilot 使用的模型</label>
<select className="field" value={model} onChange={(e) => pickModel(e.target.value)}>
<option value="">跟随默认 provider{effective ? `(当前:${effective})` : ''}</option>
{groups.map((g) => (
<optgroup key={g} label={g}>
{options.filter((o) => o.group === g).map((o) => (
<option key={g + o.value} value={o.value}>{o.label}</option>
))}
</optgroup>
))}
</select>
<div className="mt-1 text-[10px] leading-snug text-faint">
选择会被记住,只影响 Copilot 自己,不改工作流节点上的模型。
它要按协议逐行输出操作,指令遵循弱的模型(比如 Mock)生成不出东西。
</div>
</div>
{!!nodes.length && (
<label className="mt-3 flex items-center gap-2 text-[11.5px]">
<input type="checkbox" checked={useBase} onChange={(e) => setUseBase(e.target.checked)}
className="accent-[var(--accent)]" />
在当前这张图的基础上修改(不勾则重新生成)
</label>
)}
<div className="mt-2 text-[10.5px] leading-relaxed text-faint">
点击生成后弹窗会关闭,节点会<b>实时长在画布上</b>(紫色描边是新改动)。
结果<b>不会自动保存</b>——看一遍再决定。
</div>
</Modal>
)
}
/** Copilot 工作时的画布浮条:显示当前操作,可随时取消;结束后展示说明。 */
// 阶段文案。从提交到第一个节点落地中间有 5~30 秒,这段时间界面上只有
// "正在起草…"三个字一动不动,用户分不清是在想还是已经卡死——阶段会变的
// 本身就是"它还活着"的信号。
const COPILOT_PHASES: Record<string, string> = {
connecting: '正在连接模型…',
planning: '正在理解需求、规划结构…',
building: '正在放置节点…',
wiring: '正在连接数据流…',
finalizing: '正在排版和校验…',
}
function CopilotStatusBar() {
const copilot = useStudio((s) => s.copilot)
const stopCopilot = useStudio((s) => s.stopCopilot)
const retryCopilot = useStudio((s) => s.retryCopilot)
const [dismissed, setDismissed] = useState(false)
const [showThinking, setShowThinking] = useState(true)
const thinkingRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// 新一轮生成开始时重新显示
if (copilot.active) setDismissed(false)
}, [copilot.active])
// 思考是流式追加的,跟着滚到底,否则用户只能看到最早那几行
useEffect(() => {
const el = thinkingRef.current
if (el) el.scrollTop = el.scrollHeight
}, [copilot.thinking])
if (dismissed) return null
if (!copilot.active && !copilot.explanation && !copilot.error) return null
const seconds = copilot.elapsedMs ? Math.round(copilot.elapsedMs / 1000) : 0
// 有具体操作就显示操作,还没开始动就显示阶段——不再是恒定的"正在起草…"
const headline = copilot.lastOp || COPILOT_PHASES[copilot.phase] || '正在起草…'
return (
<div className="fade-up pointer-events-auto absolute bottom-4 left-4 z-30 max-w-md rounded-lg border bg-panel px-3 py-2 shadow-xl"
style={{ borderColor: copilot.error ? 'var(--err)' : '#bc8cff' }}>
{copilot.active ? (
<div>
<div className="flex items-center gap-2">
<Spinner size={13} />
<span className="min-w-0 flex-1 truncate text-[11.5px]">
<span className="mr-1.5 font-semibold" style={{ color: '#bc8cff' }}>Copilot</span>
{copilot.model && <span className="mr-1.5 text-faint">{copilot.model}</span>}
{headline}
</span>
{seconds > 0 && <span className="mono text-[10px] text-faint">{seconds}s</span>}
<button className="btn btn-sm" onClick={stopCopilot}>取消</button>
</div>
{/* 支持 thinking 的模型(Claude 4.6+)才有内容;没有就只显示阶段 */}
{copilot.thinking && (
<div className="mt-1.5 border-t pt-1.5">
<button
className="mb-1 flex items-center gap-1 text-[10px] text-faint hover:text-dim"
onClick={() => setShowThinking((v) => !v)}
>
<ChevronRight
size={10}
style={{ transform: showThinking ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }}
/>
模型正在想什么
</button>
{showThinking && (
<div
ref={thinkingRef}
className="max-h-28 overflow-y-auto whitespace-pre-wrap rounded bg-bg px-2 py-1.5 text-[10.5px] leading-relaxed text-dim"
>
{copilot.thinking}
</div>
)}
</div>
)}
</div>
) : copilot.error ? (
<div>
<div className="flex items-start gap-2">
<span className="min-w-0 flex-1 text-[11.5px] text-[var(--err)]">{copilot.error}</span>
<button className="btn btn-ghost btn-sm" onClick={() => setDismissed(true)}>✕</button>
</div>
{/* 失败多半跟需求本身无关(模型抽风、协议跑偏、网断了),
不该逼用户重新打开 Modal 把需求再敲一遍 */}
{copilot.lastInstruction && (
<div className="mt-1.5 flex items-center gap-2 border-t pt-1.5">
<span className="min-w-0 flex-1 truncate text-[10px] text-faint" title={copilot.lastInstruction}>
{copilot.lastInstruction}
</span>
<button className="btn btn-sm" onClick={() => { setDismissed(false); retryCopilot() }}>
用同一需求重试
</button>
</div>
)}
</div>
) : (
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="mb-0.5 text-[11px] font-semibold" style={{ color: '#bc8cff' }}>
Copilot 完成
</div>
<div className="whitespace-pre-wrap text-[11px] leading-relaxed text-dim">
{copilot.explanation}
</div>
<div className="mt-1 text-[10px] text-faint">检查无误后记得保存(⌘S)</div>
</div>
<button className="btn btn-ghost btn-sm" onClick={() => setDismissed(true)}>✕</button>
</div>
)}
</div>
)
}
// CopilotModal 和 CopilotStatusBar 都去掉了,搬进 run/AssistantPanel.tsx 的
// 右栏里。弹窗的问题不是样式:每改一次图都要重开一次,关掉之后需求文本就没了,
// 想微调只能重打一遍;而浮条盖在画布左下角(z-30),和 Toast(z-100)、
// Modal(z-50)三层各自为政,本身也是重复的一份 Copilot 状态渲染。
+346
View File
@@ -0,0 +1,346 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Code2, Send, Settings2, Sparkles, Square, Trash2 } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../api/client'
import { useCatalog, modelOptions } from '../store/catalog'
import { useStudio } from '../store/studio'
import { Spinner, useToast } from '../components/ui'
import { AssistantStream, StreamEmpty, type StreamTurn } from './AssistantStream'
import { decodeCopilot, decodeRun, formatDuration } from './decode'
import { ApprovalCard, RunLauncher } from './RunPanel'
/**
* 画布右栏的助手。
*
* 取代原来的「运行 / 属性 → 时间线 / 成果 / 事件」两层嵌套(5 个视图,
* 每个只装一小块,找什么都得先猜在哪一层)。现在一栏一条流:你让它做什么、
* 它做了什么、结果是什么,按时间从上往下。
*
* 两件事在这里合流——Copilot 建图和跑图。它们在协议上完全无关(SSE 操作流
* vs 事件 WebSocket,不共享 seq 和 node_id),但对用户是同一件事的两半:
* "帮我搭一个能算月度毛利的流程" → "跑一下"。decode.ts 把两种流翻译成同一种
* Step,这里才能把它们排进同一条时间轴。
*
* 右栏不能删:它是发起运行和处理审批的唯一入口,RunLauncher 必须留着。
*/
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 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 || PHASE_TEXT[copilot.phase] || '正在生成流程…')
: t.phase === 'done' ? '流程已更新到画布' : '',
steps: decodeCopilot(t.ops),
output: t.explanation ? { 说明: t.explanation } : null,
error: t.error,
}))
if (run) {
const steps = decodeRun(events)
// run 对象是启动时的快照,成果在 run.finished 事件里
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,
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, copilot.phase])
return (
<div className="flex h-full flex-col">
<RunLauncher />
{pending.map((approval) => (
<ApprovalCard key={approval.id} approval={approval}
onResolved={(runId) => attachRun(runId)} />
))}
<div className="min-h-0 flex-1">
{raw ? <RawEvents /> : (
<AssistantStream
turns={turns}
dense
empty={<PanelEmpty />}
/>
)}
</div>
<Composer />
<div className="flex items-center gap-2 border-t px-2.5 py-1 text-[10px] text-faint">
{run ? (
<>
<span className="mono truncate" title={run.id}>#{run.id.slice(0, 8)}</span>
{run.run_class === 'formal'
? <span className="chip" style={{ color: 'var(--ok)', borderColor: 'var(--ok)' }}>正式 v{run.version}</span>
: <span className="chip">探索</span>}
{streaming && <span className="text-[var(--accent)]">● 实时</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}
</>
) : <span className="flex-1" />}
{!!copilotTurns.length && !raw && (
<button className="hover:text-dim" title="清空助手记录(不影响画布)"
onClick={clearCopilot}>
<Trash2 size={11} />
</button>
)}
{/* 原始事件是排查用的开发者视图,不该占一个常驻 tab——但也不能删,
翻译层出问题时它是唯一能对照的东西 */}
<button
className={clsx('flex items-center gap-1 hover:text-dim', raw && 'text-[var(--accent)]')}
title={raw ? '回到助手视图' : `看原始事件(${events.length} 条)`}
onClick={() => setRaw((v) => !v)}
>
<Code2 size={11} />
</button>
</div>
</div>
)
}
function PanelEmpty() {
const nodes = useStudio((s) => s.nodes)
return (
<StreamEmpty
icon={<Sparkles size={20} />}
title={nodes.length ? '还没有动静' : '从一句话开始'}
hint={nodes.length
? '上面填好输入点「试运行」,或者在下面告诉它要改什么'
: '在下面描述你要的流程,它会直接画到左边的画布上'}
/>
)
}
// -------------------------------------------------------------------------
const EXAMPLES = [
'读取用户上传的问题,先查知识库,查到就基于资料回答并标注出处,查不到就联网搜索',
'把一段长文本拆成要点,逐条用模型打分,低分的让模型重写一次,最后汇总成表格',
'写代码分析数据,在沙箱里跑,出错就把报错喂回去让模型修,最多修三次',
]
/**
* Copilot 输入框。
*
* 原来是个 Modal:点按钮 → 弹窗 → 输入 → 关闭弹窗 → 盯着画布上的浮条看。
* 每改一次图都要重走一遍,而且弹窗关掉之后需求文本就没了,想微调只能重打。
* 放进侧栏之后它就是个普通输入框,说完一句接着说下一句。
*/
function Composer() {
const nodes = useStudio((s) => s.nodes)
const copilot = useStudio((s) => s.copilot)
const { runCopilot, stopCopilot, retryCopilot } = useStudio()
const providers = useCatalog((s) => s.providers)
const [text, setText] = useState('')
const [opts, setOpts] = useState(false)
const [model, setModel] = useState('')
const [effective, setEffective] = useState('')
const [useBase, setUseBase] = useState(true)
const ref = useRef<HTMLTextAreaElement>(null)
// Copilot 的模型是独立设置:它写的是编排本身,值得和工作流节点分开选
useEffect(() => {
void api.copilot.getModel().then((m) => {
setModel(m.model ?? '')
setEffective(m.effective_model ?? '')
}).catch(() => undefined)
}, [])
// 工具栏的 Copilot 按钮把焦点甩过来
useEffect(() => {
const focus = () => ref.current?.focus()
window.addEventListener('agentlab:focus-copilot', focus)
return () => window.removeEventListener('agentlab:focus-copilot', focus)
}, [])
const pickModel = (value: string) => {
setModel(value)
void api.copilot.setModel({ model: value || null })
.then((m) => setEffective(m.effective_model ?? ''))
.catch(() => undefined)
}
const send = () => {
const q = text.trim()
if (!q || copilot.active) return
runCopilot(q, useBase && nodes.length > 0, model || undefined)
setText('')
}
const options = modelOptions(providers)
const groups = [...new Set(options.map((o) => o.group))]
return (
<div className="shrink-0 border-t">
{copilot.error && (
<div className="flex items-center gap-2 border-b px-2.5 py-1.5 text-[10.5px]"
style={{ color: 'var(--err)' }}>
<span className="min-w-0 flex-1">{copilot.error}</span>
{/* 失败多半跟需求本身无关(模型抽风、协议跑偏、网断了),
不该逼用户把需求再敲一遍 */}
{copilot.lastInstruction && (
<button className="btn btn-sm shrink-0" onClick={retryCopilot}>重试</button>
)}
</div>
)}
{!nodes.length && !copilot.active && (
<div className="flex flex-wrap gap-1 border-b px-2.5 py-1.5">
{EXAMPLES.map((ex) => (
<button key={ex} className="chip text-left hover:border-[var(--accent)]"
title={ex} onClick={() => { setText(ex); ref.current?.focus() }}>
{ex.slice(0, 16)}…
</button>
))}
</div>
)}
{opts && (
<div className="space-y-2 border-b px-2.5 py-2">
<div>
<label className="label">Copilot 用哪个模型</label>
<select className="field" value={model} onChange={(e) => pickModel(e.target.value)}>
<option value="">跟随默认{effective ? `(当前:${effective})` : ''}</option>
{groups.map((g) => (
<optgroup key={g} label={g}>
{options.filter((o) => o.group === g).map((o) => (
<option key={g + o.value} value={o.value}>{o.label}</option>
))}
</optgroup>
))}
</select>
<div className="mt-1 text-[10px] leading-snug text-faint">
只影响 Copilot 自己,不改节点上的模型。它要按协议逐行输出操作,
指令遵循弱的模型生成不出东西。
</div>
</div>
{!!nodes.length && (
<label className="flex items-center gap-2 text-[11px]">
<input type="checkbox" checked={useBase} className="accent-[var(--accent)]"
onChange={(e) => setUseBase(e.target.checked)} />
在当前这张图上改(不勾就重新生成)
</label>
)}
</div>
)}
<div className="flex items-end gap-1.5 p-2">
<button className={clsx('btn btn-sm btn-ghost shrink-0', opts && 'text-[var(--accent)]')}
title="模型和生成方式" onClick={() => setOpts((v) => !v)}>
<Settings2 size={12} />
</button>
<textarea
ref={ref}
className="field min-h-[38px] flex-1 resize-none py-1.5 text-[11.5px]"
rows={2}
value={text}
placeholder={nodes.length ? '告诉它要改什么…' : '描述你要的流程…'}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() }
}}
/>
{copilot.active ? (
<button className="btn btn-sm btn-danger shrink-0" onClick={stopCopilot} title="取消生成">
<Square size={11} />
</button>
) : (
<button className="btn btn-sm btn-primary shrink-0" onClick={send}
disabled={!text.trim()} title="生成到画布(Enter)">
<Send size={11} />
</button>
)}
</div>
{copilot.active && (
<div className="flex items-center gap-1.5 border-t px-2.5 py-1 text-[10px] text-faint">
<Spinner size={10} />
<span className="min-w-0 flex-1 truncate">
{copilot.lastOp || PHASE_TEXT[copilot.phase] || '正在起草…'}
</span>
{copilot.model && <span className="truncate">{copilot.model}</span>}
{copilot.elapsedMs > 0 && (
<span className="mono">{Math.round(copilot.elapsedMs / 1000)}s</span>
)}
</div>
)}
{!copilot.active && !!copilot.explanation && (
<div className="border-t px-2.5 py-1 text-[10px] text-faint">
结果不会自动保存,检查无误记得 ⌘S
</div>
)}
</div>
)
}
// 从提交到第一个节点落地中间有 5~30 秒。阶段会变本身就是"它还活着"的信号,
// 恒定的"正在起草…"让人分不清是在想还是已经卡死
const PHASE_TEXT: Record<string, string> = {
connecting: '正在连接模型…',
planning: '正在理解需求、规划结构…',
building: '正在放置节点…',
wiring: '正在连接数据流…',
finalizing: '正在排版和校验…',
}
// -------------------------------------------------------------------------
/** 原始事件。翻译层出问题时用来对照,平时收着。 */
function RawEvents() {
const events = useStudio((s) => s.events)
const bottom = useRef<HTMLDivElement>(null)
useEffect(() => {
bottom.current?.scrollIntoView({ block: 'end' })
}, [events.length])
if (!events.length) {
return (
<div className="flex h-full items-center justify-center text-[11px] text-faint">
还没有事件
</div>
)
}
return (
<div className="h-full overflow-y-auto p-2">
{events.map((e) => (
<div key={e.seq} className="mono border-b px-1 py-1 text-[9.5px] leading-relaxed last:border-0">
<span className="text-faint">#{e.seq}</span>{' '}
<span className="text-[var(--accent)]">{e.type}</span>{' '}
{e.node_id && <span className="text-dim">{e.node_id}</span>}
<div className="break-all text-faint">{JSON.stringify(e.data).slice(0, 400)}</div>
</div>
))}
<div ref={bottom} />
</div>
)
}
+53 -22
View File
@@ -34,6 +34,8 @@ export interface StreamTurn {
output?: Record<string, any> | null
error?: string
runId?: string
/** formal / exploratory。出具横幅要据此标注"不进正式归档" */
runClass?: string
/** 这一轮建出来的图,可展开看、可放到画布 */
graph?: any
graphNote?: string
@@ -140,7 +142,9 @@ function TurnCard({ turn, dense, approvals, onOpenGraph }: {
{approvals}
{turn.output && <Output output={turn.output} dense={dense} />}
{turn.output && (
<Output output={turn.output} dense={dense} runClass={turn.runClass} />
)}
{turn.graph && (
<GraphPeek graph={turn.graph} note={turn.graphNote}
@@ -295,7 +299,9 @@ function ResultTable({ table, dense }: { table: Table; dense: boolean }) {
)
}
function Output({ output, dense }: { output: Record<string, any>; dense: boolean }) {
function Output({ output, dense, runClass }: {
output: Record<string, any>; dense: boolean; runClass?: string
}) {
// _issuance 这类下划线开头的是内部字段,不是给人看的成果。
// 空值也要滤掉:output 里留一个 {"result": ""} 很常见(图跑通了但出口
// 没接上),它会渲染出一条什么都没有的分隔线——用户只会以为界面坏了
@@ -306,7 +312,7 @@ function Output({ output, dense }: { output: Record<string, any>; dense: boolean
return (
<div className="mt-2 space-y-2 border-t pt-2">
{issuance && <IssuanceTag issuance={issuance} />}
{issuance && <IssuanceBanner issuance={issuance} runClass={runClass} />}
{entries.map(([key, value]) => (
<div key={key}>
{entries.length > 1 && (
@@ -367,37 +373,62 @@ function OutputValue({ value, dense }: { value: unknown; dense: boolean }) {
)
}
const TIER_META: Record<string, { label: string; color: string; hint: string }> = {
formal: { label: '正式出具', color: 'var(--ok)', hint: '指标齐全,叙述中所有数字均可回指口径卡' },
degraded: { label: '降档出具', color: 'var(--warn)', hint: '存在缺口,结论请对照下方声明使用' },
withheld: { label: '不予出具', color: 'var(--err)', hint: '必需指标缺失或数字无法溯源,本期结论不作数' },
}
/**
* 出具档位。
* 出具档位。全站唯一一份——画布助手栏、问数据页、运行详情都用它。
*
* 数据取自 output._issuance 而不是 issuance 事件——事件里只有 tier /
* 数据取自 output._issuance 而不是 issuance 事件:事件里只有 tier /
* missing_* / unmatched(计数),而 output 里那份还带着 matched_numbers、
* metrics_checked、unmatched_numbers[].token。拿事件当数据源,横幅会静默
* 退化成"核对 0 个指标",比不显示更糟。
* metrics_checked、calibers、unmatched_numbers[].token。拿事件当数据源,
* 横幅会静默退化成"核对 0 个指标",比不显示更糟。
*/
function IssuanceTag({ issuance }: { issuance: any }) {
export function IssuanceBanner({ issuance, runClass }: {
issuance: any; runClass?: string
}) {
const tier = String(issuance?.tier ?? '')
const label = { formal: '正式出具', degraded: '降档出具', withheld: '不予出具' }[tier] ?? tier
if (!tier) return null
const color = tier === 'formal' ? 'var(--ok)' : tier === 'withheld' ? 'var(--err)' : 'var(--warn)'
const meta = TIER_META[tier] ?? { label: tier, color: 'var(--warn)', hint: '' }
const unmatched: any[] = issuance?.unmatched_numbers ?? []
const gaps: string[] = issuance?.gaps ?? []
return (
<div className="rounded border px-2 py-1.5 text-[10.5px] leading-relaxed"
style={{ borderColor: color, color }}>
<span className="font-semibold">{label}</span>
{issuance?.metrics_checked != null && (
<span className="ml-2 text-faint">
核对 {issuance.metrics_checked} 个指标 · 回指 {issuance.matched_numbers ?? 0} 个数字
<div className="rounded-lg border p-2" style={{ borderColor: meta.color }}>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-[11.5px] font-semibold" style={{ color: meta.color }}>
⚖ {meta.label}
</span>
)}
{!!unmatched.length && (
<div className="mt-1">
无法回指:{unmatched.slice(0, 6).map((u) => u.token ?? u).join('、')}
{runClass === 'exploratory' && (
<span className="chip" style={{ color: 'var(--warn)' }}>探索性 · 不进正式归档</span>
)}
<span className="ml-auto text-[10px] text-faint">
回指 {issuance.matched_numbers ?? 0} 个数字 / 核对 {issuance.metrics_checked ?? 0} 个指标
</span>
</div>
{meta.hint && <div className="mt-1 text-[10.5px] leading-relaxed text-faint">{meta.hint}</div>}
{(issuance.calibers ?? []).map((c: any) => (
<div key={c.node ?? c.caliber} className="mt-1 text-[10.5px] text-dim">
口径:{c.caliber} @ {c.version}
</div>
))}
{!!issuance.missing_required?.length && (
<div className="mt-1 text-[10.5px]" style={{ color: 'var(--err)' }}>
缺必需指标:{issuance.missing_required.join('、')}
</div>
)}
{!!issuance.missing_expected?.length && (
<div className="mt-1 text-[10.5px]" style={{ color: 'var(--warn)' }}>
缺数据声明:{issuance.missing_expected.join('、')} 本期缺失
</div>
)}
{!!unmatched.length && (
<div className="mt-1 text-[10.5px]" style={{ color: 'var(--warn)' }}>
无法回指的数字:{unmatched.map((u: any) => u.token ?? u).join('、')}
</div>
)}
{!!gaps.length && <div className="mt-1">{gaps.join(';')}</div>}
</div>
)
}
+44 -329
View File
@@ -1,64 +1,22 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import {
AlertTriangle, Ban, Check, ChevronRight, CircleDot, Hand, Play, ShieldCheck, Square, Wrench,
Ban, Check, ChevronRight, Hand, Play, ShieldCheck, Square, Wrench,
} from 'lucide-react'
import clsx from 'clsx'
import { api } from '../api/client'
import { useStudio } from '../store/studio'
import { useCatalog } from '../store/catalog'
import { NODE_DEFS } from '../canvas/nodeDefs'
import { formatMs } from '../canvas/NodeCard'
import { Empty, StatusDot, Tabs, useToast } from '../components/ui'
import type { RunEvent } from '../types'
import { StatusDot, useToast } from '../components/ui'
export function RunPanel() {
const [tab, setTab] = useState('timeline')
const run = useStudio((s) => s.run)
const streaming = useStudio((s) => s.streaming)
const approvals = useCatalog((s) => s.approvals)
const pending = approvals.filter((a) => a.run_id === run?.id && a.status === 'pending')
return (
<div className="flex h-full flex-col">
<RunLauncher />
{pending.map((approval) => (
<ApprovalCard key={approval.id} approval={approval} />
))}
<Tabs
tabs={[
{ key: 'timeline', label: '时间线' },
{ key: 'output', label: '成果' },
{ key: 'raw', label: '事件' },
]}
active={tab}
onChange={setTab}
/>
<div className="min-h-0 flex-1 overflow-y-auto">
{tab === 'timeline' && <Timeline />}
{tab === 'output' && <OutputView />}
{tab === 'raw' && <RawEvents />}
</div>
{run && (
<div className="flex items-center justify-between gap-2 border-t px-3 py-1.5 text-[10.5px] text-faint">
<span className="mono truncate" title={run.id}>#{run.id.slice(0, 8)}</span>
{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 items-center gap-2">
{streaming && <span className="text-[var(--accent)]">● 实时</span>}
{!!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>{formatMs(run.usage.duration_ms)}</span> : null}
</span>
</div>
)}
</div>
)
}
// -------------------------------------------------------------------------
function RunLauncher() {
/**
* 发起运行。
*
* 这是整个界面里**唯一**能发起运行的地方,不能因为改版就顺手删掉——一起没掉的
* 还有审批入口,那会让卡在人工介入上的运行在界面里彻底没有出口。
*
* 跑起来之后自动收起:这时候用户要看的是执行到哪了,不是再填一遍输入。
*/
export function RunLauncher() {
const nodes = useStudio((s) => s.nodes)
const run = useStudio((s) => s.run)
const streaming = useStudio((s) => s.streaming)
@@ -71,6 +29,7 @@ function RunLauncher() {
const toast = useToast()
const [values, setValues] = useState<Record<string, string>>({})
const [busy, setBusy] = useState(false)
const [open, setOpen] = useState(true)
// 入口节点声明了什么字段,这里就渲染什么表单
const inputFields = useMemo(() => {
@@ -78,6 +37,9 @@ function RunLauncher() {
return (entry?.data.config?.fields ?? []) as any[]
}, [nodes])
// 跑起来就让位给执行过程;停下来再自己展开
useEffect(() => { if (streaming) setOpen(false) }, [streaming])
const errors = issues.filter((i) => i.level === 'error')
const launch = async (formal = false) => {
@@ -103,9 +65,25 @@ function RunLauncher() {
}
}
const running = streaming || run?.status === 'running'
return (
<div className="border-b p-3">
{inputFields.map((field) => (
<div className="shrink-0 border-b p-2.5">
{!!inputFields.length && (
<button className="mb-1.5 flex w-full items-center gap-1 text-[10.5px] text-faint hover:text-dim"
onClick={() => setOpen((v) => !v)}>
<ChevronRight size={10}
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
输入({inputFields.length} 项)
{!open && (
<span className="ml-1 min-w-0 flex-1 truncate text-left">
{inputFields.map((f) => values[f.name] ?? f.default ?? '').filter(Boolean).join(' · ')}
</span>
)}
</button>
)}
{open && inputFields.map((field) => (
<div key={field.name} className="mb-2">
<label className="label">
{field.name}
@@ -121,10 +99,8 @@ function RunLauncher() {
/>
</div>
))}
{!inputFields.length && (
<div className="mb-2 text-[11px] text-faint">
这张图没有输入节点,将直接运行
</div>
{!inputFields.length && !!nodes.length && (
<div className="mb-2 text-[10.5px] text-faint">这张图没有输入节点,将直接运行</div>
)}
{!!errors.length && (
@@ -135,7 +111,7 @@ function RunLauncher() {
)}
<div className="flex gap-2">
{streaming || run?.status === 'running' ? (
{running ? (
<button className="btn btn-danger flex-1 justify-center" onClick={stopRun}>
<Square size={12} /> 停止
</button>
@@ -286,273 +262,12 @@ export function ApprovalCard({ approval, onResolved }: {
)
}
// -------------------------------------------------------------------------
const HIDDEN_TYPES = new Set(['llm.token', 'llm.thinking.delta'])
function Timeline() {
const events = useStudio((s) => s.events)
const nodes = useStudio((s) => s.nodes)
const select = useStudio((s) => s.select)
const bottom = useRef<HTMLDivElement>(null)
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
useEffect(() => {
bottom.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [events.length])
const visible = events.filter((e) => !HIDDEN_TYPES.has(e.type))
if (!visible.length) {
return <Empty icon={<CircleDot size={22} />} title="还没有运行" hint="填好输入点「运行」,这里会实时显示每一步。" />
}
const labelOf = (id: string | null) =>
nodes.find((n) => n.id === id)?.data.label ?? id ?? ''
return (
<div className="p-2">
{visible.map((event) => {
const meta = EVENT_META[event.type] ?? { text: event.type, tone: 'dim' as const }
const detail = describe(event)
const open = expanded[event.seq]
return (
<div key={event.seq} className="fade-up group flex gap-2 px-1 py-1">
<span
className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full"
style={{ background: TONE[meta.tone] }}
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5">
<span className="text-[11.5px]" style={{ color: TONE[meta.tone] }}>
{meta.text}
</span>
{event.node_id && (
<button
className="truncate text-[11px] text-dim hover:text-fg hover:underline"
onClick={() => select(event.node_id)}
>
{labelOf(event.node_id)}
</button>
)}
{event.data?.duration_ms != null && (
<span className="ml-auto shrink-0 text-[10px] text-faint">
{formatMs(event.data.duration_ms)}
</span>
)}
</div>
{detail && (
<div
className={clsx(
'mt-0.5 text-[10.5px] leading-snug text-faint',
!open && 'line-clamp-2',
)}
>
{detail}
</div>
)}
{detail && detail.length > 90 && (
<button
className="mt-0.5 flex items-center gap-0.5 text-[10px] text-faint opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => setExpanded({ ...expanded, [event.seq]: !open })}
>
<ChevronRight size={9} className={open ? 'rotate-90' : ''} />
{open ? '收起' : '展开'}
</button>
)}
</div>
</div>
)
})}
<div ref={bottom} />
</div>
)
}
const TONE = {
ok: 'var(--ok)', err: 'var(--err)', warn: 'var(--warn)',
accent: 'var(--accent)', dim: 'var(--text-faint)',
}
const EVENT_META: Record<string, { text: string; tone: keyof typeof TONE }> = {
'run.started': { text: '开始运行', tone: 'accent' },
'run.finished': { text: '运行完成', tone: 'ok' },
'run.failed': { text: '运行失败', tone: 'err' },
'run.cancelled': { text: '已取消', tone: 'warn' },
'run.interrupted': { text: '等待人工介入', tone: 'warn' },
'run.resumed': { text: '已恢复', tone: 'accent' },
'node.started': { text: '▶', tone: 'accent' },
'node.finished': { text: '✓', tone: 'ok' },
'node.failed': { text: '✕', tone: 'err' },
'node.skipped': { text: '跳过', tone: 'dim' },
'edge.taken': { text: '分支', tone: 'warn' },
'llm.start': { text: '调用模型', tone: 'dim' },
'llm.thinking': { text: '思考', tone: 'dim' },
'llm.end': { text: '模型返回', tone: 'dim' },
'tool.start': { text: '调用工具', tone: 'dim' },
'tool.end': { text: '工具完成', tone: 'ok' },
'tool.error': { text: '工具失败', tone: 'err' },
'sandbox.start': { text: '沙箱启动', tone: 'dim' },
'sandbox.end': { text: '沙箱结束', tone: 'ok' },
'agent.step.start': { text: 'Agent 接手', tone: 'accent' },
'agent.step.end': { text: 'Agent 交付', tone: 'ok' },
'human.requested': { text: '请求人工', tone: 'warn' },
'human.resolved': { text: '人工已回复', tone: 'ok' },
'log': { text: '日志', tone: 'dim' },
'issuance': { text: '出具判定', tone: 'warn' },
'caliber.upgrade': { text: '口径升版', tone: 'warn' },
}
function describe(event: RunEvent): string {
const d = event.data ?? {}
switch (event.type) {
case 'edge.taken':
return `走 ${d.branch}${d.reason ? ` · ${d.reason}` : ''}${d.iteration != null ? ` · 第 ${d.iteration + 1} 轮` : ''}`
case 'tool.start':
return `${d.tool}(${JSON.stringify(d.args ?? {}).slice(0, 120)})`
case 'tool.end':
case 'tool.error':
return String(d.preview ?? '').slice(0, 300)
case 'llm.thinking':
return String(d.text ?? '').slice(0, 300)
case 'llm.end':
return `${d.model ?? ''} · ${d.input_tokens ?? 0}+${d.output_tokens ?? 0} tok${d.cost_usd ? ` · $${Number(d.cost_usd).toFixed(4)}` : ''}`
case 'sandbox.end':
return `exit=${d.exit_code} · ${d.backend}${d.stdout ? `\n${String(d.stdout).slice(0, 300)}` : ''}`
case 'agent.step.start':
return `${d.agent}:${String(d.instruction ?? '').slice(0, 160)}`
case 'agent.step.end':
return `${d.agent} → ${String(d.preview ?? '').slice(0, 200)}`
case 'node.failed':
case 'run.failed':
return String(d.error ?? '')
case 'log':
return String(d.message ?? '')
case 'human.requested':
return String(d.title ?? d.tool ?? '')
case 'issuance': {
const tier = { formal: '正式出具', degraded: '降档出具', withheld: '不予出具' }[d.tier as string] ?? d.tier
const parts = [tier]
if (d.missing_required?.length) parts.push(`缺必需指标 ${d.missing_required.join(',')}`)
if (d.unmatched) parts.push(`${d.unmatched} 个数字无法回指`)
return parts.join(' · ')
}
case 'caliber.upgrade':
return `方法卡 v${d.pinned} → v${d.latest} 已有新版,处置:${d.policy_label ?? d.policy}`
case 'node.finished': {
if (!d.preview) return ''
const text = typeof d.preview === 'string' ? d.preview : JSON.stringify(d.preview)
return text.slice(0, 300)
}
default:
return ''
}
}
// -------------------------------------------------------------------------
function OutputView() {
const run = useStudio((s) => s.run)
const events = useStudio((s) => s.events)
// run 对象是启动时拿到的快照,完成事件里才带最终成果
const finished = [...events].reverse().find((e) => e.type === 'run.finished')
const output = finished?.data?.output ?? run?.output
const failed = [...events].reverse().find((e) => e.type === 'run.failed')
if (failed) {
return (
<div className="p-3">
<div className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-[var(--err)]">
<AlertTriangle size={13} /> 运行失败
</div>
<pre className="mono whitespace-pre-wrap rounded border bg-bg p-2 text-[11px] leading-relaxed text-[var(--err)]">
{failed.data?.error}
</pre>
</div>
)
}
if (!output || !Object.keys(output).length) {
return <Empty title="还没有成果" hint="运行完成后,output 节点收集的结构化结果会显示在这里。" />
}
const issuance = (output as any)._issuance
return (
<div className="space-y-3 p-3">
{issuance && <IssuanceBanner issuance={issuance} runClass={run?.run_class} />}
{Object.entries(output).filter(([k]) => k !== '_issuance').map(([key, value]) => (
<div key={key}>
<div className="label">{key}</div>
<div className="whitespace-pre-wrap break-words rounded border bg-bg p-2 text-[11.5px] leading-relaxed">
{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
</div>
</div>
))}
</div>
)
}
const TIER_META: Record<string, { label: string; color: string; hint: string }> = {
formal: { label: '正式出具', color: 'var(--ok)', hint: '指标齐全,叙述中所有数字均可回指口径卡' },
degraded: { label: '降档出具', color: 'var(--warn)', hint: '存在缺口,结论请对照下方声明使用' },
withheld: { label: '不予出具', color: 'var(--err)', hint: '必需指标缺失或数字无法溯源,本期结论不作数' },
}
function IssuanceBanner({ issuance, runClass }: { issuance: any; runClass?: string }) {
const meta = TIER_META[issuance.tier] ?? TIER_META.degraded
return (
<div className="rounded-lg border p-2.5" style={{ borderColor: meta.color }}>
<div className="flex items-center gap-2">
<span className="text-[12px] font-semibold" style={{ color: meta.color }}>
⚖ {meta.label}
</span>
{runClass === 'exploratory' && (
<span className="chip" style={{ color: 'var(--warn)' }}>探索性运行 · 不进正式归档</span>
)}
<span className="ml-auto text-[10px] text-faint">
回指 {issuance.matched_numbers ?? 0} 个数字 / 核对 {issuance.metrics_checked ?? 0} 个指标
</span>
</div>
<div className="mt-1 text-[10.5px] text-faint">{meta.hint}</div>
{(issuance.calibers ?? []).map((c: any) => (
<div key={c.node} className="mt-1 text-[10.5px] text-dim">
口径:{c.caliber} @ {c.version}
</div>
))}
{!!issuance.missing_required?.length && (
<div className="mt-1 text-[10.5px]" style={{ color: 'var(--err)' }}>
缺必需指标:{issuance.missing_required.join('、')}
</div>
)}
{!!issuance.missing_expected?.length && (
<div className="mt-1 text-[10.5px]" style={{ color: 'var(--warn)' }}>
缺数据声明:{issuance.missing_expected.join('、')} 本期缺失
</div>
)}
{!!issuance.unmatched_numbers?.length && (
<div className="mt-1 text-[10.5px]" style={{ color: 'var(--warn)' }}>
无法回指的数字:{issuance.unmatched_numbers.map((u: any) => u.token).join('、')}
</div>
)}
</div>
)
}
function RawEvents() {
const events = useStudio((s) => s.events)
if (!events.length) return <Empty title="暂无事件" />
return (
<div className="p-2">
{events.map((e) => (
<div key={e.seq} className="mono border-b px-1 py-1 text-[10px] leading-relaxed last:border-0">
<span className="text-faint">#{e.seq}</span>{' '}
<span className="text-[var(--accent)]">{e.type}</span>{' '}
{e.node_id && <span className="text-dim">{e.node_id}</span>}
<div className="break-all text-faint">{JSON.stringify(e.data).slice(0, 500)}</div>
</div>
))}
</div>
)
}
// Timeline / OutputView / describe / EVENT_META / IssuanceBanner / RawEvents
// 都搬走了:
// - "事件 → 人话"归 run/decode.ts,这里曾经有一套平行的 EVENT_META + describe(),
// 和解码器各译各的。留着两套,同一次运行在画布和问数据页会读出不同的故事,
// 而用户没法判断哪个是真的。
// - 怎么画归 run/AssistantStream.tsx(含出具横幅),怎么组装归 run/AssistantPanel.tsx。
// 这个文件现在只剩两样东西:发起运行,和处理审批。
export { StatusDot, NODE_DEFS }
File diff suppressed because one or more lines are too long
+110 -23
View File
@@ -200,6 +200,45 @@ function cutAtLastCompleteRow(text: string): string | null {
return text.slice(0, lastRowEnd) + ']}'
}
/**
* 一次运行的一句话摘要,给列表用。
*
* 运行列表原本每行只有"临时图 · 1.2s · 340 tok"——三条运行长得一模一样,
* 要分清哪条是哪条只能挨个点开。人记得住的是内容(问了什么、出了什么),
* 不是耗时。
*
* 优先成果:跑完了的话,"查到多少"比"问了什么"更能认出这一条。
*/
export function summarizeRun(
input?: Record<string, any> | null,
output?: Record<string, any> | null,
): string {
const pick = (obj?: Record<string, any> | null) => {
if (!obj) return ''
for (const [k, v] of Object.entries(obj)) {
if (k.startsWith('_')) continue
if (v == null || v === '') continue
if (typeof v === 'string') {
const table = parseQueryResult(v)
if (table) {
return `${table.rows.length}${table.clipped ? '+' : ''} 行 × ${table.columns.length} 列`
}
return v.replace(/\s+/g, ' ').slice(0, 70)
}
if (typeof v === 'object') {
const table = parseQueryResult(JSON.stringify(v))
if (table) return `${table.rows.length} 行 × ${table.columns.length} 列`
const text = JSON.stringify(v)
if (text !== '{}' && text !== '[]') return text.slice(0, 70)
continue
}
return String(v).slice(0, 70)
}
return ''
}
return pick(output) || pick(input)
}
function toolStep(seq: number, tool: string, args: Record<string, any>): Step {
const base = { id: `tool-${seq}`, seq, status: 'running' as StepStatus }
// 数据库工具单独认:它是这个产品最主要的取数方式,"调用 db_query__warehouse"
@@ -233,8 +272,18 @@ export function decodeRun(events: RunEvent[]): Step[] {
const pendingTools = new Map<string, Step>()
/** node_id → 待配对的模型调用 Step */
const pendingLlm = new Map<string, Step>()
/** 已见过的 interrupt_id,避免 human.requested 与 run.interrupted 各出一行 */
const seenInterrupts = new Set<string>()
/**
* node_id → 这个节点当前那条尚未闭合的审批步骤。
*
* 审批是"开→闭"配对的,不能只靠内容去重。同一次中断会发三条事件
* (human.requested、run.interrupted,恢复后节点重放又来一条
* human.requested),三条内容完全一样;而一个循环里连续三轮审批
* (驳回 → 改写 → 再审)内容也完全一样。按内容去重的话,要么把重放
* 算成新的一轮,要么把真实的第二轮当成重放吞掉——两种都错。
*
* 开着就是同一次,闭了才是新一次。
*/
const openInterrupts = new Map<string, Step>()
const push = (step: Step, nodeId?: string) => {
const parent = nodeId ? nodeSteps.get(nodeId) : undefined
@@ -242,6 +291,18 @@ export function decodeRun(events: RunEvent[]): Step[] {
else out.push(step)
}
/** 把决定折进那条审批行,而不是另起一行——"问了什么 → 你怎么答的"是一件事 */
const closeInterrupt = (key: string, approved: unknown, note: string): boolean => {
const step = openInterrupts.get(key)
if (!step) return false
step.status = 'done'
step.level = undefined
step.title = `${step.title} → ${approved === false ? '你驳回了' : '你放行了'}`
if (note) step.detail = [step.detail, `你的备注:${note}`].filter(Boolean).join('\n')
openInterrupts.delete(key)
return true
}
for (const event of events) {
const type = String(event.type)
if (EPHEMERAL.has(type) || SILENT.has(type)) continue
@@ -260,14 +321,13 @@ export function decodeRun(events: RunEvent[]): Step[] {
break
}
if (resumed) {
// 恢复意味着之前那条"等你确认"已经过去了。level 也要一起清:
// 留着 warn 的话,那条已经处理完的审批会永远是橙色的,看上去
// 像还有什么没解决
const settle = (s: Step) => {
// 人已经答过了,那条不该再是橙色的"等你确认"。但配对关系要留着:
// 紧接着 LangGraph 会重放该节点,又发一条一模一样的
// human.requested,配对还开着才能认出那是重放而不是新一轮。
// 真正的闭合(把决定折进标题)交给带 node_id 的 human.resolved。
openInterrupts.forEach((s) => {
if (s.status === 'waiting') { s.status = 'done'; s.level = undefined }
}
out.forEach(settle)
nodeSteps.forEach((s) => s.children?.forEach(settle))
})
}
out.push({
id: `s-${seq}`, seq, kind: 'lifecycle', status: 'running',
@@ -302,6 +362,16 @@ export function decodeRun(events: RunEvent[]): Step[] {
}
case 'node.finished': {
// 人工节点跑完就说明决定已经生效了。正常路径上 human.resolved 早就
// 闭合了配对,这里只是兜底:万一那条事件没发出来(旧运行、别的
// 审批来源),配对不能一直挂着——挂着的话这个节点下一轮真实审批
// 会被当成重放吞掉
if (nodeId && openInterrupts.has(nodeId)) {
const p = d.preview ?? {}
closeInterrupt(nodeId,
typeof p === 'object' ? p.approved : undefined,
typeof p === 'object' ? String(p.note ?? '') : '')
}
const step = nodeId ? nodeSteps.get(nodeId) : undefined
if (step) {
step.status = 'done'
@@ -441,33 +511,34 @@ export function decodeRun(events: RunEvent[]): Step[] {
case 'human.requested':
case 'run.interrupted': {
// 同一次中断会来两条事件,而且审批恢复后节点重放还会再来一遍
// (LangGraph 的重放语义,不是 bug)。三条都指向同一次"等你确认",
// 界面上只该有一个。
//
// 不能用 interrupt_id 做键:human.requested 压根没这个字段,
// run.interrupted 才有。两者共有的是 payload 里的 node_id + mode + title,
// 用它们才能让三条算出同一个键。
// 同一次中断发三条事件:human.requested、run.interrupted,恢复后
// 节点重放又来一条 human.requested(LangGraph 的重放语义,不是 bug)。
// 三条都指向同一次"等你确认",界面上只该有一条。
const payload = d.payload ?? d
const key = [payload.node_id ?? nodeId, payload.mode ?? '', payload.title ?? ''].join('|')
if (seenInterrupts.has(key)) break
seenInterrupts.add(key)
push({
const key = String(payload.node_id ?? nodeId ?? '_')
if (openInterrupts.has(key)) break // 同一次中断的后续事件
const step: Step = {
id: `hm-${seq}`, seq, kind: 'human', nodeId, status: 'waiting', level: 'warn',
title: String(payload.title || d.title || '等你确认'),
detail: [payload.message, payload.tool ? `工具:${payload.tool}` : '']
.filter(Boolean).join('\n') || undefined,
}, nodeId)
}
openInterrupts.set(key, step)
push(step, nodeId)
break
}
case 'human.resolved': {
const r = d.response ?? {}
const approved = typeof r === 'object' ? r.approved : undefined
const note = typeof r === 'object' ? String(r.note ?? '') : ''
if (closeInterrupt(String(nodeId ?? '_'), approved, note)) break
// 没有对应的待决审批(历史事件不全、或者审批发生在别处)——
// 还是要把决定说出来,只是没地方折进去
push({
id: `hr-${seq}`, seq, kind: 'human', nodeId, status: 'done',
title: approved === false ? '你驳回了' : '你放行了',
detail: typeof r === 'object' && r.note ? String(r.note) : undefined,
detail: note || undefined,
}, nodeId)
break
}
@@ -653,11 +724,27 @@ export function decodeCopilot(ops: CopilotOp[]): Step[] {
case 'remove_edge':
break
case 'done':
// 心跳那条"正在理解需求…"要收尾,否则生成完了它还在转圈
closeLifecycles(out, 'done')
out.push({ id: `cd-${i}`, seq: i, kind: 'lifecycle', status: 'done',
title: `流程搭好了,共 ${nodeCount} 步`,
title: `流程搭好了,加了 ${nodeCount} 步`,
detail: String(op.explanation ?? '') || undefined })
break
case 'final': {
// 后端排版校验后的最终图才知道整张图有几步。nodeCount 只是这一轮
// 新增的数量——在"改图"场景下说"共 2 步"是错的,图上明明有四个节点
const total = op.graph?.nodes?.length
const last = out[out.length - 1]
if (total && last?.kind === 'lifecycle') {
last.title = nodeCount && nodeCount < total
? `流程搭好了,加了 ${nodeCount} 步,整张图共 ${total} 步`
: `流程搭好了,共 ${total} 步`
}
closeLifecycles(out, 'done')
break
}
case 'error':
closeLifecycles(out, 'failed')
out.push({ id: `ce-${i}`, seq: i, kind: 'error', level: 'error', status: 'failed',
title: String(op.message ?? '生成失败') })
break
+55 -88
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import { api, streamCopilot, streamRun } from '../api/client'
import type { CopilotOp } from '../run/decode'
import type { GraphSpec, Run, RunEvent } from '../types'
/**
@@ -11,6 +12,10 @@ import type { GraphSpec, Run, RunEvent } from '../types'
*
* 一轮对话 = 一次「建图 → 跑图 → 出结果」。图对用户是隐藏的(想看可以展开),
* 但它仍然是真实存在的工作流,跑完能存成模板、能在画布里继续改。
*
* 这里**只存原始流**(Copilot 的操作、运行的事件),不在 store 里翻译。
* 翻译归 run/decode.ts,全站一份;store 里再译一遍的结果是同一次运行在
* 对话页和画布上讲出两个不同的故事,而用户没法判断哪个是真的。
*/
export type Phase =
@@ -30,13 +35,15 @@ export interface ChatTurn {
status: string
/** 模型的思考(支持 thinking 的模型才有) */
thinking: string
/** Copilot 的原始操作流。thinking delta 在写入时已合并 */
ops: CopilotOp[]
/** 跑图的原始事件 */
events: RunEvent[]
/** Copilot 建出来的图 */
graph: GraphSpec | null
/** Copilot 对这张图的说明 */
explanation: string
run: Run | null
/** 跑图过程中值得показ的事件(查了什么、拿到什么) */
steps: { icon: string; text: string; detail?: string }[]
/** 最终成果 */
output: Record<string, any> | null
error: string
@@ -73,6 +80,8 @@ const PHASE_TEXT: Record<Phase, string> = {
error: '出错了',
}
type Patch = (fn: (t: ChatTurn) => Partial<ChatTurn>) => void
export const useChat = create<ChatState>((set, get) => ({
turns: [],
busy: false,
@@ -80,7 +89,7 @@ export const useChat = create<ChatState>((set, get) => ({
ask: (question) => {
const id = newId()
const patch = (fn: (t: ChatTurn) => Partial<ChatTurn>) =>
const patch: Patch = (fn) =>
set((s) => ({
turns: s.turns.map((t) => (t.id === id ? { ...t, ...fn(t) } : t)),
}))
@@ -91,20 +100,27 @@ export const useChat = create<ChatState>((set, get) => ({
...s.turns,
{
id, question, phase: 'planning', status: PHASE_TEXT.planning,
thinking: '', graph: null, explanation: '', run: null,
steps: [], output: null, error: '', startedAt: Date.now(),
thinking: '', ops: [], events: [], graph: null, explanation: '',
run: null, output: null, error: '', startedAt: Date.now(),
lastSeq: 0, cancel: null,
},
],
}))
// 建图阶段:Copilot 的操作流直接翻译成人话,不让用户看 add_node/add_edge
const nodes: any[] = []
const edges: any[] = []
const cancelCopilot = streamCopilot(
{ instruction: question, base_graph: null },
(op) => {
// 先原样收下,翻译交给 decodeCopilot。thinking 合并在写入时做:
// 一次生成几百上千条 delta,逐条存下来光数组就比图大一个量级
patch((t) => {
const last = t.ops[t.ops.length - 1]
if (op.op === 'thinking' && last?.op === 'thinking') {
const merged = { ...last, delta: String(last.delta ?? '') + String(op.delta ?? '') }
return { ops: [...t.ops.slice(0, -1), merged] }
}
return { ops: [...t.ops, op] }
})
switch (op.op) {
case 'thinking':
patch((t) => ({
@@ -119,16 +135,11 @@ export const useChat = create<ChatState>((set, get) => ({
patch(() => ({ phase: 'building', status: op.summary || '正在搭建流程…' }))
break
case 'add_node':
if (op.node) nodes.push(op.node)
patch((t) => ({
phase: 'building',
status: `正在搭建流程…(${nodes.length} 步)`,
steps: [...t.steps],
status: `正在搭建流程…(${t.ops.filter((o) => o.op === 'add_node').length} 步)`,
}))
break
case 'add_edge':
if (op.edge) edges.push(op.edge)
break
case 'done':
patch(() => ({ explanation: op.explanation ?? '' }))
break
@@ -139,7 +150,7 @@ export const useChat = create<ChatState>((set, get) => ({
graph, phase: 'running', status: PHASE_TEXT.running,
explanation: op.explanation ?? '',
}))
void launch(id, graph, patch, set, get)
void launch(id, graph, patch, set)
break
}
case 'error':
@@ -185,7 +196,7 @@ export const useChat = create<ChatState>((set, get) => ({
reattach: async (turnId) => {
const turn = get().turns.find((t) => t.id === turnId)
if (!turn?.run) return
const patch = (fn: (t: ChatTurn) => Partial<ChatTurn>) =>
const patch: Patch = (fn) =>
set((s) => ({ turns: s.turns.map((t) => (t.id === turnId ? { ...t, ...fn(t) } : t)) }))
patch(() => ({ phase: 'running', status: PHASE_TEXT.running }))
set({ busy: true })
@@ -194,13 +205,7 @@ export const useChat = create<ChatState>((set, get) => ({
}))
/** 图建好了就直接跑——用户要的是答案,不是一张图。 */
async function launch(
turnId: string,
graph: GraphSpec,
patch: (fn: (t: ChatTurn) => Partial<ChatTurn>) => void,
set: any,
_get: any,
) {
async function launch(turnId: string, graph: GraphSpec, patch: Patch, set: any) {
try {
const run = await api.runs.start({ graph, input: {} })
patch(() => ({ run }))
@@ -211,52 +216,28 @@ async function launch(
}
}
/** 订阅运行事件,把关键动作翻译成人话。 */
function watch(
runId: string,
turnId: string,
patch: (fn: (t: ChatTurn) => Partial<ChatTurn>) => void,
set: any,
after = 0,
) {
/**
* 订阅运行事件。
*
* 只做两件事:把事件原样存进这一轮,以及更新那几个驱动 UI 状态机的相位。
* "查了哪张表、跑了什么 SQL"不在这里翻译——decodeRun 会从同一批事件里读出来。
*/
function watch(runId: string, turnId: string, patch: Patch, set: any, after = 0) {
const stop = streamRun(runId, (event: RunEvent) => {
const d: any = event.data ?? {}
// 记下进度:审批恢复要重新接流,带上它才不会把历史再收一遍
patch((t) => ({ lastSeq: Math.max(t.lastSeq ?? 0, event.seq ?? 0) }))
patch((t) => ({
events: [...t.events, event],
// 记下进度:审批恢复要重新接流,带上它才不会把历史再收一遍
lastSeq: Math.max(t.lastSeq ?? 0, event.seq ?? 0),
}))
switch (event.type) {
case 'tool.start': {
const tool = String(d.tool ?? '')
const sql = String(d.args?.sql ?? '')
if (tool.startsWith('db_query')) {
patch((t) => ({
status: '正在查询数据…',
steps: [...t.steps, { icon: 'db', text: '查询数据', detail: sql }],
}))
} else if (tool.startsWith('db_schema')) {
patch((t) => ({
status: '正在了解数据结构…',
steps: [...t.steps, { icon: 'schema', text: `查看表结构 ${d.args?.table ?? ''}` }],
}))
} else if (tool) {
patch((t) => ({
status: `正在调用 ${tool}…`,
steps: [...t.steps, { icon: 'tool', text: `调用 ${tool}` }],
}))
}
break
}
case 'tool.end': {
const preview = String(d.preview ?? '')
patch((t) => {
const steps = [...t.steps]
const last = steps[steps.length - 1]
if (last && !last.detail?.startsWith('→')) {
// 查询结果里最有用的是行数,塞进上一条步骤而不是新起一行
const m = preview.match(/"row_count":\s*(\d+)/)
if (m) last.text = `${last.text} · ${m[1]} 行`
}
return { steps }
})
patch(() => ({
status: tool.startsWith('db_query') ? '正在查询数据…'
: tool.startsWith('db_schema') ? '正在了解数据结构…'
: tool ? `正在调用 ${tool}…` : '正在执行…',
}))
break
}
case 'llm.start':
@@ -266,17 +247,20 @@ function watch(
patch(() => ({ phase: 'waiting', status: PHASE_TEXT.waiting }))
set({ busy: false })
break
case 'node.failed':
patch((t) => ({
steps: [...t.steps, { icon: 'error', text: String(d.error ?? '某一步失败了') }],
}))
break
case 'run.failed':
patch(() => ({ phase: 'error', error: String(d.error ?? '运行失败') }))
set({ busy: false, cancel: null })
break
case 'run.finished':
void finish(runId, turnId, patch, set)
// 成果在事件里就有,不必再取一次 run;但 run 对象带着 usage 和
// run_class(出具横幅要用),所以还是拉一次,失败也不影响成果
patch(() => ({
phase: 'done', status: PHASE_TEXT.done, output: d.output ?? null,
}))
set({ busy: false, cancel: null })
void api.runs.get(runId)
.then((run) => patch(() => ({ run })))
.catch(() => undefined)
break
}
}, undefined, after)
@@ -285,20 +269,3 @@ function watch(
patch(() => ({ cancel: stop }))
set({ cancel: stop })
}
async function finish(
runId: string,
_turnId: string,
patch: (fn: (t: ChatTurn) => Partial<ChatTurn>) => void,
set: any,
) {
try {
const run = await api.runs.get(runId)
patch(() => ({
run, phase: 'done', status: PHASE_TEXT.done, output: run.output ?? null,
}))
} catch {
patch(() => ({ phase: 'done', status: PHASE_TEXT.done }))
}
set({ busy: false, cancel: null })
}
+71 -1
View File
@@ -5,6 +5,7 @@ import {
} from '@xyflow/react'
import { api, streamCopilot, streamRun } from '../api/client'
import { NODE_DEFS, sourceHandles } from '../canvas/nodeDefs'
import type { CopilotOp } from '../run/decode'
import type {
GraphEdge, GraphSpec, NodeRuntime, NodeType, Run, RunEvent, ValidationIssue, Workflow,
} from '../types'
@@ -63,6 +64,27 @@ export function toGraph(nodes: FlowNode[], edges: Edge[]): GraphSpec {
// -------------------------------------------------------------------------
/**
* 一轮 Copilot 对话。
*
* 以前操作流是用完即弃的——直接改画布,只在浮条上留一句 lastOp。那意味着
* 生成结束后,"它为什么这么建"这件事就没了:画布上是结果,过程不可回看。
* 侧栏要显示"它大致在想什么、动了哪些节点",就得把操作流本身留下来。
*/
export interface CopilotTurn {
id: string
instruction: string
/** 原始操作流,交给 decodeCopilot 翻译。thinking 在写入时就已合并 */
ops: CopilotOp[]
phase: 'running' | 'done' | 'error'
explanation: string
error: string
}
// 只留最近几轮。画布只反映最后一次生成的结果,更早的轮次是参考而不是现状,
// 无上限地攒着只会把内存和滚动条都撑坏。
const COPILOT_TURN_LIMIT = 10
interface StudioState {
workflow: Workflow | null
nodes: FlowNode[]
@@ -92,6 +114,7 @@ interface StudioState {
lastUseBase: boolean
}
copilotNew: string[]
copilotTurns: CopilotTurn[]
cancelCopilot: (() => void) | null
// actions
@@ -113,6 +136,7 @@ interface StudioState {
runCopilot: (instruction: string, useBase: boolean, model?: string | null) => void
stopCopilot: () => void
retryCopilot: () => void
clearCopilot: () => void
attachRun: (runId: string) => Promise<void>
stopRun: () => Promise<void>
clearRun: () => void
@@ -137,6 +161,7 @@ export const useStudio = create<StudioState>((set, get) => ({
copilot: { active: false, lastOp: '', explanation: '', error: '', model: '',
phase: '', thinking: '', elapsedMs: 0, lastInstruction: '', lastUseBase: false },
copilotNew: [],
copilotTurns: [],
cancelCopilot: null,
load: (workflow) => {
@@ -145,6 +170,8 @@ export const useStudio = create<StudioState>((set, get) => ({
set({
workflow, nodes, edges, selectedId: null, dirty: false, issues: [],
run: null, events: [], runtime: {}, activeEdges: [], streaming: false, unsubscribe: null,
// 换了一张图,之前那些"我让它改成这样"就不再指向眼前这张图了
copilotTurns: [],
})
void get().validate()
},
@@ -310,6 +337,7 @@ export const useStudio = create<StudioState>((set, get) => ({
runCopilot: (instruction, useBase, model) => {
const state = get()
state.cancelCopilot?.()
const turnId = `c${Date.now().toString(36)}${(idSeq++ % 1000).toString(36)}`
set({
copilot: {
active: true, lastOp: '', explanation: '', error: '', model: model ?? '',
@@ -317,9 +345,38 @@ export const useStudio = create<StudioState>((set, get) => ({
lastInstruction: instruction, lastUseBase: useBase,
},
copilotNew: [],
copilotTurns: [
...state.copilotTurns.slice(-(COPILOT_TURN_LIMIT - 1)),
{ id: turnId, instruction, ops: [], phase: 'running', explanation: '', error: '' },
],
})
if (!useBase) set({ nodes: [], edges: [], dirty: true })
/**
* 记下这一条操作,供侧栏回看。
*
* thinking 在写入时就合并:一次生成的 delta 是几百上千条,逐条存下来
* 光是数组本身就比图大一个量级,而 decodeCopilot 反正也要把它们并成一条。
*/
const record = (op: CopilotOp) => {
set((s: StudioState) => ({
copilotTurns: s.copilotTurns.map((t) => {
if (t.id !== turnId) return t
const last = t.ops[t.ops.length - 1]
if (op.op === 'thinking' && last?.op === 'thinking') {
const merged = { ...last, delta: String(last.delta ?? '') + String(op.delta ?? '') }
return { ...t, ops: [...t.ops.slice(0, -1), merged] }
}
return { ...t, ops: [...t.ops, op] }
}),
}))
}
const settle = (patch: Partial<CopilotTurn>) => {
set((s: StudioState) => ({
copilotTurns: s.copilotTurns.map((t) => (t.id === turnId ? { ...t, ...patch } : t)),
}))
}
// 流式期间的临时摆位:新节点放在其入边源的右侧;final 会用后端排版整体替换
const place = (nodeId: string): { x: number; y: number } => {
const { nodes, edges } = get()
@@ -340,6 +397,7 @@ export const useStudio = create<StudioState>((set, get) => ({
},
(op) => {
const s = get()
record(op)
switch (op.op) {
case 'model':
// 后端首帧告知实际用的模型,浮条上直接显示,不用猜
@@ -441,14 +499,16 @@ export const useStudio = create<StudioState>((set, get) => ({
copilot: { ...get().copilot, active: false, lastOp: '', phase: '',
thinking: '', elapsedMs: 0, error: '',
explanation: op.explanation ?? get().copilot.explanation } })
settle({ phase: 'done', explanation: op.explanation ?? get().copilot.explanation })
void get().validate()
setTimeout(() => set({ copilotNew: [] }), 6000)
break
}
case 'error':
// 保留 lastInstruction:失败浮条上的"重试"要用它,不能让用户重填
// 保留 lastInstruction:失败后的"重试"要用它,不能让用户重填
set({ copilot: { ...get().copilot, active: false, lastOp: '', phase: '',
thinking: '', explanation: '', error: op.message ?? '生成失败' } })
settle({ phase: 'error', error: op.message ?? '生成失败' })
break
}
},
@@ -458,6 +518,12 @@ export const useStudio = create<StudioState>((set, get) => ({
copilot: { ...c, active: false, error: error ?? c.error },
cancelCopilot: null,
})
// 流断了但没收到 final/error:这一轮不能一直挂在"进行中",
// 否则侧栏会永远转圈,而后台其实什么都不会再来了
const t = get().copilotTurns.find((x) => x.id === turnId)
if (t?.phase === 'running') {
settle(error ? { phase: 'error', error } : { phase: 'done' })
}
},
)
set({ cancelCopilot: stop })
@@ -468,9 +534,13 @@ export const useStudio = create<StudioState>((set, get) => ({
set({
copilot: { ...get().copilot, active: false, phase: '', thinking: '' },
cancelCopilot: null,
copilotTurns: get().copilotTurns.map((t) =>
t.phase === 'running' ? { ...t, phase: 'error', error: '已取消' } : t),
})
},
clearCopilot: () => set({ copilotTurns: [] }),
retryCopilot: () => {
// 失败后用同一条需求重来。以前只能重新打开 Modal 把需求再敲一遍,
// 而失败往往跟需求本身无关(模型抽风、协议跑偏、网络断了)
+51 -1
View File
@@ -92,6 +92,32 @@ console.log('\n=== 人工审批(含重放)===')
!all.some((s) => s.status === 'done' && s.level === 'warn'))
}
console.log('\n=== 循环里的多轮审批 ===')
{
// 真实运行:驳回 → 改写 → 再驳回(带备注)→ 改写 → 放行。三轮,每轮都有
// 重放。按内容去重必然出错——三轮的 title 一模一样,和重放无法区分。
// 这条只有拿真实运行才测得出来:构造样本不会长成这样。
const steps = mod.decodeRun(fixtures.loop_approve)
const all = flatten(steps)
const asks = all.filter((s) => s.kind === 'human')
check('三轮审批就是三条,不多不少', asks.length === 3, `${asks.length} 条`)
check('每条都带着你的决定', asks.every((s) => /你(放行|驳回)了/.test(s.title)),
asks.map((s) => s.title).join(' | '))
check('两次驳回一次放行,顺序没错',
asks.filter((s) => s.title.includes('驳回')).length === 2
&& asks[2]?.title.includes('放行'))
// detail 里是被审的草稿全文,打出来会刷屏——只报哪一轮带了备注
check('备注跟着那一轮走', asks.some((s) => s.detail?.includes('不高级')),
asks.map((s, i) => (s.detail?.includes('你的备注') ? `#${i + 1} 有备注` : '')).filter(Boolean).join(' '))
check('没有落单的"你放行了/你驳回了"孤行',
!all.some((s) => s.title === '你放行了' || s.title === '你驳回了'))
check('重放不制造重复节点', (() => {
const ids = steps.filter((s) => s.kind === 'node').map((s) => s.nodeId)
return new Set(ids).size === ids.length
})())
check('跑完了没有还在转的行', !all.some((s) => s.status === 'running'))
}
console.log('\n=== 出具判定 ===')
{
const steps = mod.decodeRun(fixtures.issue)
@@ -142,13 +168,37 @@ console.log('\n=== Copilot 操作流 ===')
{ op: 'add_edge', edge: { source: 'a', target: 'q' } },
{ op: 'done', explanation: '完成' },
]
const mid = mod.decodeCopilot(ops.slice(0, 2))
check('心跳不堆行', mid.filter((s) => s.kind === 'lifecycle' && s.status === 'running').length === 1)
const steps = mod.decodeCopilot(ops)
check('心跳不堆行', steps.filter((s) => s.kind === 'lifecycle' && s.status === 'running').length === 1)
check('连续思考并成一条', steps.filter((s) => s.kind === 'think').length === 1,
`${steps.filter((s) => s.kind === 'think').length} 条`)
check('思考全文保留在详情', steps.find((s) => s.kind === 'think')?.detail?.includes('先看表结构'))
check('连线不单独成行', !steps.some((s) => s.title.includes('edge')))
check('节点用标签不用 id', steps.some((s) => s.kind === 'node' && s.title === '取数'))
// 生成完了那条"正在理解需求…"还在转圈的话,看上去像卡住了
check('生成结束后没有还在转的行', !steps.some((s) => s.status === 'running'),
steps.filter((s) => s.status === 'running').map((s) => s.title).join(','))
// 改图场景:这一轮加了 1 个节点,但整张图有 4 个。说"共 1 步"是错的
const edited = mod.decodeCopilot([
...ops,
{ op: 'final', graph: { nodes: [{ id: 'a' }, { id: 'q' }, { id: 'b' }, { id: 'c' }] } },
])
const tail = edited[edited.length - 1]
check('改图时说清"加了几步"和"整张图几步"',
tail.title.includes('加了 1 步') && tail.title.includes('共 4 步'), tail.title)
const built = mod.decodeCopilot([
{ op: 'add_node', node: { id: 'a', type: 'input' } },
{ op: 'add_node', node: { id: 'b', type: 'output' } },
{ op: 'done', explanation: '' },
{ op: 'final', graph: { nodes: [{ id: 'a' }, { id: 'b' }] } },
])
check('从零新建时不啰嗦,只说共几步',
built[built.length - 1].title === '流程搭好了,共 2 步',
built[built.length - 1].title)
}
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过')
+1 -1
View File
@@ -12,7 +12,7 @@ const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
// 不用 playwright install:它既下不动也会动到已有缓存。系统 Chrome 就够了。
const CHROME = process.env.CHROME_PATH
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const CASES = ['db', 'think', 'human', 'issue', 'failed']
const CASES = ['db', 'think', 'human', 'issue', 'failed', 'loop_approve']
let failed = 0
const check = (name, cond, detail = '') => {
+142
View File
@@ -0,0 +1,142 @@
// 三个页面的端到端检查:真浏览器、真后端、真数据。
//
// check-decode 守翻译,check-stream 守渲染,这一层守的是"接起来还对不对"——
// 前两层都能通过而这一层坏掉:store 没把事件存进去、tab 切换把状态卸载了、
// 唯一的运行入口被改版顺手删了。
//
// 跑之前前后端都得起着:./scripts/dev.sh
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const SHOTS = process.env.SHOT_DIR ?? ''
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const browser = await chromium.launch({ executablePath: CHROME })
const ctx = await browser.newContext({ viewport: { width: 1500, height: 940 } })
/** 打开一页并收集真实报错(favicon 的 404 不算) */
async function visit(path) {
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message))
page.on('console', (m) => {
if (m.type() === 'error' && !m.text().includes('404')) errors.push(m.text())
})
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
await page.waitForTimeout(700)
return { page, errors }
}
const shot = async (page, name) => {
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${name}.png` })
}
console.log('=== 画布助手栏 ===')
{
const { page, errors } = await visit('/studio')
check('没有运行时报错', errors.length === 0, errors.join(' | '))
const tabs = await page.locator('nav, [role=tablist], button').allInnerTexts()
const flat = tabs.join(' ')
check('右栏是「助手 / 属性」两个 tab', flat.includes('助手') && flat.includes('属性'))
check('不再有「时间线 / 成果 / 事件」的第二层嵌套',
!flat.includes('时间线') && !flat.includes('原始事件'))
// 改版最容易顺手删掉的两个东西
check('发起运行的入口还在', await page.getByText('试运行').count() > 0)
check('Copilot 输入框就在栏里(不再是弹窗)',
await page.locator('textarea[placeholder*="告诉它"], textarea[placeholder*="描述你要"]').count() > 0)
// 切到属性再切回来,输入框里的草稿不该没
const composer = page.locator('textarea[placeholder*="告诉它"], textarea[placeholder*="描述你要"]').first()
await composer.fill('测试草稿不要丢')
await page.getByRole('button', { name: '属性' }).click()
await page.waitForTimeout(200)
await page.getByRole('button', { name: '助手' }).click()
await page.waitForTimeout(200)
check('切走再切回来,输入的字还在', await composer.inputValue() === '测试草稿不要丢',
await composer.inputValue())
await composer.fill('')
const overflow = await page.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth)
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
await shot(page, 'studio')
await page.close()
}
console.log('\n=== 运行历史 ===')
{
const { page, errors } = await visit('/runs')
check('没有运行时报错', errors.length === 0, errors.join(' | '))
const rows = page.locator('button').filter({ hasText: /\d{4}\/\d{1,2}\/\d{1,2}/ })
const n = await rows.count()
check('列出了运行记录', n > 0, `${n} 条`)
if (n > 0) {
// 列表原本每行只有"临时图 · 1.2s · 340 tok",十条长得一模一样
const texts = await rows.allInnerTexts()
const withSummary = texts.filter((t) => t.trim().split('\n').length >= 3).length
check('行里有内容摘要而不只是耗时和 token', withSummary > 0,
`${withSummary}/${texts.length} 行带摘要`)
await rows.first().click()
await page.waitForTimeout(900)
const body = await page.locator('body').innerText()
check('详情走的是可读视图,不是事件表',
!/\bnode\.(started|finished)\b/.test(body) && !/\brun\.started\b/.test(body))
check('详情有内容', body.length > 300, `${body.length} 字`)
// 原始事件不是常驻 tab,但必须还能看到
await page.locator('button[title*="原始事件"]').first().click()
await page.waitForTimeout(400)
const rawBody = await page.locator('body').innerText()
check('切得到原始事件', /node\.(started|finished)|run\.started/.test(rawBody))
await page.locator('button[title*="可读视图"]').first().click()
await page.waitForTimeout(300)
check('切得回来', !/\bnode\.started\b/.test(await page.locator('body').innerText()))
}
await shot(page, 'runs')
await page.close()
}
console.log('\n=== 问数据 ===')
{
const { page, errors } = await visit('/chat')
check('没有运行时报错', errors.length === 0, errors.join(' | '))
const body = await page.locator('body').innerText()
check('有空态或历史,不是白屏', body.length > 60, `${body.length} 字`)
check('有输入框', await page.locator('textarea').count() > 0)
const overflow = await page.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth)
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
await shot(page, 'chat')
await page.close()
}
console.log('\n=== 三处讲的是同一个故事 ===')
{
// 同一次运行,运行页详情和 preview 里的 dense 渲染应该给出同一批步骤。
// 这是"全站一个翻译层"的实际含义——分两套的话用户没法判断哪个是真的。
const a = await ctx.newPage()
await a.goto(`${WEB}/preview.html?case=loop_approve`, { waitUntil: 'networkidle' })
await a.waitForTimeout(400)
const wide = await a.locator('body').innerText()
const asks = (wide.match(/这条公告可以发吗/g) ?? []).length
// 宽栏和窄栏各渲染一遍,所以是 3×2
check('宽窄两栏各三轮审批,一轮不少', asks === 6, `${asks} 处`)
check('决定折进了同一行而不是另起一行',
!/^你放行了$/m.test(wide) && !/^你驳回了$/m.test(wide))
await a.close()
}
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 界面端到端全部通过')
process.exit(failed ? 1 : 0)