agent-Specialization/server/tasks/skills.py
JOJO cedef87dab fix(skills): /菜单插入 skill 改用相对路径,避免暴露服务器绝对路径
- /api/skills 列表 path 从 str(skill_file.resolve()) 改为相对工作区根路径
- 注入对话历史与任务快照的 skill path 同步改为相对路径
- _resolve_workspace_skill_path 本身支持相对路径解析,前端无需改动
- 存量绝对路径仍可解析,向后兼容
2026-08-13 00:21:33 +08:00

151 lines
6.6 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.

"""Workspace skill 读取与 /api/skills 接口。"""
from __future__ import annotations
from server.tasks import tasks_bp
import re
from pathlib import Path
from typing import Dict, Any, List
from flask import request, jsonify
from flask import session
from server.auth_helpers import api_login_required, get_current_username
from server.context import get_user_resources
from server.utils_common import debug_log
from config import WORKSPACE_SKILLS_DIRNAME
from modules.skills_manager import wait_skill_file_ready
SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n?", re.S)
SKILL_FIELD_RE = re.compile(r"^(?P<key>name|description)\s*:\s*(?P<value>.*)$")
def _parse_skill_metadata(content: str, fallback_name: str) -> Dict[str, str]:
metadata = {"name": fallback_name, "description": ""}
match = SKILL_FRONTMATTER_RE.match(content or "")
if not match:
return metadata
for raw_line in match.group("body").splitlines():
field = SKILL_FIELD_RE.match(raw_line.strip())
if not field:
continue
key = field.group("key")
value = field.group("value").strip().strip('"').strip("'")
metadata[key] = value
metadata["name"] = metadata.get("name") or fallback_name
return metadata
def _workspace_project_root(workspace) -> Path:
"""工作区根目录(已 resolve 的绝对路径)。"""
return Path(workspace.project_path).expanduser().resolve()
def _workspace_skills_dir(workspace) -> Path:
return _workspace_project_root(workspace) / WORKSPACE_SKILLS_DIRNAME
def _rel_skill_path(skill_file: Path, workspace) -> str:
"""把 skill 文件的绝对路径转成相对工作区根的路径(如 `.astrion/skills/xxx/SKILL.md`)。
前端 / 菜单插入、对话历史、任务快照均使用该相对路径,避免暴露服务器绝对路径。
"""
return str(skill_file.relative_to(_workspace_project_root(workspace)).as_posix())
def _resolve_workspace_skill_path(workspace, raw_path: str) -> Path:
try:
skills_dir = _workspace_skills_dir(workspace)
except Exception as exc:
debug_log(f"[SkillsAPI] resolve skill cannot compute skills_dir: {exc}")
raise ValueError(f"无法定位工作区 skills 目录: {exc}") from exc
debug_log(f"[SkillsAPI] resolve skill skills_dir={skills_dir!r} raw_path={raw_path!r}")
try:
target = Path(str(raw_path or "")).expanduser()
if not target.is_absolute():
target = Path(workspace.project_path).expanduser().resolve() / target
target = target.resolve()
except Exception as exc:
debug_log(f"[SkillsAPI] resolve skill cannot resolve path: {exc}")
raise ValueError(f"skill 路径解析失败: {exc}") from exc
debug_log(f"[SkillsAPI] resolve skill resolved_target={target!r} name={target.name!r}")
if target.name != "SKILL.md":
raise ValueError(f"skill 路径必须指向 SKILL.md: {target.name}")
try:
target.relative_to(skills_dir)
except ValueError as exc:
debug_log(f"[SkillsAPI] resolve skill path outside skills_dir: target={target!r} skills_dir={skills_dir!r}")
raise ValueError("skill 路径必须位于当前工作区 .astrion/skills/ 内") from exc
# 工作区 skills 目录可能被并发的全量同步rmtree+重建)短暂清空,
# 直接 is_file() 一次失败会把瞬时窗口误判为「skill 文件不存在」拒绝任务,
# 这里改为有界等待重试,等过同步窗口再下结论。
if not wait_skill_file_ready(target, skills_dir):
debug_log(f"[SkillsAPI] resolve skill file not found (after wait): {target!r}")
raise ValueError("skill 文件不存在")
return target
def _list_workspace_skills(workspace) -> List[Dict[str, str]]:
skills_dir = _workspace_skills_dir(workspace)
if not skills_dir.is_dir():
return []
result: List[Dict[str, str]] = []
for skill_file in sorted(skills_dir.glob("*/SKILL.md"), key=lambda p: p.parent.name.lower()):
try:
content = skill_file.read_text(encoding="utf-8")
except Exception:
continue
metadata = _parse_skill_metadata(content, skill_file.parent.name)
result.append({
"name": metadata.get("name") or skill_file.parent.name,
"description": metadata.get("description") or "",
"path": _rel_skill_path(skill_file, workspace),
})
return result
def _build_skill_context_messages(workspace, raw_refs: Any) -> List[Dict[str, str]]:
if not isinstance(raw_refs, list):
debug_log(f"[SkillsAPI] build_skill_context raw_refs is not list: {type(raw_refs).__name__}")
return []
messages: List[Dict[str, str]] = []
seen_paths = set()
for idx, item in enumerate(raw_refs[:10]):
if not isinstance(item, dict):
debug_log(f"[SkillsAPI] build_skill_context item[{idx}] not dict: {item!r}")
continue
raw_path = str(item.get("path") or "").strip()
debug_log(f"[SkillsAPI] build_skill_context item[{idx}] raw_path={raw_path!r} name={item.get('name')!r}")
if not raw_path:
continue
skill_file = _resolve_workspace_skill_path(workspace, raw_path)
path_key = _rel_skill_path(skill_file, workspace)
if path_key in seen_paths:
debug_log(f"[SkillsAPI] build_skill_context duplicate path skipped: {path_key}")
continue
seen_paths.add(path_key)
try:
content = skill_file.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise ValueError(f"skill 文件编码错误,无法读取: {skill_file.name}") from exc
except OSError as exc:
raise ValueError(f"读取 skill 文件失败: {exc}") from exc
metadata = _parse_skill_metadata(content, skill_file.parent.name)
messages.append({
"name": metadata.get("name") or skill_file.parent.name,
"path": path_key,
"content": content,
})
return messages
@tasks_bp.route("/api/skills", methods=["GET"])
@api_login_required
def list_skills_api():
username = get_current_username()
workspace_id = session.get("workspace_id") or "default"
try:
_terminal, workspace = get_user_resources(username, workspace_id)
if not workspace:
return jsonify({"success": False, "error": "工作区不可用"}), 400
return jsonify({
"success": True,
"skills": _list_workspace_skills(workspace),
})
except Exception as exc:
debug_log(f"[SkillsAPI] 列出技能失败: {exc}")
return jsonify({"success": False, "error": str(exc)}), 500