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

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