From 4c467931694467906179c08f43f1a495fddbf746 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Wed, 11 Feb 2026 11:39:45 +0800 Subject: [PATCH] Make server tool http based --- api/app/models.py | 4 ++ api/app/routers/tools.py | 51 ++++++++++++++++++- api/app/schemas.py | 8 +++ api/tests/test_tools.py | 14 ++++++ engine/app/backend_client.py | 20 ++++++++ engine/core/tool_executor.py | 94 ++++++++++++++++++++++++++++++------ web/pages/ToolLibrary.tsx | 91 +++++++++++++++++++++++++++++++++- web/services/backendApi.ts | 12 +++++ web/types.ts | 4 ++ 9 files changed, 281 insertions(+), 17 deletions(-) diff --git a/api/app/models.py b/api/app/models.py index cc253aa..e32d6b1 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -92,6 +92,10 @@ class ToolResource(Base): description: Mapped[str] = mapped_column(String(512), nullable=False, default="") category: Mapped[str] = mapped_column(String(32), nullable=False, default="system") # system/query icon: Mapped[str] = mapped_column(String(64), nullable=False, default="Wrench") + http_method: Mapped[str] = mapped_column(String(16), nullable=False, default="GET") + http_url: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True) + http_headers: Mapped[dict] = mapped_column(JSON, default=dict) + http_timeout_ms: Mapped[int] = mapped_column(Integer, default=10000) enabled: Mapped[bool] = mapped_column(default=True) is_system: Mapped[bool] = mapped_column(default=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/api/app/routers/tools.py b/api/app/routers/tools.py index 2d183c5..7d6291f 100644 --- a/api/app/routers/tools.py +++ b/api/app/routers/tools.py @@ -108,11 +108,37 @@ TOOL_ICON_MAP = { "decrease_volume": "Volume2", } +TOOL_HTTP_DEFAULTS = { + "current_time": { + "http_method": "GET", + "http_url": "https://worldtimeapi.org/api/ip", + "http_headers": {}, + "http_timeout_ms": 10000, + }, +} + + +def _normalize_http_method(method: Optional[str]) -> str: + normalized = str(method or "GET").strip().upper() + return normalized if normalized in {"GET", "POST", "PUT", "PATCH", "DELETE"} else "GET" + + +def _requires_http_request(category: str, tool_id: Optional[str]) -> bool: + if category != "query": + return False + return str(tool_id or "").strip() not in {"calculator", "code_interpreter"} + + +def _validate_query_http_config(*, category: str, tool_id: Optional[str], http_url: Optional[str]) -> None: + if _requires_http_request(category, tool_id) and not str(http_url or "").strip(): + raise HTTPException(status_code=400, detail="http_url is required for query tools (except calculator/code_interpreter)") + def _seed_default_tools_if_empty(db: Session) -> None: """Seed built-in tools only when tool_resources is empty.""" if db.query(ToolResource).count() > 0: return for tool_id, payload in TOOL_REGISTRY.items(): + http_defaults = TOOL_HTTP_DEFAULTS.get(tool_id, {}) db.add(ToolResource( id=tool_id, user_id=1, @@ -120,6 +146,10 @@ def _seed_default_tools_if_empty(db: Session) -> None: description=payload.get("description", ""), category=TOOL_CATEGORY_MAP.get(tool_id, "system"), icon=TOOL_ICON_MAP.get(tool_id, "Wrench"), + http_method=_normalize_http_method(http_defaults.get("http_method")), + http_url=http_defaults.get("http_url"), + http_headers=http_defaults.get("http_headers") or {}, + http_timeout_ms=int(http_defaults.get("http_timeout_ms") or 10000), enabled=True, is_system=True, )) @@ -128,8 +158,9 @@ def _seed_default_tools_if_empty(db: Session) -> None: def recreate_tool_resources(db: Session) -> None: """Recreate tool resources table content with current built-in defaults.""" - db.query(ToolResource).delete() - db.commit() + bind = db.get_bind() + ToolResource.__table__.drop(bind=bind, checkfirst=True) + ToolResource.__table__.create(bind=bind, checkfirst=True) _seed_default_tools_if_empty(db) @@ -189,6 +220,8 @@ def create_tool_resource(data: ToolResourceCreate, db: Session = Depends(get_db) if candidate_id and db.query(ToolResource).filter(ToolResource.id == candidate_id).first(): raise HTTPException(status_code=400, detail="Tool ID already exists") + _validate_query_http_config(category=data.category, tool_id=candidate_id, http_url=data.http_url) + item = ToolResource( id=candidate_id or f"tool_{str(uuid.uuid4())[:8]}", user_id=1, @@ -196,6 +229,10 @@ def create_tool_resource(data: ToolResourceCreate, db: Session = Depends(get_db) description=data.description, category=data.category, icon=data.icon, + http_method=_normalize_http_method(data.http_method), + http_url=(data.http_url or "").strip() or None, + http_headers=data.http_headers or {}, + http_timeout_ms=max(1000, int(data.http_timeout_ms or 10000)), enabled=data.enabled, is_system=False, ) @@ -214,6 +251,16 @@ def update_tool_resource(id: str, data: ToolResourceUpdate, db: Session = Depend raise HTTPException(status_code=404, detail="Tool resource not found") update_data = data.model_dump(exclude_unset=True) + + new_category = update_data.get("category", item.category) + new_http_url = update_data.get("http_url", item.http_url) + _validate_query_http_config(category=new_category, tool_id=id, http_url=new_http_url) + + if "http_method" in update_data: + update_data["http_method"] = _normalize_http_method(update_data.get("http_method")) + if "http_timeout_ms" in update_data and update_data.get("http_timeout_ms") is not None: + update_data["http_timeout_ms"] = max(1000, int(update_data["http_timeout_ms"])) + for field, value in update_data.items(): setattr(item, field, value) item.updated_at = datetime.utcnow() diff --git a/api/app/schemas.py b/api/app/schemas.py index 6b7645e..73b3c69 100644 --- a/api/app/schemas.py +++ b/api/app/schemas.py @@ -235,6 +235,10 @@ class ToolResourceBase(BaseModel): description: str = "" category: str = "system" # system/query icon: str = "Wrench" + http_method: str = "GET" + http_url: Optional[str] = None + http_headers: Dict[str, str] = Field(default_factory=dict) + http_timeout_ms: int = 10000 enabled: bool = True @@ -247,6 +251,10 @@ class ToolResourceUpdate(BaseModel): description: Optional[str] = None category: Optional[str] = None icon: Optional[str] = None + http_method: Optional[str] = None + http_url: Optional[str] = None + http_headers: Optional[Dict[str, str]] = None + http_timeout_ms: Optional[int] = None enabled: Optional[bool] = None diff --git a/api/tests/test_tools.py b/api/tests/test_tools.py index 78666c3..c4b07d9 100644 --- a/api/tests/test_tools.py +++ b/api/tests/test_tools.py @@ -288,6 +288,10 @@ class TestToolResourceCRUD: "description": "抓取页面并提取正文", "category": "query", "icon": "Globe", + "http_method": "GET", + "http_url": "https://example.com/search", + "http_headers": {}, + "http_timeout_ms": 10000, "enabled": True, }) assert create_resp.status_code == 200 @@ -315,6 +319,16 @@ class TestToolResourceCRUD: missing_resp = client.get(f"/api/tools/resources/{tool_id}") assert missing_resp.status_code == 404 + def test_create_query_tool_requires_http_url(self, client): + resp = client.post("/api/tools/resources", json={ + "name": "缺失URL的查询工具", + "description": "应当失败", + "category": "query", + "icon": "Globe", + "enabled": True, + }) + assert resp.status_code == 400 + def test_system_tool_can_be_updated_and_deleted(self, client): list_resp = client.get("/api/tools/resources") assert list_resp.status_code == 200 diff --git a/engine/app/backend_client.py b/engine/app/backend_client.py index 9bd4e77..b750564 100644 --- a/engine/app/backend_client.py +++ b/engine/app/backend_client.py @@ -189,3 +189,23 @@ async def search_knowledge_context( except Exception as exc: logger.warning(f"Knowledge search failed (kb_id={kb_id}): {exc}") return [] + + +async def fetch_tool_resource(tool_id: str) -> Optional[Dict[str, Any]]: + """Fetch tool resource configuration from backend API.""" + base_url = _backend_base_url() + if not base_url or not tool_id: + return None + + url = f"{base_url}/api/tools/resources/{tool_id}" + try: + async with aiohttp.ClientSession(timeout=_timeout()) as session: + async with session.get(url) as resp: + if resp.status == 404: + return None + resp.raise_for_status() + data = await resp.json() + return data if isinstance(data, dict) else None + except Exception as exc: + logger.warning(f"Failed to fetch tool resource ({tool_id}): {exc}") + return None diff --git a/engine/core/tool_executor.py b/engine/core/tool_executor.py index e6fc8f2..97d4c3d 100644 --- a/engine/core/tool_executor.py +++ b/engine/core/tool_executor.py @@ -1,10 +1,13 @@ """Server-side tool execution helpers.""" +import asyncio import ast import operator -from datetime import datetime from typing import Any, Dict +import aiohttp + +from app.backend_client import fetch_tool_resource _BIN_OPS = { ast.Add: operator.add, @@ -206,19 +209,6 @@ async def execute_server_tool(tool_call: Dict[str, Any]) -> Dict[str, Any]: "status": {"code": 422, "message": "invalid_expression"}, } - if tool_name == "current_time": - now = datetime.now().astimezone() - return { - "tool_call_id": call_id, - "name": tool_name, - "output": { - "iso": now.isoformat(), - "local": now.strftime("%Y-%m-%d %H:%M:%S %Z"), - "unix": int(now.timestamp()), - }, - "status": {"code": 200, "message": "ok"}, - } - if tool_name == "code_interpreter": code = str(args.get("code") or args.get("expression") or "").strip() if not code: @@ -251,6 +241,82 @@ async def execute_server_tool(tool_call: Dict[str, Any]) -> Dict[str, Any]: "status": {"code": 422, "message": "invalid_code"}, } + if tool_name and tool_name not in {"calculator", "code_interpreter"}: + resource = await fetch_tool_resource(tool_name) + if resource and str(resource.get("category") or "") == "query": + method = str(resource.get("http_method") or "GET").strip().upper() + if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}: + method = "GET" + url = str(resource.get("http_url") or "").strip() + headers = resource.get("http_headers") if isinstance(resource.get("http_headers"), dict) else {} + timeout_ms = resource.get("http_timeout_ms") + try: + timeout_s = max(1.0, float(timeout_ms) / 1000.0) + except Exception: + timeout_s = 10.0 + + if not url: + return { + "tool_call_id": call_id, + "name": tool_name, + "output": {"error": "http_url not configured"}, + "status": {"code": 422, "message": "invalid_tool_config"}, + } + + request_kwargs: Dict[str, Any] = {} + if method in {"GET", "DELETE"}: + request_kwargs["params"] = args + else: + request_kwargs["json"] = args + + try: + timeout = aiohttp.ClientTimeout(total=timeout_s) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.request(method, url, headers=headers, **request_kwargs) as resp: + content_type = str(resp.headers.get("Content-Type") or "").lower() + if "application/json" in content_type: + body: Any = await resp.json() + else: + body = await resp.text() + status_code = int(resp.status) + if 200 <= status_code < 300: + return { + "tool_call_id": call_id, + "name": tool_name, + "output": { + "method": method, + "url": url, + "status_code": status_code, + "response": _json_safe(body), + }, + "status": {"code": 200, "message": "ok"}, + } + return { + "tool_call_id": call_id, + "name": tool_name, + "output": { + "method": method, + "url": url, + "status_code": status_code, + "response": _json_safe(body), + }, + "status": {"code": status_code, "message": "http_error"}, + } + except asyncio.TimeoutError: + return { + "tool_call_id": call_id, + "name": tool_name, + "output": {"method": method, "url": url, "error": "request timeout"}, + "status": {"code": 504, "message": "http_timeout"}, + } + except Exception as exc: + return { + "tool_call_id": call_id, + "name": tool_name, + "output": {"method": method, "url": url, "error": str(exc)}, + "status": {"code": 502, "message": "http_request_failed"}, + } + return { "tool_call_id": call_id, "name": tool_name or "unknown_tool", diff --git a/web/pages/ToolLibrary.tsx b/web/pages/ToolLibrary.tsx index 9cbc115..426cce8 100644 --- a/web/pages/ToolLibrary.tsx +++ b/web/pages/ToolLibrary.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Search, Filter, Plus, Wrench, Terminal, Globe, Camera, CameraOff, Image, Images, CloudSun, Calendar, TrendingUp, Coins, Trash2, Edit2, Box } from 'lucide-react'; +import { Search, Filter, Plus, Wrench, Terminal, Globe, Camera, CameraOff, Image, Images, CloudSun, Calendar, TrendingUp, Coins, Trash2, Edit2, Box, Volume2 } from 'lucide-react'; import { Button, Input, Badge, Dialog } from '../components/UI'; import { Tool } from '../types'; import { createTool, deleteTool, fetchTools, updateTool } from '../services/backendApi'; @@ -17,6 +17,7 @@ const iconMap: Record = { Globe: , Wrench: , Box: , + Volume2: , }; export const ToolLibraryPage: React.FC = () => { @@ -32,6 +33,10 @@ export const ToolLibraryPage: React.FC = () => { const [toolCategory, setToolCategory] = useState<'system' | 'query'>('system'); const [toolIcon, setToolIcon] = useState('Wrench'); const [toolEnabled, setToolEnabled] = useState(true); + const [toolHttpMethod, setToolHttpMethod] = useState<'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'>('GET'); + const [toolHttpUrl, setToolHttpUrl] = useState(''); + const [toolHttpHeadersText, setToolHttpHeadersText] = useState('{}'); + const [toolHttpTimeoutMs, setToolHttpTimeoutMs] = useState(10000); const [saving, setSaving] = useState(false); const loadTools = async () => { @@ -57,6 +62,10 @@ export const ToolLibraryPage: React.FC = () => { setToolCategory('system'); setToolIcon('Wrench'); setToolEnabled(true); + setToolHttpMethod('GET'); + setToolHttpUrl(''); + setToolHttpHeadersText('{}'); + setToolHttpTimeoutMs(10000); setIsToolModalOpen(true); }; @@ -67,6 +76,10 @@ export const ToolLibraryPage: React.FC = () => { setToolCategory(tool.category); setToolIcon(tool.icon || 'Wrench'); setToolEnabled(tool.enabled ?? true); + setToolHttpMethod((tool.httpMethod || 'GET') as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'); + setToolHttpUrl(tool.httpUrl || ''); + setToolHttpHeadersText(JSON.stringify(tool.httpHeaders || {}, null, 2)); + setToolHttpTimeoutMs(tool.httpTimeoutMs || 10000); setIsToolModalOpen(true); }; @@ -88,12 +101,37 @@ export const ToolLibraryPage: React.FC = () => { try { setSaving(true); + let parsedHeaders: Record = {}; + if (toolCategory === 'query') { + if (!toolHttpUrl.trim() && editingTool?.id !== 'calculator' && editingTool?.id !== 'code_interpreter') { + alert('信息查询工具请填写 HTTP URL'); + setSaving(false); + return; + } + try { + const parsed = JSON.parse(toolHttpHeadersText || '{}'); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + parsedHeaders = parsed as Record; + } else { + throw new Error('headers must be object'); + } + } catch { + alert('HTTP Headers 必须是合法 JSON 对象'); + setSaving(false); + return; + } + } + if (editingTool) { const updated = await updateTool(editingTool.id, { name: toolName.trim(), description: toolDesc, category: toolCategory, icon: toolIcon, + httpMethod: toolHttpMethod, + httpUrl: toolHttpUrl.trim(), + httpHeaders: parsedHeaders, + httpTimeoutMs: toolHttpTimeoutMs, enabled: toolEnabled, }); setTools((prev) => prev.map((item) => (item.id === updated.id ? updated : item))); @@ -103,6 +141,10 @@ export const ToolLibraryPage: React.FC = () => { description: toolDesc, category: toolCategory, icon: toolIcon, + httpMethod: toolHttpMethod, + httpUrl: toolHttpUrl.trim(), + httpHeaders: parsedHeaders, + httpTimeoutMs: toolHttpTimeoutMs, enabled: toolEnabled, }); setTools((prev) => [created, ...prev]); @@ -294,6 +336,53 @@ export const ToolLibraryPage: React.FC = () => { /> + {toolCategory === 'query' && ( +
+
HTTP Request Config
+
+
+ + +
+
+ + setToolHttpUrl(e.target.value)} placeholder="https://api.example.com/endpoint" /> +
+
+
+ +