码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/web/src/components/SkillConfig.tsx
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>
  )
}