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
399 lines
15 KiB
Python
399 lines
15 KiB
Python
"""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)}
|