"""简单任务 API:将聊天任务与 WebSocket 解耦,支持后台运行与轮询。""" from __future__ import annotations from server.tasks import tasks_bp import mimetypes import json import time import threading import uuid import re from collections import deque from pathlib import Path from typing import Dict, Any, Optional, List from flask import Blueprint, request, jsonify from flask import current_app, session from server.auth_helpers import api_login_required, get_current_username from server.context import get_user_resources, ensure_conversation_loaded from server.chat_flow import run_chat_task_sync from server.state import stop_flags from server.utils_common import debug_log, log_conn_diag from utils.host_workspace_debug import write_host_workspace_debug from config import DATA_DIR, WORKSPACE_SKILLS_DIRNAME from modules.goal_state_manager import GoalStateManager, REASON_USER_CANCEL SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\n(?P.*?)\n---\s*\n?", re.S) SKILL_FIELD_RE = re.compile(r"^(?Pname|description)\s*:\s*(?P.*)$") 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_skills_dir(workspace) -> Path: return (Path(workspace.project_path).expanduser().resolve() / WORKSPACE_SKILLS_DIRNAME).resolve() def _resolve_workspace_skill_path(workspace, raw_path: str) -> Path: skills_dir = _workspace_skills_dir(workspace) target = Path(str(raw_path or "")).expanduser() if not target.is_absolute(): target = Path(workspace.project_path).expanduser().resolve() / target target = target.resolve() if target.name != "SKILL.md": raise ValueError("skill 路径必须指向 SKILL.md") try: target.relative_to(skills_dir) except ValueError as exc: raise ValueError("skill 路径必须位于当前工作区 .agents/skills/ 内") from exc if not target.is_file(): 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": str(skill_file.resolve()), }) return result def _build_skill_context_messages(workspace, raw_refs: Any) -> List[Dict[str, str]]: if not isinstance(raw_refs, list): return [] messages: List[Dict[str, str]] = [] seen_paths = set() for item in raw_refs[:10]: if not isinstance(item, dict): continue raw_path = str(item.get("path") or "").strip() if not raw_path: continue skill_file = _resolve_workspace_skill_path(workspace, raw_path) path_key = str(skill_file) if path_key in seen_paths: continue seen_paths.add(path_key) content = skill_file.read_text(encoding="utf-8") 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