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:
ThearchyHelios
2026-09-14 10:54:31 +08:00
parent dcba9a63e0
commit 1b595f699c
9 changed files with 466 additions and 45 deletions
+199
View File
@@ -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 = ""
+1
View File
@@ -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},
+3
View File
@@ -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
+1
View File
@@ -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"]},
+50
View File
@@ -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()
}
/**
* 订阅一次运行的事件流。
*
+2
View File
@@ -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 }}
+12
View File
@@ -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; }
+60 -44
View File
@@ -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>
)
}
+138 -1
View File
@@ -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