Files
AgentLab/backend/app/api/errors.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

140 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.
"""面向用户的报错:说清发生了什么、为什么、怎么办,不把 Python 异常类名端上去。
以前各处都是 f"{type(e).__name__}: {e}",界面首行红字是
「_make_query_tool.<locals>._run() got an unexpected keyword argument 'query'」
——对业务用户是天书,也没有下一步。原始异常不丢:调用方把 raw(e) 放进响应的
detail 字段或写进日志,排查时照样找得到。
判断按类名走 MRO,不 import 各家 SDK:没装的驱动和 SDK 不该让报错这一层
也跟着 ImportError。
"""
from __future__ import annotations
import re
from typing import Any
_TIMEOUT = {"TimeoutError", "TimeoutException", "APITimeoutError", "ReadTimeout",
"ConnectTimeout", "PoolTimeout", "WriteTimeout"}
_NETWORK = {"ConnectError", "ConnectionError", "APIConnectionError", "gaierror",
"ClientConnectorError", "RemoteProtocolError"}
_AUTH = {"AuthenticationError", "PermissionDeniedError", "InvalidPasswordError",
"InvalidAuthorizationSpecificationError"}
#: 这几类是程序自己的毛病,str(e) 里全是内部符号,原样端上去只会更糊涂
_BUG = {"TypeError", "AttributeError", "KeyError", "NameError", "IndexError",
"UnboundLocalError", "AssertionError", "RecursionError"}
#: SQLAlchemy 包过的驱动异常开头是「(sqlite3.OperationalError) 」
_WRAPPED = re.compile(r"^\([\w.]+\)\s*")
def raw(e: BaseException) -> str:
"""原始报错,给「技术细节」折叠区和日志用。"""
return f"{type(e).__name__}: {e}"
def _status(e: BaseException) -> int | None:
for obj in (e, getattr(e, "response", None)):
code = getattr(obj, "status_code", None)
if isinstance(code, int):
return code
return None
def first_line(e: BaseException) -> str:
"""异常自己的说明,去掉驱动包装的类名前缀和 SQLAlchemy 的文档链接。"""
text = str(e).strip()
line = text.splitlines()[0] if text else ""
return _WRAPPED.sub("", line)[:300]
def explain(e: BaseException) -> tuple[str, str]:
"""(原因, 怎么办)。原因是一句人话;说不出怎么办时第二项是空串。"""
names = {c.__name__ for c in type(e).__mro__}
low = str(e).lower()
status = _status(e)
if status in (401, 403) or names & _AUTH or "invalid api key" in low or "unauthorized" in low:
return "对方拒绝了这把密钥", "核对 API Key 有没有填错、过期,或者这个账号有没有权限"
if status == 404 or "NotFoundError" in names:
return "对方说找不到", "核对地址的路径(常见的是少了或多了 /v1)和模型名"
if status == 429 or "RateLimitError" in names:
return "请求被对方限流了", "等一会儿再试;频繁出现就检查账号额度"
if isinstance(status, int) and status >= 500:
return f"对方服务出错了(HTTP {status})", "这是对方的问题,稍后再试"
if names & _TIMEOUT or "timed out" in low:
return "等了太久没有响应", "对方可能很忙或者网络不稳,稍后再试;一直超时就检查地址和网络"
if names & _NETWORK or any(s in low for s in (
"connection refused", "nodename nor servname", "name or service not known",
"all connection attempts failed", "network is unreachable",
)):
return "连不上对方的服务", "核对地址和端口,确认服务已经启动、这台机器访问得到它"
if "JSONDecodeError" in names:
return "对方返回的不是 JSON", "地址多半指到了网页而不是 API,核对 Base URL"
if "ValidationError" in names and callable(getattr(e, "errors", None)):
return f"参数不对:{_first_validation(e)}", ""
if names & _BUG:
return "后端内部出错了", "这不是你操作的问题;把技术细节发给维护者"
return first_line(e) or "出了一个没有说明的错误", ""
def message(e: BaseException, lead: str = "") -> str:
"""一整句:lead + 原因 + 怎么办。给只能放一个字符串的地方(HTTPException 的 detail)。"""
reason, hint = explain(e)
text = f"{lead}{reason}" if lead else reason
return f"{text}。{hint}" if hint else text
def payload(e: BaseException, lead: str = "") -> dict[str, Any]:
"""{ok: false, error, hint, detail}:测试连接这类「失败也是正常结果」的接口用。"""
reason, hint = explain(e)
return {"ok": False, "error": f"{lead}{reason}", "hint": hint, "detail": raw(e)}
def not_configured(e: BaseException) -> str:
"""模型接入没配好时的原话用的是内部叫法(provider 'x'、base_url),换成设置页上的说法。"""
text = re.sub(r"provider '([^']*)'", r"模型接入「\1」", str(e))
text = re.sub(r"模型 '([^']*)'", r"模型「\1」", text)
return text.replace("base_url", "Base URL").rstrip("。")
def _has_cjk(text: str) -> bool:
return any("一" <= ch <= "鿿" for ch in text)
def _first_validation(e: Any) -> str:
err = (e.errors() or [{}])[0]
loc = ".".join(str(p) for p in err.get("loc") or ())
msg = str(err.get("msg") or "").removeprefix("Value error, ")
if _has_cjk(msg):
return msg
return f"「{loc}」{'缺了' if err.get('type') == 'missing' else '格式不对'}" if loc else "格式不对"
def graph_error(e: BaseException) -> str:
"""工作流结构读不懂时,指到是哪个节点、哪一项。
pydantic 的原文是一大段英文,还列了全部十几种节点类型和一个文档链接,
用户从里面找不到自己错在哪。
"""
errors = e.errors() if callable(getattr(e, "errors", None)) else None
if not errors:
return explain(e)[0]
err = errors[0]
loc = list(err.get("loc") or ())
msg = str(err.get("msg") or "").removeprefix("Value error, ")
more = f"(另有 {len(errors) - 1} 处)" if len(errors) > 1 else ""
if len(loc) >= 2 and loc[0] in ("nodes", "edges") and isinstance(loc[1], int):
where = f"第 {loc[1] + 1} 个{'节点' if loc[0] == 'nodes' else '连线'}"
field = str(loc[2]) if len(loc) > 2 else ""
if err.get("type") == "enum" and field == "type":
return f"{where}的类型「{err.get('input')}」不认识{more}"
if err.get("type") == "missing":
return f"{where}缺了「{field}」{more}"
if _has_cjk(msg):
return f"{where}:{msg}{more}"
return f"{where}的「{field or '内容'}」格式不对{more}"
if _has_cjk(msg):
return msg + more
return f"「{'.'.join(str(p) for p in loc) or '整体'}」格式不对{more}"