diff --git a/backend/app/api/artifacts.py b/backend/app/api/artifacts.py index d9862f8a..4b257fd3 100644 --- a/backend/app/api/artifacts.py +++ b/backend/app/api/artifacts.py @@ -20,5 +20,5 @@ async def get_artifact(artifact_id: str) -> dict[str, Any]: # 哈希对不上:明确报给调用方,这正是工件库存在的意义 raise HTTPException(409, str(e)) from e if content is None: - raise HTTPException(404, "工件不存在") + raise HTTPException(404, "这个工件不存在,可能已经被删了") return {"id": artifact_id, "content": content} diff --git a/backend/app/api/conversations.py b/backend/app/api/conversations.py index 96b078da..4e1b54cd 100644 --- a/backend/app/api/conversations.py +++ b/backend/app/api/conversations.py @@ -130,7 +130,7 @@ def _detail(row: Conversation, turns: list[ConversationTurn]) -> ConversationDet async def _get(session: AsyncSession, conversation_id: str) -> Conversation: row = await session.get(Conversation, conversation_id) if not row: - raise HTTPException(404, f"会话 {conversation_id} 不存在") + raise HTTPException(404, "这个对话不存在,可能已经被删了") return row @@ -287,7 +287,7 @@ async def patch_turn( ) -> TurnOut: turn = await session.get(ConversationTurn, turn_id) if not turn or turn.conversation_id != conversation_id: - raise HTTPException(404, f"轮次 {turn_id} 不存在") + raise HTTPException(404, "这一轮对话不存在,可能已经被删了") for field in ( "question", "answer", "explanation", "graph", "run_id", "status", "error", "review", diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index d516a918..87540df1 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -10,9 +10,11 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +# 起别名:本文件底下有个叫 explain 的接口,generate 里又有个局部变量叫 raw,同名会互相盖掉 +from app.api.errors import explain as explain_error, graph_error, not_configured, raw as raw_error from app.db.base import get_session from app.db.models import Workflow -from app.engine.layout import auto_layout +from app.engine.layout import CORRIDOR_MIN, MIN_ROW_GAP, NODE_W, _height, auto_layout 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 @@ -384,10 +386,113 @@ async def set_copilot_model( return await get_copilot_model(session) +def _unconfigured(e: ProviderNotConfigured) -> str: + return f"助手用的模型还没配好:{not_configured(e)}。到「设置 → 模型接入」检查一下" + + class GenerateOut(BaseModel): graph: dict[str, Any] explanation: str = "" issues: list[dict[str, Any]] = Field(default_factory=list) + layout: dict[str, Any] = Field(default_factory=dict) + + +# -------------------------------------------------------------------------- +# 改图时的排版:用户摆好的位置不动,只给新节点找空处 +# -------------------------------------------------------------------------- + +#: 新节点和旧节点之间至少留的空。比排版的走廊窄一点:这里只求不压住、线有地方走, +#: 不求和整体排版一样舒展——那是「自动排版」按钮的事 +_KEEP_GAP_X = 40.0 +_KEEP_GAP_Y = 24.0 +#: 同一列里上下各找几格,再找不到就往右挪一列 +_KEEP_ROWS = 8 +_KEEP_COLS = 6 + + +def _pinned_positions(base_graph: dict[str, Any] | None) -> dict[str, tuple[float, float]]: + """base_graph 里用户摆好的坐标。 + + 全堆在同一点的(接口直接传进来、从没排过版的图)不算摆过,照常整体排版—— + 钉住它们只会让一叠节点永远叠着。 + """ + pos: dict[str, tuple[float, float]] = {} + for n in (base_graph or {}).get("nodes") or []: + p = n.get("position") or {} + x, y = p.get("x"), p.get("y") + if n.get("id") and isinstance(x, (int, float)) and isinstance(y, (int, float)): + pos[n["id"]] = (float(x), float(y)) + if len(pos) > 1 and len(set(pos.values())) == 1: + return {} + return pos + + +def _layout_keeping( + spec: GraphSpec, pinned: dict[str, tuple[float, float]], +) -> tuple[GraphSpec, dict[str, Any]]: + """已有节点留在原处,新节点按上下游插到最近的空位。 + + 以前改图无条件 auto_layout 整张图:只改了一句提示词,所有节点也被挪到排版 + 位置。人工整理过的布局常带着业务含义(上游取数、口径计算、出具各占一块), + 被打乱之后用户就不敢再让助手改图了。新建(没有旧坐标)照旧整体排版。 + """ + kept = [n for n in spec.nodes if n.id in pinned] + if not kept: + auto_layout(spec) + return spec, {"mode": "full", "placed": [n.id for n in spec.nodes]} + + # 先整体排一遍,只借它的先后次序:上游的新节点先落位,下游才有东西可挨着 + auto_layout(spec) + ideal = {n.id: (n.position.x, n.position.y) for n in spec.nodes} + fresh = sorted((n for n in spec.nodes if n.id not in pinned), key=lambda n: ideal[n.id]) + + placed: dict[str, tuple[float, float, float, float]] = {} + for n in kept: + n.position.x, n.position.y = pinned[n.id] + placed[n.id] = (n.position.x, n.position.y, NODE_W, _height(n)) + + def free(x: float, y: float, h: float) -> bool: + return all( + not (x < bx + bw + _KEEP_GAP_X and bx < x + NODE_W + _KEEP_GAP_X + and y < by + bh + _KEEP_GAP_Y and by < y + h + _KEEP_GAP_Y) + for bx, by, bw, bh in placed.values() + ) + + step_x = NODE_W + CORRIDOR_MIN + for node in fresh: + h = _height(node) + preds = [placed[e.source] for e in spec.edges if e.target == node.id and e.source in placed] + succs = [placed[e.target] for e in spec.edges if e.source == node.id and e.target in placed] + if preds: + x0 = max(b[0] for b in preds) + step_x + y0 = sum(b[1] for b in preds) / len(preds) + elif succs: + x0 = min(b[0] for b in succs) - step_x + y0 = sum(b[1] for b in succs) / len(succs) + else: + # 和谁都不连:放到整张图右边,不插进别人的分区里 + x0 = max(b[0] + b[2] for b in placed.values()) + CORRIDOR_MIN + y0 = min(b[1] for b in placed.values()) + + spot = None + row = h + MIN_ROW_GAP + for col in range(_KEEP_COLS): + x = x0 + col * step_x * (1 if preds or not succs else -1) + for k in range(_KEEP_ROWS * 2 + 1): + # 0, +1, -1, +2, -2 …:先试正对着上下游的那一格 + y = y0 + ((k + 1) // 2) * row * (1 if k % 2 else -1) + if free(x, y, h): + spot = (x, y) + break + if spot: + break + if spot is None: + # 周围实在挤满了:放到整张图最下面,至少不压住任何东西 + spot = (x0, max(b[1] + b[3] for b in placed.values()) + MIN_ROW_GAP) + node.position.x, node.position.y = round(spot[0], 1), round(spot[1], 1) + placed[node.id] = (node.position.x, node.position.y, NODE_W, h) + + return spec, {"mode": "keep", "placed": [n.id for n in fresh]} GRAPH_SCHEMA: dict[str, Any] = { @@ -467,7 +572,7 @@ async def generate( try: model, _ = await get_chat_model(session, await copilot_model_spec(session, payload)) except ProviderNotConfigured as e: - raise HTTPException(400, str(e)) from e + raise HTTPException(400, _unconfigured(e)) from e messages = [("system", system), ("human", _with_history(user, await _history_section(session, payload.conversation_id)))] @@ -480,7 +585,11 @@ async def generate( 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 + reason, _ = explain_error(e) + raise HTTPException( + 502, f"模型这次没交回能用的工作流({reason})。换个说法再试一次;" + "总是这样的话,到设置里给助手换一个更听指令的模型", + ) from e graph = { "nodes": [ @@ -505,14 +614,17 @@ async def generate( try: spec = GraphSpec.model_validate(graph) except Exception as e: # noqa: BLE001 - raise HTTPException(502, f"生成的图结构非法:{e}") from e + raise HTTPException( + 502, f"模型交回的工作流结构不对:{graph_error(e)}。再试一次,或者把需求说得更具体些", + ) from e - spec = auto_layout(spec) + spec, layout = _layout_keeping(spec, _pinned_positions(payload.base_graph)) 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], + layout=layout, ) @@ -750,6 +862,8 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g # 现有图状态:修改场景从 base_graph 起步,新建场景从空图起步 nodes: dict[str, dict[str, Any]] = {} edges: list[dict[str, Any]] = [] + # 用户摆好的坐标,收尾排版时原样留着 + pinned = _pinned_positions(payload.base_graph) if payload.base_graph and payload.base_graph.get("nodes"): for n in payload.base_graph["nodes"]: nodes[n["id"]] = n @@ -769,7 +883,7 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g 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 + raise HTTPException(400, _unconfigured(e)) from e messages = [("system", system), ("human", _with_history(user, await _history_section(session, payload.conversation_id)))] @@ -809,7 +923,9 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g 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" + reason, hint = explain_error(e) + yield _sse({"op": "error", "message": f"助手这一轮没跑完:{reason}", + "hint": hint, "detail": raw_error(e)}) return # 兜底:一个操作都没落地,而上一轮有图。 @@ -854,7 +970,7 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g yield _sse(op) except Exception as e: # noqa: BLE001 - 修不成就照实交付,下面列出剩下的问题 yield _sse({"op": "check", "status": "error", - "message": f"自查修正没跑成:{type(e).__name__}: {e}"}) + "message": f"自查修正没跑成:{explain_error(e)[0]}", "detail": raw_error(e)}) break repaired = round_no remaining = _blocking_issues(nodes, edges) @@ -867,15 +983,17 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g elif remaining is not None: yield _sse({"op": "check", "status": "passed", "repaired": repaired}) - # 收尾:排版 + 校验,把最终图整体交付 + # 收尾:排版 + 校验,把最终图整体交付。改图时旧节点留在原处,只排新节点 try: - spec = auto_layout( - GraphSpec.model_validate({"nodes": list(nodes.values()), "edges": edges}) + spec, layout = _layout_keeping( + GraphSpec.model_validate({"nodes": list(nodes.values()), "edges": edges}), pinned, ) issues = [i.model_dump() for i in validate_graph(spec).issues] - # 跳过的节点要说出来,不能安静地少一步 + # 跳过的节点要说出来,不能安静地少一步。code 给前端认:这一类要单独 + # 提示「少了一步」,不能和普通校验警告混在一起 issues += [ {"level": "warning", "node_id": None, "edge_id": None, + "code": "unknown_node_type", "type": t, "message": f"模型写了一个不存在的节点类型「{t}」,这一步已跳过"} for t in dict.fromkeys(skipped_types) ] @@ -885,9 +1003,11 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g "issues": issues, "explanation": explanation, "autorun": autorun, + "layout": layout, } except Exception as e: # noqa: BLE001 - final = {"op": "error", "message": f"生成的图结构非法:{e}"} + final = {"op": "error", "message": f"模型交回的工作流结构不对:{graph_error(e)}", + "hint": "再试一次,或者把需求说得更具体些", "detail": raw_error(e)} yield f"data: {json.dumps(final, ensure_ascii=False)}\n\n" return StreamingResponse( @@ -920,7 +1040,14 @@ async def extract_template( run = await session.get(Run, payload.run_id) if not run: - raise HTTPException(404, "运行记录不存在") + raise HTTPException(404, "这次运行的记录不存在,可能已经被删了") + # 没跑通的路径提出来是半截骨架:失败节点之后的步骤一步都不在里面, + # 人拿去审改时却看不出它缺了什么 + if run.status != "succeeded": + raise HTTPException( + 409, "这次运行没有跑完(没有成功结束),提取出来的只是半截路径。" + "先让它跑成功一次,再从那次运行提取", + ) if not run.graph.get("nodes"): raise HTTPException(400, "这次运行没有保存图快照,无法提取") @@ -1013,7 +1140,7 @@ async def explain( ), ) except ProviderNotConfigured as e: - raise HTTPException(400, str(e)) from e + raise HTTPException(400, _unconfigured(e)) from e prompt = ( "用中文解释下面这个 agent 工作流:它解决什么问题、数据怎么流动、" @@ -1096,7 +1223,7 @@ async def review_run( run = await session.get(Run, payload.run_id) if not run: - raise HTTPException(404, f"运行 {payload.run_id} 不存在") + raise HTTPException(404, "这次运行的记录不存在,可能已经被删了") rows = list((await session.execute( select(RunEvent).where(RunEvent.run_id == payload.run_id).order_by(RunEvent.seq) @@ -1118,7 +1245,9 @@ async def review_run( # 没配模型不该让这一轮挂掉:规则层已经知道出了什么事,照样说得出话 return rv.ReviewResult( verdict="annotated", - note=";".join(s.detail for s in signals) or str(e), + note=";".join(s.detail for s in signals) or ( + _unconfigured(e) if isinstance(e, ProviderNotConfigured) else str(e.detail) + ), retry=rv.should_retry(signals), signals=signals, severity=rv.worst(signals), ).as_dict() diff --git a/backend/app/api/datasources.py b/backend/app/api/datasources.py index aad28a4f..4e5399d6 100644 --- a/backend/app/api/datasources.py +++ b/backend/app/api/datasources.py @@ -13,10 +13,11 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.errors import explain, first_line, raw from app.core.config import settings from app.core.crypto import encrypt, mask from app.data import introspect as introspect_mod -from app.data.engine import SUPPORTED_KINDS, engines, test_connection +from app.data.engine import SUPPORTED_KINDS, build_url, engine_args, engines from app.db.base import get_session from app.db.models import DataSource from app.tools.datasource import tool_names @@ -100,7 +101,7 @@ def _to_out(row: DataSource) -> DataSourceOut: async def _get_or_404(session: AsyncSession, source_id: str) -> DataSource: row = await session.get(DataSource, source_id) if not row: - raise HTTPException(404, "数据源不存在") + raise HTTPException(404, "这个数据源不存在,可能已经被删了") return row @@ -112,43 +113,88 @@ async def list_sources(session: AsyncSession = Depends(get_session)) -> list[Dat @router.get("/kinds") async def list_kinds() -> dict[str, Any]: - """支持哪些数据库,以及各自需要填什么——前端表单据此渲染。""" + """支持哪些数据库,以及各自需要填什么——前端表单据此渲染。 + + hint 只用表单上看得见的说法。以前写的是「在 options 里给 service_name」 + 「options 可填 charset」,表单上根本没有叫 options 的地方。advanced 列出 + 「高级连接参数」里常用的键,hint 提到的每个键都在那里找得到。 + """ return { "kinds": [ {"value": "mysql", "label": "MySQL / MariaDB", "default_port": 3306, "needs": ["host", "database", "username", "password"], - "hint": "options 可填 charset(默认 utf8mb4)"}, + "hint": "字符集默认 utf8mb4,要换就在「高级连接参数」里加 charset", + "advanced": [{"key": "charset", "label": "字符集", "placeholder": "utf8mb4"}]}, {"value": "postgres", "label": "PostgreSQL", "default_port": 5432, - "needs": ["host", "database", "username", "password"], "hint": ""}, + "needs": ["host", "database", "username", "password"], + "hint": "「schema」留空就用 public", + "advanced": []}, {"value": "oracle", "label": "Oracle", "default_port": 1521, "needs": ["host", "username", "password"], - "hint": "库名填 service_name,或在 options 里给 service_name / sid。" + "hint": "service_name 和 SID 二选一:一般填 service_name,老库只给了 SID 就切到 SID。" "驱动走 thin 模式,不需要装 Instant Client。" "只读账号名下通常没有对象——数据在别的 schema 里," - "用 options.schema 指定(如 ANALYTICS),否则探查结果是空的"}, + "把它填进「schema」(如 ANALYTICS),否则探查结果是空的", + "advanced": [ + {"key": "service_name", "label": "service_name", "placeholder": "ORCLPDB1", + "help": "和 SID 二选一"}, + {"key": "sid", "label": "SID", "placeholder": "ORCL", "help": "和 service_name 二选一"}, + ]}, {"value": "sqlite", "label": "SQLite(文件)", "default_port": None, - "needs": ["database"], "hint": "库名填数据库文件的绝对路径"}, + "needs": ["database"], "hint": "「数据库文件路径」填 .db 文件的绝对路径", + "advanced": []}, ], "supported": list(SUPPORTED_KINDS), } +def _oracle_target(options: dict[str, Any] | None) -> tuple[str, str]: + opts = options or {} + return str(opts.get("service_name") or "").strip(), str(opts.get("sid") or "").strip() + + +def _settle_oracle( + row: DataSource, old_options: dict[str, Any] | None, old_database: str | None +) -> None: + """Oracle 的 service_name / SID 只存一处:options 里,database 清空。 + + 引擎按 `options.service_name or database` 取服务名,两者都空才看 sid。老数据 + 把服务名存在 database,于是新表单切到 SID 存下去会被它压住、悄悄无视;旧表单 + 把 service_name 框绑在 database 上、保存时又原样带回 options,新填的值同样 + 被压住。两处都能存,就总有一处在暗中说了算,所以存的时候收成一处:这次 + 改了哪一处就听哪一处;都没改就照引擎原来的取法,连的还是同一个库。 + """ + if (row.kind or "").lower() != "oracle": + return + opts = {k: v for k, v in (row.options or {}).items() + if k not in ("service_name", "sid") or str(v or "").strip()} + database = (row.database or "").strip() + picked = any(_oracle_target(opts)) and _oracle_target(opts) != _oracle_target(old_options) + typed = bool(database) and database != (old_database or "").strip() + if not picked and (typed or (database and "service_name" not in opts)): + opts["service_name"] = database + opts.pop("sid", None) + row.options = opts + row.database = None + + @router.post("", response_model=DataSourceOut, status_code=201) async def create_source( payload: DataSourceIn, session: AsyncSession = Depends(get_session) ) -> DataSourceOut: if payload.kind not in SUPPORTED_KINDS: - raise HTTPException(400, f"不支持的类型 {payload.kind},支持:{', '.join(SUPPORTED_KINDS)}") + raise HTTPException(400, f"不支持「{payload.kind}」这种数据库,支持:{'、'.join(SUPPORTED_KINDS)}") exists = (await session.execute( select(DataSource).where(DataSource.name == payload.name) )).scalar_one_or_none() if exists: - raise HTTPException(409, f"已经有叫 {payload.name} 的数据源了") + raise HTTPException(409, f"已经有叫「{payload.name}」的数据源了,换个标识") row = DataSource( **payload.model_dump(exclude={"password"}), password=encrypt(payload.password), ) + _settle_oracle(row, None, None) session.add(row) await session.commit() await session.refresh(row) @@ -164,8 +210,10 @@ async def update_source( if "password" in data: # 空串表示清空,非空表示换新的 data["password"] = encrypt(data["password"]) if data["password"] else None + old_options, old_database = dict(row.options or {}), row.database for key, value in data.items(): setattr(row, key, value) + _settle_oracle(row, old_options, old_database) await session.commit() await session.refresh(row) # 连接参数可能变了,旧连接池不能再用——否则改完密码还在用旧连接,很难排查 @@ -181,11 +229,171 @@ async def delete_source(source_id: str, session: AsyncSession = Depends(get_sess await engines.invalidate(source_id) +#: 测连接最多等多久。内网库防火墙丢包时驱动默认要等一分钟,弹窗里干转一分钟 +#: 等于没有反馈 +_TEST_TIMEOUT = 15 + + +def _explain_connect(e: BaseException, kind: str) -> tuple[str, str]: + """连库失败的常见原因。驱动的原文九成能定位问题,但得先翻译成表单上的说法。""" + low = str(e).lower() + if any(s in low for s in ("password authentication failed", "access denied", "ora-01017", + "invalid username/password", "(1045")): + return "账号或密码不对", "核对「用户名」和「密码」;编辑时密码留空表示沿用已保存的那个" + if "ora-12514" in low: + return "Oracle 不认识这个 service_name", "核对 service_name;老库可能只给了 SID,切到 SID 再试" + if "ora-12505" in low: + return "Oracle 不认识这个 SID", "核对 SID,或者改用 service_name" + if "service_name 或 sid" in low: + return "没填 service_name 或 SID", "在「service_name」一栏填上,或者切到 SID" + if "unknown database" in low or "(1049" in low or ( + "does not exist" in low and "database" in low + ): + return "服务器上没有这个库", "核对「数据库」一栏" + if "unable to open database file" in low: + return "打不开这个数据库文件", "核对路径是不是绝对路径、文件在不在,以及后端进程有没有读权限" + if "file is not a database" in low: + return "这个文件不是 SQLite 数据库", "核对路径指的是不是 .db 文件" + if "no module named" in low: + return "后端没装这种数据库的驱动", "pip install 'agentlab-backend[db]' 之后重启后端" + reason, hint = explain(e) + if reason in ("等了太久没有响应", "连不上对方的服务"): + # 通用那句说的是 API,这里是数据库:多半是主机、端口或者网络不通 + hint = "核对「主机」和「端口」;内网库要确认这台机器访问得到它(防火墙、VPN)" + elif kind == "sqlite" and not hint: + hint = "核对数据库文件的路径" + return reason, hint + + +def _safe_url(source: Any) -> str: + try: + return build_url(source) # 遮掉密码的版本 + except Exception: # noqa: BLE001 - 连串都拼不出来时,原因已经在 error 里了 + return "" + + +def _missing_sqlite_file(source: Any) -> tuple[str, str, str] | None: + """SQLite 的路径指向不存在的文件时,返回 (reason, hint, detail)。 + + 可写模式下 SQLite 连一个不存在的路径会当场建一个空库:测试说「连得上」, + 连的却是个空文件,「只测不存」的接口还往磁盘上写了东西。没填路径时连的 + 是内存库,一样是假的「连得上」。按 SQLite 自己的解析方式判断(不展开 ~)。 + """ + from pathlib import Path + + if (source.kind or "").lower() != "sqlite": + return None + path = source.database or "" + if not path.strip(): + return "没填数据库文件路径", "在「数据库文件路径」里填 .db 文件的绝对路径", "database 为空" + if path == ":memory:" or Path(path).is_file(): + return None + return ("打不开这个数据库文件", + "这个路径上没有文件。核对路径是不是绝对路径、文件在不在(~ 不会被展开)", + f"文件不存在:{path}") + + +async def _probe(source: Any, *, cached: bool) -> dict[str, Any]: + """真连一次。cached=False 用一次性的 engine,测完就扔——草稿不该进连接池缓存, + 否则改完配置再测,拿到的还是上一版的连接。""" + import asyncio + import time + + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + from sqlalchemy.pool import NullPool + + started = time.perf_counter() + missing = _missing_sqlite_file(source) + if missing: + reason, hint, detail = missing + return {"ok": False, "error": f"连不上:{reason}", "hint": hint, "detail": detail, + "elapsed_ms": 0, "url": _safe_url(source)} + engine = None + try: + if cached: + engine = await engines.get(source) + else: + url, extra = engine_args(source) + engine = create_async_engine(url, poolclass=NullPool, **extra) + async with asyncio.timeout(_TEST_TIMEOUT): + async with engine.connect() as conn: + oracle = (source.kind or "").lower() == "oracle" + await conn.execute(text("SELECT 1 FROM DUAL" if oracle else "SELECT 1")) + return { + "ok": True, + "elapsed_ms": int((time.perf_counter() - started) * 1000), + "url": _safe_url(source), + } + except Exception as e: # noqa: BLE001 + reason, hint = _explain_connect(e, (source.kind or "").lower()) + return { + "ok": False, + "error": f"连不上:{reason}", + "hint": hint, + "detail": raw(e), + "elapsed_ms": int((time.perf_counter() - started) * 1000), + "url": _safe_url(source), + } + finally: + if engine is not None and not cached: + await engine.dispose() + + +class DataSourceTestIn(BaseModel): + """一份还没保存(或改了还没保存)的配置。带 id 表示在编辑已有的那个。""" + + id: str | None = None + name: str = "" + kind: str + host: str | None = None + port: int | None = None + database: str | None = None + username: str | None = None + password: str | None = None # 编辑时留空=沿用已保存的 + options: dict[str, Any] = Field(default_factory=dict) + readonly: bool = True + + +def _draft_source(payload: DataSourceTestIn, saved: DataSource | None) -> DataSource: + """拼一个不进会话的临时数据源,只拿来连一下。 + + 编辑时密码框留空表示不改——测连接也得用存着的那个,否则改个端口想测一下, + 只会得到一句"密码不对"。 + """ + if payload.password: + password = encrypt(payload.password) + else: + password = saved.password if saved else None + return DataSource( + name=payload.name or (saved.name if saved else "draft"), + kind=payload.kind, host=payload.host, port=payload.port, database=payload.database, + username=payload.username, password=password, options=dict(payload.options or {}), + readonly=payload.readonly, + ) + + +@router.post("/test") +async def test_draft( + payload: DataSourceTestIn, session: AsyncSession = Depends(get_session) +) -> dict[str, Any]: + """测一份没保存的配置,不落库。 + + 以前只能测已存在的数据源:填错一项要保存→关弹窗→点测试→看提示→再打开改, + 接内网 Oracle 这类填错一个字段就连不上的东西,来回四五趟。 + """ + if payload.kind not in SUPPORTED_KINDS: + return {"ok": False, "error": f"不支持「{payload.kind}」这种数据库", + "hint": f"支持:{'、'.join(SUPPORTED_KINDS)}", "detail": "", "elapsed_ms": 0, "url": ""} + saved = await session.get(DataSource, payload.id) if payload.id else None + return await _probe(_draft_source(payload, saved), cached=False) + + @router.post("/{source_id}/test") async def test_source(source_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]: - """测连接。失败时把驱动的原始报错带出来——这类问题九成靠错误信息定位。""" + """测连接。失败时说清原因和怎么办,驱动的原始报错放在 detail——这类问题九成靠它定位。""" row = await _get_or_404(session, source_id) - return await test_connection(row) + return await _probe(row, cached=True) @router.post("/{source_id}/introspect", response_model=DataSourceOut) @@ -197,7 +405,8 @@ async def introspect_source( try: row.schema_cache = await introspect_mod.introspect(row, schema=schema) except Exception as e: # noqa: BLE001 - raise HTTPException(400, f"探查失败:{type(e).__name__}: {e}") from e + reason, hint = _explain_connect(e, (row.kind or "").lower()) + raise HTTPException(400, f"探查表结构失败:{reason}" + (f"。{hint}" if hint else "")) from e # 失败也要把缓存存下来——里面的 available_schemas 正是下一步的线索—— # 但**不能盖上"已同步"的戳**。以前盖了,于是界面显示同步过、工具却说 # "还没探查过",两边都不说真话(run 554a0f92) @@ -285,7 +494,10 @@ async def upload_table( except UnsupportedTable as e: raise HTTPException(400, str(e)) from e except Exception as e: # noqa: BLE001 - raise HTTPException(400, f"导入失败:{type(e).__name__}: {e}") from e + raise HTTPException( + 400, f"导入失败:{first_line(e) if isinstance(e, ValueError) else explain(e)[0]}。" + "确认文件是 Excel 或 CSV、没有加密,表头行号填得对", + ) from e row = existing if row is None: diff --git a/backend/app/api/errors.py b/backend/app/api/errors.py new file mode 100644 index 00000000..ea996e7b --- /dev/null +++ b/backend/app/api/errors.py @@ -0,0 +1,139 @@ +"""面向用户的报错:说清发生了什么、为什么、怎么办,不把 Python 异常类名端上去。 + +以前各处都是 f"{type(e).__name__}: {e}",界面首行红字是 +「_make_query_tool.._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}" diff --git a/backend/app/api/governance.py b/backend/app/api/governance.py index eef26da9..1c584da0 100644 --- a/backend/app/api/governance.py +++ b/backend/app/api/governance.py @@ -29,7 +29,7 @@ async def tool_usage( """ workflow = await session.get(Workflow, workflow_id) if not workflow: - raise HTTPException(404, "工作流不存在") + raise HTTPException(404, "这个工作流不存在,可能已经被删了") spec = GraphSpec.model_validate(workflow.graph) # 各节点声明的白名单 @@ -83,7 +83,7 @@ async def tool_usage( "workflow_id": workflow_id, "runs_considered": len(run_ids), "nodes": report, - "hint": "unused 里躺了很多轮的工具是白名单衰减的信号,考虑摘除", + "hint": "授权了却很多轮都没用过的工具,是白名单在慢慢放宽的信号,考虑从节点里摘掉", } @@ -147,5 +147,5 @@ async def exploratory_clusters( return { "clusters": out, "considered": len(items), - "hint": "promote_candidate=true 的问题反复出现,考虑用「从运行提取模板」晋升为固定节", + "hint": "标为晋升候选的问题反复出现,考虑用「提取模板」把它固定成工作流里的一步", } diff --git a/backend/app/api/knowledge.py b/backend/app/api/knowledge.py index ee428145..02e4a2c8 100644 --- a/backend/app/api/knowledge.py +++ b/backend/app/api/knowledge.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from typing import Any from fastapi import ( @@ -9,11 +10,14 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.errors import explain, raw from app.core.config import settings from app.db.base import get_session from app.db.models import Document, MemoryItem, Skill from app.memory import inverted, kb, store +logger = logging.getLogger(__name__) + # -------------------------------------------------------------------------- # 长期记忆 # -------------------------------------------------------------------------- @@ -22,13 +26,19 @@ memory_router = APIRouter(prefix="/api/memory", tags=["memory"]) class MemoryIn(BaseModel): - content: str = Field(min_length=1) + content: str scope: str = "default" kind: str = "fact" importance: float = Field(default=0.5, ge=0, le=1) meta: dict[str, Any] = Field(default_factory=dict) +class MemoryPatch(BaseModel): + content: str | None = None + kind: str | None = None + importance: float | None = Field(default=None, ge=0, le=1) + + class MemoryOut(BaseModel): id: str scope: str @@ -37,11 +47,68 @@ class MemoryOut(BaseModel): importance: float use_count: int meta: dict[str, Any] + #: 这条是哪来的:kind=run 时带运行、节点和工作流名;manual 是手动添加的; + #: playground 是在工具库里直接调 remember 写进来的 + source: dict[str, Any] = Field(default_factory=dict) created_at: Any = None + # 不给 updated_at:每次召回记计数也是一次写,它跟着召回走,当「修改于」 + # 显示就是错的。界面要的「记下于」「上次召回」各有字段 + last_used_at: Any = None model_config = {"from_attributes": True} +def _content(text: str) -> str: + """只有空白的内容和空串是一回事——存下去就是一条什么都召回不到的空记忆。""" + if not text.strip(): + raise HTTPException(422, "记忆内容是空的:写上要记住的那句话再保存") + return text + + +async def _with_source(session: AsyncSession, rows: list[MemoryItem]) -> list[MemoryOut]: + """给记忆补上来源。 + + 长期记忆里存的是口径规则,审查时得知道「这条是哪次运行、哪个节点写进来的」 + 才能判断能不能信。meta 里只有 id,节点名和工作流名要回运行记录里查—— + 运行被删了就照实标 run_exists=false,不编一个出来。 + """ + from app.db.models import Run + + run_ids = {str((r.meta or {}).get("run_id") or "") for r in rows} - {"", "playground"} + runs: dict[str, Any] = {} + if run_ids: + for row in await session.execute( + select(Run.id, Run.workflow_name, Run.graph).where(Run.id.in_(run_ids)) + ): + runs[row.id] = row + + out: list[MemoryOut] = [] + for item in rows: + meta = item.meta or {} + run_id = str(meta.get("run_id") or "") + node_id = meta.get("node_id") or None + if not run_id: + source: dict[str, Any] = {"kind": "manual"} + elif run_id == "playground": + source = {"kind": "playground"} + else: + run = runs.get(run_id) + nodes = ((run.graph if run else None) or {}).get("nodes") or [] + label = next( + ((n.get("data") or {}).get("label") for n in nodes if n.get("id") == node_id), None + ) + source = { + "kind": "run", "run_id": run_id, "node_id": node_id, + "node_label": label or None, + "workflow_name": (run.workflow_name or None) if run else None, + "run_exists": run is not None, + } + view = MemoryOut.model_validate(item) + view.source = source + out.append(view) + return out + + @memory_router.get("/scopes") async def memory_scopes(session: AsyncSession = Depends(get_session)) -> list[dict[str, Any]]: return await store.list_scopes(session) @@ -52,22 +119,38 @@ async def list_memory( scope: str | None = None, limit: int = Query(default=200, le=500), session: AsyncSession = Depends(get_session), -) -> list[MemoryItem]: - return await store.list_memories(session, scope=scope, limit=limit) +) -> list[MemoryOut]: + return await _with_source(session, await store.list_memories(session, scope=scope, limit=limit)) @memory_router.post("", response_model=MemoryOut, status_code=201) async def add_memory( payload: MemoryIn, session: AsyncSession = Depends(get_session) -) -> MemoryItem: - return await store.remember( +) -> MemoryOut: + item = await store.remember( session, scope=payload.scope, - content=payload.content, + content=_content(payload.content), kind=payload.kind, importance=payload.importance, meta=payload.meta, ) + return (await _with_source(session, [item]))[0] + + +@memory_router.patch("/{memory_id}", response_model=MemoryOut) +async def update_memory( + memory_id: str, payload: MemoryPatch, session: AsyncSession = Depends(get_session) +) -> MemoryOut: + """写错了原地改,不必删掉重写——重写会丢掉来源和召回记录。""" + item = await store.revise( + session, memory_id, + content=None if payload.content is None else _content(payload.content), + kind=payload.kind, importance=payload.importance, + ) + if item is None: + raise HTTPException(404, "这条记忆不存在,可能已经被删了") + return (await _with_source(session, [item]))[0] @memory_router.get("/search") @@ -75,10 +158,11 @@ async def search_memory( q: str, scope: str = "default", limit: int = 10, + peek: bool = Query(default=False, description="只看不记:调试台用,不计入召回次数"), session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: - hits = await store.recall(session, scope=scope, query=q, limit=limit) - return {"query": q, "scope": scope, "results": hits} + hits = await store.recall(session, scope=scope, query=q, limit=limit, track=not peek) + return {"query": q, "scope": scope, "results": hits, "peek": peek} @memory_router.delete("/{memory_id}", status_code=204) @@ -86,7 +170,7 @@ async def delete_memory( memory_id: str, session: AsyncSession = Depends(get_session) ) -> None: if not await store.forget(session, memory_id): - raise HTTPException(404, "记忆不存在") + raise HTTPException(404, "这条记忆不存在,可能已经被删了") @memory_router.delete("/scope/{scope}") @@ -122,6 +206,16 @@ async def collections(session: AsyncSession = Depends(get_session)) -> list[dict return await kb.list_collections(session) +@kb_router.get("/formats") +async def formats() -> dict[str, Any]: + """上传框收哪些文件。前端的 accept 从这里取,和后端的解析判断是同一份清单。""" + from app.memory.parsing import supported_formats + + out: dict[str, Any] = supported_formats() + out["accept"] = ",".join(out["extensions"]) + return out + + @kb_router.get("/documents", response_model=list[DocumentOut]) async def list_documents( collection: str | None = None, @@ -207,11 +301,13 @@ async def _process_in_background(doc_id: str) -> None: # 不会和另一个 session 抢 SQLite 的写锁 await kb.process_document(session, doc) except Exception as e: # noqa: BLE001 + logger.warning("文档 %s 处理失败:%s", doc_id, raw(e)) await session.rollback() doc = await session.get(Document, doc_id) if doc: + reason, hint = explain(e) doc.status = "failed" - doc.error = f"{type(e).__name__}: {e}"[:500] + doc.error = (f"切块或算向量时出错:{reason}" + (f"。{hint}" if hint else ""))[:500] await session.commit() @@ -224,12 +320,18 @@ async def search_kb( description="1=纯向量, 0=纯关键词;不传则按 embedder 能力取默认"), session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: + from app.memory.embeddings import default_alpha + notes: list[str] = [] + # 没传就是运行时的默认值。把实际用的那个交回去:调试台的用处是预演 agent + # 会拿到什么,默认值对不上,人会照着错的命中去调知识库 + used = default_alpha() if alpha is None else alpha hits = await kb.search(session, collection=collection, query=q, limit=limit, - alpha=alpha, on_degrade=notes.append) + alpha=used, on_degrade=notes.append) # 退回关键词这件事要跟着结果一起交出去,而不是只写进服务端日志—— # 调用方(设置页的"试一下"、外部脚本)看到的是结果变差,得知道为什么 - return {"query": q, "collection": collection, "results": hits, "degraded": notes} + return {"query": q, "collection": collection, "results": hits, "degraded": notes, + "alpha": used} @kb_router.get("/embedding") @@ -325,7 +427,8 @@ async def probe_embedding(base_url: str) -> dict[str, Any]: async with httpx.AsyncClient(timeout=8) as client: data = (await client.get(url)).json() except Exception as e: # noqa: BLE001 - raise HTTPException(400, f"连不上 {url}:{type(e).__name__}: {e}") from e + reason, hint = explain(e) + raise HTTPException(400, f"问不到 {url} 上有哪些模型:{reason}" + (f"。{hint}" if hint else "")) from e models = [m.get("id", "") for m in (data.get("data") or []) if m.get("id")] return {"base_url": base_url, "models": models} @@ -365,7 +468,7 @@ async def get_document( doc = await session.get(Document, doc_id) if not doc: - raise HTTPException(404, "文档不存在") + raise HTTPException(404, "这份文档不存在,可能已经被删了") rows = list((await session.execute( select(Chunk).where(Chunk.document_id == doc_id).order_by(Chunk.ordinal) @@ -394,7 +497,7 @@ async def get_document( @kb_router.delete("/documents/{doc_id}", status_code=204) async def delete_document(doc_id: str, session: AsyncSession = Depends(get_session)) -> None: if not await kb.delete_document(session, doc_id): - raise HTTPException(404, "文档不存在") + raise HTTPException(404, "这份文档不存在,可能已经被删了") # -------------------------------------------------------------------------- @@ -443,7 +546,7 @@ async def update_skill( ) -> Skill: row = await session.get(Skill, skill_id) if not row: - raise HTTPException(404, "Skill 不存在") + raise HTTPException(404, "这个 Skill 不存在,可能已经被删了") for key, value in payload.model_dump().items(): setattr(row, key, value) await session.commit() @@ -455,6 +558,6 @@ async def update_skill( async def delete_skill(skill_id: str, session: AsyncSession = Depends(get_session)) -> None: row = await session.get(Skill, skill_id) if not row: - raise HTTPException(404, "Skill 不存在") + raise HTTPException(404, "这个 Skill 不存在,可能已经被删了") await session.delete(row) await session.commit() diff --git a/backend/app/api/runs.py b/backend/app/api/runs.py index 36280958..1e79b05d 100644 --- a/backend/app/api/runs.py +++ b/backend/app/api/runs.py @@ -2,7 +2,9 @@ from __future__ import annotations import asyncio import contextlib +from datetime import datetime from typing import Any +from urllib.parse import unquote from fastapi import APIRouter, Depends, Header, HTTPException, Query, WebSocket, WebSocketDisconnect from pydantic import BaseModel, Field @@ -22,12 +24,24 @@ from app.engine.schema import GraphSpec router = APIRouter(prefix="/api/runs", tags=["runs"]) +def actor_of(header: str | None) -> str | None: + """请求头 X-Actor 里的署名(设置页填的那个),没填就是 None。 + + 浏览器的请求头只能是 Latin-1:中文署名原样放进去,fetch 直接抛错、整个请求 + 发不出去。所以前端按 encodeURIComponent 编码后发,这里解回来;老客户端发的 + 纯 ASCII 署名解码前后一样。 + """ + value = unquote((header or "").strip()).strip() + return value[:100] or None + + class StartRunIn(BaseModel): workflow_id: str | None = None graph: dict[str, Any] | None = None # 直接跑一张未保存的图(画布上的"试运行") input: dict[str, Any] = Field(default_factory=dict) - memory_scope: str = "default" - collection: str = "default" + # 不传就取设置里的运行默认值(settings.resolve_run_scope) + memory_scope: str | None = None + collection: str | None = None # formal:从已发布的不可变版本发起,可复现可出具;exploratory:随便玩 run_class: str = "exploratory" version: int | None = None # formal 时可指定版本,默认用已发布版本 @@ -46,7 +60,15 @@ class RunOut(BaseModel): version: int | None = None version_hash: str | None = None manifest_hash: str | None = None + #: 封存到了哪一条事件。为空 = 还没封存(没跑完或停在审批) + manifest_seq: int | None = None started_by: str | None = None + #: 失败时能定位到的节点 + error_node_id: str | None = None + #: 发起时实际生效的记忆域和知识库 + memory_scope: str | None = None + collection: str | None = None + # 时间一律带时区(db.base.UTCDateTime 读出来就是 UTC aware) created_at: Any = None started_at: Any = None finished_at: Any = None @@ -60,7 +82,7 @@ async def start_run( session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None), ) -> Run: - actor = (x_actor or "").strip() or None + actor = actor_of(x_actor) name = "临时图" version: int | None = None version_hash: str | None = None @@ -69,12 +91,12 @@ async def start_run( # 正式运行的全部语义就这一条规则:必须引用一个不可变的已发布版本。 # 传裸 graph、或工作流还没发布过,都进不了 formal。 if not payload.workflow_id: - raise HTTPException(400, "正式运行必须指定 workflow_id") + raise HTTPException(400, "正式运行要从一个已保存的工作流发起:请求里没有指定工作流") if payload.graph is not None: - raise HTTPException(400, "正式运行不接受临时 graph——先保存并发布版本") + raise HTTPException(400, "正式运行只跑已发布的版本,不接受画布上的临时图——先保存并发布") workflow = await session.get(Workflow, payload.workflow_id) if not workflow: - raise HTTPException(404, "工作流不存在") + raise HTTPException(404, "找不到这个工作流,可能已经被删除了") # 只看 published_version:status 反映的是当前画布(改过图就退回 draft), # 而 formal 跑的是已发布的那一版,两者本来就可以不一致 if not workflow.published_version: @@ -115,20 +137,25 @@ async def start_run( if payload.workflow_id: workflow = await session.get(Workflow, payload.workflow_id) if not workflow: - raise HTTPException(404, "工作流不存在") + raise HTTPException(404, "找不到这个工作流,可能已经被删除了") graph = graph or workflow.graph name = workflow.name if not graph: - raise HTTPException(400, "需要提供 workflow_id 或 graph") + raise HTTPException(400, "没有要运行的内容:请求里既没有工作流,也没有图") + from app.api.settings import resolve_run_scope + + memory_scope, collection = await resolve_run_scope( + session, payload.memory_scope, payload.collection + ) try: run = await run_manager.start( graph=graph, input_payload=payload.input, workflow_id=payload.workflow_id, workflow_name=name, - memory_scope=payload.memory_scope, - collection=payload.collection, + memory_scope=memory_scope, + collection=collection, run_class="formal" if payload.run_class == "formal" else "exploratory", version=version, version_hash=version_hash, @@ -148,24 +175,64 @@ async def start_run( @router.get("", response_model=list[RunOut]) async def list_runs( workflow_id: str | None = None, - status: str | None = None, - limit: int = Query(default=50, le=200), + status: str | None = Query(default=None, description="逗号分隔,如 failed,cancelled"), + run_class: str | None = None, + q: str | None = Query(default=None, description="按工作流名模糊匹配"), + limit: int = Query(default=50, ge=1, le=200), + before: datetime | None = Query(default=None, description="翻页游标:只要 created_at 早于它的"), session: AsyncSession = Depends(get_session), ) -> list[Run]: + """运行列表,新的在前。 + + 以前只有 limit:待审批的运行排在第 145 位,列表只取 100 条,点了徽标也找不到它。 + 筛选都走服务端,翻页用 created_at 做游标(before 传上一页最后一条的 created_at)。 + """ stmt = select(Run).order_by(Run.created_at.desc()).limit(limit) if workflow_id: stmt = stmt.where(Run.workflow_id == workflow_id) - if status: - stmt = stmt.where(Run.status == status) + if statuses := _csv(status): + stmt = stmt.where(Run.status.in_(statuses)) + if classes := _csv(run_class): + stmt = stmt.where(Run.run_class.in_(classes)) + if q and q.strip(): + stmt = stmt.where(Run.workflow_name.ilike(f"%{_like(q.strip())}%", escape="\\")) + if before is not None: + stmt = stmt.where(Run.created_at < before) return list((await session.execute(stmt)).scalars()) +def _csv(value: str | None) -> list[str]: + return [v.strip() for v in (value or "").split(",") if v.strip()] + + +def _like(text: str) -> str: + """用户输入里的 % 和 _ 按字面匹配,不当通配符。""" + return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + @router.get("/{run_id}", response_model=RunOut) -async def get_run(run_id: str, session: AsyncSession = Depends(get_session)) -> Run: +async def get_run(run_id: str, session: AsyncSession = Depends(get_session)) -> RunOut: run = await session.get(Run, run_id) if not run: raise HTTPException(404, "运行记录不存在") - return run + out = RunOut.model_validate(run) + if out.status == "failed" and not out.error_node_id: + # 加这一列之前失败的老运行:从事件里找最后一个失败的节点 + out.error_node_id = (await session.execute( + select(RunEvent.node_id) + .where(RunEvent.run_id == run_id, RunEvent.type == "node.failed") + .order_by(RunEvent.seq.desc()).limit(1) + )).scalar_one_or_none() + return out + + +@router.get("/{run_id}/graph") +async def get_run_graph(run_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]: + """运行时的那张图(快照),不是工作流现在的样子。回放历史运行时画布照它画。""" + run = await session.get(Run, run_id) + if not run: + raise HTTPException(404, "运行记录不存在") + return {"graph": run.graph or {}, "workflow_id": run.workflow_id, "version": run.version} @router.get("/{run_id}/events") @@ -219,7 +286,9 @@ async def list_artifacts( async def cancel_run(run_id: str) -> dict[str, Any]: stopped = await run_manager.cancel(run_id) if not stopped: - raise HTTPException(409, "该运行当前不在执行中") + raise HTTPException( + 409, "这次运行已经不在执行中(可能刚结束,或者服务重启过),不需要再停止。刷新看看最新状态" + ) return {"ok": True} @@ -228,6 +297,11 @@ class ResumeIn(BaseModel): approval_id: str | None = None +def _missing(e: KeyError) -> HTTPException: + # str(KeyError) 会带上 repr 的引号,界面上就成了 "'找不到运行 x'" + return HTTPException(404, str(e.args[0]) if e.args else "运行记录不存在") + + @router.post("/{run_id}/resume", response_model=RunOut) async def resume_run( run_id: str, payload: ResumeIn, x_actor: str | None = Header(default=None) @@ -238,10 +312,10 @@ async def resume_run( return await run_manager.resume( run_id, payload.response, approval_id=payload.approval_id, - actor=(x_actor or "").strip() or None, + actor=actor_of(x_actor), ) except KeyError as e: - raise HTTPException(404, str(e)) from e + raise _missing(e) from e except ValueError as e: raise HTTPException(409, str(e)) from e @@ -258,17 +332,17 @@ async def continue_run( ) -> Run: """从失败的节点接着跑,前面跑过的不重来。 - 和 /resume 不是一回事:那条是人工介入的断点(status=interrupted),这条是 - 真的挂了(status=failed)。挡住后者的原来只是 resume 里的一行状态判定, - 而 checkpoint 一直都在——改个模型 id 就得把前面两分钟的取数重跑一遍, - 纯属白等。 + 和 /resume 不是一回事:那条回复人工审批,这条是真的挂了(status=failed), + 或者被服务重启打断、没有在等谁的 interrupted。挡住前者的原来只是 resume 里的 + 一行状态判定,而 checkpoint 一直都在——改个模型 id 就得把前面两分钟的取数 + 重跑一遍,纯属白等。 """ try: return await run_manager.continue_failed( - run_id, graph=payload.graph, actor=(x_actor or "").strip() or None, + run_id, graph=payload.graph, actor=actor_of(x_actor), ) except KeyError as e: - raise HTTPException(404, str(e)) from e + raise _missing(e) from e except ValueError as e: raise HTTPException(409, str(e)) from e @@ -310,7 +384,7 @@ async def verify_run(run_id: str) -> dict[str, Any]: try: return await verify_manifest(run_id) except KeyError as e: - raise HTTPException(404, str(e)) from e + raise _missing(e) from e @router.get("/{run_id}/history") @@ -348,10 +422,25 @@ async def get_history(run_id: str, limit: int = 50) -> list[dict[str, Any]]: @router.delete("/{run_id}", status_code=204) -async def delete_run(run_id: str, session: AsyncSession = Depends(get_session)) -> None: +async def delete_run( + run_id: str, force: bool = False, session: AsyncSession = Depends(get_session) +) -> None: + """删除运行记录。运行记录是出具的追溯依据,两种情况要拦: + + - 还在跑的:删了会留下没人管的执行任务,它还会往一条不存在的记录里写事件; + - 封存过的正式运行:删掉之后清单再也无法核对。确实要删,带 force=true。 + """ run = await session.get(Run, run_id) if not run: raise HTTPException(404, "运行记录不存在") + if run.status in ("queued", "running") or run_manager.is_active(run_id): + raise HTTPException(409, "这次运行还在执行,现在删除会留下没人管的任务。先停止它,再删除") + if run.run_class == "formal" and run.manifest_hash and not force: + raise HTTPException( + 409, + "这是一次已封存的正式运行,是出具结果的追溯凭证。删除后它的事件和工件会一并删掉," + "封存清单再也无法核对。确认要删的话请再确认一次(强制删除)", + ) await session.delete(run) await session.commit() @@ -361,11 +450,19 @@ async def delete_run(run_id: str, session: AsyncSession = Depends(get_session)) # -------------------------------------------------------------------------- +#: 不在进行中的状态:没有后续事件会来(interrupted 要等人回复,恢复会新开一段 +#: 执行,客户端到时候重新接) +_SETTLED = ("succeeded", "failed", "cancelled", "interrupted") + + @router.websocket("/{run_id}/stream") async def stream_run(websocket: WebSocket, run_id: str) -> None: """把一次运行的事件推给前端。 先补历史再接实时,所以中途打开页面、或者刷新之后,时间线都是完整的。 + 运行一旦不在进行中,补完历史就发一条 stream.end(data.status 是当时的状态) + 再关掉——interrupted 也发。以前停在审批或服务重启挂起的运行什么都不发, + 客户端一直重连、一直以为它还在跑。 """ await websocket.accept() after = int(websocket.query_params.get("after", 0) or 0) @@ -376,35 +473,19 @@ async def stream_run(websocket: WebSocket, run_id: str) -> None: # 在转圈、它的子步骤跑到了顶层。 subscription = bus.attach(run_id) try: - last_seq = after - async with SessionLocal() as session: - rows = await session.execute( - select(RunEvent) - .where(RunEvent.run_id == run_id, RunEvent.seq > after) - .order_by(RunEvent.seq) - ) - for event in rows.scalars(): - last_seq = max(last_seq, event.seq or 0) - await websocket.send_json( - { - "seq": event.seq, - "type": event.type, - "node_id": event.node_id, - "ts": event.ts, - "data": event.data, - "replay": True, - } - ) - run = await session.get(Run, run_id) + last_seq, run = await _replay(websocket, run_id, after) + if run is not None and run.status in _SETTLED and run_manager.is_active(run_id): + # 状态已经落了、执行任务还在收尾(终态事件、封存):等它收完,把这期间 + # 落库的事件补上,按最终状态收尾。否则会在 run.finished 之前就宣布结束 + await run_manager.wait_idle(run_id) + last_seq, run = await _replay(websocket, run_id, last_seq) - # 已经结束的 run 没有后续事件,补完历史就关掉 - if run and run.status in ("succeeded", "failed", "cancelled"): - await websocket.send_json({"type": "stream.end", "status": run.status}) - await websocket.close() + if run is None or run.status in _SETTLED: + await _end(websocket, run.status if run else None) return # 占位期间攒下的那几条历史里也有,按 seq 滤掉,否则前端同一步收两遍 - pump = asyncio.create_task(_pump(websocket, subscription.stream(after=last_seq))) + pump = asyncio.create_task(_pump(websocket, subscription.stream(after=last_seq), run_id)) try: # 客户端断开时读操作会抛异常,用它来结束这条连接 while True: @@ -424,10 +505,46 @@ async def stream_run(websocket: WebSocket, run_id: str) -> None: subscription.close() -async def _pump(websocket: WebSocket, stream: Any) -> None: +async def _replay(websocket: WebSocket, run_id: str, after: int) -> tuple[int, Run | None]: + """把 seq > after 的历史事件发出去,顺带取回运行当前的状态。""" + last_seq = after + async with SessionLocal() as session: + rows = await session.execute( + select(RunEvent) + .where(RunEvent.run_id == run_id, RunEvent.seq > after) + .order_by(RunEvent.seq) + ) + for event in rows.scalars(): + last_seq = max(last_seq, event.seq or 0) + await websocket.send_json( + { + "seq": event.seq, + "type": event.type, + "node_id": event.node_id, + "ts": event.ts, + "data": event.data, + "replay": True, + } + ) + run = await session.get(Run, run_id) + return last_seq, run + + +async def _end(websocket: WebSocket, status: str | None) -> None: + # 顶层的 status 留给老客户端;新口径读 data.status + await websocket.send_json({"type": "stream.end", "status": status, "data": {"status": status}}) + await websocket.close() + + +async def _pump(websocket: WebSocket, stream: Any, run_id: str) -> None: try: async for event in stream: await websocket.send_json(event.to_wire()) + # 实时流走完 = 运行到了终态,或者关停时挂起了:补一条结束标记再关, + # 客户端不用靠猜,也不会对着一条已经结束的运行无限重连 + async with SessionLocal() as session: + run = await session.get(Run, run_id) + await _end(websocket, run.status if run else None) except (WebSocketDisconnect, RuntimeError): return @@ -449,22 +566,53 @@ class ApprovalOut(BaseModel): status: str response: dict[str, Any] created_at: Any = None - - model_config = {"from_attributes": True} + #: 谁批的:设置里的署名(请求头 X-Actor)。没署名就是 null,由前端写「未署名」 + resolved_by: str | None = None + resolved_at: Any = None + # 下面几项来自所属的运行。全局待办列表要拼得出一行像样的字: + # 哪个工作流、卡在哪个节点、运行现在什么状态 + workflow_id: str | None = None + workflow_name: str | None = None + node_label: str | None = None + run_status: str | None = None + run_class: str | None = None @approvals_router.get("", response_model=list[ApprovalOut]) async def list_approvals( - status: str = "pending", + status: str = Query(default="pending", description="pending / resolved / all,可逗号分隔"), run_id: str | None = None, + limit: int = Query(default=100, ge=1, le=500), session: AsyncSession = Depends(get_session), -) -> list[Approval]: - stmt = select(Approval).order_by(Approval.created_at.desc()).limit(100) - if status != "all": - stmt = stmt.where(Approval.status == status) +) -> list[ApprovalOut]: + stmt = ( + select(Approval, Run) + .join(Run, Run.id == Approval.run_id, isouter=True) + .order_by(Approval.created_at.desc()) + .limit(limit) + ) + if status != "all" and (statuses := _csv(status)): + stmt = stmt.where(Approval.status.in_(statuses)) if run_id: stmt = stmt.where(Approval.run_id == run_id) - return list((await session.execute(stmt)).scalars()) + out: list[ApprovalOut] = [] + for approval, run in (await session.execute(stmt)).all(): + item = ApprovalOut.model_validate(approval, from_attributes=True) + if run is not None: + item.workflow_id = run.workflow_id + item.workflow_name = run.workflow_name + item.run_status = run.status + item.run_class = run.run_class + item.node_label = _node_label(run.graph, approval.node_id) + out.append(item) + return out + + +def _node_label(graph: dict[str, Any] | None, node_id: str) -> str | None: + for n in (graph or {}).get("nodes") or []: + if isinstance(n, dict) and n.get("id") == node_id: + return ((n.get("data") or {}).get("label")) or node_id + return None class DecideIn(BaseModel): @@ -483,12 +631,12 @@ async def decide( ) -> Run: approval = await session.get(Approval, approval_id) if not approval: - raise HTTPException(404, "审批请求不存在") + raise HTTPException(404, "找不到这条审批,所属的运行可能已经被删除了") if approval.status != "pending": - raise HTTPException(409, "该请求已经处理过了") + raise HTTPException(409, "这条审批已经处理过了,刷新看看最新状态") # 责任归属先落库再恢复执行——恢复失败也要留下"谁试图批的" - approval.resolved_by = (x_actor or "").strip() or None + approval.resolved_by = actor_of(x_actor) await session.commit() response: dict[str, Any] = {"approved": payload.approved, "note": payload.note} diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 0a724011..a921609d 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -8,12 +8,13 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.errors import explain, not_configured, raw from app.core.config import settings as app_settings from app.core.crypto import encrypt, mask from app.db.base import get_session from app.db.models import Provider, Setting from app.providers import catalog -from app.providers.factory import ModelSpec, ProviderNotConfigured, build_chat_model +from app.providers.factory import ModelSpec, ProviderNotConfigured, _resolve_key, build_chat_model router = APIRouter(prefix="/api", tags=["settings"]) @@ -102,11 +103,14 @@ async def provider_catalog() -> dict[str, Any]: async def create_provider( payload: ProviderIn, session: AsyncSession = Depends(get_session) ) -> ProviderOut: + problem = _config_problem(payload.kind, payload.base_url) + if problem: + raise HTTPException(400, problem) exists = ( await session.execute(select(Provider).where(Provider.name == payload.name)) ).scalar_one_or_none() if exists: - raise HTTPException(409, f"已经有叫 {payload.name!r} 的 provider 了") + raise HTTPException(409, f"已经有叫「{payload.name}」的模型接入了,换个名字") row = Provider( name=payload.name, @@ -130,7 +134,7 @@ async def update_provider( ) -> ProviderOut: row = await session.get(Provider, provider_id) if not row: - raise HTTPException(404, "provider 不存在") + raise HTTPException(404, "这个模型接入不存在,可能已经被删了") data = payload.model_dump(exclude_unset=True) if "api_key" in data: @@ -149,7 +153,7 @@ async def delete_provider( ) -> None: row = await session.get(Provider, provider_id) if not row: - raise HTTPException(404, "provider 不存在") + raise HTTPException(404, "这个模型接入不存在,可能已经被删了") await session.delete(row) await session.commit() @@ -159,6 +163,59 @@ class TestIn(BaseModel): prompt: str = "用一句话介绍你自己" +def _config_problem(kind: str, base_url: str | None) -> str | None: + """保存或测试前就能看出来的配置错误。""" + if kind not in {k["kind"] for k in catalog.PROVIDER_KINDS}: + return f"不认识「{kind}」这种接入类型" + if kind == "openai_compatible" and not (base_url or "").strip(): + return "「OpenAI 兼容」必须填 Base URL,例如 https://api.deepseek.com/v1" + return None + + +#: 测试连接最多等多久。真正跑节点用的是 model_timeout_seconds(默认 5 分钟), +#: 弹窗里转 5 分钟等于没有反馈 +_TEST_TIMEOUT = 30 + + +async def _try_model(provider: Provider, model: str | None, prompt: str) -> dict[str, Any]: + """真发一次请求。失败也是正常结果,照实返回原因,不抛。""" + import asyncio + import time + + from app.engine.state import message_text + + model = model or provider.default_model + if not model: + return {"ok": False, "error": "还没有可测的模型", + "hint": "先在「可选模型」里加一个,或者填上默认模型", "detail": ""} + try: + chat = build_chat_model(provider, ModelSpec(model=model, max_tokens=256, + timeout=_TEST_TIMEOUT)) + except ProviderNotConfigured as e: + if "API Key" in str(e): + return {"ok": False, "error": "还没有填 API Key", + "hint": "填上 Key 再测;也可以在后端的环境变量里配", "detail": str(e)} + return {"ok": False, "error": not_configured(e), "hint": "", "detail": str(e)} + + started = time.perf_counter() + try: + async with asyncio.timeout(_TEST_TIMEOUT): + reply = await chat.ainvoke(prompt) + except Exception as e: # noqa: BLE001 + reason, hint = explain(e) + return {"ok": False, "error": f"测试没通过:{reason}", "hint": hint, "detail": raw(e), + "model": model} + + usage = getattr(reply, "usage_metadata", None) or {} + return { + "ok": True, + "latency_ms": int((time.perf_counter() - started) * 1000), + "model": model, + "reply": message_text(reply)[:500], + "usage": usage, + } + + @router.post("/providers/{provider_id}/test") async def test_provider( provider_id: str, payload: TestIn, session: AsyncSession = Depends(get_session) @@ -166,33 +223,120 @@ async def test_provider( """真发一次请求验证配置。设置页的"测试连接"按钮调它。""" row = await session.get(Provider, provider_id) if not row: - raise HTTPException(404, "provider 不存在") + raise HTTPException(404, "这个模型接入不存在,可能已经被删了") + return await _try_model(row, payload.model, payload.prompt) - import time - from app.engine.state import message_text +class ProviderDraftIn(BaseModel): + """一份还没保存(或改了还没保存)的接入配置。带 id 表示在编辑已有的那个。""" + + id: str | None = None + name: str = "草稿" + kind: str = "anthropic" + base_url: str | None = None + api_key: str | None = None # 编辑时留空=沿用已保存的 + models: list[dict[str, Any]] = Field(default_factory=list) + default_model: str | None = None + extra: dict[str, Any] = Field(default_factory=dict) + #: 这次测哪个模型,不填用 default_model + model: str | None = None + prompt: str = "用一句话介绍你自己" + + +def _draft_provider(payload: ProviderDraftIn, saved: Provider | None) -> Provider: + """拼一个不进会话的临时接入点,只拿来试一下。 + + 编辑时 Key 框留空表示不改、请求头回显的是 ***——测试都得换回存着的真值, + 否则改个 Base URL 想测一下,只会得到一句"密钥不对"。 + """ + extra = dict(payload.extra or {}) + headers = extra.get("headers") + if saved and isinstance(headers, dict): + old = (saved.extra or {}).get("headers") or {} + extra["headers"] = {k: (old.get(k, v) if v == "***" else v) for k, v in headers.items()} + return Provider( + name=payload.name or (saved.name if saved else "草稿"), + kind=payload.kind, + base_url=(payload.base_url or "").strip() or None, + api_key=encrypt(payload.api_key) if payload.api_key else (saved.api_key if saved else None), + models=payload.models, + default_model=payload.default_model, + enabled=True, + extra=extra, + ) + + +async def _draft(payload: ProviderDraftIn, session: AsyncSession) -> Provider: + saved = await session.get(Provider, payload.id) if payload.id else None + return _draft_provider(payload, saved) + + +@router.post("/providers/test") +@router.post("/settings/providers/test", include_in_schema=False) +async def test_provider_draft( + payload: ProviderDraftIn, session: AsyncSession = Depends(get_session) +) -> dict[str, Any]: + """测一份没保存的配置,不落库。 + + 以前只能测已保存的:Base URL 填错要保存→关弹窗→点测试→看提示→再打开改。 + 接内网网关这种填错一个字就连不上的东西,来回四五趟。 + """ + problem = _config_problem(payload.kind, payload.base_url) + if problem: + return {"ok": False, "error": problem, "hint": "", "detail": ""} + return await _try_model(await _draft(payload, session), payload.model, payload.prompt) + + +@router.post("/providers/models") +async def probe_provider_models( + payload: ProviderDraftIn, session: AsyncSession = Depends(get_session) +) -> dict[str, Any]: + """问这个接入点有哪些模型,省得手敲模型 id。 + + 知识库那边的探测不带鉴权,接需要 Key 的网关时必然 401,所以这里单独做一份: + Key、自定义请求头都按真正调用时的方式带上。 + """ + import httpx + + problem = _config_problem(payload.kind, payload.base_url) + if problem: + return {"ok": False, "error": problem, "hint": "", "detail": "", "models": []} + provider = await _draft(payload, session) + if provider.kind == "mock": + return {"ok": True, "models": [m["id"] for m in catalog.MOCK_MODELS], "url": ""} + + extra = dict(provider.extra or {}) + # 和真正调用时取同一把钥匙:只有官方 OpenAI / Anthropic 才退回环境变量。 + # 兼容网关是别人家的服务,Key 框空着就不带——否则点一下「拉取模型」, + # 环境里那把 OpenAI 的钥匙就发给了表单上随便填的一个地址 + key = _resolve_key(provider) or "" + headers: dict[str, str] = {} + if provider.kind == "anthropic": + base = provider.base_url or os.environ.get("ANTHROPIC_BASE_URL") or "https://api.anthropic.com" + url = base.rstrip("/") + "/v1/models" + headers["anthropic-version"] = "2023-06-01" + if key: + if extra.get("auth_style") == "bearer": + headers["Authorization"] = f"Bearer {key}" + else: + headers["x-api-key"] = key + else: + url = (provider.base_url or "https://api.openai.com/v1").rstrip("/") + "/models" + if key: + headers["Authorization"] = f"Bearer {key}" + headers.update({k: str(v) for k, v in (extra.get("headers") or {}).items()}) try: - model = build_chat_model( - row, ModelSpec(model=payload.model or row.default_model, max_tokens=256) - ) - except ProviderNotConfigured as e: - return {"ok": False, "error": str(e)} - - started = time.perf_counter() - try: - reply = await model.ainvoke(payload.prompt) + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(url, headers=headers) + resp.raise_for_status() + data = resp.json() except Exception as e: # noqa: BLE001 - return {"ok": False, "error": f"{type(e).__name__}: {e}"} - - usage = getattr(reply, "usage_metadata", None) or {} - return { - "ok": True, - "latency_ms": int((time.perf_counter() - started) * 1000), - "model": payload.model or row.default_model, - "reply": message_text(reply)[:500], - "usage": usage, - } + reason, hint = explain(e) + return {"ok": False, "error": f"没拿到模型列表:{reason}", "hint": hint, + "detail": raw(e), "models": [], "url": url} + models = [m.get("id", "") for m in (data.get("data") or []) if isinstance(m, dict) and m.get("id")] + return {"ok": True, "models": models, "url": url} # -------------------------------------------------------------------------- @@ -215,6 +359,44 @@ DEFAULT_SETTINGS: dict[str, Any] = { } +async def run_defaults(session: AsyncSession) -> dict[str, Any]: + """设置里 run 这一组,没存过的键补内置默认。 + + 发起运行和引擎都从这里读。各拼各的默认值,两边迟早会对「没存过」理解得 + 不一样——以前这三项就是只存不读,设置页上改了等于没改。 + """ + row = await session.get(Setting, "run") + return {**DEFAULT_SETTINGS["run"], **((row.value if row else None) or {})} + + +def tool_approval_default(run: dict[str, Any]) -> str: + """「危险工具默认需要人工确认」换算成节点 approval 的取值,节点自己配了的不看它。 + + 只有明确存了 False 才关:值缺了、存坏了都按开着算。关掉审批得是一次明确的 + 选择,不能是某个字段没写上的副作用。 + """ + return "never" if run.get("confirm_dangerous_tools") is False else "dangerous" + + +async def resolve_run_scope( + session: AsyncSession, memory_scope: str | None, collection: str | None +) -> tuple[str, str]: + """请求没带的记忆域和知识库,取设置里的运行默认值。 + + 空串等于没填:设置页把输入框清空再保存,存下来的就是空串,拿它当记忆域 + 写进去的东西谁也读不回来。 + """ + defaults = await run_defaults(session) + + def pick(explicit: str | None, key: str) -> str: + for value in (explicit, defaults.get(key)): + if isinstance(value, str) and value.strip(): + return value.strip() + return "default" + + return pick(memory_scope, "default_memory_scope"), pick(collection, "default_collection") + + @router.get("/settings") async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str, Any]: rows = (await session.execute(select(Setting))).scalars() diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index 9c55dfdc..3f182424 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -7,11 +7,14 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.errors import explain, raw from app.db.base import get_session from app.db.models import CustomTool, McpServer from app.tools.custom import run_custom_tool from app.tools.mcp_manager import mcp_manager -from app.tools.registry import ToolContext, all_specs, call_tool +from app.tools.registry import ( + ToolArgsError, ToolContext, all_specs, build_tools, call_is_dangerous, call_tool, get_spec, +) router = APIRouter(prefix="/api/tools", tags=["tools"]) @@ -29,6 +32,9 @@ async def list_tools(session: AsyncSession = Depends(get_session)) -> list[dict[ "category": spec.category, "source": "builtin", "dangerous": spec.dangerous, + # 工作流里 approval=dangerous 的关卡认不认得它。dangerous 说的是「有副作用」, + # 这一项说的是「运行时真会停下来等审批」——两件事以前混在一个标签里 + "runtime_approval": spec.dangerous, "schema": spec.json_schema(), } ) @@ -45,12 +51,16 @@ async def list_tools(session: AsyncSession = Depends(get_session)) -> list[dict[ "category": f"自定义 · {row.kind}", "source": "custom", "dangerous": True, + # 运行时的审批关卡查不到自定义工具的危险标记,不会停下来等人—— + # 在补上之前不能让标签说它会被审批 + "runtime_approval": False, "schema": row.parameters or {}, } ) try: - out.extend({**t, "source": "mcp", "dangerous": True} for t in await mcp_manager.list_tools()) + out.extend({**t, "source": "mcp", "dangerous": True, "runtime_approval": False} + for t in await mcp_manager.list_tools()) except Exception: # noqa: BLE001 - MCP 连不上不该让整个工具列表挂掉 pass return out @@ -62,13 +72,75 @@ class RunToolIn(BaseModel): # 再加一道模式校验做纵深防御——畸形的 session 直接 422 拒掉, # 而不是悄悄被净化成 "default" 让调用方以为自己写对了。 sandbox_session: str = Field(default="playground", pattern=r"^[A-Za-z0-9_-]{1,64}$") + #: 有副作用的工具要带上它才执行。也可以写在查询串里(?confirm=true) + confirm: bool = False + + +def _builtin_effect(name: str, args: dict[str, Any]) -> str: + if name == "file_write": + path = args.get("path") or "(没填路径)" + if args.get("append"): + return f"会往 playground 工作目录里的「{path}」追加内容" + return f"会在 playground 工作目录里写入「{path}」,同名文件会被覆盖" + if name == "http_request": + return (f"会向 {args.get('url') or '(没填地址)'} 发一个 {args.get('method') or 'GET'} 请求" + "(内网和回环地址会被拦截)") + if name == "shell_exec": + return f"会在沙箱里执行命令:{str(args.get('command') or '')[:120]}" + if name == "python_exec": + return "会在沙箱里执行这段 Python 代码" + if name == "run_code": + lang = args.get("language") or "python" + return f"会在沙箱里执行这段 {lang} 代码" + (",并且允许联网" if args.get("network") else "") + return f"「{name}」有副作用" + + +async def _side_effect(name: str, args: dict[str, Any], session: AsyncSession) -> str | None: + """这次调用有副作用就说清会做什么,没有返回 None。口径和列表上的 dangerous 一致。""" + spec = get_spec(name) + if spec is not None: + return _builtin_effect(name, args) if spec.dangerous else None + if name.startswith("mcp:"): + server, _, tool = name[4:].partition("/") + return (f"会调用 MCP 服务「{server}」上的工具「{tool}」。它是外部进程," + "会做什么由它自己决定,这里预知不了") + row = (await session.execute( + select(CustomTool).where(CustomTool.name == name) + )).scalar_one_or_none() + if row is not None: + cfg = row.config or {} + if row.kind == "http": + return (f"会调用自定义接口「{name}」" + f"({cfg.get('method') or 'GET'} {cfg.get('url') or '(没填地址)'})") + return f"会在沙箱里执行自定义工具「{name}」的代码" + # 数据源工具:只读源上的查询没有副作用,可写源上的写操作有 + tools = await build_tools([name], ToolContext(run_id="playground", node_id="playground"), + session=session) + if tools and call_is_dangerous(tools[0], name, args): + return f"会在可写数据源上执行写操作:{str(args.get('sql') or '')[:120]}" + return None @router.post("/{tool_name:path}/run") async def run_tool( - tool_name: str, payload: RunToolIn, session: AsyncSession = Depends(get_session) + tool_name: str, + payload: RunToolIn, + confirm: bool = False, + session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: - """直接试跑一个工具。工具面板上的"试一下"用它,不用为了测工具去搭一张图。""" + """直接试跑一个工具。工具面板上的"试一下"用它,不用为了测工具去搭一张图。 + + 有副作用的工具先 409 说清它会做什么,带上 confirm 再来一次才执行。工作流里 + 同一个「需确认」意味着要人工审批,在这里却点一下就真跑了——同一个标签两种 + 含义,工具库里试 file_write、shell_exec 的人会以为还有一道关。 + """ + if not (confirm or payload.confirm): + effect = await _side_effect(tool_name, payload.args, session) + if effect: + # 只说后果,不说怎么确认:弹窗还是再点一次是界面的事,写死在这里 + # 放进确认框里读起来就不对 + raise HTTPException(409, f"{effect}。在工具库里执行不经过审批,确认后才会执行。") + ctx = ToolContext(run_id="playground", node_id="playground", sandbox_session=payload.sandbox_session) import time @@ -77,11 +149,18 @@ async def run_tool( try: result = await call_tool(tool_name, payload.args, ctx, session=session) except KeyError as e: - raise HTTPException(404, str(e)) from e + raise HTTPException(404, str(e.args[0] if e.args else e)) from e + except ToolArgsError as e: + # 参数对不上时报错里已经写清该填什么,原样给 + return {"ok": False, "error": str(e), "hint": "", "detail": raw(e), + "duration_ms": int((time.perf_counter() - started) * 1000)} except Exception as e: # noqa: BLE001 + reason, hint = explain(e) return { "ok": False, - "error": f"{type(e).__name__}: {e}", + "error": f"工具执行失败:{reason}", + "hint": hint, + "detail": raw(e), "duration_ms": int((time.perf_counter() - started) * 1000), } return { @@ -123,9 +202,9 @@ async def create_custom( payload: CustomToolIn, session: AsyncSession = Depends(get_session) ) -> CustomTool: if (await session.execute(select(CustomTool).where(CustomTool.name == payload.name))).scalar_one_or_none(): - raise HTTPException(409, "同名工具已存在") + raise HTTPException(409, f"已经有叫「{payload.name}」的工具了,换个名字") if payload.name in all_specs(): - raise HTTPException(409, "这个名字和内置工具冲突") + raise HTTPException(409, f"「{payload.name}」和内置工具重名了,换个名字") row = CustomTool(**payload.model_dump()) session.add(row) await session.commit() @@ -139,7 +218,7 @@ async def update_custom( ) -> CustomTool: row = await session.get(CustomTool, tool_id) if not row: - raise HTTPException(404, "工具不存在") + raise HTTPException(404, "这个工具不存在,可能已经被删了") for key, value in payload.model_dump().items(): setattr(row, key, value) await session.commit() @@ -151,7 +230,7 @@ async def update_custom( async def delete_custom(tool_id: str, session: AsyncSession = Depends(get_session)) -> None: row = await session.get(CustomTool, tool_id) if not row: - raise HTTPException(404, "工具不存在") + raise HTTPException(404, "这个工具不存在,可能已经被删了") await session.delete(row) await session.commit() @@ -162,12 +241,13 @@ async def test_custom( ) -> dict[str, Any]: row = await session.get(CustomTool, tool_id) if not row: - raise HTTPException(404, "工具不存在") + raise HTTPException(404, "这个工具不存在,可能已经被删了") ctx = ToolContext(run_id="playground", node_id="test", sandbox_session="playground") try: result = await run_custom_tool(session, row.name, payload.args, ctx) except Exception as e: # noqa: BLE001 - return {"ok": False, "error": f"{type(e).__name__}: {e}"} + reason, hint = explain(e) + return {"ok": False, "error": f"工具执行失败:{reason}", "hint": hint, "detail": raw(e)} return {"ok": True, "result": result} @@ -207,7 +287,7 @@ async def create_server( payload: McpIn, session: AsyncSession = Depends(get_session) ) -> McpServer: if (await session.execute(select(McpServer).where(McpServer.name == payload.name))).scalar_one_or_none(): - raise HTTPException(409, "同名 MCP server 已存在") + raise HTTPException(409, f"已经有叫「{payload.name}」的 MCP 服务了,换个名字") row = McpServer(**payload.model_dump()) session.add(row) await session.commit() @@ -221,7 +301,7 @@ async def update_server( ) -> McpServer: row = await session.get(McpServer, server_id) if not row: - raise HTTPException(404, "MCP server 不存在") + raise HTTPException(404, "这个 MCP 服务不存在,可能已经被删了") for key, value in payload.model_dump().items(): setattr(row, key, value) await session.commit() @@ -233,7 +313,7 @@ async def update_server( async def delete_server(server_id: str, session: AsyncSession = Depends(get_session)) -> None: row = await session.get(McpServer, server_id) if not row: - raise HTTPException(404, "MCP server 不存在") + raise HTTPException(404, "这个 MCP 服务不存在,可能已经被删了") await session.delete(row) await session.commit() @@ -244,7 +324,7 @@ async def probe_server( ) -> dict[str, Any]: row = await session.get(McpServer, server_id) if not row: - raise HTTPException(404, "MCP server 不存在") + raise HTTPException(404, "这个 MCP 服务不存在,可能已经被删了") result = await mcp_manager.probe(row) row.status = "ok" if result.get("ok") else "error" row.last_error = result.get("error") diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py index 499c09ce..b800abc7 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -7,6 +7,8 @@ from pydantic import BaseModel, Field from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.errors import graph_error +from app.api.runs import actor_of from app.core.artifact_store import graph_hash from app.db.base import get_session from app.db.models import Run, Workflow, WorkflowVersion @@ -15,6 +17,8 @@ from app.engine.schema import GraphSpec, validate_graph router = APIRouter(prefix="/api/workflows", tags=["workflows"]) +_GONE = "这个工作流不存在,可能已经被删了" + class WorkflowIn(BaseModel): name: str = Field(min_length=1, max_length=200) @@ -41,6 +45,7 @@ class WorkflowOut(BaseModel): is_template: bool status: str = "draft" published_version: int | None = None + published_by: str | None = None created_at: Any = None updated_at: Any = None run_count: int = 0 @@ -91,7 +96,7 @@ async def get_workflow( ) -> Workflow: workflow = await session.get(Workflow, workflow_id) if not workflow: - raise HTTPException(404, "工作流不存在") + raise HTTPException(404, _GONE) return workflow @@ -101,7 +106,7 @@ async def update_workflow( ) -> Workflow: workflow = await session.get(Workflow, workflow_id) if not workflow: - raise HTTPException(404, "工作流不存在") + raise HTTPException(404, _GONE) if payload.name is not None: workflow.name = payload.name @@ -138,7 +143,16 @@ async def delete_workflow( ) -> None: workflow = await session.get(Workflow, workflow_id) if not workflow: - raise HTTPException(404, "工作流不存在") + raise HTTPException(404, _GONE) + # 运行记录是 ON DELETE SET NULL:删掉工作流,已结束的运行留着、照样能核对封存; + # 还在跑的那次会失去归属,再也回不到它的画布,所以和删除运行记录一样先停再删 + live = await session.scalar( + select(func.count()).select_from(Run).where( + Run.workflow_id == workflow_id, Run.status.in_(("queued", "running")) + ) + ) + if live: + raise HTTPException(409, f"这个工作流还有 {live} 次运行在执行,现在删除它们会失去归属。先停止运行,再删除") await session.delete(workflow) await session.commit() @@ -149,7 +163,7 @@ async def duplicate_workflow( ) -> Workflow: source = await session.get(Workflow, workflow_id) if not source: - raise HTTPException(404, "工作流不存在") + raise HTTPException(404, _GONE) copy = Workflow( name=f"{source.name} 副本", description=source.description, @@ -183,13 +197,24 @@ async def list_versions( return list(rows.scalars()) -@router.post("/{workflow_id}/versions/{version}/restore", response_model=WorkflowOut) -async def restore_version( +class VersionDetailOut(VersionOut): + workflow_id: str + graph: dict[str, Any] + graph_hash: str | None = None + #: 这一版入口节点声明的字段。正式运行跑的是已发布的那一版,表单得照它来填—— + #: 画布上改过入口字段时,拿当前画布的字段去跑已发布版本会传错参数 + input_fields: list[dict[str, Any]] = Field(default_factory=list) + published: bool = False + + +@router.get("/{workflow_id}/versions/{version}", response_model=VersionDetailOut) +async def get_version( workflow_id: str, version: int, session: AsyncSession = Depends(get_session) -) -> Workflow: +) -> VersionDetailOut: + """取某一版的完整图。""" workflow = await session.get(Workflow, workflow_id) if not workflow: - raise HTTPException(404, "工作流不存在") + raise HTTPException(404, _GONE) snapshot = ( await session.execute( select(WorkflowVersion).where( @@ -199,7 +224,41 @@ async def restore_version( ) ).scalar_one_or_none() if not snapshot: - raise HTTPException(404, "版本不存在") + raise HTTPException(404, f"「{workflow.name}」没有 v{version} 这个版本") + + graph = snapshot.graph or {} + fields: list[dict[str, Any]] = [] + for node in graph.get("nodes") or []: + if node.get("type") == "input": + fields.extend( + f for f in ((node.get("data") or {}).get("config") or {}).get("fields") or [] + if isinstance(f, dict) + ) + return VersionDetailOut( + id=snapshot.id, version=snapshot.version, note=snapshot.note, + created_at=snapshot.created_at, workflow_id=workflow_id, graph=graph, + graph_hash=snapshot.graph_hash or graph_hash(graph), input_fields=fields, + published=workflow.published_version == snapshot.version, + ) + + +@router.post("/{workflow_id}/versions/{version}/restore", response_model=WorkflowOut) +async def restore_version( + workflow_id: str, version: int, session: AsyncSession = Depends(get_session) +) -> Workflow: + workflow = await session.get(Workflow, workflow_id) + if not workflow: + raise HTTPException(404, _GONE) + snapshot = ( + await session.execute( + select(WorkflowVersion).where( + WorkflowVersion.workflow_id == workflow_id, + WorkflowVersion.version == version, + ) + ) + ).scalar_one_or_none() + if not snapshot: + raise HTTPException(404, f"没有 v{version} 这个版本") workflow.graph = snapshot.graph workflow.version += 1 @@ -236,10 +295,10 @@ async def publish_workflow( 返回 200 + ok/issues 而不是抛错,让前端能把问题列表渲染出来。 """ if payload.level not in ("published", "governed"): - raise HTTPException(400, "level 只能是 published 或 governed") + raise HTTPException(400, "发布档位只能选「已发布」或「受管」") workflow = await session.get(Workflow, workflow_id) if not workflow: - raise HTTPException(404, "工作流不存在") + raise HTTPException(404, _GONE) version = payload.version or workflow.version snapshot = ( @@ -251,12 +310,12 @@ async def publish_workflow( ) ).scalar_one_or_none() if not snapshot: - raise HTTPException(404, f"版本 v{version} 不存在,先保存一次") + raise HTTPException(404, f"没有 v{version} 这个版本:画布上的改动还没保存,先保存一次再发布") try: spec = GraphSpec.model_validate(snapshot.graph) except Exception as e: # noqa: BLE001 - return {"ok": False, "issues": [{"level": "error", "message": f"图结构非法:{e}"}]} + return {"ok": False, "issues": [{"level": "error", "message": f"工作流的结构读不懂:{graph_error(e)}"}]} issues = [i.model_dump() for i in validate_graph(spec).issues] issues += [i.model_dump() for i in lint_for_publish(spec, level=payload.level).issues] @@ -267,7 +326,7 @@ async def publish_workflow( snapshot.graph_hash = graph_hash(snapshot.graph) workflow.status = payload.level workflow.published_version = version - workflow.published_by = (x_actor or "").strip() or None + workflow.published_by = actor_of(x_actor) await session.commit() return { "ok": True, @@ -289,7 +348,7 @@ async def validate(payload: ValidateIn) -> dict[str, Any]: try: spec = GraphSpec.model_validate(payload.graph) except Exception as e: # noqa: BLE001 - return {"ok": False, "issues": [{"level": "error", "message": f"图结构非法:{e}"}]} + return {"ok": False, "issues": [{"level": "error", "message": f"工作流的结构读不懂:{graph_error(e)}"}]} result = validate_graph(spec) return result.model_dump() @@ -306,5 +365,5 @@ async def variables(payload: ValidateIn) -> dict[str, Any]: try: spec = GraphSpec.model_validate(payload.graph) except Exception as e: # noqa: BLE001 - return {"variables": [], "issues": [{"level": "error", "message": f"图结构非法:{e}"}]} + return {"variables": [], "issues": [{"level": "error", "message": f"工作流的结构读不懂:{graph_error(e)}"}]} return analyze(spec).model_dump() diff --git a/backend/app/core/events.py b/backend/app/core/events.py index f2c82f80..821a6ba4 100644 --- a/backend/app/core/events.py +++ b/backend/app/core/events.py @@ -51,6 +51,10 @@ class EventType(StrEnum): # 多 agent 协作中的单个 agent 步骤 AGENT_STEP_START = "agent.step.start" AGENT_STEP_END = "agent.step.end" + # 调度者的一次决策。它是一次结构化模型调用,常常占掉团队节点大半的时间; + # 以前前后都没有事件,界面上那几十秒是黑盒 + AGENT_ROUTE_START = "agent.route.start" + AGENT_ROUTE_END = "agent.route.end" # 人工介入 HUMAN_REQUESTED = "human.requested" diff --git a/backend/app/db/base.py b/backend/app/db/base.py index a090f459..3d4c1eae 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -7,6 +7,7 @@ from typing import Any, AsyncIterator from sqlalchemy import JSON, DateTime, MetaData, event from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy.types import TypeDecorator from app.core.config import settings @@ -26,18 +27,46 @@ def new_id() -> str: return uuid.uuid4().hex +class UTCDateTime(TypeDecorator): + """存 UTC、读出来也是带时区的 UTC。 + + SQLite 没有时区类型:DateTime(timezone=True) 写进去的只是一串不带偏移的 + 字面时间,读回来是 naive 的。API 于是吐出 '2026-09-26T01:11:19',浏览器把 + 不带偏移的 ISO 串当本地时间解析,运行列表整体慢了 8 小时。在这一层补上 UTC, + pydantic 序列化自然带 +00:00,所有接口一次修好。 + + 写入前先换算成 UTC 再去掉时区:SQLite 方言格式化时直接丢弃 tzinfo, + 一个 +08:00 的时间原样写进去,读回来就差出 8 小时。 + """ + + impl = DateTime + cache_ok = True + + def process_bind_param(self, value: datetime | None, dialect: Any) -> datetime | None: + if value is None: + return None + if value.tzinfo is not None: + value = value.astimezone(timezone.utc).replace(tzinfo=None) + return value + + def process_result_value(self, value: datetime | None, dialect: Any) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + class Base(DeclarativeBase): metadata = MetaData(naming_convention=NAMING_CONVENTION) # 图定义、节点配置、事件负载都是任意形状的 JSON,统一映射到 SQLite 的 JSON 列 - type_annotation_map = {dict[str, Any]: JSON, list[Any]: JSON} + type_annotation_map = {dict[str, Any]: JSON, list[Any]: JSON, datetime: UTCDateTime} class TimestampMixin: - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=utcnow, onupdate=utcnow - ) + created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow) def _stop_aiosqlite_once() -> None: @@ -109,6 +138,12 @@ _COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [ ("runs", "manifest_hash", "VARCHAR(64)"), ("runs", "manifest_seq", "INTEGER"), ("runs", "started_by", "VARCHAR(100)"), + # 续跑、恢复要沿用第一次发起时的记忆域、知识库和审批默认值;以前只在第一段 + # _drive 里传过一次,恢复后回落成 "default",同一次运行前后两段查的不是同一个库 + ("runs", "memory_scope", "VARCHAR(100)"), + ("runs", "collection", "VARCHAR(100)"), + ("runs", "approval_default", "VARCHAR(20)"), + ("runs", "error_node_id", "VARCHAR(64)"), ("workflows", "status", "TEXT DEFAULT 'draft'"), ("workflows", "published_version", "INTEGER"), ("workflows", "published_by", "VARCHAR(100)"), diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 533c8786..30b3d0e4 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -4,11 +4,11 @@ from datetime import datetime from typing import Any from sqlalchemy import ( - DateTime, Float, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint, + Float, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint, ) from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.db.base import Base, TimestampMixin, new_id, utcnow +from app.db.base import Base, TimestampMixin, UTCDateTime, new_id, utcnow # -------------------------------------------------------------------------- # 设置:模型供应商 / 用户偏好 / MCP / 自定义工具 @@ -180,6 +180,13 @@ class Run(Base, TimestampMixin): # 为空的是记录这一列之前封存的老运行,核对时按当时的口径算 manifest_seq: Mapped[int | None] = mapped_column(Integer, default=None) started_by: Mapped[str | None] = mapped_column(String(100), default=None) + # 第一次发起时实际生效的记忆域、知识库、工具审批默认值。恢复和接着跑沿用它们, + # 而不是回落到 "default"——同一次运行前后两段必须查同一个库、守同一道门 + memory_scope: Mapped[str | None] = mapped_column(String(100), default=None) + collection: Mapped[str | None] = mapped_column(String(100), default=None) + approval_default: Mapped[str | None] = mapped_column(String(20), default=None) + # 失败时能定位到的节点。报错要能落到画布上的那张卡片,而不只是一句话 + error_node_id: Mapped[str | None] = mapped_column(String(64), default=None) events: Mapped[list["RunEvent"]] = relationship( back_populates="run", cascade="all, delete-orphan" @@ -388,9 +395,7 @@ class Conversation(Base, TimestampMixin): archived: Mapped[bool] = mapped_column(default=False) # 列表按活跃度排。不能用 updated_at —— 改个标题就会把这条顶到最前面, # 而用户对"最近聊过的"的预期是按说话时间排,不是按编辑时间 - last_active_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=utcnow, index=True - ) + last_active_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, index=True) turns: Mapped[list["ConversationTurn"]] = relationship( back_populates="conversation", diff --git a/backend/app/engine/compiler.py b/backend/app/engine/compiler.py index aeeccddc..acdb11b1 100644 --- a/backend/app/engine/compiler.py +++ b/backend/app/engine/compiler.py @@ -10,8 +10,9 @@ from langgraph.graph import END, START, StateGraph from app.core.events import EventType from app.engine.context import NodeContext, NodeError, RunContext +from app.engine.errors import describe_exception, raw_detail from app.engine.nodes import control, human, io, knowledge, llm, metrics, multi, tools -from app.engine.schema import GraphNode, GraphSpec, NodeType, back_edges +from app.engine.schema import GraphNode, GraphSpec, NodeType, back_edges, innermost_loops from app.engine.state import GraphState NodeRunner = Callable[[GraphState, NodeContext], Awaitable[dict[str, Any]]] @@ -40,11 +41,13 @@ RUNNERS: dict[NodeType, NodeRunner] = { ROUTING_TYPES = {NodeType.BRANCH, NodeType.LOOP} -def _wrap(node: GraphNode, run_ctx: RunContext) -> Callable[[GraphState], Awaitable[dict[str, Any]]]: +def _wrap( + node: GraphNode, run_ctx: RunContext, loop_id: str | None = None, +) -> Callable[[GraphState], Awaitable[dict[str, Any]]]: """给节点执行器套上事件、计时、重试和错误处理。 画布上看到的每一次高亮、每一条耗时,都来自这层包装; - 执行器本身只管自己那点业务逻辑。 + 执行器本身只管自己那点业务逻辑。loop_id 是直接包住它的那层循环。 """ runner = RUNNERS.get(node.type) ctx = NodeContext(node=node, run=run_ctx) @@ -66,7 +69,8 @@ def _wrap(node: GraphNode, run_ctx: RunContext) -> Callable[[GraphState], Awaita retries = int(node.config.get("retries", 0) or 0) backoff = float(node.config.get("retry_backoff", 1.0) or 1.0) started = time.perf_counter() - ctx.emit(EventType.NODE_STARTED, node_type=str(node.type), label=node.title) + ctx.emit(EventType.NODE_STARTED, node_type=str(node.type), label=node.title, + **_started_context(node.id, state, run_ctx, loop_id)) last_error: Exception | None = None for attempt in range(retries + 1): @@ -140,8 +144,10 @@ def _wrap(node: GraphNode, run_ctx: RunContext) -> Callable[[GraphState], Awaita elapsed = int((time.perf_counter() - started) * 1000) message = _describe(last_error) - # node_id 要带上:前端靠它把出错的节点从"转圈"收敛成"失败" - ctx.emit(EventType.NODE_FAILED, error=message, duration_ms=elapsed, node_id=node.id) + # node_id 要带上:前端靠它把出错的节点从"转圈"收敛成"失败"。 + # error 是给人看的那句,原始异常放 detail,排查的人照样拿得到 + ctx.emit(EventType.NODE_FAILED, error=message, duration_ms=elapsed, node_id=node.id, + detail=_detail(last_error)) # 容错模式:记下错误继续往下走,而不是让整张图挂掉 if node.config.get("on_error") == "continue": @@ -162,12 +168,39 @@ def _wrap(node: GraphNode, run_ctx: RunContext) -> Callable[[GraphState], Awaita def _describe(error: BaseException | None) -> str: - """错误消息。NodeError 的 message 本来就是写给人看的,不用再前缀类型名。""" + """错误消息。NodeError 的 message 本来就是写给人看的;别的异常翻成一句原因, + 不带类型名和内部符号——界面上出现过「_make_query_tool.._run() got an + unexpected keyword argument」这种首行报错。""" if error is None: return "未知错误" if isinstance(error, NodeError): return str(error) - return f"{type(error).__name__}: {error}" + return f"执行出错:{describe_exception(error)}" + + +def _detail(error: BaseException | None) -> str | None: + """原始异常。NodeError 自己是人话,真正的原因在它包住的那个异常里。""" + if isinstance(error, NodeError): + return raw_detail(error.__cause__) if error.__cause__ is not None else None + return raw_detail(error) + + +def _started_context( + node_id: str, state: GraphState, run_ctx: RunContext, loop_id: str | None, +) -> dict[str, Any]: + """node.started 附带的上下文:在循环的第几轮,以及是不是恢复后的那次重放。""" + out: dict[str, Any] = {} + if loop_id: + mark = (state.get("loops") or {}).get(loop_id) + # 循环节点决定进 body 时已经把 index 推进到"这一轮的序号"(从 1 数) + if isinstance(mark, dict) and not mark.get("finished") and mark.get("index"): + out["iteration"] = int(mark["index"]) + pending = run_ctx.extra.get("resumed") + if isinstance(pending, set) and node_id in pending: + # 只有恢复后第一次跑到它才算重放;同一次运行里之后再进来是正常执行 + pending.discard(node_id) + out["resumed"] = True + return out def _vars_preview(updates: dict[str, Any]) -> dict[str, Any] | None: @@ -277,9 +310,11 @@ def compile_graph(spec: GraphSpec, run_ctx: RunContext) -> StateGraph: builder = StateGraph(GraphState) node_map = spec.node_map() joins = _join_nodes(spec, node_map) + enclosing = innermost_loops(spec) for node in spec.nodes: - builder.add_node(node.id, _wrap(node, run_ctx), defer=node.id in joins) + builder.add_node(node.id, _wrap(node, run_ctx, enclosing.get(node.id)), + defer=node.id in joins) # 入口 entries = spec.entry_nodes() diff --git a/backend/app/engine/context.py b/backend/app/engine/context.py index edff3309..81251823 100644 --- a/backend/app/engine/context.py +++ b/backend/app/engine/context.py @@ -24,6 +24,9 @@ class RunContext: depth: int = 0 memory_scope: str = "default" collection: str = "default" + #: 工具审批策略的全局默认(设置里的「危险工具默认需要人工确认」)。节点和图级 + #: defaults 都没配 approval 时才用它;为空表示老运行,按节点各自的缺省走 + approval_default: str | None = None extra: dict[str, Any] = field(default_factory=dict) @@ -72,6 +75,18 @@ class NodeContext: return self.run.spec.defaults.get(key, default) return value + def approval_mode(self, fallback: str = "dangerous") -> str: + """工具审批策略:节点 > 图级 defaults > 全局设置 > 节点类型自己的缺省。 + + 审批恢复时节点会整个重放、再算一遍要不要问人,所以这里只能取决于 + 这次运行固定下来的东西——全局默认在发起时就定死在 RunContext 上了。 + """ + return self.cfg("approval") or self.run.approval_default or fallback + + def actor(self) -> str | None: + """回复这个节点审批的人(设置里的署名)。没署名、或者不是恢复出来的,就是 None。""" + return (self.run.extra.get("actors") or {}).get(self.node.id) + class NodeError(RuntimeError): """节点执行失败。带上 node_id 方便前端定位到具体的卡片。""" diff --git a/backend/app/engine/errors.py b/backend/app/engine/errors.py new file mode 100644 index 00000000..57d71f0d --- /dev/null +++ b/backend/app/engine/errors.py @@ -0,0 +1,81 @@ +"""把异常翻成给人看的一句话。 + +界面上出现过的原样报错:「工具 db_query__shop 执行失败:_make_query_tool.._run() +got an unexpected keyword argument 'query'」「TypeError: Integer exceeds 64-bit range」。 +问数据的业务用户读不懂这些,也不知道下一步该做什么。 + +这里只管"原因"那半句,不带异常类名和内部符号;"发生了什么"和"怎么办"由调用方 +按场景拼。原始异常另外放进事件的 detail 字段、写进日志,排查的人照样拿得到。 +""" +from __future__ import annotations + +import json +import re + +# 供应商 SDK / httpx 的网络类异常。按类名认而不 import:这些包不一定都装了, +# 而这里只需要知道"是不是连不上" +_NETWORK = {"ConnectError", "ConnectTimeout", "APIConnectionError", "RemoteProtocolError", + "ReadError", "NetworkError"} +_TIMEOUT = {"ReadTimeout", "WriteTimeout", "PoolTimeout", "APITimeoutError", "TimeoutException"} + +# 内部符号:`a.b.._run()`、``、模块路径。对使用者没有意义 +_LOCALS = re.compile(r"[\w.]*\.?[\w.]*(\(\))?") +_REPR = re.compile(r"<(?:function|bound method|coroutine|object) [^>]*>") +# 包在消息里的异常类名("连不上:ConnectError: All connection attempts failed") +_CLASS = re.compile(r"\b[A-Z]\w*(?:Error|Exception|Warning)\b:\s*") + + +def _names(exc: BaseException) -> set[str]: + return {cls.__name__ for cls in type(exc).__mro__} + + +def _scrub(text: str) -> str: + text = _LOCALS.sub("内部函数", text) + text = _REPR.sub("内部对象", text) + text = _CLASS.sub("", text) + return " ".join(text.split())[:240] + + +def describe_exception(exc: BaseException) -> str: + """原因,一句话。常见的几类写成固定说法,其余的去掉内部符号后照实说。""" + names = _names(exc) + text = str(exc).strip() + + status = getattr(exc, "status_code", None) + if isinstance(status, int): + if status in (401, 403): + return f"鉴权没通过({status}):key 无效、过期,或没有这个模型的权限" + if status == 429: + return "请求太频繁,或者额度用完了(429)" + if status >= 500: + return f"对方服务出错了({status}),多半是暂时的" + + if isinstance(exc, TypeError): + if m := re.search(r"unexpected keyword argument '(\w+)'", text): + return f"参数对不上:它不接受名为「{m.group(1)}」的参数" + if m := re.search(r"missing \d+ required (?:positional |keyword-only )?arguments?: (.+)$", text): + wanted = "、".join(re.findall(r"'(\w+)'", m.group(1))) or m.group(1) + return f"缺少必填参数「{wanted}」" + if isinstance(exc, TimeoutError) or names & _TIMEOUT: + return "等待超时:对方没有在限定时间内响应" + if isinstance(exc, ConnectionError) or names & _NETWORK: + return "连不上对方服务:网络不通,或者服务没有启动" + if isinstance(exc, PermissionError): + return "没有权限访问需要的文件或资源" + if isinstance(exc, FileNotFoundError): + return f"找不到文件{'「' + str(exc.filename) + '」' if exc.filename else ''}" + if isinstance(exc, json.JSONDecodeError): + return "返回的内容不是合法的 JSON" + if isinstance(exc, KeyError): + return f"缺少字段「{exc.args[0] if exc.args else '?'}」" + if isinstance(exc, (OverflowError, MemoryError)): + return f"数据超出了能处理的范围{':' + _scrub(text) if text else ''}" + return _scrub(text) or "没有给出原因的内部错误" + + +def raw_detail(exc: BaseException | None) -> str | None: + """原始异常,给排查的人看。只进事件的 detail 字段和日志,不进首行报错。""" + if exc is None: + return None + text = str(exc).strip() + return f"{type(exc).__name__}: {text}"[:2000] if text else type(exc).__name__ diff --git a/backend/app/engine/governance.py b/backend/app/engine/governance.py index 4754949f..bb1b712e 100644 --- a/backend/app/engine/governance.py +++ b/backend/app/engine/governance.py @@ -5,7 +5,7 @@ from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.engine.schema import GraphSpec, NodeType, ValidationResult +from app.engine.schema import GraphSpec, NodeType, ValidationResult, type_label # 口径升版的三种合法处置。没有默认值是刻意的:升版最容易静默翻车的地方 # 就是"上周和这周的数其实不可比,但报表看起来一切正常"。 @@ -34,30 +34,35 @@ def lint_for_publish(spec: GraphSpec, *, level: str) -> ValidationResult: has_contract_output = False for node in spec.nodes: cfg = node.config + # 文案用画布上的叫法:节点标题 +(节点类型),参数写界面上的选项文字 + who = f"「{node.title}」({type_label(node.type)})" if node.type == NodeType.SUPERVISOR: # 全动态规划属于探索层。受管模板里出现它,等于把 XAgent 搬上了生产线。 flag( - "受管模板不允许 supervisor 节点(全动态规划属于探索层)", + f"{who}:受管模板不允许全动态规划的节点——谁来做、做几轮都由模型临场" + "决定,属于探索层。改用固定编排的 Agent 或「模型调用」节点", node_id=node.id, hard=True, ) elif node.type == NodeType.SUBGRAPH: if not cfg.get("workflow_version"): flag( - "子图(方法卡)引用没有钉版本,口径会随上游最新版漂移", + f"{who}没有钉住版本,口径会随上游最新版漂移。在节点里选定一个版本", node_id=node.id, hard=True, ) elif node.type == NodeType.AGENT: tools = cfg.get("tools") or [] if cfg.get("approval") == "never" and tools: flag( - "agent 关闭了全部工具审批(approval=never),受管模板要求危险工具至少人工确认", + f"{who}的审批策略是「全部自动放行」,受管模板要求危险工具至少人工确认。" + "改成「仅危险工具需要确认」", node_id=node.id, hard=True, ) if not tools: - flag("agent 没有配任何工具,通常意味着它该是个 llm 节点", node_id=node.id) + flag(f"{who}没有配任何可用工具,通常意味着它该是个「模型调用」节点", + node_id=node.id) elif len(tools) > 10: flag( - f"工具白名单有 {len(tools)} 个,边界过宽(白名单衰减的前兆)", + f"{who}的可用工具有 {len(tools)} 个,边界过宽(白名单衰减的前兆)", node_id=node.id, ) elif node.type == NodeType.OUTPUT: @@ -67,11 +72,12 @@ def lint_for_publish(spec: GraphSpec, *, level: str) -> ValidationResult: _lint_contract(contract, node_id=node.id, spec=spec, flag=flag, strict=strict) elif node.type == NodeType.CODE: if cfg.get("network"): - flag("代码节点开了网络,受管模板建议把取数收敛到工具节点(有快照)", node_id=node.id) + flag(f"{who}打开了「允许联网」:受管模板建议把取数收敛到「调用工具」节点" + "(那里有取数快照)", node_id=node.id) if strict and not has_contract_output: result.add( - "受管模板的 output 节点必须声明出具契约(contract),否则三档出具无从谈起", + "受管模板至少要有一个「成果 / 出具」节点声明出具契约,否则三档出具无从谈起", level="error", ) return result @@ -88,7 +94,7 @@ def _lint_contract( "这张图过过闸"的错觉。 """ if not isinstance(contract, dict): - flag("contract 必须是对象", node_id=node_id, hard=True) + flag("出具契约必须是一个 JSON 对象", node_id=node_id, hard=True) return metric_nodes = {n.id for n in spec.nodes if n.type == NodeType.METRICS} @@ -97,26 +103,27 @@ def _lint_contract( sources = [sources] if not sources: - flag("契约没有声明 metrics_from,叙述里的数字无从回指", node_id=node_id, hard=True) + flag("出具契约没有声明 metrics_from(指标来自哪个「口径卡」节点),叙述里的数字无从回指", + node_id=node_id, hard=True) for src in sources: if src not in metric_nodes: flag( - f"契约的 metrics_from 指向 {src!r},但图里没有这个口径卡节点" + f"出具契约的 metrics_from 指向 {src!r},但工作流里没有这个「口径卡」节点" "(写错了,或者那个节点还没建)", node_id=node_id, hard=True, ) if not str(contract.get("narrative") or "").strip(): - flag("契约没有声明 narrative,数字回指校验不会执行", node_id=node_id, hard=True) + flag("出具契约没有声明 narrative(叙述),数字回指校验不会执行", node_id=node_id, hard=True) if strict and not (contract.get("required") or []): flag( - "受管模板的契约必须声明 required 指标,否则'必需指标缺失'这一档永远触发不了", + "受管模板的出具契约必须声明 required(必需指标),否则「不予出具」这一档永远触发不了", node_id=node_id, hard=True, ) if strict and not contract.get("strict"): flag( - "受管模板建议把契约设为 strict:非 strict 下未回指的数字只降档不拦截", + "受管模板建议把出具契约设为 strict:非 strict 下未回指的数字只降档不拦截", node_id=node_id, ) @@ -152,8 +159,8 @@ async def unresolved_caliber_upgrades( policy = node.config.get("upgrade_policy") if policy not in UPGRADE_POLICIES: errors.append( - f"节点 {node.title}({node.id})钉在方法卡 v{pinned},但已有 v{latest}。" - f"formal 运行前必须声明 upgrade_policy:" + f"「{node.title}」(子工作流)钉在 v{pinned},但上游已有 v{latest}。" + f"正式运行前必须在节点上声明升版策略(upgrade_policy):" + " / ".join(f"{k}({v})" for k, v in UPGRADE_POLICY_LABELS.items()) ) else: diff --git a/backend/app/engine/issuance.py b/backend/app/engine/issuance.py index 998a25cf..862542df 100644 --- a/backend/app/engine/issuance.py +++ b/backend/app/engine/issuance.py @@ -406,7 +406,7 @@ def decide_tier( withheld(不予出具):必需指标缺失,或 strict 下有未回指数字—— 结论的地基不完整,宁可不出。 degraded(降档出具):期望指标缺失、有未回指数字,或校验本身没能完整跑起来。 - formal(正式出具):清单齐、数字全部可回指,**且校验确实发生过**。 + formal(完整出具):清单齐、数字全部可回指,**且校验确实发生过**。 最后那句是这个函数最容易出错的地方:missing/unmatched 全空既可能是 "查过了都对",也可能是"根本没查"——叙述模板里一个笔误让 narrative 渲染成 diff --git a/backend/app/engine/nodes/human.py b/backend/app/engine/nodes/human.py index c50ee12e..0e9e0841 100644 --- a/backend/app/engine/nodes/human.py +++ b/backend/app/engine/nodes/human.py @@ -34,7 +34,8 @@ async def run_human(state: GraphState, ctx: NodeContext) -> dict[str, Any]: ctx.emit(EventType.HUMAN_REQUESTED, **payload) response = interrupt(payload) - ctx.emit(EventType.HUMAN_RESOLVED, response=response) + # 谁批的跟着事件走:时间线要写得出"张工驳回了",没署名就是 None,不再默认写"你" + ctx.emit(EventType.HUMAN_RESOLVED, response=response, actor=ctx.actor()) decision = read_decision(response) if mode == "approve": diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index 3ec591e6..fecb7b96 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -148,6 +148,11 @@ def _apply_contract( 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 @@ -173,7 +178,11 @@ async def run_transform(state: GraphState, ctx: NodeContext) -> dict[str, Any]: try: output = json.loads(rendered) except json.JSONDecodeError as e: - raise NodeError(ctx.node.id, f"渲染结果不是合法 JSON:{e}") from e + raise NodeError( + ctx.node.id, + f"模板渲染出来的不是合法 JSON(第 {e.lineno} 行第 {e.colno} 列附近)。" + "检查模板里的引号、逗号,字符串值要用 | json 过滤器输出", + ) from e else: raise NodeError(ctx.node.id, f"未知的整形模式:{mode}") diff --git a/backend/app/engine/nodes/llm.py b/backend/app/engine/nodes/llm.py index f094f820..88c002b9 100644 --- a/backend/app/engine/nodes/llm.py +++ b/backend/app/engine/nodes/llm.py @@ -16,6 +16,7 @@ from app.db.base import SessionLocal from app.db.models import Skill from app.engine.approval import read_decision from app.engine.context import NodeContext, NodeError +from app.engine.errors import describe_exception from app.engine.state import GraphState, message_text, template_context, thinking_text from app.providers import catalog from app.providers.factory import ( @@ -140,7 +141,8 @@ def explain_model_error(exc: Exception, model_id: str) -> str: ) if "insufficient" in text.lower() or "quota" in text.lower(): return f"模型「{model_id}」的额度用完了:{text[:200]}" - return f"调用模型「{model_id}」失败:{type(exc).__name__}: {text[:300]}" + return (f"调用模型「{model_id}」失败:{describe_exception(exc)}。" + "稍后重试;反复失败就去「设置 → 模型接入」测一下这个模型") async def _invoke_streaming( @@ -231,7 +233,11 @@ async def run_llm(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 模型压根不存在也会走到这里,那不是"结构化输出失败" if "invalid_model" in str(e) or "model does not exist" in str(e).lower(): raise NodeError(ctx.node.id, explain_model_error(e, model_id)) from e - raise NodeError(ctx.node.id, f"结构化输出失败:{type(e).__name__}: {e}") from e + raise NodeError( + ctx.node.id, + f"模型没能按「结构化输出 Schema」给出结果:{describe_exception(e)}。" + "检查 Schema 是否写对,或者换一个支持结构化输出的模型", + ) from e payload = value if isinstance(value, (dict, list)) else getattr(value, "model_dump", lambda: value)() output = {"data": payload, "text": json.dumps(payload, ensure_ascii=False, indent=2)} response: BaseMessage = AIMessage(content=output["text"]) @@ -314,7 +320,7 @@ def split_tool_calls( def _needs_approval(ctx: NodeContext, tool: BaseTool, args: dict[str, Any]) -> bool: - mode = ctx.cfg("approval", "dangerous") # never | dangerous | always + mode = ctx.approval_mode() # never | dangerous | always if mode == "always": return True if mode == "never": @@ -347,17 +353,30 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: total_usage: dict[str, Any] = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0, "calls": 0} transcript: list[dict[str, Any]] = [] + def _report(response: BaseMessage, started: float) -> None: + # 每次模型调用一条 llm.end:用量在节点跑的过程中就看得到。发在 task 里面, + # 节点因审批重放时 task 结果取自 checkpoint,这条不会重复 + ctx.emit(EventType.LLM_END, agent=ctx.node.title, model=model_id, + duration_ms=int((time.perf_counter() - started) * 1000), + **_usage_of(response, model_id)) + # @task 的返回值会进 checkpoint:人工审批导致节点重放时, # 之前已经完成的模型调用和工具执行不会重跑,也就不会重复计费。 @task async def llm_step(step: int, payload: list[BaseMessage]) -> BaseMessage: - return await _invoke_streaming(model, payload, ctx, model_id) + started = time.perf_counter() + response = await _invoke_streaming(model, payload, ctx, model_id) + _report(response, started) + return response @task async def settle_step(payload: list[BaseMessage]) -> BaseMessage: # 用 base_model 而不是上面那个绑过工具的 model:收尾轮必须在**结构上** # 发不出工具调用,靠提示词说"别调工具"是约束不住的 - return await _invoke_streaming(base_model, payload, ctx, model_id) + started = time.perf_counter() + response = await _invoke_streaming(base_model, payload, ctx, model_id) + _report(response, started) + return response @task async def tool_step(step: int, name: str, args: dict[str, Any]) -> str: @@ -429,7 +448,8 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: ) verdict = read_decision(decision) note = verdict.note - ctx.emit(EventType.HUMAN_RESOLVED, tool=name, approved=verdict.approved, note=note) + ctx.emit(EventType.HUMAN_RESOLVED, tool=name, approved=verdict.approved, note=note, + actor=ctx.actor()) if not verdict.approved: messages.append( ToolMessage( @@ -448,7 +468,7 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: content = await tool_step(step, name, args) ok = True except Exception as e: # noqa: BLE001 - 工具失败要喂回模型,让它自己纠错 - content = f"工具执行失败:{type(e).__name__}: {e}" + content = f"工具执行失败:{describe_exception(e)}" if isinstance(e, TypeError) and schema is not None: # 签名对不上还能走到这儿,说明 schema 和函数本身不一致(工具的 bug)。 # 模型改不了这个,但把参数表摊开至少让它别在同一个地方反复试 @@ -507,7 +527,7 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: settled = message_text(response).strip() except Exception as e: # noqa: BLE001 - 收尾失败不能把已有成果一起赔进去 ctx.emit(EventType.LOG, level="warn", - message=f"收尾轮没跑成:{type(e).__name__}: {e}", code="settle_failed") + message=f"收尾轮没跑成:{describe_exception(e)}", code="settle_failed") if settled: final_text = settled diff --git a/backend/app/engine/nodes/multi.py b/backend/app/engine/nodes/multi.py index 5c6fb5c8..56894acb 100644 --- a/backend/app/engine/nodes/multi.py +++ b/backend/app/engine/nodes/multi.py @@ -14,6 +14,7 @@ from app.core.events import EventType from app.db.base import SessionLocal from app.db.models import Workflow from app.engine.context import NodeContext, NodeError +from app.engine.errors import describe_exception from app.engine.state import GraphState, message_text, template_context from app.providers import catalog from app.providers.factory import ModelSpec, bind_tools_safely, get_chat_model @@ -28,6 +29,9 @@ from app.tools.registry import ( _MAX_DEPTH = 3 +#: 调度者在 llm.end 里的 agent 名。成员用各自的名字 +COORDINATOR = "调度者" + # -------------------------------------------------------------------------- # Supervisor:一个调度者 + 若干专家 @@ -100,20 +104,28 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: #: 而每多一路就多一份"基于陈旧进展"的风险 max_parallel = max(1, min(int(ctx.cfg("max_parallel", 3) or 3), len(names))) #: dangerous:需要人工确认的调用不执行(成员停不下来等人);never:全部放行 - approval = ctx.cfg("approval", "dangerous") + approval = ctx.approval_mode() transcript: list[dict[str, Any]] = [] usage_total = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0, "calls": 0} - def _accumulate(msg: Any, model_id: str) -> None: + def _usage(msg: Any, model_id: str) -> dict[str, Any]: meta = getattr(msg, "usage_metadata", None) or {} i, o = int(meta.get("input_tokens") or 0), int(meta.get("output_tokens") or 0) - usage_total["input_tokens"] += i - usage_total["output_tokens"] += o + return {"input_tokens": i, "output_tokens": o, + "cost_usd": round(catalog.estimate_cost(model_id, i, o), 6)} + + def _accumulate(one: dict[str, Any]) -> None: + usage_total["input_tokens"] += one["input_tokens"] + usage_total["output_tokens"] += one["output_tokens"] usage_total["calls"] += 1 - usage_total["cost_usd"] = round( - usage_total["cost_usd"] + catalog.estimate_cost(model_id, i, o), 6 - ) + usage_total["cost_usd"] = round(usage_total["cost_usd"] + one["cost_usd"], 6) + + def _report(who: str, model_id: str, one: dict[str, Any], t0: float) -> None: + # 每次模型调用各发一条 llm.end,界面上的用量才能实时涨,而且分得清是谁花的。 + # 它只是把 usage_total 这笔账逐次摊开:run.finished 的 usage 仍以累计为准 + ctx.emit(EventType.LLM_END, agent=who, model=model_id, + duration_ms=int((time.perf_counter() - t0) * 1000), **one) @task async def route(round_no: int, progress: str) -> dict[str, Any]: @@ -132,17 +144,33 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: "旧信息重复劳动,而这在结果里很难看出来。\n\n" "目标已经达成时,done 填 true、assignments 给空数组。" ) + t0 = time.perf_counter() + spent = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0} try: - value = await supervisor_model.with_structured_output(route_schema).ainvoke(prompt) + # include_raw:结构化结果之外把原始消息也要回来,用量在那条消息上。 + # 以前只拿解析结果,调度者的 token 一笔都没记进 usage + out = await supervisor_model.with_structured_output( + route_schema, include_raw=True).ainvoke(prompt) + if isinstance(out, dict) and "raw" in out and "parsed" in out: + spent = _usage(out["raw"], sup_model_id) + value = out["parsed"] + if value is None: + value = json.loads(message_text(out["raw"])) + else: + value = out if not isinstance(value, dict): value = json.loads(message_text(value)) except Exception: # noqa: BLE001 # 不支持结构化输出的模型退回文本:从回复里认出一个成员名,按单人派 - raw = message_text(await supervisor_model.ainvoke(prompt)) + reply = await supervisor_model.ainvoke(prompt) + spent = _usage(reply, sup_model_id) + raw = message_text(reply) picked = next((n for n in names if n in raw), None) value = ({"assignments": [{"agent": picked, "instruction": raw[:500]}]} if picked else {"assignments": [], "done": True, "reason": raw[:200]}) - return value + # 在 task 里发:节点重放时 task 结果取自 checkpoint,这条不会重复 + _report(COORDINATOR, sup_model_id, spent, t0) + return {"decision": value, "usage": spent} @task async def work(round_no: int, name: str, instruction: str, progress: str) -> dict[str, Any]: @@ -175,8 +203,11 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 和 agent 节点同一个硬顶:成员上配多少步都过不去 max_agent_steps max_steps = min(int(cfg.get("max_steps") or 4), settings.max_agent_steps) for _ in range(max_steps): + t0 = time.perf_counter() reply = await bound.ainvoke(messages) - _accumulate(reply, model_id) + spent = _usage(reply, model_id) + _accumulate(spent) + _report(name, model_id, spent, t0) messages.append(reply) tool_calls = getattr(reply, "tool_calls", None) or [] if not tool_calls: @@ -222,7 +253,7 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: raw = await tool_map[tname].ainvoke(targs) content = raw if isinstance(raw, str) else json.dumps(raw, ensure_ascii=False, default=str) except Exception as e: # noqa: BLE001 - content = f"工具失败:{type(e).__name__}: {e}" + content = f"工具失败:{describe_exception(e)}" ctx.emit(EventType.TOOL_END, tool=tname, agent=name, call_id=cid, preview=content[:1500]) calls.append({"tool": tname, "args": targs, "result": content[:2000]}) messages.append(ToolMessage(content=content, tool_call_id=cid)) @@ -236,12 +267,15 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: "信息给出你这部分的结论,说清哪些没查到、结论因此有什么局限。不要编造数据。" ))) try: + t0 = time.perf_counter() settled = await model.ainvoke(messages) - _accumulate(settled, model_id) + spent = _usage(settled, model_id) + _accumulate(spent) + _report(name, model_id, spent, t0) text = message_text(settled).strip() except Exception as e: # noqa: BLE001 - 收尾失败不能把已有的过程一起赔进去 ctx.emit(EventType.LOG, level="warn", code="settle_failed", - message=f"{name} 的收尾轮没跑成:{type(e).__name__}: {e}") + message=f"{name} 的收尾轮没跑成:{describe_exception(e)}") text = next((t for m in reversed(messages) if isinstance(m, AIMessage) and (t := message_text(m).strip())), "") ctx.emit(EventType.LOG, level="warn", code="step_limit_settled", @@ -253,8 +287,13 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: rounds_meta: list[dict[str, Any]] = [] for round_no in range(max_rounds): progress = "\n\n".join(progress_lines) - decision = await route(round_no, progress) - _accumulate(AIMessage(content=""), sup_model_id) # route 用了一次调用 + # 调度者决策前后各一条事件。它常常占掉团队节点大半的时间,以前是黑盒 + ctx.emit(EventType.AGENT_ROUTE_START, round=round_no) + route_t0 = time.perf_counter() + routed = await route(round_no, progress) + route_ms = int((time.perf_counter() - route_t0) * 1000) + decision = routed.get("decision", routed) if isinstance(routed, dict) else {} + _accumulate(routed.get("usage") or _usage(None, sup_model_id)) reason = str(decision.get("reason", "")) # 只留认得出的成员,并按上限截断。模型偶尔会重复派同一个人或者编一个 @@ -269,7 +308,12 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: if len(batch) >= max_parallel: break - if decision.get("done") or not batch: + finishing = bool(decision.get("done")) or not batch + ctx.emit(EventType.AGENT_ROUTE_END, round=round_no, duration_ms=route_ms, + agents=[] if finishing else [n for n, _ in batch], + parallel=0 if finishing else len(batch), done=finishing, reason=reason) + + if finishing: # agents / done 是结构化字段,界面照着它渲染。以前只有一句中文 # 消息串,解码层得拿正则去拆「调度 → X(理由)」——文案一改就散架 ctx.emit(EventType.LOG, level="info", round=round_no, @@ -295,31 +339,35 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # # 每个人单独计时,而不是事后拿整轮墙钟去摊:界面要显示"并行省了多少 # 时间",那个数必须是量出来的。按 N×墙钟 推算等于假设每个人都跑满了 - # 最慢那条,而实际上快的那个可能只花了三分之一 - async def _timed(name: str, instruction: str) -> tuple[str, Any, int]: + # 最慢那条,而实际上快的那个可能只花了三分之一。 + # + # 结束事件也在这里一完成就发,不等整轮 gather:以前先做完的成员要一直 + # 显示"进行中",直到最慢的那个做完——矩阵答不了"谁还在跑" + async def _timed(name: str, instruction: str, parallel: int) -> tuple[str, Any, int]: t0 = time.perf_counter() try: out: Any = await work(round_no, name, instruction, progress) - except BaseException as exc: # noqa: BLE001 - 交给下面统一处置 - out = exc - return name, out, int((time.perf_counter() - t0) * 1000) - - started = time.perf_counter() - results = await asyncio.gather(*(_timed(n, i) for n, i in batch)) - wall_ms = int((time.perf_counter() - started) * 1000) - - sum_ms = 0 - for (name, instruction), (_, outcome, each_ms) in zip(batch, results): - if isinstance(outcome, BaseException): - # 一个专家挂了不该拖垮整轮:把失败当成它的产出交回调度者, - # 让它决定改派还是收工 - text = f"({name} 执行失败:{type(outcome).__name__}: {outcome})" - outcome = {"text": text, "tool_calls": []} + except asyncio.CancelledError: + raise + except BaseException as exc: # noqa: BLE001 - 一个专家挂了不该拖垮整轮 + # 把失败当成它的产出交回调度者,让它决定改派还是收工 + text = f"({name} 执行失败:{describe_exception(exc)})" + out = {"text": text, "tool_calls": []} ctx.emit(EventType.LOG, level="warn", round=round_no, message=f"{name} 这一轮失败了:{text}") - sum_ms += each_ms + each_ms = int((time.perf_counter() - t0) * 1000) ctx.emit(EventType.AGENT_STEP_END, agent=name, duration_ms=each_ms, - round=round_no, parallel=len(batch), preview=outcome["text"][:500]) + round=round_no, parallel=parallel, preview=out["text"][:500]) + return name, out, each_ms + + started = time.perf_counter() + results = await asyncio.gather(*(_timed(n, i, len(batch)) for n, i in batch)) + wall_ms = int((time.perf_counter() - started) * 1000) + + # 进展仍按派活顺序拼:它是下一轮调度者读的输入,语义不随谁先回来而变 + sum_ms = 0 + for (name, instruction), (_, outcome, each_ms) in zip(batch, results): + sum_ms += each_ms progress_lines.append(f"【{name}】{outcome['text']}") transcript.append( {"round": round_no, "agent": name, "instruction": instruction, **outcome, @@ -371,11 +419,12 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: from app.engine.schema import GraphSpec, loop_steps if ctx.run.depth >= _MAX_DEPTH: - raise NodeError(ctx.node.id, f"子图嵌套超过 {_MAX_DEPTH} 层,已阻止") + raise NodeError(ctx.node.id, f"子工作流嵌套超过 {_MAX_DEPTH} 层,已阻止。" + "多半是几张工作流互相引用了,检查「子工作流」节点选的是哪一张") workflow_id = ctx.cfg("workflow_id") if not workflow_id: - raise NodeError(ctx.node.id, "子图节点没有选择工作流") + raise NodeError(ctx.node.id, "「子工作流」节点还没选要嵌套的工作流") # 版本钉死:这就是"方法卡"的机制核心。钉了 workflow_version 就永远 # 执行那个不可变快照,上游改了方法卡也不会让这里的口径悄悄漂移; @@ -384,7 +433,8 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: async with SessionLocal() as session: workflow = await session.get(Workflow, workflow_id) if not workflow: - raise NodeError(ctx.node.id, f"找不到工作流 {workflow_id}") + raise NodeError(ctx.node.id, f"「子工作流」引用的工作流({workflow_id})已经不在了," + "可能被删除了。在节点里重新选一张") if pinned: from sqlalchemy import select @@ -421,11 +471,14 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: depth=ctx.run.depth + 1, memory_scope=ctx.run.memory_scope, collection=ctx.run.collection, - extra={**ctx.run.extra, "node_prefix": f"{ctx.node.id}/"}, + approval_default=ctx.run.approval_default, + # 恢复重放的标记和签批人只属于父图的节点,不能漏进子图(节点 id 可能重名) + extra={**{k: v for k, v in ctx.run.extra.items() if k not in ("resumed", "actors")}, + "node_prefix": f"{ctx.node.id}/"}, ) ctx.emit(EventType.LOG, level="info", - message=f"进入子图「{workflow.name}」({len(sub_spec.nodes)} 个节点)") + message=f"进入子工作流「{workflow.name}」({len(sub_spec.nodes)} 个节点)") compiled = compile_graph(sub_spec, sub_run) # 子图不带 checkpointer:它的断点由父图的 checkpoint 统一承载 @@ -462,7 +515,7 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: "pinned": bool(pinned), "text": json.dumps(output, ensure_ascii=False) if output else "", } - ctx.emit(EventType.LOG, level="info", message=f"子图「{workflow.name}」完成") + ctx.emit(EventType.LOG, level="info", message=f"子工作流「{workflow.name}」完成") updates: dict[str, Any] = { "nodes": {ctx.node.id: result}, diff --git a/backend/app/engine/nodes/tools.py b/backend/app/engine/nodes/tools.py index b223c8da..10690ead 100644 --- a/backend/app/engine/nodes/tools.py +++ b/backend/app/engine/nodes/tools.py @@ -10,6 +10,7 @@ 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.errors import describe_exception, raw_detail from app.engine.state import GraphState from app.sandbox.base import SandboxLimits from app.sandbox.manager import sandbox_manager @@ -55,7 +56,8 @@ async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]: if not isinstance(args, dict): raise NodeError(ctx.node.id, "工具参数必须是对象") - approval = ctx.cfg("approval", "dangerous") + # 节点没配时取全局设置(「危险工具默认需要人工确认」),见 NodeContext.approval_mode + approval = ctx.approval_mode() if approval == "always" or (approval == "dangerous" and await _is_dangerous(name, args, ctx)): ctx.emit(EventType.HUMAN_REQUESTED, mode="approve", tool=name, args=args, title=f"是否允许调用 {name}?") @@ -66,7 +68,7 @@ async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]: if decision.args is not None: args = decision.args ctx.emit(EventType.HUMAN_RESOLVED, tool=name, approved=decision.approved, - note=decision.note) + note=decision.note, actor=ctx.actor()) if not decision.approved: raise NodeError(ctx.node.id, f"用户拒绝执行工具 {name}" + (f":{decision.note}" if decision.note else "")) @@ -84,7 +86,9 @@ async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]: async with SessionLocal() as session: result = await call_tool(name, args, _tool_ctx(ctx), session=session, on_fix=_fixed) except KeyError as e: - raise NodeError(ctx.node.id, str(e)) from e + # str(KeyError) 是带引号的 repr,直接拼会多出一对引号 + reason = e.args[0] if e.args else f"找不到工具 {name!r}" + raise NodeError(ctx.node.id, f"{reason}。工具可能被删除或改了名字,在节点里重新选一个") from e except ToolArgsError as e: # 参数对不上且没有唯一候选可纠。报错里已经写清楚该填什么, # 不要再套一层 "执行失败:" 把它推远 @@ -93,13 +97,19 @@ async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]: raise NodeError(ctx.node.id, f"工具 {name}:{e}") from e result = {"error": str(e)} except Exception as e: # noqa: BLE001 - ctx.emit(EventType.TOOL_ERROR, tool=name, error=f"{type(e).__name__}: {e}") + # 首行写原因、不带异常类名和内部符号;原始异常进 detail 给排查的人 + reason = describe_exception(e) + ctx.emit(EventType.TOOL_ERROR, tool=name, error=reason, detail=raw_detail(e)) if ctx.cfg("fail_fast", True): - raise NodeError(ctx.node.id, f"工具 {name} 执行失败:{e}") from e - result = {"error": f"{type(e).__name__}: {e}"} + raise NodeError( + ctx.node.id, + f"工具 {name} 执行失败:{reason}。检查节点里填的参数;参数没问题的话," + "是这个工具本身出了错,换一个工具或联系管理员", + ) from e + result = {"error": reason, "detail": raw_detail(e)} elapsed = int((time.perf_counter() - started) * 1000) - # 取数快照:query(args)和结果集一起进工件库,正式出具时数字回指的就是它 + # 取数快照:query(args)和结果集一起进工件库,完整出具时数字回指的就是它 from app.core.artifact_store import put_json try: @@ -142,7 +152,8 @@ async def run_code(state: GraphState, ctx: NodeContext) -> dict[str, Any]: )) if decision.code: code = decision.code # 允许人工改完再跑 - ctx.emit(EventType.HUMAN_RESOLVED, approved=decision.approved, note=decision.note) + ctx.emit(EventType.HUMAN_RESOLVED, approved=decision.approved, note=decision.note, + actor=ctx.actor()) if not decision.approved: raise NodeError(ctx.node.id, "用户拒绝执行代码" + (f":{decision.note}" if decision.note else "")) diff --git a/backend/app/engine/runner.py b/backend/app/engine/runner.py index bcb27a18..1af49c1f 100644 --- a/backend/app/engine/runner.py +++ b/backend/app/engine/runner.py @@ -20,6 +20,7 @@ from app.db.base import SessionLocal from app.db.models import Approval, Run, RunEvent, Workflow from app.engine.compiler import compile_graph, initial_state from app.engine.context import NodeError, RunContext +from app.engine.errors import describe_exception, raw_detail from app.engine.schema import GraphSpec, loop_steps, topology_of, validate_graph logger = logging.getLogger(__name__) @@ -54,6 +55,9 @@ class RunManager: # 一个 checkpoint 都没写过,"恢复"它只会从 START 用默认值重跑一遍, # 还丢掉 run.input——那不是恢复,是伪造一份结果。 async with SessionLocal() as session: + stranded = list((await session.execute( + select(Run.id, Run.last_seq).where(Run.status == "running") + )).all()) await session.execute( update(Run) .where(Run.status == "running") @@ -65,6 +69,14 @@ class RunManager: .values(status="failed", error="服务重启时这次运行还在排队,没有可恢复的进度,请重新发起") ) await session.commit() + # 进程被强杀时连 server_shutdown 日志都来不及发,事件流停在半路。补上这一条, + # 回放它的客户端才知道它是挂起了,而不是还在跑 + for run_id, last_seq in stranded: + bus.set_seq(run_id, last_seq or 0) + await self._emit(run_id, EventType.LOG, data={ + "level": "warn", "code": "server_shutdown", + "message": "服务上次没有正常关停,这次运行停在了半路;可以从断点接着跑", + }) async def shutdown(self) -> None: self._closing = True @@ -97,6 +109,7 @@ class RunManager: node_id: str | None = None, data: dict[str, Any] | None = None, persist: bool = True, + ts: float | None = None, ) -> None: event = RunEventModel( run_id=run_id, @@ -104,6 +117,9 @@ class RunManager: type=EventType(str(event_type)), node_id=node_id, data=data or {}, + # 节点里发出的事件带着它发生的时刻。以前一律取转发时刻:并行的成员 + # 先做完的那条也要排到整轮结束才被转发,时间线上的耗时就全错了 + **({"ts": ts} if ts else {}), ) # 先落库,再广播。顺序反过来会漏事件,而且是结构性的漏: # @@ -155,13 +171,17 @@ class RunManager: version: int | None = None, version_hash: str | None = None, started_by: str | None = None, + approval_default: str | None = None, ) -> Run: spec = GraphSpec.model_validate(graph) report = validate_graph(spec) if not report.ok: raise ValueError( - "图校验未通过:" + ";".join(i.message for i in report.issues if i.level == "error") + "工作流没有通过校验,先在画布上修好标红的节点再运行:" + + ";".join(i.message for i in report.issues if i.level == "error") ) + if approval_default is None: + approval_default = await _approval_default(run_class=run_class) async with SessionLocal() as session: run = Run( @@ -174,6 +194,9 @@ class RunManager: version=version, version_hash=version_hash, started_by=started_by, + memory_scope=memory_scope, + collection=collection, + approval_default=approval_default, ) run.thread_id = run.id # 一个 run 一条 checkpoint 线程 session.add(run) @@ -188,6 +211,7 @@ class RunManager: workflow_id=workflow_id, memory_scope=memory_scope, collection=collection, + approval_default=approval_default, ) ) return run @@ -214,9 +238,10 @@ class RunManager: if not run: raise KeyError(f"找不到运行 {run_id}") if run.status not in ("interrupted",): - raise ValueError(f"当前状态是 {run.status},无法恢复") + raise ValueError(_cannot_resume(run.status)) spec = GraphSpec.model_validate(run.graph) workflow_id = run.workflow_id + carried = _carried(run) # 先问引擎:这条线程到底停在哪、还等着哪些 interrupt run_ctx = RunContext(run_id=run_id, thread_id=run_id, spec=spec, workflow_id=workflow_id) @@ -247,13 +272,13 @@ class RunManager: if approval_id: target = next((a for a in pending if a.id == approval_id), None) if target is None: - raise ValueError(f"审批 {approval_id} 不在待处理列表里") + raise ValueError("这条审批已经处理过了,或者不属于这次运行。刷新看看最新状态") elif len(pending) == 1: target = pending[0] elif len(pending) > 1: raise ValueError( - f"这次运行有 {len(pending)} 条待处理的人工介入," - "恢复时必须指定 approval_id 说明回复的是哪一条" + f"这次运行有 {len(pending)} 条待处理的审批," + "得说明回复的是哪一条(approval_id),请在对应的审批卡上操作" ) now = datetime.now(timezone.utc) @@ -298,10 +323,13 @@ class RunManager: run = await session.get(Run, run_id) return run - # 齐了:正式落 resolved,然后驱动引擎 + # 齐了:正式落 resolved,然后驱动引擎。谁批的跟着交给节点, + # human.resolved 里才写得出签批人 + actors: dict[str, str | None] = {} for rec in answered: if rec.status == "answered": rec.status = "resolved" + actors[rec.node_id] = rec.resolved_by run = await session.get(Run, run_id) bus.set_seq(run_id, run.last_seq) run.status = "running" @@ -323,11 +351,13 @@ class RunManager: else: command = Command(resume=answers) await self._emit(run_id, EventType.RUN_RESUMED, data=( - {"message": "服务重启时中断的运行,从断点接着跑"} if command is None - else {"response": _safe(response)} + {"message": "服务重启时中断的运行,从断点接着跑", "actor": actor} if command is None + else {"response": _safe(response), "actor": actor} )) self._tasks[run_id] = asyncio.create_task( - self._drive(run_id, spec, command, workflow_id=workflow_id) + self._drive(run_id, spec, command, workflow_id=workflow_id, **carried, + resumed=[str(n) for n in (getattr(snapshot, "next", None) or ())], + actors=actors) ) return run @@ -344,18 +374,24 @@ class RunManager: graph 可以带一张改过的图,但只准改配置:节点 id、类型、连线必须和原来 一模一样。checkpoint 是按节点名存的,改了结构就对不上,续下去会拿着 错位的通道状态跑出一份似是而非的结果——那比直接报错糟得多。 + + 服务重启挂起的运行(interrupted 且没有待审批)也走这里:它没有在等谁, + 断点完好,从断点驱动和失败后接着跑是同一件事。界面上两者都叫「接着跑」。 """ async with SessionLocal() as session: run = await session.get(Run, run_id) if not run: raise KeyError(f"找不到运行 {run_id}") - if run.status != "failed": - raise ValueError( - f"当前状态是 {run.status},只有失败的运行能接着跑。" - "(等人工介入的用恢复,已完成的重新发起一次。)" - ) + has_pending = run.status == "interrupted" and (await session.execute( + select(Approval.id).where( + Approval.run_id == run_id, Approval.status.in_(["pending", "answered"]) + ).limit(1) + )).first() is not None + if not (run.status == "failed" or (run.status == "interrupted" and not has_pending)): + raise ValueError(_cannot_continue(run.status, has_pending)) spec = GraphSpec.model_validate(run.graph) workflow_id = run.workflow_id + carried = _carried(run) if graph is not None: revised = GraphSpec.model_validate(graph) @@ -406,10 +442,11 @@ class RunManager: # 分不清这次是接着跑还是整张图又跑了一遍 "message": f"从「{labels.get(pending[0], pending[0])}」接着跑" + (f",前面 {len(done)} 个节点的结果保留" if done else ""), + "actor": actor, }, ) self._tasks[run_id] = asyncio.create_task( - self._drive(run_id, spec, None, workflow_id=workflow_id) + self._drive(run_id, spec, None, workflow_id=workflow_id, **carried, resumed=pending) ) return run @@ -424,6 +461,12 @@ class RunManager: task = self._tasks.get(run_id) return bool(task and not task.done()) + async def wait_idle(self, run_id: str, timeout: float = 10.0) -> None: + """等这次运行手上的执行任务收完尾。不取消它,也不抛它的异常。""" + task = self._tasks.get(run_id) + if task is not None and not task.done(): + await asyncio.wait({task}, timeout=timeout) + # ---------------- 执行 ---------------- async def _drive( @@ -435,27 +478,41 @@ class RunManager: workflow_id: str | None = None, memory_scope: str = "default", collection: str = "default", + approval_default: str | None = None, + resumed: list[str] | tuple[str, ...] = (), + actors: dict[str, str | None] | None = None, ) -> None: started = time.perf_counter() status = "succeeded" error: str | None = None final_state: dict[str, Any] = {} + #: 终态事件(run.failed / run.cancelled)等 _finalize 算完耗时再发,timing 才带得上 + terminal: tuple[EventType, dict[str, Any]] | None = None + error_node: str | None = None deadline: asyncio.Timeout | None = None async with self._semaphore: try: async with SessionLocal() as session: - await session.execute( - update(Run) - .where(Run.id == run_id) - .values(status="running", started_at=datetime.now(timezone.utc)) - ) - await session.commit() + row = await session.get(Run, run_id) + if row is not None: + row.status = "running" + # started_at 只在第一次开始时写。以前每段 _drive 都覆盖它, + # 审批恢复、接着跑之后,运行的"开始时间"就成了最后一段的开始 + if row.started_at is None: + row.started_at = datetime.now(timezone.utc) + row.finished_at = None + row.error_node_id = None + await session.commit() await self._emit( run_id, EventType.RUN_STARTED, data={"nodes": len(spec.nodes), - "resumed": payload is None or isinstance(payload, Command)}, + "resumed": payload is None or isinstance(payload, Command), + # 实际生效的运行参数进事件流,也就进了封存清单: + # 正式运行查的是哪个库、审批默认是什么,事后可复核 + "memory_scope": memory_scope, "collection": collection, + "approval_default": approval_default}, ) run_ctx = RunContext( @@ -465,6 +522,8 @@ class RunManager: workflow_id=workflow_id, memory_scope=memory_scope, collection=collection, + approval_default=approval_default, + extra={"resumed": set(resumed), "actors": dict(actors or {})}, ) app = compile_graph(spec, run_ctx).compile(checkpointer=self.checkpointer) # 循环按自己声明的轮数另记一份预算,全局上限只管没人把关的环 @@ -510,7 +569,7 @@ class RunManager: else: status = "cancelled" error = "用户取消" - await self._emit(run_id, EventType.RUN_CANCELLED, data={}) + terminal = (EventType.RUN_CANCELLED, {}) raise except TimeoutError as e: status = "failed" @@ -519,10 +578,10 @@ class RunManager: f"这次执行超过了 {settings.max_run_seconds} 秒的上限,已中止。" "跑得慢的多半是模型或工具没有响应;确实需要更久,调大 AGENTLAB_MAX_RUN_SECONDS" ) + terminal = (EventType.RUN_FAILED, {"error": error}) else: # 别处抛的超时,不是这道上限——按普通失败说 - error = f"{type(e).__name__}: {e}" + error, terminal = _failure(e, spec) logger.exception("run %s 失败", run_id) - await self._emit(run_id, EventType.RUN_FAILED, data={"error": error}) except GraphRecursionError: status = "failed" # LangGraph 的原文是英文,还叫人去调 recursion_limit——用户碰不到那个键。 @@ -534,19 +593,18 @@ class RunManager: "多半是有个环在空转:分支连回了上游、却没有 loop 节点给它定轮数上限。" "用 loop 节点包住它;确实需要这么多步,调大 AGENTLAB_MAX_GRAPH_STEPS" ) - await self._emit(run_id, EventType.RUN_FAILED, data={"error": error}) + terminal = (EventType.RUN_FAILED, {"error": error}) except Exception as e: # noqa: BLE001 status = "failed" - # NodeError 的 message 本来就是写给人看的("人工驳回:…"), - # 再套一层类型名只会让界面上的错误更难读 - error = str(e) if isinstance(e, NodeError) else f"{type(e).__name__}: {e}" + error, terminal = _failure(e, spec) + error_node = terminal[1].get("node_id") logger.exception("run %s 失败", run_id) - await self._emit(run_id, EventType.RUN_FAILED, data={"error": error}) finally: elapsed = int((time.perf_counter() - started) * 1000) self._finalizing.add(run_id) try: - await self._finalize(run_id, status, error, final_state, elapsed) + await self._finalize(run_id, status, error, final_state, elapsed, + terminal=terminal, error_node=error_node) finally: # 收尾被打断(关停时的取消、库连接出错)也得把自己摘掉。以前写在 # _finalize 后面,一被打断就漏掉,留下的任务让下一次 shutdown 的 @@ -572,6 +630,7 @@ class RunManager: chunk.get("type", EventType.LOG), node_id=chunk.get("node_id"), data=chunk.get("data") or {}, + ts=chunk.get("ts"), ) async def _handle_updates(self, run_id: str, chunk: Any) -> bool: @@ -624,34 +683,54 @@ class RunManager: error: str | None, state: dict[str, Any], elapsed_ms: int, + *, + terminal: tuple[EventType, dict[str, Any]] | None = None, + error_node: str | None = None, ) -> None: usage = dict(state.get("usage") or {}) - usage["duration_ms"] = elapsed_ms output = dict(state.get("output") or {}) + now = datetime.now(timezone.utc) + timing = {"wall_ms": elapsed_ms, "active_ms": elapsed_ms, "wait_ms": 0} async with SessionLocal() as session: run = await session.get(Run, run_id) if run: + timing = await _timing(session, run, elapsed_ms, now) + # duration_ms 是各段执行时长之和(= active_ms)。以前用这一段的耗时 + # 覆盖它:87 秒、中间等过一次审批的运行,列表上写着 22ms + usage.update(duration_ms=timing["active_ms"], **timing) run.status = status run.error = error run.output = output run.usage = usage + run.error_node_id = error_node if status != "interrupted": - run.finished_at = datetime.now(timezone.utc) + run.finished_at = now await session.commit() if status == "succeeded": - await self._emit( - run_id, - EventType.RUN_FINISHED, - data={"output": _safe(output), "usage": usage, "duration_ms": elapsed_ms}, - ) + # 事件里的 output 仍然截断(事件体积要控制),但截了就要说:消费方据此 + # 回源 GET /api/runs/{id} 取全文。以前截得悄无声息,问数据页把 2000 字处 + # 切断的半截答案当成完整结论落了库 + clipped, truncated = _clip(output) + data: dict[str, Any] = {"output": clipped, "usage": usage, + "duration_ms": timing["active_ms"], "timing": timing} + if truncated: + data["output_truncated"] = True + await self._emit(run_id, EventType.RUN_FINISHED, data=data) + elif terminal is not None: + kind, data = terminal + await self._emit(run_id, kind, data={**data, "timing": timing}) if status != "interrupted": # 终态事件落库之后再封存。以前在 run.finished 之前算,清单里恰恰少了 # 那条宣布"跑完了、成果是什么"的事件——改它的成果,清单照样对得上。 # 中断态不封:事件还会继续追加 await self._seal(run_id) - if status in ("succeeded", "failed", "cancelled"): + if status in ("succeeded", "failed", "cancelled") or ( + status == "interrupted" and self._closing + ): + # 关停时挂起的也要让在线的客户端收尾:进程马上就没了,不说一声, + # 界面会一直以为它还在跑 await bus.close(run_id) async def _seal(self, run_id: str) -> None: @@ -690,7 +769,7 @@ async def verify_manifest(run_id: str) -> dict[str, Any]: raise KeyError(f"找不到运行 {run_id}") if not run.manifest_hash: return {"sealed": False, "ok": None, - "message": "这次运行还没有封存(没跑完,或者停在人工介入)"} + "message": "这次运行还没有封存(没跑完,或者停在人工审批)"} rows = [tuple(r) for r in await session.execute( select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data) .where(RunEvent.run_id == run_id) @@ -717,15 +796,138 @@ async def verify_manifest(run_id: str) -> dict[str, Any]: def _safe(value: Any, limit: int = 4000) -> Any: """把任意对象裁剪成能安全放进事件负载的形状。""" + return _clip(value, limit)[0] + + +def _clip(value: Any, limit: int = 4000) -> tuple[Any, bool]: + """同 _safe,另外说出有没有真的截掉东西。""" if isinstance(value, str): - return value[:limit] - if isinstance(value, dict): - return {k: _safe(v, limit // 2) for k, v in list(value.items())[:50]} - if isinstance(value, list): - return [_safe(v, limit // 2) for v in value[:50]] + return value[:limit], len(value) > limit + if isinstance(value, (dict, list)): + items = list(value.items()) if isinstance(value, dict) else list(enumerate(value)) + cut = len(items) > 50 + kept: list[tuple[Any, Any]] = [] + for key, item in items[:50]: + clipped, lost = _clip(item, limit // 2) + kept.append((key, clipped)) + cut = cut or lost + return (dict(kept) if isinstance(value, dict) else [v for _, v in kept]), cut if isinstance(value, (int, float, bool)) or value is None: - return value - return str(value)[:limit] + return value, False + text = str(value) + return text[:limit], len(text) > limit + + +_STATUS_TEXT = { + "queued": "还在排队", "running": "正在运行", "interrupted": "停在断点上", + "succeeded": "已完成", "failed": "失败了", "cancelled": "已取消", +} + + +def _cannot_resume(status: str) -> str: + hint = { + "running": "它还在跑,不需要恢复。", + "queued": "它还没开始跑。", + "succeeded": "要再跑一次,请重新发起运行。", + "failed": "失败的运行用「接着跑」从出错的节点继续。", + "cancelled": "已取消的运行请重新发起。", + }.get(status, "") + return f"这次运行{_STATUS_TEXT.get(status, status)},没有在等审批,不能恢复。{hint}" + + +def _cannot_continue(status: str, has_pending: bool) -> str: + """接着跑被拒时说清楚为什么、下一步做什么。 + + 以前一律套一句「在等待审批,只有失败的运行能接着跑」:服务重启挂起的也被说成 + 在等审批,还让人去一张不存在的审批卡上处理;已取消的只说不行,不说该怎么办。 + """ + if status == "interrupted" and has_pending: + return "这次运行在等审批,不是失败了,不用接着跑。在审批卡上放行或驳回,它会自己往下走。" + return { + "cancelled": "这次运行已取消,不能接着跑。要继续,请重新发起一次运行。", + "succeeded": "这次运行已完成,没有要接着跑的——只有失败的运行或被服务重启打断的运行" + "能接着跑。要再跑一次,请重新发起运行。", + "running": "这次运行正在运行,不需要接着跑。", + "queued": "这次运行还在排队,还没开始跑,不需要接着跑。", + }.get(status, f"这次运行{_STATUS_TEXT.get(status, status)}," + "只有失败的运行或被服务重启打断的运行能接着跑。") + + +def _carried(run: Run) -> dict[str, Any]: + """第一次发起时定下的运行参数。恢复、接着跑都沿用,老运行没记的按缺省。""" + return { + "memory_scope": run.memory_scope or "default", + "collection": run.collection or "default", + "approval_default": run.approval_default, + } + + +def _failure(exc: BaseException, spec: GraphSpec) -> tuple[str, tuple[EventType, dict[str, Any]]]: + """失败的首行报错(给人看)和 run.failed 的负载(带上定位和原始异常)。""" + if isinstance(exc, NodeError): + # NodeError 的 message 本来就是写给人看的("人工驳回:…"), + # 再套一层类型名只会让界面上的错误更难读 + message = str(exc) + detail = raw_detail(exc.__cause__) if exc.__cause__ is not None else None + node_id: str | None = exc.node_id + else: + message = (f"运行出错:{describe_exception(exc)}。技术细节已记在服务日志里;" + "可以点「接着跑」重试,仍然失败请把运行编号反馈给维护者") + detail = raw_detail(exc) + node_id = None + data: dict[str, Any] = {"error": message} + if detail: + data["detail"] = detail + if node_id: + node = spec.node_map().get(node_id) + data.update(node_id=node_id, label=node.title if node else node_id) + return message, (EventType.RUN_FAILED, data) + + +async def _approval_default(*, run_class: str) -> str: + """工具审批策略的全局默认,取自设置里的「危险工具默认需要人工确认」。 + + 正式运行不吃"关"。它跑的是封存的发布版,审批行为只该由那一版里写明的配置 + 决定,不能随一个随时能改的偏好变;受管门禁要求危险工具至少人工确认,也不能被 + 全局开关悄悄降掉。不能按工作流的 status 判断是不是受管:画布上再存一次,status + 就退回 draft,而 published_version 仍指着受管的那一版。 + """ + if run_class == "formal": + return "dangerous" + from app.api.settings import run_defaults, tool_approval_default + + async with SessionLocal() as session: + return tool_approval_default(await run_defaults(session)) + + +async def _timing(session: Any, run: Run, elapsed_ms: int, now: datetime) -> dict[str, int]: + """wall:从第一次开始到现在的墙钟;active:各段执行时长之和;wait:等人审批的总时长。 + + 等待按事件算:每段从 run.interrupted 到下一条 run.resumed。服务重启挂起的那段 + 没有 run.interrupted,不算等审批;失败后到接着跑之间也不算。 + """ + prev = run.usage or {} + before = prev.get("active_ms", prev.get("duration_ms", 0)) + active = int(before or 0) + elapsed_ms + + rows = await session.execute( + select(RunEvent.type, RunEvent.ts) + .where(RunEvent.run_id == run.id, + RunEvent.type.in_([EventType.RUN_INTERRUPTED, EventType.RUN_RESUMED])) + .order_by(RunEvent.seq) + ) + waited, since = 0.0, None + for kind, ts in rows: + if kind == EventType.RUN_INTERRUPTED: + since = ts if since is None else since + elif since is not None: + waited += max(0.0, ts - since) + since = None + if since is not None: + waited += max(0.0, now.timestamp() - since) + + wall = int((now - run.started_at).total_seconds() * 1000) if run.started_at else active + return {"wall_ms": max(wall, active), "active_ms": active, "wait_ms": int(waited * 1000)} run_manager = RunManager() diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index fe8d0ffc..686ced9a 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -34,6 +34,21 @@ class NodeType(StrEnum): METRICS = "metrics" # 口径卡:受控指标集,叙述层唯一合法的数字来源 +# 节点类型在画布上的叫法。报错和门禁文案用它,而不是枚举值——用户在界面上从没 +# 见过 supervisor、llm 这些词。和前端 decode.ts / nodeDefs.ts 的叫法保持一致 +TYPE_LABEL: dict[str, str] = { + "input": "输入", "output": "成果 / 出具", "llm": "模型调用", "agent": "Agent", + "supervisor": "多 Agent 协作", "tool": "调用工具", "code": "沙箱代码", + "branch": "条件分支", "loop": "循环", "subgraph": "子工作流", + "memory": "长期记忆", "retrieve": "知识检索", "transform": "数据整形", + "human": "人工审批", "validate": "结构校验", "metrics": "口径卡", +} + + +def type_label(node_type: Any) -> str: + return TYPE_LABEL.get(str(node_type), str(node_type)) + + class Position(BaseModel): x: float = 0 y: float = 0 @@ -222,9 +237,30 @@ def loop_steps(spec: GraphSpec) -> int: 取上界不求精确:一个节点最多跑 Π(包住它的各层循环的 max_iterations + 1) 次, 逐个加起来。一步至少跑一个节点,所以步数只会比这少。 """ + bodies = loop_bodies(spec) + if not bodies: + return 0 + loops = spec.node_map() + runs: dict[str, int] = defaultdict(lambda: 1) + for loop_id, body in bodies.items(): + try: + cap = int(loops[loop_id].config.get("max_iterations", 10) or 10) + except (TypeError, ValueError): + cap = 10 # 运行到这个节点时同样会报错,这里不替它报 + for n in body | {loop_id}: + runs[n] *= max(cap, 0) + 1 + return sum(runs.values()) + + +def loop_bodies(spec: GraphSpec) -> dict[str, set[str]]: + """每个循环节点的循环体(不含循环节点本身)。 + + 循环体 = 从 body 出口出发、不经过循环节点本身、又能绕回它的那些节点。 + 没标出口的边路由时按 default 走,body 也会走它。 + """ loops = [n for n in spec.nodes if n.type == NodeType.LOOP] if not loops: - return 0 + return {} succ: dict[str, list[str]] = defaultdict(list) pred: dict[str, list[str]] = defaultdict(list) for e in spec.edges: @@ -241,19 +277,21 @@ def loop_steps(spec: GraphSpec) -> int: stack.extend(x for x in step[n] if x != avoid) return seen - runs: dict[str, int] = defaultdict(lambda: 1) + out: dict[str, set[str]] = {} for loop in loops: - try: - cap = int(loop.config.get("max_iterations", 10) or 10) - except (TypeError, ValueError): - cap = 10 # 运行到这个节点时同样会报错,这里不替它报 - # 循环体 = 从 body 出口出发、不经过循环节点本身、又能绕回它的那些节点。 - # 没标出口的边路由时按 default 走,body 也会走它 entries = [e.target for e in spec.outgoing(loop.id) if e.sourceHandle != "done"] - body = reach(entries, succ, loop.id) & reach(pred[loop.id], pred, loop.id) - for n in body | {loop.id}: - runs[n] *= max(cap, 0) + 1 - return sum(runs.values()) + out[loop.id] = reach(entries, succ, loop.id) & reach(pred[loop.id], pred, loop.id) + return out + + +def innermost_loops(spec: GraphSpec) -> dict[str, str]: + """节点 -> 直接包住它的那一层循环。嵌套时取循环体最小的那个。""" + bodies = loop_bodies(spec) + out: dict[str, str] = {} + for loop_id, body in sorted(bodies.items(), key=lambda kv: -len(kv[1])): + for n in body: + out[n] = loop_id # 由大到小覆盖,最后留下的是最内层 + return out def expression_fields(node: GraphNode) -> list[tuple[str, str]]: @@ -294,6 +332,35 @@ _MAY_WRITE_STDOUT = re.compile( r"|^\s*\{\{.*\}\}\s*$", re.M) +def _check_case_keys(node: GraphNode, cases: list[Any], result: ValidationResult) -> None: + """分支出口的 id 就是 case 的 key,所以 key 得唯一、非空,而且不能占用兜底出口的名字。 + + default 只报警告:现存的 ⑥ 模板就写着 key=default、条件为 true,运行语义上等价于 + 兜底,是自洽的;升成 error 会让它和所有从它复制出来的图直接不能跑。可它在画布上 + 会和「其他」撞成同一个出口,跑完两条一起点亮——得说出来。 + 重复和空 key 是真坏了:路由只认 key,重复的那个永远轮不到,空的根本连不出边。 + """ + seen: set[str] = set() + for i, case in enumerate(cases, start=1): + case = case or {} + key = str(case.get("key") or "").strip() + name = case.get("label") or key or f"第 {i} 个" + if not key: + result.add(f"「条件分支」的分支「{name}」没有标识(key),连不出边、也走不到它", + node_id=node.id) + continue + if key == "default": + result.add( + f"分支「{name}」的标识用了 default:这是「其他」兜底出口的保留名,两者会合并成" + "同一个出口,跑完分不清走的是哪条。换一个标识,比如 team", + level="warning", node_id=node.id, + ) + elif key in seen: + result.add(f"「条件分支」里有两个分支的标识都是 {key!r}:路由只认标识," + "后一个永远轮不到。给它换一个不重复的标识", node_id=node.id) + seen.add(key) + + def validate_graph(spec: GraphSpec) -> ValidationResult: """编译前的静态检查。错误会挡住运行,警告只在画布上提示。""" result = ValidationResult() @@ -319,29 +386,30 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: if node.type == NodeType.BRANCH: cases = cfg.get("cases") or [] if not cases: - result.add("分支节点至少要配一个条件分支", node_id=node.id) + result.add("「条件分支」至少要配一个分支条件", node_id=node.id) handles = {e.sourceHandle for e in spec.outgoing(node.id)} + _check_case_keys(node, cases, result) for case in cases: - key = case.get("key") - if key and key not in handles: + key = str((case or {}).get("key") or "").strip() + if key and key != "default" and key not in handles: result.add( f"分支 {key!r} 没有连出去的边", level="warning", node_id=node.id ) if "default" not in handles: result.add( - "建议给分支节点连一条 default 边,兜住所有条件都不满足的情况", + "建议给「条件分支」的「其他」出口连一条边,兜住所有条件都不满足的情况", level="warning", node_id=node.id, ) elif node.type == NodeType.TOOL: if not cfg.get("tool"): - result.add("工具节点还没选工具", node_id=node.id) + result.add("「调用工具」节点还没选工具", node_id=node.id) elif node.type == NodeType.SUBGRAPH: if not cfg.get("workflow_id"): - result.add("子图节点还没选要嵌套的工作流", node_id=node.id) + result.add("「子工作流」节点还没选要嵌套的工作流", node_id=node.id) elif node.type == NodeType.VALIDATE: if not cfg.get("schema"): - result.add("校验节点需要一个 JSON Schema", node_id=node.id) + result.add("「结构校验」节点需要一个 JSON Schema", node_id=node.id) elif node.type == NodeType.METRICS: defs = cfg.get("metrics") or [] if not defs: @@ -353,11 +421,11 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: elif node.type == NodeType.OUTPUT: contract = cfg.get("contract") if contract and not contract.get("metrics_from"): - result.add("出具契约缺 metrics_from(指标来自哪个口径卡节点)", + result.add("出具契约缺 metrics_from(指标来自哪个「口径卡」节点)", node_id=node.id) elif node.type == NodeType.LOOP: if not spec.outgoing(node.id): - result.add("循环节点没有循环体", node_id=node.id) + result.add("「循环」节点没有循环体", node_id=node.id) if node.type in (NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR): if not (cfg.get("model") or spec.defaults.get("model")): diff --git a/backend/app/memory/embeddings.py b/backend/app/memory/embeddings.py index ec40ff22..b2728d53 100644 --- a/backend/app/memory/embeddings.py +++ b/backend/app/memory/embeddings.py @@ -125,7 +125,7 @@ def _make(kind: str, model: str, base_url: str = "", *, where = base_url or "api.openai.com" raise EmbedderUnavailable( f"用不了 {where} 上的 embedding 模型 " - f"{model or 'text-embedding-3-small'}:{type(e).__name__}: {e}。" + f"{model or 'text-embedding-3-small'}:{_why(e)}。" f"检查服务在不在、模型名对不对;走官方接口还要看 OPENAI_API_KEY。" ) from e return _local @@ -181,6 +181,12 @@ def has_semantics() -> bool: return getattr(get_embedder(), "name", "") != "local-hashing" +def _why(e: BaseException) -> str: + from app.api.errors import explain + + return explain(e)[0] + + def default_alpha() -> float: """向量那一路默认占多少权重。 diff --git a/backend/app/memory/parsing.py b/backend/app/memory/parsing.py index 195871d9..4fbbc9c8 100644 --- a/backend/app/memory/parsing.py +++ b/backend/app/memory/parsing.py @@ -21,6 +21,10 @@ _DOCX = {".docx"} _PPTX = {".pptx"} _HTML = {".html", ".htm"} +#: 按 UTF-8 文本读的常见格式。清单外的文件也会按文本试,这里列的是上传框里 +#: 该让人选得到的那些——前端的 accept 从 GET /api/kb/formats 取,只在这一处定义 +_TEXT = (".txt", ".md", ".markdown", ".json", ".log", ".yaml", ".yml", ".py", ".ts", ".js") + #: 表格数据。它们有更好的去处——数据源那条路会把它变成可以真查的表, #: 而传进知识库只能当文本检索:数字是模型"读"出来的不是"算"出来的, #: 而这个项目的地基恰恰是"所有算术下沉到 SQL 或口径卡"(见 engine/issuance.py) @@ -36,6 +40,24 @@ class UnsupportedDocument(ValueError): """认不出或解析不了。message 直接给用户看。""" +def supported_formats() -> dict[str, list[str]]: + """知识库收哪些格式、拒哪些。上传框的 accept 和后端的判断必须是同一份清单: + 以前前端手写了一份,漏了 .pptx(后端支持)、多了 .csv(后端必拒)。""" + extensions = sorted(_PDF | _DOCX | _PPTX) + sorted(_HTML) + list(_TEXT) + return { + "extensions": extensions, + "text": list(_TEXT), + "tabular": sorted(_TABULAR), + "legacy": sorted(_LEGACY_OFFICE), + } + + +def _why(e: BaseException) -> str: + """解析库的原话,去掉类名。它们常常是英文,但比"读不开"多一点线索。""" + text = str(e).strip() + return (text.splitlines()[0][:120] if text else "") or "没有更多说明" + + def _ext(filename: str) -> str: idx = filename.rfind(".") return filename[idx:].lower() if idx >= 0 else "" @@ -51,7 +73,10 @@ def _from_pdf(raw: bytes) -> str: try: reader = PdfReader(io.BytesIO(raw)) except Exception as e: # noqa: BLE001 - raise UnsupportedDocument(f"这个 PDF 读不开:{type(e).__name__}: {e}") from e + raise UnsupportedDocument( + f"这个 PDF 读不开:文件可能损坏、加了密码,或者其实不是 PDF({_why(e)})。" + "用原软件打开另存一份再传" + ) from e # 按页拼,页与页之间留空行——切块是按段落切的,页边界正好是天然的段落边界 pages = [] @@ -86,7 +111,10 @@ def _from_docx(raw: bytes) -> str: try: doc = docx.Document(io.BytesIO(raw)) except Exception as e: # noqa: BLE001 - raise UnsupportedDocument(f"这个 Word 文档读不开:{type(e).__name__}: {e}") from e + raise UnsupportedDocument( + f"这个 Word 文档读不开:文件可能损坏、加了密码,或者其实不是 Word({_why(e)})。" + "用原软件打开另存一份再传" + ) from e parts = [p.text.strip() for p in doc.paragraphs if p.text.strip()] # 表格也要:合同、口径说明这类文档的关键信息常常整个在表里, @@ -112,7 +140,10 @@ def _from_pptx(raw: bytes) -> str: try: deck = Presentation(io.BytesIO(raw)) except Exception as e: # noqa: BLE001 - raise UnsupportedDocument(f"这个 PowerPoint 读不开:{type(e).__name__}: {e}") from e + raise UnsupportedDocument( + f"这个 PowerPoint 读不开:文件可能损坏、加了密码,或者其实不是 PowerPoint({_why(e)})。" + "用原软件打开另存一份再传" + ) from e slides: list[str] = [] for page in deck.slides: @@ -167,9 +198,9 @@ def extract(raw: bytes, filename: str, mime: str = "") -> str: return _from_html(raw) if ext in _TABULAR: raise UnsupportedDocument( - f"{filename} 是表格数据,请传到「设置 → 数据源」那里——" - "它会变成一张可以用 SQL 查的表,数字是算出来的、能追溯到哪条查询。" - "传进知识库只能当文本检索,模型只是把数字读出来,容易读错也查不出来源。" + f"「{filename}」是表格,知识库不收表格。请到「数据源」点「传表格」导入:" + "它会变成一张能用 SQL 查的表,数字是算出来的、查得到出自哪条查询。" + "放进知识库只能按文字检索,数字靠模型去读,容易读错,也追不到来源。" ) if ext in _LEGACY_OFFICE: # 认出来再拒,而不是让它掉进下面那个"不是 UTF-8"的兜底—— diff --git a/backend/app/memory/rerank.py b/backend/app/memory/rerank.py index 12f918f4..0f531d6d 100644 --- a/backend/app/memory/rerank.py +++ b/backend/app/memory/rerank.py @@ -96,7 +96,9 @@ async def rerank( return hits[:top_n] except Exception as e: # noqa: BLE001 if on_note: - on_note(f"重排失败,按初筛顺序返回:{type(e).__name__}: {e}") + from app.api.errors import explain + + on_note(f"重排失败,按初筛顺序返回:{explain(e)[0]}") return hits[:top_n] ranked = sorted(zip(hits, scores), key=lambda t: t[1], reverse=True) diff --git a/backend/app/memory/store.py b/backend/app/memory/store.py index 30920e6a..e731739f 100644 --- a/backend/app/memory/store.py +++ b/backend/app/memory/store.py @@ -90,12 +90,16 @@ async def recall( kind: str | None = None, min_score: float = 0.05, on_degrade: Any = None, + track: bool = True, ) -> list[dict[str, Any]]: """按混合相关度取回记忆,并按重要性和使用次数做轻微加权。 存量向量和当前 embedder 对不上时整体退回纯关键词。以前这里退得悄无声息 ——知识库那边至少会发一条 warn,记忆这边只是"最近想不起事",而人根本 不会想到要去重建索引。 + + track=False 只看不记:调试台试一次回忆不该算"被召回过",否则记忆卡上的 + 召回次数会被调试操作抬高,下次排序也跟着偏。 """ stmt = select(MemoryItem).where(MemoryItem.scope == scope) if kind: @@ -151,6 +155,8 @@ async def recall( if len(results) >= limit: break + if not track: + return results # 记一下被召回过,作为下次排序的信号 for r in results: item = await session.get(MemoryItem, r["id"]) @@ -161,6 +167,36 @@ async def recall( return results +async def revise( + session: AsyncSession, + memory_id: str, + *, + content: str | None = None, + kind: str | None = None, + importance: float | None = None, +) -> MemoryItem | None: + """原地改一条记忆。找不到返回 None。 + + 内容变了向量必须跟着重算:不然召回按的还是旧话,改完的口径搜不出来, + 搜得出来的却是已经被改掉的那句。 + """ + item = await session.get(MemoryItem, memory_id) + if not item: + return None + if content is not None and content.strip() != item.content: + item.content = content.strip() + item.embedding = to_blob(await embed_text(item.content)) + item.embed_model = embedder_id() + item.embed_dim = embedder_dim() + if kind is not None: + item.kind = kind + if importance is not None: + item.importance = importance + await session.commit() + await session.refresh(item) + return item + + async def forget(session: AsyncSession, memory_id: str) -> bool: item = await session.get(MemoryItem, memory_id) if not item: diff --git a/backend/app/tools/builtin/web.py b/backend/app/tools/builtin/web.py index 661a1958..50119e7d 100644 --- a/backend/app/tools/builtin/web.py +++ b/backend/app/tools/builtin/web.py @@ -175,5 +175,7 @@ async def web_search(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]: results = await _duckduckgo(args.query, args.max_results) engine = "duckduckgo" except Exception as e: # noqa: BLE001 - 搜索失败不该让整个 run 挂掉 - return {"error": f"搜索失败:{type(e).__name__}: {e}", "results": []} + from app.api.errors import explain + + return {"error": f"搜索失败:{explain(e)[0]}", "results": []} return {"engine": engine, "query": args.query, "results": results} diff --git a/backend/app/tools/datasource.py b/backend/app/tools/datasource.py index cf98f1c8..80bcc96e 100644 --- a/backend/app/tools/datasource.py +++ b/backend/app/tools/datasource.py @@ -132,7 +132,11 @@ def _make_query_tool(source: DataSource, ctx: ToolContext) -> StructuredTool: # 给一句笼统的"失败了"只会让它瞎猜 return f"SQL 被拒绝:{e}" except Exception as e: # noqa: BLE001 - return f"查询失败:{type(e).__name__}: {e}" + from app.api.errors import explain, first_line + + # 驱动的原话留着("no such table: x" 正是模型改写 SQL 要的线索), + # 类名和 SQLAlchemy 的包装前缀去掉:这一行也会原样出现在运行面板上 + return f"查询失败:{first_line(e) or explain(e)[0]}" payload = result.to_payload() payload["source"] = source.name diff --git a/backend/app/tools/mcp_manager.py b/backend/app/tools/mcp_manager.py index c2103d35..cf39313b 100644 --- a/backend/app/tools/mcp_manager.py +++ b/backend/app/tools/mcp_manager.py @@ -53,7 +53,7 @@ class McpManager: except Exception as e: # noqa: BLE001 - 一个 server 挂了不影响其他的 logger.warning("MCP server %s 连接失败: %s", row.name, e) out[row.name] = [] - await self._mark(row.id, "error", f"{type(e).__name__}: {e}", []) + await self._mark(row.id, "error", _reason(e), []) return out async def _mark( @@ -127,7 +127,24 @@ class McpManager: ], } except Exception as e: # noqa: BLE001 - return {"ok": False, "error": f"{type(e).__name__}: {e}"} + from app.api.errors import raw + + return {"ok": False, "error": _reason(e), "detail": raw(e)} + + +def _reason(e: BaseException) -> str: + """连不上 MCP 服务的原因,写成工具页上看得懂的话。 + + 最常见的两种:命令不存在(没装 npx / uvx)和超时(进程起来了但不讲 MCP)。 + """ + from app.api.errors import explain + + if isinstance(e, FileNotFoundError): + return f"启动不了:找不到命令 {e.filename or ''}。确认它已安装、并且在后端的 PATH 里" + if isinstance(e, asyncio.TimeoutError): + return "连上了但 30 秒内没有应答:确认这个命令启动的是 MCP 服务,参数有没有写错" + reason, hint = explain(e) + return f"{reason}。{hint}" if hint else reason mcp_manager = McpManager() diff --git a/backend/tests/test_api_messages.py b/backend/tests/test_api_messages.py new file mode 100644 index 00000000..6b6a69ef --- /dev/null +++ b/backend/tests/test_api_messages.py @@ -0,0 +1,265 @@ +"""面向用户的报错不露 Python 异常类名和内部符号,并且说得出下一步。 + +以前界面首行红字是「_make_query_tool.._run() got an unexpected keyword +argument 'query'」,助手输入坞里是「RuntimeError: …」,校验失败是一大段 pydantic +英文。对问数据的业务用户这些都是天书,而且没有下一步。原始异常不丢:放进 detail, +排查的人照样拿得到。 +""" +from __future__ import annotations + +import json + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.main import app + +_INTERNAL = ("Error:", "Error)", "Exception", "Traceback", "", "Input should be", + "validation error", "errors.pydantic.dev") + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +def _clean(text: str) -> None: + for token in _INTERNAL: + assert token not in text, f"面向用户的报错里露出了 {token!r}:{text}" + + +# -------------------------------------------------------------------------- +# 翻译规则本身 +# -------------------------------------------------------------------------- + + +def test_common_failures_are_put_in_plain_words(): + import httpx + + from app.api.errors import explain, graph_error, raw + + reason, hint = explain(httpx.ConnectError("[Errno 61] Connection refused")) + assert "连不上" in reason and hint + + req = httpx.Request("GET", "https://x/v1/models") + denied = httpx.HTTPStatusError("401", request=req, response=httpx.Response(401, request=req)) + assert "密钥" in explain(denied)[0] + + reason, hint = explain(TypeError("_run() got an unexpected keyword argument 'query'")) + _clean(reason) + assert "内部" in reason and hint + assert raw(TypeError("x")) == "TypeError: x", "原始异常要原样留给 detail" + + from app.engine.schema import GraphSpec + + with pytest.raises(Exception) as e: + GraphSpec.model_validate({"nodes": [{"id": "a", "type": "nope"}]}) + text = graph_error(e.value) + _clean(text) + assert "第 1 个节点" in text and "nope" in text + + +# -------------------------------------------------------------------------- +# 各接口 +# -------------------------------------------------------------------------- + + +async def test_a_failing_provider_test_explains_itself(client, monkeypatch): + import httpx + + import app.api.settings as settings_api + + class Boom: + async def ainvoke(self, _prompt): + raise httpx.ConnectError("[Errno 61] Connection refused") + + monkeypatch.setattr(settings_api, "build_chat_model", lambda *_a, **_k: Boom()) + body = (await client.post("/api/providers/test", json={"kind": "mock", "default_model": "m"})).json() + assert body["ok"] is False + _clean(body["error"]) + assert "连不上" in body["error"] and body["hint"] + assert "ConnectError" in body["detail"], "技术细节里要留着原始异常" + + +async def test_copilot_stream_errors_are_readable(client, monkeypatch): + import app.api.copilot as copilot + + class Broken: + async def astream(self, _messages): + raise RuntimeError("upstream closed") + yield # pragma: no cover + + async def _model(*_a, **_k): + return Broken(), "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + events = [] + async with client.stream("POST", "/api/copilot/generate-stream", json={"instruction": "x"}) as r: + async for line in r.aiter_lines(): + if line.startswith("data: "): + events.append(json.loads(line[6:])) + err = next(e for e in events if e["op"] == "error") + _clean(err["message"]) + assert "upstream closed" in err["message"] and "RuntimeError" in err["detail"] + + +async def test_copilot_generate_errors_are_readable(client, monkeypatch): + import app.api.copilot as copilot + + class Garbage: + def with_structured_output(self, _schema): + return self + + async def ainvoke(self, _messages): + raise ValueError("not json") + + async def _model(*_a, **_k): + return Garbage(), "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + r = await client.post("/api/copilot/generate", json={"instruction": "x"}) + assert r.status_code == 502 + _clean(r.json()["detail"]) + + +async def test_graph_validation_points_at_the_node(client): + body = (await client.post("/api/workflows/validate", + json={"graph": {"nodes": [{"id": "a", "type": "nope"}]}})).json() + message = body["issues"][0]["message"] + _clean(message) + assert "第 1 个节点的类型「nope」不认识" in message + + +async def test_publish_level_uses_ui_words(client): + wf = (await client.post("/api/workflows", json={"name": "档位", "graph": {}})).json() + r = await client.post(f"/api/workflows/{wf['id']}/publish", json={"level": "gold"}) + assert r.status_code == 400 + assert "「已发布」" in r.json()["detail"] and "governed" not in r.json()["detail"] + await client.delete(f"/api/workflows/{wf['id']}") + + +async def test_a_failing_tool_run_explains_itself(client, monkeypatch): + import app.api.tools as tools_api + + async def boom(*_a, **_k): + raise TypeError("_run() got an unexpected keyword argument 'query'") + + monkeypatch.setattr(tools_api, "call_tool", boom) + body = (await client.post("/api/tools/file_list/run", json={"args": {}})).json() + assert body["ok"] is False + _clean(body["error"]) + assert "TypeError" in body["detail"] + + +async def test_datasource_introspect_failure_is_readable(client): + r = await client.post("/api/datasources", json={"name": "ghostfile", "kind": "sqlite", + "database": "/no/such/dir/ghost.db"}) + assert r.status_code == 201 + sid = r.json()["id"] + try: + r = await client.post(f"/api/datasources/{sid}/introspect") + assert r.status_code == 400 + _clean(r.json()["detail"]) + body = (await client.post(f"/api/datasources/{sid}/test")).json() + assert body["ok"] is False + _clean(body["error"]) + assert body["detail"], "原始报错要留在 detail" + finally: + await client.delete(f"/api/datasources/{sid}") + + +async def test_embedding_probe_failure_is_readable(client): + r = await client.get("/api/kb/embedding/probe", params={"base_url": "http://127.0.0.1:9/v1"}) + assert r.status_code == 400 + _clean(r.json()["detail"]) + + +def test_a_broken_document_is_explained_without_class_names(): + pytest.importorskip("pypdf") + from app.memory.parsing import UnsupportedDocument, extract + + with pytest.raises(UnsupportedDocument) as e: + extract(b"%PDF-1.4 definitely not a pdf", "broken.pdf") + _clean(str(e.value)) + assert "broken" not in str(e.value) or "PDF" in str(e.value) + + +async def test_background_processing_failure_is_readable(monkeypatch): + from app.api import knowledge as kb_api + from app.db.base import SessionLocal + from app.db.models import Document + from app.memory import kb + + async with SessionLocal() as session: + doc = await kb.create_document(session, collection="errs", title="t", content="正文", + source="t.txt", mime="text/plain", status="processing") + doc_id = doc.id + + async def boom(*_a, **_k): + raise AttributeError("'NoneType' object has no attribute 'encode'") + + monkeypatch.setattr(kb, "process_document", boom) + await kb_api._process_in_background(doc_id) + async with SessionLocal() as session: + doc = await session.get(Document, doc_id) + assert doc.status == "failed" + _clean(doc.error) + + +async def test_governance_hints_do_not_quote_field_names(client): + wf = (await client.post("/api/workflows", json={"name": "治理", "graph": {}})).json() + try: + body = (await client.get("/api/governance/tool-usage", params={"workflow_id": wf["id"]})).json() + assert "unused" not in body["hint"] + finally: + await client.delete(f"/api/workflows/{wf['id']}") + body = (await client.get("/api/governance/exploratory-clusters")).json() + assert "promote_candidate" not in body.get("hint", "") + + +async def test_an_unconfigured_copilot_model_uses_settings_words(client, monkeypatch): + import app.api.copilot as copilot + from app.providers.factory import ProviderNotConfigured + + async def _model(*_a, **_k): + raise ProviderNotConfigured("provider 'gw' 需要填 base_url") + + monkeypatch.setattr(copilot, "get_chat_model", _model) + r = await client.post("/api/copilot/generate-stream", json={"instruction": "x"}) + assert r.status_code == 400 + detail = r.json()["detail"] + assert "模型接入「gw」" in detail and "Base URL" in detail and "provider" not in detail, detail + + +async def test_a_missing_item_says_it_may_have_been_deleted(client): + """404 多半是别的标签页里删掉了,或者链接过期了。只说「不存在」,人会以为 + 是自己输错了什么;统一说一句「可能已经被删了」,下一步就清楚了。""" + gone = "no-such-id" + calls = [ + ("get", f"/api/workflows/{gone}", None), + ("patch", f"/api/workflows/{gone}", {"name": "x"}), + ("delete", f"/api/workflows/{gone}", None), + ("post", f"/api/workflows/{gone}/duplicate", None), + ("get", f"/api/workflows/{gone}/versions/1", None), + ("post", f"/api/workflows/{gone}/versions/1/restore", None), + ("post", f"/api/workflows/{gone}/publish", {}), + ("get", "/api/governance/tool-usage", None), + ("post", "/api/copilot/from-run", {"run_id": gone}), + ("post", "/api/copilot/review", {"run_id": gone}), + ("get", f"/api/conversations/{gone}", None), + ("get", f"/api/kb/documents/{gone}", None), + ("delete", f"/api/kb/documents/{gone}", None), + ("delete", f"/api/skills/{gone}", None), + ("get", f"/api/artifacts/{'0' * 64}", None), + ] + for method, url, body in calls: + kwargs = {"json": body} if body is not None else {} + if url.endswith("tool-usage"): + kwargs = {"params": {"workflow_id": gone}} + r = await client.request(method.upper(), url, **kwargs) + assert r.status_code == 404, (method, url, r.status_code, r.text) + detail = r.json()["detail"] + assert "可能已经被删了" in detail, (method, url, detail) + assert gone not in detail, f"把 id 原样念给用户听没用:{detail}" diff --git a/backend/tests/test_approval_default.py b/backend/tests/test_approval_default.py new file mode 100644 index 00000000..89741419 --- /dev/null +++ b/backend/tests/test_approval_default.py @@ -0,0 +1,260 @@ +"""「危险工具默认需要人工确认」是工具审批策略的全局默认,而不是一个摆设。 + +以前审批只看节点上的 approval(缺省 dangerous),设置页的开关勾不勾都一样—— +取消勾选以为关掉了审批,勾上以为全局开了,两种理解都不对。 + +规则:节点自己配了就用节点的;没配时取全局设置(勾上 = dangerous,取消 = never)。 +正式运行不吃"取消":它跑的是封存的发布版,受管门禁要求危险工具至少人工确认, +一个全局开关不能把它悄悄降掉。 + +另外守一件连带的事:记忆域和知识库以前只在第一次 _drive 时传进去,审批恢复、 +接着跑时回落成 "default"——同一次运行前后两段查的不是同一个库。 +""" + +from __future__ import annotations + +import asyncio +import sqlite3 +import uuid + +import pytest +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import DataSource, Run, RunEvent, Setting, Workflow +from app.engine import compiler +from app.engine.runner import run_manager +from app.engine.schema import NodeType + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def confirm_setting(): + """把设置里的 run 组改成给定值;测试结束删掉,别影响别的测试。""" + async def put(value: bool) -> None: + async with SessionLocal() as session: + row = await session.get(Setting, "run") + if row: + row.value = {**row.value, "confirm_dangerous_tools": value} + else: + session.add(Setting(key="run", value={"confirm_dangerous_tools": value})) + await session.commit() + + yield put + async with SessionLocal() as session: + row = await session.get(Setting, "run") + if row: + await session.delete(row) + await session.commit() + + +@pytest.fixture +async def writable(tmp_path): + path = tmp_path / "w.db" + c = sqlite3.connect(path) + c.execute("CREATE TABLE t (x INTEGER)") + c.executemany("INSERT INTO t VALUES (?)", [(i,) for i in range(5)]) + c.commit() + c.close() + name = f"w{uuid.uuid4().hex[:6]}" + async with SessionLocal() as session: + session.add(DataSource(name=name, kind="sqlite", database=str(path), readonly=False, + description="可写测试库", options={}, schema_cache={}, enabled=True)) + await session.commit() + + def count() -> int: + conn = sqlite3.connect(path) + try: + return conn.execute("SELECT COUNT(*) FROM t").fetchone()[0] + finally: + conn.close() + + return f"db_query__{name}", count + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), + "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +def _delete_graph(tool: str, **approval): + return chain( + node("start", "input", fields=[{"name": "question"}]), + node("q", "tool", tool=tool, args={"sql": "DELETE FROM t WHERE x = 0"}, **approval), + node("out", "output", fields=[{"name": "结果", "value": "{{ nodes.q }}"}]), + ) + + +async def _settle(run_id: str) -> Run: + for _ in range(400): + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row.status in ("interrupted", "succeeded", "failed", "cancelled"): + return row + await asyncio.sleep(0.03) + raise AssertionError("等超时了") + + +async def test_unchecked_setting_lets_unconfigured_nodes_run(writable, confirm_setting): + tool, count = writable + await confirm_setting(False) + run = await run_manager.start(graph=_delete_graph(tool), input_payload={"question": "q"}) + row = await _settle(run.id) + assert row.status == "succeeded", f"全局关了审批,节点没配,却还是停下来了:{row.status}" + assert count() == 4 + + +async def test_checked_setting_keeps_asking(writable, confirm_setting): + tool, count = writable + await confirm_setting(True) + run = await run_manager.start(graph=_delete_graph(tool), input_payload={"question": "q"}) + row = await _settle(run.id) + assert row.status == "interrupted" + assert count() == 5 + + +async def test_what_the_node_says_wins_over_the_setting(writable, confirm_setting): + tool, count = writable + await confirm_setting(False) + run = await run_manager.start( + graph=_delete_graph(tool, approval="dangerous"), input_payload={"question": "q"}) + row = await _settle(run.id) + assert row.status == "interrupted", "节点明说了要审批,全局开关不该盖过它" + assert count() == 5 + + +async def test_governed_formal_runs_do_not_take_the_unchecked_setting(writable, confirm_setting): + tool, count = writable + await confirm_setting(False) + graph = _delete_graph(tool) + async with SessionLocal() as session: + wf = Workflow(name=f"受管-{uuid.uuid4().hex[:4]}", graph=graph, status="governed", + published_version=1) + session.add(wf) + await session.commit() + run = await run_manager.start(graph=graph, input_payload={"question": "q"}, + workflow_id=wf.id, run_class="formal", version=1) + row = await _settle(run.id) + assert row.status == "interrupted", "受管门禁要独立生效" + assert count() == 5 + + +def _governable(tool: str): + """过得了受管门禁的最小图:危险工具节点不配 approval,后面接口径卡和出具契约。""" + return chain( + node("start", "input", fields=[{"name": "question"}]), + node("q", "tool", tool=tool, args={"sql": "DELETE FROM t WHERE x = 0"}), + node("caliber", "metrics", caliber="删除口径", caliber_version="v1", assign_to="m", + metrics=[{"id": "done", "name": "已执行", "unit": "次", "expression": "1"}]), + node("out", "output", fields=[{"name": "结果", "value": "{{ nodes.q }}"}], + contract={"metrics_from": ["caliber"], "narrative": "执行了 {{ vars.m.done }} 次", + "required": ["done"], "strict": True}), + ) + + +@pytest.mark.parametrize("level", ["governed", "published"]) +async def test_formal_runs_keep_asking_after_the_canvas_is_edited( + writable, confirm_setting, level +): + """发布之后在画布上再存一次,status 就退回 draft,published_version 仍指着发布的那一版。 + + 以前下限看的是 status == "governed":受管模板只要被编辑过,它的正式运行就跟着 + 全局开关降到「全部自动放行」,DELETE 不经人手直接执行。现在正式运行一律不吃 + "取消"——跑的是封存的发布版,它的审批行为不该取决于一个随时能改的偏好。 + """ + from httpx import ASGITransport, AsyncClient + + from app.main import app + + tool, count = writable + await confirm_setting(False) + graph = _governable(tool) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + wf = (await c.post("/api/workflows", json={"name": f"受管-{uuid.uuid4().hex[:4]}", + "graph": graph})).json() + published = (await c.post(f"/api/workflows/{wf['id']}/publish", + json={"level": level})).json() + assert published["ok"], published + edited = {**graph, "nodes": [*graph["nodes"][:-1], + {**graph["nodes"][-1], "data": { + **graph["nodes"][-1]["data"], "label": "改过的出口"}}]} + after = (await c.patch(f"/api/workflows/{wf['id']}", json={"graph": edited})).json() + assert after["status"] == "draft" and after["published_version"] == 1 + r = await c.post("/api/runs", json={"workflow_id": wf["id"], "run_class": "formal", + "input": {"question": "q"}}) + assert r.status_code == 201, r.text + row = await _settle(r.json()["id"]) + assert row.status == "interrupted", f"编辑过画布后,正式运行跳过了审批:{row.status}" + assert row.approval_default == "dangerous" + assert count() == 5 + + +async def test_exploratory_runs_of_a_governed_workflow_follow_the_setting( + writable, confirm_setting +): + """下限只管正式运行。探索运行照旧听全局开关,不然这个开关又成了摆设。""" + tool, count = writable + await confirm_setting(False) + graph = _delete_graph(tool) + async with SessionLocal() as session: + wf = Workflow(name=f"受管-{uuid.uuid4().hex[:4]}", graph=graph, status="governed", + published_version=1) + session.add(wf) + await session.commit() + run = await run_manager.start(graph=graph, input_payload={"question": "q"}, workflow_id=wf.id) + row = await _settle(run.id) + assert row.status == "succeeded", row.status + assert count() == 4 + + +async def test_effective_values_are_sealed_into_the_event_stream(confirm_setting): + await confirm_setting(False) + graph = chain(node("start", "input"), node("out", "output")) + run = await run_manager.start(graph=graph, input_payload={}, memory_scope="ops", + collection="manuals") + await _settle(run.id) + async with SessionLocal() as session: + started = (await session.execute( + select(RunEvent).where(RunEvent.run_id == run.id, RunEvent.type == "run.started") + )).scalars().first() + assert started.data["memory_scope"] == "ops" + assert started.data["collection"] == "manuals" + assert started.data["approval_default"] == "never" + + +async def test_resume_keeps_the_scope_the_run_started_with(monkeypatch): + seen: list[tuple[str, str]] = [] + original = compiler.RUNNERS[NodeType.TRANSFORM] + + async def spy(state, ctx): + seen.append((ctx.run.memory_scope, ctx.run.collection)) + return await original(state, ctx) + + monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, spy) + graph = chain( + node("start", "input"), + node("before", "transform", mode="template", template="前"), + node("gate", "human", mode="approve", title="看一眼"), + node("after", "transform", mode="template", template="后"), + node("out", "output"), + ) + run = await run_manager.start(graph=graph, input_payload={}, memory_scope="ops", + collection="manuals") + assert (await _settle(run.id)).status == "interrupted" + await run_manager.resume(run.id, {"approved": True}) + for _ in range(200): + if len(seen) == 2: + break + await asyncio.sleep(0.03) + assert seen == [("ops", "manuals"), ("ops", "manuals")], "恢复之后换了个库在查" diff --git a/backend/tests/test_copilot_patch_layout.py b/backend/tests/test_copilot_patch_layout.py new file mode 100644 index 00000000..894ee359 --- /dev/null +++ b/backend/tests/test_copilot_patch_layout.py @@ -0,0 +1,223 @@ +"""让助手改图时,用户摆好的布局不能被整张重排。 + +以前 generate-stream 收尾时无条件 auto_layout 整张图:只改了一句提示词, +所有节点也被挪到排版位置。工业场景的图常被人工整理成有业务含义的分区 +(上游数据、口径计算、出具),再加上没有撤销,手工布局就回不来了。 + +这里守三件事:已有节点坐标一个不动;新节点落在空处、不压住旧节点; +新建(没有 base_graph)照旧整体排版。顺带确认 final.issues 和 reply 照常下发。 +""" +from __future__ import annotations + +import json + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.engine.layout import NODE_W, _height +from app.engine.schema import GraphSpec +from app.main import app + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +def _node(nid: str, ntype: str, x: float, y: float, config: dict) -> dict: + return {"id": nid, "type": ntype, "position": {"x": x, "y": y}, + "data": {"label": nid, "config": config}} + + +#: 用户手工摆过的布局:故意不是排版算法会给出的样子 +BASE = { + "nodes": [ + _node("start", "input", 500, 900, {"fields": [{"name": "question"}]}), + _node("answer", "llm", -300, 40, {"prompt": "{{ input.question }}", "assign_to": "answer"}), + _node("out", "output", 1200, -600, {"fields": [{"name": "答案", "value": "{{ vars.answer }}"}]}), + ], + "edges": [{"source": "start", "target": "answer"}, {"source": "answer", "target": "out"}], +} + + +class _Scripted: + def __init__(self, lines: list[dict]) -> None: + self.lines = [json.dumps(o, ensure_ascii=False) for o in lines] + + async def astream(self, messages): + from langchain_core.messages import AIMessageChunk + + for line in self.lines: + yield AIMessageChunk(content=line + "\n") + + +async def _events(client, monkeypatch, ops: list[dict], base: dict | None) -> list[dict]: + import app.api.copilot as copilot + + async def _model(*_a, **_k): + return _Scripted(ops), "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + body = {"instruction": "改一下", "base_graph": base} + out: list[dict] = [] + async with client.stream("POST", "/api/copilot/generate-stream", json=body) as r: + async for line in r.aiter_lines(): + if line.startswith("data: "): + out.append(json.loads(line[6:])) + return out + + +def _positions(graph: dict) -> dict[str, tuple[float, float]]: + return {n["id"]: (n["position"]["x"], n["position"]["y"]) for n in graph["nodes"]} + + +def _box(graph: dict, nid: str) -> tuple[float, float, float, float]: + spec = GraphSpec.model_validate(graph) + node = spec.node_map()[nid] + return node.position.x, node.position.y, NODE_W, _height(node) + + +def _overlaps(a, b) -> bool: + ax, ay, aw, ah = a + bx, by, bw, bh = b + return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah + + +async def test_editing_a_prompt_moves_nothing(client, monkeypatch): + ops = [ + {"op": "plan", "summary": "改短提示词"}, + {"op": "update_node", "id": "answer", + "config": {"prompt": "简短回答:{{ input.question }}", "assign_to": "answer"}}, + {"op": "done", "explanation": "改好了", "run": False}, + ] + final = (await _events(client, monkeypatch, ops, BASE))[-1] + assert final["op"] == "final" + assert _positions(final["graph"]) == _positions(BASE), "只改了一句提示词,节点却被挪了" + assert final["layout"] == {"mode": "keep", "placed": []} + + +async def test_a_new_node_lands_in_free_space_and_old_ones_stay(client, monkeypatch): + ops = [ + {"op": "plan", "summary": "加一步复核"}, + {"op": "add_node", "node": {"id": "review", "type": "llm", "label": "复核", + "config": {"prompt": "复核:{{ vars.answer }}", "assign_to": "checked"}}}, + {"op": "remove_edge", "source": "answer", "target": "out"}, + {"op": "add_edge", "edge": {"source": "answer", "target": "review"}}, + {"op": "add_edge", "edge": {"source": "review", "target": "out"}}, + {"op": "done", "explanation": "加了复核", "run": False}, + ] + final = (await _events(client, monkeypatch, ops, BASE))[-1] + graph = final["graph"] + before = _positions(BASE) + after = _positions(graph) + assert {k: after[k] for k in before} == before, "新增一个节点,旧节点跟着被挪了" + + new_box = _box(graph, "review") + for nid in before: + assert not _overlaps(new_box, _box(graph, nid)), f"新节点压住了「{nid}」" + assert after["review"] != (0, 0), "新节点没排版,堆在原点" + # 落在上游右边:从 answer 连过来,不该跑到它左边去 + assert after["review"][0] > before["answer"][0] + assert final["layout"] == {"mode": "keep", "placed": ["review"]} + + +async def test_a_node_inserted_into_a_tidy_chain_does_not_cover_its_neighbour(client, monkeypatch): + """排得整整齐齐的链上插一步:正对着上游的那一格已经被下游占了,得另找空处。""" + tidy = { + "nodes": [ + _node("start", "input", 80, 0, {"fields": [{"name": "question"}]}), + _node("answer", "llm", 414, 0, {"prompt": "{{ input.question }}", "assign_to": "answer"}), + _node("out", "output", 748, 0, {"fields": [{"name": "答案", "value": "{{ vars.answer }}"}]}), + ], + "edges": BASE["edges"], + } + ops = [ + {"op": "add_node", "node": {"id": "review", "type": "llm", "label": "复核", + "config": {"prompt": "{{ vars.answer }}", "assign_to": "checked"}}}, + {"op": "remove_edge", "source": "answer", "target": "out"}, + {"op": "add_edge", "edge": {"source": "answer", "target": "review"}}, + {"op": "add_edge", "edge": {"source": "review", "target": "out"}}, + {"op": "done", "explanation": "加了复核", "run": False}, + ] + graph = (await _events(client, monkeypatch, ops, tidy))[-1]["graph"] + after = _positions(graph) + assert {k: after[k] for k in _positions(tidy)} == _positions(tidy) + for nid in ("start", "answer", "out"): + assert not _overlaps(_box(graph, "review"), _box(graph, nid)), f"新节点压住了「{nid}」" + + +async def test_several_new_nodes_do_not_pile_on_each_other(client, monkeypatch): + ops = [{"op": "plan", "summary": "并行三路"}] + for i in range(3): + ops.append({"op": "add_node", "node": {"id": f"side{i}", "type": "llm", "label": f"旁路{i}", + "config": {"prompt": "{{ input.question }}"}}}) + ops.append({"op": "add_edge", "edge": {"source": "start", "target": f"side{i}"}}) + ops.append({"op": "done", "explanation": "并行", "run": False}) + graph = (await _events(client, monkeypatch, ops, BASE))[-1]["graph"] + + ids = [n["id"] for n in graph["nodes"]] + for i, a in enumerate(ids): + for b in ids[i + 1:]: + if a in _positions(BASE) and b in _positions(BASE): + continue + assert not _overlaps(_box(graph, a), _box(graph, b)), f"「{a}」和「{b}」叠在一起" + + +async def test_building_from_scratch_still_lays_out_everything(client, monkeypatch): + ops = [ + {"op": "plan", "summary": "两步"}, + {"op": "add_node", "node": {"id": "start", "type": "input", "label": "入口", + "config": {"fields": [{"name": "question"}]}}}, + {"op": "add_node", "node": {"id": "out", "type": "output", "label": "成果", + "config": {"fields": [{"name": "答案", "value": "{{ input.question }}"}]}}}, + {"op": "add_edge", "edge": {"source": "start", "target": "out"}}, + {"op": "done", "explanation": "d", "run": False}, + ] + final = (await _events(client, monkeypatch, ops, None))[-1] + pos = _positions(final["graph"]) + assert pos["out"][0] > pos["start"][0], "新建的图没有排版" + assert final["layout"]["mode"] == "full" + + +async def test_skipped_node_types_are_reported_in_final_issues(client, monkeypatch): + ops = [ + {"op": "plan", "summary": "x"}, + {"op": "add_node", "node": {"id": "ghost", "type": "no_such_type", "config": {}}}, + {"op": "done", "explanation": "d", "run": False}, + ] + final = (await _events(client, monkeypatch, ops, BASE))[-1] + skipped = [i for i in final["issues"] if i.get("code") == "unknown_node_type"] + assert len(skipped) == 1 and skipped[0]["node_id"] is None + assert skipped[0]["type"] == "no_such_type" and "no_such_type" in skipped[0]["message"] + + +async def test_a_text_reply_is_passed_through_on_the_canvas_too(client, monkeypatch): + ops = [{"op": "reply", "text": "按 mode 字段分成两路"}] + events = await _events(client, monkeypatch, ops, BASE) + kinds = [e["op"] for e in events] + assert {"op": "reply", "text": "按 mode 字段分成两路"} in events + assert "final" not in kinds, "纯文字回复不该再附一张图" + + +async def test_the_non_streaming_generate_keeps_positions_too(client, monkeypatch): + import app.api.copilot as copilot + + class _Structured: + def with_structured_output(self, _schema): + return self + + async def ainvoke(self, _messages): + return {"explanation": "改了提示词", "nodes": [ + {"id": n["id"], "type": n["type"], "label": n["id"], "config": n["data"]["config"]} + for n in BASE["nodes"] + ], "edges": BASE["edges"]} + + async def _model(*_a, **_k): + return _Structured(), "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + r = await client.post("/api/copilot/generate", json={"instruction": "改", "base_graph": BASE}) + assert r.status_code == 200, r.text + assert _positions(r.json()["graph"]) == _positions(BASE) diff --git a/backend/tests/test_manage_api.py b/backend/tests/test_manage_api.py new file mode 100644 index 00000000..aa2675af --- /dev/null +++ b/backend/tests/test_manage_api.py @@ -0,0 +1,554 @@ +"""管理页背后的接口:调试台只看不改、危险操作先说清后果、没保存的配置也能先测。 + +每一条都对应界面上一个「看起来生效、其实没生效」或者「说的和做的不一样」的地方: +回忆测试会抬高召回计数;标着「需确认」的工具点一下就真跑;数据源的提示指向 +表单上不存在的入口;填错一项要保存→关弹窗→点测试→再打开改。 +""" +from __future__ import annotations + +import sqlite3 + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import func, select + +from app.db.base import SessionLocal +from app.db.models import DataSource, Provider, Run, RunEvent, Workflow +from app.main import app +from app.memory import embeddings as emb + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture(autouse=True) +def _local_embedder(): + emb.configure("local") + yield + emb.configure("local") + + +@pytest.fixture +def shop_db(tmp_path): + path = tmp_path / "shop.db" + db = sqlite3.connect(path) + db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount REAL)") + db.execute("INSERT INTO orders VALUES (1, 9.5)") + db.commit() + db.close() + return str(path) + + +async def _count(model) -> int: + async with SessionLocal() as session: + return int((await session.execute(select(func.count()).select_from(model))).scalar_one()) + + +def _no_class_names(text: str) -> None: + for name in ("Error:", "Exception", "Traceback", ""): + assert name not in text, f"面向用户的报错里露出了内部符号 {name!r}:{text}" + + +# -------------------------------------------------------------------------- +# manage-4:调试台和运行时对得上,回忆测试只看不改 +# -------------------------------------------------------------------------- + + +async def _use_count(client, scope: str) -> int: + items = (await client.get("/api/memory", params={"scope": scope})).json() + return items[0]["use_count"] + + +async def test_peek_recall_leaves_the_use_count_alone(client): + r = await client.post("/api/memory", json={"content": "活跃用户指当月有订单的用户", "scope": "peek"}) + assert r.status_code == 201 + + r = await client.get("/api/memory/search", params={"q": "活跃用户", "scope": "peek", "peek": "true"}) + assert r.json()["results"], "peek 也得照常召回,否则调试台就没用了" + assert await _use_count(client, "peek") == 0 + + # 不带 peek 的是运行时的语义:计一次,下次排序时用得上 + await client.get("/api/memory/search", params={"q": "活跃用户", "scope": "peek"}) + assert await _use_count(client, "peek") == 1 + + +async def test_kb_status_and_search_agree_on_the_default_alpha(client): + status = (await client.get("/api/kb/embedding")).json() + assert status["default_alpha"] == emb.default_alpha() + + body = (await client.get("/api/kb/search", params={"q": "口径"})).json() + assert body["alpha"] == status["default_alpha"], "不传 alpha 时实际用了多少,要跟结果一起交出去" + body = (await client.get("/api/kb/search", params={"q": "口径", "alpha": 0.3})).json() + assert body["alpha"] == 0.3 + + +# -------------------------------------------------------------------------- +# manage-5:危险工具在工具库里直接执行,先说清会做什么 +# -------------------------------------------------------------------------- + + +async def test_a_dangerous_tool_is_not_run_without_confirm(client): + from app.core.config import settings + + target = settings.session_dir("playground") / "confirm-probe.txt" + target.unlink(missing_ok=True) + args = {"path": "confirm-probe.txt", "content": "hi"} + + r = await client.post("/api/tools/file_write/run", json={"args": args}) + assert r.status_code == 409 + detail = r.json()["detail"] + assert "confirm-probe.txt" in detail and "审批" in detail, detail + # 怎么确认(弹窗、再点一次)是界面的事,后端的话不能替它许诺 + assert "再点" not in detail and "点击" not in detail, detail + assert not target.exists(), "409 之前就已经写下去了" + + r = await client.post("/api/tools/file_write/run", params={"confirm": "true"}, json={"args": args}) + assert r.status_code == 200 and r.json()["ok"] is True + assert target.exists() + + target.unlink() + r = await client.post("/api/tools/file_write/run", json={"args": args, "confirm": True}) + assert r.status_code == 200 and target.exists() + target.unlink() + + +async def test_a_safe_tool_runs_straight_away(client): + r = await client.post("/api/tools/file_list/run", json={"args": {}}) + assert r.status_code == 200 and r.json()["ok"] is True + + +async def test_the_tool_list_says_whether_runtime_approval_really_applies(client): + r = await client.post("/api/custom-tools", json={"name": "probe_hook", "kind": "http", + "config": {"url": "https://example.com"}}) + assert r.status_code == 201 + try: + tools = {t["id"]: t for t in (await client.get("/api/tools")).json()} + assert tools["file_write"]["runtime_approval"] is True + assert tools["file_read"]["runtime_approval"] is False + # 有副作用,但运行时的审批关卡认不出它——标签不能说它会被审批 + assert tools["probe_hook"]["dangerous"] is True + assert tools["probe_hook"]["runtime_approval"] is False + finally: + await client.delete(f"/api/custom-tools/{r.json()['id']}") + + +# -------------------------------------------------------------------------- +# manage-7:提示只提表单上有的东西 +# -------------------------------------------------------------------------- + + +async def test_datasource_hints_only_point_at_fields_the_form_has(client): + kinds = {k["value"]: k for k in (await client.get("/api/datasources/kinds")).json()["kinds"]} + for kind in kinds.values(): + assert "options" not in kind["hint"], f"{kind['value']} 的提示指向了表单上没有的 options:{kind['hint']}" + assert "schema" in kinds["oracle"]["hint"] and "SID" in kinds["oracle"]["hint"] + # 高级连接参数里有什么,由后端给出:提示里提到的键在这里都找得到 + assert "charset" in [a["key"] for a in kinds["mysql"]["advanced"]] + assert {"service_name", "sid"} <= {a["key"] for a in kinds["oracle"]["advanced"]} + + +async def _oracle(name: str, **fields) -> dict: + """直接写库,好造出新表单之前的存法:service_name 在「数据库」一栏里。""" + async with SessionLocal() as session: + row = DataSource(name=name, kind="oracle", host="db", username="u", **fields) + session.add(row) + await session.commit() + return {"id": row.id} + + +async def _oracle_url(source_id: str) -> str: + from app.data.engine import build_url + + async with SessionLocal() as session: + return build_url(await session.get(DataSource, source_id)) + + +async def test_switching_an_old_oracle_source_to_sid_is_not_ignored(client): + """引擎是 `service_name or database`:老数据的「数据库」一栏有值时,新表单切到 + SID 存下去会被悄悄无视,连的还是原来那个 service_name。""" + src = await _oracle("ora_sid", database="ORCLPDB1", options={}) + try: + r = await client.patch(f"/api/datasources/{src['id']}", json={"options": {"sid": "XE"}}) + assert r.status_code == 200, r.text + assert r.json()["database"] is None and r.json()["options"] == {"sid": "XE"} + assert (await _oracle_url(src["id"])).endswith("/?sid=XE") + + r = await client.patch(f"/api/datasources/{src['id']}", json={"options": {"service_name": "BI"}}) + assert (await _oracle_url(src["id"])).endswith("/?service_name=BI") + finally: + await client.delete(f"/api/datasources/{src['id']}") + + +async def test_oracle_service_name_is_kept_in_one_place(client): + # 改无关的字段:老数据挪进 options.service_name,连的还是同一个库 + src = await _oracle("ora_legacy", database="ORCLPDB1", options={"schema": "ODS"}) + try: + r = await client.patch(f"/api/datasources/{src['id']}", json={"enabled": False}) + body = r.json() + assert body["database"] is None + assert body["options"] == {"schema": "ODS", "service_name": "ORCLPDB1"} + assert (await _oracle_url(src["id"])).endswith("/?service_name=ORCLPDB1") + finally: + await client.delete(f"/api/datasources/{src['id']}") + + # 旧表单把 service_name 框绑在「数据库」上,保存时整张表单连 options 一起发: + # 新填的值得生效,不能被 options 里原样带回来的旧值压住 + src = await _oracle("ora_oldform", database=None, options={"service_name": "BI", "schema": "ODS"}) + try: + r = await client.patch(f"/api/datasources/{src['id']}", json={ + "database": "BI2", "options": {"service_name": "BI", "schema": "ODS"}}) + assert r.json()["options"] == {"service_name": "BI2", "schema": "ODS"} + assert (await _oracle_url(src["id"])).endswith("/?service_name=BI2") + finally: + await client.delete(f"/api/datasources/{src['id']}") + + # 两处都有值的老数据原样再存一次:引擎原来连哪个,存完还连哪个 + src = await _oracle("ora_both", database="STALE", options={"service_name": "BI"}) + try: + r = await client.patch(f"/api/datasources/{src['id']}", json={ + "database": "STALE", "options": {"service_name": "BI"}}) + assert r.json()["database"] is None + assert (await _oracle_url(src["id"])).endswith("/?service_name=BI") + finally: + await client.delete(f"/api/datasources/{src['id']}") + + r = await client.post("/api/datasources", json={"name": "ora_new", "kind": "oracle", "host": "db", + "username": "u", "database": "LEFTOVER", + "options": {"sid": "XE"}}) + assert r.status_code == 201, r.text + created = r.json() + try: + assert created["database"] is None and created["options"] == {"sid": "XE"} + finally: + await client.delete(f"/api/datasources/{created['id']}") + + +# -------------------------------------------------------------------------- +# manage-8:没保存的配置先测,不落库 +# -------------------------------------------------------------------------- + + +async def test_testing_an_unsaved_datasource_does_not_save_it(client, shop_db): + before = await _count(DataSource) + + r = await client.post("/api/datasources/test", json={"name": "draft", "kind": "sqlite", + "database": shop_db}) + assert r.status_code == 200 and r.json()["ok"] is True, r.text + + r = await client.post("/api/datasources/test", json={"kind": "sqlite", + "database": "/no/such/dir/x.db"}) + body = r.json() + assert body["ok"] is False + _no_class_names(body["error"]) + assert body["hint"] and body["detail"], "人话之外,原始报错也得留着给排查用" + + assert await _count(DataSource) == before, "测试接口往库里写了东西" + + +async def test_testing_a_mistyped_sqlite_path_does_not_create_the_file(client, tmp_path): + """可写模式下 SQLite 连一个不存在的路径会当场建一个空库:测试说「连得上」, + 指的却是一个空文件,而且「只测不存」的接口往磁盘上写了东西。""" + missing = tmp_path / "typo.db" + r = await client.post("/api/datasources/test", json={"kind": "sqlite", "database": str(missing), + "readonly": False}) + body = r.json() + assert body["ok"] is False, body + assert "打不开这个数据库文件" in body["error"] and body["hint"] and body["detail"] + assert not missing.exists(), "测连接把一个空库建出来了" + + r = await client.post("/api/datasources/test", json={"kind": "sqlite", "database": "", "readonly": False}) + assert r.json()["ok"] is False, "没填路径时连的是内存库,说「连得上」是假的" + + # 已保存的数据源走的是缓存连接,同样不能顺手建文件 + r = await client.post("/api/datasources", json={"name": "typo_src", "kind": "sqlite", + "database": str(missing), "readonly": False}) + saved = r.json() + try: + body = (await client.post(f"/api/datasources/{saved['id']}/test")).json() + assert body["ok"] is False and not missing.exists() + finally: + await client.delete(f"/api/datasources/{saved['id']}") + + +async def test_draft_test_of_an_existing_source_keeps_the_saved_password(client, shop_db): + from app.api.datasources import DataSourceTestIn, _draft_source + from app.core.crypto import decrypt, encrypt + + row = DataSource(name="saved", kind="postgres", host="db", password=encrypt("s3cret")) + draft = _draft_source(DataSourceTestIn(kind="postgres", host="db2"), row) + assert decrypt(draft.password) == "s3cret", "编辑时密码框留空表示不改,测连接也得用存着的那个" + assert draft.host == "db2" + draft = _draft_source(DataSourceTestIn(kind="postgres", password="new"), row) + assert decrypt(draft.password) == "new" + + +async def test_testing_an_unsaved_provider_does_not_save_it(client): + before = await _count(Provider) + + r = await client.post("/api/providers/test", json={"name": "草稿", "kind": "mock", + "default_model": "mock-fast"}) + body = r.json() + assert body["ok"] is True, body + assert body["reply"] + + # 设置页照着契约里的路径调也得通 + r = await client.post("/api/settings/providers/test", json={"kind": "mock", "default_model": "mock-fast"}) + assert r.json()["ok"] is True + + r = await client.post("/api/providers/test", json={"kind": "openai_compatible", "api_key": "k", + "default_model": "m"}) + body = r.json() + assert body["ok"] is False and "Base URL" in body["error"] + + assert await _count(Provider) == before + + +async def test_an_openai_compatible_provider_needs_a_base_url(client): + r = await client.post("/api/providers", json={"name": "no-url", "kind": "openai_compatible", + "api_key": "k"}) + assert r.status_code == 400 and "Base URL" in r.json()["detail"] + + +async def test_listing_models_of_an_unsaved_provider(client): + r = await client.post("/api/providers/models", json={"kind": "mock"}) + body = r.json() + assert body["ok"] is True and "mock-fast" in body["models"] + + # 连不上时照实说,不抛 500 + r = await client.post("/api/providers/models", json={"kind": "openai_compatible", + "base_url": "http://127.0.0.1:9/v1"}) + body = r.json() + assert body["ok"] is False + _no_class_names(body["error"]) + + +@pytest.fixture +def models_endpoint(): + """一个本地的 /models:记下每次请求带了哪些头,拿来看钥匙发给了谁。""" + import json + import threading + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + seen: list[dict[str, str]] = [] + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 - http.server 的约定 + seen.append({k.lower(): v for k, v in self.headers.items()}) + body = json.dumps({"data": [{"id": "m1"}]}).encode() + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}", seen + finally: + server.shutdown() + server.server_close() + + +async def test_listing_models_never_sends_the_env_openai_key_to_another_host( + client, models_endpoint, monkeypatch +): + base, seen = models_endpoint + monkeypatch.setenv("OPENAI_API_KEY", "sk-env-secret") + for name in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"): + monkeypatch.delenv(name, raising=False) + + # 表单上还没填 Key 就点「拉取模型」:兼容网关是别人家的服务,环境里那把 + # OpenAI 的钥匙不能顺手发过去——真正调用时(factory)也从不这么做 + r = await client.post("/api/providers/models", json={"kind": "openai_compatible", + "base_url": f"{base}/v1"}) + assert r.json()["models"] == ["m1"] + assert "authorization" not in seen[-1], seen[-1] + + r = await client.post("/api/providers/models", json={"kind": "openai_compatible", "api_key": "sk-typed", + "base_url": f"{base}/v1"}) + assert seen[-1]["authorization"] == "Bearer sk-typed" + + # 官方 OpenAI 本来就会退回环境变量,和真正调用时一致 + r = await client.post("/api/providers/models", json={"kind": "openai", "base_url": f"{base}/v1"}) + assert seen[-1]["authorization"] == "Bearer sk-env-secret" + + +async def test_listing_models_authenticates_like_the_real_call(client, models_endpoint): + base, seen = models_endpoint + + await client.post("/api/providers/models", json={"kind": "anthropic", "api_key": "k1", "base_url": base}) + assert seen[-1]["x-api-key"] == "k1" and "authorization" not in seen[-1] + + await client.post("/api/providers/models", json={"kind": "anthropic", "api_key": "k2", "base_url": base, + "extra": {"auth_style": "bearer"}}) + assert seen[-1]["authorization"] == "Bearer k2" and "x-api-key" not in seen[-1] + + # 编辑已有接入时,请求头回显的是 ***:拉模型得换回存着的真值 + r = await client.post("/api/providers", json={ + "name": "gw-headers", "kind": "openai_compatible", "base_url": f"{base}/v1", "api_key": "k3", + "extra": {"headers": {"X-Tenant": "acme-real"}}, + }) + assert r.status_code in (200, 201), r.text + saved = r.json() + try: + await client.post("/api/providers/models", json={ + "id": saved["id"], "kind": "openai_compatible", "base_url": f"{base}/v1", + "extra": {"headers": {"X-Tenant": "***"}}, + }) + assert seen[-1]["x-tenant"] == "acme-real" + assert seen[-1]["authorization"] == "Bearer k3", "Key 框留空表示沿用已保存的" + finally: + await client.delete(f"/api/providers/{saved['id']}") + + +# -------------------------------------------------------------------------- +# manage-17:记忆看得出是哪来的,能原地改 +# -------------------------------------------------------------------------- + + +async def test_memory_items_say_where_they_came_from_and_can_be_edited(client): + async with SessionLocal() as session: + run = Run(workflow_name="周报", status="succeeded", graph={"nodes": [ + {"id": "remember_table", "type": "memory", "data": {"label": "记下口径", "config": {}}}, + ], "edges": []}) + session.add(run) + await session.commit() + run_id = run.id + + r = await client.post("/api/memory", json={"content": "毛利率按含税收入算", "scope": "src", + "meta": {"run_id": run_id, "node_id": "remember_table"}}) + from_run = r.json() + assert from_run["source"] == {"kind": "run", "run_id": run_id, "node_id": "remember_table", + "node_label": "记下口径", "workflow_name": "周报", "run_exists": True} + assert from_run["created_at"] + + r = await client.post("/api/memory", json={"content": "我叫张三", "scope": "src"}) + assert r.json()["source"]["kind"] == "manual" + + listed = {m["id"]: m for m in (await client.get("/api/memory", params={"scope": "src"})).json()} + assert listed[from_run["id"]]["source"]["node_label"] == "记下口径" + + r = await client.patch(f"/api/memory/{from_run['id']}", + json={"content": "毛利率按不含税收入算", "importance": 0.8}) + assert r.status_code == 200, r.text + edited = r.json() + assert edited["content"] == "毛利率按不含税收入算" and edited["importance"] == 0.8 + assert edited["source"]["run_id"] == run_id, "改内容不该把来源抹掉" + from app.db.models import MemoryItem + from app.memory.embeddings import embed_text, from_blob + + async with SessionLocal() as session: + row = await session.get(MemoryItem, from_run["id"]) + stored = from_blob(row.embedding, row.embed_dim) + fresh = await embed_text("毛利率按不含税收入算") + assert stored is not None and list(stored) == list(fresh), "改完内容,向量得跟着重算" + + assert (await client.patch("/api/memory/nope", json={"content": "x"})).status_code == 404 + # 只有空白的内容和空串是一回事:存下去就是一条空记忆 + for blank in ("", " ", "\n\t"): + r = await client.patch(f"/api/memory/{from_run['id']}", json={"content": blank}) + assert r.status_code == 422, (blank, r.text) + assert (await client.post("/api/memory", json={"content": " ", "scope": "src"})).status_code == 422 + + +async def test_memory_items_do_not_offer_a_time_that_moves_on_recall(client): + """updated_at 每次召回都会跟着动(记计数也是一次写),当「修改于」显示就是错的。 + + 界面要的两个时间是「记下于」和「上次召回」,各自有字段。 + """ + r = await client.post("/api/memory", json={"content": "库存周转按月末库存算", "scope": "when"}) + item = r.json() + assert item["created_at"] and "last_used_at" in item + assert "updated_at" not in item + await client.get("/api/memory/search", params={"q": "库存周转", "scope": "when"}) + listed = (await client.get("/api/memory", params={"scope": "when"})).json()[0] + assert listed["last_used_at"] and "updated_at" not in listed + + +# -------------------------------------------------------------------------- +# manage-6:知识库收什么格式,一处定义 +# -------------------------------------------------------------------------- + + +async def test_kb_formats_come_from_the_parser(client): + formats = (await client.get("/api/kb/formats")).json() + assert ".pptx" in formats["extensions"] + assert ".csv" not in formats["extensions"] and ".csv" in formats["tabular"] + assert formats["accept"] == ",".join(formats["extensions"]) + + from app.memory.parsing import extract + + for ext in formats["text"]: + assert extract("你好".encode(), f"a{ext}") == "你好" + + +async def test_a_csv_upload_is_pointed_at_datasources(client): + r = await client.post("/api/kb/upload", files={"file": ("sales.csv", b"a,b\n1,2", "text/csv")}) + assert r.status_code == 415 + detail = r.json()["detail"] + assert "数据源" in detail and "传表格" in detail, detail + + +# -------------------------------------------------------------------------- +# studio-05:取一个历史版本的图 +# -------------------------------------------------------------------------- + + +def _graph(field: str) -> dict: + return {"nodes": [{"id": "a", "type": "input", "position": {"x": 0, "y": 0}, + "data": {"label": "入口", "config": {"fields": [{"name": field}]}}}], + "edges": []} + + +async def test_a_published_version_can_be_fetched_with_its_input_fields(client): + wf = (await client.post("/api/workflows", json={"name": "版本", "graph": _graph("q")})).json() + await client.patch(f"/api/workflows/{wf['id']}", json={"graph": _graph("q2")}) + + r = await client.get(f"/api/workflows/{wf['id']}/versions/1") + assert r.status_code == 200, r.text + body = r.json() + assert body["version"] == 1 and body["graph"] == _graph("q") + assert [f["name"] for f in body["input_fields"]] == ["q"] + assert body["graph_hash"] + + assert (await client.get(f"/api/workflows/{wf['id']}/versions/9")).status_code == 404 + assert (await client.get("/api/workflows/nope/versions/1")).status_code == 404 + await client.delete(f"/api/workflows/{wf['id']}") + + +# -------------------------------------------------------------------------- +# runs-15:只从跑通的运行提取模板 +# -------------------------------------------------------------------------- + + +async def _run_with(status: str) -> str: + graph = {"nodes": [{"id": "a", "type": "input", "data": {"label": "入口", "config": {}}}], + "edges": []} + async with SessionLocal() as session: + run = Run(workflow_name="提取", status=status, graph=graph) + session.add(run) + await session.flush() + session.add(RunEvent(run_id=run.id, seq=1, type="node.started", node_id="a", ts=0.0, data={})) + await session.commit() + return run.id + + +async def test_only_a_succeeded_run_can_become_a_template(client): + for status in ("failed", "cancelled", "interrupted", "running"): + r = await client.post("/api/copilot/from-run", json={"run_id": await _run_with(status)}) + assert r.status_code == 409, (status, r.text) + assert "没有跑完" in r.json()["detail"] + + before = await _count(Workflow) + r = await client.post("/api/copilot/from-run", json={"run_id": await _run_with("succeeded")}) + assert r.status_code == 200, r.text + assert await _count(Workflow) == before + 1 + await client.delete(f"/api/workflows/{r.json()['workflow_id']}") diff --git a/backend/tests/test_run_defaults.py b/backend/tests/test_run_defaults.py new file mode 100644 index 00000000..aa0ead28 --- /dev/null +++ b/backend/tests/test_run_defaults.py @@ -0,0 +1,127 @@ +"""设置页的「运行默认值」必须真的被读到。 + +以前这三项只存在于 DEFAULT_SETTINGS 里:发起运行永远落在 "default", +「危险工具默认需要人工确认」勾不勾都一样。用户改了默认知识库,以为以后的 +运行都会用它——这比没有这个开关更糟,它给的是虚假的确定感。 + +这里守发起运行这一侧:请求没带的 memory_scope / collection 取设置里的值; +审批默认值的换算给引擎一个唯一的出处。 +""" +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.db.base import SessionLocal +from app.db.models import Run, Setting +from app.main import app + +GRAPH = { + "nodes": [ + {"id": "a", "type": "input", "data": {"label": "入口", "config": {"fields": [{"name": "q"}]}}}, + {"id": "b", "type": "output", "data": {"label": "成果", + "config": {"fields": [{"name": "x", "value": "{{ input.q }}"}]}}}, + ], + "edges": [{"source": "a", "target": "b"}], +} + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture(autouse=True) +async def _clean_run_settings(): + async def drop() -> None: + async with SessionLocal() as session: + row = await session.get(Setting, "run") + if row: + await session.delete(row) + await session.commit() + + await drop() + yield + await drop() + + +@pytest.fixture +def started(monkeypatch): + """截下交给 run_manager.start 的参数,不真的起执行任务(测试里没有 checkpointer)。""" + from app.api import runs as runs_api + + captured: dict = {} + + async def fake_start(**kw): + captured.update(kw) + async with SessionLocal() as session: + run = Run(workflow_name=kw.get("workflow_name", ""), status="queued", + graph=kw["graph"], input=kw["input_payload"]) + session.add(run) + await session.commit() + await session.refresh(run) + return run + + monkeypatch.setattr(runs_api.run_manager, "start", fake_start) + return captured + + +async def _put_run_settings(client, **values) -> None: + base = {"default_memory_scope": "default", "default_collection": "default", + "stream_tokens": True, "confirm_dangerous_tools": True} + r = await client.put("/api/settings", json={"values": {"run": {**base, **values}}}) + assert r.status_code == 200 + + +async def test_a_run_without_scope_uses_the_settings_defaults(client, started): + await _put_run_settings(client, default_memory_scope="ops", default_collection="manuals") + + r = await client.post("/api/runs", json={"graph": GRAPH, "input": {"q": "x"}}) + assert r.status_code == 201, r.text + assert started["memory_scope"] == "ops" + assert started["collection"] == "manuals" + + +async def test_what_the_request_says_wins_over_the_defaults(client, started): + await _put_run_settings(client, default_memory_scope="ops", default_collection="manuals") + + r = await client.post("/api/runs", json={"graph": GRAPH, "memory_scope": "team", + "collection": "specs"}) + assert r.status_code == 201, r.text + assert (started["memory_scope"], started["collection"]) == ("team", "specs") + + +async def test_blank_or_missing_settings_fall_back_to_default(client, started): + r = await client.post("/api/runs", json={"graph": GRAPH}) + assert r.status_code == 201, r.text + assert (started["memory_scope"], started["collection"]) == ("default", "default") + + # 设置页把输入框清空再保存,存下来的是空串——不能拿空串当记忆域 + await _put_run_settings(client, default_memory_scope="", default_collection=" ") + r = await client.post("/api/runs", json={"graph": GRAPH}) + assert (started["memory_scope"], started["collection"]) == ("default", "default") + + +async def test_run_defaults_reads_through_to_the_stored_group(client): + from app.api.settings import run_defaults + + await _put_run_settings(client, default_collection="manuals", confirm_dangerous_tools=False) + async with SessionLocal() as session: + values = await run_defaults(session) + assert values["default_collection"] == "manuals" + assert values["confirm_dangerous_tools"] is False + # 没存过的键回落到内置默认,而不是 KeyError + assert values["stream_tokens"] is True + + +def test_confirm_dangerous_tools_maps_onto_an_approval_policy(): + """引擎那边只认 approval 的取值,这里是唯一的换算出处。""" + from app.api.settings import tool_approval_default + + assert tool_approval_default({"confirm_dangerous_tools": True}) == "dangerous" + assert tool_approval_default({"confirm_dangerous_tools": False}) == "never" + # 缺省、存坏了都按开着算:关审批必须是一次明确的选择 + assert tool_approval_default({}) == "dangerous" + assert tool_approval_default({"confirm_dangerous_tools": None}) == "dangerous" diff --git a/backend/tests/test_run_timing.py b/backend/tests/test_run_timing.py new file mode 100644 index 00000000..c7ae9384 --- /dev/null +++ b/backend/tests/test_run_timing.py @@ -0,0 +1,253 @@ +"""耗时口径:经过审批恢复或接着跑的运行,耗时不能只算最后一段。 + +真实运行 2609b1c7:事件从头到尾约 87 秒,列表和页脚却写 22ms——每次 _drive +都把 started_at 改成"这一段的开始",_finalize 又拿这一段的耗时覆盖 usage.duration_ms。 +审批恢复和接着跑正是治理的主路径,所以凡是走过这条路的运行,耗时全是错的。 + +现在的口径: +- started_at 只在第一次开始时写; +- duration_ms(= active_ms)是各段执行时长之和; +- wall_ms 是从第一次开始到结束的墙钟;wait_ms 是等人审批的总时长; +- 三者随 run.finished / run.failed / run.cancelled 的 timing 一起发出。 +""" + +from __future__ import annotations + +import asyncio +import time +from datetime import datetime + +import pytest +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine import compiler +from app.engine.context import NodeError +from app.engine.runner import run_manager +from app.engine.schema import NodeType + +SEGMENT = 0.3 + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), + "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +@pytest.fixture +def slow_transforms(monkeypatch): + """id 以 slow 开头的整形节点各卡 SEGMENT 秒:让每一段执行都有可量的长度。""" + original = compiler.RUNNERS[NodeType.TRANSFORM] + + async def runner(state, ctx): + if ctx.node.id.startswith("slow"): + await asyncio.sleep(SEGMENT) + return await original(state, ctx) + + monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, runner) + + +async def _wait(run_id: str, statuses: tuple[str, ...], timeout: float = 20.0) -> Run: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row and row.status in statuses: + return row + await asyncio.sleep(0.03) + raise AssertionError(f"等超时了:{run_id}") + + +async def _events(run_id: str) -> list[RunEvent]: + async with SessionLocal() as session: + return list((await session.execute( + select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq) + )).scalars()) + + +def _same_instant(a: datetime, b: datetime) -> bool: + return abs((a - b).total_seconds()) < 1e-3 + + +APPROVAL_GRAPH = chain( + node("start", "input", fields=[{"name": "question"}]), + node("slow1", "transform", mode="template", template="第一段"), + node("gate", "human", mode="approve", title="看一眼"), + node("slow2", "transform", mode="template", template="第二段"), + node("out", "output", fields=[{"name": "结果", "value": "{{ nodes.slow2 }}"}]), +) + +WAIT = 0.6 + + +async def test_resumed_run_keeps_its_start_and_sums_every_segment(slow_transforms): + run = await run_manager.start(graph=APPROVAL_GRAPH, input_payload={"question": "q"}) + paused = await _wait(run.id, ("interrupted", "failed")) + assert paused.status == "interrupted", paused.error + first_start = paused.started_at + assert first_start is not None + + await asyncio.sleep(WAIT) # 人去倒了杯水 + await run_manager.resume(run.id, {"approved": True}) + done = await _wait(run.id, ("succeeded", "failed")) + assert done.status == "succeeded", done.error + + assert _same_instant(done.started_at, first_start), \ + f"恢复把 started_at 从 {first_start} 改成了 {done.started_at}" + + usage = done.usage + # 两段各至少 SEGMENT 秒。只算最后一段的话这里只有一个 SEGMENT + assert usage["duration_ms"] >= 2 * SEGMENT * 1000 * 0.9, usage + assert usage["active_ms"] == usage["duration_ms"] + assert WAIT * 1000 * 0.8 <= usage["wait_ms"] < usage["wall_ms"], usage + assert usage["wall_ms"] >= usage["active_ms"] + usage["wait_ms"] - 50, usage + wall = (done.finished_at - done.started_at).total_seconds() * 1000 + assert abs(usage["wall_ms"] - wall) < 100, (usage, wall) + + finished = next(e for e in await _events(run.id) if e.type == "run.finished") + assert finished.data["timing"] == { + "wall_ms": usage["wall_ms"], "active_ms": usage["active_ms"], "wait_ms": usage["wait_ms"], + } + + +@pytest.fixture +def fails_once(monkeypatch): + original = compiler.RUNNERS[NodeType.TRANSFORM] + calls = {"n": 0} + + async def runner(state, ctx): + if ctx.node.id.startswith("slow"): + await asyncio.sleep(SEGMENT) + if ctx.node.id == "flaky": + calls["n"] += 1 + if calls["n"] == 1: + raise NodeError(ctx.node.id, "第一次故意失败") + return await original(state, ctx) + + monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, runner) + return calls + + +async def test_continued_run_keeps_its_start_and_sums_every_segment(fails_once): + graph = chain( + node("start", "input", fields=[{"name": "question"}]), + node("slow1", "transform", mode="template", template="前面这步"), + node("flaky", "transform", mode="template", template="会挂一次"), + node("out", "output", fields=[{"name": "结果", "value": "{{ nodes.flaky }}"}]), + ) + run = await run_manager.start(graph=graph, input_payload={"question": "q"}) + failed = await _wait(run.id, ("failed", "succeeded")) + assert failed.status == "failed" + first_start = failed.started_at + assert failed.usage["duration_ms"] >= SEGMENT * 1000 * 0.9 + + failed_ev = next(e for e in await _events(run.id) if e.type == "run.failed") + assert set(failed_ev.data["timing"]) == {"wall_ms", "active_ms", "wait_ms"} + assert failed_ev.data["node_id"] == "flaky" + + await run_manager.continue_failed(run.id) + done = await _wait(run.id, ("succeeded", "failed")) + assert done.status == "succeeded", done.error + assert _same_instant(done.started_at, first_start) + # 第二段只跑了 flaky 和 out,很快。只算最后一段的话会远小于 SEGMENT + assert done.usage["duration_ms"] >= SEGMENT * 1000 * 0.9, done.usage + # 失败到接着跑之间不是在等审批 + assert done.usage["wait_ms"] == 0, done.usage + + +async def test_cancel_reports_timing_too(slow_transforms): + graph = chain( + node("start", "input", fields=[{"name": "question"}]), + node("slow1", "transform", mode="template", template="x"), + node("slow2", "transform", mode="template", template="y"), + ) + run = await run_manager.start(graph=graph, input_payload={"question": "q"}) + await asyncio.sleep(SEGMENT / 2) + assert await run_manager.cancel(run.id) + await _wait(run.id, ("cancelled",)) + cancelled = next(e for e in await _events(run.id) if e.type == "run.cancelled") + assert set(cancelled.data["timing"]) == {"wall_ms", "active_ms", "wait_ms"} + assert cancelled.data["timing"]["active_ms"] > 0 + + +async def test_long_output_says_it_was_clipped(): + """事件里的 output 为了控制体积会截断——截了就得说,消费方才知道要回源取全文。""" + long_text = "很长的结论。" * 1500 + graph = chain( + node("start", "input", fields=[{"name": "question"}]), + node("out", "output", fields=[{"name": "answer", "value": "{{ input.question }}"}]), + ) + run = await run_manager.start(graph=graph, input_payload={"question": long_text}) + done = await _wait(run.id, ("succeeded", "failed")) + assert done.status == "succeeded", done.error + finished = next(e for e in await _events(run.id) if e.type == "run.finished") + assert finished.data.get("output_truncated") is True + assert len(finished.data["output"]["answer"]) < len(long_text) + assert done.output["answer"] == long_text, "库里的是完整版本" + + short = await run_manager.start(graph=graph, input_payload={"question": "短的"}) + await _wait(short.id, ("succeeded",)) + finished = next(e for e in await _events(short.id) if e.type == "run.finished") + assert not finished.data.get("output_truncated") + + +async def test_event_ts_is_when_it_happened_not_when_it_was_forwarded(): + async with SessionLocal() as session: + run = Run(workflow_name="ts", status="running", graph={}, input={}) + session.add(run) + await session.commit() + run_id = run.id + produced_at = time.time() - 5 + await run_manager._handle_custom(run_id, { + "__agentlab__": True, "type": "log", "node_id": "x", "ts": produced_at, + "data": {"message": "早先发生的事"}, + }) + [event] = await _events(run_id) + assert abs(event.ts - produced_at) < 1e-6 + + +async def test_node_started_says_which_iteration_and_whether_it_was_a_replay(slow_transforms): + graph = { + "nodes": [ + node("start", "input", fields=[{"name": "question"}]), + node("lp", "loop", mode="foreach", items='["a", "b", "c"]', max_iterations=5), + node("body", "transform", mode="template", template="{{ vars.item }}"), + node("gate", "human", mode="approve", title="收尾前看一眼"), + node("out", "output", fields=[{"name": "结果", "value": "ok"}]), + ], + "edges": [ + {"source": "start", "target": "lp"}, + {"source": "lp", "target": "body", "sourceHandle": "body"}, + {"source": "body", "target": "lp"}, + {"source": "lp", "target": "gate", "sourceHandle": "done"}, + {"source": "gate", "target": "out"}, + ], + } + run = await run_manager.start(graph=graph, input_payload={"question": "q"}) + await _wait(run.id, ("interrupted",)) + await run_manager.resume(run.id, {"approved": True}) + await _wait(run.id, ("succeeded",)) + + events = await _events(run.id) + body = [e.data.get("iteration") for e in events + if e.type == "node.started" and e.node_id == "body"] + assert body == [1, 2, 3] + gate = [e.data for e in events if e.type == "node.started" and e.node_id == "gate"] + assert len(gate) == 2 + assert not gate[0].get("resumed") + assert gate[1].get("resumed") is True, "恢复后重放的那一次要标出来" + out = [e.data for e in events if e.type == "node.started" and e.node_id == "out"] + assert not out[0].get("resumed"), "恢复之后新走到的节点不是重放" diff --git a/backend/tests/test_runs_api.py b/backend/tests/test_runs_api.py new file mode 100644 index 00000000..96bf1750 --- /dev/null +++ b/backend/tests/test_runs_api.py @@ -0,0 +1,469 @@ +"""运行与审批接口的形状:时区、列表筛选与翻页、审批上下文、删除保护、事件流收尾。 + +- 时区:库是 SQLite,DateTime 读回来是 naive 的,API 吐出 '2026-09-26T01:11:19'。 + JS 把不带偏移的 ISO 串当本地时间解析,列表整体慢 8 小时。 +- 列表:只有一个 limit,待审批的运行排在第 145 位就永远翻不到。 +- 审批:返回项里没有工作流名、节点名,全局待办列表拼不出一行像样的字。 +- 删除:运行中的能删、封存过的正式运行一键就删——出具的追溯依据说没就没。 +- 事件流:连到一条停在 interrupted 的运行,回放完就不再有下文,客户端以为它还在跑。 +- 接着跑:服务重启挂起的运行被说成「在等待审批」,还让人去一张不存在的审批卡上处理; + 已取消的只说不行,不说该怎么办。不存在的运行,detail 带着 Python repr 的引号。 +""" + +from __future__ import annotations + +import asyncio +import uuid +from datetime import datetime, timedelta, timezone +from urllib.parse import quote + +import pytest +from fastapi import WebSocketDisconnect +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent, Workflow +from app.engine import compiler +from app.engine.runner import run_manager +from app.engine.schema import NodeType +from app.main import app + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": label or nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), + "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +GATED = chain( + node("start", "input", fields=[{"name": "question"}]), + node("gate", "human", label="发布前把关", mode="approve", title="这条公告可以发吗?"), + node("out", "output", fields=[{"name": "结果", "value": "{{ nodes.gate.approved }}"}]), +) + + +async def _wait(run_id: str, statuses: tuple[str, ...], timeout: float = 15.0) -> Run: + for _ in range(int(timeout / 0.03)): + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row and row.status in statuses: + return row + await asyncio.sleep(0.03) + raise AssertionError(f"等超时了:{run_id}") + + +def _aware(value: str) -> datetime: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + assert parsed.tzinfo is not None, f"时间不带时区:{value}" + return parsed + + +# -------------------------------------------------------------------------- +# 时区 +# -------------------------------------------------------------------------- + + +async def test_every_time_the_api_returns_carries_an_offset(client): + r = await client.post("/api/runs", json={"graph": GATED, "input": {"question": "q"}}) + assert r.status_code == 201, r.text + run_id = r.json()["id"] + await _wait(run_id, ("interrupted",)) + + body = (await client.get(f"/api/runs/{run_id}")).json() + created = _aware(body["created_at"]) + _aware(body["started_at"]) + # 写进去的是 UTC 的"现在",读回来也得是同一个时刻 + assert abs((datetime.now(timezone.utc) - created).total_seconds()) < 60 + + listed = (await client.get("/api/runs", params={"limit": 5})).json() + assert all(_aware(r["created_at"]) for r in listed) + + [approval] = (await client.get("/api/approvals", params={"run_id": run_id})).json() + _aware(approval["created_at"]) + + for artifact in (await client.get(f"/api/runs/{run_id}/artifacts")).json(): + _aware(artifact["created_at"]) + + +# -------------------------------------------------------------------------- +# 列表 +# -------------------------------------------------------------------------- + + +@pytest.fixture +async def seeded_runs(): + """一个专属工作流下的一组运行,时间逐条错开。只查它们,不受别的测试干扰。""" + base = datetime(2026, 9, 1, 8, 0, tzinfo=timezone.utc) + async with SessionLocal() as session: + wf = Workflow(name=f"列表测试·周报-{uuid.uuid4().hex[:6]}", graph={}) + session.add(wf) + await session.flush() + spec = [ + ("succeeded", "formal"), ("failed", "exploratory"), ("cancelled", "exploratory"), + ("interrupted", "formal"), ("succeeded", "exploratory"), ("failed", "formal"), + ] + runs = [] + for i, (status, run_class) in enumerate(spec): + run = Run(workflow_id=wf.id, workflow_name=wf.name, status=status, + run_class=run_class, graph={}, input={}, + created_at=base + timedelta(minutes=i)) + session.add(run) + runs.append(run) + await session.commit() + ids = [r.id for r in runs] + return wf.id, ids, wf.name # 按创建时间从旧到新 + + +async def test_status_filter_takes_a_comma_list(client, seeded_runs): + wf_id, ids, _ = seeded_runs + r = await client.get("/api/runs", params={"workflow_id": wf_id, "status": "failed,cancelled"}) + assert r.status_code == 200, r.text + assert {x["status"] for x in r.json()} == {"failed", "cancelled"} + assert len(r.json()) == 3 + + +async def test_run_class_and_name_filters(client, seeded_runs): + wf_id, ids, name = seeded_runs + formal = (await client.get("/api/runs", params={"workflow_id": wf_id, "run_class": "formal"})).json() + assert len(formal) == 3 and all(x["run_class"] == "formal" for x in formal) + + by_name = (await client.get("/api/runs", params={"q": name[3:]})).json() + assert {x["id"] for x in by_name} == set(ids), "按工作流名模糊匹配" + # 用户输入的 % 和 _ 按字面算,不当通配符 + assert (await client.get("/api/runs", params={"q": "列表%周报"})).json() == [] + + +async def test_newest_first_and_before_is_a_cursor(client, seeded_runs): + wf_id, ids, _ = seeded_runs + page1 = (await client.get("/api/runs", params={"workflow_id": wf_id, "limit": 2})).json() + assert [x["id"] for x in page1] == [ids[5], ids[4]] + cursor = page1[-1]["created_at"] + page2 = (await client.get("/api/runs", params={ + "workflow_id": wf_id, "limit": 2, "before": cursor})).json() + assert [x["id"] for x in page2] == [ids[3], ids[2]] + + +async def test_limit_is_capped(client): + assert (await client.get("/api/runs", params={"limit": 201})).status_code == 422 + assert (await client.get("/api/runs", params={"limit": 200})).status_code == 200 + + +async def test_run_out_has_the_new_fields(client, seeded_runs): + _, ids, _ = seeded_runs + body = (await client.get(f"/api/runs/{ids[0]}")).json() + for key in ("manifest_seq", "finished_at", "error_node_id"): + assert key in body, key + + +async def test_graph_snapshot_endpoint(client): + r = await client.post("/api/runs", json={"graph": GATED, "input": {"question": "q"}}) + run_id = r.json()["id"] + got = await client.get(f"/api/runs/{run_id}/graph") + assert got.status_code == 200, got.text + body = got.json() + assert set(body) == {"graph", "workflow_id", "version"} + assert [n["id"] for n in body["graph"]["nodes"]] == ["start", "gate", "out"] + assert (await client.get("/api/runs/nope/graph")).status_code == 404 + + +# -------------------------------------------------------------------------- +# 审批 +# -------------------------------------------------------------------------- + + +async def test_pending_approvals_carry_their_context_newest_first(client): + wf = None + async with SessionLocal() as session: + wf = Workflow(name="审批测试·公告", graph=GATED) + session.add(wf) + await session.commit() + first = (await client.post("/api/runs", json={ + "workflow_id": wf.id, "input": {"question": "一"}})).json()["id"] + await _wait(first, ("interrupted",)) + second = (await client.post("/api/runs", json={ + "workflow_id": wf.id, "input": {"question": "二"}})).json()["id"] + await _wait(second, ("interrupted",)) + + items = (await client.get("/api/approvals", params={"status": "pending"})).json() + mine = [a for a in items if a["run_id"] in (first, second)] + assert [a["run_id"] for a in mine] == [second, first], "按发起时间倒序" + a = mine[0] + assert a["workflow_name"] == "审批测试·公告" + assert a["node_label"] == "发布前把关" + assert a["run_status"] == "interrupted" + assert a["run_class"] == "exploratory" + assert a["resolved_by"] is None and a["resolved_at"] is None + + +async def test_resolution_records_who_signed_and_when(client): + run_id = (await client.post("/api/runs", json={ + "graph": GATED, "input": {"question": "q"}})).json()["id"] + await _wait(run_id, ("interrupted",)) + [pending] = (await client.get("/api/approvals", params={"run_id": run_id})).json() + + # 浏览器的请求头只能是 Latin-1,中文署名按 encodeURIComponent 编码后发 + r = await client.post(f"/api/approvals/{pending['id']}/decide", + json={"approved": True}, headers={"X-Actor": quote("张工")}) + assert r.status_code == 200, r.text + await _wait(run_id, ("succeeded",)) + + [done] = (await client.get("/api/approvals", + params={"run_id": run_id, "status": "all"})).json() + assert done["resolved_by"] == "张工" + _aware(done["resolved_at"]) + + events = (await client.get(f"/api/runs/{run_id}/events")).json() + resolved = next(e for e in events if e["type"] == "human.resolved") + assert resolved["data"].get("actor") == "张工" + resumed = next(e for e in events if e["type"] == "run.resumed") + assert resumed["data"].get("actor") == "张工" + + +async def test_unsigned_resolution_stays_null(client): + run_id = (await client.post("/api/runs", json={ + "graph": GATED, "input": {"question": "q"}})).json()["id"] + await _wait(run_id, ("interrupted",)) + [pending] = (await client.get("/api/approvals", params={"run_id": run_id})).json() + await client.post(f"/api/approvals/{pending['id']}/decide", json={"approved": False}) + await _wait(run_id, ("succeeded", "failed")) + [done] = (await client.get("/api/approvals", + params={"run_id": run_id, "status": "all"})).json() + assert done["resolved_by"] is None + + +# -------------------------------------------------------------------------- +# 删除保护 +# -------------------------------------------------------------------------- + + +async def _plain_run(**fields) -> str: + async with SessionLocal() as session: + run = Run(workflow_name="删除测试", graph={}, input={}, **fields) + session.add(run) + await session.commit() + return run.id + + +@pytest.mark.parametrize("status", ["running", "queued"]) +async def test_a_live_run_cannot_be_deleted(client, status): + run_id = await _plain_run(status=status) + r = await client.delete(f"/api/runs/{run_id}") + assert r.status_code == 409 + assert "停止" in r.json()["detail"] + assert (await client.get(f"/api/runs/{run_id}")).status_code == 200 + + +async def test_a_sealed_formal_run_needs_force(client): + run_id = await _plain_run(status="succeeded", run_class="formal", + manifest_hash="ab" * 32, manifest_seq=12) + r = await client.delete(f"/api/runs/{run_id}") + assert r.status_code == 409 + detail = r.json()["detail"] + assert "force" not in detail.lower() or "清单" in detail + assert "清单" in detail and "无法" in detail, detail + assert (await client.get(f"/api/runs/{run_id}")).status_code == 200 + + r = await client.delete(f"/api/runs/{run_id}", params={"force": "true"}) + assert r.status_code == 204 + assert (await client.get(f"/api/runs/{run_id}")).status_code == 404 + + +async def test_an_ordinary_finished_run_deletes_straight_away(client): + run_id = await _plain_run(status="succeeded", run_class="exploratory", manifest_hash="cd" * 32) + assert (await client.delete(f"/api/runs/{run_id}")).status_code == 204 + + +# -------------------------------------------------------------------------- +# 事件流收尾 +# -------------------------------------------------------------------------- + + +class _FakeSocket: + """够 stream_run 用的最小 WebSocket:记下发出去的,客户端不主动说话。""" + + def __init__(self, after: int = 0) -> None: + self.query_params = {"after": str(after)} + self.sent: list[dict] = [] + self.closed = asyncio.Event() + + async def accept(self) -> None: + return None + + async def send_json(self, data) -> None: + if self.closed.is_set(): + raise RuntimeError("closed") + self.sent.append(data) + + async def receive_text(self) -> str: + await self.closed.wait() + raise WebSocketDisconnect() + + async def close(self, code: int = 1000) -> None: + self.closed.set() + + +async def _stream(run_id: str, timeout: float = 5.0) -> list[dict]: + from app.api.runs import stream_run + + ws = _FakeSocket() + await asyncio.wait_for(stream_run(ws, run_id), timeout) + return ws.sent + + +async def test_connecting_to_an_interrupted_run_ends_with_a_marker(client): + run_id = (await client.post("/api/runs", json={ + "graph": GATED, "input": {"question": "q"}})).json()["id"] + await _wait(run_id, ("interrupted",)) + sent = await _stream(run_id) + assert sent[-1]["type"] == "stream.end" + assert sent[-1]["data"] == {"status": "interrupted"} + assert any(m.get("type") == "run.interrupted" for m in sent[:-1]) + + +async def test_connecting_to_a_finished_run_ends_with_a_marker(client): + graph = chain(node("start", "input"), node("out", "output")) + run_id = (await client.post("/api/runs", json={"graph": graph, "input": {}})).json()["id"] + await _wait(run_id, ("succeeded",)) + sent = await _stream(run_id) + assert sent[-1]["type"] == "stream.end" + assert sent[-1]["data"] == {"status": "succeeded"} + assert sent[-1]["status"] == "succeeded", "老客户端读的是顶层 status" + + +async def test_a_live_stream_ends_with_a_marker_when_the_run_finishes(client): + run_id = (await client.post("/api/runs", json={ + "graph": GATED, "input": {"question": "q"}})).json()["id"] + await _wait(run_id, ("interrupted",)) + [pending] = (await client.get("/api/approvals", params={"run_id": run_id})).json() + await client.post(f"/api/approvals/{pending['id']}/decide", json={"approved": True}) + + # 恢复之后连上去跟着它跑完:终态之后也要有结束标记,而不是连接悬着 + sent = await _stream(run_id) + assert sent[-1]["type"] == "stream.end", [m.get("type") for m in sent[-3:]] + assert sent[-1]["data"]["status"] == "succeeded" + assert any(m.get("type") == "run.finished" for m in sent) + + +async def test_a_missing_run_ends_the_stream_instead_of_hanging(): + sent = await _stream("no-such-run") + assert sent[-1]["type"] == "stream.end" + + +async def test_a_run_stranded_by_a_hard_kill_says_so_after_restart(): + """进程被强杀时连 server_shutdown 日志都来不及发。重启扫描把它标成 interrupted 的 + 同时补一条,回放这条运行的客户端才知道它是停在了半路,而不是还在跑。""" + from app.core.bus import bus + + async with SessionLocal() as session: + run = Run(workflow_name="强杀", status="running", graph={}, input={}, last_seq=3) + session.add(run) + await session.commit() + run_id = run.id + await run_manager.shutdown() + bus._seq.pop(run_id, None) # 新进程:内存里的序号是空的 + await run_manager.setup() + + async with SessionLocal() as session: + row = await session.get(Run, run_id) + assert row.status == "interrupted" + sent = await _stream(run_id) + log = [m for m in sent if m.get("type") == "log"] + assert [m["data"].get("code") for m in log] == ["server_shutdown"] + assert log[0]["seq"] == 4, "序号要接着库里的 last_seq 往下排" + assert sent[-1]["data"] == {"status": "interrupted"} + + +# -------------------------------------------------------------------------- +# 接着跑:拒绝时说清楚为什么、下一步做什么 +# -------------------------------------------------------------------------- + + +async def test_continuing_a_cancelled_run_says_start_again(client): + run_id = await _plain_run(status="cancelled") + r = await client.post(f"/api/runs/{run_id}/continue", json={}) + assert r.status_code == 409 + detail = r.json()["detail"] + assert "已取消" in detail and "重新发起" in detail, detail + assert "审批" not in detail, detail + + +async def test_continuing_a_run_that_waits_for_approval_points_to_the_card(client): + run_id = (await client.post("/api/runs", json={ + "graph": GATED, "input": {"question": "q"}})).json()["id"] + await _wait(run_id, ("interrupted",)) + r = await client.post(f"/api/runs/{run_id}/continue", json={}) + assert r.status_code == 409 + assert "审批卡" in r.json()["detail"] + assert (await client.get(f"/api/runs/{run_id}")).json()["status"] == "interrupted" + + +async def test_a_run_suspended_by_a_restart_continues_from_its_checkpoint(client, monkeypatch): + """服务重启挂起的运行没有在等谁,断点完好。「接着跑」就是从断点驱动, + 和失败后接着跑是同一件事——以前这里回 409,说它「在等待审批」。""" + original = compiler.RUNNERS[NodeType.TRANSFORM] + calls = {"slow": 0} + + async def slow_once(state, ctx): + if ctx.node.id == "slow": + calls["slow"] += 1 + if calls["slow"] == 1: + await asyncio.sleep(30) + return await original(state, ctx) + + monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, slow_once) + graph = chain( + node("start", "input", fields=[{"name": "question"}]), + node("a", "transform", mode="template", template="前面这步"), + node("slow", "transform", mode="template", template="慢的这步"), + node("out", "output", fields=[{"name": "结果", "value": "{{ nodes.slow }}"}]), + ) + run_id = (await client.post("/api/runs", json={ + "graph": graph, "input": {"question": "q"}})).json()["id"] + for _ in range(300): + if calls["slow"]: + break + await asyncio.sleep(0.03) + await run_manager.shutdown() + await run_manager.setup() # 重启 + assert (await client.get(f"/api/runs/{run_id}")).json()["status"] == "interrupted" + + r = await client.post(f"/api/runs/{run_id}/continue", json={}) + assert r.status_code == 200, r.text + row = await _wait(run_id, ("succeeded", "failed")) + assert row.status == "succeeded", row.error + assert row.output.get("结果") == "慢的这步" + async with SessionLocal() as session: + starts = [e.node_id for e in (await session.execute( + select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == "node.started") + )).scalars()] + assert starts.count("a") == 1, f"接着跑把前面跑完的节点又跑了一遍:{starts}" + + +@pytest.mark.parametrize("method,action", [("post", "resume"), ("post", "continue"), + ("get", "verify")]) +async def test_a_missing_run_is_reported_without_python_quotes(client, method, action): + kwargs = {"json": {}} if method == "post" else {} + r = await getattr(client, method)(f"/api/runs/nope/{action}", **kwargs) + assert r.status_code == 404 + detail = r.json()["detail"] + assert detail and not detail.startswith("'"), detail + assert "nope" in detail or "不存在" in detail, detail diff --git a/backend/tests/test_team_live.py b/backend/tests/test_team_live.py new file mode 100644 index 00000000..4b5c8804 --- /dev/null +++ b/backend/tests/test_team_live.py @@ -0,0 +1,229 @@ +"""协作团队的实时性:谁先做完谁先报到,调度者在想什么也要有事件。 + +以前成员的 agent.step.end 要等整轮 gather 结束才按派活顺序统一发出——并行时先做完 +的成员一直显示"进行中",直到最慢的那个做完;矩阵声称能回答的"谁还在跑"其实答 +不了。调度者那次结构化模型调用前后没有任何事件,一个 74 秒的团队节点里约 66 秒 +是调度者在决策,界面上是个黑盒。 + +用量也一样:agent 节点、成员、调度者的模型调用都不发 llm.end,界面只能等终态 +才知道花了多少 token。补上之后必须守住另一头:run.finished 的 usage 是后端累计 +的权威值,事件只是把同一笔账逐次摊开,加起来要对得上,不能记两遍。 +""" + +from __future__ import annotations + +import asyncio +import json + +import pytest +from langchain_core.messages import AIMessage, AIMessageChunk +from langchain_core.outputs import ChatGenerationChunk +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine.runner import run_manager + +SLOW, FAST = 0.6, 0.05 +ROUTE_SECONDS = 0.15 + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def _team_graph() -> dict: + agents = [ + {"name": n, "description": f"{n} 的职责", "system": f"你是 {n}", "max_steps": 1} + for n in ("researcher", "analyst") + ] + return { + "nodes": [ + {"id": "in", "type": "input", "data": {"config": {}}}, + {"id": "team", "type": "supervisor", "data": {"label": "协作团队", "config": { + "goal": "比较两种向量库", "agents": agents, "max_rounds": 3, "max_parallel": 2, + }}}, + {"id": "out", "type": "output", "data": {"config": {}}}, + ], + "edges": [{"source": "in", "target": "team"}, {"source": "team", "target": "out"}], + } + + +def _usage(messages, reply: str) -> dict: + text = " ".join(str(getattr(m, "content", m)) for m in messages) + return {"input_tokens": 10 + len(text) // 50, "output_tokens": 3 + len(reply) // 20, + "total_tokens": 0} + + +def _script(monkeypatch, decisions: list[dict]) -> None: + """调度者按剧本派活;成员 researcher 慢、analyst 快;每次调用都报 token。 + + 调度者走 with_structured_output(include_raw=True):真实供应商会连原始消息 + 一起给回来,用量就在那条消息上。mock 默认只给解析结果,这里补齐这层行为。 + """ + from app.providers import mock_model + + turns = {"n": 0} + + def _decide(self, messages): + if self.response_format: + i = turns["n"] + turns["n"] += 1 + payload = decisions[i] if i < len(decisions) else {"assignments": [], "done": True} + return AIMessage(content=json.dumps(payload, ensure_ascii=False)) + return AIMessage(content="(干完了)") + + async def _agenerate(self, messages, stop=None, run_manager=None, **kw): + text = " ".join(str(getattr(m, "content", "")) for m in messages) + if self.response_format: + await asyncio.sleep(ROUTE_SECONDS) + else: + await asyncio.sleep(FAST if "快活" in text else SLOW) + result = self._generate(messages, stop, **kw) + msg = result.generations[0].message + msg.usage_metadata = _usage(messages, str(msg.content)) + return result + + def _structured(self, schema, *, include_raw=False, **kw): + bound = self.model_copy(update={"response_format": schema}) + + async def parse(msg): + parsed = json.loads(str(msg.content)) + return {"raw": msg, "parsed": parsed, "parsing_error": None} if include_raw else parsed + + return bound | parse + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + monkeypatch.setattr(mock_model.MockChatModel, "_agenerate", _agenerate) + monkeypatch.setattr(mock_model.MockChatModel, "with_structured_output", _structured) + + +async def _run(graph: dict) -> tuple[list[RunEvent], Run]: + run = await run_manager.start(graph=graph, input_payload={"question": "比较"}) + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed", "cancelled"): + break + else: + pytest.fail("运行没有在 20 秒内结束") + async with SessionLocal() as session: + events = list((await session.execute( + select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq) + )).scalars()) + return events, row + + +PARALLEL_ROUND = [{ + "assignments": [ + {"agent": "researcher", "instruction": "慢活"}, + {"agent": "analyst", "instruction": "快活"}, + ], + "reason": "两边互不相干", +}, {"assignments": [], "done": True, "reason": "够了"}] + + +async def test_members_report_done_in_the_order_they_actually_finish(monkeypatch): + _script(monkeypatch, PARALLEL_ROUND) + events, run = await _run(_team_graph()) + assert run.status == "succeeded", run.error + + ends = [e for e in events if e.type == "agent.step.end"] + assert [e.data["agent"] for e in ends] == ["analyst", "researcher"], \ + "快的那个先做完,就该先报到——而不是按派活顺序等整轮结束再一起报" + + start = next(e for e in events if e.type == "agent.step.start") + fast_end = ends[0] + # 快的那个结束事件的时刻要贴着它自己做完的时刻,而不是被慢的那个拖到整轮结束 + assert fast_end.ts - start.ts < (SLOW + FAST) / 2, \ + f"analyst {FAST}s 就做完了,结束事件却在 {fast_end.ts - start.ts:.2f}s 才出现" + # 字段不变:界面照旧按这些字段画矩阵 + for e in ends: + assert {"agent", "duration_ms", "round", "parallel", "preview"} <= set(e.data) + + +async def test_the_coordinator_is_not_a_black_box(monkeypatch): + _script(monkeypatch, PARALLEL_ROUND) + events, run = await _run(_team_graph()) + assert run.status == "succeeded", run.error + + starts = [e for e in events if e.type == "agent.route.start"] + ends = [e for e in events if e.type == "agent.route.end"] + assert [e.data["round"] for e in starts] == [0, 1] + assert [e.data["round"] for e in ends] == [0, 1] + assert all(e.node_id == "team" for e in starts + ends) + + first, last = ends + assert first.data["agents"] == ["researcher", "analyst"] + assert first.data["parallel"] == 2 + assert first.data["done"] is False + assert first.data["reason"] == "两边互不相干" + assert last.data["done"] is True and last.data["agents"] == [] + assert last.data["reason"] == "够了" + for e in ends: + assert e.data["duration_ms"] >= ROUTE_SECONDS * 1000 * 0.8, e.data + + # 先"开始想",再"想好了",然后才派人出去 + seq = {e.type + str(e.data.get("round", "")): e.seq for e in events} + first_dispatch = next(e.seq for e in events if e.type == "agent.step.start") + assert seq["agent.route.start0"] < seq["agent.route.end0"] < first_dispatch + + +async def test_every_model_call_reports_usage_and_the_sum_matches_the_run(monkeypatch): + _script(monkeypatch, PARALLEL_ROUND) + events, run = await _run(_team_graph()) + assert run.status == "succeeded", run.error + + calls = [e for e in events if e.type == "llm.end" and e.node_id == "team"] + by_agent: dict[str, list[dict]] = {} + for e in calls: + by_agent.setdefault(e.data["agent"], []).append(e.data) + assert set(by_agent) == {"调度者", "researcher", "analyst"}, by_agent.keys() + assert len(by_agent["调度者"]) == 2, "调度了两次,就是两次模型调用" + for d in (d for items in by_agent.values() for d in items): + assert {"model", "input_tokens", "output_tokens", "cost_usd"} <= set(d), d + assert d["input_tokens"] > 0 and d["output_tokens"] > 0, d + + finished = next(e for e in events if e.type == "run.finished") + usage = finished.data["usage"] + # 权威值是后端累计;逐次事件加起来必须等于它——多一笔少一笔都说明记重或漏记了 + assert usage["input_tokens"] == sum(e.data["input_tokens"] for e in calls) + assert usage["output_tokens"] == sum(e.data["output_tokens"] for e in calls) + assert usage["calls"] == len(calls) + assert run.usage["input_tokens"] == usage["input_tokens"] + + +async def test_agent_node_reports_each_model_call(monkeypatch): + """agent 节点以前只有 llm.start 没有 llm.end:用量要等整个节点结束才知道。""" + from app.providers import mock_model + + async def _astream(self, messages, stop=None, run_manager=None, **kw): + reply = "查完了,结论如下。" + await asyncio.sleep(0.01) + yield ChatGenerationChunk(message=AIMessageChunk( + content=reply, usage_metadata=_usage(messages, reply))) + + monkeypatch.setattr(mock_model.MockChatModel, "_astream", _astream) + graph = { + "nodes": [ + {"id": "in", "type": "input", "data": {"config": {}}}, + {"id": "ag", "type": "agent", "data": {"label": "分析员", "config": { + "prompt": "{{ input.question }}", "tools": [], "max_steps": 3}}}, + {"id": "out", "type": "output", "data": {"config": {}}}, + ], + "edges": [{"source": "in", "target": "ag"}, {"source": "ag", "target": "out"}], + } + events, run = await _run(graph) + assert run.status == "succeeded", run.error + + starts = [e for e in events if e.type == "llm.start" and e.node_id == "ag"] + ends = [e for e in events if e.type == "llm.end" and e.node_id == "ag"] + assert len(ends) == len(starts) >= 1 + assert all(e.data["agent"] == "分析员" for e in ends) + usage = next(e for e in events if e.type == "run.finished").data["usage"] + assert usage["input_tokens"] == sum(e.data["input_tokens"] for e in ends) > 0 + assert usage["output_tokens"] == sum(e.data["output_tokens"] for e in ends) > 0 diff --git a/backend/tests/test_validation_text.py b/backend/tests/test_validation_text.py new file mode 100644 index 00000000..69cacfec --- /dev/null +++ b/backend/tests/test_validation_text.py @@ -0,0 +1,208 @@ +"""校验和报错写给人看:用界面上的叫法,说清原因和下一步,不露 Python 内部名字。 + +- 分支 case 的 key 用了保留名 default:和兜底出口撞成同一个 id,跑完两条一起点亮, + 看不出实际走了哪条。重复 key、空 key 更糟——那个 case 根本路由不到。 +- 门禁文案写「受管模板不允许 supervisor 节点」「approval=never」「该是个 llm 节点」, + 界面上它们叫「多 Agent 协作」「全部自动放行」「模型调用」,报错引用了用户从没见过的词。 +- 运行失败的首行写着「工具 db_query__shop 执行失败:_make_query_tool.._run() + got an unexpected keyword argument 'query'」,恢复失败写「当前状态是 succeeded」。 +""" + +from __future__ import annotations + +import asyncio + +import pytest +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine import compiler +from app.engine.governance import lint_for_publish +from app.engine.runner import run_manager +from app.engine.schema import GraphSpec, NodeType, validate_graph + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": label or nid, "config": config}} + + +def _branch(cases: list[dict]) -> GraphSpec: + return GraphSpec.model_validate({ + "nodes": [ + node("start", "input"), + node("br", "branch", label="走哪条", mode="expression", cases=cases), + node("a", "output"), node("b", "output"), + ], + "edges": [ + {"source": "start", "target": "br"}, + {"source": "br", "target": "a", "sourceHandle": "fast"}, + {"source": "br", "target": "b", "sourceHandle": "default"}, + ], + }) + + +def _issues(spec: GraphSpec, level: str) -> list[str]: + return [i.message for i in validate_graph(spec).issues if i.level == level and i.node_id == "br"] + + +def test_case_key_default_is_flagged_as_the_reserved_fallback(): + spec = _branch([{"key": "fast", "condition": "true"}, + {"key": "default", "condition": "true", "label": "协作模式"}]) + warnings = _issues(spec, "warning") + assert any("default" in m and "保留" in m for m in warnings), warnings + # 现存的 ⑥ 模板就是这么写的:条件为 true 的 default 等价于兜底,运行语义自洽。 + # 升成 error 会让它和所有从它复制出来的图直接不能跑 + assert validate_graph(spec).ok + + +def test_duplicate_case_keys_are_an_error(): + spec = _branch([{"key": "fast", "condition": "vars.a"}, {"key": "fast", "condition": "vars.b"}]) + errors = _issues(spec, "error") + assert any("fast" in m and "重复" in m for m in errors), errors + assert not validate_graph(spec).ok + + +def test_blank_case_key_is_an_error(): + spec = _branch([{"key": "fast", "condition": "vars.a"}, {"key": " ", "condition": "vars.b"}]) + assert any("标识" in m for m in _issues(spec, "error")), _issues(spec, "error") + + +def test_validation_uses_the_names_on_the_canvas(): + spec = GraphSpec.model_validate({ + "nodes": [node("start", "input"), node("sub", "subgraph"), node("t", "tool"), + node("v", "validate"), node("out", "output")], + "edges": [{"source": "start", "target": "sub"}, {"source": "sub", "target": "t"}, + {"source": "t", "target": "v"}, {"source": "v", "target": "out"}], + }) + messages = " ".join(i.message for i in validate_graph(spec).issues) + assert "子工作流" in messages and "子图节点" not in messages + assert "「调用工具」" in messages + assert "结构校验" in messages + + +def _governed(*nodes) -> str: + spec = GraphSpec.model_validate({ + "nodes": [node("start", "input"), *nodes], + "edges": [{"source": "start", "target": n["id"]} for n in nodes], + }) + return " ".join(i.message for i in lint_for_publish(spec, level="governed").issues) + + +def test_governance_speaks_the_ui_vocabulary(): + text = _governed( + node("team", "supervisor", label="协作团队"), + node("ag", "agent", label="取数员", tools=["web_search"], approval="never"), + node("bare", "agent", label="空手的"), + node("c", "code", label="算一下", network=True), + ) + for internal in ("supervisor", "approval=never", "llm 节点", "contract"): + assert internal not in text, f"门禁文案里还有内部词 {internal!r}:{text}" + assert "「协作团队」" in text and "多 Agent 协作" in text + assert "审批策略" in text and "全部自动放行" in text + assert "模型调用" in text + assert "出具契约" in text + + +# -------------------------------------------------------------------------- +# 运行期报错 +# -------------------------------------------------------------------------- + + +@pytest.fixture +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +async def _finish(run_id: str) -> Run: + for _ in range(300): + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row.status in ("succeeded", "failed", "cancelled", "interrupted"): + return row + await asyncio.sleep(0.03) + raise AssertionError("等超时了") + + +RAW = "_make_query_tool.._run() got an unexpected keyword argument 'query'" + + +async def test_a_python_error_reads_like_a_sentence(engine_up, monkeypatch): + async def broken(state, ctx): + raise TypeError(RAW) + + monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, broken) + graph = { + "nodes": [node("start", "input"), node("shape", "transform", label="整理数据", + mode="template", template="x")], + "edges": [{"source": "start", "target": "shape"}], + } + run = await run_manager.start(graph=graph, input_payload={}) + row = await _finish(run.id) + assert row.status == "failed" + for text in (row.error or "",): + assert "TypeError" not in text and "" not in text, text + assert "query" in text, "原因要说到点子上:哪个参数对不上" + assert row.error_node_id == "shape" + + async with SessionLocal() as session: + events = list((await session.execute( + select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq) + )).scalars()) + failed_node = next(e for e in events if e.type == "node.failed") + assert "TypeError" not in failed_node.data["error"] + assert "TypeError" in failed_node.data["detail"], "原始异常要留着,给排查的人看" + failed_run = next(e for e in events if e.type == "run.failed") + assert failed_run.data["node_id"] == "shape" + assert failed_run.data["label"] == "整理数据" + assert "TypeError" not in failed_run.data["error"] + + +async def test_resume_refusal_does_not_leak_the_status_enum(engine_up): + async with SessionLocal() as session: + run = Run(workflow_name="x", status="succeeded", graph={"nodes": [], "edges": []}, input={}) + session.add(run) + await session.commit() + with pytest.raises(ValueError) as caught: + await run_manager.resume(run.id, {"approved": True}) + assert "succeeded" not in str(caught.value) + assert "已完成" in str(caught.value) + + +def test_exception_wording_helper(): + from app.engine.errors import describe_exception + + assert "query" in describe_exception(TypeError(RAW)) + assert "TypeError" not in describe_exception(TypeError(RAW)) + timeout = describe_exception(TimeoutError()) + assert timeout and "Timeout" not in timeout + generic = describe_exception(RuntimeError("Integer exceeds 64-bit range")) + assert "RuntimeError" not in generic and "64-bit" in generic + nested = describe_exception(RuntimeError("连接失败:ConnectError: All connection attempts failed")) + assert "Error" not in nested and "All connection attempts failed" in nested + + +async def test_issuance_event_says_why_it_was_downgraded(engine_up): + """只有 gaps 时判成降档,事件里却不带 gaps:时间线上只剩一个光秃秃的「降档出具」, + 横幅还说「请对照下方声明」,下方什么都没有。""" + graph = { + "nodes": [ + node("start", "input"), + node("out", "output", fields=[{"name": "结论", "value": "本周 12 单"}], + contract={"metrics_from": "没有这个口径卡", "narrative": "{{ output.结论 }}"}), + ], + "edges": [{"source": "start", "target": "out"}], + } + run = await run_manager.start(graph=graph, input_payload={}) + row = await _finish(run.id) + assert row.status == "succeeded", row.error + async with SessionLocal() as session: + issuance = (await session.execute( + select(RunEvent).where(RunEvent.run_id == run.id, RunEvent.type == "issuance") + )).scalars().one() + assert issuance.data["tier"] == "degraded" + assert any("没有这个口径卡" in g for g in issuance.data["gaps"]), issuance.data + assert issuance.data["metrics_checked"] == 0 diff --git a/backend/tests/test_workflow_delete_guard.py b/backend/tests/test_workflow_delete_guard.py new file mode 100644 index 00000000..fa3d662c --- /dev/null +++ b/backend/tests/test_workflow_delete_guard.py @@ -0,0 +1,75 @@ +"""删除工作流时的两道口子:还在跑的运行、以及中文署名。 + +- 运行记录的 workflow_id 是 ON DELETE SET NULL:删掉工作流,运行记录本身留着, + 封存核对用的是运行自带的图快照,不受影响。但还在跑的那次运行会失去归属, + 界面上再也回不到它的画布——和删除运行记录一样,先停再删。 +- 发布署名从 X-Actor 请求头来。浏览器请求头只能是 Latin-1,前端按 + encodeURIComponent 编码后发,发布时要解回来,否则中文署名存成 %E5...。 +""" + +from __future__ import annotations + +from urllib.parse import quote + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.db.base import SessionLocal +from app.db.models import Run, Workflow +from app.main import app + +GRAPH = { + "nodes": [ + {"id": "start", "type": "input", "position": {"x": 0, "y": 0}, + "data": {"label": "输入", "config": {"fields": [{"name": "q"}]}}}, + {"id": "out", "type": "output", "position": {"x": 300, "y": 0}, + "data": {"label": "成果", "config": {"fields": [{"name": "r", "value": "{{ input.q }}"}]}}}, + ], + "edges": [{"source": "start", "target": "out"}], +} + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +async def _workflow_with_run(status: str) -> tuple[str, str]: + async with SessionLocal() as session: + workflow = Workflow(name="删除保护", graph=GRAPH) + session.add(workflow) + await session.flush() + run = Run(workflow_id=workflow.id, workflow_name=workflow.name, graph=GRAPH, + input={}, status=status) + session.add(run) + await session.commit() + return workflow.id, run.id + + +@pytest.mark.parametrize("status", ["running", "queued"]) +async def test_a_workflow_with_a_live_run_cannot_be_deleted(client, status): + workflow_id, _ = await _workflow_with_run(status) + r = await client.delete(f"/api/workflows/{workflow_id}") + assert r.status_code == 409 + assert "停止" in r.json()["detail"] + assert (await client.get(f"/api/workflows/{workflow_id}")).status_code == 200 + + +async def test_finished_runs_survive_their_workflow(client): + workflow_id, run_id = await _workflow_with_run("succeeded") + assert (await client.delete(f"/api/workflows/{workflow_id}")).status_code == 204 + r = await client.get(f"/api/runs/{run_id}") + assert r.status_code == 200 + assert r.json()["workflow_id"] is None + + +async def test_a_chinese_publisher_name_is_stored_decoded(client): + r = await client.post("/api/workflows", json={"name": "署名", "graph": GRAPH}) + workflow_id = r.json()["id"] + r = await client.post(f"/api/workflows/{workflow_id}/publish", + json={"level": "published"}, headers={"X-Actor": quote("张工")}) + assert r.status_code == 200, r.text + assert r.json()["published_by"] == "张工" + assert (await client.get(f"/api/workflows/{workflow_id}")).json()["published_by"] == "张工" diff --git a/frontend/index.html b/frontend/index.html index 63666fed..7ab8eb2e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,8 +3,27 @@ - + + AgentLab · 可视化 Agent 编排实验台 + +
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 00000000..6e190775 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 41a8a8f4..18cbba7c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,106 +1,1022 @@ -import { useEffect } from 'react' -import { NavLink, Navigate, Route, Routes, useLocation } from 'react-router-dom' -import { BookOpen, FlaskConical, History, MessageSquare, Settings, Wrench } from 'lucide-react' +import { useEffect, useRef, useState, useSyncExternalStore } from 'react' +import type { ReactNode, RefObject } from 'react' +import { Link, NavLink, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' +import { Bell, BellOff, Moon, RotateCw, Sun, UserRound } from 'lucide-react' import clsx from 'clsx' -import { api } from './api/client' -import { useCatalog } from './store/catalog' +import { ApiError, NETWORK_MESSAGE, api } from './api/client' +import { useCatalog, useOnReconnect } from './store/catalog' +import type { CatalogCheck } from './store/catalog' +import { useStudio } from './store/studio' +import { useChat } from './store/chat' +import { useConversations } from './store/conversations' import { ChatPage } from './pages/ChatPage' import { StudioPage } from './pages/StudioPage' import { RunsPage } from './pages/RunsPage' +import { DataPage } from './pages/DataPage' import { ToolsPage } from './pages/ToolsPage' import { KnowledgePage } from './pages/KnowledgePage' -import { SettingsPage, applyTheme } from './pages/SettingsPage' -import { ErrorBoundary, Spinner } from './components/ui' - -const NAV = [ - { to: '/chat', label: '问数据', icon: MessageSquare }, - { to: '/studio', label: '编排', icon: FlaskConical }, - { to: '/runs', label: '运行', icon: History }, - { to: '/tools', label: '工具', icon: Wrench }, - { to: '/knowledge', label: '知识', icon: BookOpen }, - { to: '/settings', label: '设置', icon: Settings }, -] +import { SettingsPage } from './pages/SettingsPage' +import { NotFound } from './pages/NotFound' +import { Logo } from './components/Logo' +import { + CommandPalette, PAGES, ShortcutHelp, flushThemePref, openShortcutHelp, toggleCommandPalette, toggleNotify, + toggleTheme, useTheme, waitedFor, +} from './components/CommandPalette' +import { ErrorBoundary, Kbd, OfflineBanner, Spinner, StatusBadge, isComposing, toast } from './components/ui' +import { applyTheme, normalizeTheme } from './lib/theme' +import { ariaShortcut, isTypingTarget, matchShortcut } from './lib/keys' +import { formatDateTime, formatDuration, formatRelative, shortId } from './lib/format' +import { humanizeError } from './lib/errors' +import { isActivePhase } from './run/trace' +import { + composeTitle, flagAttention, notifyInBackground, notifySupported, paintFavicon, useSignals, +} from './lib/notify' +import type { Attention, FaviconDot } from './lib/notify' export default function App() { - const { loaded, refresh, refreshApprovals, approvals } = useCatalog() + const loaded = useCatalog((s) => s.loaded) + const backend = useCatalog((s) => s.backend) + const everOk = useCatalog((s) => s.lastOkAt != null) + const allFailed = useCatalog((s) => s.checks.length > 0 && s.checks.every((c) => c.state === 'error')) const location = useLocation() + const [offlineOk, setOfflineOk] = useState(false) - useEffect(() => { - void refresh() - void api.settings.get().then((s) => applyTheme(s?.ui?.theme ?? 'system')).catch(() => {}) - }, [refresh]) + useEffect(() => { void useCatalog.getState().refresh() }, []) + // 连着时每 4 秒拉一次待审批,顺带当心跳;断开后的退避重试由 catalog 自己排 + useEffect(() => useCatalog.getState().startHeartbeat(), []) - // 待审批是全局待办:别的页面也要能看到有东西等着人处理 - useEffect(() => { - const timer = setInterval(() => void refreshApprovals(), 4000) - return () => clearInterval(timer) - }, [refreshApprovals]) - - const pending = approvals.filter((a) => a.status === 'pending').length - - if (!loaded) { - return ( -
- 正在连接后端… -
- ) + // 主题的真源在后端设置里。启动时没连上就等恢复再读一次,不然这次会话一直用缓存。 + // 读到过、或者人在这台设备上换过(导航、⌘K 都派发 agentlab:theme),就不再读, + // 路上的那一次读回来也不应用:断线时换的主题不能在重连时被服务端的旧值翻回去 + const themeSettled = useRef(false) + const loadTheme = () => { + if (themeSettled.current) return + api.settings.get().then((s) => { + if (themeSettled.current) return + themeSettled.current = true + applyTheme(normalizeTheme(s?.ui?.theme)) + }).catch(() => {}) } + useEffect(loadTheme, []) + useEffect(() => { + const onLocal = () => { themeSettled.current = true } + window.addEventListener('agentlab:theme', onLocal) + return () => window.removeEventListener('agentlab:theme', onLocal) + }, []) + // 断线时换的那一下当时没存上:恢复后补存,而不是去读服务端 + useOnReconnect(() => { if (!flushThemePref()) loadTheme() }) + + useBootReport(loaded) + // 在 App 这一层算,从启动那一刻起计时:从启动页「先进去看看」时已经卡了好几秒, + // 进门那一下就得是琥珀,不能进了门再从头等 + const stalled = useStalledChecks() + + // 从没连上过就断着:停在启动页说清楚原因,而不是放人进去看一屏「还没有…」 + const offlineAtBoot = !everOk && (backend === 'down' || (allFailed && backend === 'checking')) + const booting = !offlineOk && (!loaded || offlineAtBoot) + // 启动页的清单自己会说哪几项还在等;进了外壳才需要当面提醒 + useStallWarning(booting ? NO_CHECKS : stalled) + if (booting) return setOfflineOk(true)} /> return (
- +
) } + +// ------------------------------------------------------------------------- +// 全局快捷键 +// ------------------------------------------------------------------------- + +/** 启动页上不挂:那时按下的 ⌘K 会在加载完之后才突然弹出面板 */ +function ShellKeys() { + const navigate = useNavigate() + const navRef = useRef(navigate) + navRef.current = navigate + + useEffect(() => { + // 捕获阶段注册:比各页面自己挂在 window 上的监听先到,? 只弹一层 + const onKey = (e: KeyboardEvent) => { + if (e.defaultPrevented || isComposing(e)) return + // 别的弹窗开着时键盘归它:在弹窗上再叠一层命令面板,Esc 该关谁就乱了 + const modal = document.querySelector('[aria-modal="true"]:not([data-command-palette])') + if (matchShortcut(e, 'Mod+K')) { + if (modal) return + e.preventDefault() + // 按住不放会连发:面板开了又关、关了又开 + if (!e.repeat) toggleCommandPalette() + return + } + // 其余的都让给打字:输入框里的 ? 是问号,⌥1 在 Mac 上是「¡」 + if (modal || document.querySelector('[data-command-palette]') || isTypingTarget(e.target)) return + if (matchShortcut(e, '?')) { + e.preventDefault() + e.stopPropagation() + openShortcutHelp() + return + } + const page = PAGES.find((p) => matchShortcut(e, p.shortcut)) + if (page) { + e.preventDefault() + navRef.current(page.to) + } + } + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }, []) + return null +} + +// ------------------------------------------------------------------------- +// 导航 +// ------------------------------------------------------------------------- + +/** 悬停提示:挂在导航右侧,读屏不念(导航项自己有文字,提示只是补充) */ +function Tip({ children, badge = false }: { children: ReactNode; badge?: boolean }) { + return ( + + {children} + + ) +} + +/** 有没有运行在跑:品牌标的起点节点亮起、标题加前缀都看它 */ +function useStudioActivity(): string { + return useStudio((s) => { + if (s.runPhase === 'running' || s.runPhase === 'queued') { + const total = s.trace.nodesTotal ?? s.nodes.length + let done = 0 + for (const n of Object.values(s.trace.nodes)) if (n.state === 'done' || n.state === 'skipped') done++ + return total ? `run:${Math.min(done, total)}/${total}` : 'run' + } + return s.copilot.active ? 'gen' : '' + }) +} + +function useChatActivity(): '' | 'run' | 'gen' { + return useChat((s) => { + let gen = false + for (const turns of Object.values(s.byConversation)) { + for (const t of turns) { + if (t.phase === 'running') return 'run' + if (t.phase === 'planning' || t.phase === 'building') gen = true + } + } + return gen ? 'gen' : '' + }) +} + +function Nav({ stalled }: { stalled: CatalogCheck[] }) { + const studio = useStudioActivity() + const chat = useChatActivity() + const live = studio.startsWith('run') || chat === 'run' + + // 整条导航约 520px 高,窗口再矮,最底下的遥测点和署名就被切掉、够不着。矮窗口 + // (≤600px)按高度收紧到 450px 左右,而不是让导航滚动:导航一旦 overflow, + // 挂在右侧的悬停提示和遥测浮层也会被一起裁掉 + return ( + + ) +} + +/** + * 待审批徽标。 + * + * 它是一个单独的链接(直达记录页的待审批页签),不是「记录」的一部分:导航 + * 项本身永远进 /runs,否则同一个入口时而进列表、时而进某条详情,人就摸不准 + * 点下去会到哪。挂在图标右上角外侧,外圈一道面板底色的描边把它从图标上抠开; + * 浅色下用 warn-solid 配 on-warn,不再是黑字压在深琥珀上糊成一团。 + */ +function ApprovalBadge() { + const approvals = useCatalog((s) => s.approvals) + const pending = approvals.filter((a) => a.status === 'pending') + if (!pending.length) return null + const oldest = pending.reduce((a, b) => ((a.created_at ?? '') <= (b.created_at ?? '') ? a : b)) + const waited = waitedFor(oldest.created_at) + const text = `${pending.length} 项待审批${waited ? `,最久已等 ${waited}` : ''}` + return ( + + {pending.length > 99 ? '99+' : pending.length} + + {text} + 点开去处理 + + + ) +} + +function NotifyToggle() { + const notify = useSignals((s) => s.notify) + const permission = useSignals((s) => s.permission) + if (!notifySupported()) return null + const Icon = notify ? Bell : BellOff + const tip = notify + ? '后台提醒:开 · 运行结束、失败或来了新的待审批会发系统通知' + : permission === 'denied' + ? '后台提醒:浏览器拒绝了通知权限,要先在站点设置里允许' + : '后台提醒:关 · 点开后浏览器会询问通知权限' + return ( + + ) +} + +function ThemeToggle() { + const { pref, resolved } = useTheme() + const now = resolved === 'dark' ? '深色' : '浅色' + const next = resolved === 'dark' ? '浅色' : '深色' + const Icon = resolved === 'dark' ? Moon : Sun + return ( + + ) +} + +const ACTOR_KEY = 'agentlab_actor' + +function readActor(): string { + try { + return localStorage.getItem(ACTOR_KEY)?.trim() ?? '' + } catch { + return '' + } +} + +function subscribeActor(cb: () => void): () => void { + // 设置页在同一个标签页里改署名不会触发 storage 事件;它若派发 agentlab:actor + // 就立刻跟上,否则下一次导航重绘时也会重新读到 + const events = ['storage', 'agentlab:actor', 'focus'] + events.forEach((e) => window.addEventListener(e, cb)) + return () => events.forEach((e) => window.removeEventListener(e, cb)) +} + +/** 操作者署名的首字:发布、审批、正式运行都记在这个名下,得一直看得见是谁 */ +function ActorButton() { + const actor = useSyncExternalStore(subscribeActor, readActor, () => '') + useLocation() + const initial = actor ? Array.from(actor)[0].toUpperCase() : '' + return ( + + {initial || } + + {actor ? 署名 {actor} : 未署名} + 发布、审批、正式运行记在这个名下 · 点开修改 + + + ) +} + +/** 延迟超过这个数就算慢:本机和内网通常是几十毫秒 */ +const SLOW_MS = 1500 + +interface Tone { color: string; text: string; label: string; word: string } + +/** + * 五档:离线(红)、加载中(琥珀:连得上,但请求卡着没回来)、降级(琥珀:连得上, + * 但有几张表报错)、偏慢(琥珀)、在线(绿)。加载中和降级单列出来:那几张表对应 + * 的下拉和列表是空的,不说就像"没有" + */ +function backendTone(backend: string, latency: number | null, failing: number, stalled: number): Tone { + if (backend === 'down') return { color: 'var(--st-failed)', text: '离线', label: '后端未连接', word: '未连接' } + if (backend === 'checking') return { color: 'var(--text-faint)', text: '检测中', label: '正在检测后端', word: '检测中' } + if (stalled > 0) { + return { color: 'var(--st-waiting)', text: '加载中', label: `后端在线,但还有 ${stalled} 项没取回来`, word: '加载中' } + } + if (failing > 0) { + return { color: 'var(--st-waiting)', text: '降级', label: `后端在线,但有 ${failing} 项没加载成功`, word: '降级' } + } + if (latency != null && latency >= SLOW_MS) { + return { color: 'var(--st-waiting)', text: `${latency}ms`, label: `后端响应慢,延迟 ${latency} 毫秒`, word: '偏慢' } + } + return { + color: 'var(--st-done)', + text: latency != null ? `${latency}ms` : '在线', + label: latency != null ? `后端在线,延迟 ${latency} 毫秒` : '后端在线', + word: '在线', + } +} + +const useFailingChecks = () => useCatalog((s) => s.checks.filter((c) => c.state === 'error').length) + +const NO_CHECKS: CatalogCheck[] = [] + +/** 请求发出去这么久还没回来,就不算「还在加载」,算卡住了 */ +const STALL_MS = 3000 + +/** + * 卡着没回来的 catalog 请求(后端没断时)。 + * + * refresh 是一个 Promise.all:一张表卡住(后端连得上、就是不回),六张表全都还是 + * 空的,backend 却是 ok——遥测点一片绿,编排页一屏「还没有工作流 / 新建工作流」, + * 正是离线横幅要消灭的那种假空态,只是换了个来路。正常刷新几十毫秒就回,不到 + * STALL_MS 不算,免得每次刷新遥测点都闪一下琥珀。 + */ +function useStalledChecks(): CatalogCheck[] { + const waiting = useCatalog((s) => s.checks.some((c) => c.state === 'pending')) + const down = useCatalog((s) => s.backend === 'down') + const checks = useCatalog((s) => s.checks) + const [stalled, setStalled] = useState(false) + useEffect(() => { + if (!waiting) return + const t = setTimeout(() => setStalled(true), STALL_MS) + return () => { clearTimeout(t); setStalled(false) } + }, [waiting]) + // 断开时归离线横幅说,这里不再叠一层 + return stalled && waiting && !down ? checks.filter((c) => c.state === 'pending') : NO_CHECKS +} + +/** + * 进了外壳还有请求卡着:挂一条常驻提示,点名是哪几项;全部回来(或者断开,归 + * 横幅管)就撤掉。遥测点只是一个小琥珀点,人眼前是「还没有工作流」和一个「新建」 + * 按钮,得当面说一句。点名的是卡住那一刻的几项,实时的看遥测浮层。 + */ +function useStallWarning(stalled: CatalogCheck[]) { + const any = stalled.length > 0 + const latest = useRef(stalled) + latest.current = stalled + useEffect(() => { + if (!any) return + const labels = latest.current.map((c) => c.label) + const id = toast.warn(`还有 ${labels.length} 项没取回来(${labels.join('、')}):列表可能不全,先别新建`, { + sticky: true, + key: 'boot-pending', + detail: '后端连得上,但这几个请求发出去好几秒了还没回来。在它们回来之前,工作流、知识库这些列表可能是空的,不代表数据没了。导航最底下的连接指示灯里能看到哪一项回来了。', + action: { label: '重试', onClick: () => void useCatalog.getState().refresh() }, + }) + return () => toast.dismiss(id) + }, [any]) +} + +/** 连接遥测点:导航最底下,像仪表盘上的指示灯。点开看地址、延迟、最近一次错误 */ +function Telemetry({ stalled }: { stalled: CatalogCheck[] }) { + const backend = useCatalog((s) => s.backend) + const latency = useCatalog((s) => s.latencyMs) + const failing = useFailingChecks() + const [open, setOpen] = useState(false) + const btnRef = useRef(null) + const tone = backendTone(backend, latency, failing, stalled.length) + return ( +
+ + {open && ( + { setOpen(false); if (refocus) btnRef.current?.focus() }} + /> + )} +
+ ) +} + +function useTicker(active: boolean, ms = 1000): number { + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + if (!active) return + setNow(Date.now()) + const t = setInterval(() => setNow(Date.now()), ms) + return () => clearInterval(t) + }, [active, ms]) + return now +} + +function TelemetryPanel({ onClose, anchor, stalled }: { + onClose: (refocus: boolean) => void + anchor: RefObject + stalled: CatalogCheck[] +}) { + const backend = useCatalog((s) => s.backend) + const latency = useCatalog((s) => s.latencyMs) + const lastOkAt = useCatalog((s) => s.lastOkAt) + const backendError = useCatalog((s) => s.backendError) + const retryAt = useCatalog((s) => s.retryAt) + const checks = useCatalog((s) => s.checks) + const [busy, setBusy] = useState(false) + const ref = useRef(null) + const down = backend === 'down' + const now = useTicker(down) + const tone = backendTone(backend, latency, useFailingChecks(), stalled.length) + + const onCloseRef = useRef(onClose) + onCloseRef.current = onClose + useEffect(() => { ref.current?.focus() }, []) + useEffect(() => { + const onDown = (e: MouseEvent) => { + const t = e.target as Node + if (!ref.current?.contains(t) && !anchor.current?.contains(t)) onCloseRef.current(false) + } + window.addEventListener('mousedown', onDown) + return () => window.removeEventListener('mousedown', onDown) + }, [anchor]) + + const secs = down && retryAt ? Math.max(0, Math.ceil((retryAt - now) / 1000)) : null + const retry = async () => { + setBusy(true) + try { + const ok = await useCatalog.getState().checkBackend() + if (ok && !down) await useCatalog.getState().refresh() + } finally { + setBusy(false) + } + } + + return ( +
{ + if (e.key === 'Escape' && !isComposing(e)) { + e.stopPropagation() + onClose(true) + } + }} + className="fade-up absolute bottom-0 left-full z-40 ml-2.5 w-80 rounded-lg border bg-panel text-xs shadow-elev-3 outline-none" + > +
+ 后端连接 + + + {tone.word} + +
+
+
地址
+
{window.location.origin}/api
+
延迟
+
{latency != null ? `${latency} ms` : '—'}
+
最后连通
+
+ {lastOkAt ? formatRelative(lastOkAt) : '—'} +
+ {secs != null && ( + <> +
自动重试
+
{secs} 秒后
+ + )} +
+ {backendError && ( +
+
最近一次错误
+
{backendError}
+
+ )} + {stalled.length > 0 && ( +
+
{stalled.map((c) => c.label).join('、')}还没回来
+
在那之前各页的列表可能不全,不代表数据没了,先别急着新建。
+
+ )} + {checks.length > 0 && ( +
+
最近一次全量加载
+ +
+ )} +
+ +
+
+ ) +} + +/** catalog.refresh 的逐项结果:每一行都是一个真实请求,✓ 带耗时,✕ 带状态码 */ +function CheckList({ checks, lead }: { checks: CatalogCheck[]; lead?: ReactNode }) { + return ( +
    + {lead} + {checks.map((c) => ( +
  • + + {c.label} + + {/* 勾叉是装饰图形,读屏靠这一个词分辨成败 */} + {c.state === 'pending' ? '还在等' : c.state === 'ok' ? '已取回 ' : '失败 '} + {c.state === 'pending' + ? … + : c.state === 'ok' ? `${c.ms ?? '—'} ms` : c.status ? String(c.status) : '连不上'} + +
  • + ))} +
+ ) +} + +/** 启动慢的时候读屏念什么:还在等哪几项、哪几项失败了 */ +function bootProgress(checks: CatalogCheck[]): string | null { + const waiting = checks.filter((c) => c.state === 'pending').map((c) => c.label) + const failed = checks.filter((c) => c.state === 'error').map((c) => c.label) + return [waiting.length && `还在等${waiting.join('、')}`, failed.length && `${failed.join('、')}没取回来`] + .filter(Boolean).join(';') || null +} + +function CheckGlyph({ state }: { state: CatalogCheck['state'] }) { + if (state === 'pending') return + return +} + +// ------------------------------------------------------------------------- +// 启动页 +// ------------------------------------------------------------------------- + +/** 这么久还没加载完就不再只是「正在连接」:主动探一次、给出重试 */ +const SLOW_BOOT_MS = 5000 + +/** + * 启动页:品牌标 + 启动清单。 + * + * 之前只有一个转圈和「正在连接后端…」,后端没起就永远转下去,看不出卡在哪。 + * 清单的每一行都是 catalog.refresh 的一个真实请求——✓ 带耗时,✕ 带状态码; + * 从没连上过就停在这里说清原因和怎么办,给重试,也允许先进去(离线横幅会一直 + * 挂着),不放人进一屏「还没有工作流」。 + */ +function BootScreen({ onEnterOffline }: { onEnterOffline: () => void }) { + const checks = useCatalog((s) => s.checks) + const backend = useCatalog((s) => s.backend) + const latency = useCatalog((s) => s.latencyMs) + const backendError = useCatalog((s) => s.backendError) + const retryAt = useCatalog((s) => s.retryAt) + const loaded = useCatalog((s) => s.loaded) + const [t0] = useState(() => Date.now()) + const now = useTicker(true, 250) + const [busy, setBusy] = useState(false) + const elapsed = now - t0 + const down = backend === 'down' + const slow = !down && !loaded && elapsed > SLOW_BOOT_MS + // 一闪而过的启动(本机几十毫秒)不画清单,免得整屏跳一下 + const reveal = down || elapsed > 300 + + // 请求没有超时,后端卡死(连得上但不回)时会一直挂着:到点主动探一次 /health, + // 它带超时,卡死和断开都能在这里判出来 + useEffect(() => { if (slow) void useCatalog.getState().checkBackend() }, [slow]) + + const retry = async () => { + setBusy(true) + try { + const ok = await useCatalog.getState().checkBackend() + // 从断开恢复时 catalog 自己会重拉;本来就连着(只是慢)就再拉一遍 + if (ok && !down) await useCatalog.getState().refresh() + } finally { + setBusy(false) + } + } + + const done = checks.filter((c) => c.state !== 'pending').length + const secs = down && retryAt ? Math.max(0, Math.ceil((retryAt - now) / 1000)) : null + const h = down ? humanizeError(new ApiError(0, backendError || NETWORK_MESSAGE, { kind: 'network' })) : null + const headline = down ? '连不上后端服务' : slow ? '比平时慢' : '正在连接后端' + // 读屏只播这一句:阶段变了(连接中 → 比平时慢 → 连不上)、慢的时候哪几项回来了。 + // 计时器和倒计时一秒一跳,放进播报区就是一秒念一遍 + const spoken = down ? h?.reason : slow ? bootProgress(checks) : null + + return ( +
+
+
+ +
+
AgentLab
+
受限动态编排
+
+
+ +
+ {/* 进度条只按「已经回来了几个请求」走,不插值、不做假进度 */} +
+
+
+
+ + {!down && } + {headline} + {spoken && :{spoken}} + + {!down && {formatDuration(elapsed)}} +
+
+ + {backend === 'checking' + ? + : } + 后端服务 + + {down ? '连不上' : backend === 'ok' && latency != null ? `${latency} ms` : '/api'} + + + } + /> +
+ + {(down || slow) && ( +
+ {down ? ( + <> +
{h?.reason ?? backendError ?? '请求没有到达后端'}
+
+ 确认后端已经启动(开发环境用 ./scripts/dev.sh),并且前端代理的端口和它一致。 +
+
+ {window.location.origin}/api + {backendError && backendError !== NETWORK_MESSAGE && <> · {backendError}} +
+ + ) : ( + <> +
+ 请求已经发出 {Math.floor(elapsed / 1000)} 秒还没全部回来:后端可能正在启动,或者卡在某个请求上。 +
+ {/* 后端连得上时「先进去」也还是一条路:一个请求卡死就把人永远关在门外更糟。 + 但进去之前先说清楚代价,进去以后遥测点和提示会接着说 */} +
先进去的话,没回来的那几项在页面上会是空的,不代表没有数据。
+ + )} +
+ + + {secs != null && !busy && {secs} 秒后自动重试} +
+
+ )} +
+
+
+ ) +} + +/** + * 启动时部分请求失败(后端连着,但某几张表报错):进页面,但要说出来。 + * 不说的话,那几张表对应的下拉、列表就是空的,看着像"没有"。 + */ +function useBootReport(loaded: boolean) { + const reported = useRef(false) + useEffect(() => { + if (!loaded || reported.current) return + reported.current = true + // 只报后端回了错误码的那几项:够不着后端(没有状态码)归启动页和离线横幅说, + // 这里再弹一条「6 项没加载成功」就是同一件事说两遍 + const bad = useCatalog.getState().checks.filter((c) => c.state === 'error' && c.status) + if (!bad.length) return + toast.warn( + `有 ${bad.length} 项没加载成功:${bad.map((c) => (c.status ? `${c.label}(${c.status})` : c.label)).join('、')}`, + { + detail: bad.map((c) => `${c.label}:${c.error ?? ''}`).join('\n'), + action: { label: '重试', onClick: () => void useCatalog.getState().refresh() }, + key: 'boot-partial', + }, + ) + }, [loaded]) +} + +// ------------------------------------------------------------------------- +// 后台信号:标题、favicon、系统通知 +// ------------------------------------------------------------------------- + +const PAGE_LABEL: Record = Object.fromEntries(PAGES.map((p) => [p.to.slice(1), p.label])) + +function describePath( + pathname: string, search: string, ctx: { workflowName: string | null; conversationTitle: string | null }, +): [string, string | null] { + const [seg, id] = pathname.split('/').filter(Boolean) + if (!seg) return ['问数据', null] + const page = PAGE_LABEL[seg] + if (!page) return ['页面不存在', null] + if (seg === 'studio' && id) return [page, ctx.workflowName] + if (seg === 'chat' && id) return [page, ctx.conversationTitle || null] + if (seg === 'runs') { + if (id) return [page, `运行 ${shortId(id)}`] + return [page, new URLSearchParams(search).get('tab') === 'approvals' ? '待审批' : null] + } + return [page, null] +} + +type Go = (to: string | null) => void + +/** 在后台时结束的事:挂到标题和 favicon 上,开了提醒就再发一条系统通知 */ +function report(go: Go, n: { attention: Attention; title: string; body?: string; to: string | null; tag: string }) { + if (!document.hidden) return + flagAttention(n.attention) + notifyInBackground({ title: n.title, body: n.body, tag: n.tag, onClick: () => go(n.to) }) +} + +/** 回放、打开历史运行时事件是一口气灌进来的:活跃不到这么久就结束的,不算"刚跑完" */ +const MIN_LIVE_MS = 1000 + +function watchStudio(go: Go): () => void { + let watch: { runId: string; since: number } | null = null + return useStudio.subscribe((s, p) => { + if (s.runPhase === p.runPhase && s.run?.id === p.run?.id) return + const id = s.run?.id ?? null + if (!id) { watch = null; return } + if (isActivePhase(s.runPhase)) { + if (watch?.runId !== id) watch = { runId: id, since: Date.now() } + return + } + const w = watch + watch = null + if (!w || w.runId !== id || s.trace.lastReplay || Date.now() - w.since < MIN_LIVE_MS) return + const name = s.workflow?.name || s.run?.workflow_name || '工作流' + // 人多半还停在这张画布上:点通知回来就行,不跳走 + const here = s.workflow && window.location.pathname.startsWith(`/studio/${s.workflow.id}`) + const to = here ? null : `/runs/${id}` + const tag = `run:${id}` + if (s.runPhase === 'succeeded') { + const t = s.trace + const wall = t.timing?.wallMs ?? (t.startedAt && t.endedAt ? t.endedAt - t.startedAt : null) + report(go, { + attention: { tone: 'ok', text: '✓ 运行完成' }, title: `「${name}」运行完成`, + body: wall != null ? `用时 ${formatDuration(wall)}` : undefined, to, tag, + }) + } else if (s.runPhase === 'failed') { + const nodeId = s.trace.failedNodeId + const label = nodeId ? s.nodes.find((n) => n.id === nodeId)?.data?.label || nodeId : null + const error = (nodeId && s.trace.nodes[nodeId]?.error) || s.run?.error || '' + report(go, { + attention: { tone: 'err', text: '✕ 运行失败' }, title: `「${name}」运行失败`, + body: [label && `停在「${label}」`, error.split('\n')[0]].filter(Boolean).join(':') || undefined, to, tag, + }) + } else if (s.runPhase === 'suspended') { + report(go, { + attention: { tone: 'warn', text: '‖ 运行中断' }, title: `「${name}」运行被中断`, + body: '服务重启打断了这次运行,可以从断点接着跑', to, tag, + }) + } + }) +} + +function watchChat(go: Go): () => void { + const seen = new Map() + return useChat.subscribe((s, p) => { + if (s.byConversation === p.byConversation) return + for (const [cid, turns] of Object.entries(s.byConversation)) { + for (const t of turns) { + const active = t.phase === 'planning' || t.phase === 'building' || t.phase === 'running' + const since = seen.get(t.id) ?? null + if (active) { + if (since == null) seen.set(t.id, Date.now()) + continue + } + if (since == null) continue + seen.set(t.id, null) + if (t.restored || Date.now() - since < MIN_LIVE_MS) continue + const to = window.location.pathname === `/chat/${cid}` ? null : `/chat/${cid}` + const question = t.question.length > 60 ? `${t.question.slice(0, 60)}…` : t.question + if (t.phase === 'done') { + report(go, { attention: { tone: 'ok', text: '✓ 回答好了' }, title: '问数据:回答好了', body: question, to, tag: `turn:${t.id}` }) + } else if (t.phase === 'error') { + report(go, { attention: { tone: 'err', text: '✕ 出错了' }, title: '问数据:这一问出错了', body: question, to, tag: `turn:${t.id}` }) + } + } + } + }) +} + +function watchApprovals(go: Go): () => void { + const pendingIds = (list: { id: string; status: string }[]) => list.filter((a) => a.status === 'pending').map((a) => a.id) + // 已有的那些不算「新来的」:拿到过一次真列表才记底。启动时那一拉若失败了, + // 列表是兜底的空数组,拿它记底,下一拍心跳就会把老的全当成新的 + const listed = (s: ReturnType) => + s.loaded && s.checks.find((c) => c.key === 'approvals')?.state === 'ok' + let seen: Set | null = listed(useCatalog.getState()) ? new Set(pendingIds(useCatalog.getState().approvals)) : null + return useCatalog.subscribe((s, p) => { + if (s.approvals === p.approvals) return + if (!seen) { + if (listed(s) || (s.loaded && s.backend === 'ok')) seen = new Set(pendingIds(s.approvals)) + return + } + const fresh = s.approvals.filter((a) => a.status === 'pending' && !seen!.has(a.id)) + fresh.forEach((a) => seen!.add(a.id)) + for (const a of fresh) { + notifyInBackground({ + title: `新的待审批:${a.title || '有一步在等你处理'}`, + body: [a.workflow_name, a.node_label].filter(Boolean).join(' · ') || undefined, + tag: `approval:${a.id}`, + onClick: () => go(`/runs/${a.run_id}`), + }) + } + }) +} + +function ShellSignals() { + const { pathname, search } = useLocation() + const navigate = useNavigate() + const pending = useCatalog((s) => s.approvals.filter((a) => a.status === 'pending').length) + const studio = useStudioActivity() + const chat = useChatActivity() + const attention = useSignals((s) => s.attention) + const pageTitle = useSignals((s) => s.pageTitle) + const workflowName = useStudio((s) => s.workflow?.name ?? null) + const conversationTitle = useConversations((s) => s.list.find((c) => c.id === s.currentId)?.title ?? null) + + const [page, object] = describePath(pathname, search, { workflowName, conversationTitle }) + const progress = studio.startsWith('run:') ? ` ${studio.slice(4)}` : '' + const activity = studio.startsWith('run') || chat === 'run' + ? `● 运行中${progress}` + : studio === 'gen' || chat === 'gen' ? '● 生成中' : null + + useEffect(() => { + document.title = composeTitle({ + pending, activity, attention, page: pageTitle ?? page, object: pageTitle ? null : object, + }) + }) + + // 点的优先级:失败 > 等人 > 在跑 > 后台时跑完了 + const dot: FaviconDot | null = attention?.tone === 'err' + ? 'failed' + : pending > 0 || attention?.tone === 'warn' + ? 'waiting' + : activity ? 'running' : attention?.tone === 'ok' ? 'done' : null + useEffect(() => { paintFavicon(dot) }, [dot]) + + const navRef = useRef(navigate) + navRef.current = navigate + useEffect(() => { + const go: Go = (to) => { if (to) navRef.current(to) } + const stops = [watchStudio(go), watchChat(go), watchApprovals(go)] + return () => stops.forEach((stop) => stop()) + }, []) + return null +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 68d8a35c..cc1de487 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,50 +1,218 @@ import type { Approval, Conversation, ConversationDetail, ConversationTurn, GraphSpec, - KbDocument, MemoryItem, Provider, ReviewResult, Run, RunEvent, Skill, ToolInfo, - ValidationIssue, VarIssue, Variable, Workflow, + KbDocument, MemoryItem, Provider, ReviewResult, Run, RunEvent, RunStatus, Skill, ToolInfo, + ValidationIssue, VarIssue, Variable, Workflow, WorkflowVersion, } from '../types' const BASE = '/api' +/** + * network:请求根本没到后端(没起、在重启、代理断了、超时没回)。 + * http:后端回了错误状态码,message 是后端的 detail。 + */ +export type ApiErrorKind = 'network' | 'http' + +export const NETWORK_MESSAGE = '连不上后端服务(可能没启动或正在重启),稍后重试' + export class ApiError extends Error { - constructor(public status: number, message: string) { + kind: ApiErrorKind + /** 后端 detail 的原样(字符串、校验错误数组都可能) */ + detail?: unknown + /** 原始报错文本,给「技术细节」和复制用 */ + raw?: string + /** 超时断开时等了多久 */ + timeoutMs?: number + + constructor( + public status: number, + message: string, + opts?: { kind?: ApiErrorKind; detail?: unknown; raw?: string; timeoutMs?: number }, + ) { super(message) + this.name = 'ApiError' + this.kind = opts?.kind ?? (status === 0 ? 'network' : 'http') + this.detail = opts?.detail + this.raw = opts?.raw + this.timeoutMs = opts?.timeoutMs + } +} + +/** + * 连接状态的旁听者:任何一次请求够着了后端(不管状态码)就报 true,网络层失败 + * 报 false。catalog 靠它维护全站的「后端连没连上」,不用另起一路轮询去猜。 + */ +type ConnectivityListener = (reachable: boolean, error?: ApiError) => void +const connectivity = new Set() + +export function onConnectivity(listener: ConnectivityListener): () => void { + connectivity.add(listener) + return () => { connectivity.delete(listener) } +} + +function report(reachable: boolean, error?: ApiError) { + for (const l of connectivity) { + try { l(reachable, error) } catch { /* 旁听者出错不能拖垮请求本身 */ } } } function actorHeader(): Record { try { const actor = localStorage.getItem('agentlab_actor') - return actor ? { 'X-Actor': actor } : {} + // 请求头只能是 Latin-1:中文署名原样放进去,fetch 直接抛错、整个请求发不出去。 + // 后端 runs.actor_of 解码,纯 ASCII 的老署名编码前后一样 + return actor ? { 'X-Actor': encodeURIComponent(actor) } : {} } catch { return {} } } -async function request(path: string, init?: RequestInit): Promise { - const res = await fetch(BASE + path, { - ...init, - headers: { - ...(init?.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), - ...actorHeader(), - ...init?.headers, - }, - }) - if (!res.ok) { - let detail = `${res.status} ${res.statusText}` - try { - const body = await res.json() - // FastAPI 的校验错误是数组,直接 String() 会变成 [object Object] - detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail ?? body) - } catch { - /* 响应体不是 JSON,保留状态行 */ - } - throw new ApiError(res.status, detail) +/** FastAPI 的 422 是 [{loc, msg, type}] 数组,直接 String() 会变成 [object Object] */ +function describeDetail(detail: unknown): string { + if (typeof detail === 'string') return detail + if (Array.isArray(detail)) { + const parts = detail.map((d: any) => { + const loc = Array.isArray(d?.loc) ? d.loc.filter((x: unknown) => x !== 'body' && x !== 'query').join('.') : '' + return loc ? `${loc}:${d?.msg ?? ''}` : String(d?.msg ?? JSON.stringify(d)) + }) + return `提交的内容不符合要求:${parts.join(';')}` } + try { return JSON.stringify(detail) } catch { return String(detail) } +} + +/** + * 网关类失败(代理够不着后端)也算网络失败:vite 代理连不上后端时回 500 且响应体 + * 为空,nginx 回 502/503/504 的 HTML。后端自己抛的 HTTPException 一定带 JSON detail。 + */ +function isGatewayFailure(status: number, bodyText: string, isJson: boolean): boolean { + if (isJson) return false + if (status === 502 || status === 503 || status === 504) return true + return status === 500 && !bodyText.trim() +} + +async function request(path: string, init?: RequestInit & { timeoutMs?: number }): Promise { + const { timeoutMs, ...rest } = init ?? {} + // 自己的超时和调用方的取消要分开:调用方取消照旧抛 AbortError(没人想看到 + // 「连不上后端」),只有我们自己掐断的才算网络失败 + let timer: ReturnType | undefined + let timedOut = false + let signal = rest.signal + if (timeoutMs) { + const ctrl = new AbortController() + timer = setTimeout(() => { timedOut = true; ctrl.abort() }, timeoutMs) + rest.signal?.addEventListener('abort', () => ctrl.abort()) + signal = ctrl.signal + } + let res: Response + try { + res = await fetch(BASE + path, { + ...rest, + signal, + headers: { + ...(rest.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), + ...actorHeader(), + ...rest.headers, + }, + }) + } catch (e: any) { + if (timedOut) { + const err = new ApiError(0, `后端 ${Math.round(timeoutMs! / 1000)} 秒没有响应,稍后重试`, + { kind: 'network', raw: `timeout after ${timeoutMs}ms: ${path}`, timeoutMs }) + report(false, err) + throw err + } + if (e?.name === 'AbortError') throw e + // fetch 的网络失败是 TypeError(Chrome「Failed to fetch」、Safari「Load failed」), + // 原文对用户毫无意义,收进 raw + const err = new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `${e?.name ?? 'Error'}: ${e?.message ?? e}` }) + report(false, err) + throw err + } finally { + if (timer) clearTimeout(timer) + } + if (!res.ok) { + const text = await res.text().catch(() => '') + let body: any + let isJson = false + try { body = JSON.parse(text); isJson = true } catch { /* 响应体不是 JSON */ } + if (isGatewayFailure(res.status, text, isJson)) { + const err = new ApiError(res.status, NETWORK_MESSAGE, { + kind: 'network', raw: `${res.status} ${res.statusText}${text ? `\n${text.slice(0, 500)}` : ''}`, + }) + report(false, err) + throw err + } + report(true) + if (isJson) { + const detail = body?.detail ?? body + throw new ApiError(res.status, describeDetail(detail), { + detail, raw: typeof body?.raw === 'string' ? body.raw : `${res.status} ${text.slice(0, 2000)}`, + }) + } + const message = res.status >= 500 + ? `后端出错了(${res.status}),详情看服务日志` + : `请求没有成功(${res.status} ${res.statusText})` + throw new ApiError(res.status, message, { raw: `${res.status} ${res.statusText}\n${text.slice(0, 2000)}` }) + } + report(true) if (res.status === 204) return undefined as T return res.json() } +/** + * 数据源测连接的结果。ok=false 时 error 是一句人话、hint 是怎么办、detail 是 + * 驱动的原始报错(放进「技术细节」)。 + */ +export interface ConnectionTest { + ok: boolean + elapsed_ms?: number + url?: string + error?: string + hint?: string + detail?: string + [key: string]: any +} + +/** 模型接入测试的结果,失败时的字段同 ConnectionTest */ +export interface ProviderTest { + ok: boolean + latency_ms?: number + model?: string | null + reply?: string + usage?: Record + error?: string + hint?: string + detail?: string + [key: string]: any +} + +/** 还没保存的模型接入配置。带 id 表示在编辑已有的那个,api_key 留空沿用已存的 */ +export interface ProviderDraft { + id?: string + name?: string + kind: string + base_url?: string | null + api_key?: string | null + models?: { id: string; label?: string; context?: number; pricing?: any }[] + default_model?: string | null + extra?: Record +} + +/** 拼查询串:null / undefined / 空串跳过,数组用逗号连起来 */ +function qs(params?: Record): string { + if (!params) return '' + const q = new URLSearchParams() + for (const [k, v] of Object.entries(params)) { + if (v == null || v === '') continue + if (Array.isArray(v)) { + if (v.length) q.set(k, v.join(',')) + } else { + q.set(k, String(v)) + } + } + const s = q.toString() + return s ? `?${s}` : '' +} + const get = (p: string) => request(p) const post = (p: string, body?: unknown) => request(p, { method: 'POST', body: JSON.stringify(body ?? {}) }) @@ -55,7 +223,8 @@ const put = (p: string, body: unknown) => const del = (p: string) => request(p, { method: 'DELETE' }) export const api = { - health: () => get<{ status: string }>('/health'), + /** 心跳。带超时:后端卡死(连得上但不回)时也要能判成断开,而不是永远等下去 */ + health: (timeoutMs = 5000) => request<{ status: string; service?: string }>('/health', { timeoutMs }), system: () => get('/system'), // ---- 工作流 ---- @@ -68,7 +237,9 @@ export const api = { patch(`/workflows/${id}`, body), remove: (id: string) => del(`/workflows/${id}`), duplicate: (id: string) => post(`/workflows/${id}/duplicate`), - versions: (id: string) => get(`/workflows/${id}/versions`), + versions: (id: string) => get(`/workflows/${id}/versions`), + /** 某个版本的完整快照(含 graph)。正式运行的输入字段要从已发布版本取,不能取画布 */ + version: (id: string, v: number) => get(`/workflows/${id}/versions/${v}`), restore: (id: string, v: number) => post(`/workflows/${id}/versions/${v}/restore`), publish: (id: string, level: 'published' | 'governed', version?: number) => post<{ ok: boolean; level?: string; version?: number; issues: any[] }>( @@ -87,7 +258,9 @@ export const api = { create: (body: any) => post('/datasources', body), update: (id: string, body: any) => patch(`/datasources/${id}`, body), remove: (id: string) => del(`/datasources/${id}`), - test: (id: string) => post(`/datasources/${id}/test`, {}), + test: (id: string) => post(`/datasources/${id}/test`, {}), + /** 测一份还没保存的配置,只测连接不落库。编辑时带上 id,后端可以沿用已存的密码 */ + testConfig: (body: any) => post('/datasources/test', body), /** 上传 Excel / CSV,变成一个可以用 SQL 查的数据源。同名就地替换 */ uploadTable: (file: File, body: { name: string; description?: string; header_row?: number }) => { const form = new FormData() @@ -108,13 +281,18 @@ export const api = { }, runs: { - list: (params?: { workflow_id?: string; status?: string; limit?: number }) => { - const q = new URLSearchParams( - Object.entries(params ?? {}).filter(([, v]) => v != null) as [string, string][], - ) - return get(`/runs${q.toString() ? `?${q}` : ''}`) - }, + /** + * status 可以给多个(逗号连接);q 按工作流名模糊匹配;before 是翻页游标 + * (上一页最后一条的 created_at);limit 上限 200。老后端不认的参数会被忽略。 + */ + list: (params?: { + workflow_id?: string; status?: RunStatus | RunStatus[] | string; run_class?: 'formal' | 'exploratory' + q?: string; limit?: number; before?: string + }) => get(`/runs${qs(params)}`), get: (id: string) => get(`/runs/${id}`), + /** 这次运行实际跑的那张图(运行时的快照),不是工作流现在的样子 */ + graph: (id: string) => get<{ graph: GraphSpec; workflow_id: string | null; version: number | null }>( + `/runs/${id}/graph`), start: (body: { workflow_id?: string; graph?: GraphSpec; input?: Record memory_scope?: string; collection?: string @@ -132,11 +310,15 @@ export const api = { /** 核对事件流和封存时的清单哈希:事后被改过、删过、插过都会对不上 */ verify: (id: string) => get<{ sealed: boolean; ok: boolean | null; message: string }>( `/runs/${id}/verify`), - remove: (id: string) => del(`/runs/${id}`), + /** 运行中的删不了(409);封存过的正式运行要 force,否则也是 409,detail 写明后果 */ + remove: (id: string, opts?: { force?: boolean }) => + del(`/runs/${id}${opts?.force ? '?force=true' : ''}`), }, approvals: { - list: (status = 'pending') => get(`/approvals?status=${status}`), + /** 老写法 list('pending') 照旧可用。status 可逗号分隔,'all' 取全部 */ + list: (params: string | { status?: string; run_id?: string; limit?: number } = 'pending') => + get(`/approvals${qs(typeof params === 'string' ? { status: params } : { status: 'pending', ...params })}`), decide: (id: string, body: { approved: boolean; note?: string; value?: any; args?: any }) => post(`/approvals/${id}/decide`, body), }, @@ -149,7 +331,14 @@ export const api = { update: (id: string, body: any) => patch(`/providers/${id}`, body), remove: (id: string) => del(`/providers/${id}`), test: (id: string, body: { model?: string; prompt?: string }) => - post(`/providers/${id}/test`, body), + post(`/providers/${id}/test`, body), + /** 测一份还没保存的接入配置,不落库。model 不填用 default_model */ + testConfig: (body: ProviderDraft & { model?: string | null; prompt?: string }) => + post('/providers/test', body), + /** 问这个接入点有哪些模型(带鉴权调 /v1/models),省得手敲模型 id */ + models: (body: ProviderDraft) => + post<{ ok: boolean; models: string[]; url?: string; error?: string; hint?: string; detail?: string }>( + '/providers/models', body), }, settings: { get: () => get>('/settings'), @@ -159,8 +348,9 @@ export const api = { // ---- 工具 ---- tools: { list: () => get('/tools'), - run: (name: string, args: Record) => - post(`/tools/${name}/run`, { args }), + /** 危险工具要 confirm,否则后端回 409,detail 写明它会做什么 */ + run: (name: string, args: Record, opts?: { confirm?: boolean }) => + post(`/tools/${name}/run`, opts?.confirm ? { args, confirm: true } : { args }), }, customTools: { list: () => get('/custom-tools'), @@ -191,8 +381,19 @@ export const api = { list: (scope?: string) => get(`/memory${scope ? `?scope=${scope}` : ''}`), add: (body: { content: string; scope?: string; kind?: string; importance?: number }) => post('/memory', body), - search: (q: string, scope = 'default') => - get(`/memory/search?q=${encodeURIComponent(q)}&scope=${scope}`), + search: (q: string, scope = 'default', opts?: { peek?: boolean; limit?: number }) => + get(`/memory/search${qs({ q, scope, peek: opts?.peek ? 'true' : undefined, limit: opts?.limit })}`), + /** + * 回忆调试:默认 peek,只看不计数。真实召回会给命中项 use_count += 1 并影响 + * 下次排序,调试台点几下就会把「被召回 N 次」抬高 + */ + recall: (q: string, opts?: { scope?: string; peek?: boolean; limit?: number }) => + get(`/memory/search${qs({ + q, scope: opts?.scope ?? 'default', peek: (opts?.peek ?? true) ? 'true' : undefined, limit: opts?.limit, + })}`), + /** 原地修改,保留来源和召回记录 */ + update: (id: string, body: { content?: string; importance?: number; kind?: string }) => + patch(`/memory/${id}`, body), remove: (id: string) => del(`/memory/${id}`), clearScope: (scope: string) => del(`/memory/scope/${scope}`), }, @@ -208,7 +409,11 @@ export const api = { return request(`/kb/upload?collection=${collection}`, { method: 'POST', body: form }) }, search: (q: string, collection?: string, alpha = 0.5) => - get(`/kb/search?q=${encodeURIComponent(q)}&alpha=${alpha}${collection ? `&collection=${collection}` : ''}`), + get(`/kb/search?q=${encodeURIComponent(q)}&alpha=${alpha}${collection ? `&collection=${collection}` : ''}`), + /** 上传框能收哪些文件。以后端解析器为准,免得界面放行了后端必拒的格式 */ + formats: () => get<{ + extensions: string[]; text: string[]; tabular: string[]; legacy: string[]; accept: string + }>('/kb/formats'), /** 一份文档被切成了什么样。检索不准时第一个该看的就是它 */ document: (id: string) => get<{ document: KbDocument @@ -224,6 +429,8 @@ export const api = { stale_chunks: number; stale_memories: number; unindexed_chunks: number // has_semantics 看的是实际在用的那个;fallback = 配了语义模型却退回了本地哈希 has_semantics: boolean; fallback: boolean; fallback_reason: string + /** 运行时 kb_search 实际用的混合权重。调试台的滑块初值要跟它一致 */ + default_alpha?: number }>(`/kb/embedding${collection ? `?collection=${collection}` : ''}`), setEmbedding: (body: { kind: string; model?: string; base_url?: string }) => put<{ @@ -321,11 +528,22 @@ export function streamCopilot( signal: controller.signal, }) if (!res.ok || !res.body) { - let detail = `${res.status} ${res.statusText}` - try { detail = (await res.json()).detail ?? detail } catch { /* keep */ } - onEnd(String(detail)) + const text = await res.text().catch(() => '') + let body: any + let isJson = false + try { body = JSON.parse(text); isJson = true } catch { /* 响应体不是 JSON */ } + if (isGatewayFailure(res.status, text, isJson)) { + report(false, new ApiError(res.status, NETWORK_MESSAGE, { kind: 'network' })) + onEnd(NETWORK_MESSAGE) + return + } + report(true) + onEnd(isJson ? describeDetail(body?.detail ?? body) + : res.status >= 500 ? `后端出错了(${res.status}),详情看服务日志` + : `请求没有成功(${res.status} ${res.statusText})`) return } + report(true) const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' @@ -345,8 +563,14 @@ export function streamCopilot( } onEnd() } catch (e: any) { - if (e?.name !== 'AbortError') onEnd(e?.message ?? '连接中断') - else onEnd() + if (e?.name === 'AbortError') { onEnd(); return } + // 流到一半断了和一开始就连不上,对用户是一回事:后端够不着了 + if (e instanceof TypeError) { + report(false, new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: String(e) })) + onEnd(NETWORK_MESSAGE) + return + } + onEnd(e?.message ?? '连接中断') } })() return () => controller.abort() @@ -365,7 +589,9 @@ export function streamCopilot( export function streamRun( runId: string, onEvent: (event: RunEvent) => void, - onClose?: () => void, + // stream.end 带着运行的最终状态:连到一条已经不在进行中的运行(含 interrupted) + // 时,客户端靠它知道不会再有事件,不能一直当它还在跑 + onClose?: (end?: { status?: RunStatus | string }) => void, after = 0, ): () => void { let socket: WebSocket | null = null @@ -379,11 +605,12 @@ export function streamRun( socket = new WebSocket(`${proto}://${location.host}/api/runs/${runId}/stream?after=${lastSeq}`) socket.onmessage = (ev) => { - const event = JSON.parse(ev.data) as RunEvent & { type: string } + const event = JSON.parse(ev.data) as RunEvent & { type: string; status?: string } if (event.type === 'stream.end') { closed = true socket?.close() - onClose?.() + // 新后端放在 data.status,老后端放在顶层 status + onClose?.({ status: event.data?.status ?? event.status }) return } if (event.seq) lastSeq = Math.max(lastSeq, event.seq) @@ -397,6 +624,7 @@ export function streamRun( } socket.onopen = () => { retry = 0 + report(true) } } connect() diff --git a/frontend/src/canvas/CanvasDock.tsx b/frontend/src/canvas/CanvasDock.tsx new file mode 100644 index 00000000..de6dce4e --- /dev/null +++ b/frontend/src/canvas/CanvasDock.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react' +import { AlertTriangle, ListChecks, Variable as VarIcon, X, XCircle } from 'lucide-react' +import clsx from 'clsx' +import { ProblemsPane } from './ProblemsPanel' +import { VariablesPane, VarIssueChips } from './VariablesDrawer' +import type { Problem } from './issues' +import { hintOf } from './shortcuts' +import { useStudio } from '../store/studio' +import { IconButton } from '../components/ui' + +export type DockTab = 'problems' | 'variables' + +/** + * 画布底部的停靠栏:「问题」和「变量」两页,同一个位置。 + * + * 做成挤压式而不是浮层:React Flow 的 Controls 和 MiniMap 是绝对定位在画布容器里 + * 的,浮层会把它俩埋掉;挤压会把它俩顶上去。代价是画布底部被裁掉一截,所以要限高, + * 并且保证画布至少剩一截——高度归零时 React Flow 会直接报错 004。 + */ +const MIN_CANVAS = 200 +const DEFAULT_HEIGHT = 240 +const HEIGHT_KEY = 'agentlab.studio.dockHeight' + +const readHeight = () => { + try { + const v = Number(localStorage.getItem(HEIGHT_KEY)) + return Number.isFinite(v) && v >= 120 ? v : DEFAULT_HEIGHT + } catch { + return DEFAULT_HEIGHT + } +} + +export function CanvasDock({ tab, onTab, onClose, problems, activeProblem, onLocate }: { + tab: DockTab + onTab: (t: DockTab) => void + onClose: () => void + problems: Problem[] + activeProblem: string | null + onLocate: (p: Problem) => void +}) { + const [height, setHeight] = useState(readHeight) + const variables = useStudio((s) => s.variables) + const varIssues = useStudio((s) => s.varIssues) + const onEdgesChange = useStudio((s) => s.onEdgesChange) + const errors = problems.filter((p) => p.level === 'error').length + const warns = problems.length - errors + + return ( +
+ + +
+ onTab('problems')} + title={hintOf('问题', 'problems')} controls="dock-problems"> + 问题 + {errors > 0 && ( + + {errors} + + )} + {warns > 0 && ( + + {warns} + + )} + + onTab('variables')} + title={hintOf('变量', 'variables')} controls="dock-variables"> + 变量 + {variables.length} + + {tab === 'variables' && } + + } /> +
+ +
+ {tab === 'problems' + ? onEdgesChange([{ type: 'remove', id }])} /> + : } +
+
+ ) +} + +function DockTabButton({ active, onClick, title, controls, children }: { + active: boolean; onClick: () => void; title: string; controls: string; children: React.ReactNode +}) { + return ( + + ) +} + +/** 拖拽改高。上下都要夹住:高度归零会让 React Flow 直接报错 004。 */ +function ResizeHandle({ height, onResize }: { + height: number; onResize: (h: number) => void +}) { + return ( +
{ + e.preventDefault() + const startY = e.clientY + const startH = height + const max = Math.max(MIN_CANVAS, window.innerHeight * 0.6) + let last = startH + const move = (ev: PointerEvent) => { + last = Math.min(max, Math.max(120, startH - (ev.clientY - startY))) + onResize(last) + } + const up = () => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + try { localStorage.setItem(HEIGHT_KEY, String(Math.round(last))) } catch { /* 存不下就下次用默认高度 */ } + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + }} + /> + ) +} diff --git a/frontend/src/canvas/FlowCanvas.tsx b/frontend/src/canvas/FlowCanvas.tsx index 2cccc365..542853df 100644 --- a/frontend/src/canvas/FlowCanvas.tsx +++ b/frontend/src/canvas/FlowCanvas.tsx @@ -1,138 +1,785 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react' +import './surface.css' import { - Background, BackgroundVariant, Controls, MiniMap, ReactFlow, ReactFlowProvider, - useReactFlow, type Edge, + createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, +} from 'react' +import { + Background, BackgroundVariant, ControlButton, Controls, MiniMap, ReactFlow, ReactFlowProvider, + useReactFlow, useStoreApi, type AriaLabelConfig, type Edge, type MiniMapNodeProps, type Viewport, } from '@xyflow/react' -import { FlowEdge, RoutingContext } from './FlowEdge' +import { Crosshair, Lock, Route as RouteIcon } from 'lucide-react' +import clsx from 'clsx' +import { FlowEdge, MomentContext, RoutingContext, type EdgeRunState, type EdgeView } from './FlowEdge' import { NodeCard } from './NodeCard' -import { buildRoutes } from './routing' -import { useStudio } from '../store/studio' +import { buildRoutes, NODE_WIDTH } from './routing' +import { RunTimeline, useDock } from './RunTimeline' +import { toast } from '../components/ui' +import { runClassLabel } from '../lib/terms' +import { shortId } from '../lib/format' +import { + activeEdgesOf, applyDerived, edgeIdOf, edgeKey, heldEdgesOf, topology, walkedEdges, type GraphLike, +} from '../run/derive' +import { + isActivePhase, isTerminal, type NodeState, type NodeTrace, type RunPhase, type Trace, +} from '../run/trace' +import { projectCached } from '../run/useNodeView' +import { useStudio, type FlowNode } from '../store/studio' import type { NodeType } from '../types' const nodeTypes = { card: NodeCard } const edgeTypes = { flow: FlowEdge } -const NODE_COLOR: Record = { - input: '#3fb950', output: '#a371f7', llm: '#4f8cff', agent: '#6366f1', - supervisor: '#db61a2', tool: '#f0883e', code: '#2dd4bf', branch: '#d29922', - loop: '#ec4899', human: '#f85149', validate: '#84cc16', memory: '#14b8a6', - retrieve: '#0ea5e9', transform: '#8b949e', subgraph: '#bc8cff', metrics: '#34d399', +/** 读得清卡片字的最低缩放。低于它,12px 的标题落到 7px 以下,光弧也细到看不见 */ +const READABLE_ZOOM = 0.6 +/** 语义缩放三档的门槛,上下各留 0.02 的回滞,免得停在门槛上来回跳 */ +const LOD_FULL = 0.6 +const LOD_SIGNAL = 0.35 +const HYSTERESIS = 0.02 +/** 手动平移、缩放之后,跟随执行让出这么久 */ +const FOLLOW_PAUSE_MS = 10_000 +/** 一次性时刻挂在根元素上的时长:够卡片按层级错开播完,之后撤掉,免得后来的变化再播一遍 */ +const MOMENT_MS = 2600 + +type Lod = 'full' | 'compact' | 'signal' + +/** + * cur 为 null 表示视口已经停稳:按落点直接分档,不带回滞。回滞只防"停在门槛上 + * 来回跳",停稳了还带着它,从 0.5 取景回 0.6 就会一直卡在 compact(要 0.62 才回 full), + * 同样的 60% 打开时是完整卡片、定位过去却只剩标题 + */ +function lodFor(zoom: number, cur: Lod | null): Lod { + const full = cur == null ? LOD_FULL - 1e-3 : cur === 'full' ? LOD_FULL - HYSTERESIS : LOD_FULL + HYSTERESIS + const signal = cur == null ? LOD_SIGNAL : cur === 'signal' ? LOD_SIGNAL + HYSTERESIS : LOD_SIGNAL - HYSTERESIS + return zoom >= full ? 'full' : zoom < signal ? 'signal' : 'compact' } +const reducedMotion = (): boolean => + typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches + +/** 系统「减少动效」的开关,跟着系统设置实时变 */ +function useReducedMotion(): boolean { + const [reduced, setReduced] = useState(reducedMotion) + useEffect(() => { + if (typeof matchMedia !== 'function') return + const mq = matchMedia('(prefers-reduced-motion: reduce)') + const sync = () => setReduced(mq.matches) + mq.addEventListener('change', sync) + return () => mq.removeEventListener('change', sync) + }, []) + return reduced +} + +/** + * 程序取景一律线性插值。d3 默认的 interpolateZoom 走"先拉远再推近"的弧线, + * 半路会缩到 0.43–0.5,把画布切进 compact 档:定位到失败节点,落地时错误正文被藏了 + */ +const LINEAR = 'linear' as const + +// 「只看执行路径」看的是这一次运行的路径:清掉结果、开始下一次运行(含接着跑)时复位。 +// 放在模块级而不是画布组件里:画布不在屏上时(去了别的页)清掉的结果也要复位 +useStudio.subscribe((s, prev) => { + if (s.runPhase === prev.runPhase || !useDock.getState().pathOnly) return + if (s.runPhase === 'idle' || (isActivePhase(s.runPhase) && !isActivePhase(prev.runPhase))) { + useDock.getState().set({ pathOnly: false }) + } +}) + +/** React Flow 自带控件和读屏描述默认是英文 */ +const ARIA: Partial = { + 'node.a11yDescription.default': '按回车或空格选中节点,按删除键删掉,Esc 取消。', + 'node.a11yDescription.keyboardDisabled': '按回车或空格选中节点,再用方向键移动,按删除键删掉,Esc 取消。', + 'node.a11yDescription.ariaLiveMessage': ({ x, y }: { direction: string; x: number; y: number }) => + `已移动选中的节点,新位置 x ${x},y ${y}`, + 'edge.a11yDescription.default': '按回车或空格选中连线,再按删除键删掉,Esc 取消。', + 'controls.ariaLabel': '画布控件', + 'controls.zoomIn.ariaLabel': '放大', + 'controls.zoomOut.ariaLabel': '缩小', + 'controls.fitView.ariaLabel': '适配全图', + 'minimap.ariaLabel': '小地图', + 'handle.ariaLabel': '连接点', +} + +/** 已经响应过的取景请求 */ +let lastFocus: object | null = null + +/** StudioState 的 getViewportCenter 由 W-studio 声明;这里只负责把实现挂上去 */ +const setStudio = useStudio.setState as (patch: Record) => void + +// ------------------------------------------------------------------------- +// 回放:把航迹投影到游标那一刻,边和小地图按那一刻的样子画 +// ------------------------------------------------------------------------- + +function replayTrace(t: Trace, at: number, graph: GraphLike): Trace { + const proj = projectCached(t, at) + const nodes: Record = {} + for (const [id, n] of Object.entries(t.nodes)) { + const p = proj.nodes[id] + const taken: string[] = [] + let looping = false + for (const s of n.segments) { + if (s.kind !== 'run' || s.start > at) continue + if (s.handle && !taken.includes(s.handle)) taken.push(s.handle) + looping = s.handle === 'body' && s.end != null && s.end <= at + } + const state = p?.state ?? 'idle' + // 人工审批的通过 / 驳回只记在节点上,不在段上:它结束了就用节点上的 + const finished = state === 'done' || state === 'failed' + const decided = taken.length ? taken : finished ? n.taken ?? [] : [] + nodes[id] = { + ...n, state, count: p?.count ?? 0, taken: decided, takenHandle: decided[decided.length - 1], + looping: state === 'running' && looping, + } + } + return applyDerived({ ...t, phase: proj.phase, nodes }, graph) +} + +/** 每条边此刻的运行态 */ +function classifyEdges( + trace: Trace, phase: RunPhase, graph: GraphLike, flowing: string[], +): Map { + const out = new Map() + if (phase === 'idle' && !Object.keys(trace.nodes).length) return out + const flow = new Set(flowing) + const held = new Set(heldEdgesOf(trace, graph)) + const walked = walkedEdges(trace, graph) + const settled = isTerminal(phase) + const stateOf = (id: string): NodeState => trace.nodes[id]?.state ?? 'idle' + for (const e of graph.edges) { + const id = edgeIdOf(e) + const target = stateOf(e.target) + const source = stateOf(e.source) + let s: EdgeRunState = 'idle' + if (flow.has(id)) s = 'flow' + else if (held.has(id)) s = 'held' + else if (target === 'blocked' && (source === 'failed' || source === 'blocked')) s = 'cut' + else if (walked.has(id)) s = 'walked' + else if (settled) s = 'unwalked' + out.set(id, s) + } + return out +} + +// ------------------------------------------------------------------------- +// 小地图:有运行时按状态着色,失败和等人放大;没有运行时按类型色 +// ------------------------------------------------------------------------- + +/** + * states:每个节点此刻的状态(没有运行时为 null,按类型色画)。 + * unit:小地图上 1px 合多少画布单位。失败、等人的那一圈要按屏幕像素定大小—— + * 按节点尺寸定的话,70 个节点的长图里它会缩成不到 1px,正好在最需要的时候看不见 + */ +const MiniStates = createContext<{ states: Record; unit: number } | null>(null) + +const MINI_FILL: Partial> = { + running: 'var(--st-running)', + waiting: 'var(--st-waiting)', + failed: 'var(--st-failed)', + done: 'color-mix(in srgb, var(--st-done) 70%, transparent)', + skipped: 'var(--st-skipped)', + cancelled: 'var(--st-cancelled)', + suspended: 'var(--st-suspended)', + blocked: 'var(--st-blocked)', + unreached: 'color-mix(in srgb, var(--st-unreached) 45%, transparent)', + queued: 'var(--st-queued)', +} + +const MiniNode = memo(function MiniNode({ id, x, y, width, height, color, borderRadius, shapeRendering, className }: MiniMapNodeProps) { + const mini = useContext(MiniStates) + const state = mini?.states[id] + const alert = state === 'failed' || state === 'waiting' + const fill = mini ? MINI_FILL[state ?? 'idle'] ?? 'var(--border-strong)' : color + // 圈:比节点每边大 3px,至少 12px 见方,线宽 2px(都是小地图上的屏幕像素) + const u = mini?.unit ?? 1 + const rw = Math.max(width + 6 * u, 12 * u) + const rh = Math.max(height + 6 * u, 12 * u) + return ( + <> + + {/* 失败、等人在小地图上放大一圈:大图里它们是唯一需要人去处理的地方 */} + {alert && ( + + )} + + ) +}) + +const typeColor = (n: { data?: unknown }): string => { + const type = (n.data as { nodeType?: NodeType } | undefined)?.nodeType + return type ? `var(--nt-${type})` : 'var(--text-faint)' +} + +// ------------------------------------------------------------------------- + function CanvasInner() { - const wrapper = useRef(null) - const { screenToFlowPosition, fitView } = useReactFlow() + const rf = useRef(null) + const { screenToFlowPosition, fitView, setViewport, setCenter, getViewport, getNodes, getNodesBounds, + getInternalNode, zoomTo } = useReactFlow() + const rfStore = useStoreApi() const nodes = useStudio((s) => s.nodes) const edges = useStudio((s) => s.edges) const activeEdges = useStudio((s) => s.activeEdges) const fitRequest = useStudio((s) => s.fitRequest) - // 只订阅一个字符串。直接订阅 runtime 的话每个 token 事件都会换一个新对象, - // 整张画布跟着重渲染一次——跑长文本时这是白烧的 - const takenKey = useStudio((s) => Object.entries(s.runtime) - .filter(([, r]) => r.takenHandle) - .map(([id, r]) => `${id}:${r.takenHandle}`) - .join('|')) - const { onNodesChange, onEdgesChange, onConnect, addNode, select } = useStudio() + const trace = useStudio((s) => s.trace) + const runPhase = useStudio((s) => s.runPhase) + const runClass = useStudio((s) => s.trace.runClass ?? s.run?.run_class) + const runVersion = useStudio((s) => s.run?.version ?? null) + const replayAt = useStudio((s) => s.replayAt) + const focusRequest = useStudio((s) => s.focusRequest) + const selectedId = useStudio((s) => s.selectedId) + const follow = useStudio((s) => s.follow) + const pathOnly = useDock((s) => s.pathOnly) + // 动作从 getState 取:整个 useStudio() 解构会订阅全部字段,每个 token 都让画布重渲染一次 + const actions = useStudio.getState() + + const graph = useMemo(() => ({ nodes, edges }), [nodes, edges]) + const topo = useMemo(() => topology(graph), [graph]) + const hasRun = runPhase !== 'idle' || Object.keys(trace.nodes).length > 0 + const active = isActivePhase(runPhase) + const reduced = useReducedMotion() + // 正式运行期间画布只读:它跑的是已发布的不可变版本,此刻改图既改不了它, + // 还会让事件对到已经被删掉的节点上。探索运行只关删除和连线,拖动照常 + const formalLock = active && runClass === 'formal' // 走线方案整张图算一次。节点尺寸要等 React Flow 量完才有,量到之后 // nodes 会变,这里跟着重算,端口和车道就落到实测尺寸上。 const routes = useMemo(() => buildRoutes(nodes, edges), [nodes, edges]) - // 实际走过的边:分支节点记下命中的出口,据此把那条边挑出来。跑完之后画布上 - // 留下的就是**这次的执行路径**,而不是一张"所有可能性"的图 - const takenEdges = useMemo(() => { - const hit = new Set() - const takenOf = new Map() - for (const part of takenKey.split('|')) { - const at = part.indexOf(':') - if (at > 0) takenOf.set(part.slice(0, at), part.slice(at + 1)) - } - if (!takenOf.size) return hit - for (const e of edges) { - if (e.sourceHandle && takenOf.get(e.source) === e.sourceHandle) hit.add(e.id) - } - return hit - }, [takenKey, edges]) + // ---- 此刻(实时或回放游标那一刻)的航迹 ---- + const view = useMemo(() => { + if (replayAt == null) return { trace, phase: runPhase, flowing: activeEdges } + const t = replayTrace(trace, replayAt, graph) + return { trace: t, phase: t.phase, flowing: activeEdgesOf(t, graph) } + }, [trace, runPhase, activeEdges, replayAt, graph]) - // 正在流动的边加动画,让"数据走到哪了"看得见 - const decorated = useMemo( - () => - edges.map((e) => { - const active = activeEdges.includes(e.id) - const taken = takenEdges.has(e.id) - const className = [active && 'edge-active', taken && !active && 'edge-taken'] - .filter(Boolean).join(' ') || undefined - return { ...e, type: 'flow', className, animated: active } - }), - [edges, activeEdges, takenEdges], + const edgeStates = useMemo( + () => classifyEdges(view.trace, view.phase, graph, view.flowing), + [view, graph], ) + // 「只看执行路径」只在运行真正结束、看的也是结束那一刻时压暗:运行中、等人、挂起时 + // 下游还没走到不等于"不在路径上",压暗会把正要执行的那一截藏起来 + const pathView = pathOnly && isTerminal(runPhase) && isTerminal(view.phase) + const visited = useMemo(() => { + const out = new Set() + for (const [id, n] of Object.entries(view.trace.nodes)) { + if (n.count > 0 || n.state === 'running' || n.state === 'waiting' || n.state === 'skipped') out.add(id) + } + return out + }, [view.trace]) - // 自动排版 / Copilot 生成完之后把视角拉回图上。位置全变了却不重新取景, - // 人会盯着一片空白以为图没了。 - const firstFit = useRef(true) + // ---- 一次性时刻:只由实时事件触发,回放和补发的历史直接落终态 ---- + const [moment, setMoment] = useState<{ kind: 'start' | 'success' | 'failed'; seq: number; origin: number } | null>(null) useEffect(() => { - if (firstFit.current) { - firstFit.current = false + let last = 0 + let seq = 0 + let timer: ReturnType | null = null + const unsub = useStudio.subscribe((s, prev) => { + if (s.runPhase === prev.runPhase || s.trace.lastReplay || s.replayAt != null) return + let kind: 'start' | 'success' | 'failed' | null = null + if ((prev.runPhase === 'idle' || prev.runPhase === 'queued') && s.runPhase === 'running') { + // 大图的开场点亮会变成一场烟花,跳过 + kind = s.nodes.length <= 60 ? 'start' : null + } else if (isActivePhase(prev.runPhase) && s.runPhase === 'succeeded') kind = 'success' + else if (isActivePhase(prev.runPhase) && s.runPhase === 'failed') kind = 'failed' + const now = Date.now() + // 同一秒最多一个时刻:接连来的两个只留第一个 + if (!kind || now - last < 1000) return + last = now + seq += 1 + const failedId = s.trace.failedNodeId + const origin = kind === 'failed' && failedId ? topology({ nodes: s.nodes, edges: s.edges }).rank[failedId] ?? 0 : 0 + setMoment({ kind, seq, origin }) + if (timer) clearTimeout(timer) + timer = setTimeout(() => setMoment(null), MOMENT_MS) + }) + return () => { + unsub() + if (timer) clearTimeout(timer) + } + }, []) + const momentCtx = useMemo(() => (moment ? { kind: moment.kind, seq: moment.seq } : null), [moment]) + + // ---- 边:运行态 + 回边 + 选中时的入边 / 出边 ---- + const decorated = useMemo(() => { + const editing = !hasRun && selectedId != null + return edges.map((e) => { + const state = edgeStates.get(e.id) ?? 'idle' + const back = topo.back.has(edgeKey(e)) + const target = view.trace.nodes[e.target] + const sf: EdgeView = { + state, back, rank: topo.rank[e.target] ?? 0, + loops: back ? Math.max(0, target?.iteration ?? (target?.count ?? 1) - 1) : 0, + } + const className = clsx( + `sf-e-${state}`, + back && 'sf-e-back', + editing && (e.source === selectedId ? 'sf-e-out' : e.target === selectedId ? 'sf-e-in' : 'sf-e-dim'), + pathView && (state === 'flow' || state === 'walked' || state === 'held' ? 'sf-e-onpath' : 'sf-e-offpath'), + ) + // animated 一律关掉:React Flow 的 animated 是一条永远在走的虚线,方向和"是否真在流"都说不清 + return { ...e, type: 'flow', className, animated: false, data: { ...e.data, sf } } + }) + }, [edges, edgeStates, topo, view.trace, hasRun, selectedId, pathView]) + + // ---- 节点:注入拓扑层级(一次性时刻按它错开),只看执行路径时压暗没走的 ---- + // 按原节点对象缓存:拖一个节点只换那一个的副本,其余卡片的 data 引用不变,memo 生效 + const nodeCache = useRef(new WeakMap()) + const flowNodes = useMemo(() => nodes.map((n) => { + const rank = topo.rank[n.id] ?? 0 + const off = pathView && !visited.has(n.id) + const key = `${rank}|${off ? 1 : 0}` + const hit = nodeCache.current.get(n) + if (hit && hit.key === key) return hit.out + const out: FlowNode = { + ...n, + className: clsx(n.className, off && 'sf-offpath') || undefined, + style: { ...n.style, '--rank': rank } as CSSProperties, + data: { ...n.data, rank } as FlowNode['data'], + } + nodeCache.current.set(n, { key, out }) + return out + }), [nodes, topo, pathView, visited]) + + // ---- 小地图的状态表 ---- + // 小地图按节点和视口的并集缩进 150×100。这里只按节点算(视口一动就重算不值得), + // 得到的比例偏小一点,圈在屏幕上也就小一点,不影响看见 + const miniUnit = useMemo(() => { + if (!hasRun || !nodes.length) return 1 + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity + for (const n of nodes) { + x0 = Math.min(x0, n.position.x) + y0 = Math.min(y0, n.position.y) + x1 = Math.max(x1, n.position.x + (n.measured?.width ?? NODE_WIDTH)) + y1 = Math.max(y1, n.position.y + (n.measured?.height ?? 80)) + } + return Math.max(1, (x1 - x0) / 150, (y1 - y0) / 100) + }, [hasRun, nodes]) + const miniStates = useMemo(() => { + if (!hasRun) return null + const states: Record = {} + for (const n of nodes) states[n.id] = view.trace.nodes[n.id]?.state ?? 'idle' + return { states, unit: miniUnit } + }, [hasRun, nodes, view.trace, miniUnit]) + + // ---- 语义缩放:只在跨档时写一次属性,--zoom 和缩放读数直接写到 DOM,不经过 React ---- + // 移动途中带回滞;停稳后(onMoveEnd)按落点重新分档 + const zoomText = useRef(null) + const lod = useRef('full') + const applyZoom = useCallback((zoom: number, settled = false) => { + const el = rf.current + if (!el) return + el.style.setProperty('--zoom', String(Math.round(zoom * 1000) / 1000)) + if (zoomText.current) zoomText.current.textContent = `${Math.round(zoom * 100)}%` + const next = lodFor(zoom, settled ? null : lod.current) + if (next !== lod.current || !el.dataset.lod) { + lod.current = next + el.dataset.lod = next + } + }, []) + useEffect(() => { + applyZoom(rfStore.getState().transform[2], true) + return rfStore.subscribe((s, prev) => { + if (s.transform[2] !== prev.transform[2]) applyZoom(s.transform[2]) + }) + }, [rfStore, applyZoom]) + + // 画布尺寸变了(航迹坞出现、变量抽屉挤进来)时保持视口中心不动。React Flow + // 默认钉住左上角,坞一升起来,画面下半截的节点就被它盖住了 + const box = useRef(null) + useEffect(() => { + const el = box.current + if (!el) return + let last = { w: el.clientWidth, h: el.clientHeight } + const ro = new ResizeObserver(() => { + const w = el.clientWidth + const h = el.clientHeight + if (last.w && last.h && (w !== last.w || h !== last.h)) { + const { x, y, zoom } = getViewport() + void setViewport({ x: x + (w - last.w) / 2, y: y + (h - last.h) / 2, zoom }) + } + last = { w, h } + }) + ro.observe(el) + return () => ro.disconnect() + }, [getViewport, setViewport]) + + // ---- 取景 ---- + const pausedUntil = useRef(0) + /** + * 人手动平移、缩放的那一下:开始时记下视口,结束时真的动过才让跟随让路。 + * 只是按一下(点空白处取消选择、正式运行时点节点)d3 也报开始和结束,那不算 + */ + const userMove = useRef(null) + const [followPaused, setFollowPaused] = useState(false) + const pauseFollow = useCallback(() => { + pausedUntil.current = Date.now() + FOLLOW_PAUSE_MS + setFollowPaused(true) + }, []) + useEffect(() => { + if (!followPaused) return + const t = setTimeout(() => setFollowPaused(false), FOLLOW_PAUSE_MS) + return () => clearTimeout(t) + }, [followPaused]) + + /** + * 打开一张图时的取景。整图放得下、缩放不低于可读下限就整图居中;放不下就 + * 不再硬塞——以入口为左锚、按可读缩放只取前几列,其余交给小地图。以前一律 + * 整图塞进来,12 节点的图缩到 0.2,标题只剩 3px。 + */ + const frame = useCallback((duration: number) => { + const el = rf.current + const all = getNodes() + if (!el || !all.length) return + const bounds = getNodesBounds(all) + const W = el.clientWidth + const H = el.clientHeight + const PAD = 40 + if (!W || !H || !bounds.width) return + const fit = Math.min((W - 2 * PAD) / bounds.width, (H - 2 * PAD) / bounds.height) + if (fit >= READABLE_ZOOM) { + void fitView({ padding: 0.12, maxZoom: 1.1, minZoom: READABLE_ZOOM, duration, interpolate: LINEAR }) return } - void fitView({ padding: 0.14, maxZoom: 1.1, duration: 280 }) - }, [fitRequest, fitView]) + // 按列聚类,取前四列的跨度定缩放 + const xs = [...new Set(all.map((n) => Math.round(n.position.x)))].sort((a, b) => a - b) + const cols: number[] = [] + for (const x of xs) if (!cols.length || x - cols[cols.length - 1] > 60) cols.push(x) + const firstCols = cols.slice(0, 4) + const span = (firstCols[firstCols.length - 1] ?? bounds.x) + NODE_WIDTH - bounds.x + let zoom = Math.min(1, Math.max(READABLE_ZOOM, (W - 2 * PAD) / Math.max(span, NODE_WIDTH))) + // 竖直方向放得下就别再放大:宁可多看几列 + const vfit = (H - 2 * PAD) / bounds.height + if (vfit >= READABLE_ZOOM) zoom = Math.min(zoom, vfit) + const entries = all.filter((n) => (n.data as FlowNode['data']).nodeType === 'input') + const anchor = entries[0] ?? all.reduce((a, b) => (a.position.x <= b.position.x ? a : b)) + const anchorH = anchor.measured?.height ?? 80 + const x = PAD - bounds.x * zoom + // 竖直方向按"这一屏看得见的那几列"居中,不按整张图:斜着往下长的图按整体居中, + // 入口那几列会贴在顶上,下面空一大块 + const reach = bounds.x + (W - PAD) / zoom + const seen = all.filter((n) => n.position.x <= reach) + const top = Math.min(...seen.map((n) => n.position.y)) + const bottom = Math.max(...seen.map((n) => n.position.y + (n.measured?.height ?? 80))) + const y = (bottom - top) * zoom <= H - 2 * PAD + ? (H - (bottom - top) * zoom) / 2 - top * zoom + : H / 2 - (anchor.position.y + anchorH / 2) * zoom + void setViewport({ x, y, zoom }, { duration, interpolate: LINEAR }) + }, [fitView, getNodes, getNodesBounds, setViewport]) + + // 打开、换图、自动排版、Copilot 生成完之后取景。位置全变了却不重新取景, + // 人会盯着一片空白以为图没了。要等 React Flow 量完节点尺寸才算得准 + const framed = useRef(false) + useEffect(() => { + let raf = 0 + let tries = 0 + const duration = framed.current && !reducedMotion() ? 280 : 0 + const attempt = () => { + const all = getNodes() + if (all.length && !all.every((n) => n.measured?.width) && tries++ < 40) { + raf = requestAnimationFrame(attempt) + return + } + framed.current = true + frame(duration) + } + raf = requestAnimationFrame(attempt) + return () => cancelAnimationFrame(raf) + }, [fitRequest, frame, getNodes]) + + /** 把一组节点摆进视口。都已经在视口里就不动:画面不该无缘无故地跳 */ + const bringIntoView = useCallback((ids: string[], opts: { force?: boolean; zoomAtLeast?: number } = {}) => { + const el = rf.current + if (!el) return false + const rects = ids.map((id) => getInternalNode(id)).filter(Boolean).map((n) => ({ + x: n!.internals.positionAbsolute.x, y: n!.internals.positionAbsolute.y, + w: n!.measured.width ?? NODE_WIDTH, h: n!.measured.height ?? 80, + })) + if (!rects.length) return false + const { x: vx, y: vy, zoom } = getViewport() + const M = 32 + const inside = rects.every((r) => r.x * zoom + vx >= M && r.y * zoom + vy >= M + && (r.x + r.w) * zoom + vx <= el.clientWidth - M && (r.y + r.h) * zoom + vy <= el.clientHeight - M) + if (inside && !opts.force) return false + const minX = Math.min(...rects.map((r) => r.x)) + const minY = Math.min(...rects.map((r) => r.y)) + const maxX = Math.max(...rects.map((r) => r.x + r.w)) + const maxY = Math.max(...rects.map((r) => r.y + r.h)) + const z = Math.max(zoom, opts.zoomAtLeast ?? 0) + // 几个节点同时开跑、包围盒放不下时,只追第一个,免得视口来回跳 + const fits = (maxX - minX) * z <= el.clientWidth - 2 * M && (maxY - minY) * z <= el.clientHeight - 2 * M + const target = fits ? { cx: (minX + maxX) / 2, cy: (minY + maxY) / 2 } + : { cx: rects[0].x + rects[0].w / 2, cy: rects[0].y + rects[0].h / 2 } + void setCenter(target.cx, target.cy, { zoom: z, duration: reducedMotion() ? 0 : 320, interpolate: LINEAR }) + return true + }, [getInternalNode, getViewport, setCenter]) + + // 跟随执行:有节点实时开跑、而它在视口外时平移过去。只由 node.started 驱动, + // 不新增任何动画循环;同一帧里一起开跑的并成一次 + useEffect(() => { + let raf = 0 + let pending: string[] = [] + const unsub = useStudio.subscribe((s, prev) => { + if (s.trace === prev.trace || !s.follow || s.replayAt != null || s.trace.lastReplay) return + for (const [id, n] of Object.entries(s.trace.nodes)) { + if (n.state === 'running' && !n.looping && n.count > (prev.trace.nodes[id]?.count ?? 0)) pending.push(id) + } + if (!pending.length || raf) return + raf = requestAnimationFrame(() => { + raf = 0 + const ids = pending + pending = [] + // 人正拖着画布时也不抢:松手之后再按"动过没有"决定暂不暂停 + if (Date.now() < pausedUntil.current || userMove.current) return + bringIntoView(ids, { zoomAtLeast: 0.5 }) + }) + }) + return () => { + unsub() + cancelAnimationFrame(raf) + } + }, [bringIntoView]) + + // 右栏、时间轴、胶囊请求取景:点名要看它,就算已在视口里也居中一下。 + // focusRequest 每次请求都是新对象(seq +1),同一个节点连点两次也会再取景 + useEffect(() => { + // 画布重新挂载(从别的页回来)时 store 里还留着上一次的请求,不该再取景一次 + if (!focusRequest || focusRequest === lastFocus) return + lastFocus = focusRequest + let raf = 0 + let tries = 0 + const go = () => { + if (!getInternalNode(focusRequest.id)?.measured?.width && tries++ < 20) { + raf = requestAnimationFrame(go) + return + } + if (bringIntoView([focusRequest.id], { force: true, zoomAtLeast: READABLE_ZOOM })) pauseFollow() + } + go() + return () => cancelAnimationFrame(raf) + }, [focusRequest, bringIntoView, getInternalNode, pauseFollow]) + + // 调色板点击添加节点时落在视口中心。ReactFlowProvider 在这里面,外面拿不到 useReactFlow + useEffect(() => { + const getViewportCenter = () => { + const r = rf.current?.getBoundingClientRect() + if (!r) return { x: 0, y: 0 } + return screenToFlowPosition({ x: r.left + r.width / 2, y: r.top + r.height / 2 }) + } + setStudio({ getViewportCenter }) + return () => setStudio({ getViewportCenter: null }) + }, [screenToFlowPosition]) const onDrop = useCallback( (event: React.DragEvent) => { event.preventDefault() const type = event.dataTransfer.getData('application/agentlab-node') as NodeType if (!type) return - addNode(type, screenToFlowPosition({ x: event.clientX, y: event.clientY })) + if (formalLock) { + toast.warn('正式运行进行中,画布只读。等它结束再改') + return + } + actions.addNode(type, screenToFlowPosition({ x: event.clientX, y: event.clientY })) }, - [addNode, screenToFlowPosition], + [actions, screenToFlowPosition, formalLock], ) + const rootStyle = useMemo( + () => (moment?.kind === 'failed' ? { '--moment-origin': moment.origin } as CSSProperties : undefined), + [moment], + ) + // 「适配全图」是人主动要看全:不设可读下限。打开时的取景策略在 frame() 里 + const fitAll = useMemo( + () => ({ padding: 0.12, maxZoom: 1.1, duration: reduced ? 0 : 240, interpolate: LINEAR }), + [reduced], + ) + const showFollow = active && replayAt == null && follow + return ( -
+
- select(node.id)} - onPaneClick={() => select(null)} - onDrop={onDrop} - onDragOver={(e) => { - e.preventDefault() - e.dataTransfer.dropEffect = 'move' - }} - fitView - fitViewOptions={{ padding: 0.12, maxZoom: 1.1, minZoom: 0.5 }} - minZoom={0.2} - maxZoom={2} - defaultEdgeOptions={{ type: 'flow' }} - proOptions={{ hideAttribution: true }} - deleteKeyCode={['Backspace', 'Delete']} - > - - - NODE_COLOR[(n.data as any)?.nodeType as string] ?? '#666'} - style={{ width: 140, height: 96 }} - /> - + + + actions.select(node.id)} + onPaneClick={() => actions.select(null)} + onNodeMouseEnter={(_, node) => { if (hasRun) useDock.getState().set({ canvasHover: node.id }) }} + onNodeMouseLeave={() => { if (hasRun) useDock.getState().set({ canvasHover: null }) }} + onMoveStart={(event, vp) => { + // 程序取景(跟随、聚焦)没有 event;有 event 的是人在拖、在滚轮 + if (event) userMove.current = vp + }} + onMoveEnd={(_, vp) => { + applyZoom(vp.zoom, true) + const from = userMove.current + if (!from) return + userMove.current = null + const moved = Math.abs(vp.x - from.x) > 2 || Math.abs(vp.y - from.y) > 2 + || Math.abs(vp.zoom - from.zoom) > 1e-3 + if (moved && active) pauseFollow() + }} + onDrop={onDrop} + onDragOver={(e) => { + e.preventDefault() + e.dataTransfer.dropEffect = formalLock ? 'none' : 'move' + }} + nodesDraggable={!formalLock} + nodesConnectable={!active} + edgesReconnectable={!active} + deleteKeyCode={active ? null : ['Backspace', 'Delete']} + minZoom={0.2} + maxZoom={2} + defaultEdgeOptions={{ type: 'flow' }} + ariaLabelConfig={ARIA} + proOptions={{ hideAttribution: true }} + > + {/* 两级网格:细点每格一个,粗点每五格一个,像工程图纸的坐标纸 */} + + + + void zoomTo(1, { duration: reduced ? 0 : 240, interpolate: LINEAR })} + title="当前缩放 · 点一下回到 100%" aria-label="回到 100% 缩放"> + + + + + + + + + {/* 画布顶上的浮条排成一行:跟随在左,只读横幅居中;放不下时横幅折到下一行、 + 再不够就截掉说明文字。各自绝对定位的话,笔记本宽度下横幅会盖住「恢复」 */} + {(formalLock || showFollow) && ( +
+ {showFollow && ( +
+ + {followPaused ? ( + <> + 已暂停跟随 + + + ) : ( + 跟随执行 + )} +
+ )} + {formalLock && ( +
+ + {runClassLabel('formal', runVersion)} 进行中 · 画布只读 + 跑的是已发布的不可变版本,结束后可以继续编辑 +
+ )} +
+ )}
) } +/** 画布下方的航迹坞:有运行时才出现,没有运行不占地方 */ +function RunDock() { + const trace = useStudio((s) => s.trace) + const runPhase = useStudio((s) => s.runPhase) + const nodes = useStudio((s) => s.nodes) + const edges = useStudio((s) => s.edges) + const replayAt = useStudio((s) => s.replayAt) + const hovered = useStudio((s) => s.hoveredNodeId) + const follow = useStudio((s) => s.follow) + const run = useStudio((s) => s.run) + const canvasHover = useDock((s) => s.canvasHover) + const pathOnly = useDock((s) => s.pathOnly) + const open = useDock((s) => s.open) + const height = useDock((s) => s.height) + const playing = useDock((s) => s.playing) + const speed = useDock((s) => s.speed) + const compress = useDock((s) => s.compress) + const setDock = useDock((s) => s.set) + const { setReplayAt, setHoveredNode, focusNode, setFollow } = useStudio.getState() + + const graph = useMemo(() => ({ nodes, edges }), [nodes, edges]) + const ui = useMemo(() => ({ open, height, playing, speed, compress }), [open, height, playing, speed, compress]) + if (runPhase === 'idle' && trace.startedAt == null && !Object.keys(trace.nodes).length) return null + + const title = run + ? `${runClassLabel(run.run_class ?? trace.runClass ?? 'exploratory', run.version)} ${shortId(run.id)}` + : runClassLabel(trace.runClass ?? 'exploratory') + return ( + + {/* 跟随只对还在跑的运行有意义;执行路径要等运行结束才定下来 */} + {isActivePhase(runPhase) && ( + + )} + {isTerminal(runPhase) && ( + + )} + + )} + /> + ) +} + export function FlowCanvas() { return ( - +
+ + +
) } diff --git a/frontend/src/canvas/FlowEdge.tsx b/frontend/src/canvas/FlowEdge.tsx index d6ef8127..5eaf8785 100644 --- a/frontend/src/canvas/FlowEdge.tsx +++ b/frontend/src/canvas/FlowEdge.tsx @@ -1,10 +1,43 @@ -import { createContext, useContext } from 'react' +import { createContext, useContext, type CSSProperties } from 'react' import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from '@xyflow/react' import type { Route } from './routing' /** 全画布的走线方案,由 FlowCanvas 算一次、所有边共用。 */ export const RoutingContext = createContext | null>(null) +/** + * 正在播的一次性时刻(成功回扫、失败级联)。只有边的那一半在这里画,卡片那一半 + * 归 NodeCard。seq 每次 +1:同一种时刻连着来两次,也要重新播一遍。 + */ +export const MomentContext = createContext<{ kind: 'start' | 'success' | 'failed'; seq: number } | null>(null) + +/** + * 一条边在这次运行里处在什么状态。四种运行态各自一种画法,互不混淆: + * - flow:目标此刻正在执行,accent 实线 + 一个光点(方向感) + * - walked:走过了,accent 实线,不再动 + * - held:目标停在审批上,琥珀静止线 + 目标端口前一道闸门,不画光点 + * - cut:上游失败、这条再也走不到了,红色虚线 + * - unwalked:运行结束时没走的那一侧,虚线淡出 + * idle 是编辑态,没有运行痕迹。 + */ +export type EdgeRunState = 'idle' | 'flow' | 'walked' | 'held' | 'cut' | 'unwalked' + +export interface EdgeView { + state: EdgeRunState + /** 回边(循环体连回循环节点):虚线 + ↺,和前向边一眼分开 */ + back: boolean + /** 目标节点的拓扑层级,一次性时刻按它错开 */ + rank: number + /** 回边已经兜回去几次(循环第几轮)。0 不显示 */ + loops?: number +} + +/** 路径最后一个点。走线和兜底的 smoothstep 都以「L x y」收尾 */ +function endPoint(path: string): { x: number; y: number } | null { + const m = /(-?[\d.]+)[\s,]+(-?[\d.]+)\s*$/.exec(path) + return m ? { x: Number(m[1]), y: Number(m[2]) } : null +} + /** * 画布上的边。 * @@ -14,61 +47,74 @@ export const RoutingContext = createContext | null>(null) * * 拿不到路径时(还没量到节点尺寸、边指向了不存在的节点)退回自带的 * smoothstep——宁可难看一帧,也不要线消失。 + * + * 每条边都带箭头:前向边的方向靠"源在右、目标在左"的约定还能猜,回边猜不出。 + * 走线最后一段总是从左边水平进入目标端口,箭头朝右画在端口外沿。 */ export function FlowEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, - label, labelStyle, markerEnd, markerStart, interactionWidth, style, animated, + label, labelStyle, markerEnd, markerStart, interactionWidth, style, data, }: EdgeProps) { const routes = useContext(RoutingContext) + const moment = useContext(MomentContext) const route = routes?.get(id) + const view = (data as { sf?: EdgeView } | undefined)?.sf const [fallback] = getSmoothStepPath({ sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, }) const path = route?.path || fallback + const end = (route && endPoint(route.path)) ?? { x: targetX, y: targetY } + // 端口是 9px 的圆,路径停在圆心:箭头尖顶在圆的外沿 + const tip = route ? end.x - 4.5 : end.x + const state = view?.state ?? 'idle' + const rank = view?.rank ?? 0 // 光点按路径长度定速度:长边上的光点走得慢会像卡住了,所以让**线速度** // 恒定,而不是让每条边都用同一个周期 const length = route?.length || 400 const seconds = Math.min(3, Math.max(0.8, length / 320)) + const motion: CSSProperties = { offsetPath: `path("${path}")` } + + const loopBadge = view?.back && !(label != null && label !== '') + const labelX = route?.labelX ?? sourceX + const labelY = route?.labelY ?? sourceY return ( <> - {/* 数据在流动:光点顺着线走。虚线只会让人觉得"在动",光点能看出往哪走。 - 只在真正活跃的边上出现——每条边都跑光点的话,画布会变成一片流星雨 */} - {animated && ( - <> - - - + + {/* 等人:目标端口前一道闸门。流程停在这里,不是在流 */} + {state === 'held' && ( + )} - {label != null && label !== '' && ( + {/* 数据在流动:一个光点顺着线走,看得出往哪走。只在流入正在执行的节点的 + 边上出现——每条边都跑光点的话,画布会变成一片流星雨 */} + {state === 'flow' && ( + + )} + {/* 成功回扫:走过的路径从入口往下依次亮一遍,只播这一次 */} + {moment?.kind === 'success' && state === 'walked' && ( + + )} + {(loopBadge || (label != null && label !== '')) && (
- {label} + {loopBadge ? <>↺{view?.loops ? ` ×${view.loops}` : ''} : label}
)} diff --git a/frontend/src/canvas/Inspector.tsx b/frontend/src/canvas/Inspector.tsx index 3755e5be..68ab5b33 100644 --- a/frontend/src/canvas/Inspector.tsx +++ b/frontend/src/canvas/Inspector.tsx @@ -1,42 +1,77 @@ -import { useMemo, useState } from 'react' -import { ChevronLeft, Copy, Plus, Settings2, Trash2, X } from 'lucide-react' +import { useEffect, useId, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' +import { AlertTriangle, ChevronLeft, Copy, Maximize2, Plus, Trash2, X, XCircle } from 'lucide-react' import clsx from 'clsx' -import { NODE_DEFS, type FieldDef } from './nodeDefs' +import { NODE_DEFS, syntaxOf, type FieldDef, type FieldSyntax } from './nodeDefs' +import { fieldOfIssue, type FieldRef } from './issues' +import { hintOf } from './shortcuts' import { useStudio } from '../store/studio' import { useCatalog, modelOptions } from '../store/catalog' -import { JsonInput } from '../components/ui' +import { api } from '../api/client' +import { IconButton, JsonInput, Modal, isComposing } from '../components/ui' +import { formatShortcut } from '../lib/keys' import { TemplateText } from './TemplateText' +import type { ValidationIssue, WorkflowVersion } from '../types' + +/** 检查器里一条落到字段上的问题 */ +type FieldIssue = ValidationIssue & { at: FieldRef | null } + +/** + * 运行默认值(设置 · 运行默认值)。检查器里「留空 = 跟随默认」的字段要把实际会用 + * 哪个写出来,否则「留空」是个黑箱。整个会话取一次就够:这两项很少改 + */ +let runDefaults: Promise<{ default_collection?: string; default_memory_scope?: string }> | null = null +function useRunDefaults() { + const [value, setValue] = useState<{ default_collection?: string; default_memory_scope?: string }>({}) + useEffect(() => { + runDefaults ??= api.settings.get().then((s) => s?.run ?? {}).catch(() => { + runDefaults = null // 这次没取到,下次打开再试 + return {} + }) + let alive = true + void runDefaults.then((v) => { if (alive) setValue(v) }) + return () => { alive = false } + }, []) + return value +} /** 属性面板。所有字段都由 nodeDefs 的声明驱动渲染,加节点类型不用改这里。 */ export function Inspector() { const selectedId = useStudio((s) => s.selectedId) const node = useStudio((s) => s.nodes.find((n) => n.id === s.selectedId)) const allIssues = useStudio((s) => s.issues) - const issues = useMemo( - () => allIssues.filter((i) => i.node_id === selectedId), - [allIssues, selectedId], - ) + const locked = useStudio((s) => s.copilot.active) const runtime = useStudio((s) => (selectedId ? s.runtime[selectedId] : undefined)) - const { updateNode, removeNode, duplicateNode, select } = useStudio() + // 动作逐个取:不带选择器的 useStudio() 订阅整个 store,运行时每来一个 token 这块面板都要重渲染 + const updateNode = useStudio((s) => s.updateNode) + const removeNode = useStudio((s) => s.removeNode) + const duplicateNode = useStudio((s) => s.duplicateNode) + const select = useStudio((s) => s.select) const [showAdvanced, setShowAdvanced] = useState(false) - if (!node) { - return ( -
- -
选中一个节点来编辑它
-
- ) - } + // 这张面板是盖在助手栏上的一层:节点没了(撤销、被删、换了图)就该自己让开, + // 以前会剩一块只写着「选中一个节点来编辑它」的空白层盖住助手,还没有返回按钮 + const gone = !!selectedId && !node + useEffect(() => { if (gone) select(null) }, [gone, select]) + + const issues = useMemo( + () => (node ? allIssues.filter((i) => i.node_id === node.id).map((i) => ({ ...i, at: fieldOfIssue(i, node) })) : []), + [allIssues, node], + ) + + if (!node) return null const def = NODE_DEFS[node.data.nodeType] if (!def) { // 这个界面版本不认识的类型:给出实情和唯一能做的事,而不是读 def.fields 崩掉整页 return ( -
-
不认识的节点类型「{node.data.nodeType}」
-
这个版本的界面编辑不了它,运行时后端也会拒绝这张图。
- +
+
select(null)} /> +
+
不认识的节点类型「{node.data.nodeType}」
+
这个版本的界面编辑不了它,运行时后端也会拒绝这张工作流。
+ +
) } @@ -45,81 +80,69 @@ export function Inspector() { updateNode(node.id, { config: { ...config, [key]: value } }) const visible = def.fields.filter((f) => !f.when || f.when(config)) + const keys = new Set(visible.map((f) => f.key)) const basic = visible.filter((f) => !f.advanced) const advanced = visible.filter((f) => f.advanced) + const issuesOf = (key: string) => issues.filter((i) => i.at?.key === key) + // 落不到任何一个看得见的字段上的,放在面板顶上;落在高级选项里的,高级选项自动展开 + const loose = issues.filter((i) => !i.at || !keys.has(i.at.key)) + const advancedHit = advanced.some((f) => issuesOf(f.key).length) + const advancedOpen = showAdvanced || advancedHit return (
-
- {/* 这块是盖在助手栏上的一层。不给一个明确的"回去",用户就不知道 - 底下还有东西——只看到一个 ×,会以为关掉之后什么都没有了 */} - - - - {def.label} - - -
+
select(null)}> + + {def.label} + duplicateNode(node.id)} icon={} /> + removeNode(node.id)} icon={} /> +
-
-
{def.description}
+ {/* 助手改图期间整块只读:这时候改的东西会被它的最终结果悄悄覆盖 */} +
+
{def.description}
- {!!issues.length && ( + {!!loose.length && (
- {issues.map((issue, i) => ( -
- {issue.message} -
- ))} + {loose.map((issue, i) => )}
)}
- + updateNode(node.id, { label: e.target.value })} placeholder={def.label} /> -
- ID: {node.id} · 下游用 {`{{ nodes.${node.id}.text }}`} 引用 +
+ ID {node.id} · 下游用 {`{{ nodes.${node.id}.text }}`} 引用
{basic.map((field) => ( - setConfig(field.key, v)} /> + setConfig(field.key, v)} /> ))} {!!advanced.length && (
- {showAdvanced && + {advancedOpen && advanced.map((field) => ( - setConfig(field.key, v)} /> + setConfig(field.key, v)} /> ))}
)} @@ -127,82 +150,193 @@ export function Inspector() { {runtime?.preview != null && (
上次运行输出
-
+            
               {typeof runtime.preview === 'string'
                 ? runtime.preview
                 : JSON.stringify(runtime.preview, null, 2)}
             
)} -
+
+
+ ) +} + +function Header({ onBack, children }: { onBack: () => void; children?: React.ReactNode }) { + return ( +
+ {/* 这块是盖在助手栏上的一层。不给一个明确的"回去",用户就不知道 + 底下还有东西——只看到一个 ×,会以为关掉之后什么都没有了 */} + + + {children} +
+ ) +} + +/** 一条问题。字段下面用行内版,面板顶部用带框的版本 */ +function IssueLine({ issue, boxed }: { issue: Pick; boxed?: boolean }) { + const err = issue.level === 'error' + const Icon = err ? XCircle : AlertTriangle + return ( +
+ + {issue.message}
) } // ------------------------------------------------------------------------- -function Field({ field, value, onChange }: { - field: FieldDef; value: any; onChange: (v: any) => void -}) { +/** + * 模板 / 表达式的标记。两种字段错法相反,以前外观一模一样,只能靠 placeholder 暗示; + * 后端专门有一条「{{ }} 是多余的——这里是表达式」的告警,说明这种错很常见 + */ +function SyntaxBadge({ syntax }: { syntax: FieldSyntax }) { + if (syntax === 'plain') return null + const template = syntax === 'template' return ( -
- {field.type !== 'switch' && } - + + {template ? '{{ }}' : 'ƒx'} + {template ? '模板' : '表达式'} + + ) +} + +function Field({ field, nodeId, value, config, issues, onChange }: { + field: FieldDef; nodeId: string; value: any; config: Record + issues: FieldIssue[]; onChange: (v: any) => void +}) { + const id = `f-${nodeId}-${field.key}` + const syntax = syntaxOf(field) + const composite = field.type === 'cases' || field.type === 'agents' || field.type === 'ioFields' + || field.type === 'metricsList' + // 复合字段自己把问题落到第几项;落不到具体某一项的,和普通字段一样挂在下面 + const own = composite ? issues.filter((i) => i.at?.index == null) : issues + const bad = own.some((i) => i.level === 'error') + const errorId = own.length ? `${id}-issues` : undefined + // 长文本在 360px 宽的栏里没法写:提示词、代码可以展开到大编辑器里(同一份值,边写边存) + const expandable = (field.type === 'prompt' || field.type === 'code' || field.type === 'textarea') && syntax !== 'plain' + const [wide, setWide] = useState(false) + return ( +
+ {field.type !== 'switch' && ( +
+ + + {expandable && ( + setWide(true)} icon={} /> + )} +
+ )} + {wide && createPortal( + setWide(false)} title={field.label} width={880} + footer={}> + +
改动直接写进画布,{formatShortcut('Mod+Z')} 可以撤回;打 {'{{'} 弹出这一步能取到的变量
+
, + document.body, + )} + {field.help && field.type !== 'switch' && ( -
{field.help}
+
{field.help}
+ )} + {!!own.length && ( +
+ {own.map((i, k) => )} +
)}
) } -function FieldInput({ field, value, onChange }: { - field: FieldDef; value: any; onChange: (v: any) => void +function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describedBy, issues, onChange }: { + field: FieldDef; id: string; nodeId: string; syntax: FieldSyntax; value: any + config: Record; invalid: boolean; describedBy?: string + issues: FieldIssue[]; onChange: (v: any) => void }) { const catalog = useCatalog() + const defaults = useRunDefaults() + const aria = { 'aria-invalid': invalid || undefined, 'aria-describedby': describedBy } switch (field.type) { case 'text': + if (syntax !== 'plain') { + return ( + + ) + } return ( - onChange(e.target.value)} /> ) case 'number': return ( onChange(e.target.value === '' ? undefined : Number(e.target.value))} /> ) - // 这三种默认都过模板渲染,所以带 {{ }} 补全。 - // 注意 field.template === false 是显式关闭——supervisor 的 agents[].system - // 长得和 llm.system 一模一样,但后端是 cfg.get("system") 直接取值,不渲染, - // 在那儿弹补全会是个谎 + // 这三种默认都过模板渲染,所以带 {{ }} 补全。syntax 显式写成 plain 的是长得像 + // 模板、后端却不渲染的字段——在那儿弹补全会是个谎 case 'textarea': case 'prompt': - return ( - - ) - case 'code': + if (syntax === 'plain') { + return