mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-09-29 00:32:06 +08:00
* ✨ 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>
315 lines
8.1 KiB
Python
315 lines
8.1 KiB
Python
from collections.abc import Callable
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
import os
|
||
from pathlib import Path
|
||
import stat
|
||
import time
|
||
from types import TracebackType
|
||
from typing import Any, ClassVar
|
||
|
||
import httpx
|
||
from nonebot_plugin_uninfo import Uninfo
|
||
import pypinyin
|
||
|
||
from zhenxun.configs.config import Config
|
||
from zhenxun.services.log import logger
|
||
|
||
from .limiters import CountLimiter, FreqLimiter, UserBlockLimiter # noqa: F401
|
||
|
||
|
||
@dataclass
|
||
class EntityIDs:
|
||
user_id: str
|
||
"""用户id"""
|
||
group_id: str | None
|
||
"""群组id"""
|
||
channel_id: str | None
|
||
"""频道id"""
|
||
|
||
|
||
class ResourceDirManager:
|
||
"""
|
||
临时文件管理器
|
||
"""
|
||
|
||
temp_path: ClassVar[set[Path]] = set()
|
||
|
||
@classmethod
|
||
def __tree_append(cls, path: Path, deep: int = 1, current: int = 0):
|
||
"""递归添加文件夹"""
|
||
if current >= deep and deep != -1:
|
||
return
|
||
path = path.resolve() # 标准化路径
|
||
for f in os.listdir(path):
|
||
file = (path / f).resolve() # 标准化子路径
|
||
if file.is_dir():
|
||
if file not in cls.temp_path:
|
||
cls.temp_path.add(file)
|
||
logger.debug(f"添加临时文件夹: {file}")
|
||
cls.__tree_append(file, deep, current + 1)
|
||
|
||
@classmethod
|
||
def add_temp_dir(cls, path: str | Path, tree: bool = False, deep: int = 1):
|
||
"""添加临时清理文件夹,这些文件夹会被自动清理
|
||
|
||
参数:
|
||
path: 文件夹路径
|
||
tree: 是否递归添加文件夹
|
||
deep: 深度, -1 为无限深度
|
||
"""
|
||
if isinstance(path, str):
|
||
path = Path(path)
|
||
if path not in cls.temp_path:
|
||
cls.temp_path.add(path)
|
||
logger.debug(f"添加临时文件夹: {path}")
|
||
if tree:
|
||
cls.__tree_append(path, deep)
|
||
|
||
|
||
def is_binary_file(file_path: str) -> bool:
|
||
"""判断是否为二进制文件
|
||
|
||
参数:
|
||
file_path: 文件路径
|
||
|
||
返回:
|
||
bool: 是否为二进制文件
|
||
"""
|
||
# fmt: off
|
||
# 精简但包含图片和字体的二进制文件扩展名集合
|
||
BINARY_EXTENSIONS = frozenset({
|
||
# 图片文件
|
||
"jpg", "jpeg", "png", "gif", "bmp", "ico", "webp", "tiff", "tif", "svg",
|
||
# 字体文件
|
||
"ttf", "otf", "woff", "woff2", "eot",
|
||
# 压缩文件
|
||
"zip", "rar", "7z", "tar", "gz", "bz2", "xz",
|
||
# 可执行文件和库
|
||
"exe", "dll", "so", "dylib",
|
||
# 文档文件
|
||
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
|
||
# 多媒体文件
|
||
"mp3", "mp4", "avi", "mov", "wmv", "flv",
|
||
# 其他常见二进制文件
|
||
"bin", "dat", "db", "class", "pyc"
|
||
})
|
||
|
||
# 使用os.path.splitext高效提取扩展名
|
||
_, ext = os.path.splitext(file_path)
|
||
# 去除点号并转换为小写
|
||
ext_clean = ext.lstrip(".").lower()
|
||
|
||
return ext_clean in BINARY_EXTENSIONS
|
||
|
||
|
||
def cn2py(word: str) -> str:
|
||
"""将字符串转化为拼音
|
||
|
||
参数:
|
||
word: 文本
|
||
"""
|
||
return "".join("".join(i) for i in pypinyin.pinyin(word, style=pypinyin.NORMAL))
|
||
|
||
|
||
async def get_user_avatar(uid: int | str) -> bytes | None:
|
||
"""快捷获取用户头像
|
||
|
||
参数:
|
||
uid: 用户id
|
||
"""
|
||
url = f"http://q1.qlogo.cn/g?b=qq&nk={uid}&s=160"
|
||
async with httpx.AsyncClient() as client:
|
||
for _ in range(3):
|
||
try:
|
||
return (await client.get(url)).content
|
||
except Exception:
|
||
logger.error("获取用户头像错误", "Util", target=uid)
|
||
return None
|
||
|
||
|
||
async def get_group_avatar(gid: int | str) -> bytes | None:
|
||
"""快捷获取用群头像
|
||
|
||
参数:
|
||
gid: 群号
|
||
"""
|
||
url = f"http://p.qlogo.cn/gh/{gid}/{gid}/640/"
|
||
async with httpx.AsyncClient() as client:
|
||
for _ in range(3):
|
||
try:
|
||
return (await client.get(url)).content
|
||
except Exception:
|
||
logger.error("获取群头像错误", "Util", target=gid)
|
||
return None
|
||
|
||
|
||
def change_pixiv_image_links(
|
||
url: str, size: str | None = None, nginx_url: str | None = None
|
||
) -> str:
|
||
"""根据配置改变图片大小和反代链接
|
||
|
||
参数:
|
||
url: 图片原图链接
|
||
size: 模式
|
||
nginx_url: 反代
|
||
|
||
返回:
|
||
str: url
|
||
"""
|
||
if size == "master":
|
||
img_sp = url.rsplit(".", maxsplit=1)
|
||
url = img_sp[0]
|
||
img_type = img_sp[1]
|
||
url = url.replace("original", "master") + f"_master1200.{img_type}"
|
||
if not nginx_url:
|
||
nginx_url = Config.get_config("pixiv", "PIXIV_NGINX_URL")
|
||
if nginx_url:
|
||
url = (
|
||
url.replace("i.pximg.net", nginx_url)
|
||
.replace("i.pixiv.cat", nginx_url)
|
||
.replace("i.pixiv.re", nginx_url)
|
||
.replace("_webp", "")
|
||
)
|
||
return url
|
||
|
||
|
||
def change_img_md5(path_file: str | Path) -> bool:
|
||
"""改变图片MD5
|
||
|
||
参数:
|
||
path_file: 图片路径
|
||
|
||
返还:
|
||
bool: 是否修改成功
|
||
"""
|
||
try:
|
||
with open(path_file, "a", encoding="utf-8") as f:
|
||
f.write(str(int(time.time() * 1000)))
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(f"改变图片MD5错误 Path:{path_file}", e=e)
|
||
return False
|
||
|
||
|
||
def is_valid_date(date_text: str, separator: str = "-") -> bool:
|
||
"""日期是否合法
|
||
|
||
参数:
|
||
date_text: 日期
|
||
separator: 分隔符
|
||
|
||
返回:
|
||
bool: 日期是否合法
|
||
"""
|
||
try:
|
||
datetime.strptime(date_text, f"%Y{separator}%m{separator}%d")
|
||
return True
|
||
except ValueError:
|
||
return False
|
||
|
||
|
||
def get_entity_ids(session: Uninfo) -> EntityIDs:
|
||
"""获取用户id,群组id,频道id
|
||
|
||
参数:
|
||
session: Uninfo
|
||
|
||
返回:
|
||
EntityIDs: 用户id,群组id,频道id
|
||
"""
|
||
user_id = session.user.id
|
||
group_id = None
|
||
channel_id = None
|
||
if session.group:
|
||
if session.group.parent:
|
||
group_id = session.group.parent.id
|
||
channel_id = session.group.id
|
||
else:
|
||
group_id = session.group.id
|
||
return EntityIDs(user_id=user_id, group_id=group_id, channel_id=channel_id)
|
||
|
||
|
||
def is_number(text: str) -> bool:
|
||
"""是否为数字
|
||
|
||
参数:
|
||
text: 文本
|
||
|
||
返回:
|
||
bool: 是否为数字
|
||
"""
|
||
try:
|
||
float(text)
|
||
return True
|
||
except ValueError:
|
||
return False
|
||
|
||
|
||
def win_on_rm_error(
|
||
func: Callable[[str], Any],
|
||
path: str,
|
||
_exc_info: tuple[type[BaseException], BaseException, TracebackType],
|
||
) -> None:
|
||
"""Windows下删除只读文件/目录时的回调。
|
||
|
||
去除只读属性后重试删除,避免 WinError 5。
|
||
"""
|
||
try:
|
||
os.chmod(path, stat.S_IWRITE)
|
||
except Exception:
|
||
# 即使去除权限失败也继续尝试
|
||
pass
|
||
try:
|
||
func(path)
|
||
except Exception:
|
||
# 仍失败则记录调试日志并忽略,交由上层继续处理
|
||
logger.debug(f"删除失败重试仍失败: {path}")
|
||
|
||
|
||
def infer_plugin_namespace(
|
||
default: str = "global",
|
||
) -> str:
|
||
"""
|
||
智能推断调用者所在的插件命名空间。
|
||
"""
|
||
import inspect
|
||
|
||
from nonebot.plugin import get_plugin_by_module_name
|
||
from nonebot.plugin.manager import _current_plugin
|
||
|
||
plugin = _current_plugin.get()
|
||
if plugin:
|
||
return plugin.name
|
||
|
||
try:
|
||
stack = inspect.stack()
|
||
for frame_info in stack[1:]:
|
||
module = inspect.getmodule(frame_info.frame)
|
||
if not module:
|
||
continue
|
||
m_name = module.__name__
|
||
|
||
if m_name.startswith("zhenxun.services.") or m_name.startswith(
|
||
"zhenxun.utils."
|
||
):
|
||
continue
|
||
|
||
plugin = get_plugin_by_module_name(m_name)
|
||
if plugin:
|
||
return plugin.name
|
||
|
||
parts = m_name.split(".")
|
||
for keyword in ("plugins", "builtin_plugins"):
|
||
if keyword in parts:
|
||
idx = parts.index(keyword)
|
||
if len(parts) > idx + 1:
|
||
return parts[idx + 1]
|
||
|
||
continue
|
||
|
||
except Exception:
|
||
pass
|
||
|
||
return default
|