Update tool panel

This commit is contained in:
Xin Wang
2026-02-09 00:14:11 +08:00
parent 0fc56e2685
commit 77b186dceb
7 changed files with 537 additions and 120 deletions

View File

@@ -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"

View File

@@ -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:
"""自动测试结果"""

View File

@@ -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

View File

@@ -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