diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index fb514291..989888b2 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,11 +1,12 @@ name: Docker Build and Push on: + schedule: + - cron: '0 0 * * *' push: branches: - main - classical - - dev tags: - "v*.*.*" - "v*" @@ -24,6 +25,7 @@ jobs: - name: Check out git repository uses: actions/checkout@v4 with: + ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref }} fetch-depth: 0 # Clone required dependencies @@ -77,6 +79,7 @@ jobs: - name: Check out git repository uses: actions/checkout@v4 with: + ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref }} fetch-depth: 0 # Clone required dependencies diff --git a/.gitignore b/.gitignore index 63db1c17..9db98c1d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,6 @@ run_maibot_core.bat run_voice.bat run_napcat_adapter.bat run_ad.bat -s4u.s4u llm_tool_benchmark_results.json MaiBot-Napcat-Adapter-main MaiBot-Napcat-Adapter @@ -27,6 +26,7 @@ run.bat log_debug/ run_amds.bat run_none.bat +docs-mai/ run.py message_queue_content.txt message_queue_content.bat @@ -51,6 +51,7 @@ template/compare/model_config_template.toml src/plugins/utils/statistic.py CLAUDE.md MaiBot-Dashboard/ +cloudflare-workers/ # Byte-compiled / optimized / DLL files __pycache__/ @@ -69,7 +70,6 @@ elua.confirmed .Python build/ develop-eggs/ -dist/ downloads/ eggs/ .eggs/ diff --git a/Dockerfile b/Dockerfile index b0f3775f..b6a9cd1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,8 @@ WORKDIR /MaiMBot # 复制依赖列表 COPY requirements.txt . +RUN apt-get update && apt-get install -y git + # 从编译阶段复制 LPMM 编译结果 COPY --from=lpmm-builder /usr/local/lib/python3.13/site-packages/ /usr/local/lib/python3.13/site-packages/ diff --git a/README.md b/README.md index 03eac57b..de5b2e28 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,12 @@ **🍔MaiCore 是一个基于大语言模型的可交互智能体** -- 💭 **智能对话系统**:基于 LLM 的自然语言交互,聊天时机控制。 -- 🤔 **实时思维系统**:模拟人类思考过程。 -- 🧠 **表达学习功能**:学习群友的说话风格和表达方式 -- 💝 **情感表达系统**:情绪系统和表情包系统。 -- 🔌 **强大插件系统**:提供API和事件系统,可编写强大插件。 +- 💭 **拟人构建的prompt**:使用自然语言风格构建回复器的prompt,实现近似人类言语习惯的回复。 +- 💭 **行为规划**:在合适的时间说话,使用合适的动作 +- 🧠 **表达学习**:学习群友的说话风格和表达方式,学会真实人类的说话风格 +- 🤔 **黑话学习**:自主的学习没有见过的词语,尝试理解并认知含义 +- 🔌 **插件系统**:提供API和事件系统,可编写丰富插件。 +- 💝 **情感表达**:情绪系统和表情包系统。
@@ -44,29 +45,28 @@ ## 🔥 更新和安装 -**最新版本: v0.11.0** ([更新日志](changelogs/changelog.md)) + +**最新版本: v0.11.5** ([更新日志](changelogs/changelog.md)) + 可前往 [Release](https://github.com/MaiM-with-u/MaiBot/releases/) 页面下载最新版本 可前往 [启动器发布页面](https://github.com/MaiM-with-u/mailauncher/releases/)下载最新启动器 **GitHub 分支说明:** - `main`: 稳定发布版本(推荐) + + - `dev`: 开发测试版本(不稳定) -- `classical`: 旧版本(停止维护) +- `classical`: 经典版本(停止维护) ### 最新版本部署教程 - [🚀 最新版本部署教程](https://docs.mai-mai.org/manual/deployment/mmc_deploy_windows.html) - 基于 MaiCore 的新版本部署方式(与旧版本不兼容) > [!WARNING] > - 项目处于活跃开发阶段,功能和 API 可能随时调整。 -> - 有问题可以提交 Issue 或者 Discussion。 +> - 有问题可以提交 Issue 。 > - QQ 机器人存在被限制风险,请自行了解,谨慎使用。 > - 由于程序处于开发中,可能消耗较多 token。 -## 麦麦MC项目MaiCraft(早期开发) -[让麦麦玩MC](https://github.com/MaiM-with-u/Maicraft) - -交流群:1058573197 - ## 💬 讨论 **技术交流群:** @@ -78,7 +78,7 @@ **聊天吹水群:** - [麦麦之闲聊群](https://qm.qq.com/q/JxvHZnxyec) -**插件开发测试版群:** +**插件开发/测试版讨论群:** - [插件开发群](https://qm.qq.com/q/1036092828) ## 📚 文档 @@ -87,7 +87,22 @@ - [📚 核心 Wiki 文档](https://docs.mai-mai.org) - 项目最全面的文档中心,你可以了解麦麦有关的一切。 -### 设计理念(原始时代的火花) + +## 📚 衍生项目 + +### MaiCraft(早期开发) +[MaiCraft](https://github.com/MaiM-with-u/Maicraft) +> 让麦麦具有玩MC能力的项目 +> 交流群:1058573197 + +### MoFox_Bot +[MoFox - 仓库地址](https://github.com/MoFox-Studio/MoFox-Core) +> MoFox_Bot 是一个基于 MaiCore 0.10.0 snapshot.5 的增强型 fork 项目 +> 我们保留了原项目几乎所有核心功能,并在此基础上进行了深度优化与功能扩展,致力于打造一个更稳定、更智能、更具趣味性的 AI 智能体。 + + + +## 设计理念(原始时代的火花) > **千石可乐说:** > - 这个项目最初只是为了给牛牛 bot 添加一点额外的功能,但是功能越写越多,最后决定重写。其目的是为了创造一个活跃在 QQ 群聊的"生命体"。目的并不是为了写一个功能齐全的机器人,而是一个尽可能让人感知到真实的类人存在。 @@ -99,7 +114,7 @@ ## 🙋 贡献和致谢 你可以阅读[开发文档](https://docs.mai-mai.org/develop/)来更好的了解麦麦! MaiCore 是一个开源项目,我们非常欢迎你的参与。你的贡献,无论是提交 bug 报告、功能需求还是代码 pr,都对项目非常宝贵。我们非常感谢你的支持!🎉 -但无序的讨论会降低沟通效率,进而影响问题的解决速度,因此在提交任何贡献前,请务必先阅读本项目的[贡献指南](docs/CONTRIBUTE.md)。(待补完) +但无序的讨论会降低沟通效率,进而影响问题的解决速度,因此在提交任何贡献前,请务必先阅读本项目的[贡献指南](docs-src/CONTRIBUTE.md)。(待补完) ### 贡献者 diff --git a/bot.py b/bot.py index cf342507..68c6e110 100644 --- a/bot.py +++ b/bot.py @@ -1,7 +1,6 @@ import asyncio import hashlib import os -import sys import time import platform import traceback @@ -30,7 +29,7 @@ else: raise # 最早期初始化日志系统,确保所有后续模块都使用正确的日志格式 -from src.common.logger import initialize_logging, get_logger, shutdown_logging #noqa +from src.common.logger import initialize_logging, get_logger, shutdown_logging # noqa initialize_logging() @@ -76,6 +75,15 @@ async def graceful_shutdown(): # sourcery skip: use-named-expression try: logger.info("正在优雅关闭麦麦...") + # 关闭 WebUI 服务器 + try: + from src.webui.webui_server import get_webui_server + webui_server = get_webui_server() + if webui_server and webui_server._server: + await webui_server.shutdown() + except Exception as e: + logger.warning(f"关闭 WebUI 服务器时出错: {e}") + from src.plugin_system.core.events_manager import events_manager from src.plugin_system.base.component_types import EventType @@ -107,9 +115,6 @@ async def graceful_shutdown(): # sourcery skip: use-named-expression logger.info("麦麦优雅关闭完成") - # 关闭日志系统,释放文件句柄 - shutdown_logging() - except Exception as e: logger.error(f"麦麦关闭失败: {e}", exc_info=True) @@ -216,6 +221,11 @@ if __name__ == "__main__": loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) + # 初始化 WebSocket 日志推送 + from src.common.logger import initialize_ws_handler + + initialize_ws_handler(loop) + try: # 执行初始化和任务调度 loop.run_until_complete(main_system.initialize()) @@ -241,7 +251,7 @@ if __name__ == "__main__": # 确保 loop 在任何情况下都尝试关闭(如果存在且未关闭) if "loop" in locals() and loop and not loop.is_closed(): loop.close() - logger.info("事件循环已关闭") + print("[主程序] 事件循环已关闭") # 关闭日志系统,释放文件句柄 try: @@ -249,6 +259,8 @@ if __name__ == "__main__": except Exception as e: print(f"关闭日志系统时出错: {e}") - # 在程序退出前暂停,让你有机会看到输出 - # input("按 Enter 键退出...") # <--- 添加这行 - sys.exit(exit_code) # <--- 使用记录的退出码 + print("[主程序] 准备退出...") + + # 使用 os._exit() 强制退出,避免被阻塞 + # 由于已经在 graceful_shutdown() 中完成了所有清理工作,这是安全的 + os._exit(exit_code) diff --git a/changelogs/changelog.md b/changelogs/changelog.md index 1b01a405..0a4f34b9 100644 --- a/changelogs/changelog.md +++ b/changelogs/changelog.md @@ -1,6 +1,99 @@ # Changelog +## [0.11.5] - 2025-11-21 +### 🌟 重大更新 +- WebUI 现支持手动重启麦麦,曲线救国版“热重载” +- 新增麦麦 QQ 适配器可视化编辑 UI(独立进程,需手动上传/下载并覆盖适配器文件) +- 麦麦主程序配置支持可视化模式与源代码模式双模式编辑,后端执行 TOML 校验 +- 优化 planner 与 replyer 协同机制,调试日志更细 -## [0.11.2] - 2025-11-15 +### 新增 +- 表情包管理、人物信息管理、表达方式管理界面手机端适配 +- 配置页“重启麦麦”提示 +- 详细的debug prompt显示配置 +- 麦麦界面操作主题色按钮 +- 前端集成 CodeMirror(Python/JSON/TOML 语法高亮)并对 JSON 配置提供自动纠错提示 + +### 修复 +- 表情包缩略图过小 +- 添加模型后无法立即显示 +- 插件市场分类错误 +- 浅色模式下日志查看器背景异常 +- 表情包详情窗口无法关闭 +- 情绪标签无法正常读取(issues/1373) +- 模型任务配置界面模型温度不可更改(issues/1369) +- 模型任务配置界面部分输入框无法删除默认值 +- 插件商店默认勾选“仅显示兼容插件” +- 插件商店标签页数量显示错误 +- 日志查看器日志换行异常 +- 主题色未正确应用 +- 侧边栏滚动条问题 +- 移除前端 TOML 解析库导致的兼容问题 + +### 优化 +- 首页加载用户体验 +- 首页加载速度(9s → <1s) +- 整个界面的初屏加载速度 + +### 更新 +- 适配器配置支持上传模式与指定路径模式;指定路径模式可免去反复上传/下载配置文件 +- 适配器配置界面标签页响应式优化,小屏仅显示简短标签 +- 麦麦资源管理所有界面的批量删除能力 +- 资源管理所有界面的分页、每页数量选择与页跳转 +- 插件市场新增点赞、点踩、评分与下载量统计(基于 Cloudflare,保证国内可访问) +- 麦麦表情包查看界面的描述部分支持 Markdown 渲染 + +## [0.11.4] - 2025-11-19 +### 🌟 主要更新内容 +- **首个官方 Web 管理界面上线**:在此版本之前,MaiBot 没有 WebUI,所有配置需手动编辑 TOML 文件 +- **认证系统**:Token 安全登录(支持系统生成 64 位随机令牌 / 自定义 Token),首次配置向导 +- **配置管理(可视化编辑,无需手动改 TOML)**: + - 麦麦主程序配置:基础设置、人格、表情、黑话、情绪等 + - 模型提供商配置:OpenAI、Anthropic、DeepSeek、Qwen、Ollama 等 + - 模型配置:对话/视觉/嵌入模型分配 +- **资源管理**: + - 表情包管理:查看、搜索、注册、封禁 + - 表达方式管理:查看麦麦的表达记录 + - 人物信息管理:查看联系人列表 +- **插件系统**: + - 插件市场浏览 + - 一键安装/卸载/更新 + - 版本兼容性检查 + - 实时安装进度推送 +- **日志查看器**: + - WebSocket 实时日志流 + - 日志级别过滤(DEBUG/INFO/WARNING/ERROR/CRITICAL) + - 搜索功能 +- **主题定制**: + - 浅色/深色/跟随系统 + - 12 种主题色(6 单色 + 6 渐变色) + - 自定义颜色选择器 +- **全局搜索**:Cmd/Ctrl + K 快捷键,快速跳转任意页面 + +### 细节 +- **技术栈**: + - 前端: React 19 + TypeScript + Vite + TanStack Router + shadcn/ui + - 后端: FastAPI + Uvicorn + WebSocket + - 特点: SPA 单页应用,前后端同端口,静态文件托管 +- **使用方式**:参照 template.env 文件更新 .env 文件,添加两个字段: + - `WEBUI_ENABLED=true` + - `WEBUI_MODE=production` +- **WebUI 开源协议**:GPLv3 +- **WebUI 地址**:https://github.com/Mai-with-u/MaiBot-Dashboard + +告别手动编辑配置文件,享受现代化图形界面! + +## [0.11.3] - 2025-11-18 +### 功能更改和修复 +- 优化记忆提取策略 +- 优化黑话提取 +- 优化表达方式学习 +- 修改readme +- 加入测试版webui + +提示:清理旧的记忆数据和表达方式,表现更好 +方法:删除数据库中 expression jargon 和 thinking_back 的全部内容 + +## [0.11.2] - 2025-11-16 ### 🌟 主要功能更改 - "海马体Agent"记忆系统上线,最新最好的记忆系统,默认已接入lpmm - 添加黑话jargon学习系统 diff --git a/docker-compose.yml b/docker-compose.yml index 3bcf0e54..29887250 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,8 @@ services: - TZ=Asia/Shanghai # - EULA_AGREE=99f08e0cab0190de853cb6af7d64d4de # 同意EULA # - PRIVACY_AGREE=9943b855e72199d0f5016ea39052f1b6 # 同意EULA -# ports: + ports: + - "18001:8001" # webui端口 # - "8000:8000" volumes: - ./docker-config/mmc/.env:/MaiMBot/.env # 持久化env配置文件 diff --git a/docs/Bing.md b/docs-src/Bing.md similarity index 100% rename from docs/Bing.md rename to docs-src/Bing.md diff --git a/docs/CONTRIBUTE.md b/docs-src/CONTRIBUTE.md similarity index 100% rename from docs/CONTRIBUTE.md rename to docs-src/CONTRIBUTE.md diff --git a/docs/plugins/action-components.md b/docs-src/plugins/action-components.md similarity index 100% rename from docs/plugins/action-components.md rename to docs-src/plugins/action-components.md diff --git a/docs/plugins/api/chat-api.md b/docs-src/plugins/api/chat-api.md similarity index 100% rename from docs/plugins/api/chat-api.md rename to docs-src/plugins/api/chat-api.md diff --git a/docs/plugins/api/component-manage-api.md b/docs-src/plugins/api/component-manage-api.md similarity index 100% rename from docs/plugins/api/component-manage-api.md rename to docs-src/plugins/api/component-manage-api.md diff --git a/docs/plugins/api/config-api.md b/docs-src/plugins/api/config-api.md similarity index 100% rename from docs/plugins/api/config-api.md rename to docs-src/plugins/api/config-api.md diff --git a/docs/plugins/api/database-api.md b/docs-src/plugins/api/database-api.md similarity index 100% rename from docs/plugins/api/database-api.md rename to docs-src/plugins/api/database-api.md diff --git a/docs/plugins/api/emoji-api.md b/docs-src/plugins/api/emoji-api.md similarity index 100% rename from docs/plugins/api/emoji-api.md rename to docs-src/plugins/api/emoji-api.md diff --git a/docs/plugins/api/generator-api.md b/docs-src/plugins/api/generator-api.md similarity index 100% rename from docs/plugins/api/generator-api.md rename to docs-src/plugins/api/generator-api.md diff --git a/docs/plugins/api/llm-api.md b/docs-src/plugins/api/llm-api.md similarity index 100% rename from docs/plugins/api/llm-api.md rename to docs-src/plugins/api/llm-api.md diff --git a/docs/plugins/api/logging-api.md b/docs-src/plugins/api/logging-api.md similarity index 100% rename from docs/plugins/api/logging-api.md rename to docs-src/plugins/api/logging-api.md diff --git a/docs/plugins/api/message-api.md b/docs-src/plugins/api/message-api.md similarity index 100% rename from docs/plugins/api/message-api.md rename to docs-src/plugins/api/message-api.md diff --git a/docs/plugins/api/person-api.md b/docs-src/plugins/api/person-api.md similarity index 100% rename from docs/plugins/api/person-api.md rename to docs-src/plugins/api/person-api.md diff --git a/docs/plugins/api/plugin-manage-api.md b/docs-src/plugins/api/plugin-manage-api.md similarity index 100% rename from docs/plugins/api/plugin-manage-api.md rename to docs-src/plugins/api/plugin-manage-api.md diff --git a/docs/plugins/api/send-api.md b/docs-src/plugins/api/send-api.md similarity index 100% rename from docs/plugins/api/send-api.md rename to docs-src/plugins/api/send-api.md diff --git a/docs/plugins/api/tool-api.md b/docs-src/plugins/api/tool-api.md similarity index 100% rename from docs/plugins/api/tool-api.md rename to docs-src/plugins/api/tool-api.md diff --git a/docs/plugins/command-components.md b/docs-src/plugins/command-components.md similarity index 100% rename from docs/plugins/command-components.md rename to docs-src/plugins/command-components.md diff --git a/docs/plugins/configuration-guide.md b/docs-src/plugins/configuration-guide.md similarity index 100% rename from docs/plugins/configuration-guide.md rename to docs-src/plugins/configuration-guide.md diff --git a/docs/plugins/dependency-management.md b/docs-src/plugins/dependency-management.md similarity index 100% rename from docs/plugins/dependency-management.md rename to docs-src/plugins/dependency-management.md diff --git a/docs/plugins/image/quick-start/1750326700269.png b/docs-src/plugins/image/quick-start/1750326700269.png similarity index 100% rename from docs/plugins/image/quick-start/1750326700269.png rename to docs-src/plugins/image/quick-start/1750326700269.png diff --git a/docs/plugins/image/quick-start/1750332508760.png b/docs-src/plugins/image/quick-start/1750332508760.png similarity index 100% rename from docs/plugins/image/quick-start/1750332508760.png rename to docs-src/plugins/image/quick-start/1750332508760.png diff --git a/docs/plugins/index.md b/docs-src/plugins/index.md similarity index 100% rename from docs/plugins/index.md rename to docs-src/plugins/index.md diff --git a/docs/plugins/manifest-guide.md b/docs-src/plugins/manifest-guide.md similarity index 100% rename from docs/plugins/manifest-guide.md rename to docs-src/plugins/manifest-guide.md diff --git a/docs/plugins/quick-start.md b/docs-src/plugins/quick-start.md similarity index 100% rename from docs/plugins/quick-start.md rename to docs-src/plugins/quick-start.md diff --git a/docs/plugins/tool-components.md b/docs-src/plugins/tool-components.md similarity index 100% rename from docs/plugins/tool-components.md rename to docs-src/plugins/tool-components.md diff --git a/docs/image-1.png b/docs/image-1.png deleted file mode 100644 index c7a0adc8..00000000 Binary files a/docs/image-1.png and /dev/null differ diff --git a/docs/image.png b/docs/image.png deleted file mode 100644 index 63416251..00000000 Binary files a/docs/image.png and /dev/null differ diff --git a/docs/model_configuration_guide.md b/docs/model_configuration_guide.md deleted file mode 100644 index f2da8be1..00000000 --- a/docs/model_configuration_guide.md +++ /dev/null @@ -1,336 +0,0 @@ -# 模型配置指南 - -本文档将指导您如何配置 `model_config.toml` 文件,该文件用于配置 MaiBot 的各种AI模型和API服务提供商。 - -## 配置文件结构 - -配置文件主要包含以下几个部分: -- 版本信息 -- API服务提供商配置 -- 模型配置 -- 模型任务配置 - -## 1. 版本信息 - -```toml -[inner] -version = "1.1.1" -``` - -用于标识配置文件的版本,遵循语义化版本规则。 - -## 2. API服务提供商配置 - -### 2.1 基本配置 - -使用 `[[api_providers]]` 数组配置多个API服务提供商: - -```toml -[[api_providers]] -name = "DeepSeek" # 服务商名称(自定义) -base_url = "https://api.deepseek.com/v1" # API服务的基础URL -api_key = "your-api-key-here" # API密钥 -client_type = "openai" # 客户端类型 -max_retry = 2 # 最大重试次数 -timeout = 30 # 超时时间(秒) -retry_interval = 10 # 重试间隔(秒) -``` - -### 2.2 配置参数说明 - -| 参数 | 必填 | 说明 | 默认值 | -|------|------|------|--------| -| `name` | ✅ | 服务商名称,需要在模型配置中引用 | - | -| `base_url` | ✅ | API服务的基础URL | - | -| `api_key` | ✅ | API密钥,请替换为实际密钥 | - | -| `client_type` | ❌ | 客户端类型:`openai`(OpenAI格式)或 `gemini`(Gemini格式) | `openai` | -| `max_retry` | ❌ | API调用失败时的最大重试次数 | 2 | -| `timeout` | ❌ | API请求超时时间(秒) | 30 | -| `retry_interval` | ❌ | 重试间隔时间(秒) | 10 | - -**请注意,对于`client_type`为`gemini`的模型,`retry`字段由`gemini`自己决定。** -### 2.3 支持的服务商示例 - -#### DeepSeek -```toml -[[api_providers]] -name = "DeepSeek" -base_url = "https://api.deepseek.com/v1" -api_key = "your-deepseek-api-key" -client_type = "openai" -``` - -#### SiliconFlow -```toml -[[api_providers]] -name = "SiliconFlow" -base_url = "https://api.siliconflow.cn/v1" -api_key = "your-siliconflow-api-key" -client_type = "openai" -``` - -#### Google Gemini -```toml -[[api_providers]] -name = "Google" -base_url = "https://generativelanguage.googleapis.com/v1beta" -api_key = "your-google-api-key" -client_type = "gemini" # 注意:Gemini需要使用特殊客户端 -``` - -## 3. 模型配置 - -### 3.1 基本模型配置 - -使用 `[[models]]` 数组配置多个模型: - -```toml -[[models]] -model_identifier = "deepseek-chat" # 模型在API服务商中的标识符 -name = "deepseek-v3" # 自定义模型名称 -api_provider = "DeepSeek" # 引用的API服务商名称 -price_in = 2.0 # 输入价格(元/M token) -price_out = 8.0 # 输出价格(元/M token) -``` - -### 3.2 高级模型配置 - -#### 强制流式输出 -对于不支持非流式输出的模型: -```toml -[[models]] -model_identifier = "some-model" -name = "custom-name" -api_provider = "Provider" -force_stream_mode = true # 启用强制流式输出 -``` - -#### 额外参数配置`extra_params` -```toml -[[models]] -model_identifier = "Qwen/Qwen3-8B" -name = "qwen3-8b" -api_provider = "SiliconFlow" -[models.extra_params] -enable_thinking = false # 禁用思考 -``` -这里的 `extra_params` 可以包含任何API服务商支持的额外参数配置,**配置时应参考相应的API文档**。 - -比如上面就是参考SiliconFlow的文档配置配置的`Qwen3`禁用思考参数。 - -![SiliconFlow文档截图](image-1.png) - -以豆包文档为另一个例子 - -![豆包文档截图](image.png) - -得到豆包`"doubao-seed-1-6-250615"`的禁用思考配置方法为 -```toml -[[models]] -# 你的模型 -[models.extra_params] -thinking = {type = "disabled"} # 禁用思考 -``` - -而对于`gemini`需要单独进行配置 -```toml -[[models]] -model_identifier = "gemini-2.5-flash" -name = "gemini-2.5-flash" -api_provider = "Google" -[models.extra_params] -thinking_budget = 0 # 禁用思考 -# thinking_budget = -1 由模型自己决定 -``` - -请注意,`extra_params` 的配置应该构成一个合法的TOML字典结构,具体内容取决于API服务商的要求。 - -### 3.3 配置参数说明 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `model_identifier` | ✅ | API服务商提供的模型标识符 | -| `name` | ✅ | 自定义模型名称,用于在任务配置中引用 | -| `api_provider` | ✅ | 对应的API服务商名称 | -| `price_in` | ❌ | 输入价格(元/M token),用于成本统计 | -| `price_out` | ❌ | 输出价格(元/M token),用于成本统计 | -| `force_stream_mode` | ❌ | 是否强制使用流式输出 | -| `extra_params` | ❌ | 额外的模型参数配置 | - -## 4. 模型任务配置 - -### utils - 工具模型 -用于表情包模块、取名模块、关系模块等核心功能: -```toml -[model_task_config.utils] -model_list = ["siliconflow-deepseek-v3"] -temperature = 0.2 -max_tokens = 800 -``` - -### utils_small - 小型工具模型 -用于高频率调用的场景,建议使用速度快的小模型: -```toml -[model_task_config.utils_small] -model_list = ["qwen3-8b"] -temperature = 0.7 -max_tokens = 800 -``` - -### replyer - 主要回复模型 -首要回复模型,也用于表达器和表达方式学习: -```toml -[model_task_config.replyer] -model_list = ["siliconflow-deepseek-v3"] -temperature = 0.2 -max_tokens = 800 -``` - -### planner - 决策模型 -负责决定MaiBot该做什么: -```toml -[model_task_config.planner] -model_list = ["siliconflow-deepseek-v3"] -temperature = 0.3 -max_tokens = 800 -``` - -### emotion - 情绪模型 -负责MaiBot的情绪变化: -```toml -[model_task_config.emotion] -model_list = ["siliconflow-deepseek-v3"] -temperature = 0.3 -max_tokens = 800 -``` - -### memory - 记忆模型 -```toml -[model_task_config.memory] -model_list = ["qwen3-30b"] -temperature = 0.7 -max_tokens = 800 -``` - -### vlm - 视觉语言模型 -用于图像识别: -```toml -[model_task_config.vlm] -model_list = ["qwen2.5-vl-72b"] -max_tokens = 800 -``` - -### voice - 语音识别模型 -```toml -[model_task_config.voice] -model_list = ["sensevoice-small"] -``` - -### embedding - 嵌入模型 -```toml -[model_task_config.embedding] -model_list = ["bge-m3"] -``` - -### tool_use - 工具调用模型 -需要使用支持工具调用的模型: -```toml -[model_task_config.tool_use] -model_list = ["qwen3-14b"] -temperature = 0.7 -max_tokens = 800 -``` - -### lpmm_entity_extract - 实体提取模型 -```toml -[model_task_config.lpmm_entity_extract] -model_list = ["siliconflow-deepseek-v3"] -temperature = 0.2 -max_tokens = 800 -``` - -### lpmm_rdf_build - RDF构建模型 -```toml -[model_task_config.lpmm_rdf_build] -model_list = ["siliconflow-deepseek-v3"] -temperature = 0.2 -max_tokens = 800 -``` - -### lpmm_qa - 问答模型 -```toml -[model_task_config.lpmm_qa] -model_list = ["deepseek-r1-distill-qwen-32b"] -temperature = 0.7 -max_tokens = 800 -``` - -## 5. 配置建议 - -### 5.1 Temperature 参数选择 - -| 任务类型 | 推荐温度 | 说明 | -|----------|----------|------| -| 精确任务(工具调用、实体提取) | 0.1-0.3 | 需要准确性和一致性 | -| 创意任务(对话、记忆) | 0.5-0.8 | 需要多样性和创造性 | -| 平衡任务(决策、情绪) | 0.3-0.5 | 平衡准确性和灵活性 | - -### 5.2 模型选择建议 - -| 任务类型 | 推荐模型类型 | 示例 | -|----------|--------------|------| -| 高精度任务 | 大模型 | DeepSeek-V3, GPT-4 | -| 高频率任务 | 小模型 | Qwen3-8B | -| 多模态任务 | 专用模型 | Qwen2.5-VL, SenseVoice | -| 工具调用 | 支持Function Call的模型 | Qwen3-14B | - -### 5.3 成本优化 - -1. **分层使用**:核心功能使用高质量模型,辅助功能使用经济模型 -2. **合理配置max_tokens**:根据实际需求设置,避免浪费 -3. **选择免费模型**:对于测试环境,优先使用price为0的模型 - -## 6. 配置验证 - -### 6.1 必要检查项 - -1. ✅ API密钥是否正确配置 -2. ✅ 模型标识符是否与API服务商提供的一致 -3. ✅ 任务配置中引用的模型名称是否在models中定义 -4. ✅ 多模态任务是否配置了对应的专用模型 - -### 6.2 测试配置 - -建议在正式使用前: -1. 使用少量测试数据验证配置 -2. 检查API调用是否正常 -3. 确认成本统计功能正常工作 - -## 7. 故障排除 - -### 7.1 常见问题 - -**问题1**: API调用失败 -- 检查API密钥是否正确 -- 确认base_url是否可访问 -- 检查模型标识符是否正确 - -**问题2**: 模型未找到 -- 确认模型名称在任务配置和模型定义中一致 -- 检查api_provider名称是否匹配 - -**问题3**: 响应异常 -- 检查温度参数是否合理(0-1之间) -- 确认max_tokens设置是否合适 -- 验证模型是否支持所需功能 - -### 7.2 日志查看 - -查看 `logs/` 目录下的日志文件,寻找相关错误信息。 - -## 8. 更新和维护 - -1. **定期更新**: 关注API服务商的模型更新,及时调整配置 -2. **性能监控**: 监控模型调用的成本和性能 -3. **备份配置**: 在修改前备份当前配置文件 - diff --git a/helm-chart/.gitlab-ci.yml b/helm-chart/.gitlab-ci.yml index e10b203a..ce6f25e5 100644 --- a/helm-chart/.gitlab-ci.yml +++ b/helm-chart/.gitlab-ci.yml @@ -1,7 +1,6 @@ stages: -# - initialize-maibot-git-repo - - build - - package + - build-image + - package-helm-chart # 仅在helm-chart分支运行 workflow: @@ -9,49 +8,15 @@ workflow: - if: '$CI_COMMIT_BRANCH == "helm-chart"' - when: never -## 查询并将麦麦仓库的工作区置为最后一个tag的版本 -#initialize-maibot-git-repo: -# stage: initialize-maibot-git-repo -# image: reg.mikumikumi.xyz/base/git:latest -# cache: -# key: git-repo -# policy: push -# paths: -# - target-repo/ -# script: -# - git clone https://github.com/Mai-with-u/MaiBot.git target-repo/ -# - cd target-repo/ -# - export MAIBOT_VERSION=$(git describe --tags --abbrev=0) -# - echo "Current version is ${MAIBOT_VERSION}" -# - git reset --hard ${MAIBOT_VERSION} -# - echo ${MAIBOT_VERSION} > MAIBOT_VERSION -# - git clone https://github.com/MaiM-with-u/maim_message maim_message -# - git clone https://github.com/MaiM-with-u/MaiMBot-LPMM.git MaiMBot-LPMM -# - ls -al -# -## 构建最后一个tag的麦麦本体的镜像 -#build-core: -# stage: build -# image: reg.mikumikumi.xyz/base/kaniko-builder:latest -# cache: -# key: git-repo -# policy: pull -# paths: -# - target-repo/ -# script: -# - cd target-repo/ -# - export BUILD_CONTEXT=$(pwd) -# - ls -al -# - export BUILD_DESTINATION="reg.mikumikumi.xyz/maibot/maibot:tag-$(cat MAIBOT_VERSION)" -# - build - -# 将Helm Chart版本作为tag,构建并推送镜像 +# 构建并推送adapter-cm-generator镜像 build-adapter-cm-generator: - stage: build + stage: build-image image: reg.mikumikumi.xyz/base/kaniko-builder:latest -# rules: -# - changes: -# - helm-chart/adapter-cm-generator/** + variables: + BUILD_NO_CACHE: true + rules: + - changes: + - helm-chart/adapter-cm-generator/** script: - export BUILD_CONTEXT=helm-chart/adapter-cm-generator - export TMP_DST=reg.mikumikumi.xyz/maibot/adapter-cm-generator @@ -60,16 +25,36 @@ build-adapter-cm-generator: - export BUILD_ARGS="--destination ${TMP_DST}:latest" - build +# 构建并推送core-webui-cm-sync镜像 +build-core-webui-cm-sync: + stage: build-image + image: reg.mikumikumi.xyz/base/kaniko-builder:latest + variables: + BUILD_NO_CACHE: true + rules: + - changes: + - helm-chart/core-webui-cm-sync/** + script: + - export BUILD_CONTEXT=helm-chart/core-webui-cm-sync + - export TMP_DST=reg.mikumikumi.xyz/maibot/core-webui-cm-sync + - export CHART_VERSION=$(cat helm-chart/Chart.yaml | grep '^version:' | cut -d' ' -f2) + - export BUILD_DESTINATION="${TMP_DST}:${CHART_VERSION}" + - export BUILD_ARGS="--destination ${TMP_DST}:latest" + - build + # 打包并推送helm chart package-helm-chart: - stage: package + stage: package-helm-chart image: reg.mikumikumi.xyz/mirror/helm:latest -# rules: -# - changes: -# - helm-chart/files/** -# - helm-chart/templates/** -# - helm-chart/Chart.yaml -# - helm-chart/values.yaml + rules: + - changes: + - helm-chart/files/** + - helm-chart/templates/** + - helm-chart/.gitignore + - helm-chart/.helmignore + - helm-chart/Chart.yaml + - helm-chart/README.md + - helm-chart/values.yaml script: - export CHART_VERSION=$(cat helm-chart/Chart.yaml | grep '^version:' | cut -d' ' -f2) - helm registry login reg.mikumikumi.xyz --username ${CI_REGISTRY_USER} --password ${CI_REGISTRY_PASSWORD} diff --git a/helm-chart/.helmignore b/helm-chart/.helmignore index 8cb2bba7..d4fe42ef 100644 --- a/helm-chart/.helmignore +++ b/helm-chart/.helmignore @@ -1,2 +1,3 @@ adapter-cm-generator +core-webui-cm-sync .gitlab-ci.yml \ No newline at end of file diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml index bf6f4ace..ea75ef79 100644 --- a/helm-chart/Chart.yaml +++ b/helm-chart/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: maibot description: "Maimai Bot, a cyber friend dedicated to group chats" type: application -version: 0.11.2-beta -appVersion: 0.11.2-beta +version: 0.11.5-beta +appVersion: 0.11.5-beta diff --git a/helm-chart/README.md b/helm-chart/README.md index c7f72686..edbb417c 100644 --- a/helm-chart/README.md +++ b/helm-chart/README.md @@ -10,6 +10,8 @@ | Helm Chart版本 | 对应的MaiBot版本 | Commit SHA | |----------------|--------------|------------------------------------------| +| 0.11.5-beta | 0.11.5-beta | ad2df627001f18996802f23c405b263e78af0d0f | +| 0.11.3-beta | 0.11.3-beta | cd6dc18f546f81e08803d3b8dba48e504dad9295 | | 0.11.2-beta | 0.11.2-beta | d3c8cea00dbb97f545350f2c3d5bcaf252443df2 | | 0.11.1-beta | 0.11.1-beta | 94e079a340a43dff8a2bc178706932937fc10b11 | | 0.11.0-beta | 0.11.0-beta | 16059532d8ef87ac28e2be0838ff8b3a34a91d0f | diff --git a/helm-chart/adapter-cm-generator/Dockerfile b/helm-chart/adapter-cm-generator/Dockerfile index 355b7b81..3149f0f5 100644 --- a/helm-chart/adapter-cm-generator/Dockerfile +++ b/helm-chart/adapter-cm-generator/Dockerfile @@ -5,6 +5,6 @@ WORKDIR /app COPY . /app -RUN pip3 install --no-cache-dir -i https://mirrors.ustc.edu.cn/pypi/simple -r requirements.txt +RUN pip3 install --no-cache-dir -r requirements.txt ENTRYPOINT ["python3", "adapter-cm-generator.py"] diff --git a/helm-chart/adapter-cm-generator/adapter-cm-generator.py b/helm-chart/adapter-cm-generator/adapter-cm-generator.py index e860aff3..95d7b65b 100644 --- a/helm-chart/adapter-cm-generator/adapter-cm-generator.py +++ b/helm-chart/adapter-cm-generator/adapter-cm-generator.py @@ -29,7 +29,7 @@ data['maibot_server']['host'] = f'{release_name}-maibot-core' # 根据release data['maibot_server']['port'] = 8000 # 创建/修改configmap -cm_name = f'{release_name}-maibot-adapter' +cm_name = f'{release_name}-maibot-adapter-config' cm = client.V1ConfigMap( metadata=client.V1ObjectMeta(name=cm_name), data={'config.toml': toml.dumps(data)} diff --git a/helm-chart/core-webui-cm-sync/Dockerfile b/helm-chart/core-webui-cm-sync/Dockerfile new file mode 100644 index 00000000..55a8c25b --- /dev/null +++ b/helm-chart/core-webui-cm-sync/Dockerfile @@ -0,0 +1,10 @@ +# 此镜像用于辅助麦麦的WebUI更新配置文件,随core容器持续运行 +FROM python:3.13-slim + +WORKDIR /MaiMBot + +COPY . /MaiMBot + +RUN pip3 install --no-cache-dir -r requirements.txt + +ENTRYPOINT ["python3", "core-webui-cm-sync.py"] diff --git a/helm-chart/core-webui-cm-sync/core-webui-cm-sync.py b/helm-chart/core-webui-cm-sync/core-webui-cm-sync.py new file mode 100644 index 00000000..e179d6f6 --- /dev/null +++ b/helm-chart/core-webui-cm-sync/core-webui-cm-sync.py @@ -0,0 +1,92 @@ +#!/bin/python3 +# 这个程序的作用是辅助麦麦的WebUI更新配置文件,随core容器持续运行。 +# 麦麦的配置文件存储于ConfigMap中,挂载进core容器后属于只读文件,无法直接修改。 +# 此程序将core容器内的配置文件替换为可读写的中间层临时文件。启动时将实际配置文件写入,并在后台持续检测文件变化,实时同步到k8s apiServer,反向修改ConfigMap。 +# 工作目录:/MaiMBot/webui-cm-sync + +import os +import time +from datetime import datetime +from kubernetes import client, config +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler + +work_dir = '/MaiMBot/webui-cm-sync' +os.chdir(work_dir) + +config.load_incluster_config() +core_api = client.CoreV1Api() +with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "r") as f: + namespace = f.read().strip() +release_name = os.getenv("RELEASE_NAME") +model_configmap_name = f'{release_name}-maibot-core-model-config' +bot_configmap_name = f'{release_name}-maibot-core-bot-config' + +# 过滤列表,只监控指定文件 +target_files = { + os.path.abspath("model_config.toml"): (model_configmap_name, "model_config.toml"), + os.path.abspath("bot_config.toml"): (bot_configmap_name, "bot_config.toml") +} + + +def get_configmap(configmap_name: str): + """获取core的ConfigMap内容""" + cm = core_api.read_namespaced_config_map(name=configmap_name, namespace=namespace) + return cm.data + + +def set_configmap(configmap_name: str, configmap_data: dict[str, str]): + """设置core的ConfigMap内容""" + core_api.patch_namespaced_config_map(configmap_name, namespace, {'data': configmap_data}) + + +class ConfigObserverHandler(FileSystemEventHandler): + """配置文件变化的事件处理器""" + def on_modified(self, event): + if os.path.abspath(event.src_path) in target_files: + print(f'[{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}] File `{event.src_path}` was modified. ' + f'Start to sync...') + with open(event.src_path, "r", encoding="utf-8") as _f: + current_data = _f.read() + _path = str(os.path.abspath(event.src_path)) + new_cm = { + target_files[_path][1]: current_data + } + try: + set_configmap(target_files[_path][0], new_cm) + print(f'\tSync done.') + except client.exceptions.ApiException as _e: + print(f'\tError while setting configmap:\n' + f'\t\tStatus Code: {_e.status}\n' + f'\t\tReason: {_e.reason}') + + +if __name__ == '__main__': + # 初始化配置文件 + print(f'[{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}] Initializing config files...') + try: + __initial_model_config = get_configmap(model_configmap_name)['model_config.toml'] + __initial_bot_config = get_configmap(bot_configmap_name)['bot_config.toml'] + except client.exceptions.ApiException as e: + print(f'\tError while getting configmap:\n' + f'\t\tStatus Code: {e.status}\n' + f'\t\tReason: {e.reason}') + exit(1) + with open('model_config.toml', 'w') as f: + f.write(__initial_model_config) + with open('bot_config.toml', 'w') as f: + f.write(__initial_bot_config) + with open('ready', 'w') as f: + f.write('true') + print(f'[{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}] Initializing done. Ready to sync.') + + # 持续检测变化并同步 + observer = Observer() + observer.schedule(ConfigObserverHandler(), work_dir, recursive=False) + observer.start() + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + observer.stop() + observer.join() diff --git a/helm-chart/core-webui-cm-sync/requirements.txt b/helm-chart/core-webui-cm-sync/requirements.txt new file mode 100644 index 00000000..58c73d28 --- /dev/null +++ b/helm-chart/core-webui-cm-sync/requirements.txt @@ -0,0 +1,3 @@ +toml~=0.10.2 +kubernetes~=34.1.0 +watchdog~=6.0.0 diff --git a/helm-chart/files/k8s-init.sh b/helm-chart/files/k8s-init.sh new file mode 100644 index 00000000..3729a68f --- /dev/null +++ b/helm-chart/files/k8s-init.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# 此脚本用于覆盖core容器的默认启动命令,进行一些初始化 +# 1 +# 由于k8s与docker-compose的卷挂载方式有所不同,需要利用此脚本为一些文件和目录提前创建好软链接 +# /MaiMBot/data是麦麦数据的实际挂载路径 +# /MaiMBot/statistics是统计数据的实际挂载路径 +# 2 +# 此脚本等待辅助容器webui-cm-sync就绪后再启动麦麦 +# 通过检测/MaiMBot/webui-cm-sync/ready文件来判断 + +set -e +echo "[K8s Init] Preparing volume..." + +# 初次启动,在存储卷中检查并创建关键文件和目录 +mkdir -p /MaiMBot/data/plugins +mkdir -p /MaiMBot/data/logs +if [ ! -d "/MaiMBot/statistics" ] +then + echo "[K8s Init] Statistics volume is disabled." +else + touch /MaiMBot/statistics/index.html +fi + +# 删除默认插件目录,准备创建用户插件目录软链接 +rm -rf /MaiMBot/plugins + +# 创建软链接,从存储卷链接到实际位置 +ln -s /MaiMBot/data/plugins /MaiMBot/plugins +ln -s /MaiMBot/data/logs /MaiMBot/logs +if [ -f "/MaiMBot/statistics/index.html" ] +then + ln -s /MaiMBot/statistics/index.html /MaiMBot/maibot_statistics.html +fi + +echo "[K8s Init] Volume ready." + +# 如果启用了WebUI,则等待辅助容器webui-cm-sync就绪,然后创建中间层配置文件软链接 +if [ "$MAIBOT_WEBUI_ENABLED" = "true" ] +then + echo "[K8s Init] WebUI enabled. Waiting for container 'webui-cm-sync' ready..." + while [ ! -f /MaiMBot/webui-cm-sync/ready ]; do + sleep 1 + done + echo "[K8s Init] Container 'webui-cm-sync' ready." + mkdir -p /MaiMBot/config + ln -s /MaiMBot/webui-cm-sync/model_config.toml /MaiMBot/config/model_config.toml + ln -s /MaiMBot/webui-cm-sync/bot_config.toml /MaiMBot/config/bot_config.toml + echo "[K8s Init] Config files middle layer for WebUI created." +else + echo "[K8s Init] WebUI is disabled." +fi + +# 启动麦麦 +echo "[K8s Init] Waking up MaiBot..." +echo +exec python bot.py diff --git a/helm-chart/files/volume-linker.sh b/helm-chart/files/volume-linker.sh deleted file mode 100644 index 4a371e3c..00000000 --- a/helm-chart/files/volume-linker.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/sh -# 此脚本用于覆盖core容器的默认启动命令 -# 由于k8s与docker-compose的卷挂载方式有所不同,需要利用此脚本为一些文件和目录提前创建好软链接 -# /MaiMBot/data是麦麦数据的实际挂载路径 -# /MaiMBot/statistics是统计数据的实际挂载路径 - -set -e -echo "[VolumeLinker] Preparing volume..." - -# 初次启动,在存储卷中检查并创建关键文件和目录 -mkdir -p /MaiMBot/data/plugins -mkdir -p /MaiMBot/data/logs -if [ ! -d "/MaiMBot/statistics" ] -then - echo "[VolumeLinker] Statistics volume disabled." -else - touch /MaiMBot/statistics/index.html -fi - -# 删除空的插件目录,准备创建软链接 -rm -rf /MaiMBot/plugins - -# 创建软链接,从存储卷链接到实际位置 -ln -s /MaiMBot/data/plugins /MaiMBot/plugins -ln -s /MaiMBot/data/logs /MaiMBot/logs -if [ -f "/MaiMBot/statistics/index.html" ] -then - ln -s /MaiMBot/statistics/index.html /MaiMBot/maibot_statistics.html -fi - -# 启动麦麦 -echo "[VolumeLinker] Starting MaiBot..." -exec python bot.py diff --git a/helm-chart/templates/adapter/statefulset.yaml b/helm-chart/templates/adapter/statefulset.yaml index 262891b4..0d9b76da 100644 --- a/helm-chart/templates/adapter/statefulset.yaml +++ b/helm-chart/templates/adapter/statefulset.yaml @@ -58,5 +58,5 @@ spec: items: - key: config.toml path: config.toml - name: {{ .Release.Name }}-maibot-adapter + name: {{ .Release.Name }}-maibot-adapter-config name: config diff --git a/helm-chart/templates/core/configmap-bot-config.yaml b/helm-chart/templates/core/configmap-bot-config.yaml new file mode 100644 index 00000000..d1395296 --- /dev/null +++ b/helm-chart/templates/core/configmap-bot-config.yaml @@ -0,0 +1,14 @@ +{{- if or .Release.IsInstall (and .Values.core.webui.enabled .Values.config.enable_config_override_with_webui) (and (not .Values.core.webui.enabled) .Values.config.enable_config_override_without_webui) }} +# 渲染规则: +# 初次安装,或配置了覆盖规则 +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-maibot-core-bot-config + namespace: {{ .Release.Namespace }} + annotations: + "helm.sh/resource-policy": keep +data: + bot_config.toml: | + {{ .Values.config.core_bot_config | nindent 4 }} +{{- end }} diff --git a/helm-chart/templates/core/configmap-env-config.yaml b/helm-chart/templates/core/configmap-env-config.yaml new file mode 100644 index 00000000..17b1d627 --- /dev/null +++ b/helm-chart/templates/core/configmap-env-config.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-maibot-core-env-config + namespace: {{ .Release.Namespace }} +data: + .env: | + HOST=0.0.0.0 + PORT=8000 + WEBUI_ENABLED={{ if .Values.core.webui.enabled }}true{{ else }}false{{ end }} + WEBUI_MODE=production + WEBUI_HOST=0.0.0.0 + WEBUI_PORT=8001 diff --git a/helm-chart/templates/core/configmap-model-config.yaml b/helm-chart/templates/core/configmap-model-config.yaml new file mode 100644 index 00000000..bf122244 --- /dev/null +++ b/helm-chart/templates/core/configmap-model-config.yaml @@ -0,0 +1,14 @@ +{{- if or .Release.IsInstall (and .Values.core.webui.enabled .Values.config.enable_config_override_with_webui) (and (not .Values.core.webui.enabled) .Values.config.enable_config_override_without_webui) }} +# 渲染规则: +# 初次安装,或配置了覆盖规则 +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-maibot-core-model-config + namespace: {{ .Release.Namespace }} + annotations: + "helm.sh/resource-policy": keep +data: + model_config.toml: | + {{ .Values.config.core_model_config | nindent 4 }} +{{- end }} diff --git a/helm-chart/templates/core/configmap.yaml b/helm-chart/templates/core/configmap.yaml deleted file mode 100644 index 25f0ef2d..00000000 --- a/helm-chart/templates/core/configmap.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ .Release.Name }}-maibot-core - namespace: {{ .Release.Namespace }} -data: - .env: | - HOST=0.0.0.0 - PORT=8000 - model_config.toml: | - {{ .Values.config.core_model_config | nindent 4 }} - bot_config.toml: | - {{ .Values.config.core_bot_config | nindent 4 }} diff --git a/helm-chart/templates/core/ingress.yaml b/helm-chart/templates/core/ingress.yaml new file mode 100644 index 00000000..203626d6 --- /dev/null +++ b/helm-chart/templates/core/ingress.yaml @@ -0,0 +1,26 @@ +{{- if and .Values.core.webui.enabled .Values.core.webui.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ .Release.Name }}-maibot-webui + namespace: {{ .Release.Namespace }} + {{- if .Values.core.webui.ingress.annotations }} + annotations: + {{ toYaml .Values.core.webui.ingress.annotations | nindent 4 }} + {{- end }} + labels: + app: {{ .Release.Name }}-maibot-core +spec: + ingressClassName: {{ .Values.core.webui.ingress.className }} + rules: + - host: {{ .Values.core.webui.ingress.host }} + http: + paths: + - backend: + service: + name: {{ .Release.Name }}-maibot-core + port: + number: {{ .Values.core.webui.service.port }} + path: {{ .Values.core.webui.ingress.path }} + pathType: {{ .Values.core.webui.ingress.pathType }} +{{- end }} diff --git a/helm-chart/templates/core/service.yaml b/helm-chart/templates/core/service.yaml index c236e746..5b67de50 100644 --- a/helm-chart/templates/core/service.yaml +++ b/helm-chart/templates/core/service.yaml @@ -11,6 +11,15 @@ spec: port: 8000 protocol: TCP targetPort: 8000 + {{- if .Values.core.webui.enabled }} + - name: webui + port: {{ .Values.core.webui.service.port }} + protocol: TCP + targetPort: 8001 + {{- if eq .Values.core.webui.service.type "NodePort" }} + nodePort: {{ .Values.core.webui.service.nodePort | default nil }} + {{- end }} + {{- end }} selector: app: {{ .Release.Name }}-maibot-core type: ClusterIP diff --git a/helm-chart/templates/core/statefulset.yaml b/helm-chart/templates/core/statefulset.yaml index ca12dca0..2bc8d14c 100644 --- a/helm-chart/templates/core/statefulset.yaml +++ b/helm-chart/templates/core/statefulset.yaml @@ -18,23 +18,32 @@ spec: spec: containers: - name: core - command: # 为了在k8s中初始化存储卷,这里替换启动命令为指定脚本 + command: # 为了在k8s中初始化,这里替换启动命令为指定脚本 - sh args: - - /MaiMBot/volume-linker.sh + - /MaiMBot/k8s-init.sh env: - name: TZ - value: Asia/Shanghai + value: "Asia/Shanghai" - name: EULA_AGREE - value: 99f08e0cab0190de853cb6af7d64d4de + value: "99f08e0cab0190de853cb6af7d64d4de" - name: PRIVACY_AGREE - value: 9943b855e72199d0f5016ea39052f1b6 - image: {{ .Values.core.image.repository | default "sengokucola/maibot" }}:{{ .Values.core.image.tag | default "0.11.2-beta" }} + value: "9943b855e72199d0f5016ea39052f1b6" + {{- if .Values.core.webui.enabled }} + - name: MAIBOT_WEBUI_ENABLED + value: "true" + {{- end}} + image: {{ .Values.core.image.repository | default "sengokucola/maibot" }}:{{ .Values.core.image.tag | default "0.11.5-beta" }} imagePullPolicy: {{ .Values.core.image.pullPolicy }} ports: - containerPort: 8000 name: adapter-ws protocol: TCP + {{- if .Values.core.webui.enabled }} + - containerPort: 8001 + name: webui + protocol: TCP + {{- end }} {{- if .Values.core.resources }} resources: {{ toYaml .Values.core.resources | nindent 12 }} @@ -42,26 +51,45 @@ spec: volumeMounts: - mountPath: /MaiMBot/data name: data - - mountPath: /MaiMBot/volume-linker.sh + - mountPath: /MaiMBot/k8s-init.sh name: scripts readOnly: true - subPath: volume-linker.sh + subPath: k8s-init.sh - mountPath: /MaiMBot/.env - name: config + name: env-config readOnly: true subPath: .env + {{- if not .Values.core.webui.enabled }} - mountPath: /MaiMBot/config/model_config.toml - name: config + name: model-config readOnly: true subPath: model_config.toml - mountPath: /MaiMBot/config/bot_config.toml - name: config + name: bot-config readOnly: true subPath: bot_config.toml + {{- end }} {{- if .Values.statistics_dashboard.enabled }} - mountPath: /MaiMBot/statistics name: statistics {{- end }} + {{- if .Values.core.webui.enabled }} + - mountPath: /MaiMBot/webui-cm-sync + name: webui-cm-sync + {{- end }} + {{- if .Values.core.webui.enabled }} + - name: webui-cm-sync + image: {{ .Values.core.webui.cm_sync.image.repository | default "reg.mikumikumi.xyz/maibot/core-webui-cm-sync" }}:{{ .Values.core.webui.cm_sync.image.tag | default "0.11.5-beta" }} + imagePullPolicy: {{ .Values.core.webui.cm_sync.image.pullPolicy }} + env: + - name: PYTHONUNBUFFERED + value: "1" + - name: RELEASE_NAME + value: {{ .Release.Name }} + volumeMounts: + - mountPath: /MaiMBot/webui-cm-sync + name: webui-cm-sync + {{- end }} {{- if .Values.core.setup_default_plugins }} initContainers: # 用户插件目录存储在存储卷中,会在启动时覆盖掉容器的默认插件目录。此初始化容器用于默认插件更新后或麦麦首次启动时为用户自动安装默认插件到存储卷中 - args: @@ -69,7 +97,7 @@ spec: command: - python3 workingDir: /MaiMBot - image: {{ .Values.core.image.repository | default "sengokucola/maibot" }}:{{ .Values.core.image.tag | default "0.11.2-beta" }} + image: {{ .Values.core.image.repository | default "sengokucola/maibot" }}:{{ .Values.core.image.tag | default "0.11.5-beta" }} imagePullPolicy: {{ .Values.core.image.pullPolicy }} name: setup-plugins resources: { } @@ -81,6 +109,7 @@ spec: readOnly: true subPath: setup-plugins.py {{- end }} + serviceAccountName: {{ .Release.Name }}-maibot-sa {{- if .Values.core.image.pullSecrets }} imagePullSecrets: {{ toYaml .Values.core.image.pullSecrets | nindent 8 }} @@ -99,8 +128,8 @@ spec: claimName: {{ .Release.Name }}-maibot-core - configMap: items: - - key: volume-linker.sh - path: volume-linker.sh + - key: k8s-init.sh + path: k8s-init.sh {{- if .Values.core.setup_default_plugins }} - key: setup-plugins.py path: setup-plugins.py @@ -111,14 +140,28 @@ spec: items: - key: .env path: .env + name: {{ .Release.Name }}-maibot-core-env-config + name: env-config + {{- if not .Values.core.webui.enabled }} + - configMap: + items: - key: model_config.toml path: model_config.toml + name: {{ .Release.Name }}-maibot-core-model-config + name: model-config + - configMap: + items: - key: bot_config.toml path: bot_config.toml - name: {{ .Release.Name }}-maibot-core - name: config + name: {{ .Release.Name }}-maibot-core-bot-config + name: bot-config + {{- end }} {{- if .Values.statistics_dashboard.enabled }} - name: statistics persistentVolumeClaim: claimName: {{ .Release.Name }}-maibot-statistics-dashboard {{- end }} + {{- if .Values.core.webui.enabled }} + - emptyDir: {} + name: webui-cm-sync + {{- end }} diff --git a/helm-chart/templates/napcat/statefulset.yaml b/helm-chart/templates/napcat/statefulset.yaml index a6dd9771..7cca56df 100644 --- a/helm-chart/templates/napcat/statefulset.yaml +++ b/helm-chart/templates/napcat/statefulset.yaml @@ -26,7 +26,7 @@ spec: value: "{{ .Values.napcat.permission.uid }}" - name: TZ value: Asia/Shanghai - image: {{ .Values.napcat.image.repository | default "mlikiowa/napcat-docker" }}:{{ .Values.napcat.image.tag | default "v4.9.70" }} + image: {{ .Values.napcat.image.repository | default "mlikiowa/napcat-docker" }}:{{ .Values.napcat.image.tag | default "v4.9.73" }} imagePullPolicy: {{ .Values.napcat.image.pullPolicy }} livenessProbe: failureThreshold: 3 diff --git a/helm-chart/templates/other/configmap-scripts.yaml b/helm-chart/templates/other/configmap-scripts.yaml index 4d1e546f..dd61ca68 100644 --- a/helm-chart/templates/other/configmap-scripts.yaml +++ b/helm-chart/templates/other/configmap-scripts.yaml @@ -5,8 +5,8 @@ metadata: namespace: {{ .Release.Namespace }} data: # core - volume-linker.sh: | - {{ .Files.Get "files/volume-linker.sh" | nindent 4 }} + k8s-init.sh: | + {{ .Files.Get "files/k8s-init.sh" | nindent 4 }} # core的初始化容器 {{- if .Values.core.setup_default_plugins }} setup-plugins.py: | diff --git a/helm-chart/templates/job-post-install/job-gen-adapter-cm.yaml b/helm-chart/templates/other/job-gen-adapter-cm.yaml similarity index 88% rename from helm-chart/templates/job-post-install/job-gen-adapter-cm.yaml rename to helm-chart/templates/other/job-gen-adapter-cm.yaml index 4a3a30e7..ad01b568 100644 --- a/helm-chart/templates/job-post-install/job-gen-adapter-cm.yaml +++ b/helm-chart/templates/other/job-gen-adapter-cm.yaml @@ -11,11 +11,11 @@ spec: backoffLimit: 2 template: spec: - serviceAccountName: {{ .Release.Name }}-maibot-adapter-cm-generator + serviceAccountName: {{ .Release.Name }}-maibot-sa restartPolicy: Never containers: - name: adapter-cm-generator - image: {{ .Values.adapter.cm_generator.image.repository | default "reg.mikumikumi.xyz/maibot/adapter-cm-generator" }}:{{ .Values.adapter.cm_generator.image.tag | default "0.11.2-beta" }} + image: {{ .Values.adapter.cm_generator.image.repository | default "reg.mikumikumi.xyz/maibot/adapter-cm-generator" }}:{{ .Values.adapter.cm_generator.image.tag | default "0.11.5-beta" }} workingDir: /app env: - name: PYTHONUNBUFFERED diff --git a/helm-chart/templates/job-post-install/rbac-gen-adapter-cm.yaml b/helm-chart/templates/other/rbac.yaml similarity index 64% rename from helm-chart/templates/job-post-install/rbac-gen-adapter-cm.yaml rename to helm-chart/templates/other/rbac.yaml index 1df08343..8b8075c7 100644 --- a/helm-chart/templates/job-post-install/rbac-gen-adapter-cm.yaml +++ b/helm-chart/templates/other/rbac.yaml @@ -1,14 +1,14 @@ -# 动态生成adapter配置文件的configmap所需要的rbac授权 +# 初始化及反向修改ConfigMap所需要的rbac授权 apiVersion: v1 kind: ServiceAccount metadata: - name: {{ .Release.Name }}-maibot-adapter-cm-generator + name: {{ .Release.Name }}-maibot-sa namespace: {{ .Release.Namespace }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: - name: {{ .Release.Name }}-maibot-adapter-cm-gen-role + name: {{ .Release.Name }}-maibot-role namespace: {{ .Release.Namespace }} rules: - apiGroups: [""] @@ -21,13 +21,13 @@ rules: apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: - name: {{ .Release.Name }}-maibot-adapter-cm-gen-role-binding + name: {{ .Release.Name }}-maibot-rolebinding namespace: {{ .Release.Namespace }} subjects: - kind: ServiceAccount - name: {{ .Release.Name }}-maibot-adapter-cm-generator + name: {{ .Release.Name }}-maibot-sa namespace: {{ .Release.Namespace }} roleRef: kind: Role - name: {{ .Release.Name }}-maibot-adapter-cm-gen-role + name: {{ .Release.Name }}-maibot-role apiGroup: rbac.authorization.k8s.io diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index 6cbd9fb1..dacb8c3d 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -38,7 +38,7 @@ adapter: cm_generator: image: repository: # 默认 reg.mikumikumi.xyz/maibot/adapter-cm-generator - tag: # 默认 0.11.2-beta + tag: # 默认 0.11.5-beta pullPolicy: IfNotPresent pullSecrets: [ ] @@ -48,7 +48,7 @@ core: image: repository: # 默认 sengokucola/maibot - tag: # 默认 0.11.2-beta + tag: # 默认 0.11.5-beta pullPolicy: IfNotPresent pullSecrets: [ ] @@ -65,6 +65,28 @@ core: setup_default_plugins: true # 启用一个初始化容器,用于为用户自动安装默认插件到存储卷中 + webui: # WebUI相关配置 + # 对从helm chart 0.11.5-beta 之前的旧版升级的用户的重要提示: + # 旧版本helm chart没有配置configmap的保留策略,直接升级同时开启WebUI后会导致configmap丢失从而无法启动。 + # 这种情况可以先禁用WebUI更新一次,再启用WebUI更新一次即可解决问题。 + enabled: true # 默认启用 + cm_sync: # WebUI的辅助容器配置 + image: + repository: # 默认 reg.mikumikumi.xyz/maibot/core-webui-cm-sync + tag: # 默认 0.11.5-beta + pullPolicy: IfNotPresent + service: + type: ClusterIP # ClusterIP / NodePort 指定NodePort可以将内网的服务端口映射到物理节点的端口 + port: 8001 # 服务端口 + nodePort: # 仅在设置NodePort类型时有效,不指定则会随机分配端口 + ingress: + enabled: false + className: nginx + annotations: { } + host: maim.example.com # 访问麦麦WebUI的域名 + path: / + pathType: Prefix + # 麦麦的运行统计看板配置 # 麦麦每隔一段时间会自动输出html格式的运行统计报告,此统计报告可以作为静态网页访问 # 此功能默认禁用。如果你认为报告可以被公开访问(报告包含联系人/群组名称、模型token花费信息等),则可以启用此功能 @@ -117,7 +139,7 @@ napcat: image: repository: # 默认 mlikiowa/napcat-docker - tag: # 默认 v4.9.70 + tag: # 默认 v4.9.73 pullPolicy: IfNotPresent pullSecrets: [ ] @@ -189,9 +211,16 @@ sqlite_web: path: / pathType: Prefix -# 麦麦各部分组件的运行配置文件 +# 手动设置麦麦各部分组件的运行配置文件 config: + # 启用WebUI后,配置文件的修改即可在WebUI进行。如果通过WebUI修改了配置,则实际的配置文件将与values中的配置存在差异。 + # 如果用户在k8s的ConfigMap中手动修改了配置文件,则实际的配置文件也会与values中的配置存在差异。 + # 出现上述两种情况时,为了避免helm升级麦麦时,下面values中的配置覆盖掉已有的配置文件而导致配置丢失,可以在这里禁止本次部署时的配置覆盖。 + # 注:由于adapter的配置无法通过WebUI修改,因此下面的adapter_config配置仍然会覆盖已有配置文件。 + enable_config_override_without_webui: true # 未启用WebUI时,默认覆盖 + enable_config_override_with_webui: false # 启用WebUI时,默认不覆盖 + # adapter的config.toml adapter_config: | [inner] @@ -227,7 +256,7 @@ config: # core的model_config.toml core_model_config: | [inner] - version = "1.7.7" + version = "1.7.8" # 配置文件版本号迭代规则同bot_config.toml @@ -388,7 +417,7 @@ config: # core的bot_config.toml core_bot_config: | [inner] - version = "6.21.4" + version = "6.21.8" #----以下是给开发人员阅读的,如果你只是部署了麦麦,不需要阅读---- #如果你想要修改配置文件,请递增version的值 @@ -493,7 +522,7 @@ config: include_planner_reasoning = false # 是否将planner推理加入replyer,默认关闭(不加入) [memory] - max_agent_iterations = 5 # 记忆思考深度(最低为1(不深入思考)) + max_agent_iterations = 3 # 记忆思考深度(最低为1(不深入思考)) [jargon] all_global = true # 是否开启全局黑话模式,注意,此功能关闭后,已经记录的全局黑话不会改变,需要手动删除 @@ -600,6 +629,9 @@ config: show_replyer_prompt = false # 是否显示回复器prompt show_replyer_reasoning = false # 是否显示回复器推理 show_jargon_prompt = false # 是否显示jargon相关提示词 + show_memory_prompt = false # 是否显示记忆检索相关提示词 + show_planner_prompt = false # 是否显示planner的prompt和原始返回结果 + show_lpmm_paragraph = false # 是否显示lpmm找到的相关文段日志 [maim_message] auth_token = [] # 认证令牌,用于API验证,为空则不启用验证 diff --git a/scripts/build_io_pairs.py b/scripts/build_io_pairs.py index f934566a..944d7671 100644 --- a/scripts/build_io_pairs.py +++ b/scripts/build_io_pairs.py @@ -16,8 +16,6 @@ if PROJECT_ROOT not in sys.path: sys.path.insert(0, PROJECT_ROOT) - - SECONDS_5_MINUTES = 5 * 60 diff --git a/scripts/expression_scatter_analysis.py b/scripts/expression_scatter_analysis.py index b022c94e..3cb22f71 100644 --- a/scripts/expression_scatter_analysis.py +++ b/scripts/expression_scatter_analysis.py @@ -12,7 +12,6 @@ project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, project_root) - # 设置中文字体 plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "DejaVu Sans"] plt.rcParams["axes.unicode_minus"] = False diff --git a/scripts/mmipkg_tool.py b/scripts/mmipkg_tool.py index 6f14ae5b..3b5369ac 100644 --- a/scripts/mmipkg_tool.py +++ b/scripts/mmipkg_tool.py @@ -57,8 +57,8 @@ from src.common.database.database import db from src.common.database.database_model import Emoji # 常量定义 -MAGIC = b'MMIP' -FOOTER_MAGIC = b'MMFF' +MAGIC = b"MMIP" +FOOTER_MAGIC = b"MMFF" VERSION = 1 FOOTER_VERSION = 1 @@ -67,7 +67,7 @@ MAX_MANIFEST_SIZE = 200 * 1024 * 1024 # 200 MB MAX_PAYLOAD_SIZE = 10 * 1024 * 1024 * 1024 # 10 GB # 支持的图片格式 -SUPPORTED_FORMATS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.bmp'} +SUPPORTED_FORMATS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp"} # 创建控制台对象 console = Console() @@ -75,6 +75,7 @@ console = Console() class MMIPKGError(Exception): """MMIPKG 相关错误""" + pass @@ -97,56 +98,56 @@ def get_image_info(file_path: str) -> Tuple[int, int, str]: try: with Image.open(file_path) as img: width, height = img.size - format_lower = img.format.lower() if img.format else 'unknown' + format_lower = img.format.lower() if img.format else "unknown" mime_map = { - 'jpeg': 'image/jpeg', - 'jpg': 'image/jpeg', - 'png': 'image/png', - 'gif': 'image/gif', - 'webp': 'image/webp', - 'avif': 'image/avif', - 'bmp': 'image/bmp' + "jpeg": "image/jpeg", + "jpg": "image/jpeg", + "png": "image/png", + "gif": "image/gif", + "webp": "image/webp", + "avif": "image/avif", + "bmp": "image/bmp", } - mime_type = mime_map.get(format_lower, f'image/{format_lower}') + mime_type = mime_map.get(format_lower, f"image/{format_lower}") return width, height, mime_type except Exception as e: print(f"警告: 无法读取图片信息 {file_path}: {e}") - return 0, 0, 'image/unknown' + return 0, 0, "image/unknown" -def reencode_image(file_path: str, output_format: str = 'webp', quality: int = 80) -> bytes: +def reencode_image(file_path: str, output_format: str = "webp", quality: int = 80) -> bytes: """重新编码图片""" try: with Image.open(file_path) as img: # 转换为 RGB(如果需要) - if img.mode in ('RGBA', 'LA', 'P'): - if output_format.lower() == 'jpeg': + if img.mode in ("RGBA", "LA", "P"): + if output_format.lower() == "jpeg": # JPEG 不支持透明度,转为白色背景 - background = Image.new('RGB', img.size, (255, 255, 255)) - if img.mode == 'P': - img = img.convert('RGBA') - background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + background = Image.new("RGB", img.size, (255, 255, 255)) + if img.mode == "P": + img = img.convert("RGBA") + background.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None) img = background - elif output_format.lower() == 'webp': + elif output_format.lower() == "webp": # WebP 支持透明度 - if img.mode == 'P': - img = img.convert('RGBA') - elif img.mode not in ('RGB', 'RGBA'): - img = img.convert('RGB') - + if img.mode == "P": + img = img.convert("RGBA") + elif img.mode not in ("RGB", "RGBA"): + img = img.convert("RGB") + # 编码图片 output = io.BytesIO() - save_kwargs = {'format': output_format.upper()} - - if output_format.lower() in {'jpeg', 'jpg'}: - save_kwargs['quality'] = quality - save_kwargs['optimize'] = True - elif output_format.lower() == 'webp': - save_kwargs['quality'] = quality - save_kwargs['method'] = 6 # 更好的压缩 - elif output_format.lower() == 'png': - save_kwargs['optimize'] = True - + save_kwargs = {"format": output_format.upper()} + + if output_format.lower() in {"jpeg", "jpg"}: + save_kwargs["quality"] = quality + save_kwargs["optimize"] = True + elif output_format.lower() == "webp": + save_kwargs["quality"] = quality + save_kwargs["method"] = 6 # 更好的压缩 + elif output_format.lower() == "png": + save_kwargs["optimize"] = True + img.save(output, **save_kwargs) return output.getvalue() except Exception as e: @@ -155,25 +156,28 @@ def reencode_image(file_path: str, output_format: str = 'webp', quality: int = 8 class MMIPKGPacker: """MMIPKG 打包器""" - - def __init__(self, - use_compression: bool = True, - zstd_level: int = 3, - reencode: Optional[str] = None, - reencode_quality: int = 80): + + def __init__( + self, + use_compression: bool = True, + zstd_level: int = 3, + reencode: Optional[str] = None, + reencode_quality: int = 80, + ): self.use_compression = use_compression and zstd is not None self.zstd_level = zstd_level self.reencode = reencode self.reencode_quality = reencode_quality - + if use_compression and zstd is None: print("警告: zstandard 未安装,将不使用压缩") self.use_compression = False - - def pack_from_db(self, output_path: str, pack_name: Optional[str] = None, - custom_manifest: Optional[Dict] = None) -> bool: + + def pack_from_db( + self, output_path: str, pack_name: Optional[str] = None, custom_manifest: Optional[Dict] = None + ) -> bool: """从数据库导出已注册的表情包 - + Args: output_path: 输出文件路径 pack_name: 包名称 @@ -183,21 +187,21 @@ class MMIPKGPacker: # 连接数据库 if db.is_closed(): db.connect() - + # 查询所有已注册的表情包 emojis = Emoji.select().where(Emoji.is_registered) emoji_count = emojis.count() - + if emoji_count == 0: print("错误: 数据库中没有已注册的表情包") return False - + print(f"找到 {emoji_count} 个已注册的表情包") - + # 准备 items items = [] image_data_list = [] - + # 使用进度条处理表情包 with Progress( SpinnerColumn(), @@ -205,37 +209,39 @@ class MMIPKGPacker: BarColumn(), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), TimeElapsedColumn(), - console=console + console=console, ) as progress: task = progress.add_task("[cyan]扫描表情包...", total=emoji_count) - + for idx, emoji in enumerate(emojis, 1): - progress.update(task, description=f"[cyan]处理 {idx}/{emoji_count}: {os.path.basename(emoji.full_path)}") - + progress.update( + task, description=f"[cyan]处理 {idx}/{emoji_count}: {os.path.basename(emoji.full_path)}" + ) + # 检查文件是否存在 if not os.path.exists(emoji.full_path): console.print(" [yellow]警告: 文件不存在,跳过[/yellow]") progress.advance(task) continue - + # 读取或重新编码图片 if self.reencode: try: img_bytes = reencode_image(emoji.full_path, self.reencode, self.reencode_quality) except Exception as e: console.print(f" [yellow]警告: 重新编码失败,使用原始文件: {e}[/yellow]") - with open(emoji.full_path, 'rb') as f: + with open(emoji.full_path, "rb") as f: img_bytes = f.read() else: - with open(emoji.full_path, 'rb') as f: + with open(emoji.full_path, "rb") as f: img_bytes = f.read() - + # 计算 SHA256 img_sha = calculate_sha256(img_bytes) - + # 获取图片信息 width, height, mime_type = get_image_info(emoji.full_path) - + # 构建 item(使用短字段名) filename = os.path.basename(emoji.full_path) item = { @@ -259,95 +265,97 @@ class MMIPKGPacker: "emoji_hash": emoji.emoji_hash or "", "is_registered": True, "is_banned": emoji.is_banned or False, - } + }, } - + items.append(item) image_data_list.append(img_bytes) progress.advance(task) - + if not items: print("错误: 没有有效的表情包可以打包") return False - + print(f"找到 {len(items)} 个表情包可以打包...") - + # 准备打包 pack_id = str(uuid.uuid4()) if pack_name is None: pack_name = f"MaiBot_Emojis_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - + manifest = { "p": pack_id, # pack_id "n": pack_name, # pack_name "t": datetime.now().isoformat(), # created_at - "a": items # items array + "a": items, # items array } - + # 添加自定义字段 if custom_manifest: for key, value in custom_manifest.items(): if key not in manifest: # 不覆盖核心字段 manifest[key] = value - + # 序列化 manifest manifest_bytes = msgpack.packb(manifest, use_bin_type=True) manifest_len = len(manifest_bytes) - + # 计算 payload 大小 payload_size = 4 + manifest_len # manifest_len + manifest_bytes for img_bytes in image_data_list: payload_size += 4 + len(img_bytes) # img_len + img_bytes - + print(f"Manifest 大小: {manifest_len / 1024:.2f} KB") print(f"Payload 未压缩大小: {payload_size / 1024 / 1024:.2f} MB") - + # 写入文件 return self._write_package(output_path, manifest_bytes, image_data_list, payload_size) - + except Exception as e: print(f"打包失败: {e}") import traceback + traceback.print_exc() return False finally: if not db.is_closed(): db.close() - - def _write_package(self, output_path: str, manifest_bytes: bytes, - image_data_list: List[bytes], payload_size: int) -> bool: + + def _write_package( + self, output_path: str, manifest_bytes: bytes, image_data_list: List[bytes], payload_size: int + ) -> bool: """写入打包文件""" try: - with open(output_path, 'wb') as f: + with open(output_path, "wb") as f: # 写入 Header (32 bytes) flags = 0x01 if self.use_compression else 0x00 header = MAGIC # 4 bytes - header += struct.pack('B', VERSION) # 1 byte - header += struct.pack('B', flags) # 1 byte - header += b'\x00\x00' # 2 bytes reserved - header += struct.pack('>Q', payload_size) # 8 bytes - header += struct.pack('>Q', len(manifest_bytes)) # 8 bytes - header += b'\x00' * 8 # 8 bytes reserved - + header += struct.pack("B", VERSION) # 1 byte + header += struct.pack("B", flags) # 1 byte + header += b"\x00\x00" # 2 bytes reserved + header += struct.pack(">Q", payload_size) # 8 bytes + header += struct.pack(">Q", len(manifest_bytes)) # 8 bytes + header += b"\x00" * 8 # 8 bytes reserved + assert len(header) == 32, f"Header size mismatch: {len(header)}" f.write(header) - + # 准备 payload 并计算 SHA256 payload_sha = hashlib.sha256() - + # 写入 payload(可能压缩) if self.use_compression: console.print(f"[cyan]使用 Zstd 压缩 (level={self.zstd_level})...[/cyan]") compressor = zstd.ZstdCompressor(level=self.zstd_level) - + with compressor.stream_writer(f, closefd=False) as writer: # 写入 manifest - manifest_len_bytes = struct.pack('>I', len(manifest_bytes)) + manifest_len_bytes = struct.pack(">I", len(manifest_bytes)) writer.write(manifest_len_bytes) writer.write(manifest_bytes) payload_sha.update(manifest_len_bytes) payload_sha.update(manifest_bytes) - + # 使用进度条写入所有图片 with Progress( SpinnerColumn(), @@ -355,13 +363,13 @@ class MMIPKGPacker: BarColumn(), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), TimeRemainingColumn(), - console=console + console=console, ) as progress: task = progress.add_task("[green]压缩写入图片...", total=len(image_data_list)) - + for idx, img_bytes in enumerate(image_data_list, 1): progress.update(task, description=f"[green]压缩写入 {idx}/{len(image_data_list)}") - img_len_bytes = struct.pack('>I', len(img_bytes)) + img_len_bytes = struct.pack(">I", len(img_bytes)) writer.write(img_len_bytes) writer.write(img_bytes) payload_sha.update(img_len_bytes) @@ -370,12 +378,12 @@ class MMIPKGPacker: else: # 不压缩,直接写入 # 写入 manifest - manifest_len_bytes = struct.pack('>I', len(manifest_bytes)) + manifest_len_bytes = struct.pack(">I", len(manifest_bytes)) f.write(manifest_len_bytes) f.write(manifest_bytes) payload_sha.update(manifest_len_bytes) payload_sha.update(manifest_bytes) - + # 使用进度条写入所有图片 with Progress( SpinnerColumn(), @@ -383,29 +391,29 @@ class MMIPKGPacker: BarColumn(), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), TimeRemainingColumn(), - console=console + console=console, ) as progress: task = progress.add_task("[green]写入图片...", total=len(image_data_list)) - + for idx, img_bytes in enumerate(image_data_list, 1): progress.update(task, description=f"[green]写入 {idx}/{len(image_data_list)}") - img_len_bytes = struct.pack('>I', len(img_bytes)) + img_len_bytes = struct.pack(">I", len(img_bytes)) f.write(img_len_bytes) f.write(img_bytes) payload_sha.update(img_len_bytes) payload_sha.update(img_bytes) progress.advance(task) - + # 写入 Footer (40 bytes) file_sha256 = payload_sha.digest() footer = FOOTER_MAGIC # 4 bytes footer += file_sha256 # 32 bytes - footer += struct.pack('B', FOOTER_VERSION) # 1 byte - footer += b'\x00' * 3 # 3 bytes reserved - + footer += struct.pack("B", FOOTER_VERSION) # 1 byte + footer += b"\x00" * 3 # 3 bytes reserved + assert len(footer) == 40, f"Footer size mismatch: {len(footer)}" f.write(footer) - + file_size = f.tell() print("\n打包完成!") print(f"输出文件: {output_path}") @@ -413,100 +421,100 @@ class MMIPKGPacker: if self.use_compression: ratio = (1 - file_size / (payload_size + 32 + 40)) * 100 print(f"压缩率: {ratio:.1f}%") - + return True - + except Exception as e: print(f"写入文件失败: {e}") import traceback + traceback.print_exc() return False class MMIPKGUnpacker: """MMIPKG 解包器""" - + def __init__(self, verify_sha: bool = True): self.verify_sha = verify_sha - - def import_to_db(self, package_path: str, - output_dir: Optional[str] = None, - replace_existing: bool = False, - batch_size: int = 500) -> bool: + + def import_to_db( + self, package_path: str, output_dir: Optional[str] = None, replace_existing: bool = False, batch_size: int = 500 + ) -> bool: """导入到数据库""" try: if not os.path.exists(package_path): print(f"错误: 文件不存在: {package_path}") return False - + # 连接数据库 if db.is_closed(): db.connect() - + # 如果未指定输出目录,使用默认的已注册表情包目录 if output_dir is None: output_dir = os.path.join(PROJECT_ROOT, "data", "emoji_registed") - + os.makedirs(output_dir, exist_ok=True) - + print(f"正在读取包: {package_path}") - - with open(package_path, 'rb') as f: + + with open(package_path, "rb") as f: # 读取 Header header = f.read(32) if len(header) != 32: raise MMIPKGError("Header 大小不正确") - + magic = header[:4] if magic != MAGIC: raise MMIPKGError(f"无效的 MAGIC: {magic}") - - version = struct.unpack('B', header[4:5])[0] + + version = struct.unpack("B", header[4:5])[0] if version != VERSION: print(f"警告: 包版本 {version} 与当前版本 {VERSION} 不匹配") - - flags = struct.unpack('B', header[5:6])[0] + + flags = struct.unpack("B", header[5:6])[0] is_compressed = bool(flags & 0x01) - - payload_uncompressed_len = struct.unpack('>Q', header[8:16])[0] - manifest_uncompressed_len = struct.unpack('>Q', header[16:24])[0] - + + payload_uncompressed_len = struct.unpack(">Q", header[8:16])[0] + manifest_uncompressed_len = struct.unpack(">Q", header[16:24])[0] + # 安全检查 if manifest_uncompressed_len > MAX_MANIFEST_SIZE: raise MMIPKGError(f"Manifest 过大: {manifest_uncompressed_len} bytes") if payload_uncompressed_len > MAX_PAYLOAD_SIZE: raise MMIPKGError(f"Payload 过大: {payload_uncompressed_len} bytes") - + print(f"压缩: {'是' if is_compressed else '否'}") print(f"Payload 大小: {payload_uncompressed_len / 1024 / 1024:.2f} MB") - + # 读取 payload payload_start = f.tell() - + # 找到 footer 位置 f.seek(-40, 2) # 从文件末尾向前 40 bytes footer = f.read(40) - + if footer[:4] != FOOTER_MAGIC: raise MMIPKGError("无效的 Footer MAGIC") - + expected_sha = footer[4:36] - + # 回到 payload 开始 f.seek(payload_start) - + # 读取整个 payload(用于计算 SHA) footer_start = os.path.getsize(package_path) - 40 payload_data_size = footer_start - payload_start - + # 解压或直接读取 if is_compressed: if zstd is None: raise MMIPKGError("需要 zstandard 库来解压此包") - + print("解压 payload...") compressed_data = f.read(payload_data_size) - + # 使用流式解压,不需要预知解压后大小 decompressor = zstd.ZstdDecompressor() try: @@ -519,60 +527,63 @@ class MMIPKGUnpacker: # 方法2:如果流式失败,尝试直接解压(兼容旧格式) print(f" 流式解压失败,尝试直接解压: {e}") try: - payload_data = decompressor.decompress(compressed_data, max_output_size=payload_uncompressed_len) + payload_data = decompressor.decompress( + compressed_data, max_output_size=payload_uncompressed_len + ) except Exception as e2: raise MMIPKGError(f"解压失败: {e2}") from e2 else: payload_data = f.read(payload_data_size) - + # 验证 SHA256 actual_sha = calculate_sha256(payload_data) if self.verify_sha and actual_sha != expected_sha: raise MMIPKGError("SHA256 校验失败!") if self.verify_sha: print("✓ SHA256 校验通过") - + # 解析 payload payload_stream = io.BytesIO(payload_data) - + # 读取 manifest manifest_len_bytes = payload_stream.read(4) - manifest_len = struct.unpack('>I', manifest_len_bytes)[0] + manifest_len = struct.unpack(">I", manifest_len_bytes)[0] manifest_bytes = payload_stream.read(manifest_len) manifest = msgpack.unpackb(manifest_bytes, raw=False) - + pack_id = manifest.get("p", "unknown") pack_name = manifest.get("n", "unknown") created_at = manifest.get("t", "unknown") items = manifest.get("a", []) - + print("\n包信息:") print(f" ID: {pack_id}") print(f" 名称: {pack_name}") print(f" 创建时间: {created_at}") print(f" 表情包数量: {len(items)}") - + # 导入表情包 - return self._import_items(payload_stream, items, output_dir, - replace_existing, batch_size) - + return self._import_items(payload_stream, items, output_dir, replace_existing, batch_size) + except Exception as e: print(f"导入失败: {e}") import traceback + traceback.print_exc() return False finally: if not db.is_closed(): db.close() - - def _import_items(self, payload_stream: BinaryIO, items: List[Dict], - output_dir: str, replace_existing: bool, batch_size: int) -> bool: + + def _import_items( + self, payload_stream: BinaryIO, items: List[Dict], output_dir: str, replace_existing: bool, batch_size: int + ) -> bool: """导入 items 到数据库""" try: imported_count = 0 skipped_count = 0 error_count = 0 - + # 开始事务,使用进度条 with db.atomic(): with Progress( @@ -581,14 +592,14 @@ class MMIPKGUnpacker: BarColumn(), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), TimeRemainingColumn(), - console=console + console=console, ) as progress: task = progress.add_task("[cyan]导入表情包...", total=len(items)) - + for idx, item in enumerate(items, 1): try: progress.update(task, description=f"[cyan]导入 {idx}/{len(items)}") - + # 读取图片数据 img_len_bytes = payload_stream.read(4) if len(img_len_bytes) != 4: @@ -596,16 +607,16 @@ class MMIPKGUnpacker: error_count += 1 progress.advance(task) continue - - img_len = struct.unpack('>I', img_len_bytes)[0] + + img_len = struct.unpack(">I", img_len_bytes)[0] img_bytes = payload_stream.read(img_len) - + if len(img_bytes) != img_len: console.print(f"[red]错误: 图片数据不完整 (item {idx})[/red]") error_count += 1 progress.advance(task) continue - + # 验证图片 SHA if self.verify_sha and (expected_sha := item.get("h")): actual_sha = calculate_sha256(img_bytes) @@ -614,24 +625,24 @@ class MMIPKGUnpacker: error_count += 1 progress.advance(task) continue - + # 获取元数据 opt = item.get("opt", {}) # 使用 or 提供回退值,如果 emoji_hash 为空则使用后续计算的值 emoji_hash = opt.get("emoji_hash") or calculate_sha256(img_bytes).hex() - + # 检查是否已存在 existing = Emoji.get_or_none(Emoji.emoji_hash == emoji_hash) - + if existing and not replace_existing: skipped_count += 1 progress.advance(task) continue - + # 保存图片文件 filename = item.get("fn", f"{emoji_hash[:8]}.{opt.get('format', 'png')}") file_path = os.path.join(output_dir, filename) - + # 如果文件已存在且不替换,生成新文件名 if os.path.exists(file_path) and not replace_existing: base, ext = os.path.splitext(filename) @@ -640,14 +651,14 @@ class MMIPKGUnpacker: filename = f"{base}_{counter}{ext}" file_path = os.path.join(output_dir, filename) counter += 1 - - with open(file_path, 'wb') as img_file: + + with open(file_path, "wb") as img_file: img_file.write(img_bytes) - + # 准备数据库记录 current_time = time.time() emotion_str = opt.get("emotion", "") - + if existing and replace_existing: # 更新现有记录 - 恢复完整的数据库信息 existing.full_path = file_path @@ -678,28 +689,29 @@ class MMIPKGUnpacker: is_registered=opt.get("is_registered", True), is_banned=opt.get("is_banned", False), ) - + imported_count += 1 progress.advance(task) - + except Exception as e: console.print(f"[red]处理 item {idx} 时出错: {e}[/red]") error_count += 1 progress.advance(task) continue - + # 输出统计 - + console.print(f"\n[green]✓ 成功导入 {imported_count} 个表情包[/green]") console.print(f" [yellow]跳过 {skipped_count} 个[/yellow]") if error_count > 0: console.print(f" [red]错误 {error_count} 个[/red]") - + return error_count == 0 - + except Exception as e: console.print(f"[red]导入 items 失败: {e}[/red]") import traceback + traceback.print_exc() return False @@ -719,26 +731,27 @@ def print_menu(): console.print(" [2] [bold]导入表情包[/bold] (从 .mmipkg 文件导入到数据库)") console.print(" [0] [bold]退出[/bold]") console.print() -def get_input(prompt: str, default: Optional[str] = None, - choices: Optional[List[str]] = None) -> str: + + +def get_input(prompt: str, default: Optional[str] = None, choices: Optional[List[str]] = None) -> str: """获取用户输入""" if default: prompt = f"{prompt} (默认: {default})" - + while True: try: value = input(f"{prompt}: ").strip() - + if not value: if default: return default console.print(" [yellow]⚠ 输入不能为空,请重新输入[/yellow]") continue - + if choices and value not in choices: console.print(f" [yellow]⚠ 无效的选择,请选择: {', '.join(choices)}[/yellow]") continue - + return value except KeyboardInterrupt: console.print("\n[yellow]操作已取消[/yellow]") @@ -756,13 +769,13 @@ def get_yes_no(prompt: str, default: bool = False) -> bool: while True: try: value = input(f"{prompt} ({default_str}): ").strip().lower() - + if not value: return default - - if value in ('y', 'yes', '是'): + + if value in ("y", "yes", "是"): return True - elif value in ('n', 'no', '否'): + elif value in ("n", "no", "否"): return False else: console.print(" [yellow]⚠ 请输入 y/yes/是 或 n/no/否[/yellow]") @@ -778,10 +791,10 @@ def get_int(prompt: str, default: int, min_val: int = 1, max_val: int = 100) -> while True: try: value = input(f"{prompt} (默认: {default}, 范围: {min_val}-{max_val}): ").strip() - + if not value: return default - + try: num = int(value) if min_val <= num <= max_val: @@ -818,15 +831,15 @@ def interactive_export(): console.print("\n[cyan]" + "-" * 70 + "[/cyan]") console.print("[bold]导出表情包到 .mmipkg 文件[/bold]") console.print("[cyan]" + "-" * 70 + "[/cyan]") - + # 检查数据库 try: if db.is_closed(): db.connect() - + emoji_count = Emoji.select().where(Emoji.is_registered).count() console.print(f"\n[green]✓ 找到 {emoji_count} 个已注册的表情包[/green]") - + if emoji_count == 0: console.print("[red]✗ 数据库中没有已注册的表情包,无法导出[/red]") return False @@ -836,27 +849,25 @@ def interactive_export(): finally: if not db.is_closed(): db.close() - + # 获取输出文件路径 console.print("\n[yellow]1. 输出文件设置[/yellow]") default_filename = f"maibot_emojis_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mmipkg" output_path = get_input(" 输出文件路径", default_filename) - + # 确保有 .mmipkg 扩展名 - if not output_path.endswith('.mmipkg'): - output_path += '.mmipkg' - + if not output_path.endswith(".mmipkg"): + output_path += ".mmipkg" + # 获取包名称 default_pack_name = f"MaiBot表情包_{datetime.now().strftime('%Y%m%d')}" pack_name = get_input(" 包名称", default_pack_name) - + # 自定义 manifest console.print("\n[yellow]2. 包信息设置(可选)[/yellow]") if get_yes_no(" 是否添加包的作者和介绍信息", False): - custom_manifest = { - "author": author - } if (author := input(" 作者名称(可选): ").strip()) else {} - + custom_manifest = {"author": author} if (author := input(" 作者名称(可选): ").strip()) else {} + # 介绍信息 console.print(" 包介绍(限制 100 字以内):") if description := input(" > ").strip(): @@ -864,23 +875,23 @@ def interactive_export(): console.print(f" [yellow]⚠ 介绍过长({len(description)} 字),已截断至 100 字[/yellow]") description = description[:100] custom_manifest["description"] = description - + if not custom_manifest: custom_manifest = None else: console.print(" [green]✓ 已添加包信息[/green]") else: custom_manifest = None - + # 压缩设置 console.print("\n[yellow]3. 压缩设置[/yellow]") use_compression = get_yes_no(" 使用 Zstd 压缩", True) - + zstd_level = 3 if use_compression: print_compression_level_info() zstd_level = get_int(" 选择压缩级别", 3, 1, 22) - + # 重新编码设置 console.print("\n[yellow]4. 图片编码设置[/yellow]") if get_yes_no(" 是否重新编码图片(可显著减小文件大小)", False): @@ -888,13 +899,13 @@ def interactive_export(): console.print(" webp: 推荐,体积小且支持透明度") console.print(" jpeg: 最小体积,但不支持透明度") console.print(" png: 无损,文件较大") - reencode = get_input(" 选择格式", "webp", ['webp', 'jpeg', 'png']) - - quality = get_int(" 编码质量", 80, 1, 100) if reencode in ('webp', 'jpeg') else 80 + reencode = get_input(" 选择格式", "webp", ["webp", "jpeg", "png"]) + + quality = get_int(" 编码质量", 80, 1, 100) if reencode in ("webp", "jpeg") else 80 else: reencode = None quality = 80 - + # 确认导出 console.print("\n[cyan]" + "-" * 70 + "[/cyan]") console.print("[bold]导出配置:[/bold]") @@ -912,27 +923,24 @@ def interactive_export(): console.print(f" 编码质量: {quality}") console.print(f" 表情包数量: {emoji_count}") console.print("[cyan]" + "-" * 70 + "[/cyan]") - + if not get_yes_no("\n确认导出", True): console.print("[red]✗ 已取消导出[/red]") return False - + # 开始导出 console.print("\n[cyan]开始导出...[/cyan]") packer = MMIPKGPacker( - use_compression=use_compression, - zstd_level=zstd_level, - reencode=reencode, - reencode_quality=quality + use_compression=use_compression, zstd_level=zstd_level, reencode=reencode, reencode_quality=quality ) - + success = packer.pack_from_db(output_path, pack_name, custom_manifest) - + if success: console.print(f"\n[green]✓ 导出成功: {output_path}[/green]") else: console.print("\n[red]✗ 导出失败[/red]") - + return success @@ -941,37 +949,37 @@ def interactive_import(): console.print("\n[cyan]" + "-" * 70 + "[/cyan]") console.print("[bold]从 .mmipkg 文件导入表情包[/bold]") console.print("[cyan]" + "-" * 70 + "[/cyan]") - + # 选择导入模式 print_import_mode_selection() - import_mode = get_input("请选择", "1", ['1', '2']) - + import_mode = get_input("请选择", "1", ["1", "2"]) + input_files = [] - - if import_mode == '1': + + if import_mode == "1": # 自动扫描模式 import_dir = os.path.join(PROJECT_ROOT, "data", "import_emoji") os.makedirs(import_dir, exist_ok=True) - + console.print(f"\n[cyan]扫描目录: {import_dir}[/cyan]") - + # 查找所有 .mmipkg 文件 for file in os.listdir(import_dir): - if file.endswith('.mmipkg'): + if file.endswith(".mmipkg"): file_path = os.path.join(import_dir, file) if os.path.isfile(file_path): input_files.append(file_path) - + if not input_files: console.print("[red]✗ 目录中没有找到 .mmipkg 文件[/red]") console.print(f" 请将表情包文件放入: {import_dir}") return False - + console.print(f"\n[green]找到 {len(input_files)} 个文件:[/green]") for i, file_path in enumerate(input_files, 1): file_size = os.path.getsize(file_path) / 1024 / 1024 console.print(f" [{i}] {os.path.basename(file_path)} ({file_size:.2f} MB)") - + if not get_yes_no(f"\n确认导入这 {len(input_files)} 个文件", True): console.print("[red]✗ 已取消导入[/red]") return False @@ -979,23 +987,23 @@ def interactive_import(): # 手动输入模式 console.print("\n[yellow]1. 输入文件设置[/yellow]") input_path = get_input(" 输入文件路径 (.mmipkg)") - + if not os.path.exists(input_path): console.print(f"[red]✗ 文件不存在: {input_path}[/red]") return False - + input_files.append(input_path) - + # 获取输出目录 console.print("\n[yellow]2. 输出目录设置[/yellow]") default_output_dir = os.path.join(PROJECT_ROOT, "data", "emoji_registed") output_dir = get_input(" 输出目录", default_output_dir) - + # 导入选项 console.print("\n[yellow]3. 导入选项[/yellow]") replace_existing = get_yes_no(" 替换已存在的表情包", False) verify_sha = get_yes_no(" 验证 SHA256 完整性(推荐)", True) - + # 批量大小 console.print("\n[yellow]4. 性能设置[/yellow]") console.print(" [cyan]批量大小说明:[/cyan]") @@ -1003,7 +1011,7 @@ def interactive_import(): console.print(" 500-1000: 快速导入大量表情包") console.print(" 1000+: 极速模式,但内存占用更高") batch_size = get_int(" 批量提交大小", 500, 100, 5000) - + # 确认导入 console.print("\n[cyan]" + "-" * 70 + "[/cyan]") console.print("[bold]导入配置:[/bold]") @@ -1014,17 +1022,17 @@ def interactive_import(): console.print(f" SHA256 验证: {'是' if verify_sha else '否'}") console.print(f" 批量大小: {batch_size}") console.print("[cyan]" + "-" * 70 + "[/cyan]") - + if not get_yes_no("\n确认导入", True): console.print("[red]✗ 已取消导入[/red]") return False - + # 开始导入 unpacker = MMIPKGUnpacker(verify_sha=verify_sha) - + total_success = 0 total_failed = 0 - + # 使用进度条处理多个文件 with Progress( SpinnerColumn(), @@ -1032,31 +1040,28 @@ def interactive_import(): BarColumn(), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), TimeElapsedColumn(), - console=console + console=console, ) as progress: task = progress.add_task("[cyan]导入文件...", total=len(input_files)) - + for i, input_path in enumerate(input_files, 1): progress.update(task, description=f"[cyan]导入 [{i}/{len(input_files)}]: {os.path.basename(input_path)}") - + console.print(f"\n[bold]{'=' * 70}[/bold]") console.print(f"[bold]导入文件 [{i}/{len(input_files)}]: {os.path.basename(input_path)}[/bold]") console.print(f"[bold]{'=' * 70}[/bold]") - + success = unpacker.import_to_db( - input_path, - output_dir=output_dir, - replace_existing=replace_existing, - batch_size=batch_size + input_path, output_dir=output_dir, replace_existing=replace_existing, batch_size=batch_size ) - + if success: total_success += 1 else: total_failed += 1 - + progress.advance(task) - + # 总结 console.print(f"\n[bold]{'=' * 70}[/bold]") console.print("[bold]导入总结:[/bold]") @@ -1064,28 +1069,28 @@ def interactive_import(): if total_failed > 0: console.print(f" [red]失败: {total_failed} 个文件[/red]") console.print(f"[bold]{'=' * 70}[/bold]") - + return total_failed == 0 def main(): """主函数 - 交互式界面""" print_header() - + try: while True: print_menu() try: - choice = get_input("请选择", "1", ['0', '1', '2']) + choice = get_input("请选择", "1", ["0", "1", "2"]) except KeyboardInterrupt: console.print("\n[green]再见![/green]") return 0 - - if choice == '0': + + if choice == "0": console.print("\n[green]再见![/green]") return 0 - - elif choice == '1': + + elif choice == "1": try: interactive_export() except KeyboardInterrupt: @@ -1093,14 +1098,15 @@ def main(): except Exception as e: console.print(f"\n[red]✗ 发生错误: {e}[/red]") import traceback + traceback.print_exc() - + try: input("\n按 Enter 键继续...") except (KeyboardInterrupt, EOFError): pass - - elif choice == '2': + + elif choice == "2": try: interactive_import() except KeyboardInterrupt: @@ -1108,8 +1114,9 @@ def main(): except Exception as e: console.print(f"\n[red]✗ 发生错误: {e}[/red]") import traceback + traceback.print_exc() - + try: input("\n按 Enter 键继续...") except (KeyboardInterrupt, EOFError): @@ -1117,9 +1124,9 @@ def main(): except KeyboardInterrupt: console.print("\n[green]再见![/green]") return 0 - + return 0 -if __name__ == '__main__': +if __name__ == "__main__": sys.exit(main()) diff --git a/src/chat/heart_flow/heartFC_chat.py b/src/chat/heart_flow/heartFC_chat.py index 707c44bd..bb37e29e 100644 --- a/src/chat/heart_flow/heartFC_chat.py +++ b/src/chat/heart_flow/heartFC_chat.py @@ -230,7 +230,7 @@ class HeartFChatting: if (message.is_mentioned or message.is_at) and global_config.chat.mentioned_bot_reply: mentioned_message = message - logger.info(f"{self.log_prefix} 当前talk_value: {global_config.chat.get_talk_value(self.stream_id)}") + # logger.info(f"{self.log_prefix} 当前talk_value: {global_config.chat.get_talk_value(self.stream_id)}") # *控制频率用 if mentioned_message: @@ -333,7 +333,6 @@ class HeartFChatting: # 重置连续 no_reply 计数 self.consecutive_no_reply_count = 0 reason = "" - await database_api.store_action_info( chat_stream=self.chat_stream, @@ -411,7 +410,7 @@ class HeartFChatting: # asyncio.create_task(self.chat_history_summarizer.process()) cycle_timers, thinking_id = self.start_cycle() - logger.info(f"{self.log_prefix} 开始第{self._cycle_counter}次思考") + logger.info(f"{self.log_prefix} 开始第{self._cycle_counter}次思考(频率: {global_config.chat.get_talk_value(self.stream_id)})") # 第一步:动作检查 available_actions: Dict[str, ActionInfo] = {} diff --git a/src/chat/knowledge/__init__.py b/src/chat/knowledge/__init__.py index b9c96708..a570277c 100644 --- a/src/chat/knowledge/__init__.py +++ b/src/chat/knowledge/__init__.py @@ -30,9 +30,11 @@ DATA_PATH = os.path.join(ROOT_PATH, "data") qa_manager = None inspire_manager = None + def get_qa_manager(): return qa_manager + def lpmm_start_up(): # sourcery skip: extract-duplicate-method # 检查LPMM知识库是否启用 if global_config.lpmm_knowledge.enable: diff --git a/src/chat/knowledge/qa_manager.py b/src/chat/knowledge/qa_manager.py index 6bbc1dd5..1a087a7f 100644 --- a/src/chat/knowledge/qa_manager.py +++ b/src/chat/knowledge/qa_manager.py @@ -92,14 +92,20 @@ class QAManager: # 过滤阈值 result = dyn_select_top_k(result, 0.5, 1.0) - for res in result: - raw_paragraph = self.embed_manager.paragraphs_embedding_store.store[res[0]].str - logger.info(f"找到相关文段,相关系数:{res[1]:.8f}\n{raw_paragraph}\n\n") + if global_config.debug.show_lpmm_paragraph: + for res in result: + raw_paragraph = self.embed_manager.paragraphs_embedding_store.store[res[0]].str + logger.info(f"找到相关文段,相关系数:{res[1]:.8f}\n{raw_paragraph}\n\n") return result, ppr_node_weights - async def get_knowledge(self, question: str) -> Optional[str]: - """获取知识""" + async def get_knowledge(self, question: str, limit: int = 5) -> Optional[str]: + """获取知识 + + Args: + question: 查询问题 + limit: 返回的相关知识条数 + """ # 处理查询 processed_result = await self.process_query(question) if processed_result is not None: @@ -109,6 +115,8 @@ class QAManager: logger.debug("知识库查询结果为空,可能是知识库中没有相关内容") return None + limit = max(1, limit) if isinstance(limit, int) else 5 + knowledge = [ ( self.embed_manager.paragraphs_embedding_store.store[res[0]].str, @@ -116,9 +124,17 @@ class QAManager: ) for res in query_res ] - found_knowledge = "\n".join( - [f"第{i + 1}条知识:{k[0]}\n 该条知识对于问题的相关性:{k[1]}" for i, k in enumerate(knowledge)] - ) + + # max_score = max([k[1] for k in knowledge]) if knowledge else None + selected_knowledge = knowledge[:limit] + + formatted_knowledge = [ + f"第{i + 1}条知识:{k[0]}\n 该条知识对于问题的相关性:{k[1]}" for i, k in enumerate(selected_knowledge) + ] + # if max_score is not None: + # formatted_knowledge.insert(0, f"最高相关系数:{max_score}") + + found_knowledge = "\n".join(formatted_knowledge) if len(found_knowledge) > MAX_KNOWLEDGE_LENGTH: found_knowledge = found_knowledge[:MAX_KNOWLEDGE_LENGTH] + "\n" return found_knowledge diff --git a/src/chat/planner_actions/planner.py b/src/chat/planner_actions/planner.py index 7af3291a..5771feed 100644 --- a/src/chat/planner_actions/planner.py +++ b/src/chat/planner_actions/planner.py @@ -7,7 +7,6 @@ from typing import Dict, Optional, Tuple, List, TYPE_CHECKING, Union from rich.traceback import install from datetime import datetime from json_repair import repair_json - from src.llm_models.utils_model import LLMRequest from src.config.config import global_config, model_config from src.common.logger import get_logger @@ -164,6 +163,45 @@ class ActionPlanner: return item[1] return None + def _replace_message_ids_with_text( + self, text: Optional[str], message_id_list: List[Tuple[str, "DatabaseMessages"]] + ) -> Optional[str]: + """将文本中的 m+数字 消息ID替换为原消息内容,并添加双引号""" + if not text: + return text + + id_to_message = {msg_id: msg for msg_id, msg in message_id_list} + + # 匹配m后带2-4位数字,前后不是字母数字下划线 + pattern = r"(? str: + msg_id = match.group(0) + message = id_to_message.get(msg_id) + if not message: + logger.warning(f"{self.log_prefix}planner理由引用 {msg_id} 未找到对应消息,保持原样") + return msg_id + + msg_text = (message.processed_plain_text or message.display_message or "").strip() + if not msg_text: + logger.warning(f"{self.log_prefix}planner理由引用 {msg_id} 的消息内容为空,保持原样") + return msg_id + + preview = msg_text if len(msg_text) <= 100 else f"{msg_text[:97]}..." + logger.info(f"{self.log_prefix}planner理由引用 {msg_id} -> 消息({preview})") + return f"消息({msg_text})" + + return re.sub(pattern, _replace, text) + def _parse_single_action( self, action_json: dict, @@ -176,7 +214,10 @@ class ActionPlanner: try: action = action_json.get("action", "no_reply") - reasoning = action_json.get("reason", "未提供原因") + original_reasoning = action_json.get("reason", "未提供原因") + reasoning = self._replace_message_ids_with_text(original_reasoning, message_id_list) + if reasoning is None: + reasoning = original_reasoning action_data = {key: value for key, value in action_json.items() if key not in ["action", "reason"]} # 非no_reply动作需要target_message_id target_message = None @@ -573,9 +614,6 @@ class ActionPlanner: # 调用LLM llm_content, (reasoning_content, _, _) = await self.planner_llm.generate_response_async(prompt=prompt) - # logger.info(f"{self.log_prefix}规划器原始提示词: {prompt}") - # logger.info(f"{self.log_prefix}规划器原始响应: {llm_content}") - if global_config.debug.show_planner_prompt: logger.info(f"{self.log_prefix}规划器原始提示词: {prompt}") logger.info(f"{self.log_prefix}规划器原始响应: {llm_content}") @@ -604,6 +642,7 @@ class ActionPlanner: if llm_content: try: json_objects, extracted_reasoning = self._extract_json_from_markdown(llm_content) + extracted_reasoning = self._replace_message_ids_with_text(extracted_reasoning, message_id_list) or "" if json_objects: logger.debug(f"{self.log_prefix}从响应中提取到{len(json_objects)}个JSON对象") filtered_actions_list = list(filtered_actions.items()) diff --git a/src/chat/replyer/group_generator.py b/src/chat/replyer/group_generator.py index 3019ca0d..cc948db1 100644 --- a/src/chat/replyer/group_generator.py +++ b/src/chat/replyer/group_generator.py @@ -226,7 +226,9 @@ class DefaultReplyer: traceback.print_exc() return False, llm_response - async def build_expression_habits(self, chat_history: str, target: str, reply_reason: str = "") -> Tuple[str, List[int]]: + async def build_expression_habits( + self, chat_history: str, target: str, reply_reason: str = "" + ) -> Tuple[str, List[int]]: # sourcery skip: for-append-to-extend """构建表达习惯块 @@ -1094,10 +1096,10 @@ class DefaultReplyer: if not global_config.lpmm_knowledge.enable: logger.debug("LPMM知识库未启用,跳过获取知识库内容") return "" - + if global_config.lpmm_knowledge.lpmm_mode == "agent": return "" - + time_now = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) bot_name = global_config.bot.nickname @@ -1115,10 +1117,10 @@ class DefaultReplyer: model_config=model_config.model_task_config.tool_use, tool_options=[SearchKnowledgeFromLPMMTool.get_tool_definition()], ) - + # logger.info(f"工具调用提示词: {prompt}") # logger.info(f"工具调用: {tool_calls}") - + if tool_calls: result = await self.tool_executor.execute_tool_call(tool_calls[0], SearchKnowledgeFromLPMMTool()) end_time = time.time() diff --git a/src/chat/replyer/private_generator.py b/src/chat/replyer/private_generator.py index 76eedf8c..74b04f6e 100644 --- a/src/chat/replyer/private_generator.py +++ b/src/chat/replyer/private_generator.py @@ -241,7 +241,9 @@ class PrivateReplyer: return f"{sender_relation}" - async def build_expression_habits(self, chat_history: str, target: str, reply_reason: str = "") -> Tuple[str, List[int]]: + async def build_expression_habits( + self, chat_history: str, target: str, reply_reason: str = "" + ) -> Tuple[str, List[int]]: # sourcery skip: for-append-to-extend """构建表达习惯块 @@ -1032,10 +1034,10 @@ class PrivateReplyer: if not global_config.lpmm_knowledge.enable: logger.debug("LPMM知识库未启用,跳过获取知识库内容") return "" - + if global_config.lpmm_knowledge.lpmm_mode == "agent": return "" - + time_now = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) bot_name = global_config.bot.nickname diff --git a/src/chat/utils/chat_history_summarizer.py b/src/chat/utils/chat_history_summarizer.py index 36bb5ff0..7fa55834 100644 --- a/src/chat/utils/chat_history_summarizer.py +++ b/src/chat/utils/chat_history_summarizer.py @@ -106,8 +106,8 @@ class ChatHistorySummarizer: await self._check_and_package(current_time) self.last_check_time = current_time return - - logger.info( + + logger.debug( f"{self.log_prefix} 开始处理聊天概括,时间窗口: {self.last_check_time:.2f} -> {current_time:.2f}" ) @@ -119,7 +119,7 @@ class ChatHistorySummarizer: before_count = len(self.current_batch.messages) self.current_batch.messages.extend(new_messages) self.current_batch.end_time = current_time - logger.info(f"{self.log_prefix} 批次更新: {before_count} -> {len(self.current_batch.messages)} 条消息") + logger.info(f"{self.log_prefix} 更新聊天话题: {before_count} -> {len(self.current_batch.messages)} 条消息") else: # 创建新批次 self.current_batch = MessageBatch( @@ -127,7 +127,7 @@ class ChatHistorySummarizer: start_time=new_messages[0].time if new_messages else current_time, end_time=current_time, ) - logger.info(f"{self.log_prefix} 新建批次: {len(new_messages)} 条消息") + logger.info(f"{self.log_prefix} 新建聊天话题: {len(new_messages)} 条消息") # 检查是否需要打包 await self._check_and_package(current_time) diff --git a/src/common/database/database_model.py b/src/common/database/database_model.py index 3673e6d2..c97c0b72 100644 --- a/src/common/database/database_model.py +++ b/src/common/database/database_model.py @@ -311,6 +311,8 @@ class Expression(BaseModel): context = TextField(null=True) up_content = TextField(null=True) + content_list = TextField(null=True) + count = IntegerField(default=1) last_active_time = FloatField() chat_id = TextField(index=True) create_date = FloatField(null=True) # 创建日期,允许为空以兼容老数据 diff --git a/src/common/logger.py b/src/common/logger.py index 4546e88a..4cf40398 100644 --- a/src/common/logger.py +++ b/src/common/logger.py @@ -19,6 +19,7 @@ PROJECT_ROOT = logger_file.parent.parent.parent.resolve() # 全局handler实例,避免重复创建 _file_handler = None _console_handler = None +_ws_handler = None def get_file_handler(): @@ -59,6 +60,35 @@ def get_console_handler(): return _console_handler +def get_ws_handler(): + """获取 WebSocket handler 单例""" + global _ws_handler + if _ws_handler is None: + _ws_handler = WebSocketLogHandler() + # WebSocket handler 推送所有级别的日志 + _ws_handler.setLevel(logging.DEBUG) + return _ws_handler + + +def initialize_ws_handler(loop): + """初始化 WebSocket handler 的事件循环 + + Args: + loop: asyncio 事件循环 + """ + handler = get_ws_handler() + handler.set_loop(loop) + + # 为 WebSocket handler 设置 JSON 格式化器(与文件格式相同) + handler.setFormatter(file_formatter) + + # 添加到根日志记录器 + root_logger = logging.getLogger() + if handler not in root_logger.handlers: + root_logger.addHandler(handler) + print("[日志系统] ✅ WebSocket 日志推送已启用") + + class TimestampedFileHandler(logging.Handler): """基于时间戳的文件处理器,简单的轮转份数限制""" @@ -145,12 +175,76 @@ class TimestampedFileHandler(logging.Handler): super().close() +class WebSocketLogHandler(logging.Handler): + """WebSocket 日志处理器 - 将日志实时推送到前端""" + + _log_counter = 0 # 类级别计数器,确保 ID 唯一性 + + def __init__(self, loop=None): + super().__init__() + self.loop = loop + self._initialized = False + + def set_loop(self, loop): + """设置事件循环""" + self.loop = loop + self._initialized = True + + def emit(self, record): + """发送日志到 WebSocket 客户端""" + if not self._initialized or self.loop is None: + return + + try: + # 获取格式化后的消息 + # 对于 structlog,formatted message 包含完整的日志信息 + formatted_msg = self.format(record) if self.formatter else record.getMessage() + + # 如果是 JSON 格式(文件格式化器),解析它 + message = formatted_msg + try: + import json + + log_dict = json.loads(formatted_msg) + message = log_dict.get("event", formatted_msg) + except (json.JSONDecodeError, ValueError): + # 不是 JSON,直接使用消息 + message = formatted_msg + + # 生成唯一 ID: 时间戳毫秒 + 自增计数器 + WebSocketLogHandler._log_counter += 1 + log_id = f"{int(record.created * 1000)}_{WebSocketLogHandler._log_counter}" + + # 格式化日志数据 + log_data = { + "id": log_id, + "timestamp": datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S"), + "level": record.levelname, + "module": record.name, + "message": message, + } + + # 异步广播日志(不阻塞日志记录) + try: + import asyncio + from src.webui.logs_ws import broadcast_log + + asyncio.run_coroutine_threadsafe(broadcast_log(log_data), self.loop) + except Exception: + # WebSocket 推送失败不影响日志记录 + pass + + except Exception: + # 不要让 WebSocket 错误影响日志系统 + self.handleError(record) + + # 旧的轮转文件处理器已移除,现在使用基于时间戳的处理器 def close_handlers(): """安全关闭所有handler""" - global _file_handler, _console_handler + global _file_handler, _console_handler, _ws_handler if _file_handler: _file_handler.close() @@ -160,6 +254,10 @@ def close_handlers(): _console_handler.close() _console_handler = None + if _ws_handler: + _ws_handler.close() + _ws_handler = None + def remove_duplicate_handlers(): # sourcery skip: for-append-to-extend, list-comprehension """移除重复的handler,特别是文件handler""" @@ -843,8 +941,8 @@ def start_log_cleanup_task(): def shutdown_logging(): """优雅关闭日志系统,释放所有文件句柄""" - logger = get_logger("logger") - logger.info("正在关闭日志系统...") + # 先输出到控制台,避免日志系统关闭后无法输出 + print("[logger] 正在关闭日志系统...") # 关闭所有handler root_logger = logging.getLogger() @@ -865,4 +963,5 @@ def shutdown_logging(): handler.close() logger_obj.removeHandler(handler) - logger.info("日志系统已关闭") + # 使用 print 而不是 logger,因为 logger 已经关闭 + print("[logger] 日志系统已关闭") diff --git a/src/common/server.py b/src/common/server.py index 140d86d1..88608677 100644 --- a/src/common/server.py +++ b/src/common/server.py @@ -1,7 +1,7 @@ from fastapi import FastAPI, APIRouter -from fastapi.middleware.cors import CORSMiddleware # 新增导入 from typing import Optional from uvicorn import Config, Server as UvicornServer +import asyncio import os from rich.traceback import install @@ -16,21 +16,6 @@ class Server: self._server: Optional[UvicornServer] = None self.set_address(host, port) - # 配置 CORS - origins = [ - "http://localhost:7999", # 允许的前端源 - "http://127.0.0.1:7999", - # 在生产环境中,您应该添加实际的前端域名 - ] - - self.app.add_middleware( - CORSMiddleware, - allow_origins=origins, - allow_credentials=True, # 是否支持 cookie - allow_methods=["*"], # 允许所有 HTTP 方法 - allow_headers=["*"], # 允许所有 HTTP 请求头 - ) - def register_router(self, router: APIRouter, prefix: str = ""): """注册路由 @@ -82,8 +67,17 @@ class Server: """安全关闭服务器""" if self._server: self._server.should_exit = True - await self._server.shutdown() - self._server = None + try: + # 添加 3 秒超时,避免 shutdown 永久挂起 + await asyncio.wait_for(self._server.shutdown(), timeout=3.0) + except asyncio.TimeoutError: + # 超时就强制标记为 None,让垃圾回收处理 + pass + except Exception: + # 忽略其他异常 + pass + finally: + self._server = None def get_app(self) -> FastAPI: """获取 FastAPI 实例""" diff --git a/src/config/config.py b/src/config/config.py index f0810756..01b9f6ca 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -56,7 +56,7 @@ TEMPLATE_DIR = os.path.join(PROJECT_ROOT, "template") # 考虑到,实际上配置文件中的mai_version是不会自动更新的,所以采用硬编码 # 对该字段的更新,请严格参照语义化版本规范:https://semver.org/lang/zh-CN/ -MMC_VERSION = "0.11.2" +MMC_VERSION = "0.11.5" def get_key_comment(toml_table, key): diff --git a/src/config/official_configs.py b/src/config/official_configs.py index 795b38cb..fc084ecb 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -581,9 +581,15 @@ class DebugConfig(ConfigBase): show_jargon_prompt: bool = False """是否显示jargon相关提示词""" + show_memory_prompt: bool = False + """是否显示记忆检索相关prompt""" + show_planner_prompt: bool = False """是否显示planner相关提示词""" + show_lpmm_paragraph: bool = False + """是否显示lpmm找到的相关文段日志""" + @dataclass class ExperimentalConfig(ConfigBase): @@ -647,7 +653,7 @@ class LPMMKnowledgeConfig(ConfigBase): enable: bool = True """是否启用LPMM知识库""" - + lpmm_mode: Literal["classic", "agent"] = "classic" """LPMM知识库模式,可选:classic经典模式,agent 模式,结合最新的记忆一同使用""" @@ -690,4 +696,4 @@ class JargonConfig(ConfigBase): """Jargon配置类""" all_global: bool = False - """是否将所有新增的jargon项目默认为全局(is_global=True),chat_id记录第一次存储时的id""" \ No newline at end of file + """是否将所有新增的jargon项目默认为全局(is_global=True),chat_id记录第一次存储时的id""" diff --git a/src/express/express_utils.py b/src/express/express_utils.py index c27306d1..20095561 100644 --- a/src/express/express_utils.py +++ b/src/express/express_utils.py @@ -61,6 +61,37 @@ def format_create_date(timestamp: float) -> str: return "未知时间" +def _compute_weights(population: List[Dict]) -> List[float]: + """ + 根据表达的count计算权重,范围限定在1~3之间。 + count越高,权重越高,但最多为基础权重的3倍。 + """ + if not population: + return [] + + counts = [] + for item in population: + count = item.get("count", 1) + try: + count_value = float(count) + except (TypeError, ValueError): + count_value = 1.0 + counts.append(max(count_value, 0.0)) + + min_count = min(counts) + max_count = max(counts) + + if max_count == min_count: + return [1.0 for _ in counts] + + weights = [] + for count_value in counts: + # 线性映射到[1,3]区间 + normalized = (count_value - min_count) / (max_count - min_count) + weights.append(1.0 + normalized * 2.0) # 1~3 + return weights + + def weighted_sample(population: List[Dict], k: int) -> List[Dict]: """ 随机抽样函数 @@ -78,15 +109,24 @@ def weighted_sample(population: List[Dict], k: int) -> List[Dict]: if len(population) <= k: return population.copy() - # 使用随机抽样 - selected = [] + selected: List[Dict] = [] population_copy = population.copy() - for _ in range(k): - if not population_copy: - break - # 随机选择一个元素 - idx = random.randint(0, len(population_copy) - 1) - selected.append(population_copy.pop(idx)) + for _ in range(min(k, len(population_copy))): + weights = _compute_weights(population_copy) + total_weight = sum(weights) + if total_weight <= 0: + # 回退到均匀随机 + idx = random.randint(0, len(population_copy) - 1) + selected.append(population_copy.pop(idx)) + continue + + threshold = random.uniform(0, total_weight) + cumulative = 0.0 + for idx, weight in enumerate(weights): + cumulative += weight + if threshold <= cumulative: + selected.append(population_copy.pop(idx)) + break return selected diff --git a/src/express/expression_learner.py b/src/express/expression_learner.py index 72dd831a..57c03e77 100644 --- a/src/express/expression_learner.py +++ b/src/express/expression_learner.py @@ -77,6 +77,9 @@ class ExpressionLearner: self.express_learn_model: LLMRequest = LLMRequest( model_set=model_config.model_task_config.utils, request_type="expression.learner" ) + self.summary_model: LLMRequest = LLMRequest( + model_set=model_config.model_task_config.utils_small, request_type="expression.summary" + ) self.embedding_model: LLMRequest = LLMRequest( model_set=model_config.model_task_config.embedding, request_type="expression.embedding" ) @@ -91,8 +94,8 @@ class ExpressionLearner: _, self.enable_learning, self.learning_intensity = global_config.expression.get_expression_config_for_chat( self.chat_id ) - self.min_messages_for_learning = 30 / self.learning_intensity # 触发学习所需的最少消息数 - self.min_learning_interval = 300 / self.learning_intensity + self.min_messages_for_learning = 15 / self.learning_intensity # 触发学习所需的最少消息数 + self.min_learning_interval = 120 / self.learning_intensity def should_trigger_learning(self) -> bool: """ @@ -186,25 +189,13 @@ class ExpressionLearner: context, up_content, ) in learnt_expressions: - # 查找是否已存在相似表达方式 - query = Expression.select().where( - (Expression.chat_id == self.chat_id) & (Expression.situation == situation) & (Expression.style == style) + await self._upsert_expression_record( + situation=situation, + style=style, + context=context, + up_content=up_content, + current_time=current_time, ) - if query.exists(): - # 表达方式完全相同,只更新时间戳 - expr_obj = query.get() - expr_obj.last_active_time = current_time - expr_obj.save() - else: - Expression.create( - situation=situation, - style=style, - last_active_time=current_time, - chat_id=self.chat_id, - create_date=current_time, # 手动设置创建日期 - context=context, - up_content=up_content, - ) return learnt_expressions @@ -362,6 +353,10 @@ class ExpressionLearner: logger.error(f"学习表达方式失败,模型生成出错: {e}") return None expressions: List[Tuple[str, str]] = self.parse_expression_response(response) + expressions = self._filter_self_reference_styles(expressions) + if not expressions: + logger.info("过滤后没有可用的表达方式(style 与机器人名称重复)") + return None # logger.debug(f"学习{type_str}的response: {response}") # 对表达方式溯源 @@ -433,6 +428,149 @@ class ExpressionLearner: expressions.append((situation, style)) return expressions + def _filter_self_reference_styles(self, expressions: List[Tuple[str, str]]) -> List[Tuple[str, str]]: + """ + 过滤掉style与机器人名称/昵称重复的表达 + """ + banned_names = set() + bot_nickname = (global_config.bot.nickname or "").strip() + if bot_nickname: + banned_names.add(bot_nickname) + + alias_names = global_config.bot.alias_names or [] + for alias in alias_names: + alias = alias.strip() + if alias: + banned_names.add(alias) + + banned_casefold = {name.casefold() for name in banned_names if name} + + filtered: List[Tuple[str, str]] = [] + removed_count = 0 + for situation, style in expressions: + normalized_style = (style or "").strip() + if normalized_style and normalized_style.casefold() not in banned_casefold: + filtered.append((situation, style)) + else: + removed_count += 1 + + if removed_count: + logger.debug(f"已过滤 {removed_count} 条style与机器人名称重复的表达方式") + + return filtered + + async def _upsert_expression_record( + self, + situation: str, + style: str, + context: str, + up_content: str, + current_time: float, + ) -> None: + expr_obj = Expression.select().where((Expression.chat_id == self.chat_id) & (Expression.style == style)).first() + + if expr_obj: + await self._update_existing_expression( + expr_obj=expr_obj, + situation=situation, + context=context, + up_content=up_content, + current_time=current_time, + ) + return + + await self._create_expression_record( + situation=situation, + style=style, + context=context, + up_content=up_content, + current_time=current_time, + ) + + async def _create_expression_record( + self, + situation: str, + style: str, + context: str, + up_content: str, + current_time: float, + ) -> None: + content_list = [situation] + formatted_situation = await self._compose_situation_text(content_list, 1, situation) + + Expression.create( + situation=formatted_situation, + style=style, + content_list=json.dumps(content_list, ensure_ascii=False), + count=1, + last_active_time=current_time, + chat_id=self.chat_id, + create_date=current_time, + context=context, + up_content=up_content, + ) + + async def _update_existing_expression( + self, + expr_obj: Expression, + situation: str, + context: str, + up_content: str, + current_time: float, + ) -> None: + content_list = self._parse_content_list(expr_obj.content_list) + content_list.append(situation) + + expr_obj.content_list = json.dumps(content_list, ensure_ascii=False) + expr_obj.count = (expr_obj.count or 0) + 1 + expr_obj.last_active_time = current_time + expr_obj.context = context + expr_obj.up_content = up_content + + new_situation = await self._compose_situation_text( + content_list=content_list, + count=expr_obj.count, + fallback=expr_obj.situation, + ) + expr_obj.situation = new_situation + + expr_obj.save() + + def _parse_content_list(self, stored_list: Optional[str]) -> List[str]: + if not stored_list: + return [] + try: + data = json.loads(stored_list) + except json.JSONDecodeError: + return [] + return [str(item) for item in data if isinstance(item, str)] if isinstance(data, list) else [] + + async def _compose_situation_text(self, content_list: List[str], count: int, fallback: str = "") -> str: + sanitized = [c.strip() for c in content_list if c.strip()] + summary = await self._summarize_situations(sanitized) + if summary: + return summary + return "/".join(sanitized) if sanitized else fallback + + async def _summarize_situations(self, situations: List[str]) -> Optional[str]: + if not situations: + return None + + prompt = ( + "请阅读以下多个聊天情境描述,并将它们概括成一句简短的话," + "长度不超过20个字,保留共同特点:\n" + f"{chr(10).join(f'- {s}' for s in situations[-10:])}\n只输出概括内容。" + ) + + try: + summary, _ = await self.summary_model.generate_response_async(prompt, temperature=0.2) + summary = summary.strip() + if summary: + return summary + except Exception as e: + logger.error(f"概括表达情境失败: {e}") + return None + def _build_bare_lines(self, messages: List) -> List[Tuple[int, str]]: """ 为每条消息构建精简文本列表,保留到原消息索引的映射 diff --git a/src/express/expression_selector.py b/src/express/expression_selector.py index 031cc714..b4e25f36 100644 --- a/src/express/expression_selector.py +++ b/src/express/expression_selector.py @@ -42,8 +42,6 @@ def init_prompt(): Prompt(expression_evaluation_prompt, "expression_evaluation_prompt") - - class ExpressionSelector: def __init__(self): self.llm_model = LLMRequest( @@ -139,6 +137,7 @@ class ExpressionSelector: "last_active_time": expr.last_active_time, "source_id": expr.chat_id, "create_date": expr.create_date if expr.create_date is not None else expr.last_active_time, + "count": expr.count if getattr(expr, "count", None) is not None else 1, } for expr in style_query ] @@ -237,9 +236,9 @@ class ExpressionSelector: else: target_message_str = "" target_message_extra_block = "" - + chat_context = f"以下是正在进行的聊天内容:{chat_info}" - + # 构建reply_reason块 if reply_reason: reply_reason_block = f"你的回复理由是:{reply_reason}" @@ -261,9 +260,8 @@ class ExpressionSelector: # 4. 调用LLM content, (reasoning_content, model_name, _) = await self.llm_model.generate_response_async(prompt=prompt) - # print(prompt) - + if not content: logger.warning("LLM返回空结果") return [], [] diff --git a/src/jargon/jargon_miner.py b/src/jargon/jargon_miner.py index 67ad8b2c..4319ad3d 100644 --- a/src/jargon/jargon_miner.py +++ b/src/jargon/jargon_miner.py @@ -23,6 +23,26 @@ from src.chat.utils.prompt_builder import Prompt, global_prompt_manager logger = get_logger("jargon") +def _contains_bot_self_name(content: str) -> bool: + """ + 判断词条是否包含机器人的昵称或别名 + """ + if not content: + return False + + bot_config = getattr(global_config, "bot", None) + if not bot_config: + return False + + target = content.strip().lower() + nickname = str(getattr(bot_config, "nickname", "") or "").strip().lower() + alias_names = [str(alias or "").strip().lower() for alias in getattr(bot_config, "alias_names", []) or []] + + candidates = [name for name in [nickname, *alias_names] if name] + + return any(name in target for name in candidates if target) + + def _init_prompt() -> None: prompt_str = """ **聊天内容,其中的SELF是你自己的发言** @@ -126,7 +146,7 @@ async def _enrich_raw_content_if_needed( ) -> List[str]: """ 检查raw_content是否只包含黑话本身,如果是,则获取该消息的前三条消息作为原始内容 - + Args: content: 黑话内容 raw_content_list: 原始raw_content列表 @@ -134,22 +154,22 @@ async def _enrich_raw_content_if_needed( messages: 当前时间窗口内的消息列表 extraction_start_time: 提取开始时间 extraction_end_time: 提取结束时间 - + Returns: 处理后的raw_content列表 """ enriched_list = [] - + for raw_content in raw_content_list: # 检查raw_content是否只包含黑话本身(去除空白字符后比较) raw_content_clean = raw_content.strip() content_clean = content.strip() - + # 如果raw_content只包含黑话本身(可能有一些标点或空白),则尝试获取上下文 # 去除所有空白字符后比较,确保只包含黑话本身 raw_content_normalized = raw_content_clean.replace(" ", "").replace("\n", "").replace("\t", "") content_normalized = content_clean.replace(" ", "").replace("\n", "").replace("\t", "") - + if raw_content_normalized == content_normalized: # 在消息列表中查找只包含该黑话的消息(去除空白后比较) target_message = None @@ -160,22 +180,20 @@ async def _enrich_raw_content_if_needed( if msg_content_normalized == content_normalized: target_message = msg break - + if target_message and target_message.time: # 获取该消息的前三条消息 try: previous_messages = get_raw_msg_before_timestamp_with_chat( - chat_id=chat_id, - timestamp=target_message.time, - limit=3 + chat_id=chat_id, timestamp=target_message.time, limit=3 ) - + if previous_messages: # 将前三条消息和当前消息一起格式化 context_messages = previous_messages + [target_message] # 按时间排序 context_messages.sort(key=lambda x: x.time or 0) - + # 格式化为可读消息 formatted_context, _ = await build_readable_messages_with_list( context_messages, @@ -183,7 +201,7 @@ async def _enrich_raw_content_if_needed( timestamp_mode="relative", truncate=False, ) - + if formatted_context.strip(): enriched_list.append(formatted_context.strip()) logger.warning(f"为黑话 {content} 补充了上下文消息") @@ -203,7 +221,7 @@ async def _enrich_raw_content_if_needed( else: # raw_content包含更多内容,直接使用 enriched_list.append(raw_content) - + return enriched_list @@ -217,31 +235,31 @@ def _should_infer_meaning(jargon_obj: Jargon) -> bool: # 如果已完成所有推断,不再推断 if jargon_obj.is_complete: return False - + count = jargon_obj.count or 0 last_inference = jargon_obj.last_inference_count or 0 - + # 阈值列表:3,6, 10, 20, 40, 60, 100 - thresholds = [3,6, 10, 20, 40, 60, 100] - + thresholds = [3, 6, 10, 20, 40, 60, 100] + if count < thresholds[0]: return False - + # 如果count没有超过上次判定值,不需要判定 if count <= last_inference: return False - + # 找到第一个大于last_inference的阈值 next_threshold = None for threshold in thresholds: if threshold > last_inference: next_threshold = threshold break - + # 如果没有找到下一个阈值,说明已经超过100,不应该再推断 if next_threshold is None: return False - + # 检查count是否达到或超过这个阈值 return count >= next_threshold @@ -251,14 +269,14 @@ class JargonMiner: self.chat_id = chat_id self.last_learning_time: float = time.time() # 频率控制,可按需调整 - self.min_messages_for_learning: int = 15 - self.min_learning_interval: float = 20 + self.min_messages_for_learning: int = 10 + self.min_learning_interval: float = 20 self.llm = LLMRequest( model_set=model_config.model_task_config.utils, request_type="jargon.extract", ) - + # 初始化stream_name作为类属性,避免重复提取 chat_manager = get_chat_manager() stream_name = chat_manager.get_stream_name(self.chat_id) @@ -283,17 +301,19 @@ class JargonMiner: try: content = jargon_obj.content raw_content_str = jargon_obj.raw_content or "" - + # 解析raw_content列表 raw_content_list = [] if raw_content_str: try: - raw_content_list = json.loads(raw_content_str) if isinstance(raw_content_str, str) else raw_content_str + raw_content_list = ( + json.loads(raw_content_str) if isinstance(raw_content_str, str) else raw_content_str + ) if not isinstance(raw_content_list, list): raw_content_list = [raw_content_list] if raw_content_list else [] except (json.JSONDecodeError, TypeError): raw_content_list = [raw_content_str] if raw_content_str else [] - + if not raw_content_list: logger.warning(f"jargon {content} 没有raw_content,跳过推断") return @@ -305,12 +325,12 @@ class JargonMiner: content=content, raw_content_list=raw_content_text, ) - + response1, _ = await self.llm.generate_response_async(prompt1, temperature=0.3) if not response1: logger.warning(f"jargon {content} 推断1失败:无响应") return - + # 解析推断1结果 inference1 = None try: @@ -326,7 +346,7 @@ class JargonMiner: except Exception as e: logger.error(f"jargon {content} 推断1解析失败: {e}") return - + # 检查推断1是否表示信息不足无法推断 no_info = inference1.get("no_info", False) meaning1 = inference1.get("meaning", "").strip() @@ -337,18 +357,17 @@ class JargonMiner: jargon_obj.save() return - # 步骤2: 仅基于content推断 prompt2 = await global_prompt_manager.format_prompt( "jargon_inference_content_only_prompt", content=content, ) - + response2, _ = await self.llm.generate_response_async(prompt2, temperature=0.3) if not response2: logger.warning(f"jargon {content} 推断2失败:无响应") return - + # 解析推断2结果 inference2 = None try: @@ -364,13 +383,12 @@ class JargonMiner: except Exception as e: logger.error(f"jargon {content} 推断2解析失败: {e}") return - - logger.info(f"jargon {content} 推断2提示词: {prompt2}") - logger.info(f"jargon {content} 推断2结果: {response2}") - logger.info(f"jargon {content} 推断1提示词: {prompt1}") - logger.info(f"jargon {content} 推断1结果: {response1}") - + # logger.info(f"jargon {content} 推断2提示词: {prompt2}") + # logger.info(f"jargon {content} 推断2结果: {response2}") + # logger.info(f"jargon {content} 推断1提示词: {prompt1}") + # logger.info(f"jargon {content} 推断1结果: {response1}") + if global_config.debug.show_jargon_prompt: logger.info(f"jargon {content} 推断2提示词: {prompt2}") logger.info(f"jargon {content} 推断2结果: {response2}") @@ -381,22 +399,22 @@ class JargonMiner: logger.debug(f"jargon {content} 推断2结果: {response2}") logger.debug(f"jargon {content} 推断1提示词: {prompt1}") logger.debug(f"jargon {content} 推断1结果: {response1}") - + # 步骤3: 比较两个推断结果 prompt3 = await global_prompt_manager.format_prompt( "jargon_compare_inference_prompt", inference1=json.dumps(inference1, ensure_ascii=False), inference2=json.dumps(inference2, ensure_ascii=False), ) - + if global_config.debug.show_jargon_prompt: logger.info(f"jargon {content} 比较提示词: {prompt3}") - + response3, _ = await self.llm.generate_response_async(prompt3, temperature=0.3) if not response3: logger.warning(f"jargon {content} 比较失败:无响应") return - + # 解析比较结果 comparison = None try: @@ -416,7 +434,7 @@ class JargonMiner: # 判断是否为黑话 is_similar = comparison.get("is_similar", False) is_jargon = not is_similar # 如果相似,说明不是黑话;如果有差异,说明是黑话 - + # 更新数据库记录 jargon_obj.is_jargon = is_jargon if is_jargon: @@ -425,33 +443,36 @@ class JargonMiner: else: # 不是黑话,也记录含义(使用推断2的结果,因为含义明确) jargon_obj.meaning = inference2.get("meaning", "") - + # 更新最后一次判定的count值,避免重启后重复判定 jargon_obj.last_inference_count = jargon_obj.count or 0 - + # 如果count>=100,标记为完成,不再进行推断 if (jargon_obj.count or 0) >= 100: jargon_obj.is_complete = True - + jargon_obj.save() - logger.info(f"jargon {content} 推断完成: is_jargon={is_jargon}, meaning={jargon_obj.meaning}, last_inference_count={jargon_obj.last_inference_count}, is_complete={jargon_obj.is_complete}") - + logger.debug( + f"jargon {content} 推断完成: is_jargon={is_jargon}, meaning={jargon_obj.meaning}, last_inference_count={jargon_obj.last_inference_count}, is_complete={jargon_obj.is_complete}" + ) + # 固定输出推断结果,格式化为可读形式 if is_jargon: # 是黑话,输出格式:[聊天名]xxx的含义是 xxxxxxxxxxx meaning = jargon_obj.meaning or "无详细说明" is_global = jargon_obj.is_global if is_global: - logger.info(f"[通用黑话]{content}的含义是 {meaning}") + logger.info(f"[黑话]{content}的含义是 {meaning}") else: logger.info(f"[{self.stream_name}]{content}的含义是 {meaning}") else: # 不是黑话,输出格式:[聊天名]xxx 不是黑话 logger.info(f"[{self.stream_name}]{content} 不是黑话") - + except Exception as e: logger.error(f"jargon推断失败: {e}") import traceback + traceback.print_exc() def should_trigger(self) -> bool: @@ -479,7 +500,7 @@ class JargonMiner: # 记录本次提取的时间窗口,避免重复提取 extraction_start_time = self.last_learning_time extraction_end_time = time.time() - + # 拉取学习窗口内的消息 messages = get_raw_msg_by_timestamp_with_chat_inclusive( chat_id=self.chat_id, @@ -502,7 +523,7 @@ class JargonMiner: response, _ = await self.llm.generate_response_async(prompt, temperature=0.2) if not response: return - + if global_config.debug.show_jargon_prompt: logger.info(f"jargon提取提示词: {prompt}") logger.info(f"jargon提取结果: {response}") @@ -532,7 +553,7 @@ class JargonMiner: continue content = str(item.get("content", "")).strip() raw_content_value = item.get("raw_content", "") - + # 处理raw_content:可能是字符串或列表 raw_content_list = [] if isinstance(raw_content_value, list): @@ -543,12 +564,12 @@ class JargonMiner: raw_content_str = raw_content_value.strip() if raw_content_str: raw_content_list = [raw_content_str] - + if content and raw_content_list: - entries.append({ - "content": content, - "raw_content": raw_content_list - }) + if _contains_bot_self_name(content): + logger.debug(f"解析阶段跳过包含机器人昵称/别名的词条: {content}") + continue + entries.append({"content": content, "raw_content": raw_content_list}) except Exception as e: logger.error(f"解析jargon JSON失败: {e}; 原始: {response}") return @@ -565,13 +586,13 @@ class JargonMiner: if content_key not in seen: seen.add(content_key) uniq_entries.append(entry) - + saved = 0 updated = 0 for entry in uniq_entries: content = entry["content"] raw_content_list = entry["raw_content"] # 已经是列表 - + # 检查并补充raw_content:如果只包含黑话本身,则获取前三条消息作为上下文 raw_content_list = await _enrich_raw_content_if_needed( content=content, @@ -581,60 +602,53 @@ class JargonMiner: extraction_start_time=extraction_start_time, extraction_end_time=extraction_end_time, ) - + try: # 根据all_global配置决定查询逻辑 if global_config.jargon.all_global: # 开启all_global:无视chat_id,查询所有content匹配的记录(所有记录都是全局的) - query = ( - Jargon.select() - .where(Jargon.content == content) - ) + query = Jargon.select().where(Jargon.content == content) else: # 关闭all_global:只查询chat_id匹配的记录(不考虑is_global) - query = ( - Jargon.select() - .where( - (Jargon.chat_id == self.chat_id) & - (Jargon.content == content) - ) - ) - + query = Jargon.select().where((Jargon.chat_id == self.chat_id) & (Jargon.content == content)) + if query.exists(): obj = query.get() try: obj.count = (obj.count or 0) + 1 except Exception: obj.count = 1 - + # 合并raw_content列表:读取现有列表,追加新值,去重 existing_raw_content = [] if obj.raw_content: try: - existing_raw_content = json.loads(obj.raw_content) if isinstance(obj.raw_content, str) else obj.raw_content + existing_raw_content = ( + json.loads(obj.raw_content) if isinstance(obj.raw_content, str) else obj.raw_content + ) if not isinstance(existing_raw_content, list): existing_raw_content = [existing_raw_content] if existing_raw_content else [] except (json.JSONDecodeError, TypeError): existing_raw_content = [obj.raw_content] if obj.raw_content else [] - + # 合并并去重 merged_list = list(dict.fromkeys(existing_raw_content + raw_content_list)) obj.raw_content = json.dumps(merged_list, ensure_ascii=False) - + # 开启all_global时,确保记录标记为is_global=True if global_config.jargon.all_global: obj.is_global = True # 关闭all_global时,保持原有is_global不变(不修改) - + obj.save() - + # 检查是否需要推断(达到阈值且超过上次判定值) if _should_infer_meaning(obj): # 异步触发推断,不阻塞主流程 # 重新加载对象以确保数据最新 jargon_id = obj.id asyncio.create_task(self._infer_meaning_by_id(jargon_id)) - + updated += 1 else: # 没找到匹配记录,创建新记录 @@ -644,13 +658,13 @@ class JargonMiner: else: # 关闭all_global:新记录is_global=False is_global_new = False - + Jargon.create( content=content, raw_content=json.dumps(raw_content_list, ensure_ascii=False), chat_id=self.chat_id, is_global=is_global_new, - count=1 + count=1, ) saved += 1 except Exception as e: @@ -662,13 +676,13 @@ class JargonMiner: # 收集所有提取的jargon内容 jargon_list = [entry["content"] for entry in uniq_entries] jargon_str = ",".join(jargon_list) - + # 输出格式化的结果(使用logger.info会自动应用jargon模块的颜色) logger.info(f"[{self.stream_name}]疑似黑话: {jargon_str}") - + # 更新为本次提取的结束时间,确保不会重复提取相同的消息窗口 self.last_learning_time = extraction_end_time - + if saved or updated: logger.info(f"jargon写入: 新增 {saved} 条,更新 {updated} 条,chat_id={self.chat_id}") except Exception as e: @@ -694,15 +708,11 @@ async def extract_and_store_jargon(chat_id: str) -> None: def search_jargon( - keyword: str, - chat_id: Optional[str] = None, - limit: int = 10, - case_sensitive: bool = False, - fuzzy: bool = True + keyword: str, chat_id: Optional[str] = None, limit: int = 10, case_sensitive: bool = False, fuzzy: bool = True ) -> List[Dict[str, str]]: """ 搜索jargon,支持大小写不敏感和模糊搜索 - + Args: keyword: 搜索关键词 chat_id: 可选的聊天ID @@ -711,21 +721,18 @@ def search_jargon( limit: 返回结果数量限制,默认10 case_sensitive: 是否大小写敏感,默认False(不敏感) fuzzy: 是否模糊搜索,默认True(使用LIKE匹配) - + Returns: List[Dict[str, str]]: 包含content, meaning的字典列表 """ if not keyword or not keyword.strip(): return [] - + keyword = keyword.strip() - + # 构建查询 - query = Jargon.select( - Jargon.content, - Jargon.meaning - ) - + query = Jargon.select(Jargon.content, Jargon.meaning) + # 构建搜索条件 if case_sensitive: # 大小写敏感 @@ -734,7 +741,7 @@ def search_jargon( search_condition = Jargon.content.contains(keyword) else: # 精确匹配 - search_condition = (Jargon.content == keyword) + search_condition = Jargon.content == keyword else: # 大小写不敏感 if fuzzy: @@ -742,10 +749,10 @@ def search_jargon( search_condition = fn.LOWER(Jargon.content).contains(keyword.lower()) else: # 精确匹配(使用LOWER函数) - search_condition = (fn.LOWER(Jargon.content) == keyword.lower()) - + search_condition = fn.LOWER(Jargon.content) == keyword.lower() + query = query.where(search_condition) - + # 根据all_global配置决定查询逻辑 if global_config.jargon.all_global: # 开启all_global:所有记录都是全局的,查询所有is_global=True的记录(无视chat_id) @@ -753,35 +760,28 @@ def search_jargon( else: # 关闭all_global:如果提供了chat_id,优先搜索该聊天或global的jargon if chat_id: - query = query.where( - (Jargon.chat_id == chat_id) | Jargon.is_global - ) - + query = query.where((Jargon.chat_id == chat_id) | Jargon.is_global) + # 只返回有meaning的记录 - query = query.where( - (Jargon.meaning.is_null(False)) & (Jargon.meaning != "") - ) - + query = query.where((Jargon.meaning.is_null(False)) & (Jargon.meaning != "")) + # 按count降序排序,优先返回出现频率高的 query = query.order_by(Jargon.count.desc()) - + # 限制结果数量 query = query.limit(limit) - + # 执行查询并返回结果 results = [] for jargon in query: - results.append({ - "content": jargon.content or "", - "meaning": jargon.meaning or "" - }) - + results.append({"content": jargon.content or "", "meaning": jargon.meaning or ""}) + return results async def store_jargon_from_answer(jargon_keyword: str, answer: str, chat_id: str) -> None: """将黑话存入jargon系统 - + Args: jargon_keyword: 黑话关键词 answer: 答案内容(将概括为raw_content) @@ -794,53 +794,52 @@ async def store_jargon_from_answer(jargon_keyword: str, answer: str, chat_id: st 答案:{answer} 只输出概括后的内容,不要输出其他内容:""" - + success, summary, _, _ = await llm_api.generate_with_model( summary_prompt, model_config=model_config.model_task_config.utils_small, request_type="memory.summarize_jargon", ) - + logger.info(f"概括答案提示: {summary_prompt}") logger.info(f"概括答案: {summary}") - + if not success: logger.warning(f"概括答案失败,使用原始答案: {summary}") summary = answer[:100] # 截取前100字符作为备用 - + raw_content = summary.strip()[:200] # 限制长度 - + # 检查是否已存在 if global_config.jargon.all_global: query = Jargon.select().where(Jargon.content == jargon_keyword) else: - query = Jargon.select().where( - (Jargon.chat_id == chat_id) & - (Jargon.content == jargon_keyword) - ) - + query = Jargon.select().where((Jargon.chat_id == chat_id) & (Jargon.content == jargon_keyword)) + if query.exists(): # 更新现有记录 obj = query.get() obj.count = (obj.count or 0) + 1 - + # 合并raw_content列表 existing_raw_content = [] if obj.raw_content: try: - existing_raw_content = json.loads(obj.raw_content) if isinstance(obj.raw_content, str) else obj.raw_content + existing_raw_content = ( + json.loads(obj.raw_content) if isinstance(obj.raw_content, str) else obj.raw_content + ) if not isinstance(existing_raw_content, list): existing_raw_content = [existing_raw_content] if existing_raw_content else [] except (json.JSONDecodeError, TypeError): existing_raw_content = [obj.raw_content] if obj.raw_content else [] - + # 合并并去重 merged_list = list(dict.fromkeys(existing_raw_content + [raw_content])) obj.raw_content = json.dumps(merged_list, ensure_ascii=False) - + if global_config.jargon.all_global: obj.is_global = True - + obj.save() logger.info(f"更新jargon记录: {jargon_keyword}") else: @@ -851,11 +850,9 @@ async def store_jargon_from_answer(jargon_keyword: str, answer: str, chat_id: st raw_content=json.dumps([raw_content], ensure_ascii=False), chat_id=chat_id, is_global=is_global_new, - count=1 + count=1, ) logger.info(f"创建新jargon记录: {jargon_keyword}") - + except Exception as e: logger.error(f"存储jargon失败: {e}") - - diff --git a/src/llm_models/model_client/gemini_client.py b/src/llm_models/model_client/gemini_client.py index 444c5671..b3fafca0 100644 --- a/src/llm_models/model_client/gemini_client.py +++ b/src/llm_models/model_client/gemini_client.py @@ -147,7 +147,7 @@ def _convert_tool_options(tool_options: list[ToolOption]) -> list[FunctionDeclar param_type_value = tool_option_param.param_type.value if param_type_value == "bool": param_type_value = "boolean" - + return_dict: dict[str, Any] = { "type": param_type_value, "description": tool_option_param.description, diff --git a/src/llm_models/model_client/openai_client.py b/src/llm_models/model_client/openai_client.py index 01e12588..f573d33e 100644 --- a/src/llm_models/model_client/openai_client.py +++ b/src/llm_models/model_client/openai_client.py @@ -122,7 +122,7 @@ def _convert_tool_options(tool_options: list[ToolOption]) -> list[dict[str, Any] param_type_value = tool_option_param.param_type.value if param_type_value == "bool": param_type_value = "boolean" - + return_dict: dict[str, Any] = { "type": param_type_value, "description": tool_option_param.description, diff --git a/src/llm_models/payload_content/message.py b/src/llm_models/payload_content/message.py index ddcdf57f..960de08b 100644 --- a/src/llm_models/payload_content/message.py +++ b/src/llm_models/payload_content/message.py @@ -116,9 +116,7 @@ class MessageBuilder: 构建消息对象 :return: Message对象 """ - if len(self.__content) == 0 and not ( - self.__role == RoleType.Assistant and self.__tool_calls - ): + if len(self.__content) == 0 and not (self.__role == RoleType.Assistant and self.__tool_calls): raise ValueError("内容不能为空") if self.__role == RoleType.Tool and self.__tool_call_id is None: raise ValueError("Tool角色的工具调用ID不能为空") diff --git a/src/llm_models/utils_model.py b/src/llm_models/utils_model.py index 28f63c11..1ed74e03 100644 --- a/src/llm_models/utils_model.py +++ b/src/llm_models/utils_model.py @@ -166,7 +166,7 @@ class LLMRequest: time_cost=time.time() - start_time, ) return content or "", (reasoning_content, model_info.name, tool_calls) - + async def generate_response_with_message_async( self, message_factory: Callable[[BaseClient], List[Message]], diff --git a/src/main.py b/src/main.py index a75d4d26..09ead248 100644 --- a/src/main.py +++ b/src/main.py @@ -36,37 +36,39 @@ class MainSystem: # 使用消息API替代直接的FastAPI实例 self.app: MessageServer = get_global_api() self.server: Server = get_global_server() - - # 注册 WebUI API 路由 - self._register_webui_routes() - - # 设置 WebUI(开发/生产模式) - self._setup_webui() + self.webui_server = None # 独立的 WebUI 服务器 - def _register_webui_routes(self): - """注册 WebUI API 路由""" - try: - from src.webui.routes import router as webui_router - self.server.register_router(webui_router) - logger.info("WebUI API 路由已注册") - except Exception as e: - logger.warning(f"注册 WebUI API 路由失败: {e}") + # 设置独立的 WebUI 服务器 + self._setup_webui_server() - def _setup_webui(self): - """设置 WebUI(根据环境变量决定模式)""" + def _setup_webui_server(self): + """设置独立的 WebUI 服务器""" import os + webui_enabled = os.getenv("WEBUI_ENABLED", "false").lower() == "true" if not webui_enabled: logger.info("WebUI 已禁用") return - + webui_mode = os.getenv("WEBUI_MODE", "production").lower() - + try: - from src.webui.manager import setup_webui - setup_webui(mode=webui_mode) + from src.webui.webui_server import get_webui_server + + self.webui_server = get_webui_server() + + if webui_mode == "development": + logger.info("📝 WebUI 开发模式已启用") + logger.info("🌐 后端 API 将运行在 http://0.0.0.0:8001") + logger.info("💡 请手动启动前端开发服务器: cd MaiBot-Dashboard && bun dev") + logger.info("💡 前端将运行在 http://localhost:7999") + else: + logger.info("✅ WebUI 生产模式已启用") + logger.info(f"🌐 WebUI 将运行在 http://0.0.0.0:8001") + logger.info("💡 请确保已构建前端: cd MaiBot-Dashboard && bun run build") + except Exception as e: - logger.error(f"设置 WebUI 失败: {e}") + logger.error(f"❌ 初始化 WebUI 服务器失败: {e}") async def initialize(self): """初始化系统组件""" @@ -161,6 +163,10 @@ class MainSystem: self.server.run(), ] + # 如果 WebUI 服务器已初始化,添加到任务列表 + if self.webui_server: + tasks.append(self.webui_server.start()) + await asyncio.gather(*tasks) # async def forget_memory_task(self): diff --git a/src/memory_system/memory_retrieval.py b/src/memory_system/memory_retrieval.py index a878f14d..7d129bbc 100644 --- a/src/memory_system/memory_retrieval.py +++ b/src/memory_system/memory_retrieval.py @@ -11,15 +11,43 @@ from src.plugin_system.apis import llm_api from src.common.database.database_model import ThinkingBack from json_repair import repair_json from src.memory_system.retrieval_tools import get_tool_registry, init_all_tools +from src.memory_system.retrieval_tools.query_lpmm_knowledge import query_lpmm_knowledge from src.llm_models.payload_content.message import MessageBuilder, RoleType, Message logger = get_logger("memory_retrieval") +THINKING_BACK_NOT_FOUND_RETENTION_SECONDS = 36000 # 未找到答案记录保留时长 +THINKING_BACK_CLEANUP_INTERVAL_SECONDS = 3000 # 清理频率 +_last_not_found_cleanup_ts: float = 0.0 + + +def _cleanup_stale_not_found_thinking_back() -> None: + """定期清理过期的未找到答案记录""" + global _last_not_found_cleanup_ts + + now = time.time() + if now - _last_not_found_cleanup_ts < THINKING_BACK_CLEANUP_INTERVAL_SECONDS: + return + + threshold_time = now - THINKING_BACK_NOT_FOUND_RETENTION_SECONDS + try: + deleted_rows = ( + ThinkingBack.delete() + .where((ThinkingBack.found_answer == 0) & (ThinkingBack.update_time < threshold_time)) + .execute() + ) + if deleted_rows: + logger.info(f"清理过期的未找到答案thinking_back记录 {deleted_rows} 条") + _last_not_found_cleanup_ts = now + except Exception as e: + logger.error(f"清理未找到答案的thinking_back记录失败: {e}") + + def init_memory_retrieval_prompt(): """初始化记忆检索相关的 prompt 模板和工具""" # 首先注册所有工具 init_all_tools() - + # 第一步:问题生成prompt Prompt( """ @@ -34,20 +62,17 @@ def init_memory_retrieval_prompt(): 1. 对话中是否提到了过去发生的事情、人物、事件或信息 2. 是否有需要回忆的内容(比如"之前说过"、"上次"、"以前"等) 3. 是否有需要查找历史信息的问题 -4. 是否需要查找某人的信息(person: 如果对话中提到人名、昵称、用户ID等,需要查询该人物的详细信息) -5. 是否有问题可以搜集信息帮助你聊天 -6. 对话中是否包含黑话、俚语、缩写等可能需要查询的概念 +4. 是否有问题可以搜集信息帮助你聊天 +5. 对话中是否包含黑话、俚语、缩写等可能需要查询的概念 重要提示: - **每次只能提出一个问题**,选择最需要查询的关键问题 -- 如果"最近已查询的问题和结果"中已经包含了类似的问题,请避免重复生成相同或相似的问题 +- 如果"最近已查询的问题和结果"中已经包含了类似的问题并得到了答案,请避免重复生成相同或相似的问题,不需要重复查询 - 如果之前已经查询过某个问题但未找到答案,可以尝试用不同的方式提问或更具体的问题 -- 如果之前已经查询过某个问题并找到了答案,可以直接参考已有结果,不需要重复查询 如果你认为需要从记忆中检索信息来回答,请: -1. 先识别对话中可能需要查询的概念(黑话/俚语/缩写/专有名词等关键词),放入"concepts"字段 -2. 识别对话中提到的人物名称(人名、昵称等),放入"person"字段 -3. 然后根据上下文提出**一个**最关键的问题来帮助你回复目标消息,放入"questions"字段 +1. 识别对话中可能需要查询的概念(黑话/俚语/缩写/专有名词等关键词),放入"concepts"字段 +2. 根据上下文提出**一个**最关键的问题来帮助你回复目标消息,放入"questions"字段 问题格式示例: - "xxx在前几天干了什么" @@ -55,17 +80,11 @@ def init_memory_retrieval_prompt(): - "xxxx和xxx的关系是什么" - "xxx在某个时间点发生了什么" -请输出JSON格式,包含三个字段: -- "concepts": 需要检索的概念列表(字符串数组),如果不需要检索概念则输出空数组[] -- "person": 需要查询的人物名称列表(字符串数组),如果不需要查询人物信息则输出空数组[] -- "questions": 问题数组(字符串数组),如果不需要检索记忆则输出空数组[],如果需要检索则只输出包含一个问题的数组 - 输出格式示例(需要检索时): ```json {{ - "concepts": ["AAA", "BBB", "CCC"], - "person": ["张三", "李四"], - "questions": ["张三在前几天干了什么"] + "concepts": ["AAA", "BBB", "CCC"], #需要检索的概念列表(字符串数组),如果不需要检索概念则输出空数组[] + "questions": ["张三在前几天干了什么"] #问题数组(字符串数组),如果不需要检索记忆则输出空数组[],如果需要检索则只输出包含一个问题的数组 }} ``` @@ -73,7 +92,6 @@ def init_memory_retrieval_prompt(): ```json {{ "concepts": [], - "person": [], "questions": [] }} ``` @@ -82,13 +100,11 @@ def init_memory_retrieval_prompt(): """, name="memory_retrieval_question_prompt", ) - + # 第二步:ReAct Agent prompt(使用function calling,要求先思考再行动) Prompt( - """ -你的名字是{bot_name}。现在是{time_now}。 + """你的名字是{bot_name}。现在是{time_now}。 你正在参与聊天,你需要搜集信息来回答问题,帮助你参与聊天。 -你需要通过思考(Think)、行动(Action)、观察(Observation)的循环来回答问题。 **重要限制:** - 最大查询轮数:{max_iterations}轮(当前第{current_iteration}轮,剩余{remaining_iterations}轮) @@ -101,76 +117,32 @@ def init_memory_retrieval_prompt(): {collected_info} **执行步骤:** - **第一步:思考(Think)** 在思考中分析: - 当前信息是否足够回答问题? - **如果信息足够且能找到明确答案**,在思考中直接给出答案,格式为:found_answer(answer="你的答案内容") -- **如果信息不足或无法找到答案**,在思考中给出:not_enough_info(reason="信息不足或无法找到答案的原因") -- 如果还需要继续查询,说明最需要查询什么,并输出为纯文本说明 +- **如果需要尝试搜集更多信息,进一步调用工具,进入第二步行动环节 +- **如果已有信息不足或无法找到答案**,在思考中给出:not_enough_info(reason="信息不足或无法找到答案的原因") **第二步:行动(Action)** -根据思考结果立即行动: -- 如果思考中已给出found_answer → 无需调用工具,直接结束 -- 如果思考中已给出not_enough_info → 无需调用工具,直接结束 -- 如果信息不足且需要继续查询 → 调用相应工具查询(可并行调用多个工具) +- 如果涉及过往事件,可以使用聊天记录查询工具查询过往事件 +- 如果涉及概念,可以用jargon查询,或根据关键词检索聊天记录 +- 如果涉及人物,可以使用人物信息查询工具查询人物信息 +- 如果不确定查询类别,也可以使用lpmm知识库查询 +- 如果信息不足且需要继续查询,说明最需要查询什么,并输出为纯文本说明,然后调用相应工具查询(可并行调用多个工具) **重要规则:** - **只有在检索到明确、有关的信息并得出答案时,才使用found_answer** - **如果信息不足、无法确定、找不到相关信息,必须使用not_enough_info,不要使用found_answer** -- 答案必须在思考中给出,格式为 found_answer(answer="...") 或 not_enough_info(reason="..."),不要调用工具。 -""", - name="memory_retrieval_react_prompt", - ) - - # 第二步:ReAct Agent prompt(使用function calling,要求先思考再行动) - Prompt( - """ -你的名字是{bot_name}。现在是{time_now}。 -你正在参与聊天,你需要搜集信息来回答问题,帮助你参与聊天。 -你需要通过思考(Think)、行动(Action)、观察(Observation)的循环来回答问题。 - -**重要限制:** -- 最大查询轮数:{max_iterations}轮(当前第{current_iteration}轮,剩余{remaining_iterations}轮) -- 必须尽快得出答案,避免不必要的查询 -- 思考要简短,直接切入要点 -- 必须严格使用检索到的信息回答问题,不要编造信息 - -当前问题:{question} - -**执行步骤:** - -**第一步:思考(Think)** -在思考中分析: -- 当前信息是否足够回答问题? -- **如果信息足够且能找到明确答案**,在思考中直接给出答案,格式为:found_answer(answer="你的答案内容") -- **如果信息不足或无法找到答案**,在思考中给出:not_enough_info(reason="信息不足或无法找到答案的原因") -- 如果还需要继续查询,说明最需要查询什么,并输出为纯文本说明 - -**第二步:行动(Action)** -根据思考结果立即行动: -- 如果思考中已给出found_answer → 无需调用工具,直接结束 -- 如果思考中已给出not_enough_info → 无需调用工具,直接结束 -- 如果信息不足且需要继续查询 → 调用相应工具查询(可并行调用多个工具) - -**重要规则:** -- **只有在检索到明确、具体的答案时,才使用found_answer** -- **如果信息不足、无法确定、找不到相关信息,必须使用not_enough_info,不要使用found_answer** -- 答案必须在思考中给出,格式为 found_answer(answer="...") 或 not_enough_info(reason="..."),不要调用工具。 +- 答案必须在思考中给出,格式为 found_answer(answer="...") 或 not_enough_info(reason="...") """, name="memory_retrieval_react_prompt_head", ) - + # 额外,如果最后一轮迭代:ReAct Agent prompt(使用function calling,要求先思考再行动) Prompt( - """ -你的名字是{bot_name}。现在是{time_now}。 -你正在参与聊天,你需要搜集信息来回答问题,帮助你参与聊天。 - -**重要限制:** -- 你已经经过几轮查询,尝试了信息搜集,现在你需要总结信息,选择回答问题或判断问题无法回答 -- 思考要简短,直接切入要点 -- 必须严格使用检索到的信息回答问题,不要编造信息 + """你的名字是{bot_name}。现在是{time_now}。 +你正在参与聊天,你需要根据搜集到的信息判断问题是否可以回答问题。 当前问题:{question} 已收集的信息: @@ -183,6 +155,9 @@ def init_memory_retrieval_prompt(): - **如果信息不足或无法找到答案**,在思考中给出:not_enough_info(reason="信息不足或无法找到答案的原因") **重要规则:** +- 你已经经过几轮查询,尝试了信息搜集,现在你需要总结信息,选择回答问题或判断问题无法回答 +- 必须严格使用检索到的信息回答问题,不要编造信息 +- 答案必须精简,不要过多解释 - **只有在检索到明确、具体的答案时,才使用found_answer** - **如果信息不足、无法确定、找不到相关信息,必须使用not_enough_info,不要使用found_answer** - 答案必须给出,格式为 found_answer(answer="...") 或 not_enough_info(reason="...")。 @@ -193,10 +168,10 @@ def init_memory_retrieval_prompt(): def _parse_react_response(response: str) -> Optional[Dict[str, Any]]: """解析ReAct Agent的响应 - + Args: response: LLM返回的响应 - + Returns: Dict[str, Any]: 解析后的动作信息,如果解析失败返回None 格式: {"thought": str, "actions": List[Dict[str, Any]]} @@ -206,90 +181,75 @@ def _parse_react_response(response: str) -> Optional[Dict[str, Any]]: # 尝试提取JSON(可能包含在```json代码块中) json_pattern = r"```json\s*(.*?)\s*```" matches = re.findall(json_pattern, response, re.DOTALL) - + if matches: json_str = matches[0] else: # 尝试直接解析整个响应 json_str = response.strip() - + # 修复可能的JSON错误 repaired_json = repair_json(json_str) - + # 解析JSON action_info = json.loads(repaired_json) - + if not isinstance(action_info, dict): logger.warning(f"解析的JSON不是对象格式: {action_info}") return None - + # 确保actions字段存在且为列表 if "actions" not in action_info: logger.warning(f"响应中缺少actions字段: {action_info}") return None - + if not isinstance(action_info["actions"], list): logger.warning(f"actions字段不是数组格式: {action_info['actions']}") return None - + # 确保actions不为空 if len(action_info["actions"]) == 0: logger.warning("actions数组为空") return None - + return action_info - + except Exception as e: logger.error(f"解析ReAct响应失败: {e}, 响应内容: {response[:200]}...") return None -async def _retrieve_concepts_with_jargon( - concepts: List[str], - chat_id: str -) -> str: +async def _retrieve_concepts_with_jargon(concepts: List[str], chat_id: str) -> str: """对概念列表进行jargon检索 - + Args: concepts: 概念列表 chat_id: 聊天ID - + Returns: str: 检索结果字符串 """ if not concepts: return "" - + from src.jargon.jargon_miner import search_jargon - + results = [] for concept in concepts: concept = concept.strip() if not concept: continue - + # 先尝试精确匹配 - jargon_results = search_jargon( - keyword=concept, - chat_id=chat_id, - limit=10, - case_sensitive=False, - fuzzy=False - ) - + jargon_results = search_jargon(keyword=concept, chat_id=chat_id, limit=10, case_sensitive=False, fuzzy=False) + is_fuzzy_match = False - + # 如果精确匹配未找到,尝试模糊搜索 if not jargon_results: - jargon_results = search_jargon( - keyword=concept, - chat_id=chat_id, - limit=10, - case_sensitive=False, - fuzzy=True - ) + jargon_results = search_jargon(keyword=concept, chat_id=chat_id, limit=10, case_sensitive=False, fuzzy=True) is_fuzzy_match = True - + if jargon_results: # 找到结果 if is_fuzzy_match: @@ -312,72 +272,26 @@ async def _retrieve_concepts_with_jargon( results.append(";".join(output_parts) if len(output_parts) > 1 else output_parts[0]) logger.info(f"在jargon库中找到匹配(精确匹配): {concept},找到{len(jargon_results)}条结果") else: - # 未找到 - results.append(f"未在jargon库中找到'{concept}'的解释") + # 未找到,不返回占位信息,只记录日志 logger.info(f"在jargon库中未找到匹配: {concept}") - + if results: return "【概念检索结果】\n" + "\n".join(results) + "\n" return "" -async def _retrieve_persons_info( - persons: List[str], - chat_id: str -) -> str: - """对人物列表进行信息检索 - - Args: - persons: 人物名称列表 - chat_id: 聊天ID - - Returns: - str: 检索结果字符串 - """ - if not persons: - return "" - - from src.memory_system.retrieval_tools.query_person_info import query_person_info - - results = [] - for person in persons: - person = person.strip() - if not person: - continue - - try: - person_info = await query_person_info(person) - if person_info and "未找到" not in person_info: - results.append(f"【{person}】\n{person_info}") - logger.info(f"查询到人物信息: {person}") - else: - results.append(f"未找到人物'{person}'的信息") - logger.info(f"未找到人物信息: {person}") - except Exception as e: - logger.error(f"查询人物信息失败: {person}, 错误: {e}") - results.append(f"查询人物'{person}'信息时发生错误: {str(e)}") - - if results: - return "【人物信息检索结果】\n" + "\n\n".join(results) + "\n" - return "" - - async def _react_agent_solve_question( - question: str, - chat_id: str, - max_iterations: int = 5, - timeout: float = 30.0, - initial_info: str = "" + question: str, chat_id: str, max_iterations: int = 5, timeout: float = 30.0, initial_info: str = "" ) -> Tuple[bool, str, List[Dict[str, Any]], bool]: """使用ReAct架构的Agent来解决问题 - + Args: question: 要回答的问题 chat_id: 聊天ID max_iterations: 最大迭代次数 timeout: 超时时间(秒) initial_info: 初始信息(如概念检索结果),将作为collected_info的初始值 - + Returns: Tuple[bool, str, List[Dict[str, Any]], bool]: (是否找到答案, 答案内容, 思考步骤列表, 是否超时) """ @@ -386,58 +300,67 @@ async def _react_agent_solve_question( thinking_steps = [] is_timeout = False conversation_messages: List[Message] = [] - + for iteration in range(max_iterations): # 检查超时 if time.time() - start_time > timeout: logger.warning(f"ReAct Agent超时,已迭代{iteration}次") is_timeout = True break - + # 获取工具注册器 tool_registry = get_tool_registry() - + # 获取bot_name bot_name = global_config.bot.nickname - + # 获取当前时间 time_now = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) - + # 计算剩余迭代次数 current_iteration = iteration + 1 remaining_iterations = max_iterations - current_iteration is_final_iteration = current_iteration >= max_iterations - - # 构建prompt(不再需要工具文本描述) - prompt_type = "memory_retrieval_react_prompt" if is_final_iteration: - prompt_type = "memory_retrieval_react_final_prompt" + # 最后一次迭代,使用最终prompt tool_definitions = [] - logger.info(f"ReAct Agent 第 {iteration + 1} 次迭代,问题: {question}|可用工具数量: 0(最后一次迭代,不提供工具调用)") + logger.info( + f"ReAct Agent 第 {iteration + 1} 次迭代,问题: {question}|可用工具数量: 0(最后一次迭代,不提供工具调用)" + ) + + prompt = await global_prompt_manager.format_prompt( + "memory_retrieval_react_final_prompt", + bot_name=bot_name, + time_now=time_now, + question=question, + collected_info=collected_info if collected_info else "暂无信息", + current_iteration=current_iteration, + remaining_iterations=remaining_iterations, + max_iterations=max_iterations, + ) + + if global_config.debug.show_memory_prompt: + logger.info(f"ReAct Agent 第 {iteration + 1} 次Prompt: {prompt}") + success, response, reasoning_content, model_name, tool_calls = await llm_api.generate_with_model_with_tools( + prompt, + model_config=model_config.model_task_config.tool_use, + tool_options=tool_definitions, + request_type="memory.react", + ) else: + # 非最终迭代,使用head_prompt tool_definitions = tool_registry.get_tool_definitions() - logger.info(f"ReAct Agent 第 {iteration + 1} 次迭代,问题: {question}|可用工具数量: {len(tool_definitions)}") + logger.info( + f"ReAct Agent 第 {iteration + 1} 次迭代,问题: {question}|可用工具数量: {len(tool_definitions)}" + ) - prompt = await global_prompt_manager.format_prompt( - prompt_type, - bot_name=bot_name, - time_now=time_now, - question=question, - collected_info=collected_info if collected_info else "暂无信息", - current_iteration=current_iteration, - remaining_iterations=remaining_iterations, - max_iterations=max_iterations, - ) - - - - if not is_final_iteration: head_prompt = await global_prompt_manager.format_prompt( "memory_retrieval_react_prompt_head", bot_name=bot_name, time_now=time_now, question=question, + collected_info=collected_info if collected_info else "", current_iteration=current_iteration, remaining_iterations=remaining_iterations, max_iterations=max_iterations, @@ -447,7 +370,6 @@ async def _react_agent_solve_question( _client, *, _head_prompt: str = head_prompt, - _prompt: str = prompt, _conversation_messages: List[Message] = conversation_messages, ) -> List[Message]: messages: List[Message] = [] @@ -455,34 +377,67 @@ async def _react_agent_solve_question( system_builder = MessageBuilder() system_builder.set_role(RoleType.System) system_builder.add_text_content(_head_prompt) - if _prompt.strip(): - system_builder.add_text_content(f"\n{_prompt}") messages.append(system_builder.build()) messages.extend(_conversation_messages) - - # for msg in messages: - # print(msg) + + if global_config.debug.show_memory_prompt: + # 优化日志展示 - 合并所有消息到一条日志 + log_lines = [] + for idx, msg in enumerate(messages, 1): + role_name = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + + # 处理内容 - 显示完整内容,不截断 + if isinstance(msg.content, str): + full_content = msg.content + content_type = "文本" + elif isinstance(msg.content, list): + text_parts = [item for item in msg.content if isinstance(item, str)] + image_count = len([item for item in msg.content if isinstance(item, tuple)]) + full_content = "".join(text_parts) if text_parts else "" + content_type = f"混合({len(text_parts)}段文本, {image_count}张图片)" + else: + full_content = str(msg.content) + content_type = "未知" + + # 构建单条消息的日志信息 + msg_info = f"\n[消息 {idx}] 角色: {role_name} 内容类型: {content_type}\n========================================" + + if full_content: + msg_info += f"\n{full_content}" + + if msg.tool_calls: + msg_info += f"\n 工具调用: {len(msg.tool_calls)}个" + for tool_call in msg.tool_calls: + msg_info += f"\n - {tool_call}" + + if msg.tool_call_id: + msg_info += f"\n 工具调用ID: {msg.tool_call_id}" + + log_lines.append(msg_info) + + # 合并所有消息为一条日志输出 + logger.info(f"消息列表 (共{len(messages)}条):{''.join(log_lines)}") return messages - success, response, reasoning_content, model_name, tool_calls = await llm_api.generate_with_model_with_tools_by_message_factory( + ( + success, + response, + reasoning_content, + model_name, + tool_calls, + ) = await llm_api.generate_with_model_with_tools_by_message_factory( message_factory, model_config=model_config.model_task_config.tool_use, tool_options=tool_definitions, request_type="memory.react", ) - else: - logger.info(f"ReAct Agent 第 {iteration + 1} 次Prompt: {prompt}") - success, response, reasoning_content, model_name, tool_calls = await llm_api.generate_with_model_with_tools( - prompt, - model_config=model_config.model_task_config.tool_use, - tool_options=tool_definitions, - request_type="memory.react", - ) - - logger.info(f"ReAct Agent 第 {iteration + 1} 次迭代 模型: {model_name} ,调用工具数量: {len(tool_calls) if tool_calls else 0} ,调用工具响应: {response}") - + + logger.info( + f"ReAct Agent 第 {iteration + 1} 次迭代 模型: {model_name} ,调用工具数量: {len(tool_calls) if tool_calls else 0} ,调用工具响应: {response}" + ) + if not success: logger.error(f"ReAct Agent LLM调用失败: {response}") break @@ -500,105 +455,108 @@ async def _react_agent_solve_question( assistant_builder.set_role(RoleType.Assistant) assistant_builder.add_text_content(response) assistant_message = assistant_builder.build() - + # 记录思考步骤 - step = { - "iteration": iteration + 1, - "thought": response, - "actions": [], - "observations": [] - } - + step = {"iteration": iteration + 1, "thought": response, "actions": [], "observations": []} + # 优先从思考内容中提取found_answer或not_enough_info def extract_quoted_content(text, func_name, param_name): """从文本中提取函数调用中参数的值,支持单引号和双引号 - + Args: text: 要搜索的文本 func_name: 函数名,如 'found_answer' param_name: 参数名,如 'answer' - + Returns: 提取的参数值,如果未找到则返回None """ if not text: return None - + # 查找函数调用位置(不区分大小写) func_pattern = func_name.lower() text_lower = text.lower() func_pos = text_lower.find(func_pattern) if func_pos == -1: return None - + # 查找参数名和等号 - param_pattern = f'{param_name}=' + param_pattern = f"{param_name}=" param_pos = text_lower.find(param_pattern, func_pos) if param_pos == -1: return None - + # 跳过参数名、等号和空白 start_pos = param_pos + len(param_pattern) - while start_pos < len(text) and text[start_pos] in ' \t\n': + while start_pos < len(text) and text[start_pos] in " \t\n": start_pos += 1 - + if start_pos >= len(text): return None - + # 确定引号类型 quote_char = text[start_pos] if quote_char not in ['"', "'"]: return None - + # 查找匹配的结束引号(考虑转义) end_pos = start_pos + 1 while end_pos < len(text): if text[end_pos] == quote_char: # 检查是否是转义的引号 - if end_pos > start_pos + 1 and text[end_pos - 1] == '\\': + if end_pos > start_pos + 1 and text[end_pos - 1] == "\\": end_pos += 1 continue # 找到匹配的引号 - content = text[start_pos + 1:end_pos] + content = text[start_pos + 1 : end_pos] # 处理转义字符 - content = content.replace('\\"', '"').replace("\\'", "'").replace('\\\\', '\\') + content = content.replace('\\"', '"').replace("\\'", "'").replace("\\\\", "\\") return content end_pos += 1 - + return None - + # 从LLM的直接输出内容中提取found_answer或not_enough_info found_answer_content = None not_enough_info_reason = None - + # 只检查response(LLM的直接输出内容),不检查reasoning_content if response: - found_answer_content = extract_quoted_content(response, 'found_answer', 'answer') + found_answer_content = extract_quoted_content(response, "found_answer", "answer") if not found_answer_content: - not_enough_info_reason = extract_quoted_content(response, 'not_enough_info', 'reason') - + not_enough_info_reason = extract_quoted_content(response, "not_enough_info", "reason") + # 如果从输出内容中找到了答案,直接返回 if found_answer_content: step["actions"].append({"action_type": "found_answer", "action_params": {"answer": found_answer_content}}) step["observations"] = ["从LLM输出内容中检测到found_answer"] thinking_steps.append(step) - logger.info(f"ReAct Agent 第 {iteration + 1} 次迭代 从LLM输出内容中检测到found_answer: {found_answer_content[:100]}...") + logger.info( + f"ReAct Agent 第 {iteration + 1} 次迭代 从LLM输出内容中检测到found_answer: {found_answer_content[:100]}..." + ) return True, found_answer_content, thinking_steps, False - + if not_enough_info_reason: - step["actions"].append({"action_type": "not_enough_info", "action_params": {"reason": not_enough_info_reason}}) + step["actions"].append( + {"action_type": "not_enough_info", "action_params": {"reason": not_enough_info_reason}} + ) step["observations"] = ["从LLM输出内容中检测到not_enough_info"] thinking_steps.append(step) - logger.info(f"ReAct Agent 第 {iteration + 1} 次迭代 从LLM输出内容中检测到not_enough_info: {not_enough_info_reason[:100]}...") + logger.info( + f"ReAct Agent 第 {iteration + 1} 次迭代 从LLM输出内容中检测到not_enough_info: {not_enough_info_reason[:100]}..." + ) return False, not_enough_info_reason, thinking_steps, False - + if is_final_iteration: - step["actions"].append({"action_type": "not_enough_info", "action_params": {"reason": "已到达最后一次迭代,无法找到答案"}}) + step["actions"].append( + {"action_type": "not_enough_info", "action_params": {"reason": "已到达最后一次迭代,无法找到答案"}} + ) step["observations"] = ["已到达最后一次迭代,无法找到答案"] thinking_steps.append(step) logger.info(f"ReAct Agent 第 {iteration + 1} 次迭代 已到达最后一次迭代,无法找到答案") return False, "已到达最后一次迭代,无法找到答案", thinking_steps, False - + if assistant_message: conversation_messages.append(assistant_message) @@ -607,7 +565,7 @@ async def _react_agent_solve_question( thought_summary = reasoning_content or (response[:200] if response else "") if thought_summary: collected_info += f"\n[思考] {thought_summary}\n" - + # 处理工具调用 if not tool_calls: # 没有工具调用,说明LLM在思考中已经给出了答案(已在前面检查),或者需要继续查询 @@ -625,28 +583,31 @@ async def _react_agent_solve_question( step["observations"] = ["无响应且无工具调用"] thinking_steps.append(step) break - + # 处理工具调用 tool_tasks = [] - + for i, tool_call in enumerate(tool_calls): tool_name = tool_call.func_name tool_args = tool_call.args or {} - - logger.info(f"ReAct Agent 第 {iteration + 1} 次迭代 工具调用 {i+1}/{len(tool_calls)}: {tool_name}({tool_args})") - + + logger.info( + f"ReAct Agent 第 {iteration + 1} 次迭代 工具调用 {i + 1}/{len(tool_calls)}: {tool_name}({tool_args})" + ) + # 普通工具调用 tool = tool_registry.get_tool(tool_name) if tool: # 准备工具参数(需要添加chat_id如果工具需要) tool_params = tool_args.copy() - + # 如果工具函数签名需要chat_id,添加它 import inspect + sig = inspect.signature(tool.execute_func) if "chat_id" in sig.parameters: tool_params["chat_id"] = chat_id - + # 创建异步任务 async def execute_single_tool(tool_instance, params, tool_name_str, iter_num): try: @@ -657,23 +618,23 @@ async def _react_agent_solve_question( error_msg = f"工具执行失败: {str(e)}" logger.error(f"ReAct Agent 第 {iter_num + 1} 次迭代 工具 {tool_name_str} {error_msg}") return f"查询{tool_name_str}失败: {error_msg}" - + tool_tasks.append(execute_single_tool(tool, tool_params, tool_name, iteration)) step["actions"].append({"action_type": tool_name, "action_params": tool_args}) else: error_msg = f"未知的工具类型: {tool_name}" - logger.warning(f"ReAct Agent 第 {iteration + 1} 次迭代 工具 {i+1}/{len(tool_calls)} {error_msg}") + logger.warning(f"ReAct Agent 第 {iteration + 1} 次迭代 工具 {i + 1}/{len(tool_calls)} {error_msg}") tool_tasks.append(asyncio.create_task(asyncio.sleep(0, result=f"查询{tool_name}失败: {error_msg}"))) - + # 并行执行所有工具 if tool_tasks: observations = await asyncio.gather(*tool_tasks, return_exceptions=True) - + # 处理执行结果 for i, (tool_call_item, observation) in enumerate(zip(tool_calls, observations, strict=False)): if isinstance(observation, Exception): observation = f"工具执行异常: {str(observation)}" - logger.error(f"ReAct Agent 第 {iteration + 1} 次迭代 工具 {i+1} 执行异常: {observation}") + logger.error(f"ReAct Agent 第 {iteration + 1} 次迭代 工具 {i + 1} 执行异常: {observation}") observation_text = observation if isinstance(observation, str) else str(observation) step["observations"].append(observation_text) @@ -685,14 +646,16 @@ async def _react_agent_solve_question( tool_builder.add_tool_call(tool_call_item.call_id) conversation_messages.append(tool_builder.build()) # logger.info(f"ReAct Agent 第 {iteration + 1} 次迭代 工具 {i+1} 执行结果: {observation_text}") - + thinking_steps.append(step) - + # 达到最大迭代次数或超时,但Agent没有明确返回found_answer # 迭代超时应该直接视为not_enough_info,而不是使用已有信息 # 只有Agent明确返回found_answer时,才认为找到了答案 if collected_info: - logger.warning(f"ReAct Agent达到最大迭代次数或超时,但未明确返回found_answer。已收集信息: {collected_info[:100]}...") + logger.warning( + f"ReAct Agent达到最大迭代次数或超时,但未明确返回found_answer。已收集信息: {collected_info[:100]}..." + ) if is_timeout: logger.warning("ReAct Agent超时,直接视为not_enough_info") else: @@ -702,35 +665,32 @@ async def _react_agent_solve_question( def _get_recent_query_history(chat_id: str, time_window_seconds: float = 300.0) -> str: """获取最近一段时间内的查询历史 - + Args: chat_id: 聊天ID time_window_seconds: 时间窗口(秒),默认10分钟 - + Returns: str: 格式化的查询历史字符串 """ try: current_time = time.time() start_time = current_time - time_window_seconds - + # 查询最近时间窗口内的记录,按更新时间倒序 records = ( ThinkingBack.select() - .where( - (ThinkingBack.chat_id == chat_id) & - (ThinkingBack.update_time >= start_time) - ) + .where((ThinkingBack.chat_id == chat_id) & (ThinkingBack.update_time >= start_time)) .order_by(ThinkingBack.update_time.desc()) .limit(5) # 最多返回5条最近的记录 ) - + if not records.exists(): return "" - + history_lines = [] history_lines.append("最近已查询的问题和结果:") - + for record in records: status = "✓ 已找到答案" if record.found_answer else "✗ 未找到答案" answer_preview = "" @@ -740,15 +700,15 @@ def _get_recent_query_history(chat_id: str, time_window_seconds: float = 300.0) answer_preview = record.answer[:100] if len(record.answer) > 100: answer_preview += "..." - + history_lines.append(f"- 问题:{record.question}") history_lines.append(f" 状态:{status}") if answer_preview: history_lines.append(f" 答案:{answer_preview}") history_lines.append("") # 空行分隔 - + return "\n".join(history_lines) - + except Exception as e: logger.error(f"获取查询历史失败: {e}") return "" @@ -756,40 +716,40 @@ def _get_recent_query_history(chat_id: str, time_window_seconds: float = 300.0) def _get_cached_memories(chat_id: str, time_window_seconds: float = 300.0) -> List[str]: """获取最近一段时间内缓存的记忆(只返回找到答案的记录) - + Args: chat_id: 聊天ID time_window_seconds: 时间窗口(秒),默认300秒(5分钟) - + Returns: List[str]: 格式化的记忆列表,每个元素格式为 "问题:xxx\n答案:xxx" """ try: current_time = time.time() start_time = current_time - time_window_seconds - + # 查询最近时间窗口内找到答案的记录,按更新时间倒序 records = ( ThinkingBack.select() .where( - (ThinkingBack.chat_id == chat_id) & - (ThinkingBack.update_time >= start_time) & - (ThinkingBack.found_answer == 1) + (ThinkingBack.chat_id == chat_id) + & (ThinkingBack.update_time >= start_time) + & (ThinkingBack.found_answer == 1) ) .order_by(ThinkingBack.update_time.desc()) .limit(5) # 最多返回5条最近的记录 ) - + if not records.exists(): return [] - + cached_memories = [] for record in records: if record.answer: cached_memories.append(f"问题:{record.question}\n答案:{record.answer}") - + return cached_memories - + except Exception as e: logger.error(f"获取缓存记忆失败: {e}") return [] @@ -797,11 +757,11 @@ def _get_cached_memories(chat_id: str, time_window_seconds: float = 300.0) -> Li def _query_thinking_back(chat_id: str, question: str) -> Optional[Tuple[bool, str]]: """从thinking_back数据库中查询是否有现成的答案 - + Args: chat_id: 聊天ID question: 问题 - + Returns: Optional[Tuple[bool, str]]: 如果找到记录,返回(found_answer, answer),否则返回None found_answer: 是否找到答案(True表示found_answer=1,False表示found_answer=0) @@ -812,23 +772,20 @@ def _query_thinking_back(chat_id: str, question: str) -> Optional[Tuple[bool, st # 按更新时间倒序,获取最新的记录 records = ( ThinkingBack.select() - .where( - (ThinkingBack.chat_id == chat_id) & - (ThinkingBack.question == question) - ) + .where((ThinkingBack.chat_id == chat_id) & (ThinkingBack.question == question)) .order_by(ThinkingBack.update_time.desc()) .limit(1) ) - + if records.exists(): record = records.get() found_answer = bool(record.found_answer) answer = record.answer or "" logger.info(f"在thinking_back中找到记录,问题: {question[:50]}...,found_answer: {found_answer}") return found_answer, answer - + return None - + except Exception as e: logger.error(f"查询thinking_back失败: {e}") return None @@ -836,7 +793,7 @@ def _query_thinking_back(chat_id: str, question: str) -> Optional[Tuple[bool, st async def _analyze_question_answer(question: str, answer: str, chat_id: str) -> None: """异步分析问题和答案的类别,并存储到相应系统 - + Args: question: 问题 answer: 答案 @@ -863,41 +820,42 @@ async def _analyze_question_answer(question: str, answer: str, chat_id: str) -> }} 只输出JSON,不要输出其他内容:""" - + success, response, _, _ = await llm_api.generate_with_model( analysis_prompt, model_config=model_config.model_task_config.utils, request_type="memory.analyze_qa", ) - + if not success: logger.error(f"分析问题和答案失败: {response}") return - + # 解析JSON响应 try: json_pattern = r"```json\s*(.*?)\s*```" matches = re.findall(json_pattern, response, re.DOTALL) - + if matches: json_str = matches[0] else: json_str = response.strip() - + repaired_json = repair_json(json_str) analysis_result = json.loads(repaired_json) - + category = analysis_result.get("category", "").strip() - + if category == "黑话": # 处理黑话 jargon_keyword = analysis_result.get("jargon_keyword", "").strip() if jargon_keyword: from src.jargon.jargon_miner import store_jargon_from_answer + await store_jargon_from_answer(jargon_keyword, answer, chat_id) else: logger.warning(f"分析为黑话但未提取到关键词,问题: {question[:50]}...") - + elif category == "人物信息": # 处理人物信息 # person_name = analysis_result.get("person_name", "").strip() @@ -908,28 +866,22 @@ async def _analyze_question_answer(question: str, answer: str, chat_id: str) -> # else: # logger.warning(f"分析为人物信息但未提取到人物名称或记忆内容,问题: {question[:50]}...") pass # 功能暂时禁用 - + else: logger.info(f"问题和答案类别为'其他',不进行存储,问题: {question[:50]}...") - + except Exception as e: logger.error(f"解析分析结果失败: {e}, 响应: {response[:200]}...") - + except Exception as e: logger.error(f"分析问题和答案时发生异常: {e}") - def _store_thinking_back( - chat_id: str, - question: str, - context: str, - found_answer: bool, - answer: str, - thinking_steps: List[Dict[str, Any]] + chat_id: str, question: str, context: str, found_answer: bool, answer: str, thinking_steps: List[Dict[str, Any]] ) -> None: """存储或更新思考过程到数据库(如果已存在则更新,否则创建) - + Args: chat_id: 聊天ID question: 问题 @@ -940,18 +892,15 @@ def _store_thinking_back( """ try: now = time.time() - + # 先查询是否已存在相同chat_id和问题的记录 existing = ( ThinkingBack.select() - .where( - (ThinkingBack.chat_id == chat_id) & - (ThinkingBack.question == question) - ) + .where((ThinkingBack.chat_id == chat_id) & (ThinkingBack.question == question)) .order_by(ThinkingBack.update_time.desc()) .limit(1) ) - + if existing.exists(): # 更新现有记录 record = existing.get() @@ -972,75 +921,84 @@ def _store_thinking_back( answer=answer, thinking_steps=json.dumps(thinking_steps, ensure_ascii=False), create_time=now, - update_time=now + update_time=now, ) logger.info(f"已创建思考过程到数据库,问题: {question[:50]}...") except Exception as e: logger.error(f"存储思考过程失败: {e}") -async def _process_single_question( - question: str, - chat_id: str, - context: str, - initial_info: str = "" -) -> Optional[str]: +async def _process_single_question(question: str, chat_id: str, context: str, initial_info: str = "") -> Optional[str]: """处理单个问题的查询(包含缓存检查逻辑) - + Args: question: 要查询的问题 chat_id: 聊天ID context: 上下文信息 initial_info: 初始信息(如概念检索结果),将传递给ReAct Agent - + Returns: Optional[str]: 如果找到答案,返回格式化的结果字符串,否则返回None """ logger.info(f"开始处理问题: {question}") - + + _cleanup_stale_not_found_thinking_back() + + question_initial_info = initial_info or "" + + # 预先进行一次LPMM知识库查询,作为后续ReAct Agent的辅助信息 + if global_config.lpmm_knowledge.enable: + try: + lpmm_result = await query_lpmm_knowledge(question, limit=2) + if lpmm_result and lpmm_result.startswith("你从LPMM知识库中找到"): + if question_initial_info: + question_initial_info += "\n" + question_initial_info += f"【LPMM知识库预查询】\n{lpmm_result}" + logger.info(f"LPMM预查询命中,问题: {question[:50]}...") + else: + logger.info(f"LPMM预查询未命中或未找到信息,问题: {question[:50]}...") + except Exception as e: + logger.error(f"LPMM预查询失败,问题: {question[:50]}... 错误: {e}") + # 先检查thinking_back数据库中是否有现成答案 cached_result = _query_thinking_back(chat_id, question) should_requery = False - + if cached_result: cached_found_answer, cached_answer = cached_result - - # 根据found_answer的值决定是否重新查询 + if cached_found_answer: # found_answer == 1 (True) # found_answer == 1:20%概率重新查询 - if random.random() < 0.2: + if random.random() < 0.5: should_requery = True logger.info(f"found_answer=1,触发20%概率重新查询,问题: {question[:50]}...") - else: # found_answer == 0 (False) - # found_answer == 0:40%概率重新查询 - if random.random() < 0.4: - should_requery = True - logger.info(f"found_answer=0,触发40%概率重新查询,问题: {question[:50]}...") - - # 如果不需要重新查询,使用缓存答案 - if not should_requery: - if cached_answer: + + if not should_requery and cached_answer: logger.info(f"从thinking_back缓存中获取答案,问题: {question[:50]}...") return f"问题:{question}\n答案:{cached_answer}" - else: - # 缓存中没有答案,需要查询 + elif not cached_answer: should_requery = True - + logger.info(f"found_answer=1 但缓存答案为空,重新查询,问题: {question[:50]}...") + else: + # found_answer == 0:不使用缓存,直接重新查询 + should_requery = True + logger.info(f"thinking_back存在但未找到答案,忽略缓存重新查询,问题: {question[:50]}...") + # 如果没有缓存答案或需要重新查询,使用ReAct Agent查询 if not cached_result or should_requery: if should_requery: logger.info(f"概率触发重新查询,使用ReAct Agent查询,问题: {question[:50]}...") else: logger.info(f"未找到缓存答案,使用ReAct Agent查询,问题: {question[:50]}...") - + found_answer, answer, thinking_steps, is_timeout = await _react_agent_solve_question( question=question, chat_id=chat_id, max_iterations=global_config.memory.max_agent_iterations, timeout=120.0, - initial_info=initial_info + initial_info=question_initial_info, ) - + # 存储到数据库(超时时不存储) if not is_timeout: _store_thinking_back( @@ -1049,16 +1007,16 @@ async def _process_single_question( context=context, found_answer=found_answer, answer=answer, - thinking_steps=thinking_steps + thinking_steps=thinking_steps, ) else: logger.info(f"ReAct Agent超时,不存储到数据库,问题: {question[:50]}...") - + if found_answer and answer: # 创建异步任务分析问题和答案 asyncio.create_task(_analyze_question_answer(question, answer, chat_id)) return f"问题:{question}\n答案:{answer}" - + return None @@ -1071,30 +1029,30 @@ async def build_memory_retrieval_prompt( ) -> str: """构建记忆检索提示 使用两段式查询:第一步生成问题,第二步使用ReAct Agent查询答案 - + Args: message: 聊天历史记录 sender: 发送者名称 target: 目标消息内容 chat_stream: 聊天流对象 tool_executor: 工具执行器(保留参数以兼容接口) - + Returns: str: 记忆检索结果字符串 """ start_time = time.time() - + logger.info(f"检测是否需要回忆,元消息:{message[:30]}...,消息长度: {len(message)}") try: time_now = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) bot_name = global_config.bot.nickname chat_id = chat_stream.stream_id - + # 获取最近查询历史(最近1小时内的查询) recent_query_history = _get_recent_query_history(chat_id, time_window_seconds=300.0) if not recent_query_history: recent_query_history = "最近没有查询记录。" - + # 第一步:生成问题 question_prompt = await global_prompt_manager.format_prompt( "memory_retrieval_question_prompt", @@ -1105,26 +1063,26 @@ async def build_memory_retrieval_prompt( sender=sender, target_message=target, ) - + success, response, reasoning_content, model_name = await llm_api.generate_with_model( question_prompt, model_config=model_config.model_task_config.tool_use, request_type="memory.question", ) - - logger.info(f"记忆检索问题生成提示词: {question_prompt}") + + if global_config.debug.show_memory_prompt: + logger.info(f"记忆检索问题生成提示词: {question_prompt}") logger.info(f"记忆检索问题生成响应: {response}") - + if not success: logger.error(f"LLM生成问题失败: {response}") return "" - - # 解析概念列表、人物列表和问题列表 - concepts, persons, questions = _parse_questions_json(response) + + # 解析概念列表和问题列表 + concepts, questions = _parse_questions_json(response) logger.info(f"解析到 {len(concepts)} 个概念: {concepts}") - logger.info(f"解析到 {len(persons)} 个人物: {persons}") logger.info(f"解析到 {len(questions)} 个问题: {questions}") - + # 对概念进行jargon检索,作为初始信息 initial_info = "" if concepts: @@ -1135,57 +1093,42 @@ async def build_memory_retrieval_prompt( logger.info(f"概念检索完成,结果: {concept_info[:200]}...") else: logger.info("概念检索未找到任何结果") - - # 对人物进行信息检索,添加到初始信息 - if persons: - logger.info(f"开始对 {len(persons)} 个人物进行信息检索") - person_info = await _retrieve_persons_info(persons, chat_id) - if person_info: - initial_info += person_info - logger.info(f"人物信息检索完成,结果: {person_info[:200]}...") - else: - logger.info("人物信息检索未找到任何结果") - + # 获取缓存的记忆(与question时使用相同的时间窗口和数量限制) cached_memories = _get_cached_memories(chat_id, time_window_seconds=300.0) - + if not questions: logger.debug("模型认为不需要检索记忆或解析失败") - # 即使没有当次查询,也返回缓存的记忆、概念检索结果和人物信息检索结果 + # 即使没有当次查询,也返回缓存的记忆和概念检索结果 all_results = [] if initial_info: all_results.append(initial_info.strip()) if cached_memories: all_results.extend(cached_memories) - + if all_results: retrieved_memory = "\n\n".join(all_results) end_time = time.time() - logger.info(f"无当次查询,返回缓存记忆、概念检索和人物信息检索结果,耗时: {(end_time - start_time):.3f}秒") + logger.info(f"无当次查询,返回缓存记忆和概念检索结果,耗时: {(end_time - start_time):.3f}秒") return f"你回忆起了以下信息:\n{retrieved_memory}\n如果与回复内容相关,可以参考这些回忆的信息。\n" else: return "" - + logger.info(f"解析到 {len(questions)} 个问题: {questions}") - + # 第二步:并行处理所有问题(使用配置的最大迭代次数/120秒超时) max_iterations = global_config.memory.max_agent_iterations logger.info(f"问题数量: {len(questions)},设置最大迭代次数: {max_iterations},超时时间: 120秒") - + # 并行处理所有问题,将概念检索结果作为初始信息传递 question_tasks = [ - _process_single_question( - question=question, - chat_id=chat_id, - context=message, - initial_info=initial_info - ) + _process_single_question(question=question, chat_id=chat_id, context=message, initial_info=initial_info) for question in questions ] - + # 并行执行所有查询任务 results = await asyncio.gather(*question_tasks, return_exceptions=True) - + # 收集所有有效结果 all_results = [] current_questions = set() # 用于去重,避免缓存和当次查询重复 @@ -1198,7 +1141,7 @@ async def build_memory_retrieval_prompt( if result.startswith("问题:"): question = result.split("\n")[0].replace("问题:", "").strip() current_questions.add(question) - + # 将缓存的记忆添加到结果中(排除当次查询已包含的问题,避免重复) for cached_memory in cached_memories: if cached_memory.startswith("问题:"): @@ -1207,72 +1150,70 @@ async def build_memory_retrieval_prompt( if question not in current_questions: all_results.append(cached_memory) logger.debug(f"添加缓存记忆: {question[:50]}...") - + end_time = time.time() - + if all_results: retrieved_memory = "\n\n".join(all_results) - logger.info(f"记忆检索成功,耗时: {(end_time - start_time):.3f}秒,包含 {len(all_results)} 条记忆(含缓存)") + logger.info( + f"记忆检索成功,耗时: {(end_time - start_time):.3f}秒,包含 {len(all_results)} 条记忆(含缓存)" + ) return f"你回忆起了以下信息:\n{retrieved_memory}\n如果与回复内容相关,可以参考这些回忆的信息。\n" else: logger.debug("所有问题均未找到答案,且无缓存记忆") return "" - + except Exception as e: logger.error(f"记忆检索时发生异常: {str(e)}") return "" -def _parse_questions_json(response: str) -> Tuple[List[str], List[str], List[str]]: - """解析问题JSON,返回概念列表、人物列表和问题列表 - +def _parse_questions_json(response: str) -> Tuple[List[str], List[str]]: + """解析问题JSON,返回概念列表和问题列表 + Args: response: LLM返回的响应 - + Returns: - Tuple[List[str], List[str], List[str]]: (概念列表, 人物列表, 问题列表) + Tuple[List[str], List[str]]: (概念列表, 问题列表) """ try: # 尝试提取JSON(可能包含在```json代码块中) json_pattern = r"```json\s*(.*?)\s*```" matches = re.findall(json_pattern, response, re.DOTALL) - + if matches: json_str = matches[0] else: # 尝试直接解析整个响应 json_str = response.strip() - + # 修复可能的JSON错误 repaired_json = repair_json(json_str) - + # 解析JSON parsed = json.loads(repaired_json) - - # 只支持新格式:包含concepts、person和questions的对象 + + # 只支持新格式:包含concepts和questions的对象 if not isinstance(parsed, dict): logger.warning(f"解析的JSON不是对象格式: {parsed}") - return [], [], [] - + return [], [] + concepts_raw = parsed.get("concepts", []) - persons_raw = parsed.get("person", []) questions_raw = parsed.get("questions", []) - + # 确保是列表 if not isinstance(concepts_raw, list): concepts_raw = [] - if not isinstance(persons_raw, list): - persons_raw = [] if not isinstance(questions_raw, list): questions_raw = [] - + # 确保所有元素都是字符串 concepts = [c for c in concepts_raw if isinstance(c, str) and c.strip()] - persons = [p for p in persons_raw if isinstance(p, str) and p.strip()] questions = [q for q in questions_raw if isinstance(q, str) and q.strip()] - - return concepts, persons, questions - + + return concepts, questions + except Exception as e: logger.error(f"解析问题JSON失败: {e}, 响应内容: {response[:200]}...") - return [], [], [] + return [], [] diff --git a/src/memory_system/memory_utils.py b/src/memory_system/memory_utils.py index af16456b..bff39f95 100644 --- a/src/memory_system/memory_utils.py +++ b/src/memory_system/memory_utils.py @@ -3,6 +3,7 @@ 记忆系统工具函数 包含模糊查找、相似度计算等工具函数 """ + import json import re from datetime import datetime @@ -14,6 +15,7 @@ from src.common.logger import get_logger logger = get_logger("memory_utils") + def parse_md_json(json_text: str) -> list[str]: """从Markdown格式的内容中提取JSON对象和推理内容""" json_objects = [] @@ -52,14 +54,15 @@ def parse_md_json(json_text: str) -> list[str]: return json_objects, reasoning_content + def calculate_similarity(text1: str, text2: str) -> float: """ 计算两个文本的相似度 - + Args: text1: 第一个文本 text2: 第二个文本 - + Returns: float: 相似度分数 (0-1) """ @@ -67,16 +70,16 @@ def calculate_similarity(text1: str, text2: str) -> float: # 预处理文本 text1 = preprocess_text(text1) text2 = preprocess_text(text2) - + # 使用SequenceMatcher计算相似度 similarity = SequenceMatcher(None, text1, text2).ratio() - + # 如果其中一个文本包含另一个,提高相似度 if text1 in text2 or text2 in text1: similarity = max(similarity, 0.8) - + return similarity - + except Exception as e: logger.error(f"计算相似度时出错: {e}") return 0.0 @@ -85,31 +88,30 @@ def calculate_similarity(text1: str, text2: str) -> float: def preprocess_text(text: str) -> str: """ 预处理文本,提高匹配准确性 - + Args: text: 原始文本 - + Returns: str: 预处理后的文本 """ try: # 转换为小写 text = text.lower() - + # 移除标点符号和特殊字符 - text = re.sub(r'[^\w\s]', '', text) - + text = re.sub(r"[^\w\s]", "", text) + # 移除多余空格 - text = re.sub(r'\s+', ' ', text).strip() - + text = re.sub(r"\s+", " ", text).strip() + return text - + except Exception as e: logger.error(f"预处理文本时出错: {e}") return text - def parse_datetime_to_timestamp(value: str) -> float: """ 接受多种常见格式并转换为时间戳(秒) @@ -143,25 +145,24 @@ def parse_datetime_to_timestamp(value: str) -> float: def parse_time_range(time_range: str) -> Tuple[float, float]: """ 解析时间范围字符串,返回开始和结束时间戳 - + Args: time_range: 时间范围字符串,格式:"YYYY-MM-DD HH:MM:SS - YYYY-MM-DD HH:MM:SS" - + Returns: Tuple[float, float]: (开始时间戳, 结束时间戳) """ if " - " not in time_range: raise ValueError(f"时间范围格式错误,应为 '开始时间 - 结束时间': {time_range}") - + parts = time_range.split(" - ", 1) if len(parts) != 2: raise ValueError(f"时间范围格式错误: {time_range}") - + start_str = parts[0].strip() end_str = parts[1].strip() - + start_timestamp = parse_datetime_to_timestamp(start_str) end_timestamp = parse_datetime_to_timestamp(end_str) - - return start_timestamp, end_timestamp + return start_timestamp, end_timestamp diff --git a/src/memory_system/retrieval_tools/__init__.py b/src/memory_system/retrieval_tools/__init__.py index e02875be..4b5c40c0 100644 --- a/src/memory_system/retrieval_tools/__init__.py +++ b/src/memory_system/retrieval_tools/__init__.py @@ -17,6 +17,7 @@ from .query_lpmm_knowledge import register_tool as register_lpmm_knowledge from .query_person_info import register_tool as register_query_person_info from src.config.config import global_config + def init_all_tools(): """初始化并注册所有记忆检索工具""" register_query_jargon() diff --git a/src/memory_system/retrieval_tools/query_chat_history.py b/src/memory_system/retrieval_tools/query_chat_history.py index 85776250..407bba05 100644 --- a/src/memory_system/retrieval_tools/query_chat_history.py +++ b/src/memory_system/retrieval_tools/query_chat_history.py @@ -15,13 +15,10 @@ logger = get_logger("memory_retrieval_tools") async def query_chat_history( - chat_id: str, - keyword: Optional[str] = None, - time_range: Optional[str] = None, - fuzzy: bool = True + chat_id: str, keyword: Optional[str] = None, time_range: Optional[str] = None, fuzzy: bool = True ) -> str: """根据时间或关键词在chat_history表中查询聊天记录概述 - + Args: chat_id: 聊天ID keyword: 关键词(可选,支持多个关键词,可用空格、逗号等分隔) @@ -31,7 +28,7 @@ async def query_chat_history( fuzzy: 是否使用模糊匹配模式(默认True) - True: 模糊匹配,只要包含任意一个关键词即匹配(OR关系) - False: 全匹配,必须包含所有关键词才匹配(AND关系) - + Returns: str: 查询结果 """ @@ -39,10 +36,10 @@ async def query_chat_history( # 检查参数 if not keyword and not time_range: return "未指定查询参数(需要提供keyword或time_range之一)" - + # 构建查询条件 query = ChatHistory.select().where(ChatHistory.chat_id == chat_id) - + # 时间过滤条件 if time_range: # 判断是时间点还是时间范围 @@ -50,79 +47,79 @@ async def query_chat_history( # 时间范围:查询与时间范围有交集的记录 start_timestamp, end_timestamp = parse_time_range(time_range) # 交集条件:start_time < end_timestamp AND end_time > start_timestamp - time_filter = ( - (ChatHistory.start_time < end_timestamp) & - (ChatHistory.end_time > start_timestamp) - ) + time_filter = (ChatHistory.start_time < end_timestamp) & (ChatHistory.end_time > start_timestamp) else: # 时间点:查询包含该时间点的记录(start_time <= time_point <= end_time) target_timestamp = parse_datetime_to_timestamp(time_range) - time_filter = ( - (ChatHistory.start_time <= target_timestamp) & - (ChatHistory.end_time >= target_timestamp) - ) + time_filter = (ChatHistory.start_time <= target_timestamp) & (ChatHistory.end_time >= target_timestamp) query = query.where(time_filter) - + # 执行查询 records = list(query.order_by(ChatHistory.start_time.desc()).limit(50)) - + # 如果有关键词,进一步过滤 if keyword: # 解析多个关键词(支持空格、逗号等分隔符) keywords_list = parse_keywords_string(keyword) if not keywords_list: keywords_list = [keyword.strip()] if keyword.strip() else [] - + # 转换为小写以便匹配 keywords_lower = [kw.lower() for kw in keywords_list if kw.strip()] - + if not keywords_lower: return "关键词为空" - + filtered_records = [] - + for record in records: # 在theme、keywords、summary、original_text中搜索 theme = (record.theme or "").lower() summary = (record.summary or "").lower() original_text = (record.original_text or "").lower() - + # 解析record中的keywords JSON record_keywords_list = [] if record.keywords: try: - keywords_data = json.loads(record.keywords) if isinstance(record.keywords, str) else record.keywords + keywords_data = ( + json.loads(record.keywords) if isinstance(record.keywords, str) else record.keywords + ) if isinstance(keywords_data, list): record_keywords_list = [str(k).lower() for k in keywords_data] except (json.JSONDecodeError, TypeError, ValueError): pass - + # 根据匹配模式检查关键词 matched = False if fuzzy: # 模糊匹配:只要包含任意一个关键词即匹配(OR关系) for kw in keywords_lower: - if (kw in theme or - kw in summary or - kw in original_text or - any(kw in k for k in record_keywords_list)): + if ( + kw in theme + or kw in summary + or kw in original_text + or any(kw in k for k in record_keywords_list) + ): matched = True break else: # 全匹配:必须包含所有关键词才匹配(AND关系) matched = True for kw in keywords_lower: - kw_matched = (kw in theme or - kw in summary or - kw in original_text or - any(kw in k for k in record_keywords_list)) + kw_matched = ( + kw in theme + or kw in summary + or kw in original_text + or any(kw in k for k in record_keywords_list) + ) if not kw_matched: matched = False break - + if matched: filtered_records.append(record) - + if not filtered_records: keywords_str = "、".join(keywords_list) match_mode = "包含任意一个关键词" if fuzzy else "包含所有关键词" @@ -130,9 +127,9 @@ async def query_chat_history( return f"未找到{match_mode}'{keywords_str}'且在指定时间范围内的聊天记录概述" else: return f"未找到{match_mode}'{keywords_str}'的聊天记录概述" - + records = filtered_records - + # 如果没有记录(可能是时间范围查询但没有匹配的记录) if not records: if time_range: @@ -148,22 +145,23 @@ async def query_chat_history( record.count = (record.count or 0) + 1 except Exception as update_error: logger.error(f"更新聊天记录概述计数失败: {update_error}") - + # 构建结果文本 results = [] for record in records_to_use: # 最多返回3条记录 result_parts = [] - + # 添加主题 if record.theme: result_parts.append(f"主题:{record.theme}") - + # 添加时间范围 from datetime import datetime + start_str = datetime.fromtimestamp(record.start_time).strftime("%Y-%m-%d %H:%M:%S") end_str = datetime.fromtimestamp(record.end_time).strftime("%Y-%m-%d %H:%M:%S") result_parts.append(f"时间:{start_str} - {end_str}") - + # 添加概括(优先使用summary,如果没有则使用original_text的前200字符) if record.summary: result_parts.append(f"概括:{record.summary}") @@ -172,18 +170,18 @@ async def query_chat_history( if len(record.original_text) > 200: text_preview += "..." result_parts.append(f"内容:{text_preview}") - + results.append("\n".join(result_parts)) - + if not results: return "未找到相关聊天记录概述" - + response_text = "\n\n---\n\n".join(results) if len(records) > len(records_to_use): omitted_count = len(records) - len(records_to_use) response_text += f"\n\n(还有{omitted_count}条历史记录已省略)" return response_text - + except Exception as e: logger.error(f"查询聊天历史概述失败: {e}") return f"查询失败: {str(e)}" @@ -193,26 +191,26 @@ def register_tool(): """注册工具""" register_memory_retrieval_tool( name="query_chat_history", - description="根据时间或关键词在chat_history表的聊天记录概述库中查询。可以查询某个时间点发生了什么、某个时间范围内的事件,或根据关键词搜索消息概述。支持两种匹配模式:模糊匹配(默认,只要包含任意一个关键词即匹配)和全匹配(必须包含所有关键词才匹配)", + description="根据时间或关键词在聊天记录中查询。可以查询某个时间点发生了什么、某个时间范围内的事件,或根据关键词搜索消息概述。支持两种匹配模式:模糊匹配(默认,只要包含任意一个关键词即匹配)和全匹配(必须包含所有关键词才匹配)", parameters=[ { "name": "keyword", "type": "string", "description": "关键词(可选,支持多个关键词,可用空格、逗号、斜杠等分隔,如:'麦麦 百度网盘' 或 '麦麦,百度网盘'。用于在主题、关键词、概括、原文中搜索)", - "required": False + "required": False, }, { "name": "time_range", "type": "string", "description": "时间范围或时间点(可选)。格式:'YYYY-MM-DD HH:MM:SS - YYYY-MM-DD HH:MM:SS'(时间范围,查询与时间范围有交集的记录)或 'YYYY-MM-DD HH:MM:SS'(时间点,查询包含该时间点的记录)", - "required": False + "required": False, }, { "name": "fuzzy", "type": "boolean", "description": "是否使用模糊匹配模式(默认True)。True表示模糊匹配(只要包含任意一个关键词即匹配,OR关系),False表示全匹配(必须包含所有关键词才匹配,AND关系)", - "required": False - } + "required": False, + }, ], - execute_func=query_chat_history + execute_func=query_chat_history, ) diff --git a/src/memory_system/retrieval_tools/query_lpmm_knowledge.py b/src/memory_system/retrieval_tools/query_lpmm_knowledge.py index aa9268db..c1f39270 100644 --- a/src/memory_system/retrieval_tools/query_lpmm_knowledge.py +++ b/src/memory_system/retrieval_tools/query_lpmm_knowledge.py @@ -10,7 +10,7 @@ from .tool_registry import register_memory_retrieval_tool logger = get_logger("memory_retrieval_tools") -async def query_lpmm_knowledge(query: str) -> str: +async def query_lpmm_knowledge(query: str, limit: int = 5) -> str: """在LPMM知识库中查询相关信息 Args: @@ -24,6 +24,12 @@ async def query_lpmm_knowledge(query: str) -> str: if not content: return "查询关键词为空" + try: + limit_value = int(limit) + except (TypeError, ValueError): + limit_value = 5 + limit_value = max(1, limit_value) + if not global_config.lpmm_knowledge.enable: logger.debug("LPMM知识库未启用") return "LPMM知识库未启用" @@ -33,7 +39,7 @@ async def query_lpmm_knowledge(query: str) -> str: logger.debug("LPMM知识库未初始化,跳过查询") return "LPMM知识库未初始化" - knowledge_info = await qa_manager.get_knowledge(content) + knowledge_info = await qa_manager.get_knowledge(content, limit=limit_value) logger.debug(f"LPMM知识库查询结果: {knowledge_info}") if knowledge_info: @@ -57,9 +63,13 @@ def register_tool(): "type": "string", "description": "需要查询的关键词或问题", "required": True, - } + }, + { + "name": "limit", + "type": "integer", + "description": "希望返回的相关知识条数,默认为5", + "required": False, + }, ], execute_func=query_lpmm_knowledge, ) - - diff --git a/src/memory_system/retrieval_tools/query_person_info.py b/src/memory_system/retrieval_tools/query_person_info.py index 878daf4d..bc192722 100644 --- a/src/memory_system/retrieval_tools/query_person_info.py +++ b/src/memory_system/retrieval_tools/query_person_info.py @@ -14,23 +14,25 @@ logger = get_logger("memory_retrieval_tools") def _format_group_nick_names(group_nick_name_field) -> str: """格式化群昵称信息 - + Args: group_nick_name_field: 群昵称字段(可能是字符串JSON或None) - + Returns: str: 格式化后的群昵称信息字符串 """ if not group_nick_name_field: return "" - + try: # 解析JSON格式的群昵称列表 - group_nick_names_data = json.loads(group_nick_name_field) if isinstance(group_nick_name_field, str) else group_nick_name_field - + group_nick_names_data = ( + json.loads(group_nick_name_field) if isinstance(group_nick_name_field, str) else group_nick_name_field + ) + if not isinstance(group_nick_names_data, list) or not group_nick_names_data: return "" - + # 格式化群昵称列表 group_nick_list = [] for item in group_nick_names_data: @@ -41,7 +43,7 @@ def _format_group_nick_names(group_nick_name_field) -> str: elif isinstance(item, str): # 兼容旧格式(如果存在) group_nick_list.append(f" - {item}") - + if group_nick_list: return "群昵称:\n" + "\n".join(group_nick_list) return "" @@ -58,10 +60,10 @@ def _format_group_nick_names(group_nick_name_field) -> str: async def query_person_info(person_name: str) -> str: """根据person_name查询用户信息,使用模糊查询 - + Args: person_name: 用户名称(person_name字段) - + Returns: str: 查询结果,包含用户的所有信息 """ @@ -69,37 +71,35 @@ async def query_person_info(person_name: str) -> str: person_name = str(person_name).strip() if not person_name: return "用户名称为空" - + # 构建查询条件(使用模糊查询) - query = PersonInfo.select().where( - PersonInfo.person_name.contains(person_name) - ) - + query = PersonInfo.select().where(PersonInfo.person_name.contains(person_name)) + # 执行查询 records = list(query.limit(20)) # 最多返回20条记录 - + if not records: return f"未找到模糊匹配'{person_name}'的用户信息" - + # 区分精确匹配和模糊匹配的结果 exact_matches = [] fuzzy_matches = [] - + for record in records: # 检查是否是精确匹配 if record.person_name and record.person_name.strip() == person_name: exact_matches.append(record) else: fuzzy_matches.append(record) - + # 构建结果文本 results = [] - + # 先处理精确匹配的结果 for record in exact_matches: result_parts = [] result_parts.append("【精确匹配】") # 标注为精确匹配 - + # 基本信息 if record.person_name: result_parts.append(f"用户名称:{record.person_name}") @@ -111,19 +111,19 @@ async def query_person_info(person_name: str) -> str: result_parts.append(f"平台:{record.platform}") if record.user_id: result_parts.append(f"平台用户ID:{record.user_id}") - + # 群昵称信息 group_nick_name_str = _format_group_nick_names(getattr(record, "group_nick_name", None)) if group_nick_name_str: result_parts.append(group_nick_name_str) - + # 名称设定原因 if record.name_reason: result_parts.append(f"名称设定原因:{record.name_reason}") - + # 认识状态 result_parts.append(f"是否已认识:{'是' if record.is_known else '否'}") - + # 时间信息 if record.know_since: know_since_str = datetime.fromtimestamp(record.know_since).strftime("%Y-%m-%d %H:%M:%S") @@ -133,11 +133,15 @@ async def query_person_info(person_name: str) -> str: result_parts.append(f"最后认识时间:{last_know_str}") if record.know_times: result_parts.append(f"认识次数:{int(record.know_times)}") - + # 记忆点(memory_points) if record.memory_points: try: - memory_points_data = json.loads(record.memory_points) if isinstance(record.memory_points, str) else record.memory_points + memory_points_data = ( + json.loads(record.memory_points) + if isinstance(record.memory_points, str) + else record.memory_points + ) if isinstance(memory_points_data, list) and memory_points_data: # 解析记忆点格式:category:content:weight memory_list = [] @@ -151,7 +155,7 @@ async def query_person_info(person_name: str) -> str: memory_list.append(f" - [{category}] {content} (权重: {weight})") else: memory_list.append(f" - {memory_point}") - + if memory_list: result_parts.append("记忆点:\n" + "\n".join(memory_list)) except (json.JSONDecodeError, TypeError, ValueError) as e: @@ -161,14 +165,14 @@ async def query_person_info(person_name: str) -> str: if len(str(record.memory_points)) > 200: memory_preview += "..." result_parts.append(f"记忆点(原始数据):{memory_preview}") - + results.append("\n".join(result_parts)) - + # 再处理模糊匹配的结果 for record in fuzzy_matches: result_parts = [] result_parts.append("【模糊匹配】") # 标注为模糊匹配 - + # 基本信息 if record.person_name: result_parts.append(f"用户名称:{record.person_name}") @@ -180,19 +184,19 @@ async def query_person_info(person_name: str) -> str: result_parts.append(f"平台:{record.platform}") if record.user_id: result_parts.append(f"平台用户ID:{record.user_id}") - + # 群昵称信息 group_nick_name_str = _format_group_nick_names(getattr(record, "group_nick_name", None)) if group_nick_name_str: result_parts.append(group_nick_name_str) - + # 名称设定原因 if record.name_reason: result_parts.append(f"名称设定原因:{record.name_reason}") - + # 认识状态 result_parts.append(f"是否已认识:{'是' if record.is_known else '否'}") - + # 时间信息 if record.know_since: know_since_str = datetime.fromtimestamp(record.know_since).strftime("%Y-%m-%d %H:%M:%S") @@ -202,11 +206,15 @@ async def query_person_info(person_name: str) -> str: result_parts.append(f"最后认识时间:{last_know_str}") if record.know_times: result_parts.append(f"认识次数:{int(record.know_times)}") - + # 记忆点(memory_points) if record.memory_points: try: - memory_points_data = json.loads(record.memory_points) if isinstance(record.memory_points, str) else record.memory_points + memory_points_data = ( + json.loads(record.memory_points) + if isinstance(record.memory_points, str) + else record.memory_points + ) if isinstance(memory_points_data, list) and memory_points_data: # 解析记忆点格式:category:content:weight memory_list = [] @@ -220,7 +228,7 @@ async def query_person_info(person_name: str) -> str: memory_list.append(f" - [{category}] {content} (权重: {weight})") else: memory_list.append(f" - {memory_point}") - + if memory_list: result_parts.append("记忆点:\n" + "\n".join(memory_list)) except (json.JSONDecodeError, TypeError, ValueError) as e: @@ -230,20 +238,20 @@ async def query_person_info(person_name: str) -> str: if len(str(record.memory_points)) > 200: memory_preview += "..." result_parts.append(f"记忆点(原始数据):{memory_preview}") - + results.append("\n".join(result_parts)) - + # 组合所有结果 if not results: return f"未找到匹配'{person_name}'的用户信息" - + response_text = "\n\n---\n\n".join(results) - + # 添加统计信息 total_count = len(records) exact_count = len(exact_matches) fuzzy_count = len(fuzzy_matches) - + # 显示精确匹配和模糊匹配的统计 if exact_count > 0 or fuzzy_count > 0: stats_parts = [] @@ -257,13 +265,13 @@ async def query_person_info(person_name: str) -> str: response_text = f"找到 {total_count} 条匹配的用户信息:\n\n{response_text}" else: response_text = f"找到用户信息:\n\n{response_text}" - + # 如果结果数量达到限制,添加提示 if total_count >= 20: response_text += "\n\n(已显示前20条结果,可能还有更多匹配记录)" - + return response_text - + except Exception as e: logger.error(f"查询用户信息失败: {e}") return f"查询失败: {str(e)}" @@ -275,13 +283,7 @@ def register_tool(): name="query_person_info", description="根据查询某个用户的所有信息。名称、昵称、平台、用户ID、qq号、群昵称等", parameters=[ - { - "name": "person_name", - "type": "string", - "description": "用户名称,用于查询用户信息", - "required": True - } + {"name": "person_name", "type": "string", "description": "用户名称,用于查询用户信息", "required": True} ], - execute_func=query_person_info + execute_func=query_person_info, ) - diff --git a/src/memory_system/retrieval_tools/tool_registry.py b/src/memory_system/retrieval_tools/tool_registry.py index 143666ab..1e1fa62b 100644 --- a/src/memory_system/retrieval_tools/tool_registry.py +++ b/src/memory_system/retrieval_tools/tool_registry.py @@ -47,10 +47,10 @@ class MemoryRetrievalTool: async def execute(self, **kwargs) -> str: """执行工具""" return await self.execute_func(**kwargs) - + def get_tool_definition(self) -> Dict[str, Any]: """获取工具定义,用于LLM function calling - + Returns: Dict[str, Any]: 工具定义字典,格式与BaseTool一致 格式: {"name": str, "description": str, "parameters": List[Tuple]} @@ -58,14 +58,14 @@ class MemoryRetrievalTool: # 转换参数格式为元组列表,格式与BaseTool一致 # 格式: [("param_name", ToolParamType, "description", required, enum_values)] param_tuples = [] - + for param in self.parameters: param_name = param.get("name", "") param_type_str = param.get("type", "string").lower() param_desc = param.get("description", "") is_required = param.get("required", False) enum_values = param.get("enum", None) - + # 转换类型字符串到ToolParamType type_mapping = { "string": ToolParamType.STRING, @@ -76,18 +76,14 @@ class MemoryRetrievalTool: "bool": ToolParamType.BOOLEAN, } param_type = type_mapping.get(param_type_str, ToolParamType.STRING) - + # 构建参数元组 param_tuple = (param_name, param_type, param_desc, is_required, enum_values) param_tuples.append(param_tuple) - + # 构建工具定义,格式与BaseTool.get_tool_definition()一致 - tool_def = { - "name": self.name, - "description": self.description, - "parameters": param_tuples - } - + tool_def = {"name": self.name, "description": self.description, "parameters": param_tuples} + return tool_def @@ -126,10 +122,10 @@ class MemoryRetrievalToolRegistry: action_types.append("final_answer") action_types.append("no_answer") return " 或 ".join([f'"{at}"' for at in action_types]) - + def get_tool_definitions(self) -> List[Dict[str, Any]]: """获取所有工具的定义列表,用于LLM function calling - + Returns: List[Dict[str, Any]]: 工具定义列表,每个元素是一个工具定义字典 """ diff --git a/src/person_info/person_info.py b/src/person_info/person_info.py index bbc3fb99..f5cfdcd0 100644 --- a/src/person_info/person_info.py +++ b/src/person_info/person_info.py @@ -162,7 +162,12 @@ def levenshtein_distance(s1: str, s2: str) -> int: class Person: @classmethod def register_person( - cls, platform: str, user_id: str, nickname: str, group_id: Optional[str] = None, group_nick_name: Optional[str] = None + cls, + platform: str, + user_id: str, + nickname: str, + group_id: Optional[str] = None, + group_nick_name: Optional[str] = None, ): """ 注册新用户的类方法 @@ -727,7 +732,7 @@ person_info_manager = PersonInfoManager() async def store_person_memory_from_answer(person_name: str, memory_content: str, chat_id: str) -> None: """将人物信息存入person_info的memory_points - + Args: person_name: 人物名称 memory_content: 记忆内容 @@ -739,13 +744,13 @@ async def store_person_memory_from_answer(person_name: str, memory_content: str, if not chat_stream: logger.warning(f"无法获取chat_stream for chat_id: {chat_id}") return - + platform = chat_stream.platform - + # 尝试从person_name查找person_id # 首先尝试通过person_name查找 person_id = get_person_id_by_person_name(person_name) - + if not person_id: # 如果通过person_name找不到,尝试从chat_stream获取user_info if chat_stream.user_info: @@ -754,25 +759,25 @@ async def store_person_memory_from_answer(person_name: str, memory_content: str, else: logger.warning(f"无法确定person_id for person_name: {person_name}, chat_id: {chat_id}") return - + # 创建或获取Person对象 person = Person(person_id=person_id) - + if not person.is_known: logger.warning(f"用户 {person_name} (person_id: {person_id}) 尚未认识,无法存储记忆") return - + # 确定记忆分类(可以根据memory_content判断,这里使用通用分类) category = "其他" # 默认分类,可以根据需要调整 - + # 记忆点格式:category:content:weight weight = "1.0" # 默认权重 memory_point = f"{category}:{memory_content}:{weight}" - + # 添加到memory_points if not person.memory_points: person.memory_points = [] - + # 检查是否已存在相似的记忆点(避免重复) is_duplicate = False for existing_point in person.memory_points: @@ -781,16 +786,20 @@ async def store_person_memory_from_answer(person_name: str, memory_content: str, if len(parts) >= 2: existing_content = parts[1].strip() # 简单相似度检查(如果内容相同或非常相似,则跳过) - if existing_content == memory_content or memory_content in existing_content or existing_content in memory_content: + if ( + existing_content == memory_content + or memory_content in existing_content + or existing_content in memory_content + ): is_duplicate = True break - + if not is_duplicate: person.memory_points.append(memory_point) person.sync_to_database() logger.info(f"成功添加记忆点到 {person_name} (person_id: {person_id}): {memory_point}") else: logger.debug(f"记忆点已存在,跳过: {memory_point}") - + except Exception as e: logger.error(f"存储人物记忆失败: {e}") diff --git a/src/plugin_system/core/tool_use.py b/src/plugin_system/core/tool_use.py index 12c11795..915ed7aa 100644 --- a/src/plugin_system/core/tool_use.py +++ b/src/plugin_system/core/tool_use.py @@ -124,7 +124,6 @@ class ToolExecutor: response, (reasoning_content, model_name, tool_calls) = await self.llm_model.generate_response_async( prompt=prompt, tools=tools, raise_when_empty=False ) - # 执行工具调用 tool_results, used_tools = await self.execute_tool_calls(tool_calls) diff --git a/src/plugins/built_in/emoji_plugin/emoji.py b/src/plugins/built_in/emoji_plugin/emoji.py index 7078a817..3783886a 100644 --- a/src/plugins/built_in/emoji_plugin/emoji.py +++ b/src/plugins/built_in/emoji_plugin/emoji.py @@ -102,13 +102,13 @@ class EmojiAction(BaseAction): # 5. 调用LLM models = llm_api.get_available_models() - chat_model_config = models.get("replyer") # 使用字典访问方式 + chat_model_config = models.get("utils") # 使用字典访问方式 if not chat_model_config: - logger.error(f"{self.log_prefix} 未找到'replyer'模型配置,无法调用LLM") - return False, "未找到'replyer'模型配置" + logger.error(f"{self.log_prefix} 未找到'utils'模型配置,无法调用LLM") + return False, "未找到'utils'模型配置" success, chosen_emotion, _, _ = await llm_api.generate_with_model( - prompt, model_config=chat_model_config, request_type="emoji" + prompt, model_config=chat_model_config, request_type="emoji.select" ) if not success: diff --git a/src/plugins/built_in/knowledge/lpmm_get_knowledge.py b/src/plugins/built_in/knowledge/lpmm_get_knowledge.py index ba44b2ea..174bef1c 100644 --- a/src/plugins/built_in/knowledge/lpmm_get_knowledge.py +++ b/src/plugins/built_in/knowledge/lpmm_get_knowledge.py @@ -15,6 +15,7 @@ class SearchKnowledgeFromLPMMTool(BaseTool): description = "从知识库中搜索相关信息,如果你需要知识,就使用这个工具" parameters = [ ("query", ToolParamType.STRING, "搜索查询关键词", True, None), + ("limit", ToolParamType.INTEGER, "希望返回的相关知识条数,默认5", False, 5), ] available_for_llm = global_config.lpmm_knowledge.enable @@ -29,6 +30,12 @@ class SearchKnowledgeFromLPMMTool(BaseTool): """ try: query: str = function_args.get("query") # type: ignore + limit = function_args.get("limit", 5) + try: + limit_value = int(limit) + except (TypeError, ValueError): + limit_value = 5 + limit_value = max(1, limit_value) # threshold = function_args.get("threshold", 0.4) # 检查LPMM知识库是否启用 @@ -38,7 +45,7 @@ class SearchKnowledgeFromLPMMTool(BaseTool): # 调用知识库搜索 - knowledge_info = await qa_manager.get_knowledge(query) + knowledge_info = await qa_manager.get_knowledge(query, limit=limit_value) logger.debug(f"知识库查询结果: {knowledge_info}") diff --git a/src/webui/config_routes.py b/src/webui/config_routes.py new file mode 100644 index 00000000..84c660ae --- /dev/null +++ b/src/webui/config_routes.py @@ -0,0 +1,559 @@ +""" +配置管理API路由 +""" + +import os +import tomlkit +from fastapi import APIRouter, HTTPException, Body +from typing import Any + +from src.common.logger import get_logger +from src.config.config import Config, APIAdapterConfig, CONFIG_DIR +from src.config.official_configs import ( + BotConfig, + PersonalityConfig, + RelationshipConfig, + ChatConfig, + MessageReceiveConfig, + EmojiConfig, + ExpressionConfig, + KeywordReactionConfig, + ChineseTypoConfig, + ResponsePostProcessConfig, + ResponseSplitterConfig, + TelemetryConfig, + ExperimentalConfig, + MaimMessageConfig, + LPMMKnowledgeConfig, + ToolConfig, + MemoryConfig, + DebugConfig, + MoodConfig, + VoiceConfig, + JargonConfig, +) +from src.config.api_ada_configs import ( + ModelTaskConfig, + ModelInfo, + APIProvider, +) +from src.webui.config_schema import ConfigSchemaGenerator + +logger = get_logger("webui") + +router = APIRouter(prefix="/config", tags=["config"]) + + +# ===== 辅助函数 ===== + + +def _update_dict_preserve_comments(target: Any, source: Any) -> None: + """ + 递归合并字典,保留 target 中的注释和格式 + 将 source 的值更新到 target 中(仅更新已存在的键) + + Args: + target: 目标字典(tomlkit 对象,包含注释) + source: 源字典(普通 dict 或 list) + """ + # 如果 source 是列表,直接替换(数组表没有注释保留的意义) + if isinstance(source, list): + return # 调用者需要直接赋值 + + # 如果都是字典,递归合并 + if isinstance(source, dict) and isinstance(target, dict): + for key, value in source.items(): + if key == "version": + continue # 跳过版本号 + if key in target: + target_value = target[key] + # 递归处理嵌套字典 + if isinstance(value, dict) and isinstance(target_value, dict): + _update_dict_preserve_comments(target_value, value) + else: + # 使用 tomlkit.item 保持类型 + try: + target[key] = tomlkit.item(value) + except (TypeError, ValueError): + target[key] = value + + +# ===== 架构获取接口 ===== + + +@router.get("/schema/bot") +async def get_bot_config_schema(): + """获取麦麦主程序配置架构""" + try: + # Config 类包含所有子配置 + schema = ConfigSchemaGenerator.generate_config_schema(Config) + return {"success": True, "schema": schema} + except Exception as e: + logger.error(f"获取配置架构失败: {e}") + raise HTTPException(status_code=500, detail=f"获取配置架构失败: {str(e)}") + + +@router.get("/schema/model") +async def get_model_config_schema(): + """获取模型配置架构(包含提供商和模型任务配置)""" + try: + schema = ConfigSchemaGenerator.generate_config_schema(APIAdapterConfig) + return {"success": True, "schema": schema} + except Exception as e: + logger.error(f"获取模型配置架构失败: {e}") + raise HTTPException(status_code=500, detail=f"获取模型配置架构失败: {str(e)}") + + +# ===== 子配置架构获取接口 ===== + + +@router.get("/schema/section/{section_name}") +async def get_config_section_schema(section_name: str): + """ + 获取指定配置节的架构 + + 支持的section_name: + - bot: BotConfig + - personality: PersonalityConfig + - relationship: RelationshipConfig + - chat: ChatConfig + - message_receive: MessageReceiveConfig + - emoji: EmojiConfig + - expression: ExpressionConfig + - keyword_reaction: KeywordReactionConfig + - chinese_typo: ChineseTypoConfig + - response_post_process: ResponsePostProcessConfig + - response_splitter: ResponseSplitterConfig + - telemetry: TelemetryConfig + - experimental: ExperimentalConfig + - maim_message: MaimMessageConfig + - lpmm_knowledge: LPMMKnowledgeConfig + - tool: ToolConfig + - memory: MemoryConfig + - debug: DebugConfig + - mood: MoodConfig + - voice: VoiceConfig + - jargon: JargonConfig + - model_task_config: ModelTaskConfig + - api_provider: APIProvider + - model_info: ModelInfo + """ + section_map = { + "bot": BotConfig, + "personality": PersonalityConfig, + "relationship": RelationshipConfig, + "chat": ChatConfig, + "message_receive": MessageReceiveConfig, + "emoji": EmojiConfig, + "expression": ExpressionConfig, + "keyword_reaction": KeywordReactionConfig, + "chinese_typo": ChineseTypoConfig, + "response_post_process": ResponsePostProcessConfig, + "response_splitter": ResponseSplitterConfig, + "telemetry": TelemetryConfig, + "experimental": ExperimentalConfig, + "maim_message": MaimMessageConfig, + "lpmm_knowledge": LPMMKnowledgeConfig, + "tool": ToolConfig, + "memory": MemoryConfig, + "debug": DebugConfig, + "mood": MoodConfig, + "voice": VoiceConfig, + "jargon": JargonConfig, + "model_task_config": ModelTaskConfig, + "api_provider": APIProvider, + "model_info": ModelInfo, + } + + if section_name not in section_map: + raise HTTPException(status_code=404, detail=f"配置节 '{section_name}' 不存在") + + try: + config_class = section_map[section_name] + schema = ConfigSchemaGenerator.generate_schema(config_class, include_nested=False) + return {"success": True, "schema": schema} + except Exception as e: + logger.error(f"获取配置节架构失败: {e}") + raise HTTPException(status_code=500, detail=f"获取配置节架构失败: {str(e)}") + + +# ===== 配置读取接口 ===== + + +@router.get("/bot") +async def get_bot_config(): + """获取麦麦主程序配置""" + try: + config_path = os.path.join(CONFIG_DIR, "bot_config.toml") + if not os.path.exists(config_path): + raise HTTPException(status_code=404, detail="配置文件不存在") + + with open(config_path, "r", encoding="utf-8") as f: + config_data = tomlkit.load(f) + + return {"success": True, "config": config_data} + except HTTPException: + raise + except Exception as e: + logger.error(f"读取配置文件失败: {e}") + raise HTTPException(status_code=500, detail=f"读取配置文件失败: {str(e)}") + + +@router.get("/model") +async def get_model_config(): + """获取模型配置(包含提供商和模型任务配置)""" + try: + config_path = os.path.join(CONFIG_DIR, "model_config.toml") + if not os.path.exists(config_path): + raise HTTPException(status_code=404, detail="配置文件不存在") + + with open(config_path, "r", encoding="utf-8") as f: + config_data = tomlkit.load(f) + + return {"success": True, "config": config_data} + except HTTPException: + raise + except Exception as e: + logger.error(f"读取配置文件失败: {e}") + raise HTTPException(status_code=500, detail=f"读取配置文件失败: {str(e)}") + + +# ===== 配置更新接口 ===== + + +@router.post("/bot") +async def update_bot_config(config_data: dict[str, Any] = Body(...)): + """更新麦麦主程序配置""" + try: + # 验证配置数据 + try: + Config.from_dict(config_data) + except Exception as e: + raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") + + # 保存配置文件 + config_path = os.path.join(CONFIG_DIR, "bot_config.toml") + with open(config_path, "w", encoding="utf-8") as f: + tomlkit.dump(config_data, f) + + logger.info("麦麦主程序配置已更新") + return {"success": True, "message": "配置已保存"} + except HTTPException: + raise + except Exception as e: + logger.error(f"保存配置文件失败: {e}") + raise HTTPException(status_code=500, detail=f"保存配置文件失败: {str(e)}") + + +@router.post("/model") +async def update_model_config(config_data: dict[str, Any] = Body(...)): + """更新模型配置""" + try: + # 验证配置数据 + try: + APIAdapterConfig.from_dict(config_data) + except Exception as e: + raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") + + # 保存配置文件 + config_path = os.path.join(CONFIG_DIR, "model_config.toml") + with open(config_path, "w", encoding="utf-8") as f: + tomlkit.dump(config_data, f) + + logger.info("模型配置已更新") + return {"success": True, "message": "配置已保存"} + except HTTPException: + raise + except Exception as e: + logger.error(f"保存配置文件失败: {e}") + raise HTTPException(status_code=500, detail=f"保存配置文件失败: {str(e)}") + + +# ===== 配置节更新接口 ===== + + +@router.post("/bot/section/{section_name}") +async def update_bot_config_section(section_name: str, section_data: Any = Body(...)): + """更新麦麦主程序配置的指定节(保留注释和格式)""" + try: + # 读取现有配置 + config_path = os.path.join(CONFIG_DIR, "bot_config.toml") + if not os.path.exists(config_path): + raise HTTPException(status_code=404, detail="配置文件不存在") + + with open(config_path, "r", encoding="utf-8") as f: + config_data = tomlkit.load(f) + + # 更新指定节 + if section_name not in config_data: + raise HTTPException(status_code=404, detail=f"配置节 '{section_name}' 不存在") + + # 使用递归合并保留注释(对于字典类型) + # 对于数组类型(如 platforms, aliases),直接替换 + if isinstance(section_data, list): + # 列表直接替换 + config_data[section_name] = section_data + elif isinstance(section_data, dict) and isinstance(config_data[section_name], dict): + # 字典递归合并 + _update_dict_preserve_comments(config_data[section_name], section_data) + else: + # 其他类型直接替换 + config_data[section_name] = section_data + + # 验证完整配置 + try: + Config.from_dict(config_data) + except Exception as e: + raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") + + # 保存配置(tomlkit.dump 会保留注释) + with open(config_path, "w", encoding="utf-8") as f: + tomlkit.dump(config_data, f) + + logger.info(f"配置节 '{section_name}' 已更新(保留注释)") + return {"success": True, "message": f"配置节 '{section_name}' 已保存"} + except HTTPException: + raise + except Exception as e: + logger.error(f"更新配置节失败: {e}") + raise HTTPException(status_code=500, detail=f"更新配置节失败: {str(e)}") + + +# ===== 原始 TOML 文件操作接口 ===== + + +@router.get("/bot/raw") +async def get_bot_config_raw(): + """获取麦麦主程序配置的原始 TOML 内容""" + try: + config_path = os.path.join(CONFIG_DIR, "bot_config.toml") + if not os.path.exists(config_path): + raise HTTPException(status_code=404, detail="配置文件不存在") + + with open(config_path, "r", encoding="utf-8") as f: + raw_content = f.read() + + return {"success": True, "content": raw_content} + except HTTPException: + raise + except Exception as e: + logger.error(f"读取配置文件失败: {e}") + raise HTTPException(status_code=500, detail=f"读取配置文件失败: {str(e)}") + + +@router.post("/bot/raw") +async def update_bot_config_raw(raw_content: str = Body(..., embed=True)): + """更新麦麦主程序配置(直接保存原始 TOML 内容,会先验证格式)""" + try: + # 验证 TOML 格式 + try: + config_data = tomlkit.loads(raw_content) + except Exception as e: + raise HTTPException(status_code=400, detail=f"TOML 格式错误: {str(e)}") + + # 验证配置数据结构 + try: + Config.from_dict(config_data) + except Exception as e: + raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") + + # 保存配置文件 + config_path = os.path.join(CONFIG_DIR, "bot_config.toml") + with open(config_path, "w", encoding="utf-8") as f: + f.write(raw_content) + + logger.info("麦麦主程序配置已更新(原始模式)") + return {"success": True, "message": "配置已保存"} + except HTTPException: + raise + except Exception as e: + logger.error(f"保存配置文件失败: {e}") + raise HTTPException(status_code=500, detail=f"保存配置文件失败: {str(e)}") + + +@router.post("/model/section/{section_name}") +async def update_model_config_section(section_name: str, section_data: Any = Body(...)): + """更新模型配置的指定节(保留注释和格式)""" + try: + # 读取现有配置 + config_path = os.path.join(CONFIG_DIR, "model_config.toml") + if not os.path.exists(config_path): + raise HTTPException(status_code=404, detail="配置文件不存在") + + with open(config_path, "r", encoding="utf-8") as f: + config_data = tomlkit.load(f) + + # 更新指定节 + if section_name not in config_data: + raise HTTPException(status_code=404, detail=f"配置节 '{section_name}' 不存在") + + # 使用递归合并保留注释(对于字典类型) + # 对于数组表(如 [[models]], [[api_providers]]),直接替换 + if isinstance(section_data, list): + # 列表直接替换 + config_data[section_name] = section_data + elif isinstance(section_data, dict) and isinstance(config_data[section_name], dict): + # 字典递归合并 + _update_dict_preserve_comments(config_data[section_name], section_data) + else: + # 其他类型直接替换 + config_data[section_name] = section_data + + # 验证完整配置 + try: + APIAdapterConfig.from_dict(config_data) + except Exception as e: + raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") + + # 保存配置(tomlkit.dump 会保留注释) + with open(config_path, "w", encoding="utf-8") as f: + tomlkit.dump(config_data, f) + + logger.info(f"配置节 '{section_name}' 已更新(保留注释)") + return {"success": True, "message": f"配置节 '{section_name}' 已保存"} + except HTTPException: + raise + except Exception as e: + logger.error(f"更新配置节失败: {e}") + raise HTTPException(status_code=500, detail=f"更新配置节失败: {str(e)}") + + +# ===== 适配器配置管理接口 ===== + + +@router.get("/adapter-config/path") +async def get_adapter_config_path(): + """获取保存的适配器配置文件路径""" + try: + # 从 data/webui.json 读取路径偏好 + webui_data_path = os.path.join("data", "webui.json") + if not os.path.exists(webui_data_path): + return {"success": True, "path": None} + + import json + with open(webui_data_path, "r", encoding="utf-8") as f: + webui_data = json.load(f) + + adapter_config_path = webui_data.get("adapter_config_path") + if not adapter_config_path: + return {"success": True, "path": None} + + # 检查文件是否存在并返回最后修改时间 + if os.path.exists(adapter_config_path): + import datetime + mtime = os.path.getmtime(adapter_config_path) + last_modified = datetime.datetime.fromtimestamp(mtime).isoformat() + return {"success": True, "path": adapter_config_path, "lastModified": last_modified} + else: + return {"success": True, "path": adapter_config_path, "lastModified": None} + + except Exception as e: + logger.error(f"获取适配器配置路径失败: {e}") + raise HTTPException(status_code=500, detail=f"获取配置路径失败: {str(e)}") + + +@router.post("/adapter-config/path") +async def save_adapter_config_path(data: dict[str, str] = Body(...)): + """保存适配器配置文件路径偏好""" + try: + path = data.get("path") + if not path: + raise HTTPException(status_code=400, detail="路径不能为空") + + # 保存到 data/webui.json + webui_data_path = os.path.join("data", "webui.json") + import json + + # 读取现有数据 + if os.path.exists(webui_data_path): + with open(webui_data_path, "r", encoding="utf-8") as f: + webui_data = json.load(f) + else: + webui_data = {} + + # 更新路径 + webui_data["adapter_config_path"] = path + + # 保存 + os.makedirs("data", exist_ok=True) + with open(webui_data_path, "w", encoding="utf-8") as f: + json.dump(webui_data, f, ensure_ascii=False, indent=2) + + logger.info(f"适配器配置路径已保存: {path}") + return {"success": True, "message": "路径已保存"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"保存适配器配置路径失败: {e}") + raise HTTPException(status_code=500, detail=f"保存路径失败: {str(e)}") + + +@router.get("/adapter-config") +async def get_adapter_config(path: str): + """从指定路径读取适配器配置文件""" + try: + if not path: + raise HTTPException(status_code=400, detail="路径参数不能为空") + + # 检查文件是否存在 + if not os.path.exists(path): + raise HTTPException(status_code=404, detail=f"配置文件不存在: {path}") + + # 检查文件扩展名 + if not path.endswith(".toml"): + raise HTTPException(status_code=400, detail="只支持 .toml 格式的配置文件") + + # 读取文件内容 + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + logger.info(f"已读取适配器配置: {path}") + return {"success": True, "content": content} + + except HTTPException: + raise + except Exception as e: + logger.error(f"读取适配器配置失败: {e}") + raise HTTPException(status_code=500, detail=f"读取配置失败: {str(e)}") + + +@router.post("/adapter-config") +async def save_adapter_config(data: dict[str, str] = Body(...)): + """保存适配器配置到指定路径""" + try: + path = data.get("path") + content = data.get("content") + + if not path: + raise HTTPException(status_code=400, detail="路径不能为空") + if content is None: + raise HTTPException(status_code=400, detail="配置内容不能为空") + + # 检查文件扩展名 + if not path.endswith(".toml"): + raise HTTPException(status_code=400, detail="只支持 .toml 格式的配置文件") + + # 验证 TOML 格式 + try: + import toml + toml.loads(content) + except Exception as e: + raise HTTPException(status_code=400, detail=f"TOML 格式错误: {str(e)}") + + # 确保目录存在 + os.makedirs(os.path.dirname(path) if os.path.dirname(path) else ".", exist_ok=True) + + # 保存文件 + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + logger.info(f"适配器配置已保存: {path}") + return {"success": True, "message": "配置已保存"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"保存适配器配置失败: {e}") + raise HTTPException(status_code=500, detail=f"保存配置失败: {str(e)}") + diff --git a/src/webui/config_schema.py b/src/webui/config_schema.py new file mode 100644 index 00000000..c1608bc4 --- /dev/null +++ b/src/webui/config_schema.py @@ -0,0 +1,336 @@ +""" +配置架构生成器 - 自动从配置类生成前端表单架构 +""" + +import inspect +from dataclasses import fields, MISSING +from typing import Any, get_origin, get_args, Literal, Optional +from enum import Enum + +from src.config.config_base import ConfigBase + + +class FieldType(str, Enum): + """字段类型枚举""" + + STRING = "string" + NUMBER = "number" + INTEGER = "integer" + BOOLEAN = "boolean" + SELECT = "select" + ARRAY = "array" + OBJECT = "object" + TEXTAREA = "textarea" + + +class FieldSchema: + """字段架构""" + + def __init__( + self, + name: str, + type: FieldType, + label: str, + description: str = "", + default: Any = None, + required: bool = True, + options: Optional[list[str]] = None, + min_value: Optional[float] = None, + max_value: Optional[float] = None, + items: Optional[dict] = None, + properties: Optional[dict] = None, + ): + self.name = name + self.type = type + self.label = label + self.description = description + self.default = default + self.required = required + self.options = options + self.min_value = min_value + self.max_value = max_value + self.items = items + self.properties = properties + + def to_dict(self) -> dict: + """转换为字典""" + result = { + "name": self.name, + "type": self.type.value, + "label": self.label, + "description": self.description, + "required": self.required, + } + + if self.default is not None: + result["default"] = self.default + + if self.options is not None: + result["options"] = self.options + + if self.min_value is not None: + result["minValue"] = self.min_value + + if self.max_value is not None: + result["maxValue"] = self.max_value + + if self.items is not None: + result["items"] = self.items + + if self.properties is not None: + result["properties"] = self.properties + + return result + + +class ConfigSchemaGenerator: + """配置架构生成器""" + + @staticmethod + def _extract_field_description(config_class: type, field_name: str) -> str: + """ + 从类定义中提取字段的文档字符串描述 + + Args: + config_class: 配置类 + field_name: 字段名 + + Returns: + str: 字段描述 + """ + try: + # 获取源代码 + source = inspect.getsource(config_class) + lines = source.split("\n") + + # 查找字段定义 + field_found = False + description_lines = [] + + for i, line in enumerate(lines): + # 匹配字段定义行,例如: platform: str + if f"{field_name}:" in line and "=" in line: + field_found = True + # 查找下一行的文档字符串 + if i + 1 < len(lines): + next_line = lines[i + 1].strip() + if next_line.startswith('"""') or next_line.startswith("'''"): + # 单行文档字符串 + if next_line.count('"""') == 2 or next_line.count("'''") == 2: + description_lines.append(next_line.strip('"""').strip("'''").strip()) + else: + # 多行文档字符串 + quote = '"""' if next_line.startswith('"""') else "'''" + description_lines.append(next_line.strip(quote).strip()) + for j in range(i + 2, len(lines)): + if quote in lines[j]: + description_lines.append(lines[j].split(quote)[0].strip()) + break + description_lines.append(lines[j].strip()) + break + elif f"{field_name}:" in line and "=" not in line: + # 没有默认值的字段 + field_found = True + if i + 1 < len(lines): + next_line = lines[i + 1].strip() + if next_line.startswith('"""') or next_line.startswith("'''"): + if next_line.count('"""') == 2 or next_line.count("'''") == 2: + description_lines.append(next_line.strip('"""').strip("'''").strip()) + else: + quote = '"""' if next_line.startswith('"""') else "'''" + description_lines.append(next_line.strip(quote).strip()) + for j in range(i + 2, len(lines)): + if quote in lines[j]: + description_lines.append(lines[j].split(quote)[0].strip()) + break + description_lines.append(lines[j].strip()) + break + + if field_found and description_lines: + return " ".join(description_lines) + + except Exception: + pass + + return "" + + @staticmethod + def _get_field_type_and_options(field_type: type) -> tuple[FieldType, Optional[list[str]], Optional[dict]]: + """ + 获取字段类型和选项 + + Args: + field_type: 字段类型 + + Returns: + tuple: (FieldType, options, items) + """ + origin = get_origin(field_type) + args = get_args(field_type) + + # 处理 Literal 类型(枚举选项) + if origin is Literal: + return FieldType.SELECT, [str(arg) for arg in args], None + + # 处理 list 类型 + if origin is list: + item_type = args[0] if args else str + if item_type is str: + items = {"type": "string"} + elif item_type is int: + items = {"type": "integer"} + elif item_type is float: + items = {"type": "number"} + elif item_type is bool: + items = {"type": "boolean"} + elif item_type is dict: + items = {"type": "object"} + else: + items = {"type": "string"} + return FieldType.ARRAY, None, items + + # 处理 set 类型(与 list 类似) + if origin is set: + item_type = args[0] if args else str + if item_type is str: + items = {"type": "string"} + else: + items = {"type": "string"} + return FieldType.ARRAY, None, items + + # 处理基本类型 + if field_type is bool or field_type == bool: + return FieldType.BOOLEAN, None, None + elif field_type is int or field_type == int: + return FieldType.INTEGER, None, None + elif field_type is float or field_type == float: + return FieldType.NUMBER, None, None + elif field_type is str or field_type == str: + return FieldType.STRING, None, None + elif field_type is dict or origin is dict: + return FieldType.OBJECT, None, None + + # 默认为字符串 + return FieldType.STRING, None, None + + @staticmethod + def _format_field_name(name: str) -> str: + """ + 格式化字段名为可读的标签 + + Args: + name: 原始字段名 + + Returns: + str: 格式化后的标签 + """ + # 将下划线替换为空格,并首字母大写 + return " ".join(word.capitalize() for word in name.split("_")) + + @staticmethod + def generate_schema(config_class: type[ConfigBase], include_nested: bool = True) -> dict: + """ + 从配置类生成前端表单架构 + + Args: + config_class: 配置类(必须继承自 ConfigBase) + include_nested: 是否包含嵌套的配置对象 + + Returns: + dict: 前端表单架构 + """ + if not issubclass(config_class, ConfigBase): + raise ValueError(f"{config_class.__name__} 必须继承自 ConfigBase") + + schema_fields = [] + nested_schemas = {} + + for field in fields(config_class): + # 跳过私有字段和内部字段 + if field.name.startswith("_") or field.name in ["MMC_VERSION"]: + continue + + # 提取字段描述 + description = ConfigSchemaGenerator._extract_field_description(config_class, field.name) + + # 判断是否必填 + required = field.default is MISSING and field.default_factory is MISSING + + # 获取默认值 + default_value = None + if field.default is not MISSING: + default_value = field.default + elif field.default_factory is not MISSING: + try: + default_value = field.default_factory() + except Exception: + default_value = None + + # 检查是否为嵌套的 ConfigBase + if isinstance(field.type, type) and issubclass(field.type, ConfigBase): + if include_nested: + # 递归生成嵌套配置的架构 + nested_schema = ConfigSchemaGenerator.generate_schema(field.type, include_nested=True) + nested_schemas[field.name] = nested_schema + + field_schema = FieldSchema( + name=field.name, + type=FieldType.OBJECT, + label=ConfigSchemaGenerator._format_field_name(field.name), + description=description or field.type.__doc__ or "", + default=default_value, + required=required, + properties=nested_schema, + ) + else: + continue + else: + # 获取字段类型和选项 + field_type, options, items = ConfigSchemaGenerator._get_field_type_and_options(field.type) + + # 特殊处理:长文本使用 textarea + if field_type == FieldType.STRING and field.name in [ + "personality", + "reply_style", + "interest", + "plan_style", + "visual_style", + "private_plan_style", + "emotion_style", + "reaction", + "filtration_prompt", + ]: + field_type = FieldType.TEXTAREA + + field_schema = FieldSchema( + name=field.name, + type=field_type, + label=ConfigSchemaGenerator._format_field_name(field.name), + description=description, + default=default_value, + required=required, + options=options, + items=items, + ) + + schema_fields.append(field_schema.to_dict()) + + return { + "className": config_class.__name__, + "classDoc": config_class.__doc__ or "", + "fields": schema_fields, + "nested": nested_schemas if nested_schemas else None, + } + + @staticmethod + def generate_config_schema(config_class: type[ConfigBase]) -> dict: + """ + 生成完整的配置架构(包含所有嵌套的子配置) + + Args: + config_class: 配置类 + + Returns: + dict: 完整的配置架构 + """ + return ConfigSchemaGenerator.generate_schema(config_class, include_nested=True) diff --git a/src/webui/emoji_routes.py b/src/webui/emoji_routes.py new file mode 100644 index 00000000..471eb6d7 --- /dev/null +++ b/src/webui/emoji_routes.py @@ -0,0 +1,562 @@ +"""表情包管理 API 路由""" + +from fastapi import APIRouter, HTTPException, Header, Query +from fastapi.responses import FileResponse +from pydantic import BaseModel +from typing import Optional, List +from src.common.logger import get_logger +from src.common.database.database_model import Emoji +from .token_manager import get_token_manager +import json +import time +import os + +logger = get_logger("webui.emoji") + +# 创建路由器 +router = APIRouter(prefix="/emoji", tags=["Emoji"]) + + +class EmojiResponse(BaseModel): + """表情包响应""" + + id: int + full_path: str + format: str + emoji_hash: str + description: str + query_count: int + is_registered: bool + is_banned: bool + emotion: Optional[str] # 直接返回字符串 + record_time: float + register_time: Optional[float] + usage_count: int + last_used_time: Optional[float] + + +class EmojiListResponse(BaseModel): + """表情包列表响应""" + + success: bool + total: int + page: int + page_size: int + data: List[EmojiResponse] + + +class EmojiDetailResponse(BaseModel): + """表情包详情响应""" + + success: bool + data: EmojiResponse + + +class EmojiUpdateRequest(BaseModel): + """表情包更新请求""" + + description: Optional[str] = None + is_registered: Optional[bool] = None + is_banned: Optional[bool] = None + emotion: Optional[str] = None + + +class EmojiUpdateResponse(BaseModel): + """表情包更新响应""" + + success: bool + message: str + data: Optional[EmojiResponse] = None + + +class EmojiDeleteResponse(BaseModel): + """表情包删除响应""" + + success: bool + message: str + + +class BatchDeleteRequest(BaseModel): + """批量删除请求""" + + emoji_ids: List[int] + + +class BatchDeleteResponse(BaseModel): + """批量删除响应""" + + success: bool + message: str + deleted_count: int + failed_count: int + failed_ids: List[int] = [] + + +def verify_auth_token(authorization: Optional[str]) -> bool: + """验证认证 Token""" + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="未提供有效的认证信息") + + token = authorization.replace("Bearer ", "") + token_manager = get_token_manager() + + if not token_manager.verify_token(token): + raise HTTPException(status_code=401, detail="Token 无效或已过期") + + return True + + +def emoji_to_response(emoji: Emoji) -> EmojiResponse: + """将 Emoji 模型转换为响应对象""" + return EmojiResponse( + id=emoji.id, + full_path=emoji.full_path, + format=emoji.format, + emoji_hash=emoji.emoji_hash, + description=emoji.description, + query_count=emoji.query_count, + is_registered=emoji.is_registered, + is_banned=emoji.is_banned, + emotion=str(emoji.emotion) if emoji.emotion is not None else None, + record_time=emoji.record_time, + register_time=emoji.register_time, + usage_count=emoji.usage_count, + last_used_time=emoji.last_used_time, + ) + + +@router.get("/list", response_model=EmojiListResponse) +async def get_emoji_list( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + search: Optional[str] = Query(None, description="搜索关键词"), + is_registered: Optional[bool] = Query(None, description="是否已注册筛选"), + is_banned: Optional[bool] = Query(None, description="是否被禁用筛选"), + format: Optional[str] = Query(None, description="格式筛选"), + authorization: Optional[str] = Header(None), +): + """ + 获取表情包列表 + + Args: + page: 页码 (从 1 开始) + page_size: 每页数量 (1-100) + search: 搜索关键词 (匹配 description, emoji_hash) + is_registered: 是否已注册筛选 + is_banned: 是否被禁用筛选 + format: 格式筛选 + authorization: Authorization header + + Returns: + 表情包列表 + """ + try: + verify_auth_token(authorization) + + # 构建查询 + query = Emoji.select() + + # 搜索过滤 + if search: + query = query.where((Emoji.description.contains(search)) | (Emoji.emoji_hash.contains(search))) + + # 注册状态过滤 + if is_registered is not None: + query = query.where(Emoji.is_registered == is_registered) + + # 禁用状态过滤 + if is_banned is not None: + query = query.where(Emoji.is_banned == is_banned) + + # 格式过滤 + if format: + query = query.where(Emoji.format == format) + + # 排序:使用次数倒序,然后按记录时间倒序 + from peewee import Case + + query = query.order_by( + Emoji.usage_count.desc(), Case(None, [(Emoji.record_time.is_null(), 1)], 0), Emoji.record_time.desc() + ) + + # 获取总数 + total = query.count() + + # 分页 + offset = (page - 1) * page_size + emojis = query.offset(offset).limit(page_size) + + # 转换为响应对象 + data = [emoji_to_response(emoji) for emoji in emojis] + + return EmojiListResponse(success=True, total=total, page=page, page_size=page_size, data=data) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取表情包列表失败: {e}") + raise HTTPException(status_code=500, detail=f"获取表情包列表失败: {str(e)}") from e + + +@router.get("/{emoji_id}", response_model=EmojiDetailResponse) +async def get_emoji_detail(emoji_id: int, authorization: Optional[str] = Header(None)): + """ + 获取表情包详细信息 + + Args: + emoji_id: 表情包ID + authorization: Authorization header + + Returns: + 表情包详细信息 + """ + try: + verify_auth_token(authorization) + + emoji = Emoji.get_or_none(Emoji.id == emoji_id) + + if not emoji: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + + return EmojiDetailResponse(success=True, data=emoji_to_response(emoji)) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取表情包详情失败: {e}") + raise HTTPException(status_code=500, detail=f"获取表情包详情失败: {str(e)}") from e + + +@router.patch("/{emoji_id}", response_model=EmojiUpdateResponse) +async def update_emoji(emoji_id: int, request: EmojiUpdateRequest, authorization: Optional[str] = Header(None)): + """ + 增量更新表情包(只更新提供的字段) + + Args: + emoji_id: 表情包ID + request: 更新请求(只包含需要更新的字段) + authorization: Authorization header + + Returns: + 更新结果 + """ + try: + verify_auth_token(authorization) + + emoji = Emoji.get_or_none(Emoji.id == emoji_id) + + if not emoji: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + + # 只更新提供的字段 + update_data = request.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException(status_code=400, detail="未提供任何需要更新的字段") + + # emotion 字段直接使用字符串,无需转换 + + # 如果注册状态从 False 变为 True,记录注册时间 + if "is_registered" in update_data and update_data["is_registered"] and not emoji.is_registered: + update_data["register_time"] = time.time() + + # 执行更新 + for field, value in update_data.items(): + setattr(emoji, field, value) + + emoji.save() + + logger.info(f"表情包已更新: ID={emoji_id}, 字段: {list(update_data.keys())}") + + return EmojiUpdateResponse( + success=True, message=f"成功更新 {len(update_data)} 个字段", data=emoji_to_response(emoji) + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"更新表情包失败: {e}") + raise HTTPException(status_code=500, detail=f"更新表情包失败: {str(e)}") from e + + +@router.delete("/{emoji_id}", response_model=EmojiDeleteResponse) +async def delete_emoji(emoji_id: int, authorization: Optional[str] = Header(None)): + """ + 删除表情包 + + Args: + emoji_id: 表情包ID + authorization: Authorization header + + Returns: + 删除结果 + """ + try: + verify_auth_token(authorization) + + emoji = Emoji.get_or_none(Emoji.id == emoji_id) + + if not emoji: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + + # 记录删除信息 + emoji_hash = emoji.emoji_hash + + # 执行删除 + emoji.delete_instance() + + logger.info(f"表情包已删除: ID={emoji_id}, hash={emoji_hash}") + + return EmojiDeleteResponse(success=True, message=f"成功删除表情包: {emoji_hash}") + + except HTTPException: + raise + except Exception as e: + logger.exception(f"删除表情包失败: {e}") + raise HTTPException(status_code=500, detail=f"删除表情包失败: {str(e)}") from e + + +@router.get("/stats/summary") +async def get_emoji_stats(authorization: Optional[str] = Header(None)): + """ + 获取表情包统计数据 + + Args: + authorization: Authorization header + + Returns: + 统计数据 + """ + try: + verify_auth_token(authorization) + + total = Emoji.select().count() + registered = Emoji.select().where(Emoji.is_registered).count() + banned = Emoji.select().where(Emoji.is_banned).count() + + # 按格式统计 + formats = {} + for emoji in Emoji.select(Emoji.format): + fmt = emoji.format + formats[fmt] = formats.get(fmt, 0) + 1 + + # 获取最常用的表情包(前10) + top_used = Emoji.select().order_by(Emoji.usage_count.desc()).limit(10) + top_used_list = [ + { + "id": emoji.id, + "emoji_hash": emoji.emoji_hash, + "description": emoji.description, + "usage_count": emoji.usage_count, + } + for emoji in top_used + ] + + return { + "success": True, + "data": { + "total": total, + "registered": registered, + "banned": banned, + "unregistered": total - registered, + "formats": formats, + "top_used": top_used_list, + }, + } + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取统计数据失败: {e}") + raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}") from e + + +@router.post("/{emoji_id}/register", response_model=EmojiUpdateResponse) +async def register_emoji(emoji_id: int, authorization: Optional[str] = Header(None)): + """ + 注册表情包(快捷操作) + + Args: + emoji_id: 表情包ID + authorization: Authorization header + + Returns: + 更新结果 + """ + try: + verify_auth_token(authorization) + + emoji = Emoji.get_or_none(Emoji.id == emoji_id) + + if not emoji: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + + if emoji.is_registered: + raise HTTPException(status_code=400, detail="该表情包已经注册") + + if emoji.is_banned: + raise HTTPException(status_code=400, detail="该表情包已被禁用,无法注册") + + # 注册表情包 + emoji.is_registered = True + emoji.register_time = time.time() + emoji.save() + + logger.info(f"表情包已注册: ID={emoji_id}") + + return EmojiUpdateResponse(success=True, message="表情包注册成功", data=emoji_to_response(emoji)) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"注册表情包失败: {e}") + raise HTTPException(status_code=500, detail=f"注册表情包失败: {str(e)}") from e + + +@router.post("/{emoji_id}/ban", response_model=EmojiUpdateResponse) +async def ban_emoji(emoji_id: int, authorization: Optional[str] = Header(None)): + """ + 禁用表情包(快捷操作) + + Args: + emoji_id: 表情包ID + authorization: Authorization header + + Returns: + 更新结果 + """ + try: + verify_auth_token(authorization) + + emoji = Emoji.get_or_none(Emoji.id == emoji_id) + + if not emoji: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + + # 禁用表情包(同时取消注册) + emoji.is_banned = True + emoji.is_registered = False + emoji.save() + + logger.info(f"表情包已禁用: ID={emoji_id}") + + return EmojiUpdateResponse(success=True, message="表情包禁用成功", data=emoji_to_response(emoji)) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"禁用表情包失败: {e}") + raise HTTPException(status_code=500, detail=f"禁用表情包失败: {str(e)}") from e + + +@router.get("/{emoji_id}/thumbnail") +async def get_emoji_thumbnail( + emoji_id: int, + token: Optional[str] = Query(None, description="访问令牌"), + authorization: Optional[str] = Header(None), +): + """ + 获取表情包缩略图 + + Args: + emoji_id: 表情包ID + token: 访问令牌(通过 query parameter) + authorization: Authorization header + + Returns: + 表情包图片文件 + """ + try: + # 优先使用 query parameter 中的 token(用于 img 标签) + if token: + token_manager = get_token_manager() + if not token_manager.verify_token(token): + raise HTTPException(status_code=401, detail="Token 无效或已过期") + else: + # 如果没有 query token,则验证 Authorization header + verify_auth_token(authorization) + + emoji = Emoji.get_or_none(Emoji.id == emoji_id) + + if not emoji: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + + # 检查文件是否存在 + if not os.path.exists(emoji.full_path): + raise HTTPException(status_code=404, detail="表情包文件不存在") + + # 根据格式设置 MIME 类型 + mime_types = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "webp": "image/webp", + "bmp": "image/bmp", + } + + media_type = mime_types.get(emoji.format.lower(), "application/octet-stream") + + return FileResponse(path=emoji.full_path, media_type=media_type, filename=f"{emoji.emoji_hash}.{emoji.format}") + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取表情包缩略图失败: {e}") + raise HTTPException(status_code=500, detail=f"获取表情包缩略图失败: {str(e)}") from e + + +@router.post("/batch/delete", response_model=BatchDeleteResponse) +async def batch_delete_emojis(request: BatchDeleteRequest, authorization: Optional[str] = Header(None)): + """ + 批量删除表情包 + + Args: + request: 包含emoji_ids列表的请求 + authorization: Authorization header + + Returns: + 批量删除结果 + """ + try: + verify_auth_token(authorization) + + if not request.emoji_ids: + raise HTTPException(status_code=400, detail="未提供要删除的表情包ID") + + deleted_count = 0 + failed_count = 0 + failed_ids = [] + + for emoji_id in request.emoji_ids: + try: + emoji = Emoji.get_or_none(Emoji.id == emoji_id) + if emoji: + emoji.delete_instance() + deleted_count += 1 + logger.info(f"批量删除表情包: {emoji_id}") + else: + failed_count += 1 + failed_ids.append(emoji_id) + except Exception as e: + logger.error(f"删除表情包 {emoji_id} 失败: {e}") + failed_count += 1 + failed_ids.append(emoji_id) + + message = f"成功删除 {deleted_count} 个表情包" + if failed_count > 0: + message += f",{failed_count} 个失败" + + return BatchDeleteResponse( + success=True, + message=message, + deleted_count=deleted_count, + failed_count=failed_count, + failed_ids=failed_ids, + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"批量删除表情包失败: {e}") + raise HTTPException(status_code=500, detail=f"批量删除失败: {str(e)}") from e diff --git a/src/webui/expression_routes.py b/src/webui/expression_routes.py new file mode 100644 index 00000000..983918cf --- /dev/null +++ b/src/webui/expression_routes.py @@ -0,0 +1,432 @@ +"""表达方式管理 API 路由""" + +from fastapi import APIRouter, HTTPException, Header, Query +from pydantic import BaseModel +from typing import Optional, List +from src.common.logger import get_logger +from src.common.database.database_model import Expression +from .token_manager import get_token_manager +import time + +logger = get_logger("webui.expression") + +# 创建路由器 +router = APIRouter(prefix="/expression", tags=["Expression"]) + + +class ExpressionResponse(BaseModel): + """表达方式响应""" + + id: int + situation: str + style: str + context: Optional[str] + up_content: Optional[str] + last_active_time: float + chat_id: str + create_date: Optional[float] + + +class ExpressionListResponse(BaseModel): + """表达方式列表响应""" + + success: bool + total: int + page: int + page_size: int + data: List[ExpressionResponse] + + +class ExpressionDetailResponse(BaseModel): + """表达方式详情响应""" + + success: bool + data: ExpressionResponse + + +class ExpressionCreateRequest(BaseModel): + """表达方式创建请求""" + + situation: str + style: str + context: Optional[str] = None + up_content: Optional[str] = None + chat_id: str + + +class ExpressionUpdateRequest(BaseModel): + """表达方式更新请求""" + + situation: Optional[str] = None + style: Optional[str] = None + context: Optional[str] = None + up_content: Optional[str] = None + chat_id: Optional[str] = None + + +class ExpressionUpdateResponse(BaseModel): + """表达方式更新响应""" + + success: bool + message: str + data: Optional[ExpressionResponse] = None + + +class ExpressionDeleteResponse(BaseModel): + """表达方式删除响应""" + + success: bool + message: str + + +class ExpressionCreateResponse(BaseModel): + """表达方式创建响应""" + + success: bool + message: str + data: ExpressionResponse + + +def verify_auth_token(authorization: Optional[str]) -> bool: + """验证认证 Token""" + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="未提供有效的认证信息") + + token = authorization.replace("Bearer ", "") + token_manager = get_token_manager() + + if not token_manager.verify_token(token): + raise HTTPException(status_code=401, detail="Token 无效或已过期") + + return True + + +def expression_to_response(expression: Expression) -> ExpressionResponse: + """将 Expression 模型转换为响应对象""" + return ExpressionResponse( + id=expression.id, + situation=expression.situation, + style=expression.style, + context=expression.context, + up_content=expression.up_content, + last_active_time=expression.last_active_time, + chat_id=expression.chat_id, + create_date=expression.create_date, + ) + + +@router.get("/list", response_model=ExpressionListResponse) +async def get_expression_list( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + search: Optional[str] = Query(None, description="搜索关键词"), + chat_id: Optional[str] = Query(None, description="聊天ID筛选"), + authorization: Optional[str] = Header(None), +): + """ + 获取表达方式列表 + + Args: + page: 页码 (从 1 开始) + page_size: 每页数量 (1-100) + search: 搜索关键词 (匹配 situation, style, context) + chat_id: 聊天ID筛选 + authorization: Authorization header + + Returns: + 表达方式列表 + """ + try: + verify_auth_token(authorization) + + # 构建查询 + query = Expression.select() + + # 搜索过滤 + if search: + query = query.where( + (Expression.situation.contains(search)) + | (Expression.style.contains(search)) + | (Expression.context.contains(search)) + ) + + # 聊天ID过滤 + if chat_id: + query = query.where(Expression.chat_id == chat_id) + + # 排序:最后活跃时间倒序(NULL 值放在最后) + from peewee import Case + + query = query.order_by( + Case(None, [(Expression.last_active_time.is_null(), 1)], 0), Expression.last_active_time.desc() + ) + + # 获取总数 + total = query.count() + + # 分页 + offset = (page - 1) * page_size + expressions = query.offset(offset).limit(page_size) + + # 转换为响应对象 + data = [expression_to_response(expr) for expr in expressions] + + return ExpressionListResponse(success=True, total=total, page=page, page_size=page_size, data=data) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取表达方式列表失败: {e}") + raise HTTPException(status_code=500, detail=f"获取表达方式列表失败: {str(e)}") from e + + +@router.get("/{expression_id}", response_model=ExpressionDetailResponse) +async def get_expression_detail(expression_id: int, authorization: Optional[str] = Header(None)): + """ + 获取表达方式详细信息 + + Args: + expression_id: 表达方式ID + authorization: Authorization header + + Returns: + 表达方式详细信息 + """ + try: + verify_auth_token(authorization) + + expression = Expression.get_or_none(Expression.id == expression_id) + + if not expression: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {expression_id} 的表达方式") + + return ExpressionDetailResponse(success=True, data=expression_to_response(expression)) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取表达方式详情失败: {e}") + raise HTTPException(status_code=500, detail=f"获取表达方式详情失败: {str(e)}") from e + + +@router.post("/", response_model=ExpressionCreateResponse) +async def create_expression(request: ExpressionCreateRequest, authorization: Optional[str] = Header(None)): + """ + 创建新的表达方式 + + Args: + request: 创建请求 + authorization: Authorization header + + Returns: + 创建结果 + """ + try: + verify_auth_token(authorization) + + current_time = time.time() + + # 创建表达方式 + expression = Expression.create( + situation=request.situation, + style=request.style, + context=request.context, + up_content=request.up_content, + chat_id=request.chat_id, + last_active_time=current_time, + create_date=current_time, + ) + + logger.info(f"表达方式已创建: ID={expression.id}, situation={request.situation}") + + return ExpressionCreateResponse( + success=True, message="表达方式创建成功", data=expression_to_response(expression) + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"创建表达方式失败: {e}") + raise HTTPException(status_code=500, detail=f"创建表达方式失败: {str(e)}") from e + + +@router.patch("/{expression_id}", response_model=ExpressionUpdateResponse) +async def update_expression( + expression_id: int, request: ExpressionUpdateRequest, authorization: Optional[str] = Header(None) +): + """ + 增量更新表达方式(只更新提供的字段) + + Args: + expression_id: 表达方式ID + request: 更新请求(只包含需要更新的字段) + authorization: Authorization header + + Returns: + 更新结果 + """ + try: + verify_auth_token(authorization) + + expression = Expression.get_or_none(Expression.id == expression_id) + + if not expression: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {expression_id} 的表达方式") + + # 只更新提供的字段 + update_data = request.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException(status_code=400, detail="未提供任何需要更新的字段") + + # 更新最后活跃时间 + update_data["last_active_time"] = time.time() + + # 执行更新 + for field, value in update_data.items(): + setattr(expression, field, value) + + expression.save() + + logger.info(f"表达方式已更新: ID={expression_id}, 字段: {list(update_data.keys())}") + + return ExpressionUpdateResponse( + success=True, message=f"成功更新 {len(update_data)} 个字段", data=expression_to_response(expression) + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"更新表达方式失败: {e}") + raise HTTPException(status_code=500, detail=f"更新表达方式失败: {str(e)}") from e + + +@router.delete("/{expression_id}", response_model=ExpressionDeleteResponse) +async def delete_expression(expression_id: int, authorization: Optional[str] = Header(None)): + """ + 删除表达方式 + + Args: + expression_id: 表达方式ID + authorization: Authorization header + + Returns: + 删除结果 + """ + try: + verify_auth_token(authorization) + + expression = Expression.get_or_none(Expression.id == expression_id) + + if not expression: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {expression_id} 的表达方式") + + # 记录删除信息 + situation = expression.situation + + # 执行删除 + expression.delete_instance() + + logger.info(f"表达方式已删除: ID={expression_id}, situation={situation}") + + return ExpressionDeleteResponse(success=True, message=f"成功删除表达方式: {situation}") + + except HTTPException: + raise + except Exception as e: + logger.exception(f"删除表达方式失败: {e}") + raise HTTPException(status_code=500, detail=f"删除表达方式失败: {str(e)}") from e + + +class BatchDeleteRequest(BaseModel): + """批量删除请求""" + + ids: List[int] + + +@router.post("/batch/delete", response_model=ExpressionDeleteResponse) +async def batch_delete_expressions(request: BatchDeleteRequest, authorization: Optional[str] = Header(None)): + """ + 批量删除表达方式 + + Args: + request: 包含要删除的ID列表的请求 + authorization: Authorization header + + Returns: + 删除结果 + """ + try: + verify_auth_token(authorization) + + if not request.ids: + raise HTTPException(status_code=400, detail="未提供要删除的表达方式ID") + + # 查找所有要删除的表达方式 + expressions = Expression.select().where(Expression.id.in_(request.ids)) + found_ids = [expr.id for expr in expressions] + + # 检查是否有未找到的ID + not_found_ids = set(request.ids) - set(found_ids) + if not_found_ids: + logger.warning(f"部分表达方式未找到: {not_found_ids}") + + # 执行批量删除 + deleted_count = Expression.delete().where(Expression.id.in_(found_ids)).execute() + + logger.info(f"批量删除了 {deleted_count} 个表达方式") + + return ExpressionDeleteResponse(success=True, message=f"成功删除 {deleted_count} 个表达方式") + + except HTTPException: + raise + except Exception as e: + logger.exception(f"批量删除表达方式失败: {e}") + raise HTTPException(status_code=500, detail=f"批量删除表达方式失败: {str(e)}") from e + + +@router.get("/stats/summary") +async def get_expression_stats(authorization: Optional[str] = Header(None)): + """ + 获取表达方式统计数据 + + Args: + authorization: Authorization header + + Returns: + 统计数据 + """ + try: + verify_auth_token(authorization) + + total = Expression.select().count() + + # 按 chat_id 统计 + chat_stats = {} + for expr in Expression.select(Expression.chat_id): + chat_id = expr.chat_id + chat_stats[chat_id] = chat_stats.get(chat_id, 0) + 1 + + # 获取最近创建的记录数(7天内) + seven_days_ago = time.time() - (7 * 24 * 60 * 60) + recent = ( + Expression.select() + .where((Expression.create_date.is_null(False)) & (Expression.create_date >= seven_days_ago)) + .count() + ) + + return { + "success": True, + "data": { + "total": total, + "recent_7days": recent, + "chat_count": len(chat_stats), + "top_chats": dict(sorted(chat_stats.items(), key=lambda x: x[1], reverse=True)[:10]), + }, + } + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取统计数据失败: {e}") + raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}") from e diff --git a/src/webui/git_mirror_service.py b/src/webui/git_mirror_service.py new file mode 100644 index 00000000..df00cde9 --- /dev/null +++ b/src/webui/git_mirror_service.py @@ -0,0 +1,662 @@ +"""Git 镜像源服务 - 支持多镜像源、错误重试、Git 克隆和 Raw 文件获取""" + +from typing import Optional, List, Dict, Any +from enum import Enum +import httpx +import json +import asyncio +import subprocess +import shutil +from pathlib import Path +from datetime import datetime +from src.common.logger import get_logger + +logger = get_logger("webui.git_mirror") + +# 导入进度更新函数(避免循环导入) +_update_progress = None + + +def set_update_progress_callback(callback): + """设置进度更新回调函数""" + global _update_progress + _update_progress = callback + + +class MirrorType(str, Enum): + """镜像源类型""" + + GH_PROXY = "gh-proxy" # gh-proxy 主节点 + HK_GH_PROXY = "hk-gh-proxy" # gh-proxy 香港节点 + CDN_GH_PROXY = "cdn-gh-proxy" # gh-proxy CDN 节点 + EDGEONE_GH_PROXY = "edgeone-gh-proxy" # gh-proxy EdgeOne 节点 + MEYZH_GITHUB = "meyzh-github" # Meyzh GitHub 镜像 + GITHUB = "github" # GitHub 官方源(兜底) + CUSTOM = "custom" # 自定义镜像源 + + +class GitMirrorConfig: + """Git 镜像源配置管理""" + + # 配置文件路径 + CONFIG_FILE = Path("data/webui.json") + + # 默认镜像源配置 + DEFAULT_MIRRORS = [ + { + "id": "gh-proxy", + "name": "gh-proxy 镜像", + "raw_prefix": "https://gh-proxy.org/https://raw.githubusercontent.com", + "clone_prefix": "https://gh-proxy.org/https://github.com", + "enabled": True, + "priority": 1, + "created_at": None, + }, + { + "id": "hk-gh-proxy", + "name": "gh-proxy 香港节点", + "raw_prefix": "https://hk.gh-proxy.org/https://raw.githubusercontent.com", + "clone_prefix": "https://hk.gh-proxy.org/https://github.com", + "enabled": True, + "priority": 2, + "created_at": None, + }, + { + "id": "cdn-gh-proxy", + "name": "gh-proxy CDN 节点", + "raw_prefix": "https://cdn.gh-proxy.org/https://raw.githubusercontent.com", + "clone_prefix": "https://cdn.gh-proxy.org/https://github.com", + "enabled": True, + "priority": 3, + "created_at": None, + }, + { + "id": "edgeone-gh-proxy", + "name": "gh-proxy EdgeOne 节点", + "raw_prefix": "https://edgeone.gh-proxy.org/https://raw.githubusercontent.com", + "clone_prefix": "https://edgeone.gh-proxy.org/https://github.com", + "enabled": True, + "priority": 4, + "created_at": None, + }, + { + "id": "meyzh-github", + "name": "Meyzh GitHub 镜像", + "raw_prefix": "https://meyzh.github.io/https://raw.githubusercontent.com", + "clone_prefix": "https://meyzh.github.io/https://github.com", + "enabled": True, + "priority": 5, + "created_at": None, + }, + { + "id": "github", + "name": "GitHub 官方源(兜底)", + "raw_prefix": "https://raw.githubusercontent.com", + "clone_prefix": "https://github.com", + "enabled": True, + "priority": 999, + "created_at": None, + }, + ] + + def __init__(self): + """初始化配置管理器""" + self.config_file = self.CONFIG_FILE + self.mirrors: List[Dict[str, Any]] = [] + self._load_config() + + def _load_config(self) -> None: + """加载配置文件""" + try: + if self.config_file.exists(): + with open(self.config_file, "r", encoding="utf-8") as f: + data = json.load(f) + + # 检查是否有镜像源配置 + if "git_mirrors" not in data or not data["git_mirrors"]: + logger.info("配置文件中未找到镜像源配置,使用默认配置") + self._init_default_mirrors() + else: + self.mirrors = data["git_mirrors"] + logger.info(f"已加载 {len(self.mirrors)} 个镜像源配置") + else: + logger.info("配置文件不存在,创建默认配置") + self._init_default_mirrors() + except Exception as e: + logger.error(f"加载配置文件失败: {e}") + self._init_default_mirrors() + + def _init_default_mirrors(self) -> None: + """初始化默认镜像源""" + current_time = datetime.now().isoformat() + self.mirrors = [] + + for mirror in self.DEFAULT_MIRRORS: + mirror_copy = mirror.copy() + mirror_copy["created_at"] = current_time + self.mirrors.append(mirror_copy) + + self._save_config() + logger.info(f"已初始化 {len(self.mirrors)} 个默认镜像源") + + def _save_config(self) -> None: + """保存配置到文件""" + try: + # 确保目录存在 + self.config_file.parent.mkdir(parents=True, exist_ok=True) + + # 读取现有配置 + existing_data = {} + if self.config_file.exists(): + with open(self.config_file, "r", encoding="utf-8") as f: + existing_data = json.load(f) + + # 更新镜像源配置 + existing_data["git_mirrors"] = self.mirrors + + # 写入文件 + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump(existing_data, f, indent=2, ensure_ascii=False) + + logger.debug(f"配置已保存到 {self.config_file}") + except Exception as e: + logger.error(f"保存配置文件失败: {e}") + + def get_all_mirrors(self) -> List[Dict[str, Any]]: + """获取所有镜像源""" + return self.mirrors.copy() + + def get_enabled_mirrors(self) -> List[Dict[str, Any]]: + """获取所有启用的镜像源,按优先级排序""" + enabled = [m for m in self.mirrors if m.get("enabled", False)] + return sorted(enabled, key=lambda x: x.get("priority", 999)) + + def get_mirror_by_id(self, mirror_id: str) -> Optional[Dict[str, Any]]: + """根据 ID 获取镜像源""" + for mirror in self.mirrors: + if mirror.get("id") == mirror_id: + return mirror.copy() + return None + + def add_mirror( + self, + mirror_id: str, + name: str, + raw_prefix: str, + clone_prefix: str, + enabled: bool = True, + priority: Optional[int] = None, + ) -> Dict[str, Any]: + """ + 添加新的镜像源 + + Returns: + 添加的镜像源配置 + + Raises: + ValueError: 如果镜像源 ID 已存在 + """ + # 检查 ID 是否已存在 + if self.get_mirror_by_id(mirror_id): + raise ValueError(f"镜像源 ID 已存在: {mirror_id}") + + # 如果未指定优先级,使用最大优先级 + 1 + if priority is None: + max_priority = max((m.get("priority", 0) for m in self.mirrors), default=0) + priority = max_priority + 1 + + new_mirror = { + "id": mirror_id, + "name": name, + "raw_prefix": raw_prefix, + "clone_prefix": clone_prefix, + "enabled": enabled, + "priority": priority, + "created_at": datetime.now().isoformat(), + } + + self.mirrors.append(new_mirror) + self._save_config() + + logger.info(f"已添加镜像源: {mirror_id} - {name}") + return new_mirror.copy() + + def update_mirror( + self, + mirror_id: str, + name: Optional[str] = None, + raw_prefix: Optional[str] = None, + clone_prefix: Optional[str] = None, + enabled: Optional[bool] = None, + priority: Optional[int] = None, + ) -> Optional[Dict[str, Any]]: + """ + 更新镜像源配置 + + Returns: + 更新后的镜像源配置,如果不存在则返回 None + """ + for mirror in self.mirrors: + if mirror.get("id") == mirror_id: + if name is not None: + mirror["name"] = name + if raw_prefix is not None: + mirror["raw_prefix"] = raw_prefix + if clone_prefix is not None: + mirror["clone_prefix"] = clone_prefix + if enabled is not None: + mirror["enabled"] = enabled + if priority is not None: + mirror["priority"] = priority + + mirror["updated_at"] = datetime.now().isoformat() + self._save_config() + + logger.info(f"已更新镜像源: {mirror_id}") + return mirror.copy() + + return None + + def delete_mirror(self, mirror_id: str) -> bool: + """ + 删除镜像源 + + Returns: + True 如果删除成功,False 如果镜像源不存在 + """ + for i, mirror in enumerate(self.mirrors): + if mirror.get("id") == mirror_id: + self.mirrors.pop(i) + self._save_config() + logger.info(f"已删除镜像源: {mirror_id}") + return True + + return False + + def get_default_priority_list(self) -> List[str]: + """获取默认优先级列表(仅启用的镜像源 ID)""" + enabled = self.get_enabled_mirrors() + return [m["id"] for m in enabled] + + +class GitMirrorService: + """Git 镜像源服务""" + + def __init__(self, max_retries: int = 3, timeout: int = 30, config: Optional[GitMirrorConfig] = None): + """ + 初始化 Git 镜像源服务 + + Args: + max_retries: 最大重试次数 + timeout: 请求超时时间(秒) + config: 镜像源配置管理器(可选,默认创建新实例) + """ + self.max_retries = max_retries + self.timeout = timeout + self.config = config or GitMirrorConfig() + logger.info(f"Git镜像源服务初始化完成,已加载 {len(self.config.get_enabled_mirrors())} 个启用的镜像源") + + def get_mirror_config(self) -> GitMirrorConfig: + """获取镜像源配置管理器""" + return self.config + + @staticmethod + def check_git_installed() -> Dict[str, Any]: + """ + 检查本机是否安装了 Git + + Returns: + Dict 包含: + - installed: bool - 是否已安装 Git + - version: str - Git 版本号(如果已安装) + - path: str - Git 可执行文件路径(如果已安装) + - error: str - 错误信息(如果未安装或检测失败) + """ + import subprocess + import shutil + + try: + # 查找 git 可执行文件路径 + git_path = shutil.which("git") + + if not git_path: + logger.warning("未找到 Git 可执行文件") + return {"installed": False, "error": "系统中未找到 Git,请先安装 Git"} + + # 获取 Git 版本 + result = subprocess.run(["git", "--version"], capture_output=True, text=True, timeout=5) + + if result.returncode == 0: + version = result.stdout.strip() + logger.info(f"检测到 Git: {version} at {git_path}") + return {"installed": True, "version": version, "path": git_path} + else: + logger.warning(f"Git 命令执行失败: {result.stderr}") + return {"installed": False, "error": f"Git 命令执行失败: {result.stderr}"} + + except subprocess.TimeoutExpired: + logger.error("Git 版本检测超时") + return {"installed": False, "error": "Git 版本检测超时"} + except Exception as e: + logger.error(f"检测 Git 时发生错误: {e}") + return {"installed": False, "error": f"检测 Git 时发生错误: {str(e)}"} + + async def fetch_raw_file( + self, + owner: str, + repo: str, + branch: str, + file_path: str, + mirror_id: Optional[str] = None, + custom_url: Optional[str] = None, + ) -> Dict[str, Any]: + """ + 获取 GitHub 仓库的 Raw 文件内容 + + Args: + owner: 仓库所有者 + repo: 仓库名称 + branch: 分支名称 + file_path: 文件路径 + mirror_id: 指定的镜像源 ID + custom_url: 自定义完整 URL(如果提供,将忽略其他参数) + + Returns: + Dict 包含: + - success: bool - 是否成功 + - data: str - 文件内容(成功时) + - error: str - 错误信息(失败时) + - mirror_used: str - 使用的镜像源 + - attempts: int - 尝试次数 + """ + logger.info(f"开始获取 Raw 文件: {owner}/{repo}/{branch}/{file_path}") + + if custom_url: + # 使用自定义 URL + return await self._fetch_with_url(custom_url, "custom") + + # 确定要使用的镜像源列表 + if mirror_id: + # 使用指定的镜像源 + mirror = self.config.get_mirror_by_id(mirror_id) + if not mirror: + return {"success": False, "error": f"未找到镜像源: {mirror_id}", "mirror_used": None, "attempts": 0} + mirrors_to_try = [mirror] + else: + # 使用所有启用的镜像源 + mirrors_to_try = self.config.get_enabled_mirrors() + + total_mirrors = len(mirrors_to_try) + + # 依次尝试每个镜像源 + for index, mirror in enumerate(mirrors_to_try, 1): + # 推送进度:正在尝试第 N 个镜像源 + if _update_progress: + try: + progress = 30 + int((index - 1) / total_mirrors * 40) # 30% - 70% + await _update_progress( + stage="loading", + progress=progress, + message=f"正在尝试镜像源 {index}/{total_mirrors}: {mirror['name']}", + total_plugins=0, + loaded_plugins=0, + ) + except Exception as e: + logger.warning(f"推送进度失败: {e}") + + result = await self._fetch_raw_from_mirror(owner, repo, branch, file_path, mirror) + + if result["success"]: + # 成功,推送进度 + if _update_progress: + try: + await _update_progress( + stage="loading", + progress=70, + message=f"成功从 {mirror['name']} 获取数据", + total_plugins=0, + loaded_plugins=0, + ) + except Exception as e: + logger.warning(f"推送进度失败: {e}") + return result + + # 失败,记录日志并推送失败信息 + logger.warning(f"镜像源 {mirror['id']} 失败: {result.get('error')}") + + if _update_progress and index < total_mirrors: + try: + await _update_progress( + stage="loading", + progress=30 + int(index / total_mirrors * 40), + message=f"镜像源 {mirror['name']} 失败,尝试下一个...", + total_plugins=0, + loaded_plugins=0, + ) + except Exception as e: + logger.warning(f"推送进度失败: {e}") + + # 所有镜像源都失败 + return {"success": False, "error": "所有镜像源均失败", "mirror_used": None, "attempts": len(mirrors_to_try)} + + async def _fetch_raw_from_mirror( + self, owner: str, repo: str, branch: str, file_path: str, mirror: Dict[str, Any] + ) -> Dict[str, Any]: + """从指定镜像源获取文件""" + # 构建 URL + raw_prefix = mirror["raw_prefix"] + url = f"{raw_prefix}/{owner}/{repo}/{branch}/{file_path}" + + return await self._fetch_with_url(url, mirror["id"]) + + async def _fetch_with_url(self, url: str, mirror_type: str) -> Dict[str, Any]: + """使用指定 URL 获取文件,支持重试""" + attempts = 0 + last_error = None + + for attempt in range(self.max_retries): + attempts += 1 + try: + logger.debug(f"尝试 #{attempt + 1}: {url}") + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get(url) + response.raise_for_status() + + logger.info(f"成功获取文件: {url}") + return { + "success": True, + "data": response.text, + "mirror_used": mirror_type, + "attempts": attempts, + "url": url, + } + except httpx.HTTPStatusError as e: + last_error = f"HTTP {e.response.status_code}: {e}" + logger.warning(f"HTTP 错误 (尝试 {attempt + 1}/{self.max_retries}): {last_error}") + except httpx.TimeoutException as e: + last_error = f"请求超时: {e}" + logger.warning(f"超时 (尝试 {attempt + 1}/{self.max_retries}): {last_error}") + except Exception as e: + last_error = f"未知错误: {e}" + logger.error(f"错误 (尝试 {attempt + 1}/{self.max_retries}): {last_error}") + + return {"success": False, "error": last_error, "mirror_used": mirror_type, "attempts": attempts, "url": url} + + async def clone_repository( + self, + owner: str, + repo: str, + target_path: Path, + branch: Optional[str] = None, + mirror_id: Optional[str] = None, + custom_url: Optional[str] = None, + depth: Optional[int] = None, + ) -> Dict[str, Any]: + """ + 克隆 GitHub 仓库 + + Args: + owner: 仓库所有者 + repo: 仓库名称 + target_path: 目标路径 + branch: 分支名称(可选) + mirror_id: 指定的镜像源 ID + custom_url: 自定义克隆 URL + depth: 克隆深度(浅克隆) + + Returns: + Dict 包含: + - success: bool - 是否成功 + - path: str - 克隆路径(成功时) + - error: str - 错误信息(失败时) + - mirror_used: str - 使用的镜像源 + - attempts: int - 尝试次数 + """ + logger.info(f"开始克隆仓库: {owner}/{repo} 到 {target_path}") + + if custom_url: + # 使用自定义 URL + return await self._clone_with_url(custom_url, target_path, branch, depth, "custom") + + # 确定要使用的镜像源列表 + if mirror_id: + # 使用指定的镜像源 + mirror = self.config.get_mirror_by_id(mirror_id) + if not mirror: + return {"success": False, "error": f"未找到镜像源: {mirror_id}", "mirror_used": None, "attempts": 0} + mirrors_to_try = [mirror] + else: + # 使用所有启用的镜像源 + mirrors_to_try = self.config.get_enabled_mirrors() + + # 依次尝试每个镜像源 + for mirror in mirrors_to_try: + result = await self._clone_from_mirror(owner, repo, target_path, branch, depth, mirror) + if result["success"]: + return result + logger.warning(f"镜像源 {mirror['id']} 克隆失败: {result.get('error')}") + + # 所有镜像源都失败 + return {"success": False, "error": "所有镜像源克隆均失败", "mirror_used": None, "attempts": len(mirrors_to_try)} + + async def _clone_from_mirror( + self, + owner: str, + repo: str, + target_path: Path, + branch: Optional[str], + depth: Optional[int], + mirror: Dict[str, Any], + ) -> Dict[str, Any]: + """从指定镜像源克隆仓库""" + # 构建克隆 URL + clone_prefix = mirror["clone_prefix"] + url = f"{clone_prefix}/{owner}/{repo}.git" + + return await self._clone_with_url(url, target_path, branch, depth, mirror["id"]) + + async def _clone_with_url( + self, url: str, target_path: Path, branch: Optional[str], depth: Optional[int], mirror_type: str + ) -> Dict[str, Any]: + """使用指定 URL 克隆仓库,支持重试""" + attempts = 0 + last_error = None + + for attempt in range(self.max_retries): + attempts += 1 + + try: + # 确保目标路径不存在 + if target_path.exists(): + logger.warning(f"目标路径已存在,删除: {target_path}") + shutil.rmtree(target_path, ignore_errors=True) + + # 构建 git clone 命令 + cmd = ["git", "clone"] + + # 添加分支参数 + if branch: + cmd.extend(["-b", branch]) + + # 添加深度参数(浅克隆) + if depth: + cmd.extend(["--depth", str(depth)]) + + # 添加 URL 和目标路径 + cmd.extend([url, str(target_path)]) + + logger.info(f"尝试克隆 #{attempt + 1}: {' '.join(cmd)}") + + # 推送进度 + if _update_progress: + try: + await _update_progress( + stage="loading", + progress=20 + attempt * 10, + message=f"正在克隆仓库 (尝试 {attempt + 1}/{self.max_retries})...", + operation="install", + ) + except Exception as e: + logger.warning(f"推送进度失败: {e}") + + # 执行 git clone(在线程池中运行以避免阻塞) + loop = asyncio.get_event_loop() + + def run_git_clone(): + return subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300, # 5分钟超时 + ) + + process = await loop.run_in_executor(None, run_git_clone) + + if process.returncode == 0: + logger.info(f"成功克隆仓库: {url} -> {target_path}") + return { + "success": True, + "path": str(target_path), + "mirror_used": mirror_type, + "attempts": attempts, + "url": url, + "branch": branch or "default", + } + else: + last_error = f"Git 克隆失败: {process.stderr}" + logger.warning(f"克隆失败 (尝试 {attempt + 1}/{self.max_retries}): {last_error}") + + except subprocess.TimeoutExpired: + last_error = "克隆超时(超过 5 分钟)" + logger.warning(f"克隆超时 (尝试 {attempt + 1}/{self.max_retries})") + + # 清理可能的部分克隆 + if target_path.exists(): + shutil.rmtree(target_path, ignore_errors=True) + + except FileNotFoundError: + last_error = "Git 未安装或不在 PATH 中" + logger.error(f"Git 未找到: {last_error}") + break # Git 不存在,不需要重试 + + except Exception as e: + last_error = f"未知错误: {e}" + logger.error(f"克隆错误 (尝试 {attempt + 1}/{self.max_retries}): {last_error}") + + # 清理可能的部分克隆 + if target_path.exists(): + shutil.rmtree(target_path, ignore_errors=True) + + return {"success": False, "error": last_error, "mirror_used": mirror_type, "attempts": attempts, "url": url} + + +# 全局服务实例 +_git_mirror_service: Optional[GitMirrorService] = None + + +def get_git_mirror_service() -> GitMirrorService: + """获取 Git 镜像源服务实例(单例)""" + global _git_mirror_service + if _git_mirror_service is None: + _git_mirror_service = GitMirrorService() + return _git_mirror_service diff --git a/src/webui/log_broadcaster.py b/src/webui/log_broadcaster.py new file mode 100644 index 00000000..e69de29b diff --git a/src/webui/logs_routes.py b/src/webui/logs_routes.py new file mode 100644 index 00000000..e69de29b diff --git a/src/webui/logs_ws.py b/src/webui/logs_ws.py new file mode 100644 index 00000000..e0e0a9a1 --- /dev/null +++ b/src/webui/logs_ws.py @@ -0,0 +1,141 @@ +"""WebSocket 日志推送模块""" + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from typing import Set +import json +from pathlib import Path +from src.common.logger import get_logger + +logger = get_logger("webui.logs_ws") +router = APIRouter() + +# 全局 WebSocket 连接池 +active_connections: Set[WebSocket] = set() + + +def load_recent_logs(limit: int = 100) -> list[dict]: + """从日志文件中加载最近的日志 + + Args: + limit: 返回的最大日志条数 + + Returns: + 日志列表 + """ + logs = [] + log_dir = Path("logs") + + if not log_dir.exists(): + return logs + + # 获取所有日志文件,按修改时间排序 + log_files = sorted(log_dir.glob("app_*.log.jsonl"), key=lambda f: f.stat().st_mtime, reverse=True) + + # 用于生成唯一 ID 的计数器 + log_counter = 0 + + # 从最新的文件开始读取 + for log_file in log_files: + if len(logs) >= limit: + break + + try: + with open(log_file, "r", encoding="utf-8") as f: + lines = f.readlines() + # 从文件末尾开始读取 + for line in reversed(lines): + if len(logs) >= limit: + break + try: + log_entry = json.loads(line.strip()) + # 转换为前端期望的格式 + # 使用时间戳 + 计数器生成唯一 ID + timestamp_id = ( + log_entry.get("timestamp", "0").replace("-", "").replace(" ", "").replace(":", "") + ) + formatted_log = { + "id": f"{timestamp_id}_{log_counter}", + "timestamp": log_entry.get("timestamp", ""), + "level": log_entry.get("level", "INFO").upper(), + "module": log_entry.get("logger_name", ""), + "message": log_entry.get("event", ""), + } + logs.append(formatted_log) + log_counter += 1 + except (json.JSONDecodeError, KeyError): + continue + except Exception as e: + logger.error(f"读取日志文件失败 {log_file}: {e}") + continue + + # 反转列表,使其按时间顺序排列(旧到新) + return list(reversed(logs)) + + +@router.websocket("/ws/logs") +async def websocket_logs(websocket: WebSocket): + """WebSocket 日志推送端点 + + 客户端连接后会持续接收服务器端的日志消息 + """ + await websocket.accept() + active_connections.add(websocket) + logger.info(f"📡 WebSocket 客户端已连接,当前连接数: {len(active_connections)}") + + # 连接建立后,立即发送历史日志 + try: + recent_logs = load_recent_logs(limit=100) + logger.info(f"发送 {len(recent_logs)} 条历史日志到客户端") + + for log_entry in recent_logs: + await websocket.send_text(json.dumps(log_entry, ensure_ascii=False)) + except Exception as e: + logger.error(f"发送历史日志失败: {e}") + + try: + # 保持连接,等待客户端消息或断开 + while True: + # 接收客户端消息(用于心跳或控制指令) + data = await websocket.receive_text() + + # 可以处理客户端的控制消息,例如: + # - "ping" -> 心跳检测 + # - {"filter": "ERROR"} -> 设置日志级别过滤 + if data == "ping": + await websocket.send_text("pong") + + except WebSocketDisconnect: + active_connections.discard(websocket) + logger.info(f"📡 WebSocket 客户端已断开,当前连接数: {len(active_connections)}") + except Exception as e: + logger.error(f"❌ WebSocket 错误: {e}") + active_connections.discard(websocket) + + +async def broadcast_log(log_data: dict): + """广播日志到所有连接的 WebSocket 客户端 + + Args: + log_data: 日志数据字典 + """ + if not active_connections: + return + + # 格式化为 JSON + message = json.dumps(log_data, ensure_ascii=False) + + # 记录需要断开的连接 + disconnected = set() + + # 广播到所有客户端 + for connection in active_connections: + try: + await connection.send_text(message) + except Exception: + # 发送失败,标记为断开 + disconnected.add(connection) + + # 清理断开的连接 + if disconnected: + active_connections.difference_update(disconnected) + logger.debug(f"清理了 {len(disconnected)} 个断开的 WebSocket 连接") diff --git a/src/webui/manager.py b/src/webui/manager.py deleted file mode 100644 index f4302c50..00000000 --- a/src/webui/manager.py +++ /dev/null @@ -1,93 +0,0 @@ -"""WebUI 管理器 - 处理开发/生产环境的 WebUI 启动""" -import os -from pathlib import Path -from src.common.logger import get_logger -from .token_manager import get_token_manager - -logger = get_logger("webui") - - -def setup_webui(mode: str = "production") -> bool: - """ - 设置 WebUI - - Args: - mode: 运行模式,"development" 或 "production" - - Returns: - bool: 是否成功设置 - """ - # 初始化 Token 管理器(确保 token 文件存在) - token_manager = get_token_manager() - current_token = token_manager.get_token() - logger.info(f"🔑 WebUI Access Token: {current_token}") - logger.info("💡 请使用此 Token 登录 WebUI") - - if mode == "development": - return setup_dev_mode() - else: - return setup_production_mode() - - -def setup_dev_mode() -> bool: - """设置开发模式 - 仅启用 CORS,前端自行启动""" - logger.info("📝 WebUI 开发模式已启用") - logger.info("🌐 请手动启动前端开发服务器: cd webui && npm run dev") - logger.info("💡 前端将运行在 http://localhost:7999") - return True - - -def setup_production_mode() -> bool: - """设置生产模式 - 挂载静态文件""" - try: - from src.common.server import get_global_server - from fastapi.staticfiles import StaticFiles - from fastapi.responses import FileResponse - - server = get_global_server() - base_dir = Path(__file__).parent.parent.parent - static_path = base_dir / "webui" / "dist" - - if not static_path.exists(): - logger.warning(f"❌ WebUI 静态文件目录不存在: {static_path}") - logger.warning("💡 请先构建前端: cd webui && npm run build") - return False - - if not (static_path / "index.html").exists(): - logger.warning(f"❌ 未找到 index.html: {static_path / 'index.html'}") - logger.warning("💡 请确认前端已正确构建") - return False - - # 挂载静态资源 - if (static_path / "assets").exists(): - server.app.mount( - "/assets", - StaticFiles(directory=str(static_path / "assets")), - name="assets" - ) - - # 处理 SPA 路由 - @server.app.get("/{full_path:path}") - async def serve_spa(full_path: str): - """服务单页应用""" - # API 路由不处理 - if full_path.startswith("api/"): - return None - - # 检查文件是否存在 - file_path = static_path / full_path - if file_path.is_file(): - return FileResponse(file_path) - - # 返回 index.html(SPA 路由) - return FileResponse(static_path / "index.html") - - host = os.getenv("HOST", "127.0.0.1") - port = os.getenv("PORT", "8000") - logger.info("✅ WebUI 生产模式已挂载") - logger.info(f"🌐 访问 http://{host}:{port} 查看 WebUI") - return True - - except Exception as e: - logger.error(f"挂载 WebUI 静态文件失败: {e}") - return False diff --git a/src/webui/person_routes.py b/src/webui/person_routes.py new file mode 100644 index 00000000..5935a2fa --- /dev/null +++ b/src/webui/person_routes.py @@ -0,0 +1,408 @@ +"""人物信息管理 API 路由""" + +from fastapi import APIRouter, HTTPException, Header, Query +from pydantic import BaseModel +from typing import Optional, List, Dict +from src.common.logger import get_logger +from src.common.database.database_model import PersonInfo +from .token_manager import get_token_manager +import json +import time + +logger = get_logger("webui.person") + +# 创建路由器 +router = APIRouter(prefix="/person", tags=["Person"]) + + +class PersonInfoResponse(BaseModel): + """人物信息响应""" + + id: int + is_known: bool + person_id: str + person_name: Optional[str] + name_reason: Optional[str] + platform: str + user_id: str + nickname: Optional[str] + group_nick_name: Optional[List[Dict[str, str]]] # 解析后的 JSON + memory_points: Optional[str] + know_times: Optional[float] + know_since: Optional[float] + last_know: Optional[float] + + +class PersonListResponse(BaseModel): + """人物列表响应""" + + success: bool + total: int + page: int + page_size: int + data: List[PersonInfoResponse] + + +class PersonDetailResponse(BaseModel): + """人物详情响应""" + + success: bool + data: PersonInfoResponse + + +class PersonUpdateRequest(BaseModel): + """人物信息更新请求""" + + person_name: Optional[str] = None + name_reason: Optional[str] = None + nickname: Optional[str] = None + memory_points: Optional[str] = None + is_known: Optional[bool] = None + + +class PersonUpdateResponse(BaseModel): + """人物信息更新响应""" + + success: bool + message: str + data: Optional[PersonInfoResponse] = None + + +class PersonDeleteResponse(BaseModel): + """人物删除响应""" + + success: bool + message: str + + +class BatchDeleteRequest(BaseModel): + """批量删除请求""" + + person_ids: List[str] + + +class BatchDeleteResponse(BaseModel): + """批量删除响应""" + + success: bool + message: str + deleted_count: int + failed_count: int + failed_ids: List[str] = [] + + +def verify_auth_token(authorization: Optional[str]) -> bool: + """验证认证 Token""" + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="未提供有效的认证信息") + + token = authorization.replace("Bearer ", "") + token_manager = get_token_manager() + + if not token_manager.verify_token(token): + raise HTTPException(status_code=401, detail="Token 无效或已过期") + + return True + + +def parse_group_nick_name(group_nick_name_str: Optional[str]) -> Optional[List[Dict[str, str]]]: + """解析群昵称 JSON 字符串""" + if not group_nick_name_str: + return None + try: + return json.loads(group_nick_name_str) + except (json.JSONDecodeError, TypeError): + return None + + +def person_to_response(person: PersonInfo) -> PersonInfoResponse: + """将 PersonInfo 模型转换为响应对象""" + return PersonInfoResponse( + id=person.id, + is_known=person.is_known, + person_id=person.person_id, + person_name=person.person_name, + name_reason=person.name_reason, + platform=person.platform, + user_id=person.user_id, + nickname=person.nickname, + group_nick_name=parse_group_nick_name(person.group_nick_name), + memory_points=person.memory_points, + know_times=person.know_times, + know_since=person.know_since, + last_know=person.last_know, + ) + + +@router.get("/list", response_model=PersonListResponse) +async def get_person_list( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + search: Optional[str] = Query(None, description="搜索关键词"), + is_known: Optional[bool] = Query(None, description="是否已认识筛选"), + platform: Optional[str] = Query(None, description="平台筛选"), + authorization: Optional[str] = Header(None), +): + """ + 获取人物信息列表 + + Args: + page: 页码 (从 1 开始) + page_size: 每页数量 (1-100) + search: 搜索关键词 (匹配 person_name, nickname, user_id) + is_known: 是否已认识筛选 + platform: 平台筛选 + authorization: Authorization header + + Returns: + 人物信息列表 + """ + try: + verify_auth_token(authorization) + + # 构建查询 + query = PersonInfo.select() + + # 搜索过滤 + if search: + query = query.where( + (PersonInfo.person_name.contains(search)) + | (PersonInfo.nickname.contains(search)) + | (PersonInfo.user_id.contains(search)) + ) + + # 已认识状态过滤 + if is_known is not None: + query = query.where(PersonInfo.is_known == is_known) + + # 平台过滤 + if platform: + query = query.where(PersonInfo.platform == platform) + + # 排序:最后更新时间倒序(NULL 值放在最后) + # Peewee 不支持 nulls_last,使用 CASE WHEN 来实现 + from peewee import Case + + query = query.order_by(Case(None, [(PersonInfo.last_know.is_null(), 1)], 0), PersonInfo.last_know.desc()) + + # 获取总数 + total = query.count() + + # 分页 + offset = (page - 1) * page_size + persons = query.offset(offset).limit(page_size) + + # 转换为响应对象 + data = [person_to_response(person) for person in persons] + + return PersonListResponse(success=True, total=total, page=page, page_size=page_size, data=data) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取人物列表失败: {e}") + raise HTTPException(status_code=500, detail=f"获取人物列表失败: {str(e)}") from e + + +@router.get("/{person_id}", response_model=PersonDetailResponse) +async def get_person_detail(person_id: str, authorization: Optional[str] = Header(None)): + """ + 获取人物详细信息 + + Args: + person_id: 人物唯一 ID + authorization: Authorization header + + Returns: + 人物详细信息 + """ + try: + verify_auth_token(authorization) + + person = PersonInfo.get_or_none(PersonInfo.person_id == person_id) + + if not person: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {person_id} 的人物信息") + + return PersonDetailResponse(success=True, data=person_to_response(person)) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取人物详情失败: {e}") + raise HTTPException(status_code=500, detail=f"获取人物详情失败: {str(e)}") from e + + +@router.patch("/{person_id}", response_model=PersonUpdateResponse) +async def update_person(person_id: str, request: PersonUpdateRequest, authorization: Optional[str] = Header(None)): + """ + 增量更新人物信息(只更新提供的字段) + + Args: + person_id: 人物唯一 ID + request: 更新请求(只包含需要更新的字段) + authorization: Authorization header + + Returns: + 更新结果 + """ + try: + verify_auth_token(authorization) + + person = PersonInfo.get_or_none(PersonInfo.person_id == person_id) + + if not person: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {person_id} 的人物信息") + + # 只更新提供的字段 + update_data = request.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException(status_code=400, detail="未提供任何需要更新的字段") + + # 更新最后修改时间 + update_data["last_know"] = time.time() + + # 执行更新 + for field, value in update_data.items(): + setattr(person, field, value) + + person.save() + + logger.info(f"人物信息已更新: {person_id}, 字段: {list(update_data.keys())}") + + return PersonUpdateResponse( + success=True, message=f"成功更新 {len(update_data)} 个字段", data=person_to_response(person) + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"更新人物信息失败: {e}") + raise HTTPException(status_code=500, detail=f"更新人物信息失败: {str(e)}") from e + + +@router.delete("/{person_id}", response_model=PersonDeleteResponse) +async def delete_person(person_id: str, authorization: Optional[str] = Header(None)): + """ + 删除人物信息 + + Args: + person_id: 人物唯一 ID + authorization: Authorization header + + Returns: + 删除结果 + """ + try: + verify_auth_token(authorization) + + person = PersonInfo.get_or_none(PersonInfo.person_id == person_id) + + if not person: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {person_id} 的人物信息") + + # 记录删除信息 + person_name = person.person_name or person.nickname or person.user_id + + # 执行删除 + person.delete_instance() + + logger.info(f"人物信息已删除: {person_id} ({person_name})") + + return PersonDeleteResponse(success=True, message=f"成功删除人物信息: {person_name}") + + except HTTPException: + raise + except Exception as e: + logger.exception(f"删除人物信息失败: {e}") + raise HTTPException(status_code=500, detail=f"删除人物信息失败: {str(e)}") from e + + +@router.get("/stats/summary") +async def get_person_stats(authorization: Optional[str] = Header(None)): + """ + 获取人物信息统计数据 + + Args: + authorization: Authorization header + + Returns: + 统计数据 + """ + try: + verify_auth_token(authorization) + + total = PersonInfo.select().count() + known = PersonInfo.select().where(PersonInfo.is_known).count() + unknown = total - known + + # 按平台统计 + platforms = {} + for person in PersonInfo.select(PersonInfo.platform): + platform = person.platform + platforms[platform] = platforms.get(platform, 0) + 1 + + return {"success": True, "data": {"total": total, "known": known, "unknown": unknown, "platforms": platforms}} + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取统计数据失败: {e}") + raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}") from e + + +@router.post("/batch/delete", response_model=BatchDeleteResponse) +async def batch_delete_persons(request: BatchDeleteRequest, authorization: Optional[str] = Header(None)): + """ + 批量删除人物信息 + + Args: + request: 包含person_ids列表的请求 + authorization: Authorization header + + Returns: + 批量删除结果 + """ + try: + verify_auth_token(authorization) + + if not request.person_ids: + raise HTTPException(status_code=400, detail="未提供要删除的人物ID") + + deleted_count = 0 + failed_count = 0 + failed_ids = [] + + for person_id in request.person_ids: + try: + person = PersonInfo.get_or_none(PersonInfo.person_id == person_id) + if person: + person.delete_instance() + deleted_count += 1 + logger.info(f"批量删除: {person_id}") + else: + failed_count += 1 + failed_ids.append(person_id) + except Exception as e: + logger.error(f"删除 {person_id} 失败: {e}") + failed_count += 1 + failed_ids.append(person_id) + + message = f"成功删除 {deleted_count} 个人物" + if failed_count > 0: + message += f",{failed_count} 个失败" + + return BatchDeleteResponse( + success=True, + message=message, + deleted_count=deleted_count, + failed_count=failed_count, + failed_ids=failed_ids, + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"批量删除人物信息失败: {e}") + raise HTTPException(status_code=500, detail=f"批量删除失败: {str(e)}") from e diff --git a/src/webui/plugin_progress_ws.py b/src/webui/plugin_progress_ws.py new file mode 100644 index 00000000..7e0fb647 --- /dev/null +++ b/src/webui/plugin_progress_ws.py @@ -0,0 +1,128 @@ +"""WebSocket 插件加载进度推送模块""" + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from typing import Set, Dict, Any +import json +import asyncio +from src.common.logger import get_logger + +logger = get_logger("webui.plugin_progress") + +# 创建路由器 +router = APIRouter() + +# 全局 WebSocket 连接池 +active_connections: Set[WebSocket] = set() + +# 当前加载进度状态 +current_progress: Dict[str, Any] = { + "operation": "idle", # idle, fetch, install, uninstall, update + "stage": "idle", # idle, loading, success, error + "progress": 0, # 0-100 + "message": "", + "error": None, + "plugin_id": None, # 当前操作的插件 ID + "total_plugins": 0, + "loaded_plugins": 0, +} + + +async def broadcast_progress(progress_data: Dict[str, Any]): + """广播进度更新到所有连接的客户端""" + global current_progress + current_progress = progress_data.copy() + + if not active_connections: + return + + message = json.dumps(progress_data, ensure_ascii=False) + disconnected = set() + + for websocket in active_connections: + try: + await websocket.send_text(message) + except Exception as e: + logger.error(f"发送进度更新失败: {e}") + disconnected.add(websocket) + + # 移除断开的连接 + for websocket in disconnected: + active_connections.discard(websocket) + + +async def update_progress( + stage: str, + progress: int, + message: str, + operation: str = "fetch", + error: str = None, + plugin_id: str = None, + total_plugins: int = 0, + loaded_plugins: int = 0, +): + """更新并广播进度 + + Args: + stage: 阶段 (idle, loading, success, error) + progress: 进度百分比 (0-100) + message: 当前消息 + operation: 操作类型 (fetch, install, uninstall, update) + error: 错误信息(可选) + plugin_id: 当前操作的插件 ID + total_plugins: 总插件数 + loaded_plugins: 已加载插件数 + """ + progress_data = { + "operation": operation, + "stage": stage, + "progress": progress, + "message": message, + "error": error, + "plugin_id": plugin_id, + "total_plugins": total_plugins, + "loaded_plugins": loaded_plugins, + "timestamp": asyncio.get_event_loop().time(), + } + + await broadcast_progress(progress_data) + logger.debug(f"进度更新: [{operation}] {stage} - {progress}% - {message}") + + +@router.websocket("/ws/plugin-progress") +async def websocket_plugin_progress(websocket: WebSocket): + """WebSocket 插件加载进度推送端点 + + 客户端连接后会立即收到当前进度状态 + """ + await websocket.accept() + active_connections.add(websocket) + logger.info(f"📡 插件进度 WebSocket 客户端已连接,当前连接数: {len(active_connections)}") + + try: + # 发送当前进度状态 + await websocket.send_text(json.dumps(current_progress, ensure_ascii=False)) + + # 保持连接并处理客户端消息 + while True: + try: + data = await websocket.receive_text() + + # 处理客户端心跳 + if data == "ping": + await websocket.send_text("pong") + + except Exception as e: + logger.error(f"处理客户端消息时出错: {e}") + break + + except WebSocketDisconnect: + active_connections.discard(websocket) + logger.info(f"📡 插件进度 WebSocket 客户端已断开,当前连接数: {len(active_connections)}") + except Exception as e: + logger.error(f"❌ WebSocket 错误: {e}") + active_connections.discard(websocket) + + +def get_progress_router() -> APIRouter: + """获取插件进度 WebSocket 路由器""" + return router diff --git a/src/webui/plugin_routes.py b/src/webui/plugin_routes.py new file mode 100644 index 00000000..cb559fb7 --- /dev/null +++ b/src/webui/plugin_routes.py @@ -0,0 +1,1082 @@ +from fastapi import APIRouter, HTTPException, Header +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any +from pathlib import Path +import json +from src.common.logger import get_logger +from src.config.config import MMC_VERSION +from .git_mirror_service import get_git_mirror_service, set_update_progress_callback +from .token_manager import get_token_manager +from .plugin_progress_ws import update_progress + +logger = get_logger("webui.plugin_routes") + +# 创建路由器 +router = APIRouter(prefix="/plugins", tags=["插件管理"]) + +# 设置进度更新回调 +set_update_progress_callback(update_progress) + + +def parse_version(version_str: str) -> tuple[int, int, int]: + """ + 解析版本号字符串 + + 支持格式: + - 0.11.2 -> (0, 11, 2) + - 0.11.2.snapshot.2 -> (0, 11, 2) + + Returns: + (major, minor, patch) 三元组 + """ + # 移除 snapshot 等后缀 + base_version = version_str.split(".snapshot")[0].split(".dev")[0].split(".alpha")[0].split(".beta")[0] + + parts = base_version.split(".") + if len(parts) < 3: + # 补齐到 3 位 + parts.extend(["0"] * (3 - len(parts))) + + try: + major = int(parts[0]) + minor = int(parts[1]) + patch = int(parts[2]) + return (major, minor, patch) + except (ValueError, IndexError): + logger.warning(f"无法解析版本号: {version_str},返回默认值 (0, 0, 0)") + return (0, 0, 0) + + +# ============ 请求/响应模型 ============ + + +class FetchRawFileRequest(BaseModel): + """获取 Raw 文件请求""" + + owner: str = Field(..., description="仓库所有者", example="MaiM-with-u") + repo: str = Field(..., description="仓库名称", example="plugin-repo") + branch: str = Field(..., description="分支名称", example="main") + file_path: str = Field(..., description="文件路径", example="plugin_details.json") + mirror_id: Optional[str] = Field(None, description="指定镜像源 ID") + custom_url: Optional[str] = Field(None, description="自定义完整 URL") + + +class FetchRawFileResponse(BaseModel): + """获取 Raw 文件响应""" + + success: bool = Field(..., description="是否成功") + data: Optional[str] = Field(None, description="文件内容") + error: Optional[str] = Field(None, description="错误信息") + mirror_used: Optional[str] = Field(None, description="使用的镜像源") + attempts: int = Field(..., description="尝试次数") + url: Optional[str] = Field(None, description="实际请求的 URL") + + +class CloneRepositoryRequest(BaseModel): + """克隆仓库请求""" + + owner: str = Field(..., description="仓库所有者", example="MaiM-with-u") + repo: str = Field(..., description="仓库名称", example="plugin-repo") + target_path: str = Field(..., description="目标路径(相对于插件目录)") + branch: Optional[str] = Field(None, description="分支名称", example="main") + mirror_id: Optional[str] = Field(None, description="指定镜像源 ID") + custom_url: Optional[str] = Field(None, description="自定义克隆 URL") + depth: Optional[int] = Field(None, description="克隆深度(浅克隆)", ge=1) + + +class CloneRepositoryResponse(BaseModel): + """克隆仓库响应""" + + success: bool = Field(..., description="是否成功") + path: Optional[str] = Field(None, description="克隆路径") + error: Optional[str] = Field(None, description="错误信息") + mirror_used: Optional[str] = Field(None, description="使用的镜像源") + attempts: int = Field(..., description="尝试次数") + url: Optional[str] = Field(None, description="实际克隆的 URL") + message: Optional[str] = Field(None, description="附加信息") + + +class MirrorConfigResponse(BaseModel): + """镜像源配置响应""" + + id: str = Field(..., description="镜像源 ID") + name: str = Field(..., description="镜像源名称") + raw_prefix: str = Field(..., description="Raw 文件前缀") + clone_prefix: str = Field(..., description="克隆前缀") + enabled: bool = Field(..., description="是否启用") + priority: int = Field(..., description="优先级(数字越小优先级越高)") + + +class AvailableMirrorsResponse(BaseModel): + """可用镜像源列表响应""" + + mirrors: List[MirrorConfigResponse] = Field(..., description="镜像源列表") + default_priority: List[str] = Field(..., description="默认优先级顺序(ID 列表)") + + +class AddMirrorRequest(BaseModel): + """添加镜像源请求""" + + id: str = Field(..., description="镜像源 ID", example="custom-mirror") + name: str = Field(..., description="镜像源名称", example="自定义镜像源") + raw_prefix: str = Field(..., description="Raw 文件前缀", example="https://example.com/raw") + clone_prefix: str = Field(..., description="克隆前缀", example="https://example.com/clone") + enabled: bool = Field(True, description="是否启用") + priority: Optional[int] = Field(None, description="优先级") + + +class UpdateMirrorRequest(BaseModel): + """更新镜像源请求""" + + name: Optional[str] = Field(None, description="镜像源名称") + raw_prefix: Optional[str] = Field(None, description="Raw 文件前缀") + clone_prefix: Optional[str] = Field(None, description="克隆前缀") + enabled: Optional[bool] = Field(None, description="是否启用") + priority: Optional[int] = Field(None, description="优先级") + + +class GitStatusResponse(BaseModel): + """Git 安装状态响应""" + + installed: bool = Field(..., description="是否已安装 Git") + version: Optional[str] = Field(None, description="Git 版本号") + path: Optional[str] = Field(None, description="Git 可执行文件路径") + error: Optional[str] = Field(None, description="错误信息") + + +class InstallPluginRequest(BaseModel): + """安装插件请求""" + + plugin_id: str = Field(..., description="插件 ID") + repository_url: str = Field(..., description="插件仓库 URL") + branch: Optional[str] = Field("main", description="分支名称") + mirror_id: Optional[str] = Field(None, description="指定镜像源 ID") + + +class VersionResponse(BaseModel): + """麦麦版本响应""" + + version: str = Field(..., description="麦麦版本号") + version_major: int = Field(..., description="主版本号") + version_minor: int = Field(..., description="次版本号") + version_patch: int = Field(..., description="补丁版本号") + + +class UninstallPluginRequest(BaseModel): + """卸载插件请求""" + + plugin_id: str = Field(..., description="插件 ID") + + +class UpdatePluginRequest(BaseModel): + """更新插件请求""" + + plugin_id: str = Field(..., description="插件 ID") + repository_url: str = Field(..., description="插件仓库 URL") + branch: Optional[str] = Field("main", description="分支名称") + mirror_id: Optional[str] = Field(None, description="指定镜像源 ID") + + +# ============ API 路由 ============ + + +@router.get("/version", response_model=VersionResponse) +async def get_maimai_version() -> VersionResponse: + """ + 获取麦麦版本信息 + + 此接口无需认证,用于前端检查插件兼容性 + """ + major, minor, patch = parse_version(MMC_VERSION) + + return VersionResponse(version=MMC_VERSION, version_major=major, version_minor=minor, version_patch=patch) + + +@router.get("/git-status", response_model=GitStatusResponse) +async def check_git_status() -> GitStatusResponse: + """ + 检查本机 Git 安装状态 + + 此接口无需认证,用于前端快速检测是否可以使用插件安装功能 + """ + service = get_git_mirror_service() + result = service.check_git_installed() + + return GitStatusResponse(**result) + + +@router.get("/mirrors", response_model=AvailableMirrorsResponse) +async def get_available_mirrors(authorization: Optional[str] = Header(None)) -> AvailableMirrorsResponse: + """ + 获取所有可用的镜像源配置 + """ + # Token 验证 + token = authorization.replace("Bearer ", "") if authorization else None + token_manager = get_token_manager() + if not token or not token_manager.verify_token(token): + raise HTTPException(status_code=401, detail="未授权:无效的访问令牌") + + service = get_git_mirror_service() + config = service.get_mirror_config() + + all_mirrors = config.get_all_mirrors() + mirrors = [ + MirrorConfigResponse( + id=m["id"], + name=m["name"], + raw_prefix=m["raw_prefix"], + clone_prefix=m["clone_prefix"], + enabled=m["enabled"], + priority=m["priority"], + ) + for m in all_mirrors + ] + + return AvailableMirrorsResponse(mirrors=mirrors, default_priority=config.get_default_priority_list()) + + +@router.post("/mirrors", response_model=MirrorConfigResponse) +async def add_mirror(request: AddMirrorRequest, authorization: Optional[str] = Header(None)) -> MirrorConfigResponse: + """ + 添加新的镜像源 + """ + # Token 验证 + token = authorization.replace("Bearer ", "") if authorization else None + token_manager = get_token_manager() + if not token or not token_manager.verify_token(token): + raise HTTPException(status_code=401, detail="未授权:无效的访问令牌") + + try: + service = get_git_mirror_service() + config = service.get_mirror_config() + + mirror = config.add_mirror( + mirror_id=request.id, + name=request.name, + raw_prefix=request.raw_prefix, + clone_prefix=request.clone_prefix, + enabled=request.enabled, + priority=request.priority, + ) + + return MirrorConfigResponse( + id=mirror["id"], + name=mirror["name"], + raw_prefix=mirror["raw_prefix"], + clone_prefix=mirror["clone_prefix"], + enabled=mirror["enabled"], + priority=mirror["priority"], + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + logger.error(f"添加镜像源失败: {e}") + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.put("/mirrors/{mirror_id}", response_model=MirrorConfigResponse) +async def update_mirror( + mirror_id: str, request: UpdateMirrorRequest, authorization: Optional[str] = Header(None) +) -> MirrorConfigResponse: + """ + 更新镜像源配置 + """ + # Token 验证 + token = authorization.replace("Bearer ", "") if authorization else None + token_manager = get_token_manager() + if not token or not token_manager.verify_token(token): + raise HTTPException(status_code=401, detail="未授权:无效的访问令牌") + + try: + service = get_git_mirror_service() + config = service.get_mirror_config() + + mirror = config.update_mirror( + mirror_id=mirror_id, + name=request.name, + raw_prefix=request.raw_prefix, + clone_prefix=request.clone_prefix, + enabled=request.enabled, + priority=request.priority, + ) + + if not mirror: + raise HTTPException(status_code=404, detail=f"未找到镜像源: {mirror_id}") + + return MirrorConfigResponse( + id=mirror["id"], + name=mirror["name"], + raw_prefix=mirror["raw_prefix"], + clone_prefix=mirror["clone_prefix"], + enabled=mirror["enabled"], + priority=mirror["priority"], + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"更新镜像源失败: {e}") + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.delete("/mirrors/{mirror_id}") +async def delete_mirror(mirror_id: str, authorization: Optional[str] = Header(None)) -> Dict[str, Any]: + """ + 删除镜像源 + """ + # Token 验证 + token = authorization.replace("Bearer ", "") if authorization else None + token_manager = get_token_manager() + if not token or not token_manager.verify_token(token): + raise HTTPException(status_code=401, detail="未授权:无效的访问令牌") + + service = get_git_mirror_service() + config = service.get_mirror_config() + + success = config.delete_mirror(mirror_id) + + if not success: + raise HTTPException(status_code=404, detail=f"未找到镜像源: {mirror_id}") + + return {"success": True, "message": f"已删除镜像源: {mirror_id}"} + + +@router.post("/fetch-raw", response_model=FetchRawFileResponse) +async def fetch_raw_file( + request: FetchRawFileRequest, authorization: Optional[str] = Header(None) +) -> FetchRawFileResponse: + """ + 获取 GitHub 仓库的 Raw 文件内容 + + 支持多镜像源自动切换和错误重试 + + 注意:此接口可公开访问,用于获取插件仓库等公开资源 + """ + # Token 验证(可选,用于日志记录) + token = authorization.replace("Bearer ", "") if authorization else None + token_manager = get_token_manager() + is_authenticated = token and token_manager.verify_token(token) + + # 对于公开仓库的访问,不强制要求认证 + # 只在日志中记录是否认证 + logger.info( + f"收到获取 Raw 文件请求 (认证: {is_authenticated}): " + f"{request.owner}/{request.repo}/{request.branch}/{request.file_path}" + ) + + # 发送开始加载进度 + await update_progress( + stage="loading", + progress=10, + message=f"正在获取插件列表: {request.file_path}", + total_plugins=0, + loaded_plugins=0, + ) + + try: + service = get_git_mirror_service() + + # git_mirror_service 会自动推送 30%-70% 的详细镜像源尝试进度 + result = await service.fetch_raw_file( + owner=request.owner, + repo=request.repo, + branch=request.branch, + file_path=request.file_path, + mirror_id=request.mirror_id, + custom_url=request.custom_url, + ) + + if result.get("success"): + # 更新进度:成功获取 + await update_progress( + stage="loading", progress=70, message="正在解析插件数据...", total_plugins=0, loaded_plugins=0 + ) + + # 尝试解析插件数量 + try: + import json + + data = json.loads(result.get("data", "[]")) + total = len(data) if isinstance(data, list) else 0 + + # 发送成功状态 + await update_progress( + stage="success", + progress=100, + message=f"成功加载 {total} 个插件", + total_plugins=total, + loaded_plugins=total, + ) + except Exception: + # 如果解析失败,仍然发送成功状态 + await update_progress( + stage="success", progress=100, message="加载完成", total_plugins=0, loaded_plugins=0 + ) + + return FetchRawFileResponse(**result) + + except Exception as e: + logger.error(f"获取 Raw 文件失败: {e}") + + # 发送错误进度 + await update_progress( + stage="error", progress=0, message="加载失败", error=str(e), total_plugins=0, loaded_plugins=0 + ) + + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.post("/clone", response_model=CloneRepositoryResponse) +async def clone_repository( + request: CloneRepositoryRequest, authorization: Optional[str] = Header(None) +) -> CloneRepositoryResponse: + """ + 克隆 GitHub 仓库到本地 + + 支持多镜像源自动切换和错误重试 + """ + # Token 验证 + token = authorization.replace("Bearer ", "") if authorization else None + token_manager = get_token_manager() + if not token or not token_manager.verify_token(token): + raise HTTPException(status_code=401, detail="未授权:无效的访问令牌") + + logger.info(f"收到克隆仓库请求: {request.owner}/{request.repo} -> {request.target_path}") + + try: + # TODO: 验证 target_path 的安全性,防止路径遍历攻击 + # TODO: 确定实际的插件目录基路径 + base_plugin_path = Path("./plugins") # 临时路径 + target_path = base_plugin_path / request.target_path + + service = get_git_mirror_service() + result = await service.clone_repository( + owner=request.owner, + repo=request.repo, + target_path=target_path, + branch=request.branch, + mirror_id=request.mirror_id, + custom_url=request.custom_url, + depth=request.depth, + ) + + return CloneRepositoryResponse(**result) + + except Exception as e: + logger.error(f"克隆仓库失败: {e}") + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.post("/install") +async def install_plugin(request: InstallPluginRequest, authorization: Optional[str] = Header(None)) -> Dict[str, Any]: + """ + 安装插件 + + 从 Git 仓库克隆插件到本地插件目录 + """ + # Token 验证 + token = authorization.replace("Bearer ", "") if authorization else None + token_manager = get_token_manager() + if not token or not token_manager.verify_token(token): + raise HTTPException(status_code=401, detail="未授权:无效的访问令牌") + + logger.info(f"收到安装插件请求: {request.plugin_id}") + + try: + # 推送进度:开始安装 + await update_progress( + stage="loading", + progress=5, + message=f"开始安装插件: {request.plugin_id}", + operation="install", + plugin_id=request.plugin_id, + ) + + # 1. 解析仓库 URL + # repository_url 格式: https://github.com/owner/repo + repo_url = request.repository_url.rstrip("/") + if repo_url.endswith(".git"): + repo_url = repo_url[:-4] + + parts = repo_url.split("/") + if len(parts) < 2: + raise HTTPException(status_code=400, detail="无效的仓库 URL") + + owner = parts[-2] + repo = parts[-1] + + await update_progress( + stage="loading", + progress=10, + message=f"解析仓库信息: {owner}/{repo}", + operation="install", + plugin_id=request.plugin_id, + ) + + # 2. 确定插件安装路径 + plugins_dir = Path("plugins") + plugins_dir.mkdir(exist_ok=True) + + target_path = plugins_dir / request.plugin_id + + # 检查插件是否已安装 + if target_path.exists(): + await update_progress( + stage="error", + progress=0, + message="插件已存在", + operation="install", + plugin_id=request.plugin_id, + error="插件已安装,请先卸载", + ) + raise HTTPException(status_code=400, detail="插件已安装") + + await update_progress( + stage="loading", + progress=15, + message=f"准备克隆到: {target_path}", + operation="install", + plugin_id=request.plugin_id, + ) + + # 3. 克隆仓库(这里会自动推送 20%-80% 的进度) + service = get_git_mirror_service() + + # 如果是 GitHub 仓库,使用镜像源 + if "github.com" in repo_url: + result = await service.clone_repository( + owner=owner, + repo=repo, + target_path=target_path, + branch=request.branch, + mirror_id=request.mirror_id, + depth=1, # 浅克隆,节省时间和空间 + ) + else: + # 自定义仓库,直接使用 URL + result = await service.clone_repository( + owner=owner, repo=repo, target_path=target_path, branch=request.branch, custom_url=repo_url, depth=1 + ) + + if not result.get("success"): + error_msg = result.get("error", "克隆失败") + await update_progress( + stage="error", + progress=0, + message="克隆仓库失败", + operation="install", + plugin_id=request.plugin_id, + error=error_msg, + ) + raise HTTPException(status_code=500, detail=error_msg) + + # 4. 验证插件完整性 + await update_progress( + stage="loading", progress=85, message="验证插件文件...", operation="install", plugin_id=request.plugin_id + ) + + manifest_path = target_path / "_manifest.json" + if not manifest_path.exists(): + # 清理失败的安装 + import shutil + + shutil.rmtree(target_path, ignore_errors=True) + + await update_progress( + stage="error", + progress=0, + message="插件缺少 _manifest.json", + operation="install", + plugin_id=request.plugin_id, + error="无效的插件格式", + ) + raise HTTPException(status_code=400, detail="无效的插件:缺少 _manifest.json") + + # 5. 读取并验证 manifest + await update_progress( + stage="loading", progress=90, message="读取插件配置...", operation="install", plugin_id=request.plugin_id + ) + + try: + import json as json_module + + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = json_module.load(f) + + # 基本验证 + required_fields = ["manifest_version", "name", "version", "author"] + for field in required_fields: + if field not in manifest: + raise ValueError(f"缺少必需字段: {field}") + + except Exception as e: + # 清理失败的安装 + import shutil + + shutil.rmtree(target_path, ignore_errors=True) + + await update_progress( + stage="error", + progress=0, + message="_manifest.json 无效", + operation="install", + plugin_id=request.plugin_id, + error=str(e), + ) + raise HTTPException(status_code=400, detail=f"无效的 _manifest.json: {e}") from e + + # 6. 安装成功 + await update_progress( + stage="success", + progress=100, + message=f"成功安装插件: {manifest['name']} v{manifest['version']}", + operation="install", + plugin_id=request.plugin_id, + ) + + return { + "success": True, + "message": "插件安装成功", + "plugin_id": request.plugin_id, + "plugin_name": manifest["name"], + "version": manifest["version"], + "path": str(target_path), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"安装插件失败: {e}", exc_info=True) + + await update_progress( + stage="error", + progress=0, + message="安装失败", + operation="install", + plugin_id=request.plugin_id, + error=str(e), + ) + + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.post("/uninstall") +async def uninstall_plugin( + request: UninstallPluginRequest, authorization: Optional[str] = Header(None) +) -> Dict[str, Any]: + """ + 卸载插件 + + 删除插件目录及其所有文件 + """ + # Token 验证 + token = authorization.replace("Bearer ", "") if authorization else None + token_manager = get_token_manager() + if not token or not token_manager.verify_token(token): + raise HTTPException(status_code=401, detail="未授权:无效的访问令牌") + + logger.info(f"收到卸载插件请求: {request.plugin_id}") + + try: + # 推送进度:开始卸载 + await update_progress( + stage="loading", + progress=10, + message=f"开始卸载插件: {request.plugin_id}", + operation="uninstall", + plugin_id=request.plugin_id, + ) + + # 1. 检查插件是否存在 + plugins_dir = Path("plugins") + plugin_path = plugins_dir / request.plugin_id + + if not plugin_path.exists(): + await update_progress( + stage="error", + progress=0, + message="插件不存在", + operation="uninstall", + plugin_id=request.plugin_id, + error="插件未安装或已被删除", + ) + raise HTTPException(status_code=404, detail="插件未安装") + + await update_progress( + stage="loading", + progress=30, + message=f"正在删除插件文件: {plugin_path}", + operation="uninstall", + plugin_id=request.plugin_id, + ) + + # 2. 读取插件信息(用于日志) + manifest_path = plugin_path / "_manifest.json" + plugin_name = request.plugin_id + + if manifest_path.exists(): + try: + import json as json_module + + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = json_module.load(f) + plugin_name = manifest.get("name", request.plugin_id) + except Exception: + pass # 如果读取失败,使用插件 ID 作为名称 + + await update_progress( + stage="loading", + progress=50, + message=f"正在删除 {plugin_name}...", + operation="uninstall", + plugin_id=request.plugin_id, + ) + + # 3. 删除插件目录 + import shutil + import stat + + def remove_readonly(func, path, _): + """清除只读属性并删除文件""" + import os + + os.chmod(path, stat.S_IWRITE) + func(path) + + shutil.rmtree(plugin_path, onerror=remove_readonly) + + logger.info(f"成功卸载插件: {request.plugin_id} ({plugin_name})") + + # 4. 推送成功状态 + await update_progress( + stage="success", + progress=100, + message=f"成功卸载插件: {plugin_name}", + operation="uninstall", + plugin_id=request.plugin_id, + ) + + return {"success": True, "message": "插件卸载成功", "plugin_id": request.plugin_id, "plugin_name": plugin_name} + + except HTTPException: + raise + except PermissionError as e: + logger.error(f"卸载插件失败(权限错误): {e}") + + await update_progress( + stage="error", + progress=0, + message="卸载失败", + operation="uninstall", + plugin_id=request.plugin_id, + error="权限不足,无法删除插件文件", + ) + + raise HTTPException(status_code=500, detail="权限不足,无法删除插件文件") from e + except Exception as e: + logger.error(f"卸载插件失败: {e}", exc_info=True) + + await update_progress( + stage="error", + progress=0, + message="卸载失败", + operation="uninstall", + plugin_id=request.plugin_id, + error=str(e), + ) + + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.post("/update") +async def update_plugin(request: UpdatePluginRequest, authorization: Optional[str] = Header(None)) -> Dict[str, Any]: + """ + 更新插件 + + 删除旧版本,重新克隆新版本 + """ + # Token 验证 + token = authorization.replace("Bearer ", "") if authorization else None + token_manager = get_token_manager() + if not token or not token_manager.verify_token(token): + raise HTTPException(status_code=401, detail="未授权:无效的访问令牌") + + logger.info(f"收到更新插件请求: {request.plugin_id}") + + try: + # 推送进度:开始更新 + await update_progress( + stage="loading", + progress=5, + message=f"开始更新插件: {request.plugin_id}", + operation="update", + plugin_id=request.plugin_id, + ) + + # 1. 检查插件是否已安装 + plugins_dir = Path("plugins") + plugin_path = plugins_dir / request.plugin_id + + if not plugin_path.exists(): + await update_progress( + stage="error", + progress=0, + message="插件不存在", + operation="update", + plugin_id=request.plugin_id, + error="插件未安装,请先安装", + ) + raise HTTPException(status_code=404, detail="插件未安装") + + # 2. 读取旧版本信息 + manifest_path = plugin_path / "_manifest.json" + old_version = "unknown" + plugin_name = request.plugin_id + + if manifest_path.exists(): + try: + import json as json_module + + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = json_module.load(f) + old_version = manifest.get("version", "unknown") + _plugin_name = manifest.get("name", request.plugin_id) + except Exception: + pass + + await update_progress( + stage="loading", + progress=10, + message=f"当前版本: {old_version},准备更新...", + operation="update", + plugin_id=request.plugin_id, + ) + + # 3. 删除旧版本 + await update_progress( + stage="loading", progress=20, message="正在删除旧版本...", operation="update", plugin_id=request.plugin_id + ) + + import shutil + import stat + + def remove_readonly(func, path, _): + """清除只读属性并删除文件""" + import os + + os.chmod(path, stat.S_IWRITE) + func(path) + + shutil.rmtree(plugin_path, onerror=remove_readonly) + + logger.info(f"已删除旧版本: {request.plugin_id} v{old_version}") + + # 4. 解析仓库 URL + await update_progress( + stage="loading", + progress=30, + message="正在准备下载新版本...", + operation="update", + plugin_id=request.plugin_id, + ) + + repo_url = request.repository_url.rstrip("/") + if repo_url.endswith(".git"): + repo_url = repo_url[:-4] + + parts = repo_url.split("/") + if len(parts) < 2: + raise HTTPException(status_code=400, detail="无效的仓库 URL") + + owner = parts[-2] + repo = parts[-1] + + # 5. 克隆新版本(这里会推送 35%-85% 的进度) + service = get_git_mirror_service() + + if "github.com" in repo_url: + result = await service.clone_repository( + owner=owner, + repo=repo, + target_path=plugin_path, + branch=request.branch, + mirror_id=request.mirror_id, + depth=1, + ) + else: + result = await service.clone_repository( + owner=owner, repo=repo, target_path=plugin_path, branch=request.branch, custom_url=repo_url, depth=1 + ) + + if not result.get("success"): + error_msg = result.get("error", "克隆失败") + await update_progress( + stage="error", + progress=0, + message="下载新版本失败", + operation="update", + plugin_id=request.plugin_id, + error=error_msg, + ) + raise HTTPException(status_code=500, detail=error_msg) + + # 6. 验证新版本 + await update_progress( + stage="loading", progress=90, message="验证新版本...", operation="update", plugin_id=request.plugin_id + ) + + new_manifest_path = plugin_path / "_manifest.json" + if not new_manifest_path.exists(): + # 清理失败的更新 + def remove_readonly(func, path, _): + """清除只读属性并删除文件""" + import os + + os.chmod(path, stat.S_IWRITE) + func(path) + + shutil.rmtree(plugin_path, onerror=remove_readonly) + + await update_progress( + stage="error", + progress=0, + message="新版本缺少 _manifest.json", + operation="update", + plugin_id=request.plugin_id, + error="无效的插件格式", + ) + raise HTTPException(status_code=400, detail="无效的插件:缺少 _manifest.json") + + # 7. 读取新版本信息 + try: + with open(new_manifest_path, "r", encoding="utf-8") as f: + new_manifest = json_module.load(f) + + new_version = new_manifest.get("version", "unknown") + new_name = new_manifest.get("name", request.plugin_id) + + logger.info(f"成功更新插件: {request.plugin_id} {old_version} → {new_version}") + + # 8. 推送成功状态 + await update_progress( + stage="success", + progress=100, + message=f"成功更新 {new_name}: {old_version} → {new_version}", + operation="update", + plugin_id=request.plugin_id, + ) + + return { + "success": True, + "message": "插件更新成功", + "plugin_id": request.plugin_id, + "plugin_name": new_name, + "old_version": old_version, + "new_version": new_version, + } + + except Exception as e: + # 清理失败的更新 + shutil.rmtree(plugin_path, ignore_errors=True) + + await update_progress( + stage="error", + progress=0, + message="_manifest.json 无效", + operation="update", + plugin_id=request.plugin_id, + error=str(e), + ) + raise HTTPException(status_code=400, detail=f"无效的 _manifest.json: {e}") from e + + except HTTPException: + raise + except Exception as e: + logger.error(f"更新插件失败: {e}", exc_info=True) + + await update_progress( + stage="error", progress=0, message="更新失败", operation="update", plugin_id=request.plugin_id, error=str(e) + ) + + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.get("/installed") +async def get_installed_plugins(authorization: Optional[str] = Header(None)) -> Dict[str, Any]: + """ + 获取已安装的插件列表 + + 扫描 plugins 目录,返回所有已安装插件的 ID 和基本信息 + """ + # Token 验证 + token = authorization.replace("Bearer ", "") if authorization else None + token_manager = get_token_manager() + if not token or not token_manager.verify_token(token): + raise HTTPException(status_code=401, detail="未授权:无效的访问令牌") + + logger.info("收到获取已安装插件列表请求") + + try: + plugins_dir = Path("plugins") + + # 如果插件目录不存在,返回空列表 + if not plugins_dir.exists(): + logger.info("插件目录不存在,创建目录") + plugins_dir.mkdir(exist_ok=True) + return {"success": True, "plugins": []} + + installed_plugins = [] + + # 遍历插件目录 + for plugin_path in plugins_dir.iterdir(): + # 只处理目录 + if not plugin_path.is_dir(): + continue + + # 目录名即为插件 ID + plugin_id = plugin_path.name + + # 跳过隐藏目录和特殊目录 + if plugin_id.startswith(".") or plugin_id.startswith("__"): + continue + + # 读取 _manifest.json + manifest_path = plugin_path / "_manifest.json" + + if not manifest_path.exists(): + logger.warning(f"插件 {plugin_id} 缺少 _manifest.json,跳过") + continue + + try: + import json as json_module + + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = json_module.load(f) + + # 基本验证 + if "name" not in manifest or "version" not in manifest: + logger.warning(f"插件 {plugin_id} 的 _manifest.json 格式无效,跳过") + continue + + # 添加到已安装列表(返回完整的 manifest 信息) + installed_plugins.append( + { + "id": plugin_id, + "manifest": manifest, # 返回完整的 manifest 对象 + "path": str(plugin_path.absolute()), + } + ) + + except json.JSONDecodeError as e: + logger.warning(f"插件 {plugin_id} 的 _manifest.json 解析失败: {e}") + continue + except Exception as e: + logger.error(f"读取插件 {plugin_id} 信息时出错: {e}") + continue + + logger.info(f"找到 {len(installed_plugins)} 个已安装插件") + + return {"success": True, "plugins": installed_plugins, "total": len(installed_plugins)} + + except Exception as e: + logger.error(f"获取已安装插件列表失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e diff --git a/src/webui/routers/system.py b/src/webui/routers/system.py new file mode 100644 index 00000000..fb203f79 --- /dev/null +++ b/src/webui/routers/system.py @@ -0,0 +1,97 @@ +""" +系统控制路由 + +提供系统重启、状态查询等功能 +""" + +import os +import sys +import time +from datetime import datetime +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from src.config.config import MMC_VERSION + +router = APIRouter(prefix="/system", tags=["system"]) + +# 记录启动时间 +_start_time = time.time() + + +class RestartResponse(BaseModel): + """重启响应""" + + success: bool + message: str + + +class StatusResponse(BaseModel): + """状态响应""" + + running: bool + uptime: float + version: str + start_time: str + + +@router.post("/restart", response_model=RestartResponse) +async def restart_maibot(): + """ + 重启麦麦主程序 + + 使用 os.execv 重启当前进程,配置更改将在重启后生效。 + 注意:此操作会使麦麦暂时离线。 + """ + try: + # 记录重启操作 + print(f"[{datetime.now()}] WebUI 触发重启操作") + + # 使用 os.execv 重启当前进程 + # 这会替换当前进程,保持相同的 PID + python = sys.executable + args = [python] + sys.argv + + # 返回成功响应(实际上这个响应可能不会发送,因为进程会立即重启) + # 但我们仍然返回它以保持 API 一致性 + os.execv(python, args) + + return RestartResponse(success=True, message="麦麦正在重启中...") + except Exception as e: + raise HTTPException(status_code=500, detail=f"重启失败: {str(e)}") from e + + +@router.get("/status", response_model=StatusResponse) +async def get_maibot_status(): + """ + 获取麦麦运行状态 + + 返回麦麦的运行状态、运行时长和版本信息。 + """ + try: + uptime = time.time() - _start_time + + # 尝试获取版本信息(需要根据实际情况调整) + version = MMC_VERSION # 可以从配置或常量中读取 + + return StatusResponse( + running=True, uptime=uptime, version=version, start_time=datetime.fromtimestamp(_start_time).isoformat() + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"获取状态失败: {str(e)}") from e + + +# 可选:添加更多系统控制功能 + + +@router.post("/reload-config") +async def reload_config(): + """ + 热重载配置(不重启进程) + + 仅重新加载配置文件,某些配置可能需要重启才能生效。 + 此功能需要在主程序中实现配置热重载逻辑。 + """ + # 这里需要调用主程序的配置重载函数 + # 示例:await app_instance.reload_config() + + return {"success": True, "message": "配置重载功能待实现"} diff --git a/src/webui/routes.py b/src/webui/routes.py index 37f82e5d..3eb7e673 100644 --- a/src/webui/routes.py +++ b/src/webui/routes.py @@ -1,45 +1,97 @@ """WebUI API 路由""" + from fastapi import APIRouter, HTTPException, Header from pydantic import BaseModel, Field from typing import Optional from src.common.logger import get_logger from .token_manager import get_token_manager +from .config_routes import router as config_router +from .statistics_routes import router as statistics_router +from .person_routes import router as person_router +from .expression_routes import router as expression_router +from .emoji_routes import router as emoji_router +from .plugin_routes import router as plugin_router +from .plugin_progress_ws import get_progress_router +from .routers.system import router as system_router logger = get_logger("webui.api") # 创建路由器 router = APIRouter(prefix="/api/webui", tags=["WebUI"]) +# 注册配置管理路由 +router.include_router(config_router) +# 注册统计数据路由 +router.include_router(statistics_router) +# 注册人物信息管理路由 +router.include_router(person_router) +# 注册表达方式管理路由 +router.include_router(expression_router) +# 注册表情包管理路由 +router.include_router(emoji_router) +# 注册插件管理路由 +router.include_router(plugin_router) +# 注册插件进度 WebSocket 路由 +router.include_router(get_progress_router()) +# 注册系统控制路由 +router.include_router(system_router) + class TokenVerifyRequest(BaseModel): """Token 验证请求""" + token: str = Field(..., description="访问令牌") class TokenVerifyResponse(BaseModel): """Token 验证响应""" + valid: bool = Field(..., description="Token 是否有效") message: str = Field(..., description="验证结果消息") class TokenUpdateRequest(BaseModel): """Token 更新请求""" + new_token: str = Field(..., description="新的访问令牌", min_length=10) class TokenUpdateResponse(BaseModel): """Token 更新响应""" + success: bool = Field(..., description="是否更新成功") message: str = Field(..., description="更新结果消息") class TokenRegenerateResponse(BaseModel): """Token 重新生成响应""" + success: bool = Field(..., description="是否生成成功") token: str = Field(..., description="新生成的令牌") message: str = Field(..., description="生成结果消息") +class FirstSetupStatusResponse(BaseModel): + """首次配置状态响应""" + + is_first_setup: bool = Field(..., description="是否为首次配置") + message: str = Field(..., description="状态消息") + + +class CompleteSetupResponse(BaseModel): + """完成配置响应""" + + success: bool = Field(..., description="是否成功") + message: str = Field(..., description="结果消息") + + +class ResetSetupResponse(BaseModel): + """重置配置响应""" + + success: bool = Field(..., description="是否成功") + message: str = Field(..., description="结果消息") + + @router.get("/health") async def health_check(): """健康检查""" @@ -50,44 +102,35 @@ async def health_check(): async def verify_token(request: TokenVerifyRequest): """ 验证访问令牌 - + Args: request: 包含 token 的验证请求 - + Returns: 验证结果 """ try: token_manager = get_token_manager() is_valid = token_manager.verify_token(request.token) - + if is_valid: - return TokenVerifyResponse( - valid=True, - message="Token 验证成功" - ) + return TokenVerifyResponse(valid=True, message="Token 验证成功") else: - return TokenVerifyResponse( - valid=False, - message="Token 无效或已过期" - ) + return TokenVerifyResponse(valid=False, message="Token 无效或已过期") except Exception as e: logger.error(f"Token 验证失败: {e}") raise HTTPException(status_code=500, detail="Token 验证失败") from e @router.post("/auth/update", response_model=TokenUpdateResponse) -async def update_token( - request: TokenUpdateRequest, - authorization: Optional[str] = Header(None) -): +async def update_token(request: TokenUpdateRequest, authorization: Optional[str] = Header(None)): """ 更新访问令牌(需要当前有效的 token) - + Args: request: 包含新 token 的更新请求 authorization: Authorization header (Bearer token) - + Returns: 更新结果 """ @@ -95,20 +138,17 @@ async def update_token( # 验证当前 token if not authorization or not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="未提供有效的认证信息") - + current_token = authorization.replace("Bearer ", "") token_manager = get_token_manager() - + if not token_manager.verify_token(current_token): raise HTTPException(status_code=401, detail="当前 Token 无效") - + # 更新 token success, message = token_manager.update_token(request.new_token) - - return TokenUpdateResponse( - success=success, - message=message - ) + + return TokenUpdateResponse(success=success, message=message) except HTTPException: raise except Exception as e: @@ -120,10 +160,10 @@ async def update_token( async def regenerate_token(authorization: Optional[str] = Header(None)): """ 重新生成访问令牌(需要当前有效的 token) - + Args: authorization: Authorization header (Bearer token) - + Returns: 新生成的 token """ @@ -131,24 +171,118 @@ async def regenerate_token(authorization: Optional[str] = Header(None)): # 验证当前 token if not authorization or not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="未提供有效的认证信息") - + current_token = authorization.replace("Bearer ", "") token_manager = get_token_manager() - + if not token_manager.verify_token(current_token): raise HTTPException(status_code=401, detail="当前 Token 无效") - + # 重新生成 token new_token = token_manager.regenerate_token() - - return TokenRegenerateResponse( - success=True, - token=new_token, - message="Token 已重新生成" - ) + + return TokenRegenerateResponse(success=True, token=new_token, message="Token 已重新生成") except HTTPException: raise except Exception as e: logger.error(f"Token 重新生成失败: {e}") raise HTTPException(status_code=500, detail="Token 重新生成失败") from e + +@router.get("/setup/status", response_model=FirstSetupStatusResponse) +async def get_setup_status(authorization: Optional[str] = Header(None)): + """ + 获取首次配置状态 + + Args: + authorization: Authorization header (Bearer token) + + Returns: + 首次配置状态 + """ + try: + # 验证 token + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="未提供有效的认证信息") + + current_token = authorization.replace("Bearer ", "") + token_manager = get_token_manager() + + if not token_manager.verify_token(current_token): + raise HTTPException(status_code=401, detail="Token 无效") + + # 检查是否为首次配置 + is_first = token_manager.is_first_setup() + + return FirstSetupStatusResponse(is_first_setup=is_first, message="首次配置" if is_first else "已完成配置") + except HTTPException: + raise + except Exception as e: + logger.error(f"获取配置状态失败: {e}") + raise HTTPException(status_code=500, detail="获取配置状态失败") from e + + +@router.post("/setup/complete", response_model=CompleteSetupResponse) +async def complete_setup(authorization: Optional[str] = Header(None)): + """ + 标记首次配置完成 + + Args: + authorization: Authorization header (Bearer token) + + Returns: + 完成结果 + """ + try: + # 验证 token + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="未提供有效的认证信息") + + current_token = authorization.replace("Bearer ", "") + token_manager = get_token_manager() + + if not token_manager.verify_token(current_token): + raise HTTPException(status_code=401, detail="Token 无效") + + # 标记配置完成 + success = token_manager.mark_setup_completed() + + return CompleteSetupResponse(success=success, message="配置已完成" if success else "标记失败") + except HTTPException: + raise + except Exception as e: + logger.error(f"标记配置完成失败: {e}") + raise HTTPException(status_code=500, detail="标记配置完成失败") from e + + +@router.post("/setup/reset", response_model=ResetSetupResponse) +async def reset_setup(authorization: Optional[str] = Header(None)): + """ + 重置首次配置状态,允许重新进入配置向导 + + Args: + authorization: Authorization header (Bearer token) + + Returns: + 重置结果 + """ + try: + # 验证 token + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="未提供有效的认证信息") + + current_token = authorization.replace("Bearer ", "") + token_manager = get_token_manager() + + if not token_manager.verify_token(current_token): + raise HTTPException(status_code=401, detail="Token 无效") + + # 重置配置状态 + success = token_manager.reset_setup_status() + + return ResetSetupResponse(success=success, message="配置状态已重置" if success else "重置失败") + except HTTPException: + raise + except Exception as e: + logger.error(f"重置配置状态失败: {e}") + raise HTTPException(status_code=500, detail="重置配置状态失败") from e diff --git a/src/webui/statistics_routes.py b/src/webui/statistics_routes.py new file mode 100644 index 00000000..b0a3664c --- /dev/null +++ b/src/webui/statistics_routes.py @@ -0,0 +1,310 @@ +"""统计数据 API 路由""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +from typing import Dict, Any, List +from datetime import datetime, timedelta +from peewee import fn + +from src.common.logger import get_logger +from src.common.database.database_model import LLMUsage, OnlineTime, Messages + +logger = get_logger("webui.statistics") + +router = APIRouter(prefix="/statistics", tags=["statistics"]) + + +class StatisticsSummary(BaseModel): + """统计数据摘要""" + + total_requests: int = Field(0, description="总请求数") + total_cost: float = Field(0.0, description="总花费") + total_tokens: int = Field(0, description="总token数") + online_time: float = Field(0.0, description="在线时间(秒)") + total_messages: int = Field(0, description="总消息数") + total_replies: int = Field(0, description="总回复数") + avg_response_time: float = Field(0.0, description="平均响应时间") + cost_per_hour: float = Field(0.0, description="每小时花费") + tokens_per_hour: float = Field(0.0, description="每小时token数") + + +class ModelStatistics(BaseModel): + """模型统计""" + + model_name: str + request_count: int + total_cost: float + total_tokens: int + avg_response_time: float + + +class TimeSeriesData(BaseModel): + """时间序列数据""" + + timestamp: str + requests: int = 0 + cost: float = 0.0 + tokens: int = 0 + + +class DashboardData(BaseModel): + """仪表盘数据""" + + summary: StatisticsSummary + model_stats: List[ModelStatistics] + hourly_data: List[TimeSeriesData] + daily_data: List[TimeSeriesData] + recent_activity: List[Dict[str, Any]] + + +@router.get("/dashboard", response_model=DashboardData) +async def get_dashboard_data(hours: int = 24): + """ + 获取仪表盘统计数据 + + Args: + hours: 统计时间范围(小时),默认24小时 + + Returns: + 仪表盘数据 + """ + try: + now = datetime.now() + start_time = now - timedelta(hours=hours) + + # 获取摘要数据 + summary = await _get_summary_statistics(start_time, now) + + # 获取模型统计 + model_stats = await _get_model_statistics(start_time) + + # 获取小时级时间序列数据 + hourly_data = await _get_hourly_statistics(start_time, now) + + # 获取日级时间序列数据(最近7天) + daily_start = now - timedelta(days=7) + daily_data = await _get_daily_statistics(daily_start, now) + + # 获取最近活动 + recent_activity = await _get_recent_activity(limit=10) + + return DashboardData( + summary=summary, + model_stats=model_stats, + hourly_data=hourly_data, + daily_data=daily_data, + recent_activity=recent_activity, + ) + except Exception as e: + logger.error(f"获取仪表盘数据失败: {e}") + raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}") from e + + +async def _get_summary_statistics(start_time: datetime, end_time: datetime) -> StatisticsSummary: + """获取摘要统计数据(优化:使用数据库聚合)""" + summary = StatisticsSummary() + + # 使用聚合查询替代全量加载 + query = LLMUsage.select( + fn.COUNT(LLMUsage.id).alias("total_requests"), + fn.COALESCE(fn.SUM(LLMUsage.cost), 0).alias("total_cost"), + fn.COALESCE(fn.SUM(LLMUsage.prompt_tokens + LLMUsage.completion_tokens), 0).alias("total_tokens"), + fn.COALESCE(fn.AVG(LLMUsage.time_cost), 0).alias("avg_response_time"), + ).where((LLMUsage.timestamp >= start_time) & (LLMUsage.timestamp <= end_time)) + + result = query.dicts().get() + summary.total_requests = result["total_requests"] + summary.total_cost = result["total_cost"] + summary.total_tokens = result["total_tokens"] + summary.avg_response_time = result["avg_response_time"] or 0.0 + + # 查询在线时间 - 这个数据量通常不大,保留原逻辑 + online_records = list( + OnlineTime.select().where((OnlineTime.start_timestamp >= start_time) | (OnlineTime.end_timestamp >= start_time)) + ) + + for record in online_records: + start = max(record.start_timestamp, start_time) + end = min(record.end_timestamp, end_time) + if end > start: + summary.online_time += (end - start).total_seconds() + + # 查询消息数量 - 使用聚合优化 + messages_query = Messages.select(fn.COUNT(Messages.id).alias("total")).where( + (Messages.time >= start_time.timestamp()) & (Messages.time <= end_time.timestamp()) + ) + summary.total_messages = messages_query.scalar() or 0 + + # 统计回复数量 + replies_query = Messages.select(fn.COUNT(Messages.id).alias("total")).where( + (Messages.time >= start_time.timestamp()) + & (Messages.time <= end_time.timestamp()) + & (Messages.reply_to.is_null(False)) + ) + summary.total_replies = replies_query.scalar() or 0 + + # 计算派生指标 + if summary.online_time > 0: + online_hours = summary.online_time / 3600.0 + summary.cost_per_hour = summary.total_cost / online_hours + summary.tokens_per_hour = summary.total_tokens / online_hours + + return summary + + +async def _get_model_statistics(start_time: datetime) -> List[ModelStatistics]: + """获取模型统计数据(优化:使用数据库聚合和分组)""" + # 使用GROUP BY聚合,避免全量加载 + query = ( + LLMUsage.select( + fn.COALESCE(LLMUsage.model_assign_name, LLMUsage.model_name, "unknown").alias("model_name"), + fn.COUNT(LLMUsage.id).alias("request_count"), + fn.COALESCE(fn.SUM(LLMUsage.cost), 0).alias("total_cost"), + fn.COALESCE(fn.SUM(LLMUsage.prompt_tokens + LLMUsage.completion_tokens), 0).alias("total_tokens"), + fn.COALESCE(fn.AVG(LLMUsage.time_cost), 0).alias("avg_response_time"), + ) + .where(LLMUsage.timestamp >= start_time) + .group_by(fn.COALESCE(LLMUsage.model_assign_name, LLMUsage.model_name, "unknown")) + .order_by(fn.COUNT(LLMUsage.id).desc()) + .limit(10) # 只取前10个 + ) + + result = [] + for row in query.dicts(): + result.append( + ModelStatistics( + model_name=row["model_name"], + request_count=row["request_count"], + total_cost=row["total_cost"], + total_tokens=row["total_tokens"], + avg_response_time=row["avg_response_time"] or 0.0, + ) + ) + + return result + + +async def _get_hourly_statistics(start_time: datetime, end_time: datetime) -> List[TimeSeriesData]: + """获取小时级统计数据(优化:使用数据库聚合)""" + # SQLite的日期时间函数进行小时分组 + # 使用strftime将timestamp格式化为小时级别 + query = ( + LLMUsage.select( + fn.strftime("%Y-%m-%dT%H:00:00", LLMUsage.timestamp).alias("hour"), + fn.COUNT(LLMUsage.id).alias("requests"), + fn.COALESCE(fn.SUM(LLMUsage.cost), 0).alias("cost"), + fn.COALESCE(fn.SUM(LLMUsage.prompt_tokens + LLMUsage.completion_tokens), 0).alias("tokens"), + ) + .where((LLMUsage.timestamp >= start_time) & (LLMUsage.timestamp <= end_time)) + .group_by(fn.strftime("%Y-%m-%dT%H:00:00", LLMUsage.timestamp)) + ) + + # 转换为字典以快速查找 + data_dict = {row["hour"]: row for row in query.dicts()} + + # 填充所有小时(包括没有数据的) + result = [] + current = start_time.replace(minute=0, second=0, microsecond=0) + while current <= end_time: + hour_str = current.strftime("%Y-%m-%dT%H:00:00") + if hour_str in data_dict: + row = data_dict[hour_str] + result.append( + TimeSeriesData(timestamp=hour_str, requests=row["requests"], cost=row["cost"], tokens=row["tokens"]) + ) + else: + result.append(TimeSeriesData(timestamp=hour_str, requests=0, cost=0.0, tokens=0)) + current += timedelta(hours=1) + + return result + + +async def _get_daily_statistics(start_time: datetime, end_time: datetime) -> List[TimeSeriesData]: + """获取日级统计数据(优化:使用数据库聚合)""" + # 使用strftime按日期分组 + query = ( + LLMUsage.select( + fn.strftime("%Y-%m-%dT00:00:00", LLMUsage.timestamp).alias("day"), + fn.COUNT(LLMUsage.id).alias("requests"), + fn.COALESCE(fn.SUM(LLMUsage.cost), 0).alias("cost"), + fn.COALESCE(fn.SUM(LLMUsage.prompt_tokens + LLMUsage.completion_tokens), 0).alias("tokens"), + ) + .where((LLMUsage.timestamp >= start_time) & (LLMUsage.timestamp <= end_time)) + .group_by(fn.strftime("%Y-%m-%dT00:00:00", LLMUsage.timestamp)) + ) + + # 转换为字典 + data_dict = {row["day"]: row for row in query.dicts()} + + # 填充所有天 + result = [] + current = start_time.replace(hour=0, minute=0, second=0, microsecond=0) + while current <= end_time: + day_str = current.strftime("%Y-%m-%dT00:00:00") + if day_str in data_dict: + row = data_dict[day_str] + result.append( + TimeSeriesData(timestamp=day_str, requests=row["requests"], cost=row["cost"], tokens=row["tokens"]) + ) + else: + result.append(TimeSeriesData(timestamp=day_str, requests=0, cost=0.0, tokens=0)) + current += timedelta(days=1) + + return result + + +async def _get_recent_activity(limit: int = 10) -> List[Dict[str, Any]]: + """获取最近活动""" + records = list(LLMUsage.select().order_by(LLMUsage.timestamp.desc()).limit(limit)) + + activities = [] + for record in records: + activities.append( + { + "timestamp": record.timestamp.isoformat(), + "model": record.model_assign_name or record.model_name, + "request_type": record.request_type, + "tokens": (record.prompt_tokens or 0) + (record.completion_tokens or 0), + "cost": record.cost or 0.0, + "time_cost": record.time_cost or 0.0, + "status": record.status, + } + ) + + return activities + + +@router.get("/summary") +async def get_summary(hours: int = 24): + """ + 获取统计摘要 + + Args: + hours: 统计时间范围(小时) + """ + try: + now = datetime.now() + start_time = now - timedelta(hours=hours) + summary = await _get_summary_statistics(start_time, now) + return summary + except Exception as e: + logger.error(f"获取统计摘要失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/models") +async def get_model_stats(hours: int = 24): + """ + 获取模型统计 + + Args: + hours: 统计时间范围(小时) + """ + try: + now = datetime.now() + start_time = now - timedelta(hours=hours) + stats = await _get_model_statistics(start_time) + return stats + except Exception as e: + logger.error(f"获取模型统计失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/src/webui/token_manager.py b/src/webui/token_manager.py index 2b606b84..69abf1d8 100644 --- a/src/webui/token_manager.py +++ b/src/webui/token_manager.py @@ -19,7 +19,7 @@ class TokenManager: def __init__(self, config_path: Optional[Path] = None): """ 初始化 Token 管理器 - + Args: config_path: 配置文件路径,默认为项目根目录的 data/webui.json """ @@ -27,10 +27,10 @@ class TokenManager: # 获取项目根目录 (src/webui -> src -> 根目录) project_root = Path(__file__).parent.parent.parent config_path = project_root / "data" / "webui.json" - + self.config_path = config_path self.config_path.parent.mkdir(parents=True, exist_ok=True) - + # 确保配置文件存在并包含有效的 token self._ensure_config() @@ -75,21 +75,23 @@ class TokenManager: """生成新的 64 位随机 token""" # 生成 64 位十六进制字符串 (32 字节 = 64 hex 字符) token = secrets.token_hex(32) - + config = { "access_token": token, "created_at": self._get_current_timestamp(), - "updated_at": self._get_current_timestamp() + "updated_at": self._get_current_timestamp(), + "first_setup_completed": False, # 标记首次配置未完成 } - + self._save_config(config) logger.info(f"新的 WebUI Token 已生成: {token[:8]}...") - + return token def _get_current_timestamp(self) -> str: """获取当前时间戳字符串""" from datetime import datetime + return datetime.now().isoformat() def get_token(self) -> str: @@ -100,38 +102,38 @@ class TokenManager: def verify_token(self, token: str) -> bool: """ 验证 token 是否有效 - + Args: token: 待验证的 token - + Returns: bool: token 是否有效 """ if not token: return False - + current_token = self.get_token() if not current_token: logger.error("系统中没有有效的 token") return False - + # 使用 secrets.compare_digest 防止时序攻击 is_valid = secrets.compare_digest(token, current_token) - + if is_valid: logger.debug("Token 验证成功") else: logger.warning("Token 验证失败") - + return is_valid def update_token(self, new_token: str) -> tuple[bool, str]: """ 更新 token - + Args: new_token: 新的 token (最少 10 位,必须包含大小写字母和特殊符号) - + Returns: tuple[bool, str]: (是否更新成功, 错误消息) """ @@ -140,17 +142,17 @@ class TokenManager: if not is_valid: logger.error(f"Token 格式无效: {error_msg}") return False, error_msg - + try: config = self._load_config() old_token = config.get("access_token", "")[:8] - + config["access_token"] = new_token config["updated_at"] = self._get_current_timestamp() - + self._save_config(config) logger.info(f"Token 已更新: {old_token}... -> {new_token[:8]}...") - + return True, "Token 更新成功" except Exception as e: logger.error(f"更新 Token 失败: {e}") @@ -159,7 +161,7 @@ class TokenManager: def regenerate_token(self) -> str: """ 重新生成 token - + Returns: str: 新生成的 token """ @@ -169,20 +171,20 @@ class TokenManager: def _validate_token_format(self, token: str) -> bool: """ 验证 token 格式是否正确(旧的 64 位十六进制验证,保留用于系统生成的 token) - + Args: token: 待验证的 token - + Returns: bool: 格式是否正确 """ if not token or not isinstance(token, str): return False - + # 必须是 64 位十六进制字符串 if len(token) != 64: return False - + # 验证是否为有效的十六进制字符串 try: int(token, 16) @@ -193,44 +195,91 @@ class TokenManager: def _validate_custom_token(self, token: str) -> tuple[bool, str]: """ 验证自定义 token 格式 - + 要求: - 最少 10 位 - 包含大写字母 - 包含小写字母 - 包含特殊符号 - + Args: token: 待验证的 token - + Returns: tuple[bool, str]: (是否有效, 错误消息) """ if not token or not isinstance(token, str): return False, "Token 不能为空" - + # 检查长度 if len(token) < 10: return False, "Token 长度至少为 10 位" - + # 检查是否包含大写字母 has_upper = any(c.isupper() for c in token) if not has_upper: return False, "Token 必须包含大写字母" - + # 检查是否包含小写字母 has_lower = any(c.islower() for c in token) if not has_lower: return False, "Token 必须包含小写字母" - + # 检查是否包含特殊符号 special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?/" has_special = any(c in special_chars for c in token) if not has_special: return False, f"Token 必须包含特殊符号 ({special_chars})" - + return True, "Token 格式正确" + def is_first_setup(self) -> bool: + """ + 检查是否为首次配置 + + Returns: + bool: 是否为首次配置 + """ + config = self._load_config() + return not config.get("first_setup_completed", False) + + def mark_setup_completed(self) -> bool: + """ + 标记首次配置已完成 + + Returns: + bool: 是否标记成功 + """ + try: + config = self._load_config() + config["first_setup_completed"] = True + config["setup_completed_at"] = self._get_current_timestamp() + self._save_config(config) + logger.info("首次配置已标记为完成") + return True + except Exception as e: + logger.error(f"标记首次配置完成失败: {e}") + return False + + def reset_setup_status(self) -> bool: + """ + 重置首次配置状态,允许重新进入配置向导 + + Returns: + bool: 是否重置成功 + """ + try: + config = self._load_config() + config["first_setup_completed"] = False + if "setup_completed_at" in config: + del config["setup_completed_at"] + self._save_config(config) + logger.info("首次配置状态已重置") + return True + except Exception as e: + logger.error(f"重置首次配置状态失败: {e}") + return False + # 全局单例 _token_manager_instance: Optional[TokenManager] = None diff --git a/src/webui/webui_server.py b/src/webui/webui_server.py new file mode 100644 index 00000000..61d279e2 --- /dev/null +++ b/src/webui/webui_server.py @@ -0,0 +1,148 @@ +"""独立的 WebUI 服务器 - 运行在 0.0.0.0:8001""" + +import os +import asyncio +import mimetypes +from pathlib import Path +from fastapi import FastAPI +from fastapi.responses import FileResponse +from uvicorn import Config, Server as UvicornServer +from src.common.logger import get_logger + +logger = get_logger("webui_server") + + +class WebUIServer: + """独立的 WebUI 服务器""" + + def __init__(self, host: str = "0.0.0.0", port: int = 8001): + self.host = host + self.port = port + self.app = FastAPI(title="MaiBot WebUI") + self._server = None + + # 显示 Access Token + self._show_access_token() + + # 重要:先注册 API 路由,再设置静态文件 + self._register_api_routes() + self._setup_static_files() + + def _show_access_token(self): + """显示 WebUI Access Token""" + try: + from src.webui.token_manager import get_token_manager + + token_manager = get_token_manager() + current_token = token_manager.get_token() + logger.info(f"🔑 WebUI Access Token: {current_token}") + logger.info("💡 请使用此 Token 登录 WebUI") + except Exception as e: + logger.error(f"❌ 获取 Access Token 失败: {e}") + + def _setup_static_files(self): + """设置静态文件服务""" + # 确保正确的 MIME 类型映射 + mimetypes.init() + mimetypes.add_type("application/javascript", ".js") + mimetypes.add_type("application/javascript", ".mjs") + mimetypes.add_type("text/css", ".css") + mimetypes.add_type("application/json", ".json") + + base_dir = Path(__file__).parent.parent.parent + static_path = base_dir / "webui" / "dist" + + if not static_path.exists(): + logger.warning(f"❌ WebUI 静态文件目录不存在: {static_path}") + logger.warning("💡 请先构建前端: cd webui && npm run build") + return + + if not (static_path / "index.html").exists(): + logger.warning(f"❌ 未找到 index.html: {static_path / 'index.html'}") + logger.warning("💡 请确认前端已正确构建") + return + + # 处理 SPA 路由 - 注意:这个路由优先级最低 + @self.app.get("/{full_path:path}", include_in_schema=False) + async def serve_spa(full_path: str): + """服务单页应用 - 只处理非 API 请求""" + # 如果是根路径,直接返回 index.html + if not full_path or full_path == "/": + return FileResponse(static_path / "index.html", media_type="text/html") + + # 检查是否是静态文件 + file_path = static_path / full_path + if file_path.is_file() and file_path.exists(): + # 自动检测 MIME 类型 + media_type = mimetypes.guess_type(str(file_path))[0] + return FileResponse(file_path, media_type=media_type) + + # 其他路径返回 index.html(SPA 路由) + return FileResponse(static_path / "index.html", media_type="text/html") + + logger.info(f"✅ WebUI 静态文件服务已配置: {static_path}") + + def _register_api_routes(self): + """注册所有 WebUI API 路由""" + try: + # 导入所有 WebUI 路由 + from src.webui.routes import router as webui_router + from src.webui.logs_ws import router as logs_router + + # 注册路由 + self.app.include_router(webui_router) + self.app.include_router(logs_router) + + logger.info("✅ WebUI API 路由已注册") + except Exception as e: + logger.error(f"❌ 注册 WebUI API 路由失败: {e}") + + async def start(self): + """启动服务器""" + config = Config( + app=self.app, + host=self.host, + port=self.port, + log_config=None, + access_log=False, + ) + self._server = UvicornServer(config=config) + + logger.info("🌐 WebUI 服务器启动中...") + logger.info(f"🌐 访问地址: http://{self.host}:{self.port}") + + try: + await self._server.serve() + except Exception as e: + logger.error(f"❌ WebUI 服务器运行错误: {e}") + raise + + async def shutdown(self): + """关闭服务器""" + if self._server: + logger.info("正在关闭 WebUI 服务器...") + self._server.should_exit = True + try: + await asyncio.wait_for(self._server.shutdown(), timeout=3.0) + logger.info("✅ WebUI 服务器已关闭") + except asyncio.TimeoutError: + logger.warning("⚠️ WebUI 服务器关闭超时") + except Exception as e: + logger.error(f"❌ WebUI 服务器关闭失败: {e}") + finally: + self._server = None + + +# 全局 WebUI 服务器实例 +_webui_server = None + + +def get_webui_server() -> WebUIServer: + """获取全局 WebUI 服务器实例""" + global _webui_server + if _webui_server is None: + # 从环境变量读取配置 + host = os.getenv("WEBUI_HOST", "0.0.0.0") + port = int(os.getenv("WEBUI_PORT", "8001")) + _webui_server = WebUIServer(host=host, port=port) + return _webui_server diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index cc540e6a..2c1da1be 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "6.21.4" +version = "6.21.8" #----以下是给开发人员阅读的,如果你只是部署了麦麦,不需要阅读---- #如果你想要修改配置文件,请递增version的值 @@ -104,7 +104,7 @@ talk_value_rules = [ include_planner_reasoning = false # 是否将planner推理加入replyer,默认关闭(不加入) [memory] -max_agent_iterations = 5 # 记忆思考深度(最低为1(不深入思考)) +max_agent_iterations = 3 # 记忆思考深度(最低为1(不深入思考)) [jargon] all_global = true # 是否开启全局黑话模式,注意,此功能关闭后,已经记录的全局黑话不会改变,需要手动删除 @@ -211,6 +211,9 @@ show_prompt = false # 是否显示prompt show_replyer_prompt = false # 是否显示回复器prompt show_replyer_reasoning = false # 是否显示回复器推理 show_jargon_prompt = false # 是否显示jargon相关提示词 +show_memory_prompt = false # 是否显示记忆检索相关提示词 +show_planner_prompt = false # 是否显示planner的prompt和原始返回结果 +show_lpmm_paragraph = false # 是否显示lpmm找到的相关文段日志 [maim_message] auth_token = [] # 认证令牌,用于API验证,为空则不启用验证 diff --git a/template/model_config_template.toml b/template/model_config_template.toml index 998c2c13..6d956ace 100644 --- a/template/model_config_template.toml +++ b/template/model_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "1.7.7" +version = "1.7.8" # 配置文件版本号迭代规则同bot_config.toml diff --git a/template/template.env b/template/template.env index b9d612af..b6dd0e5c 100644 --- a/template/template.env +++ b/template/template.env @@ -1,7 +1,9 @@ +# 麦麦主程序配置 HOST=127.0.0.1 PORT=8000 -# WebUI 配置 -# WEBUI_ENABLED=true -# WEBUI_MODE=development # 开发模式(需手动启动前端: cd webui && npm run dev,端口 7999) -# WEBUI_MODE=production # 生产模式(需先构建前端: cd webui && npm run build) \ No newline at end of file +# WebUI 独立服务器配置 +WEBUI_ENABLED=true +WEBUI_MODE=production # 模式: development(开发) 或 production(生产) +WEBUI_HOST=0.0.0.0 # WebUI 服务器监听地址 +WEBUI_PORT=8001 # WebUI 服务器端口 \ No newline at end of file diff --git a/webui/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 b/webui/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 new file mode 100644 index 00000000..0acaaff0 Binary files /dev/null and b/webui/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 differ diff --git a/webui/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff b/webui/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff new file mode 100644 index 00000000..b804d7b3 Binary files /dev/null and b/webui/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff differ diff --git a/webui/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf b/webui/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf new file mode 100644 index 00000000..c6f9a5e7 Binary files /dev/null and b/webui/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf differ diff --git a/webui/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf b/webui/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf new file mode 100644 index 00000000..9ff4a5e0 Binary files /dev/null and b/webui/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf differ diff --git a/webui/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff b/webui/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff new file mode 100644 index 00000000..9759710d Binary files /dev/null and b/webui/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff differ diff --git a/webui/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 b/webui/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 new file mode 100644 index 00000000..f390922e Binary files /dev/null and b/webui/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 differ diff --git a/webui/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff b/webui/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff new file mode 100644 index 00000000..9bdd534f Binary files /dev/null and b/webui/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff differ diff --git a/webui/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 b/webui/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 new file mode 100644 index 00000000..75344a1f Binary files /dev/null and b/webui/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 differ diff --git a/webui/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf b/webui/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf new file mode 100644 index 00000000..f522294f Binary files /dev/null and b/webui/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf differ diff --git a/webui/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf b/webui/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf new file mode 100644 index 00000000..4e98259c Binary files /dev/null and b/webui/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf differ diff --git a/webui/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff b/webui/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff new file mode 100644 index 00000000..e7730f66 Binary files /dev/null and b/webui/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff differ diff --git a/webui/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 b/webui/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 new file mode 100644 index 00000000..395f28be Binary files /dev/null and b/webui/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 differ diff --git a/webui/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf b/webui/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf new file mode 100644 index 00000000..b8461b27 Binary files /dev/null and b/webui/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf differ diff --git a/webui/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 b/webui/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 new file mode 100644 index 00000000..735f6948 Binary files /dev/null and b/webui/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 differ diff --git a/webui/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff b/webui/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff new file mode 100644 index 00000000..acab069f Binary files /dev/null and b/webui/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff differ diff --git a/webui/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 b/webui/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 new file mode 100644 index 00000000..ab2ad21d Binary files /dev/null and b/webui/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 differ diff --git a/webui/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff b/webui/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff new file mode 100644 index 00000000..f38136ac Binary files /dev/null and b/webui/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff differ diff --git a/webui/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf b/webui/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf new file mode 100644 index 00000000..4060e627 Binary files /dev/null and b/webui/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf differ diff --git a/webui/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 b/webui/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 new file mode 100644 index 00000000..5931794d Binary files /dev/null and b/webui/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 differ diff --git a/webui/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf b/webui/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf new file mode 100644 index 00000000..dc007977 Binary files /dev/null and b/webui/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf differ diff --git a/webui/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff b/webui/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff new file mode 100644 index 00000000..67807b0b Binary files /dev/null and b/webui/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff differ diff --git a/webui/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf b/webui/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf new file mode 100644 index 00000000..0e9b0f35 Binary files /dev/null and b/webui/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf differ diff --git a/webui/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff b/webui/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff new file mode 100644 index 00000000..6f43b594 Binary files /dev/null and b/webui/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff differ diff --git a/webui/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 b/webui/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 new file mode 100644 index 00000000..b50920e1 Binary files /dev/null and b/webui/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 differ diff --git a/webui/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 b/webui/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 new file mode 100644 index 00000000..eb24a7ba Binary files /dev/null and b/webui/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 differ diff --git a/webui/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff b/webui/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff new file mode 100644 index 00000000..21f58129 Binary files /dev/null and b/webui/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff differ diff --git a/webui/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf b/webui/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf new file mode 100644 index 00000000..dd45e1ed Binary files /dev/null and b/webui/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf differ diff --git a/webui/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf b/webui/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf new file mode 100644 index 00000000..728ce7a1 Binary files /dev/null and b/webui/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf differ diff --git a/webui/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 b/webui/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 new file mode 100644 index 00000000..29657023 Binary files /dev/null and b/webui/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 differ diff --git a/webui/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff b/webui/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff new file mode 100644 index 00000000..0ae390d7 Binary files /dev/null and b/webui/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff differ diff --git a/webui/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff b/webui/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff new file mode 100644 index 00000000..eb5159d4 Binary files /dev/null and b/webui/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff differ diff --git a/webui/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf b/webui/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf new file mode 100644 index 00000000..70d559b4 Binary files /dev/null and b/webui/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf differ diff --git a/webui/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 b/webui/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 new file mode 100644 index 00000000..215c143f Binary files /dev/null and b/webui/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 differ diff --git a/webui/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf b/webui/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf new file mode 100644 index 00000000..2f65a8a3 Binary files /dev/null and b/webui/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf differ diff --git a/webui/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 b/webui/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 new file mode 100644 index 00000000..cfaa3bda Binary files /dev/null and b/webui/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 differ diff --git a/webui/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff b/webui/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff new file mode 100644 index 00000000..8d47c02d Binary files /dev/null and b/webui/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff differ diff --git a/webui/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 b/webui/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 new file mode 100644 index 00000000..349c06dc Binary files /dev/null and b/webui/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 differ diff --git a/webui/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff b/webui/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff new file mode 100644 index 00000000..7e02df96 Binary files /dev/null and b/webui/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff differ diff --git a/webui/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf b/webui/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf new file mode 100644 index 00000000..d5850df9 Binary files /dev/null and b/webui/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf differ diff --git a/webui/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf b/webui/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf new file mode 100644 index 00000000..537279f6 Binary files /dev/null and b/webui/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf differ diff --git a/webui/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff b/webui/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff new file mode 100644 index 00000000..31b84829 Binary files /dev/null and b/webui/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff differ diff --git a/webui/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 b/webui/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 new file mode 100644 index 00000000..a90eea85 Binary files /dev/null and b/webui/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 differ diff --git a/webui/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf b/webui/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf new file mode 100644 index 00000000..fd679bf3 Binary files /dev/null and b/webui/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf differ diff --git a/webui/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 b/webui/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 new file mode 100644 index 00000000..b3048fc1 Binary files /dev/null and b/webui/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 differ diff --git a/webui/dist/assets/KaTeX_Script-Regular-D5yQViql.woff b/webui/dist/assets/KaTeX_Script-Regular-D5yQViql.woff new file mode 100644 index 00000000..0e7da821 Binary files /dev/null and b/webui/dist/assets/KaTeX_Script-Regular-D5yQViql.woff differ diff --git a/webui/dist/assets/KaTeX_Size1-Regular-C195tn64.woff b/webui/dist/assets/KaTeX_Size1-Regular-C195tn64.woff new file mode 100644 index 00000000..7f292d91 Binary files /dev/null and b/webui/dist/assets/KaTeX_Size1-Regular-C195tn64.woff differ diff --git a/webui/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf b/webui/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf new file mode 100644 index 00000000..871fd7d1 Binary files /dev/null and b/webui/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf differ diff --git a/webui/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 b/webui/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 new file mode 100644 index 00000000..c5a8462f Binary files /dev/null and b/webui/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 differ diff --git a/webui/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf b/webui/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf new file mode 100644 index 00000000..7a212caf Binary files /dev/null and b/webui/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf differ diff --git a/webui/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 b/webui/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 new file mode 100644 index 00000000..e1bccfe2 Binary files /dev/null and b/webui/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 differ diff --git a/webui/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff b/webui/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff new file mode 100644 index 00000000..d241d9be Binary files /dev/null and b/webui/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff differ diff --git a/webui/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff b/webui/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff new file mode 100644 index 00000000..e6e9b658 Binary files /dev/null and b/webui/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff differ diff --git a/webui/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf b/webui/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf new file mode 100644 index 00000000..00bff349 Binary files /dev/null and b/webui/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf differ diff --git a/webui/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff b/webui/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff new file mode 100644 index 00000000..e1ec5457 Binary files /dev/null and b/webui/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff differ diff --git a/webui/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf b/webui/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf new file mode 100644 index 00000000..74f08921 Binary files /dev/null and b/webui/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf differ diff --git a/webui/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 b/webui/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 new file mode 100644 index 00000000..680c1308 Binary files /dev/null and b/webui/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 differ diff --git a/webui/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff b/webui/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff new file mode 100644 index 00000000..2432419f Binary files /dev/null and b/webui/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff differ diff --git a/webui/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 b/webui/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 new file mode 100644 index 00000000..771f1af7 Binary files /dev/null and b/webui/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 differ diff --git a/webui/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf b/webui/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf new file mode 100644 index 00000000..c83252c5 Binary files /dev/null and b/webui/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf differ diff --git a/webui/dist/assets/charts-B1JvyJzO.js b/webui/dist/assets/charts-B1JvyJzO.js new file mode 100644 index 00000000..7d2ae929 Binary files /dev/null and b/webui/dist/assets/charts-B1JvyJzO.js differ diff --git a/webui/dist/assets/icons-D6w7t-x9.js b/webui/dist/assets/icons-D6w7t-x9.js new file mode 100644 index 00000000..2cac469f Binary files /dev/null and b/webui/dist/assets/icons-D6w7t-x9.js differ diff --git a/webui/dist/assets/index-BmybQ2BG.css b/webui/dist/assets/index-BmybQ2BG.css new file mode 100644 index 00000000..bb6e4a9a Binary files /dev/null and b/webui/dist/assets/index-BmybQ2BG.css differ diff --git a/webui/dist/assets/index-Cga6iLH3.js b/webui/dist/assets/index-Cga6iLH3.js new file mode 100644 index 00000000..85f669a3 Binary files /dev/null and b/webui/dist/assets/index-Cga6iLH3.js differ diff --git a/webui/dist/assets/react-vendor-Dtc2IqVY.js b/webui/dist/assets/react-vendor-Dtc2IqVY.js new file mode 100644 index 00000000..087b3f14 Binary files /dev/null and b/webui/dist/assets/react-vendor-Dtc2IqVY.js differ diff --git a/webui/dist/assets/router-BWgTyY51.js b/webui/dist/assets/router-BWgTyY51.js new file mode 100644 index 00000000..78a82272 Binary files /dev/null and b/webui/dist/assets/router-BWgTyY51.js differ diff --git a/webui/dist/assets/ui-vendor-nTGLnMlb.js b/webui/dist/assets/ui-vendor-nTGLnMlb.js new file mode 100644 index 00000000..0fcce437 Binary files /dev/null and b/webui/dist/assets/ui-vendor-nTGLnMlb.js differ diff --git a/webui/dist/index.html b/webui/dist/index.html new file mode 100644 index 00000000..494a8c93 Binary files /dev/null and b/webui/dist/index.html differ diff --git a/webui/dist/maimai.ico b/webui/dist/maimai.ico new file mode 100644 index 00000000..578b11cd Binary files /dev/null and b/webui/dist/maimai.ico differ diff --git a/webui/dist/vite.svg b/webui/dist/vite.svg new file mode 100644 index 00000000..e7b8dfb1 Binary files /dev/null and b/webui/dist/vite.svg differ