feat: add skill management
This commit is contained in:
+214
-7
@@ -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"}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+2
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user