mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 19:01:58 +00:00
e10c4f23fc
来自 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 项旧断言(按旧日期格式找运行行),第三波更新。
194 lines
7.5 KiB
Python
194 lines
7.5 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from app.core.events import EventType
|
|
from app.engine.context import NodeContext, NodeError
|
|
from app.engine.expressions import ExpressionError, eval_expression
|
|
from app.engine.state import GraphState, template_context
|
|
|
|
|
|
async def run_input(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
|
"""入口节点。把运行输入按声明的字段整理进变量池,补上默认值。"""
|
|
raw = dict(state.get("input") or {})
|
|
fields = ctx.cfg("fields", []) or []
|
|
resolved: dict[str, Any] = dict(raw)
|
|
|
|
for field in fields:
|
|
name = field.get("name")
|
|
if not name:
|
|
continue
|
|
if name not in resolved or resolved[name] in (None, ""):
|
|
if field.get("required") and field.get("default") in (None, ""):
|
|
raise NodeError(ctx.node.id, f"缺少必填输入:{name}")
|
|
resolved[name] = field.get("default")
|
|
|
|
return {
|
|
"input": resolved,
|
|
"vars": resolved,
|
|
"nodes": {ctx.node.id: resolved},
|
|
}
|
|
|
|
|
|
async def run_output(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
|
"""出口节点。按配置把散落在各节点的结果收成一个结构化成果。
|
|
|
|
配了 contract 就升级成出具契约:核对指标齐不齐、叙述里的数字
|
|
能不能回指到指标集,然后给出三档判定(formal / degraded / withheld)。
|
|
判定和缺口清单一起印在成果上——降档不是悄悄地降。
|
|
"""
|
|
mapping = ctx.cfg("fields", None)
|
|
tctx = template_context(state)
|
|
|
|
if mapping:
|
|
result: dict[str, Any] = {}
|
|
for field in mapping:
|
|
name = field.get("name")
|
|
if not name:
|
|
continue
|
|
result[name] = ctx.render(field.get("value", ""), state)
|
|
else:
|
|
# 没配映射就把最后一条消息当作结果,让新建的图开箱即用
|
|
result = {"result": tctx.get("last_message", "")}
|
|
|
|
contract = ctx.cfg("contract")
|
|
if contract:
|
|
result["_issuance"] = _apply_contract(contract, state, ctx)
|
|
|
|
return {"output": result, "nodes": {ctx.node.id: result}}
|
|
|
|
|
|
def _apply_contract(
|
|
contract: dict[str, Any], state: GraphState, ctx: NodeContext
|
|
) -> dict[str, Any]:
|
|
from datetime import datetime, timezone
|
|
|
|
from app.engine.issuance import decide_tier, trace_numbers
|
|
|
|
nodes = state.get("nodes") or {}
|
|
|
|
# 1) 收指标集:contract.metrics_from 指向一个或多个口径卡节点
|
|
metrics: list[dict[str, Any]] = []
|
|
calibers: list[dict[str, str]] = []
|
|
sources = contract.get("metrics_from") or []
|
|
if isinstance(sources, str):
|
|
sources = [sources]
|
|
# gaps 记的是"校验本身哪里没跑起来",和"校验跑了但没通过"是两回事。
|
|
# 没有它,一个写错的节点名会让所有列表都是空的,看起来和"全部通过"一模一样。
|
|
gaps: list[str] = []
|
|
unresolved: list[str] = []
|
|
for source in sources:
|
|
payload = nodes.get(source)
|
|
if isinstance(payload, dict) and payload.get("kind") == "metric_set":
|
|
metrics.extend(payload.get("metrics") or [])
|
|
calibers.append(
|
|
{
|
|
"node": source,
|
|
"caliber": payload.get("caliber", ""),
|
|
"version": payload.get("caliber_version", ""),
|
|
}
|
|
)
|
|
else:
|
|
# 节点不存在、还没跑到、或者根本不是口径卡——指标集就是缺的,
|
|
# 不能当作"这次没有指标要查"
|
|
unresolved.append(source)
|
|
if unresolved:
|
|
gaps.append(f"指标源未解析:{', '.join(unresolved[:5])}")
|
|
|
|
present = {m["id"] for m in metrics}
|
|
missing_required = [m for m in (contract.get("required") or []) if m not in present]
|
|
missing_expected = [m for m in (contract.get("expected") or []) if m not in present]
|
|
|
|
# 2) 叙述数字回指
|
|
declared_narrative = str(contract.get("narrative", ""))
|
|
narrative = ctx.render_str(declared_narrative, state)
|
|
# 模板取不到路径时 render 出来是空串,所以"声明了叙述但渲染成空"必须单独识别:
|
|
# 节点改名、vars 写错都会让回指校验静默变成空操作
|
|
if declared_narrative.strip() and not narrative.strip():
|
|
gaps.append("叙述模板渲染为空(路径可能写错了)")
|
|
elif not declared_narrative.strip():
|
|
gaps.append("契约没有声明叙述,数字回指未执行")
|
|
|
|
trace = (
|
|
trace_numbers(narrative, metrics, allow=contract.get("allow_numbers"))
|
|
if narrative.strip()
|
|
else None
|
|
)
|
|
unmatched = trace.unmatched if trace else []
|
|
|
|
# 声明了 metrics_from 却一个指标都没收到,同样是"没查成"
|
|
if sources and not metrics:
|
|
gaps.append("指标集为空,叙述里的数字无从回指")
|
|
|
|
tier = decide_tier(
|
|
missing_required=missing_required,
|
|
missing_expected=missing_expected,
|
|
unmatched=unmatched,
|
|
strict=bool(contract.get("strict")),
|
|
gaps=gaps,
|
|
)
|
|
|
|
issuance = {
|
|
"tier": tier,
|
|
"calibers": calibers, # 口径版本照常印
|
|
"metrics_checked": len(metrics),
|
|
"missing_required": missing_required,
|
|
"missing_expected": missing_expected, # 缺数据声明照常印
|
|
"unmatched_numbers": unmatched[:20],
|
|
"matched_numbers": len(trace.matched) if trace else 0,
|
|
# 校验没跑全的原因照实印出来——读的人要能分辨"查过都对"和"根本没查"
|
|
"gaps": gaps,
|
|
"declared_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
ctx.emit(
|
|
EventType.ISSUANCE,
|
|
tier=tier,
|
|
missing_required=missing_required,
|
|
missing_expected=missing_expected,
|
|
unmatched=len(unmatched),
|
|
calibers=calibers,
|
|
# 降档的原因要跟着事件走:只有 gaps 的时候,时间线上的出具步骤以前只能写
|
|
# 一个光秃秃的「降档出具」,横幅还说「请对照下方声明」,下方却什么都没有
|
|
gaps=gaps,
|
|
metrics_checked=len(metrics),
|
|
matched_numbers=len(trace.matched) if trace else 0,
|
|
)
|
|
return issuance
|
|
|
|
|
|
async def run_transform(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
|
"""数据整形。用受限表达式把上游输出揉成下游要的形状,不用为此专门起一个 LLM。"""
|
|
tctx = template_context(state)
|
|
mode = ctx.cfg("mode", "expression")
|
|
output: Any
|
|
|
|
if mode == "expression":
|
|
expr = ctx.cfg("expression", "")
|
|
if not expr:
|
|
raise NodeError(ctx.node.id, "整形节点没有填表达式")
|
|
try:
|
|
output = eval_expression(expr, tctx)
|
|
except ExpressionError as e:
|
|
raise NodeError(ctx.node.id, f"表达式错误:{e}") from e
|
|
elif mode == "template":
|
|
output = ctx.render_str(ctx.cfg("template", ""), state)
|
|
elif mode == "json":
|
|
rendered = ctx.render_str(ctx.cfg("template", "{}"), state)
|
|
try:
|
|
output = json.loads(rendered)
|
|
except json.JSONDecodeError as e:
|
|
raise NodeError(
|
|
ctx.node.id,
|
|
f"模板渲染出来的不是合法 JSON(第 {e.lineno} 行第 {e.colno} 列附近)。"
|
|
"检查模板里的引号、逗号,字符串值要用 | json 过滤器输出",
|
|
) from e
|
|
else:
|
|
raise NodeError(ctx.node.id, f"未知的整形模式:{mode}")
|
|
|
|
updates: dict[str, Any] = {"nodes": {ctx.node.id: output}}
|
|
var_name = ctx.cfg("assign_to", "")
|
|
if var_name:
|
|
updates["vars"] = {var_name: output}
|
|
return updates
|