feat: add skill management

This commit is contained in:
qixinbo
2026-03-16 17:26:02 +08:00
parent 518a7eeaa4
commit 66dfd94486
6 changed files with 465 additions and 128 deletions
+214 -7
View File
@@ -1,12 +1,22 @@
import json
import os
import shutil
import zipfile
import tarfile
import yaml
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, HTTPException
from datetime import datetime
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from pydantic import BaseModel, Field
router = APIRouter()
DATA_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "data", "skills.json")
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
DATA_FILE = os.path.join(BASE_DIR, "data", "skills.json")
SKILL_HUB_DIR = os.path.join(BASE_DIR, "data", "skill-hub")
# Ensure skill-hub directory exists
os.makedirs(SKILL_HUB_DIR, exist_ok=True)
class Skill(BaseModel):
id: str = Field(..., description="Unique identifier for the skill")
@@ -15,6 +25,10 @@ class Skill(BaseModel):
content: str = Field(..., description="The content/prompt/logic of the skill")
type: str = Field("python", description="Type of the skill (python, sql, api)")
project_id: Optional[int] = Field(None, description="The ID of the project this skill belongs to")
source: str = Field("本地导入", description="Source of the skill (e.g., 本地导入, GitHub 导入)")
installation_time: str = Field(default_factory=lambda: datetime.now().strftime("%Y年%m月%d"), description="Time when the skill was installed")
status: str = Field("安全", description="Security status of the skill (e.g., 安全, 低风险)")
file_path: Optional[str] = Field(None, description="Path to the skill folder in skill-hub")
class SkillCreate(BaseModel):
id: str
@@ -23,6 +37,10 @@ class SkillCreate(BaseModel):
content: str
type: str = "python"
project_id: Optional[int] = None
source: str = "本地导入"
installation_time: Optional[str] = None
status: str = "安全"
file_path: Optional[str] = None
class SkillUpdate(BaseModel):
name: Optional[str] = None
@@ -30,6 +48,54 @@ class SkillUpdate(BaseModel):
content: Optional[str] = None
type: Optional[str] = None
project_id: Optional[int] = None
source: Optional[str] = None
installation_time: Optional[str] = None
status: Optional[str] = None
file_path: Optional[str] = None
def _parse_skill_md(file_path: str) -> Dict[str, Any]:
"""Parse SKILL.md for metadata and content according to agentskills.io standard."""
if not os.path.exists(file_path):
return {}
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
except Exception as e:
print(f"Error reading {file_path}: {e}")
return {}
# Split YAML frontmatter and Markdown body
# Support both --- and +++ for frontmatter
metadata = {}
body = content
if content.startswith('---'):
parts = content.split('---', 2)
if len(parts) >= 3:
try:
metadata = yaml.safe_load(parts[1]) or {}
body = parts[2].strip()
except Exception as e:
print(f"Error parsing YAML frontmatter: {e}")
# Extract name and description, fallback to some defaults
name = metadata.get("name")
description = metadata.get("description")
# If name not in metadata, try to find the first H1 in markdown body
if not name:
for line in body.split('\n'):
if line.startswith('# '):
name = line[2:].strip()
break
return {
"name": name,
"description": description,
"content": body,
"metadata": metadata
}
def _load_data() -> List[Dict[str, Any]]:
if not os.path.exists(DATA_FILE):
@@ -37,13 +103,13 @@ def _load_data() -> List[Dict[str, Any]]:
try:
with open(DATA_FILE, "r") as f:
return json.load(f)
except json.JSONDecodeError:
except (json.JSONDecodeError, FileNotFoundError):
return []
def _save_data(data: List[Dict[str, Any]]):
os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True)
with open(DATA_FILE, "w") as f:
json.dump(data, f, indent=2)
json.dump(data, f, indent=2, ensure_ascii=False)
def load_skills(project_id: Optional[int] = None) -> List[Dict[str, Any]]:
data = _load_data()
@@ -66,16 +132,142 @@ def get_skill(skill_id: str, project_id: Optional[int] = None):
return Skill(**item)
raise HTTPException(status_code=404, detail="Skill not found")
@router.post("/skills/upload")
async def upload_skill(
file: UploadFile = File(...),
project_id: Optional[int] = Form(None)
):
"""Upload a skill file (SKILL.md) or a packaged skill (zip/tar.gz)."""
filename = file.filename
print(f"Uploading skill: {filename}, project_id: {project_id}")
# Create a unique temp directory
temp_dir_name = f"temp_{datetime.now().timestamp()}_{os.urandom(4).hex()}"
temp_dir = os.path.join(SKILL_HUB_DIR, temp_dir_name)
os.makedirs(temp_dir, exist_ok=True)
try:
file_path = os.path.join(temp_dir, filename)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
skill_source_dir = None
# Handle different file types
if filename.endswith(".zip"):
try:
with zipfile.ZipFile(file_path, 'r') as zip_ref:
zip_ref.extractall(temp_dir)
os.remove(file_path)
# Find the directory containing SKILL.md
for root, dirs, files in os.walk(temp_dir):
if "SKILL.md" in files:
skill_source_dir = root
break
except Exception as e:
print(f"Zip extraction failed: {e}")
raise HTTPException(status_code=400, detail=f"Failed to extract zip: {str(e)}")
elif filename.endswith((".tar.gz", ".tgz")):
try:
with tarfile.open(file_path, 'r:gz') as tar_ref:
tar_ref.extractall(temp_dir)
os.remove(file_path)
for root, dirs, files in os.walk(temp_dir):
if "SKILL.md" in files:
skill_source_dir = root
break
except Exception as e:
print(f"Tarball extraction failed: {e}")
raise HTTPException(status_code=400, detail=f"Failed to extract tarball: {str(e)}")
elif filename == "SKILL.md":
skill_source_dir = temp_dir
else:
print(f"Unsupported file type: {filename}")
raise HTTPException(status_code=400, detail="Only SKILL.md or packaged skills (zip/tar.gz) are supported")
if not skill_source_dir or not os.path.exists(os.path.join(skill_source_dir, "SKILL.md")):
print(f"SKILL.md not found in {filename}")
raise HTTPException(status_code=400, detail="SKILL.md not found in the uploaded file")
# Parse metadata
skill_md_path = os.path.join(skill_source_dir, "SKILL.md")
metadata_res = _parse_skill_md(skill_md_path)
# Use metadata name, or fallback to folder name or filename
skill_name = metadata_res.get("name")
if not skill_name:
if filename == "SKILL.md":
skill_name = "unnamed_skill"
else:
# Use filename without extension
skill_name = os.path.splitext(filename)[0]
# Create a safe directory name for the skill
import re
safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '_', skill_name).lower()
if not safe_name: safe_name = "skill"
final_skill_id = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
final_skill_dir = os.path.join(SKILL_HUB_DIR, final_skill_id)
print(f"Finalizing skill: {skill_name} -> {final_skill_dir}")
# Move the skill content to final destination
os.makedirs(final_skill_dir, exist_ok=True)
for item in os.listdir(skill_source_dir):
s = os.path.join(skill_source_dir, item)
d = os.path.join(final_skill_dir, item)
if os.path.isdir(s):
shutil.copytree(s, d, dirs_exist_ok=True)
else:
shutil.copy2(s, d)
# Register in skills.json
data = _load_data()
new_skill = {
"id": final_skill_id,
"name": skill_name,
"description": metadata_res.get("description") or "No description provided",
"content": metadata_res.get("content") or "",
"type": "agentskill",
"project_id": project_id,
"source": "文件上传",
"installation_time": datetime.now().strftime("%Y年%m月%d"),
"status": "安全",
"file_path": final_skill_dir
}
data.append(new_skill)
_save_data(data)
print(f"Skill registered successfully: {final_skill_id}")
return new_skill
except HTTPException:
raise
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(e)}")
finally:
# Cleanup temp directory
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
@router.post("/skills", response_model=Skill)
def create_skill(skill: SkillCreate):
data = _load_data()
if any(item["id"] == skill.id for item in data):
raise HTTPException(status_code=400, detail="Skill with this ID already exists")
new_skill = skill.dict()
data.append(new_skill)
new_skill_dict = skill.dict()
if not new_skill_dict.get("installation_time"):
new_skill_dict["installation_time"] = datetime.now().strftime("%Y年%m月%d")
data.append(new_skill_dict)
_save_data(data)
return Skill(**new_skill)
return Skill(**new_skill_dict)
@router.put("/skills/{skill_id}", response_model=Skill)
def update_skill(skill_id: str, skill: SkillUpdate, project_id: Optional[int] = None):
@@ -100,17 +292,32 @@ def delete_skill(skill_id: str, project_id: Optional[int] = None):
# If project_id is provided, we only delete if it matches
new_data = []
found = False
skill_to_delete = None
for item in data:
if item["id"] == skill_id:
if project_id is not None and item.get("project_id") != project_id:
new_data.append(item)
continue
found = True
skill_to_delete = item
else:
new_data.append(item)
if not found:
raise HTTPException(status_code=404, detail="Skill not found")
# Clean up file_path if it exists
if skill_to_delete and skill_to_delete.get("file_path"):
file_path = skill_to_delete["file_path"]
if os.path.exists(file_path):
try:
if os.path.isdir(file_path):
shutil.rmtree(file_path)
else:
os.remove(file_path)
except Exception as e:
print(f"Error deleting skill files at {file_path}: {e}")
_save_data(new_data)
return {"message": "Skill deleted successfully"}
+1
View File
@@ -34,6 +34,7 @@ dependencies = [
"python-socketio>=5.16.0,<6.0.0",
"python-socks[asyncio]>=2.8.0,<3.0.0",
"python-telegram-bot[socks]>=22.6,<23.0",
"pyyaml>=6.0.3",
"qq-botpy>=1.2.0,<2.0.0",
"readability-lxml>=0.8.4,<1.0.0",
"rich>=14.0.0,<15.0.0",
+2
View File
@@ -220,6 +220,7 @@ dependencies = [
{ name = "python-socketio" },
{ name = "python-socks" },
{ name = "python-telegram-bot", extra = ["socks"] },
{ name = "pyyaml" },
{ name = "qq-botpy" },
{ name = "readability-lxml" },
{ name = "rich" },
@@ -264,6 +265,7 @@ requires-dist = [
{ name = "python-socketio", specifier = ">=5.16.0,<6.0.0" },
{ name = "python-socks", extras = ["asyncio"], specifier = ">=2.8.0,<3.0.0" },
{ name = "python-telegram-bot", extras = ["socks"], specifier = ">=22.6,<23.0" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "qq-botpy", specifier = ">=1.2.0,<2.0.0" },
{ name = "readability-lxml", specifier = ">=0.8.4,<1.0.0" },
{ name = "rich", specifier = ">=14.0.0,<15.0.0" },
+4 -1
View File
@@ -547,7 +547,10 @@ function SidebarBody() {
</div>
</button>
<button className="flex items-center gap-1.5 text-sm hover:text-zinc-900 transition-colors px-2 py-1.5 rounded-md hover:bg-zinc-100">
<button
className="flex items-center gap-1.5 text-sm hover:text-zinc-900 transition-colors px-2 py-1.5 rounded-md hover:bg-zinc-100"
onClick={() => navigate("/skills")}
>
<Wand2 className="h-4 w-4" />
</button>
+9 -5
View File
@@ -7,11 +7,15 @@ interface RequestOptions extends RequestInit {
async function request<T>(url: string, options: RequestOptions = {}): Promise<T> {
const token = localStorage.getItem('token');
const defaultHeaders = {
'Content-Type': 'application/json',
const defaultHeaders: Record<string, string> = {
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
};
// Only set Content-Type to application/json if data is not FormData
if (!(options.body instanceof FormData)) {
defaultHeaders['Content-Type'] = 'application/json';
}
const config: RequestInit = {
...options,
headers: {
@@ -50,10 +54,10 @@ async function request<T>(url: string, options: RequestOptions = {}): Promise<T>
export const api = {
get: <T>(url: string, options?: RequestOptions) => request<T>(url, { ...options, method: 'GET' }),
post: <T>(url: string, data: any, options?: RequestOptions) =>
request<T>(url, { ...options, method: 'POST', body: JSON.stringify(data) }),
request<T>(url, { ...options, method: 'POST', body: data instanceof FormData ? data : JSON.stringify(data) }),
put: <T>(url: string, data: any, options?: RequestOptions) =>
request<T>(url, { ...options, method: 'PUT', body: JSON.stringify(data) }),
request<T>(url, { ...options, method: 'PUT', body: data instanceof FormData ? data : JSON.stringify(data) }),
delete: <T>(url: string, options?: RequestOptions) => request<T>(url, { ...options, method: 'DELETE' }),
patch: <T>(url: string, data: any, options?: RequestOptions) =>
request<T>(url, { ...options, method: 'PATCH', body: JSON.stringify(data) }),
request<T>(url, { ...options, method: 'PATCH', body: data instanceof FormData ? data : JSON.stringify(data) }),
};
+214 -94
View File
@@ -2,22 +2,28 @@ import { useState, useEffect } from 'react';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Trash2, Edit2, Plus, Terminal, Loader2, FolderOpen } from "lucide-react";
import { Trash2, Edit2, Plus, Terminal, Loader2, FolderOpen, Share2, Download, Eye, ShieldCheck, AlertCircle, Wand2, Upload } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import { useProjectStore } from "@/store/projectStore";
import { useRef } from 'react';
interface Skill {
id: string;
name: string;
description: string;
content: string;
type: 'python' | 'sql' | 'api';
type: string;
project_id?: number;
source: string;
installation_time: string;
status: string;
file_path?: string;
}
export function Skills() {
@@ -25,8 +31,9 @@ export function Skills() {
const [isLoading, setIsLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
const [newSkill, setNewSkill] = useState<Partial<Skill>>({ type: 'python', content: '' });
const [newSkill, setNewSkill] = useState<Partial<Skill>>({ type: 'python', content: '', source: '本地导入', status: '安全' });
const { currentProject } = useProjectStore();
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (currentProject) {
@@ -47,26 +54,47 @@ export function Skills() {
}
};
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !currentProject) return;
const formData = new FormData();
formData.append('file', file);
formData.append('project_id', currentProject.id.toString());
setIsLoading(true);
try {
await api.post('/api/v1/skills/upload', formData);
await fetchSkills();
} catch (error: any) {
console.error("Failed to upload skill", error);
const errorMessage = error.response?.data?.detail || error.message || "未知错误";
alert("上传失败: " + errorMessage);
} finally {
setIsLoading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleAddSkill = async () => {
if (!currentProject) return;
if (newSkill.name && newSkill.description && newSkill.content) {
try {
if (editingSkill) {
const updatedSkill = await api.put<Skill>(`/api/v1/skills/${editingSkill.id}?project_id=${currentProject.id}`, {
await api.put<Skill>(`/api/v1/skills/${editingSkill.id}?project_id=${currentProject.id}`, {
...newSkill,
project_id: currentProject.id
});
setSkills(skills.map(s => s.id === editingSkill.id ? updatedSkill : s));
} else {
const skillToCreate = {
...newSkill,
id: Date.now().toString(),
project_id: currentProject.id
};
const createdSkill = await api.post<Skill>('/api/v1/skills', skillToCreate);
setSkills([...skills, createdSkill]);
await api.post<Skill>('/api/v1/skills', skillToCreate);
}
setNewSkill({ type: 'python', content: '' });
await fetchSkills();
setNewSkill({ type: 'python', content: '', source: '本地导入', status: '安全' });
setEditingSkill(null);
setIsDialogOpen(false);
} catch (error) {
@@ -102,136 +130,228 @@ export function Skills() {
}
return (
<div className="p-6 h-full flex flex-col overflow-hidden">
<div className="flex justify-between items-center mb-6 shrink-0">
<div className="h-full flex flex-col bg-white overflow-hidden">
<div className="border-b border-zinc-100 px-8 py-5 flex items-center justify-between bg-white shrink-0">
<div>
<h1 className="text-2xl font-bold"> - {currentProject.name}</h1>
<p className="text-muted-foreground"> AI </p>
<h1 className="text-2xl font-bold text-zinc-900 flex items-center gap-2">
< Wand2 className="h-6 w-6 text-indigo-500" />
Skills - {currentProject.name}
</h1>
<p className="text-sm text-zinc-500 mt-1"> AI agentskills.io </p>
</div>
<div className="flex gap-3">
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
className="hidden"
accept=".md,.zip,.tar.gz,.tgz"
/>
<Button
className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-4 w-4" />
Skill
</Button>
</div>
</div>
<div className="flex-1 overflow-auto p-4 md:p-8 bg-zinc-50/30">
<div className="bg-white rounded-xl border border-zinc-200 shadow-sm overflow-hidden min-w-[800px] lg:min-w-0">
<Table className="table-fixed w-full">
<TableHeader className="bg-zinc-50/50">
<TableRow className="hover:bg-transparent">
<TableHead className="w-[40%] font-semibold text-zinc-700 py-3 px-4 text-sm"></TableHead>
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm"></TableHead>
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm text-center"></TableHead>
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm text-center"></TableHead>
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="py-24 text-center">
<div className="flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-indigo-500" />
</div>
</TableCell>
</TableRow>
) : (
<>
{skills.map((skill) => (
<TableRow key={skill.id} className="group hover:bg-zinc-50/50 transition-colors border-zinc-100">
<TableCell className="py-4 px-4 overflow-hidden">
<div className="flex items-start gap-3 min-w-0">
<div className="p-2 bg-indigo-50 rounded-lg text-indigo-600 mt-0.5 shrink-0">
<Terminal className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<h3 className="font-bold text-zinc-900 text-sm md:text-base truncate flex-1" title={skill.name}>{skill.name}</h3>
{skill.type === 'agentskill' && (
<span className="px-1.5 py-0.5 bg-indigo-100 text-indigo-700 text-[10px] font-bold rounded uppercase tracking-wider shrink-0">
Agent
</span>
)}
</div>
<p
className="text-zinc-500 text-xs leading-relaxed truncate cursor-help"
title={skill.description}
>
{skill.description}
</p>
</div>
</div>
</TableCell>
<TableCell className="py-4 px-4 text-zinc-600 text-sm">
<div className="truncate" title={skill.source}>{skill.source}</div>
</TableCell>
<TableCell className="py-4 px-4 text-zinc-400 text-center text-xs">
<div className="truncate">{skill.installation_time}</div>
</TableCell>
<TableCell className="py-4 px-4 text-center">
<div className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] md:text-xs font-medium whitespace-nowrap ${
skill.status === '安全'
? 'bg-green-50 text-green-700 border border-green-100'
: 'bg-amber-50 text-amber-700 border border-amber-100'
}`}>
{skill.status === '安全' ? (
<ShieldCheck className="h-3 w-3" />
) : (
<AlertCircle className="h-3 w-3" />
)}
{skill.status}
</div>
</TableCell>
<TableCell className="py-4 px-4 text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-zinc-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-md transition-all"
onClick={() => handleEditSkill(skill)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-zinc-400 hover:text-rose-600 hover:bg-rose-50 rounded-md transition-all"
onClick={() => handleDeleteSkill(skill.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{skills.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-24 text-center">
<div className="flex flex-col items-center gap-3 text-zinc-400">
<div className="p-4 bg-zinc-50 rounded-2xl">
<Terminal className="h-10 w-10 opacity-20" />
</div>
<p className="text-sm"> Skill</p>
</div>
</TableCell>
</TableRow>
)}
</>
)}
</TableBody>
</Table>
</div>
</div>
<Dialog open={isDialogOpen} onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) {
setEditingSkill(null);
setNewSkill({ type: 'python', content: '' });
setNewSkill({ type: 'python', content: '', source: '本地导入', status: '安全' });
}
}}>
<DialogTrigger render={
<Button onClick={() => {
setEditingSkill(null);
setNewSkill({ type: 'python', content: '' });
setIsDialogOpen(true);
}}>
<Plus className="h-4 w-4 mr-2" />
</Button>
} />
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{editingSkill ? '编辑技能' : '添加新技能'}</DialogTitle>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col rounded-2xl p-0 overflow-hidden">
<DialogHeader className="p-6 pb-2">
<DialogTitle className="text-xl font-bold text-zinc-900">{editingSkill ? '编辑技能' : '添加新技能'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right"></Label>
<div className="flex-1 overflow-y-auto px-6 py-2">
<div className="grid gap-5">
<div className="grid gap-1.5">
<Label htmlFor="name" className="text-zinc-600 font-medium text-sm"></Label>
<Input
id="name"
placeholder="技能名称"
value={newSkill.name || ''}
onChange={(e) => setNewSkill({...newSkill, name: e.target.value})}
className="col-span-3"
className="rounded-lg border-zinc-200 h-10"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right"></Label>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label htmlFor="type" className="text-zinc-600 font-medium text-sm"></Label>
<Select
value={newSkill.type}
onValueChange={(val: any) => setNewSkill({...newSkill, type: val})}
>
<SelectTrigger className="col-span-3">
<SelectTrigger className="rounded-lg border-zinc-200 h-10">
<SelectValue placeholder="选择类型" />
</SelectTrigger>
<SelectContent>
<SelectContent className="rounded-lg">
<SelectItem value="python">Python</SelectItem>
<SelectItem value="sql">SQL</SelectItem>
<SelectItem value="api">API</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right"></Label>
<div className="grid gap-1.5">
<Label htmlFor="status" className="text-zinc-600 font-medium text-sm"></Label>
<Select
value={newSkill.status}
onValueChange={(val: any) => setNewSkill({...newSkill, status: val})}
>
<SelectTrigger className="rounded-lg border-zinc-200 h-10">
<SelectValue placeholder="选择状态" />
</SelectTrigger>
<SelectContent className="rounded-lg">
<SelectItem value="安全"></SelectItem>
<SelectItem value="低风险"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="description" className="text-zinc-600 font-medium text-sm"></Label>
<Textarea
id="description"
placeholder="简要描述技能的功能..."
value={newSkill.description || ''}
onChange={(e) => setNewSkill({...newSkill, description: e.target.value})}
className="col-span-3"
className="rounded-lg border-zinc-200 min-h-[80px] py-2 text-sm"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="content" className="text-right"></Label>
<div className="grid gap-1.5">
<Label htmlFor="content" className="text-zinc-600 font-medium text-sm"></Label>
<Textarea
id="content"
value={newSkill.content || ''}
onChange={(e) => setNewSkill({...newSkill, content: e.target.value})}
className="col-span-3 font-mono text-xs"
className="rounded-lg border-zinc-200 font-mono text-xs min-h-[160px] py-3 bg-zinc-50"
placeholder="Python 代码、SQL 查询模板或 API 规范..."
/>
</div>
</div>
<DialogFooter>
<Button onClick={handleAddSkill}></Button>
</div>
<DialogFooter className="p-6 pt-2">
<Button onClick={handleAddSkill} className="bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg px-6 h-10 w-full">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<ScrollArea className="flex-1">
{isLoading ? (
<div className="flex items-center justify-center h-40">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-4">
{skills.map((skill) => (
<Card key={skill.id} className="hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<div className="space-y-1">
<CardTitle className="text-base font-medium flex items-center gap-2">
<Terminal className="h-4 w-4 text-muted-foreground" />
{skill.name}
</CardTitle>
<CardDescription>{skill.type.toUpperCase()}</CardDescription>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => handleEditSkill(skill)}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => handleDeleteSkill(skill.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground line-clamp-2">{skill.description}</p>
</CardContent>
</Card>
))}
{skills.length === 0 && (
<div className="col-span-full py-12 text-center text-zinc-500 bg-zinc-50 rounded-xl border-2 border-dashed border-zinc-100">
<Terminal className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p></p>
</div>
)}
</div>
)}
</ScrollArea>
</div>
);
}