mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 21:21:59 +00:00
e10c4f23fc
来自 2026-09 的 UI/UX 审查(142 条)。本提交是前两波,第三波(记录页接入时间轴、跨页互链、check-ui 更新、全站扫尾)另行提交。 地基 - 设计令牌:全局规则和 .btn/.field/.chip 放进 CSS 层,Tailwind 工具类重新生效,选中态、开启态不再被吞掉;字号阶梯; 辅助文字在所有底色上 >= 4.5:1;状态色专用,节点类型色与之拉开色相;「跟随系统」真的跟随,首帧不闪深色;新字标与 favicon - 基础组件:confirmDialog / promptDialog 取代原生对话框;toast 出错不自动消失、可复制、读屏可播报;离线横幅与断线恢复; 九种剪影的状态徽标;唯一一份状态文案(lib/status)、格式化(lib/format)、术语(lib/terms)、报错人话(lib/errors) - 运行态内核:事件折成航迹(run/trace.ts),可投影到任意时刻;取消 / 失败 / 服务重启后统一收尾,不再永远转圈; run.status 随事件回写;token 事件不再堆进 events - 后端:协作成员逐个完成即发事件,调度者决策有事件;时长分执行 / 墙钟 / 等人;API 时间带时区; 中断的运行也发 stream.end;运行列表可筛选翻页;删除运行 / 工作流有保护;运行默认值接通;可测未保存的配置; 报错说清发生了什么、为什么、怎么办 功能 - 画布:状态四重编码、固定高度遥测槽、按缩放三档、运行胶囊与 HUD、航迹时间轴与回放、执行轨迹边、 等待闸门、跟随执行、正式运行只读、一次性时刻(开场点亮、回扫、阻断级联、出具印章) - 编辑:撤销重做、版本历史、问题面板可定位、三栏可收起、模板与表达式字段区分、变量血缘、快捷键、带搜索的选择器 - 回答流 / 问数据:贴底才跟随滚动、工件下钻、出具横幅列出缺口、答案可复制导出、按会话的忙闲、 答案不再在 2000 字处截断、重启后这一轮会收尾 - 记录页:待审批 / 运行中 / 失败页签、筛选翻页、实时接流、排错路径指到节点、封存凭证常驻 - 管理页:新的「数据」页、写操作失败都有反馈、弹窗里先测连接、统一持久的连接状态 - 外壳:导航改版、⌘K 命令面板、? 快捷键帮助、404、页面标题与 favicon 状态点、后台提醒 验证(沙箱):tsc 干净,pytest 579 passed,14 个检查脚本中 13 个全绿;check-ui 剩 1 项旧断言(按旧日期格式找运行行),第三波更新。
224 lines
9.8 KiB
Python
224 lines
9.8 KiB
Python
"""让助手改图时,用户摆好的布局不能被整张重排。
|
|
|
|
以前 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)
|