码桶
发现社区成员的开源项目
SkillConfig.tsx23.6 KB
import { useState, useEffect, useCallback, useRef } from 'react'
import { api } from '@/lib/api'
import { toast } from 'sonner'
import { Switch } from '@/components/ui/switch'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Plus, Pencil, Trash2, Loader2, Upload, FileArchive, CheckCircle2, XCircle, AlertTriangle, SkipForward, FileWarning } from 'lucide-react'
interface SkillItem {
id: string
name: string
icon: string
description: string
installed: boolean
isCustom: boolean
}
interface CustomSkillRow {
id: number
name: string
icon: string
description: string
prompt: string
created_at: number
updated_at: number
}
interface SkillEditorData {
id?: number // 编辑时有值
name: string
icon: string
description: string
prompt: string
}
const emptyEditor: SkillEditorData = { name: '', icon: '', description: '', prompt: '' }
export default function SkillConfig({ botId }: { botId: number }) {
const [skills, setSkills] = useState<SkillItem[]>([])
const [customSkills, setCustomSkills] = useState<CustomSkillRow[]>([])
const [loading, setLoading] = useState(true)
const [installing, setInstalling] = useState<Set<string>>(new Set())
// 编辑器状态
const [editorOpen, setEditorOpen] = useState(false)
const [editor, setEditor] = useState<SkillEditorData>(emptyEditor)
const [saving, setSaving] = useState(false)
// 删除确认
const [deleteOpen, setDeleteOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<CustomSkillRow | null>(null)
const load = useCallback(async () => {
if (!botId) return
setLoading(true)
try {
const d = await api.post('/api/skill/list', { bot_id: botId })
if (d.ok) {
setSkills(d.skills || [])
setCustomSkills(d.custom_skills || [])
}
} catch (e: any) {
toast.error(e.message || '加载技能列表失败')
} finally {
setLoading(false)
}
}, [botId])
useEffect(() => { load() }, [load])
/** 安装/卸载 */
async function toggleSkill(skill: SkillItem) {
const key = skill.id
if (installing.has(key)) return
setInstalling(s => new Set(s).add(key))
const action = skill.installed ? 'uninstall' : 'install'
try {
const d = await api.post('/api/skill/' + action, { bot_id: botId, skill_id: skill.id })
if (!d.ok) { toast.error(d.msg || '操作失败'); return }
toast.success(skill.installed ? '已卸载' : '已安装')
setSkills(prev => prev.map(s => s.id === skill.id ? { ...s, installed: !skill.installed } : s))
} catch (e: any) {
toast.error(e.message)
} finally {
setInstalling(s => { const n = new Set(s); n.delete(key); return n })
}
}
/** 打开创建对话框 */
function openCreate() {
setEditor(emptyEditor)
setEditorOpen(true)
}
/** 打开编辑对话框 */
function openEdit(cs: CustomSkillRow) {
setEditor({
id: cs.id,
name: cs.name,
icon: cs.icon,
description: cs.description,
prompt: cs.prompt,
})
setEditorOpen(true)
}
/** 保存(创建或更新) */
async function saveEditor() {
if (!editor.name.trim()) { toast.error('请输入技能名称'); return }
if (!editor.prompt.trim()) { toast.error('请输入技能提示词'); return }
setSaving(true)
const endpoint = editor.id ? '/api/skill/update' : '/api/skill/create'
const body = editor.id
? { id: editor.id, name: editor.name, icon: editor.icon, description: editor.description, prompt: editor.prompt }
: { name: editor.name, icon: editor.icon, description: editor.description, prompt: editor.prompt }
try {
const d = await api.post(endpoint, body)
if (!d.ok) { toast.error(d.msg || '操作失败'); return }
toast.success(editor.id ? '技能已更新' : '技能已创建')
setEditorOpen(false)
load()
} catch (e: any) {
toast.error(e.message)
} finally {
setSaving(false)
}
}
/** 删除自定义技能 */
async function confirmDelete() {
if (!deleteTarget) return
try {
const d = await api.post('/api/skill/delete', { id: deleteTarget.id })
if (!d.ok) { toast.error(d.msg || '删除失败'); return }
toast.success('已删除')
setDeleteOpen(false)
setDeleteTarget(null)
load()
} catch (e: any) {
toast.error(e.message)
}
}
// ========== ZIP 批量上传 ==========
const [uploadOpen, setUploadOpen] = useState(false)
const [uploadFiles, setUploadFiles] = useState<File[]>([])
const [uploading, setUploading] = useState(false)
const [uploadResults, setUploadResults] = useState<any[] | null>(null)
const [uploadSummary, setUploadSummary] = useState<any | null>(null)
const [dragOver, setDragOver] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
/** 验证文件是否为 ZIP */
function isValidZip(file: File) {
const name = file.name.toLowerCase()
if (name.endsWith('.zip')) return true
if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed') return true
return false
}
/** 添加文件到待上传列表 */
function addFiles(files: FileList | File[]) {
const arr = Array.from(files)
const zips = arr.filter(f => {
if (!isValidZip(f)) { toast.error(`「${f.name}」不是 ZIP 文件,已忽略`); return false }
return true
})
if (zips.length === 0) return
setUploadFiles(prev => [...prev, ...zips])
setUploadResults(null)
setUploadSummary(null)
}
/** 移除一个待上传文件 */
function removeFile(idx: number) {
setUploadFiles(prev => prev.filter((_, i) => i !== idx))
}
/** 执行上传 */
async function doUpload() {
if (uploadFiles.length === 0) { toast.error('请先选择 ZIP 文件'); return }
setUploading(true)
setUploadResults(null)
setUploadSummary(null)
try {
const form = new FormData()
uploadFiles.forEach(f => form.append('files', f))
const d = await api.upload('/api/skill/upload-zip', form)
if (d.ok) {
setUploadResults(d.results || [])
setUploadSummary(d.summary || null)
const summary = d.summary
if (summary) {
if (summary.imported > 0) {
toast.success(`成功导入 ${summary.imported} 个技能`)
} else if (summary.skipped > 0) {
toast.warning(`${summary.skipped} 个技能已存在,已自动跳过`)
}
}
load() // 刷新技能列表
} else {
toast.error(d.msg || '上传失败')
}
} catch (e: any) {
toast.error(e.message || '上传失败')
} finally {
setUploading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
}
const builtIn = skills.filter(s => !s.isCustom)
const custom = skills.filter(s => s.isCustom)
return (
<div className="space-y-6">
{/* 操作栏 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
安装技能包后,AI 会自动获得对应的专业能力和行为模式
</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => { setUploadOpen(true); setUploadResults(null); setUploadSummary(null) }}>
<Upload className="mr-1.5 h-4 w-4" />
上传 Skills
</Button>
<Button size="sm" onClick={openCreate}>
<Plus className="mr-1.5 h-4 w-4" />
创建自定义技能
</Button>
</div>
</div>
{/* 内置技能 */}
<div>
<h3 className="mb-3 text-sm font-semibold text-muted-foreground">
内置技能({builtIn.length})
</h3>
<div className="grid gap-3 sm:grid-cols-2">
{builtIn.map(s => (
<SkillCard key={s.id} skill={s} installing={installing} onToggle={toggleSkill} />
))}
</div>
</div>
{/* 自定义技能 */}
{custom.length > 0 && (
<div>
<h3 className="mb-3 text-sm font-semibold text-muted-foreground">
自定义技能({custom.length})
</h3>
<div className="grid gap-3 sm:grid-cols-2">
{custom.map(s => {
const cs = customSkills.find(c => 'custom_' + c.id === s.id)
return (
<SkillCard
key={s.id}
skill={s}
installing={installing}
onToggle={toggleSkill}
isCustom
onEdit={cs ? () => openEdit(cs) : undefined}
onDelete={cs ? () => { setDeleteTarget(cs); setDeleteOpen(true) } : undefined}
/>
)
})}
</div>
</div>
)}
{/* 创建/编辑对话框 */}
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editor.id ? '编辑自定义技能' : '创建自定义技能'}</DialogTitle>
<DialogDescription>
自定义技能会扩展 AI 的专业能力,安装后自动生效
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 名称 */}
<div className="space-y-1.5">
<Label>技能名称</Label>
<Input
value={editor.name}
onChange={e => setEditor(p => ({ ...p, name: e.target.value }))}
placeholder="如:法律顾问"
maxLength={20}
/>
</div>
{/* 描述 */}
<div className="space-y-1.5">
<Label>简短描述</Label>
<Input
value={editor.description}
onChange={e => setEditor(p => ({ ...p, description: e.target.value }))}
placeholder="一句话描述技能的功能"
maxLength={60}
/>
</div>
{/* Prompt 提示词 */}
<div className="space-y-1.5">
<Label>系统提示词</Label>
<Textarea
value={editor.prompt}
onChange={e => setEditor(p => ({ ...p, prompt: e.target.value }))}
placeholder={`设定 AI 的角色和行为,例如:
你是一位资深法律顾问,熟悉中国法律体系。回答法律相关问题时,提供清晰的法律依据和实务建议。对于不确定的问题,建议咨询专业律师。`}
rows={8}
className="font-mono text-sm"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditorOpen(false)} disabled={saving}>
取消
</Button>
<Button onClick={saveEditor} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editor.id ? '保存更改' : '创建技能'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认 */}
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
删除自定义技能「{deleteTarget?.name}」后,所有已安装此技能的机器人将自动卸载。此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ZIP 上传对话框 */}
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
<DialogContent className="sm:max-w-xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="h-5 w-5 text-blue-500" />
上传 Skills
</DialogTitle>
<DialogDescription>
支持批量上传 ZIP 技能包,系统会自动校验目录结构
</DialogDescription>
</DialogHeader>
{/* 拖拽上传区 */}
<div
className={`relative rounded-xl border-2 border-dashed p-8 text-center transition-colors cursor-pointer ${
dragOver
? 'border-blue-400 bg-blue-50 dark:bg-blue-950/20'
: 'border-gray-200 hover:border-blue-300 hover:bg-blue-50/50 dark:border-gray-700 dark:hover:border-blue-700 dark:hover:bg-blue-950/10'
}`}
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
onDragLeave={e => { e.preventDefault(); setDragOver(false) }}
onDrop={e => {
e.preventDefault()
setDragOver(false)
if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files)
}}
onClick={() => fileInputRef.current?.click()}
>
<FileArchive className="mx-auto h-10 w-10 text-muted-foreground mb-3" />
<p className="text-sm font-medium">
拖拽多个 ZIP 文件到这里
</p>
<p className="text-xs text-muted-foreground mt-1">
或者点击之后在文件夹中选择多个文件
</p>
<input
ref={fileInputRef}
type="file"
accept=".zip"
multiple
className="hidden"
onChange={e => { if (e.target.files?.length) addFiles(e.target.files) }}
/>
</div>
{/* 待上传文件列表 */}
{uploadFiles.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
待上传文件({uploadFiles.length} 个)
</p>
<div className="max-h-32 overflow-y-auto space-y-1">
{uploadFiles.map((f, i) => (
<div key={i} className="flex items-center justify-between rounded-lg border bg-muted/30 px-3 py-1.5 text-xs">
<div className="flex items-center gap-2 min-w-0">
<FileArchive className="h-3.5 w-3.5 shrink-0 text-blue-500" />
<span className="truncate">{f.name}</span>
<span className="text-muted-foreground shrink-0">
{(f.size / 1024).toFixed(0)} KB
</span>
</div>
<button
onClick={e => { e.stopPropagation(); removeFile(i) }}
className="ml-2 shrink-0 rounded p-0.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
disabled={uploading}
>
<XCircle className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</div>
)}
{/* 上传按钮 */}
{uploadFiles.length > 0 && (
<Button onClick={doUpload} disabled={uploading} className="w-full">
{uploading ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />正在上传并校验…</>
) : (
<><Upload className="mr-2 h-4 w-4" />开始上传 {uploadFiles.length} 个文件</>
)}
</Button>
)}
{/* 上传结果 */}
{uploadResults && (
<div className="flex-1 overflow-y-auto -mx-6 px-6 space-y-2">
{/* 汇总 */}
{uploadSummary && (
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2 text-xs">
<FileArchive className="h-4 w-4 text-blue-500" />
<span>{uploadSummary.files} 个文件</span>
<span className="text-muted-foreground">·</span>
<span>{uploadSummary.skills} 个技能</span>
{uploadSummary.imported > 0 && (
<span className="text-green-600 font-medium">{uploadSummary.imported} 个导入成功</span>
)}
{uploadSummary.skipped > 0 && (
<span className="text-amber-600 font-medium">{uploadSummary.skipped} 个已跳过</span>
)}
{uploadSummary.failed > 0 && (
<span className="text-red-600 font-medium">{uploadSummary.failed} 个失败</span>
)}
</div>
)}
{/* 逐文件结果 */}
{uploadResults.map((fr: any, fi: number) => (
<div key={fi} className="rounded-xl border overflow-hidden">
{/* 文件头 */}
<div className={`flex items-center gap-2 px-3 py-2 text-xs font-medium ${
fr.status === 'success' ? 'bg-green-50 dark:bg-green-950/20' :
fr.status === 'error' ? 'bg-red-50 dark:bg-red-950/20' :
fr.status === 'partial' ? 'bg-amber-50 dark:bg-amber-950/20' :
fr.status === 'skipped' ? 'bg-amber-50 dark:bg-amber-950/20' :
'bg-muted/30'
}`}>
{fr.status === 'success' ? <CheckCircle2 className="h-4 w-4 text-green-500" /> :
fr.status === 'error' ? <XCircle className="h-4 w-4 text-red-500" /> :
fr.status === 'partial' ? <AlertTriangle className="h-4 w-4 text-amber-500" /> :
fr.status === 'skipped' ? <SkipForward className="h-4 w-4 text-amber-500" /> :
<FileWarning className="h-4 w-4 text-muted-foreground" />}
<span className="flex-1 truncate">{fr.file}</span>
{fr.summary && (
<span className="text-muted-foreground">
{fr.summary.imported > 0 && `${fr.summary.imported} OK `}
{fr.summary.skipped > 0 && `${fr.summary.skipped} Skip `}
{fr.summary.failed > 0 && `${fr.summary.failed} Fail`}
</span>
)}
</div>
{/* 文件级错误 */}
{fr.errors && fr.errors.length > 0 && (
<div className="px-3 py-1.5 space-y-0.5">
{fr.errors.map((err: string, ei: number) => (
<p key={ei} className="text-xs text-red-600">{err}</p>
))}
</div>
)}
{/* 技能级结果 */}
{fr.skills && fr.skills.length > 0 && (
<div className="divide-y">
{fr.skills.map((sr: any, si: number) => (
<div key={si} className="flex items-center gap-2 px-3 py-1.5 text-xs">
{sr.status === 'imported' ? (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
) : sr.status === 'skipped' ? (
<SkipForward className="h-3.5 w-3.5 text-amber-500 shrink-0" />
) : (
<XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
)}
<span className="font-medium">{sr.name}</span>
<span className="text-muted-foreground">{sr.msg}</span>
</div>
))}
</div>
)}
</div>
))}
{/* 关闭 + 重新上传 */}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => { setUploadFiles([]); setUploadResults(null); setUploadSummary(null) }}
>
重新上传
</Button>
<Button size="sm" variant="outline" onClick={() => { setUploadOpen(false); setTimeout(() => { setUploadFiles([]); setUploadResults(null); setUploadSummary(null) }, 200) }}>
关闭
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}
/** 单个技能卡片 */
function SkillCard({
skill,
installing,
onToggle,
isCustom,
onEdit,
onDelete,
}: {
skill: SkillItem
installing: Set<string>
onToggle: (s: SkillItem) => void
isCustom?: boolean
onEdit?: () => void
onDelete?: () => void
}) {
const busy = installing.has(skill.id)
return (
<div className={`flex items-start gap-3 rounded-xl border p-4 transition-colors ${
skill.installed ? 'border-primary/30 bg-primary/5' : 'bg-card hover:bg-accent/40'
}`}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold truncate">{skill.name}</span>
{isCustom && (
<span className="shrink-0 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-400">
自定义
</span>
)}
{skill.installed && (
<span className="shrink-0 rounded bg-green-100 px-1.5 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/40 dark:text-green-400">
已安装
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">{skill.description}</p>
{isCustom && (
<div className="mt-2 flex gap-1">
{onEdit && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={onEdit}>
<Pencil className="mr-1 h-3 w-3" />编辑
</Button>
)}
{onDelete && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-destructive hover:text-destructive" onClick={onDelete}>
<Trash2 className="mr-1 h-3 w-3" />删除
</Button>
)}
</div>
)}
</div>
<Switch
checked={skill.installed}
onCheckedChange={() => onToggle(skill)}
disabled={busy}
/>
</div>
)
}