feat: skill unified
This commit is contained in:
+95
-14
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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')} />
|
||||||
|
|||||||
Reference in New Issue
Block a user