diff --git a/api/app/models.py b/api/app/models.py index 4e4c21a..c69bd78 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -82,6 +82,24 @@ class ASRModel(Base): user = relationship("User") +# ============ Tool Resource ============ +class ToolResource(Base): + __tablename__ = "tool_resources" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id"), index=True, nullable=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + 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") + 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) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + user = relationship("User") + + # ============ Assistant ============ class Assistant(Base): __tablename__ = "assistants" diff --git a/api/app/routers/tools.py b/api/app/routers/tools.py index 1c34622..5c42fc3 100644 --- a/api/app/routers/tools.py +++ b/api/app/routers/tools.py @@ -1,12 +1,14 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List import time import uuid import httpx +from datetime import datetime from ..db import get_db -from ..models import LLMModel, ASRModel +from ..models import LLMModel, ASRModel, ToolResource +from ..schemas import ToolResourceCreate, ToolResourceOut, ToolResourceUpdate router = APIRouter(prefix="/tools", tags=["Tools & Autotest"]) @@ -83,6 +85,36 @@ TOOL_REGISTRY = { }, } +TOOL_CATEGORY_MAP = { + "search": "query", + "weather": "query", + "translate": "query", + "knowledge": "query", + "calculator": "system", + "code_interpreter": "system", +} + +TOOL_ICON_MAP = { + "search": "Globe", + "weather": "CloudSun", + "translate": "Globe", + "knowledge": "Box", + "calculator": "Terminal", + "code_interpreter": "Terminal", +} + + +def _builtin_tool_to_resource(tool_id: str, payload: Dict[str, Any]) -> Dict[str, Any]: + return { + "id": tool_id, + "name": payload.get("name", tool_id), + "description": payload.get("description", ""), + "category": TOOL_CATEGORY_MAP.get(tool_id, "system"), + "icon": TOOL_ICON_MAP.get(tool_id, "Wrench"), + "enabled": True, + "is_system": True, + } + @router.get("/list") def list_available_tools(): @@ -98,6 +130,122 @@ def get_tool_detail(tool_id: str): return TOOL_REGISTRY[tool_id] +# ============ Tool Resource CRUD ============ +@router.get("/resources") +def list_tool_resources( + category: Optional[str] = None, + enabled: Optional[bool] = None, + include_system: bool = True, + page: int = 1, + limit: int = 100, + db: Session = Depends(get_db), +): + """获取工具资源列表(内置工具 + 自定义工具)。""" + merged: List[Dict[str, Any]] = [] + + if include_system: + for tool_id, payload in TOOL_REGISTRY.items(): + merged.append(_builtin_tool_to_resource(tool_id, payload)) + + query = db.query(ToolResource) + if category: + query = query.filter(ToolResource.category == category) + if enabled is not None: + query = query.filter(ToolResource.enabled == enabled) + custom_tools = query.order_by(ToolResource.created_at.desc()).all() + + for item in custom_tools: + merged.append({ + "id": item.id, + "name": item.name, + "description": item.description, + "category": item.category, + "icon": item.icon, + "enabled": item.enabled, + "is_system": item.is_system, + }) + + if category: + merged = [item for item in merged if item.get("category") == category] + if enabled is not None: + merged = [item for item in merged if item.get("enabled") == enabled] + + total = len(merged) + start = max(page - 1, 0) * limit + end = start + limit + return {"total": total, "page": page, "limit": limit, "list": merged[start:end]} + + +@router.get("/resources/{id}", response_model=ToolResourceOut) +def get_tool_resource(id: str, db: Session = Depends(get_db)): + """获取单个工具资源详情。""" + if id in TOOL_REGISTRY: + tool = _builtin_tool_to_resource(id, TOOL_REGISTRY[id]) + return ToolResourceOut(**tool) + + item = db.query(ToolResource).filter(ToolResource.id == id).first() + if not item: + raise HTTPException(status_code=404, detail="Tool resource not found") + return item + + +@router.post("/resources", response_model=ToolResourceOut) +def create_tool_resource(data: ToolResourceCreate, db: Session = Depends(get_db)): + """创建自定义工具资源。""" + candidate_id = (data.id or "").strip() + if candidate_id and candidate_id in TOOL_REGISTRY: + raise HTTPException(status_code=400, detail="Tool ID conflicts with system tool") + + item = ToolResource( + id=candidate_id or f"tool_{str(uuid.uuid4())[:8]}", + user_id=1, + name=data.name, + description=data.description, + category=data.category, + icon=data.icon, + enabled=data.enabled, + is_system=False, + ) + db.add(item) + db.commit() + db.refresh(item) + return item + + +@router.put("/resources/{id}", response_model=ToolResourceOut) +def update_tool_resource(id: str, data: ToolResourceUpdate, db: Session = Depends(get_db)): + """更新自定义工具资源。""" + if id in TOOL_REGISTRY: + raise HTTPException(status_code=400, detail="System tools are read-only") + + item = db.query(ToolResource).filter(ToolResource.id == id).first() + if not item: + raise HTTPException(status_code=404, detail="Tool resource not found") + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(item, field, value) + item.updated_at = datetime.utcnow() + + db.commit() + db.refresh(item) + return item + + +@router.delete("/resources/{id}") +def delete_tool_resource(id: str, db: Session = Depends(get_db)): + """删除自定义工具资源。""" + if id in TOOL_REGISTRY: + raise HTTPException(status_code=400, detail="System tools cannot be deleted") + + item = db.query(ToolResource).filter(ToolResource.id == id).first() + if not item: + raise HTTPException(status_code=404, detail="Tool resource not found") + db.delete(item) + db.commit() + return {"message": "Deleted successfully"} + + # ============ Autotest ============ class AutotestResult: """自动测试结果""" diff --git a/api/app/schemas.py b/api/app/schemas.py index 041a8f6..0b40d82 100644 --- a/api/app/schemas.py +++ b/api/app/schemas.py @@ -229,6 +229,38 @@ class ASRTestResponse(BaseModel): error: Optional[str] = None +# ============ Tool Resource ============ +class ToolResourceBase(BaseModel): + name: str + description: str = "" + category: str = "system" # system/query + icon: str = "Wrench" + enabled: bool = True + + +class ToolResourceCreate(ToolResourceBase): + id: Optional[str] = None + + +class ToolResourceUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + category: Optional[str] = None + icon: Optional[str] = None + enabled: Optional[bool] = None + + +class ToolResourceOut(ToolResourceBase): + id: str + user_id: Optional[int] = None + is_system: bool = False + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + # ============ Assistant ============ class AssistantBase(BaseModel): name: str diff --git a/api/tests/test_tools.py b/api/tests/test_tools.py index 9ce6cb4..85c8c1f 100644 --- a/api/tests/test_tools.py +++ b/api/tests/test_tools.py @@ -265,3 +265,55 @@ class TestAutotestAPI: assert data["name"] == "翻译" assert "text" in data["parameters"]["properties"] assert "target_lang" in data["parameters"]["properties"] + + +class TestToolResourceCRUD: + """Test cases for persistent tool resource CRUD endpoints.""" + + def test_list_tool_resources_contains_system_tools(self, client): + response = client.get("/api/tools/resources") + assert response.status_code == 200 + payload = response.json() + assert payload["total"] >= 1 + ids = [item["id"] for item in payload["list"]] + assert "search" in ids + + def test_create_update_delete_tool_resource(self, client): + create_resp = client.post("/api/tools/resources", json={ + "name": "自定义网页抓取", + "description": "抓取页面并提取正文", + "category": "query", + "icon": "Globe", + "enabled": True, + }) + assert create_resp.status_code == 200 + created = create_resp.json() + tool_id = created["id"] + assert created["name"] == "自定义网页抓取" + assert created["is_system"] is False + + update_resp = client.put(f"/api/tools/resources/{tool_id}", json={ + "name": "自定义网页检索", + "category": "system", + }) + assert update_resp.status_code == 200 + updated = update_resp.json() + assert updated["name"] == "自定义网页检索" + assert updated["category"] == "system" + + get_resp = client.get(f"/api/tools/resources/{tool_id}") + assert get_resp.status_code == 200 + assert get_resp.json()["id"] == tool_id + + delete_resp = client.delete(f"/api/tools/resources/{tool_id}") + assert delete_resp.status_code == 200 + + missing_resp = client.get(f"/api/tools/resources/{tool_id}") + assert missing_resp.status_code == 404 + + def test_system_tool_is_read_only(self, client): + update_resp = client.put("/api/tools/resources/search", json={"name": "new"}) + assert update_resp.status_code == 400 + + delete_resp = client.delete("/api/tools/resources/search") + assert delete_resp.status_code == 400 diff --git a/web/pages/ToolLibrary.tsx b/web/pages/ToolLibrary.tsx index d2faba0..324e683 100644 --- a/web/pages/ToolLibrary.tsx +++ b/web/pages/ToolLibrary.tsx @@ -1,11 +1,9 @@ - -import React, { useState } from 'react'; -import { Search, Filter, Plus, Wrench, Terminal, Globe, Camera, CameraOff, Image, Images, CloudSun, Calendar, TrendingUp, Coins, Trash2, Edit2, X, Box } from 'lucide-react'; +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 { Button, Input, Badge, Dialog } from '../components/UI'; -import { mockTools } from '../services/mockData'; import { Tool } from '../types'; +import { createTool, deleteTool, fetchTools, updateTool } from '../services/backendApi'; -// Map icon strings to React Nodes const iconMap: Record = { Camera: , CameraOff: , @@ -18,172 +16,293 @@ const iconMap: Record = { Terminal: , Globe: , Wrench: , + Box: , }; export const ToolLibraryPage: React.FC = () => { - const [tools, setTools] = useState(mockTools); + const [tools, setTools] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [categoryFilter, setCategoryFilter] = useState<'all' | 'system' | 'query'>('all'); - const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isToolModalOpen, setIsToolModalOpen] = useState(false); + const [editingTool, setEditingTool] = useState(null); - // New Tool Form - const [newToolName, setNewToolName] = useState(''); - const [newToolDesc, setNewToolDesc] = useState(''); - const [newToolCategory, setNewToolCategory] = useState<'system' | 'query'>('system'); + const [toolName, setToolName] = useState(''); + const [toolDesc, setToolDesc] = useState(''); + const [toolCategory, setToolCategory] = useState<'system' | 'query'>('system'); + const [toolIcon, setToolIcon] = useState('Wrench'); + const [toolEnabled, setToolEnabled] = useState(true); + const [saving, setSaving] = useState(false); - const filteredTools = tools.filter(tool => { - const matchesSearch = tool.name.toLowerCase().includes(searchTerm.toLowerCase()); + const loadTools = async () => { + setIsLoading(true); + try { + setTools(await fetchTools()); + } catch (error) { + console.error(error); + setTools([]); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadTools(); + }, []); + + const openAdd = () => { + setEditingTool(null); + setToolName(''); + setToolDesc(''); + setToolCategory('system'); + setToolIcon('Wrench'); + setToolEnabled(true); + setIsToolModalOpen(true); + }; + + const openEdit = (tool: Tool) => { + setEditingTool(tool); + setToolName(tool.name); + setToolDesc(tool.description || ''); + setToolCategory(tool.category); + setToolIcon(tool.icon || 'Wrench'); + setToolEnabled(tool.enabled ?? true); + setIsToolModalOpen(true); + }; + + const filteredTools = tools.filter((tool) => { + const q = searchTerm.toLowerCase(); + const matchesSearch = + tool.name.toLowerCase().includes(q) || + (tool.description || '').toLowerCase().includes(q) || + tool.id.toLowerCase().includes(q); const matchesCategory = categoryFilter === 'all' || tool.category === categoryFilter; return matchesSearch && matchesCategory; }); - const handleAddTool = () => { - if (!newToolName.trim()) return; - const newTool: Tool = { - id: `custom_${Date.now()}`, - name: newToolName, - description: newToolDesc, - category: newToolCategory, - icon: newToolCategory === 'system' ? 'Terminal' : 'Globe', - isCustom: true - }; - setTools([...tools, newTool]); - setIsAddModalOpen(false); - setNewToolName(''); - setNewToolDesc(''); + const handleSaveTool = async () => { + if (!toolName.trim()) { + alert('请填写工具名称'); + return; + } + + try { + setSaving(true); + if (editingTool) { + const updated = await updateTool(editingTool.id, { + name: toolName.trim(), + description: toolDesc, + category: toolCategory, + icon: toolIcon, + enabled: toolEnabled, + }); + setTools((prev) => prev.map((item) => (item.id === updated.id ? updated : item))); + } else { + const created = await createTool({ + name: toolName.trim(), + description: toolDesc, + category: toolCategory, + icon: toolIcon, + enabled: toolEnabled, + }); + setTools((prev) => [created, ...prev]); + } + setIsToolModalOpen(false); + } catch (error: any) { + alert(error?.message || '保存工具失败'); + } finally { + setSaving(false); + } }; - const handleDeleteTool = (e: React.MouseEvent, id: string) => { - e.stopPropagation(); - if (confirm('确认删除该工具吗?')) { - setTools(prev => prev.filter(t => t.id !== id)); - } + const handleDeleteTool = async (e: React.MouseEvent, tool: Tool) => { + e.stopPropagation(); + if (tool.isSystem) { + alert('系统工具不可删除'); + return; + } + if (!confirm('确认删除该工具吗?')) return; + + try { + await deleteTool(tool.id); + setTools((prev) => prev.filter((item) => item.id !== tool.id)); + } catch (error: any) { + alert(error?.message || '删除失败'); + } }; return (

工具与插件

-
-
- - setSearchTerm(e.target.value)} - /> -
-
- - -
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ + +
- {filteredTools.map(tool => ( -
-
- {iconMap[tool.icon] || } -
-
-
- {tool.name} - {tool.isCustom && CUSTOM} -
-
- - {tool.category === 'system' ? 'SYSTEM' : 'QUERY'} - - ID: {tool.id} -
-

{tool.description}

-
- - {tool.isCustom && ( -
- -
+ {!isLoading && filteredTools.map((tool) => ( +
+
+ {iconMap[tool.icon] || } +
+
+
+ {tool.name} + {tool.isSystem ? ( + SYSTEM + ) : ( + CUSTOM )} +
+
+ + {tool.category === 'system' ? 'SYSTEM' : 'QUERY'} + + ID: {tool.id} +
+

{tool.description}

+ +
+ {!tool.isSystem && ( + + )} + {!tool.isSystem && ( + + )} +
+
))} - {filteredTools.length === 0 && ( -
- -

未找到相关工具

-
+ + {!isLoading && filteredTools.length === 0 && ( +
+ +

未找到相关工具

+
+ )} + + {isLoading && ( +
+ +

加载中...

+
)}
setIsAddModalOpen(false)} - title="添加自定义工具" + isOpen={isToolModalOpen} + onClose={() => setIsToolModalOpen(false)} + title={editingTool ? '编辑自定义工具' : '添加自定义工具'} footer={ <> - - + + } >
- -
- - + +
+ +
+
+ + +
+
- setNewToolName(e.target.value)} - placeholder="例如: 智能家居控制" + setToolName(e.target.value)} + placeholder="例如: 智能家居控制" autoFocus />
+
-