mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:21:59 +00:00
Copilot 流式生成:图在画布上实时长出来
此前点击生成后是 20-30 秒黑盒等待,模态框关着,用户看不到任何过程。 改成操作流协议:模型按 NDJSON 逐行输出 add_node / add_edge / update_node / remove_node / done,后端边流边经 SSE 转发,前端每收 一个操作就应用到画布——节点带紫色描边逐个出现(临时摆位跟着入边源), 左下角浮条显示当前动作并可随时取消;done 后后端统一排版+校验, 以 final 事件整体落位,高亮几秒后淡出。修改现有图的场景只输出改动 操作,用户能看到 Copilot 具体动了哪里。 行解析对模型犯规免疫:markdown 围栏、夹杂的解释文字、done 之后的 多余输出都被跳过或截断(单测覆盖跨块行缓冲)。 顺手修结构化输出必失败的 bug:langchain 靠 title 字段识别裸 JSON Schema dict,四处 with_structured_output 的 schema 都缺它,抛 ValueError: Unsupported function 后靠 fallback 文本解析兜底—— 每次都白调一次模型。补上 title(llm 节点 output_schema、branch 语义分类、supervisor 路由、旧版 copilot generate)。 实测:首节点 7.2s 上画布(原先干等 27s),浏览器采样节点数 0→2→3→4→5→6 逐步生长,6 个新节点全部高亮,零页面错误。
This commit is contained in:
@@ -102,6 +102,7 @@ class GenerateOut(BaseModel):
|
||||
|
||||
|
||||
GRAPH_SCHEMA: dict[str, Any] = {
|
||||
"title": "workflow_graph", # langchain 靠 title 识别 JSON Schema dict
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"explanation": {"type": "string", "description": "一两句话说明这张图怎么跑"},
|
||||
@@ -223,6 +224,204 @@ async def generate(
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 流式生成:模型输出逐行操作流(NDJSON),画布看着图长出来
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_STREAM_PROTOCOL = """\
|
||||
输出格式(严格遵守):
|
||||
- 每行一个完整的 JSON 对象,除此之外不输出任何东西——不要 markdown 围栏、不要解释文字、不要空行注释
|
||||
- 可用操作:
|
||||
{"op":"plan","summary":"一句话说明打算怎么搭"} ← 第一行
|
||||
{"op":"add_node","node":{"id":"...","type":"...","label":"中文标签","config":{...}}}
|
||||
{"op":"update_node","id":"...","label":"...","config":{...}} ← 修改现有节点(config 整体替换)
|
||||
{"op":"remove_node","id":"..."}
|
||||
{"op":"add_edge","edge":{"source":"...","target":"...","sourceHandle":"..."}}
|
||||
{"op":"remove_edge","source":"...","target":"...","sourceHandle":"..."}
|
||||
{"op":"done","explanation":"两三句话说明这张图怎么跑"} ← 最后一行
|
||||
- 按执行顺序添加节点(先入口后出口);每加一个节点,立刻把连向它的边(两端都已存在的)输出出来,让图连贯地生长
|
||||
- 修改现有图时只输出改动,没提到的节点不要动"""
|
||||
|
||||
|
||||
def _parse_op_line(line: str) -> dict[str, Any] | None:
|
||||
"""解析一行操作。围栏行、空行、解析失败、缺 op 的一律返回 None——
|
||||
模型偶尔犯规不该毁掉整条流,跳过就是。"""
|
||||
text = line.strip()
|
||||
if not text or text.startswith("```"):
|
||||
return None
|
||||
try:
|
||||
obj = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(obj, dict) or "op" not in obj:
|
||||
return None
|
||||
return obj
|
||||
|
||||
|
||||
def _apply_op(nodes: dict[str, dict[str, Any]], edges: list[dict[str, Any]], op: dict[str, Any]) -> bool:
|
||||
"""把一个操作应用到服务端维护的图状态。返回是否真的改了图。"""
|
||||
kind = op.get("op")
|
||||
if kind == "add_node":
|
||||
node = op.get("node") or {}
|
||||
if not node.get("id") or not node.get("type"):
|
||||
return False
|
||||
nodes[node["id"]] = {
|
||||
"id": node["id"],
|
||||
"type": node["type"],
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {"label": node.get("label") or "", "config": node.get("config") or {}},
|
||||
}
|
||||
return True
|
||||
if kind == "update_node":
|
||||
node = nodes.get(op.get("id") or "")
|
||||
if not node:
|
||||
return False
|
||||
if op.get("label") is not None:
|
||||
node["data"]["label"] = op["label"]
|
||||
if op.get("config") is not None:
|
||||
node["data"]["config"] = op["config"]
|
||||
return True
|
||||
if kind == "remove_node":
|
||||
node_id = op.get("id")
|
||||
if node_id not in nodes:
|
||||
return False
|
||||
nodes.pop(node_id)
|
||||
edges[:] = [e for e in edges if e["source"] != node_id and e["target"] != node_id]
|
||||
return True
|
||||
if kind == "add_edge":
|
||||
edge = op.get("edge") or {}
|
||||
if not edge.get("source") or not edge.get("target"):
|
||||
return False
|
||||
edges.append(
|
||||
{
|
||||
"source": edge["source"],
|
||||
"target": edge["target"],
|
||||
"sourceHandle": edge.get("sourceHandle"),
|
||||
}
|
||||
)
|
||||
return True
|
||||
if kind == "remove_edge":
|
||||
before = len(edges)
|
||||
edges[:] = [
|
||||
e
|
||||
for e in edges
|
||||
if not (
|
||||
e["source"] == op.get("source")
|
||||
and e["target"] == op.get("target")
|
||||
and (op.get("sourceHandle") is None or e.get("sourceHandle") == op.get("sourceHandle"))
|
||||
)
|
||||
]
|
||||
return len(edges) != before
|
||||
return False
|
||||
|
||||
|
||||
async def _iter_ops(model: Any, messages: list[Any]):
|
||||
"""流式读模型输出,按行切出操作。done 之后即停——后面就算有闲话也不要了。"""
|
||||
from app.engine.state import message_text as _text
|
||||
|
||||
buffer = ""
|
||||
async for chunk in model.astream(messages):
|
||||
buffer += _text(chunk)
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
op = _parse_op_line(line)
|
||||
if op:
|
||||
yield op
|
||||
if op.get("op") == "done":
|
||||
return
|
||||
op = _parse_op_line(buffer)
|
||||
if op:
|
||||
yield op
|
||||
|
||||
|
||||
@router.post("/generate-stream")
|
||||
async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(get_session)):
|
||||
"""流式版生成:SSE 逐操作推送,前端边收边把节点摆上画布。
|
||||
|
||||
结束时(done 或流断)发一条 final:服务端把累积的图排版、校验后整体给出——
|
||||
过程可见性来自操作流,最终质量仍由和手工编排相同的 layout + validate 保证。
|
||||
"""
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
tool_list = "\n".join(
|
||||
f"- {name}({spec.category}):{spec.description}"
|
||||
for name, spec in sorted(all_specs().items())
|
||||
)
|
||||
system = (
|
||||
"你是一个 agent 工作流编排专家。根据用户需求,以操作流的方式逐步搭出一张可执行的工作流图。\n\n"
|
||||
f"{NODE_REFERENCE}\n\n可用工具:\n{tool_list}\n\n"
|
||||
"结构要求:\n"
|
||||
"1. 必须有且只有一个 input 节点和至少一个 output 节点\n"
|
||||
"2. 节点 id 用简短英文小写下划线;label 用中文,一眼看懂\n"
|
||||
"3. 用 assign_to 传结果,下游 {{ vars.变量名 }} 引用\n"
|
||||
"4. 只能用上面列出的工具名\n"
|
||||
"5. 能用 3 个节点解决就别堆 8 个\n\n"
|
||||
f"{_STREAM_PROTOCOL}"
|
||||
)
|
||||
|
||||
# 现有图状态:修改场景从 base_graph 起步,新建场景从空图起步
|
||||
nodes: dict[str, dict[str, Any]] = {}
|
||||
edges: list[dict[str, Any]] = []
|
||||
if payload.base_graph and payload.base_graph.get("nodes"):
|
||||
for n in payload.base_graph["nodes"]:
|
||||
nodes[n["id"]] = n
|
||||
edges = [
|
||||
{k: v for k, v in e.items() if k in ("source", "target", "sourceHandle")}
|
||||
for e in payload.base_graph.get("edges") or []
|
||||
]
|
||||
user = (
|
||||
"这是当前的工作流:\n"
|
||||
f"{json.dumps(_slim(payload.base_graph), ensure_ascii=False, indent=2)}\n\n"
|
||||
f"请按下面的要求修改它(只输出改动操作):\n{payload.instruction}"
|
||||
)
|
||||
else:
|
||||
user = f"请设计一个工作流:{payload.instruction}"
|
||||
|
||||
try:
|
||||
model, _ = await get_chat_model(
|
||||
session, ModelSpec(provider=payload.provider, model=payload.model, max_tokens=8192)
|
||||
)
|
||||
except ProviderNotConfigured as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
messages = [("system", system), ("human", user)]
|
||||
|
||||
async def event_stream():
|
||||
explanation = ""
|
||||
try:
|
||||
async for op in _iter_ops(model, messages):
|
||||
if op.get("op") == "done":
|
||||
explanation = str(op.get("explanation", ""))
|
||||
changed = _apply_op(nodes, edges, op)
|
||||
if changed or op.get("op") in ("plan", "done"):
|
||||
yield f"data: {json.dumps(op, ensure_ascii=False)}\n\n"
|
||||
except Exception as e: # noqa: BLE001
|
||||
yield f"data: {json.dumps({'op': 'error', 'message': f'{type(e).__name__}: {e}'}, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
# 收尾:排版 + 校验,把最终图整体交付
|
||||
try:
|
||||
spec = auto_layout(
|
||||
GraphSpec.model_validate({"nodes": list(nodes.values()), "edges": edges})
|
||||
)
|
||||
issues = [i.model_dump() for i in validate_graph(spec).issues]
|
||||
final = {
|
||||
"op": "final",
|
||||
"graph": spec.model_dump(mode="json"),
|
||||
"issues": issues,
|
||||
"explanation": explanation,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
final = {"op": "error", "message": f"生成的图结构非法:{e}"}
|
||||
yield f"data: {json.dumps(final, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
class FromRunIn(BaseModel):
|
||||
run_id: str
|
||||
name: str = ""
|
||||
|
||||
@@ -81,6 +81,7 @@ async def _classify_with_llm(
|
||||
)
|
||||
|
||||
schema = {
|
||||
"title": "classify", # langchain 靠 title 识别 JSON Schema dict
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {"type": "string", "enum": keys},
|
||||
|
||||
@@ -176,6 +176,9 @@ async def run_llm(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
# 要结构化结果就走原生 structured output,这条路径拿不到 token 流
|
||||
ctx.emit(EventType.LLM_START, structured=True, message_count=len(messages))
|
||||
try:
|
||||
# langchain 靠 "title" 识别裸 JSON Schema dict,缺了会抛 Unsupported function
|
||||
if isinstance(schema, dict) and "title" not in schema:
|
||||
schema = {"title": "output", **schema}
|
||||
structured = model.with_structured_output(schema)
|
||||
value = await structured.ainvoke(messages)
|
||||
except Exception as e: # noqa: BLE001
|
||||
|
||||
@@ -59,6 +59,7 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
for n in names
|
||||
)
|
||||
route_schema = {
|
||||
"title": "route", # langchain 靠 title 识别 JSON Schema dict
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"next": {"type": "string", "enum": names + ["FINISH"]},
|
||||
|
||||
@@ -202,6 +202,56 @@ export const api = {
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式生成工作流:SSE 逐操作回调,返回取消函数。
|
||||
* 每个操作(add_node / add_edge / …)到达即回调,画布边收边长。
|
||||
*/
|
||||
export function streamCopilot(
|
||||
body: { instruction: string; base_graph?: GraphSpec | null },
|
||||
onOp: (op: any) => void,
|
||||
onEnd: (error?: string) => void,
|
||||
): () => void {
|
||||
const controller = new AbortController()
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/copilot/generate-stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...actorHeader() },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok || !res.body) {
|
||||
let detail = `${res.status} ${res.statusText}`
|
||||
try { detail = (await res.json()).detail ?? detail } catch { /* keep */ }
|
||||
onEnd(String(detail))
|
||||
return
|
||||
}
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let idx: number
|
||||
while ((idx = buffer.indexOf('\n\n')) >= 0) {
|
||||
const frame = buffer.slice(0, idx)
|
||||
buffer = buffer.slice(idx + 2)
|
||||
if (!frame.startsWith('data: ')) continue
|
||||
try {
|
||||
onOp(JSON.parse(frame.slice(6)))
|
||||
} catch { /* 半截帧,忽略 */ }
|
||||
}
|
||||
}
|
||||
onEnd()
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') onEnd(e?.message ?? '连接中断')
|
||||
else onEnd()
|
||||
}
|
||||
})()
|
||||
return () => controller.abort()
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅一次运行的事件流。
|
||||
*
|
||||
|
||||
@@ -58,6 +58,7 @@ function NodeCardImpl({ id, data, selected }: NodeProps<FlowNode>) {
|
||||
const runtime = useStudio((s) => s.runtime[id])
|
||||
// selector 必须返回稳定引用:filter() 每次都造新数组,会触发无限重渲染
|
||||
const hasError = useStudio((s) => s.issues.some((i) => i.node_id === id && i.level === 'error'))
|
||||
const copilotNew = useStudio((s) => s.copilotNew.includes(id))
|
||||
const Icon = def?.icon ?? Sparkles
|
||||
const handles = sourceHandles(data.nodeType, data.config)
|
||||
const status = runtime?.status ?? 'idle'
|
||||
@@ -73,6 +74,7 @@ function NodeCardImpl({ id, data, selected }: NodeProps<FlowNode>) {
|
||||
status === 'done' && 'node-done',
|
||||
status === 'failed' && 'node-failed',
|
||||
status === 'waiting' && 'node-waiting',
|
||||
copilotNew && 'node-copilot-new',
|
||||
selected && 'ring-2 ring-[var(--accent)] ring-offset-1 ring-offset-[var(--bg)]',
|
||||
)}
|
||||
style={{ width: 238, borderColor: selected ? 'var(--accent)' : undefined }}
|
||||
|
||||
@@ -201,6 +201,18 @@ pre,
|
||||
border-color: var(--warn) !important;
|
||||
}
|
||||
|
||||
/* Copilot 刚放上来的节点:紫色描边 + 出现动画,final 后几秒淡出 */
|
||||
@keyframes copilot-pop {
|
||||
from { opacity: 0; transform: scale(0.85); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.node-copilot-new {
|
||||
animation: copilot-pop 0.25s ease-out;
|
||||
border-color: #bc8cff !important;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, #bc8cff 25%, transparent);
|
||||
transition: box-shadow 0.8s, border-color 0.8s;
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
|
||||
@@ -142,8 +142,9 @@ export function StudioPage() {
|
||||
<aside className="w-52 shrink-0 border-r bg-panel">
|
||||
<Palette />
|
||||
</aside>
|
||||
<main className="min-w-0 flex-1">
|
||||
<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
|
||||
@@ -345,32 +346,16 @@ function PublishModal({ workflow, onClose, onDone }: {
|
||||
}
|
||||
|
||||
function CopilotModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const toast = useToast()
|
||||
const nodes = useStudio((s) => s.nodes)
|
||||
const edges = useStudio((s) => s.edges)
|
||||
const setGraph = useStudio((s) => s.setGraph)
|
||||
const runCopilot = useStudio((s) => s.runCopilot)
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [result, setResult] = useState<{ explanation: string; issues: any[] } | null>(null)
|
||||
const [useBase, setUseBase] = useState(true)
|
||||
|
||||
const generate = async () => {
|
||||
// 流式生成:发起后立刻关窗,图在画布上实时长出来,状态见左下角浮条
|
||||
const generate = () => {
|
||||
if (!instruction.trim()) return
|
||||
setBusy(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await api.copilot.generate({
|
||||
instruction,
|
||||
base_graph: useBase && nodes.length ? toGraph(nodes, edges) : null,
|
||||
})
|
||||
setGraph(res.graph)
|
||||
setResult({ explanation: res.explanation, issues: res.issues })
|
||||
toast('已生成,记得检查后保存', 'ok')
|
||||
} catch (e: any) {
|
||||
toast(e.message ?? '生成失败', 'error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
runCopilot(instruction, useBase && nodes.length > 0)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const examples = [
|
||||
@@ -388,8 +373,8 @@ function CopilotModal({ open, onClose }: { open: boolean; onClose: () => void })
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={onClose}>关闭</button>
|
||||
<button className="btn btn-primary" onClick={generate} disabled={busy || !instruction.trim()}>
|
||||
{busy ? <Spinner /> : <Wand2 size={12} />} 生成到画布
|
||||
<button className="btn btn-primary" onClick={generate} disabled={!instruction.trim()}>
|
||||
<Wand2 size={12} /> 生成到画布
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
@@ -420,27 +405,58 @@ function CopilotModal({ open, onClose }: { open: boolean; onClose: () => void })
|
||||
)}
|
||||
|
||||
<div className="mt-2 text-[10.5px] leading-relaxed text-faint">
|
||||
生成结果会直接铺到画布上并自动排版,但<b>不会自动保存</b>——先看一遍再决定。
|
||||
点击生成后弹窗会关闭,节点会<b>实时长在画布上</b>(紫色描边是新改动)。
|
||||
结果<b>不会自动保存</b>——看一遍再决定。
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<div className="label">Copilot 说明</div>
|
||||
<div className="whitespace-pre-wrap rounded border bg-bg p-2 text-[11.5px] leading-relaxed">
|
||||
{result.explanation || '(无)'}
|
||||
</div>
|
||||
{!!result.issues.length && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{result.issues.map((issue, i) => (
|
||||
<div key={i} className="text-[10.5px]"
|
||||
style={{ color: issue.level === 'error' ? 'var(--err)' : 'var(--warn)' }}>
|
||||
{issue.level === 'error' ? '✕' : '!'} {issue.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** Copilot 工作时的画布浮条:显示当前操作,可随时取消;结束后展示说明。 */
|
||||
function CopilotStatusBar() {
|
||||
const copilot = useStudio((s) => s.copilot)
|
||||
const stopCopilot = useStudio((s) => s.stopCopilot)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 新一轮生成开始时重新显示
|
||||
if (copilot.active) setDismissed(false)
|
||||
}, [copilot.active])
|
||||
|
||||
if (dismissed) return null
|
||||
if (!copilot.active && !copilot.explanation && !copilot.error) return null
|
||||
|
||||
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 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.lastOp}
|
||||
</span>
|
||||
<button className="btn btn-sm" onClick={stopCopilot}>取消</button>
|
||||
</div>
|
||||
) : copilot.error ? (
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
addEdge, applyEdgeChanges, applyNodeChanges,
|
||||
type Connection, type Edge, type EdgeChange, type Node, type NodeChange,
|
||||
} from '@xyflow/react'
|
||||
import { api, streamRun } from '../api/client'
|
||||
import { api, streamCopilot, streamRun } from '../api/client'
|
||||
import { NODE_DEFS } from '../canvas/nodeDefs'
|
||||
import type {
|
||||
GraphEdge, GraphSpec, NodeRuntime, NodeType, Run, RunEvent, ValidationIssue, Workflow,
|
||||
@@ -78,6 +78,10 @@ interface StudioState {
|
||||
streaming: boolean
|
||||
unsubscribe: (() => void) | null
|
||||
|
||||
copilot: { active: boolean; lastOp: string; explanation: string; error: string }
|
||||
copilotNew: string[]
|
||||
cancelCopilot: (() => void) | null
|
||||
|
||||
// actions
|
||||
load: (workflow: Workflow) => void
|
||||
setGraph: (graph: GraphSpec) => void
|
||||
@@ -94,6 +98,8 @@ interface StudioState {
|
||||
|
||||
startRun: (input: Record<string, any>) => Promise<Run | null>
|
||||
startFormalRun: (input: Record<string, any>) => Promise<Run | null>
|
||||
runCopilot: (instruction: string, useBase: boolean) => void
|
||||
stopCopilot: () => void
|
||||
attachRun: (runId: string) => Promise<void>
|
||||
stopRun: () => Promise<void>
|
||||
clearRun: () => void
|
||||
@@ -115,6 +121,9 @@ export const useStudio = create<StudioState>((set, get) => ({
|
||||
activeEdges: [],
|
||||
streaming: false,
|
||||
unsubscribe: null,
|
||||
copilot: { active: false, lastOp: '', explanation: '', error: '' },
|
||||
copilotNew: [],
|
||||
cancelCopilot: null,
|
||||
|
||||
load: (workflow) => {
|
||||
const { nodes, edges } = toFlow(workflow.graph)
|
||||
@@ -252,6 +261,134 @@ export const useStudio = create<StudioState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
runCopilot: (instruction, useBase) => {
|
||||
const state = get()
|
||||
state.cancelCopilot?.()
|
||||
set({
|
||||
copilot: { active: true, lastOp: '正在起草…', explanation: '', error: '' },
|
||||
copilotNew: [],
|
||||
})
|
||||
if (!useBase) set({ nodes: [], edges: [], dirty: true })
|
||||
|
||||
// 流式期间的临时摆位:新节点放在其入边源的右侧;final 会用后端排版整体替换
|
||||
const place = (nodeId: string): { x: number; y: number } => {
|
||||
const { nodes, edges } = get()
|
||||
const incoming = edges.find((e) => e.target === nodeId)
|
||||
const source = incoming && get().nodes.find((n) => n.id === incoming.source)
|
||||
if (source) {
|
||||
const siblings = edges.filter((e) => e.source === source.id).length - 1
|
||||
return { x: source.position.x + 290, y: source.position.y + siblings * 150 }
|
||||
}
|
||||
return { x: 120 + nodes.length * 290, y: 320 }
|
||||
}
|
||||
|
||||
const stop = streamCopilot(
|
||||
{ instruction, base_graph: useBase && state.nodes.length ? toGraph(state.nodes, state.edges) : null },
|
||||
(op) => {
|
||||
const s = get()
|
||||
switch (op.op) {
|
||||
case 'plan':
|
||||
set({ copilot: { ...s.copilot, lastOp: op.summary ?? '规划中' } })
|
||||
break
|
||||
case 'add_node': {
|
||||
const n = op.node
|
||||
if (!n?.id || !n?.type) break
|
||||
const node: FlowNode = {
|
||||
id: n.id, type: 'card', position: { x: 0, y: 0 },
|
||||
data: { nodeType: n.type, label: n.label ?? '', config: n.config ?? {} },
|
||||
}
|
||||
set({
|
||||
nodes: [...s.nodes.filter((x) => x.id !== n.id), node],
|
||||
copilotNew: [...s.copilotNew, n.id],
|
||||
copilot: { ...s.copilot, lastOp: `添加节点:${n.label || n.id}` },
|
||||
dirty: true,
|
||||
})
|
||||
// 位置要等边可能已到齐后算——直接再取一次最新状态摆位
|
||||
set({
|
||||
nodes: get().nodes.map((x) => (x.id === n.id ? { ...x, position: place(n.id) } : x)),
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'update_node':
|
||||
set({
|
||||
nodes: s.nodes.map((x) =>
|
||||
x.id === op.id
|
||||
? { ...x, data: { ...x.data,
|
||||
...(op.label != null ? { label: op.label } : {}),
|
||||
...(op.config != null ? { config: op.config } : {}) } }
|
||||
: x),
|
||||
copilotNew: s.copilotNew.includes(op.id) ? s.copilotNew : [...s.copilotNew, op.id],
|
||||
copilot: { ...s.copilot, lastOp: `修改节点:${op.id}` },
|
||||
dirty: true,
|
||||
})
|
||||
break
|
||||
case 'remove_node':
|
||||
set({
|
||||
nodes: s.nodes.filter((x) => x.id !== op.id),
|
||||
edges: s.edges.filter((e) => e.source !== op.id && e.target !== op.id),
|
||||
copilot: { ...s.copilot, lastOp: `移除节点:${op.id}` },
|
||||
dirty: true,
|
||||
})
|
||||
break
|
||||
case 'add_edge': {
|
||||
const e = op.edge
|
||||
if (!e?.source || !e?.target) break
|
||||
set({
|
||||
edges: [...s.edges, {
|
||||
id: `cp_${e.source}_${e.sourceHandle ?? ''}_${e.target}`,
|
||||
source: e.source, target: e.target,
|
||||
sourceHandle: e.sourceHandle ?? null, type: 'smoothstep',
|
||||
}],
|
||||
copilot: { ...s.copilot, lastOp: `连线:${e.source} → ${e.target}` },
|
||||
dirty: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'remove_edge':
|
||||
set({
|
||||
edges: s.edges.filter((e) =>
|
||||
!(e.source === op.source && e.target === op.target
|
||||
&& (op.sourceHandle == null || e.sourceHandle === op.sourceHandle))),
|
||||
dirty: true,
|
||||
})
|
||||
break
|
||||
case 'done':
|
||||
set({ copilot: { ...s.copilot, lastOp: '排版整理中…', explanation: op.explanation ?? '' } })
|
||||
break
|
||||
case 'final': {
|
||||
// 后端排版+校验后的最终图整体落位;高亮集合保留几秒供辨认
|
||||
const { nodes, edges } = toFlow(op.graph)
|
||||
set({ nodes, edges, dirty: true,
|
||||
copilot: { active: false, lastOp: '',
|
||||
explanation: op.explanation ?? get().copilot.explanation, error: '' } })
|
||||
void get().validate()
|
||||
setTimeout(() => set({ copilotNew: [] }), 6000)
|
||||
break
|
||||
}
|
||||
case 'error':
|
||||
set({ copilot: { active: false, lastOp: '', explanation: '', error: op.message ?? '生成失败' } })
|
||||
break
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
const c = get().copilot
|
||||
set({
|
||||
copilot: { ...c, active: false, error: error ?? c.error },
|
||||
cancelCopilot: null,
|
||||
})
|
||||
},
|
||||
)
|
||||
set({ cancelCopilot: stop })
|
||||
},
|
||||
|
||||
stopCopilot: () => {
|
||||
get().cancelCopilot?.()
|
||||
set({
|
||||
copilot: { ...get().copilot, active: false },
|
||||
cancelCopilot: null,
|
||||
})
|
||||
},
|
||||
|
||||
startFormalRun: async (input) => {
|
||||
const { workflow } = get()
|
||||
if (!workflow) return null
|
||||
|
||||
Reference in New Issue
Block a user