feat: skill unified

This commit is contained in:
qixinbo
2026-03-30 21:40:34 +08:00
parent 307ea0189e
commit 71e2b87583
3 changed files with 133 additions and 21 deletions
+95 -14
View File
@@ -5,17 +5,20 @@ import zipfile
import tarfile import tarfile
import re import re
import yaml import yaml
from pathlib import Path
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, HTTPException, UploadFile, File, Form from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.core.data_root import get_data_root, get_workspace_root from app.core.data_root import get_data_root, get_workspace_root
from nanobot.agent.skills import BUILTIN_SKILLS_DIR as NANOBOT_BUILTIN_SKILLS_DIR
router = APIRouter() router = APIRouter()
DATA_FILE = str(get_data_root() / "skills.json") DATA_FILE = str(get_data_root() / "skills.json")
SKILL_HUB_DIR = str(get_workspace_root() / "skills") SKILL_HUB_DIR = str(get_workspace_root() / "skills")
BACKEND_BUILTIN_SKILLS_DIR = str(Path(__file__).resolve().parents[1] / "skills_builtin")
def _ensure_skill_hub_dir() -> None: def _ensure_skill_hub_dir() -> None:
os.makedirs(SKILL_HUB_DIR, exist_ok=True) os.makedirs(SKILL_HUB_DIR, exist_ok=True)
@@ -118,16 +121,22 @@ def _dedupe_skills(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
deduped: Dict[str, Dict[str, Any]] = {} deduped: Dict[str, Dict[str, Any]] = {}
for item in data: for item in data:
skill_id = str(item.get("id") or "").strip() skill_id = str(item.get("id") or "").strip()
project_id = item.get("project_id")
if not skill_id: if not skill_id:
continue continue
existing = deduped.get(skill_id)
# Use a composite key of (id, project_id) for deduplication
# so that different projects can theoretically have the same skill_id
dedupe_key = f"{skill_id}_{project_id}"
existing = deduped.get(dedupe_key)
if existing is None: if existing is None:
deduped[skill_id] = item deduped[dedupe_key] = item
continue continue
existing_project = existing.get("project_id")
incoming_project = item.get("project_id") # If they somehow have the exact same dedupe_key, we just keep the later one
if existing_project is None and incoming_project is not None: deduped[dedupe_key] = item
deduped[skill_id] = item
return list(deduped.values()) return list(deduped.values())
def _safe_skill_dir_name(value: str) -> str: def _safe_skill_dir_name(value: str) -> str:
@@ -150,6 +159,48 @@ def _write_skill_markdown(skill_dir: str, skill_name: str, description: Optional
f.write(markdown) f.write(markdown)
return skill_md_path return skill_md_path
def _scan_builtin_skills(data: List[Dict[str, Any]], registered_paths: set, source_dir: str, source_name: str):
if not os.path.exists(source_dir):
return
for item in os.listdir(source_dir):
skill_dir = os.path.abspath(os.path.join(source_dir, item))
if os.path.isdir(skill_dir):
skill_md_path = os.path.join(skill_dir, "SKILL.md")
if os.path.exists(skill_md_path):
metadata_res = _parse_skill_md(skill_md_path)
skill_name = metadata_res.get("name") or item
existing = None
for d in data:
if (d.get("id") == item and d.get("is_builtin")) or d.get("file_path") == skill_dir:
existing = d
break
if existing:
existing["name"] = skill_name
existing["description"] = metadata_res.get("description") or "No description provided"
existing["content"] = metadata_res.get("content") or ""
existing["file_path"] = skill_dir
existing["is_builtin"] = True
existing["source"] = source_name
registered_paths.add(skill_dir)
else:
new_skill = {
"id": item,
"name": skill_name,
"description": metadata_res.get("description") or "No description provided",
"content": metadata_res.get("content") or "",
"type": "agentskill",
"project_id": None,
"source": source_name,
"installation_time": datetime.now().strftime("%Y年%m月%d"),
"status": "安全",
"file_path": skill_dir,
"is_builtin": True
}
data.append(new_skill)
registered_paths.add(skill_dir)
def load_skills(project_id: Optional[int] = None) -> List[Dict[str, Any]]: def load_skills(project_id: Optional[int] = None) -> List[Dict[str, Any]]:
_ensure_skill_hub_dir() _ensure_skill_hub_dir()
data = _load_data() data = _load_data()
@@ -158,7 +209,11 @@ def load_skills(project_id: Optional[int] = None) -> List[Dict[str, Any]]:
# Sync registered skills with their SKILL.md if available # Sync registered skills with their SKILL.md if available
for item in data: for item in data:
item.setdefault("is_builtin", False) if item.get("id") in ("nl2sql", "visualization") or item.get("is_builtin"):
item["is_builtin"] = True
else:
item.setdefault("is_builtin", False)
if item.get("file_path"): if item.get("file_path"):
abs_path = os.path.abspath(item["file_path"]) abs_path = os.path.abspath(item["file_path"])
registered_paths.add(abs_path) registered_paths.add(abs_path)
@@ -172,7 +227,11 @@ def load_skills(project_id: Optional[int] = None) -> List[Dict[str, Any]]:
if metadata_res.get("content"): if metadata_res.get("content"):
item["content"] = metadata_res["content"] item["content"] = metadata_res["content"]
# Scan for unregistered skills in SKILL_HUB_DIR # Scan builtin skills
_scan_builtin_skills(data, registered_paths, NANOBOT_BUILTIN_SKILLS_DIR, "系统内置")
_scan_builtin_skills(data, registered_paths, BACKEND_BUILTIN_SKILLS_DIR, "系统内置")
# Scan for unregistered skills in SKILL_HUB_DIR (1-level deep to match nanobot's behavior)
if os.path.exists(SKILL_HUB_DIR): if os.path.exists(SKILL_HUB_DIR):
for item in os.listdir(SKILL_HUB_DIR): for item in os.listdir(SKILL_HUB_DIR):
skill_dir = os.path.abspath(os.path.join(SKILL_HUB_DIR, item)) skill_dir = os.path.abspath(os.path.join(SKILL_HUB_DIR, item))
@@ -182,14 +241,19 @@ def load_skills(project_id: Optional[int] = None) -> List[Dict[str, Any]]:
metadata_res = _parse_skill_md(skill_md_path) metadata_res = _parse_skill_md(skill_md_path)
skill_name = metadata_res.get("name") or item skill_name = metadata_res.get("name") or item
# Create a new entry for this discovered skill # Try to deduce project_id from directory prefix (e.g., p123_skillname)
deduced_project_id = None
match = re.match(r'^p(\d+)_', item)
if match:
deduced_project_id = int(match.group(1))
new_skill = { new_skill = {
"id": item, "id": item,
"name": skill_name, "name": skill_name,
"description": metadata_res.get("description") or "No description provided", "description": metadata_res.get("description") or "No description provided",
"content": metadata_res.get("content") or "", "content": metadata_res.get("content") or "",
"type": "agentskill", "type": "agentskill",
"project_id": None, "project_id": deduced_project_id,
"source": "后台生成", "source": "后台生成",
"installation_time": datetime.now().strftime("%Y年%m月%d"), "installation_time": datetime.now().strftime("%Y年%m月%d"),
"status": "安全", "status": "安全",
@@ -295,7 +359,14 @@ async def upload_skill(
# Create a safe directory name for the skill # Create a safe directory name for the skill
safe_name = _safe_skill_dir_name(skill_name) safe_name = _safe_skill_dir_name(skill_name)
final_skill_id = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S')}" 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)
if project_id is not None:
# Prefix the folder name with p{project_id}_ to distinguish projects in storage
# without breaking nanobot's 1-level-deep skill loader
final_skill_dir = os.path.join(SKILL_HUB_DIR, f"p{project_id}_{final_skill_id}")
final_skill_id = f"p{project_id}_{final_skill_id}"
else:
final_skill_dir = os.path.join(SKILL_HUB_DIR, final_skill_id)
print(f"Finalizing skill: {skill_name} -> {final_skill_dir}") print(f"Finalizing skill: {skill_name} -> {final_skill_dir}")
@@ -345,14 +416,23 @@ async def upload_skill(
def create_skill(skill: SkillCreate): def create_skill(skill: SkillCreate):
_ensure_skill_hub_dir() _ensure_skill_hub_dir()
data = load_skills() data = load_skills()
if any(item["id"] == skill.id for item in data): if any(item["id"] == skill.id and item.get("project_id") == skill.project_id for item in data):
raise HTTPException(status_code=400, detail="Skill with this ID already exists") raise HTTPException(status_code=400, detail="Skill with this ID already exists in this project")
new_skill_dict = skill.dict() new_skill_dict = skill.dict()
if not new_skill_dict.get("installation_time"): if not new_skill_dict.get("installation_time"):
new_skill_dict["installation_time"] = datetime.now().strftime("%Y年%m月%d") new_skill_dict["installation_time"] = datetime.now().strftime("%Y年%m月%d")
if not new_skill_dict.get("file_path"): if not new_skill_dict.get("file_path"):
skill_dir = os.path.join(SKILL_HUB_DIR, _safe_skill_dir_name(new_skill_dict["id"])) project_id = new_skill_dict.get("project_id")
base_dir_name = _safe_skill_dir_name(new_skill_dict["id"])
if project_id is not None:
# Add prefix for project storage distinction
if not base_dir_name.startswith(f"p{project_id}_"):
base_dir_name = f"p{project_id}_{base_dir_name}"
skill_dir = os.path.join(SKILL_HUB_DIR, base_dir_name)
else:
skill_dir = os.path.join(SKILL_HUB_DIR, base_dir_name)
_write_skill_markdown( _write_skill_markdown(
skill_dir=skill_dir, skill_dir=skill_dir,
skill_name=new_skill_dict["name"], skill_name=new_skill_dict["name"],
@@ -360,6 +440,7 @@ def create_skill(skill: SkillCreate):
content=new_skill_dict.get("content", ""), content=new_skill_dict.get("content", ""),
) )
new_skill_dict["file_path"] = skill_dir new_skill_dict["file_path"] = skill_dir
new_skill_dict["id"] = base_dir_name
data.append(new_skill_dict) data.append(new_skill_dict)
_save_data(data) _save_data(data)
+37 -6
View File
@@ -53,6 +53,7 @@ const dedupeSkillsById = (skills: Skill[]): Skill[] => {
export function Skills() { export function Skills() {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'skills' | 'mcp'>('skills'); const [activeTab, setActiveTab] = useState<'skills' | 'mcp'>('skills');
const [sourceFilter, setSourceFilter] = useState<string>('all');
// Skills state // Skills state
const [skills, setSkills] = useState<Skill[]>([]); const [skills, setSkills] = useState<Skill[]>([]);
@@ -126,6 +127,14 @@ export function Skills() {
} }
}; };
// Get unique sources for the filter dropdown
const uniqueSources = Array.from(new Set(skills.map(s => s.source))).filter(Boolean);
// Filtered skills
const filteredSkills = sourceFilter === 'all'
? skills
: skills.filter(skill => skill.source === sourceFilter);
const fetchMcpServers = async () => { const fetchMcpServers = async () => {
if (!currentProject) return; if (!currentProject) return;
setIsMcpLoading(true); setIsMcpLoading(true);
@@ -329,6 +338,19 @@ export function Skills() {
</div> </div>
{activeTab === 'skills' ? ( {activeTab === 'skills' ? (
<> <>
{uniqueSources.length > 0 && (
<Select value={sourceFilter} onValueChange={(val) => { if (val) setSourceFilter(val); }}>
<SelectTrigger className="w-[140px] h-9">
<SelectValue placeholder={t('filterBySource', '筛选来源')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('allSources', '全部来源')}</SelectItem>
{uniqueSources.map(source => (
<SelectItem key={source} value={source}>{source}</SelectItem>
))}
</SelectContent>
</Select>
)}
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
@@ -339,8 +361,17 @@ export function Skills() {
<Button <Button
className="h-9 bg-[#ff4d29] hover:bg-[#ff4d29]/90 text-white gap-2 rounded-md px-3" className="h-9 bg-[#ff4d29] hover:bg-[#ff4d29]/90 text-white gap-2 rounded-md px-3"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
> >
<Upload className="h-4 w-4" />{t('uploadSkill')} {isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{isLoading ? t('uploading', '上传中...') : t('uploadSkill')}
</Button>
<Button
onClick={() => setIsDialogOpen(true)}
className="h-9 bg-indigo-600 hover:bg-indigo-700 text-white gap-2 rounded-md px-3 shadow-sm"
>
<Plus className="h-4 w-4" />
{t('addNewSkill')}
</Button> </Button>
</> </>
) : ( ) : (
@@ -393,7 +424,7 @@ export function Skills() {
</TableRow> </TableRow>
) : ( ) : (
<> <>
{skills.map((skill, index) => ( {filteredSkills.map((skill, index) => (
<TableRow key={`${skill.id}_${index}`} className="group hover:bg-muted/50/50 transition-colors border-border"> <TableRow key={`${skill.id}_${index}`} className="group hover:bg-muted/50/50 transition-colors border-border">
<TableCell className="py-4 px-4 overflow-hidden"> <TableCell className="py-4 px-4 overflow-hidden">
<div className="flex items-start gap-3 min-w-0"> <div className="flex items-start gap-3 min-w-0">
@@ -464,7 +495,7 @@ export function Skills() {
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
{skills.length === 0 && ( {filteredSkills.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={5} className="py-24 text-center"> <TableCell colSpan={5} className="py-24 text-center">
<div className="flex flex-col items-center gap-3 text-muted-foreground"> <div className="flex flex-col items-center gap-3 text-muted-foreground">
@@ -517,11 +548,11 @@ export function Skills() {
</TableCell> </TableCell>
<TableCell className="py-4 px-4 text-muted-foreground text-sm truncate"> <TableCell className="py-4 px-4 text-muted-foreground text-sm truncate">
<div className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] md:text-xs font-medium whitespace-nowrap ${ <div className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] md:text-xs font-medium whitespace-nowrap ${
mcp.status === 'connected' !mcp.status || mcp.status === 'connected'
? 'bg-green-50 text-green-700 border border-green-100' ? 'bg-green-50 text-green-700 border border-green-100'
: mcp.status.startsWith('error') : mcp.status.startsWith('error')
? 'bg-red-50 text-red-700 border border-red-100' ? 'bg-rose-50 text-rose-700 border border-rose-100'
: 'bg-muted/50 text-foreground/80 border border-border' : 'bg-amber-50 text-amber-700 border border-amber-100'
}`} }`}
title={mcp.status} title={mcp.status}
> >
+1 -1
View File
@@ -97,7 +97,7 @@ export function WebSearchConfig() {
<Label>{t('provider', 'Provider')}</Label> <Label>{t('provider', 'Provider')}</Label>
<Select <Select
value={config.provider} value={config.provider}
onValueChange={(val) => setConfig({ ...config, provider: val })} onValueChange={(val) => { if (val) setConfig({ ...config, provider: val }) }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={t('selectProvider', 'Select a provider')} /> <SelectValue placeholder={t('selectProvider', 'Select a provider')} />