Update tool panel
This commit is contained in:
@@ -82,6 +82,24 @@ class ASRModel(Base):
|
|||||||
user = relationship("User")
|
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 ============
|
# ============ Assistant ============
|
||||||
class Assistant(Base):
|
class Assistant(Base):
|
||||||
__tablename__ = "assistants"
|
__tablename__ = "assistants"
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, List
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import httpx
|
import httpx
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from ..db import get_db
|
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"])
|
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")
|
@router.get("/list")
|
||||||
def list_available_tools():
|
def list_available_tools():
|
||||||
@@ -98,6 +130,122 @@ def get_tool_detail(tool_id: str):
|
|||||||
return TOOL_REGISTRY[tool_id]
|
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 ============
|
# ============ Autotest ============
|
||||||
class AutotestResult:
|
class AutotestResult:
|
||||||
"""自动测试结果"""
|
"""自动测试结果"""
|
||||||
|
|||||||
@@ -229,6 +229,38 @@ class ASRTestResponse(BaseModel):
|
|||||||
error: Optional[str] = None
|
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 ============
|
# ============ Assistant ============
|
||||||
class AssistantBase(BaseModel):
|
class AssistantBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -265,3 +265,55 @@ class TestAutotestAPI:
|
|||||||
assert data["name"] == "翻译"
|
assert data["name"] == "翻译"
|
||||||
assert "text" in data["parameters"]["properties"]
|
assert "text" in data["parameters"]["properties"]
|
||||||
assert "target_lang" 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
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
import React, { 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, X, Box } from 'lucide-react';
|
|
||||||
import { Button, Input, Badge, Dialog } from '../components/UI';
|
import { Button, Input, Badge, Dialog } from '../components/UI';
|
||||||
import { mockTools } from '../services/mockData';
|
|
||||||
import { Tool } from '../types';
|
import { Tool } from '../types';
|
||||||
|
import { createTool, deleteTool, fetchTools, updateTool } from '../services/backendApi';
|
||||||
|
|
||||||
// Map icon strings to React Nodes
|
|
||||||
const iconMap: Record<string, React.ReactNode> = {
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
Camera: <Camera className="w-5 h-5" />,
|
Camera: <Camera className="w-5 h-5" />,
|
||||||
CameraOff: <CameraOff className="w-5 h-5" />,
|
CameraOff: <CameraOff className="w-5 h-5" />,
|
||||||
@@ -18,45 +16,118 @@ const iconMap: Record<string, React.ReactNode> = {
|
|||||||
Terminal: <Terminal className="w-5 h-5" />,
|
Terminal: <Terminal className="w-5 h-5" />,
|
||||||
Globe: <Globe className="w-5 h-5" />,
|
Globe: <Globe className="w-5 h-5" />,
|
||||||
Wrench: <Wrench className="w-5 h-5" />,
|
Wrench: <Wrench className="w-5 h-5" />,
|
||||||
|
Box: <Box className="w-5 h-5" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ToolLibraryPage: React.FC = () => {
|
export const ToolLibraryPage: React.FC = () => {
|
||||||
const [tools, setTools] = useState<Tool[]>(mockTools);
|
const [tools, setTools] = useState<Tool[]>([]);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [categoryFilter, setCategoryFilter] = useState<'all' | 'system' | 'query'>('all');
|
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<Tool | null>(null);
|
||||||
|
|
||||||
// New Tool Form
|
const [toolName, setToolName] = useState('');
|
||||||
const [newToolName, setNewToolName] = useState('');
|
const [toolDesc, setToolDesc] = useState('');
|
||||||
const [newToolDesc, setNewToolDesc] = useState('');
|
const [toolCategory, setToolCategory] = useState<'system' | 'query'>('system');
|
||||||
const [newToolCategory, setNewToolCategory] = 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 loadTools = async () => {
|
||||||
const matchesSearch = tool.name.toLowerCase().includes(searchTerm.toLowerCase());
|
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;
|
const matchesCategory = categoryFilter === 'all' || tool.category === categoryFilter;
|
||||||
return matchesSearch && matchesCategory;
|
return matchesSearch && matchesCategory;
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAddTool = () => {
|
const handleSaveTool = async () => {
|
||||||
if (!newToolName.trim()) return;
|
if (!toolName.trim()) {
|
||||||
const newTool: Tool = {
|
alert('请填写工具名称');
|
||||||
id: `custom_${Date.now()}`,
|
return;
|
||||||
name: newToolName,
|
}
|
||||||
description: newToolDesc,
|
|
||||||
category: newToolCategory,
|
try {
|
||||||
icon: newToolCategory === 'system' ? 'Terminal' : 'Globe',
|
setSaving(true);
|
||||||
isCustom: true
|
if (editingTool) {
|
||||||
};
|
const updated = await updateTool(editingTool.id, {
|
||||||
setTools([...tools, newTool]);
|
name: toolName.trim(),
|
||||||
setIsAddModalOpen(false);
|
description: toolDesc,
|
||||||
setNewToolName('');
|
category: toolCategory,
|
||||||
setNewToolDesc('');
|
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) => {
|
const handleDeleteTool = async (e: React.MouseEvent, tool: Tool) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (confirm('确认删除该工具吗?')) {
|
if (tool.isSystem) {
|
||||||
setTools(prev => prev.filter(t => t.id !== id));
|
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 || '删除失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,7 +135,7 @@ export const ToolLibraryPage: React.FC = () => {
|
|||||||
<div className="space-y-6 animate-in fade-in py-4 pb-10">
|
<div className="space-y-6 animate-in fade-in py-4 pb-10">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-white">工具与插件</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-white">工具与插件</h1>
|
||||||
<Button onClick={() => setIsAddModalOpen(true)} className="shadow-[0_0_15px_rgba(6,182,212,0.4)]">
|
<Button onClick={openAdd} className="shadow-[0_0_15px_rgba(6,182,212,0.4)]">
|
||||||
<Plus className="mr-2 h-4 w-4" /> 添加工具
|
<Plus className="mr-2 h-4 w-4" /> 添加工具
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,7 +147,7 @@ export const ToolLibraryPage: React.FC = () => {
|
|||||||
placeholder="搜索工具名称..."
|
placeholder="搜索工具名称..."
|
||||||
className="pl-9 border-0 bg-white/5"
|
className="pl-9 border-0 bg-white/5"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@@ -84,7 +155,7 @@ export const ToolLibraryPage: React.FC = () => {
|
|||||||
<select
|
<select
|
||||||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||||||
value={categoryFilter}
|
value={categoryFilter}
|
||||||
onChange={(e) => setCategoryFilter(e.target.value as any)}
|
onChange={(e) => setCategoryFilter(e.target.value as 'all' | 'system' | 'query')}
|
||||||
>
|
>
|
||||||
<option value="all">所有类型</option>
|
<option value="all">所有类型</option>
|
||||||
<option value="system">系统指令 (System)</option>
|
<option value="system">系统指令 (System)</option>
|
||||||
@@ -94,56 +165,79 @@ export const ToolLibraryPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{filteredTools.map(tool => (
|
{!isLoading && filteredTools.map((tool) => (
|
||||||
<div
|
<div
|
||||||
key={tool.id}
|
key={tool.id}
|
||||||
className={`p-5 rounded-xl border transition-all relative group flex items-start space-x-4 bg-card/30 border-white/5 hover:bg-white/5 hover:border-white/10 hover:shadow-lg`}
|
className="p-5 rounded-xl border transition-all relative group flex items-start space-x-4 bg-card/30 border-white/5 hover:bg-white/5 hover:border-white/10 hover:shadow-lg"
|
||||||
>
|
>
|
||||||
<div className={`p-3 rounded-lg shrink-0 transition-colors ${tool.category === 'system' ? 'bg-primary/10 text-primary' : 'bg-blue-500/10 text-blue-400'}`}>
|
<div className={`p-3 rounded-lg shrink-0 transition-colors ${tool.category === 'system' ? 'bg-primary/10 text-primary' : 'bg-blue-500/10 text-blue-400'}`}>
|
||||||
{iconMap[tool.icon] || <Box className="w-5 h-5" />}
|
{iconMap[tool.icon] || <Box className="w-5 h-5" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1 gap-2">
|
||||||
<span className="text-base font-bold text-white">{tool.name}</span>
|
<span className="text-base font-bold text-white truncate">{tool.name}</span>
|
||||||
{tool.isCustom && <Badge variant="outline" className="text-[9px] h-4 px-1">CUSTOM</Badge>}
|
{tool.isSystem ? (
|
||||||
|
<Badge variant="outline" className="text-[9px] h-4 px-1">SYSTEM</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-[9px] h-4 px-1">CUSTOM</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Badge variant="outline" className={`text-[10px] border-0 px-0 ${tool.category === 'system' ? 'text-primary' : 'text-blue-400'}`}>
|
<Badge variant="outline" className={`text-[10px] border-0 px-0 ${tool.category === 'system' ? 'text-primary' : 'text-blue-400'}`}>
|
||||||
{tool.category === 'system' ? 'SYSTEM' : 'QUERY'}
|
{tool.category === 'system' ? 'SYSTEM' : 'QUERY'}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-[10px] text-muted-foreground font-mono opacity-50">ID: {tool.id}</span>
|
<span className="text-[10px] text-muted-foreground font-mono opacity-50 truncate">ID: {tool.id}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed opacity-80">{tool.description}</p>
|
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed opacity-80">{tool.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tool.isCustom && (
|
|
||||||
<div className="absolute top-3 right-3 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="absolute top-3 right-3 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{!tool.isSystem && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleDeleteTool(e, tool.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openEdit(tool);
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-md hover:bg-primary/20 text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!tool.isSystem && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDeleteTool(e, tool)}
|
||||||
className="p-1.5 rounded-md hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
className="p-1.5 rounded-md hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{filteredTools.length === 0 && (
|
|
||||||
|
{!isLoading && filteredTools.length === 0 && (
|
||||||
<div className="col-span-full py-12 flex flex-col items-center justify-center text-muted-foreground opacity-50">
|
<div className="col-span-full py-12 flex flex-col items-center justify-center text-muted-foreground opacity-50">
|
||||||
<Wrench className="w-12 h-12 mb-4 stroke-1" />
|
<Wrench className="w-12 h-12 mb-4 stroke-1" />
|
||||||
<p>未找到相关工具</p>
|
<p>未找到相关工具</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="col-span-full py-12 flex flex-col items-center justify-center text-muted-foreground opacity-70">
|
||||||
|
<Wrench className="w-12 h-12 mb-4 stroke-1 animate-pulse" />
|
||||||
|
<p>加载中...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
isOpen={isAddModalOpen}
|
isOpen={isToolModalOpen}
|
||||||
onClose={() => setIsAddModalOpen(false)}
|
onClose={() => setIsToolModalOpen(false)}
|
||||||
title="添加自定义工具"
|
title={editingTool ? '编辑自定义工具' : '添加自定义工具'}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={() => setIsAddModalOpen(false)}>取消</Button>
|
<Button variant="ghost" onClick={() => setIsToolModalOpen(false)}>取消</Button>
|
||||||
<Button onClick={handleAddTool}>确认添加</Button>
|
<Button onClick={handleSaveTool} disabled={saving}>{saving ? '保存中...' : (editingTool ? '保存修改' : '确认添加')}</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -152,38 +246,63 @@ export const ToolLibraryPage: React.FC = () => {
|
|||||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">工具类型</label>
|
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">工具类型</label>
|
||||||
<div className="flex bg-white/5 p-1 rounded-lg border border-white/10">
|
<div className="flex bg-white/5 p-1 rounded-lg border border-white/10">
|
||||||
<button
|
<button
|
||||||
onClick={() => setNewToolCategory('system')}
|
onClick={() => {
|
||||||
className={`flex-1 flex items-center justify-center py-2 text-xs font-bold rounded-md transition-all ${newToolCategory === 'system' ? 'bg-primary text-primary-foreground shadow-lg' : 'text-muted-foreground hover:text-foreground'}`}
|
setToolCategory('system');
|
||||||
|
if (!toolIcon) setToolIcon('Terminal');
|
||||||
|
}}
|
||||||
|
className={`flex-1 flex items-center justify-center py-2 text-xs font-bold rounded-md transition-all ${toolCategory === 'system' ? 'bg-primary text-primary-foreground shadow-lg' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
>
|
>
|
||||||
<Terminal className="w-3.5 h-3.5 mr-2" /> 系统指令
|
<Terminal className="w-3.5 h-3.5 mr-2" /> 系统指令
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setNewToolCategory('query')}
|
onClick={() => {
|
||||||
className={`flex-1 flex items-center justify-center py-2 text-xs font-bold rounded-md transition-all ${newToolCategory === 'query' ? 'bg-blue-500 text-white shadow-lg' : 'text-muted-foreground hover:text-foreground'}`}
|
setToolCategory('query');
|
||||||
|
if (!toolIcon) setToolIcon('Globe');
|
||||||
|
}}
|
||||||
|
className={`flex-1 flex items-center justify-center py-2 text-xs font-bold rounded-md transition-all ${toolCategory === 'query' ? 'bg-blue-500 text-white shadow-lg' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
>
|
>
|
||||||
<Globe className="w-3.5 h-3.5 mr-2" /> 信息查询
|
<Globe className="w-3.5 h-3.5 mr-2" /> 信息查询
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">图标 (Icon)</label>
|
||||||
|
<select
|
||||||
|
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||||||
|
value={toolIcon}
|
||||||
|
onChange={(e) => setToolIcon(e.target.value)}
|
||||||
|
>
|
||||||
|
{Object.keys(iconMap).map((icon) => (
|
||||||
|
<option key={icon} value={icon}>{icon}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">工具名称</label>
|
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">工具名称</label>
|
||||||
<Input
|
<Input
|
||||||
value={newToolName}
|
value={toolName}
|
||||||
onChange={e => setNewToolName(e.target.value)}
|
onChange={(e) => setToolName(e.target.value)}
|
||||||
placeholder="例如: 智能家居控制"
|
placeholder="例如: 智能家居控制"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">工具描述 (给 AI 的说明)</label>
|
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">工具描述 (给 AI 的说明)</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="flex min-h-[100px] w-full rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-white"
|
className="flex min-h-[100px] w-full rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-white"
|
||||||
value={newToolDesc}
|
value={toolDesc}
|
||||||
onChange={e => setNewToolDesc(e.target.value)}
|
onChange={(e) => setToolDesc(e.target.value)}
|
||||||
placeholder="描述该工具的功能,以及 AI 应该在什么情况下调用它..."
|
placeholder="描述该工具的功能,以及 AI 应该在什么情况下调用它..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2 text-xs text-muted-foreground">
|
||||||
|
<input type="checkbox" checked={toolEnabled} onChange={(e) => setToolEnabled(e.target.checked)} />
|
||||||
|
<span>启用该工具</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ASRModel, Assistant, CallLog, InteractionDetail, KnowledgeBase, KnowledgeDocument, LLMModel, Voice, Workflow, WorkflowEdge, WorkflowNode } from '../types';
|
import { ASRModel, Assistant, CallLog, InteractionDetail, KnowledgeBase, KnowledgeDocument, LLMModel, Tool, Voice, Workflow, WorkflowEdge, WorkflowNode } from '../types';
|
||||||
import { apiRequest } from './apiClient';
|
import { apiRequest } from './apiClient';
|
||||||
|
|
||||||
type AnyRecord = Record<string, any>;
|
type AnyRecord = Record<string, any>;
|
||||||
@@ -91,6 +91,17 @@ const mapLLMModel = (raw: AnyRecord): LLMModel => ({
|
|||||||
enabled: Boolean(readField(raw, ['enabled'], true)),
|
enabled: Boolean(readField(raw, ['enabled'], true)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mapTool = (raw: AnyRecord): Tool => ({
|
||||||
|
id: String(readField(raw, ['id'], '')),
|
||||||
|
name: readField(raw, ['name'], ''),
|
||||||
|
description: readField(raw, ['description'], ''),
|
||||||
|
category: readField(raw, ['category'], 'system') as 'system' | 'query',
|
||||||
|
icon: readField(raw, ['icon'], 'Wrench'),
|
||||||
|
isSystem: Boolean(readField(raw, ['isSystem', 'is_system'], false)),
|
||||||
|
enabled: Boolean(readField(raw, ['enabled'], true)),
|
||||||
|
isCustom: !Boolean(readField(raw, ['isSystem', 'is_system'], false)),
|
||||||
|
});
|
||||||
|
|
||||||
const mapWorkflowNode = (raw: AnyRecord): WorkflowNode => ({
|
const mapWorkflowNode = (raw: AnyRecord): WorkflowNode => ({
|
||||||
name: readField(raw, ['name'], ''),
|
name: readField(raw, ['name'], ''),
|
||||||
type: readField(raw, ['type'], 'conversation') as 'conversation' | 'tool' | 'human' | 'end',
|
type: readField(raw, ['type'], 'conversation') as 'conversation' | 'tool' | 'human' | 'end',
|
||||||
@@ -425,6 +436,41 @@ export const previewLLMModel = async (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchTools = async (): Promise<Tool[]> => {
|
||||||
|
const response = await apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>('/tools/resources');
|
||||||
|
const list = Array.isArray(response) ? response : (response.list || []);
|
||||||
|
return list.map((item) => mapTool(item));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTool = async (data: Partial<Tool>): Promise<Tool> => {
|
||||||
|
const payload = {
|
||||||
|
id: data.id || undefined,
|
||||||
|
name: data.name || 'New Tool',
|
||||||
|
description: data.description || '',
|
||||||
|
category: data.category || 'system',
|
||||||
|
icon: data.icon || (data.category === 'query' ? 'Globe' : 'Terminal'),
|
||||||
|
enabled: data.enabled ?? true,
|
||||||
|
};
|
||||||
|
const response = await apiRequest<AnyRecord>('/tools/resources', { method: 'POST', body: payload });
|
||||||
|
return mapTool(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTool = async (id: string, data: Partial<Tool>): Promise<Tool> => {
|
||||||
|
const payload = {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
category: data.category,
|
||||||
|
icon: data.icon,
|
||||||
|
enabled: data.enabled,
|
||||||
|
};
|
||||||
|
const response = await apiRequest<AnyRecord>(`/tools/resources/${id}`, { method: 'PUT', body: payload });
|
||||||
|
return mapTool(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTool = async (id: string): Promise<void> => {
|
||||||
|
await apiRequest(`/tools/resources/${id}`, { method: 'DELETE' });
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchWorkflows = async (): Promise<Workflow[]> => {
|
export const fetchWorkflows = async (): Promise<Workflow[]> => {
|
||||||
const response = await apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>('/workflows');
|
const response = await apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>('/workflows');
|
||||||
const list = Array.isArray(response) ? response : (response.list || []);
|
const list = Array.isArray(response) ? response : (response.list || []);
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ export interface Tool {
|
|||||||
category: 'system' | 'query';
|
category: 'system' | 'query';
|
||||||
icon: string;
|
icon: string;
|
||||||
isCustom?: boolean;
|
isCustom?: boolean;
|
||||||
|
isSystem?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LLMModel {
|
export interface LLMModel {
|
||||||
|
|||||||
Reference in New Issue
Block a user