toolhub.py 27 KB

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