mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-10 14:20:04 +08:00
* ♻️ refactor(core): 重构 AI 能力与定时任务调度系统 - 【AI 能力与工具】重构 Capability 注册与管理机制,引入 CapabilityManager 统一管理 - 移除全局能力注册表,改用声明式装饰器 `@capability` 进行解耦注册 - 重构工具解析器链,使用统一的 BaseToolResolver 代替原有的多个特定解析器 - 增强工具查询过滤,支持通配符匹配、工具箱过滤和排除标签 - 【定时任务调度】重构定时任务管理器,引入 SchedulerRegistry 统一管理任务元数据 - 引入 JobConfig 聚合定时任务配置,支持用户维度的定时任务调度 - 重构执行分发器,支持并发限制、串行间隔和随机延迟打散 - 【运行上下文】引入 ScheduledDeps 以支持后台和定时任务环境下的依赖注入 - 优化 RunContext,支持从定时任务上下文快速构造,并提供 emit 辅助方法 - 【日志与监控】引入 AILoggerProxy,实现 AI 各模块的专属日志输出 - 将各模块的全局 logger 替换为对应的模块专属日志代理 - 【其他优化】修复 Pydantic V1 兼容层中 model_validator 的装饰器兼容性问题 - 在非交互式环境(如定时任务)中自动隐藏 HITL 交互工具以节省 Token * ♻️ refactor(core): 优化内部导入路径并提升 Pydantic 兼容性 - 【重构】将 `services/ai` 模块内的绝对导入重构为相对导入,优化包结构 - 【重构】移除不必要的 `if TYPE_CHECKING` 保护,通过 `from __future__ import annotations` 直接导入类型 - 【清理】清理 `core/messages/types.py` 中未使用的 `AssistantContentUnion` 等联合类型定义 - 【优化】在 `utils/pydantic_compat.py` 中新增 `model_rebuild` 兼容函数,统一 Pydantic V1/V2 的模型重建逻辑 - 【优化】将部分函数内部的延迟导入提升至模块顶部,规范代码结构 * ♻️ refactor(imports): 优化导入路径为相对导入并清理冗余导入 - 【重构】将 AI 服务相关模块中的绝对导入路径修改为相对导入,提升模块内聚性与可移植性 - 【清理】移除多处函数内部或类方法中未使用的冗余导入,避免循环引用和资源浪费 - 【格式化】微调部分工具装饰器和返回语句的格式与尾随逗号 * 🚨 auto fix by pre-commit hooks --------- Co-authored-by: webjoin111 <455457521@qq.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
403 lines
14 KiB
Python
403 lines
14 KiB
Python
from abc import ABC, abstractmethod
|
|
import asyncio
|
|
from collections.abc import Sequence
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
from typing import Any, cast
|
|
from typing_extensions import Self
|
|
|
|
import aiofiles
|
|
import yaml
|
|
|
|
from zhenxun.configs.path_config import DATA_PATH
|
|
from zhenxun.services.ai.utils.logger import log_tool as logger
|
|
from zhenxun.utils.pydantic_compat import model_dump, model_validate
|
|
from zhenxun.utils.utils import infer_plugin_namespace
|
|
|
|
from .models import (
|
|
INSTRUCTIONS,
|
|
METADATA,
|
|
RESOURCES,
|
|
Skill,
|
|
SkillBlueprintBuilder,
|
|
SkillEnvConfig,
|
|
SkillFrontmatter,
|
|
)
|
|
|
|
|
|
class SkillConfigManager:
|
|
"""基于 Skill ID 隔离的持久化配置金库管理器"""
|
|
|
|
def __init__(self):
|
|
self.config_path = DATA_PATH / "ai" / "skill_envs.json"
|
|
self.config = SkillEnvConfig()
|
|
self._load_sync()
|
|
|
|
def _load_sync(self):
|
|
if self.config_path.exists():
|
|
try:
|
|
with open(self.config_path, encoding="utf-8") as f:
|
|
self.config = model_validate(SkillEnvConfig, json.load(f))
|
|
except Exception as e:
|
|
logger.error(f"加载 skill_envs.json 失败: {e}")
|
|
|
|
async def save(self):
|
|
try:
|
|
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
async with aiofiles.open(self.config_path, "w", encoding="utf-8") as f:
|
|
content = json.dumps(
|
|
model_dump(self.config), ensure_ascii=False, indent=4
|
|
)
|
|
await f.write(content)
|
|
except Exception as e:
|
|
logger.error(f"保存 skill_envs.json 失败: {e}")
|
|
|
|
def get_envs_for_skill(self, namespace: str, skill_id: str) -> dict[str, str]:
|
|
return self.config.envs.get(namespace, {}).get(skill_id, {})
|
|
|
|
async def ensure_template(
|
|
self, namespace: str, skill_id: str, required_envs: list[str]
|
|
):
|
|
"""确保配置文件中存在所需的模板,缺失则补充为空字符串"""
|
|
if not required_envs:
|
|
return
|
|
|
|
changed = False
|
|
skill_envs = self.config.envs.setdefault(namespace, {}).setdefault(skill_id, {})
|
|
for env_key in required_envs:
|
|
if env_key not in skill_envs:
|
|
skill_envs[env_key] = ""
|
|
changed = True
|
|
|
|
if changed:
|
|
await self.save()
|
|
|
|
|
|
skill_env_manager = SkillConfigManager()
|
|
|
|
|
|
class SkillManager:
|
|
"""全局技能注册与发现中心 (本地文件系统模式)"""
|
|
|
|
_instance: "SkillManager | None" = None
|
|
|
|
def __new__(cls) -> Self:
|
|
if cls._instance is None:
|
|
cls._instance = super().__new__(cls)
|
|
return cast(Self, cls._instance)
|
|
|
|
def __init__(self):
|
|
if getattr(self, "_initialized", False):
|
|
return
|
|
self._skills: dict[str, Skill] = {}
|
|
self._scan_dirs: dict[Path, str] = {}
|
|
self._initialized = True
|
|
self._discovery_lock = asyncio.Lock()
|
|
|
|
default_skill_dir = DATA_PATH / "ai" / "skills"
|
|
default_skill_dir.mkdir(parents=True, exist_ok=True)
|
|
self._scan_dirs[default_skill_dir] = "global"
|
|
|
|
def add_scan_dir(self, path: str | Path, namespace: str | None = None):
|
|
p = Path(path).resolve()
|
|
if namespace is None:
|
|
namespace = infer_plugin_namespace()
|
|
if p not in self._scan_dirs:
|
|
self._scan_dirs[p] = namespace
|
|
|
|
def _get_valid_skill_dir(self, path: str | Path) -> Path | None:
|
|
"""统一目录校验:判断是否为合法的技能目录"""
|
|
p = Path(path).resolve()
|
|
if p.exists() and p.is_dir() and not p.name.startswith("."):
|
|
return p
|
|
return None
|
|
|
|
def _iter_valid_skill_dirs(self, target_dir: str | Path):
|
|
"""统一扫描器:迭代目标父目录下的所有合法技能子目录"""
|
|
p = Path(target_dir).resolve()
|
|
if not p.exists() or not p.is_dir():
|
|
return
|
|
for child in p.iterdir():
|
|
if valid_child := self._get_valid_skill_dir(child):
|
|
yield valid_child
|
|
|
|
async def load_local_skill(
|
|
self, path: str | Path, namespace: str | None = None
|
|
) -> Skill:
|
|
"""
|
|
[无痕加载] 读取指定路径的技能并返回独立的 Skill 实例。
|
|
该技能不会被注册到全局 manager 中,专门用于局部按需挂载。
|
|
"""
|
|
if namespace is None:
|
|
namespace = infer_plugin_namespace()
|
|
|
|
skill = await self._parse_skill_from_path(path, namespace)
|
|
if not skill:
|
|
raise ValueError(
|
|
f"无法从路径加载技能,请检查目录与 SKILL.md 是否合法: {path}"
|
|
)
|
|
return skill
|
|
|
|
async def discover_skills(self) -> dict[str, Skill]:
|
|
"""遍历所有扫描目录,发现并合并所有合法技能"""
|
|
async with self._discovery_lock:
|
|
if self._skills:
|
|
return self._skills
|
|
|
|
for scan_dir, namespace in self._scan_dirs.items():
|
|
for child in self._iter_valid_skill_dirs(scan_dir):
|
|
try:
|
|
skill = await self._parse_skill_metadata(child, namespace)
|
|
if skill:
|
|
self._skills[f"{namespace}::{skill.id}"] = skill
|
|
except Exception as e:
|
|
logger.error(f"解析技能 {child.name} 失败: {e}", e=e)
|
|
|
|
logger.info(f"技能扫描完成,共加载 {len(self._skills)} 个技能。")
|
|
return self._skills
|
|
|
|
async def get_skill_details(
|
|
self, name: str, namespace: str = "global"
|
|
) -> Skill | None:
|
|
"""获取技能详细信息,并在内存中缓存补全后的资源"""
|
|
skills = await self.discover_skills()
|
|
skill = skills.get(f"{namespace}::{name}") or skills.get(f"global::{name}")
|
|
if not skill:
|
|
return None
|
|
if skill.disclosure_level < RESOURCES:
|
|
skill = self._load_skill_resources(skill)
|
|
async with self._discovery_lock:
|
|
self._skills[f"{skill.namespace}::{skill.id}"] = skill
|
|
return skill
|
|
|
|
async def resolve_mixed_skills(
|
|
self, skills: Sequence[Any], namespace: str | None = None
|
|
) -> list[Skill]:
|
|
"""统一解析混合类型的技能列表(str, Path, Skill, SkillSource)"""
|
|
caller_namespace = namespace or infer_plugin_namespace()
|
|
|
|
resolved = []
|
|
resolvers = [self._normalize_to_resolver(s) for s in skills]
|
|
for r in resolvers:
|
|
resolved.extend(await r.resolve(self, caller_namespace))
|
|
|
|
return resolved
|
|
|
|
def _normalize_to_resolver(self, item: Any) -> "BaseSkillResolver":
|
|
if isinstance(item, Skill):
|
|
return SkillInstanceResolver(item)
|
|
elif isinstance(item, str):
|
|
return StringSkillResolver(item)
|
|
elif isinstance(item, Path):
|
|
return PathSkillResolver(item)
|
|
elif type(item).__name__ == "SkillSource":
|
|
return SourceSkillResolver(item)
|
|
raise TypeError(f"无法将对象 {type(item)} 解析为技能源。")
|
|
|
|
async def read_skill_resource(self, skill: Skill, file_path: str) -> str | None:
|
|
"""提供统一的资源文件(如 markdown 参考、脚本等)物理读取接口"""
|
|
target_file = (skill.path / file_path).resolve()
|
|
if not target_file.is_file():
|
|
fallback_file = (skill.path / "scripts" / file_path).resolve()
|
|
if fallback_file.is_file():
|
|
target_file = fallback_file
|
|
|
|
if target_file.is_file() and target_file.is_relative_to(skill.path.resolve()):
|
|
return target_file.read_text(encoding="utf-8")
|
|
return None
|
|
|
|
async def clear_cache(self):
|
|
"""清空缓存以重新扫描(方便测试热更)"""
|
|
async with self._discovery_lock:
|
|
self._skills.clear()
|
|
|
|
async def _parse_skill_from_path(
|
|
self, path: str | Path, namespace: str
|
|
) -> Skill | None:
|
|
"""按径解析技能,返回提权到最高级别的孤立技能实例,不污染全局状态"""
|
|
p = self._get_valid_skill_dir(path)
|
|
if not p:
|
|
logger.warning(
|
|
f"[SkillManager] 忽略无效技能目录 (不存在、非目录或为隐藏目录): {path}"
|
|
)
|
|
return None
|
|
try:
|
|
skill = await self._parse_skill_metadata(p, namespace)
|
|
if skill:
|
|
skill = self._load_skill_resources(skill)
|
|
return skill
|
|
except Exception as e:
|
|
logger.error(f"解析孤立技能 {p.name} 失败: {e}", e=e)
|
|
return None
|
|
|
|
async def _parse_frontmatter_and_body(
|
|
self, skill_dir: Path
|
|
) -> tuple[dict | None, str]:
|
|
skill_md_path = skill_dir / "SKILL.md"
|
|
if not skill_md_path.exists():
|
|
return None, ""
|
|
async with aiofiles.open(skill_md_path, encoding="utf-8") as f:
|
|
content = await f.read()
|
|
if not content.startswith("---"):
|
|
return None, ""
|
|
match = re.search(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", content, re.DOTALL)
|
|
if not match:
|
|
return None, ""
|
|
yaml_content, body = match.groups()
|
|
try:
|
|
return yaml.safe_load(yaml_content), body.strip()
|
|
except yaml.YAMLError as e:
|
|
logger.warning(f"Skill {skill_dir.name} YAML 解析失败: {e}")
|
|
return None, ""
|
|
|
|
async def _parse_skill_metadata(
|
|
self, skill_dir: Path, namespace: str
|
|
) -> Skill | None:
|
|
fm_dict, _ = await self._parse_frontmatter_and_body(skill_dir)
|
|
if (
|
|
not isinstance(fm_dict, dict)
|
|
or "name" not in fm_dict
|
|
or "description" not in fm_dict
|
|
):
|
|
return None
|
|
|
|
blueprint, required_envs = SkillBlueprintBuilder.build(fm_dict)
|
|
fm_dict["blueprint"] = blueprint
|
|
fm_dict["required_envs"] = required_envs
|
|
|
|
skill = Skill(
|
|
frontmatter=SkillFrontmatter(**fm_dict),
|
|
path=skill_dir,
|
|
disclosure_level=METADATA,
|
|
source="local",
|
|
namespace=namespace,
|
|
)
|
|
|
|
if skill.frontmatter.required_envs:
|
|
await skill_env_manager.ensure_template(
|
|
namespace, skill.id, skill.frontmatter.required_envs
|
|
)
|
|
|
|
return skill
|
|
|
|
def _load_skill_instructions(self, skill: Skill) -> Skill:
|
|
if skill.disclosure_level >= INSTRUCTIONS:
|
|
return skill
|
|
_, body = skill_manager._parse_frontmatter_and_body_sync(skill.path)
|
|
return skill.with_disclosure_level(level=INSTRUCTIONS, instructions=body)
|
|
|
|
def _parse_frontmatter_and_body_sync(
|
|
self, skill_dir: Path
|
|
) -> tuple[dict | None, str]:
|
|
"""同步版本的回退方法"""
|
|
skill_md_path = skill_dir / "SKILL.md"
|
|
if not skill_md_path.exists():
|
|
return None, ""
|
|
content = skill_md_path.read_text(encoding="utf-8")
|
|
if not content.startswith("---"):
|
|
return None, ""
|
|
match = re.search(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", content, re.DOTALL)
|
|
if not match:
|
|
return None, ""
|
|
try:
|
|
return yaml.safe_load(match.group(1)), match.group(2).strip()
|
|
except yaml.YAMLError:
|
|
return None, ""
|
|
|
|
def _load_skill_resources(self, skill: Skill) -> Skill:
|
|
if skill.disclosure_level >= RESOURCES:
|
|
return skill
|
|
if skill.disclosure_level < INSTRUCTIONS:
|
|
skill = self._load_skill_instructions(skill)
|
|
|
|
scripts = (
|
|
[
|
|
f.name
|
|
for f in (skill.path / "scripts").iterdir()
|
|
if f.is_file() and not f.name.startswith(".")
|
|
]
|
|
if (skill.path / "scripts").is_dir()
|
|
else []
|
|
)
|
|
refs = (
|
|
[
|
|
f.name
|
|
for f in (skill.path / "references").iterdir()
|
|
if f.is_file() and not f.name.startswith(".")
|
|
]
|
|
if (skill.path / "references").is_dir()
|
|
else []
|
|
)
|
|
return skill.with_disclosure_level(
|
|
level=RESOURCES, scripts=scripts, references=refs
|
|
)
|
|
|
|
|
|
skill_manager = SkillManager()
|
|
|
|
|
|
class BaseSkillResolver(ABC):
|
|
"""技能解析器基类,用于统一不同类型的技能加载逻辑"""
|
|
|
|
@abstractmethod
|
|
async def resolve(self, manager: SkillManager, namespace: str) -> list[Skill]: ...
|
|
|
|
|
|
class SkillInstanceResolver(BaseSkillResolver):
|
|
"""直接解析已有的 Skill 实例解析器"""
|
|
|
|
def __init__(self, skill: Skill):
|
|
self.skill = skill
|
|
|
|
async def resolve(self, manager: SkillManager, namespace: str) -> list[Skill]:
|
|
return [self.skill]
|
|
|
|
|
|
class StringSkillResolver(BaseSkillResolver):
|
|
"""根据技能 ID 解析技能的解析器"""
|
|
|
|
def __init__(self, skill_id: str):
|
|
self.skill_id = skill_id
|
|
|
|
async def resolve(self, manager: SkillManager, namespace: str) -> list[Skill]:
|
|
skill = await manager.get_skill_details(self.skill_id, namespace)
|
|
return [skill] if skill else []
|
|
|
|
|
|
class PathSkillResolver(BaseSkillResolver):
|
|
"""根据本地路径解析技能的解析器"""
|
|
|
|
def __init__(self, path: Path):
|
|
self.path = path
|
|
|
|
async def resolve(self, manager: SkillManager, namespace: str) -> list[Skill]:
|
|
skill = await manager.load_local_skill(self.path, namespace)
|
|
return [skill] if skill else []
|
|
|
|
|
|
class SourceSkillResolver(BaseSkillResolver):
|
|
"""根据 SkillSource 描述符动态扫描解析技能的解析器"""
|
|
|
|
def __init__(self, source: Any):
|
|
self.source = source
|
|
|
|
async def resolve(self, manager: SkillManager, namespace: str) -> list[Skill]:
|
|
candidates = []
|
|
if self.source.fetch_all:
|
|
candidates = list((await manager.discover_skills()).values())
|
|
elif self.source.scan_dir:
|
|
for child in manager._iter_valid_skill_dirs(self.source.scan_dir):
|
|
sk = await manager._parse_skill_from_path(child, namespace)
|
|
if sk:
|
|
candidates.append(sk)
|
|
|
|
resolved = []
|
|
for cand in candidates:
|
|
if (
|
|
not self.source.exclude_skills
|
|
or cand.id not in self.source.exclude_skills
|
|
):
|
|
resolved.append(cand)
|
|
return resolved
|