ForcePilot/src/services/skill_service.py

654 lines
21 KiB
Python
Raw Normal View History

from __future__ import annotations
import re
import shutil
import tempfile
import uuid
import zipfile
from pathlib import Path, PurePosixPath
from typing import Any
import yaml
from sqlalchemy.ext.asyncio import AsyncSession
from src import config as sys_config
from src.repositories.skill_repository import SkillRepository
from src.services.mcp_service import get_mcp_server_names
from src.storage.postgres.manager import pg_manager
from src.storage.postgres.models_business import Skill
from src.utils.logging_config import logger
SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
TEXT_FILE_EXTENSIONS = {
".md",
".txt",
".py",
".js",
".ts",
".json",
".yaml",
".yml",
".toml",
".ini",
".cfg",
".conf",
".xml",
".html",
".css",
".sql",
".sh",
".bat",
".ps1",
".env",
".csv",
".tsv",
".rst",
".ipynb",
".vue",
".jsx",
".tsx",
}
_skill_options_cache: list[dict[str, str]] = []
_skill_prompt_metadata_cache: dict[str, dict[str, str]] = {}
_skill_dependency_cache: dict[str, dict[str, list[str]]] = {}
def _normalize_string_list(values: list[str] | None) -> list[str]:
if not values:
return []
normalized: list[str] = []
seen: set[str] = set()
for value in values:
if not isinstance(value, str):
continue
item = value.strip()
if not item or item in seen:
continue
seen.add(item)
normalized.append(item)
return normalized
def _get_buildin_tool_names() -> list[str]:
from src.agents.common.tools import get_buildin_tools
return [tool.name for tool in get_buildin_tools()]
def get_skills_root_dir() -> Path:
root = Path(sys_config.save_dir) / "skills"
root.mkdir(parents=True, exist_ok=True)
return root
def get_skill_options() -> list[dict[str, str]]:
"""返回技能选项缓存(用于 BaseContext configurable options"""
return list(_skill_options_cache)
def get_skill_dependency_options() -> dict[str, list[str]]:
return {
"tools": _get_buildin_tool_names(),
"mcps": get_mcp_server_names(),
"skills": [item["id"] for item in _skill_options_cache],
}
def get_skill_prompt_metadata_by_slugs(slugs: list[str]) -> list[dict[str, str]]:
"""按 slug 顺序返回 skills prompt 元数据(仅缓存,无 IO"""
if not slugs:
return []
result: list[dict[str, str]] = []
seen: set[str] = set()
for slug in slugs:
if slug in seen:
continue
seen.add(slug)
item = _skill_prompt_metadata_cache.get(slug)
if not item:
logger.debug(f"Skill slug not found in cache, skip prompt metadata: {slug}")
continue
result.append(dict(item))
return result
def expand_skill_closure(slugs: list[str]) -> list[str]:
"""递归展开 skill 依赖(仅缓存,无 IO去重保序并去环。"""
ordered_roots = _normalize_string_list(slugs)
if not ordered_roots:
return []
result: list[str] = []
seen: set[str] = set()
def dfs(slug: str, stack: set[str]) -> None:
if slug in stack:
logger.warning(f"Cycle detected in skill dependencies, skip: {' -> '.join([*stack, slug])}")
return
if slug in seen:
return
node = _skill_dependency_cache.get(slug)
if not node:
logger.warning(f"Skill dependency target not found in cache, skip: {slug}")
return
seen.add(slug)
result.append(slug)
next_stack = set(stack)
next_stack.add(slug)
for dep in node.get("skills", []):
dfs(dep, next_stack)
for root in ordered_roots:
dfs(root, set())
return result
def get_dependency_bundle_for_activated_skills(activated_slugs: list[str]) -> dict[str, list[str]]:
closure = expand_skill_closure(activated_slugs)
tools: list[str] = []
mcps: list[str] = []
seen_tools: set[str] = set()
seen_mcps: set[str] = set()
for slug in closure:
dep = _skill_dependency_cache.get(slug, {})
for tool_name in dep.get("tools", []):
if tool_name in seen_tools:
continue
seen_tools.add(tool_name)
tools.append(tool_name)
for server_name in dep.get("mcps", []):
if server_name in seen_mcps:
continue
seen_mcps.add(server_name)
mcps.append(server_name)
return {"tools": tools, "mcps": mcps, "skills": closure}
def get_expanded_visible_skill_slugs(selected_slugs: list[str]) -> list[str]:
"""展开运行时可见 skills根 skills + 递归依赖)。"""
return expand_skill_closure(selected_slugs)
def _set_skill_options_cache(items: list[Skill]) -> None:
global _skill_options_cache, _skill_prompt_metadata_cache, _skill_dependency_cache
_skill_options_cache = [
{
"id": item.slug,
"name": item.name,
"description": item.description,
}
for item in items
]
_skill_prompt_metadata_cache = {
item.slug: {
"name": item.name,
"description": item.description,
"path": f"/skills/{item.slug}/SKILL.md",
}
for item in items
}
_skill_dependency_cache = {
item.slug: {
"tools": _normalize_string_list(item.tool_dependencies or []),
"mcps": _normalize_string_list(item.mcp_dependencies or []),
"skills": _normalize_string_list(item.skill_dependencies or []),
}
for item in items
}
async def init_skills_cache() -> None:
"""启动时加载技能缓存,避免每次构建 configurable_items 触发 DB IO。"""
try:
async with pg_manager.get_async_session_context() as session:
repo = SkillRepository(session)
items = await repo.list_all()
_set_skill_options_cache(items)
logger.info(f"Loaded skills cache with {len(items)} items")
except Exception as e:
logger.warning(f"Failed to initialize skills cache: {e}")
async def list_skills(db: AsyncSession) -> list[Skill]:
repo = SkillRepository(db)
items = await repo.list_all()
_set_skill_options_cache(items)
return items
def _validate_dependencies(
*,
slug: str,
tool_dependencies: list[str],
mcp_dependencies: list[str],
skill_dependencies: list[str],
) -> tuple[list[str], list[str], list[str]]:
tools = _normalize_string_list(tool_dependencies)
mcps = _normalize_string_list(mcp_dependencies)
skills = _normalize_string_list(skill_dependencies)
available_tools = set(_get_buildin_tool_names())
invalid_tools = [name for name in tools if name not in available_tools]
if invalid_tools:
raise ValueError(f"存在无效工具依赖: {', '.join(invalid_tools)}")
available_mcps = set(get_mcp_server_names())
invalid_mcps = [name for name in mcps if name not in available_mcps]
if invalid_mcps:
raise ValueError(f"存在无效 MCP 依赖: {', '.join(invalid_mcps)}")
available_skills = {item["id"] for item in _skill_options_cache}
invalid_skills = [name for name in skills if name not in available_skills]
if invalid_skills:
raise ValueError(f"存在无效 skill 依赖: {', '.join(invalid_skills)}")
if slug in skills:
raise ValueError("skill_dependencies 不允许包含自身")
return tools, mcps, skills
async def update_skill_dependencies(
db: AsyncSession,
*,
slug: str,
tool_dependencies: list[str],
mcp_dependencies: list[str],
skill_dependencies: list[str],
updated_by: str | None,
) -> Skill:
item = await get_skill_or_raise(db, slug)
repo = SkillRepository(db)
# 写操作前先同步一次缓存,确保依赖校验基于最新技能集合。
_set_skill_options_cache(await repo.list_all())
tools, mcps, skills = _validate_dependencies(
slug=slug,
tool_dependencies=tool_dependencies,
mcp_dependencies=mcp_dependencies,
skill_dependencies=skill_dependencies,
)
updated = await repo.update_dependencies(
item,
tool_dependencies=tools,
mcp_dependencies=mcps,
skill_dependencies=skills,
updated_by=updated_by,
)
_set_skill_options_cache(await repo.list_all())
return updated
def _validate_skill_name(name: str) -> str:
name = name.strip()
if not name:
raise ValueError("SKILL.md frontmatter 缺少 name")
if len(name) > 128:
raise ValueError("skill name 长度不能超过 128")
if not SKILL_NAME_PATTERN.match(name):
raise ValueError("skill name 必须是小写字母/数字/短横线,且不能连续短横线")
return name
def _parse_skill_markdown(content: str) -> tuple[str, str, dict[str, Any]]:
match = FRONTMATTER_PATTERN.match(content)
if not match:
raise ValueError("SKILL.md 缺少有效 frontmatter--- ... ---")
frontmatter_raw = match.group(1)
try:
data = yaml.safe_load(frontmatter_raw)
except yaml.YAMLError as e:
raise ValueError(f"SKILL.md frontmatter YAML 解析失败: {e}") from e
if not isinstance(data, dict):
raise ValueError("SKILL.md frontmatter 必须是对象")
name = _validate_skill_name(str(data.get("name", "")))
description = str(data.get("description", "")).strip()
if not description:
raise ValueError("SKILL.md frontmatter 缺少 description")
return name, description, data
def _rewrite_frontmatter_name(content: str, new_name: str) -> str:
match = FRONTMATTER_PATTERN.match(content)
if not match:
raise ValueError("SKILL.md 缺少有效 frontmatter--- ... ---")
frontmatter_raw = match.group(1)
body = content[match.end() :]
data = yaml.safe_load(frontmatter_raw)
if not isinstance(data, dict):
raise ValueError("SKILL.md frontmatter 必须是对象")
data["name"] = new_name
dumped = yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip()
return f"---\n{dumped}\n---\n{body}"
def _validate_zip_paths(zip_file: zipfile.ZipFile) -> None:
for name in zip_file.namelist():
pure = PurePosixPath(name)
if pure.is_absolute():
raise ValueError(f"ZIP 包含不安全绝对路径: {name}")
if ".." in pure.parts:
raise ValueError(f"ZIP 包含路径穿越片段: {name}")
async def _generate_available_slug(repo: SkillRepository, base_slug: str) -> str:
root = get_skills_root_dir()
if not await repo.exists_slug(base_slug) and not (root / base_slug).exists():
return base_slug
idx = 2
while True:
candidate = f"{base_slug}-v{idx}"
if not await repo.exists_slug(candidate) and not (root / candidate).exists():
return candidate
idx += 1
def _resolve_skill_dir(item: Skill) -> Path:
dir_path = Path(item.dir_path)
if dir_path.is_absolute():
return dir_path
return (Path(sys_config.save_dir) / dir_path).resolve()
def _resolve_relative_path(skill_dir: Path, relative_path: str, *, allow_root: bool = False) -> tuple[Path, str]:
rel = (relative_path or "").strip().replace("\\", "/")
rel = rel.lstrip("/")
if not rel and not allow_root:
raise ValueError("path 不能为空")
pure = PurePosixPath(rel) if rel else PurePosixPath(".")
if ".." in pure.parts:
raise ValueError("非法路径:不允许上级路径引用")
target = (skill_dir / pure).resolve()
try:
target.relative_to(skill_dir)
except ValueError:
raise ValueError("非法路径:越界访问被拒绝") from None
return target, rel
def _is_text_path(path: Path) -> bool:
if path.name == "SKILL.md":
return True
suffix = path.suffix.lower()
return suffix in TEXT_FILE_EXTENSIONS
def _build_tree(path: Path, base_dir: Path) -> list[dict[str, Any]]:
children: list[dict[str, Any]] = []
for child in sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
rel = child.relative_to(base_dir).as_posix()
if child.is_dir():
children.append(
{
"name": child.name,
"path": rel,
"is_dir": True,
"children": _build_tree(child, base_dir),
}
)
else:
children.append(
{
"name": child.name,
"path": rel,
"is_dir": False,
}
)
return children
async def import_skill_zip(
db: AsyncSession,
*,
filename: str,
file_bytes: bytes,
created_by: str | None,
) -> Skill:
if not filename.lower().endswith(".zip"):
raise ValueError("仅支持上传 .zip 文件")
repo = SkillRepository(db)
skills_root = get_skills_root_dir()
with tempfile.TemporaryDirectory(prefix=".skill-import-", dir=str(skills_root.parent)) as temp_root:
temp_root_path = Path(temp_root)
zip_path = temp_root_path / "upload.zip"
extract_dir = temp_root_path / "extract"
stage_dir = temp_root_path / "stage"
extract_dir.mkdir(parents=True, exist_ok=True)
zip_path.write_bytes(file_bytes)
with zipfile.ZipFile(zip_path, "r") as zf:
_validate_zip_paths(zf)
zf.extractall(extract_dir)
skill_md_files = list(extract_dir.rglob("SKILL.md"))
if len(skill_md_files) != 1:
raise ValueError("ZIP 必须且只能包含一个技能(检测到一个 SKILL.md")
skill_md_path = skill_md_files[0]
source_skill_dir = skill_md_path.parent
content = skill_md_path.read_text(encoding="utf-8")
parsed_name, parsed_desc, _ = _parse_skill_markdown(content)
final_slug = await _generate_available_slug(repo, parsed_name)
final_name = parsed_name
if final_slug != parsed_name:
final_name = final_slug
content = _rewrite_frontmatter_name(content, final_name)
skill_md_path.write_text(content, encoding="utf-8")
shutil.copytree(source_skill_dir, stage_dir)
temp_target = skills_root / f".{final_slug}.tmp-{uuid.uuid4().hex[:8]}"
if temp_target.exists():
shutil.rmtree(temp_target)
shutil.move(str(stage_dir), str(temp_target))
final_dir = skills_root / final_slug
if final_dir.exists():
shutil.rmtree(temp_target, ignore_errors=True)
raise ValueError(f"技能目录冲突,请重试: {final_slug}")
temp_target.rename(final_dir)
try:
item = await repo.create(
slug=final_slug,
name=final_name,
description=parsed_desc,
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=[],
dir_path=(Path("skills") / final_slug).as_posix(),
created_by=created_by,
)
except Exception:
shutil.rmtree(final_dir, ignore_errors=True)
raise
items = await repo.list_all()
_set_skill_options_cache(items)
return item
async def get_skill_or_raise(db: AsyncSession, slug: str) -> Skill:
repo = SkillRepository(db)
item = await repo.get_by_slug(slug)
if not item:
raise ValueError(f"技能 '{slug}' 不存在")
return item
async def get_skill_tree(db: AsyncSession, slug: str) -> list[dict[str, Any]]:
item = await get_skill_or_raise(db, slug)
skill_dir = _resolve_skill_dir(item)
if not skill_dir.exists() or not skill_dir.is_dir():
raise ValueError(f"技能目录不存在: {item.dir_path}")
return _build_tree(skill_dir, skill_dir)
async def read_skill_file(db: AsyncSession, slug: str, relative_path: str) -> dict[str, Any]:
item = await get_skill_or_raise(db, slug)
skill_dir = _resolve_skill_dir(item)
target, rel = _resolve_relative_path(skill_dir, relative_path)
if not target.exists() or not target.is_file():
raise ValueError(f"文件不存在: {relative_path}")
if not _is_text_path(target):
raise ValueError("仅支持读取文本文件")
try:
content = target.read_text(encoding="utf-8")
except UnicodeDecodeError as e:
raise ValueError(f"文件编码不支持(仅支持 UTF-8: {e}") from e
return {"path": rel, "content": content}
async def create_skill_node(
db: AsyncSession,
*,
slug: str,
relative_path: str,
is_dir: bool,
content: str | None,
updated_by: str | None,
) -> None:
item = await get_skill_or_raise(db, slug)
skill_dir = _resolve_skill_dir(item)
target, _ = _resolve_relative_path(skill_dir, relative_path)
if target.exists():
raise ValueError("目标已存在")
if is_dir:
target.mkdir(parents=True, exist_ok=False)
return
if not _is_text_path(target):
raise ValueError("仅支持创建文本文件")
parsed_name: str | None = None
parsed_desc: str | None = None
if target.name == "SKILL.md" and target.parent == skill_dir:
parsed_name, parsed_desc, _ = _parse_skill_markdown(content or "")
if parsed_name != item.slug:
raise ValueError("SKILL.md frontmatter.name 必须与 skill slug 一致")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content or "", encoding="utf-8")
if parsed_name is not None and parsed_desc is not None:
repo = SkillRepository(db)
await repo.update_metadata(item, name=parsed_name, description=parsed_desc, updated_by=updated_by)
_set_skill_options_cache(await repo.list_all())
async def update_skill_file(
db: AsyncSession,
*,
slug: str,
relative_path: str,
content: str,
updated_by: str | None,
) -> None:
item = await get_skill_or_raise(db, slug)
skill_dir = _resolve_skill_dir(item)
target, _ = _resolve_relative_path(skill_dir, relative_path)
if not target.exists() or not target.is_file():
raise ValueError("文件不存在")
if not _is_text_path(target):
raise ValueError("仅支持编辑文本文件")
parsed_name = None
parsed_desc = None
if target.name == "SKILL.md" and target.parent == skill_dir:
parsed_name, parsed_desc, _ = _parse_skill_markdown(content)
if parsed_name != item.slug:
raise ValueError("SKILL.md frontmatter.name 必须与 skill slug 一致")
target.write_text(content, encoding="utf-8")
if parsed_name is not None and parsed_desc is not None:
repo = SkillRepository(db)
await repo.update_metadata(item, name=parsed_name, description=parsed_desc, updated_by=updated_by)
_set_skill_options_cache(await repo.list_all())
async def delete_skill_node(db: AsyncSession, *, slug: str, relative_path: str) -> None:
item = await get_skill_or_raise(db, slug)
skill_dir = _resolve_skill_dir(item)
target, rel = _resolve_relative_path(skill_dir, relative_path, allow_root=False)
if not target.exists():
raise ValueError("目标不存在")
if rel == "SKILL.md":
raise ValueError("不允许删除根目录 SKILL.md")
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
async def export_skill_zip(db: AsyncSession, slug: str) -> tuple[str, str]:
item = await get_skill_or_raise(db, slug)
skill_dir = _resolve_skill_dir(item)
if not skill_dir.exists() or not skill_dir.is_dir():
raise ValueError("技能目录不存在")
fd, export_path = tempfile.mkstemp(prefix=f"skill-{slug}-", suffix=".zip")
Path(export_path).unlink(missing_ok=True)
export_file = Path(export_path)
try:
with zipfile.ZipFile(export_file, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in skill_dir.rglob("*"):
arcname = Path(slug) / p.relative_to(skill_dir)
zf.write(p, arcname.as_posix())
except Exception:
export_file.unlink(missing_ok=True)
raise
return export_path, f"{slug}.zip"
async def delete_skill(db: AsyncSession, *, slug: str) -> None:
repo = SkillRepository(db)
item = await repo.get_by_slug(slug)
if not item:
raise ValueError(f"技能 '{slug}' 不存在")
skill_dir = _resolve_skill_dir(item)
trash_dir: Path | None = None
if skill_dir.exists():
trash_dir = skill_dir.with_name(f".deleted-{slug}-{uuid.uuid4().hex[:8]}")
skill_dir.rename(trash_dir)
try:
await repo.delete(item)
except Exception:
if trash_dir and trash_dir.exists():
trash_dir.rename(skill_dir)
raise
if trash_dir and trash_dir.exists():
shutil.rmtree(trash_dir, ignore_errors=True)
_set_skill_options_cache(await repo.list_all())