mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:11:59 +00:00
1756476a30
从点"生成"到第一个节点落到画布上,本机实测 31.7 秒(DeepSeek V4 pro,中等 复杂度需求)。这段时间界面上只有"正在起草…"五个字一动不动——用户没法判断 是在想还是已经卡死。生成质量本身没问题,瓶颈全在这段等待的体验。 三处改动: ## 思考转发 _iter_ops 原先只取正文、丢掉其余一切,模型的 thinking 块也一并丢了。现在用 thinking_text() 抽出来单独转成 thinking 事件,前端可折叠地实时显示。 刻意不用"把非 JSON 行当成思考"这种取巧写法:那会把模型的 markdown 注释、 协议跑偏时的乱输出一并当作思考展示出来,反而误导。不支持 thinking 的模型 就只有心跳,不硬凑假的思考。 注意这条只对支持 adaptive thinking 的模型(Claude 4.6+)生效,当前默认的 DeepSeek V4 pro 不会产出——代码路径用构造的 chunk 验证过,但端到端触发需要 启用 Anthropic provider。 ## 心跳与阶段 模型沉默时每 3 秒补一拍,带阶段和已用时长。必须在 _with_heartbeat 这一层做: 模型不吐 chunk 时 _iter_ops 里那个 async for 的循环体根本不执行,在里面判断 时间差轮不到。阶段跟着实际操作走(planning → building → wiring → finalizing), 阶段会变本身就是"它还活着"的信号。实测那 31.7 秒里补了 10 拍。 ## 失败后不用重敲需求 生成失败只有一个 ✕,重试得重新打开 Modal 把需求再敲一遍——而失败多半跟需求 本身无关(模型抽风、协议跑偏、网断了)。store 里留住 lastInstruction, 错误浮条上直接给"用同一需求重试"。 ## 顺带:项目第一组测试 这组守的是体验契约不是实现细节——thinking 和 heartbeat 两路反馈只要断了就是 回归,而这种回归在界面上表现为"又变回干等",很难被注意到。4 个用例覆盖: thinking 块转发、纯文本模型不伪造思考、沉默期补心跳、阶段随进度推进。 pytest 走 [dev] extra,environment.yml 一并带上。
712 lines
28 KiB
Python
712 lines
28 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import time
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.db.base import get_session
|
||
from app.db.models import Workflow
|
||
from app.engine.schema import GraphSpec, NodeType, validate_graph
|
||
from app.engine.state import message_text
|
||
from app.providers.factory import ModelSpec, ProviderNotConfigured, get_chat_model
|
||
from app.tools.registry import all_specs
|
||
|
||
router = APIRouter(prefix="/api/copilot", tags=["copilot"])
|
||
|
||
|
||
NODE_REFERENCE = """\
|
||
可用节点类型(type 字段):
|
||
- input:入口。config.fields = [{name, required, default, description}]
|
||
- output:出口,收集最终成果。config.fields = [{name, value}],value 里写模板引用
|
||
- llm:单次模型调用。config: {system, prompt, model, temperature, max_tokens, assign_to, output_schema}
|
||
- agent:带工具循环的 agent。config: {system, prompt, tools:[工具名], max_steps, approval, assign_to}
|
||
- supervisor:多 agent 协作。config: {goal, agents:[{name, description, system, tools, model}], max_rounds}
|
||
- tool:直接调一个工具。config: {tool: 工具名, args: {...}, assign_to}
|
||
- code:沙箱里跑代码。config: {language: python|bash|node, code, timeout, network, assign_to}
|
||
- branch:条件分支。config: {mode: expression|llm, cases:[{key, condition, label}]}
|
||
- loop:循环。config: {mode: foreach|while, items, item_var, condition, max_iterations}
|
||
- retrieve:知识库检索。config: {query, collection, limit, assign_to}
|
||
- memory:长期记忆读写。config: {action: recall|write, query, content, scope, assign_to}
|
||
- human:人工介入。config: {mode: approve|input|edit, title, message}
|
||
- validate:JSON Schema 校验,可自动让模型修复。config: {schema, source, max_retries}
|
||
- transform:数据整形。config: {mode: expression|template|json, expression/template, assign_to}
|
||
- subgraph:嵌套另一个工作流。config: {workflow_id, input}
|
||
|
||
模板语法(在任意字符串里用):
|
||
- {{ input.字段名 }} 入口输入
|
||
- {{ vars.变量名 }} 某节点 assign_to 写入的变量
|
||
- {{ nodes.节点id.text }} 某节点的输出
|
||
- {{ last_message }} 最近一条消息文本
|
||
- 过滤器:{{ vars.x | json }}
|
||
|
||
连线规则:
|
||
- edges 里每条边 {source, target, sourceHandle?}
|
||
- branch 节点的出边必须带 sourceHandle,取值是 cases 里的 key,另外要有一条 sourceHandle="default" 兜底
|
||
- loop 节点:sourceHandle="body" 连循环体,循环体最后一个节点再连回 loop 节点;sourceHandle="done" 连循环结束后的去向
|
||
- human 节点(approve 模式):sourceHandle 用 "approved" 和 "rejected"
|
||
- 一个节点连出多条普通边 = 并行执行,多条边汇入同一节点 = 等待汇聚
|
||
"""
|
||
|
||
|
||
def auto_layout(spec: GraphSpec, *, x_gap: int = 300, y_gap: int = 150) -> GraphSpec:
|
||
"""按拓扑层级排版。
|
||
|
||
模型生成的图只有结构没有坐标,全堆在原点就没法看了。
|
||
这里按最长路径分层:同层的节点竖排,层与层横向铺开。
|
||
"""
|
||
nodes = spec.node_map()
|
||
depth: dict[str, int] = {}
|
||
|
||
def compute(node_id: str, seen: frozenset[str]) -> int:
|
||
if node_id in depth:
|
||
return depth[node_id]
|
||
if node_id in seen: # 环:就地截断,避免递归爆栈
|
||
return 0
|
||
incoming = [e.source for e in spec.incoming(node_id) if e.source in nodes]
|
||
value = 0 if not incoming else 1 + max(
|
||
compute(src, seen | {node_id}) for src in incoming
|
||
)
|
||
depth[node_id] = value
|
||
return value
|
||
|
||
for node in spec.nodes:
|
||
compute(node.id, frozenset())
|
||
|
||
by_level: dict[int, list[str]] = {}
|
||
for node_id, level in depth.items():
|
||
by_level.setdefault(level, []).append(node_id)
|
||
|
||
for level, ids in sorted(by_level.items()):
|
||
offset = -(len(ids) - 1) * y_gap / 2
|
||
for i, node_id in enumerate(sorted(ids)):
|
||
node = nodes[node_id]
|
||
node.position.x = 80 + level * x_gap
|
||
node.position.y = 300 + offset + i * y_gap
|
||
return spec
|
||
|
||
|
||
class GenerateIn(BaseModel):
|
||
instruction: str = Field(min_length=1, description="用自然语言描述想要的工作流")
|
||
base_graph: dict[str, Any] | None = Field(default=None, description="在现有图上修改时传入")
|
||
provider: str | None = None
|
||
model: str | None = None
|
||
|
||
|
||
COPILOT_SETTING_KEY = "copilot"
|
||
|
||
|
||
async def copilot_model_spec(
|
||
session: AsyncSession, payload: GenerateIn, *, max_tokens: int = 8192
|
||
) -> ModelSpec:
|
||
"""决定 Copilot 用哪个模型。
|
||
|
||
优先级:本次请求显式指定 > 设置里存的 Copilot 专用模型 > provider 兜底。
|
||
做成独立设置是因为 Copilot 是元任务——它写的是编排本身,对指令遵循的要求
|
||
和工作流里的节点不是一回事,值得单独选,而不是跟着"第一个启用的 provider"漂。
|
||
"""
|
||
from app.db.models import Setting
|
||
|
||
provider, model = payload.provider, payload.model
|
||
if not provider and not model:
|
||
row = await session.get(Setting, COPILOT_SETTING_KEY)
|
||
saved = row.value if row else {}
|
||
provider = saved.get("provider") or None
|
||
model = saved.get("model") or None
|
||
return ModelSpec(provider=provider, model=model, max_tokens=max_tokens)
|
||
|
||
|
||
@router.get("/model")
|
||
async def get_copilot_model(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
"""当前 Copilot 会用哪个模型,以及是显式配的还是兜底来的。"""
|
||
from app.db.models import Setting
|
||
from app.providers.factory import resolve_provider
|
||
|
||
row = await session.get(Setting, COPILOT_SETTING_KEY)
|
||
saved = row.value if row else {}
|
||
configured = bool(saved.get("provider") or saved.get("model"))
|
||
resolved = await resolve_provider(session, saved.get("provider"), saved.get("model"))
|
||
return {
|
||
"configured": configured,
|
||
"provider": saved.get("provider") or None,
|
||
"model": saved.get("model") or None,
|
||
"effective_provider": resolved.name if resolved else None,
|
||
"effective_model": saved.get("model") or (resolved.default_model if resolved else None),
|
||
}
|
||
|
||
|
||
class CopilotModelIn(BaseModel):
|
||
provider: str | None = None
|
||
model: str | None = None
|
||
|
||
|
||
@router.put("/model")
|
||
async def set_copilot_model(
|
||
payload: CopilotModelIn, session: AsyncSession = Depends(get_session)
|
||
) -> dict[str, Any]:
|
||
"""设定 Copilot 专用模型。两个字段都留空即恢复兜底。"""
|
||
from app.db.models import Setting
|
||
|
||
value = {"provider": payload.provider or "", "model": payload.model or ""}
|
||
row = await session.get(Setting, COPILOT_SETTING_KEY)
|
||
if row:
|
||
row.value = value
|
||
else:
|
||
session.add(Setting(key=COPILOT_SETTING_KEY, value=value))
|
||
await session.commit()
|
||
return await get_copilot_model(session)
|
||
|
||
|
||
class GenerateOut(BaseModel):
|
||
graph: dict[str, Any]
|
||
explanation: str = ""
|
||
issues: list[dict[str, Any]] = Field(default_factory=list)
|
||
|
||
|
||
GRAPH_SCHEMA: dict[str, Any] = {
|
||
"title": "workflow_graph", # langchain 靠 title 识别 JSON Schema dict
|
||
"type": "object",
|
||
"properties": {
|
||
"explanation": {"type": "string", "description": "一两句话说明这张图怎么跑"},
|
||
"nodes": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"id": {"type": "string"},
|
||
"type": {"type": "string", "enum": [t.value for t in NodeType]},
|
||
"label": {"type": "string"},
|
||
"config": {"type": "object"},
|
||
},
|
||
"required": ["id", "type"],
|
||
},
|
||
},
|
||
"edges": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"source": {"type": "string"},
|
||
"target": {"type": "string"},
|
||
"sourceHandle": {"type": "string"},
|
||
},
|
||
"required": ["source", "target"],
|
||
},
|
||
},
|
||
},
|
||
"required": ["nodes", "edges"],
|
||
}
|
||
|
||
|
||
@router.post("/generate", response_model=GenerateOut)
|
||
async def generate(
|
||
payload: GenerateIn, session: AsyncSession = Depends(get_session)
|
||
) -> GenerateOut:
|
||
"""用自然语言生成或改写一张工作流图。
|
||
|
||
产物会经过和手工编排完全相同的校验与排版,所以生成完就是可运行、可读的,
|
||
而不是一段还要人去修的 JSON。
|
||
"""
|
||
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 用简短英文小写下划线,例如 fetch_data、summarize\n"
|
||
"3. 每个节点写清楚 label(中文),让人在画布上一眼看懂它干什么\n"
|
||
"4. 需要把结果传给下游时,用 assign_to 命名变量,下游用 {{ vars.变量名 }} 引用\n"
|
||
"5. 不要凭空发明工具名,只能用上面列出的\n"
|
||
"6. 结构尽量简单:能用 3 个节点解决就别堆 8 个"
|
||
)
|
||
|
||
if payload.base_graph and payload.base_graph.get("nodes"):
|
||
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, await copilot_model_spec(session, payload))
|
||
except ProviderNotConfigured as e:
|
||
raise HTTPException(400, str(e)) from e
|
||
|
||
messages = [("system", system), ("human", user)]
|
||
try:
|
||
raw = await model.with_structured_output(GRAPH_SCHEMA).ainvoke(messages)
|
||
if not isinstance(raw, dict):
|
||
raw = json.loads(message_text(raw))
|
||
except Exception: # noqa: BLE001 - 不支持结构化输出的模型退回文本解析
|
||
try:
|
||
text = message_text(await model.ainvoke(messages))
|
||
raw = _extract_json(text)
|
||
except Exception as e: # noqa: BLE001
|
||
raise HTTPException(502, f"模型没有返回可用的图:{type(e).__name__}: {e}") from e
|
||
|
||
graph = {
|
||
"nodes": [
|
||
{
|
||
"id": n.get("id") or f"node{i}",
|
||
"type": n.get("type") or "llm",
|
||
"position": {"x": 0, "y": 0},
|
||
"data": {"label": n.get("label") or "", "config": n.get("config") or {}},
|
||
}
|
||
for i, n in enumerate(raw.get("nodes") or [])
|
||
],
|
||
"edges": [
|
||
{
|
||
"source": e.get("source", ""),
|
||
"target": e.get("target", ""),
|
||
"sourceHandle": e.get("sourceHandle"),
|
||
}
|
||
for e in (raw.get("edges") or [])
|
||
],
|
||
}
|
||
|
||
try:
|
||
spec = GraphSpec.model_validate(graph)
|
||
except Exception as e: # noqa: BLE001
|
||
raise HTTPException(502, f"生成的图结构非法:{e}") from e
|
||
|
||
spec = auto_layout(spec)
|
||
report = validate_graph(spec)
|
||
return GenerateOut(
|
||
graph=spec.model_dump(mode="json"),
|
||
explanation=str(raw.get("explanation", "")),
|
||
issues=[i.model_dump() for i in report.issues],
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 流式生成:模型输出逐行操作流(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 之后即停——后面就算有闲话也不要了。
|
||
|
||
除了操作,还把模型的思考原样转出去。原先这里只取正文、丢掉其余一切,
|
||
于是从请求发出到第一个操作到达之间(实测 5~30 秒)前端一个字都收不到,
|
||
只能干挂着"正在起草…"——用户没法判断是在想、还是已经卡死了。
|
||
|
||
思考走 thinking_text()(Claude 4.6+ 的 thinking 块),不是"把非 JSON 行
|
||
当成思考":后者会把模型的 markdown 注释、协议跑偏时的乱输出一并当作思考
|
||
展示出来,反而误导。不支持 thinking 的模型就只有心跳,不硬凑。
|
||
"""
|
||
from app.engine.state import message_text as _text
|
||
from app.engine.state import thinking_text
|
||
|
||
buffer = ""
|
||
async for chunk in model.astream(messages):
|
||
reasoning = thinking_text(chunk)
|
||
if reasoning:
|
||
yield {"op": "thinking", "delta": reasoning}
|
||
|
||
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
|
||
|
||
|
||
# 多久没动静就补一拍心跳。3 秒是"用户开始怀疑是不是卡了"的量级。
|
||
_HEARTBEAT_SECONDS = 3.0
|
||
|
||
|
||
async def _with_heartbeat(source: Any):
|
||
"""模型沉默时按拍补心跳,让前端能显示阶段和已用时长。
|
||
|
||
必须在这一层做而不是在 _iter_ops 里判断时间差:模型不吐 chunk 时那个
|
||
async for 的循环体根本不执行,压根轮不到检查。
|
||
"""
|
||
started = time.monotonic()
|
||
phase = "planning"
|
||
iterator = source.__aiter__()
|
||
while True:
|
||
pending = asyncio.ensure_future(iterator.__anext__())
|
||
while True:
|
||
done, _ = await asyncio.wait({pending}, timeout=_HEARTBEAT_SECONDS)
|
||
if pending in done:
|
||
try:
|
||
op = pending.result()
|
||
except StopAsyncIteration:
|
||
return
|
||
kind = op.get("op")
|
||
# 阶段跟着实际操作走,前端据此换文案
|
||
if kind == "add_node":
|
||
phase = "building"
|
||
elif kind in ("add_edge", "connect"):
|
||
phase = "wiring"
|
||
elif kind == "done":
|
||
phase = "finalizing"
|
||
yield op
|
||
break
|
||
yield {
|
||
"op": "heartbeat",
|
||
"phase": phase,
|
||
"elapsed_ms": int((time.monotonic() - started) * 1000),
|
||
}
|
||
|
||
|
||
@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:
|
||
spec_ = await copilot_model_spec(session, payload)
|
||
model, model_id = await get_chat_model(session, spec_)
|
||
except ProviderNotConfigured as e:
|
||
raise HTTPException(400, str(e)) from e
|
||
|
||
messages = [("system", system), ("human", user)]
|
||
|
||
async def event_stream():
|
||
explanation = ""
|
||
yield f"data: {json.dumps({'op': 'model', 'model': model_id}, ensure_ascii=False)}\n\n"
|
||
try:
|
||
async for op in _with_heartbeat(_iter_ops(model, messages)):
|
||
kind = op.get("op")
|
||
# 思考和心跳不是图操作,不进 _apply_op,直接转给前端
|
||
if kind in ("thinking", "heartbeat"):
|
||
yield f"data: {json.dumps(op, ensure_ascii=False)}\n\n"
|
||
continue
|
||
if kind == "done":
|
||
explanation = str(op.get("explanation", ""))
|
||
changed = _apply_op(nodes, edges, op)
|
||
if changed or kind 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 = ""
|
||
|
||
|
||
@router.post("/from-run")
|
||
async def extract_template(
|
||
payload: FromRunIn, session: AsyncSession = Depends(get_session)
|
||
) -> dict[str, Any]:
|
||
"""轨迹 → 模板:把一次探索性运行实际走过的路径提取成草稿工作流。
|
||
|
||
探索层的价值不在单次答案,而在"跑通的路径能沉淀为资产"。
|
||
提取是确定性的:只保留真正执行过的节点和它们之间的边,分支上
|
||
没走的岔路剪掉;输入值参数化成 input 字段默认值。产物落成 draft,
|
||
人审、改名、发布之后才成为正式模板——提取器是模板的作者,不是发布者。
|
||
"""
|
||
from sqlalchemy import select as _sel
|
||
|
||
from app.api.copilot import auto_layout # 自引用仅为显式
|
||
from app.db.models import Run, RunEvent, Workflow, WorkflowVersion
|
||
from app.core.artifact_store import graph_hash
|
||
|
||
run = await session.get(Run, payload.run_id)
|
||
if not run:
|
||
raise HTTPException(404, "运行记录不存在")
|
||
if not run.graph.get("nodes"):
|
||
raise HTTPException(400, "这次运行没有保存图快照,无法提取")
|
||
|
||
events = list(
|
||
(
|
||
await session.execute(
|
||
_sel(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq)
|
||
)
|
||
).scalars()
|
||
)
|
||
executed = {e.node_id for e in events if e.type == "node.started" and e.node_id}
|
||
taken = {
|
||
(e.node_id, str((e.data or {}).get("branch")))
|
||
for e in events
|
||
if e.type == "edge.taken" and e.node_id
|
||
}
|
||
if not executed:
|
||
raise HTTPException(400, "事件流里没有节点执行记录")
|
||
|
||
spec = GraphSpec.model_validate(run.graph)
|
||
kept_nodes = [n for n in spec.nodes if n.id in executed]
|
||
kept_ids = {n.id for n in kept_nodes}
|
||
kept_edges = []
|
||
for edge in spec.edges:
|
||
if edge.source not in kept_ids or edge.target not in kept_ids:
|
||
continue
|
||
# 分支/循环节点:只保留实际走过的出口,没走的岔路不进模板
|
||
source_node = spec.node_map().get(edge.source)
|
||
if source_node and source_node.type in (NodeType.BRANCH,) and edge.sourceHandle:
|
||
if (edge.source, edge.sourceHandle) not in taken:
|
||
continue
|
||
kept_edges.append(edge)
|
||
|
||
# 输入参数化:实际输入值变成字段默认值
|
||
for node in kept_nodes:
|
||
if node.type.value == "input":
|
||
node.data.config["fields"] = [
|
||
{"name": key, "default": value if isinstance(value, (str, int, float)) else json.dumps(value, ensure_ascii=False)}
|
||
for key, value in (run.input or {}).items()
|
||
] or node.data.config.get("fields", [])
|
||
|
||
draft_spec = auto_layout(
|
||
GraphSpec(nodes=kept_nodes, edges=kept_edges, defaults=spec.defaults)
|
||
)
|
||
graph = draft_spec.model_dump(mode="json")
|
||
workflow = Workflow(
|
||
name=payload.name or f"{run.workflow_name or '探索'}·提取模板",
|
||
description=f"从运行 {run.id[:8]} 的实际执行路径提取({len(kept_nodes)} 节点),待人审后发布",
|
||
graph=graph,
|
||
tags=["extracted"],
|
||
status="draft",
|
||
)
|
||
session.add(workflow)
|
||
await session.flush()
|
||
session.add(
|
||
WorkflowVersion(
|
||
workflow_id=workflow.id, version=1, graph=graph,
|
||
graph_hash=graph_hash(graph), note=f"自运行 {run.id} 提取",
|
||
)
|
||
)
|
||
await session.commit()
|
||
return {
|
||
"workflow_id": workflow.id,
|
||
"name": workflow.name,
|
||
"nodes": len(kept_nodes),
|
||
"edges": len(kept_edges),
|
||
"dropped_nodes": len(spec.nodes) - len(kept_nodes),
|
||
"source_run": run.id,
|
||
}
|
||
|
||
|
||
class ExplainIn(BaseModel):
|
||
graph: dict[str, Any]
|
||
provider: str | None = None
|
||
model: str | None = None
|
||
|
||
|
||
@router.post("/explain")
|
||
async def explain(
|
||
payload: ExplainIn, session: AsyncSession = Depends(get_session)
|
||
) -> dict[str, str]:
|
||
"""用大白话讲清楚一张图在干什么。接手别人的编排时很有用。"""
|
||
try:
|
||
model, _ = await get_chat_model(
|
||
session,
|
||
await copilot_model_spec(
|
||
session,
|
||
GenerateIn(instruction="_", provider=payload.provider, model=payload.model),
|
||
max_tokens=2048,
|
||
),
|
||
)
|
||
except ProviderNotConfigured as e:
|
||
raise HTTPException(400, str(e)) from e
|
||
|
||
prompt = (
|
||
"用中文解释下面这个 agent 工作流:它解决什么问题、数据怎么流动、"
|
||
"有哪些分支和风险点。用简洁的分点说明,不要复述 JSON。\n\n"
|
||
f"{json.dumps(_slim(payload.graph), ensure_ascii=False, indent=2)}"
|
||
)
|
||
return {"explanation": message_text(await model.ainvoke(prompt))}
|
||
|
||
|
||
@router.post("/layout")
|
||
async def relayout(payload: ExplainIn) -> dict[str, Any]:
|
||
"""一键整理画布。"""
|
||
spec = GraphSpec.model_validate(payload.graph)
|
||
return auto_layout(spec).model_dump(mode="json")
|
||
|
||
|
||
def _slim(graph: dict[str, Any]) -> dict[str, Any]:
|
||
"""喂给模型时去掉坐标之类的噪音,省 token 也省得它被干扰。"""
|
||
return {
|
||
"nodes": [
|
||
{
|
||
"id": n.get("id"),
|
||
"type": n.get("type"),
|
||
"label": (n.get("data") or {}).get("label"),
|
||
"config": (n.get("data") or {}).get("config"),
|
||
}
|
||
for n in graph.get("nodes") or []
|
||
],
|
||
"edges": [
|
||
{k: v for k, v in e.items() if k in ("source", "target", "sourceHandle") and v}
|
||
for e in graph.get("edges") or []
|
||
],
|
||
}
|
||
|
||
|
||
def _extract_json(text: str) -> dict[str, Any]:
|
||
text = text.strip()
|
||
if text.startswith("```"):
|
||
lines = text.splitlines()
|
||
text = "\n".join(lines[1 : -1 if lines[-1].strip().startswith("```") else None])
|
||
start = text.find("{")
|
||
for end in range(len(text), start, -1):
|
||
try:
|
||
return json.loads(text[start:end])
|
||
except json.JSONDecodeError:
|
||
continue
|
||
raise ValueError("模型输出里找不到 JSON")
|