toolhub.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  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. 实际 API 端点(通过 /openapi.json 确认):
  9. GET /health → 健康检查
  10. GET /tools → 列出所有工具(含分组、参数 schema)
  11. POST /run_tool → 调用工具 {"tool_id": str, "params": dict}
  12. POST /chat → 对话接口(不在此封装)
  13. """
  14. import base64
  15. import contextvars
  16. import json
  17. import logging
  18. import mimetypes
  19. import time
  20. from pathlib import Path
  21. from typing import Any, Dict, List, Optional
  22. import httpx
  23. from agent.tools import tool, ToolResult
  24. logger = logging.getLogger(__name__)
  25. # ── 配置 ─────────────────────────────────────────────
  26. TOOLHUB_BASE_URL = "http://43.106.118.91:8001"
  27. DEFAULT_TIMEOUT = 30.0
  28. CALL_TIMEOUT = 600.0 # 图像生成类工具耗时较长,云端机器启动可能需要数分钟
  29. # OSS 上传配置
  30. OSS_BUCKET_NAME = "aigc-admin"
  31. OSS_BUCKET_PATH = "toolhub_images"
  32. # 输出目录(相对于项目根目录)
  33. OUTPUT_BASE_DIR = Path("outputs")
  34. # trace_id 上下文变量,由 runner 在执行工具前设置
  35. _trace_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("toolhub_trace_id", default="")
  36. def set_trace_context(trace_id: str):
  37. """由 runner 调用,设置当前 trace_id 供图片保存使用"""
  38. _trace_id_var.set(trace_id)
  39. def _get_output_dir(tool_id: str) -> Path:
  40. """获取图片输出目录:outputs/{trace_id}/,无 trace_id 时用时间戳"""
  41. trace_id = _trace_id_var.get("")
  42. if trace_id:
  43. # trace_id 可能含 @ 等特殊字符,取前段作为目录名
  44. safe_id = trace_id.split("@")[0][:12] if "@" in trace_id else trace_id[:12]
  45. out_dir = OUTPUT_BASE_DIR / safe_id
  46. else:
  47. out_dir = OUTPUT_BASE_DIR / f"no_trace_{int(time.time())}"
  48. out_dir.mkdir(parents=True, exist_ok=True)
  49. return out_dir
  50. # ── 图片处理辅助 ─────────────────────────────────────
  51. async def _upload_to_oss(local_path: str) -> Optional[str]:
  52. """上传本地文件到 OSS,返回 CDN URL"""
  53. try:
  54. from cyber_sdk.ali_oss import upload_localfile
  55. import os
  56. safe_path = os.path.abspath(local_path).replace("\\", "/")
  57. result = await upload_localfile(
  58. file_path=safe_path,
  59. bucket_path=OSS_BUCKET_PATH,
  60. bucket_name=OSS_BUCKET_NAME,
  61. )
  62. oss_key = result.get("oss_object_key")
  63. if oss_key:
  64. cdn_url = f"https://res.cybertogether.net/{oss_key}"
  65. logger.info(f"[ToolHub] 图片已上传 OSS: {cdn_url}")
  66. return cdn_url
  67. except Exception as e:
  68. logger.warning(f"[ToolHub] OSS 上传失败: {e}")
  69. return None
  70. async def _process_images(raw_images: List[str], tool_id: str) -> tuple:
  71. """
  72. 统一处理工具返回的图片列表。
  73. 对每张图片:下载(如需) → 保存本地 → 上传 OSS → 拿到 CDN URL
  74. Returns:
  75. (images_for_llm, cdn_urls, saved_paths)
  76. - images_for_llm: 给 runner 的图片列表(base64 格式,用于 LLM 多模态查看)
  77. - cdn_urls: 永久 CDN URL 列表
  78. - saved_paths: 本地文件路径列表
  79. """
  80. images_for_llm = []
  81. cdn_urls = []
  82. saved_paths = []
  83. original_urls = []
  84. out_dir = _get_output_dir(tool_id)
  85. for idx, img in enumerate(raw_images):
  86. if not isinstance(img, str) or len(img) <= 100:
  87. continue
  88. img_bytes = None
  89. media_type = "image/png"
  90. if img.startswith(("http://", "https://")):
  91. original_urls.append(img)
  92. try:
  93. async with httpx.AsyncClient(timeout=60, trust_env=False) as dl:
  94. img_resp = await dl.get(img)
  95. img_resp.raise_for_status()
  96. ct = img_resp.headers.get("content-type", "image/png").split(";")[0].strip()
  97. if not ct.startswith("image/"):
  98. ct = mimetypes.guess_type(img.split("?")[0])[0] or "image/png"
  99. media_type = ct
  100. img_bytes = img_resp.content
  101. except Exception as e:
  102. logger.warning(f"[ToolHub] 图片下载失败: {e}")
  103. continue
  104. elif img.startswith("data:"):
  105. header, b64 = img.split(",", 1)
  106. media_type = header.split(";")[0].replace("data:", "")
  107. img_bytes = base64.b64decode(b64)
  108. else:
  109. # raw base64
  110. img_bytes = base64.b64decode(img)
  111. if not img_bytes:
  112. continue
  113. # 1. 保存本地(用时间戳区分多次调用)
  114. ts = int(time.time() * 1000)
  115. ext = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp"}.get(media_type, ".png")
  116. save_path = out_dir / f"{tool_id}_{ts}_{idx}{ext}"
  117. save_path.write_bytes(img_bytes)
  118. saved_paths.append(str(save_path))
  119. # 2. 上传 OSS 拿 CDN URL
  120. cdn_url = await _upload_to_oss(str(save_path))
  121. if cdn_url:
  122. cdn_urls.append(cdn_url)
  123. # 3. base64 给 LLM 多模态查看
  124. b64_data = base64.b64encode(img_bytes).decode()
  125. images_for_llm.append({"type": "base64", "media_type": media_type, "data": b64_data})
  126. return images_for_llm, cdn_urls, saved_paths
  127. async def _preprocess_params(params: Dict[str, Any]) -> Dict[str, Any]:
  128. """
  129. 预处理工具参数:检测本地文件路径,自动上传到 OSS 并替换为 CDN URL。
  130. 支持的参数名:image, image_url, mask_image, pose_image, images (数组)
  131. """
  132. if not params:
  133. return params
  134. processed = params.copy()
  135. # 单个图片参数
  136. for key in ("image", "image_url", "mask_image", "pose_image"):
  137. if key in processed and isinstance(processed[key], str):
  138. val = processed[key]
  139. # 检测是否为本地路径(不是 http/https/data: 开头)
  140. if not val.startswith(("http://", "https://", "data:")):
  141. # 尝试读取本地文件
  142. try:
  143. from pathlib import Path
  144. p = Path(val)
  145. if p.exists() and p.is_file():
  146. logger.info(f"[ToolHub] 检测到本地文件 {key}={val},上传到 OSS...")
  147. cdn_url = await _upload_to_oss(str(p.resolve()))
  148. if cdn_url:
  149. processed[key] = cdn_url
  150. logger.info(f"[ToolHub] {key} 已替换为 CDN URL: {cdn_url}")
  151. else:
  152. logger.warning(f"[ToolHub] {key} 上传失败,保持原路径")
  153. except Exception as e:
  154. logger.warning(f"[ToolHub] {key} 路径处理失败: {e}")
  155. # images 数组参数
  156. if "images" in processed and isinstance(processed["images"], list):
  157. new_images = []
  158. for idx, img in enumerate(processed["images"]):
  159. if isinstance(img, str) and not img.startswith(("http://", "https://", "data:")):
  160. try:
  161. from pathlib import Path
  162. p = Path(img)
  163. if p.exists() and p.is_file():
  164. logger.info(f"[ToolHub] 检测到本地文件 images[{idx}]={img},上传到 OSS...")
  165. cdn_url = await _upload_to_oss(str(p.resolve()))
  166. if cdn_url:
  167. new_images.append(cdn_url)
  168. logger.info(f"[ToolHub] images[{idx}] 已替换为 CDN URL: {cdn_url}")
  169. else:
  170. new_images.append(img)
  171. else:
  172. new_images.append(img)
  173. except Exception as e:
  174. logger.warning(f"[ToolHub] images[{idx}] 路径处理失败: {e}")
  175. new_images.append(img)
  176. else:
  177. new_images.append(img)
  178. processed["images"] = new_images
  179. return processed
  180. # ── 工具实现 ──────────────────────────────────────────
  181. @tool(
  182. display={
  183. "zh": {"name": "ToolHub 健康检查", "params": {}},
  184. "en": {"name": "ToolHub Health Check", "params": {}},
  185. }
  186. )
  187. async def toolhub_health() -> ToolResult:
  188. """检查 ToolHub 远程工具库服务是否可用
  189. 检查 ToolHub 服务的健康状态,确认服务是否正常运行。
  190. 建议在调用其他 toolhub 工具之前先检查。
  191. Returns:
  192. ToolResult 包含服务健康状态信息
  193. """
  194. try:
  195. async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, trust_env=False) as client:
  196. resp = await client.get(f"{TOOLHUB_BASE_URL}/health")
  197. resp.raise_for_status()
  198. data = resp.json()
  199. return ToolResult(
  200. title="ToolHub 健康检查",
  201. output=json.dumps(data, ensure_ascii=False, indent=2),
  202. long_term_memory=f"ToolHub service at {TOOLHUB_BASE_URL} is healthy.",
  203. )
  204. except httpx.ConnectError:
  205. return ToolResult(
  206. title="ToolHub 健康检查",
  207. output="",
  208. error=f"无法连接到 ToolHub 服务 {TOOLHUB_BASE_URL},请确认服务已启动。",
  209. )
  210. except Exception as e:
  211. return ToolResult(
  212. title="ToolHub 健康检查",
  213. output="",
  214. error=str(e),
  215. )
  216. @tool(
  217. display={
  218. "zh": {"name": "搜索 ToolHub 工具", "params": {"keyword": "搜索关键词"}},
  219. "en": {"name": "Search ToolHub", "params": {"keyword": "Search keyword"}},
  220. }
  221. )
  222. async def toolhub_search(keyword: Optional[str] = None) -> ToolResult:
  223. """搜索 ToolHub 远程工具库中可用的工具
  224. 从 ToolHub 工具库中获取可用工具列表,返回每个工具的完整信息,包括:
  225. tool_id、名称、分类、状态、参数列表(含类型、是否必填、默认值)、输出 schema、
  226. 分组信息(如 RunComfy 生命周期组)等。
  227. 调用 toolhub_call 之前,应先使用此工具了解目标工具的 tool_id 和所需参数。
  228. 不填 keyword 则返回所有工具。
  229. Args:
  230. keyword: 搜索关键词,用于过滤工具名称或描述(客户端过滤);为空则返回所有工具
  231. Returns:
  232. ToolResult 包含匹配的工具列表及其参数说明
  233. """
  234. try:
  235. async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, trust_env=False) as client:
  236. resp = await client.get(f"{TOOLHUB_BASE_URL}/tools")
  237. resp.raise_for_status()
  238. data = resp.json()
  239. tools = data.get("tools", [])
  240. groups = data.get("groups", [])
  241. # 客户端关键词过滤
  242. if keyword:
  243. kw = keyword.lower()
  244. tools = [
  245. t for t in tools
  246. if kw in t.get("name", "").lower()
  247. or kw in t.get("description", "").lower()
  248. or kw in t.get("tool_id", "").lower()
  249. or kw in t.get("category", "").lower()
  250. ]
  251. total = len(tools)
  252. # 构建给 LLM 的结构化摘要
  253. summaries = []
  254. for t in tools:
  255. input_props = t.get("input_schema", {}).get("properties", {})
  256. required_fields = t.get("input_schema", {}).get("required", [])
  257. params_desc = []
  258. for name, info in input_props.items():
  259. req = "必填" if name in required_fields else "可选"
  260. desc = info.get("description", "")
  261. default_str = f", 默认={info['default']}" if info.get("default") is not None else ""
  262. enum_str = f", 可选值={info['enum']}" if info.get("enum") else ""
  263. params_desc.append(
  264. f" - {name} ({info.get('type','any')}, {req}): {desc}{default_str}{enum_str}"
  265. )
  266. group_str = ""
  267. if t.get("group_ids"):
  268. group_str = f"\n 所属分组: {', '.join(t['group_ids'])}"
  269. tool_block = (
  270. f"[{t['tool_id']}] {t['name']}\n"
  271. f" 状态: {t['state']} | 运行时: {t['backend_runtime']} | 分类: {t.get('category','')}"
  272. f"{group_str}\n"
  273. f" 描述: {t.get('description', '')}"
  274. )
  275. if params_desc:
  276. tool_block += "\n 参数:\n" + "\n".join(params_desc)
  277. else:
  278. tool_block += "\n 参数: 无"
  279. summaries.append(tool_block)
  280. # 分组使用说明
  281. group_summary = []
  282. for g in groups:
  283. group_summary.append(
  284. f"[组: {g['group_id']}] {g['name']}\n"
  285. f" 调用顺序: {' → '.join(g.get('usage_order', []))}\n"
  286. f" 说明: {g.get('usage_example', '')}"
  287. )
  288. output_parts = [f"共找到 {total} 个工具({'关键词: ' + keyword if keyword else '全量'}):\n"]
  289. output_parts.append("\n\n".join(summaries))
  290. if group_summary:
  291. output_parts.append("\n\n=== 工具分组(有顺序依赖)===\n" + "\n\n".join(group_summary))
  292. return ToolResult(
  293. title=f"ToolHub 搜索{f': {keyword}' if keyword else '(全量)'}",
  294. output="\n".join(output_parts),
  295. long_term_memory=(
  296. f"ToolHub 共 {total} 个工具: "
  297. + ", ".join(t["tool_id"] for t in tools[:15])
  298. + ("..." if total > 15 else "")
  299. ),
  300. )
  301. except Exception as e:
  302. return ToolResult(
  303. title="搜索 ToolHub 工具失败",
  304. output="",
  305. error=str(e),
  306. )
  307. @tool(
  308. display={
  309. "zh": {
  310. "name": "调用 ToolHub 工具",
  311. "params": {"tool_id": "工具ID", "params": "工具参数"},
  312. },
  313. "en": {
  314. "name": "Call ToolHub Tool",
  315. "params": {"tool_id": "Tool ID", "params": "Tool parameters"},
  316. },
  317. }
  318. )
  319. async def toolhub_call(
  320. tool_id: str,
  321. params: Optional[Dict[str, Any]] = None,
  322. ) -> ToolResult:
  323. """调用 ToolHub 远程工具库中的指定工具
  324. 通过 tool_id 调用 ToolHub 工具库中的某个工具,传入该工具所需的参数。
  325. 不同工具的参数不同,请先用 toolhub_search 查询目标工具的参数说明。
  326. 注意:部分工具为异步生命周期(如 RunComfy、即梦、FLUX),需要按分组顺序
  327. 依次调用多个工具(如先 launch → 再 executor → 再 stop)。
  328. 参数通过 params 字典传入,键名和类型需与工具定义一致。
  329. 例如调用图片拼接工具:
  330. tool_id="image_stitcher"
  331. params={"images": [...], "direction": "grid", "columns": 2}
  332. Args:
  333. tool_id: 要调用的工具 ID(从 toolhub_search 获取)
  334. params: 工具参数字典,键值对根据目标工具的参数定义决定
  335. Returns:
  336. ToolResult 包含工具执行结果
  337. """
  338. try:
  339. # 预处理参数:本地文件路径自动上传成 CDN URL
  340. params = await _preprocess_params(params or {})
  341. payload = {
  342. "tool_id": tool_id,
  343. "params": params,
  344. }
  345. async with httpx.AsyncClient(timeout=CALL_TIMEOUT, trust_env=False) as client:
  346. resp = await client.post(
  347. f"{TOOLHUB_BASE_URL}/run_tool", json=payload
  348. )
  349. resp.raise_for_status()
  350. data = resp.json()
  351. status = data.get("status")
  352. if status == "success":
  353. result = data.get("result", {})
  354. result_str = json.dumps(result, ensure_ascii=False, indent=2)
  355. # 提取图片并统一处理(下载 → 保存本地 → 上传 OSS → CDN URL)
  356. images = []
  357. if isinstance(result, dict):
  358. # 收集所有图片(单张 image 字段 + images 列表字段)
  359. raw_images = []
  360. has_single_image = False
  361. has_images_list = False
  362. if result.get("image") and isinstance(result["image"], str):
  363. raw_images.append(result["image"])
  364. has_single_image = True
  365. if result.get("images") and isinstance(result["images"], list):
  366. raw_images.extend(result["images"])
  367. has_images_list = True
  368. if raw_images:
  369. images, cdn_urls, saved_paths = await _process_images(raw_images, tool_id)
  370. # 构建文本输出(去掉原始图片数据)
  371. result_display = {k: v for k, v in result.items() if k not in ("image", "images")}
  372. if cdn_urls:
  373. result_display["cdn_urls"] = cdn_urls
  374. result_display["_note"] = (
  375. "图片已上传至 CDN(永久链接),可通过 cdn_urls 访问、传给其他工具或下载保存。"
  376. "同时也作为附件附加在本条消息中可直接查看。"
  377. )
  378. if saved_paths:
  379. result_display["saved_files"] = saved_paths
  380. result_display["image_count"] = len(images)
  381. result_str = json.dumps(result_display, ensure_ascii=False, indent=2)
  382. return ToolResult(
  383. title=f"ToolHub [{tool_id}] 执行成功",
  384. output=result_str,
  385. long_term_memory=f"Called ToolHub tool '{tool_id}' → success",
  386. images=images,
  387. )
  388. else:
  389. error_msg = data.get("error", "未知错误")
  390. return ToolResult(
  391. title=f"ToolHub [{tool_id}] 执行失败",
  392. output=json.dumps(data, ensure_ascii=False, indent=2),
  393. error=error_msg,
  394. )
  395. except httpx.TimeoutException:
  396. return ToolResult(
  397. title=f"ToolHub [{tool_id}] 调用超时",
  398. output="",
  399. error=f"调用工具 {tool_id} 超时({CALL_TIMEOUT:.0f}s),图像生成类工具可能需要更长时间。",
  400. )
  401. except Exception as e:
  402. return ToolResult(
  403. title=f"ToolHub [{tool_id}] 调用失败",
  404. output="",
  405. error=str(e),
  406. )
  407. @tool(
  408. display={
  409. "zh": {"name": "上传本地图片", "params": {"local_path": "本地文件路径"}},
  410. "en": {"name": "Upload Local Image", "params": {"local_path": "Local file path"}},
  411. }
  412. )
  413. async def image_uploader(local_path: str) -> ToolResult:
  414. """将本地图片上传到 OSS,返回可用的 CDN URL(image_url)
  415. 当你需要获取一张本地图片的 HTTP 链接时使用此工具。
  416. 传入本地文件路径,自动上传到 OSS 并返回永久 CDN URL。
  417. 注意:在调用 toolhub_call 时,image/image_url 等参数可以直接传本地路径,
  418. 系统会自动上传。此工具适用于你需要单独获取图片 URL 的场景。
  419. Args:
  420. local_path: 本地图片文件路径(相对路径或绝对路径均可)
  421. Returns:
  422. ToolResult 包含上传后的 CDN URL
  423. """
  424. import os
  425. from pathlib import Path
  426. p = Path(local_path)
  427. if not p.exists():
  428. return ToolResult(
  429. title="图片上传失败",
  430. output="",
  431. error=f"文件不存在: {local_path}",
  432. )
  433. if not p.is_file():
  434. return ToolResult(
  435. title="图片上传失败",
  436. output="",
  437. error=f"路径不是文件: {local_path}",
  438. )
  439. cdn_url = await _upload_to_oss(str(p.resolve()))
  440. if cdn_url:
  441. result = {
  442. "local_path": str(p.resolve()),
  443. "cdn_url": cdn_url,
  444. "file_size": os.path.getsize(p),
  445. }
  446. return ToolResult(
  447. title="图片上传成功",
  448. output=json.dumps(result, ensure_ascii=False, indent=2),
  449. long_term_memory=f"Uploaded {local_path} → {cdn_url}",
  450. )
  451. else:
  452. return ToolResult(
  453. title="图片上传失败",
  454. output="",
  455. error=f"OSS 上传失败,请检查文件路径和网络连接: {local_path}",
  456. )
  457. @tool(
  458. display={
  459. "zh": {"name": "下载图片到本地", "params": {"url": "图片URL", "save_path": "保存路径"}},
  460. "en": {"name": "Download Image", "params": {"url": "Image URL", "save_path": "Save path"}},
  461. }
  462. )
  463. async def image_downloader(url: str, save_path: str = "") -> ToolResult:
  464. """下载网络图片到本地文件
  465. 从 HTTP/HTTPS 链接下载图片并保存到本地。
  466. 适用于需要将 CDN 图片、生成结果等保存到本地目录的场景。
  467. Args:
  468. url: 图片的 HTTP/HTTPS 链接
  469. save_path: 本地保存路径(相对或绝对路径均可)。
  470. 如不指定,自动保存到当前输出目录,文件名从 URL 提取。
  471. Returns:
  472. ToolResult 包含下载后的本地文件路径和文件大小
  473. """
  474. import os
  475. from pathlib import Path
  476. from urllib.parse import urlparse, unquote
  477. if not url.startswith(("http://", "https://")):
  478. return ToolResult(
  479. title="图片下载失败",
  480. output="",
  481. error=f"无效的 URL(必须以 http:// 或 https:// 开头): {url}",
  482. )
  483. # 自动生成保存路径
  484. if not save_path:
  485. out_dir = _get_output_dir("download")
  486. # 从 URL 提取文件名
  487. url_path = urlparse(url).path
  488. filename = Path(unquote(url_path)).name if url_path else ""
  489. if not filename or not any(filename.lower().endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp")):
  490. filename = f"download_{int(time.time())}.png"
  491. save_path = str(out_dir / filename)
  492. # 确保目录存在
  493. p = Path(save_path)
  494. p.parent.mkdir(parents=True, exist_ok=True)
  495. try:
  496. async with httpx.AsyncClient(timeout=60.0, follow_redirects=True, trust_env=False) as client:
  497. resp = await client.get(url)
  498. resp.raise_for_status()
  499. p.write_bytes(resp.content)
  500. file_size = os.path.getsize(p)
  501. result = {
  502. "save_path": str(p.resolve()),
  503. "file_size": file_size,
  504. "source_url": url,
  505. }
  506. return ToolResult(
  507. title="图片下载成功",
  508. output=json.dumps(result, ensure_ascii=False, indent=2),
  509. long_term_memory=f"Downloaded {url} → {save_path}",
  510. )
  511. except httpx.HTTPStatusError as e:
  512. return ToolResult(
  513. title="图片下载失败",
  514. output="",
  515. error=f"HTTP 错误 {e.response.status_code}: {url}",
  516. )
  517. except Exception as e:
  518. return ToolResult(
  519. title="图片下载失败",
  520. output="",
  521. error=f"下载失败: {e}",
  522. )