skill_routes.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. # -*- coding: utf-8 -*-
  2. """
  3. 技能管理 API —— 查看/启用/禁用/上传技能
  4. 端点:
  5. - GET /api/skills 技能列表
  6. - GET /api/skills/{name} 查看技能内容(SKILL.md)
  7. - POST /api/skills/{name}/toggle 启用/禁用
  8. - POST /api/skills/upload 上传新技能(.zip)
  9. """
  10. import json
  11. import os
  12. import re
  13. import shutil
  14. import zipfile
  15. from pathlib import Path
  16. from typing import Optional
  17. from fastapi import APIRouter, File, UploadFile
  18. from fastapi.responses import JSONResponse
  19. router = APIRouter(prefix="/skills", tags=["技能管理"])
  20. # 项目根目录
  21. PROJECT_ROOT = str(Path(__file__).parent.parent)
  22. SKILLS_DIR = os.path.join(PROJECT_ROOT, "skills")
  23. DISABLED_FILE = os.path.join(PROJECT_ROOT, "skills_disabled.json")
  24. # ── 技能 → 所属智能体映射 ──
  25. SKILL_AGENT_MAP = {
  26. "click-interpret-tun": ["点选解读 Agent"],
  27. "click-interpret-device": ["点选解读 Agent"],
  28. "dialog-interpret": ["通风对话助手"],
  29. "needq-calc": ["通风对话助手"],
  30. "vent-plan-review-form": ["形式审查 Agent"],
  31. "vent-plan-review-data": ["数据一致性审查 Agent"],
  32. "vent-plan-review-calc": ["计算核验审查 Agent"],
  33. "vent-plan-review-summary": ["汇总审查 Agent"],
  34. }
  35. def _get_disabled_set() -> set:
  36. """读取 skills_disabled.json,返回被禁用的技能名集合。"""
  37. if not os.path.exists(DISABLED_FILE):
  38. return set()
  39. try:
  40. with open(DISABLED_FILE, "r", encoding="utf-8") as f:
  41. data = json.load(f)
  42. return set(data.get("disabled", []))
  43. except (json.JSONDecodeError, KeyError):
  44. return set()
  45. def _save_disabled_set(disabled: set):
  46. """保存被禁用的技能名集合到 skills_disabled.json。"""
  47. with open(DISABLED_FILE, "w", encoding="utf-8") as f:
  48. json.dump({"disabled": sorted(disabled)}, f, ensure_ascii=False, indent=2)
  49. def _parse_skill_md(skill_dir: str) -> dict:
  50. """解析技能目录下的 SKILL.md,提取 frontmatter。
  51. Returns:
  52. {"name": str, "description": str} 或空字典
  53. """
  54. md_path = os.path.join(skill_dir, "SKILL.md")
  55. if not os.path.exists(md_path):
  56. return {}
  57. try:
  58. with open(md_path, "r", encoding="utf-8") as f:
  59. content = f.read()
  60. except Exception:
  61. return {}
  62. # 解析 YAML frontmatter(--- ... ---)
  63. match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
  64. if not match:
  65. return {"name": os.path.basename(skill_dir), "description": ""}
  66. frontmatter_text = match.group(1)
  67. # 简单解析 YAML(只取 name 和 description,不引入 pyyaml 依赖)
  68. result = {}
  69. # 解析 name: xxx
  70. name_match = re.search(r"^name:\s*(.+)$", frontmatter_text, re.MULTILINE)
  71. if name_match:
  72. result["name"] = name_match.group(1).strip().strip('"').strip("'")
  73. # 解析 description: |
  74. desc_match = re.search(r"^description:\s*\|\s*\n(.*?)(?=^\S|\Z)", frontmatter_text, re.MULTILINE | re.DOTALL)
  75. if desc_match:
  76. desc = desc_match.group(1).strip()
  77. # 去掉每行开头的缩进
  78. desc = "\n".join(line.strip() for line in desc.split("\n"))
  79. # 合并为单行(截取前 80 字符用于列表展示)
  80. desc = " ".join(desc.split())
  81. result["description"] = desc[:120] + ("..." if len(desc) > 120 else "")
  82. else:
  83. # 尝试单行 description
  84. desc_match = re.search(r"^description:\s*(.+)$", frontmatter_text, re.MULTILINE)
  85. if desc_match:
  86. d = desc_match.group(1).strip().strip('"').strip("'")
  87. result["description"] = d[:120] + ("..." if len(d) > 120 else "")
  88. return result
  89. # ── GET /api/skills ──
  90. @router.get("")
  91. async def list_skills():
  92. """获取所有技能列表(含启用状态和所属智能体)。"""
  93. if not os.path.isdir(SKILLS_DIR):
  94. return {"skills": []}
  95. disabled_set = _get_disabled_set()
  96. skills = []
  97. for entry in sorted(os.listdir(SKILLS_DIR)):
  98. skill_path = os.path.join(SKILLS_DIR, entry)
  99. # 跳过非目录、隐藏目录、Python 缓存目录
  100. if not os.path.isdir(skill_path) or entry.startswith(".") or entry == "__pycache__":
  101. continue
  102. meta = _parse_skill_md(skill_path)
  103. name = meta.get("name", entry)
  104. description = meta.get("description", "")
  105. has_skill_md = os.path.exists(os.path.join(skill_path, "SKILL.md"))
  106. agents = SKILL_AGENT_MAP.get(entry, ["(未分配)"])
  107. skills.append({
  108. "name": entry,
  109. "display_name": name,
  110. "path": f"skills/{entry}",
  111. "description": description,
  112. "enabled": entry not in disabled_set,
  113. "agents": agents,
  114. "has_skill_md": has_skill_md,
  115. })
  116. return {"skills": skills}
  117. # ── GET /api/skills/{name} ──
  118. @router.get("/{name}")
  119. async def get_skill_content(name: str):
  120. """获取指定技能的 SKILL.md 完整内容。"""
  121. skill_dir = os.path.join(SKILLS_DIR, name)
  122. md_path = os.path.join(skill_dir, "SKILL.md")
  123. if not os.path.isdir(skill_dir):
  124. return JSONResponse(status_code=404, content={"error": f"技能不存在: {name}"})
  125. if not os.path.exists(md_path):
  126. return JSONResponse(status_code=404, content={"error": f"技能 {name} 缺少 SKILL.md"})
  127. try:
  128. with open(md_path, "r", encoding="utf-8") as f:
  129. content = f.read()
  130. except Exception as e:
  131. return JSONResponse(status_code=500, content={"error": f"读取失败: {str(e)}"})
  132. return {
  133. "name": name,
  134. "content": content,
  135. }
  136. # ── POST /api/skills/{name}/toggle ──
  137. @router.post("/{name}/toggle")
  138. async def toggle_skill(name: str):
  139. """启用或禁用指定技能。"""
  140. skill_dir = os.path.join(SKILLS_DIR, name)
  141. if not os.path.isdir(skill_dir):
  142. return JSONResponse(status_code=404, content={"error": f"技能不存在: {name}"})
  143. disabled_set = _get_disabled_set()
  144. if name in disabled_set:
  145. disabled_set.discard(name)
  146. enabled = True
  147. msg = f"技能「{name}」已启用"
  148. else:
  149. disabled_set.add(name)
  150. enabled = False
  151. msg = f"技能「{name}」已禁用"
  152. _save_disabled_set(disabled_set)
  153. # 禁用技能时,通知 agent 缓存失效(下次请求 agent 会跳过已禁用的技能)
  154. if not enabled:
  155. try:
  156. from agents.vent_agent import invalidate_dialog_cache
  157. invalidate_dialog_cache()
  158. except Exception:
  159. pass
  160. try:
  161. from agents.review_agent import invalidate_cache
  162. invalidate_cache()
  163. except Exception:
  164. pass
  165. return {"name": name, "enabled": enabled, "message": msg}
  166. # ── POST /api/skills/upload ──
  167. @router.post("/upload")
  168. async def upload_skill(file: UploadFile = File(...)):
  169. """上传新技能(.zip 压缩包)。
  170. 要求:
  171. - 文件为 .zip 格式
  172. - 解压后必须包含 SKILL.md 文件
  173. - 技能名不能与已有技能重复
  174. """
  175. # 校验文件类型
  176. if not file.filename or not file.filename.lower().endswith(".zip"):
  177. return JSONResponse(status_code=400, content={"error": "仅支持 .zip 格式的技能包"})
  178. # 读取文件内容
  179. try:
  180. content = await file.read()
  181. except Exception as e:
  182. return JSONResponse(status_code=400, content={"error": f"读取文件失败: {str(e)}"})
  183. # 保存临时文件
  184. tmp_path = os.path.join(PROJECT_ROOT, "data", f"_tmp_skill_{file.filename}")
  185. os.makedirs(os.path.dirname(tmp_path), exist_ok=True)
  186. try:
  187. with open(tmp_path, "wb") as f:
  188. f.write(content)
  189. # 解压
  190. with zipfile.ZipFile(tmp_path, "r") as zf:
  191. # 安全检查:防止路径穿越攻击
  192. for member in zf.infolist():
  193. if member.filename.startswith("/") or ".." in member.filename:
  194. return JSONResponse(status_code=400, content={"error": "技能包包含非法路径"})
  195. # 查找顶层目录名(技能名)
  196. names = zf.namelist()
  197. if not names:
  198. return JSONResponse(status_code=400, content={"error": "技能包为空"})
  199. # 取第一个条目的顶层目录作为技能名
  200. root_name = names[0].split("/")[0]
  201. if not root_name:
  202. return JSONResponse(status_code=400, content={"error": "技能包格式不正确"})
  203. # 检查 SKILL.md 是否存在
  204. has_skill_md = any(
  205. n == f"{root_name}/SKILL.md" or n.startswith(f"{root_name}/") and n.endswith("/SKILL.md")
  206. for n in names
  207. )
  208. if not has_skill_md:
  209. return JSONResponse(status_code=400, content={"error": "技能包缺少 SKILL.md 文件"})
  210. # 检查是否与已有技能重名
  211. target_dir = os.path.join(SKILLS_DIR, root_name)
  212. if os.path.exists(target_dir):
  213. return JSONResponse(status_code=409, content={"error": f"技能「{root_name}」已存在"})
  214. # 解压到 skills/ 目录
  215. zf.extractall(SKILLS_DIR)
  216. except zipfile.BadZipFile:
  217. return JSONResponse(status_code=400, content={"error": "文件不是有效的 .zip 压缩包"})
  218. except Exception as e:
  219. return JSONResponse(status_code=500, content={"error": f"解压失败: {str(e)}"})
  220. finally:
  221. # 清理临时文件
  222. if os.path.exists(tmp_path):
  223. try:
  224. os.unlink(tmp_path)
  225. except OSError:
  226. pass
  227. return {
  228. "name": root_name,
  229. "message": f"技能「{root_name}」上传成功",
  230. }