mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-23 05:00:02 +08:00
✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 (#2146)
* ✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 - 【重构】将原 services/llm 重构并迁移至全新的 services/ai 架构,提供向下兼容垫片 - 【新增】引入 Agent、Team、Workflow 三大智能体与工作流编排范式 - 【新增】引入基于 RAG 的长期向量记忆与中期槽位记忆系统 - 【新增】引入基于 Docker 的安全代码执行沙箱环境 - 【新增】支持 MCP 协议,允许动态管理和调用 MCP 服务 - 【新增】引入输入输出安全合规护栏与自愈反思机制 - 【优化】重构并优化多厂商 API 适配器 (Gemini, OpenAI, DeepSeek, GLM 等) - 【优化】优化日志脱敏与 Token 预估机制 - 【移除】移除旧版 llm default 和 llm reset-key 命令,新增 llm mcp 管理命令 * 🔧 chore(deps): 更新项目依赖与配置 - 添加 mcp、jieba 和 aiodocker 依赖到配置文件及 requirements.txt - 在 pyright 配置中设置 reportMissingImports 为 none - 调整 .gitignore 中 resources 目录的忽略规则 * ♻️ refactor(tools): 重构工具终止机制并清理知识库日志输出 - 统一使用 `context.state["__end_run__"]` 替代 `EndRunResult` 控制任务结束 - 移除文件系统和向量知识库检索工具中 `ToolResult` 的 `.with_log` 调用 - 调整指令处理器(Directive)的返回值为 `tool_res.output` - 修复部分类型检查警告并优化联合类型判断语法 * ♻️ refactor(tools): 重构工具副作用指令与控制流熔断机制 - 引入 `DirectivePayload` 及 `ToolResult` 的子类以结构化表达工具副作用 - 移除通过 `context.state` 传递魔术变量的隐式控制流设计 - 重构 `DirectiveManager` 处理器接口,直接在处理器中修改 `AgentState` 并构建 `AgentRunResult` - 在 `StandardAgentExecutor` 中统一通过 `directive_manager` 调度工具返回的副作用指令 - 补全 `MessageBuilder` 中部分核心方法的文档注释 * 🐛 fix(sandbox): 修复 Docker 沙箱容器状态检测与会话清理逻辑 -【修复】修正 `is_alive` 中直接读取私有属性的问题,改用 `show()` 返回值 -【修复】解决 `execute_code` 中缓存的执行器与当前会话不一致的问题 -【优化】在清理工作区前增加容器存活检测,避免向已死容器发送请求 -【优化】创建容器时增加运行状态校验,若已停止则自动从缓存中移除并重建 -【优化】优化容器销毁和清理逻辑,静默处理容器不存在 (404) 的异常 * 📝 docs(core): 补充核心模块初始化方法的文档注释 * 🚨 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>
This commit is contained in:
co-authored by
webjoin111
pre-commit-ci[bot]
parent
bdc1374848
commit
80fc5b86a7
@@ -0,0 +1,21 @@
|
||||
from .manager import skill_manager
|
||||
from .models import (
|
||||
INSTRUCTIONS,
|
||||
METADATA,
|
||||
RESOURCES,
|
||||
Skill,
|
||||
SkillFrontmatter,
|
||||
SkillSource,
|
||||
)
|
||||
from .toolkit import SkillMetaToolkit
|
||||
|
||||
__all__ = [
|
||||
"INSTRUCTIONS",
|
||||
"METADATA",
|
||||
"RESOURCES",
|
||||
"Skill",
|
||||
"SkillFrontmatter",
|
||||
"SkillMetaToolkit",
|
||||
"SkillSource",
|
||||
"skill_manager",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from zhenxun.services.ai.capabilities import AbstractCapability
|
||||
from zhenxun.services.ai.run import RunContext
|
||||
from zhenxun.services.ai.tools.providers.skills.manager import skill_manager
|
||||
from zhenxun.services.ai.tools.providers.skills.models import Skill, SkillSource
|
||||
from zhenxun.services.ai.tools.providers.skills.toolkit import SkillMetaToolkit
|
||||
from zhenxun.utils.utils import infer_plugin_namespace
|
||||
|
||||
|
||||
class SkillCapability(AbstractCapability):
|
||||
"""技能库挂载能力组件"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
skills: Sequence[str | Path | Skill | SkillSource] | None = None,
|
||||
namespace: str | None = None,
|
||||
):
|
||||
self.skills = skills or []
|
||||
self.namespace = namespace or infer_plugin_namespace()
|
||||
|
||||
async def get_tools(self, context: RunContext) -> list[Any]:
|
||||
tools = []
|
||||
|
||||
if self.skills:
|
||||
resolved_skills = await skill_manager.resolve_mixed_skills(
|
||||
self.skills, namespace=self.namespace
|
||||
)
|
||||
tools.append(SkillMetaToolkit(allowed_skills=resolved_skills))
|
||||
|
||||
return tools
|
||||
@@ -0,0 +1,401 @@
|
||||
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.tools.providers.skills.models import (
|
||||
INSTRUCTIONS,
|
||||
METADATA,
|
||||
RESOURCES,
|
||||
Skill,
|
||||
SkillBlueprintBuilder,
|
||||
SkillEnvConfig,
|
||||
SkillFrontmatter,
|
||||
)
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.pydantic_compat import model_dump, model_validate
|
||||
from zhenxun.utils.utils import infer_plugin_namespace
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,260 @@
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from zhenxun.services.ai.sandbox.models import SandboxBlueprint
|
||||
from zhenxun.utils.pydantic_compat import model_validator
|
||||
|
||||
DisclosureLevel = Annotated[
|
||||
Literal[1, 2, 3], "Progressive disclosure levels for skill loading."
|
||||
]
|
||||
METADATA: DisclosureLevel = 1
|
||||
INSTRUCTIONS: DisclosureLevel = 2
|
||||
RESOURCES: DisclosureLevel = 3
|
||||
|
||||
|
||||
class SkillEnvConfig(BaseModel):
|
||||
"""全量技能环境变量配置根节点"""
|
||||
|
||||
envs: dict[str, dict[str, dict[str, str]]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SkillFrontmatter(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True) # type: ignore
|
||||
|
||||
name: str = Field(...)
|
||||
"""技能名称"""
|
||||
description: str = Field(...)
|
||||
"""技能描述"""
|
||||
compatibility: str | None = Field(default=None)
|
||||
"""环境要求"""
|
||||
metadata: dict[str, Any] | None = Field(default=None)
|
||||
"""自定义元数据"""
|
||||
allowed_tools: list[str] | None = Field(default=None, alias="allowed-tools")
|
||||
"""允许的工具"""
|
||||
permissions: dict[str, Any] | None = Field(default=None)
|
||||
"""沙箱权限声明 (如 network: true/false)"""
|
||||
blueprint: SandboxBlueprint = Field(default_factory=SandboxBlueprint)
|
||||
"""统一环境装配蓝图声明 (根据 metadata 等自动推导)"""
|
||||
required_envs: list[str] = Field(default_factory=list)
|
||||
"""声明该技能必需的全局环境变量 Key"""
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def parse_allowed_tools(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""将字符串格式的 allowed-tools 按空格切分为列表"""
|
||||
key = "allowed-tools"
|
||||
alt_key = "allowed_tools"
|
||||
raw = values.get(key) or values.get(alt_key)
|
||||
if isinstance(raw, str):
|
||||
values[key] = raw.split()
|
||||
return values
|
||||
|
||||
@property
|
||||
def enable_network(self) -> bool:
|
||||
"""网络权限:默认开启(高信任静态资产),
|
||||
允许在 YAML 中通过 permissions.network 显式关闭"""
|
||||
if self.permissions and isinstance(self.permissions, dict):
|
||||
return self.permissions.get("network", True)
|
||||
return True
|
||||
|
||||
|
||||
class Skill(BaseModel):
|
||||
frontmatter: SkillFrontmatter
|
||||
"""解析后的YAML元数据"""
|
||||
instructions: str | None = Field(default=None)
|
||||
"""SKILL.md正文指令,在INSTRUCTIONS级别填充"""
|
||||
path: Path
|
||||
"""技能所在目录"""
|
||||
disclosure_level: DisclosureLevel = Field(default=METADATA)
|
||||
"""当前披露等级"""
|
||||
scripts: list[str] = Field(default_factory=list)
|
||||
"""脚本文件列表"""
|
||||
references: list[str] = Field(default_factory=list)
|
||||
"""参考文档列表"""
|
||||
source: str = Field(default="local")
|
||||
"""技能来源提供者标识"""
|
||||
namespace: str = Field(default="global")
|
||||
"""隔离所属的插件命名空间"""
|
||||
|
||||
def with_disclosure_level(
|
||||
self,
|
||||
level: DisclosureLevel,
|
||||
instructions: str | None = None,
|
||||
scripts: list[str] | None = None,
|
||||
references: list[str] | None = None,
|
||||
) -> "Skill":
|
||||
"""创建一个提升了披露等级的全新 Skill 实例"""
|
||||
return Skill(
|
||||
frontmatter=self.frontmatter,
|
||||
instructions=instructions
|
||||
if instructions is not None
|
||||
else self.instructions,
|
||||
path=self.path,
|
||||
disclosure_level=level,
|
||||
scripts=scripts if scripts is not None else self.scripts,
|
||||
references=references if references is not None else self.references,
|
||||
source=self.source,
|
||||
namespace=self.namespace,
|
||||
)
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""技能的唯一标识符(强制使用所在文件夹的名称)"""
|
||||
return self.path.name
|
||||
|
||||
def to_xml(self) -> str:
|
||||
"""将技能转化为结构化的 XML 格式,供大模型友好读取"""
|
||||
xml = f"<skill>\n <name>{self.id}</name>\n <description>{self.description}</description>\n" # noqa: E501
|
||||
if self.instructions:
|
||||
xml += f" <instructions>\n{self.instructions}\n </instructions>\n"
|
||||
if self.scripts:
|
||||
xml += (
|
||||
" <available_scripts>\n"
|
||||
+ "\n".join(f" <script>{s}</script>" for s in self.scripts)
|
||||
+ "\n </available_scripts>\n"
|
||||
)
|
||||
if self.references:
|
||||
xml += (
|
||||
" <available_references>\n"
|
||||
+ "\n".join(f" <reference>{r}</reference>" for r in self.references)
|
||||
+ "\n </available_references>\n"
|
||||
)
|
||||
xml += "</skill>"
|
||||
return xml
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""技能的显示名称(来自 YAML,可能不符合规范)"""
|
||||
return self.frontmatter.name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self.frontmatter.description
|
||||
|
||||
|
||||
class SkillSource(BaseModel):
|
||||
"""显式定义的技能源 (动态解析器)"""
|
||||
|
||||
fetch_all: bool = Field(default=False)
|
||||
"""是否拉取全局所有已注册的技能"""
|
||||
scan_dir: Path | None = Field(default=None)
|
||||
"""扫描特定物理目录下的所有技能"""
|
||||
exclude_skills: list[str] | None = Field(default=None)
|
||||
"""需要排除的技能 ID 列表"""
|
||||
|
||||
@classmethod
|
||||
def all(cls, exclude: list[str] | None = None) -> "SkillSource":
|
||||
"""声明式:获取系统全局挂载目录下的所有技能"""
|
||||
return cls(fetch_all=True, exclude_skills=exclude)
|
||||
|
||||
@classmethod
|
||||
def from_dir(
|
||||
cls, path: str | Path, exclude: list[str] | None = None
|
||||
) -> "SkillSource":
|
||||
"""声明式:获取指定物理目录下的所有技能(支持私有独立技能库)"""
|
||||
from pathlib import Path
|
||||
|
||||
return cls(scan_dir=Path(path), exclude_skills=exclude)
|
||||
|
||||
|
||||
class SkillBlueprintBuilder:
|
||||
"""环境蓝图组装器:将 YAML 字典剥离解析为沙箱 Blueprint 和环境变量要求"""
|
||||
|
||||
@classmethod
|
||||
def build(cls, values: dict[str, Any]) -> tuple[SandboxBlueprint, list[str]]:
|
||||
env_setup_data = values.get("env_setup", {})
|
||||
python_packages = env_setup_data.get("python_packages", [])
|
||||
system_packages = env_setup_data.get("system_packages", [])
|
||||
node_packages = env_setup_data.get("node_packages", [])
|
||||
bins = env_setup_data.get("bins", [])
|
||||
install_scripts = env_setup_data.get("install_scripts", [])
|
||||
required_envs = values.get("required_envs", [])
|
||||
|
||||
metadata = values.get("metadata", {})
|
||||
if isinstance(metadata, str):
|
||||
import json
|
||||
|
||||
try:
|
||||
metadata = json.loads(metadata)
|
||||
except Exception:
|
||||
metadata = {}
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
for data in metadata.values():
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
requires = data.get("requires", {})
|
||||
if isinstance(requires, dict):
|
||||
for bin_key in ("bins", "anyBins"):
|
||||
b = requires.get(bin_key, [])
|
||||
if isinstance(b, list):
|
||||
bins.extend(b)
|
||||
envs = requires.get("env", [])
|
||||
if isinstance(envs, list):
|
||||
required_envs.extend(envs)
|
||||
|
||||
installs = data.get("install", [])
|
||||
if isinstance(installs, list):
|
||||
for inst in installs:
|
||||
if not isinstance(inst, dict):
|
||||
continue
|
||||
kind = inst.get("kind", "").lower()
|
||||
if kind in ("python", "pip", "uv"):
|
||||
pkg = inst.get("package", "")
|
||||
if isinstance(pkg, str) and pkg:
|
||||
python_packages.extend(pkg.split())
|
||||
elif kind in ("node", "npm"):
|
||||
pkg = inst.get("package", "")
|
||||
if isinstance(pkg, str) and pkg:
|
||||
node_packages.append(pkg)
|
||||
elif kind in ("apt"):
|
||||
pkg = inst.get("formula") or inst.get("package") or ""
|
||||
if isinstance(pkg, str) and pkg:
|
||||
pkg_name = pkg.split("/")[-1]
|
||||
system_packages.append(pkg_name)
|
||||
elif kind == "go":
|
||||
mod = inst.get("module", "")
|
||||
if isinstance(mod, str) and mod:
|
||||
install_scripts.append(f"go install {mod}")
|
||||
|
||||
key = "allowed-tools"
|
||||
alt_key = "allowed_tools"
|
||||
raw_tools = values.get(key) or values.get(alt_key)
|
||||
tools_list = (
|
||||
raw_tools.split()
|
||||
if isinstance(raw_tools, str)
|
||||
else (raw_tools if isinstance(raw_tools, list) else [])
|
||||
)
|
||||
|
||||
import re
|
||||
|
||||
for tool in tools_list:
|
||||
if isinstance(tool, str) and tool.startswith("Bash("):
|
||||
match = re.search(r"Bash\((.*?)\)", tool)
|
||||
if match:
|
||||
inner = match.group(1)
|
||||
cmd = inner.split(":")[0]
|
||||
if cmd not in bins:
|
||||
bins.append(cmd)
|
||||
|
||||
from zhenxun.services.ai.sandbox.models import (
|
||||
AptSetup,
|
||||
NodeSetup,
|
||||
PythonSetup,
|
||||
ShellSetup,
|
||||
)
|
||||
|
||||
steps = []
|
||||
if system_packages:
|
||||
steps.append(AptSetup(packages=list(dict.fromkeys(system_packages))))
|
||||
if python_packages:
|
||||
steps.append(PythonSetup(packages=list(dict.fromkeys(python_packages))))
|
||||
if node_packages:
|
||||
steps.append(NodeSetup(packages=list(dict.fromkeys(node_packages))))
|
||||
if install_scripts:
|
||||
steps.append(ShellSetup(scripts=install_scripts))
|
||||
|
||||
blueprint = SandboxBlueprint(setup_steps=steps)
|
||||
return blueprint, list(dict.fromkeys(required_envs))
|
||||
@@ -0,0 +1,320 @@
|
||||
from typing import Any, cast
|
||||
|
||||
from zhenxun.services.ai.run import Inject, RunContext
|
||||
from zhenxun.services.ai.sandbox.models import SandboxBlueprint
|
||||
from zhenxun.services.ai.sandbox.protocols import (
|
||||
SupportsCommandExecution,
|
||||
SupportsFileSystem,
|
||||
)
|
||||
from zhenxun.services.ai.tools.core.decorators import silent, tool
|
||||
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
|
||||
from zhenxun.services.ai.tools.models import ResolvedToolPayload, ToolResult
|
||||
from zhenxun.services.ai.tools.providers.skills.manager import (
|
||||
skill_env_manager,
|
||||
skill_manager,
|
||||
)
|
||||
from zhenxun.services.ai.tools.providers.skills.models import Skill
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.pydantic_compat import model_copy
|
||||
from zhenxun.utils.utils import infer_plugin_namespace
|
||||
|
||||
|
||||
class SkillSandboxExecutionMixin:
|
||||
"""技能沙箱执行与自愈逻辑混入类"""
|
||||
|
||||
async def _ensure_skill_workspace(
|
||||
self, skill: Skill, session_id: str, sandbox: Any
|
||||
) -> tuple[Any, str]:
|
||||
"""负责环境隔离与沙箱启动装配,返回底层执行器和目标工作区路径"""
|
||||
bp = model_copy(skill.frontmatter.blueprint, deep=True)
|
||||
bp.enable_network = skill.frontmatter.enable_network
|
||||
|
||||
executor = await sandbox.get_or_create_session(session_id, blueprint=bp)
|
||||
fs_executor = cast(SupportsFileSystem, executor)
|
||||
|
||||
target_workspace = f"{executor.workspace_path}/{skill.id}"
|
||||
|
||||
if skill.id not in executor.loaded_skills:
|
||||
await fs_executor.upload_raw_dir(str(skill.path), target_workspace)
|
||||
try:
|
||||
await sandbox.setup_workspace_environment(session_id, target_workspace)
|
||||
except Exception as e:
|
||||
logger.warning(f"设置沙箱工作区环境失败: {e}")
|
||||
executor.loaded_skills.add(skill.id)
|
||||
|
||||
return executor, target_workspace
|
||||
|
||||
def _prepare_skill_env_vars(
|
||||
self, skill: Skill, context: RunContext | None, **kwargs
|
||||
) -> dict[str, str] | ToolResult:
|
||||
"""处理环境变量注入与缺失拦截"""
|
||||
configured_envs = skill_env_manager.get_envs_for_skill(
|
||||
skill.namespace, skill.id
|
||||
)
|
||||
missing_keys = [
|
||||
k for k in skill.frontmatter.required_envs if not configured_envs.get(k)
|
||||
]
|
||||
if missing_keys:
|
||||
logger.warning(f"技能 {skill.id} 因缺少环境变量 {missing_keys} 被拦截。")
|
||||
return ToolResult(
|
||||
output=(
|
||||
f"❌ 技能执行被系统拦截:缺少必需的全局环境变量 "
|
||||
f"{missing_keys}。\n"
|
||||
"💡 [智能体自愈引导]:当前技能的底层配置缺失,无法正常运行。"
|
||||
"请你立即停止尝试,并向用户抱歉,提示用户(或 Bot 管理员)"
|
||||
"在机器人后端的 `data/ai/skill_envs.json` 文件中为该技能配置"
|
||||
"相应的环境变量(API Key 等),配置完成后方可使用。"
|
||||
),
|
||||
).as_error()
|
||||
|
||||
env_vars = {}
|
||||
for k, v in configured_envs.items():
|
||||
if v:
|
||||
env_vars[k] = str(v)
|
||||
|
||||
for k, v in kwargs.items():
|
||||
env_vars[k] = str(v)
|
||||
if context:
|
||||
for k, v in context.state.items():
|
||||
if isinstance(v, str | int | float | bool):
|
||||
env_vars[k] = str(v)
|
||||
|
||||
final_env = {}
|
||||
for k, v in env_vars.items():
|
||||
final_env[k] = str(v)
|
||||
final_env[k.upper()] = str(v)
|
||||
|
||||
return final_env
|
||||
|
||||
def _format_execution_result(self, result: Any, command: str) -> ToolResult:
|
||||
"""统一处理沙箱输出并转换为 ToolResult"""
|
||||
output = (
|
||||
result.stdout + ("\nSTDERR:\n" + result.stderr if result.stderr else "")
|
||||
).strip()
|
||||
|
||||
if getattr(result, "is_timeout", False) or result.exit_code == -1:
|
||||
final_output = (
|
||||
f"🚨 终端命令执行发生严重系统异常或超时被强杀\n"
|
||||
f"(Exit Code: {result.exit_code})!\n"
|
||||
"这通常意味着网络不通、下载数据过大耗时太长,"
|
||||
"或沙箱环境崩溃。输出为空。"
|
||||
)
|
||||
elif result.exit_code != 0:
|
||||
final_output = (
|
||||
f"❌ 终端命令执行失败 (Exit Code: {result.exit_code})。\n"
|
||||
f"输出日志:\n{output or '无日志输出'}"
|
||||
)
|
||||
else:
|
||||
final_output = output or "✅ 执行成功 (无控制台输出)。"
|
||||
|
||||
logger.info(f"终端命令 {command} 执行完毕, Exit Code: {result.exit_code}")
|
||||
tool_result = ToolResult(output=final_output)
|
||||
if result.exit_code != 0:
|
||||
tool_result = tool_result.as_error()
|
||||
return tool_result
|
||||
|
||||
async def _execute_skill_command_in_sandbox(
|
||||
self,
|
||||
skill: Skill,
|
||||
command: str,
|
||||
context: RunContext | None,
|
||||
sandbox: Any,
|
||||
**kwargs,
|
||||
) -> ToolResult:
|
||||
"""主调度方法"""
|
||||
session_id = (
|
||||
context.session_id
|
||||
if context and context.session_id
|
||||
else f"skill_{skill.id}_session"
|
||||
)
|
||||
|
||||
executor, target_workspace = await self._ensure_skill_workspace(
|
||||
skill, session_id, sandbox
|
||||
)
|
||||
|
||||
env_res = self._prepare_skill_env_vars(skill, context, **kwargs)
|
||||
if isinstance(env_res, ToolResult):
|
||||
return env_res
|
||||
|
||||
env_res["SKILL_DIR"] = target_workspace
|
||||
|
||||
cmd_executor = cast(SupportsCommandExecution, executor)
|
||||
result = await cmd_executor.run_process(
|
||||
command, cwd=target_workspace, timeout=180, env=env_res
|
||||
)
|
||||
|
||||
return self._format_execution_result(result, command)
|
||||
|
||||
|
||||
class SkillMetaToolkit(BaseToolkit, SkillSandboxExecutionMixin):
|
||||
"""动态发现模式。提供通用的元工具,供大模型按需加载和执行任意可用技能。"""
|
||||
|
||||
default_prefix = ""
|
||||
|
||||
def __init__(self, allowed_skills: list[Skill] | None = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._allowed_skills = allowed_skills
|
||||
if self._allowed_skills is not None:
|
||||
self._instance_instructions = (
|
||||
(self._instance_instructions or self.default_instructions)
|
||||
+ "\n\n🚨 **[系统安全提示]**:当前运行在严格沙盒隔离模式,"
|
||||
"你只能访问特定被授权的技能。"
|
||||
)
|
||||
|
||||
async def _get_skill(self, skill_name: str) -> Skill | None:
|
||||
"""按需从隔离白名单或全局管理器中获取技能"""
|
||||
if self._allowed_skills is not None:
|
||||
for s in self._allowed_skills:
|
||||
if s.id == skill_name or s.name == skill_name:
|
||||
return s
|
||||
return None
|
||||
|
||||
return await skill_manager.get_skill_details(
|
||||
skill_name, namespace=infer_plugin_namespace()
|
||||
)
|
||||
|
||||
async def resolve(self, context: RunContext | None = None) -> ResolvedToolPayload:
|
||||
payload = await super().resolve(context)
|
||||
if self._allowed_skills is not None:
|
||||
catalog_parts = []
|
||||
for skill in self._allowed_skills:
|
||||
catalog_parts.append(
|
||||
f" <skill>\n <name>{skill.id}</name>\n"
|
||||
f" <description>{skill.description}</description>\n </skill>"
|
||||
)
|
||||
if catalog_parts:
|
||||
catalog_xml = (
|
||||
"<available_skills>\n"
|
||||
+ "\n".join(catalog_parts)
|
||||
+ "\n</available_skills>"
|
||||
)
|
||||
payload.injected_prompts.append(
|
||||
f"--- 当前受限的可用技能库 ---\n\n{catalog_xml}"
|
||||
)
|
||||
return payload
|
||||
|
||||
default_instructions = """\
|
||||
## 技能元工具系统
|
||||
你可以通过此工具箱动态加载和执行外部技能。工作流如下:
|
||||
1. 使用 `read_skill_instructions` 传入技能名称,获取该技能的完整指南。
|
||||
2. 系统将返回严谨的 `<skill>` XML 节点树,请仔细阅读 `<instructions>` 了解业务规则。
|
||||
3. **[重点] 环境装配与执行规范**:
|
||||
- **环境变量**:系统已自动将该技能的物理根目录注入为环境变量 `$SKILL_DIR`,在执行终端命令时可直接使用它来定位文件(例如 `cat $SKILL_DIR/package.json`)。
|
||||
- **按需安装(懒加载)**:沙箱内通常已通过底层 Blueprint 预装了所需的依赖包。**即使技能指南(说明文档)中写了“安装依赖”的步骤,你也必须忽略它!** 严禁在没有任何报错的情况下主动去执行安装命令(如 `npm install`, `pip install`)。
|
||||
- **脚本执行**:若指南中要求执行物理存在的脚本文件(如 `<available_scripts>` 节点列出的文件),请统一调用 `execute_skill_command` 并加上解释器和完整路径(如 `python3 $SKILL_DIR/scripts/xxx.py`)。
|
||||
- **终端执行**:若指南中提供的是纯命令行终端指令(例如 `curl`, `infsh`, `gh` 等),请调用 `execute_skill_command` 在技能专属沙箱中直接执行该命令。
|
||||
- **智能自愈 (Agentic Healing)**:如果执行脚本或命令时失败(如 Exit Code 非 0),你必须自主阅读输出日志 (Stderr/Stdout),分析报错原因。
|
||||
- **只有当运行报错且明确提示缺少依赖时**(如 `command not found`, `ModuleNotFoundError` 等),你才可以调用 `execute_skill_command` 安装缺失的依赖(Python 依赖强烈建议使用 `uv pip install <pkg>`,Node 使用 `npm install <pkg>`),安装成功后再次重试。
|
||||
- 如果是其他错误,请结合技能指南调整参数或操作流程后重试。
|
||||
4. 如需阅读参考文档,请参考 `<available_references>` 节点并调用 `read_skill_file`。""" # noqa: E501
|
||||
|
||||
@tool(
|
||||
name="read_skill_instructions",
|
||||
description="加载指定技能的完整使用说明与可用资源清单。返回值为 XML 结构。",
|
||||
)
|
||||
@silent()
|
||||
async def read_skill_instructions(self, skill_name: str) -> ToolResult:
|
||||
skill = await self._get_skill(skill_name)
|
||||
if not skill:
|
||||
return ToolResult(output=f"越权操作或未找到技能: {skill_name}").as_error()
|
||||
|
||||
res = skill.to_xml()
|
||||
res += (
|
||||
"\n\n**提示**: 你可以使用 `read_skill_file` 工具读取该技能"
|
||||
"目录下的任何附加文件(如 references/ 或 templates/ 下的文档)。"
|
||||
)
|
||||
logger.info(f"已加载技能 {skill.name} 指南。")
|
||||
return ToolResult(output=res)
|
||||
|
||||
@tool(
|
||||
name="execute_skill_command",
|
||||
description=(
|
||||
"在技能专属的安全沙箱内执行任意终端命令行指令"
|
||||
"(如 curl, npm, gh 等)。仅当技能指南中提供的是终端命令"
|
||||
"而非特定脚本文件时使用。"
|
||||
),
|
||||
)
|
||||
async def execute_skill_command(
|
||||
self,
|
||||
skill_name: str,
|
||||
command: str,
|
||||
context: RunContext | None = None,
|
||||
sandbox: Inject.Sandbox = None,
|
||||
**kwargs,
|
||||
) -> ToolResult:
|
||||
skill = await self._get_skill(skill_name)
|
||||
if not skill:
|
||||
return ToolResult(
|
||||
output=f"越权操作或未找到技能: {skill_name}",
|
||||
).as_error()
|
||||
|
||||
if sandbox is None and context is not None:
|
||||
sandbox = Inject._providers["sandbox"]["global"](context)
|
||||
|
||||
return await self._execute_skill_command_in_sandbox(
|
||||
skill, command, context, sandbox, **kwargs
|
||||
)
|
||||
|
||||
@tool(
|
||||
name="read_skill_file",
|
||||
description=(
|
||||
"安全读取指定技能目录下的附加文件"
|
||||
"(如 references/ 里的参考文档或 scripts/ 里的代码)。"
|
||||
),
|
||||
)
|
||||
@silent()
|
||||
async def read_skill_file(
|
||||
self,
|
||||
skill_name: str,
|
||||
file_path: str,
|
||||
context: RunContext | None = None,
|
||||
sandbox: Inject.Sandbox = None,
|
||||
) -> ToolResult:
|
||||
skill = await self._get_skill(skill_name)
|
||||
if not skill:
|
||||
return ToolResult(output=f"越权操作或未找到技能: {skill_name}").as_error()
|
||||
|
||||
content = await skill_manager.read_skill_resource(skill, file_path)
|
||||
|
||||
if content is not None:
|
||||
logger.info(
|
||||
f"已从本地读取文件 {file_path} (共 {len(content)} 字符)。"
|
||||
f"内容摘要: {content[:100]}..."
|
||||
)
|
||||
return ToolResult(output=content)
|
||||
|
||||
session_id = (
|
||||
context.session_id
|
||||
if context and context.session_id
|
||||
else f"skill_{skill.id}_session"
|
||||
)
|
||||
bp = SandboxBlueprint(enable_network=skill.frontmatter.enable_network)
|
||||
try:
|
||||
if sandbox is None and context is not None:
|
||||
sandbox = Inject._providers["sandbox"]["global"](context)
|
||||
|
||||
executor = await sandbox.get_or_create_session(session_id, blueprint=bp)
|
||||
fs_executor = cast(SupportsFileSystem, executor)
|
||||
|
||||
clean_file_path = file_path.lstrip("/")
|
||||
sandbox_target_path = (
|
||||
f"{executor.workspace_path}/{skill.id}/{clean_file_path}"
|
||||
)
|
||||
|
||||
content = await fs_executor.read_raw_file(sandbox_target_path)
|
||||
|
||||
if content.startswith("Error: File") or content.startswith("Failed to"):
|
||||
return ToolResult(
|
||||
output=(
|
||||
f"❌ 找不到文件: {file_path} "
|
||||
f"(Provider {skill.source} 与沙箱中均未找到)"
|
||||
),
|
||||
).as_error()
|
||||
|
||||
logger.info(
|
||||
"已从沙箱读取动态生成的文件 "
|
||||
f"{sandbox_target_path} (共 {len(content)} 字符)。"
|
||||
)
|
||||
return ToolResult(output=content)
|
||||
except Exception as e:
|
||||
return ToolResult(output=f"❌ 尝试读取沙箱文件时发生异常: {e}").as_error()
|
||||
Reference in New Issue
Block a user