|
|
@@ -7,11 +7,20 @@ ToolHub - 远程工具库集成模块
|
|
|
2. toolhub_search - 搜索/发现远程工具(GET /tools)
|
|
|
3. toolhub_call - 调用远程工具(POST /run_tool)
|
|
|
|
|
|
+图片参数统一使用本地文件路径:
|
|
|
+ - 输入:params 中的 image/image_url 等参数直接传本地路径,内部自动上传
|
|
|
+ - 输出:生成的图片自动保存到 outputs/ 目录,返回本地路径
|
|
|
+
|
|
|
实际 API 端点(通过 /openapi.json 确认):
|
|
|
GET /health → 健康检查
|
|
|
GET /tools → 列出所有工具(含分组、参数 schema)
|
|
|
POST /run_tool → 调用工具 {"tool_id": str, "params": dict}
|
|
|
POST /chat → 对话接口(不在此封装)
|
|
|
+
|
|
|
+CLI 用法:
|
|
|
+ python -m agent.tools.builtin.toolhub health
|
|
|
+ python -m agent.tools.builtin.toolhub search --keyword=image
|
|
|
+ python -m agent.tools.builtin.toolhub call --tool_id=flux_gen --params='{"prompt":"a cat"}'
|
|
|
"""
|
|
|
|
|
|
import base64
|
|
|
@@ -19,6 +28,7 @@ import contextvars
|
|
|
import json
|
|
|
import logging
|
|
|
import mimetypes
|
|
|
+import os
|
|
|
import time
|
|
|
from pathlib import Path
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
@@ -159,62 +169,77 @@ async def _process_images(raw_images: List[str], tool_id: str) -> tuple:
|
|
|
return images_for_llm, cdn_urls, saved_paths
|
|
|
|
|
|
|
|
|
+_SINGLE_IMAGE_PARAMS = ("image", "image_url", "mask_image", "pose_image", "reference_image")
|
|
|
+_ARRAY_IMAGE_PARAMS = ("images", "image_urls", "reference_images")
|
|
|
+
|
|
|
+
|
|
|
+async def _maybe_upload_local(val: str) -> Optional[str]:
|
|
|
+ """如果 val 是存在的本地文件路径,上传 OSS 并返回 CDN URL;否则返回 None。"""
|
|
|
+ if not isinstance(val, str):
|
|
|
+ return None
|
|
|
+ if val.startswith(("http://", "https://", "data:")):
|
|
|
+ return None
|
|
|
+ try:
|
|
|
+ p = Path(val)
|
|
|
+ if p.exists() and p.is_file():
|
|
|
+ return await _upload_to_oss(str(p.resolve()))
|
|
|
+ except Exception as e:
|
|
|
+ logger.warning(f"[ToolHub] 本地路径处理失败 {val}: {e}")
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
async def _preprocess_params(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
"""
|
|
|
预处理工具参数:检测本地文件路径,自动上传到 OSS 并替换为 CDN URL。
|
|
|
|
|
|
- 支持的参数名:image, image_url, mask_image, pose_image, images (数组)
|
|
|
+ 支持的单值参数:image, image_url, mask_image, pose_image, reference_image
|
|
|
+ 支持的数组参数:images, image_urls, reference_images
|
|
|
+
|
|
|
+ 设计要点:远程工具服务的 cwd 和调用方不一样,相对路径在服务器上会找不到文件。
|
|
|
+ 所以必须在客户端就把本地路径转成 CDN URL,不能期望服务器侧有 fallback。
|
|
|
"""
|
|
|
if not params:
|
|
|
return params
|
|
|
|
|
|
processed = params.copy()
|
|
|
|
|
|
- # 单个图片参数
|
|
|
- for key in ("image", "image_url", "mask_image", "pose_image"):
|
|
|
+ # 单值图片参数
|
|
|
+ for key in _SINGLE_IMAGE_PARAMS:
|
|
|
if key in processed and isinstance(processed[key], str):
|
|
|
val = processed[key]
|
|
|
- # 检测是否为本地路径(不是 http/https/data: 开头)
|
|
|
- if not val.startswith(("http://", "https://", "data:")):
|
|
|
- # 尝试读取本地文件
|
|
|
- try:
|
|
|
- from pathlib import Path
|
|
|
- p = Path(val)
|
|
|
- if p.exists() and p.is_file():
|
|
|
- logger.info(f"[ToolHub] 检测到本地文件 {key}={val},上传到 OSS...")
|
|
|
- cdn_url = await _upload_to_oss(str(p.resolve()))
|
|
|
- if cdn_url:
|
|
|
- processed[key] = cdn_url
|
|
|
- logger.info(f"[ToolHub] {key} 已替换为 CDN URL: {cdn_url}")
|
|
|
- else:
|
|
|
- logger.warning(f"[ToolHub] {key} 上传失败,保持原路径")
|
|
|
- except Exception as e:
|
|
|
- logger.warning(f"[ToolHub] {key} 路径处理失败: {e}")
|
|
|
-
|
|
|
- # images 数组参数
|
|
|
- if "images" in processed and isinstance(processed["images"], list):
|
|
|
- new_images = []
|
|
|
- for idx, img in enumerate(processed["images"]):
|
|
|
- if isinstance(img, str) and not img.startswith(("http://", "https://", "data:")):
|
|
|
- try:
|
|
|
- from pathlib import Path
|
|
|
- p = Path(img)
|
|
|
- if p.exists() and p.is_file():
|
|
|
- logger.info(f"[ToolHub] 检测到本地文件 images[{idx}]={img},上传到 OSS...")
|
|
|
- cdn_url = await _upload_to_oss(str(p.resolve()))
|
|
|
- if cdn_url:
|
|
|
- new_images.append(cdn_url)
|
|
|
- logger.info(f"[ToolHub] images[{idx}] 已替换为 CDN URL: {cdn_url}")
|
|
|
- else:
|
|
|
- new_images.append(img)
|
|
|
- else:
|
|
|
- new_images.append(img)
|
|
|
- except Exception as e:
|
|
|
- logger.warning(f"[ToolHub] images[{idx}] 路径处理失败: {e}")
|
|
|
- new_images.append(img)
|
|
|
+ if val.startswith(("http://", "https://", "data:")):
|
|
|
+ continue
|
|
|
+ cdn_url = await _maybe_upload_local(val)
|
|
|
+ if cdn_url:
|
|
|
+ processed[key] = cdn_url
|
|
|
+ logger.info(f"[ToolHub] {key} 本地路径已替换为 CDN: {cdn_url}")
|
|
|
+ elif not os.path.isfile(val):
|
|
|
+ # 既不是远程 URL 也不是已存在的本地文件,直接报错比让远程服务抛神秘的 base64 错误强
|
|
|
+ logger.warning(f"[ToolHub] {key}={val!r} 既不是 URL 也不是存在的本地文件")
|
|
|
+
|
|
|
+ # 数组型图片参数
|
|
|
+ for array_key in _ARRAY_IMAGE_PARAMS:
|
|
|
+ if array_key not in processed or not isinstance(processed[array_key], list):
|
|
|
+ continue
|
|
|
+ new_list = []
|
|
|
+ for idx, item in enumerate(processed[array_key]):
|
|
|
+ if not isinstance(item, str):
|
|
|
+ new_list.append(item)
|
|
|
+ continue
|
|
|
+ if item.startswith(("http://", "https://", "data:")):
|
|
|
+ new_list.append(item)
|
|
|
+ continue
|
|
|
+ cdn_url = await _maybe_upload_local(item)
|
|
|
+ if cdn_url:
|
|
|
+ new_list.append(cdn_url)
|
|
|
+ logger.info(f"[ToolHub] {array_key}[{idx}] 本地路径已替换为 CDN: {cdn_url}")
|
|
|
else:
|
|
|
- new_images.append(img)
|
|
|
- processed["images"] = new_images
|
|
|
+ new_list.append(item)
|
|
|
+ if not os.path.isfile(item):
|
|
|
+ logger.warning(
|
|
|
+ f"[ToolHub] {array_key}[{idx}]={item!r} 既不是 URL 也不是存在的本地文件"
|
|
|
+ )
|
|
|
+ processed[array_key] = new_list
|
|
|
|
|
|
return processed
|
|
|
|
|
|
@@ -254,10 +279,11 @@ async def toolhub_health() -> ToolResult:
|
|
|
error=f"无法连接到 ToolHub 服务 {TOOLHUB_BASE_URL},请确认服务已启动。",
|
|
|
)
|
|
|
except Exception as e:
|
|
|
+ err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
|
|
|
return ToolResult(
|
|
|
title="ToolHub 健康检查",
|
|
|
output="",
|
|
|
- error=str(e),
|
|
|
+ error=err_msg,
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -292,16 +318,46 @@ async def toolhub_search(keyword: Optional[str] = None) -> ToolResult:
|
|
|
tools = data.get("tools", [])
|
|
|
groups = data.get("groups", [])
|
|
|
|
|
|
- # 客户端关键词过滤
|
|
|
+ # 客户端关键词过滤:三层匹配策略
|
|
|
+ # 1) 原始子串匹配(最严格,但会被分隔符切断:nanobanana vs nano_banana)
|
|
|
+ # 2) 归一化子串匹配(去掉 _ - 空格 .,解决分隔符问题)
|
|
|
+ # 3) 分词交集匹配(keyword 拆成 token,任意 token 命中即保留,解决多词查询)
|
|
|
if keyword:
|
|
|
- kw = keyword.lower()
|
|
|
- tools = [
|
|
|
- t for t in tools
|
|
|
- if kw in t.get("name", "").lower()
|
|
|
- or kw in t.get("description", "").lower()
|
|
|
- or kw in t.get("tool_id", "").lower()
|
|
|
- or kw in t.get("category", "").lower()
|
|
|
- ]
|
|
|
+ def _normalize(s: str) -> str:
|
|
|
+ """去掉分隔符和空白,全小写"""
|
|
|
+ return "".join(c for c in s.lower() if c.isalnum())
|
|
|
+
|
|
|
+ def _tokenize(s: str) -> set:
|
|
|
+ """按分隔符拆成 token 集合"""
|
|
|
+ import re
|
|
|
+ return {t for t in re.split(r"[\s_\-.,/]+", s.lower()) if t}
|
|
|
+
|
|
|
+ kw_raw = keyword.lower()
|
|
|
+ kw_norm = _normalize(keyword)
|
|
|
+ kw_tokens = _tokenize(keyword)
|
|
|
+
|
|
|
+ def _matches(t: dict) -> bool:
|
|
|
+ fields = [
|
|
|
+ t.get("name", ""),
|
|
|
+ t.get("description", ""),
|
|
|
+ t.get("tool_id", ""),
|
|
|
+ t.get("category", ""),
|
|
|
+ ]
|
|
|
+ combined = " ".join(fields).lower()
|
|
|
+ # 原始子串
|
|
|
+ if kw_raw in combined:
|
|
|
+ return True
|
|
|
+ # 归一化子串(容忍分隔符差异)
|
|
|
+ if kw_norm and kw_norm in _normalize(combined):
|
|
|
+ return True
|
|
|
+ # token 交集(多词关键词的 OR 匹配)
|
|
|
+ if kw_tokens:
|
|
|
+ field_tokens = _tokenize(combined)
|
|
|
+ if kw_tokens & field_tokens:
|
|
|
+ return True
|
|
|
+ return False
|
|
|
+
|
|
|
+ tools = [t for t in tools if _matches(t)]
|
|
|
|
|
|
total = len(tools)
|
|
|
|
|
|
@@ -337,9 +393,16 @@ async def toolhub_search(keyword: Optional[str] = None) -> ToolResult:
|
|
|
|
|
|
summaries.append(tool_block)
|
|
|
|
|
|
- # 分组使用说明
|
|
|
+ # 分组使用说明:仅显示搜出的工具实际所属的分组,避免噪音
|
|
|
+ relevant_group_ids = set()
|
|
|
+ for t in tools:
|
|
|
+ for gid in t.get("group_ids", []) or []:
|
|
|
+ relevant_group_ids.add(gid)
|
|
|
+
|
|
|
group_summary = []
|
|
|
for g in groups:
|
|
|
+ if g["group_id"] not in relevant_group_ids:
|
|
|
+ continue
|
|
|
group_summary.append(
|
|
|
f"[组: {g['group_id']}] {g['name']}\n"
|
|
|
f" 调用顺序: {' → '.join(g.get('usage_order', []))}\n"
|
|
|
@@ -360,11 +423,26 @@ async def toolhub_search(keyword: Optional[str] = None) -> ToolResult:
|
|
|
+ ("..." if total > 15 else "")
|
|
|
),
|
|
|
)
|
|
|
+ except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.WriteTimeout, httpx.PoolTimeout) as e:
|
|
|
+ return ToolResult(
|
|
|
+ title="ToolHub /tools 超时",
|
|
|
+ output="",
|
|
|
+ error=f"ToolHub 的 /tools 接口在 {DEFAULT_TIMEOUT:.0f}s 内未响应({type(e).__name__})。"
|
|
|
+ f"服务器可能在检测各工具状态导致列表慢,请稍后重试或联系维护者。",
|
|
|
+ )
|
|
|
+ except httpx.ConnectError as e:
|
|
|
+ return ToolResult(
|
|
|
+ title="ToolHub 连接失败",
|
|
|
+ output="",
|
|
|
+ error=f"无法连接到 ToolHub 服务 {TOOLHUB_BASE_URL}:{type(e).__name__}: {e}",
|
|
|
+ )
|
|
|
except Exception as e:
|
|
|
+ # 注意 httpx 的部分异常 str(e) 是空的,必须带 type name
|
|
|
+ err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
|
|
|
return ToolResult(
|
|
|
title="搜索 ToolHub 工具失败",
|
|
|
output="",
|
|
|
- error=str(e),
|
|
|
+ error=err_msg,
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -389,20 +467,20 @@ async def toolhub_call(
|
|
|
通过 tool_id 调用 ToolHub 工具库中的某个工具,传入该工具所需的参数。
|
|
|
不同工具的参数不同,请先用 toolhub_search 查询目标工具的参数说明。
|
|
|
|
|
|
+ 图片参数(image、image_url、mask_image、pose_image、images)直接传本地文件路径即可,
|
|
|
+ 系统会自动上传。生成的图片会自动保存到本地 outputs/ 目录,返回结果中的
|
|
|
+ saved_files 字段包含本地文件路径。
|
|
|
+
|
|
|
注意:部分工具为异步生命周期(如 RunComfy、即梦、FLUX),需要按分组顺序
|
|
|
依次调用多个工具(如先 launch → 再 executor → 再 stop)。
|
|
|
|
|
|
- 参数通过 params 字典传入,键名和类型需与工具定义一致。
|
|
|
- 例如调用图片拼接工具:
|
|
|
- tool_id="image_stitcher"
|
|
|
- params={"images": [...], "direction": "grid", "columns": 2}
|
|
|
-
|
|
|
Args:
|
|
|
tool_id: 要调用的工具 ID(从 toolhub_search 获取)
|
|
|
- params: 工具参数字典,键值对根据目标工具的参数定义决定
|
|
|
+ params: 工具参数字典,键值对根据目标工具的参数定义决定。
|
|
|
+ 图片参数可直接使用本地文件路径(如 "/path/to/image.png")。
|
|
|
|
|
|
Returns:
|
|
|
- ToolResult 包含工具执行结果
|
|
|
+ ToolResult 包含工具执行结果,图片结果通过 saved_files 返回本地路径
|
|
|
"""
|
|
|
try:
|
|
|
# 预处理参数:本地文件路径自动上传成 CDN URL
|
|
|
@@ -443,17 +521,13 @@ async def toolhub_call(
|
|
|
if raw_images:
|
|
|
images, cdn_urls, saved_paths = await _process_images(raw_images, tool_id)
|
|
|
|
|
|
- # 构建文本输出(去掉原始图片数据)
|
|
|
+ # 构建文本输出(去掉原始图片数据,以本地路径为主)
|
|
|
result_display = {k: v for k, v in result.items() if k not in ("image", "images")}
|
|
|
- if cdn_urls:
|
|
|
- result_display["cdn_urls"] = cdn_urls
|
|
|
- result_display["_note"] = (
|
|
|
- "图片已上传至 CDN(永久链接),可通过 cdn_urls 访问、传给其他工具或下载保存。"
|
|
|
- "同时也作为附件附加在本条消息中可直接查看。"
|
|
|
- )
|
|
|
+ result_display["image_count"] = len(images)
|
|
|
if saved_paths:
|
|
|
result_display["saved_files"] = saved_paths
|
|
|
- result_display["image_count"] = len(images)
|
|
|
+ if cdn_urls:
|
|
|
+ result_display["cdn_urls"] = cdn_urls
|
|
|
result_str = json.dumps(result_display, ensure_ascii=False, indent=2)
|
|
|
|
|
|
return ToolResult(
|
|
|
@@ -469,41 +543,29 @@ async def toolhub_call(
|
|
|
output=json.dumps(data, ensure_ascii=False, indent=2),
|
|
|
error=error_msg,
|
|
|
)
|
|
|
- except httpx.TimeoutException:
|
|
|
+ except httpx.TimeoutException as e:
|
|
|
return ToolResult(
|
|
|
title=f"ToolHub [{tool_id}] 调用超时",
|
|
|
output="",
|
|
|
- error=f"调用工具 {tool_id} 超时({CALL_TIMEOUT:.0f}s),图像生成类工具可能需要更长时间。",
|
|
|
+ error=f"调用工具 {tool_id} 超时({CALL_TIMEOUT:.0f}s,{type(e).__name__}),"
|
|
|
+ f"图像生成类工具可能需要更长时间。",
|
|
|
)
|
|
|
except Exception as e:
|
|
|
+ err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
|
|
|
return ToolResult(
|
|
|
title=f"ToolHub [{tool_id}] 调用失败",
|
|
|
output="",
|
|
|
- error=str(e),
|
|
|
+ error=err_msg,
|
|
|
)
|
|
|
|
|
|
|
|
|
-@tool(
|
|
|
- display={
|
|
|
- "zh": {"name": "上传本地图片", "params": {"local_path": "本地文件路径"}},
|
|
|
- "en": {"name": "Upload Local Image", "params": {"local_path": "Local file path"}},
|
|
|
- }
|
|
|
-)
|
|
|
-async def image_uploader(local_path: str) -> ToolResult:
|
|
|
- """将本地图片上传到 OSS,返回可用的 CDN URL(image_url)
|
|
|
+# 注意:image_uploader 和 image_downloader 不再注册为 Agent 工具。
|
|
|
+# toolhub_call 已内置完整的图片管线(输入自动上传,输出自动下载保存),
|
|
|
+# 无需单独暴露上传/下载工具。以下函数保留供内部或 CLI 使用。
|
|
|
|
|
|
- 当你需要获取一张本地图片的 HTTP 链接时使用此工具。
|
|
|
- 传入本地文件路径,自动上传到 OSS 并返回永久 CDN URL。
|
|
|
|
|
|
- 注意:在调用 toolhub_call 时,image/image_url 等参数可以直接传本地路径,
|
|
|
- 系统会自动上传。此工具适用于你需要单独获取图片 URL 的场景。
|
|
|
-
|
|
|
- Args:
|
|
|
- local_path: 本地图片文件路径(相对路径或绝对路径均可)
|
|
|
-
|
|
|
- Returns:
|
|
|
- ToolResult 包含上传后的 CDN URL
|
|
|
- """
|
|
|
+async def image_uploader(local_path: str) -> ToolResult:
|
|
|
+ """将本地图片上传到 OSS,返回可用的 CDN URL(内部工具,不注册给 Agent)"""
|
|
|
import os
|
|
|
from pathlib import Path
|
|
|
|
|
|
@@ -541,26 +603,8 @@ async def image_uploader(local_path: str) -> ToolResult:
|
|
|
)
|
|
|
|
|
|
|
|
|
-@tool(
|
|
|
- display={
|
|
|
- "zh": {"name": "下载图片到本地", "params": {"url": "图片URL", "save_path": "保存路径"}},
|
|
|
- "en": {"name": "Download Image", "params": {"url": "Image URL", "save_path": "Save path"}},
|
|
|
- }
|
|
|
-)
|
|
|
async def image_downloader(url: str, save_path: str = "") -> ToolResult:
|
|
|
- """下载网络图片到本地文件
|
|
|
-
|
|
|
- 从 HTTP/HTTPS 链接下载图片并保存到本地。
|
|
|
- 适用于需要将 CDN 图片、生成结果等保存到本地目录的场景。
|
|
|
-
|
|
|
- Args:
|
|
|
- url: 图片的 HTTP/HTTPS 链接
|
|
|
- save_path: 本地保存路径(相对或绝对路径均可)。
|
|
|
- 如不指定,自动保存到当前输出目录,文件名从 URL 提取。
|
|
|
-
|
|
|
- Returns:
|
|
|
- ToolResult 包含下载后的本地文件路径和文件大小
|
|
|
- """
|
|
|
+ """下载网络图片到本地文件(内部工具,不注册给 Agent)"""
|
|
|
import os
|
|
|
from pathlib import Path
|
|
|
from urllib.parse import urlparse, unquote
|
|
|
@@ -615,3 +659,67 @@ async def image_downloader(url: str, save_path: str = "") -> ToolResult:
|
|
|
output="",
|
|
|
error=f"下载失败: {e}",
|
|
|
)
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ import sys
|
|
|
+
|
|
|
+ COMMANDS = {
|
|
|
+ "health": toolhub_health,
|
|
|
+ "search": toolhub_search,
|
|
|
+ "call": toolhub_call,
|
|
|
+ }
|
|
|
+
|
|
|
+ def _parse_args(argv):
|
|
|
+ kwargs = {}
|
|
|
+ for arg in argv:
|
|
|
+ if arg.startswith("--") and "=" in arg:
|
|
|
+ k, v = arg.split("=", 1)
|
|
|
+ k = k.lstrip("-").replace("-", "_")
|
|
|
+ try:
|
|
|
+ v = json.loads(v)
|
|
|
+ except (json.JSONDecodeError, ValueError):
|
|
|
+ pass
|
|
|
+ kwargs[k] = v
|
|
|
+ return kwargs
|
|
|
+
|
|
|
+ if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
|
|
|
+ print(f"用法: python {sys.argv[0]} <command> [--key=value ...]")
|
|
|
+ print(f"可用命令: {', '.join(COMMANDS.keys())}")
|
|
|
+ sys.exit(0)
|
|
|
+
|
|
|
+ cmd = sys.argv[1]
|
|
|
+ if cmd not in COMMANDS:
|
|
|
+ print(f"未知命令: {cmd},可用: {', '.join(COMMANDS.keys())}")
|
|
|
+ sys.exit(1)
|
|
|
+
|
|
|
+ import asyncio
|
|
|
+ import uuid
|
|
|
+ import os
|
|
|
+
|
|
|
+ kwargs = _parse_args(sys.argv[2:])
|
|
|
+
|
|
|
+ # trace_id:CLI 参数 > 环境变量 > 自动生成(用于图片输出目录)
|
|
|
+ trace_id = kwargs.pop("trace_id", None) or os.getenv("TRACE_ID") or f"cli-{uuid.uuid4().hex[:8]}"
|
|
|
+ set_trace_context(trace_id)
|
|
|
+
|
|
|
+ result = asyncio.run(COMMANDS[cmd](**kwargs))
|
|
|
+
|
|
|
+ # 修复双重 JSON 编码:如果 output 已经是一段 JSON 字符串(toolhub_call 内部
|
|
|
+ # 把 result dict 做过 json.dumps),解析回原生 dict 再嵌入 CLI 的最终 JSON,
|
|
|
+ # 避免调用方拿到"output 字段是被字符串化的 JSON"这种反人类形式。
|
|
|
+ output_value = result.output
|
|
|
+ if isinstance(output_value, str):
|
|
|
+ stripped = output_value.lstrip()
|
|
|
+ if stripped.startswith("{") or stripped.startswith("["):
|
|
|
+ try:
|
|
|
+ output_value = json.loads(output_value)
|
|
|
+ except (json.JSONDecodeError, ValueError):
|
|
|
+ pass # 非 JSON 文本,保持原样
|
|
|
+
|
|
|
+ out = {"trace_id": trace_id, "output": output_value}
|
|
|
+ if result.error:
|
|
|
+ out["error"] = result.error
|
|
|
+ if result.metadata:
|
|
|
+ out["metadata"] = result.metadata
|
|
|
+ print(json.dumps(out, ensure_ascii=False, indent=2))
|