291 lines
14 KiB
TypeScript
291 lines
14 KiB
TypeScript
'use client'
|
||
|
||
import { useState } from 'react'
|
||
import useSWR from 'swr'
|
||
import { api } from '@/lib/api-client'
|
||
import type { AgentTemplate } from '@/lib/types'
|
||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
||
import { TableSkeleton } from '@/components/ui/skeleton'
|
||
|
||
export default function AgentTemplatesPage() {
|
||
const [page, setPage] = useState(1)
|
||
const [error, setError] = useState('')
|
||
const [showCreate, setShowCreate] = useState(false)
|
||
const [editingId, setEditingId] = useState<string | null>(null)
|
||
|
||
const { data, isLoading, mutate } = useSWR(
|
||
['agentTemplates.list', page],
|
||
() => api.agentTemplates.list({ page, page_size: 50 }),
|
||
)
|
||
|
||
const templates = data?.items ?? []
|
||
const total = data?.total ?? 0
|
||
|
||
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
|
||
e.preventDefault()
|
||
const fd = new FormData(e.currentTarget)
|
||
try {
|
||
const tools = (fd.get('tools') as string || '').split(',').map(s => s.trim()).filter(Boolean)
|
||
const capabilities = (fd.get('capabilities') as string || '').split(',').map(s => s.trim()).filter(Boolean)
|
||
await api.agentTemplates.create({
|
||
name: fd.get('name') as string,
|
||
description: (fd.get('description') as string) || undefined,
|
||
category: (fd.get('category') as string) || 'general',
|
||
model: (fd.get('model') as string) || undefined,
|
||
system_prompt: (fd.get('system_prompt') as string) || undefined,
|
||
tools: tools.length > 0 ? tools : undefined,
|
||
capabilities: capabilities.length > 0 ? capabilities : undefined,
|
||
temperature: (fd.get('temperature') as string) ? parseFloat(fd.get('temperature') as string) : undefined,
|
||
max_tokens: (fd.get('max_tokens') as string) ? parseInt(fd.get('max_tokens') as string, 10) : undefined,
|
||
visibility: (fd.get('visibility') as string) || 'public',
|
||
})
|
||
setShowCreate(false)
|
||
mutate()
|
||
} catch {
|
||
setError('创建失败')
|
||
}
|
||
}
|
||
|
||
const handleArchive = async (id: string, name: string) => {
|
||
if (!confirm(`确认归档模板 "${name}"?`)) return
|
||
try {
|
||
await api.agentTemplates.archive(id)
|
||
mutate()
|
||
} catch {
|
||
setError('归档失败')
|
||
}
|
||
}
|
||
|
||
const statusBadge = (status: string) => {
|
||
const colors: Record<string, string> = {
|
||
active: 'bg-emerald-500/20 text-emerald-400',
|
||
archived: 'bg-zinc-500/20 text-zinc-400',
|
||
}
|
||
return <span className={`px-2 py-0.5 text-xs rounded-full ${colors[status] || colors.archived}`}>{status}</span>
|
||
}
|
||
|
||
const sourceBadge = (source: string) => {
|
||
const colors: Record<string, string> = {
|
||
builtin: 'bg-blue-500/20 text-blue-400',
|
||
custom: 'bg-purple-500/20 text-purple-400',
|
||
}
|
||
return (
|
||
<span className={`px-2 py-0.5 text-xs rounded-full ${colors[source] || ''}`}>
|
||
{source === 'builtin' ? '内置' : '自定义'}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-white">Agent 配置模板</h1>
|
||
<p className="text-sm text-zinc-400 mt-1">管理 Agent 配置模板,支持团队共享和一键复用</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowCreate(true)}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
|
||
>
|
||
+ 新建模板
|
||
</button>
|
||
</div>
|
||
|
||
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
|
||
|
||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-zinc-800">
|
||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">名称</th>
|
||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">分类</th>
|
||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">来源</th>
|
||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">模型</th>
|
||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">工具数</th>
|
||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">可见性</th>
|
||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">状态</th>
|
||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">更新时间</th>
|
||
<th className="text-right px-4 py-3 text-zinc-400 font-medium">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{isLoading ? (
|
||
<tr>
|
||
<td colSpan={9}>
|
||
<TableSkeleton rows={5} cols={9} hasToolbar={false} />
|
||
</td>
|
||
</tr>
|
||
) : templates.length === 0 ? (
|
||
<tr><td colSpan={9}><EmptyState message="暂无 Agent 模板" /></td></tr>
|
||
) : (
|
||
templates.map(t => (
|
||
<tr key={t.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||
<td className="px-4 py-3">
|
||
<div>
|
||
<span className="text-white font-medium">{t.name}</span>
|
||
{t.description && (
|
||
<p className="text-xs text-zinc-500 mt-0.5 truncate max-w-[200px]">{t.description}</p>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-zinc-400">{t.category}</td>
|
||
<td className="px-4 py-3">{sourceBadge(t.source)}</td>
|
||
<td className="px-4 py-3 text-zinc-300 font-mono text-xs">{t.model || '-'}</td>
|
||
<td className="px-4 py-3 text-zinc-400">{t.tools.length}</td>
|
||
<td className="px-4 py-3 text-zinc-400">{t.visibility}</td>
|
||
<td className="px-4 py-3">{statusBadge(t.status)}</td>
|
||
<td className="px-4 py-3 text-zinc-500 text-xs">
|
||
{new Date(t.updated_at).toLocaleString('zh-CN')}
|
||
</td>
|
||
<td className="px-4 py-3 text-right">
|
||
<button
|
||
onClick={() => setEditingId(editingId === t.id ? null : t.id)}
|
||
className="text-zinc-400 hover:text-white mr-2"
|
||
>
|
||
详情
|
||
</button>
|
||
{t.source === 'custom' && (
|
||
<button
|
||
onClick={() => handleArchive(t.id, t.name)}
|
||
className="text-red-400 hover:text-red-300"
|
||
>
|
||
归档
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
<div className="px-4 py-2 text-xs text-zinc-500 border-t border-zinc-800">
|
||
共 {total} 个模板
|
||
</div>
|
||
</div>
|
||
|
||
{/* 展开详情 */}
|
||
{editingId && (() => {
|
||
const t = templates.find(t => t.id === editingId)
|
||
if (!t) return null
|
||
return (
|
||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h2 className="text-lg font-semibold text-white">{t.name} — 详情</h2>
|
||
<button onClick={() => setEditingId(null)} className="text-zinc-400 hover:text-white text-sm">关闭</button>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<span className="text-zinc-500">分类:</span>
|
||
<span className="text-zinc-300">{t.category}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-zinc-500">模型:</span>
|
||
<span className="text-zinc-300 font-mono">{t.model || '未指定'}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-zinc-500">温度:</span>
|
||
<span className="text-zinc-300">{t.temperature?.toFixed(2) || '默认'}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-zinc-500">最大 Token:</span>
|
||
<span className="text-zinc-300">{t.max_tokens || '未限制'}</span>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<span className="text-zinc-500">工具:</span>
|
||
<div className="flex flex-wrap gap-1 mt-1">
|
||
{t.tools.length > 0 ? t.tools.map(tool => (
|
||
<span key={tool} className="px-2 py-0.5 bg-zinc-800 rounded text-xs text-zinc-300">{tool}</span>
|
||
)) : <span className="text-zinc-600">无</span>}
|
||
</div>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<span className="text-zinc-500">能力:</span>
|
||
<div className="flex flex-wrap gap-1 mt-1">
|
||
{t.capabilities.length > 0 ? t.capabilities.map(cap => (
|
||
<span key={cap} className="px-2 py-0.5 bg-blue-500/10 rounded text-xs text-blue-400">{cap}</span>
|
||
)) : <span className="text-zinc-600">无</span>}
|
||
</div>
|
||
</div>
|
||
{t.system_prompt && (
|
||
<div className="col-span-2">
|
||
<span className="text-zinc-500">系统提示词:</span>
|
||
<pre className="text-xs text-zinc-400 bg-zinc-800/50 rounded p-2 mt-1 overflow-x-auto max-h-32">
|
||
{t.system_prompt.substring(0, 500)}{t.system_prompt.length > 500 ? '...' : ''}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{/* Create Modal */}
|
||
{showCreate && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||
<form onSubmit={handleCreate} className="bg-zinc-900 rounded-xl border border-zinc-700 p-6 w-full max-w-lg space-y-4 max-h-[80vh] overflow-y-auto">
|
||
<h2 className="text-lg font-semibold text-white">新建 Agent 模板</h2>
|
||
<div>
|
||
<label className="block text-sm text-zinc-400 mb-1">名称 *</label>
|
||
<input name="name" required className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="my_agent" />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm text-zinc-400 mb-1">描述</label>
|
||
<input name="description" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="可选" />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm text-zinc-400 mb-1">分类</label>
|
||
<select name="category" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm">
|
||
<option value="general">通用</option>
|
||
<option value="coding">编程</option>
|
||
<option value="research">研究</option>
|
||
<option value="creative">创意</option>
|
||
<option value="assistant">助手</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm text-zinc-400 mb-1">模型</label>
|
||
<input name="model" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="如 glm-4-plus" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm text-zinc-400 mb-1">系统提示词</label>
|
||
<textarea name="system_prompt" rows={4} className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm font-mono" placeholder="Agent 系统提示词" />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm text-zinc-400 mb-1">工具(逗号分隔)</label>
|
||
<input name="tools" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="browser, file_system, code_execute" />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm text-zinc-400 mb-1">能力(逗号分隔)</label>
|
||
<input name="capabilities" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="streaming, vision, function_calling" />
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<div>
|
||
<label className="block text-sm text-zinc-400 mb-1">温度</label>
|
||
<input name="temperature" type="number" step="0.1" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="默认" />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm text-zinc-400 mb-1">最大 Token</label>
|
||
<input name="max_tokens" type="number" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="不限" />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm text-zinc-400 mb-1">可见性</label>
|
||
<select name="visibility" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm">
|
||
<option value="public">公开</option>
|
||
<option value="team">团队</option>
|
||
<option value="private">私有</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2 justify-end">
|
||
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-sm">取消</button>
|
||
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">创建</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|