mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 22:51:58 +00:00
e10c4f23fc
来自 2026-09 的 UI/UX 审查(142 条)。本提交是前两波,第三波(记录页接入时间轴、跨页互链、check-ui 更新、全站扫尾)另行提交。 地基 - 设计令牌:全局规则和 .btn/.field/.chip 放进 CSS 层,Tailwind 工具类重新生效,选中态、开启态不再被吞掉;字号阶梯; 辅助文字在所有底色上 >= 4.5:1;状态色专用,节点类型色与之拉开色相;「跟随系统」真的跟随,首帧不闪深色;新字标与 favicon - 基础组件:confirmDialog / promptDialog 取代原生对话框;toast 出错不自动消失、可复制、读屏可播报;离线横幅与断线恢复; 九种剪影的状态徽标;唯一一份状态文案(lib/status)、格式化(lib/format)、术语(lib/terms)、报错人话(lib/errors) - 运行态内核:事件折成航迹(run/trace.ts),可投影到任意时刻;取消 / 失败 / 服务重启后统一收尾,不再永远转圈; run.status 随事件回写;token 事件不再堆进 events - 后端:协作成员逐个完成即发事件,调度者决策有事件;时长分执行 / 墙钟 / 等人;API 时间带时区; 中断的运行也发 stream.end;运行列表可筛选翻页;删除运行 / 工作流有保护;运行默认值接通;可测未保存的配置; 报错说清发生了什么、为什么、怎么办 功能 - 画布:状态四重编码、固定高度遥测槽、按缩放三档、运行胶囊与 HUD、航迹时间轴与回放、执行轨迹边、 等待闸门、跟随执行、正式运行只读、一次性时刻(开场点亮、回扫、阻断级联、出具印章) - 编辑:撤销重做、版本历史、问题面板可定位、三栏可收起、模板与表达式字段区分、变量血缘、快捷键、带搜索的选择器 - 回答流 / 问数据:贴底才跟随滚动、工件下钻、出具横幅列出缺口、答案可复制导出、按会话的忙闲、 答案不再在 2000 字处截断、重启后这一轮会收尾 - 记录页:待审批 / 运行中 / 失败页签、筛选翻页、实时接流、排错路径指到节点、封存凭证常驻 - 管理页:新的「数据」页、写操作失败都有反馈、弹窗里先测连接、统一持久的连接状态 - 外壳:导航改版、⌘K 命令面板、? 快捷键帮助、404、页面标题与 favicon 状态点、后台提醒 验证(沙箱):tsc 干净,pytest 579 passed,14 个检查脚本中 13 个全绿;check-ui 剩 1 项旧断言(按旧日期格式找运行行),第三波更新。
182 lines
6.2 KiB
Python
182 lines
6.2 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.tools.net import UnsafeUrlError, safe_request
|
|
from app.tools.registry import ToolContext, register
|
|
|
|
# --------------------------------------------------------------------------
|
|
# HTTP 请求
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
class HttpRequestArgs(BaseModel):
|
|
url: str = Field(description="完整的 http/https URL")
|
|
method: Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"] = "GET"
|
|
headers: dict[str, str] | None = Field(default=None, description="额外请求头")
|
|
params: dict[str, str] | None = Field(default=None, description="查询参数")
|
|
json_body: dict[str, Any] | None = Field(default=None, description="JSON 请求体")
|
|
|
|
|
|
@register(
|
|
name="http_request",
|
|
category="网络",
|
|
description="发起 HTTP 请求并返回状态码与响应体。内网地址会被拒绝。",
|
|
args_schema=HttpRequestArgs,
|
|
dangerous=True,
|
|
)
|
|
async def http_request(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
|
|
args = HttpRequestArgs(**kwargs)
|
|
try:
|
|
result = await safe_request(
|
|
args.method,
|
|
args.url,
|
|
headers=args.headers,
|
|
params=args.params,
|
|
json_body=args.json_body,
|
|
)
|
|
except UnsafeUrlError as e:
|
|
return {"error": str(e)}
|
|
body = result["body"]
|
|
if isinstance(body, str) and len(body) > 20_000:
|
|
result["body"] = body[:20_000] + "\n…(已截断)"
|
|
return result
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 抓网页
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
class WebFetchArgs(BaseModel):
|
|
url: str = Field(description="要抓取的网页地址")
|
|
max_chars: int = Field(default=8000, description="返回正文的最大字符数")
|
|
|
|
|
|
def _html_to_text(html: str) -> tuple[str, str]:
|
|
"""抽正文。去掉脚本样式导航,保留标题和段落结构。"""
|
|
from bs4 import BeautifulSoup
|
|
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
for tag in soup(["script", "style", "noscript", "iframe", "svg", "nav", "footer", "header"]):
|
|
tag.decompose()
|
|
title = (soup.title.string or "").strip() if soup.title else ""
|
|
main = soup.find("article") or soup.find("main") or soup.body or soup
|
|
lines: list[str] = []
|
|
for el in main.find_all(["h1", "h2", "h3", "h4", "li", "p", "pre", "blockquote"]):
|
|
text = " ".join(el.get_text(" ", strip=True).split())
|
|
if not text:
|
|
continue
|
|
if el.name.startswith("h"):
|
|
lines.append(f"\n{'#' * int(el.name[1])} {text}")
|
|
elif el.name == "li":
|
|
lines.append(f"- {text}")
|
|
else:
|
|
lines.append(text)
|
|
body = "\n".join(lines)
|
|
if not body.strip():
|
|
body = " ".join(main.get_text(" ", strip=True).split())
|
|
return title, body
|
|
|
|
|
|
@register(
|
|
name="web_fetch",
|
|
category="网络",
|
|
description="抓取网页并转换成干净的正文文本,适合让模型阅读。",
|
|
args_schema=WebFetchArgs,
|
|
)
|
|
async def web_fetch(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
|
|
args = WebFetchArgs(**kwargs)
|
|
try:
|
|
result = await safe_request("GET", args.url, headers={"User-Agent": "AgentLab/0.1"})
|
|
except UnsafeUrlError as e:
|
|
return {"error": str(e)}
|
|
if result["status"] >= 400:
|
|
return {"error": f"HTTP {result['status']}", "url": result["url"]}
|
|
title, text = _html_to_text(str(result["body"]))
|
|
truncated = len(text) > args.max_chars
|
|
return {
|
|
"url": result["url"],
|
|
"title": title,
|
|
"content": text[: args.max_chars] + ("\n…(已截断)" if truncated else ""),
|
|
"truncated": truncated,
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 搜索
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
class WebSearchArgs(BaseModel):
|
|
query: str = Field(description="搜索关键词")
|
|
max_results: int = Field(default=5, ge=1, le=20)
|
|
|
|
|
|
async def _tavily(query: str, n: int, key: str) -> list[dict[str, str]]:
|
|
result = await safe_request(
|
|
"POST",
|
|
"https://api.tavily.com/search",
|
|
json_body={"api_key": key, "query": query, "max_results": n},
|
|
)
|
|
data = json.loads(str(result["body"]))
|
|
return [
|
|
{"title": r.get("title", ""), "url": r.get("url", ""), "snippet": r.get("content", "")}
|
|
for r in data.get("results", [])
|
|
]
|
|
|
|
|
|
async def _duckduckgo(query: str, n: int) -> list[dict[str, str]]:
|
|
"""没有搜索 API key 时的兜底:解析 DuckDuckGo 的 lite 页面。"""
|
|
from bs4 import BeautifulSoup
|
|
|
|
result = await safe_request(
|
|
"POST",
|
|
"https://lite.duckduckgo.com/lite/",
|
|
data=f"q={query}",
|
|
headers={
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"User-Agent": "Mozilla/5.0 (compatible; AgentLab/0.1)",
|
|
},
|
|
)
|
|
soup = BeautifulSoup(str(result["body"]), "html.parser")
|
|
out: list[dict[str, str]] = []
|
|
for row in soup.select("a.result-link"):
|
|
out.append(
|
|
{
|
|
"title": row.get_text(" ", strip=True),
|
|
"url": row.get("href", ""),
|
|
"snippet": "",
|
|
}
|
|
)
|
|
if len(out) >= n:
|
|
break
|
|
return out
|
|
|
|
|
|
@register(
|
|
name="web_search",
|
|
category="网络",
|
|
description="搜索互联网。配了 TAVILY_API_KEY 就走 Tavily,否则退回 DuckDuckGo。",
|
|
args_schema=WebSearchArgs,
|
|
)
|
|
async def web_search(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
|
|
args = WebSearchArgs(**kwargs)
|
|
key = os.environ.get("TAVILY_API_KEY")
|
|
try:
|
|
if key:
|
|
results = await _tavily(args.query, args.max_results, key)
|
|
engine = "tavily"
|
|
else:
|
|
results = await _duckduckgo(args.query, args.max_results)
|
|
engine = "duckduckgo"
|
|
except Exception as e: # noqa: BLE001 - 搜索失败不该让整个 run 挂掉
|
|
from app.api.errors import explain
|
|
|
|
return {"error": f"搜索失败:{explain(e)[0]}", "results": []}
|
|
return {"engine": engine, "query": args.query, "results": results}
|