Files
AgentLab/backend/app/engine/nodes/control.py
T
ThearchyHelios 1b595f699c 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 个新节点全部高亮,零页面错误。
2026-09-14 10:54:31 +08:00

185 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
from typing import Any
from app.core.events import EventType
from app.db.base import SessionLocal
from app.engine.context import NodeContext, NodeError
from app.engine.expressions import ExpressionError, eval_condition
from app.engine.state import GraphState, message_text, template_context
from app.providers.factory import ModelSpec, get_chat_model
# 分支和循环的"决定"写在 nodes[node_id] 里,编译器的路由函数再读出来。
# 路由函数本身不能有副作用,所以判断必须发生在节点执行阶段。
DECISION_KEY = "__decision__"
async def run_branch(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
"""条件分支。两种模式:表达式判断,或让模型做语义分类。"""
cases = ctx.cfg("cases", []) or []
if not cases:
raise NodeError(ctx.node.id, "分支节点没有配置任何条件")
mode = ctx.cfg("mode", "expression")
tctx = template_context(state)
chosen = "default"
reason = ""
if mode == "llm":
chosen, reason = await _classify_with_llm(state, ctx, cases)
else:
for case in cases:
expr = case.get("condition", "")
key = case.get("key") or case.get("label") or "case"
if not expr:
continue
try:
if eval_condition(expr, tctx):
chosen, reason = key, f"命中条件:{expr}"
break
except ExpressionError as e:
raise NodeError(ctx.node.id, f"分支条件写错了({expr}):{e}") from e
else:
reason = "所有条件都不满足,走 default"
ctx.emit(EventType.EDGE_TAKEN, branch=chosen, reason=reason, mode=mode)
return {
"nodes": {ctx.node.id: {DECISION_KEY: chosen, "branch": chosen, "reason": reason}},
}
async def _classify_with_llm(
state: GraphState, ctx: NodeContext, cases: list[dict[str, Any]]
) -> tuple[str, str]:
"""让模型在给定选项里挑一个。用结构化输出保证它只会吐出合法的 key。"""
options = [
{"key": c.get("key") or f"case{i}", "描述": c.get("label") or c.get("condition") or ""}
for i, c in enumerate(cases)
]
keys = [o["key"] for o in options] + ["default"]
subject = ctx.render_str(ctx.cfg("input", "{{ last_message }}"), state)
instruction = ctx.render_str(ctx.cfg("instruction", ""), state)
prompt = (
f"{instruction}\n\n" if instruction else ""
) + (
"请把下面的内容归入最合适的一类。\n\n"
f"可选类别:\n{json.dumps(options, ensure_ascii=False, indent=2)}\n\n"
f"内容:\n{subject}\n\n"
"只返回 JSON:{\"key\": <类别key>, \"reason\": <一句话理由>}"
)
async with SessionLocal() as session:
model, _ = await get_chat_model(
session,
ModelSpec(
provider=ctx.cfg("provider"),
model=ctx.cfg("model"),
max_tokens=512,
),
)
schema = {
"title": "classify", # langchain 靠 title 识别 JSON Schema dict
"type": "object",
"properties": {
"key": {"type": "string", "enum": keys},
"reason": {"type": "string"},
},
"required": ["key"],
}
try:
value = await model.with_structured_output(schema).ainvoke(prompt)
if not isinstance(value, dict):
value = json.loads(message_text(value))
except Exception: # noqa: BLE001 - 结构化不支持就退回自由文本再解析
raw = message_text(await model.ainvoke(prompt))
value = {}
for key in keys:
if key in raw:
value = {"key": key, "reason": raw[:200]}
break
chosen = str(value.get("key") or "default")
if chosen not in keys:
chosen = "default"
return chosen, str(value.get("reason", ""))
async def run_loop(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
"""循环控制。
画布上的接法:loop 节点的 `body` 出口连向循环体,循环体最后一个节点再连回 loop 节点;
`done` 出口连向循环结束后要走的分支。每进一次这个节点就推进一次计数。
"""
mode = ctx.cfg("mode", "foreach")
node_id = ctx.node.id
loops = dict(state.get("loops") or {})
cursor = int((loops.get(node_id) or {}).get("index", 0)) if isinstance(loops.get(node_id), dict) else 0
max_iter = int(ctx.cfg("max_iterations", 10) or 10)
tctx = template_context(state)
item_var = ctx.cfg("item_var", "item")
updates_vars: dict[str, Any] = {}
keep_going = False
current_item: Any = None
total = None
if mode == "foreach":
items = ctx.render(ctx.cfg("items", ""), state)
if isinstance(items, str):
try:
items = json.loads(items)
except json.JSONDecodeError:
items = [line for line in items.splitlines() if line.strip()]
if not isinstance(items, list):
items = [items] if items not in (None, "") else []
total = len(items)
keep_going = cursor < len(items) and cursor < max_iter
if keep_going:
current_item = items[cursor]
updates_vars = {item_var: current_item, f"{item_var}_index": cursor}
else: # while
condition = ctx.cfg("condition", "")
if cursor >= max_iter:
keep_going = False
elif not condition:
keep_going = False
else:
try:
keep_going = eval_condition(condition, tctx)
except ExpressionError as e:
raise NodeError(ctx.node.id, f"循环条件写错了:{e}") from e
hit_limit = cursor >= max_iter
if hit_limit:
ctx.emit(
EventType.LOG, level="warn",
message=f"循环达到上限 {max_iter} 次,强制退出",
)
decision = "body" if keep_going else "done"
ctx.emit(
EventType.EDGE_TAKEN,
branch=decision,
iteration=cursor,
total=total,
mode=mode,
)
result = {
DECISION_KEY: decision,
"iteration": cursor,
"total": total,
"item": current_item,
"finished": not keep_going,
}
updates: dict[str, Any] = {
"nodes": {node_id: result},
"loops": {node_id: {"index": cursor + 1 if keep_going else cursor}},
}
if updates_vars:
updates["vars"] = updates_vars
return updates