Files
AgentLab/backend/app/engine/nodes/human.py
T
ThearchyHelios e10c4f23fc 界面与交互全面翻修(前两波):运行态有了 HUD 和时间轴,终态会收尾,全站令牌、组件、文案统一
来自 2026-09 的 UI/UX 审查(142 条)。本提交是前两波,第三波(记录页接入时间轴、跨页互链、check-ui 更新、全站扫尾)另行提交。

地基
- 设计令牌:全局规则和 .btn/.field/.chip 放进 CSS 层,Tailwind 工具类重新生效,选中态、开启态不再被吞掉;字号阶梯;
  辅助文字在所有底色上 >= 4.5:1;状态色专用,节点类型色与之拉开色相;「跟随系统」真的跟随,首帧不闪深色;新字标与 favicon
- 基础组件:confirmDialog / promptDialog 取代原生对话框;toast 出错不自动消失、可复制、读屏可播报;离线横幅与断线恢复;
  九种剪影的状态徽标;唯一一份状态文案(lib/status)、格式化(lib/format)、术语(lib/terms)、报错人话(lib/errors)
- 运行态内核:事件折成航迹(run/trace.ts),可投影到任意时刻;取消 / 失败 / 服务重启后统一收尾,不再永远转圈;
  run.status 随事件回写;token 事件不再堆进 events
- 后端:协作成员逐个完成即发事件,调度者决策有事件;时长分执行 / 墙钟 / 等人;API 时间带时区;
  中断的运行也发 stream.end;运行列表可筛选翻页;删除运行 / 工作流有保护;运行默认值接通;可测未保存的配置;
  报错说清发生了什么、为什么、怎么办

功能
- 画布:状态四重编码、固定高度遥测槽、按缩放三档、运行胶囊与 HUD、航迹时间轴与回放、执行轨迹边、
  等待闸门、跟随执行、正式运行只读、一次性时刻(开场点亮、回扫、阻断级联、出具印章)
- 编辑:撤销重做、版本历史、问题面板可定位、三栏可收起、模板与表达式字段区分、变量血缘、快捷键、带搜索的选择器
- 回答流 / 问数据:贴底才跟随滚动、工件下钻、出具横幅列出缺口、答案可复制导出、按会话的忙闲、
  答案不再在 2000 字处截断、重启后这一轮会收尾
- 记录页:待审批 / 运行中 / 失败页签、筛选翻页、实时接流、排错路径指到节点、封存凭证常驻
- 管理页:新的「数据」页、写操作失败都有反馈、弹窗里先测连接、统一持久的连接状态
- 外壳:导航改版、⌘K 命令面板、? 快捷键帮助、404、页面标题与 favicon 状态点、后台提醒

验证(沙箱):tsc 干净,pytest 579 passed,14 个检查脚本中 13 个全绿;check-ui 剩 1 项旧断言(按旧日期格式找运行行),第三波更新。
2026-09-26 23:11:31 +08:00

168 lines
7.0 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 langgraph.types import interrupt
from app.core.events import EventType
from app.db.base import SessionLocal
from app.engine.approval import read_decision
from app.engine.context import NodeContext, NodeError
from app.engine.state import GraphState, message_text, template_context
from app.providers.factory import ModelSpec, get_chat_model
async def run_human(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
"""人工介入节点。
执行到这里会挂起整张图并落盘 checkpoint;进程重启也不影响,
人在前端回复之后从断点继续,而不是从头重跑。
"""
mode = ctx.cfg("mode", "approve") # approve | input | edit
payload = {
"kind": "human_node",
"node_id": ctx.node.id,
"mode": mode,
"title": ctx.render_str(ctx.cfg("title", "需要你确认"), state),
"message": ctx.render_str(ctx.cfg("message", ""), state),
"schema": ctx.cfg("schema") or {},
"context": ctx.render(ctx.cfg("context", {}) or {}, state),
}
if mode == "edit":
payload["draft"] = ctx.render(ctx.cfg("draft", ""), state)
ctx.emit(EventType.HUMAN_REQUESTED, **payload)
response = interrupt(payload)
# 谁批的跟着事件走:时间线要写得出"张工驳回了",没署名就是 None,不再默认写"你"
ctx.emit(EventType.HUMAN_RESOLVED, response=response, actor=ctx.actor())
decision = read_decision(response)
if mode == "approve":
approved = decision.approved
result: dict[str, Any] = {
"approved": approved,
"note": decision.note,
"__decision__": "approved" if approved else "rejected",
}
if not approved and ctx.cfg("stop_on_reject", False):
raise NodeError(ctx.node.id, f"人工拒绝:{result['note'] or '未说明原因'}")
else: # edit / input
# 驳回在这两种模式下以前是个摆设:__decision__ 硬编码成 approved,
# approved 字段看都不看。界面上按钮在、toast 说"已驳回",运行却照常
# 往下走——比没有这个按钮更糟。
#
# 这两种模式在图上只有一个 out 出口(没有 rejected 那条边),所以驳回
# 的正确语义是终止运行,而不是当作通过继续。缺省仍是通过:只传 value
# 不带 approved 的老调用方行为不变。
#
# 只有结构体里明说了"不"才算驳回:这两种模式下裸字符串就是内容本身,
# 用户在输入框里回一句"no"是在交稿,不是在驳回。
if isinstance(response, dict) and decision.rejected:
raise NodeError(ctx.node.id, f"人工驳回:{decision.note or '未说明原因'}")
note = decision.note if isinstance(response, dict) else "" # 裸字符串是内容,不是备注
result = {"value": decision.value, "note": note, "__decision__": "approved"}
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}}
var_name = ctx.cfg("assign_to", "")
if var_name:
updates["vars"] = {var_name: result.get("value", result.get("approved"))}
return updates
# --------------------------------------------------------------------------
# 结构化校验
# --------------------------------------------------------------------------
async def run_validate(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
"""按 JSON Schema 校验上游产出。
校验失败时可以把错误原样喂回模型让它改 —— 这比在 prompt 里祈祷它输出正确格式可靠得多。
"""
import jsonschema
schema = ctx.cfg("schema")
if not schema:
raise NodeError(ctx.node.id, "校验节点需要一个 JSON Schema")
source_cfg = ctx.cfg("source", "{{ last_message }}")
raw = ctx.render(source_cfg, state) if not isinstance(source_cfg, str) else ctx.render_str(source_cfg, state)
max_retries = int(ctx.cfg("max_retries", 2) or 0)
repair = bool(ctx.cfg("repair_with_llm", True))
attempt = 0
errors: list[str] = []
value: Any = raw
while True:
value, parse_error = _coerce_json(raw)
if parse_error:
errors = [parse_error]
else:
try:
jsonschema.validate(value, schema)
ctx.emit(EventType.LOG, level="info", message=f"校验通过(第 {attempt + 1} 次尝试)")
result = {"valid": True, "data": value, "attempts": attempt + 1, "errors": []}
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}}
var_name = ctx.cfg("assign_to", "")
if var_name:
updates["vars"] = {var_name: value}
return updates
except jsonschema.ValidationError as e:
path = "/".join(str(p) for p in e.absolute_path) or "(根)"
errors = [f"{path}: {e.message}"]
ctx.emit(EventType.LOG, level="warn", message=f"第 {attempt + 1} 次校验失败:{errors[0]}",
code="validate_retry")
attempt += 1
if attempt > max_retries or not repair:
break
raw = await _repair(raw, schema, errors, ctx, state)
result = {"valid": False, "data": value, "attempts": attempt, "errors": errors}
if ctx.cfg("fail_fast", True):
raise NodeError(ctx.node.id, f"结构化校验未通过:{'; '.join(errors)}")
return {"nodes": {ctx.node.id: result}}
def _coerce_json(raw: Any) -> tuple[Any, str | None]:
"""尽量把模型输出解析成 JSON:剥掉 ``` 围栏,再退一步找第一个完整对象。"""
if isinstance(raw, (dict, list)):
return raw, None
text = str(raw or "").strip()
if text.startswith("```"):
lines = text.splitlines()
text = "\n".join(lines[1 : -1 if lines[-1].strip().startswith("```") else None]).strip()
try:
return json.loads(text), None
except json.JSONDecodeError:
pass
start = min(
(i for i in (text.find("{"), text.find("[")) if i >= 0), default=-1
)
if start >= 0:
for end in range(len(text), start, -1):
try:
return json.loads(text[start:end]), None
except json.JSONDecodeError:
continue
return text, "输出不是合法 JSON"
async def _repair(raw: Any, schema: dict[str, Any], errors: list[str], ctx: NodeContext,
state: GraphState) -> str:
async with SessionLocal() as session:
model, _ = await get_chat_model(
session,
ModelSpec(provider=ctx.cfg("provider"), model=ctx.cfg("model"), max_tokens=4096),
)
prompt = (
"下面这段输出没有通过 JSON Schema 校验,请修正后重新输出。\n\n"
f"Schema:\n{json.dumps(schema, ensure_ascii=False, indent=2)}\n\n"
f"错误:\n- " + "\n- ".join(errors) + "\n\n"
f"原始输出:\n{str(raw)[:6000]}\n\n"
"只输出修正后的 JSON,不要任何解释或代码围栏。"
)
return message_text(await model.ainvoke(prompt))