toolhub.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763
  1. """
  2. ToolHub - 远程工具库集成模块
  3. 将 http://43.106.118.91:8001 的工具库 API 包装为 Agent 可调用的工具。
  4. 提供三个工具:
  5. 1. toolhub_health - 健康检查
  6. 2. toolhub_search - 搜索/发现远程工具(GET /tools)
  7. 3. toolhub_call - 调用远程工具(POST /run_tool)
  8. 图片参数统一使用本地文件路径:
  9. - 输入:params 中的 image/image_url 等参数直接传本地路径,内部自动上传
  10. - 输出:生成的图片自动保存到 outputs/ 目录,返回本地路径
  11. 实际 API 端点(通过 /openapi.json 确认):
  12. GET /health → 健康检查
  13. GET /tools → 列出所有工具(含分组、参数 schema)
  14. POST /run_tool → 调用工具 {"tool_id": str, "params": dict}
  15. POST /chat → 对话接口(不在此封装)
  16. CLI 用法:
  17. python -m agent.tools.builtin.toolhub health
  18. python -m agent.tools.builtin.toolhub search --keyword=image
  19. python -m agent.tools.builtin.toolhub call --tool_id=flux_gen --params='{"prompt":"a cat"}'
  20. """
  21. import base64
  22. import contextvars
  23. import json
  24. import logging
  25. import mimetypes
  26. import os
  27. import time
  28. from pathlib import Path
  29. from typing import Any, Dict, List, Optional
  30. import httpx
  31. # ── agent.tools 解耦 shim ────────────────────────────
  32. # 原脚本依赖 `from agent.tools import tool, ToolResult`。为了让本 skill 不再
  33. # 强依赖 cyber-agent editable install,这里 inline 两个等价替身:
  34. # - `tool(...)` 装饰器:agent runtime 用来把函数注册进全局 registry。
  35. # CLI 场景下直接调用函数、不经过 registry,所以这里是 **纯空转**,
  36. # 保留 kwargs 仅为了接住原来的 display/groups 等参数不报错。
  37. # - `ToolResult`:agent runtime 的统一返回类型。这里复刻必要字段,
  38. # 只给 CLI 的 `__main__` 段消费(访问 .output / .error / .metadata)。
  39. from dataclasses import dataclass, field as _field
  40. from typing import Callable, TypeVar as _TypeVar
  41. _F = _TypeVar("_F", bound=Callable)
  42. def tool(**_kwargs) -> Callable[[_F], _F]: # noqa: D401 — 签名与原装饰器保持兼容
  43. """空转装饰器(CLI 模式)。原 agent runtime 里负责向 registry 注册,此处不需要。"""
  44. def decorator(func: _F) -> _F:
  45. return func
  46. return decorator
  47. @dataclass
  48. class ToolResult:
  49. """简化版 ToolResult。仅保留本文件实际用到的字段,行为与 agent.tools.models.ToolResult 兼容。"""
  50. title: str
  51. output: str
  52. long_term_memory: Optional[str] = None
  53. metadata: Dict[str, Any] = _field(default_factory=dict)
  54. error: Optional[str] = None
  55. attachments: List[str] = _field(default_factory=list)
  56. images: List[Dict[str, Any]] = _field(default_factory=list)
  57. logger = logging.getLogger(__name__)
  58. # ── 配置 ─────────────────────────────────────────────
  59. TOOLHUB_BASE_URL = "http://43.106.118.91:8001"
  60. DEFAULT_TIMEOUT = 30.0
  61. CALL_TIMEOUT = 600.0 # 图像生成类工具耗时较长,云端机器启动可能需要数分钟
  62. # OSS 上传配置
  63. OSS_BUCKET_NAME = "aigc-admin"
  64. OSS_BUCKET_PATH = "toolhub_images"
  65. # 输出目录(锚在调用方 CWD 的 .cache/toolhub_outputs/,每个项目独立;目录不存在会自动创建)
  66. # 与 agent skill 的 trace 路径风格一致,方便 gitignore:`.cache/` 加一行搞定
  67. OUTPUT_BASE_DIR = Path(os.getcwd()) / ".cache" / "toolhub_outputs"
  68. # trace_id 上下文变量,由 runner 在执行工具前设置
  69. _trace_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("toolhub_trace_id", default="")
  70. def set_trace_context(trace_id: str):
  71. """由 runner 调用,设置当前 trace_id 供图片保存使用"""
  72. _trace_id_var.set(trace_id)
  73. def _get_output_dir(tool_id: str) -> Path:
  74. """获取图片输出目录:outputs/{trace_id}/,无 trace_id 时用时间戳"""
  75. trace_id = _trace_id_var.get("")
  76. if trace_id:
  77. # trace_id 可能含 @ 等特殊字符,取前段作为目录名
  78. safe_id = trace_id.split("@")[0][:12] if "@" in trace_id else trace_id[:12]
  79. out_dir = OUTPUT_BASE_DIR / safe_id
  80. else:
  81. out_dir = OUTPUT_BASE_DIR / f"no_trace_{int(time.time())}"
  82. out_dir.mkdir(parents=True, exist_ok=True)
  83. return out_dir
  84. # ── 图片处理辅助 ─────────────────────────────────────
  85. async def _upload_to_oss(local_path: str) -> Optional[str]:
  86. """上传本地文件到 OSS,返回 CDN URL"""
  87. try:
  88. from cyber_sdk.ali_oss import upload_localfile
  89. import os
  90. safe_path = os.path.abspath(local_path).replace("\\", "/")
  91. result = await upload_localfile(
  92. file_path=safe_path,
  93. bucket_path=OSS_BUCKET_PATH,
  94. bucket_name=OSS_BUCKET_NAME,
  95. )
  96. oss_key = result.get("oss_object_key")
  97. if oss_key:
  98. cdn_url = f"https://res.cybertogether.net/{oss_key}"
  99. logger.info(f"[ToolHub] 图片已上传 OSS: {cdn_url}")
  100. return cdn_url
  101. except Exception as e:
  102. logger.warning(f"[ToolHub] OSS 上传失败: {e}")
  103. return None
  104. async def _process_images(raw_images: List[str], tool_id: str) -> tuple:
  105. """
  106. 统一处理工具返回的图片列表。
  107. 对每张图片:下载(如需) → 保存本地 → 上传 OSS 拿 CDN URL → base64 供 LLM 查看
  108. 三种形态并存是为了覆盖不同消费者:
  109. - saved_paths: 用户本地归档、后续工具链引用
  110. - cdn_urls: 永久引用,**上下文压缩后 LLM 若要重新审视,可通过 URL 二次加载**;
  111. 也让"A 工具输出 → B 工具输入"直接用 URL 串起来,不经过本地中转
  112. - images_for_llm: 当前轮 LLM 多模态推理用(base64 直接嵌 payload)
  113. Returns:
  114. (images_for_llm, cdn_urls, saved_paths)
  115. """
  116. images_for_llm = []
  117. cdn_urls = []
  118. saved_paths = []
  119. out_dir = _get_output_dir(tool_id)
  120. for idx, img in enumerate(raw_images):
  121. if not isinstance(img, str) or len(img) <= 100:
  122. continue
  123. img_bytes = None
  124. media_type = "image/png"
  125. if img.startswith(("http://", "https://")):
  126. try:
  127. async with httpx.AsyncClient(timeout=60, trust_env=False) as dl:
  128. img_resp = await dl.get(img)
  129. img_resp.raise_for_status()
  130. ct = img_resp.headers.get("content-type", "image/png").split(";")[0].strip()
  131. if not ct.startswith("image/"):
  132. ct = mimetypes.guess_type(img.split("?")[0])[0] or "image/png"
  133. media_type = ct
  134. img_bytes = img_resp.content
  135. except Exception as e:
  136. logger.warning(f"[ToolHub] 图片下载失败: {e}")
  137. continue
  138. elif img.startswith("data:"):
  139. header, b64 = img.split(",", 1)
  140. media_type = header.split(";")[0].replace("data:", "")
  141. img_bytes = base64.b64decode(b64)
  142. else:
  143. # raw base64
  144. img_bytes = base64.b64decode(img)
  145. if not img_bytes:
  146. continue
  147. # 1. 保存本地(用时间戳区分多次调用)
  148. ts = int(time.time() * 1000)
  149. ext = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp"}.get(media_type, ".png")
  150. save_path = out_dir / f"{tool_id}_{ts}_{idx}{ext}"
  151. save_path.write_bytes(img_bytes)
  152. saved_paths.append(str(save_path))
  153. # 2. 上传 OSS 拿 CDN URL(best-effort —— cyber_sdk 未装则静默跳过)
  154. # 为什么输出也 CDN:上下文压缩会丢 base64,后续如果需要 LLM 重看这张图,URL 是可复访的手段;
  155. # 同时对称的 CDN 设计让"工具 A 输出 → 工具 B 输入"能直接用 URL 串接,不用本地中转
  156. cdn_url = await _upload_to_oss(str(save_path))
  157. if cdn_url:
  158. cdn_urls.append(cdn_url)
  159. # 3. base64 给当前轮 LLM 多模态查看
  160. b64_data = base64.b64encode(img_bytes).decode()
  161. images_for_llm.append({"type": "base64", "media_type": media_type, "data": b64_data})
  162. return images_for_llm, cdn_urls, saved_paths
  163. _SINGLE_IMAGE_PARAMS = ("image", "image_url", "mask_image", "pose_image", "reference_image")
  164. _ARRAY_IMAGE_PARAMS = ("images", "image_urls", "reference_images")
  165. async def _maybe_upload_local(val: str) -> Optional[str]:
  166. """如果 val 是存在的本地文件路径,上传 OSS 并返回 CDN URL;否则返回 None。"""
  167. if not isinstance(val, str):
  168. return None
  169. if val.startswith(("http://", "https://", "data:")):
  170. return None
  171. try:
  172. p = Path(val)
  173. if p.exists() and p.is_file():
  174. return await _upload_to_oss(str(p.resolve()))
  175. except Exception as e:
  176. logger.warning(f"[ToolHub] 本地路径处理失败 {val}: {e}")
  177. return None
  178. async def _preprocess_params(params: Dict[str, Any]) -> Dict[str, Any]:
  179. """
  180. 预处理工具参数:检测本地文件路径,自动上传到 OSS 并替换为 CDN URL。
  181. 支持的单值参数:image, image_url, mask_image, pose_image, reference_image
  182. 支持的数组参数:images, image_urls, reference_images
  183. 设计要点:远程工具服务的 cwd 和调用方不一样,相对路径在服务器上会找不到文件。
  184. 所以必须在客户端就把本地路径转成 CDN URL,不能期望服务器侧有 fallback。
  185. """
  186. if not params:
  187. return params
  188. processed = params.copy()
  189. # 单值图片参数
  190. for key in _SINGLE_IMAGE_PARAMS:
  191. if key in processed and isinstance(processed[key], str):
  192. val = processed[key]
  193. if val.startswith(("http://", "https://", "data:")):
  194. continue
  195. cdn_url = await _maybe_upload_local(val)
  196. if cdn_url:
  197. processed[key] = cdn_url
  198. logger.info(f"[ToolHub] {key} 本地路径已替换为 CDN: {cdn_url}")
  199. elif not os.path.isfile(val):
  200. # 既不是远程 URL 也不是已存在的本地文件,直接报错比让远程服务抛神秘的 base64 错误强
  201. logger.warning(f"[ToolHub] {key}={val!r} 既不是 URL 也不是存在的本地文件")
  202. # 数组型图片参数
  203. for array_key in _ARRAY_IMAGE_PARAMS:
  204. if array_key not in processed or not isinstance(processed[array_key], list):
  205. continue
  206. new_list = []
  207. for idx, item in enumerate(processed[array_key]):
  208. if not isinstance(item, str):
  209. new_list.append(item)
  210. continue
  211. if item.startswith(("http://", "https://", "data:")):
  212. new_list.append(item)
  213. continue
  214. cdn_url = await _maybe_upload_local(item)
  215. if cdn_url:
  216. new_list.append(cdn_url)
  217. logger.info(f"[ToolHub] {array_key}[{idx}] 本地路径已替换为 CDN: {cdn_url}")
  218. else:
  219. new_list.append(item)
  220. if not os.path.isfile(item):
  221. logger.warning(
  222. f"[ToolHub] {array_key}[{idx}]={item!r} 既不是 URL 也不是存在的本地文件"
  223. )
  224. processed[array_key] = new_list
  225. return processed
  226. # ── 工具实现 ──────────────────────────────────────────
  227. @tool(
  228. display={
  229. "zh": {"name": "ToolHub 健康检查", "params": {}},
  230. "en": {"name": "ToolHub Health Check", "params": {}},
  231. },
  232. groups=["toolhub"],
  233. )
  234. async def toolhub_health() -> ToolResult:
  235. """检查 ToolHub 远程工具库服务是否可用
  236. 检查 ToolHub 服务的健康状态,确认服务是否正常运行。
  237. 建议在调用其他 toolhub 工具之前先检查。
  238. Returns:
  239. ToolResult 包含服务健康状态信息
  240. """
  241. try:
  242. async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, trust_env=False) as client:
  243. resp = await client.get(f"{TOOLHUB_BASE_URL}/health")
  244. resp.raise_for_status()
  245. data = resp.json()
  246. return ToolResult(
  247. title="ToolHub 健康检查",
  248. output=json.dumps(data, ensure_ascii=False, indent=2),
  249. long_term_memory=f"ToolHub service at {TOOLHUB_BASE_URL} is healthy.",
  250. )
  251. except httpx.ConnectError:
  252. return ToolResult(
  253. title="ToolHub 健康检查",
  254. output="",
  255. error=f"无法连接到 ToolHub 服务 {TOOLHUB_BASE_URL},请确认服务已启动。",
  256. )
  257. except Exception as e:
  258. err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
  259. return ToolResult(
  260. title="ToolHub 健康检查",
  261. output="",
  262. error=err_msg,
  263. )
  264. @tool(
  265. display={
  266. "zh": {"name": "搜索 ToolHub 工具", "params": {"keyword": "搜索关键词"}},
  267. "en": {"name": "Search ToolHub", "params": {"keyword": "Search keyword"}},
  268. },
  269. groups=["toolhub"],
  270. )
  271. async def toolhub_search(keyword: Optional[str] = None) -> ToolResult:
  272. """搜索 ToolHub 远程工具库中可用的工具
  273. 从 ToolHub 工具库中获取可用工具列表,返回每个工具的完整信息,包括:
  274. tool_id、名称、分类、状态、参数列表(含类型、是否必填、默认值)、输出 schema、
  275. 分组信息(如 RunComfy 生命周期组)等。
  276. 调用 toolhub_call 之前,应先使用此工具了解目标工具的 tool_id 和所需参数。
  277. 不填 keyword 则返回所有工具。
  278. Args:
  279. keyword: 搜索关键词,用于过滤工具名称或描述(客户端过滤);为空则返回所有工具
  280. Returns:
  281. ToolResult 包含匹配的工具列表及其参数说明
  282. """
  283. try:
  284. async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, trust_env=False) as client:
  285. resp = await client.get(f"{TOOLHUB_BASE_URL}/tools")
  286. resp.raise_for_status()
  287. data = resp.json()
  288. tools = data.get("tools", [])
  289. groups = data.get("groups", [])
  290. # 客户端关键词过滤:三层匹配策略
  291. # 1) 原始子串匹配(最严格,但会被分隔符切断:nanobanana vs nano_banana)
  292. # 2) 归一化子串匹配(去掉 _ - 空格 .,解决分隔符问题)
  293. # 3) 分词交集匹配(keyword 拆成 token,任意 token 命中即保留,解决多词查询)
  294. if keyword:
  295. def _normalize(s: str) -> str:
  296. """去掉分隔符和空白,全小写"""
  297. return "".join(c for c in s.lower() if c.isalnum())
  298. def _tokenize(s: str) -> set:
  299. """按分隔符拆成 token 集合"""
  300. import re
  301. return {t for t in re.split(r"[\s_\-.,/]+", s.lower()) if t}
  302. kw_raw = keyword.lower()
  303. kw_norm = _normalize(keyword)
  304. kw_tokens = _tokenize(keyword)
  305. def _matches(t: dict) -> bool:
  306. fields = [
  307. t.get("name", ""),
  308. t.get("description", ""),
  309. t.get("tool_id", ""),
  310. t.get("category", ""),
  311. ]
  312. combined = " ".join(fields).lower()
  313. # 原始子串
  314. if kw_raw in combined:
  315. return True
  316. # 归一化子串(容忍分隔符差异)
  317. if kw_norm and kw_norm in _normalize(combined):
  318. return True
  319. # token 交集(多词关键词的 OR 匹配)
  320. if kw_tokens:
  321. field_tokens = _tokenize(combined)
  322. if kw_tokens & field_tokens:
  323. return True
  324. return False
  325. tools = [t for t in tools if _matches(t)]
  326. total = len(tools)
  327. # 构建给 LLM 的结构化摘要
  328. summaries = []
  329. for t in tools:
  330. input_props = t.get("input_schema", {}).get("properties", {})
  331. required_fields = t.get("input_schema", {}).get("required", [])
  332. params_desc = []
  333. for name, info in input_props.items():
  334. req = "必填" if name in required_fields else "可选"
  335. desc = info.get("description", "")
  336. default_str = f", 默认={info['default']}" if info.get("default") is not None else ""
  337. enum_str = f", 可选值={info['enum']}" if info.get("enum") else ""
  338. params_desc.append(
  339. f" - {name} ({info.get('type','any')}, {req}): {desc}{default_str}{enum_str}"
  340. )
  341. group_str = ""
  342. if t.get("group_ids"):
  343. group_str = f"\n 所属分组: {', '.join(t['group_ids'])}"
  344. tool_block = (
  345. f"[{t['tool_id']}] {t['name']}\n"
  346. f" 状态: {t['state']} | 运行时: {t['backend_runtime']} | 分类: {t.get('category','')}"
  347. f"{group_str}\n"
  348. f" 描述: {t.get('description', '')}"
  349. )
  350. if params_desc:
  351. tool_block += "\n 参数:\n" + "\n".join(params_desc)
  352. else:
  353. tool_block += "\n 参数: 无"
  354. summaries.append(tool_block)
  355. # 分组使用说明:仅显示搜出的工具实际所属的分组,避免噪音
  356. relevant_group_ids = set()
  357. for t in tools:
  358. for gid in t.get("group_ids", []) or []:
  359. relevant_group_ids.add(gid)
  360. group_summary = []
  361. for g in groups:
  362. if g["group_id"] not in relevant_group_ids:
  363. continue
  364. group_summary.append(
  365. f"[组: {g['group_id']}] {g['name']}\n"
  366. f" 调用顺序: {' → '.join(g.get('usage_order', []))}\n"
  367. f" 说明: {g.get('usage_example', '')}"
  368. )
  369. output_parts = [f"共找到 {total} 个工具({'关键词: ' + keyword if keyword else '全量'}):\n"]
  370. output_parts.append("\n\n".join(summaries))
  371. if group_summary:
  372. output_parts.append("\n\n=== 工具分组(有顺序依赖)===\n" + "\n\n".join(group_summary))
  373. return ToolResult(
  374. title=f"ToolHub 搜索{f': {keyword}' if keyword else '(全量)'}",
  375. output="\n".join(output_parts),
  376. long_term_memory=(
  377. f"ToolHub 共 {total} 个工具: "
  378. + ", ".join(t["tool_id"] for t in tools[:15])
  379. + ("..." if total > 15 else "")
  380. ),
  381. )
  382. except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.WriteTimeout, httpx.PoolTimeout) as e:
  383. return ToolResult(
  384. title="ToolHub /tools 超时",
  385. output="",
  386. error=f"ToolHub 的 /tools 接口在 {DEFAULT_TIMEOUT:.0f}s 内未响应({type(e).__name__})。"
  387. f"服务器可能在检测各工具状态导致列表慢,请稍后重试或联系维护者。",
  388. )
  389. except httpx.ConnectError as e:
  390. return ToolResult(
  391. title="ToolHub 连接失败",
  392. output="",
  393. error=f"无法连接到 ToolHub 服务 {TOOLHUB_BASE_URL}:{type(e).__name__}: {e}",
  394. )
  395. except Exception as e:
  396. # 注意 httpx 的部分异常 str(e) 是空的,必须带 type name
  397. err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
  398. return ToolResult(
  399. title="搜索 ToolHub 工具失败",
  400. output="",
  401. error=err_msg,
  402. )
  403. @tool(
  404. display={
  405. "zh": {
  406. "name": "调用 ToolHub 工具",
  407. "params": {"tool_id": "工具ID", "params": "工具参数"},
  408. },
  409. "en": {
  410. "name": "Call ToolHub Tool",
  411. "params": {"tool_id": "Tool ID", "params": "Tool parameters"},
  412. },
  413. },
  414. groups=["toolhub"],
  415. )
  416. async def toolhub_call(
  417. tool_id: str,
  418. params: Optional[Dict[str, Any]] = None,
  419. ) -> ToolResult:
  420. """调用 ToolHub 远程工具库中的指定工具
  421. 通过 tool_id 调用 ToolHub 工具库中的某个工具,传入该工具所需的参数。
  422. 不同工具的参数不同,请先用 toolhub_search 查询目标工具的参数说明。
  423. 图片参数(image、image_url、mask_image、pose_image、images)直接传本地文件路径即可,
  424. 系统会自动上传。生成的图片会自动保存到本地 outputs/ 目录,返回结果中的
  425. saved_files 字段包含本地文件路径。
  426. 注意:部分工具为异步生命周期(如 RunComfy、即梦、FLUX),需要按分组顺序
  427. 依次调用多个工具(如先 launch → 再 executor → 再 stop)。
  428. Args:
  429. tool_id: 要调用的工具 ID(从 toolhub_search 获取)
  430. params: 工具参数字典,键值对根据目标工具的参数定义决定。
  431. 图片参数可直接使用本地文件路径(如 "/path/to/image.png")。
  432. Returns:
  433. ToolResult 包含工具执行结果,图片结果通过 saved_files 返回本地路径
  434. """
  435. try:
  436. # 预处理参数:本地文件路径自动上传成 CDN URL
  437. params = await _preprocess_params(params or {})
  438. payload = {
  439. "tool_id": tool_id,
  440. "params": params,
  441. }
  442. async with httpx.AsyncClient(timeout=CALL_TIMEOUT, trust_env=False) as client:
  443. resp = await client.post(
  444. f"{TOOLHUB_BASE_URL}/run_tool", json=payload
  445. )
  446. resp.raise_for_status()
  447. data = resp.json()
  448. status = data.get("status")
  449. if status == "success":
  450. result = data.get("result", {})
  451. result_str = json.dumps(result, ensure_ascii=False, indent=2)
  452. # 提取图片并统一处理(下载 → 保存本地 → 上传 OSS → CDN URL)
  453. images = []
  454. if isinstance(result, dict):
  455. # 收集所有图片(单张 image 字段 + images 列表字段)
  456. raw_images = []
  457. has_single_image = False
  458. has_images_list = False
  459. if result.get("image") and isinstance(result["image"], str):
  460. raw_images.append(result["image"])
  461. has_single_image = True
  462. if result.get("images") and isinstance(result["images"], list):
  463. raw_images.extend(result["images"])
  464. has_images_list = True
  465. if raw_images:
  466. images, cdn_urls, saved_paths = await _process_images(raw_images, tool_id)
  467. # 构建文本输出(去掉原始图片数据,以本地路径为主)
  468. result_display = {k: v for k, v in result.items() if k not in ("image", "images")}
  469. result_display["image_count"] = len(images)
  470. if saved_paths:
  471. result_display["saved_files"] = saved_paths
  472. if cdn_urls:
  473. result_display["cdn_urls"] = cdn_urls
  474. result_str = json.dumps(result_display, ensure_ascii=False, indent=2)
  475. return ToolResult(
  476. title=f"ToolHub [{tool_id}] 执行成功",
  477. output=result_str,
  478. long_term_memory=f"Called ToolHub tool '{tool_id}' → success",
  479. images=images,
  480. )
  481. else:
  482. error_msg = data.get("error", "未知错误")
  483. return ToolResult(
  484. title=f"ToolHub [{tool_id}] 执行失败",
  485. output=json.dumps(data, ensure_ascii=False, indent=2),
  486. error=error_msg,
  487. )
  488. except httpx.TimeoutException as e:
  489. return ToolResult(
  490. title=f"ToolHub [{tool_id}] 调用超时",
  491. output="",
  492. error=f"调用工具 {tool_id} 超时({CALL_TIMEOUT:.0f}s,{type(e).__name__}),"
  493. f"图像生成类工具可能需要更长时间。",
  494. )
  495. except Exception as e:
  496. err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
  497. return ToolResult(
  498. title=f"ToolHub [{tool_id}] 调用失败",
  499. output="",
  500. error=err_msg,
  501. )
  502. # 注意:image_uploader 和 image_downloader 不再注册为 Agent 工具。
  503. # toolhub_call 已内置完整的图片管线(输入自动上传,输出自动下载保存),
  504. # 无需单独暴露上传/下载工具。以下函数保留供内部或 CLI 使用。
  505. async def image_uploader(local_path: str) -> ToolResult:
  506. """将本地图片上传到 OSS,返回可用的 CDN URL(内部工具,不注册给 Agent)"""
  507. import os
  508. from pathlib import Path
  509. p = Path(local_path)
  510. if not p.exists():
  511. return ToolResult(
  512. title="图片上传失败",
  513. output="",
  514. error=f"文件不存在: {local_path}",
  515. )
  516. if not p.is_file():
  517. return ToolResult(
  518. title="图片上传失败",
  519. output="",
  520. error=f"路径不是文件: {local_path}",
  521. )
  522. cdn_url = await _upload_to_oss(str(p.resolve()))
  523. if cdn_url:
  524. result = {
  525. "local_path": str(p.resolve()),
  526. "cdn_url": cdn_url,
  527. "file_size": os.path.getsize(p),
  528. }
  529. return ToolResult(
  530. title="图片上传成功",
  531. output=json.dumps(result, ensure_ascii=False, indent=2),
  532. long_term_memory=f"Uploaded {local_path} → {cdn_url}",
  533. )
  534. else:
  535. return ToolResult(
  536. title="图片上传失败",
  537. output="",
  538. error=f"OSS 上传失败,请检查文件路径和网络连接: {local_path}",
  539. )
  540. async def image_downloader(url: str, save_path: str = "") -> ToolResult:
  541. """下载网络图片到本地文件(内部工具,不注册给 Agent)"""
  542. import os
  543. from pathlib import Path
  544. from urllib.parse import urlparse, unquote
  545. if not url.startswith(("http://", "https://")):
  546. return ToolResult(
  547. title="图片下载失败",
  548. output="",
  549. error=f"无效的 URL(必须以 http:// 或 https:// 开头): {url}",
  550. )
  551. # 自动生成保存路径
  552. if not save_path:
  553. out_dir = _get_output_dir("download")
  554. # 从 URL 提取文件名
  555. url_path = urlparse(url).path
  556. filename = Path(unquote(url_path)).name if url_path else ""
  557. if not filename or not any(filename.lower().endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp")):
  558. filename = f"download_{int(time.time())}.png"
  559. save_path = str(out_dir / filename)
  560. # 确保目录存在
  561. p = Path(save_path)
  562. p.parent.mkdir(parents=True, exist_ok=True)
  563. try:
  564. async with httpx.AsyncClient(timeout=60.0, follow_redirects=True, trust_env=False) as client:
  565. resp = await client.get(url)
  566. resp.raise_for_status()
  567. p.write_bytes(resp.content)
  568. file_size = os.path.getsize(p)
  569. result = {
  570. "save_path": str(p.resolve()),
  571. "file_size": file_size,
  572. "source_url": url,
  573. }
  574. return ToolResult(
  575. title="图片下载成功",
  576. output=json.dumps(result, ensure_ascii=False, indent=2),
  577. long_term_memory=f"Downloaded {url} → {save_path}",
  578. )
  579. except httpx.HTTPStatusError as e:
  580. return ToolResult(
  581. title="图片下载失败",
  582. output="",
  583. error=f"HTTP 错误 {e.response.status_code}: {url}",
  584. )
  585. except Exception as e:
  586. return ToolResult(
  587. title="图片下载失败",
  588. output="",
  589. error=f"下载失败: {e}",
  590. )
  591. if __name__ == "__main__":
  592. import sys
  593. COMMANDS = {
  594. "health": toolhub_health,
  595. "search": toolhub_search,
  596. "call": toolhub_call,
  597. }
  598. def _parse_args(argv):
  599. kwargs = {}
  600. for arg in argv:
  601. if arg.startswith("--") and "=" in arg:
  602. k, v = arg.split("=", 1)
  603. k = k.lstrip("-").replace("-", "_")
  604. try:
  605. v = json.loads(v)
  606. except (json.JSONDecodeError, ValueError):
  607. pass
  608. kwargs[k] = v
  609. return kwargs
  610. if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
  611. print(f"用法: python {sys.argv[0]} <command> [--key=value ...]")
  612. print(f"可用命令: {', '.join(COMMANDS.keys())}")
  613. sys.exit(0)
  614. cmd = sys.argv[1]
  615. if cmd not in COMMANDS:
  616. print(f"未知命令: {cmd},可用: {', '.join(COMMANDS.keys())}")
  617. sys.exit(1)
  618. import asyncio
  619. import uuid
  620. import os
  621. kwargs = _parse_args(sys.argv[2:])
  622. # trace_id:CLI 参数 > 环境变量 > 自动生成(用于图片输出目录)
  623. trace_id = kwargs.pop("trace_id", None) or os.getenv("TRACE_ID") or f"cli-{uuid.uuid4().hex[:8]}"
  624. set_trace_context(trace_id)
  625. result = asyncio.run(COMMANDS[cmd](**kwargs))
  626. # 修复双重 JSON 编码:如果 output 已经是一段 JSON 字符串(toolhub_call 内部
  627. # 把 result dict 做过 json.dumps),解析回原生 dict 再嵌入 CLI 的最终 JSON,
  628. # 避免调用方拿到"output 字段是被字符串化的 JSON"这种反人类形式。
  629. output_value = result.output
  630. if isinstance(output_value, str):
  631. stripped = output_value.lstrip()
  632. if stripped.startswith("{") or stripped.startswith("["):
  633. try:
  634. output_value = json.loads(output_value)
  635. except (json.JSONDecodeError, ValueError):
  636. pass # 非 JSON 文本,保持原样
  637. out = {"trace_id": trace_id, "output": output_value}
  638. if result.error:
  639. out["error"] = result.error
  640. if result.metadata:
  641. out["metadata"] = result.metadata
  642. print(json.dumps(out, ensure_ascii=False, indent=2))