mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
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:
+11
-2
@@ -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
@@ -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()
|
||||
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 状态渲染。
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 把需求再敲一遍,
|
||||
// 而失败往往跟需求本身无关(模型抽风、协议跑偏、网络断了)
|
||||
|
||||
@@ -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✓ 解码器全部通过')
|
||||
|
||||
@@ -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 = '') => {
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user