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

View File

@@ -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<string, React.ReactNode> = {
Camera: <Camera className="w-5 h-5" />,
CameraOff: <CameraOff className="w-5 h-5" />,
@@ -18,172 +16,293 @@ const iconMap: Record<string, React.ReactNode> = {
Terminal: <Terminal className="w-5 h-5" />,
Globe: <Globe className="w-5 h-5" />,
Wrench: <Wrench className="w-5 h-5" />,
Box: <Box className="w-5 h-5" />,
};
export const ToolLibraryPage: React.FC = () => {
const [tools, setTools] = useState<Tool[]>(mockTools);
const [tools, setTools] = useState<Tool[]>([]);
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<Tool | null>(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 (
<div className="space-y-6 animate-in fade-in py-4 pb-10">
<div className="flex items-center justify-between">
<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)]">
<Plus className="mr-2 h-4 w-4" />
<Button onClick={openAdd} className="shadow-[0_0_15px_rgba(6,182,212,0.4)]">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
<div className="relative col-span-1 md:col-span-2">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索工具名称..."
className="pl-9 border-0 bg-white/5"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<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={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value as any)}
>
<option value="all"></option>
<option value="system"> (System)</option>
<option value="query"> (Query)</option>
</select>
</div>
<div className="relative col-span-1 md:col-span-2">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索工具名称..."
className="pl-9 border-0 bg-white/5"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<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={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value as 'all' | 'system' | 'query')}
>
<option value="all"></option>
<option value="system"> (System)</option>
<option value="query"> (Query)</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredTools.map(tool => (
<div
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`}
>
<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" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-base font-bold text-white">{tool.name}</span>
{tool.isCustom && <Badge variant="outline" className="text-[9px] h-4 px-1">CUSTOM</Badge>}
</div>
<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'}`}>
{tool.category === 'system' ? 'SYSTEM' : 'QUERY'}
</Badge>
<span className="text-[10px] text-muted-foreground font-mono opacity-50">ID: {tool.id}</span>
</div>
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed opacity-80">{tool.description}</p>
</div>
{tool.isCustom && (
<div className="absolute top-3 right-3 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => handleDeleteTool(e, tool.id)}
className="p-1.5 rounded-md hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{!isLoading && filteredTools.map((tool) => (
<div
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"
>
<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" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1 gap-2">
<span className="text-base font-bold text-white truncate">{tool.name}</span>
{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 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'}`}>
{tool.category === 'system' ? 'SYSTEM' : 'QUERY'}
</Badge>
<span className="text-[10px] text-muted-foreground font-mono opacity-50 truncate">ID: {tool.id}</span>
</div>
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed opacity-80">{tool.description}</p>
</div>
<div className="absolute top-3 right-3 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!tool.isSystem && (
<button
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"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
))}
{filteredTools.length === 0 && (
<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" />
<p></p>
</div>
{!isLoading && filteredTools.length === 0 && (
<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" />
<p></p>
</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>
<Dialog
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
title="添加自定义工具"
isOpen={isToolModalOpen}
onClose={() => setIsToolModalOpen(false)}
title={editingTool ? '编辑自定义工具' : '添加自定义工具'}
footer={
<>
<Button variant="ghost" onClick={() => setIsAddModalOpen(false)}></Button>
<Button onClick={handleAddTool}></Button>
<Button variant="ghost" onClick={() => setIsToolModalOpen(false)}></Button>
<Button onClick={handleSaveTool} disabled={saving}>{saving ? '保存中...' : (editingTool ? '保存修改' : '确认添加')}</Button>
</>
}
>
<div className="space-y-4">
<div className="space-y-1.5">
<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">
<button
onClick={() => setNewToolCategory('system')}
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'}`}
>
<Terminal className="w-3.5 h-3.5 mr-2" />
</button>
<button
onClick={() => setNewToolCategory('query')}
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'}`}
>
<Globe className="w-3.5 h-3.5 mr-2" />
</button>
<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">
<button
onClick={() => {
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" />
</button>
<button
onClick={() => {
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" />
</button>
</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">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"></label>
<Input
value={newToolName}
onChange={e => setNewToolName(e.target.value)}
placeholder="例如: 智能家居控制"
<Input
value={toolName}
onChange={(e) => setToolName(e.target.value)}
placeholder="例如: 智能家居控制"
autoFocus
/>
</div>
<div className="space-y-1.5">
<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"
value={newToolDesc}
onChange={e => setNewToolDesc(e.target.value)}
value={toolDesc}
onChange={(e) => setToolDesc(e.target.value)}
placeholder="描述该工具的功能,以及 AI 应该在什么情况下调用它..."
/>
</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>
</Dialog>
</div>

View File

@@ -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';
type AnyRecord = Record<string, any>;
@@ -91,6 +91,17 @@ const mapLLMModel = (raw: AnyRecord): LLMModel => ({
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 => ({
name: readField(raw, ['name'], ''),
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[]> => {
const response = await apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>('/workflows');
const list = Array.isArray(response) ? response : (response.list || []);

View File

@@ -161,6 +161,8 @@ export interface Tool {
category: 'system' | 'query';
icon: string;
isCustom?: boolean;
isSystem?: boolean;
enabled?: boolean;
}
export interface LLMModel {