agent-Specialization/modules/skills_manager.py
JOJO 08d9812f45 feat(docker): support isolated projects in web mode
Implement Docker Web project management on top of workspace_id while keeping host mode workspace behavior intact.

Backend changes:

- Move Docker user files to users/<user>/projects/<project_id>/{project,data,logs} and shared user state to users/<user>/personal.

- Add automatic legacy migration from old project/data/logs/agentskills layouts and from the temporary project/shared layout.

- Generate project IDs server-side from the project name, reserve internal IDs, and hide project paths from the UI.

- Add project rename/delete APIs; deleting a Docker project removes its project directory and releases terminal/container state.

- Add host workspace rename/delete APIs; deleting a host workspace only removes catalog entries and keeps files on disk.

- Scope Docker containers, terminals, uploads, conversations, storage, and running task metadata by workspace_id.

- Store private skills under users/<user>/personal/agentskills and sync them into each project workspace.

- Inject the current project/workspace name through prompts/workspace_system.txt.

Frontend changes:

- Replace Docker file-tree area with project management UX and reuse host multi-workspace running-task behavior.

- Add a management dialog for creating, renaming, and deleting projects/workspaces.

- Show project labels instead of internal IDs in toasts and project lists.

- Clear stale conversation history during project/workspace switching and show loading until the new list is ready.

- Keep running-task loader/check behavior consistent across host workspaces and Docker projects.

Validation:

- python3 -m py_compile modules/user_manager.py modules/host_workspace_manager.py modules/skills_manager.py core/main_terminal_parts/tools_execution.py server/auth.py server/context.py server/status.py server/conversation.py server/tasks.py utils/context_manager.py

- python3 -m unittest test.test_server_refactor_smoke

- npm run build --silent 2>&1 | tail -n 20
2026-05-29 19:49:48 +08:00

399 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Agent skills manager / 智能体技能管理器。
负责扫描全局 skills 库、生成可用清单,并同步到工作区 skills/。
"""
from __future__ import annotations
import re
import shutil
import threading
from pathlib import Path
from typing import Dict, List, Optional, Sequence
from config import AGENT_SKILLS_DIR, WORKSPACE_SKILLS_DIRNAME
from utils.logger import setup_logger
logger = setup_logger(__name__)
SKILL_FILE_NAME = "SKILL.md"
SKILL_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
_SYNC_LOCK_GUARD = threading.Lock()
_SYNC_LOCKS: Dict[str, threading.Lock] = {}
def _get_sync_lock(skills_dir: Path) -> threading.Lock:
key = str(skills_dir.resolve())
with _SYNC_LOCK_GUARD:
lock = _SYNC_LOCKS.get(key)
if lock is None:
lock = threading.Lock()
_SYNC_LOCKS[key] = lock
return lock
def ensure_agent_skills_dir(base_dir: Optional[str] = None) -> Path:
"""Ensure the global skills directory exists / 确保全局技能目录存在。"""
root = Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
return root
def ensure_workspace_skills_dir(project_path: str | Path) -> Path:
"""Ensure workspace skills directory exists / 确保工作区 skills 目录存在。"""
root = Path(project_path).expanduser().resolve()
skills_dir = (root / WORKSPACE_SKILLS_DIRNAME).resolve()
skills_dir.mkdir(parents=True, exist_ok=True)
return skills_dir
def infer_private_skills_dir(data_dir: str | Path | None) -> Optional[Path]:
"""Infer per-user private skills dir from workspace data_dir."""
if not data_dir:
return None
try:
data_path = Path(data_dir).expanduser().resolve()
if data_path.name == "data" and data_path.parent.parent.name == "workspaces":
return (data_path.parent.parent.parent / "agentskills").resolve()
# Docker Web 项目化布局:
# users/<user>/projects/<project_id>/data -> users/<user>/personal/agentskills
if data_path.name == "data" and data_path.parent.parent.name == "projects":
user_root = data_path.parent.parent.parent
shared_root = (user_root / "personal" / "agentskills").resolve()
legacy_root = (data_path.parent / "agentskills").resolve()
if legacy_root.exists() and legacy_root.is_dir() and legacy_root != shared_root:
shared_root.mkdir(parents=True, exist_ok=True)
for item in list(legacy_root.iterdir()):
dest = shared_root / item.name
if not dest.exists():
shutil.move(str(item), str(dest))
try:
legacy_root.rmdir()
except Exception:
pass
return shared_root
# 兼容上一轮临时结构:
# users/<user>/project/<project_id>/data -> users/<user>/personal/agentskills
if data_path.name == "data" and data_path.parent.parent.name == "project":
user_root = data_path.parent.parent.parent
shared_root = (user_root / "personal" / "agentskills").resolve()
temp_shared_root = (data_path.parent.parent / "shared" / "agentskills").resolve()
for legacy_root in (temp_shared_root, data_path.parent / "agentskills"):
if legacy_root.exists() and legacy_root.is_dir() and legacy_root != shared_root:
shared_root.mkdir(parents=True, exist_ok=True)
for item in list(legacy_root.iterdir()):
dest = shared_root / item.name
if not dest.exists():
shutil.move(str(item), str(dest))
try:
legacy_root.rmdir()
except Exception:
pass
return shared_root
return (data_path.parent / "agentskills").resolve()
except Exception:
return None
def validate_skill_directory(source_dir: str | Path) -> Dict[str, object]:
"""Validate a skill folder with minimal required SKILL.md frontmatter."""
source = Path(source_dir).expanduser().resolve()
if not source.exists() or not source.is_dir():
return {"success": False, "error": "source_dir 不是目录", "source_dir": str(source)}
skill_id = source.name
if not _is_valid_skill_id(skill_id):
return {"success": False, "error": "skill 文件夹名称不合法", "skill_name": skill_id}
skill_file = source / SKILL_FILE_NAME
if not skill_file.exists() or not skill_file.is_file():
return {"success": False, "error": "缺少 SKILL.md", "skill_name": skill_id}
try:
content = skill_file.read_text(encoding="utf-8")
except Exception as exc:
return {"success": False, "error": f"读取 SKILL.md 失败: {exc}", "skill_name": skill_id}
if not re.search(r"(?m)^\s*name\s*:", content):
return {"success": False, "error": "缺少 name:", "skill_name": skill_id}
if not re.search(r"(?m)^\s*description\s*:", content):
return {"success": False, "error": "缺少 description:", "skill_name": skill_id}
return {
"success": True,
"skill_name": skill_id,
"source_dir": str(source),
"skill_file": str(skill_file),
}
def archive_skill_directory(source_dir: str | Path, target_root: str | Path) -> Dict[str, object]:
"""Move a validated skill folder into a skills library root."""
validation = validate_skill_directory(source_dir)
if not validation.get("success"):
return validation
source = Path(str(validation["source_dir"])).expanduser().resolve()
target_base = Path(target_root).expanduser().resolve()
target_base.mkdir(parents=True, exist_ok=True)
target = (target_base / source.name).resolve()
try:
target.relative_to(target_base)
except ValueError:
return {"success": False, "error": "目标路径不合法", "skill_name": source.name}
if target.exists():
return {
"success": False,
"error": "目标 skill 已存在",
"skill_name": source.name,
"target_dir": str(target),
}
try:
shutil.move(str(source), str(target))
except Exception as exc:
return {
"success": False,
"error": f"移动 skill 失败: {exc}",
"skill_name": source.name,
"source_dir": str(source),
"target_dir": str(target),
}
return {
"success": True,
"skill_name": source.name,
"source_dir": str(source),
"target_dir": str(target),
}
def _parse_frontmatter(text: str) -> Dict[str, str]:
"""Parse simple YAML frontmatter / 解析简单 YAML 头信息。"""
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return {}
end_idx = None
for idx in range(1, len(lines)):
if lines[idx].strip() == "---":
end_idx = idx
break
if end_idx is None:
return {}
meta: Dict[str, str] = {}
for line in lines[1:end_idx]:
if ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and value:
meta[key] = value
return meta
def _is_valid_skill_id(name: str) -> bool:
"""Validate skill id / 校验技能目录名。"""
return bool(name and SKILL_ID_PATTERN.match(name))
def _scan_skills_catalog(root: Path) -> List[Dict[str, str]]:
"""Scan one skills root."""
if not root.exists() or not root.is_dir():
return []
catalog: List[Dict[str, str]] = []
for child in sorted(root.iterdir()):
if not child.is_dir():
continue
if not _is_valid_skill_id(child.name):
continue
skill_file = child / SKILL_FILE_NAME
if not skill_file.exists():
continue
meta: Dict[str, str] = {}
try:
meta = _parse_frontmatter(skill_file.read_text(encoding="utf-8"))
except Exception:
meta = {}
label = meta.get("name") or child.name
description = meta.get("description") or ""
catalog.append({
"id": child.name,
"label": label,
"description": description,
})
return catalog
def get_skills_catalog(
base_dir: Optional[str] = None,
private_dir: Optional[str | Path] = None,
) -> List[Dict[str, str]]:
"""List available skills from global library plus optional private library."""
roots: List[Path] = [Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()]
if private_dir:
private_root = Path(private_dir).expanduser().resolve()
if private_root not in roots:
roots.append(private_root)
merged: Dict[str, Dict[str, str]] = {}
order: List[str] = []
for root in roots:
for item in _scan_skills_catalog(root):
skill_id = item.get("id")
if not skill_id:
continue
if skill_id not in merged:
order.append(skill_id)
# Later roots (private) override metadata for same id.
merged[skill_id] = item
return [merged[skill_id] for skill_id in order if skill_id in merged]
def _get_skill_source_map(
base_dir: Optional[str] = None,
private_dir: Optional[str | Path] = None,
) -> Dict[str, Path]:
roots: List[Path] = [Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()]
if private_dir:
private_root = Path(private_dir).expanduser().resolve()
if private_root not in roots:
roots.append(private_root)
sources: Dict[str, Path] = {}
for root in roots:
if not root.exists() or not root.is_dir():
continue
for item in _scan_skills_catalog(root):
skill_id = item.get("id")
if skill_id:
sources[skill_id] = root / skill_id
return sources
def resolve_enabled_skills(
enabled_skills: Optional[Sequence[str]],
catalog: Sequence[Dict[str, str]],
) -> List[str]:
"""Resolve enabled skills list / 解析启用技能列表。"""
catalog_ids = [item.get("id", "") for item in catalog if item.get("id")]
if enabled_skills is None:
return catalog_ids
if not isinstance(enabled_skills, (list, tuple)):
return catalog_ids
allowed = set(catalog_ids)
seen = set()
resolved: List[str] = []
for item in enabled_skills:
if not isinstance(item, str):
continue
skill_id = item.strip()
if not skill_id or skill_id in seen or skill_id not in allowed:
continue
resolved.append(skill_id)
seen.add(skill_id)
return resolved
def build_skills_list(
catalog: Sequence[Dict[str, str]],
enabled_skill_ids: Sequence[str],
) -> List[str]:
"""Build skills list lines / 生成 skills 列表行。"""
if not enabled_skill_ids:
return []
lookup = {item.get("id"): item for item in catalog if item.get("id")}
lines: List[str] = []
for skill_id in enabled_skill_ids:
meta = lookup.get(skill_id)
if not meta:
continue
description = (meta.get("description") or "").strip()
if description:
lines.append(f".agents/skills/{skill_id}{description}")
else:
lines.append(f".agents/skills/{skill_id}")
return lines
def merge_enabled_skills(
enabled_skill_ids: Optional[Sequence[str]],
catalog: Sequence[Dict[str, str]],
catalog_snapshot: Optional[Sequence[str]] = None,
) -> List[str]:
"""Merge enabled skills with new catalog items / 合并启用列表并默认开启新增技能。"""
catalog_ids = [item.get("id") for item in catalog if item.get("id")]
if enabled_skill_ids is None:
base = list(catalog_ids)
else:
base_set = {item for item in enabled_skill_ids if isinstance(item, str)}
base = [item for item in catalog_ids if item in base_set]
if catalog_snapshot:
snapshot_set = {item for item in catalog_snapshot if isinstance(item, str)}
new_items = [item for item in catalog_ids if item not in snapshot_set]
for item in new_items:
if item not in base:
base.append(item)
return base
def build_skills_prompt(template: str, skills_list: Sequence[str]) -> str:
"""Build skills prompt from template / 根据模板生成 skills 提示。"""
if not template:
return ""
list_block = "\n".join(skills_list) if skills_list else ""
content = template
if list_block:
content = content.replace("{skills_list}", list_block)
content = re.sub(r"\[skills_empty\].*?\[/skills_empty\]\n?", "", content, flags=re.S)
else:
content = content.replace("{skills_list}", "")
content = content.replace("[skills_empty]", "").replace("[/skills_empty]", "")
return content.strip()
def sync_workspace_skills(
project_path: str | Path,
enabled_skills: Optional[Sequence[str]] = None,
base_dir: Optional[str] = None,
private_dir: Optional[str | Path] = None,
) -> Dict[str, object]:
"""Sync global skills into workspace / 将全局 skills 同步到工作区。"""
root = Path(project_path).expanduser().resolve()
skills_dir = (root / WORKSPACE_SKILLS_DIRNAME).resolve()
try:
skills_dir.relative_to(root)
except Exception:
return {"success": False, "error": "skills 目录不在项目路径内"}
ensure_agent_skills_dir(base_dir)
catalog = get_skills_catalog(base_dir, private_dir=private_dir)
resolved = resolve_enabled_skills(enabled_skills, catalog)
sources = _get_skill_source_map(base_dir, private_dir=private_dir)
lock = _get_sync_lock(skills_dir)
try:
with lock:
if skills_dir.exists():
shutil.rmtree(skills_dir, ignore_errors=True)
skills_dir.mkdir(parents=True, exist_ok=True)
for skill_id in resolved:
src = sources.get(skill_id)
if src is None or not src.exists() or not src.is_dir():
continue
dst = skills_dir / skill_id
if dst.exists() and not dst.is_dir():
dst.unlink(missing_ok=True)
shutil.copytree(src, dst, dirs_exist_ok=True)
return {
"success": True,
"copied": list(resolved),
"available": [item.get("id") for item in catalog],
"target": str(skills_dir),
}
except Exception as exc:
logger.error("同步 skills 失败: %s", exc, exc_info=True)
return {"success": False, "error": str(exc), "target": str(skills_dir)}