From 703f4b4c218ac432b93757b25de7a7f3337945ef Mon Sep 17 00:00:00 2001 From: zhanggangyong Date: Fri, 30 Jan 2026 17:41:12 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20v2.2=20-=20=E7=AE=80=E5=8C=96=E7=89=88?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=94=9F=E6=88=90=E6=8E=A5=E5=8F=A3=20+=20TO?= =?UTF-8?q?OL=5FAPI=5FKEY=20=E8=87=AA=E5=8A=A8=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: - 新增 /external-tools/generate-simple 简化版接口,默认使用 AI 辅助 - AuthConfig 支持 token 字段(与 key 等效) - TOOL_API_KEY 环境变量自动注入到 K8s Deployment - use_ai 参数默认改为 True 修复: - 修复 gitee_manager.py 缩进错误 文档: - 更新 EXTERNAL_TOOL_API.md 文档到 v2.2 --- agent_code_generator.py | 430 +++++++++++++++++- docs/EXTERNAL_TOOL_API.md | 164 ++++++- external_tool_api.py | 318 +++++++++++-- requirements.txt | 1 + .../tool-jina_reader_v5-d57dcc49/config.json | 83 ++++ .../jina_reader_v5.py | 68 +++ 6 files changed, 1007 insertions(+), 57 deletions(-) create mode 100644 tool_storage/tool-jina_reader_v5-d57dcc49/config.json create mode 100644 tool_storage/tool-jina_reader_v5-d57dcc49/jina_reader_v5.py diff --git a/agent_code_generator.py b/agent_code_generator.py index 17f8517..16883d7 100644 --- a/agent_code_generator.py +++ b/agent_code_generator.py @@ -101,12 +101,111 @@ class AgentCodeGenerator: } return type_map.get(json_type, "Any") - def generate_tool_code(self, tool_config: dict) -> str: + async def generate_tool_code_with_ai(self, tool_config: dict, api_key: str = None) -> str: """ - 根据工具配置生成 Pydantic 工具代码 + 使用 AI 理解工具定义并生成代码(更智能,支持复杂场景如 URL 拼接) Args: - tool_config: 包含 name, url, method, auth, request_params 等 + tool_config: 工具配置 + api_key: LLM API Key + + Returns: + AI 生成的 Python 代码 + """ + import httpx + + # 使用实例配置或环境变量(默认使用 claude-sonnet 生成高质量代码) + llm_url = self.llm_base_url + llm_model = self.llm_model # 默认: taiji/claude-sonnet-4-5 + llm_key = api_key or self.llm_api_key + + if not llm_key: + # 如果没有 API Key,回退到模板生成 + logger.warning("未提供 LLM API Key,使用模板生成") + return self.generate_tool_code(tool_config) + + name = tool_config.get("name", "custom_tool") + func_name = self._convert_name_to_python(name) + + # 构建 prompt + prompt = f"""你是一个 Python 代码生成专家。根据以下工具定义,生成一个异步 Python 函数。 + +## 工具定义 +- 名称: {tool_config.get("name")} +- 描述: {tool_config.get("description")} +- API URL: {tool_config.get("url")} +- HTTP 方法: {tool_config.get("method", "GET")} +- 认证方式: {json.dumps(tool_config.get("auth", {}), ensure_ascii=False)} +- 请求参数: {json.dumps(tool_config.get("request_params", tool_config.get("input_schema", {})), ensure_ascii=False)} +- 请求体: {json.dumps(tool_config.get("request_body", {}), ensure_ascii=False)} + +## 重要提示 +1. 如果 URL 像 `https://r.jina.ai/` 这样需要将参数拼接到路径中(如 `https://r.jina.ai/{{target_url}}`),请正确处理 URL 拼接 +2. 如果认证是 bearer token,使用环境变量 `TOOL_API_KEY` 获取 +3. 函数必须是 async def,返回 JSON 字符串 +4. 使用 httpx 作为 HTTP 客户端 +5. 包含完整的错误处理 + +## 输出格式 +只输出 Python 代码,不要其他解释。代码格式如下: + +```python +\"\"\" +工具: {{name}} +描述: {{description}} +\"\"\" +import os +import json +from typing import Optional, Any +import httpx + +async def {func_name}(...) -> str: + ... +```""" + + try: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{llm_url}/chat/completions", + headers={ + "Authorization": f"Bearer {llm_key}", + "Content-Type": "application/json" + }, + json={ + "model": llm_model, + "messages": [ + {"role": "system", "content": "你是一个专业的 Python 代码生成器,只输出代码,不要解释。"}, + {"role": "user", "content": prompt} + ], + "temperature": 0.2, + "max_tokens": 2000 + } + ) + response.raise_for_status() + + content = response.json()["choices"][0]["message"]["content"] + + # 提取代码块 + if "```python" in content: + code = content.split("```python")[1].split("```")[0].strip() + elif "```" in content: + code = content.split("```")[1].split("```")[0].strip() + else: + code = content.strip() + + logger.info(f"✅ AI 成功生成工具代码: {name}") + return code + + except Exception as e: + logger.error(f"AI 生成代码失败: {e},回退到模板生成") + return self.generate_tool_code(tool_config) + + def generate_tool_code(self, tool_config: dict) -> str: + """ + 根据工具配置生成 Pydantic 工具代码(模板方式) + + Args: + tool_config: 包含 name, url, method, auth, request_params/input_schema 等 Returns: 生成的 Python 代码字符串 @@ -117,20 +216,23 @@ class AgentCodeGenerator: url = tool_config.get("url", "") method = tool_config.get("method", "GET").upper() auth = tool_config.get("auth", {}) - request_params = tool_config.get("request_params", {}) + # 兼容 request_params 和 input_schema 两种字段名 + request_params = tool_config.get("request_params") or tool_config.get("input_schema") or {} request_body = tool_config.get("request_body", {}) timeout = tool_config.get("timeout", 30) # 构建参数 params = [] params_doc = [] + required_params = [] # 支持两种格式: - # 1. {"properties": {"symbol": {...}}} + # 1. {"properties": {"symbol": {...}}, "required": ["symbol"]} # 2. {"symbol": {...}} (直接参数格式) param_props = request_params if request_params and request_params.get("properties"): param_props = request_params["properties"] + required_params = request_params.get("required", []) elif request_params and not any(k in request_params for k in ["type", "required", "description"]): param_props = request_params else: @@ -142,7 +244,8 @@ class AgentCodeGenerator: continue p_type = self._json_type_to_python(p_info.get("type", "string")) p_desc = p_info.get("description", "") - is_required = p_info.get("required", False) + # 检查是否在 required 列表中 + is_required = p_name in required_params or p_info.get("required", False) default = p_info.get("default") if is_required: @@ -162,11 +265,14 @@ class AgentCodeGenerator: # 构建参数字典代码 params_dict_code = "" if param_props: - params_dict_code = "params = {" + params_dict_items = [] for p_name in param_props.keys(): if isinstance(param_props[p_name], dict): - params_dict_code += f'"{p_name}": {p_name}, ' - params_dict_code = params_dict_code.rstrip(", ") + "}" + params_dict_items.append(f'"{p_name}": {p_name}') + if params_dict_items: + params_dict_code = "params = {" + ", ".join(params_dict_items) + "}" + else: + params_dict_code = "params = {}" else: params_dict_code = "params = {}" @@ -175,6 +281,31 @@ class AgentCodeGenerator: key_name = auth.get("name", "apikey") params_dict_code += f'\n params["{key_name}"] = os.getenv("TOOL_API_KEY", "")' + # 生成 URL 代码(使用 api_url 避免与参数名冲突) + url_code = f'api_url = "{url}"' + + # 根据 HTTP 方法决定参数传递方式 + # POST/PUT/PATCH: 参数放到请求体 (json) + # GET/DELETE: 参数放到查询参数 (params) + if method in ["POST", "PUT", "PATCH"]: + # POST 请求:参数作为 JSON 请求体 + request_code = f'''async with httpx.AsyncClient(timeout={timeout}) as client: + response = await client.request( + method="{method}", + url=api_url, + headers=headers, + json={{k: v for k, v in params.items() if v is not None}} + )''' + else: + # GET 请求:参数作为查询参数 + request_code = f'''async with httpx.AsyncClient(timeout={timeout}) as client: + response = await client.request( + method="{method}", + url=api_url, + headers=headers, + params={{k: v for k, v in params.items() if v is not None}} + )''' + # 生成函数代码 code = f'''""" 工具: {name} @@ -197,18 +328,12 @@ async def {func_name}({params_str}) -> str: Returns: API 响应结果 (JSON 格式) """ - url = "{url}" + {url_code} {auth_headers} {params_dict_code} try: - async with httpx.AsyncClient(timeout={timeout}) as client: - response = await client.request( - method="{method}", - url=url, - headers=headers, - params={{k: v for k, v in params.items() if v is not None}} - ) + {request_code} if response.status_code == 200: return json.dumps({{ @@ -925,6 +1050,241 @@ async def batch_call_tools(request: MultiToolCallRequest, api_key: str = Depends ) +# ==================== 智能对话(Agent Chat)==================== + +# LLM 配置 +LLM_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io/v1") +LLM_MODEL = os.getenv("MODEL_NAME", "taiji/gpt-4o-mini") + +class ChatRequest(BaseModel): + """聊天请求""" + message: str + conversation_id: Optional[str] = None + user_id: Optional[str] = None + stream: bool = False + +class ChatResponse(BaseModel): + """聊天响应""" + success: bool + message: str + tools_used: List[str] = [] + conversation_id: Optional[str] = None + error: Optional[str] = None + +# 对话历史存储 +conversations: Dict[str, List[Dict]] = {{}} + +def get_tools_description() -> str: + \"\"\"生成工具描述供 LLM 使用\"\"\" + tools_desc = [] + for t in TOOL_LIST: + params = t.get("parameters", {{}}) + param_desc = ", ".join([f"{{k}}: {{v.get('type', 'string')}}" for k, v in params.items()]) + tools_desc.append(f"- {{t['name']}}: {{t['description']}}\\n 参数: {{param_desc or '无'}}") + return "\\n".join(tools_desc) + +def build_system_prompt() -> str: + \"\"\"构建系统提示\"\"\" + tools_desc = get_tools_description() + json_example = '{{"action": "tool_call", "tool": "工具名称", "parameters": {{"参数名": "参数值"}}}}' + return f\"\"\"你是一个智能助手 {{SERVER_NAME}},可以使用以下工具来帮助用户: + +{{tools_desc}} + +当用户的问题需要使用工具时,请按以下 JSON 格式回复: +{{json_example}} + +当不需要工具时,直接回复用户的问题。 + +重要规则: +1. 如果问题可以用工具解决,优先使用工具 +2. 工具调用必须严格使用上述 JSON 格式 +3. 参数名必须与工具定义匹配 +4. 一次只调用一个工具\"\"\" + +async def call_llm(messages: List[Dict], api_key: str) -> str: + \"\"\"调用 LLM - 使用请求传入的 API Key(用于计费)\"\"\" + import httpx + + if not api_key or api_key in ("sk", "sk-test", "test"): + raise ValueError("请提供有效的 API Key(用于计费)") + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{{LLM_BASE_URL}}/chat/completions", + headers={{ + "Authorization": f"Bearer {{api_key}}", + "Content-Type": "application/json" + }}, + json={{ + "model": LLM_MODEL, + "messages": messages, + "temperature": 0.3, + "max_tokens": 2000 + }} + ) + response.raise_for_status() + return response.json()["choices"][0]["message"]["content"] + +def parse_tool_call(response: str) -> Optional[Dict]: + \"\"\"解析 LLM 响应中的工具调用\"\"\" + # 方法1:尝试直接解析整个响应 + try: + data = json.loads(response.strip()) + if isinstance(data, dict) and data.get("action") == "tool_call": + return data + except json.JSONDecodeError: + pass + + # 方法2:提取 JSON 块(处理 markdown 代码块) + import re + # 匹配 ```json ... ``` 或 ``` ... ``` + code_block = re.search(r'```(?:json)?\\s*([\\s\\S]*?)```', response) + if code_block: + try: + data = json.loads(code_block.group(1).strip()) + if isinstance(data, dict) and data.get("action") == "tool_call": + return data + except json.JSONDecodeError: + pass + + # 方法3:查找 JSON 对象(从 {{ 到匹配的 }}) + start = response.find('{{') + if start == -1: + start = response.find('{{"{{"') # 处理转义 + if start == -1: + start = response.find('{{"action"') + + if start != -1: + # 找到平衡的 }} + depth = 0 + end = start + for i, c in enumerate(response[start:]): + if c == '{{': + depth += 1 + elif c == '}}': + depth -= 1 + if depth == 0: + end = start + i + 1 + break + + try: + data = json.loads(response[start:end]) + if isinstance(data, dict) and data.get("action") == "tool_call": + return data + except json.JSONDecodeError: + pass + + return None + +@app.post("/chat", response_model=ChatResponse) +async def chat(request: ChatRequest, api_key: str = Depends(verify_api_key)): + \"\"\" + 智能对话端点 - Agent 自动选择并调用工具 + + 输入自然语言,Agent 会: + 1. 理解用户意图 + 2. 自动选择合适的工具 + 3. 执行工具并返回结果 + \"\"\" + effective_user_id = request.user_id or USER_ID + tools_used = [] + + # 获取或创建对话历史 + conv_id = request.conversation_id or str(uuid.uuid4()) + if conv_id not in conversations: + conversations[conv_id] = [] + + # 构建消息 + messages = [ + {{"role": "system", "content": build_system_prompt()}} + ] + messages.extend(conversations[conv_id]) + messages.append({{"role": "user", "content": request.message}}) + + try: + # 调用 LLM + llm_response = await call_llm(messages, api_key) + + # 检查是否需要调用工具 + tool_call = parse_tool_call(llm_response) + + if tool_call and tool_call.get("tool") in TOOL_MAP: + tool_name = tool_call["tool"] + tool_params = tool_call.get("parameters", {{}}) + tools_used.append(tool_name) + + logger.info(f"🔧 调用工具: {{tool_name}}, 参数: {{tool_params}}") + + # 执行工具调用 + if effective_user_id: + handler = get_callback_handler() + with CallbackContextManager( + handler=handler, + user_id=effective_user_id, + request_id=f"chat-{{int(datetime.utcnow().timestamp())}}" + ) as ctx: + ctx.add_tool(tool_name) + tool_result = await TOOL_MAP[tool_name](**tool_params) + else: + tool_result = await TOOL_MAP[tool_name](**tool_params) + + # 将工具结果发送给 LLM 生成最终回复 + messages.append({{"role": "assistant", "content": llm_response}}) + messages.append({{"role": "user", "content": f"工具 {{tool_name}} 返回结果:{{tool_result}}\\n\\n请根据这个结果回答用户的问题。"}}) + + final_response = await call_llm(messages, api_key) + + # 保存对话历史 + conversations[conv_id].append({{"role": "user", "content": request.message}}) + conversations[conv_id].append({{"role": "assistant", "content": final_response}}) + + return ChatResponse( + success=True, + message=final_response, + tools_used=tools_used, + conversation_id=conv_id + ) + else: + # 不需要工具,直接返回 LLM 回复 + conversations[conv_id].append({{"role": "user", "content": request.message}}) + conversations[conv_id].append({{"role": "assistant", "content": llm_response}}) + + return ChatResponse( + success=True, + message=llm_response, + tools_used=[], + conversation_id=conv_id + ) + + except Exception as e: + logger.error(f"聊天失败: {{e}}") + import traceback + traceback.print_exc() + return ChatResponse( + success=False, + message="", + error=str(e), + conversation_id=conv_id + ) + + +@app.get("/chat/history/{{conversation_id}}") +async def get_chat_history(conversation_id: str): + \"\"\"获取对话历史\"\"\" + if conversation_id not in conversations: + raise HTTPException(status_code=404, detail="对话不存在") + return {{"conversation_id": conversation_id, "messages": conversations[conversation_id]}} + + +@app.delete("/chat/history/{{conversation_id}}") +async def clear_chat_history(conversation_id: str): + \"\"\"清除对话历史\"\"\" + if conversation_id in conversations: + del conversations[conversation_id] + return {{"success": True, "message": "对话历史已清除"}} + + if __name__ == '__main__': import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) @@ -1208,7 +1568,8 @@ class CallbackContextManager: cpu_limit: str = "500m", memory_request: str = "128Mi", memory_limit: str = "512Mi", - replicas: int = 1 + replicas: int = 1, + tool_api_keys: List[str] = None ) -> str: """ 生成 Gitea Actions CI/CD 配置 @@ -1223,10 +1584,29 @@ class CallbackContextManager: memory_request: 内存请求 (如 128Mi, 256Mi) memory_limit: 内存限制 (如 512Mi, 1Gi) replicas: 副本数量 + tool_api_keys: 工具 API 密钥列表(将注入到容器环境变量) """ k8s_name = agent_name.lower().replace("_", "-").replace(" ", "-") image_repo = f"{self.acr_namespace}/{k8s_name}" + # 生成工具 API Key 环境变量配置 + tool_api_key_env = "" + if tool_api_keys: + # 如果只有一个 key,使用 TOOL_API_KEY + if len(tool_api_keys) == 1: + tool_api_key_env = f''' - name: TOOL_API_KEY + value: "{tool_api_keys[0]}"''' + else: + # 多个 key 时,使用编号 + env_lines = [] + for i, key in enumerate(tool_api_keys): + env_lines.append(f''' - name: TOOL_API_KEY_{i} + value: "{key}"''') + # 第一个 key 也设置为默认的 TOOL_API_KEY + env_lines.insert(0, f''' - name: TOOL_API_KEY + value: "{tool_api_keys[0]}"''') + tool_api_key_env = "\n".join(env_lines) + deploy_step = "" if auto_deploy: deploy_step = f''' @@ -1301,6 +1681,7 @@ class CallbackContextManager: value: "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io/v1" - name: MODEL_NAME value: "taiji/gpt-4o-mini" +{tool_api_key_env} resources: requests: cpu: "{cpu_request}" @@ -1642,6 +2023,16 @@ MIT License # 生成 requirements.txt files["requirements.txt"] = self.generate_requirements() + # 从工具配置中提取 API Keys + tool_api_keys = [] + for tool in tools_config: + auth = tool.get("auth") + if auth: + # 兼容 token 和 key 字段 + api_key = auth.get("token") or auth.get("key") + if api_key: + tool_api_keys.append(api_key) + # 生成 CI/CD 配置 files[".gitea/workflows/ci-cd.yaml"] = self.generate_gitea_action( agent_name=k8s_name, @@ -1650,7 +2041,8 @@ MIT License cpu_limit=cpu_limit, memory_request=memory_request, memory_limit=memory_limit, - replicas=replicas + replicas=replicas, + tool_api_keys=tool_api_keys if tool_api_keys else None ) # 生成 README diff --git a/docs/EXTERNAL_TOOL_API.md b/docs/EXTERNAL_TOOL_API.md index a9252ca..97f3080 100644 --- a/docs/EXTERNAL_TOOL_API.md +++ b/docs/EXTERNAL_TOOL_API.md @@ -1,6 +1,6 @@ # 外部工具 API 文档 -> **版本**: 2026-01-30 v2.1 +> **版本**: 2026-01-31 v2.2 > **服务地址**: http://20.212.121.126 > **规范参考**: [Agent-Manager外部工具接口规范](http://gitee.ath.cx:3000/xiaohei/taiji-AI-PAD/src/branch/feature/chenchen/Docs/Agent-Manager%E5%A4%96%E9%83%A8%E5%B7%A5%E5%85%B7%E6%8E%A5%E5%8F%A3%E8%A7%84%E8%8C%83.md) @@ -43,6 +43,47 @@ --- +## 🆕 v2.2 新增功能 + +### 1. 简化版工具生成接口 🚀 + +新增 `/external-tools/generate-simple` 接口,**默认使用 AI 辅助生成**,只需三个核心字段: + +| 核心字段 | 说明 | +|----------|------| +| `url` | API 端点 URL | +| `auth` | 认证配置(支持 `token` 和 `key` 字段) | +| `request_body_schema` | 请求体 Schema(JSON Schema 格式) | + +### 2. AuthConfig 增强 + +认证配置现在同时支持 `token` 和 `key` 字段(兼容更多使用习惯): + +```json +{ + "type": "bearer", + "token": "your-api-token" // 与 "key" 等效 +} +``` + +### 3. TOOL_API_KEY 环境变量自动注入 ⭐ + +创建 Agent 时,系统会自动从工具配置中提取 API Key,并注入到 K8s Deployment 的环境变量中: + +```yaml +env: +- name: TOOL_API_KEY + value: "your-extracted-api-key" +``` + +工具代码可以通过 `os.getenv("TOOL_API_KEY")` 获取。 + +### 4. use_ai 默认开启 + +`/external-tools/generate` 接口的 `use_ai` 参数现在默认为 `True`,AI 会更智能地生成工具代码。 + +--- + ## 🆕 v2.1 新增功能 ### 1. 资源配置支持 @@ -127,7 +168,8 @@ X-User-ID: # 可选,用于计费 | 序号 | 接口 | 方法 | 说明 | |------|------|------|------| -| 1 | `/external-tools/generate` | POST | 生成外部数据工具 | +| 1 | `/external-tools/generate` | POST | 生成外部数据工具(完整版) | +| **1.1** | **`/external-tools/generate-simple`** | **POST** | **🆕 简化版工具生成(AI 辅助,推荐)** | | 2 | `/external-tools/{tool_ref_id}` | GET | 获取工具详情 | | 3 | `/external-tools/{tool_ref_id}` | PUT | 更新外部数据工具 | | 4 | `/external-tools/{tool_ref_id}` | DELETE | 删除外部数据工具 | @@ -135,8 +177,8 @@ X-User-ID: # 可选,用于计费 | 6 | `/external-tools/{tool_ref_id}/code` | GET | 获取生成的代码 | | 7 | `/external-tools/` | GET | 列出所有工具 | | 8 | `/external-tools/agents/create-with-tools` | POST | 创建带工具的 Agent | -| 9 | `/external-tools/agents/{agent_ref_id}/build-status` | GET | 🆕 查询构建状态 | -| 10 | `/external-tools/agents/{agent_ref_id}/deployment-info` | GET | 🆕 查询部署信息 | +| 9 | `/external-tools/agents/{agent_ref_id}/build-status` | GET | 查询构建状态 | +| 10 | `/external-tools/agents/{agent_ref_id}/deployment-info` | GET | 查询部署信息 | | 11 | `/agents` | POST | 创建 Agent(支持 tool_refs 字段) | --- @@ -253,6 +295,118 @@ curl -X POST http://20.212.121.126/external-tools/generate \ --- +## 1.1️⃣ 🆕 简化版工具生成(推荐) + +### 接口 + +``` +POST /external-tools/generate-simple +``` + +### 功能描述 + +简化版工具生成接口,**默认使用 AI 辅助生成**,只需提供三个核心字段。AI 会智能理解您的配置并生成高质量的 Pydantic AI 工具代码。 + +### 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| name | string | ✅ | 工具名称(1-100 字符) | +| url | string | ✅ | **核心字段** - API 端点 URL | +| method | string | ❌ | HTTP 方法,默认 `POST` | +| user_id | string | ❌ | 用户 ID,默认 `default` | +| auth | object | ❌ | **核心字段** - 认证配置(支持 `token` 或 `key`) | +| request_body_schema | object | ❌ | **核心字段** - 请求体 Schema(JSON Schema 格式) | +| request_params | object | ❌ | URL 查询参数定义 | +| headers | object | ❌ | 自定义请求头 | +| description | string | ❌ | 工具描述(可选,AI 会自动推断) | +| api_key | string | ❌ | LLM API Key(可选,使用系统默认) | + +### 认证配置(支持两种字段名) + +```json +// 方式1: 使用 token 字段 +{ + "type": "bearer", + "token": "your-api-token" +} + +// 方式2: 使用 key 字段 +{ + "type": "bearer", + "key": "your-api-key" +} +``` + +### 请求示例 + +```bash +curl -X POST http://20.212.121.126/external-tools/generate-simple \ + -H "Content-Type: application/json" \ + -d '{ + "name": "jina_reader", + "url": "https://r.jina.ai/", + "method": "POST", + "user_id": "test-user", + "auth": { + "type": "bearer", + "token": "jina_xxxxxxxxxxxxxx" + }, + "request_body_schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "要爬取的网页URL" + } + }, + "required": ["url"] + } + }' +``` + +### 响应示例 + +```json +{ + "success": true, + "data": { + "tool_ref_id": "tool-jina_reader-d57dcc49", + "name": "jina_reader", + "description": "调用 jina_reader API,参数: url", + "url": "https://r.jina.ai/", + "method": "POST", + "has_auth": true, + "created_at": "2026-01-30T17:25:13.855295" + }, + "message": "工具生成成功 (AI 辅助)" +} +``` + +### AI 生成的代码示例 + +AI 会智能理解 API 的调用方式,例如 Jina Reader API 需要将目标 URL 拼接到路径中: + +```python +async def jina_reader(url: str) -> str: + """调用 jina_reader API 爬取网页内容""" + api_key = os.getenv("TOOL_API_KEY", "default_key") + + # AI 正确理解了 URL 拼接方式 + api_url = f"https://r.jina.ai/{url}" + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post(api_url, headers=headers) + # ... 完整的错误处理 +``` + +--- + ## 2️⃣ 更新外部数据工具 ### 接口 @@ -778,3 +932,5 @@ curl "http://${DOMAIN}/" |------|------|----------| | 2026-01-29 | v1.0 | 初始版本,实现 MCP-Server 外部工具接口规范 | | 2026-01-29 | v2.0 | 新增回调功能(计费)、多工具支持、构建状态查询、部署信息查询 | +| 2026-01-30 | v2.1 | 新增资源配置参数(cpu_request, cpu_limit, memory_request, memory_limit, replicas) | +| 2026-01-31 | v2.2 | 🆕 新增简化版工具生成接口 `/generate-simple`、AuthConfig 支持 `token` 字段、TOOL_API_KEY 环境变量自动注入、use_ai 默认开启 | \ No newline at end of file diff --git a/external_tool_api.py b/external_tool_api.py index b87fc1e..fad1e6f 100644 --- a/external_tool_api.py +++ b/external_tool_api.py @@ -36,6 +36,7 @@ class AuthConfig(BaseModel): """认证配置""" type: str = Field(..., description="认证类型: api_key, bearer, basic") key: Optional[str] = Field(None, description="API Key 或 Bearer Token") + token: Optional[str] = Field(None, description="Bearer Token(与 key 等效,兼容字段)") username: Optional[str] = Field(None, description="Basic Auth 用户名") password: Optional[str] = Field(None, description="Basic Auth 密码") in_location: Optional[str] = Field("header", alias="in", description="API Key 位置: header, query") @@ -43,6 +44,10 @@ class AuthConfig(BaseModel): class Config: populate_by_name = True + + def get_token_or_key(self) -> Optional[str]: + """获取 token 或 key(兼容两种字段名)""" + return self.token or self.key class RetryConfig(BaseModel): @@ -66,9 +71,38 @@ class GenerateToolRequest(BaseModel): auth: Optional[AuthConfig] = Field(None, description="认证配置") request_params: Optional[Dict] = Field(None, description="URL 查询参数定义(JSON Schema 格式)") request_body: Optional[Dict] = Field(None, description="请求体定义(JSON Schema 格式)") + input_schema: Optional[Dict] = Field(None, description="输入参数定义(兼容字段,等同于 request_params)") response_mapping: Optional[Dict] = Field(None, description="响应字段映射") timeout: int = Field(30, description="超时时间(秒),默认 30") retry: Optional[RetryConfig] = Field(None, description="重试配置") + use_ai: bool = Field(True, description="是否使用 AI 智能生成代码(默认开启,支持复杂场景如 URL 拼接)") + api_key: Optional[str] = Field(None, description="LLM API Key(use_ai=true 时需要)") + + +class SimpleGenerateToolRequest(BaseModel): + """ + 简化版工具生成请求 + 只需要三个核心字段:url、auth、request_body_schema + 默认使用 AI 辅助生成 + """ + name: str = Field(..., min_length=1, max_length=100, description="工具名称") + description: Optional[str] = Field(None, description="工具描述(可选,AI 会自动推断)") + url: str = Field(..., description="API 端点 URL") + method: str = Field("POST", description="HTTP 方法,默认 POST") + user_id: str = Field("default", description="用户 ID") + + # 核心三要素 + auth: Optional[AuthConfig] = Field(None, description="认证配置") + request_body_schema: Optional[Dict] = Field(None, description="请求体 Schema (JSON Schema 格式)") + request_params: Optional[Dict] = Field(None, description="URL 查询参数定义(可选)") + headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头(可选)") + + # AI 生成相关 + api_key: Optional[str] = Field(None, description="LLM API Key(可选,使用系统默认)") + + class Config: + # 允许额外字段,让 AI 可以处理任意用户输入 + extra = "allow" class UpdateToolRequest(BaseModel): @@ -157,6 +191,17 @@ async def generate_tool(request: GenerateToolRequest): # 生成唯一 tool_ref_id tool_ref_id = f"tool-{request.name.lower().replace(' ', '-')}-{uuid.uuid4().hex[:8]}" + # 合并 request_params 和 input_schema(兼容两种字段名) + merged_params = request.request_params or request.input_schema + + # 处理认证配置 - 兼容 token 和 key 字段 + auth_config = None + if request.auth: + auth_config = request.auth.model_dump(by_alias=True) + # 兼容 token 字段:如果用户使用 token,将其映射到 key + if auth_config.get("token") and not auth_config.get("key"): + auth_config["key"] = auth_config["token"] + # 构建工具配置 tool_config = { "name": request.name, @@ -164,8 +209,9 @@ async def generate_tool(request: GenerateToolRequest): "url": request.url, "method": request.method.upper(), "headers": request.headers, - "auth": request.auth.model_dump(by_alias=True) if request.auth else None, - "request_params": request.request_params, + "auth": auth_config, + "request_params": merged_params, + "input_schema": merged_params, # 保留两种格式供 AI 理解 "request_body": request.request_body, "response_mapping": request.response_mapping, "timeout": request.timeout, @@ -173,7 +219,29 @@ async def generate_tool(request: GenerateToolRequest): } # 生成 Pydantic AI 工具代码 - tool_code = agent_code_generator.generate_tool_code(tool_config) + if request.use_ai: + # 使用 AI 智能生成(支持复杂场景) + import asyncio + import concurrent.futures + logger.info(f"🤖 使用 AI 生成工具代码: {request.name}") + + # 在新线程中运行异步代码 + def run_async(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete( + agent_code_generator.generate_tool_code_with_ai(tool_config, request.api_key) + ) + finally: + loop.close() + + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(run_async) + tool_code = future.result(timeout=90) + else: + # 使用模板生成 + tool_code = agent_code_generator.generate_tool_code(tool_config) # 存储工具配置和代码 save_result = tool_storage.save_tool( @@ -217,6 +285,151 @@ async def generate_tool(request: GenerateToolRequest): } +@router.post("/generate-simple") +async def generate_tool_simple(request: SimpleGenerateToolRequest): + """ + 🚀 简化版工具生成接口 - 默认使用 AI 辅助 + + 只需要提供三个核心字段: + 1. url - API 端点 URL + 2. auth - 认证配置(bearer/api_key/basic) + 3. request_body_schema - 请求体 Schema + + AI 会智能理解您的配置并生成高质量的 Pydantic AI 工具代码。 + + 示例请求: + ```json + { + "name": "jina_reader", + "url": "https://r.jina.ai/", + "method": "POST", + "user_id": "test-user", + "auth": { + "type": "bearer", + "token": "your-token-here" + }, + "request_body_schema": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "要爬取的网页URL"} + }, + "required": ["url"] + } + } + ``` + """ + try: + # 验证 URL 格式 + if not request.url.startswith(("http://", "https://")): + return { + "success": False, + "error": "invalid_url", + "message": "URL 必须以 http:// 或 https:// 开头" + } + + # 生成唯一 tool_ref_id + tool_ref_id = f"tool-{request.name.lower().replace(' ', '-')}-{uuid.uuid4().hex[:8]}" + + # 处理认证配置 - 兼容 token 和 key 字段 + auth_config = None + if request.auth: + auth_config = request.auth.model_dump(by_alias=True) + # 兼容 token 字段:如果用户使用 token,将其映射到 key + if auth_config.get("token") and not auth_config.get("key"): + auth_config["key"] = auth_config["token"] + + # 自动推断描述(如果未提供) + description = request.description + if not description: + description = f"调用 {request.name} API" + if request.request_body_schema: + props = request.request_body_schema.get("properties", {}) + if props: + param_names = list(props.keys())[:3] + description += f",参数: {', '.join(param_names)}" + + # 构建完整的工具配置 - 保留用户原始输入供 AI 理解 + tool_config = { + "name": request.name, + "description": description, + "url": request.url, + "method": request.method.upper(), + "headers": request.headers, + "auth": auth_config, + # 支持两种参数格式 + "request_body": request.request_body_schema, + "request_params": request.request_params, + "input_schema": request.request_body_schema or request.request_params, + "timeout": 30, + # 保存原始请求供 AI 参考(可能包含额外字段) + "_original_request": request.model_dump(exclude_unset=False) + } + + # 🤖 默认使用 AI 生成(更智能、更灵活) + import asyncio + import concurrent.futures + + logger.info(f"🤖 [简化模式] 使用 AI 生成工具代码: {request.name}") + + def run_async(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete( + agent_code_generator.generate_tool_code_with_ai(tool_config, request.api_key) + ) + finally: + loop.close() + + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(run_async) + tool_code = future.result(timeout=90) + + # 存储工具配置和代码 + save_result = tool_storage.save_tool( + tool_ref_id=tool_ref_id, + name=request.name, + description=description, + config=tool_config, + code=tool_code, + user_id=request.user_id, + tenant_id=None + ) + + if not save_result.get("success"): + return { + "success": False, + "error": "generation_failed", + "message": save_result.get("error", "工具保存失败") + } + + logger.info(f"✅ [简化模式] 工具生成成功: {tool_ref_id}") + + return { + "success": True, + "data": { + "tool_ref_id": tool_ref_id, + "name": request.name, + "description": description, + "url": request.url, + "method": request.method.upper(), + "has_auth": bool(request.auth), + "created_at": datetime.utcnow().isoformat() + }, + "message": "工具生成成功 (AI 辅助)" + } + + except Exception as e: + logger.error(f"[简化模式] 工具生成失败: {e}") + import traceback + logger.error(traceback.format_exc()) + return { + "success": False, + "error": "generation_failed", + "message": str(e) + } + + @router.put("/{tool_ref_id}") async def update_tool(tool_ref_id: str, request: UpdateToolRequest): """ @@ -392,7 +605,8 @@ async def test_tool(tool_ref_id: str, request: TestToolRequest = None): if auth_type == "api_key": location = auth.get("in", "header") key_name = auth.get("name", "X-API-Key") - key_value = auth.get("key", "") + # 兼容 token 和 key 字段 + key_value = auth.get("token") or auth.get("key", "") if location == "header": headers[key_name] = key_value @@ -400,7 +614,9 @@ async def test_tool(tool_ref_id: str, request: TestToolRequest = None): params[key_name] = key_value elif auth_type == "bearer": - headers["Authorization"] = f"Bearer {auth.get('key', '')}" + # 兼容 token 和 key 字段 + token_value = auth.get("token") or auth.get("key", "") + headers["Authorization"] = f"Bearer {token_value}" elif auth_type == "basic": import base64 @@ -639,13 +855,19 @@ async def create_agent_with_tools(request: CreateAgentWithToolsRequest): "timeout": config.get("timeout", 30) }) - # 生成仓库名 - repo_name = f"agent-{request.name.lower().replace('_', '-')}-{uuid.uuid4().hex[:6]}" + # 生成唯一后缀,确保不同用户创建同名 Agent 不会冲突 + unique_suffix = uuid.uuid4().hex[:6] + base_name = request.name.lower().replace("_", "-").replace(" ", "-") + + # k8s_name 带唯一后缀,避免域名/namespace 冲突 + k8s_name = f"{base_name}-{unique_suffix}" + repo_name = f"agent-{k8s_name}" agent_ref_id = f"agent-{repo_name}" # 生成完整项目文件(传递资源配置) + # 使用 k8s_name 作为 agent_name,确保 CI/CD 中创建的 DNS/namespace 与 API 返回一致 project_files = agent_code_generator.generate_full_project( - agent_name=request.name, + agent_name=k8s_name, description=f"Agent with {len(tools)} external tools", tools_config=tools_config, auto_deploy=True, @@ -673,15 +895,8 @@ async def create_agent_with_tools(request: CreateAgentWithToolsRequest): # 获取仓库所有者 repo_owner = repo_result.get("owner", gitee_manager.gitee_username) - # 推送文件 - push_result = gitee_manager.push_files( - repo_name=repo_name, - files=project_files, - commit_message=f"Initial commit: {request.name} with {len(tools)} tools", - owner=repo_owner - ) - - # 设置 CI/CD Secrets - 使用新生成的 Azure 凭证 + # ⚠️ 重要:先设置 CI/CD Secrets,再推送文件 + # 因为推送文件会触发 CI/CD,必须确保 secrets 已经就绪 cicd_secrets = { "ACR_LOGIN_SERVER": "agnettaiji.azurecr.io", "ACR_USERNAME": "agnettaiji", @@ -696,29 +911,47 @@ async def create_agent_with_tools(request: CreateAgentWithToolsRequest): "AZURE_DNS_RG": "taiji-Ai-v0" } - gitee_manager.set_repo_secrets( + logger.info(f"📝 设置 CI/CD Secrets(共 {len(cicd_secrets)} 个)...") + secrets_result = gitee_manager.set_repo_secrets( repo_name=repo_name, secrets=cicd_secrets, owner=repo_owner ) + if not secrets_result.get("success"): + logger.warning(f"⚠️ 部分 Secrets 设置可能失败: {secrets_result}") + + # 等待一小段时间确保 secrets 生效 + import time + time.sleep(1) + + # 推送文件(这会触发 CI/CD) + logger.info(f"📤 推送项目文件...") + push_result = gitee_manager.push_files( + repo_name=repo_name, + files=project_files, + commit_message=f"Initial commit: {request.name} with {len(tools)} tools", + owner=repo_owner + ) + # 标记工具被使用 for ref in request.tool_refs: tool_storage.mark_tool_in_use(ref, request.name) - # 计算 K8s 相关名称 - k8s_name = repo_name.lower().replace("_", "-").replace(" ", "-") + # 计算域名和 namespace(k8s_name 已在上面定义,带唯一后缀) expected_domain = f"{k8s_name}.taijiagnet.com" - agent_ref_id = f"agent-{repo_name}" + namespace = f"agent-{k8s_name}" # 存储 Agent 信息以便后续查询 AGENT_REFS[agent_ref_id] = { "agent_ref_id": agent_ref_id, - "name": request.name, + "name": request.name, # 原始名称(用户输入) + "display_name": request.name, # 显示名称 + "k8s_name": k8s_name, # K8s 名称(带唯一后缀,用于域名/namespace) "repo_name": repo_name, "repo_url": repo_result.get("html_url"), "repo_owner": repo_owner, - "namespace": f"agent-{k8s_name}", + "namespace": namespace, "domain": expected_domain, "image_name": f"agnettaiji.azurecr.io/ai-agents/{repo_name}:latest", "tools": [t["name"] for t in tools], @@ -736,7 +969,7 @@ async def create_agent_with_tools(request: CreateAgentWithToolsRequest): "success": True, "name": request.name, "agent_ref_id": agent_ref_id, - "namespace": f"agent-{k8s_name}", + "namespace": namespace, "status": "Building", "created_at": datetime.utcnow().isoformat(), "template": request.template, @@ -862,9 +1095,19 @@ async def get_agent_build_status(agent_ref_id: str): elif action_status.get("status") == "no_runs": overall_status = "pending" - # 计算域名 - k8s_name = repo_name.lower().replace("_", "-").replace(" ", "-") + # 计算域名 - 优先使用存储的 k8s_name + if agent_info and agent_info.get("k8s_name"): + k8s_name = agent_info.get("k8s_name") + else: + # 无法从 repo_name 准确推断,使用 agent 名称 + agent_name = agent_info.get("name") if agent_info else None + if agent_name: + k8s_name = agent_name.lower().replace("_", "-").replace(" ", "-") + else: + k8s_name = repo_name.lower().replace("_", "-").replace(" ", "-") + expected_domain = f"{k8s_name}.taijiagnet.com" + namespace = f"agent-{k8s_name}" return { "success": True, @@ -887,7 +1130,7 @@ async def get_agent_build_status(agent_ref_id: str): "access_info": { "expected_domain": expected_domain, "expected_url": f"http://{expected_domain}", - "expected_namespace": f"agent-{k8s_name}" + "expected_namespace": namespace } } } @@ -913,14 +1156,21 @@ async def get_agent_deployment_info(agent_ref_id: str): - 访问 URL """ try: - # 从 agent_ref_id 推断 repo_name - if agent_ref_id.startswith("agent-"): - repo_name = agent_ref_id.replace("agent-", "", 1) - else: - repo_name = agent_ref_id + # 检查是否有存储的 agent 信息 + agent_info = AGENT_REFS.get(agent_ref_id) - k8s_name = repo_name.lower().replace("_", "-").replace(" ", "-") - namespace = f"agent-{k8s_name}" + # 从 agent_ref_id 推断 repo_name + if agent_info: + repo_name = agent_info.get("repo_name") + k8s_name = agent_info.get("k8s_name") + namespace = agent_info.get("namespace") + else: + if agent_ref_id.startswith("agent-"): + repo_name = agent_ref_id.replace("agent-", "", 1) + else: + repo_name = agent_ref_id + k8s_name = repo_name.lower().replace("_", "-").replace(" ", "-") + namespace = f"agent-{k8s_name}" # 导入 K8sManager 查询实际状态 from k8s_manager import K8sManager diff --git a/requirements.txt b/requirements.txt index 4e584ce..c4df544 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pydantic==2.5.0 python-dotenv==1.0.0 sqlalchemy==2.0.23 psycopg2-binary==2.9.9 +httpx>=0.25.0 \ No newline at end of file diff --git a/tool_storage/tool-jina_reader_v5-d57dcc49/config.json b/tool_storage/tool-jina_reader_v5-d57dcc49/config.json new file mode 100644 index 0000000..3ad1cbe --- /dev/null +++ b/tool_storage/tool-jina_reader_v5-d57dcc49/config.json @@ -0,0 +1,83 @@ +{ + "tool_ref_id": "tool-jina_reader_v5-d57dcc49", + "name": "jina_reader_v5", + "description": "调用 jina_reader_v5 API,参数: url", + "config": { + "name": "jina_reader_v5", + "description": "调用 jina_reader_v5 API,参数: url", + "url": "https://r.jina.ai/", + "method": "POST", + "headers": null, + "auth": { + "type": "bearer", + "key": "jina_e26dc30420a44a1e859216528065b203TkMRmsoz-FgMDQC5FZX9jr5oF2CI", + "token": "jina_e26dc30420a44a1e859216528065b203TkMRmsoz-FgMDQC5FZX9jr5oF2CI", + "username": null, + "password": null, + "in": "header", + "name": "X-API-Key" + }, + "request_body": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "要爬取的网页URL" + } + }, + "required": [ + "url" + ] + }, + "request_params": null, + "input_schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "要爬取的网页URL" + } + }, + "required": [ + "url" + ] + }, + "timeout": 30, + "_original_request": { + "name": "jina_reader_v5", + "description": null, + "url": "https://r.jina.ai/", + "method": "POST", + "user_id": "test-user", + "auth": { + "type": "bearer", + "key": null, + "token": "jina_e26dc30420a44a1e859216528065b203TkMRmsoz-FgMDQC5FZX9jr5oF2CI", + "username": null, + "password": null, + "in_location": "header", + "name": "X-API-Key" + }, + "request_body_schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "要爬取的网页URL" + } + }, + "required": [ + "url" + ] + }, + "request_params": null, + "headers": null, + "api_key": null + } + }, + "user_id": "test-user", + "tenant_id": null, + "created_at": "2026-01-30T17:25:13.854736", + "updated_at": "2026-01-30T17:25:13.854746", + "status": "created" +} \ No newline at end of file diff --git a/tool_storage/tool-jina_reader_v5-d57dcc49/jina_reader_v5.py b/tool_storage/tool-jina_reader_v5-d57dcc49/jina_reader_v5.py new file mode 100644 index 0000000..60b733c --- /dev/null +++ b/tool_storage/tool-jina_reader_v5-d57dcc49/jina_reader_v5.py @@ -0,0 +1,68 @@ +""" +工具: jina_reader_v5 +描述: 调用 jina_reader_v5 API,参数: url +""" +import os +import json +from typing import Optional, Any +import httpx + +async def jina_reader_v5(url: str) -> str: + """ + 调用 jina_reader_v5 API 爬取网页内容 + + Args: + url: 要爬取的网页URL + + Returns: + JSON 字符串格式的响应结果 + """ + try: + api_key = os.getenv("TOOL_API_KEY", "jina_e26dc30420a44a1e859216528065b203TkMRmsoz-FgMDQC5FZX9jr5oF2CI") + + # 将目标URL拼接到API路径中 + api_url = f"https://r.jina.ai/{url}" + + headers = { + "Authorization": f"Bearer {api_key}", + "X-API-Key": api_key, + "Content-Type": "application/json" + } + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + api_url, + headers=headers + ) + response.raise_for_status() + + # 尝试解析为JSON,如果失败则返回文本内容 + try: + result = response.json() + except json.JSONDecodeError: + result = {"content": response.text, "status": "success"} + + return json.dumps(result, ensure_ascii=False) + + except httpx.HTTPStatusError as e: + error_detail = { + "error": "HTTP错误", + "status_code": e.response.status_code, + "message": str(e), + "response": e.response.text + } + return json.dumps(error_detail, ensure_ascii=False) + + except httpx.RequestError as e: + error_detail = { + "error": "请求错误", + "message": str(e) + } + return json.dumps(error_detail, ensure_ascii=False) + + except Exception as e: + error_detail = { + "error": "未知错误", + "message": str(e) + } + return json.dumps(error_detail, ensure_ascii=False) \ No newline at end of file