From f88031b0c96df03f719cb4d0a2f5c0c8d113b48f Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Tue, 2 Sep 2025 23:39:24 +0800 Subject: [PATCH] Release: v4.0.0-beta.1 (#2509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor: using sqlmodel(sqlchemy+pydantic) as ORM framework and switch to async-based sqlite operation (#2294) * stage * stage * refactor: using sqlchemy as ORM framework, switch to async-based sqlite operation - using sqlmodel as ORM(based on sqlchemy and pydantic) - add Persona, Preference, PlatformMessageHistory table * fix: conversation * fix: remove redundant explicit session.commit, and fix some type error * fix: conversation context issue * chore: remove comments * chore: remove exclude_content param * Fix: 当多个相同消息平台实例部署时上下文可能混乱(共享) (#2298) * perf: update astrbot event session format, using platfrom id to ensure uniqueness fixes: #1000 * fix: 更新 MessageSession 类以使用 platform_id 作为唯一标识符,并调整相关方法以确保一致性 * fix: 更新 MessageSession 文档以明确 platform_id 的赋值规则,并调整 get_platform 和 get_platform_inst 方法的返回类型 * Improve: 引入全新的人格管理模式以及重构函数工具管理器 (#2305) * feat: add persona management * refactor: 重构函数工具管理器,引入 ToolSet,并让 Persona 支持绑定 Tools * feat: 更新 Persona 工具选择逻辑,支持全选和指定工具的切换 * feat: 更新 BaseDatabase 中的 persona 方法返回类型,支持返回 None * fix: platform id * feat: add support to sync mcp servers from ModelScope (#2313) * fix: 修复访问令牌的空格问题 * chore: 移除 MCP 市场相关逻辑 (#2314) * chore: 移除 MCP 市场相关路由 * Refactor: 重构配置文件管理,以支持更灵活的、会话粒度的(基于 umo part)配置文件隔离 (#2328) * refactor: 重构配置文件管理,以支持更灵活的、基于 umo part 的配置文件隔离 * Refactor: 重构配置前端页面,新增数个配置项 (#2331) * refactor: 重构配置前端页面,新增数个配置项 * feat: 完善多配置文件结构 * perf: 系统配置入口 * fix: normal config item list not display * fix: 修复 axios 请求中的上下文引用问题 * chore: remove status checking in chat page * fix: 修复 stage 在不同 pipeline 中被重复使用的问题和 persona 相关问题 * Feature: 增加图片转述提供商配置、支持用户自定义模型模态能力 (#2422) * feat: 增加图片转述提供商配置、支持用户自定义模型模态能力 * fix: 修复 LLMRequestSubStage 中会话管理方法参数不一致的问题,简化方法调用 * Feature: 优化 WebSearch 的爬取网页速度并且支持使用 Tavily 作为搜索引擎 (#2427) * feat: 优化了 websearch 的速度;支持 Tavily 作为搜索引擎 * fix: 优化日志记录格式,修复搜索结果处理中的索引和内容显示问题 * feat: 添加对话选中状态管理,优化默认对话加载逻辑 * feat: 支持通过解析URL 的方式导入网页数据到知识库 (#2280) * feat:为webchat页面添加一个手动上传文件按钮(目前只处理图片) * fix:上传后清空value,允许触发change事件以多次上传同一张图片 * perf:webchat页面消息发送后清空图片预览缩略图,维持与文本信息行为一致 * perf:将文件输入的值重置为空字符串以提升浏览器兼容性 * feat:webchat文件上传按钮支持多选文件上传 * fix:释放blob URL以防止内存泄漏 * perf:并行化sendMessage中的图片获取逻辑 * feat:完成从url获取部分的UI * feat: 添加从URL导入功能的组件 * fix: 优化导入结果处理,添加整体摘要和主题摘要的文件命名 * perf: 更新url导入选项添加默认值 * perf: 在导入url的部分配置项未启用时隐藏暂不使用的下拉框选项 * feat: 添加上传前提提示信息至导入url至知识库功能 * feat: 更新导入功能提示信息,添加上传状态通知 * fix: 优化url转知识库错误处理 * feat: 合并知识库的上传文件和 URL 标签页 * feat: 删除导入URL至知识库功能的相关组件 --------- Co-authored-by: Soulter <905617992@qq.com> * feat: 添加条件显示逻辑以优化插件配置项的可见性管理 (#2433) * Feature: 支持在 WebUI 配置文件页中配置默认知识库 (#2437) * feat: 支持配置默认知识库 * chore: clean code * refactor: 重构 Function Tool 管理并初步引入 Multi Agent 及 Agent Handsoff 机制 (#2454) * stage * refactor: 重构 Function Tool 管理并引入 multi agent handsoff 机制 - Updated `star_request.py` to use the global `call_handler` instead of context-specific calls. - Modified `entities.py` to remove the dependency on `FunctionToolManager` and streamline the function tool handling. - Refactored `func_tool_manager.py` to simplify the `FunctionTool` class and its methods, removing deprecated code and enhancing clarity. - Adjusted `provider.py` to align with the new function tool structure, removing unnecessary type unions. - Enhanced `star_handler.py` to support agent registration and tool association, introducing `RegisteringAgent` for better encapsulation. - Updated `star_manager.py` to handle tool registration for agents, ensuring proper binding of handlers. - Revised `main.py` in the web searcher package to utilize the new agent registration system for web search tools. * chore: websearch * perf: 减少嵌套 * chore: 移除未使用的 mcp 导入 * feat: 添加 WebUI 迁移助手以及相关迁移方法 (#2477) * fix: 修复迁移对话时的一些问题 * feat: 增加工具使用模型能力选项 * feat: 添加知识库插件更新检查和更新功能 * perf: 调整 WebUI sidebar 顺序 * refactor: 重构 SharedPreference 类并采用数据库存储替换 json 存储 (#2482) * perf: 使用 run_coroutine_threadsafe Co-authored-by: Raven95676 * Feature: 支持配置重排序模型(vLLM API 格式)用于 score 任务 (#2496) * feat: 支持添加重排序模型(vLLM API 格式)用于 score 任务 * fix: update rerank API base URL to use localhost * feat: 知识库支持配置重排序模型 * fix: remove debug print statement for reranked results in FaissVecDB * fix: 移除知识库中的提示文本 * Feature: 支持在配置文件配置可用的插件组 (#2505) * feat: 增加可用插件集合配置项 * remove: 旧版平台可用性配置 已经基于多配置文件实现。 * feat: 应用配置文件插件可用性配置 * perf: hoist if from if * feat: llm_tool 装饰器返回值支持返回 mcp 库中 tool 的返回值类型(mcp.type.CallToolResult) (#2507) * fix: add type definition for migrationDialog and ensure open method exists before calling * chore: update project version to 4.0.0 * feat: 多 t2i 服务的随机负载均衡 (#2529) * fix: bugfixes * Improve: 扩大配置文件生效范围的自定义程度到会话粒度 (#2532) * feat: 扩大配置文件生效范围的自定义程度 * perf: 冲突检测 * refactor: simplify config form validation and improve conflict message clarity * chore: clean code * feat: 插件配置支持多个快捷魔法配置项 * chore: 修复当自动更新 webchat title 时,history 被重置的问题 * bugfixes * feat: add custom T2I template editor (#2581) * perf: add option to clear provider selection in ProviderSelector component * 📦 release: bump verstion to v4.0.0-beta.1 * chore: delete uv.lock --------- Co-authored-by: RC-CHN <67079377+RC-CHN@users.noreply.github.com> Co-authored-by: Raven95676 --- astrbot/api/__init__.py | 15 +- astrbot/core/__init__.py | 3 +- astrbot/core/agent/agent.py | 13 + astrbot/core/agent/handoff.py | 34 + astrbot/core/agent/hooks.py | 27 + astrbot/core/agent/mcp_client.py | 208 ++ astrbot/core/agent/response.py | 12 + astrbot/core/agent/run_context.py | 17 + astrbot/core/agent/runners/__init__.py | 3 + .../agent_runner => agent/runners}/base.py | 41 +- .../runners/tool_loop_agent_runner.py} | 230 +- astrbot/core/agent/tool.py | 256 ++ astrbot/core/agent/tool_executor.py | 11 + astrbot/core/astr_agent_context.py | 11 + astrbot/core/astrbot_config_mgr.py | 276 ++ astrbot/core/config/default.py | 837 +++-- astrbot/core/conversation_mgr.py | 206 +- astrbot/core/core_lifecycle.py | 75 +- astrbot/core/db/__init__.py | 353 ++- astrbot/core/db/migration/helper.py | 64 + astrbot/core/db/migration/migra_3_to_4.py | 338 ++ .../db/migration/shared_preferences_v3.py | 45 + astrbot/core/db/migration/sqlite_v3.py | 493 +++ astrbot/core/db/po.py | 303 +- astrbot/core/db/sqlite.py | 1083 ++++--- astrbot/core/db/sqlite_init.sql | 50 - astrbot/core/db/vec_db/faiss_impl/vec_db.py | 33 +- astrbot/core/event_bus.py | 36 +- astrbot/core/persona_mgr.py | 183 ++ astrbot/core/pipeline/__init__.py | 3 - astrbot/core/pipeline/context.py | 108 +- astrbot/core/pipeline/context_utils.py | 98 + .../pipeline/platform_compatibility/stage.py | 56 - .../process_stage/method/llm_request.py | 441 ++- .../process_stage/method/star_request.py | 14 +- astrbot/core/pipeline/respond/stage.py | 4 +- .../core/pipeline/result_decorate/stage.py | 9 +- astrbot/core/pipeline/scheduler.py | 17 +- astrbot/core/pipeline/stage.py | 6 +- astrbot/core/pipeline/waking_check/stage.py | 11 +- astrbot/core/platform/astr_message_event.py | 32 +- astrbot/core/platform/manager.py | 3 + astrbot/core/platform/message_session.py | 28 + astrbot/core/platform/platform.py | 2 +- astrbot/core/platform/platform_metadata.py | 2 +- .../sources/webchat/webchat_adapter.py | 2 +- astrbot/core/platform_message_history_mgr.py | 47 + astrbot/core/provider/entities.py | 12 +- astrbot/core/provider/func_tool_manager.py | 572 ++-- astrbot/core/provider/manager.py | 209 +- astrbot/core/provider/provider.py | 41 +- .../core/provider/sources/dashscope_source.py | 3 +- astrbot/core/provider/sources/dify_source.py | 3 +- .../provider/sources/vllm_rerank_source.py | 59 + astrbot/core/star/__init__.py | 2 +- astrbot/core/star/context.py | 145 +- astrbot/core/star/register/__init__.py | 2 + astrbot/core/star/register/star_handler.py | 72 +- astrbot/core/star/session_llm_manager.py | 127 +- astrbot/core/star/session_plugin_manager.py | 16 +- astrbot/core/star/star.py | 24 - astrbot/core/star/star_handler.py | 50 +- astrbot/core/star/star_manager.py | 84 +- astrbot/core/utils/metrics.py | 7 +- astrbot/core/utils/shared_preferences.py | 199 +- astrbot/core/utils/t2i/network_strategy.py | 124 +- astrbot/core/utils/t2i/renderer.py | 6 +- astrbot/dashboard/routes/__init__.py | 5 +- astrbot/dashboard/routes/chat.py | 138 +- astrbot/dashboard/routes/config.py | 334 +- astrbot/dashboard/routes/conversation.py | 38 +- astrbot/dashboard/routes/persona.py | 199 ++ astrbot/dashboard/routes/plugin.py | 105 - astrbot/dashboard/routes/route.py | 10 +- .../dashboard/routes/session_management.py | 232 +- astrbot/dashboard/routes/stat.py | 6 +- astrbot/dashboard/routes/tools.py | 130 +- astrbot/dashboard/routes/update.py | 17 + astrbot/dashboard/server.py | 3 + changelogs/v4.0.0-beta.1.md | 3 + .../src/assets/images/astrbot_banner.png | Bin 0 -> 46768 bytes dashboard/src/assets/images/logo-normal.svg | 40 - dashboard/src/assets/images/logo-waifu.png | Bin 55188 -> 0 bytes .../src/components/shared/AstrBotConfig.vue | 113 +- .../src/components/shared/AstrBotConfigV4.vue | 456 +++ .../shared/KnowledgeBaseSelector.vue | 225 ++ .../src/components/shared/ListConfigItem.vue | 307 +- .../src/components/shared/MigrationDialog.vue | 275 ++ .../src/components/shared/PersonaSelector.vue | 152 + .../components/shared/PluginSetSelector.vue | 226 ++ .../components/shared/ProviderSelector.vue | 167 + .../components/shared/T2ITemplateEditor.vue | 307 ++ dashboard/src/i18n/loader.ts | 2 + .../i18n/locales/en-US/core/navigation.json | 3 +- .../en-US/features/alkaid/knowledge-base.json | 54 +- .../locales/en-US/features/extension.json | 15 - .../locales/en-US/features/migration.json | 16 + .../i18n/locales/en-US/features/persona.json | 67 + .../i18n/locales/en-US/features/provider.json | 6 +- .../i18n/locales/en-US/features/settings.json | 5 + .../i18n/locales/en-US/features/tool-use.json | 11 +- .../i18n/locales/zh-CN/core/navigation.json | 7 +- .../zh-CN/features/alkaid/knowledge-base.json | 56 +- .../i18n/locales/zh-CN/features/config.json | 6 +- .../locales/zh-CN/features/extension.json | 15 - .../locales/zh-CN/features/migration.json | 16 + .../i18n/locales/zh-CN/features/persona.json | 67 + .../i18n/locales/zh-CN/features/provider.json | 7 +- .../i18n/locales/zh-CN/features/settings.json | 5 + .../i18n/locales/zh-CN/features/tool-use.json | 51 +- dashboard/src/i18n/translations.ts | 12 +- dashboard/src/layouts/full/FullLayout.vue | 34 + .../full/vertical-header/VerticalHeader.vue | 4 +- .../full/vertical-sidebar/VerticalSidebar.vue | 3 +- .../full/vertical-sidebar/sidebarItem.ts | 27 +- dashboard/src/router/MainRoutes.ts | 50 +- .../src/scss/components/_VTextField.scss | 18 +- dashboard/src/views/AboutPage.vue | 246 +- dashboard/src/views/AlkaidPage_sigma.vue | 438 --- dashboard/src/views/ChatPage.vue | 126 +- dashboard/src/views/ConfigPage.vue | 1168 ++++++- dashboard/src/views/ExtensionPage.vue | 208 +- dashboard/src/views/PersonaPage.vue | 808 +++++ dashboard/src/views/PlatformPage.vue | 5 - dashboard/src/views/ProviderPage.vue | 122 +- dashboard/src/views/Settings.vue | 45 +- dashboard/src/views/ToolUsePage.vue | 594 ++-- dashboard/src/views/alkaid/KnowledgeBase.vue | 639 +++- packages/astrbot/long_term_memory.py | 90 +- packages/astrbot/main.py | 224 +- packages/session_controller/main.py | 15 +- packages/thinking_filter/main.py | 10 +- packages/web_searcher/main.py | 360 ++- pyproject.toml | 5 +- uv.lock | 2748 ----------------- 135 files changed, 11901 insertions(+), 7891 deletions(-) create mode 100644 astrbot/core/agent/agent.py create mode 100644 astrbot/core/agent/handoff.py create mode 100644 astrbot/core/agent/hooks.py create mode 100644 astrbot/core/agent/mcp_client.py create mode 100644 astrbot/core/agent/response.py create mode 100644 astrbot/core/agent/run_context.py create mode 100644 astrbot/core/agent/runners/__init__.py rename astrbot/core/{pipeline/process_stage/agent_runner => agent/runners}/base.py (56%) rename astrbot/core/{pipeline/process_stage/agent_runner/tool_loop_agent.py => agent/runners/tool_loop_agent_runner.py} (55%) create mode 100644 astrbot/core/agent/tool.py create mode 100644 astrbot/core/agent/tool_executor.py create mode 100644 astrbot/core/astr_agent_context.py create mode 100644 astrbot/core/astrbot_config_mgr.py create mode 100644 astrbot/core/db/migration/helper.py create mode 100644 astrbot/core/db/migration/migra_3_to_4.py create mode 100644 astrbot/core/db/migration/shared_preferences_v3.py create mode 100644 astrbot/core/db/migration/sqlite_v3.py delete mode 100644 astrbot/core/db/sqlite_init.sql create mode 100644 astrbot/core/persona_mgr.py create mode 100644 astrbot/core/pipeline/context_utils.py delete mode 100644 astrbot/core/pipeline/platform_compatibility/stage.py create mode 100644 astrbot/core/platform/message_session.py create mode 100644 astrbot/core/platform_message_history_mgr.py create mode 100644 astrbot/core/provider/sources/vllm_rerank_source.py create mode 100644 astrbot/dashboard/routes/persona.py create mode 100644 changelogs/v4.0.0-beta.1.md create mode 100644 dashboard/src/assets/images/astrbot_banner.png delete mode 100644 dashboard/src/assets/images/logo-normal.svg delete mode 100644 dashboard/src/assets/images/logo-waifu.png create mode 100644 dashboard/src/components/shared/AstrBotConfigV4.vue create mode 100644 dashboard/src/components/shared/KnowledgeBaseSelector.vue create mode 100644 dashboard/src/components/shared/MigrationDialog.vue create mode 100644 dashboard/src/components/shared/PersonaSelector.vue create mode 100644 dashboard/src/components/shared/PluginSetSelector.vue create mode 100644 dashboard/src/components/shared/ProviderSelector.vue create mode 100644 dashboard/src/components/shared/T2ITemplateEditor.vue create mode 100644 dashboard/src/i18n/locales/en-US/features/migration.json create mode 100644 dashboard/src/i18n/locales/en-US/features/persona.json create mode 100644 dashboard/src/i18n/locales/zh-CN/features/migration.json create mode 100644 dashboard/src/i18n/locales/zh-CN/features/persona.json delete mode 100644 dashboard/src/views/AlkaidPage_sigma.vue create mode 100644 dashboard/src/views/PersonaPage.vue delete mode 100644 uv.lock diff --git a/astrbot/api/__init__.py b/astrbot/api/__init__.py index e8a9d23a9..540171f1d 100644 --- a/astrbot/api/__init__.py +++ b/astrbot/api/__init__.py @@ -3,5 +3,18 @@ from astrbot import logger from astrbot.core import html_renderer from astrbot.core import sp from astrbot.core.star.register import register_llm_tool as llm_tool +from astrbot.core.star.register import register_agent as agent +from astrbot.core.agent.tool import ToolSet, FunctionTool +from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor -__all__ = ["AstrBotConfig", "logger", "html_renderer", "llm_tool", "sp"] +__all__ = [ + "AstrBotConfig", + "logger", + "html_renderer", + "llm_tool", + "agent", + "sp", + "ToolSet", + "FunctionTool", + "BaseFunctionToolExecutor", +] diff --git a/astrbot/core/__init__.py b/astrbot/core/__init__.py index 16f108ece..235a8284b 100644 --- a/astrbot/core/__init__.py +++ b/astrbot/core/__init__.py @@ -1,5 +1,4 @@ import os -import asyncio from .log import LogManager, LogBroker # noqa from astrbot.core.utils.t2i.renderer import HtmlRenderer from astrbot.core.utils.shared_preferences import SharedPreferences @@ -21,7 +20,7 @@ html_renderer = HtmlRenderer(t2i_base_url) logger = LogManager.GetLogger(log_name="astrbot") db_helper = SQLiteDatabase(DB_PATH) # 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中 -sp = SharedPreferences() +sp = SharedPreferences(db_helper=db_helper) # 文件令牌服务 file_token_service = FileTokenService() pip_installer = PipInstaller( diff --git a/astrbot/core/agent/agent.py b/astrbot/core/agent/agent.py new file mode 100644 index 000000000..70536ca88 --- /dev/null +++ b/astrbot/core/agent/agent.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass +from .tool import FunctionTool +from typing import Generic +from .run_context import TContext +from .hooks import BaseAgentRunHooks + + +@dataclass +class Agent(Generic[TContext]): + name: str + instructions: str | None = None + tools: list[str, FunctionTool] | None = None + run_hooks: BaseAgentRunHooks[TContext] | None = None diff --git a/astrbot/core/agent/handoff.py b/astrbot/core/agent/handoff.py new file mode 100644 index 000000000..d26463147 --- /dev/null +++ b/astrbot/core/agent/handoff.py @@ -0,0 +1,34 @@ +from typing import Generic +from .tool import FunctionTool +from .agent import Agent +from .run_context import TContext + + +class HandoffTool(FunctionTool, Generic[TContext]): + """Handoff tool for delegating tasks to another agent.""" + + def __init__( + self, agent: Agent[TContext], parameters: dict | None = None, **kwargs + ): + self.agent = agent + super().__init__( + name=f"transfer_to_{agent.name}", + parameters=parameters or self.default_parameters(), + description=agent.instructions or self.default_description(agent.name), + **kwargs, + ) + + def default_parameters(self) -> dict: + return { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The input to be handed off to another agent. This should be a clear and concise request or task.", + }, + }, + } + + def default_description(self, agent_name: str | None) -> str: + agent_name = agent_name or "another" + return f"Delegate tasks to {self.name} agent to handle the request." diff --git a/astrbot/core/agent/hooks.py b/astrbot/core/agent/hooks.py new file mode 100644 index 000000000..884fe6bd4 --- /dev/null +++ b/astrbot/core/agent/hooks.py @@ -0,0 +1,27 @@ +import mcp +from dataclasses import dataclass +from .run_context import ContextWrapper, TContext +from typing import Generic +from astrbot.core.provider.entities import LLMResponse +from astrbot.core.agent.tool import FunctionTool + + +@dataclass +class BaseAgentRunHooks(Generic[TContext]): + async def on_agent_begin(self, run_context: ContextWrapper[TContext]): ... + async def on_tool_start( + self, + run_context: ContextWrapper[TContext], + tool: FunctionTool, + tool_args: dict | None, + ): ... + async def on_tool_end( + self, + run_context: ContextWrapper[TContext], + tool: FunctionTool, + tool_args: dict | None, + tool_result: mcp.types.CallToolResult | None, + ): ... + async def on_agent_done( + self, run_context: ContextWrapper[TContext], llm_response: LLMResponse + ): ... diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py new file mode 100644 index 000000000..f22a222a0 --- /dev/null +++ b/astrbot/core/agent/mcp_client.py @@ -0,0 +1,208 @@ +import asyncio +import logging +from datetime import timedelta +from typing import Optional +from contextlib import AsyncExitStack +from astrbot import logger +from astrbot.core.utils.log_pipe import LogPipe + +try: + import mcp + from mcp.client.sse import sse_client +except (ModuleNotFoundError, ImportError): + logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。") + +try: + from mcp.client.streamable_http import streamablehttp_client +except (ModuleNotFoundError, ImportError): + logger.warning( + "警告: 缺少依赖库 'mcp' 或者 mcp 库版本过低,无法使用 Streamable HTTP 连接方式。" + ) + + +def _prepare_config(config: dict) -> dict: + """准备配置,处理嵌套格式""" + if "mcpServers" in config and config["mcpServers"]: + first_key = next(iter(config["mcpServers"])) + config = config["mcpServers"][first_key] + config.pop("active", None) + return config + + +async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: + """快速测试 MCP 服务器可达性""" + import aiohttp + + cfg = _prepare_config(config.copy()) + + url = cfg["url"] + headers = cfg.get("headers", {}) + timeout = cfg.get("timeout", 10) + + try: + async with aiohttp.ClientSession() as session: + if cfg.get("transport") == "streamable_http": + test_payload = { + "jsonrpc": "2.0", + "method": "initialize", + "id": 0, + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.2.3"}, + }, + } + async with session.post( + url, + headers={ + **headers, + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + json=test_payload, + timeout=aiohttp.ClientTimeout(total=timeout), + ) as response: + if response.status == 200: + return True, "" + else: + return False, f"HTTP {response.status}: {response.reason}" + else: + async with session.get( + url, + headers={ + **headers, + "Accept": "application/json, text/event-stream", + }, + timeout=aiohttp.ClientTimeout(total=timeout), + ) as response: + if response.status == 200: + return True, "" + else: + return False, f"HTTP {response.status}: {response.reason}" + + except asyncio.TimeoutError: + return False, f"连接超时: {timeout}秒" + except Exception as e: + return False, f"{e!s}" + + +class MCPClient: + def __init__(self): + # Initialize session and client objects + self.session: Optional[mcp.ClientSession] = None + self.exit_stack = AsyncExitStack() + + self.name = None + self.active: bool = True + self.tools: list[mcp.Tool] = [] + self.server_errlogs: list[str] = [] + self.running_event = asyncio.Event() + + async def connect_to_server(self, mcp_server_config: dict, name: str): + """连接到 MCP 服务器 + + 如果 `url` 参数存在: + 1. 当 transport 指定为 `streamable_http` 时,使用 Streamable HTTP 连接方式。 + 1. 当 transport 指定为 `sse` 时,使用 SSE 连接方式。 + 2. 如果没有指定,默认使用 SSE 的方式连接到 MCP 服务。 + + Args: + mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server + """ + cfg = _prepare_config(mcp_server_config.copy()) + + def logging_callback(msg: str): + # 处理 MCP 服务的错误日志 + print(f"MCP Server {name} Error: {msg}") + self.server_errlogs.append(msg) + + if "url" in cfg: + success, error_msg = await _quick_test_mcp_connection(cfg) + if not success: + raise Exception(error_msg) + + if cfg.get("transport") != "streamable_http": + # SSE transport method + self._streams_context = sse_client( + url=cfg["url"], + headers=cfg.get("headers", {}), + timeout=cfg.get("timeout", 5), + sse_read_timeout=cfg.get("sse_read_timeout", 60 * 5), + ) + streams = await self.exit_stack.enter_async_context( + self._streams_context + ) + + # Create a new client session + read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 20)) + self.session = await self.exit_stack.enter_async_context( + mcp.ClientSession( + *streams, + read_timeout_seconds=read_timeout, + logging_callback=logging_callback, # type: ignore + ) + ) + else: + timeout = timedelta(seconds=cfg.get("timeout", 30)) + sse_read_timeout = timedelta( + seconds=cfg.get("sse_read_timeout", 60 * 5) + ) + self._streams_context = streamablehttp_client( + url=cfg["url"], + headers=cfg.get("headers", {}), + timeout=timeout, + sse_read_timeout=sse_read_timeout, + terminate_on_close=cfg.get("terminate_on_close", True), + ) + read_s, write_s, _ = await self.exit_stack.enter_async_context( + self._streams_context + ) + + # Create a new client session + read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 20)) + self.session = await self.exit_stack.enter_async_context( + mcp.ClientSession( + read_stream=read_s, + write_stream=write_s, + read_timeout_seconds=read_timeout, + logging_callback=logging_callback, # type: ignore + ) + ) + + else: + server_params = mcp.StdioServerParameters( + **cfg, + ) + + def callback(msg: str): + # 处理 MCP 服务的错误日志 + self.server_errlogs.append(msg) + + stdio_transport = await self.exit_stack.enter_async_context( + mcp.stdio_client( + server_params, + errlog=LogPipe( + level=logging.ERROR, + logger=logger, + identifier=f"MCPServer-{name}", + callback=callback, + ), # type: ignore + ), + ) + + # Create a new client session + self.session = await self.exit_stack.enter_async_context( + mcp.ClientSession(*stdio_transport) + ) + await self.session.initialize() + + async def list_tools_and_save(self) -> mcp.ListToolsResult: + """List all tools from the server and save them to self.tools""" + response = await self.session.list_tools() + self.tools = response.tools + return response + + async def cleanup(self): + """Clean up resources""" + await self.exit_stack.aclose() + self.running_event.set() # Set the running event to indicate cleanup is done diff --git a/astrbot/core/agent/response.py b/astrbot/core/agent/response.py new file mode 100644 index 000000000..3f683a233 --- /dev/null +++ b/astrbot/core/agent/response.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass +import typing as T +from astrbot.core.message.message_event_result import MessageChain + +class AgentResponseData(T.TypedDict): + chain: MessageChain + + +@dataclass +class AgentResponse: + type: str + data: AgentResponseData diff --git a/astrbot/core/agent/run_context.py b/astrbot/core/agent/run_context.py new file mode 100644 index 000000000..58ea2ca43 --- /dev/null +++ b/astrbot/core/agent/run_context.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from typing import Any, Generic +from typing_extensions import TypeVar + +from astrbot.core.platform.astr_message_event import AstrMessageEvent + +TContext = TypeVar("TContext", default=Any) + + +@dataclass +class ContextWrapper(Generic[TContext]): + """A context for running an agent, which can be used to pass additional data or state.""" + + context: TContext + event: AstrMessageEvent + +NoContext = ContextWrapper[None] diff --git a/astrbot/core/agent/runners/__init__.py b/astrbot/core/agent/runners/__init__.py new file mode 100644 index 000000000..c13589f51 --- /dev/null +++ b/astrbot/core/agent/runners/__init__.py @@ -0,0 +1,3 @@ +from .base import BaseAgentRunner + +__all__ = ["BaseAgentRunner"] diff --git a/astrbot/core/pipeline/process_stage/agent_runner/base.py b/astrbot/core/agent/runners/base.py similarity index 56% rename from astrbot/core/pipeline/process_stage/agent_runner/base.py rename to astrbot/core/agent/runners/base.py index 431a95ca6..83821ae29 100644 --- a/astrbot/core/pipeline/process_stage/agent_runner/base.py +++ b/astrbot/core/agent/runners/base.py @@ -1,32 +1,33 @@ import abc import typing as T -from dataclasses import dataclass -from astrbot.core.provider.entities import LLMResponse -from ....message.message_event_result import MessageChain from enum import Enum, auto +from ..run_context import ContextWrapper, TContext +from ..response import AgentResponse +from ..hooks import BaseAgentRunHooks +from ..tool_executor import BaseFunctionToolExecutor +from astrbot.core.provider import Provider +from astrbot.core.provider.entities import LLMResponse class AgentState(Enum): - """Agent 状态枚举""" - IDLE = auto() # 初始状态 - RUNNING = auto() # 运行中 - DONE = auto() # 完成 - ERROR = auto() # 错误状态 + """Defines the state of the agent.""" + + IDLE = auto() # Initial state + RUNNING = auto() # Currently processing + DONE = auto() # Completed + ERROR = auto() # Error state -class AgentResponseData(T.TypedDict): - chain: MessageChain - - -@dataclass -class AgentResponse: - type: str - data: AgentResponseData - - -class BaseAgentRunner: +class BaseAgentRunner(T.Generic[TContext]): @abc.abstractmethod - async def reset(self) -> None: + async def reset( + self, + provider: Provider, + run_context: ContextWrapper[TContext], + tool_executor: BaseFunctionToolExecutor[TContext], + agent_hooks: BaseAgentRunHooks[TContext], + **kwargs: T.Any, + ) -> None: """ Reset the agent to its initial state. This method should be called before starting a new run. diff --git a/astrbot/core/pipeline/process_stage/agent_runner/tool_loop_agent.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py similarity index 55% rename from astrbot/core/pipeline/process_stage/agent_runner/tool_loop_agent.py rename to astrbot/core/agent/runners/tool_loop_agent_runner.py index c2961ded5..c38285f55 100644 --- a/astrbot/core/pipeline/process_stage/agent_runner/tool_loop_agent.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -1,10 +1,12 @@ import sys import traceback import typing as T -from .base import BaseAgentRunner, AgentResponse, AgentResponseData, AgentState -from ...context import PipelineContext +from .base import BaseAgentRunner, AgentResponse, AgentState +from ..hooks import BaseAgentRunHooks +from ..tool_executor import BaseFunctionToolExecutor +from ..run_context import ContextWrapper, TContext +from ..response import AgentResponseData from astrbot.core.provider.provider import Provider -from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.message.message_event_result import ( MessageChain, ) @@ -21,8 +23,8 @@ from mcp.types import ( EmbeddedResource, TextResourceContents, BlobResourceContents, + CallToolResult, ) -from astrbot.core.star.star_handler import EventType from astrbot import logger if sys.version_info >= (3, 12): @@ -31,28 +33,25 @@ else: from typing_extensions import override -# TODO: -# 1. 处理平台不兼容的处理器 - - -class ToolLoopAgent(BaseAgentRunner): - def __init__( - self, provider: Provider, event: AstrMessageEvent, pipeline_ctx: PipelineContext - ) -> None: - self.provider = provider - self.req = None - self.event = event - self.pipeline_ctx = pipeline_ctx - self._state = AgentState.IDLE - self.final_llm_resp = None - self.streaming = False - +class ToolLoopAgentRunner(BaseAgentRunner[TContext]): @override - async def reset(self, req: ProviderRequest, streaming: bool) -> None: - self.req = req - self.streaming = streaming + async def reset( + self, + provider: Provider, + request: ProviderRequest, + run_context: ContextWrapper[TContext], + tool_executor: BaseFunctionToolExecutor[TContext], + agent_hooks: BaseAgentRunHooks[TContext], + **kwargs: T.Any, + ) -> None: + self.req = request + self.streaming = kwargs.get("streaming", False) + self.provider = provider self.final_llm_resp = None self._state = AgentState.IDLE + self.tool_executor = tool_executor + self.agent_hooks = agent_hooks + self.run_context = run_context def _transition_state(self, new_state: AgentState) -> None: """转换 Agent 状态""" @@ -78,6 +77,12 @@ class ToolLoopAgent(BaseAgentRunner): if not self.req: raise ValueError("Request is not set. Please call reset() first.") + if self._state == AgentState.IDLE: + try: + await self.agent_hooks.on_agent_begin(self.run_context) + except Exception as e: + logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True) + # 开始处理,转换到运行状态 self._transition_state(AgentState.RUNNING) llm_resp_result = None @@ -124,12 +129,10 @@ class ToolLoopAgent(BaseAgentRunner): # 如果没有工具调用,转换到完成状态 self.final_llm_resp = llm_resp self._transition_state(AgentState.DONE) - - # 执行事件钩子 - if await self.pipeline_ctx.call_event_hook( - self.event, EventType.OnLLMResponseEvent, llm_resp - ): - return + try: + await self.agent_hooks.on_agent_done(self.run_context, llm_resp) + except Exception as e: + logger.error(f"Error in on_agent_done hook: {e}", exc_info=True) # 返回 LLM 结果 if llm_resp.result_chain: @@ -193,50 +196,33 @@ class ToolLoopAgent(BaseAgentRunner): if not req.func_tool: return func_tool = req.func_tool.get_func(func_tool_name) - if func_tool.origin == "mcp": - logger.info( - f"从 MCP 服务 {func_tool.mcp_server_name} 调用工具函数:{func_tool.name},参数:{func_tool_args}" + logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}") + + try: + await self.agent_hooks.on_tool_start( + self.run_context, func_tool, func_tool_args ) - client = req.func_tool.mcp_client_dict[func_tool.mcp_server_name] - res = await client.session.call_tool(func_tool.name, func_tool_args) - if not res: - continue - if isinstance(res.content[0], TextContent): - tool_call_result_blocks.append( - ToolCallMessageSegment( - role="tool", - tool_call_id=func_tool_id, - content=res.content[0].text, - ) - ) - yield MessageChain().message(res.content[0].text) - elif isinstance(res.content[0], ImageContent): - tool_call_result_blocks.append( - ToolCallMessageSegment( - role="tool", - tool_call_id=func_tool_id, - content="返回了图片(已直接发送给用户)", - ) - ) - yield MessageChain(type="tool_direct_result").base64_image( - res.content[0].data - ) - elif isinstance(res.content[0], EmbeddedResource): - resource = res.content[0].resource - if isinstance(resource, TextResourceContents): + except Exception as e: + logger.error(f"Error in on_tool_start hook: {e}", exc_info=True) + + executor = self.tool_executor.execute( + tool=func_tool, + run_context=self.run_context, + **func_tool_args, + ) + async for resp in executor: + if isinstance(resp, CallToolResult): + res = resp + if isinstance(res.content[0], TextContent): tool_call_result_blocks.append( ToolCallMessageSegment( role="tool", tool_call_id=func_tool_id, - content=resource.text, + content=res.content[0].text, ) ) - yield MessageChain().message(resource.text) - elif ( - isinstance(resource, BlobResourceContents) - and resource.mimeType - and resource.mimeType.startswith("image/") - ): + yield MessageChain().message(res.content[0].text) + elif isinstance(res.content[0], ImageContent): tool_call_result_blocks.append( ToolCallMessageSegment( role="tool", @@ -247,43 +233,85 @@ class ToolLoopAgent(BaseAgentRunner): yield MessageChain(type="tool_direct_result").base64_image( res.content[0].data ) - else: - tool_call_result_blocks.append( - ToolCallMessageSegment( - role="tool", - tool_call_id=func_tool_id, - content="返回的数据类型不受支持", - ) - ) - yield MessageChain().message("返回的数据类型不受支持。") - else: - logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}") - # 尝试调用工具函数 - wrapper = self.pipeline_ctx.call_handler( - self.event, func_tool.handler, **func_tool_args - ) - async for resp in wrapper: - if resp is not None: - # Tool 返回结果 - tool_call_result_blocks.append( - ToolCallMessageSegment( - role="tool", - tool_call_id=func_tool_id, - content=resp, - ) - ) - yield MessageChain().message(resp) - else: - # Tool 直接请求发送消息给用户 - # 这里我们将直接结束 Agent Loop。 - self._transition_state(AgentState.DONE) - if res := self.event.get_result(): - if res.chain: - yield MessageChain( - chain=res.chain, type="tool_direct_result" + elif isinstance(res.content[0], EmbeddedResource): + resource = res.content[0].resource + if isinstance(resource, TextResourceContents): + tool_call_result_blocks.append( + ToolCallMessageSegment( + role="tool", + tool_call_id=func_tool_id, + content=resource.text, ) + ) + yield MessageChain().message(resource.text) + elif ( + isinstance(resource, BlobResourceContents) + and resource.mimeType + and resource.mimeType.startswith("image/") + ): + tool_call_result_blocks.append( + ToolCallMessageSegment( + role="tool", + tool_call_id=func_tool_id, + content="返回了图片(已直接发送给用户)", + ) + ) + yield MessageChain( + type="tool_direct_result" + ).base64_image(res.content[0].data) + else: + tool_call_result_blocks.append( + ToolCallMessageSegment( + role="tool", + tool_call_id=func_tool_id, + content="返回的数据类型不受支持", + ) + ) + yield MessageChain().message("返回的数据类型不受支持。") - self.event.clear_result() + try: + await self.agent_hooks.on_tool_end( + self.run_context, + func_tool_name, + func_tool_args, + resp, + ) + except Exception as e: + logger.error( + f"Error in on_tool_end hook: {e}", exc_info=True + ) + elif resp is None: + # Tool 直接请求发送消息给用户 + # 这里我们将直接结束 Agent Loop。 + self._transition_state(AgentState.DONE) + if res := self.run_context.event.get_result(): + if res.chain: + yield MessageChain( + chain=res.chain, type="tool_direct_result" + ) + try: + await self.agent_hooks.on_tool_end( + self.run_context, func_tool_name, func_tool_args, None + ) + except Exception as e: + logger.error( + f"Error in on_tool_end hook: {e}", exc_info=True + ) + else: + logger.warning( + f"Tool 返回了不支持的类型: {type(resp)},将忽略。" + ) + + try: + await self.agent_hooks.on_tool_end( + self.run_context, func_tool_name, func_tool_args, None + ) + except Exception as e: + logger.error( + f"Error in on_tool_end hook: {e}", exc_info=True + ) + + self.run_context.event.clear_result() except Exception as e: logger.warning(traceback.format_exc()) tool_call_result_blocks.append( diff --git a/astrbot/core/agent/tool.py b/astrbot/core/agent/tool.py new file mode 100644 index 000000000..743deae1f --- /dev/null +++ b/astrbot/core/agent/tool.py @@ -0,0 +1,256 @@ +from dataclasses import dataclass +from deprecated import deprecated +from typing import Awaitable, Literal, Any, Optional +from .mcp_client import MCPClient + + +@dataclass +class FunctionTool: + """A class representing a function tool that can be used in function calling.""" + + name: str | None = None + parameters: dict | None = None + description: str | None = None + handler: Awaitable | None = None + """处理函数, 当 origin 为 mcp 时,这个为空""" + handler_module_path: str | None = None + """处理函数的模块路径,当 origin 为 mcp 时,这个为空 + + 必须要保留这个字段, handler 在初始化会被 functools.partial 包装,导致 handler 的 __module__ 为 functools + """ + active: bool = True + """是否激活""" + + origin: Literal["local", "mcp"] = "local" + """函数工具的来源, local 为本地函数工具, mcp 为 MCP 服务""" + + # MCP 相关字段 + mcp_server_name: str | None = None + """MCP 服务名称,当 origin 为 mcp 时有效""" + mcp_client: MCPClient | None = None + """MCP 客户端,当 origin 为 mcp 时有效""" + + def __repr__(self): + return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description}, active={self.active}, origin={self.origin})" + + def __dict__(self) -> dict[str, Any]: + """将 FunctionTool 转换为字典格式""" + return { + "name": self.name, + "parameters": self.parameters, + "description": self.description, + "active": self.active, + "origin": self.origin, + "mcp_server_name": self.mcp_server_name, + } + + +class ToolSet: + """A set of function tools that can be used in function calling. + + This class provides methods to add, remove, and retrieve tools, as well as + convert the tools to different API formats (OpenAI, Anthropic, Google GenAI).""" + + def __init__(self, tools: list[FunctionTool] = None): + self.tools: list[FunctionTool] = tools or [] + + def empty(self) -> bool: + """Check if the tool set is empty.""" + return len(self.tools) == 0 + + def add_tool(self, tool: FunctionTool): + """Add a tool to the set.""" + # 检查是否已存在同名工具 + for i, existing_tool in enumerate(self.tools): + if existing_tool.name == tool.name: + self.tools[i] = tool + return + self.tools.append(tool) + + def remove_tool(self, name: str): + """Remove a tool by its name.""" + self.tools = [tool for tool in self.tools if tool.name != name] + + def get_tool(self, name: str) -> Optional[FunctionTool]: + """Get a tool by its name.""" + for tool in self.tools: + if tool.name == name: + return tool + return None + + @deprecated(reason="Use add_tool() instead", version="4.0.0") + def add_func(self, name: str, func_args: list, desc: str, handler: Awaitable): + """Add a function tool to the set.""" + params = { + "type": "object", # hard-coded here + "properties": {}, + } + for param in func_args: + params["properties"][param["name"]] = { + "type": param["type"], + "description": param["description"], + } + _func = FunctionTool( + name=name, + parameters=params, + description=desc, + handler=handler, + ) + self.add_tool(_func) + + @deprecated(reason="Use remove_tool() instead", version="4.0.0") + def remove_func(self, name: str): + """Remove a function tool by its name.""" + self.remove_tool(name) + + @deprecated(reason="Use get_tool() instead", version="4.0.0") + def get_func(self, name: str) -> list[FunctionTool]: + """Get all function tools.""" + return self.get_tool(name) + + @property + def func_list(self) -> list[FunctionTool]: + """Get the list of function tools.""" + return self.tools + + def openai_schema(self, omit_empty_parameter_field: bool = False) -> list[dict]: + """Convert tools to OpenAI API function calling schema format.""" + result = [] + for tool in self.tools: + func_def = { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + }, + } + + if tool.parameters.get("properties") or not omit_empty_parameter_field: + func_def["function"]["parameters"] = tool.parameters + + result.append(func_def) + return result + + def anthropic_schema(self) -> list[dict]: + """Convert tools to Anthropic API format.""" + result = [] + for tool in self.tools: + tool_def = { + "name": tool.name, + "description": tool.description, + "input_schema": { + "type": "object", + "properties": tool.parameters.get("properties", {}), + "required": tool.parameters.get("required", []), + }, + } + result.append(tool_def) + return result + + def google_schema(self) -> dict: + """Convert tools to Google GenAI API format.""" + + def convert_schema(schema: dict) -> dict: + """Convert schema to Gemini API format.""" + supported_types = { + "string", + "number", + "integer", + "boolean", + "array", + "object", + "null", + } + supported_formats = { + "string": {"enum", "date-time"}, + "integer": {"int32", "int64"}, + "number": {"float", "double"}, + } + + if "anyOf" in schema: + return {"anyOf": [convert_schema(s) for s in schema["anyOf"]]} + + result = {} + + if "type" in schema and schema["type"] in supported_types: + result["type"] = schema["type"] + if "format" in schema and schema["format"] in supported_formats.get( + result["type"], set() + ): + result["format"] = schema["format"] + else: + result["type"] = "null" + + support_fields = { + "title", + "description", + "enum", + "minimum", + "maximum", + "maxItems", + "minItems", + "nullable", + "required", + } + result.update({k: schema[k] for k in support_fields if k in schema}) + + if "properties" in schema: + properties = {} + for key, value in schema["properties"].items(): + prop_value = convert_schema(value) + if "default" in prop_value: + del prop_value["default"] + properties[key] = prop_value + + if properties: + result["properties"] = properties + + if "items" in schema: + result["items"] = convert_schema(schema["items"]) + + return result + + tools = [ + { + "name": tool.name, + "description": tool.description, + "parameters": convert_schema(tool.parameters), + } + for tool in self.tools + ] + + declarations = {} + if tools: + declarations["function_declarations"] = tools + return declarations + + @deprecated(reason="Use openai_schema() instead", version="4.0.0") + def get_func_desc_openai_style(self, omit_empty_parameter_field: bool = False): + return self.openai_schema(omit_empty_parameter_field) + + @deprecated(reason="Use anthropic_schema() instead", version="4.0.0") + def get_func_desc_anthropic_style(self): + return self.anthropic_schema() + + @deprecated(reason="Use google_schema() instead", version="4.0.0") + def get_func_desc_google_genai_style(self): + return self.google_schema() + + def names(self) -> list[str]: + """获取所有工具的名称列表""" + return [tool.name for tool in self.tools] + + def __len__(self): + return len(self.tools) + + def __bool__(self): + return len(self.tools) > 0 + + def __iter__(self): + return iter(self.tools) + + def __repr__(self): + return f"ToolSet(tools={self.tools})" + + def __str__(self): + return f"ToolSet(tools={self.tools})" diff --git a/astrbot/core/agent/tool_executor.py b/astrbot/core/agent/tool_executor.py new file mode 100644 index 000000000..34a2f5e77 --- /dev/null +++ b/astrbot/core/agent/tool_executor.py @@ -0,0 +1,11 @@ +import mcp +from typing import Any, Generic, AsyncGenerator +from .run_context import TContext, ContextWrapper +from .tool import FunctionTool + + +class BaseFunctionToolExecutor(Generic[TContext]): + @classmethod + async def execute( + cls, tool: FunctionTool, run_context: ContextWrapper[TContext], **tool_args + ) -> AsyncGenerator[Any | mcp.types.CallToolResult, None]: ... diff --git a/astrbot/core/astr_agent_context.py b/astrbot/core/astr_agent_context.py new file mode 100644 index 000000000..b09d03b3c --- /dev/null +++ b/astrbot/core/astr_agent_context.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from astrbot.core.provider import Provider +from astrbot.core.provider.entities import ProviderRequest + + +@dataclass +class AstrAgentContext: + provider: Provider + first_provider_request: ProviderRequest + curr_provider_request: ProviderRequest + streaming: bool diff --git a/astrbot/core/astrbot_config_mgr.py b/astrbot/core/astrbot_config_mgr.py new file mode 100644 index 000000000..51ea8fcd4 --- /dev/null +++ b/astrbot/core/astrbot_config_mgr.py @@ -0,0 +1,276 @@ +import os +import uuid +from astrbot.core import AstrBotConfig, logger +from astrbot.core.utils.shared_preferences import SharedPreferences +from astrbot.core.config.astrbot_config import ASTRBOT_CONFIG_PATH +from astrbot.core.config.default import DEFAULT_CONFIG +from astrbot.core.platform.message_session import MessageSession +from astrbot.core.utils.astrbot_path import get_astrbot_config_path +from typing import TypeVar, TypedDict + +_VT = TypeVar("_VT") + + +class ConfInfo(TypedDict): + """Configuration information for a specific session or platform.""" + + id: str # UUID of the configuration or "default" + umop: list[str] # Unified Message Origin Pattern + name: str + path: str # File name to the configuration file + + +DEFAULT_CONFIG_CONF_INFO = ConfInfo( + id="default", + umop=["::"], + name="default", + path=ASTRBOT_CONFIG_PATH, +) + + +class AstrBotConfigManager: + """A class to manage the system configuration of AstrBot, aka ACM""" + + def __init__(self, default_config: AstrBotConfig, sp: SharedPreferences): + self.sp = sp + self.confs: dict[str, AstrBotConfig] = {} + """uuid / "default" -> AstrBotConfig""" + self.confs["default"] = default_config + self._load_all_configs() + + def _load_all_configs(self): + """Load all configurations from the shared preferences.""" + abconf_data = self.sp.get( + "abconf_mapping", {}, scope="global", scope_id="global" + ) + for uuid_, meta in abconf_data.items(): + filename = meta["path"] + conf_path = os.path.join(get_astrbot_config_path(), filename) + if os.path.exists(conf_path): + conf = AstrBotConfig(config_path=conf_path) + self.confs[uuid_] = conf + else: + logger.warning( + f"Config file {conf_path} for UUID {uuid_} does not exist, skipping." + ) + continue + + def _is_umo_match(self, p1: str, p2: str) -> bool: + """判断 p2 umo 是否逻辑包含于 p1 umo""" + p1_ls = p1.split(":") + p2_ls = p2.split(":") + + if len(p1_ls) != 3 or len(p2_ls) != 3: + return False # 非法格式 + + return all(p == "" or p == "*" or p == t for p, t in zip(p1_ls, p2_ls)) + + def _load_conf_mapping(self, umo: str | MessageSession) -> ConfInfo: + """获取指定 umo 的配置文件 uuid, 如果不存在则返回默认配置(返回 "default") + + Returns: + ConfInfo: 包含配置文件的 uuid, 路径和名称等信息, 是一个 dict 类型 + """ + # uuid -> { "umop": list, "path": str, "name": str } + abconf_data = self.sp.get( + "abconf_mapping", {}, scope="global", scope_id="global" + ) + if isinstance(umo, MessageSession): + umo = str(umo) + else: + try: + umo = str(MessageSession.from_str(umo)) # validate + except Exception: + return DEFAULT_CONFIG_CONF_INFO + + for uuid_, meta in abconf_data.items(): + for pattern in meta["umop"]: + if self._is_umo_match(pattern, umo): + return ConfInfo(**meta, id=uuid_) + + return DEFAULT_CONFIG_CONF_INFO + + def _save_conf_mapping( + self, + abconf_path: str, + abconf_id: str, + umo_parts: list[str] | list[MessageSession], + abconf_name: str | None = None, + ) -> None: + """保存配置文件的映射关系""" + for part in umo_parts: + if isinstance(part, MessageSession): + part = str(part) + elif not isinstance(part, str): + raise ValueError( + "umo_parts must be a list of strings or MessageSession instances" + ) + abconf_data = self.sp.get( + "abconf_mapping", {}, scope="global", scope_id="global" + ) + random_word = abconf_name or uuid.uuid4().hex[:8] + abconf_data[abconf_id] = { + "umop": umo_parts, + "path": abconf_path, + "name": random_word, + } + self.sp.put("abconf_mapping", abconf_data, scope="global", scope_id="global") + + def get_conf(self, umo: str | MessageSession | None) -> AstrBotConfig: + """获取指定 umo 的配置文件。如果不存在,则 fallback 到默认配置文件。""" + if not umo: + return self.confs["default"] + if isinstance(umo, MessageSession): + umo = f"{umo.platform_id}:{umo.message_type}:{umo.session_id}" + + uuid_ = self._load_conf_mapping(umo)["id"] + + conf = self.confs.get(uuid_) + if not conf: + conf = self.confs["default"] # default MUST exists + + return conf + + @property + def default_conf(self) -> AstrBotConfig: + """获取默认配置文件""" + return self.confs["default"] + + def get_conf_info(self, umo: str | MessageSession) -> ConfInfo: + """获取指定 umo 的配置文件元数据""" + if isinstance(umo, MessageSession): + umo = f"{umo.platform_id}:{umo.message_type}:{umo.session_id}" + + return self._load_conf_mapping(umo) + + def get_conf_list(self) -> list[ConfInfo]: + """获取所有配置文件的元数据列表""" + conf_list = [] + conf_list.append(DEFAULT_CONFIG_CONF_INFO) + abconf_mapping = self.sp.get( + "abconf_mapping", {}, scope="global", scope_id="global" + ) + for uuid_, meta in abconf_mapping.items(): + conf_list.append(ConfInfo(**meta, id=uuid_)) + return conf_list + + def create_conf( + self, + umo_parts: list[str] | list[MessageSession], + config: dict = DEFAULT_CONFIG, + name: str | None = None, + ) -> str: + """ + umo 由三个部分组成 [platform_id]:[message_type]:[session_id]。 + + umo_parts 可以是 "::" (代表所有), 可以是 "[platform_id]::" (代表指定平台下的所有类型消息和会话)。 + """ + conf_uuid = str(uuid.uuid4()) + conf_file_name = f"abconf_{conf_uuid}.json" + conf_path = os.path.join(get_astrbot_config_path(), conf_file_name) + conf = AstrBotConfig(config_path=conf_path, default_config=config) + conf.save_config() + self._save_conf_mapping(conf_file_name, conf_uuid, umo_parts, abconf_name=name) + self.confs[conf_uuid] = conf + return conf_uuid + + def delete_conf(self, conf_id: str) -> bool: + """删除指定配置文件 + + Args: + conf_id: 配置文件的 UUID + + Returns: + bool: 删除是否成功 + + Raises: + ValueError: 如果试图删除默认配置文件 + """ + if conf_id == "default": + raise ValueError("不能删除默认配置文件") + + # 从映射中移除 + abconf_data = self.sp.get( + "abconf_mapping", {}, scope="global", scope_id="global" + ) + if conf_id not in abconf_data: + logger.warning(f"配置文件 {conf_id} 不存在于映射中") + return False + + # 获取配置文件路径 + conf_path = os.path.join( + get_astrbot_config_path(), abconf_data[conf_id]["path"] + ) + + # 删除配置文件 + try: + if os.path.exists(conf_path): + os.remove(conf_path) + logger.info(f"已删除配置文件: {conf_path}") + except Exception as e: + logger.error(f"删除配置文件 {conf_path} 失败: {e}") + return False + + # 从内存中移除 + if conf_id in self.confs: + del self.confs[conf_id] + + # 从映射中移除 + del abconf_data[conf_id] + self.sp.put("abconf_mapping", abconf_data, scope="global", scope_id="global") + + logger.info(f"成功删除配置文件 {conf_id}") + return True + + def update_conf_info( + self, conf_id: str, name: str | None = None, umo_parts: list[str] | None = None + ) -> bool: + """更新配置文件信息 + + Args: + conf_id: 配置文件的 UUID + name: 新的配置文件名称 (可选) + umo_parts: 新的 UMO 部分列表 (可选) + + Returns: + bool: 更新是否成功 + """ + if conf_id == "default": + raise ValueError("不能更新默认配置文件的信息") + + abconf_data = self.sp.get( + "abconf_mapping", {}, scope="global", scope_id="global" + ) + if conf_id not in abconf_data: + logger.warning(f"配置文件 {conf_id} 不存在于映射中") + return False + + # 更新名称 + if name is not None: + abconf_data[conf_id]["name"] = name + + # 更新 UMO 部分 + if umo_parts is not None: + # 验证 UMO 部分格式 + for part in umo_parts: + if isinstance(part, MessageSession): + part = str(part) + elif not isinstance(part, str): + raise ValueError( + "umo_parts must be a list of strings or MessageSession instances" + ) + abconf_data[conf_id]["umop"] = umo_parts + + # 保存更新 + self.sp.put("abconf_mapping", abconf_data, scope="global", scope_id="global") + logger.info(f"成功更新配置文件 {conf_id} 的信息") + return True + + def g( + self, umo: str | None = None, key: str | None = None, default: _VT = None + ) -> _VT: + """获取配置项。umo 为 None 时使用默认配置""" + if umo is None: + return self.confs["default"].get(key, default) + conf = self.get_conf(umo) + return conf.get(key, default) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 637d30bcb..f9663b7a0 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -6,14 +6,13 @@ import os from astrbot.core.utils.astrbot_path import get_astrbot_data_path -VERSION = "3.5.26" -DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db") +VERSION = "4.0.0-beta.1" +DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") # 默认配置 DEFAULT_CONFIG = { "config_version": 2, "platform_settings": { - "plugin_enable": {}, "unique_session": False, "rate_limit": { "time": 60, @@ -51,21 +50,26 @@ DEFAULT_CONFIG = { "provider_settings": { "enable": True, "default_provider_id": "", + "default_image_caption_provider_id": "", + "image_caption_prompt": "Please describe the image using Chinese.", + "provider_pool": ["*"], # "*" 表示使用所有可用的提供者 "wake_prefix": "", "web_search": False, + "websearch_provider": "default", + "websearch_tavily_key": "", "web_search_link": False, "display_reasoning_text": False, "identifier": False, "datetime_system_prompt": True, "default_personality": "default", + "persona_pool": ["*"], "prompt_prefix": "", "max_context_length": -1, "dequeue_context_length": 1, "streaming_response": False, "show_tool_use_status": False, "streaming_segmented": False, - "separate_provider": True, - "max_agent_step": 30 + "max_agent_step": 30, }, "provider_stt_settings": { "enable": False, @@ -81,13 +85,10 @@ DEFAULT_CONFIG = { "group_icl_enable": False, "group_message_max_cnt": 300, "image_caption": False, - "image_caption_provider_id": "", - "image_caption_prompt": "Please describe the image using Chinese.", "active_reply": { "enable": False, "method": "possibility_reply", "possibility_reply": 0.1, - "prompt": "", "whitelist": [], }, }, @@ -117,17 +118,17 @@ DEFAULT_CONFIG = { "log_level": "INFO", "pip_install_arg": "", "pypi_index_url": "https://mirrors.aliyun.com/pypi/simple/", - "knowledge_db": {}, - "persona": [], - "timezone": "", + "persona": [], # deprecated + "timezone": "Asia/Shanghai", "callback_api_base": "", + "default_kb_collection": "", # 默认知识库名称 + "plugin_set": ["*"], # "*" 表示使用所有可用的插件, 空列表表示不使用任何插件 } # 配置项的中文描述、值类型 CONFIG_METADATA_2 = { "platform_group": { - "name": "消息平台", "metadata": { "platform": { "description": "消息平台适配器", @@ -385,152 +386,117 @@ CONFIG_METADATA_2 = { }, }, "platform_settings": { - "description": "平台设置", "type": "object", "items": { - "plugin_enable": { - "invisible": True, # 隐藏插件启用配置 - }, "unique_session": { - "description": "会话隔离", "type": "bool", - "hint": "启用后,在群组或者频道中,每个人的消息上下文都是独立的。", }, "rate_limit": { - "description": "速率限制", - "hint": "每个会话在 `time` 秒内最多只能发送 `count` 条消息。", "type": "object", "items": { - "time": {"description": "消息速率限制时间", "type": "int"}, - "count": {"description": "消息速率限制计数", "type": "int"}, + "time": {"type": "int"}, + "count": {"type": "int"}, "strategy": { - "description": "速率限制策略", "type": "string", "options": ["stall", "discard"], - "hint": "当消息速率超过限制时的处理策略。stall 为等待,discard 为丢弃。", }, }, }, "no_permission_reply": { - "description": "无权限回复", "type": "bool", "hint": "启用后,当用户没有权限执行某个操作时,机器人会回复一条消息。", }, "empty_mention_waiting": { - "description": "只 @ 机器人是否触发等待", "type": "bool", "hint": "启用后,当消息内容只有 @ 机器人时,会触发等待,在 60 秒内的该用户的任意一条消息均会唤醒机器人。这在某些平台不支持 @ 和语音/图片等消息同时发送时特别有用。", }, "empty_mention_waiting_need_reply": { - "description": "只 @ 机器人触发等待时是否需要回复提醒", "type": "bool", "hint": "在上面一个配置项中,如果启用了触发等待,启用此项后,机器人会使用 LLM 生成一条回复。否则,将不回复而只是等待。", }, "friend_message_needs_wake_prefix": { - "description": "私聊消息是否需要唤醒前缀", "type": "bool", "hint": "启用后,私聊消息需要唤醒前缀才会被处理,同群聊一样。", }, "ignore_bot_self_message": { - "description": "是否忽略机器人自身的消息", "type": "bool", "hint": "某些平台会将自身账号在其他 APP 端发送的消息也当做消息事件下发导致给自己发消息时唤醒机器人", }, "ignore_at_all": { - "description": "是否忽略 @ 全体成员", "type": "bool", "hint": "启用后,机器人会忽略 @ 全体成员 的消息事件。", }, "segmented_reply": { - "description": "分段回复", "type": "object", "items": { "enable": { - "description": "启用分段回复", "type": "bool", }, "only_llm_result": { - "description": "仅对 LLM 结果分段", "type": "bool", }, "interval_method": { - "description": "间隔时间计算方法", "type": "string", "options": ["random", "log"], "hint": "分段回复的间隔时间计算方法。random 为随机时间,log 为根据消息长度计算,$y=log_(x)$,x为字数,y的单位为秒。", }, "interval": { - "description": "随机间隔时间(秒)", "type": "string", "hint": "`random` 方法用。每一段回复的间隔时间,格式为 `最小时间,最大时间`。如 `0.75,2.5`", }, "log_base": { - "description": "对数函数底数", "type": "float", "hint": "`log` 方法用。对数函数的底数。默认为 2.6", }, "words_count_threshold": { - "description": "字数阈值", "type": "int", "hint": "超过这个字数的消息不会被分段回复。默认为 150", }, "regex": { - "description": "正则表达式", "type": "string", "hint": "用于分隔一段消息。默认情况下会根据句号、问号等标点符号分隔。re.findall(r'', text)", }, "content_cleanup_rule": { - "description": "过滤分段后的内容", "type": "string", "hint": "移除分段后的内容中的指定的内容。支持正则表达式。如填写 `[。?!]` 将移除所有的句号、问号、感叹号。re.sub(r'', '', text)", }, }, }, "reply_prefix": { - "description": "回复前缀", "type": "string", "hint": "机器人回复消息时带有的前缀。", }, "forward_threshold": { - "description": "转发消息的字数阈值", "type": "int", "hint": "超过一定字数后,机器人会将消息折叠成 QQ 群聊的 “转发消息”,以防止刷屏。目前仅 QQ 平台适配器适用。", }, "enable_id_white_list": { - "description": "启用 ID 白名单", "type": "bool", }, "id_whitelist": { - "description": "ID 白名单", "type": "list", "items": {"type": "string"}, "hint": "只处理填写的 ID 发来的消息事件,为空时不启用。可使用 /sid 指令获取在平台上的会话 ID(类似 abc:GroupMessage:123)。管理员可使用 /wl 添加白名单", }, "id_whitelist_log": { - "description": "打印白名单日志", "type": "bool", "hint": "启用后,当一条消息没通过白名单时,会输出 INFO 级别的日志。", }, "wl_ignore_admin_on_group": { - "description": "管理员群组消息无视 ID 白名单", "type": "bool", }, "wl_ignore_admin_on_friend": { - "description": "管理员私聊消息无视 ID 白名单", "type": "bool", }, "reply_with_mention": { - "description": "回复时 @ 发送者", "type": "bool", "hint": "启用后,机器人回复消息时会 @ 发送者。实际效果以具体的平台适配器为准。", }, "reply_with_quote": { - "description": "回复时引用消息", "type": "bool", "hint": "启用后,机器人回复消息时会引用原消息。实际效果以具体的平台适配器为准。", }, "path_mapping": { - "description": "路径映射", "type": "list", "items": {"type": "string"}, "hint": "此功能解决由于文件系统不一致导致路径不存在的问题。格式为 <原路径>:<映射路径>。如 `/app/.config/QQ:/var/lib/docker/volumes/xxxx/_data`。这样,当消息平台下发的事件中图片和语音路径以 `/app/.config/QQ` 开头时,开头被替换为 `/var/lib/docker/volumes/xxxx/_data`。这在 AstrBot 或者平台协议端使用 Docker 部署时特别有用。", @@ -538,41 +504,33 @@ CONFIG_METADATA_2 = { }, }, "content_safety": { - "description": "内容安全", "type": "object", "items": { "also_use_in_response": { - "description": "对大模型响应安全审核", "type": "bool", "hint": "启用后,大模型的响应也会通过内容安全审核。", }, "baidu_aip": { - "description": "百度内容审核配置", "type": "object", "items": { "enable": { - "description": "启用百度内容审核", "type": "bool", "hint": "启用此功能前,您需要手动在设备中安装 baidu-aip 库。一般来说,安装指令如下: `pip3 install baidu-aip`", }, "app_id": {"description": "APP ID", "type": "string"}, "api_key": {"description": "API Key", "type": "string"}, "secret_key": { - "description": "Secret Key", "type": "string", }, }, }, "internal_keywords": { - "description": "内部关键词过滤", "type": "object", "items": { "enable": { - "description": "启用内部关键词过滤", "type": "bool", }, "extra_keywords": { - "description": "额外关键词", "type": "list", "items": {"type": "string"}, "hint": "额外的屏蔽关键词列表,支持正则表达式。", @@ -587,7 +545,6 @@ CONFIG_METADATA_2 = { "name": "服务提供商", "metadata": { "provider": { - "description": "服务提供商配置", "type": "list", "config_template": { "OpenAI": { @@ -599,11 +556,9 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "https://api.openai.com/v1", "timeout": 120, - "model_config": { - "model": "gpt-4o-mini", - "temperature": 0.4 - }, - "hint": "也兼容所有与OpenAI API兼容的服务。" + "model_config": {"model": "gpt-4o-mini", "temperature": 0.4}, + "modalities": ["text", "image", "tool_use"], + "hint": "也兼容所有与 OpenAI API 兼容的服务。", }, "Azure OpenAI": { "id": "azure", @@ -615,10 +570,8 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "", "timeout": 120, - "model_config": { - "model": "gpt-4o-mini", - "temperature": 0.4 - }, + "model_config": {"model": "gpt-4o-mini", "temperature": 0.4}, + "modalities": ["text", "image", "tool_use"], }, "xAI": { "id": "xai", @@ -629,10 +582,8 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "https://api.x.ai/v1", "timeout": 120, - "model_config": { - "model": "grok-2-latest", - "temperature": 0.4 - }, + "model_config": {"model": "grok-2-latest", "temperature": 0.4}, + "modalities": ["text", "image", "tool_use"], }, "Anthropic": { "hint": "注意Claude系列模型的温度调节范围为0到1.0,超出可能导致报错", @@ -647,11 +598,12 @@ CONFIG_METADATA_2 = { "model_config": { "model": "claude-3-5-sonnet-latest", "max_tokens": 4096, - "temperature": 0.2 + "temperature": 0.2, }, + "modalities": ["text", "image", "tool_use"], }, "Ollama": { - "hint":"启用前请确保已正确安装并运行 Ollama 服务端,Ollama默认不带鉴权,无需修改key", + "hint": "启用前请确保已正确安装并运行 Ollama 服务端,Ollama默认不带鉴权,无需修改key", "id": "ollama_default", "provider": "ollama", "type": "openai_chat_completion", @@ -659,10 +611,8 @@ CONFIG_METADATA_2 = { "enable": True, "key": ["ollama"], # ollama 的 key 默认是 ollama "api_base": "http://localhost:11434/v1", - "model_config": { - "model": "llama3.1-8b", - "temperature": 0.4 - }, + "model_config": {"model": "llama3.1-8b", "temperature": 0.4}, + "modalities": ["text", "image", "tool_use"], }, "LM Studio": { "id": "lm_studio", @@ -675,6 +625,7 @@ CONFIG_METADATA_2 = { "model_config": { "model": "llama-3.1-8b", }, + "modalities": ["text", "image", "tool_use"], }, "Gemini(OpenAI兼容)": { "id": "gemini_default", @@ -687,8 +638,9 @@ CONFIG_METADATA_2 = { "timeout": 120, "model_config": { "model": "gemini-1.5-flash", - "temperature": 0.4 + "temperature": 0.4, }, + "modalities": ["text", "image", "tool_use"], }, "Gemini": { "id": "gemini_default", @@ -701,7 +653,7 @@ CONFIG_METADATA_2 = { "timeout": 120, "model_config": { "model": "gemini-2.0-flash-exp", - "temperature": 0.4 + "temperature": 0.4, }, "gm_resp_image_modal": False, "gm_native_search": False, @@ -716,6 +668,7 @@ CONFIG_METADATA_2 = { "gm_thinking_config": { "budget": 0, }, + "modalities": ["text", "image", "tool_use"], }, "DeepSeek": { "id": "deepseek_default", @@ -726,10 +679,8 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "https://api.deepseek.com/v1", "timeout": 120, - "model_config": { - "model": "deepseek-chat", - "temperature": 0.4 - }, + "model_config": {"model": "deepseek-chat", "temperature": 0.4}, + "modalities": ["text", "image", "tool_use"], }, "302.AI": { "id": "302ai", @@ -740,10 +691,8 @@ CONFIG_METADATA_2 = { "key": [], "api_base": "https://api.302.ai/v1", "timeout": 120, - "model_config": { - "model": "gpt-4.1-mini", - "temperature": 0.4 - }, + "model_config": {"model": "gpt-4.1-mini", "temperature": 0.4}, + "modalities": ["text", "image", "tool_use"], }, "硅基流动": { "id": "siliconflow", @@ -756,8 +705,9 @@ CONFIG_METADATA_2 = { "api_base": "https://api.siliconflow.cn/v1", "model_config": { "model": "deepseek-ai/DeepSeek-V3", - "temperature": 0.4 + "temperature": 0.4, }, + "modalities": ["text", "image", "tool_use"], }, "PPIO派欧云": { "id": "ppio", @@ -770,7 +720,7 @@ CONFIG_METADATA_2 = { "timeout": 120, "model_config": { "model": "deepseek/deepseek-r1", - "temperature": 0.4 + "temperature": 0.4, }, }, "优云智算": { @@ -785,6 +735,7 @@ CONFIG_METADATA_2 = { "model_config": { "model": "moonshotai/Kimi-K2-Instruct", }, + "modalities": ["text", "image", "tool_use"], }, "Kimi": { "id": "moonshot", @@ -795,10 +746,8 @@ CONFIG_METADATA_2 = { "key": [], "timeout": 120, "api_base": "https://api.moonshot.cn/v1", - "model_config": { - "model": "moonshot-v1-8k", - "temperature": 0.4 - }, + "model_config": {"model": "moonshot-v1-8k", "temperature": 0.4}, + "modalities": ["text", "image", "tool_use"], }, "智谱 AI": { "id": "zhipu_default", @@ -812,6 +761,7 @@ CONFIG_METADATA_2 = { "model_config": { "model": "glm-4-flash", }, + "modalities": ["text", "image", "tool_use"], }, "Dify": { "id": "dify_app_default", @@ -826,7 +776,7 @@ CONFIG_METADATA_2 = { "dify_query_input_key": "astrbot_text_query", "variables": {}, "timeout": 60, - "hint": "请确保你在 AstrBot 里设置的 APP 类型和 Dify 里面创建的应用的类型一致!" + "hint": "请确保你在 AstrBot 里设置的 APP 类型和 Dify 里面创建的应用的类型一致!", }, "阿里云百炼应用": { "id": "dashscope", @@ -854,10 +804,8 @@ CONFIG_METADATA_2 = { "key": [], "timeout": 120, "api_base": "https://api-inference.modelscope.cn/v1", - "model_config": { - "model": "Qwen/Qwen3-32B", - "temperature": 0.4 - }, + "model_config": {"model": "Qwen/Qwen3-32B", "temperature": 0.4}, + "modalities": ["text", "image", "tool_use"], }, "FastGPT": { "id": "fastgpt", @@ -1073,8 +1021,42 @@ CONFIG_METADATA_2 = { "embedding_dimensions": 768, "timeout": 20, }, + "vLLM Rerank": { + "id": "vllm_rerank", + "type": "vllm_rerank", + "provider": "vllm", + "provider_type": "rerank", + "enable": True, + "rerank_api_key": "", + "rerank_api_base": "http://127.0.0.1:8000", + "rerank_model": "BAAI/bge-reranker-base", + "timeout": 20, + }, }, "items": { + "rerank_api_base": { + "description": "重排序模型 API Base URL", + "type": "string", + "hint": "AstrBot 会在请求时在末尾加上 /v1/rerank。", + }, + "rerank_api_key": { + "description": "API Key", + "type": "string", + "hint": "如果不需要 API Key, 请留空。", + }, + "rerank_model": { + "description": "重排序模型名称", + "type": "string", + }, + "modalities": { + "description": "模型能力", + "type": "list", + "items": {"type": "string"}, + "options": ["text", "image", "tool_use"], + "labels": ["文本", "图像", "工具使用"], + "render_type": "checkbox", + "hint": "模型支持的模态。如所填写的模型不支持图像,请取消勾选图像。", + }, "provider": { "type": "string", "invisible": True, @@ -1654,88 +1636,52 @@ CONFIG_METADATA_2 = { }, }, "provider_settings": { - "description": "大语言模型设置", "type": "object", "items": { "enable": { - "description": "启用大语言模型聊天", "type": "bool", - "hint": "如需切换大语言模型提供商,请使用 /provider 命令。", - }, - "separate_provider": { - "description": "提供商会话隔离", - "type": "bool", - "hint": "启用后,每个会话支持独立选择文本生成、STT、TTS 等提供商。如果会话在使用 /provider 指令时提示无权限,可以将会话加入管理员名单或者使用 /alter_cmd provider member 将指令设为非管理员指令。", }, "default_provider_id": { - "description": "默认模型提供商 ID", "type": "string", - "hint": "可选。每个聊天会话的默认提供商 ID。", }, "wake_prefix": { - "description": "LLM 聊天额外唤醒前缀", "type": "string", - "hint": "使用 LLM 聊天额外的触发条件。如填写 `chat`,则需要消息前缀加上 `/chat` 才能触发 LLM 聊天,是一个防止滥用的手段。", }, "web_search": { - "description": "启用网页搜索", "type": "bool", - "hint": "能访问 Google 时效果最佳(国内需要在 `其他配置` 开启 HTTP 代理)。如果 Google 访问失败,程序会依次访问 Bing, Sogo 搜索引擎。", }, "web_search_link": { - "description": "网页搜索引用链接", "type": "bool", - "hint": "开启后,将会传入网页搜索结果的链接给模型,并引导模型输出引用链接。", }, "display_reasoning_text": { - "description": "显示思考内容", "type": "bool", - "hint": "开启后,将在回复中显示模型的思考过程。", }, "identifier": { - "description": "启动识别群员", "type": "bool", - "hint": "在 Prompt 前加上群成员的名字以让模型更好地了解群聊状态。启用将略微增加 token 开销。", }, "datetime_system_prompt": { - "description": "启用日期时间系统提示", "type": "bool", - "hint": "启用后,会在系统提示词中加上当前机器的日期时间。", }, "default_personality": { - "description": "默认采用的人格情景的名称", "type": "string", - "hint": "", }, "prompt_prefix": { - "description": "Prompt 前缀文本", "type": "string", - "hint": "添加之后,会在每次对话的 Prompt 前加上此文本。", }, "max_context_length": { - "description": "最多携带对话数量(条)", "type": "int", - "hint": "超出这个数量时将丢弃最旧的部分,用户和AI的一轮聊天记为 1 条。-1 表示不限制,默认为不限制。", }, "dequeue_context_length": { - "description": "丢弃对话数量(条)", "type": "int", - "hint": "超出 最多携带对话数量(条) 时,丢弃多少条记录,用户和AI的一轮聊天记为 1 条。适宜的配置,可以提高超长上下文对话 deepseek 命中缓存效果,理想情况下计费将降低到1/3以下", }, "streaming_response": { - "description": "启用流式回复", "type": "bool", - "hint": "启用后,将会流式输出 LLM 的响应。目前仅支持 OpenAI API提供商 以及 Telegram、QQ Official 私聊 两个平台", }, "show_tool_use_status": { - "description": "函数调用状态输出", "type": "bool", - "hint": "在触发函数调用时输出其函数名和内容。", }, "streaming_segmented": { - "description": "不支持流式回复的平台分段输出", "type": "bool", - "hint": "启用后,若平台不支持流式回复,会分段输出。目前仅支持 aiocqhttp 两个平台,不支持或无需使用流式分段输出的平台会静默忽略此选项", }, "max_agent_step": { "description": "工具调用轮数上限", @@ -1743,143 +1689,65 @@ CONFIG_METADATA_2 = { }, }, }, - "persona": { - "description": "人格情景设置", - "type": "list", - "config_template": { - "新人格情景": { - "name": "", - "prompt": "", - "begin_dialogs": [], - "mood_imitation_dialogs": [], - } - }, - "tmpl_display_title": "name", - "items": { - "name": { - "description": "人格名称", - "type": "string", - "hint": "人格名称,用于在多个人格中区分。使用 /persona 指令可切换人格。在 大语言模型设置 处可以设置默认人格。", - }, - "prompt": { - "description": "设定(系统提示词)", - "type": "text", - "hint": "填写人格的身份背景、性格特征、兴趣爱好、个人经历、口头禅等。", - }, - "begin_dialogs": { - "description": "预设对话", - "type": "list", - "items": {"type": "string"}, - "hint": "可选。在每个对话前会插入这些预设对话。对话需要成对(用户和助手),输入完一个角色的内容之后按【回车】。需要偶数个对话", - }, - "mood_imitation_dialogs": { - "description": "对话风格模仿", - "type": "list", - "items": {"type": "string"}, - "hint": "旨在让模型尽可能模仿学习到所填写的对话的语气风格。格式和 `预设对话` 一致。对话需要成对(用户和助手),输入完一个角色的内容之后按【回车】。需要偶数个对话", - }, - }, - }, "provider_stt_settings": { - "description": "语音转文本(STT)", "type": "object", "items": { "enable": { - "description": "启用语音转文本(STT)", "type": "bool", - "hint": "启用前请在 服务提供商配置 处创建支持 语音转文本任务 的提供商。如 whisper。", }, "provider_id": { - "description": "提供商 ID", "type": "string", - "hint": "语音转文本提供商 ID。如果不填写将使用载入的第一个提供商。", }, }, }, "provider_tts_settings": { - "description": "文本转语音(TTS)", "type": "object", "items": { "enable": { - "description": "启用文本转语音(TTS)", "type": "bool", - "hint": "启用前请在 服务提供商配置 处创建支持 语音转文本任务 的提供商。如 openai_tts。", }, "provider_id": { - "description": "提供商 ID", "type": "string", - "hint": "文本转语音提供商 ID。如果不填写将使用载入的第一个提供商。", }, "dual_output": { - "description": "启用语音和文字双输出", "type": "bool", - "hint": "启用后,Bot 将同时输出语音和文字消息。", }, "use_file_service": { - "description": "使用文件服务提供 TTS 语音文件", "type": "bool", - "hint": "启用后,如已配置 callback_api_base ,将会使用文件服务提供TTS语音文件", }, }, }, "provider_ltm_settings": { - "description": "聊天记忆增强(Beta)", "type": "object", "items": { "group_icl_enable": { - "description": "群聊内记录各群员对话", "type": "bool", - "hint": "启用后,会记录群聊内各群员的对话。使用 /reset 命令清除记录。推荐使用 gpt-4o-mini 模型。", }, "group_message_max_cnt": { - "description": "群聊消息最大数量", "type": "int", - "hint": "群聊消息最大数量。超过此数量后,会自动清除旧消息。", }, "image_caption": { - "description": "群聊图像转述(需模型支持)", "type": "bool", - "hint": "用模型将群聊中的图片消息转述为文字,推荐 gpt-4o-mini 模型。和机器人的唤醒聊天中的图片消息仍然会直接作为上下文输入。", - }, - "image_caption_provider_id": { - "description": "图像转述提供商 ID", - "type": "string", - "hint": "可选。图像转述提供商 ID。如为空将选择聊天使用的提供商。", }, "image_caption_prompt": { - "description": "图像转述提示词", "type": "string", }, "active_reply": { - "description": "主动回复", "type": "object", "items": { "enable": { - "description": "启用主动回复", "type": "bool", - "hint": "启用后,会根据触发概率主动回复群聊内的对话。QQ官方API(qq_official)不可用", }, "whitelist": { - "description": "主动回复白名单", "type": "list", "items": {"type": "string"}, - "hint": "启用后,只有在白名单内的群聊会被主动回复。为空时不启用白名单过滤。需要通过 /sid 获取 SID 添加到这里。", }, "method": { - "description": "回复方法", "type": "string", "options": ["possibility_reply"], - "hint": "回复方法。possibility_reply 为根据概率回复", }, "possibility_reply": { - "description": "回复概率", "type": "float", - "hint": "回复概率。当回复方法为 possibility_reply 时有效。当概率 >= 1 时,每条消息都会回复。", - }, - "prompt": { - "description": "提示词", - "type": "string", - "hint": "提示词。当提示词为空时,如果触发回复,则向 LLM 请求的是触发的消息的内容;否则是提示词。此项可以和定时回复(暂未实现)配合使用。", }, }, }, @@ -1888,34 +1756,23 @@ CONFIG_METADATA_2 = { }, }, "misc_config_group": { - "name": "其他配置", "metadata": { "wake_prefix": { - "description": "机器人唤醒前缀", "type": "list", "items": {"type": "string"}, - "hint": "在不 @ 机器人的情况下,可以通过外加消息前缀来唤醒机器人。更改此配置将影响整个 Bot 的功能唤醒,包括所有指令。如果您不保留 `/`,则内置指令(help等)将需要通过您的唤醒前缀来触发。", }, "t2i": { - "description": "文本转图像", "type": "bool", - "hint": "启用后,超出一定长度的文本将会通过 AstrBot API 渲染成 Markdown 图片发送。可以缓解审核和消息过长刷屏的问题,并提高 Markdown 文本的可读性。", }, "t2i_word_threshold": { - "description": "文本转图像字数阈值", "type": "int", - "hint": "超出此字符长度的文本将会被转换成图片。字数不能低于 50。", }, "admins_id": { - "description": "管理员 ID", "type": "list", "items": {"type": "string"}, - "hint": "管理员 ID 列表,管理员可以使用一些特权命令,如 `update`, `plugin` 等。ID 可以通过 `/sid` 指令获得。回车添加,可添加多个。", }, "http_proxy": { - "description": "HTTP 代理", "type": "string", - "hint": "启用后,会以添加环境变量的方式设置代理。格式为 `http://ip:port`", }, "no_proxy": { "description": "直连地址列表", @@ -1924,51 +1781,553 @@ CONFIG_METADATA_2 = { "hint": "在此处添加不希望通过代理访问的地址,例如内部服务地址。回车添加,可添加多个,如未设置代理请忽略此配置", }, "timezone": { - "description": "时区", "type": "string", - "hint": "时区设置。请填写 IANA 时区名称, 如 Asia/Shanghai, 为空时使用系统默认时区。所有时区请查看: https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab", }, "callback_api_base": { - "description": "对外可达的回调接口地址", "type": "string", - "hint": "外部服务可能会通过 AstrBot 生成的回调链接(如文件下载链接)访问 AstrBot 后端。由于 AstrBot 无法自动判断部署环境中对外可达的主机地址(host),因此需要通过此配置项显式指定 “外部服务如何访问 AstrBot” 的地址。如 http://localhost:6185,https://example.com 等。", }, "log_level": { - "description": "控制台日志级别", "type": "string", - "hint": "控制台输出日志的级别。", "options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], }, "t2i_strategy": { - "description": "文本转图像渲染源", "type": "string", - "hint": "文本转图像策略。`remote` 为使用远程基于 HTML 的渲染服务,`local` 为使用 PIL 本地渲染。当使用 local 时,将 ttf 字体命名为 'font.ttf' 放在 data/ 目录下可自定义字体。", "options": ["remote", "local"], }, "t2i_endpoint": { - "description": "文本转图像服务接口", "type": "string", - "hint": "当 t2i_strategy 为 remote 时生效。为空时使用 AstrBot API 服务", }, "t2i_use_file_service": { - "description": "本地文本转图像使用文件服务提供文件", "type": "bool", - "hint": "当 t2i_strategy 为 local 并且配置 callback_api_base 时生效。是否使用文件服务提供文件。", }, "pip_install_arg": { - "description": "pip 安装参数", "type": "string", - "hint": "安装插件依赖时,会使用 Python 的 pip 工具。这里可以填写额外的参数,如 `--break-system-package` 等。", }, "pypi_index_url": { - "description": "PyPI 软件仓库地址", "type": "string", - "hint": "安装 Python 依赖时请求的 PyPI 软件仓库地址。默认为 https://mirrors.aliyun.com/pypi/simple/", + }, + "default_kb_collection": { + "type": "string", }, }, }, } + +CONFIG_METADATA_3 = { + "ai_group": { + "name": "AI 配置", + "metadata": { + "ai": { + "description": "模型", + "type": "object", + "items": { + "provider_settings.enable": { + "description": "启用大语言模型聊天", + "type": "bool", + }, + "provider_settings.default_provider_id": { + "description": "默认聊天模型", + "type": "string", + "_special": "select_provider", + "hint": "留空时使用第一个模型。", + }, + "provider_settings.default_image_caption_provider_id": { + "description": "默认图片转述模型", + "type": "string", + "_special": "select_provider", + "hint": "留空代表不使用。可用于不支持视觉模态的聊天模型。", + }, + "provider_stt_settings.provider_id": { + "description": "语音转文本模型", + "type": "string", + "hint": "留空代表不使用。", + "_special": "select_provider_stt", + }, + "provider_tts_settings.provider_id": { + "description": "文本转语音模型", + "type": "string", + "hint": "留空代表不使用。", + "_special": "select_provider_tts", + }, + "provider_settings.image_caption_prompt": { + "description": "图片转述提示词", + "type": "text", + }, + }, + }, + "persona": { + "description": "人格", + "type": "object", + "items": { + "provider_settings.default_personality": { + "description": "默认采用的人格", + "type": "string", + "_special": "select_persona", + }, + }, + }, + "knowledgebase": { + "description": "知识库", + "type": "object", + "items": { + "default_kb_collection": { + "description": "默认使用的知识库", + "type": "string", + "_special": "select_knowledgebase", + }, + }, + }, + "websearch": { + "description": "网页搜索", + "type": "object", + "items": { + "provider_settings.web_search": { + "description": "启用网页搜索", + "type": "bool", + }, + "provider_settings.websearch_provider": { + "description": "网页搜索提供商", + "type": "string", + "options": ["default", "tavily"], + }, + "provider_settings.websearch_tavily_key": { + "description": "Tavily API Key", + "type": "string", + "condition": { + "provider_settings.websearch_provider": "tavily", + }, + }, + "provider_settings.web_search_link": { + "description": "显示来源引用", + "type": "bool", + }, + }, + }, + "others": { + "description": "其他配置", + "type": "object", + "items": { + "provider_settings.display_reasoning_text": { + "description": "显示思考内容", + "type": "bool", + }, + "provider_settings.identifier": { + "description": "用户感知", + "type": "bool", + }, + "provider_settings.datetime_system_prompt": { + "description": "现实世界时间感知", + "type": "bool", + }, + "provider_settings.show_tool_use_status": { + "description": "输出函数调用状态", + "type": "bool", + }, + "provider_settings.max_agent_step": { + "description": "工具调用轮数上限", + "type": "bool", + }, + "provider_settings.streaming_response": { + "description": "流式回复", + "type": "bool", + }, + "provider_settings.streaming_segmented": { + "description": "不支持流式回复的平台采取分段输出", + "type": "bool", + }, + "provider_settings.max_context_length": { + "description": "最多携带对话轮数", + "type": "int", + "hint": "超出这个数量时丢弃最旧的部分,一轮聊天记为 1 条。-1 为不限制。", + }, + "provider_settings.dequeue_context_length": { + "description": "丢弃对话轮数", + "type": "int", + "hint": "超出最多携带对话轮数时, 一次丢弃的聊天轮数。", + }, + "provider_settings.wake_prefix": { + "description": "LLM 聊天额外唤醒前缀 ", + "type": "string", + }, + "provider_settings.prompt_prefix": { + "description": "额外前缀提示词", + "type": "string", + }, + "provider_settings.dual_output": { + "description": "开启 TTS 时同时输出语音和文字内容", + "type": "bool", + }, + }, + }, + }, + }, + "platform_group": { + "name": "平台配置", + "metadata": { + "general": { + "description": "基本", + "type": "object", + "items": { + "admins_id": { + "description": "管理员 ID", + "type": "list", + "items": {"type": "string"}, + }, + "platform_settings.unique_session": { + "description": "隔离会话", + "type": "bool", + "hint": "启用后,群成员的上下文独立。", + }, + "wake_prefix": { + "description": "唤醒词", + "type": "list", + "items": {"type": "string"}, + }, + "platform_settings.friend_message_needs_wake_prefix": { + "description": "私聊消息需要唤醒词", + "type": "bool", + }, + "platform_settings.reply_prefix": { + "description": "回复时的文本前缀", + "type": "string", + }, + "platform_settings.reply_with_mention": { + "description": "回复时 @ 发送人", + "type": "bool", + }, + "platform_settings.reply_with_quote": { + "description": "回复时引用发送人消息", + "type": "bool", + }, + "platform_settings.forward_threshold": { + "description": "转发消息的字数阈值", + "type": "int", + }, + "platform_settings.empty_mention_waiting": { + "description": "只 @ 机器人是否触发等待", + "type": "bool", + }, + }, + }, + "whitelist": { + "description": "白名单", + "type": "object", + "items": { + "platform_settings.enable_id_white_list": { + "description": "启用白名单", + "type": "bool", + "hint": "启用后,只有在白名单内的会话会被响应。", + }, + "platform_settings.id_whitelist": { + "description": "白名单 ID 列表", + "type": "list", + "items": {"type": "string"}, + "hint": "使用 /sid 获取 ID。", + }, + "platform_settings.id_whitelist_log": { + "description": "输出日志", + "type": "bool", + "hint": "启用后,当一条消息没通过白名单时,会输出 INFO 级别的日志。", + }, + "platform_settings.wl_ignore_admin_on_group": { + "description": "管理员群组消息无视 ID 白名单", + "type": "bool", + }, + "platform_settings.wl_ignore_admin_on_friend": { + "description": "管理员私聊消息无视 ID 白名单", + "type": "bool", + }, + }, + }, + "rate_limit": { + "description": "速率限制", + "type": "object", + "items": { + "platform_settings.rate_limit.time": { + "description": "消息速率限制时间(秒)", + "type": "int", + }, + "platform_settings.rate_limit.count": { + "description": "消息速率限制计数", + "type": "int", + }, + "platform_settings.rate_limit.strategy": { + "description": "速率限制策略", + "type": "string", + "options": ["stall", "discard"], + }, + }, + }, + "content_safety": { + "description": "内容安全", + "type": "object", + "items": { + "platform_settings.content_safety.also_use_in_response": { + "description": "同时检查模型的响应内容", + "type": "bool", + }, + "platform_settings.content_safety.baidu_aip.enable": { + "description": "使用百度内容安全审核", + "type": "bool", + "hint": "您需要手动安装 baidu-aip 库。", + }, + "platform_settings.content_safety.baidu_aip.app_id": { + "description": "App ID", + "type": "string", + "condition": { + "platform_settings.content_safety.baidu_aip.enable": True, + }, + }, + "platform_settings.content_safety.baidu_aip.api_key": { + "description": "API Key", + "type": "string", + "condition": { + "platform_settings.content_safety.baidu_aip.enable": True, + }, + }, + "platform_settings.content_safety.baidu_aip.secret_key": { + "description": "Secret Key", + "type": "string", + "condition": { + "platform_settings.content_safety.baidu_aip.enable": True, + }, + }, + "platform_settings.content_safety.internal_keywords.enable": { + "description": "关键词检查", + "type": "bool", + }, + "platform_settings.content_safety.internal_keywords.extra_keywords": { + "description": "额外关键词", + "type": "list", + "items": {"type": "string"}, + "hint": "额外的屏蔽关键词列表,支持正则表达式。", + }, + }, + }, + "t2i": { + "description": "文本转图像", + "type": "object", + "items": { + "t2i": { + "description": "文本转图像输出", + "type": "bool", + }, + "t2i_word_threshold": { + "description": "文本转图像字数阈值", + "type": "int", + }, + }, + }, + "others": { + "description": "其他配置", + "type": "object", + "items": { + "platform_settings.ignore_bot_self_message": { + "description": "是否忽略机器人自身的消息", + "type": "bool", + }, + "platform_settings.ignore_at_all": { + "description": "是否忽略 @ 全体成员事件", + "type": "bool", + }, + "platform_settings.no_permission_reply": { + "description": "用户权限不足时是否回复", + "type": "bool", + }, + }, + }, + }, + }, + "plugin_group": { + "name": "插件配置", + "metadata": { + "plugin": { + "description": "插件", + "type": "object", + "items": { + "plugin_set": { + "description": "可用插件", + "type": "bool", + "hint": "默认启用全部未被禁用的插件。若插件在插件页面被禁用,则此处的选择不会生效。", + "_special": "select_plugin_set", + }, + }, + }, + }, + }, + "ext_group": { + "name": "扩展功能", + "metadata": { + "segmented_reply": { + "description": "分段回复", + "type": "object", + "items": { + "platform_settings.segmented_reply.enable": { + "description": "启用分段回复", + "type": "bool", + }, + "platform_settings.segmented_reply.only_llm_result": { + "description": "仅对 LLM 结果分段", + "type": "bool", + }, + "platform_settings.segmented_reply.interval_method": { + "description": "间隔方法", + "type": "string", + "options": ["random", "log"], + }, + "platform_settings.segmented_reply.interval": { + "description": "随机间隔时间", + "type": "string", + "hint": "格式:最小值,最大值(如:1.5,3.5)", + "condition": { + "platform_settings.segmented_reply.interval_method": "random", + }, + }, + "platform_settings.segmented_reply.log_base": { + "description": "对数底数", + "type": "float", + "hint": "对数间隔的底数,默认为 2.0。取值范围为 1.0-10.0。", + "condition": { + "platform_settings.segmented_reply.interval_method": "log", + }, + }, + "platform_settings.segmented_reply.words_count_threshold": { + "description": "分段回复字数阈值", + "type": "int", + }, + "platform_settings.segmented_reply.regex": { + "description": "分段正则表达式", + "type": "string", + }, + "platform_settings.segmented_reply.content_cleanup_rule": { + "description": "内容过滤正则表达式", + "type": "string", + "hint": "移除分段后内容中的指定内容。如填写 `[。?!]` 将移除所有的句号、问号、感叹号。", + }, + }, + }, + "ltm": { + "description": "群聊上下文感知(原聊天记忆增强)", + "type": "object", + "items": { + "provider_ltm_settings.group_icl_enable": { + "description": "启用群聊上下文感知", + "type": "bool", + }, + "provider_ltm_settings.group_message_max_cnt": { + "description": "最大消息数量", + "type": "int", + }, + "provider_ltm_settings.image_caption": { + "description": "自动理解图片", + "type": "bool", + "hint": "需要设置默认图片转述模型。", + }, + "provider_ltm_settings.active_reply.enable": { + "description": "主动回复", + "type": "bool", + }, + "provider_ltm_settings.active_reply.method": { + "description": "主动回复方法", + "type": "string", + "options": ["possibility_reply"], + "condition": { + "provider_ltm_settings.active_reply.enable": True, + }, + }, + "provider_ltm_settings.active_reply.possibility_reply": { + "description": "回复概率", + "type": "float", + "hint": "0.0-1.0 之间的数值", + "condition": { + "provider_ltm_settings.active_reply.enable": True, + }, + }, + "provider_ltm_settings.active_reply.whitelist": { + "description": "主动回复白名单", + "type": "list", + "items": {"type": "string"}, + "hint": "为空时不启用白名单过滤。使用 /sid 获取 ID。", + "condition": { + "provider_ltm_settings.active_reply.enable": True, + }, + }, + }, + }, + }, + }, +} + +CONFIG_METADATA_3_SYSTEM = { + "system_group": { + "name": "系统配置", + "metadata": { + "system": { + "description": "系统配置", + "type": "object", + "items": { + "t2i_strategy": { + "description": "文本转图像策略", + "type": "string", + "hint": "文本转图像策略。`remote` 为使用远程基于 HTML 的渲染服务,`local` 为使用 PIL 本地渲染。当使用 local 时,将 ttf 字体命名为 'font.ttf' 放在 data/ 目录下可自定义字体。", + "options": ["remote", "local"], + }, + "t2i_endpoint": { + "description": "文本转图像服务 API 地址", + "type": "string", + "hint": "为空时使用 AstrBot API 服务", + "condition": { + "t2i_strategy": "remote", + }, + }, + "t2i_template": { + "description": "文本转图像自定义模版", + "type": "bool", + "hint": "启用后可自定义 HTML 模板用于文转图渲染。", + "condition": { + "t2i_strategy": "remote", + }, + "_special": "t2i_template" + }, + "log_level": { + "description": "控制台日志级别", + "type": "string", + "hint": "控制台输出日志的级别。", + "options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + }, + "pip_install_arg": { + "description": "pip 安装额外参数", + "type": "string", + "hint": "安装插件依赖时,会使用 Python 的 pip 工具。这里可以填写额外的参数,如 `--break-system-package` 等。", + }, + "pypi_index_url": { + "description": "PyPI 软件仓库地址", + "type": "string", + "hint": "安装 Python 依赖时请求的 PyPI 软件仓库地址。默认为 https://mirrors.aliyun.com/pypi/simple/", + }, + "callback_api_base": { + "description": "对外可达的回调接口地址", + "type": "string", + "hint": "外部服务可能会通过 AstrBot 生成的回调链接(如文件下载链接)访问 AstrBot 后端。由于 AstrBot 无法自动判断部署环境中对外可达的主机地址(host),因此需要通过此配置项显式指定 “外部服务如何访问 AstrBot” 的地址。如 http://localhost:6185,https://example.com 等。", + }, + "timezone": { + "description": "时区", + "type": "string", + "hint": "时区设置。请填写 IANA 时区名称, 如 Asia/Shanghai, 为空时使用系统默认时区。所有时区请查看: https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab", + }, + "http_proxy": { + "description": "HTTP 代理", + "type": "string", + "hint": "启用后,会以添加环境变量的方式设置代理。格式为 `http://ip:port`", + }, + }, + } + }, + } +} + + DEFAULT_VALUE_MAP = { "int": 0, "float": 0.0, diff --git a/astrbot/core/conversation_mgr.py b/astrbot/core/conversation_mgr.py index b665488e4..76112fa60 100644 --- a/astrbot/core/conversation_mgr.py +++ b/astrbot/core/conversation_mgr.py @@ -5,40 +5,44 @@ AstrBot 会话-对话管理器, 维护两个本地存储, 其中一个是 json 在一个会话中可以建立多个对话, 并且支持对话的切换和删除 """ -import uuid import json -import asyncio from astrbot.core import sp from typing import Dict, List from astrbot.core.db import BaseDatabase -from astrbot.core.db.po import Conversation +from astrbot.core.db.po import Conversation, ConversationV2 class ConversationManager: """负责管理会话与 LLM 的对话,某个会话当前正在用哪个对话。""" def __init__(self, db_helper: BaseDatabase): - # session_conversations 字典记录会话ID-对话ID 映射关系 - self.session_conversations: Dict[str, str] = sp.get("session_conversation", {}) + self.session_conversations: Dict[str, str] = {} self.db = db_helper self.save_interval = 60 # 每 60 秒保存一次 - self._start_periodic_save() - def _start_periodic_save(self): - """启动定时保存任务""" - asyncio.create_task(self._periodic_save()) + def _convert_conv_from_v2_to_v1(self, conv_v2: ConversationV2) -> Conversation: + """将 ConversationV2 对象转换为 Conversation 对象""" + created_at = int(conv_v2.created_at.timestamp()) + updated_at = int(conv_v2.updated_at.timestamp()) + return Conversation( + platform_id=conv_v2.platform_id, + user_id=conv_v2.user_id, + cid=conv_v2.conversation_id, + history=json.dumps(conv_v2.content or []), + title=conv_v2.title, + persona_id=conv_v2.persona_id, + created_at=created_at, + updated_at=updated_at, + ) - async def _periodic_save(self): - """定时保存会话对话映射关系到存储中""" - while True: - await asyncio.sleep(self.save_interval) - self._save_to_storage() - - def _save_to_storage(self): - """保存会话对话映射关系到存储中""" - sp.put("session_conversation", self.session_conversations) - - async def new_conversation(self, unified_msg_origin: str) -> str: + async def new_conversation( + self, + unified_msg_origin: str, + platform_id: str | None = None, + content: list[dict] | None = None, + title: str | None = None, + persona_id: str | None = None, + ) -> str: """新建对话,并将当前会话的对话转移到新对话 Args: @@ -46,11 +50,23 @@ class ConversationManager: Returns: conversation_id (str): 对话 ID, 是 uuid 格式的字符串 """ - conversation_id = str(uuid.uuid4()) - self.db.new_conversation(user_id=unified_msg_origin, cid=conversation_id) - self.session_conversations[unified_msg_origin] = conversation_id - sp.put("session_conversation", self.session_conversations) - return conversation_id + if not platform_id: + # 如果没有提供 platform_id,则从 unified_msg_origin 中解析 + parts = unified_msg_origin.split(":") + if len(parts) >= 3: + platform_id = parts[0] + if not platform_id: + platform_id = "unknown" + conv = await self.db.create_conversation( + user_id=unified_msg_origin, + platform_id=platform_id, + content=content, + title=title, + persona_id=persona_id, + ) + self.session_conversations[unified_msg_origin] = conv.conversation_id + await sp.session_put(unified_msg_origin, "sel_conv_id", conv.conversation_id) + return conv.conversation_id async def switch_conversation(self, unified_msg_origin: str, conversation_id: str): """切换会话的对话 @@ -60,10 +76,10 @@ class ConversationManager: conversation_id (str): 对话 ID, 是 uuid 格式的字符串 """ self.session_conversations[unified_msg_origin] = conversation_id - sp.put("session_conversation", self.session_conversations) + await sp.session_put(unified_msg_origin, "sel_conv_id", conversation_id) async def delete_conversation( - self, unified_msg_origin: str, conversation_id: str = None + self, unified_msg_origin: str, conversation_id: str | None = None ): """删除会话的对话,当 conversation_id 为 None 时删除会话当前的对话 @@ -71,13 +87,18 @@ class ConversationManager: unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id conversation_id (str): 对话 ID, 是 uuid 格式的字符串 """ - conversation_id = self.session_conversations.get(unified_msg_origin) + f = False + if not conversation_id: + conversation_id = self.session_conversations.get(unified_msg_origin) + if conversation_id: + f = True if conversation_id: - self.db.delete_conversation(user_id=unified_msg_origin, cid=conversation_id) - del self.session_conversations[unified_msg_origin] - sp.put("session_conversation", self.session_conversations) + await self.db.delete_conversation(cid=conversation_id) + if f: + self.session_conversations.pop(unified_msg_origin, None) + await sp.session_remove(unified_msg_origin, "sel_conv_id") - async def get_curr_conversation_id(self, unified_msg_origin: str) -> str: + async def get_curr_conversation_id(self, unified_msg_origin: str) -> str | None: """获取会话当前的对话 ID Args: @@ -85,14 +106,19 @@ class ConversationManager: Returns: conversation_id (str): 对话 ID, 是 uuid 格式的字符串 """ - return self.session_conversations.get(unified_msg_origin, None) + ret = self.session_conversations.get(unified_msg_origin, None) + if not ret: + ret = await sp.session_get(unified_msg_origin, "sel_conv_id", None) + if ret: + self.session_conversations[unified_msg_origin] = ret + return ret async def get_conversation( self, unified_msg_origin: str, conversation_id: str, create_if_not_exists: bool = False, - ) -> Conversation: + ) -> Conversation | None: """获取会话的对话 Args: @@ -101,27 +127,74 @@ class ConversationManager: Returns: conversation (Conversation): 对话对象 """ - conv = self.db.get_conversation_by_user_id(unified_msg_origin, conversation_id) + conv = await self.db.get_conversation_by_id(cid=conversation_id) if not conv and create_if_not_exists: # 如果对话不存在且需要创建,则新建一个对话 conversation_id = await self.new_conversation(unified_msg_origin) - return self.db.get_conversation_by_user_id( - unified_msg_origin, conversation_id - ) - return self.db.get_conversation_by_user_id(unified_msg_origin, conversation_id) + conv = await self.db.get_conversation_by_id(cid=conversation_id) + conv_res = None + if conv: + conv_res = self._convert_conv_from_v2_to_v1(conv) + return conv_res - async def get_conversations(self, unified_msg_origin: str) -> List[Conversation]: - """获取会话的所有对话 + async def get_conversations( + self, unified_msg_origin: str | None = None, platform_id: str | None = None + ) -> List[Conversation]: + """获取对话列表 Args: - unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id + unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id,可选 + platform_id (str): 平台 ID, 可选参数, 用于过滤对话 Returns: conversations (List[Conversation]): 对话对象列表 """ - return self.db.get_conversations(unified_msg_origin) + convs = await self.db.get_conversations( + user_id=unified_msg_origin, platform_id=platform_id + ) + convs_res = [] + for conv in convs: + conv_res = self._convert_conv_from_v2_to_v1(conv) + convs_res.append(conv_res) + return convs_res + + async def get_filtered_conversations( + self, + page: int = 1, + page_size: int = 20, + platform_ids: list[str] | None = None, + search_query: str = "", + **kwargs, + ) -> tuple[list[Conversation], int]: + """获取过滤后的对话列表 + + Args: + page (int): 页码, 默认为 1 + page_size (int): 每页大小, 默认为 20 + platform_ids (list[str]): 平台 ID 列表, 可选 + search_query (str): 搜索查询字符串, 可选 + Returns: + conversations (list[Conversation]): 对话对象列表 + """ + convs, cnt = await self.db.get_filtered_conversations( + page=page, + page_size=page_size, + platform_ids=platform_ids, + search_query=search_query, + **kwargs, + ) + convs_res = [] + for conv in convs: + conv_res = self._convert_conv_from_v2_to_v1(conv) + convs_res.append(conv_res) + return convs_res, cnt async def update_conversation( - self, unified_msg_origin: str, conversation_id: str, history: List[Dict] + self, + unified_msg_origin: str, + conversation_id: str | None = None, + history: list[dict] | None = None, + title: str | None = None, + persona_id: str | None = None, ): """更新会话的对话 @@ -130,40 +203,55 @@ class ConversationManager: conversation_id (str): 对话 ID, 是 uuid 格式的字符串 history (List[Dict]): 对话历史记录, 是一个字典列表, 每个字典包含 role 和 content 字段 """ + if not conversation_id: + # 如果没有提供 conversation_id,则获取当前的 + conversation_id = await self.get_curr_conversation_id(unified_msg_origin) if conversation_id: - self.db.update_conversation( - user_id=unified_msg_origin, + await self.db.update_conversation( cid=conversation_id, - history=json.dumps(history), + title=title, + persona_id=persona_id, + content=history, ) - async def update_conversation_title(self, unified_msg_origin: str, title: str): + async def update_conversation_title( + self, unified_msg_origin: str, title: str, conversation_id: str | None = None + ): """更新会话的对话标题 Args: unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id title (str): 对话标题 + + Deprecated: + Use `update_conversation` with `title` parameter instead. """ - conversation_id = self.session_conversations.get(unified_msg_origin) - if conversation_id: - self.db.update_conversation_title( - user_id=unified_msg_origin, cid=conversation_id, title=title - ) + await self.update_conversation( + unified_msg_origin=unified_msg_origin, + conversation_id=conversation_id, + title=title, + ) async def update_conversation_persona_id( - self, unified_msg_origin: str, persona_id: str + self, + unified_msg_origin: str, + persona_id: str, + conversation_id: str | None = None, ): """更新会话的对话 Persona ID Args: unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id persona_id (str): 对话 Persona ID + + Deprecated: + Use `update_conversation` with `persona_id` parameter instead. """ - conversation_id = self.session_conversations.get(unified_msg_origin) - if conversation_id: - self.db.update_conversation_persona_id( - user_id=unified_msg_origin, cid=conversation_id, persona_id=persona_id - ) + await self.update_conversation( + unified_msg_origin=unified_msg_origin, + conversation_id=conversation_id, + persona_id=persona_id, + ) async def get_human_readable_context( self, unified_msg_origin, conversation_id, page=1, page_size=10 diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 12226d9e1..972a5f4f1 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -15,20 +15,23 @@ import time import threading import os from .event_bus import EventBus -from . import astrbot_config +from . import astrbot_config, html_renderer from asyncio import Queue from typing import List from astrbot.core.pipeline.scheduler import PipelineScheduler, PipelineContext from astrbot.core.star import PluginManager from astrbot.core.platform.manager import PlatformManager from astrbot.core.star.context import Context +from astrbot.core.persona_mgr import PersonaManager from astrbot.core.provider.manager import ProviderManager from astrbot.core import LogBroker from astrbot.core.db import BaseDatabase from astrbot.core.updator import AstrBotUpdator -from astrbot.core import logger +from astrbot.core import logger, sp from astrbot.core.config.default import VERSION from astrbot.core.conversation_mgr import ConversationManager +from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager +from astrbot.core.astrbot_config_mgr import AstrBotConfigManager from astrbot.core.star.star_handler import star_handlers_registry, EventType from astrbot.core.star.star_handler import star_map @@ -77,11 +80,26 @@ class AstrBotCoreLifecycle: else: logger.setLevel(self.astrbot_config["log_level"]) # 设置日志级别 + await self.db.initialize() + + await html_renderer.initialize() + + # 初始化 AstrBot 配置管理器 + self.astrbot_config_mgr = AstrBotConfigManager( + default_config=self.astrbot_config, sp=sp + ) + # 初始化事件队列 self.event_queue = Queue() + # 初始化人格管理器 + self.persona_mgr = PersonaManager(self.db, self.astrbot_config_mgr) + await self.persona_mgr.initialize() + # 初始化供应商管理器 - self.provider_manager = ProviderManager(self.astrbot_config, self.db) + self.provider_manager = ProviderManager( + self.astrbot_config_mgr, self.db, self.persona_mgr + ) # 初始化平台管理器 self.platform_manager = PlatformManager(self.astrbot_config, self.event_queue) @@ -89,6 +107,9 @@ class AstrBotCoreLifecycle: # 初始化对话管理器 self.conversation_manager = ConversationManager(self.db) + # 初始化平台消息历史管理器 + self.platform_message_history_manager = PlatformMessageHistoryManager(self.db) + # 初始化提供给插件的上下文 self.star_context = Context( self.event_queue, @@ -97,6 +118,9 @@ class AstrBotCoreLifecycle: self.provider_manager, self.platform_manager, self.conversation_manager, + self.platform_message_history_manager, + self.persona_mgr, + self.astrbot_config_mgr, ) # 初始化插件管理器 @@ -109,16 +133,16 @@ class AstrBotCoreLifecycle: await self.provider_manager.initialize() # 初始化消息事件流水线调度器 - self.pipeline_scheduler = PipelineScheduler( - PipelineContext(self.astrbot_config, self.plugin_manager) - ) - await self.pipeline_scheduler.initialize() + + self.pipeline_scheduler_mapping = await self.load_pipeline_scheduler() # 初始化更新器 self.astrbot_updator = AstrBotUpdator() # 初始化事件总线 - self.event_bus = EventBus(self.event_queue, self.pipeline_scheduler) + self.event_bus = EventBus( + self.event_queue, self.pipeline_scheduler_mapping, self.astrbot_config_mgr + ) # 记录启动时间 self.start_time = int(time.time()) @@ -235,6 +259,39 @@ class AstrBotCoreLifecycle: platform_insts = self.platform_manager.get_insts() for platform_inst in platform_insts: tasks.append( - asyncio.create_task(platform_inst.run(), name=platform_inst.meta().name) + asyncio.create_task( + platform_inst.run(), + name=f"{platform_inst.meta().id}({platform_inst.meta().name})", + ) ) return tasks + + async def load_pipeline_scheduler(self) -> dict[str, PipelineScheduler]: + """加载消息事件流水线调度器 + + Returns: + dict[str, PipelineScheduler]: 平台 ID 到流水线调度器的映射 + """ + mapping = {} + for conf_id, ab_config in self.astrbot_config_mgr.confs.items(): + scheduler = PipelineScheduler( + PipelineContext(ab_config, self.plugin_manager, conf_id) + ) + await scheduler.initialize() + mapping[conf_id] = scheduler + return mapping + + async def reload_pipeline_scheduler(self, conf_id: str): + """重新加载消息事件流水线调度器 + + Returns: + dict[str, PipelineScheduler]: 平台 ID 到流水线调度器的映射 + """ + ab_config = self.astrbot_config_mgr.confs.get(conf_id) + if not ab_config: + raise ValueError(f"配置文件 {conf_id} 不存在") + scheduler = PipelineScheduler( + PipelineContext(ab_config, self.plugin_manager, conf_id) + ) + await scheduler.initialize() + self.pipeline_scheduler_mapping[conf_id] = scheduler diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index 6688dcced..2de109b7d 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -1,7 +1,20 @@ import abc +import datetime +import typing as T +from deprecated import deprecated from dataclasses import dataclass -from typing import List, Dict, Any, Tuple -from astrbot.core.db.po import Stats, LLMHistory, ATRIVision, Conversation +from astrbot.core.db.po import ( + Stats, + PlatformStat, + ConversationV2, + PlatformMessageHistory, + Attachment, + Persona, + Preference, +) +from contextlib import asynccontextmanager +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker @dataclass @@ -10,152 +23,262 @@ class BaseDatabase(abc.ABC): 数据库基类 """ + DATABASE_URL = "" + def __init__(self) -> None: + self.engine = create_async_engine( + self.DATABASE_URL, + echo=False, + future=True, + ) + self.AsyncSessionLocal = sessionmaker( + self.engine, class_=AsyncSession, expire_on_commit=False + ) + + async def initialize(self): + """初始化数据库连接""" pass - def insert_base_metrics(self, metrics: dict): - """插入基础指标数据""" - self.insert_platform_metrics(metrics["platform_stats"]) - self.insert_plugin_metrics(metrics["plugin_stats"]) - self.insert_command_metrics(metrics["command_stats"]) - self.insert_llm_metrics(metrics["llm_stats"]) - - @abc.abstractmethod - def insert_platform_metrics(self, metrics: dict): - """插入平台指标数据""" - raise NotImplementedError - - @abc.abstractmethod - def insert_plugin_metrics(self, metrics: dict): - """插入插件指标数据""" - raise NotImplementedError - - @abc.abstractmethod - def insert_command_metrics(self, metrics: dict): - """插入指令指标数据""" - raise NotImplementedError - - @abc.abstractmethod - def insert_llm_metrics(self, metrics: dict): - """插入 LLM 指标数据""" - raise NotImplementedError - - @abc.abstractmethod - def update_llm_history(self, session_id: str, content: str, provider_type: str): - """更新 LLM 历史记录。当不存在 session_id 时插入""" - raise NotImplementedError - - @abc.abstractmethod - def get_llm_history( - self, session_id: str = None, provider_type: str = None - ) -> List[LLMHistory]: - """获取 LLM 历史记录, 如果 session_id 为 None, 返回所有""" - raise NotImplementedError + @asynccontextmanager + async def get_db(self) -> T.AsyncGenerator[AsyncSession, None]: + """Get a database session.""" + if not self.inited: + await self.initialize() + self.inited = True + async with self.AsyncSessionLocal() as session: + yield session + @deprecated(version="4.0.0", reason="Use get_platform_stats instead") @abc.abstractmethod def get_base_stats(self, offset_sec: int = 86400) -> Stats: """获取基础统计数据""" raise NotImplementedError + @deprecated(version="4.0.0", reason="Use get_platform_stats instead") @abc.abstractmethod def get_total_message_count(self) -> int: """获取总消息数""" raise NotImplementedError + @deprecated(version="4.0.0", reason="Use get_platform_stats instead") @abc.abstractmethod def get_grouped_base_stats(self, offset_sec: int = 86400) -> Stats: """获取基础统计数据(合并)""" raise NotImplementedError - @abc.abstractmethod - def insert_atri_vision_data(self, vision_data: ATRIVision): - """插入 ATRI 视觉数据""" - raise NotImplementedError + # New methods in v4.0.0 @abc.abstractmethod - def get_atri_vision_data(self) -> List[ATRIVision]: - """获取 ATRI 视觉数据""" - raise NotImplementedError + async def insert_platform_stats( + self, + platform_id: str, + platform_type: str, + count: int = 1, + timestamp: datetime.datetime | None = None, + ) -> None: + """Insert a new platform statistic record.""" + ... @abc.abstractmethod - def get_atri_vision_data_by_path_or_id( - self, url_or_path: str, id: str - ) -> ATRIVision: - """通过 url 或 path 获取 ATRI 视觉数据""" - raise NotImplementedError + async def count_platform_stats(self) -> int: + """Count the number of platform statistics records.""" + ... @abc.abstractmethod - def get_conversation_by_user_id(self, user_id: str, cid: str) -> Conversation: - """通过 user_id 和 cid 获取 Conversation""" - raise NotImplementedError + async def get_platform_stats(self, offset_sec: int = 86400) -> list[PlatformStat]: + """Get platform statistics within the specified offset in seconds and group by platform_id.""" + ... @abc.abstractmethod - def new_conversation(self, user_id: str, cid: str): - """新建 Conversation""" - raise NotImplementedError + async def get_conversations( + self, user_id: str | None = None, platform_id: str | None = None + ) -> list[ConversationV2]: + """Get all conversations for a specific user and platform_id(optional). - @abc.abstractmethod - def get_conversations(self, user_id: str) -> List[Conversation]: - raise NotImplementedError - - @abc.abstractmethod - def update_conversation(self, user_id: str, cid: str, history: str): - """更新 Conversation""" - raise NotImplementedError - - @abc.abstractmethod - def delete_conversation(self, user_id: str, cid: str): - """删除 Conversation""" - raise NotImplementedError - - @abc.abstractmethod - def update_conversation_title(self, user_id: str, cid: str, title: str): - """更新 Conversation 标题""" - raise NotImplementedError - - @abc.abstractmethod - def update_conversation_persona_id(self, user_id: str, cid: str, persona_id: str): - """更新 Conversation Persona ID""" - raise NotImplementedError - - @abc.abstractmethod - def get_all_conversations( - self, page: int = 1, page_size: int = 20 - ) -> Tuple[List[Dict[str, Any]], int]: - """获取所有对话,支持分页 - - Args: - page: 页码,从1开始 - page_size: 每页数量 - - Returns: - Tuple[List[Dict[str, Any]], int]: 返回一个元组,包含对话列表和总对话数 + content is not included in the result. """ - raise NotImplementedError + ... @abc.abstractmethod - def get_filtered_conversations( + async def get_conversation_by_id(self, cid: str) -> ConversationV2: + """Get a specific conversation by its ID.""" + ... + + @abc.abstractmethod + async def get_all_conversations( + self, page: int = 1, page_size: int = 20 + ) -> list[ConversationV2]: + """Get all conversations with pagination.""" + ... + + @abc.abstractmethod + async def get_filtered_conversations( self, page: int = 1, page_size: int = 20, - platforms: List[str] = None, - message_types: List[str] = None, - search_query: str = None, - exclude_ids: List[str] = None, - exclude_platforms: List[str] = None, - ) -> Tuple[List[Dict[str, Any]], int]: - """获取筛选后的对话列表 + platform_ids: list[str] | None = None, + search_query: str = "", + **kwargs, + ) -> tuple[list[ConversationV2], int]: + """Get conversations filtered by platform IDs and search query.""" + ... - Args: - page: 页码 - page_size: 每页数量 - platforms: 平台筛选列表 - message_types: 消息类型筛选列表 - search_query: 搜索关键词 - exclude_ids: 排除的用户ID列表 - exclude_platforms: 排除的平台列表 + @abc.abstractmethod + async def create_conversation( + self, + user_id: str, + platform_id: str, + content: list[dict] | None = None, + title: str | None = None, + persona_id: str | None = None, + cid: str | None = None, + created_at: datetime.datetime | None = None, + updated_at: datetime.datetime | None = None, + ) -> ConversationV2: + """Create a new conversation.""" + ... - Returns: - Tuple[List[Dict[str, Any]], int]: 返回一个元组,包含对话列表和总对话数 - """ - raise NotImplementedError + @abc.abstractmethod + async def update_conversation( + self, + cid: str, + title: str | None = None, + persona_id: str | None = None, + content: list[dict] | None = None, + ) -> None: + """Update a conversation's history.""" + ... + + @abc.abstractmethod + async def delete_conversation(self, cid: str) -> None: + """Delete a conversation by its ID.""" + ... + + @abc.abstractmethod + async def insert_platform_message_history( + self, + platform_id: str, + user_id: str, + content: list[dict], + sender_id: str | None = None, + sender_name: str | None = None, + ) -> None: + """Insert a new platform message history record.""" + ... + + @abc.abstractmethod + async def delete_platform_message_offset( + self, platform_id: str, user_id: str, offset_sec: int = 86400 + ) -> None: + """Delete platform message history records older than the specified offset.""" + ... + + @abc.abstractmethod + async def get_platform_message_history( + self, + platform_id: str, + user_id: str, + page: int = 1, + page_size: int = 20, + ) -> list[PlatformMessageHistory]: + """Get platform message history for a specific user.""" + ... + + @abc.abstractmethod + async def insert_attachment( + self, + path: str, + type: str, + mime_type: str, + ): + """Insert a new attachment record.""" + ... + + @abc.abstractmethod + async def get_attachment_by_id(self, attachment_id: str) -> Attachment: + """Get an attachment by its ID.""" + ... + + @abc.abstractmethod + async def insert_persona( + self, + persona_id: str, + system_prompt: str, + begin_dialogs: list[str] | None = None, + tools: list[str] | None = None, + ) -> Persona: + """Insert a new persona record.""" + ... + + @abc.abstractmethod + async def get_persona_by_id(self, persona_id: str) -> Persona: + """Get a persona by its ID.""" + ... + + @abc.abstractmethod + async def get_personas(self) -> list[Persona]: + """Get all personas for a specific bot.""" + ... + + @abc.abstractmethod + async def update_persona( + self, + persona_id: str, + system_prompt: str | None = None, + begin_dialogs: list[str] | None = None, + tools: list[str] | None = None, + ) -> Persona | None: + """Update a persona's system prompt or begin dialogs.""" + ... + + @abc.abstractmethod + async def delete_persona(self, persona_id: str) -> None: + """Delete a persona by its ID.""" + ... + + @abc.abstractmethod + async def insert_preference_or_update( + self, scope: str, scope_id: str, key: str, value: dict + ) -> Preference: + """Insert a new preference record.""" + ... + + @abc.abstractmethod + async def get_preference(self, scope: str, scope_id: str, key: str) -> Preference: + """Get a preference by scope ID and key.""" + ... + + @abc.abstractmethod + async def get_preferences( + self, scope: str, scope_id: str | None = None, key: str | None = None + ) -> list[Preference]: + """Get all preferences for a specific scope ID or key.""" + ... + + @abc.abstractmethod + async def remove_preference(self, scope: str, scope_id: str, key: str) -> None: + """Remove a preference by scope ID and key.""" + ... + + @abc.abstractmethod + async def clear_preferences(self, scope: str, scope_id: str) -> None: + """Clear all preferences for a specific scope ID.""" + ... + + # @abc.abstractmethod + # async def insert_llm_message( + # self, + # cid: str, + # role: str, + # content: list, + # tool_calls: list = None, + # tool_call_id: str = None, + # parent_id: str = None, + # ) -> LLMMessage: + # """Insert a new LLM message into the conversation.""" + # ... + + # @abc.abstractmethod + # async def get_llm_messages(self, cid: str) -> list[LLMMessage]: + # """Get all LLM messages for a specific conversation.""" + # ... diff --git a/astrbot/core/db/migration/helper.py b/astrbot/core/db/migration/helper.py new file mode 100644 index 000000000..796a7b336 --- /dev/null +++ b/astrbot/core/db/migration/helper.py @@ -0,0 +1,64 @@ +import os +from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.db import BaseDatabase +from astrbot.core.config import AstrBotConfig +from astrbot.api import logger, sp +from .migra_3_to_4 import ( + migration_conversation_table, + migration_platform_table, + migration_webchat_data, + migration_persona_data, + migration_preferences, +) + + +async def check_migration_needed_v4(db_helper: BaseDatabase) -> bool: + """ + 检查是否需要进行数据库迁移 + 如果存在 data_v3.db 并且 preference 中没有 migration_done_v4,则需要进行迁移。 + """ + data_v3_exists = os.path.exists(get_astrbot_data_path()) + if not data_v3_exists: + return False + migration_done = await db_helper.get_preference( + "global", "global", "migration_done_v4" + ) + if migration_done: + return False + return True + + +async def do_migration_v4( + db_helper: BaseDatabase, + platform_id_map: dict[str, dict[str, str]], + astrbot_config: AstrBotConfig, +): + """ + 执行数据库迁移 + 迁移旧的 webchat_conversation 表到新的 conversation 表。 + 迁移旧的 platform 到新的 platform_stats 表。 + """ + if not await check_migration_needed_v4(db_helper): + return + + logger.info("开始执行数据库迁移...") + + # 执行会话表迁移 + await migration_conversation_table(db_helper, platform_id_map) + + # 执行人格数据迁移 + await migration_persona_data(db_helper, astrbot_config) + + # 执行 WebChat 数据迁移 + await migration_webchat_data(db_helper, platform_id_map) + + # 执行偏好设置迁移 + await migration_preferences(db_helper,platform_id_map) + + # 执行平台统计表迁移 + await migration_platform_table(db_helper, platform_id_map) + + # 标记迁移完成 + await sp.put_async("global", "global", "migration_done_v4", True) + + logger.info("数据库迁移完成。") diff --git a/astrbot/core/db/migration/migra_3_to_4.py b/astrbot/core/db/migration/migra_3_to_4.py new file mode 100644 index 000000000..4aa5082db --- /dev/null +++ b/astrbot/core/db/migration/migra_3_to_4.py @@ -0,0 +1,338 @@ +import json +import datetime +from .. import BaseDatabase +from .sqlite_v3 import SQLiteDatabase as SQLiteV3DatabaseV3 +from .shared_preferences_v3 import sp as sp_v3 +from astrbot.core.config.default import DB_PATH +from astrbot.api import logger, sp +from astrbot.core.config import AstrBotConfig +from astrbot.core.platform.astr_message_event import MessageSesion +from sqlalchemy.ext.asyncio import AsyncSession +from astrbot.core.db.po import ConversationV2, PlatformMessageHistory +from sqlalchemy import text + +""" +1. 迁移旧的 webchat_conversation 表到新的 conversation 表。 +2. 迁移旧的 platform 到新的 platform_stats 表。 +""" + + +def get_platform_id( + platform_id_map: dict[str, dict[str, str]], old_platform_name: str +) -> str: + return platform_id_map.get( + old_platform_name, + {"platform_id": old_platform_name, "platform_type": old_platform_name}, + ).get("platform_id", old_platform_name) + + +def get_platform_type( + platform_id_map: dict[str, dict[str, str]], old_platform_name: str +) -> str: + return platform_id_map.get( + old_platform_name, + {"platform_id": old_platform_name, "platform_type": old_platform_name}, + ).get("platform_type", old_platform_name) + + +async def migration_conversation_table( + db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]] +): + db_helper_v3 = SQLiteV3DatabaseV3( + db_path=DB_PATH.replace("data_v4.db", "data_v3.db") + ) + conversations, total_cnt = db_helper_v3.get_all_conversations( + page=1, page_size=10000000 + ) + logger.info(f"迁移 {total_cnt} 条旧的会话数据到新的表中...") + + async with db_helper.get_db() as dbsession: + dbsession: AsyncSession + async with dbsession.begin(): + for idx, conversation in enumerate(conversations): + if total_cnt > 0 and (idx + 1) % max(1, total_cnt // 10) == 0: + progress = int((idx + 1) / total_cnt * 100) + if progress % 10 == 0: + logger.info(f"进度: {progress}% ({idx + 1}/{total_cnt})") + try: + conv = db_helper_v3.get_conversation_by_user_id( + user_id=conversation.get("user_id", "unknown"), + cid=conversation.get("cid", "unknown"), + ) + if not conv: + logger.info( + f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。" + ) + if ":" not in conv.user_id: + continue + session = MessageSesion.from_str(session_str=conv.user_id) + platform_id = get_platform_id( + platform_id_map, session.platform_name + ) + session.platform_id = platform_id # 更新平台名称为新的 ID + conv_v2 = ConversationV2( + user_id=str(session), + content=json.loads(conv.history) if conv.history else [], + platform_id=platform_id, + title=conv.title, + persona_id=conv.persona_id, + conversation_id=conv.cid, + created_at=datetime.datetime.fromtimestamp(conv.created_at), + updated_at=datetime.datetime.fromtimestamp(conv.updated_at), + ) + dbsession.add(conv_v2) + except Exception as e: + logger.error( + f"迁移旧会话 {conversation.get('cid', 'unknown')} 失败: {e}", + exc_info=True, + ) + logger.info(f"成功迁移 {total_cnt} 条旧的会话数据到新表。") + + +async def migration_platform_table( + db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]] +): + db_helper_v3 = SQLiteV3DatabaseV3( + db_path=DB_PATH.replace("data_v4.db", "data_v3.db") + ) + secs_from_2023_4_10_to_now = ( + datetime.datetime.now(datetime.timezone.utc) + - datetime.datetime(2023, 4, 10, tzinfo=datetime.timezone.utc) + ).total_seconds() + offset_sec = int(secs_from_2023_4_10_to_now) + logger.info(f"迁移旧平台数据,offset_sec: {offset_sec} 秒。") + stats = db_helper_v3.get_base_stats(offset_sec=offset_sec) + logger.info(f"迁移 {len(stats.platform)} 条旧的平台数据到新的表中...") + platform_stats_v3 = stats.platform + + if not platform_stats_v3: + logger.info("没有找到旧平台数据,跳过迁移。") + return + + first_time_stamp = platform_stats_v3[0].timestamp + end_time_stamp = platform_stats_v3[-1].timestamp + start_time = first_time_stamp - (first_time_stamp % 3600) # 向下取整到小时 + end_time = end_time_stamp + (3600 - (end_time_stamp % 3600)) # 向上取整到小时 + + idx = 0 + + async with db_helper.get_db() as dbsession: + dbsession: AsyncSession + async with dbsession.begin(): + total_buckets = (end_time - start_time) // 3600 + for bucket_idx, bucket_end in enumerate(range(start_time, end_time, 3600)): + if bucket_idx % 500 == 0: + progress = int((bucket_idx + 1) / total_buckets * 100) + logger.info(f"进度: {progress}% ({bucket_idx + 1}/{total_buckets})") + cnt = 0 + while ( + idx < len(platform_stats_v3) + and platform_stats_v3[idx].timestamp < bucket_end + ): + cnt += platform_stats_v3[idx].count + idx += 1 + if cnt == 0: + continue + platform_id = get_platform_id( + platform_id_map, platform_stats_v3[idx].name + ) + platform_type = get_platform_type( + platform_id_map, platform_stats_v3[idx].name + ) + try: + await dbsession.execute( + text(""" + INSERT INTO platform_stats (timestamp, platform_id, platform_type, count) + VALUES (:timestamp, :platform_id, :platform_type, :count) + ON CONFLICT(timestamp, platform_id, platform_type) DO UPDATE SET + count = platform_stats.count + EXCLUDED.count + """), + { + "timestamp": datetime.datetime.fromtimestamp( + bucket_end, tz=datetime.timezone.utc + ), + "platform_id": platform_id, + "platform_type": platform_type, + "count": cnt, + }, + ) + except Exception: + logger.error( + f"迁移平台统计数据失败: {platform_id}, {platform_type}, 时间戳: {bucket_end}", + exc_info=True, + ) + logger.info(f"成功迁移 {len(platform_stats_v3)} 条旧的平台数据到新表。") + + +async def migration_webchat_data( + db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]] +): + """迁移 WebChat 的历史记录到新的 PlatformMessageHistory 表中""" + db_helper_v3 = SQLiteV3DatabaseV3( + db_path=DB_PATH.replace("data_v4.db", "data_v3.db") + ) + conversations, total_cnt = db_helper_v3.get_all_conversations( + page=1, page_size=10000000 + ) + logger.info(f"迁移 {total_cnt} 条旧的 WebChat 会话数据到新的表中...") + + async with db_helper.get_db() as dbsession: + dbsession: AsyncSession + async with dbsession.begin(): + for idx, conversation in enumerate(conversations): + if total_cnt > 0 and (idx + 1) % max(1, total_cnt // 10) == 0: + progress = int((idx + 1) / total_cnt * 100) + if progress % 10 == 0: + logger.info(f"进度: {progress}% ({idx + 1}/{total_cnt})") + try: + conv = db_helper_v3.get_conversation_by_user_id( + user_id=conversation.get("user_id", "unknown"), + cid=conversation.get("cid", "unknown"), + ) + if not conv: + logger.info( + f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。" + ) + if ":" in conv.user_id: + continue + platform_id = "webchat" + history = json.loads(conv.history) if conv.history else [] + for msg in history: + type_ = msg.get("type") # user type, "bot" or "user" + new_history = PlatformMessageHistory( + platform_id=platform_id, + user_id=conv.cid, # we use conv.cid as user_id for webchat + content=msg, + sender_id=type_, + sender_name=type_, + ) + dbsession.add(new_history) + + except Exception: + logger.error( + f"迁移旧 WebChat 会话 {conversation.get('cid', 'unknown')} 失败", + exc_info=True, + ) + + logger.info(f"成功迁移 {total_cnt} 条旧的 WebChat 会话数据到新表。") + + +async def migration_persona_data( + db_helper: BaseDatabase, astrbot_config: AstrBotConfig +): + """ + 迁移 Persona 数据到新的表中。 + 旧的 Persona 数据存储在 preference 中,新的 Persona 数据存储在 persona 表中。 + """ + v3_persona_config: list[dict] = astrbot_config.get("persona", []) + total_personas = len(v3_persona_config) + logger.info(f"迁移 {total_personas} 个 Persona 配置到新表中...") + + for idx, persona in enumerate(v3_persona_config): + if total_personas > 0 and (idx + 1) % max(1, total_personas // 10) == 0: + progress = int((idx + 1) / total_personas * 100) + if progress % 10 == 0: + logger.info(f"进度: {progress}% ({idx + 1}/{total_personas})") + try: + begin_dialogs = persona.get("begin_dialogs", []) + mood_imitation_dialogs = persona.get("mood_imitation_dialogs", []) + mood_prompt = "" + user_turn = True + for mood_dialog in mood_imitation_dialogs: + if user_turn: + mood_prompt += f"A: {mood_dialog}\n" + else: + mood_prompt += f"B: {mood_dialog}\n" + user_turn = not user_turn + system_prompt = persona.get("prompt", "") + if mood_prompt: + system_prompt += f"Here are few shots of dialogs, you need to imitate the tone of 'B' in the following dialogs to respond:\n {mood_prompt}" + persona_new = await db_helper.insert_persona( + persona_id=persona["name"], + system_prompt=system_prompt, + begin_dialogs=begin_dialogs, + ) + logger.info( + f"迁移 Persona {persona['name']}({persona_new.system_prompt[:30]}...) 到新表成功。" + ) + except Exception as e: + logger.error(f"解析 Persona 配置失败:{e}") + + +async def migration_preferences( + db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]] +): + # 1. global scope migration + keys = [ + "inactivated_llm_tools", + "inactivated_plugins", + "curr_provider", + "curr_provider_tts", + "curr_provider_stt", + "alter_cmd", + ] + for key in keys: + value = sp_v3.get(key) + if value is not None: + await sp.put_async("global", "global", key, value) + logger.info(f"迁移全局偏好设置 {key} 成功,值: {value}") + + # 2. umo scope migration + session_conversation = sp_v3.get("session_conversation", default={}) + for umo, conversation_id in session_conversation.items(): + if not umo or not conversation_id: + continue + try: + session = MessageSesion.from_str(session_str=umo) + platform_id = get_platform_id(platform_id_map, session.platform_name) + session.platform_id = platform_id + await sp.put_async("umo", str(session), "sel_conv_id", conversation_id) + logger.info(f"迁移会话 {umo} 的对话数据到新表成功,平台 ID: {platform_id}") + except Exception as e: + logger.error(f"迁移会话 {umo} 的对话数据失败: {e}", exc_info=True) + + session_service_config = sp_v3.get("session_service_config", default={}) + for umo, config in session_service_config.items(): + if not umo or not config: + continue + try: + session = MessageSesion.from_str(session_str=umo) + platform_id = get_platform_id(platform_id_map, session.platform_name) + session.platform_id = platform_id + + await sp.put_async("umo", str(session), "session_service_config", config) + + logger.info(f"迁移会话 {umo} 的服务配置到新表成功,平台 ID: {platform_id}") + except Exception as e: + logger.error(f"迁移会话 {umo} 的服务配置失败: {e}", exc_info=True) + + session_variables = sp_v3.get("session_variables", default={}) + for umo, variables in session_variables.items(): + if not umo or not variables: + continue + try: + session = MessageSesion.from_str(session_str=umo) + platform_id = get_platform_id(platform_id_map, session.platform_name) + session.platform_id = platform_id + await sp.put_async("umo", str(session), "session_variables", variables) + except Exception as e: + logger.error(f"迁移会话 {umo} 的变量失败: {e}", exc_info=True) + + session_provider_perf = sp_v3.get("session_provider_perf", default={}) + for umo, perf in session_provider_perf.items(): + if not umo or not perf: + continue + try: + session = MessageSesion.from_str(session_str=umo) + platform_id = get_platform_id(platform_id_map, session.platform_name) + session.platform_id = platform_id + + for provider_type, provider_id in perf.items(): + await sp.put_async( + "umo", str(session), f"provider_perf_{provider_type}", provider_id + ) + logger.info( + f"迁移会话 {umo} 的提供商偏好到新表成功,平台 ID: {platform_id}" + ) + except Exception as e: + logger.error(f"迁移会话 {umo} 的提供商偏好失败: {e}", exc_info=True) diff --git a/astrbot/core/db/migration/shared_preferences_v3.py b/astrbot/core/db/migration/shared_preferences_v3.py new file mode 100644 index 000000000..dda2cbcaf --- /dev/null +++ b/astrbot/core/db/migration/shared_preferences_v3.py @@ -0,0 +1,45 @@ +import json +import os +from typing import TypeVar +from astrbot.core.utils.astrbot_path import get_astrbot_data_path + +_VT = TypeVar("_VT") + +class SharedPreferences: + def __init__(self, path=None): + if path is None: + path = os.path.join(get_astrbot_data_path(), "shared_preferences.json") + self.path = path + self._data = self._load_preferences() + + def _load_preferences(self): + if os.path.exists(self.path): + try: + with open(self.path, "r") as f: + return json.load(f) + except json.JSONDecodeError: + os.remove(self.path) + return {} + + def _save_preferences(self): + with open(self.path, "w") as f: + json.dump(self._data, f, indent=4, ensure_ascii=False) + f.flush() + + def get(self, key, default: _VT = None) -> _VT: + return self._data.get(key, default) + + def put(self, key, value): + self._data[key] = value + self._save_preferences() + + def remove(self, key): + if key in self._data: + del self._data[key] + self._save_preferences() + + def clear(self): + self._data.clear() + self._save_preferences() + +sp = SharedPreferences() diff --git a/astrbot/core/db/migration/sqlite_v3.py b/astrbot/core/db/migration/sqlite_v3.py new file mode 100644 index 000000000..e7e734abd --- /dev/null +++ b/astrbot/core/db/migration/sqlite_v3.py @@ -0,0 +1,493 @@ +import sqlite3 +import time +from astrbot.core.db.po import Platform, Stats +from typing import Tuple, List, Dict, Any +from dataclasses import dataclass + +@dataclass +class Conversation: + """LLM 对话存储 + + 对于网页聊天,history 存储了包括指令、回复、图片等在内的所有消息。 + 对于其他平台的聊天,不存储非 LLM 的回复(因为考虑到已经存储在各自的平台上)。 + """ + + user_id: str + cid: str + history: str = "" + """字符串格式的列表。""" + created_at: int = 0 + updated_at: int = 0 + title: str = "" + persona_id: str = "" + + +INIT_SQL = """ +CREATE TABLE IF NOT EXISTS platform( + name VARCHAR(32), + count INTEGER, + timestamp INTEGER +); +CREATE TABLE IF NOT EXISTS llm( + name VARCHAR(32), + count INTEGER, + timestamp INTEGER +); +CREATE TABLE IF NOT EXISTS plugin( + name VARCHAR(32), + count INTEGER, + timestamp INTEGER +); +CREATE TABLE IF NOT EXISTS command( + name VARCHAR(32), + count INTEGER, + timestamp INTEGER +); +CREATE TABLE IF NOT EXISTS llm_history( + provider_type VARCHAR(32), + session_id VARCHAR(32), + content TEXT +); + +-- ATRI +CREATE TABLE IF NOT EXISTS atri_vision( + id TEXT, + url_or_path TEXT, + caption TEXT, + is_meme BOOLEAN, + keywords TEXT, + platform_name VARCHAR(32), + session_id VARCHAR(32), + sender_nickname VARCHAR(32), + timestamp INTEGER +); + +CREATE TABLE IF NOT EXISTS webchat_conversation( + user_id TEXT, -- 会话 id + cid TEXT, -- 对话 id + history TEXT, + created_at INTEGER, + updated_at INTEGER, + title TEXT, + persona_id TEXT +); + +PRAGMA encoding = 'UTF-8'; +""" + + +class SQLiteDatabase(): + def __init__(self, db_path: str) -> None: + super().__init__() + self.db_path = db_path + + sql = INIT_SQL + + # 初始化数据库 + self.conn = self._get_conn(self.db_path) + c = self.conn.cursor() + c.executescript(sql) + self.conn.commit() + + # 检查 webchat_conversation 的 title 字段是否存在 + c.execute( + """ + PRAGMA table_info(webchat_conversation) + """ + ) + res = c.fetchall() + has_title = False + has_persona_id = False + for row in res: + if row[1] == "title": + has_title = True + if row[1] == "persona_id": + has_persona_id = True + if not has_title: + c.execute( + """ + ALTER TABLE webchat_conversation ADD COLUMN title TEXT; + """ + ) + self.conn.commit() + if not has_persona_id: + c.execute( + """ + ALTER TABLE webchat_conversation ADD COLUMN persona_id TEXT; + """ + ) + self.conn.commit() + + c.close() + + def _get_conn(self, db_path: str) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path) + conn.text_factory = str + return conn + + def _exec_sql(self, sql: str, params: Tuple = None): + conn = self.conn + try: + c = self.conn.cursor() + except sqlite3.ProgrammingError: + conn = self._get_conn(self.db_path) + c = conn.cursor() + + if params: + c.execute(sql, params) + c.close() + else: + c.execute(sql) + c.close() + + conn.commit() + + def insert_platform_metrics(self, metrics: dict): + for k, v in metrics.items(): + self._exec_sql( + """ + INSERT INTO platform(name, count, timestamp) VALUES (?, ?, ?) + """, + (k, v, int(time.time())), + ) + + def insert_llm_metrics(self, metrics: dict): + for k, v in metrics.items(): + self._exec_sql( + """ + INSERT INTO llm(name, count, timestamp) VALUES (?, ?, ?) + """, + (k, v, int(time.time())), + ) + + def get_base_stats(self, offset_sec: int = 86400) -> Stats: + """获取 offset_sec 秒前到现在的基础统计数据""" + where_clause = f" WHERE timestamp >= {int(time.time()) - offset_sec}" + + try: + c = self.conn.cursor() + except sqlite3.ProgrammingError: + c = self._get_conn(self.db_path).cursor() + + c.execute( + """ + SELECT * FROM platform + """ + + where_clause + ) + + platform = [] + for row in c.fetchall(): + platform.append(Platform(*row)) + + c.close() + + return Stats(platform=platform) + + def get_total_message_count(self) -> int: + try: + c = self.conn.cursor() + except sqlite3.ProgrammingError: + c = self._get_conn(self.db_path).cursor() + + c.execute( + """ + SELECT SUM(count) FROM platform + """ + ) + res = c.fetchone() + c.close() + return res[0] + + def get_grouped_base_stats(self, offset_sec: int = 86400) -> Stats: + """获取 offset_sec 秒前到现在的基础统计数据(合并)""" + where_clause = f" WHERE timestamp >= {int(time.time()) - offset_sec}" + + try: + c = self.conn.cursor() + except sqlite3.ProgrammingError: + c = self._get_conn(self.db_path).cursor() + + c.execute( + """ + SELECT name, SUM(count), timestamp FROM platform + """ + + where_clause + + " GROUP BY name" + ) + + platform = [] + for row in c.fetchall(): + platform.append(Platform(*row)) + + c.close() + + return Stats(platform, [], []) + + def get_conversation_by_user_id(self, user_id: str, cid: str) -> Conversation: + try: + c = self.conn.cursor() + except sqlite3.ProgrammingError: + c = self._get_conn(self.db_path).cursor() + + c.execute( + """ + SELECT * FROM webchat_conversation WHERE user_id = ? AND cid = ? + """, + (user_id, cid), + ) + + res = c.fetchone() + c.close() + + if not res: + return + + return Conversation(*res) + + def new_conversation(self, user_id: str, cid: str): + history = "[]" + updated_at = int(time.time()) + created_at = updated_at + self._exec_sql( + """ + INSERT INTO webchat_conversation(user_id, cid, history, updated_at, created_at) VALUES (?, ?, ?, ?, ?) + """, + (user_id, cid, history, updated_at, created_at), + ) + + def get_conversations(self, user_id: str) -> Tuple: + try: + c = self.conn.cursor() + except sqlite3.ProgrammingError: + c = self._get_conn(self.db_path).cursor() + + c.execute( + """ + SELECT cid, created_at, updated_at, title, persona_id FROM webchat_conversation WHERE user_id = ? ORDER BY updated_at DESC + """, + (user_id,), + ) + + res = c.fetchall() + c.close() + conversations = [] + for row in res: + cid = row[0] + created_at = row[1] + updated_at = row[2] + title = row[3] + persona_id = row[4] + conversations.append( + Conversation("", cid, "[]", created_at, updated_at, title, persona_id) + ) + return conversations + + def update_conversation(self, user_id: str, cid: str, history: str): + """更新对话,并且同时更新时间""" + updated_at = int(time.time()) + self._exec_sql( + """ + UPDATE webchat_conversation SET history = ?, updated_at = ? WHERE user_id = ? AND cid = ? + """, + (history, updated_at, user_id, cid), + ) + + def update_conversation_title(self, user_id: str, cid: str, title: str): + self._exec_sql( + """ + UPDATE webchat_conversation SET title = ? WHERE user_id = ? AND cid = ? + """, + (title, user_id, cid), + ) + + def update_conversation_persona_id(self, user_id: str, cid: str, persona_id: str): + self._exec_sql( + """ + UPDATE webchat_conversation SET persona_id = ? WHERE user_id = ? AND cid = ? + """, + (persona_id, user_id, cid), + ) + + def delete_conversation(self, user_id: str, cid: str): + self._exec_sql( + """ + DELETE FROM webchat_conversation WHERE user_id = ? AND cid = ? + """, + (user_id, cid), + ) + + def get_all_conversations( + self, page: int = 1, page_size: int = 20 + ) -> Tuple[List[Dict[str, Any]], int]: + """获取所有对话,支持分页,按更新时间降序排序""" + try: + c = self.conn.cursor() + except sqlite3.ProgrammingError: + c = self._get_conn(self.db_path).cursor() + + try: + # 获取总记录数 + c.execute(""" + SELECT COUNT(*) FROM webchat_conversation + """) + total_count = c.fetchone()[0] + + # 计算偏移量 + offset = (page - 1) * page_size + + # 获取分页数据,按更新时间降序排序 + c.execute( + """ + SELECT user_id, cid, created_at, updated_at, title, persona_id + FROM webchat_conversation + ORDER BY updated_at DESC + LIMIT ? OFFSET ? + """, + (page_size, offset), + ) + + rows = c.fetchall() + + conversations = [] + + for row in rows: + user_id, cid, created_at, updated_at, title, persona_id = row + # 确保 cid 是字符串类型且至少有8个字符,否则使用一个默认值 + safe_cid = str(cid) if cid else "unknown" + display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid + + conversations.append( + { + "user_id": user_id or "", + "cid": safe_cid, + "title": title or f"对话 {display_cid}", + "persona_id": persona_id or "", + "created_at": created_at or 0, + "updated_at": updated_at or 0, + } + ) + + return conversations, total_count + + except Exception as _: + # 返回空列表和0,确保即使出错也有有效的返回值 + return [], 0 + finally: + c.close() + + def get_filtered_conversations( + self, + page: int = 1, + page_size: int = 20, + platforms: List[str] = None, + message_types: List[str] = None, + search_query: str = None, + exclude_ids: List[str] = None, + exclude_platforms: List[str] = None, + ) -> Tuple[List[Dict[str, Any]], int]: + """获取筛选后的对话列表""" + try: + c = self.conn.cursor() + except sqlite3.ProgrammingError: + c = self._get_conn(self.db_path).cursor() + + try: + # 构建查询条件 + where_clauses = [] + params = [] + + # 平台筛选 + if platforms and len(platforms) > 0: + platform_conditions = [] + for platform in platforms: + platform_conditions.append("user_id LIKE ?") + params.append(f"{platform}:%") + + if platform_conditions: + where_clauses.append(f"({' OR '.join(platform_conditions)})") + + # 消息类型筛选 + if message_types and len(message_types) > 0: + message_type_conditions = [] + for msg_type in message_types: + message_type_conditions.append("user_id LIKE ?") + params.append(f"%:{msg_type}:%") + + if message_type_conditions: + where_clauses.append(f"({' OR '.join(message_type_conditions)})") + + # 搜索关键词 + if search_query: + search_query = search_query.encode("unicode_escape").decode("utf-8") + where_clauses.append( + "(title LIKE ? OR user_id LIKE ? OR cid LIKE ? OR history LIKE ?)" + ) + search_param = f"%{search_query}%" + params.extend([search_param, search_param, search_param, search_param]) + + # 排除特定用户ID + if exclude_ids and len(exclude_ids) > 0: + for exclude_id in exclude_ids: + where_clauses.append("user_id NOT LIKE ?") + params.append(f"{exclude_id}%") + + # 排除特定平台 + if exclude_platforms and len(exclude_platforms) > 0: + for exclude_platform in exclude_platforms: + where_clauses.append("user_id NOT LIKE ?") + params.append(f"{exclude_platform}:%") + + # 构建完整的 WHERE 子句 + where_sql = " WHERE " + " AND ".join(where_clauses) if where_clauses else "" + + # 构建计数查询 + count_sql = f"SELECT COUNT(*) FROM webchat_conversation{where_sql}" + + # 获取总记录数 + c.execute(count_sql, params) + total_count = c.fetchone()[0] + + # 计算偏移量 + offset = (page - 1) * page_size + + # 构建分页数据查询 + data_sql = f""" + SELECT user_id, cid, created_at, updated_at, title, persona_id + FROM webchat_conversation + {where_sql} + ORDER BY updated_at DESC + LIMIT ? OFFSET ? + """ + query_params = params + [page_size, offset] + + # 获取分页数据 + c.execute(data_sql, query_params) + rows = c.fetchall() + + conversations = [] + + for row in rows: + user_id, cid, created_at, updated_at, title, persona_id = row + # 确保 cid 是字符串类型,否则使用一个默认值 + safe_cid = str(cid) if cid else "unknown" + display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid + + conversations.append( + { + "user_id": user_id or "", + "cid": safe_cid, + "title": title or f"对话 {display_cid}", + "persona_id": persona_id or "", + "created_at": created_at or 0, + "updated_at": updated_at or 0, + } + ) + + return conversations, total_count + + except Exception as _: + # 返回空列表和0,确保即使出错也有有效的返回值 + return [], 0 + finally: + c.close() diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index 49adb2781..88113d130 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -1,7 +1,233 @@ -"""指标数据""" +import uuid +from datetime import datetime, timezone from dataclasses import dataclass, field -from typing import List +from sqlmodel import ( + SQLModel, + Text, + JSON, + UniqueConstraint, + Field, +) +from typing import Optional, TypedDict + + +class PlatformStat(SQLModel, table=True): + """This class represents the statistics of bot usage across different platforms. + + Note: In astrbot v4, we moved `platform` table to here. + """ + + __tablename__ = "platform_stats" + + id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True}) + timestamp: datetime = Field(nullable=False) + platform_id: str = Field(nullable=False) + platform_type: str = Field(nullable=False) # such as "aiocqhttp", "slack", etc. + count: int = Field(default=0, nullable=False) + + __table_args__ = ( + UniqueConstraint( + "timestamp", + "platform_id", + "platform_type", + name="uix_platform_stats", + ), + ) + + +class ConversationV2(SQLModel, table=True): + __tablename__ = "conversations" + + inner_conversation_id: int = Field( + primary_key=True, sa_column_kwargs={"autoincrement": True} + ) + conversation_id: str = Field( + max_length=36, + nullable=False, + unique=True, + default_factory=lambda: str(uuid.uuid4()), + ) + platform_id: str = Field(nullable=False) + user_id: str = Field(nullable=False) + content: Optional[list] = Field(default=None, sa_type=JSON) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + sa_column_kwargs={"onupdate": datetime.now(timezone.utc)}, + ) + title: Optional[str] = Field(default=None, max_length=255) + persona_id: Optional[str] = Field(default=None) + + __table_args__ = ( + UniqueConstraint( + "conversation_id", + name="uix_conversation_id", + ), + ) + + +class Persona(SQLModel, table=True): + """Persona is a set of instructions for LLMs to follow. + + It can be used to customize the behavior of LLMs. + """ + + __tablename__ = "personas" + + id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True}) + persona_id: str = Field(max_length=255, nullable=False) + system_prompt: str = Field(sa_type=Text, nullable=False) + begin_dialogs: Optional[list] = Field(default=None, sa_type=JSON) + """a list of strings, each representing a dialog to start with""" + tools: Optional[list] = Field(default=None, sa_type=JSON) + """None means use ALL tools for default, empty list means no tools, otherwise a list of tool names.""" + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + sa_column_kwargs={"onupdate": datetime.now(timezone.utc)}, + ) + + __table_args__ = ( + UniqueConstraint( + "persona_id", + name="uix_persona_id", + ), + ) + + +class Preference(SQLModel, table=True): + """This class represents preferences for bots.""" + + __tablename__ = "preferences" + + id: int | None = Field( + default=None, primary_key=True, sa_column_kwargs={"autoincrement": True} + ) + scope: str = Field(nullable=False) + """Scope of the preference, such as 'global', 'umo', 'plugin'.""" + scope_id: str = Field(nullable=False) + """ID of the scope, such as 'global', 'umo', 'plugin_name'.""" + key: str = Field(nullable=False) + value: dict = Field(sa_type=JSON, nullable=False) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + sa_column_kwargs={"onupdate": datetime.now(timezone.utc)}, + ) + + __table_args__ = ( + UniqueConstraint( + "scope", + "scope_id", + "key", + name="uix_preference_scope_scope_id_key", + ), + ) + + +class PlatformMessageHistory(SQLModel, table=True): + """This class represents the message history for a specific platform. + + It is used to store messages that are not LLM-generated, such as user messages + or platform-specific messages. + """ + + __tablename__ = "platform_message_history" + + id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True}) + platform_id: str = Field(nullable=False) + user_id: str = Field(nullable=False) # An id of group, user in platform + sender_id: Optional[str] = Field(default=None) # ID of the sender in the platform + sender_name: Optional[str] = Field( + default=None + ) # Name of the sender in the platform + content: dict = Field(sa_type=JSON, nullable=False) # a message chain list + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + sa_column_kwargs={"onupdate": datetime.now(timezone.utc)}, + ) + + +class Attachment(SQLModel, table=True): + """This class represents attachments for messages in AstrBot. + + Attachments can be images, files, or other media types. + """ + + __tablename__ = "attachments" + + inner_attachment_id: int = Field( + primary_key=True, sa_column_kwargs={"autoincrement": True} + ) + attachment_id: str = Field( + max_length=36, + nullable=False, + unique=True, + default_factory=lambda: str(uuid.uuid4()), + ) + path: str = Field(nullable=False) # Path to the file on disk + type: str = Field(nullable=False) # Type of the file (e.g., 'image', 'file') + mime_type: str = Field(nullable=False) # MIME type of the file + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + sa_column_kwargs={"onupdate": datetime.now(timezone.utc)}, + ) + + __table_args__ = ( + UniqueConstraint( + "attachment_id", + name="uix_attachment_id", + ), + ) + + +@dataclass +class Conversation: + """LLM 对话类 + + 对于 WebChat,history 存储了包括指令、回复、图片等在内的所有消息。 + 对于其他平台的聊天,不存储非 LLM 的回复(因为考虑到已经存储在各自的平台上)。 + + 在 v4.0.0 版本及之后,WebChat 的历史记录被迁移至 `PlatformMessageHistory` 表中, + """ + + platform_id: str + user_id: str + cid: str + """对话 ID, 是 uuid 格式的字符串""" + history: str = "" + """字符串格式的对话列表。""" + title: str | None = "" + persona_id: str | None = "" + created_at: int = 0 + updated_at: int = 0 + + +class Personality(TypedDict): + """LLM 人格类。 + + 在 v4.0.0 版本及之后,推荐使用上面的 Persona 类。并且, mood_imitation_dialogs 字段已被废弃。 + """ + + prompt: str = "" + name: str = "" + begin_dialogs: list[str] = [] + mood_imitation_dialogs: list[str] = [] + """情感模拟对话预设。在 v4.0.0 版本及之后,已被废弃。""" + tools: list[str] | None = None + """工具列表。None 表示使用所有工具,空列表表示不使用任何工具""" + + # cache + _begin_dialogs_processed: list[dict] = [] + _mood_imitation_dialogs_processed: str = "" + + +# ==== +# Deprecated, and will be removed in future versions. +# ==== @dataclass @@ -13,77 +239,6 @@ class Platform: timestamp: int -@dataclass -class Provider: - """供应商使用统计数据""" - - name: str - count: int - timestamp: int - - -@dataclass -class Plugin: - """插件使用统计数据""" - - name: str - count: int - timestamp: int - - -@dataclass -class Command: - """命令使用统计数据""" - - name: str - count: int - timestamp: int - - @dataclass class Stats: - platform: List[Platform] = field(default_factory=list) - command: List[Command] = field(default_factory=list) - llm: List[Provider] = field(default_factory=list) - - -@dataclass -class LLMHistory: - """LLM 聊天时持久化的信息""" - - provider_type: str - session_id: str - content: str - - -@dataclass -class ATRIVision: - """Deprecated""" - - id: str - url_or_path: str - caption: str - is_meme: bool - keywords: List[str] - platform_name: str - session_id: str - sender_nickname: str - timestamp: int = -1 - - -@dataclass -class Conversation: - """LLM 对话存储 - - 对于网页聊天,history 存储了包括指令、回复、图片等在内的所有消息。 - 对于其他平台的聊天,不存储非 LLM 的回复(因为考虑到已经存储在各自的平台上)。 - """ - - user_id: str - cid: str - history: str = "" - """字符串格式的列表。""" - created_at: int = 0 - updated_at: int = 0 - title: str = "" - persona_id: str = "" + platform: list[Platform] = field(default_factory=list) diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index 2abba1de9..418b35761 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -1,567 +1,542 @@ -import sqlite3 -import os -import time -from astrbot.core.db.po import Platform, Stats, LLMHistory, ATRIVision, Conversation -from . import BaseDatabase -from typing import Tuple, List, Dict, Any +import asyncio +import typing as T +import threading +from datetime import datetime, timedelta +from astrbot.core.db import BaseDatabase +from astrbot.core.db.po import ( + ConversationV2, + PlatformStat, + PlatformMessageHistory, + Attachment, + Persona, + Preference, + Stats as DeprecatedStats, + Platform as DeprecatedPlatformStat, + SQLModel, +) + +from sqlalchemy import select, update, delete, text +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql import func + +NOT_GIVEN = T.TypeVar("NOT_GIVEN") class SQLiteDatabase(BaseDatabase): def __init__(self, db_path: str) -> None: - super().__init__() self.db_path = db_path + self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}" + self.inited = False + super().__init__() - with open( - os.path.dirname(__file__) + "/sqlite_init.sql", "r", encoding="utf-8" - ) as f: - sql = f.read() + async def initialize(self) -> None: + """Initialize the database by creating tables if they do not exist.""" + async with self.engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + await conn.commit() - # 初始化数据库 - self.conn = self._get_conn(self.db_path) - c = self.conn.cursor() - c.executescript(sql) - self.conn.commit() + # ==== + # Platform Statistics + # ==== - # 检查 webchat_conversation 的 title 字段是否存在 - c.execute( - """ - PRAGMA table_info(webchat_conversation) - """ - ) - res = c.fetchall() - has_title = False - has_persona_id = False - for row in res: - if row[1] == "title": - has_title = True - if row[1] == "persona_id": - has_persona_id = True - if not has_title: - c.execute( - """ - ALTER TABLE webchat_conversation ADD COLUMN title TEXT; - """ - ) - self.conn.commit() - if not has_persona_id: - c.execute( - """ - ALTER TABLE webchat_conversation ADD COLUMN persona_id TEXT; - """ - ) - self.conn.commit() - - c.close() - - def _get_conn(self, db_path: str) -> sqlite3.Connection: - conn = sqlite3.connect(self.db_path) - conn.text_factory = str - return conn - - def _exec_sql(self, sql: str, params: Tuple = None): - conn = self.conn - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - conn = self._get_conn(self.db_path) - c = conn.cursor() - - if params: - c.execute(sql, params) - c.close() - else: - c.execute(sql) - c.close() - - conn.commit() - - def insert_platform_metrics(self, metrics: dict): - for k, v in metrics.items(): - self._exec_sql( - """ - INSERT INTO platform(name, count, timestamp) VALUES (?, ?, ?) - """, - (k, v, int(time.time())), - ) - - def insert_plugin_metrics(self, metrics: dict): - pass - - def insert_command_metrics(self, metrics: dict): - for k, v in metrics.items(): - self._exec_sql( - """ - INSERT INTO command(name, count, timestamp) VALUES (?, ?, ?) - """, - (k, v, int(time.time())), - ) - - def insert_llm_metrics(self, metrics: dict): - for k, v in metrics.items(): - self._exec_sql( - """ - INSERT INTO llm(name, count, timestamp) VALUES (?, ?, ?) - """, - (k, v, int(time.time())), - ) - - def update_llm_history(self, session_id: str, content: str, provider_type: str): - res = self.get_llm_history(session_id, provider_type) - if res: - self._exec_sql( - """ - UPDATE llm_history SET content = ? WHERE session_id = ? AND provider_type = ? - """, - (content, session_id, provider_type), - ) - else: - self._exec_sql( - """ - INSERT INTO llm_history(provider_type, session_id, content) VALUES (?, ?, ?) - """, - (provider_type, session_id, content), - ) - - def get_llm_history( - self, session_id: str = None, provider_type: str = None - ) -> Tuple: - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - conditions = [] - params = [] - - if session_id: - conditions.append("session_id = ?") - params.append(session_id) - - if provider_type: - conditions.append("provider_type = ?") - params.append(provider_type) - - sql = "SELECT * FROM llm_history" - if conditions: - sql += " WHERE " + " AND ".join(conditions) - - c.execute(sql, params) - - res = c.fetchall() - histories = [] - for row in res: - histories.append(LLMHistory(*row)) - c.close() - return histories - - def get_base_stats(self, offset_sec: int = 86400) -> Stats: - """获取 offset_sec 秒前到现在的基础统计数据""" - where_clause = f" WHERE timestamp >= {int(time.time()) - offset_sec}" - - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - c.execute( - """ - SELECT * FROM platform - """ - + where_clause - ) - - platform = [] - for row in c.fetchall(): - platform.append(Platform(*row)) - - # c.execute( - # ''' - # SELECT * FROM command - # ''' + where_clause - # ) - - # command = [] - # for row in c.fetchall(): - # command.append(Command(*row)) - - # c.execute( - # ''' - # SELECT * FROM llm - # ''' + where_clause - # ) - - # llm = [] - # for row in c.fetchall(): - # llm.append(Provider(*row)) - - c.close() - - return Stats(platform, [], []) - - def get_total_message_count(self) -> int: - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - c.execute( - """ - SELECT SUM(count) FROM platform - """ - ) - res = c.fetchone() - c.close() - return res[0] - - def get_grouped_base_stats(self, offset_sec: int = 86400) -> Stats: - """获取 offset_sec 秒前到现在的基础统计数据(合并)""" - where_clause = f" WHERE timestamp >= {int(time.time()) - offset_sec}" - - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - c.execute( - """ - SELECT name, SUM(count), timestamp FROM platform - """ - + where_clause - + " GROUP BY name" - ) - - platform = [] - for row in c.fetchall(): - platform.append(Platform(*row)) - - c.close() - - return Stats(platform, [], []) - - def get_conversation_by_user_id(self, user_id: str, cid: str) -> Conversation: - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - c.execute( - """ - SELECT * FROM webchat_conversation WHERE user_id = ? AND cid = ? - """, - (user_id, cid), - ) - - res = c.fetchone() - c.close() - - if not res: - return - - return Conversation(*res) - - def new_conversation(self, user_id: str, cid: str): - history = "[]" - updated_at = int(time.time()) - created_at = updated_at - self._exec_sql( - """ - INSERT INTO webchat_conversation(user_id, cid, history, updated_at, created_at) VALUES (?, ?, ?, ?, ?) - """, - (user_id, cid, history, updated_at, created_at), - ) - - def get_conversations(self, user_id: str) -> Tuple: - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - c.execute( - """ - SELECT cid, created_at, updated_at, title, persona_id FROM webchat_conversation WHERE user_id = ? ORDER BY updated_at DESC - """, - (user_id,), - ) - - res = c.fetchall() - c.close() - conversations = [] - for row in res: - cid = row[0] - created_at = row[1] - updated_at = row[2] - title = row[3] - persona_id = row[4] - conversations.append( - Conversation("", cid, "[]", created_at, updated_at, title, persona_id) - ) - return conversations - - def update_conversation(self, user_id: str, cid: str, history: str): - """更新对话,并且同时更新时间""" - updated_at = int(time.time()) - self._exec_sql( - """ - UPDATE webchat_conversation SET history = ?, updated_at = ? WHERE user_id = ? AND cid = ? - """, - (history, updated_at, user_id, cid), - ) - - def update_conversation_title(self, user_id: str, cid: str, title: str): - self._exec_sql( - """ - UPDATE webchat_conversation SET title = ? WHERE user_id = ? AND cid = ? - """, - (title, user_id, cid), - ) - - def update_conversation_persona_id(self, user_id: str, cid: str, persona_id: str): - self._exec_sql( - """ - UPDATE webchat_conversation SET persona_id = ? WHERE user_id = ? AND cid = ? - """, - (persona_id, user_id, cid), - ) - - def delete_conversation(self, user_id: str, cid: str): - self._exec_sql( - """ - DELETE FROM webchat_conversation WHERE user_id = ? AND cid = ? - """, - (user_id, cid), - ) - - def insert_atri_vision_data(self, vision: ATRIVision): - ts = int(time.time()) - keywords = ",".join(vision.keywords) - self._exec_sql( - """ - INSERT INTO atri_vision(id, url_or_path, caption, is_meme, keywords, platform_name, session_id, sender_nickname, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - vision.id, - vision.url_or_path, - vision.caption, - vision.is_meme, - keywords, - vision.platform_name, - vision.session_id, - vision.sender_nickname, - ts, - ), - ) - - def get_atri_vision_data(self) -> Tuple: - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - c.execute( - """ - SELECT * FROM atri_vision - """ - ) - - res = c.fetchall() - visions = [] - for row in res: - visions.append(ATRIVision(*row)) - c.close() - return visions - - def get_atri_vision_data_by_path_or_id( - self, url_or_path: str, id: str - ) -> ATRIVision: - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - c.execute( - """ - SELECT * FROM atri_vision WHERE url_or_path = ? OR id = ? - """, - (url_or_path, id), - ) - - res = c.fetchone() - c.close() - if res: - return ATRIVision(*res) - return None - - def get_all_conversations( - self, page: int = 1, page_size: int = 20 - ) -> Tuple[List[Dict[str, Any]], int]: - """获取所有对话,支持分页,按更新时间降序排序""" - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - try: - # 获取总记录数 - c.execute(""" - SELECT COUNT(*) FROM webchat_conversation - """) - total_count = c.fetchone()[0] - - # 计算偏移量 - offset = (page - 1) * page_size - - # 获取分页数据,按更新时间降序排序 - c.execute( - """ - SELECT user_id, cid, created_at, updated_at, title, persona_id - FROM webchat_conversation - ORDER BY updated_at DESC - LIMIT ? OFFSET ? - """, - (page_size, offset), - ) - - rows = c.fetchall() - - conversations = [] - - for row in rows: - user_id, cid, created_at, updated_at, title, persona_id = row - # 确保 cid 是字符串类型且至少有8个字符,否则使用一个默认值 - safe_cid = str(cid) if cid else "unknown" - display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid - - conversations.append( - { - "user_id": user_id or "", - "cid": safe_cid, - "title": title or f"对话 {display_cid}", - "persona_id": persona_id or "", - "created_at": created_at or 0, - "updated_at": updated_at or 0, - } - ) - - return conversations, total_count - - except Exception as _: - # 返回空列表和0,确保即使出错也有有效的返回值 - return [], 0 - finally: - c.close() - - def get_filtered_conversations( + async def insert_platform_stats( self, - page: int = 1, - page_size: int = 20, - platforms: List[str] = None, - message_types: List[str] = None, - search_query: str = None, - exclude_ids: List[str] = None, - exclude_platforms: List[str] = None, - ) -> Tuple[List[Dict[str, Any]], int]: - """获取筛选后的对话列表""" - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - try: - # 构建查询条件 - where_clauses = [] - params = [] - - # 平台筛选 - if platforms and len(platforms) > 0: - platform_conditions = [] - for platform in platforms: - platform_conditions.append("user_id LIKE ?") - params.append(f"{platform}:%") - - if platform_conditions: - where_clauses.append(f"({' OR '.join(platform_conditions)})") - - # 消息类型筛选 - if message_types and len(message_types) > 0: - message_type_conditions = [] - for msg_type in message_types: - message_type_conditions.append("user_id LIKE ?") - params.append(f"%:{msg_type}:%") - - if message_type_conditions: - where_clauses.append(f"({' OR '.join(message_type_conditions)})") - - # 搜索关键词 - if search_query: - search_query = search_query.encode("unicode_escape").decode("utf-8") - where_clauses.append( - "(title LIKE ? OR user_id LIKE ? OR cid LIKE ? OR history LIKE ?)" - ) - search_param = f"%{search_query}%" - params.extend([search_param, search_param, search_param, search_param]) - - # 排除特定用户ID - if exclude_ids and len(exclude_ids) > 0: - for exclude_id in exclude_ids: - where_clauses.append("user_id NOT LIKE ?") - params.append(f"{exclude_id}%") - - # 排除特定平台 - if exclude_platforms and len(exclude_platforms) > 0: - for exclude_platform in exclude_platforms: - where_clauses.append("user_id NOT LIKE ?") - params.append(f"{exclude_platform}:%") - - # 构建完整的 WHERE 子句 - where_sql = " WHERE " + " AND ".join(where_clauses) if where_clauses else "" - - # 构建计数查询 - count_sql = f"SELECT COUNT(*) FROM webchat_conversation{where_sql}" - - # 获取总记录数 - c.execute(count_sql, params) - total_count = c.fetchone()[0] - - # 计算偏移量 - offset = (page - 1) * page_size - - # 构建分页数据查询 - data_sql = f""" - SELECT user_id, cid, created_at, updated_at, title, persona_id - FROM webchat_conversation - {where_sql} - ORDER BY updated_at DESC - LIMIT ? OFFSET ? - """ - query_params = params + [page_size, offset] - - # 获取分页数据 - c.execute(data_sql, query_params) - rows = c.fetchall() - - conversations = [] - - for row in rows: - user_id, cid, created_at, updated_at, title, persona_id = row - # 确保 cid 是字符串类型,否则使用一个默认值 - safe_cid = str(cid) if cid else "unknown" - display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid - - conversations.append( + platform_id: str, + platform_type: str, + count: int = 1, + timestamp: datetime = None, + ) -> None: + """Insert a new platform statistic record.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + if timestamp is None: + timestamp = datetime.now().replace( + minute=0, second=0, microsecond=0 + ) + current_hour = timestamp + await session.execute( + text(""" + INSERT INTO platform_stats (timestamp, platform_id, platform_type, count) + VALUES (:timestamp, :platform_id, :platform_type, :count) + ON CONFLICT(timestamp, platform_id, platform_type) DO UPDATE SET + count = platform_stats.count + EXCLUDED.count + """), { - "user_id": user_id or "", - "cid": safe_cid, - "title": title or f"对话 {display_cid}", - "persona_id": persona_id or "", - "created_at": created_at or 0, - "updated_at": updated_at or 0, - } + "timestamp": current_hour, + "platform_id": platform_id, + "platform_type": platform_type, + "count": count, + }, ) - return conversations, total_count + async def count_platform_stats(self) -> int: + """Count the number of platform statistics records.""" + async with self.get_db() as session: + session: AsyncSession + result = await session.execute( + select(func.count(PlatformStat.platform_id)).select_from(PlatformStat) + ) + count = result.scalar_one_or_none() + return count if count is not None else 0 - except Exception as _: - # 返回空列表和0,确保即使出错也有有效的返回值 - return [], 0 - finally: - c.close() + async def get_platform_stats(self, offset_sec: int = 86400) -> T.List[PlatformStat]: + """Get platform statistics within the specified offset in seconds and group by platform_id.""" + async with self.get_db() as session: + session: AsyncSession + now = datetime.now() + start_time = now - timedelta(seconds=offset_sec) + result = await session.execute( + text(""" + SELECT * FROM platform_stats + WHERE timestamp >= :start_time + ORDER BY timestamp DESC + GROUP BY platform_id + """), + {"start_time": start_time}, + ) + return result.scalars().all() + + # ==== + # Conversation Management + # ==== + + async def get_conversations(self, user_id=None, platform_id=None): + async with self.get_db() as session: + session: AsyncSession + query = select(ConversationV2) + + if user_id: + query = query.where(ConversationV2.user_id == user_id) + if platform_id: + query = query.where(ConversationV2.platform_id == platform_id) + # order by + query = query.order_by(ConversationV2.created_at.desc()) + result = await session.execute(query) + + return result.scalars().all() + + async def get_conversation_by_id(self, cid): + async with self.get_db() as session: + session: AsyncSession + query = select(ConversationV2).where(ConversationV2.conversation_id == cid) + result = await session.execute(query) + return result.scalar_one_or_none() + + async def get_all_conversations(self, page=1, page_size=20): + async with self.get_db() as session: + session: AsyncSession + offset = (page - 1) * page_size + result = await session.execute( + select(ConversationV2) + .order_by(ConversationV2.created_at.desc()) + .offset(offset) + .limit(page_size) + ) + return result.scalars().all() + + async def get_filtered_conversations( + self, + page=1, + page_size=20, + platform_ids=None, + search_query="", + **kwargs, + ): + async with self.get_db() as session: + session: AsyncSession + # Build the base query with filters + base_query = select(ConversationV2) + + if platform_ids: + base_query = base_query.where( + ConversationV2.platform_id.in_(platform_ids) + ) + if search_query: + base_query = base_query.where( + ConversationV2.title.ilike(f"%{search_query}%") + ) + + # Get total count matching the filters + count_query = select(func.count()).select_from(base_query.subquery()) + total_count = await session.execute(count_query) + total = total_count.scalar_one() + + # Get paginated results + offset = (page - 1) * page_size + result_query = ( + base_query.order_by(ConversationV2.created_at.desc()) + .offset(offset) + .limit(page_size) + ) + result = await session.execute(result_query) + conversations = result.scalars().all() + + return conversations, total + + async def create_conversation( + self, + user_id, + platform_id, + content=None, + title=None, + persona_id=None, + cid=None, + created_at=None, + updated_at=None, + ): + kwargs = {} + if cid: + kwargs["conversation_id"] = cid + if created_at: + kwargs["created_at"] = created_at + if updated_at: + kwargs["updated_at"] = updated_at + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + new_conversation = ConversationV2( + user_id=user_id, + content=content or [], + platform_id=platform_id, + title=title, + persona_id=persona_id, + **kwargs, + ) + session.add(new_conversation) + return new_conversation + + async def update_conversation(self, cid, title=None, persona_id=None, content=None): + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + query = update(ConversationV2).where( + ConversationV2.conversation_id == cid + ) + values = {} + if title is not None: + values["title"] = title + if persona_id is not None: + values["persona_id"] = persona_id + if content is not None: + values["content"] = content + if not values: + return + query = query.values(**values) + await session.execute(query) + return await self.get_conversation_by_id(cid) + + async def delete_conversation(self, cid): + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + await session.execute( + delete(ConversationV2).where(ConversationV2.conversation_id == cid) + ) + + async def insert_platform_message_history( + self, + platform_id, + user_id, + content, + sender_id=None, + sender_name=None, + ): + """Insert a new platform message history record.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + new_history = PlatformMessageHistory( + platform_id=platform_id, + user_id=user_id, + content=content, + sender_id=sender_id, + sender_name=sender_name, + ) + session.add(new_history) + return new_history + + async def delete_platform_message_offset( + self, platform_id, user_id, offset_sec=86400 + ): + """Delete platform message history records older than the specified offset.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + now = datetime.now() + cutoff_time = now - timedelta(seconds=offset_sec) + await session.execute( + delete(PlatformMessageHistory).where( + PlatformMessageHistory.platform_id == platform_id, + PlatformMessageHistory.user_id == user_id, + PlatformMessageHistory.created_at < cutoff_time, + ) + ) + + async def get_platform_message_history( + self, platform_id, user_id, page=1, page_size=20 + ): + """Get platform message history records.""" + async with self.get_db() as session: + session: AsyncSession + offset = (page - 1) * page_size + query = ( + select(PlatformMessageHistory) + .where( + PlatformMessageHistory.platform_id == platform_id, + PlatformMessageHistory.user_id == user_id, + ) + .order_by(PlatformMessageHistory.created_at.desc()) + ) + result = await session.execute(query.offset(offset).limit(page_size)) + return result.scalars().all() + + async def insert_attachment(self, path, type, mime_type): + """Insert a new attachment record.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + new_attachment = Attachment( + path=path, + type=type, + mime_type=mime_type, + ) + session.add(new_attachment) + return new_attachment + + async def get_attachment_by_id(self, attachment_id): + """Get an attachment by its ID.""" + async with self.get_db() as session: + session: AsyncSession + query = select(Attachment).where(Attachment.id == attachment_id) + result = await session.execute(query) + return result.scalar_one_or_none() + + async def insert_persona( + self, persona_id, system_prompt, begin_dialogs=None, tools=None + ): + """Insert a new persona record.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + new_persona = Persona( + persona_id=persona_id, + system_prompt=system_prompt, + begin_dialogs=begin_dialogs or [], + tools=tools, + ) + session.add(new_persona) + return new_persona + + async def get_persona_by_id(self, persona_id): + """Get a persona by its ID.""" + async with self.get_db() as session: + session: AsyncSession + query = select(Persona).where(Persona.persona_id == persona_id) + result = await session.execute(query) + return result.scalar_one_or_none() + + async def get_personas(self): + """Get all personas for a specific bot.""" + async with self.get_db() as session: + session: AsyncSession + query = select(Persona) + result = await session.execute(query) + return result.scalars().all() + + async def update_persona( + self, persona_id, system_prompt=None, begin_dialogs=None, tools=NOT_GIVEN + ): + """Update a persona's system prompt or begin dialogs.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + query = update(Persona).where(Persona.persona_id == persona_id) + values = {} + if system_prompt is not None: + values["system_prompt"] = system_prompt + if begin_dialogs is not None: + values["begin_dialogs"] = begin_dialogs + if tools is not NOT_GIVEN: + values["tools"] = tools + if not values: + return + query = query.values(**values) + await session.execute(query) + return await self.get_persona_by_id(persona_id) + + async def delete_persona(self, persona_id): + """Delete a persona by its ID.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + await session.execute( + delete(Persona).where(Persona.persona_id == persona_id) + ) + + async def insert_preference_or_update(self, scope, scope_id, key, value): + """Insert a new preference record or update if it exists.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + query = select(Preference).where( + Preference.scope == scope, + Preference.scope_id == scope_id, + Preference.key == key, + ) + result = await session.execute(query) + existing_preference = result.scalar_one_or_none() + if existing_preference: + existing_preference.value = value + else: + new_preference = Preference( + scope=scope, scope_id=scope_id, key=key, value=value + ) + session.add(new_preference) + return existing_preference or new_preference + + async def get_preference(self, scope, scope_id, key): + """Get a preference by key.""" + async with self.get_db() as session: + session: AsyncSession + query = select(Preference).where( + Preference.scope == scope, + Preference.scope_id == scope_id, + Preference.key == key, + ) + result = await session.execute(query) + return result.scalar_one_or_none() + + async def get_preferences(self, scope, scope_id=None, key=None): + """Get all preferences for a specific scope ID or key.""" + async with self.get_db() as session: + session: AsyncSession + query = select(Preference).where(Preference.scope == scope) + if scope_id is not None: + query = query.where(Preference.scope_id == scope_id) + if key is not None: + query = query.where(Preference.key == key) + result = await session.execute(query) + return result.scalars().all() + + async def remove_preference(self, scope, scope_id, key): + """Remove a preference by scope ID and key.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + await session.execute( + delete(Preference).where( + Preference.scope == scope, + Preference.scope_id == scope_id, + Preference.key == key, + ) + ) + await session.commit() + + async def clear_preferences(self, scope, scope_id): + """Clear all preferences for a specific scope ID.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + await session.execute( + delete(Preference).where( + Preference.scope == scope, Preference.scope_id == scope_id + ) + ) + await session.commit() + + # ==== + # Deprecated Methods + # ==== + + def get_base_stats(self, offset_sec=86400): + """Get base statistics within the specified offset in seconds.""" + + async def _inner(): + async with self.get_db() as session: + session: AsyncSession + now = datetime.now() + start_time = now - timedelta(seconds=offset_sec) + result = await session.execute( + select(PlatformStat).where(PlatformStat.timestamp >= start_time) + ) + all_datas = result.scalars().all() + deprecated_stats = DeprecatedStats() + for data in all_datas: + deprecated_stats.platform.append( + DeprecatedPlatformStat( + name=data.platform_id, + count=data.count, + timestamp=data.timestamp.timestamp(), + ) + ) + return deprecated_stats + + result = None + + def runner(): + nonlocal result + result = asyncio.run(_inner()) + + t = threading.Thread(target=runner) + t.start() + t.join() + return result + + def get_total_message_count(self): + """Get the total message count from platform statistics.""" + + async def _inner(): + async with self.get_db() as session: + session: AsyncSession + result = await session.execute( + select(func.sum(PlatformStat.count)).select_from(PlatformStat) + ) + total_count = result.scalar_one_or_none() + return total_count if total_count is not None else 0 + + result = None + + def runner(): + nonlocal result + result = asyncio.run(_inner()) + + t = threading.Thread(target=runner) + t.start() + t.join() + return result + + def get_grouped_base_stats(self, offset_sec=86400): + # group by platform_id + async def _inner(): + async with self.get_db() as session: + session: AsyncSession + now = datetime.now() + start_time = now - timedelta(seconds=offset_sec) + result = await session.execute( + select(PlatformStat.platform_id, func.sum(PlatformStat.count)) + .where(PlatformStat.timestamp >= start_time) + .group_by(PlatformStat.platform_id) + ) + grouped_stats = result.all() + deprecated_stats = DeprecatedStats() + for platform_id, count in grouped_stats: + deprecated_stats.platform.append( + DeprecatedPlatformStat( + name=platform_id, + count=count, + timestamp=start_time.timestamp(), + ) + ) + return deprecated_stats + + result = None + + def runner(): + nonlocal result + result = asyncio.run(_inner()) + + t = threading.Thread(target=runner) + t.start() + t.join() + return result diff --git a/astrbot/core/db/sqlite_init.sql b/astrbot/core/db/sqlite_init.sql deleted file mode 100644 index a1ebc54b5..000000000 --- a/astrbot/core/db/sqlite_init.sql +++ /dev/null @@ -1,50 +0,0 @@ -CREATE TABLE IF NOT EXISTS platform( - name VARCHAR(32), - count INTEGER, - timestamp INTEGER -); -CREATE TABLE IF NOT EXISTS llm( - name VARCHAR(32), - count INTEGER, - timestamp INTEGER -); -CREATE TABLE IF NOT EXISTS plugin( - name VARCHAR(32), - count INTEGER, - timestamp INTEGER -); -CREATE TABLE IF NOT EXISTS command( - name VARCHAR(32), - count INTEGER, - timestamp INTEGER -); -CREATE TABLE IF NOT EXISTS llm_history( - provider_type VARCHAR(32), - session_id VARCHAR(32), - content TEXT -); - --- ATRI -CREATE TABLE IF NOT EXISTS atri_vision( - id TEXT, - url_or_path TEXT, - caption TEXT, - is_meme BOOLEAN, - keywords TEXT, - platform_name VARCHAR(32), - session_id VARCHAR(32), - sender_nickname VARCHAR(32), - timestamp INTEGER -); - -CREATE TABLE IF NOT EXISTS webchat_conversation( - user_id TEXT, -- 会话 id - cid TEXT, -- 对话 id - history TEXT, - created_at INTEGER, - updated_at INTEGER, - title TEXT, - persona_id TEXT -); - -PRAGMA encoding = 'UTF-8'; \ No newline at end of file diff --git a/astrbot/core/db/vec_db/faiss_impl/vec_db.py b/astrbot/core/db/vec_db/faiss_impl/vec_db.py index 8d95c2501..bc23922ef 100644 --- a/astrbot/core/db/vec_db/faiss_impl/vec_db.py +++ b/astrbot/core/db/vec_db/faiss_impl/vec_db.py @@ -5,6 +5,7 @@ from .document_storage import DocumentStorage from .embedding_storage import EmbeddingStorage from ..base import Result, BaseVecDB from astrbot.core.provider.provider import EmbeddingProvider +from astrbot.core.provider.provider import RerankProvider class FaissVecDB(BaseVecDB): @@ -17,6 +18,7 @@ class FaissVecDB(BaseVecDB): doc_store_path: str, index_store_path: str, embedding_provider: EmbeddingProvider, + rerank_provider: RerankProvider | None = None, ): self.doc_store_path = doc_store_path self.index_store_path = index_store_path @@ -26,11 +28,14 @@ class FaissVecDB(BaseVecDB): embedding_provider.get_dim(), index_store_path ) self.embedding_provider = embedding_provider + self.rerank_provider = rerank_provider async def initialize(self): await self.document_storage.initialize() - async def insert(self, content: str, metadata: dict = None, id: str = None) -> int: + async def insert( + self, content: str, metadata: dict | None = None, id: str | None = None + ) -> int: """ 插入一条文本和其对应向量,自动生成 ID 并保持一致性。 """ @@ -53,7 +58,12 @@ class FaissVecDB(BaseVecDB): return int_id async def retrieve( - self, query: str, k: int = 5, fetch_k: int = 20, metadata_filters: dict = None + self, + query: str, + k: int = 5, + fetch_k: int = 20, + rerank: bool = False, + metadata_filters: dict | None = None, ) -> list[Result]: """ 搜索最相似的文档。 @@ -62,6 +72,7 @@ class FaissVecDB(BaseVecDB): query (str): 查询文本 k (int): 返回的最相似文档的数量 fetch_k (int): 在根据 metadata 过滤前从 FAISS 中获取的数量 + rerank (bool): 是否使用重排序。这需要在实例化时提供 rerank_provider, 如果未提供并且 rerank 为 True, 不会抛出异常。 metadata_filters (dict): 元数据过滤器 Returns: @@ -72,7 +83,6 @@ class FaissVecDB(BaseVecDB): vector=np.array([embedding]).astype("float32"), k=fetch_k if metadata_filters else k, ) - # TODO: rerank if len(indices[0]) == 0 or indices[0][0] == -1: return [] # normalize scores @@ -83,7 +93,7 @@ class FaissVecDB(BaseVecDB): ) if not fetched_docs: return [] - result_docs = [] + result_docs: list[Result] = [] idx_pos = {fetch_doc["id"]: idx for idx, fetch_doc in enumerate(fetched_docs)} for i, indice_idx in enumerate(indices[0]): @@ -93,7 +103,20 @@ class FaissVecDB(BaseVecDB): fetch_doc = fetched_docs[pos] score = scores[0][i] result_docs.append(Result(similarity=float(score), data=fetch_doc)) - return result_docs[:k] + + top_k_results = result_docs[:k] + + if rerank and self.rerank_provider: + documents = [doc.data["text"] for doc in top_k_results] + reranked_results = await self.rerank_provider.rerank(query, documents) + reranked_results = sorted( + reranked_results, key=lambda x: x.relevance_score, reverse=True + ) + top_k_results = [ + top_k_results[reranked_result.index] for reranked_result in reranked_results + ] + + return top_k_results async def delete(self, doc_id: int): """ diff --git a/astrbot/core/event_bus.py b/astrbot/core/event_bus.py index d4caa2910..2ae709396 100644 --- a/astrbot/core/event_bus.py +++ b/astrbot/core/event_bus.py @@ -16,30 +16,32 @@ from asyncio import Queue from astrbot.core.pipeline.scheduler import PipelineScheduler from astrbot.core import logger from .platform import AstrMessageEvent +from astrbot.core.astrbot_config_mgr import AstrBotConfigManager class EventBus: - """事件总线: 用于处理事件的分发和处理 + """用于处理事件的分发和处理""" - 维护一个异步队列, 来接受各种消息事件 - """ - - def __init__(self, event_queue: Queue, pipeline_scheduler: PipelineScheduler): + def __init__( + self, + event_queue: Queue, + pipeline_scheduler_mapping: dict[str, PipelineScheduler], + astrbot_config_mgr: AstrBotConfigManager = None, + ): self.event_queue = event_queue # 事件队列 - self.pipeline_scheduler = pipeline_scheduler # 管道调度器 + # abconf uuid -> scheduler + self.pipeline_scheduler_mapping = pipeline_scheduler_mapping + self.astrbot_config_mgr = astrbot_config_mgr async def dispatch(self): - """无限循环的调度函数, 从事件队列中获取新的事件, 打印日志并创建一个新的异步任务来执行管道调度器的处理逻辑""" while True: - event: AstrMessageEvent = ( - await self.event_queue.get() - ) # 从事件队列中获取新的事件 - self._print_event(event) # 打印日志 - asyncio.create_task( - self.pipeline_scheduler.execute(event) - ) # 创建新的异步任务来执行管道调度器的处理逻辑 + event: AstrMessageEvent = await self.event_queue.get() + conf_info = self.astrbot_config_mgr.get_conf_info(event.unified_msg_origin) + self._print_event(event, conf_info["name"]) + scheduler = self.pipeline_scheduler_mapping.get(conf_info["id"]) + asyncio.create_task(scheduler.execute(event)) - def _print_event(self, event: AstrMessageEvent): + def _print_event(self, event: AstrMessageEvent, conf_name: str): """用于记录事件信息 Args: @@ -48,10 +50,10 @@ class EventBus: # 如果有发送者名称: [平台名] 发送者名称/发送者ID: 消息概要 if event.get_sender_name(): logger.info( - f"[{event.get_platform_name()}] {event.get_sender_name()}/{event.get_sender_id()}: {event.get_message_outline()}" + f"[{conf_name}] [{event.get_platform_id()}({event.get_platform_name()})] {event.get_sender_name()}/{event.get_sender_id()}: {event.get_message_outline()}" ) # 没有发送者名称: [平台名] 发送者ID: 消息概要 else: logger.info( - f"[{event.get_platform_name()}] {event.get_sender_id()}: {event.get_message_outline()}" + f"[{conf_name}] [{event.get_platform_id()}({event.get_platform_name()})] {event.get_sender_id()}: {event.get_message_outline()}" ) diff --git a/astrbot/core/persona_mgr.py b/astrbot/core/persona_mgr.py new file mode 100644 index 000000000..add3c74bc --- /dev/null +++ b/astrbot/core/persona_mgr.py @@ -0,0 +1,183 @@ +from astrbot.core.db import BaseDatabase +from astrbot.core.db.po import Persona, Personality +from astrbot.core.astrbot_config_mgr import AstrBotConfigManager +from astrbot.core.platform.message_session import MessageSession +from astrbot import logger + +DEFAULT_PERSONALITY = Personality( + prompt="You are a helpful and friendly assistant.", + name="default", + begin_dialogs=[], + mood_imitation_dialogs=[], + tools=None, + _begin_dialogs_processed=[], + _mood_imitation_dialogs_processed="", +) + + +class PersonaManager: + def __init__(self, db_helper: BaseDatabase, acm: AstrBotConfigManager): + self.db = db_helper + self.acm = acm + default_ps = acm.default_conf.get("provider_settings", {}) + self.default_persona: str = default_ps.get("default_personality", "default") + self.personas: list[Persona] = [] + self.selected_default_persona: Persona | None = None + + self.personas_v3: list[Personality] = [] + self.selected_default_persona_v3: Personality | None = None + self.persona_v3_config: list[dict] = [] + + async def initialize(self): + self.personas = await self.get_all_personas() + self.get_v3_persona_data() + logger.info(f"已加载 {len(self.personas)} 个人格。") + + async def get_persona(self, persona_id: str): + """获取指定 persona 的信息""" + persona = await self.db.get_persona_by_id(persona_id) + if not persona: + raise ValueError(f"Persona with ID {persona_id} does not exist.") + return persona + + async def get_default_persona_v3( + self, umo: str | MessageSession | None = None + ) -> Personality: + """获取默认 persona""" + cfg = self.acm.get_conf(umo) + default_persona_id = cfg.get("provider_settings", {}).get( + "default_personality", "default" + ) + if not default_persona_id or default_persona_id == "default": + return DEFAULT_PERSONALITY + try: + return next(p for p in self.personas_v3 if p["name"] == default_persona_id) + except Exception: + return DEFAULT_PERSONALITY + + async def delete_persona(self, persona_id: str): + """删除指定 persona""" + if not await self.db.get_persona_by_id(persona_id): + raise ValueError(f"Persona with ID {persona_id} does not exist.") + await self.db.delete_persona(persona_id) + self.personas = [p for p in self.personas if p.persona_id != persona_id] + self.get_v3_persona_data() + + async def update_persona( + self, + persona_id: str, + system_prompt: str = None, + begin_dialogs: list[str] = None, + tools: list[str] = None, + ): + """更新指定 persona 的信息。tools 参数为 None 时表示使用所有工具,空列表表示不使用任何工具""" + existing_persona = await self.db.get_persona_by_id(persona_id) + if not existing_persona: + raise ValueError(f"Persona with ID {persona_id} does not exist.") + persona = await self.db.update_persona( + persona_id, system_prompt, begin_dialogs, tools=tools + ) + if persona: + for i, p in enumerate(self.personas): + if p.persona_id == persona_id: + self.personas[i] = persona + break + self.get_v3_persona_data() + return persona + + async def get_all_personas(self) -> list[Persona]: + """获取所有 personas""" + return await self.db.get_personas() + + async def create_persona( + self, + persona_id: str, + system_prompt: str, + begin_dialogs: list[str] = None, + tools: list[str] = None, + ) -> Persona: + """创建新的 persona。tools 参数为 None 时表示使用所有工具,空列表表示不使用任何工具""" + if await self.db.get_persona_by_id(persona_id): + raise ValueError(f"Persona with ID {persona_id} already exists.") + new_persona = await self.db.insert_persona( + persona_id, system_prompt, begin_dialogs, tools=tools + ) + self.personas.append(new_persona) + self.get_v3_persona_data() + return new_persona + + def get_v3_persona_data( + self, + ) -> tuple[list[dict], list[Personality], Personality]: + """获取 AstrBot <4.0.0 版本的 persona 数据。 + + Returns: + - list[dict]: 包含 persona 配置的字典列表。 + - list[Personality]: 包含 Personality 对象的列表。 + - Personality: 默认选择的 Personality 对象。 + """ + v3_persona_config = [ + { + "prompt": persona.system_prompt, + "name": persona.persona_id, + "begin_dialogs": persona.begin_dialogs or [], + "mood_imitation_dialogs": [], # deprecated + "tools": persona.tools, + } + for persona in self.personas + ] + + personas_v3: list[Personality] = [] + selected_default_persona: Personality | None = None + + for persona_cfg in v3_persona_config: + begin_dialogs = persona_cfg.get("begin_dialogs", []) + bd_processed = [] + if begin_dialogs: + if len(begin_dialogs) % 2 != 0: + logger.error( + f"{persona_cfg['name']} 人格情景预设对话格式不对,条数应该为偶数。" + ) + begin_dialogs = [] + user_turn = True + for dialog in begin_dialogs: + bd_processed.append( + { + "role": "user" if user_turn else "assistant", + "content": dialog, + "_no_save": None, # 不持久化到 db + } + ) + user_turn = not user_turn + + try: + persona = Personality( + **persona_cfg, + _begin_dialogs_processed=bd_processed, + _mood_imitation_dialogs_processed="", # deprecated + ) + if persona["name"] == self.default_persona: + selected_default_persona = persona + personas_v3.append(persona) + except Exception as e: + logger.error(f"解析 Persona 配置失败:{e}") + + if not selected_default_persona and len(personas_v3) > 0: + # 默认选择第一个 + selected_default_persona = personas_v3[0] + + if not selected_default_persona: + selected_default_persona = DEFAULT_PERSONALITY + personas_v3.append(selected_default_persona) + + self.personas_v3 = personas_v3 + self.selected_default_persona_v3 = selected_default_persona + self.persona_v3_config = v3_persona_config + self.selected_default_persona = Persona( + persona_id=selected_default_persona["name"], + system_prompt=selected_default_persona["prompt"], + begin_dialogs=selected_default_persona["begin_dialogs"], + tools=selected_default_persona["tools"] or None, + ) + + return v3_persona_config, personas_v3, selected_default_persona diff --git a/astrbot/core/pipeline/__init__.py b/astrbot/core/pipeline/__init__.py index 3501a5271..29a324a1d 100644 --- a/astrbot/core/pipeline/__init__.py +++ b/astrbot/core/pipeline/__init__.py @@ -4,7 +4,6 @@ from astrbot.core.message.message_event_result import ( ) from .content_safety_check.stage import ContentSafetyCheckStage -from .platform_compatibility.stage import PlatformCompatibilityStage from .preprocess_stage.stage import PreProcessStage from .process_stage.stage import ProcessStage from .rate_limit_check.stage import RateLimitStage @@ -21,7 +20,6 @@ STAGES_ORDER = [ "SessionStatusCheckStage", # 检查会话是否整体启用 "RateLimitStage", # 检查会话是否超过频率限制 "ContentSafetyCheckStage", # 检查内容安全 - "PlatformCompatibilityStage", # 检查所有处理器的平台兼容性 "PreProcessStage", # 预处理 "ProcessStage", # 交由 Stars 处理(a.k.a 插件),或者 LLM 调用 "ResultDecorateStage", # 处理结果,比如添加回复前缀、t2i、转换为语音 等 @@ -34,7 +32,6 @@ __all__ = [ "SessionStatusCheckStage", "RateLimitStage", "ContentSafetyCheckStage", - "PlatformCompatibilityStage", "PreProcessStage", "ProcessStage", "ResultDecorateStage", diff --git a/astrbot/core/pipeline/context.py b/astrbot/core/pipeline/context.py index 0b9d9e533..803626aaa 100644 --- a/astrbot/core/pipeline/context.py +++ b/astrbot/core/pipeline/context.py @@ -1,14 +1,7 @@ -import inspect -import traceback -import typing as T from dataclasses import dataclass -from astrbot.core.config.astrbot_config import AstrBotConfig -from astrbot.core.platform.astr_message_event import AstrMessageEvent +from astrbot.core.config import AstrBotConfig from astrbot.core.star import PluginManager -from astrbot.api import logger -from astrbot.core.star.star_handler import star_handlers_registry, EventType -from astrbot.core.star.star import star_map -from astrbot.core.message.message_event_result import MessageEventResult, CommandResult +from .context_utils import call_handler, call_event_hook @dataclass @@ -17,97 +10,6 @@ class PipelineContext: astrbot_config: AstrBotConfig # AstrBot 配置对象 plugin_manager: PluginManager # 插件管理器对象 - - async def call_event_hook( - self, - event: AstrMessageEvent, - hook_type: EventType, - *args, - ) -> bool: - """调用事件钩子函数 - - Returns: - bool: 如果事件被终止,返回 True - """ - platform_id = event.get_platform_id() - handlers = star_handlers_registry.get_handlers_by_event_type( - hook_type, platform_id=platform_id - ) - for handler in handlers: - try: - logger.debug( - f"hook(on_llm_request) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}" - ) - await handler.handler(event, *args) - except BaseException: - logger.error(traceback.format_exc()) - - if event.is_stopped(): - logger.info( - f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了事件传播。" - ) - - return event.is_stopped() - - async def call_handler( - self, - event: AstrMessageEvent, - handler: T.Awaitable, - *args, - **kwargs, - ) -> T.AsyncGenerator[None, None]: - """执行事件处理函数并处理其返回结果 - - 该方法负责调用处理函数并处理不同类型的返回值。它支持两种类型的处理函数: - 1. 异步生成器: 实现洋葱模型,每次 yield 都会将控制权交回上层 - 2. 协程: 执行一次并处理返回值 - - Args: - ctx (PipelineContext): 消息管道上下文对象 - event (AstrMessageEvent): 事件对象 - handler (Awaitable): 事件处理函数 - - Returns: - AsyncGenerator[None, None]: 异步生成器,用于在管道中传递控制流 - """ - ready_to_call = None # 一个协程或者异步生成器 - - trace_ = None - - try: - ready_to_call = handler(event, *args, **kwargs) - except TypeError as _: - # 向下兼容 - trace_ = traceback.format_exc() - # 以前的 handler 会额外传入一个参数, 但是 context 对象实际上在插件实例中有一份 - ready_to_call = handler(event, self.plugin_manager.context, *args, **kwargs) - - if inspect.isasyncgen(ready_to_call): - _has_yielded = False - try: - async for ret in ready_to_call: - # 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码 - # 返回值只能是 MessageEventResult 或者 None(无返回值) - _has_yielded = True - if isinstance(ret, (MessageEventResult, CommandResult)): - # 如果返回值是 MessageEventResult, 设置结果并继续 - event.set_result(ret) - yield - else: - # 如果返回值是 None, 则不设置结果并继续 - # 继续执行后续阶段 - yield ret - if not _has_yielded: - # 如果这个异步生成器没有执行到 yield 分支 - yield - except Exception as e: - logger.error(f"Previous Error: {trace_}") - raise e - elif inspect.iscoroutine(ready_to_call): - # 如果只是一个协程, 直接执行 - ret = await ready_to_call - if isinstance(ret, (MessageEventResult, CommandResult)): - event.set_result(ret) - yield - else: - yield ret + astrbot_config_id: str + call_handler = call_handler + call_event_hook = call_event_hook diff --git a/astrbot/core/pipeline/context_utils.py b/astrbot/core/pipeline/context_utils.py new file mode 100644 index 000000000..02e87e6d0 --- /dev/null +++ b/astrbot/core/pipeline/context_utils.py @@ -0,0 +1,98 @@ +import inspect +import traceback +import typing as T +from astrbot import logger +from astrbot.core.star.star_handler import star_handlers_registry, EventType +from astrbot.core.star.star import star_map +from astrbot.core.message.message_event_result import MessageEventResult, CommandResult +from astrbot.core.platform.astr_message_event import AstrMessageEvent + + +async def call_handler( + event: AstrMessageEvent, + handler: T.Awaitable, + *args, + **kwargs, +) -> T.AsyncGenerator[T.Any, None]: + """执行事件处理函数并处理其返回结果 + + 该方法负责调用处理函数并处理不同类型的返回值。它支持两种类型的处理函数: + 1. 异步生成器: 实现洋葱模型,每次 yield 都会将控制权交回上层 + 2. 协程: 执行一次并处理返回值 + + Args: + event (AstrMessageEvent): 事件对象 + handler (Awaitable): 事件处理函数 + + Returns: + AsyncGenerator[None, None]: 异步生成器,用于在管道中传递控制流 + """ + ready_to_call = None # 一个协程或者异步生成器 + + trace_ = None + + try: + ready_to_call = handler(event, *args, **kwargs) + except TypeError: + logger.error("处理函数参数不匹配,请检查 handler 的定义。", exc_info=True) + + if inspect.isasyncgen(ready_to_call): + _has_yielded = False + try: + async for ret in ready_to_call: + # 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码 + # 返回值只能是 MessageEventResult 或者 None(无返回值) + _has_yielded = True + if isinstance(ret, (MessageEventResult, CommandResult)): + # 如果返回值是 MessageEventResult, 设置结果并继续 + event.set_result(ret) + yield + else: + # 如果返回值是 None, 则不设置结果并继续 + # 继续执行后续阶段 + yield ret + if not _has_yielded: + # 如果这个异步生成器没有执行到 yield 分支 + yield + except Exception as e: + logger.error(f"Previous Error: {trace_}") + raise e + elif inspect.iscoroutine(ready_to_call): + # 如果只是一个协程, 直接执行 + ret = await ready_to_call + if isinstance(ret, (MessageEventResult, CommandResult)): + event.set_result(ret) + yield + else: + yield ret + + +async def call_event_hook( + event: AstrMessageEvent, + hook_type: EventType, + *args, + **kwargs, +) -> bool: + """调用事件钩子函数 + + Returns: + bool: 如果事件被终止,返回 True + # """ + handlers = star_handlers_registry.get_handlers_by_event_type( + hook_type, plugins_name=event.plugins_name + ) + for handler in handlers: + try: + logger.debug( + f"hook({hook_type.name}) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}" + ) + await handler.handler(event, *args, **kwargs) + except BaseException: + logger.error(traceback.format_exc()) + + if event.is_stopped(): + logger.info( + f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了事件传播。" + ) + + return event.is_stopped() diff --git a/astrbot/core/pipeline/platform_compatibility/stage.py b/astrbot/core/pipeline/platform_compatibility/stage.py deleted file mode 100644 index 644912c26..000000000 --- a/astrbot/core/pipeline/platform_compatibility/stage.py +++ /dev/null @@ -1,56 +0,0 @@ -from ..stage import Stage, register_stage -from ..context import PipelineContext -from typing import Union, AsyncGenerator -from astrbot.core.platform.astr_message_event import AstrMessageEvent -from astrbot.core.star.star import star_map -from astrbot.core.star.star_handler import StarHandlerMetadata -from astrbot.core import logger - - -@register_stage -class PlatformCompatibilityStage(Stage): - """检查所有处理器的平台兼容性。 - - 这个阶段会检查所有处理器是否在当前平台启用,如果未启用则设置platform_compatible属性为False。 - """ - - async def initialize(self, ctx: PipelineContext) -> None: - """初始化平台兼容性检查阶段 - - Args: - ctx (PipelineContext): 消息管道上下文对象, 包括配置和插件管理器 - """ - self.ctx = ctx - - async def process( - self, event: AstrMessageEvent - ) -> Union[None, AsyncGenerator[None, None]]: - # 获取当前平台ID - platform_id = event.get_platform_id() - - # 获取已激活的处理器 - activated_handlers = event.get_extra("activated_handlers") - if activated_handlers is None: - activated_handlers = [] - - # 标记不兼容的处理器 - for handler in activated_handlers: - if not isinstance(handler, StarHandlerMetadata): - continue - # 检查处理器是否在当前平台启用 - enabled = handler.is_enabled_for_platform(platform_id) - if not enabled: - if handler.handler_module_path in star_map: - plugin_name = star_map[handler.handler_module_path].name - logger.debug( - f"[PlatformCompatibilityStage] 插件 {plugin_name} 在平台 {platform_id} 未启用,标记处理器 {handler.handler_name} 为平台不兼容" - ) - # 设置处理器为平台不兼容状态 - # TODO: 更好的标记方式 - handler.platform_compatible = False - else: - # 确保处理器为平台兼容状态 - handler.platform_compatible = True - - # 更新已激活的处理器列表 - event.set_extra("activated_handlers", activated_handlers) diff --git a/astrbot/core/pipeline/process_stage/method/llm_request.py b/astrbot/core/pipeline/process_stage/method/llm_request.py index c81a5df51..c07ba0d70 100644 --- a/astrbot/core/pipeline/process_stage/method/llm_request.py +++ b/astrbot/core/pipeline/process_stage/method/llm_request.py @@ -20,12 +20,270 @@ from astrbot.core.provider.entities import ( LLMResponse, ProviderRequest, ) +from astrbot.core.agent.hooks import BaseAgentRunHooks +from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner +from astrbot.core.agent.run_context import ContextWrapper +from astrbot.core.agent.tool import ToolSet, FunctionTool +from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor +from astrbot.core.agent.handoff import HandoffTool from astrbot.core.star.session_llm_manager import SessionServiceManager from astrbot.core.star.star_handler import EventType from astrbot.core.utils.metrics import Metric -from ...context import PipelineContext -from ..agent_runner.tool_loop_agent import ToolLoopAgent +from ...context import PipelineContext, call_event_hook, call_handler from ..stage import Stage +from astrbot.core.provider.register import llm_tools +from astrbot.core.star.star_handler import star_map +from astrbot.core.astr_agent_context import AstrAgentContext + +try: + import mcp +except (ModuleNotFoundError, ImportError): + logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。") + + +AgentContextWrapper = ContextWrapper[AstrAgentContext] +AgentRunner = ToolLoopAgentRunner[AgentContextWrapper] + + +class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): + @classmethod + async def execute(cls, tool, run_context, **tool_args): + """执行函数调用。 + + Args: + event (AstrMessageEvent): 事件对象, 当 origin 为 local 时必须提供。 + **kwargs: 函数调用的参数。 + + Returns: + AsyncGenerator[None | mcp.types.CallToolResult, None] + """ + if isinstance(tool, HandoffTool): + async for r in cls._execute_handoff(tool, run_context, **tool_args): + yield r + return + + if tool.origin == "local": + async for r in cls._execute_local(tool, run_context, **tool_args): + yield r + return + + elif tool.origin == "mcp": + async for r in cls._execute_mcp(tool, run_context, **tool_args): + yield r + return + + raise Exception(f"Unknown function origin: {tool.origin}") + + @classmethod + async def _execute_handoff( + cls, + tool: HandoffTool, + run_context: ContextWrapper[AstrAgentContext], + **tool_args, + ): + input_ = tool_args.get("input", "agent") + agent_runner = AgentRunner() + + # make toolset for the agent + tools = tool.agent.tools + if tools: + toolset = ToolSet() + for t in tools: + if isinstance(t, str): + _t = llm_tools.get_func(t) + if _t: + toolset.add_tool(_t) + elif isinstance(t, FunctionTool): + toolset.add_tool(t) + else: + toolset = None + + request = ProviderRequest( + prompt=input_, + system_prompt=tool.description, + image_urls=[], # 暂时不传递原始 agent 的上下文 + contexts=[], # 暂时不传递原始 agent 的上下文 + func_tool=toolset, + ) + astr_agent_ctx = AstrAgentContext( + provider=run_context.context.provider, + first_provider_request=run_context.context.first_provider_request, + curr_provider_request=request, + streaming=run_context.context.streaming, + ) + + logger.debug(f"正在将任务委托给 Agent: {tool.agent.name}, input: {input_}") + await run_context.event.send( + MessageChain().message("✨ 正在将任务委托给 Agent: " + tool.agent.name) + ) + + await agent_runner.reset( + provider=run_context.context.provider, + request=request, + run_context=AgentContextWrapper( + context=astr_agent_ctx, event=run_context.event + ), + tool_executor=FunctionToolExecutor(), + agent_hooks=tool.agent.run_hooks or BaseAgentRunHooks[AstrAgentContext](), + streaming=run_context.context.streaming, + ) + + async for _ in run_agent(agent_runner, 15, True): + pass + + if agent_runner.done(): + llm_response = agent_runner.get_final_llm_resp() + logger.debug( + f"Agent {tool.agent.name} 任务完成, response: {llm_response.completion_text}" + ) + + result = ( + f"Agent {tool.agent.name} respond with: {llm_response.completion_text}\n\n" + "Note: If the result is error or need user provide more information, please provide more information to the agent(you can ask user for more information first)." + ) + + text_content = mcp.types.TextContent( + type="text", + text=result, + ) + yield mcp.types.CallToolResult(content=[text_content]) + else: + yield mcp.types.TextContent( + type="text", + text=f"error when deligate task to {tool.agent.name}", + ) + yield mcp.types.CallToolResult(content=[text_content]) + return + + @classmethod + async def _execute_local( + cls, + tool: FunctionTool, + run_context: ContextWrapper[AstrAgentContext], + **tool_args, + ): + if not run_context.event: + raise ValueError("Event must be provided for local function tools.") + + # 检查 tool 下有没有 run 方法 + if not tool.handler and not hasattr(tool, "run"): + raise ValueError("Tool must have a valid handler or 'run' method.") + awaitable = tool.handler or getattr(tool, "run") + + wrapper = call_handler( + event=run_context.event, + handler=awaitable, + **tool_args, + ) + async for resp in wrapper: + if resp is not None: + if isinstance(resp, mcp.types.CallToolResult): + yield resp + else: + text_content = mcp.types.TextContent( + type="text", + text=str(resp), + ) + yield mcp.types.CallToolResult(content=[text_content]) + else: + # NOTE: Tool 在这里直接请求发送消息给用户 + # TODO: 是否需要判断 event.get_result() 是否为空? + # 如果为空,则说明没有发送消息给用户,并且返回值为空,将返回一个特殊的 TextContent,其内容如"工具没有返回内容" + yield None + + @classmethod + async def _execute_mcp( + cls, + tool: FunctionTool, + run_context: ContextWrapper[AstrAgentContext], + **tool_args, + ): + if not tool.mcp_client: + raise ValueError("MCP client is not available for MCP function tools.") + res = await tool.mcp_client.session.call_tool( + name=tool.name, + arguments=tool_args, + ) + if not res: + return + yield res + + +class MainAgentHooks(BaseAgentRunHooks[AgentContextWrapper]): + async def on_agent_done(self, run_context, llm_response): + # 执行事件钩子 + await call_event_hook( + run_context.event, EventType.OnLLMResponseEvent, llm_response + ) + + +MAIN_AGENT_HOOKS = MainAgentHooks() + + +async def run_agent( + agent_runner: AgentRunner, max_step: int = 30, show_tool_use: bool = True +) -> AsyncGenerator[MessageChain, None]: + step_idx = 0 + astr_event = agent_runner.run_context.event + while step_idx < max_step: + step_idx += 1 + try: + async for resp in agent_runner.step(): + if astr_event.is_stopped(): + return + if resp.type == "tool_call_result": + msg_chain = resp.data["chain"] + if msg_chain.type == "tool_direct_result": + # tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容 + resp.data["chain"].type = "tool_call_result" + await astr_event.send(resp.data["chain"]) + continue + # 对于其他情况,暂时先不处理 + continue + elif resp.type == "tool_call": + if agent_runner.streaming: + # 用来标记流式响应需要分节 + yield MessageChain(chain=[], type="break") + if show_tool_use or astr_event.get_platform_name() == "webchat": + resp.data["chain"].type = "tool_call" + await astr_event.send(resp.data["chain"]) + continue + + if not agent_runner.streaming: + content_typ = ( + ResultContentType.LLM_RESULT + if resp.type == "llm_result" + else ResultContentType.GENERAL_RESULT + ) + astr_event.set_result( + MessageEventResult( + chain=resp.data["chain"].chain, + result_content_type=content_typ, + ) + ) + yield + astr_event.clear_result() + else: + if resp.type == "streaming_delta": + yield resp.data["chain"] # MessageChain + if agent_runner.done(): + break + + except Exception as e: + logger.error(traceback.format_exc()) + astr_event.set_result( + MessageEventResult().message( + f"AstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {str(e)}\n\n请在控制台查看和分享错误详情。\n" + ) + ) + return + asyncio.create_task( + Metric.upload( + llm_tick=1, + model_name=agent_runner.provider.get_model(), + provider_type=agent_runner.provider.meta().type, + ) + ) class LLMRequestSubStage(Stage): @@ -65,6 +323,20 @@ class LLMRequestSubStage(Stage): return _ctx.get_using_provider(umo=event.unified_msg_origin) + async def _get_session_conv(self, event: AstrMessageEvent): + umo = event.unified_msg_origin + conv_mgr = self.conv_manager + + # 获取对话上下文 + cid = await conv_mgr.get_curr_conversation_id(umo) + if not cid: + cid = await conv_mgr.new_conversation(umo, event.get_platform_id()) + conversation = await conv_mgr.get_conversation(umo, cid) + if not conversation: + cid = await conv_mgr.new_conversation(umo, event.get_platform_id()) + conversation = await conv_mgr.get_conversation(umo, cid) + return conversation + async def process( self, event: AstrMessageEvent, _nested: bool = False ) -> Union[None, AsyncGenerator[None, None]]: @@ -100,30 +372,14 @@ class LLMRequestSubStage(Stage): if not event.message_str.startswith(self.provider_wake_prefix): return req.prompt = event.message_str[len(self.provider_wake_prefix) :] - req.func_tool = self.ctx.plugin_manager.context.get_llm_tool_manager() + # func_tool selection 现在已经转移到 packages/astrbot 插件中进行选择。 + # req.func_tool = self.ctx.plugin_manager.context.get_llm_tool_manager() for comp in event.message_obj.message: if isinstance(comp, Image): image_path = await comp.convert_to_file_path() req.image_urls.append(image_path) - # 获取对话上下文 - conversation_id = await self.conv_manager.get_curr_conversation_id( - event.unified_msg_origin - ) - if not conversation_id: - conversation_id = await self.conv_manager.new_conversation( - event.unified_msg_origin - ) - conversation = await self.conv_manager.get_conversation( - event.unified_msg_origin, conversation_id - ) - if not conversation: - conversation_id = await self.conv_manager.new_conversation( - event.unified_msg_origin - ) - conversation = await self.conv_manager.get_conversation( - event.unified_msg_origin, conversation_id - ) + conversation = await self._get_session_conv(event) req.conversation = conversation req.contexts = json.loads(conversation.history) @@ -133,7 +389,7 @@ class LLMRequestSubStage(Stage): return # 执行请求 LLM 前事件钩子。 - if await self.ctx.call_event_hook(event, EventType.OnLLMRequestEvent, req): + if await call_event_hook(event, EventType.OnLLMRequestEvent, req): return if isinstance(req.contexts, str): @@ -167,92 +423,62 @@ class LLMRequestSubStage(Stage): # fix messages req.contexts = self.fix_messages(req.contexts) - # Call Agent - tool_loop_agent = ToolLoopAgent( - provider=provider, - event=event, - pipeline_ctx=self.ctx, - ) + # check provider modalities + # 如果提供商不支持图像/工具使用,但请求中包含图像/工具列表,则清空。图片转述等的检测和调用发生在这之前,因此这里可以这样处理。 + if req.image_urls: + provider_cfg = provider.provider_config.get("modalities", ["image"]) + if "image" not in provider_cfg: + logger.debug(f"用户设置提供商 {provider} 不支持图像,清空图像列表。") + req.image_urls = [] + if req.func_tool: + provider_cfg = provider.provider_config.get("modalities", ["tool_use"]) + # 如果模型不支持工具使用,但请求中包含工具列表,则清空。 + if "tool_use" not in provider_cfg: + logger.debug(f"用户设置提供商 {provider} 不支持工具使用,清空工具列表。") + req.func_tool = None + # 插件可用性设置 + if event.plugins_name is not None and req.func_tool: + new_tool_set = ToolSet() + for tool in req.func_tool.tools: + plugin = star_map.get(tool.handler_module_path) + if not plugin: + continue + if plugin.name in event.plugins_name or plugin.reserved: + new_tool_set.add_tool(tool) + req.func_tool = new_tool_set + + # run agent + agent_runner = AgentRunner() logger.debug( f"handle provider[id: {provider.provider_config['id']}] request: {req}" ) - await tool_loop_agent.reset(req=req, streaming=self.streaming_response) - - async def requesting(): - step_idx = 0 - while step_idx < self.max_step: - step_idx += 1 - try: - async for resp in tool_loop_agent.step(): - if event.is_stopped(): - return - if resp.type == "tool_call_result": - msg_chain = resp.data["chain"] - if msg_chain.type == "tool_direct_result": - # tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容 - resp.data["chain"].type = "tool_call_result" - await event.send(resp.data["chain"]) - continue - # 对于其他情况,暂时先不处理 - continue - elif resp.type == "tool_call": - if self.streaming_response: - # 用来标记流式响应需要分节 - yield MessageChain(chain=[], type="break") - if ( - self.show_tool_use - or event.get_platform_name() == "webchat" - ): - resp.data["chain"].type = "tool_call" - await event.send(resp.data["chain"]) - continue - - if not self.streaming_response: - content_typ = ( - ResultContentType.LLM_RESULT - if resp.type == "llm_result" - else ResultContentType.GENERAL_RESULT - ) - event.set_result( - MessageEventResult( - chain=resp.data["chain"].chain, - result_content_type=content_typ, - ) - ) - yield - event.clear_result() - else: - if resp.type == "streaming_delta": - yield resp.data["chain"] # MessageChain - if tool_loop_agent.done(): - break - - except Exception as e: - logger.error(traceback.format_exc()) - event.set_result( - MessageEventResult().message( - f"AstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {str(e)}\n\n请在控制台查看和分享错误详情。\n" - ) - ) - return - asyncio.create_task( - Metric.upload( - llm_tick=1, - model_name=provider.get_model(), - provider_type=provider.meta().type, - ) - ) + astr_agent_ctx = AstrAgentContext( + provider=provider, + first_provider_request=req, + curr_provider_request=req, + streaming=self.streaming_response, + ) + await agent_runner.reset( + provider=provider, + request=req, + run_context=AgentContextWrapper(context=astr_agent_ctx, event=event), + tool_executor=FunctionToolExecutor(), + agent_hooks=MAIN_AGENT_HOOKS, + streaming=self.streaming_response, + ) if self.streaming_response: # 流式响应 event.set_result( MessageEventResult() .set_result_content_type(ResultContentType.STREAMING_RESULT) - .set_async_stream(requesting()) + .set_async_stream( + run_agent(agent_runner, self.max_step, self.show_tool_use) + ) ) yield - if tool_loop_agent.done(): - if final_llm_resp := tool_loop_agent.get_final_llm_resp(): + if agent_runner.done(): + if final_llm_resp := agent_runner.get_final_llm_resp(): if final_llm_resp.completion_text: chain = ( MessageChain().message(final_llm_resp.completion_text).chain @@ -266,15 +492,15 @@ class LLMRequestSubStage(Stage): ) ) else: - async for _ in requesting(): + async for _ in run_agent(agent_runner, self.max_step, self.show_tool_use): yield + await self._save_to_history(event, req, agent_runner.get_final_llm_resp()) + # 异步处理 WebChat 特殊情况 if event.get_platform_name() == "webchat": asyncio.create_task(self._handle_webchat(event, req, provider)) - await self._save_to_history(event, req, tool_loop_agent.get_final_llm_resp()) - async def _handle_webchat( self, event: AstrMessageEvent, req: ProviderRequest, prov: Provider ): @@ -307,19 +533,10 @@ class LLMRequestSubStage(Stage): if not title or "" in title: return await self.conv_manager.update_conversation_title( - event.unified_msg_origin, title=title + unified_msg_origin=event.unified_msg_origin, + title=title, + conversation_id=req.conversation.cid, ) - # 由于 WebChat 平台特殊性,其有两个对话,因此我们要更新两个对话的标题 - # webchat adapter 中,session_id 的格式是 f"webchat!{username}!{cid}" - # TODO: 优化 WebChat 适配器的对话管理 - if event.session_id: - username, cid = event.session_id.split("!")[1:3] - db_helper = self.ctx.plugin_manager.context._db - db_helper.update_conversation_title( - user_id=username, - cid=cid, - title=title, - ) async def _save_to_history( self, diff --git a/astrbot/core/pipeline/process_stage/method/star_request.py b/astrbot/core/pipeline/process_stage/method/star_request.py index 00f58d55b..c5c0f5738 100644 --- a/astrbot/core/pipeline/process_stage/method/star_request.py +++ b/astrbot/core/pipeline/process_stage/method/star_request.py @@ -2,7 +2,7 @@ 本地 Agent 模式的 AstrBot 插件调用 Stage """ -from ...context import PipelineContext +from ...context import PipelineContext, call_handler from ..stage import Stage from typing import Dict, Any, List, AsyncGenerator, Union from astrbot.core.platform.astr_message_event import AstrMessageEvent @@ -33,16 +33,6 @@ class StarRequestSubStage(Stage): handlers_parsed_params = {} for handler in activated_handlers: - # 检查处理器是否在当前平台兼容 - if ( - hasattr(handler, "platform_compatible") - and handler.platform_compatible is False - ): - logger.debug( - f"处理器 {handler.handler_name} 在当前平台不兼容,跳过执行" - ) - continue - params = handlers_parsed_params.get(handler.handler_full_name, {}) try: if handler.handler_module_path not in star_map: @@ -50,7 +40,7 @@ class StarRequestSubStage(Stage): logger.debug( f"plugin -> {star_map.get(handler.handler_module_path).name} - {handler.handler_name}" ) - wrapper = self.ctx.call_handler(event, handler.handler, **params) + wrapper = call_handler(event, handler.handler, **params) async for ret in wrapper: yield ret event.clear_result() # 清除上一个 handler 的结果 diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 77e62ec7c..ebbba7ed3 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -128,7 +128,7 @@ class RespondStage(Stage): use_fallback = self.config.get("provider_settings", {}).get( "streaming_segmented", False ) - logger.info(f"应用流式输出({event.get_platform_name()})") + logger.info(f"应用流式输出({event.get_platform_id()})") await event.send_streaming(result.async_stream, use_fallback) return elif len(result.chain) > 0: @@ -214,7 +214,7 @@ class RespondStage(Stage): ) handlers = star_handlers_registry.get_handlers_by_event_type( - EventType.OnAfterMessageSentEvent, platform_id=event.get_platform_id() + EventType.OnAfterMessageSentEvent, plugins_name=event.plugins_name ) for handler in handlers: try: diff --git a/astrbot/core/pipeline/result_decorate/stage.py b/astrbot/core/pipeline/result_decorate/stage.py index c9b8b4b8a..f87f7bbc0 100644 --- a/astrbot/core/pipeline/result_decorate/stage.py +++ b/astrbot/core/pipeline/result_decorate/stage.py @@ -64,9 +64,10 @@ class ResultDecorateStage(Stage): ] self.content_safe_check_stage = None if self.content_safe_check_reply: - for stage in registered_stages: - if stage.__class__.__name__ == "ContentSafetyCheckStage": - self.content_safe_check_stage = stage + for stage_cls in registered_stages: + if stage_cls.__name__ == "ContentSafetyCheckStage": + self.content_safe_check_stage = stage_cls() + await self.content_safe_check_stage.initialize(ctx) async def process( self, event: AstrMessageEvent @@ -98,7 +99,7 @@ class ResultDecorateStage(Stage): # 发送消息前事件钩子 handlers = star_handlers_registry.get_handlers_by_event_type( - EventType.OnDecoratingResultEvent, platform_id=event.get_platform_id() + EventType.OnDecoratingResultEvent, plugins_name=event.plugins_name ) for handler in handlers: try: diff --git a/astrbot/core/pipeline/scheduler.py b/astrbot/core/pipeline/scheduler.py index a014aae6f..f1c3988a6 100644 --- a/astrbot/core/pipeline/scheduler.py +++ b/astrbot/core/pipeline/scheduler.py @@ -11,16 +11,17 @@ class PipelineScheduler: def __init__(self, context: PipelineContext): registered_stages.sort( - key=lambda x: STAGES_ORDER.index(x.__class__.__name__) + key=lambda x: STAGES_ORDER.index(x.__name__) ) # 按照顺序排序 self.ctx = context # 上下文对象 + self.stages = [] # 存储阶段实例 async def initialize(self): """初始化管道调度器时, 初始化所有阶段""" - for stage in registered_stages: - # logger.debug(f"初始化阶段 {stage.__class__ .__name__}") - - await stage.initialize(self.ctx) + for stage_cls in registered_stages: + stage_instance = stage_cls() # 创建实例 + await stage_instance.initialize(self.ctx) + self.stages.append(stage_instance) async def _process_stages(self, event: AstrMessageEvent, from_stage=0): """依次执行各个阶段 @@ -29,9 +30,9 @@ class PipelineScheduler: event (AstrMessageEvent): 事件对象 from_stage (int): 从第几个阶段开始执行, 默认从0开始 """ - for i in range(from_stage, len(registered_stages)): - stage = registered_stages[i] # 获取当前要执行的阶段 - # logger.debug(f"执行阶段 {stage.__class__ .__name__}") + for i in range(from_stage, len(self.stages)): + stage = self.stages[i] # 获取当前要执行的阶段 + # logger.debug(f"执行阶段 {stage.__class__.__name__}") coroutine = stage.process( event ) # 调用阶段的process方法, 返回协程或者异步生成器 diff --git a/astrbot/core/pipeline/stage.py b/astrbot/core/pipeline/stage.py index b41794733..c4550495a 100644 --- a/astrbot/core/pipeline/stage.py +++ b/astrbot/core/pipeline/stage.py @@ -1,15 +1,15 @@ from __future__ import annotations import abc -from typing import List, AsyncGenerator, Union +from typing import List, AsyncGenerator, Union, Type from astrbot.core.platform.astr_message_event import AstrMessageEvent from .context import PipelineContext -registered_stages: List[Stage] = [] # 维护了所有已注册的 Stage 实现类 +registered_stages: List[Type[Stage]] = [] # 维护了所有已注册的 Stage 实现类类型 def register_stage(cls): """一个简单的装饰器,用于注册 pipeline 包下的 Stage 实现类""" - registered_stages.append(cls()) + registered_stages.append(cls) return cls diff --git a/astrbot/core/pipeline/waking_check/stage.py b/astrbot/core/pipeline/waking_check/stage.py index 2345b6466..63bc8b52d 100644 --- a/astrbot/core/pipeline/waking_check/stage.py +++ b/astrbot/core/pipeline/waking_check/stage.py @@ -112,8 +112,17 @@ class WakingCheckStage(Stage): activated_handlers = [] handlers_parsed_params = {} # 注册了指令的 handler + # 将 plugins_name 设置到 event 中 + enabled_plugins_name = self.ctx.astrbot_config.get("plugin_set", ["*"]) + if enabled_plugins_name == ["*"]: + # 如果是 *,则表示所有插件都启用 + event.plugins_name = None + else: + event.plugins_name = enabled_plugins_name + logger.debug(f"enabled_plugins_name: {enabled_plugins_name}") + for handler in star_handlers_registry.get_handlers_by_event_type( - EventType.AdapterMessageEvent + EventType.AdapterMessageEvent, plugins_name=event.plugins_name ): # filter 需满足 AND 逻辑关系 passed = True diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py index 9867a51b3..75ea317ad 100644 --- a/astrbot/core/platform/astr_message_event.py +++ b/astrbot/core/platform/astr_message_event.py @@ -3,9 +3,10 @@ import asyncio import re import hashlib import uuid -from dataclasses import dataclass + from typing import List, Union, Optional, AsyncGenerator +from astrbot import logger from astrbot.core.db.po import Conversation from astrbot.core.message.components import ( Plain, @@ -23,21 +24,7 @@ from astrbot.core.provider.entities import ProviderRequest from astrbot.core.utils.metrics import Metric from .astrbot_message import AstrBotMessage, Group from .platform_metadata import PlatformMetadata - - -@dataclass -class MessageSesion: - platform_name: str - message_type: MessageType - session_id: str - - def __str__(self): - return f"{self.platform_name}:{self.message_type.value}:{self.session_id}" - - @staticmethod - def from_str(session_str: str): - platform_name, message_type, session_id = session_str.split(":") - return MessageSesion(platform_name, MessageType(message_type), session_id) +from .message_session import MessageSession, MessageSesion # noqa class AstrMessageEvent(abc.ABC): @@ -64,7 +51,7 @@ class AstrMessageEvent(abc.ABC): """是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)""" self._extras = {} self.session = MessageSesion( - platform_name=platform_meta.name, + platform_name=platform_meta.id, message_type=message_obj.type, session_id=session_id, ) @@ -78,13 +65,23 @@ class AstrMessageEvent(abc.ABC): self.call_llm = False """是否在此消息事件中禁止默认的 LLM 请求""" + self.plugins_name: list[str] | None = None + """该事件启用的插件名称列表。None 表示所有插件都启用。空列表表示没有启用任何插件。""" + # back_compability self.platform = platform_meta def get_platform_name(self): + """获取这个事件所属的平台的类型(如 aiocqhttp, slack, discord 等)。 + + NOTE: 用户可能会同时运行多个相同类型的平台适配器。""" return self.platform_meta.name def get_platform_id(self): + """获取这个事件所属的平台的 ID。 + + NOTE: 用户可能会同时运行多个相同类型的平台适配器,但能确定的是 ID 是唯一的。 + """ return self.platform_meta.id def get_message_str(self) -> str: @@ -188,6 +185,7 @@ class AstrMessageEvent(abc.ABC): """ 清除额外的信息。 """ + logger.info(f"清除 {self.get_platform_name()} 的额外信息: {self._extras}") self._extras.clear() def is_private_chat(self) -> bool: diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 23109ca53..62328e881 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -18,6 +18,9 @@ class PlatformManager: self.platforms_config = config["platform"] self.settings = config["platform_settings"] + """NOTE: 这里是 default 的配置文件,以保证最大的兼容性; + 这个配置中的 unique_session 需要特殊处理, + 约定整个项目中对 unique_session 的引用都从 default 的配置中获取""" self.event_queue = event_queue async def initialize(self): diff --git a/astrbot/core/platform/message_session.py b/astrbot/core/platform/message_session.py new file mode 100644 index 000000000..bf5a72a9a --- /dev/null +++ b/astrbot/core/platform/message_session.py @@ -0,0 +1,28 @@ +from astrbot.core.platform.message_type import MessageType +from dataclasses import dataclass + + +@dataclass +class MessageSession: + """描述一条消息在 AstrBot 中对应的会话的唯一标识。 + 如果您需要实例化 MessageSession,请不要给 platform_id 赋值(或者同时给 platform_name 和 platform_id 赋值相同值)。它会在 __post_init__ 中自动设置为 platform_name 的值。""" + + platform_name: str + """平台适配器实例的唯一标识符。自 AstrBot v4.0.0 起,该字段实际为 platform_id。""" + message_type: MessageType + session_id: str + platform_id: str = None + + def __str__(self): + return f"{self.platform_id}:{self.message_type.value}:{self.session_id}" + + def __post_init__(self): + self.platform_id = self.platform_name + + @staticmethod + def from_str(session_str: str): + platform_id, message_type, session_id = session_str.split(":") + return MessageSession(platform_id, MessageType(message_type), session_id) + + +MessageSesion = MessageSession # back compatibility diff --git a/astrbot/core/platform/platform.py b/astrbot/core/platform/platform.py index 6ed53fe0e..c109f29b4 100644 --- a/astrbot/core/platform/platform.py +++ b/astrbot/core/platform/platform.py @@ -5,7 +5,7 @@ from asyncio import Queue from .platform_metadata import PlatformMetadata from .astr_message_event import AstrMessageEvent from astrbot.core.message.message_event_result import MessageChain -from .astr_message_event import MessageSesion +from .message_session import MessageSesion from astrbot.core.utils.metrics import Metric diff --git a/astrbot/core/platform/platform_metadata.py b/astrbot/core/platform/platform_metadata.py index dd0e93fec..7fb7f9d3e 100644 --- a/astrbot/core/platform/platform_metadata.py +++ b/astrbot/core/platform/platform_metadata.py @@ -4,7 +4,7 @@ from dataclasses import dataclass @dataclass class PlatformMetadata: name: str - """平台的名称""" + """平台的名称,即平台的类型,如 aiocqhttp, discord, slack""" description: str """平台的描述""" id: str = None diff --git a/astrbot/core/platform/sources/webchat/webchat_adapter.py b/astrbot/core/platform/sources/webchat/webchat_adapter.py index aaac8e289..43da100f4 100644 --- a/astrbot/core/platform/sources/webchat/webchat_adapter.py +++ b/astrbot/core/platform/sources/webchat/webchat_adapter.py @@ -77,7 +77,7 @@ class WebChatAdapter(Platform): os.makedirs(self.imgs_dir, exist_ok=True) self.metadata = PlatformMetadata( - name="webchat", description="webchat", id=self.config.get("id", "") + name="webchat", description="webchat", id="webchat" ) async def send_by_session( diff --git a/astrbot/core/platform_message_history_mgr.py b/astrbot/core/platform_message_history_mgr.py new file mode 100644 index 000000000..16e59a5cc --- /dev/null +++ b/astrbot/core/platform_message_history_mgr.py @@ -0,0 +1,47 @@ +from astrbot.core.db import BaseDatabase +from astrbot.core.db.po import PlatformMessageHistory + + +class PlatformMessageHistoryManager: + def __init__(self, db_helper: BaseDatabase): + self.db = db_helper + + async def insert( + self, + platform_id: str, + user_id: str, + content: list[dict], # TODO: parse from message chain + sender_id: str = None, + sender_name: str = None, + ): + """Insert a new platform message history record.""" + await self.db.insert_platform_message_history( + platform_id=platform_id, + user_id=user_id, + content=content, + sender_id=sender_id, + sender_name=sender_name, + ) + + async def get( + self, + platform_id: str, + user_id: str, + page: int = 1, + page_size: int = 200, + ) -> list[PlatformMessageHistory]: + """Get platform message history for a specific user.""" + history = await self.db.get_platform_message_history( + platform_id=platform_id, + user_id=user_id, + page=page, + page_size=page_size, + ) + history.reverse() + return history + + async def delete(self, platform_id: str, user_id: str, offset_sec: int = 86400): + """Delete platform message history records older than the specified offset.""" + await self.db.delete_platform_message_offset( + platform_id=platform_id, user_id=user_id, offset_sec=offset_sec + ) diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index 2d120d7f6..0a31093ae 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -5,7 +5,7 @@ from astrbot.core.utils.io import download_image_by_url from astrbot import logger from dataclasses import dataclass, field from typing import List, Dict, Type -from .func_tool_manager import FuncCall +from astrbot.core.agent.tool import ToolSet from openai.types.chat.chat_completion import ChatCompletion from openai.types.chat.chat_completion_message_tool_call import ( ChatCompletionMessageToolCall, @@ -20,6 +20,7 @@ class ProviderType(enum.Enum): SPEECH_TO_TEXT = "speech_to_text" TEXT_TO_SPEECH = "text_to_speech" EMBEDDING = "embedding" + RERANK = "rerank" @dataclass @@ -97,7 +98,7 @@ class ProviderRequest: """会话 ID""" image_urls: list[str] = field(default_factory=list) """图片 URL 列表""" - func_tool: FuncCall | None = None + func_tool: ToolSet | None = None """可用的函数工具""" contexts: list[dict] = field(default_factory=list) """上下文。格式与 openai 的上下文格式一致: @@ -293,3 +294,10 @@ class LLMResponse: } ) return ret + +@dataclass +class RerankResult: + index: int + """在候选列表中的索引位置""" + relevance_score: float + """相关性分数""" diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index 07a0fbd8f..509975556 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -1,32 +1,17 @@ from __future__ import annotations import json -import textwrap import os import asyncio -import logging -from datetime import timedelta +import aiohttp -from typing import Dict, List, Awaitable, Literal, Any -from dataclasses import dataclass -from typing import Optional -from contextlib import AsyncExitStack +from typing import Dict, List, Awaitable from astrbot import logger -from astrbot.core.utils.log_pipe import LogPipe +from astrbot.core import sp from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.agent.mcp_client import MCPClient +from astrbot.core.agent.tool import ToolSet, FunctionTool -try: - import mcp - from mcp.client.sse import sse_client -except (ModuleNotFoundError, ImportError): - logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。") - -try: - from mcp.client.streamable_http import streamablehttp_client -except (ModuleNotFoundError, ImportError): - logger.warning( - "警告: 缺少依赖库 'mcp' 或者 mcp 库版本过低,无法使用 Streamable HTTP 连接方式。" - ) DEFAULT_MCP_CONFIG = {"mcpServers": {}} @@ -39,6 +24,10 @@ SUPPORTED_TYPES = [ ] # json schema 支持的数据类型 +# alias +FuncTool = FunctionTool + + def _prepare_config(config: dict) -> dict: """准备配置,处理嵌套格式""" if "mcpServers" in config and config["mcpServers"]: @@ -105,181 +94,9 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: return False, f"{e!s}" -@dataclass -class FuncTool: - """ - 用于描述一个函数调用工具。 - """ - - name: str - parameters: Dict - description: str - handler: Awaitable = None - """处理函数, 当 origin 为 mcp 时,这个为空""" - handler_module_path: str = None - """处理函数的模块路径,当 origin 为 mcp 时,这个为空 - - 必须要保留这个字段, handler 在初始化会被 functools.partial 包装,导致 handler 的 __module__ 为 functools - """ - active: bool = True - """是否激活""" - - origin: Literal["local", "mcp"] = "local" - """函数工具的来源, local 为本地函数工具, mcp 为 MCP 服务""" - - # MCP 相关字段 - mcp_server_name: str = None - """MCP 服务名称,当 origin 为 mcp 时有效""" - mcp_client: MCPClient = None - """MCP 客户端,当 origin 为 mcp 时有效""" - - def __repr__(self): - return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description}, active={self.active}, origin={self.origin})" - - async def execute(self, **args) -> Any: - """执行函数调用""" - if self.origin == "local": - if not self.handler: - raise Exception(f"Local function {self.name} has no handler") - return await self.handler(**args) - elif self.origin == "mcp": - if not self.mcp_client or not self.mcp_client.session: - raise Exception(f"MCP client for {self.name} is not available") - # 使用name属性而不是额外的mcp_tool_name - actual_tool_name = ( - self.name.split(":")[-1] if ":" in self.name else self.name - ) - return await self.mcp_client.session.call_tool(actual_tool_name, args) - else: - raise Exception(f"Unknown function origin: {self.origin}") - - -class MCPClient: - def __init__(self): - # Initialize session and client objects - self.session: Optional[mcp.ClientSession] = None - self.exit_stack = AsyncExitStack() - - self.name = None - self.active: bool = True - self.tools: List[mcp.Tool] = [] - self.server_errlogs: List[str] = [] - self.running_event = asyncio.Event() - - async def connect_to_server(self, mcp_server_config: dict, name: str): - """连接到 MCP 服务器 - - 如果 `url` 参数存在: - 1. 当 transport 指定为 `streamable_http` 时,使用 Streamable HTTP 连接方式。 - 1. 当 transport 指定为 `sse` 时,使用 SSE 连接方式。 - 2. 如果没有指定,默认使用 SSE 的方式连接到 MCP 服务。 - - Args: - mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server - """ - cfg = _prepare_config(mcp_server_config.copy()) - - def logging_callback(msg: str): - # 处理 MCP 服务的错误日志 - print(f"MCP Server {name} Error: {msg}") - self.server_errlogs.append(msg) - - if "url" in cfg: - success, error_msg = await _quick_test_mcp_connection(cfg) - if not success: - raise Exception(error_msg) - - if cfg.get("transport") != "streamable_http": - # SSE transport method - self._streams_context = sse_client( - url=cfg["url"], - headers=cfg.get("headers", {}), - timeout=cfg.get("timeout", 5), - sse_read_timeout=cfg.get("sse_read_timeout", 60 * 5), - ) - streams = await self.exit_stack.enter_async_context( - self._streams_context - ) - - # Create a new client session - read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 20)) - self.session = await self.exit_stack.enter_async_context( - mcp.ClientSession( - *streams, - read_timeout_seconds=read_timeout, - logging_callback=logging_callback, # type: ignore - ) - ) - else: - timeout = timedelta(seconds=cfg.get("timeout", 30)) - sse_read_timeout = timedelta( - seconds=cfg.get("sse_read_timeout", 60 * 5) - ) - self._streams_context = streamablehttp_client( - url=cfg["url"], - headers=cfg.get("headers", {}), - timeout=timeout, - sse_read_timeout=sse_read_timeout, - terminate_on_close=cfg.get("terminate_on_close", True), - ) - read_s, write_s, _ = await self.exit_stack.enter_async_context( - self._streams_context - ) - - # Create a new client session - read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 20)) - self.session = await self.exit_stack.enter_async_context( - mcp.ClientSession( - read_stream=read_s, - write_stream=write_s, - read_timeout_seconds=read_timeout, - logging_callback=logging_callback, # type: ignore - ) - ) - - else: - server_params = mcp.StdioServerParameters( - **cfg, - ) - - def callback(msg: str): - # 处理 MCP 服务的错误日志 - self.server_errlogs.append(msg) - - stdio_transport = await self.exit_stack.enter_async_context( - mcp.stdio_client( - server_params, - errlog=LogPipe( - level=logging.ERROR, - logger=logger, - identifier=f"MCPServer-{name}", - callback=callback, - ), # type: ignore - ), - ) - - # Create a new client session - self.session = await self.exit_stack.enter_async_context( - mcp.ClientSession(*stdio_transport) - ) - await self.session.initialize() - - async def list_tools_and_save(self) -> mcp.ListToolsResult: - """List all tools from the server and save them to self.tools""" - response = await self.session.list_tools() - self.tools = response.tools - return response - - async def cleanup(self): - """Clean up resources""" - await self.exit_stack.aclose() - self.running_event.set() # Set the running event to indicate cleanup is done - - -class FuncCall: +class FunctionToolManager: def __init__(self) -> None: self.func_list: List[FuncTool] = [] - """内部加载的 func tools""" self.mcp_client_dict: Dict[str, MCPClient] = {} """MCP 服务列表""" self.mcp_client_event: Dict[str, asyncio.Event] = {} @@ -287,6 +104,29 @@ class FuncCall: def empty(self) -> bool: return len(self.func_list) == 0 + def spec_to_func( + self, + name: str, + func_args: list, + desc: str, + handler: Awaitable, + ) -> FuncTool: + params = { + "type": "object", # hard-coded here + "properties": {}, + } + for param in func_args: + params["properties"][param["name"]] = { + "type": param["type"], + "description": param["description"], + } + return FuncTool( + name=name, + parameters=params, + description=desc, + handler=handler, + ) + def add_func( self, name: str, @@ -304,22 +144,14 @@ class FuncCall: # check if the tool has been added before self.remove_func(name) - params = { - "type": "object", # hard-coded here - "properties": {}, - } - for param in func_args: - params["properties"][param["name"]] = { - "type": param["type"], - "description": param["description"], - } - _func = FuncTool( - name=name, - parameters=params, - description=desc, - handler=handler, + self.func_list.append( + self.spec_to_func( + name=name, + func_args=func_args, + desc=desc, + handler=handler, + ) ) - self.func_list.append(_func) logger.info(f"添加函数调用工具: {name}") def remove_func(self, name: str) -> None: @@ -331,11 +163,15 @@ class FuncCall: self.func_list.pop(i) break - def get_func(self, name) -> FuncTool: + def get_func(self, name) -> FuncTool | None: for f in self.func_list: if f.name == name: return f - return None + + def get_full_tool_set(self) -> ToolSet: + """获取完整工具集""" + tool_set = ToolSet(self.func_list.copy()) + return tool_set async def init_mcp_clients(self) -> None: """从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下: @@ -556,203 +392,179 @@ class FuncCall: """ 获得 OpenAI API 风格的**已经激活**的工具描述 """ - _l = [] - # 处理所有工具(包括本地和MCP工具) - for f in self.func_list: - if not f.active: - continue - func_ = { - "type": "function", - "function": { - "name": f.name, - # "parameters": f.parameters, - "description": f.description, - }, - } - func_["function"]["parameters"] = f.parameters - if not f.parameters.get("properties") and omit_empty_parameter_field: - # 如果 properties 为空,并且 omit_empty_parameter_field 为 True,则删除 parameters 字段 - del func_["function"]["parameters"] - _l.append(func_) - return _l + tools = [f for f in self.func_list if f.active] + toolset = ToolSet(tools) + return toolset.openai_schema( + omit_empty_parameter_field=omit_empty_parameter_field + ) def get_func_desc_anthropic_style(self) -> list: """ 获得 Anthropic API 风格的**已经激活**的工具描述 """ - tools = [] - for f in self.func_list: - if not f.active: - continue - - # Convert internal format to Anthropic style - tool = { - "name": f.name, - "description": f.description, - "input_schema": { - "type": "object", - "properties": f.parameters.get("properties", {}), - # Keep the required field from the original parameters if it exists - "required": f.parameters.get("required", []), - }, - } - tools.append(tool) - return tools + tools = [f for f in self.func_list if f.active] + toolset = ToolSet(tools) + return toolset.anthropic_schema() def get_func_desc_google_genai_style(self) -> dict: """ 获得 Google GenAI API 风格的**已经激活**的工具描述 """ + tools = [f for f in self.func_list if f.active] + toolset = ToolSet(tools) + return toolset.google_schema() - # Gemini API 支持的数据类型和格式 - supported_types = { - "string", - "number", - "integer", - "boolean", - "array", - "object", - "null", - } - supported_formats = { - "string": {"enum", "date-time"}, - "integer": {"int32", "int64"}, - "number": {"float", "double"}, - } + def deactivate_llm_tool(self, name: str) -> bool: + """停用一个已经注册的函数调用工具。 - def convert_schema(schema: dict) -> dict: - """转换 schema 为 Gemini API 格式""" + Returns: + 如果没找到,会返回 False""" + func_tool = self.get_func(name) + if func_tool is not None: + func_tool.active = False - # 如果 schema 包含 anyOf,则只返回 anyOf 字段 - if "anyOf" in schema: - return {"anyOf": [convert_schema(s) for s in schema["anyOf"]]} - - result = {} - - if "type" in schema and schema["type"] in supported_types: - result["type"] = schema["type"] - if "format" in schema and schema["format"] in supported_formats.get( - result["type"], set() - ): - result["format"] = schema["format"] - else: - # 暂时指定默认为null - result["type"] = "null" - - support_fields = { - "title", - "description", - "enum", - "minimum", - "maximum", - "maxItems", - "minItems", - "nullable", - "required", - } - result.update({k: schema[k] for k in support_fields if k in schema}) - - if "properties" in schema: - properties = {} - for key, value in schema["properties"].items(): - prop_value = convert_schema(value) - if "default" in prop_value: - del prop_value["default"] - properties[key] = prop_value - - if properties: # 只在有非空属性时添加 - result["properties"] = properties - - if "items" in schema: - result["items"] = convert_schema(schema["items"]) - - return result - - tools = [ - { - "name": f.name, - "description": f.description, - **({"parameters": convert_schema(f.parameters)}), - } - for f in self.func_list - if f.active - ] - - declarations = {} - if tools: - declarations["function_declarations"] = tools - return declarations - - async def func_call(self, question: str, session_id: str, provider) -> tuple: - _l = [] - for f in self.func_list: - if not f.active: - continue - _l.append( - { - "name": f.name, - "parameters": f.parameters, - "description": f.description, - } + inactivated_llm_tools: list = sp.get( + "inactivated_llm_tools", [], scope="global", scope_id="global" ) - func_definition = json.dumps(_l, ensure_ascii=False) + if name not in inactivated_llm_tools: + inactivated_llm_tools.append(name) + sp.put( + "inactivated_llm_tools", + inactivated_llm_tools, + scope="global", + scope_id="global", + ) - prompt = textwrap.dedent(f""" - ROLE: - 你是一个 Function calling AI Agent, 你的任务是将用户的提问转化为函数调用。 + return True + return False - TOOLS: - 可用的函数列表: + # 因为不想解决循环引用,所以这里直接传入 star_map 先了... + def activate_llm_tool(self, name: str, star_map: dict) -> bool: + func_tool = self.get_func(name) + if func_tool is not None: + if func_tool.handler_module_path in star_map: + if not star_map[func_tool.handler_module_path].activated: + raise ValueError( + f"此函数调用工具所属的插件 {star_map[func_tool.handler_module_path].name} 已被禁用,请先在管理面板启用再激活此工具。" + ) - {func_definition} + func_tool.active = True - LIMIT: - 1. 你返回的内容应当能够被 Python 的 json 模块解析的 Json 格式字符串。 - 2. 你的 Json 返回的格式如下:`[{{"name": "", "args": }}, ...]`。参数根据上面提供的函数列表中的参数来填写。 - 3. 允许必要时返回多个函数调用,但需保证这些函数调用的顺序正确。 - 4. 如果用户的提问中不需要用到给定的函数,请直接返回 `{{"res": False}}`。 + inactivated_llm_tools: list = sp.get( + "inactivated_llm_tools", [], scope="global", scope_id="global" + ) + if name in inactivated_llm_tools: + inactivated_llm_tools.remove(name) + sp.put( + "inactivated_llm_tools", + inactivated_llm_tools, + scope="global", + scope_id="global", + ) - EXAMPLE: - 1. `用户提问`:请问一下天气怎么样? `函数调用`:[{{"name": "get_weather", "args": {{"city": "北京"}}}}] + return True + return False - 用户的提问是:{question} - """) + @property + def mcp_config_path(self): + data_dir = get_astrbot_data_path() + return os.path.join(data_dir, "mcp_server.json") - _c = 0 - while _c < 3: - try: - res = await provider.text_chat(prompt, session_id) - if res.find("```") != -1: - res = res[res.find("```json") + 7 : res.rfind("```")] - res = json.loads(res) - break - except Exception as e: - _c += 1 - if _c == 3: - raise e - if "The message you submitted was too long" in str(e): - raise e + def load_mcp_config(self): + if not os.path.exists(self.mcp_config_path): + # 配置文件不存在,创建默认配置 + os.makedirs(os.path.dirname(self.mcp_config_path), exist_ok=True) + with open(self.mcp_config_path, "w", encoding="utf-8") as f: + json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4) + return DEFAULT_MCP_CONFIG - if "res" in res and not res["res"]: - return "", False + try: + with open(self.mcp_config_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + logger.error(f"加载 MCP 配置失败: {e}") + return DEFAULT_MCP_CONFIG - tool_call_result = [] - for tool in res: - # 说明有函数调用 - func_name = tool["name"] - args = tool["args"] - # 调用函数 - func_tool = self.get_func(func_name) - if not func_tool: - raise Exception(f"Request function {func_name} not found.") + def save_mcp_config(self, config: dict): + try: + with open(self.mcp_config_path, "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=4) + return True + except Exception as e: + logger.error(f"保存 MCP 配置失败: {e}") + return False - ret = await func_tool.execute(**args) - if ret: - tool_call_result.append(str(ret)) - return tool_call_result, True + async def sync_modelscope_mcp_servers(self, access_token: str) -> None: + """从 ModelScope 平台同步 MCP 服务器配置""" + base_url = "https://www.modelscope.cn/openapi/v1" + url = f"{base_url}/mcp/servers/operational" + headers = { + "Authorization": f"Bearer {access_token.strip()}", + "Content-Type": "application/json", + } + + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as response: + if response.status == 200: + data = await response.json() + mcp_server_list = data.get("data", {}).get( + "mcp_server_list", [] + ) + local_mcp_config = self.load_mcp_config() + + synced_count = 0 + for server in mcp_server_list: + server_name = server["name"] + operational_urls = server.get("operational_urls", []) + if not operational_urls: + continue + url_info = operational_urls[0] + server_url = url_info.get("url") + if not server_url: + continue + # 添加到配置中(同名会覆盖) + local_mcp_config["mcpServers"][server_name] = { + "url": server_url, + "transport": "sse", + "active": True, + "provider": "modelscope", + } + synced_count += 1 + + if synced_count > 0: + self.save_mcp_config(local_mcp_config) + tasks = [] + for server in mcp_server_list: + name = server["name"] + tasks.append( + self.enable_mcp_server( + name=name, + config=local_mcp_config["mcpServers"][name], + ) + ) + await asyncio.gather(*tasks) + logger.info( + f"从 ModelScope 同步了 {synced_count} 个 MCP 服务器" + ) + else: + logger.warning("没有找到可用的 ModelScope MCP 服务器") + else: + raise Exception( + f"ModelScope API 请求失败: HTTP {response.status}" + ) + + except aiohttp.ClientError as e: + raise Exception(f"网络连接错误: {str(e)}") + except Exception as e: + raise Exception(f"同步 ModelScope MCP 服务器时发生错误: {str(e)}") def __str__(self): return str(self.func_list) def __repr__(self): return str(self.func_list) + + +# alias +FuncCall = FunctionToolManager diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index 370c5322b..19f62edfa 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -3,89 +3,32 @@ import traceback from typing import List from astrbot.core import logger, sp -from astrbot.core.config.astrbot_config import AstrBotConfig +from astrbot.core.astrbot_config_mgr import AstrBotConfigManager from astrbot.core.db import BaseDatabase from .entities import ProviderType -from .provider import Personality, Provider, STTProvider, TTSProvider, EmbeddingProvider +from .provider import Provider, STTProvider, TTSProvider, EmbeddingProvider from .register import llm_tools, provider_cls_map +from ..persona_mgr import PersonaManager class ProviderManager: - def __init__(self, config: AstrBotConfig, db_helper: BaseDatabase): + def __init__( + self, + acm: AstrBotConfigManager, + db_helper: BaseDatabase, + persona_mgr: PersonaManager, + ): + self.persona_mgr = persona_mgr + self.acm = acm + config = acm.confs["default"] self.providers_config: List = config["provider"] self.provider_settings: dict = config["provider_settings"] self.provider_stt_settings: dict = config.get("provider_stt_settings", {}) self.provider_tts_settings: dict = config.get("provider_tts_settings", {}) - self.persona_configs: list = config.get("persona", []) - self.astrbot_config = config - # 人格情景管理 - # 目前没有拆成独立的模块 - self.default_persona_name = self.provider_settings.get( - "default_personality", "default" - ) - self.personas: List[Personality] = [] - self.selected_default_persona = None - for persona in self.persona_configs: - begin_dialogs = persona.get("begin_dialogs", []) - mood_imitation_dialogs = persona.get("mood_imitation_dialogs", []) - bd_processed = [] - mid_processed = "" - if begin_dialogs: - if len(begin_dialogs) % 2 != 0: - logger.error( - f"{persona['name']} 人格情景预设对话格式不对,条数应该为偶数。" - ) - begin_dialogs = [] - user_turn = True - for dialog in begin_dialogs: - bd_processed.append( - { - "role": "user" if user_turn else "assistant", - "content": dialog, - "_no_save": None, # 不持久化到 db - } - ) - user_turn = not user_turn - if mood_imitation_dialogs: - if len(mood_imitation_dialogs) % 2 != 0: - logger.error( - f"{persona['name']} 对话风格对话格式不对,条数应该为偶数。" - ) - mood_imitation_dialogs = [] - user_turn = True - for dialog in mood_imitation_dialogs: - role = "A" if user_turn else "B" - mid_processed += f"{role}: {dialog}\n" - if not user_turn: - mid_processed += "\n" - user_turn = not user_turn - - try: - persona = Personality( - **persona, - _begin_dialogs_processed=bd_processed, - _mood_imitation_dialogs_processed=mid_processed, - ) - if persona["name"] == self.default_persona_name: - self.selected_default_persona = persona - self.personas.append(persona) - except Exception as e: - logger.error(f"解析 Persona 配置失败:{e}") - - if not self.selected_default_persona and len(self.personas) > 0: - # 默认选择第一个 - self.selected_default_persona = self.personas[0] - - if not self.selected_default_persona: - self.selected_default_persona = Personality( - prompt="You are a helpful and friendly assistant.", - name="default", - _begin_dialogs_processed=[], - _mood_imitation_dialogs_processed="", - ) - self.personas.append(self.selected_default_persona) + # 人格相关属性,v4.0.0 版本后被废弃,推荐使用 PersonaManager + self.default_persona_name = persona_mgr.default_persona self.provider_insts: List[Provider] = [] """加载的 Provider 的实例""" @@ -100,46 +43,111 @@ class ProviderManager: self.llm_tools = llm_tools self.curr_provider_inst: Provider | None = None - """默认的 Provider 实例""" + """默认的 Provider 实例。已弃用,请使用 get_using_provider() 方法获取当前使用的 Provider 实例。""" self.curr_stt_provider_inst: STTProvider | None = None - """默认的 Speech To Text Provider 实例""" + """默认的 Speech To Text Provider 实例。已弃用,请使用 get_using_provider() 方法获取当前使用的 Provider 实例。""" self.curr_tts_provider_inst: TTSProvider | None = None - """默认的 Text To Speech Provider 实例""" + """默认的 Text To Speech Provider 实例。已弃用,请使用 get_using_provider() 方法获取当前使用的 Provider 实例。""" self.db_helper = db_helper - # kdb(experimental) - self.curr_kdb_name = "" - kdb_cfg = config.get("knowledge_db", {}) - if kdb_cfg and len(kdb_cfg): - self.curr_kdb_name = list(kdb_cfg.keys())[0] + @property + def persona_configs(self) -> list: + """动态获取最新的 persona 配置""" + return self.persona_mgr.persona_v3_config + + @property + def personas(self) -> list: + """动态获取最新的 personas 列表""" + return self.persona_mgr.personas_v3 + + @property + def selected_default_persona(self): + """动态获取最新的默认选中 persona。已弃用,请使用 context.persona_mgr.get_default_persona_v3()""" + return self.persona_mgr.selected_default_persona_v3 async def set_provider( - self, provider_id: str, provider_type: ProviderType, umo: str = None + self, provider_id: str, provider_type: ProviderType, umo: str | None = None ): """设置提供商。 Args: provider_id (str): 提供商 ID。 provider_type (ProviderType): 提供商类型。 - umo (str, optional): 用户会话 ID,用于提供商会话隔离。当用户启用了提供商会话隔离时此参数才生效。 + umo (str, optional): 用户会话 ID,用于提供商会话隔离。 + + Version 4.0.0: 这个版本下已经默认隔离提供商 """ if provider_id not in self.inst_map: raise ValueError(f"提供商 {provider_id} 不存在,无法设置。") - if umo and self.provider_settings["separate_provider"]: - perf = sp.get("session_provider_perf", {}) - session_perf = perf.get(umo, {}) - session_perf[provider_type.value] = provider_id - perf[umo] = session_perf - sp.put("session_provider_perf", perf) + if umo: + await sp.session_put( + umo, + f"provider_perf_{provider_type.value}", + provider_id, + ) return # 不启用提供商会话隔离模式的情况 self.curr_provider_inst = self.inst_map[provider_id] if provider_type == ProviderType.TEXT_TO_SPEECH: - sp.put("curr_provider_tts", provider_id) + sp.put("curr_provider_tts", provider_id, scope="global", scope_id="global") elif provider_type == ProviderType.SPEECH_TO_TEXT: - sp.put("curr_provider_stt", provider_id) + sp.put("curr_provider_stt", provider_id, scope="global", scope_id="global") elif provider_type == ProviderType.CHAT_COMPLETION: - sp.put("curr_provider", provider_id) + sp.put("curr_provider", provider_id, scope="global", scope_id="global") + + async def get_provider_by_id(self, provider_id: str) -> Provider | None: + """根据提供商 ID 获取提供商实例""" + return self.inst_map.get(provider_id) + + def get_using_provider(self, provider_type: ProviderType, umo=None): + """获取正在使用的提供商实例。 + + Args: + provider_type (ProviderType): 提供商类型。 + umo (str, optional): 用户会话 ID,用于提供商会话隔离。 + + Returns: + Provider: 正在使用的提供商实例。 + """ + provider = None + if umo: + provider_id = sp.get( + f"provider_perf_{provider_type.value}", + None, + scope="umo", + scope_id=umo, + ) + if provider_id: + provider = self.inst_map.get(provider_id) + if not provider: + # default setting + config = self.acm.get_conf(umo) + if provider_type == ProviderType.CHAT_COMPLETION: + provider_id = config["provider_settings"].get("default_provider_id") + provider = self.inst_map.get(provider_id) + if not provider: + provider = self.provider_insts[0] if self.provider_insts else None + elif provider_type == ProviderType.SPEECH_TO_TEXT: + provider_id = config["provider_stt_settings"].get("provider_id") + if not provider_id: + return None + provider = self.inst_map.get(provider_id) + if not provider: + provider = ( + self.stt_provider_insts[0] if self.stt_provider_insts else None + ) + elif provider_type == ProviderType.TEXT_TO_SPEECH: + provider_id = config["provider_tts_settings"].get("provider_id") + if not provider_id: + return None + provider = self.inst_map.get(provider_id) + if not provider: + provider = ( + self.tts_provider_insts[0] if self.tts_provider_insts else None + ) + else: + raise ValueError(f"Unknown provider type: {provider_type}") + return provider async def initialize(self): # 逐个初始化提供商 @@ -148,13 +156,22 @@ class ProviderManager: # 设置默认提供商 selected_provider_id = sp.get( - "curr_provider", self.provider_settings.get("default_provider_id") + "curr_provider", + self.provider_settings.get("default_provider_id"), + scope="global", + scope_id="global", ) selected_stt_provider_id = sp.get( - "curr_provider_stt", self.provider_stt_settings.get("provider_id") + "curr_provider_stt", + self.provider_stt_settings.get("provider_id"), + scope="global", + scope_id="global", ) selected_tts_provider_id = sp.get( - "curr_provider_tts", self.provider_tts_settings.get("provider_id") + "curr_provider_tts", + self.provider_tts_settings.get("provider_id"), + scope="global", + scope_id="global", ) self.curr_provider_inst = self.inst_map.get(selected_provider_id) if not self.curr_provider_inst and self.provider_insts: @@ -262,6 +279,10 @@ class ProviderManager: from .sources.gemini_embedding_source import ( GeminiEmbeddingProvider as GeminiEmbeddingProvider, ) + case "vllm_rerank": + from .sources.vllm_rerank_source import ( + VLLMRerankProvider as VLLMRerankProvider, + ) except (ImportError, ModuleNotFoundError) as e: logger.critical( f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。" @@ -345,7 +366,7 @@ class ProviderManager: if not self.curr_provider_inst: self.curr_provider_inst = inst - elif provider_metadata.provider_type == ProviderType.EMBEDDING: + elif provider_metadata.provider_type in [ProviderType.EMBEDDING, ProviderType.RERANK]: inst = provider_metadata.cls_type( provider_config, self.provider_settings ) diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py index 36401b089..01618767c 100644 --- a/astrbot/core/provider/provider.py +++ b/astrbot/core/provider/provider.py @@ -1,23 +1,18 @@ import abc from typing import List -from typing import TypedDict, AsyncGenerator -from astrbot.core.provider.func_tool_manager import FuncCall -from astrbot.core.provider.entities import LLMResponse, ToolCallsResult, ProviderType +from typing import AsyncGenerator +from astrbot.core.agent.tool import ToolSet +from astrbot.core.provider.entities import ( + LLMResponse, + ToolCallsResult, + ProviderType, + RerankResult, +) from astrbot.core.provider.register import provider_cls_map +from astrbot.core.db.po import Personality from dataclasses import dataclass -class Personality(TypedDict): - prompt: str = "" - name: str = "" - begin_dialogs: List[str] = [] - mood_imitation_dialogs: List[str] = [] - - # cache - _begin_dialogs_processed: List[dict] = [] - _mood_imitation_dialogs_processed: str = "" - - @dataclass class ProviderMeta: id: str @@ -90,7 +85,7 @@ class Provider(AbstractProvider): prompt: str, session_id: str = None, image_urls: list[str] = None, - func_tool: FuncCall = None, + func_tool: ToolSet = None, contexts: list = None, system_prompt: str = None, tool_calls_result: ToolCallsResult | list[ToolCallsResult] = None, @@ -119,7 +114,7 @@ class Provider(AbstractProvider): prompt: str, session_id: str = None, image_urls: list[str] = None, - func_tool: FuncCall = None, + func_tool: ToolSet = None, contexts: list = None, system_prompt: str = None, tool_calls_result: ToolCallsResult | list[ToolCallsResult] = None, @@ -206,3 +201,17 @@ class EmbeddingProvider(AbstractProvider): def get_dim(self) -> int: """获取向量的维度""" ... + + +class RerankProvider(AbstractProvider): + def __init__(self, provider_config: dict, provider_settings: dict) -> None: + super().__init__(provider_config) + self.provider_config = provider_config + self.provider_settings = provider_settings + + @abc.abstractmethod + async def rerank( + self, query: str, documents: list[str], top_n: int | None = None + ) -> list[RerankResult]: + """获取查询和文档的重排序分数""" + ... diff --git a/astrbot/core/provider/sources/dashscope_source.py b/astrbot/core/provider/sources/dashscope_source.py index 46b12726b..4e14d20da 100644 --- a/astrbot/core/provider/sources/dashscope_source.py +++ b/astrbot/core/provider/sources/dashscope_source.py @@ -75,8 +75,7 @@ class ProviderDashscope(ProviderOpenAIOfficial): # 获得会话变量 payload_vars = self.variables.copy() # 动态变量 - session_vars = sp.get("session_variables", {}) - session_var = session_vars.get(session_id, {}) + session_var = await sp.session_get(session_id, "session_variables", default={}) payload_vars.update(session_var) if ( diff --git a/astrbot/core/provider/sources/dify_source.py b/astrbot/core/provider/sources/dify_source.py index 9539227fe..e19e912ac 100644 --- a/astrbot/core/provider/sources/dify_source.py +++ b/astrbot/core/provider/sources/dify_source.py @@ -97,8 +97,7 @@ class ProviderDify(Provider): # 获得会话变量 payload_vars = self.variables.copy() # 动态变量 - session_vars = sp.get("session_variables", {}) - session_var = session_vars.get(session_id, {}) + session_var = await sp.session_get(session_id, "session_variables", default={}) payload_vars.update(session_var) payload_vars["system_prompt"] = system_prompt diff --git a/astrbot/core/provider/sources/vllm_rerank_source.py b/astrbot/core/provider/sources/vllm_rerank_source.py new file mode 100644 index 000000000..af48e69af --- /dev/null +++ b/astrbot/core/provider/sources/vllm_rerank_source.py @@ -0,0 +1,59 @@ +import aiohttp +from ..provider import RerankProvider +from ..register import register_provider_adapter +from ..entities import ProviderType, RerankResult + + +@register_provider_adapter( + "vllm_rerank", + "VLLM Rerank 适配器", + provider_type=ProviderType.RERANK, +) +class VLLMRerankProvider(RerankProvider): + def __init__(self, provider_config: dict, provider_settings: dict) -> None: + super().__init__(provider_config, provider_settings) + self.provider_config = provider_config + self.provider_settings = provider_settings + self.auth_key = provider_config.get("rerank_api_key", "") + self.base_url = provider_config.get("rerank_api_base", "http://127.0.0.1:8000") + self.base_url = self.base_url.rstrip("/") + self.timeout = provider_config.get("timeout", 20) + self.model = provider_config.get("rerank_model", "BAAI/bge-reranker-base") + + h = {} + if self.auth_key: + h["Authorization"] = f"Bearer {self.auth_key}" + self.client = aiohttp.ClientSession( + headers=h, + timeout=aiohttp.ClientTimeout(total=self.timeout), + ) + + async def rerank( + self, query: str, documents: list[str], top_n: int | None = None + ) -> list[RerankResult]: + payload = { + "query": query, + "documents": documents, + "model": self.model, + } + if top_n is not None: + payload["top_n"] = top_n + async with self.client.post( + f"{self.base_url}/v1/rerank", json=payload + ) as response: + response_data = await response.json() + results = response_data.get("results", []) + + return [ + RerankResult( + index=result["index"], + relevance_score=result["relevance_score"], + ) + for result in results + ] + + async def terminate(self) -> None: + """关闭客户端会话""" + if self.client: + await self.client.close() + self.client = None diff --git a/astrbot/core/star/__init__.py b/astrbot/core/star/__init__.py index 86318f8b7..fab39294b 100644 --- a/astrbot/core/star/__init__.py +++ b/astrbot/core/star/__init__.py @@ -34,7 +34,7 @@ class Star(CommandParserMixin): @staticmethod async def html_render( - tmpl: str, data: dict, return_url=True, options: dict = None + tmpl: str, data: dict, return_url=True, options: dict | None = None ) -> str: """渲染 HTML""" return await html_renderer.render_custom_template( diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 0b14525d3..76db898aa 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -1,17 +1,24 @@ from asyncio import Queue from typing import List, Union -from astrbot.core import sp -from astrbot.core.provider.provider import Provider, TTSProvider, STTProvider, EmbeddingProvider +from astrbot.core.provider.provider import ( + Provider, + TTSProvider, + STTProvider, + EmbeddingProvider, +) from astrbot.core.provider.entities import ProviderType from astrbot.core.db import BaseDatabase from astrbot.core.config.astrbot_config import AstrBotConfig -from astrbot.core.provider.func_tool_manager import FuncCall +from astrbot.core.provider.func_tool_manager import FunctionToolManager from astrbot.core.platform.astr_message_event import MessageSesion from astrbot.core.message.message_event_result import MessageChain from astrbot.core.provider.manager import ProviderManager from astrbot.core.platform import Platform from astrbot.core.platform.manager import PlatformManager +from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager +from astrbot.core.astrbot_config_mgr import AstrBotConfigManager +from astrbot.core.persona_mgr import PersonaManager from .star import star_registry, StarMetadata, star_map from .star_handler import star_handlers_registry, StarHandlerMetadata, EventType from .filter.command import CommandFilter @@ -22,6 +29,7 @@ from astrbot.core.star.filter.platform_adapter_type import ( PlatformAdapterType, ADAPTER_NAME_2_TYPE, ) +from deprecated import deprecated class Context: @@ -29,19 +37,6 @@ class Context: 暴露给插件的接口上下文。 """ - _event_queue: Queue = None - """事件队列。消息平台通过事件队列传递消息事件。""" - - _config: AstrBotConfig = None - """AstrBot 配置信息""" - - _db: BaseDatabase = None - """AstrBot 数据库""" - - provider_manager: ProviderManager = None - - platform_manager: PlatformManager = None - registered_web_apis: list = [] # back compatibility @@ -53,18 +48,27 @@ class Context: event_queue: Queue, config: AstrBotConfig, db: BaseDatabase, - provider_manager: ProviderManager = None, - platform_manager: PlatformManager = None, - conversation_manager: ConversationManager = None, + provider_manager: ProviderManager, + platform_manager: PlatformManager, + conversation_manager: ConversationManager, + message_history_manager: PlatformMessageHistoryManager, + persona_manager: PersonaManager, + astrbot_config_mgr: AstrBotConfigManager, ): self._event_queue = event_queue + """事件队列。消息平台通过事件队列传递消息事件。""" self._config = config + """AstrBot 默认配置""" self._db = db + """AstrBot 数据库""" self.provider_manager = provider_manager self.platform_manager = platform_manager self.conversation_manager = conversation_manager + self.message_history_manager = message_history_manager + self.persona_manager = persona_manager + self.astrbot_config_mgr = astrbot_config_mgr - def get_registered_star(self, star_name: str) -> StarMetadata: + def get_registered_star(self, star_name: str) -> StarMetadata | None: """根据插件名获取插件的 Metadata""" for star in star_registry: if star.name == star_name: @@ -74,7 +78,7 @@ class Context: """获取当前载入的所有插件 Metadata 的列表""" return star_registry - def get_llm_tool_manager(self) -> FuncCall: + def get_llm_tool_manager(self) -> FunctionToolManager: """获取 LLM Tool Manager,其用于管理注册的所有的 Function-calling tools""" return self.provider_manager.llm_tools @@ -84,40 +88,14 @@ class Context: Returns: 如果没找到,会返回 False """ - func_tool = self.provider_manager.llm_tools.get_func(name) - if func_tool is not None: - if func_tool.handler_module_path in star_map: - if not star_map[func_tool.handler_module_path].activated: - raise ValueError( - f"此函数调用工具所属的插件 {star_map[func_tool.handler_module_path].name} 已被禁用,请先在管理面板启用再激活此工具。" - ) - - func_tool.active = True - - inactivated_llm_tools: list = sp.get("inactivated_llm_tools", []) - if name in inactivated_llm_tools: - inactivated_llm_tools.remove(name) - sp.put("inactivated_llm_tools", inactivated_llm_tools) - - return True - return False + return self.provider_manager.llm_tools.activate_llm_tool(name, star_map) def deactivate_llm_tool(self, name: str) -> bool: """停用一个已经注册的函数调用工具。 Returns: 如果没找到,会返回 False""" - func_tool = self.provider_manager.llm_tools.get_func(name) - if func_tool is not None: - func_tool.active = False - - inactivated_llm_tools: list = sp.get("inactivated_llm_tools", []) - if name not in inactivated_llm_tools: - inactivated_llm_tools.append(name) - sp.put("inactivated_llm_tools", inactivated_llm_tools) - - return True - return False + return self.provider_manager.llm_tools.deactivate_llm_tool(name) def register_provider(self, provider: Provider): """ @@ -125,7 +103,7 @@ class Context: """ self.provider_manager.provider_insts.append(provider) - def get_provider_by_id(self, provider_id: str) -> Provider: + def get_provider_by_id(self, provider_id: str) -> Provider | None: """通过 ID 获取对应的 LLM Provider(Chat_Completion 类型)。""" return self.provider_manager.inst_map.get(provider_id) @@ -145,51 +123,49 @@ class Context: """获取所有用于 Embedding 任务的 Provider。""" return self.provider_manager.embedding_provider_insts - def get_using_provider(self, umo: str = None) -> Provider: + def get_using_provider(self, umo: str | None = None) -> Provider | None: """ 获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。通过 /provider 指令切换。 Args: umo(str): unified_message_origin 值,如果传入并且用户启用了提供商会话隔离,则使用该会话偏好的提供商。 """ - if umo and self._config["provider_settings"]["separate_provider"]: - perf = sp.get("session_provider_perf", {}) - prov_id = perf.get(umo, {}).get(ProviderType.CHAT_COMPLETION.value, None) - if inst := self.provider_manager.inst_map.get(prov_id, None): - return inst - return self.provider_manager.curr_provider_inst + return self.provider_manager.get_using_provider( + provider_type=ProviderType.CHAT_COMPLETION, + umo=umo, + ) - def get_using_tts_provider(self, umo: str = None) -> TTSProvider: + def get_using_tts_provider(self, umo: str | None = None) -> TTSProvider: """ 获取当前使用的用于 TTS 任务的 Provider。 Args: umo(str): unified_message_origin 值,如果传入,则使用该会话偏好的提供商。 """ - if umo and self._config["provider_settings"]["separate_provider"]: - perf = sp.get("session_provider_perf", {}) - prov_id = perf.get(umo, {}).get(ProviderType.TEXT_TO_SPEECH.value, None) - if inst := self.provider_manager.inst_map.get(prov_id, None): - return inst - return self.provider_manager.curr_tts_provider_inst + return self.provider_manager.get_using_provider( + provider_type=ProviderType.TEXT_TO_SPEECH, + umo=umo, + ) - def get_using_stt_provider(self, umo: str = None) -> STTProvider: + def get_using_stt_provider(self, umo: str | None = None) -> STTProvider: """ 获取当前使用的用于 STT 任务的 Provider。 Args: umo(str): unified_message_origin 值,如果传入,则使用该会话偏好的提供商。 """ - if umo and self._config["provider_settings"]["separate_provider"]: - perf = sp.get("session_provider_perf", {}) - prov_id = perf.get(umo, {}).get(ProviderType.SPEECH_TO_TEXT.value, None) - if inst := self.provider_manager.inst_map.get(prov_id, None): - return inst - return self.provider_manager.curr_stt_provider_inst + return self.provider_manager.get_using_provider( + provider_type=ProviderType.SPEECH_TO_TEXT, + umo=umo, + ) - def get_config(self) -> AstrBotConfig: + def get_config(self, umo: str | None = None) -> AstrBotConfig: """获取 AstrBot 的配置。""" - return self._config + if not umo: + # using default config + return self._config + else: + return self.astrbot_config_mgr.get_conf(umo) def get_db(self) -> BaseDatabase: """获取 AstrBot 数据库。""" @@ -201,9 +177,14 @@ class Context: """ return self._event_queue - def get_platform(self, platform_type: Union[PlatformAdapterType, str]) -> Platform: + @deprecated(version="4.0.0", reason="Use get_platform_inst instead") + def get_platform( + self, platform_type: Union[PlatformAdapterType, str] + ) -> Platform | None: """ 获取指定类型的平台适配器。 + + 该方法已经过时,请使用 get_platform_inst 方法。(>= AstrBot v4.0.0) """ for platform in self.platform_manager.platform_insts: name = platform.meta().name @@ -217,6 +198,20 @@ class Context: ): return platform + def get_platform_inst(self, platform_id: str) -> Platform | None: + """ + 获取指定 ID 的平台适配器实例。 + + Args: + platform_id (str): 平台适配器的唯一标识符。你可以通过 event.get_platform_id() 获取。 + + Returns: + Platform: 平台适配器实例,如果未找到则返回 None。 + """ + for platform in self.platform_manager.platform_insts: + if platform.meta().id == platform_id: + return platform + async def send_message( self, session: Union[str, MessageSesion], message_chain: MessageChain ) -> bool: @@ -240,7 +235,7 @@ class Context: raise ValueError("不合法的 session 字符串: " + str(e)) for platform in self.platform_manager.platform_insts: - if platform.meta().name == session.platform_name: + if platform.meta().id == session.platform_name: await platform.send_by_session(session, message_chain) return True return False diff --git a/astrbot/core/star/register/__init__.py b/astrbot/core/star/register/__init__.py index fa6a730ba..55a4393da 100644 --- a/astrbot/core/star/register/__init__.py +++ b/astrbot/core/star/register/__init__.py @@ -11,6 +11,7 @@ from .star_handler import ( register_on_llm_request, register_on_llm_response, register_llm_tool, + register_agent, register_on_decorating_result, register_after_message_sent, ) @@ -28,6 +29,7 @@ __all__ = [ "register_on_llm_request", "register_on_llm_response", "register_llm_tool", + "register_agent", "register_on_decorating_result", "register_after_message_sent", ] diff --git a/astrbot/core/star/register/star_handler.py b/astrbot/core/star/register/star_handler.py index 0b9f7ad09..101f3a95f 100644 --- a/astrbot/core/star/register/star_handler.py +++ b/astrbot/core/star/register/star_handler.py @@ -15,6 +15,11 @@ from ..filter.regex import RegexFilter from typing import Awaitable from astrbot.core.provider.func_tool_manager import SUPPORTED_TYPES from astrbot.core.provider.register import llm_tools +from astrbot.core.agent.agent import Agent +from astrbot.core.agent.tool import FunctionTool +from astrbot.core.agent.handoff import HandoffTool +from astrbot.core.agent.hooks import BaseAgentRunHooks +from astrbot.core.astr_agent_context import AstrAgentContext def get_handler_full_name(awaitable: Awaitable) -> str: @@ -306,7 +311,7 @@ def register_on_llm_response(**kwargs): return decorator -def register_llm_tool(name: str = None): +def register_llm_tool(name: str = None, **kwargs): """为函数调用(function-calling / tools-use)添加工具。 请务必按照以下格式编写一个工具(包括函数注释,AstrBot 会尝试解析该函数注释) @@ -340,6 +345,9 @@ def register_llm_tool(name: str = None): """ name_ = name + registering_agent = None + if kwargs.get("registering_agent"): + registering_agent = kwargs["registering_agent"] def decorator(awaitable: Awaitable): llm_tool_name = name_ if name_ else awaitable.__name__ @@ -357,15 +365,69 @@ def register_llm_tool(name: str = None): "description": arg.description, } ) - md = get_handler_or_create(awaitable, EventType.OnCallingFuncToolEvent) - llm_tools.add_func( - llm_tool_name, args, docstring.description.strip(), md.handler - ) + # print(llm_tool_name, registering_agent) + if not registering_agent: + md = get_handler_or_create(awaitable, EventType.OnCallingFuncToolEvent) + llm_tools.add_func( + llm_tool_name, args, docstring.description.strip(), md.handler + ) + else: + assert isinstance(registering_agent, RegisteringAgent) + # print(f"Registering tool {llm_tool_name} for agent", registering_agent._agent.name) + if registering_agent._agent.tools is None: + registering_agent._agent.tools = [] + registering_agent._agent.tools.append(llm_tools.spec_to_func( + llm_tool_name, args, docstring.description.strip(), awaitable + )) + return awaitable return decorator +class RegisteringAgent: + """用于 Agent 注册""" + + def llm_tool(self, *args, **kwargs): + kwargs["registering_agent"] = self + return register_llm_tool(*args, **kwargs) + + def __init__(self, agent: Agent[AstrAgentContext]): + self._agent = agent + + +def register_agent( + name: str, + instruction: str, + tools: list[str | FunctionTool] = None, + run_hooks: BaseAgentRunHooks[AstrAgentContext] = None, +): + """注册一个 Agent + + Args: + name: Agent 的名称 + instruction: Agent 的指令 + tools: Agent 使用的工具列表 + run_hooks: Agent 运行时的钩子函数 + """ + tools_ = tools or [] + + def decorator(awaitable: Awaitable): + AstrAgent = Agent[AstrAgentContext] + agent = AstrAgent( + name=name, + instructions=instruction, + tools=tools_, + run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](), + ) + handoff_tool = HandoffTool(agent=agent) + handoff_tool.handler=awaitable + llm_tools.func_list.append(handoff_tool) + return RegisteringAgent(agent) + + return decorator + + def register_on_decorating_result(**kwargs): """在发送消息前的事件""" diff --git a/astrbot/core/star/session_llm_manager.py b/astrbot/core/star/session_llm_manager.py index 4bceb1109..6c5bc994d 100644 --- a/astrbot/core/star/session_llm_manager.py +++ b/astrbot/core/star/session_llm_manager.py @@ -2,8 +2,6 @@ 会话服务管理器 - 负责管理每个会话的LLM、TTS等服务的启停状态 """ -from typing import Dict - from astrbot.core import logger, sp from astrbot.core.platform.astr_message_event import AstrMessageEvent @@ -26,8 +24,9 @@ class SessionServiceManager: bool: True表示启用,False表示禁用 """ # 获取会话服务配置 - session_config = sp.get("session_service_config", {}) or {} - session_services = session_config.get(session_id, {}) + session_services = sp.get( + "session_service_config", {}, scope="umo", scope_id=session_id + ) # 如果配置了该会话的LLM状态,返回该状态 llm_enabled = session_services.get("llm_enabled") @@ -45,16 +44,13 @@ class SessionServiceManager: session_id: 会话ID (unified_msg_origin) enabled: True表示启用,False表示禁用 """ - # 获取当前配置 - session_config = sp.get("session_service_config", {}) or {} - if session_id not in session_config: - session_config[session_id] = {} - - # 设置LLM状态 - session_config[session_id]["llm_enabled"] = enabled - - # 保存配置 - sp.put("session_service_config", session_config) + session_config = ( + sp.get("session_service_config", {}, scope="umo", scope_id=session_id) or {} + ) + session_config["llm_enabled"] = enabled + sp.put( + "session_service_config", session_config, scope="umo", scope_id=session_id + ) logger.info( f"会话 {session_id} 的LLM状态已更新为: {'启用' if enabled else '禁用'}" @@ -88,8 +84,9 @@ class SessionServiceManager: bool: True表示启用,False表示禁用 """ # 获取会话服务配置 - session_config = sp.get("session_service_config", {}) or {} - session_services = session_config.get(session_id, {}) + session_services = sp.get( + "session_service_config", {}, scope="umo", scope_id=session_id + ) # 如果配置了该会话的TTS状态,返回该状态 tts_enabled = session_services.get("tts_enabled") @@ -107,16 +104,13 @@ class SessionServiceManager: session_id: 会话ID (unified_msg_origin) enabled: True表示启用,False表示禁用 """ - # 获取当前配置 - session_config = sp.get("session_service_config", {}) or {} - if session_id not in session_config: - session_config[session_id] = {} - - # 设置TTS状态 - session_config[session_id]["tts_enabled"] = enabled - - # 保存配置 - sp.put("session_service_config", session_config) + session_config = ( + sp.get("session_service_config", {}, scope="umo", scope_id=session_id) or {} + ) + session_config["tts_enabled"] = enabled + sp.put( + "session_service_config", session_config, scope="umo", scope_id=session_id + ) logger.info( f"会话 {session_id} 的TTS状态已更新为: {'启用' if enabled else '禁用'}" @@ -150,8 +144,9 @@ class SessionServiceManager: bool: True表示启用,False表示禁用 """ # 获取会话服务配置 - session_config = sp.get("session_service_config", {}) or {} - session_services = session_config.get(session_id, {}) + session_services = sp.get( + "session_service_config", {}, scope="umo", scope_id=session_id + ) # 如果配置了该会话的整体状态,返回该状态 session_enabled = session_services.get("session_enabled") @@ -169,16 +164,13 @@ class SessionServiceManager: session_id: 会话ID (unified_msg_origin) enabled: True表示启用,False表示禁用 """ - # 获取当前配置 - session_config = sp.get("session_service_config", {}) or {} - if session_id not in session_config: - session_config[session_id] = {} - - # 设置会话整体状态 - session_config[session_id]["session_enabled"] = enabled - - # 保存配置 - sp.put("session_service_config", session_config) + session_config = ( + sp.get("session_service_config", {}, scope="umo", scope_id=session_id) or {} + ) + session_config["session_enabled"] = enabled + sp.put( + "session_service_config", session_config, scope="umo", scope_id=session_id + ) logger.info( f"会话 {session_id} 的整体状态已更新为: {'启用' if enabled else '禁用'}" @@ -202,7 +194,7 @@ class SessionServiceManager: # ============================================================================= @staticmethod - def get_session_custom_name(session_id: str) -> str: + def get_session_custom_name(session_id: str) -> str | None: """获取会话的自定义名称 Args: @@ -211,8 +203,9 @@ class SessionServiceManager: Returns: str: 自定义名称,如果没有设置则返回None """ - session_config = sp.get("session_service_config", {}) or {} - session_services = session_config.get(session_id, {}) + session_services = sp.get( + "session_service_config", {}, scope="umo", scope_id=session_id + ) return session_services.get("custom_name") @staticmethod @@ -223,20 +216,17 @@ class SessionServiceManager: session_id: 会话ID (unified_msg_origin) custom_name: 自定义名称,可以为空字符串来清除名称 """ - # 获取当前配置 - session_config = sp.get("session_service_config", {}) or {} - if session_id not in session_config: - session_config[session_id] = {} - - # 设置自定义名称 + session_config = ( + sp.get("session_service_config", {}, scope="umo", scope_id=session_id) or {} + ) if custom_name and custom_name.strip(): - session_config[session_id]["custom_name"] = custom_name.strip() + session_config["custom_name"] = custom_name.strip() else: # 如果传入空名称,则删除自定义名称 - session_config[session_id].pop("custom_name", None) - - # 保存配置 - sp.put("session_service_config", session_config) + session_config.pop("custom_name", None) + sp.put( + "session_service_config", session_config, scope="umo", scope_id=session_id + ) logger.info( f"会话 {session_id} 的自定义名称已更新为: {custom_name.strip() if custom_name and custom_name.strip() else '已清除'}" @@ -258,36 +248,3 @@ class SessionServiceManager: # 如果没有自定义名称,返回session_id的最后一段 return session_id.split(":")[2] if session_id.count(":") >= 2 else session_id - - # ============================================================================= - # 通用配置方法 - # ============================================================================= - - @staticmethod - def get_session_service_config(session_id: str) -> Dict[str, bool]: - """获取指定会话的服务配置 - - Args: - session_id: 会话ID (unified_msg_origin) - - Returns: - Dict[str, bool]: 包含session_enabled、llm_enabled、tts_enabled的字典 - """ - session_config = sp.get("session_service_config", {}) or {} - return session_config.get( - session_id, - { - "session_enabled": True, # 默认启用 - "llm_enabled": True, # 默认启用 - "tts_enabled": True, # 默认启用 - }, - ) - - @staticmethod - def get_all_session_configs() -> Dict[str, Dict[str, bool]]: - """获取所有会话的服务配置 - - Returns: - Dict[str, Dict[str, bool]]: 所有会话的服务配置 - """ - return sp.get("session_service_config", {}) or {} diff --git a/astrbot/core/star/session_plugin_manager.py b/astrbot/core/star/session_plugin_manager.py index c0d1bbd73..5c7303e8d 100644 --- a/astrbot/core/star/session_plugin_manager.py +++ b/astrbot/core/star/session_plugin_manager.py @@ -22,7 +22,9 @@ class SessionPluginManager: bool: True表示启用,False表示禁用 """ # 获取会话插件配置 - session_plugin_config = sp.get("session_plugin_config", {}) or {} + session_plugin_config = sp.get( + "session_plugin_config", {}, scope="umo", scope_id=session_id + ) session_config = session_plugin_config.get(session_id, {}) enabled_plugins = session_config.get("enabled_plugins", []) @@ -51,7 +53,9 @@ class SessionPluginManager: enabled: True表示启用,False表示禁用 """ # 获取当前配置 - session_plugin_config = sp.get("session_plugin_config", {}) or {} + session_plugin_config = sp.get( + "session_plugin_config", {}, scope="umo", scope_id=session_id + ) if session_id not in session_plugin_config: session_plugin_config[session_id] = { "enabled_plugins": [], @@ -79,7 +83,9 @@ class SessionPluginManager: session_config["enabled_plugins"] = enabled_plugins session_config["disabled_plugins"] = disabled_plugins session_plugin_config[session_id] = session_config - sp.put("session_plugin_config", session_plugin_config) + sp.put( + "session_plugin_config", session_plugin_config, scope="umo", scope_id=session_id + ) logger.info( f"会话 {session_id} 的插件 {plugin_name} 状态已更新为: {'启用' if enabled else '禁用'}" @@ -95,7 +101,9 @@ class SessionPluginManager: Returns: Dict[str, List[str]]: 包含enabled_plugins和disabled_plugins的字典 """ - session_plugin_config = sp.get("session_plugin_config", {}) or {} + session_plugin_config = sp.get( + "session_plugin_config", {}, scope="umo", scope_id=session_id + ) return session_plugin_config.get( session_id, {"enabled_plugins": [], "disabled_plugins": []} ) diff --git a/astrbot/core/star/star.py b/astrbot/core/star/star.py index 2fe9dd7f3..0563e8cc8 100644 --- a/astrbot/core/star/star.py +++ b/astrbot/core/star/star.py @@ -56,32 +56,8 @@ class StarMetadata: star_handler_full_names: list[str] = field(default_factory=list) """注册的 Handler 的全名列表""" - supported_platforms: dict[str, bool] = field(default_factory=dict) - """插件支持的平台ID字典,key为平台ID,value为是否支持""" - def __str__(self) -> str: return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}" def __repr__(self) -> str: return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}" - - def update_platform_compatibility(self, plugin_enable_config: dict) -> None: - """更新插件支持的平台列表 - - Args: - plugin_enable_config: 平台插件启用配置,即platform_settings.plugin_enable配置项 - """ - if not plugin_enable_config: - return - - # 清空之前的配置 - self.supported_platforms.clear() - - # 遍历所有平台配置 - for platform_id, plugins in plugin_enable_config.items(): - # 检查该插件在当前平台的配置 - if self.name in plugins: - self.supported_platforms[platform_id] = plugins[self.name] - else: - # 如果没有明确配置,默认为启用 - self.supported_platforms[platform_id] = True diff --git a/astrbot/core/star/star_handler.py b/astrbot/core/star/star_handler.py index d375091e5..43a74396a 100644 --- a/astrbot/core/star/star_handler.py +++ b/astrbot/core/star/star_handler.py @@ -7,6 +7,7 @@ from .star import star_map T = TypeVar("T", bound="StarHandlerMetadata") + class StarHandlerRegistry(Generic[T]): def __init__(self): self.star_handlers_map: Dict[str, StarHandlerMetadata] = {} @@ -26,7 +27,10 @@ class StarHandlerRegistry(Generic[T]): print(handler.handler_full_name) def get_handlers_by_event_type( - self, event_type: EventType, only_activated=True, platform_id=None + self, + event_type: EventType, + only_activated=True, + plugins_name: list[str] | None = None, ) -> List[StarHandlerMetadata]: handlers = [] for handler in self._handlers: @@ -36,8 +40,15 @@ class StarHandlerRegistry(Generic[T]): plugin = star_map.get(handler.handler_module_path) if not (plugin and plugin.activated): continue - if platform_id and event_type != EventType.OnAstrBotLoadedEvent: - if not handler.is_enabled_for_platform(platform_id): + if plugins_name is not None and plugins_name != ["*"]: + plugin = star_map.get(handler.handler_module_path) + if not plugin: + continue + if ( + plugin.name not in plugins_name + and event_type != EventType.OnAstrBotLoadedEvent + and not plugin.reserved + ): continue handlers.append(handler) return handlers @@ -49,7 +60,8 @@ class StarHandlerRegistry(Generic[T]): self, module_name: str ) -> List[StarHandlerMetadata]: return [ - handler for handler in self._handlers + handler + for handler in self._handlers if handler.handler_module_path == module_name ] @@ -67,6 +79,7 @@ class StarHandlerRegistry(Generic[T]): def __len__(self): return len(self._handlers) + star_handlers_registry = StarHandlerRegistry() @@ -119,32 +132,3 @@ class StarHandlerMetadata: return self.extras_configs.get("priority", 0) < other.extras_configs.get( "priority", 0 ) - - def is_enabled_for_platform(self, platform_id: str) -> bool: - """检查插件是否在指定平台启用 - - Args: - platform_id: 平台ID,这是从event.get_platform_id()获取的,用于唯一标识平台实例 - - Returns: - bool: 是否启用,True表示启用,False表示禁用 - """ - plugin = star_map.get(self.handler_module_path) - - # 如果插件元数据不存在,默认允许执行 - if not plugin or not plugin.name: - return True - - # 先检查插件是否被激活 - if not plugin.activated: - return False - - # 直接使用StarMetadata中缓存的supported_platforms判断平台兼容性 - if ( - hasattr(plugin, "supported_platforms") - and platform_id in plugin.supported_platforms - ): - return plugin.supported_platforms[platform_id] - - # 如果没有缓存数据,默认允许执行 - return True diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index ab98b254e..5fb1b1dfa 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -22,6 +22,7 @@ from astrbot.core.utils.astrbot_path import ( get_astrbot_plugin_path, ) from astrbot.core.utils.io import remove_dir +from astrbot.core.agent.handoff import HandoffTool, FunctionTool from . import StarMetadata from .context import Context @@ -336,30 +337,8 @@ class PluginManager: result = await self.load(specified_module_path) - # 更新所有插件的平台兼容性 - await self.update_all_platform_compatibility() - return result - async def update_all_platform_compatibility(self): - """更新所有插件的平台兼容性设置""" - # 获取最新的平台插件启用配置 - plugin_enable_config = self.config.get("platform_settings", {}).get( - "plugin_enable", {} - ) - logger.debug( - f"更新所有插件的平台兼容性设置,平台数量: {len(plugin_enable_config)}" - ) - - # 遍历所有插件,更新平台兼容性 - for plugin in self.context.get_all_stars(): - plugin.update_platform_compatibility(plugin_enable_config) - logger.debug( - f"插件 {plugin.name} 支持的平台: {list(plugin.supported_platforms.keys())}" - ) - - return True - async def load(self, specified_module_path=None, specified_dir_name=None): """载入插件。 当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。 @@ -373,10 +352,9 @@ class PluginManager: - success (bool): 是否全部加载成功 - error_message (str|None): 错误信息,成功时为 None """ - inactivated_plugins: list = sp.get("inactivated_plugins", []) - inactivated_llm_tools: list = sp.get("inactivated_llm_tools", []) - - alter_cmd = sp.get("alter_cmd", {}) + inactivated_plugins = await sp.global_get("inactivated_plugins", []) + inactivated_llm_tools = await sp.global_get("inactivated_llm_tools", []) + alter_cmd = await sp.global_get("alter_cmd", {}) plugin_modules = self._get_plugin_modules() if plugin_modules is None: @@ -480,12 +458,6 @@ class PluginManager: metadata.root_dir_name = root_dir_name metadata.reserved = reserved - # 更新插件的平台兼容性 - plugin_enable_config = self.config.get("platform_settings", {}).get( - "plugin_enable", {} - ) - metadata.update_platform_compatibility(plugin_enable_config) - assert metadata.module_path is not None, ( f"插件 {metadata.name} 的模块路径为空。" ) @@ -503,17 +475,27 @@ class PluginManager: ) # 绑定 llm_tool handler for func_tool in llm_tools.func_list: - if ( - func_tool.handler - and func_tool.handler.__module__ == metadata.module_path - ): - func_tool.handler_module_path = metadata.module_path - func_tool.handler = functools.partial( - func_tool.handler, - metadata.star_cls, # type: ignore - ) - if func_tool.name in inactivated_llm_tools: - func_tool.active = False + if isinstance(func_tool, HandoffTool): + need_apply = [] + sub_tools = func_tool.agent.tools + for sub_tool in sub_tools: + if isinstance(sub_tool, FunctionTool): + need_apply.append(sub_tool) + else: + need_apply = [func_tool] + + for ft in need_apply: + if ( + ft.handler + and ft.handler.__module__ == metadata.module_path + ): + ft.handler_module_path = metadata.module_path + ft.handler = functools.partial( + ft.handler, + metadata.star_cls, # type: ignore + ) + if ft.name in inactivated_llm_tools: + ft.active = False else: # v3.4.0 以前的方式注册插件 @@ -776,12 +758,12 @@ class PluginManager: await self._terminate_plugin(plugin) # 加入到 shared_preferences 中 - inactivated_plugins: list = sp.get("inactivated_plugins", []) + inactivated_plugins: list = await sp.global_get("inactivated_plugins", []) if plugin.module_path not in inactivated_plugins: inactivated_plugins.append(plugin.module_path) inactivated_llm_tools: list = list( - set(sp.get("inactivated_llm_tools", [])) + set(await sp.global_get("inactivated_llm_tools", [])) ) # 后向兼容 # 禁用插件启用的 llm_tool @@ -791,8 +773,8 @@ class PluginManager: if func_tool.name not in inactivated_llm_tools: inactivated_llm_tools.append(func_tool.name) - sp.put("inactivated_plugins", inactivated_plugins) - sp.put("inactivated_llm_tools", inactivated_llm_tools) + await sp.global_put("inactivated_plugins", inactivated_plugins) + await sp.global_put("inactivated_llm_tools", inactivated_llm_tools) plugin.activated = False @@ -818,11 +800,11 @@ class PluginManager: async def turn_on_plugin(self, plugin_name: str): plugin = self.context.get_registered_star(plugin_name) - inactivated_plugins: list = sp.get("inactivated_plugins", []) - inactivated_llm_tools: list = sp.get("inactivated_llm_tools", []) + inactivated_plugins: list = await sp.global_get("inactivated_plugins", []) + inactivated_llm_tools: list = await sp.global_get("inactivated_llm_tools", []) if plugin.module_path in inactivated_plugins: inactivated_plugins.remove(plugin.module_path) - sp.put("inactivated_plugins", inactivated_plugins) + await sp.global_put("inactivated_plugins", inactivated_plugins) # 启用插件启用的 llm_tool for func_tool in llm_tools.func_list: @@ -832,7 +814,7 @@ class PluginManager: ): inactivated_llm_tools.remove(func_tool.name) func_tool.active = True - sp.put("inactivated_llm_tools", inactivated_llm_tools) + await sp.global_put("inactivated_llm_tools", inactivated_llm_tools) await self.reload(plugin_name) diff --git a/astrbot/core/utils/metrics.py b/astrbot/core/utils/metrics.py index a3a73fcc8..7fe9bde05 100644 --- a/astrbot/core/utils/metrics.py +++ b/astrbot/core/utils/metrics.py @@ -58,9 +58,10 @@ class Metric: pass try: if "adapter_name" in kwargs: - db_helper.insert_platform_metrics({kwargs["adapter_name"]: 1}) - if "llm_name" in kwargs: - db_helper.insert_llm_metrics({kwargs["llm_name"]: 1}) + await db_helper.insert_platform_stats( + platform_id=kwargs["adapter_name"], + platform_type=kwargs.get("adapter_type", "unknown"), + ) except Exception as e: logger.error(f"保存指标到数据库失败: {e}") pass diff --git a/astrbot/core/utils/shared_preferences.py b/astrbot/core/utils/shared_preferences.py index 42018d19e..c1368f186 100644 --- a/astrbot/core/utils/shared_preferences.py +++ b/astrbot/core/utils/shared_preferences.py @@ -1,43 +1,180 @@ -import json +from astrbot.core.db import BaseDatabase +from astrbot.core.db.po import Preference +import threading +import asyncio import os -from typing import TypeVar +from typing import TypeVar, Any, overload from .astrbot_path import get_astrbot_data_path + _VT = TypeVar("_VT") + class SharedPreferences: - def __init__(self, path=None): - if path is None: - path = os.path.join(get_astrbot_data_path(), "shared_preferences.json") - self.path = path - self._data = self._load_preferences() + def __init__(self, db_helper: BaseDatabase, json_storage_path=None): + if json_storage_path is None: + json_storage_path = os.path.join( + get_astrbot_data_path(), "shared_preferences.json" + ) + self.path = json_storage_path + self.db_helper = db_helper - def _load_preferences(self): - if os.path.exists(self.path): - try: - with open(self.path, "r") as f: - return json.load(f) - except json.JSONDecodeError: - os.remove(self.path) - return {} + self._sync_loop = asyncio.new_event_loop() + t = threading.Thread(target=self._sync_loop.run_forever, daemon=True) + t.start() - def _save_preferences(self): - with open(self.path, "w") as f: - json.dump(self._data, f, indent=4, ensure_ascii=False) - f.flush() + async def get_async( + self, + scope: str, + scope_id: str, + key: str, + default: _VT = None, + ) -> _VT: + """获取指定范围和键的偏好设置""" + if scope_id is not None and key is not None: + result = await self.db_helper.get_preference(scope, scope_id, key) + if result: + ret = result.value["val"] + else: + ret = default + return ret + else: + raise ValueError( + "scope_id and key cannot be None when getting a specific preference." + ) - def get(self, key, default: _VT = None) -> _VT: - return self._data.get(key, default) + async def range_get_async( + self, scope: str, scope_id: str | None = None, key: str | None = None + ) -> list[Preference]: + """获取指定范围的偏好设置 + Note: 返回 Preference 列表,其中的 value 属性是一个 dict,value["val"] 为值。scope_id 和 key 可以为 None,这时返回该范围下所有的偏好设置。 + """ + ret = await self.db_helper.get_preferences(scope, scope_id, key) + return ret - def put(self, key, value): - self._data[key] = value - self._save_preferences() + @overload + async def session_get( + self, umo: None, key: str, default: Any = None + ) -> list[Preference]: ... - def remove(self, key): - if key in self._data: - del self._data[key] - self._save_preferences() + @overload + async def session_get( + self, umo: str, key: None, default: Any = None + ) -> list[Preference]: ... - def clear(self): - self._data.clear() - self._save_preferences() + @overload + async def session_get( + self, umo: None, key: None, default: Any = None + ) -> list[Preference]: ... + + async def session_get( + self, umo: str | None, key: str | None = None, default: _VT = None + ) -> _VT | list[Preference]: + """获取会话范围的偏好设置 + + Note: 当 scope_id 或者 key 为 None,时,返回 Preference 列表,其中的 value 属性是一个 dict,value["val"] 为值。 + """ + if umo is None or key is None: + return await self.range_get_async("umo", umo, key) + return await self.get_async("umo", umo, key, default) + + @overload + async def global_get(self, key: None, default: Any = None) -> list[Preference]: ... + + @overload + async def global_get(self, key: str, default: _VT = None) -> _VT: ... + + async def global_get( + self, key: str | None, default: _VT = None + ) -> _VT | list[Preference]: + """获取全局范围的偏好设置 + + Note: 当 scope_id 或者 key 为 None,时,返回 Preference 列表,其中的 value 属性是一个 dict,value["val"] 为值。 + """ + if key is None: + return await self.range_get_async("global", "global", key) + return await self.get_async("global", "global", key, default) + + async def put_async(self, scope: str, scope_id: str, key: str, value: Any): + """设置指定范围和键的偏好设置""" + await self.db_helper.insert_preference_or_update( + scope, scope_id, key, {"val": value} + ) + + async def session_put(self, umo: str, key: str, value: Any): + await self.put_async("umo", umo, key, value) + + async def global_put(self, key: str, value: Any): + await self.put_async("global", "global", key, value) + + async def remove_async(self, scope: str, scope_id: str, key: str): + """删除指定范围和键的偏好设置""" + await self.db_helper.remove_preference(scope, scope_id, key) + + async def session_remove(self, umo: str, key: str): + await self.remove_async("umo", umo, key) + + async def global_remove(self, key: str): + """删除全局偏好设置""" + await self.remove_async("global", "global", key) + + async def clear_async(self, scope: str, scope_id: str): + """清空指定范围的所有偏好设置""" + await self.db_helper.clear_preferences(scope, scope_id) + + # ==== + # DEPRECATED METHODS + # ==== + + def get( + self, + key: str, + default: _VT = None, + scope: str | None = None, + scope_id: str | None = "", + ) -> _VT: + """获取偏好设置(已弃用)""" + if scope_id == "": + scope_id = "unknown" + if scope_id is None or key is None: + # result = asyncio.run(self.range_get_async(scope, scope_id, key)) + raise ValueError( + "scope_id and key cannot be None when getting a specific preference." + ) + result = asyncio.run_coroutine_threadsafe( + self.get_async(scope or "unknown", scope_id or "unknown", key, default), + self._sync_loop, + ).result() + + return result if result is not None else default + + def range_get( + self, scope: str, scope_id: str | None = None, key: str | None = None + ) -> list[Preference]: + """获取指定范围的偏好设置(已弃用)""" + result = asyncio.run_coroutine_threadsafe( + self.range_get_async(scope, scope_id, key), self._sync_loop + ).result() + + return result + + def put(self, key, value, scope: str | None = None, scope_id: str | None = None): + """设置偏好设置(已弃用)""" + asyncio.run_coroutine_threadsafe( + self.put_async(scope or "unknown", scope_id or "unknown", key, value), + self._sync_loop, + ).result() + + def remove(self, key, scope: str | None = None, scope_id: str | None = None): + """删除偏好设置(已弃用)""" + asyncio.run_coroutine_threadsafe( + self.remove_async(scope or "unknown", scope_id or "unknown", key), + self._sync_loop, + ).result() + + def clear(self, scope: str | None = None, scope_id: str | None = None): + """清空偏好设置(已弃用)""" + asyncio.run_coroutine_threadsafe( + self.clear_async(scope or "unknown", scope_id or "unknown"), + self._sync_loop, + ).result() diff --git a/astrbot/core/utils/t2i/network_strategy.py b/astrbot/core/utils/t2i/network_strategy.py index 03db6d5e7..2295f051b 100644 --- a/astrbot/core/utils/t2i/network_strategy.py +++ b/astrbot/core/utils/t2i/network_strategy.py @@ -1,37 +1,76 @@ import aiohttp +import asyncio import os import ssl import certifi - +import logging +import random from . import RenderStrategy from astrbot.core.config import VERSION from astrbot.core.utils.io import download_image_by_url +from astrbot.core.utils.astrbot_path import get_astrbot_data_path ASTRBOT_T2I_DEFAULT_ENDPOINT = "https://t2i.soulter.top/text2img" +CUSTOM_T2I_TEMPLATE_PATH = os.path.join(get_astrbot_data_path(), "t2i_template.html") + +logger = logging.getLogger("astrbot") class NetworkRenderStrategy(RenderStrategy): def __init__(self, base_url: str | None = None) -> None: super().__init__() if not base_url: - base_url = ASTRBOT_T2I_DEFAULT_ENDPOINT - self.BASE_RENDER_URL = base_url - self.TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "template") + self.BASE_RENDER_URL = ASTRBOT_T2I_DEFAULT_ENDPOINT + else: + self.BASE_RENDER_URL = self._clean_url(base_url) + self.TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "template", "base.html") + with open(self.TEMPLATE_PATH, "r", encoding="utf-8") as f: + self.DEFAULT_TEMPLATE = f.read() - if self.BASE_RENDER_URL.endswith("/"): - self.BASE_RENDER_URL = self.BASE_RENDER_URL[:-1] - if not self.BASE_RENDER_URL.endswith("text2img"): - self.BASE_RENDER_URL += "/text2img" + self.endpoints = [self.BASE_RENDER_URL] - def set_endpoint(self, base_url: str): - if not base_url: - base_url = ASTRBOT_T2I_DEFAULT_ENDPOINT - self.BASE_RENDER_URL = base_url + async def initialize(self): + if self.BASE_RENDER_URL == ASTRBOT_T2I_DEFAULT_ENDPOINT: + asyncio.create_task(self.get_official_endpoints()) - if self.BASE_RENDER_URL.endswith("/"): - self.BASE_RENDER_URL = self.BASE_RENDER_URL[:-1] - if not self.BASE_RENDER_URL.endswith("text2img"): - self.BASE_RENDER_URL += "/text2img" + async def get_template(self) -> str: + """获取文转图 HTML 模板 + + Returns: + str: 文转图 HTML 模板字符串 + """ + if os.path.exists(CUSTOM_T2I_TEMPLATE_PATH): + with open(CUSTOM_T2I_TEMPLATE_PATH, "r", encoding="utf-8") as f: + return f.read() + return self.DEFAULT_TEMPLATE + + async def get_official_endpoints(self): + """获取官方的 t2i 端点列表。""" + try: + async with aiohttp.ClientSession() as session: + async with session.get( + "https://api.soulter.top/astrbot/t2i-endpoints" + ) as resp: + if resp.status == 200: + data = await resp.json() + all_endpoints: list[dict] = data.get("data", []) + self.endpoints = [ + ep.get("url") + for ep in all_endpoints + if ep.get("active") and ep.get("url") + ] + logger.info( + f"Successfully got {len(self.endpoints)} official T2I endpoints." + ) + except Exception as e: + logger.error(f"Failed to get official endpoints: {e}") + + def _clean_url(self, url: str): + if url.endswith("/"): + url = url[:-1] + if not url.endswith("text2img"): + url += "/text2img" + return url async def render_custom_template( self, @@ -41,6 +80,7 @@ class NetworkRenderStrategy(RenderStrategy): options: dict | None = None, ) -> str: """使用自定义文转图模板""" + default_options = {"full_page": True, "type": "jpeg", "quality": 40} if options: default_options |= options @@ -51,30 +91,44 @@ class NetworkRenderStrategy(RenderStrategy): "tmpldata": tmpl_data, "options": default_options, } - if return_url: - ssl_context = ssl.create_default_context(cafile=certifi.where()) - connector = aiohttp.TCPConnector(ssl=ssl_context) - async with aiohttp.ClientSession( - trust_env=True, connector=connector - ) as session: - async with session.post( - f"{self.BASE_RENDER_URL}/generate", json=post_data - ) as resp: - ret = await resp.json() - return f"{self.BASE_RENDER_URL}/{ret['data']['id']}" - return await download_image_by_url( - f"{self.BASE_RENDER_URL}/generate", post=True, post_data=post_data - ) + + endpoints = self.endpoints.copy() if self.endpoints else [self.BASE_RENDER_URL] + random.shuffle(endpoints) + last_exception = None + for endpoint in endpoints: + try: + if return_url: + ssl_context = ssl.create_default_context(cafile=certifi.where()) + connector = aiohttp.TCPConnector(ssl=ssl_context) + async with aiohttp.ClientSession( + trust_env=True, connector=connector + ) as session: + async with session.post( + f"{endpoint}/generate", json=post_data + ) as resp: + if resp.status == 200: + ret = await resp.json() + return f"{endpoint}/{ret['data']['id']}" + else: + raise Exception(f"HTTP {resp.status}") + else: + # download_image_by_url 失败时抛异常 + return await download_image_by_url( + f"{endpoint}/generate", post=True, post_data=post_data + ) + except Exception as e: + last_exception = e + logger.warning(f"Endpoint {endpoint} failed: {e}, trying next...") + continue + # 全部失败 + logger.error(f"All endpoints failed: {last_exception}") + raise RuntimeError(f"All endpoints failed: {last_exception}") async def render(self, text: str, return_url: bool = False) -> str: """ 返回图像的文件路径 """ - with open( - os.path.join(self.TEMPLATE_PATH, "base.html"), "r", encoding="utf-8" - ) as f: - tmpl_str = f.read() - assert tmpl_str + tmpl_str = await self.get_template() text = text.replace("`", "\\`") return await self.render_custom_template( tmpl_str, {"text": text, "version": f"v{VERSION}"}, return_url diff --git a/astrbot/core/utils/t2i/renderer.py b/astrbot/core/utils/t2i/renderer.py index 9e423be15..a3ceec4ad 100644 --- a/astrbot/core/utils/t2i/renderer.py +++ b/astrbot/core/utils/t2i/renderer.py @@ -10,10 +10,8 @@ class HtmlRenderer: self.network_strategy = NetworkRenderStrategy(endpoint_url) self.local_strategy = LocalRenderStrategy() - def set_network_endpoint(self, endpoint_url: str): - """设置 t2i 的网络端点。""" - logger.info("文本转图像服务接口: " + endpoint_url) - self.network_strategy.set_endpoint(endpoint_url) + async def initialize(self): + await self.network_strategy.initialize() async def render_custom_template( self, diff --git a/astrbot/dashboard/routes/__init__.py b/astrbot/dashboard/routes/__init__.py index 8d08b9d53..ef2fa3e86 100644 --- a/astrbot/dashboard/routes/__init__.py +++ b/astrbot/dashboard/routes/__init__.py @@ -6,11 +6,11 @@ from .stat import StatRoute from .log import LogRoute from .static_file import StaticFileRoute from .chat import ChatRoute -from .tools import ToolsRoute # 导入新的ToolsRoute +from .tools import ToolsRoute from .conversation import ConversationRoute from .file import FileRoute from .session_management import SessionManagementRoute - +from .persona import PersonaRoute __all__ = [ "AuthRoute", @@ -25,4 +25,5 @@ __all__ = [ "ConversationRoute", "FileRoute", "SessionManagementRoute", + "PersonaRoute", ] diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index 651f1b65c..083647bc3 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -9,6 +9,7 @@ import asyncio from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.platform.astr_message_event import MessageSession class ChatRoute(Route): @@ -29,28 +30,15 @@ class ChatRoute(Route): "/chat/get_file": ("GET", self.get_file), "/chat/post_image": ("POST", self.post_image), "/chat/post_file": ("POST", self.post_file), - "/chat/status": ("GET", self.status), } - self.db = db self.core_lifecycle = core_lifecycle self.register_routes() self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") os.makedirs(self.imgs_dir, exist_ok=True) self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"] - - async def status(self): - has_llm_enabled = ( - self.core_lifecycle.provider_manager.curr_provider_inst is not None - ) - has_stt_enabled = ( - self.core_lifecycle.provider_manager.curr_stt_provider_inst is not None - ) - return ( - Response() - .ok(data={"llm_enabled": has_llm_enabled, "stt_enabled": has_stt_enabled}) - .__dict__ - ) + self.conv_mgr = core_lifecycle.conversation_manager + self.platform_history_mgr = core_lifecycle.platform_message_history_manager async def get_file(self): filename = request.args.get("filename") @@ -131,24 +119,23 @@ class ChatRoute(Route): if not conversation_id: return Response().error("conversation_id is empty").__dict__ - # Get conversation-specific queues - back_queue = webchat_queue_mgr.get_or_create_back_queue(conversation_id) - # append user message - conversation = self.db.get_conversation_by_user_id(username, conversation_id) - try: - history = json.loads(conversation.history) - except BaseException as e: - logger.error(f"Failed to parse conversation history: {e}") - history = [] + webchat_conv_id = await self._get_webchat_conv_id_from_conv_id(conversation_id) + + # Get conversation-specific queues + back_queue = webchat_queue_mgr.get_or_create_back_queue(webchat_conv_id) + new_his = {"type": "user", "message": message} if image_url: new_his["image_url"] = image_url if audio_url: new_his["audio_url"] = audio_url - history.append(new_his) - self.db.update_conversation( - username, conversation_id, history=json.dumps(history) + await self.platform_history_mgr.insert( + platform_id="webchat", + user_id=webchat_conv_id, + content=new_his, + sender_id=username, + sender_name=username, ) async def stream(): @@ -164,7 +151,6 @@ class ChatRoute(Route): result_text = result["data"] type = result.get("type") - cid = result.get("cid") streaming = result.get("streaming", False) yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n" await asyncio.sleep(0.05) @@ -173,17 +159,13 @@ class ChatRoute(Route): break elif (streaming and type == "complete") or not streaming: # append bot message - conversation = self.db.get_conversation_by_user_id( - username, cid - ) - try: - history = json.loads(conversation.history) - except BaseException as e: - logger.error(f"Failed to parse conversation history: {e}") - history = [] - history.append({"type": "bot", "message": result_text}) - self.db.update_conversation( - username, cid, history=json.dumps(history) + new_his = {"type": "bot", "message": result_text} + await self.platform_history_mgr.insert( + platform_id="webchat", + user_id=webchat_conv_id, + content=new_his, + sender_id="bot", + sender_name="bot", ) except BaseException as _: @@ -191,11 +173,11 @@ class ChatRoute(Route): return # Put message to conversation-specific queue - chat_queue = webchat_queue_mgr.get_or_create_queue(conversation_id) + chat_queue = webchat_queue_mgr.get_or_create_queue(webchat_conv_id) await chat_queue.put( ( username, - conversation_id, + webchat_conv_id, { "message": message, "image_url": image_url, # list @@ -217,25 +199,51 @@ class ChatRoute(Route): ) return response + async def _get_webchat_conv_id_from_conv_id(self, conversation_id: str) -> str: + """从对话 ID 中提取 WebChat 会话 ID + + NOTE: 关于这里为什么要单独做一个 WebChat 的 Conversation ID 出来,这个是为了向前兼容。 + """ + conversation = await self.conv_mgr.get_conversation( + unified_msg_origin="webchat", conversation_id=conversation_id + ) + if not conversation: + raise ValueError(f"Conversation with ID {conversation_id} not found.") + conv_user_id = conversation.user_id + webchat_session_id = MessageSession.from_str(conv_user_id).session_id + if "!" not in webchat_session_id: + raise ValueError(f"Invalid conv user ID: {conv_user_id}") + return webchat_session_id.split("!")[-1] + async def delete_conversation(self): - username = g.get("username", "guest") conversation_id = request.args.get("conversation_id") if not conversation_id: return Response().error("Missing key: conversation_id").__dict__ + username = g.get("username", "guest") # Clean up queues when deleting conversation webchat_queue_mgr.remove_queues(conversation_id) - self.db.delete_conversation(username, conversation_id) + webchat_conv_id = await self._get_webchat_conv_id_from_conv_id(conversation_id) + await self.conv_mgr.delete_conversation( + unified_msg_origin=f"webchat:FriendMessage:webchat!{username}!{webchat_conv_id}", + conversation_id=conversation_id, + ) + await self.platform_history_mgr.delete( + platform_id="webchat", user_id=webchat_conv_id, offset_sec=99999999 + ) return Response().ok().__dict__ async def new_conversation(self): username = g.get("username", "guest") - conversation_id = str(uuid.uuid4()) - self.db.new_conversation(username, conversation_id) - return Response().ok(data={"conversation_id": conversation_id}).__dict__ + webchat_conv_id = str(uuid.uuid4()) + conv_id = await self.conv_mgr.new_conversation( + unified_msg_origin=f"webchat:FriendMessage:webchat!{username}!{webchat_conv_id}", + platform_id="webchat", + content=[], + ) + return Response().ok(data={"conversation_id": conv_id}).__dict__ async def rename_conversation(self): - username = g.get("username", "guest") post_data = await request.json if "conversation_id" not in post_data or "title" not in post_data: return Response().error("Missing key: conversation_id or title").__dict__ @@ -243,20 +251,42 @@ class ChatRoute(Route): conversation_id = post_data["conversation_id"] title = post_data["title"] - self.db.update_conversation_title(username, conversation_id, title=title) + await self.conv_mgr.update_conversation( + unified_msg_origin="webchat", # fake + conversation_id=conversation_id, + title=title, + ) return Response().ok(message="重命名成功!").__dict__ async def get_conversations(self): - username = g.get("username", "guest") - conversations = self.db.get_conversations(username) - return Response().ok(data=conversations).__dict__ + conversations = await self.conv_mgr.get_conversations(platform_id="webchat") + # remove content + conversations_ = [] + for conv in conversations: + conv.history = None + conversations_.append(conv) + return Response().ok(data=conversations_).__dict__ async def get_conversation(self): - username = g.get("username", "guest") conversation_id = request.args.get("conversation_id") if not conversation_id: return Response().error("Missing key: conversation_id").__dict__ - conversation = self.db.get_conversation_by_user_id(username, conversation_id) + webchat_conv_id = await self._get_webchat_conv_id_from_conv_id(conversation_id) - return Response().ok(data=conversation).__dict__ + # Get platform message history + history_ls = await self.platform_history_mgr.get( + platform_id="webchat", user_id=webchat_conv_id, page=1, page_size=1000 + ) + + history_res = [history.model_dump() for history in history_ls] + + return ( + Response() + .ok( + data={ + "history": history_res, + } + ) + .__dict__ + ) diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 7de720a38..8cb548c62 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -4,16 +4,22 @@ import os from .route import Route, Response, RouteContext from astrbot.core.provider.entities import ProviderType from quart import request -from astrbot.core.config.default import CONFIG_METADATA_2, DEFAULT_VALUE_MAP +from astrbot.core.config.default import ( + CONFIG_METADATA_2, + DEFAULT_VALUE_MAP, + CONFIG_METADATA_3, + CONFIG_METADATA_3_SYSTEM, +) from astrbot.core.utils.astrbot_path import get_astrbot_path from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.platform.register import platform_registry from astrbot.core.provider.register import provider_registry from astrbot.core.star.star import star_registry -from astrbot.core import logger +from astrbot.core import logger, html_renderer from astrbot.core.provider import Provider import asyncio +from astrbot.core.utils.t2i.network_strategy import CUSTOM_T2I_TEMPLATE_PATH def try_cast(value: str, type_: str): @@ -159,33 +165,166 @@ class ConfigRoute(Route): super().__init__(context) self.core_lifecycle = core_lifecycle self.config: AstrBotConfig = core_lifecycle.astrbot_config + self.acm = core_lifecycle.astrbot_config_mgr self.routes = { + "/config/abconf/new": ("POST", self.create_abconf), + "/config/abconf": ("GET", self.get_abconf), + "/config/abconfs": ("GET", self.get_abconf_list), + "/config/abconf/delete": ("POST", self.delete_abconf), + "/config/abconf/update": ("POST", self.update_abconf), "/config/get": ("GET", self.get_configs), "/config/astrbot/update": ("POST", self.post_astrbot_configs), "/config/plugin/update": ("POST", self.post_plugin_configs), "/config/platform/new": ("POST", self.post_new_platform), "/config/platform/update": ("POST", self.post_update_platform), "/config/platform/delete": ("POST", self.post_delete_platform), + "/config/platform/list": ("GET", self.get_platform_list), "/config/provider/new": ("POST", self.post_new_provider), "/config/provider/update": ("POST", self.post_update_provider), "/config/provider/delete": ("POST", self.post_delete_provider), - "/config/llmtools": ("GET", self.get_llm_tools), "/config/provider/check_one": ("GET", self.check_one_provider_status), "/config/provider/list": ("GET", self.get_provider_config_list), "/config/provider/model_list": ("GET", self.get_provider_model_list), - "/config/provider/get_session_seperate": ( - "GET", - lambda: Response() - .ok({"enable": self.config["provider_settings"]["separate_provider"]}) - .__dict__, - ), - "/config/provider/set_session_seperate": ( - "POST", - self.post_session_seperate, - ), + "/config/astrbot/t2i-template/get": ("GET", self.get_t2i_template), + "/config/astrbot/t2i-template/save": ("POST", self.post_t2i_template), + "/config/astrbot/t2i-template/delete": ("DELETE", self.delete_t2i_template), } self.register_routes() + async def get_t2i_template(self): + """获取 T2I 模板""" + try: + template = await html_renderer.network_strategy.get_template() + has_custom_template = os.path.exists(CUSTOM_T2I_TEMPLATE_PATH) + return ( + Response() + .ok({"template": template, "has_custom_template": has_custom_template}) + .__dict__ + ) + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"获取模板失败: {str(e)}").__dict__ + + async def post_t2i_template(self): + """保存 T2I 模板""" + try: + post_data = await request.json + if not post_data or "template" not in post_data: + return Response().error("缺少模板内容").__dict__ + + template_content = post_data["template"] + + # 保存自定义模板到文件 + with open(CUSTOM_T2I_TEMPLATE_PATH, "w", encoding="utf-8") as f: + f.write(template_content) + + return Response().ok(message="模板保存成功").__dict__ + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"保存模板失败: {str(e)}").__dict__ + + async def delete_t2i_template(self): + """删除自定义 T2I 模板,恢复默认模板""" + try: + if os.path.exists(CUSTOM_T2I_TEMPLATE_PATH): + os.remove(CUSTOM_T2I_TEMPLATE_PATH) + return Response().ok(message="已恢复默认模板").__dict__ + else: + return Response().ok(message="未找到自定义模板文件").__dict__ + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"删除模板失败: {str(e)}").__dict__ + + async def get_abconf_list(self): + """获取所有 AstrBot 配置文件的列表""" + abconf_list = self.acm.get_conf_list() + return Response().ok({"info_list": abconf_list}).__dict__ + + async def create_abconf(self): + """创建新的 AstrBot 配置文件""" + post_data = await request.json + if not post_data: + return Response().error("缺少配置数据").__dict__ + umo_parts = post_data["umo_parts"] + name = post_data.get("name", None) + + try: + conf_id = self.acm.create_conf(umo_parts=umo_parts, name=name) + return Response().ok(message="创建成功", data={"conf_id": conf_id}).__dict__ + except ValueError as e: + return Response().error(str(e)).__dict__ + + async def get_abconf(self): + """获取指定 AstrBot 配置文件""" + abconf_id = request.args.get("id") + system_config = request.args.get("system_config", "0").lower() == "1" + if not abconf_id and not system_config: + return Response().error("缺少配置文件 ID").__dict__ + + try: + if system_config: + abconf = self.acm.confs["default"] + return ( + Response() + .ok({"config": abconf, "metadata": CONFIG_METADATA_3_SYSTEM}) + .__dict__ + ) + abconf = self.acm.confs[abconf_id] + return ( + Response() + .ok({"config": abconf, "metadata": CONFIG_METADATA_3}) + .__dict__ + ) + except ValueError as e: + return Response().error(str(e)).__dict__ + + async def delete_abconf(self): + """删除指定 AstrBot 配置文件""" + post_data = await request.json + if not post_data: + return Response().error("缺少配置数据").__dict__ + + conf_id = post_data.get("id") + if not conf_id: + return Response().error("缺少配置文件 ID").__dict__ + + try: + success = self.acm.delete_conf(conf_id) + if success: + return Response().ok(message="删除成功").__dict__ + else: + return Response().error("删除失败").__dict__ + except ValueError as e: + return Response().error(str(e)).__dict__ + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"删除配置文件失败: {str(e)}").__dict__ + + async def update_abconf(self): + """更新指定 AstrBot 配置文件信息""" + post_data = await request.json + if not post_data: + return Response().error("缺少配置数据").__dict__ + + conf_id = post_data.get("id") + if not conf_id: + return Response().error("缺少配置文件 ID").__dict__ + + name = post_data.get("name") + umo_parts = post_data.get("umo_parts") + + try: + success = self.acm.update_conf_info(conf_id, name=name, umo_parts=umo_parts) + if success: + return Response().ok(message="更新成功").__dict__ + else: + return Response().error("更新失败").__dict__ + except ValueError as e: + return Response().error(str(e)).__dict__ + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"更新配置文件失败: {str(e)}").__dict__ + async def _test_single_provider(self, provider): """辅助函数:测试单个 provider 的可用性""" meta = provider.meta() @@ -210,11 +349,16 @@ class ConfigRoute(Route): response = await asyncio.wait_for( provider.text_chat(prompt="REPLY `PONG` ONLY"), timeout=45.0 ) - logger.debug(f"Received response from {status_info['name']}: {response}") + logger.debug( + f"Received response from {status_info['name']}: {response}" + ) if response is not None: status_info["status"] = "available" response_text_snippet = "" - if hasattr(response, "completion_text") and response.completion_text: + if ( + hasattr(response, "completion_text") + and response.completion_text + ): response_text_snippet = ( response.completion_text[:70] + "..." if len(response.completion_text) > 70 @@ -233,29 +377,48 @@ class ConfigRoute(Route): f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{response_text_snippet}'" ) else: - status_info["error"] = "Test call returned None, but expected an LLMResponse object." - logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) test call returned None.") + status_info["error"] = ( + "Test call returned None, but expected an LLMResponse object." + ) + logger.warning( + f"Provider {status_info['name']} (ID: {status_info['id']}) test call returned None." + ) except asyncio.TimeoutError: - status_info["error"] = "Connection timed out after 45 seconds during test call." - logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) timed out.") + status_info["error"] = ( + "Connection timed out after 45 seconds during test call." + ) + logger.warning( + f"Provider {status_info['name']} (ID: {status_info['id']}) timed out." + ) except Exception as e: error_message = str(e) status_info["error"] = error_message - logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}") - logger.debug(f"Traceback for {status_info['name']}:\n{traceback.format_exc()}") + logger.warning( + f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}" + ) + logger.debug( + f"Traceback for {status_info['name']}:\n{traceback.format_exc()}" + ) elif provider_capability_type == ProviderType.EMBEDDING: try: # For embedding, we can call the get_embedding method with a short prompt. embedding_result = await provider.get_embedding("health_check") - if isinstance(embedding_result, list) and (not embedding_result or isinstance(embedding_result[0], float)): + if isinstance(embedding_result, list) and ( + not embedding_result or isinstance(embedding_result[0], float) + ): status_info["status"] = "available" else: status_info["status"] = "unavailable" - status_info["error"] = f"Embedding test failed: unexpected result type {type(embedding_result)}" + status_info["error"] = ( + f"Embedding test failed: unexpected result type {type(embedding_result)}" + ) except Exception as e: - logger.error(f"Error testing embedding provider {provider_name}: {e}", exc_info=True) + logger.error( + f"Error testing embedding provider {provider_name}: {e}", + exc_info=True, + ) status_info["status"] = "unavailable" status_info["error"] = f"Embedding test failed: {str(e)}" @@ -267,41 +430,71 @@ class ConfigRoute(Route): status_info["status"] = "available" else: status_info["status"] = "unavailable" - status_info["error"] = f"TTS test failed: unexpected result type {type(audio_result)}" + status_info["error"] = ( + f"TTS test failed: unexpected result type {type(audio_result)}" + ) except Exception as e: - logger.error(f"Error testing TTS provider {provider_name}: {e}", exc_info=True) + logger.error( + f"Error testing TTS provider {provider_name}: {e}", exc_info=True + ) status_info["status"] = "unavailable" status_info["error"] = f"TTS test failed: {str(e)}" elif provider_capability_type == ProviderType.SPEECH_TO_TEXT: try: - logger.debug(f"Sending health check audio to provider: {status_info['name']}") - sample_audio_path = os.path.join(get_astrbot_path(), "samples", "stt_health_check.wav") + logger.debug( + f"Sending health check audio to provider: {status_info['name']}" + ) + sample_audio_path = os.path.join( + get_astrbot_path(), "samples", "stt_health_check.wav" + ) if not os.path.exists(sample_audio_path): status_info["status"] = "unavailable" - status_info["error"] = "STT test failed: sample audio file not found." - logger.warning(f"STT test for {status_info['name']} failed: sample audio file not found at {sample_audio_path}") + status_info["error"] = ( + "STT test failed: sample audio file not found." + ) + logger.warning( + f"STT test for {status_info['name']} failed: sample audio file not found at {sample_audio_path}" + ) else: text_result = await provider.get_text(sample_audio_path) if isinstance(text_result, str) and text_result: status_info["status"] = "available" - snippet = text_result[:70] + "..." if len(text_result) > 70 else text_result - logger.info(f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{snippet}'") + snippet = ( + text_result[:70] + "..." + if len(text_result) > 70 + else text_result + ) + logger.info( + f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{snippet}'" + ) else: status_info["status"] = "unavailable" - status_info["error"] = f"STT test failed: unexpected result type {type(text_result)}" - logger.warning(f"STT test for {status_info['name']} failed: unexpected result type {type(text_result)}") + status_info["error"] = ( + f"STT test failed: unexpected result type {type(text_result)}" + ) + logger.warning( + f"STT test for {status_info['name']} failed: unexpected result type {type(text_result)}" + ) except Exception as e: - logger.error(f"Error testing STT provider {provider_name}: {e}", exc_info=True) + logger.error( + f"Error testing STT provider {provider_name}: {e}", exc_info=True + ) status_info["status"] = "unavailable" status_info["error"] = f"STT test failed: {str(e)}" else: - logger.debug(f"Provider {provider_name} is not a Chat Completion or Embedding provider. Marking as available without test. Meta: {meta}") + logger.debug( + f"Provider {provider_name} is not a Chat Completion or Embedding provider. Marking as available without test. Meta: {meta}" + ) status_info["status"] = "available" - status_info["error"] = "This provider type is not tested and is assumed to be available." + status_info["error"] = ( + "This provider type is not tested and is assumed to be available." + ) return status_info - def _error_response(self, message: str, status_code: int = 500, log_fn=logger.error): + def _error_response( + self, message: str, status_code: int = 500, log_fn=logger.error + ): log_fn(message) # 记录更详细的traceback信息,但只在是严重错误时 if status_code == 500: @@ -312,7 +505,9 @@ class ConfigRoute(Route): """API: check a single LLM Provider's status by id""" provider_id = request.args.get("id") if not provider_id: - return self._error_response("Missing provider_id parameter", 400, logger.warning) + return self._error_response( + "Missing provider_id parameter", 400, logger.warning + ) logger.info(f"API call: /config/provider/check_one id={provider_id}") try: @@ -320,16 +515,21 @@ class ConfigRoute(Route): target = prov_mgr.inst_map.get(provider_id) if not target: - logger.warning(f"Provider with id '{provider_id}' not found in provider_manager.") - return Response().error(f"Provider with id '{provider_id}' not found").__dict__ + logger.warning( + f"Provider with id '{provider_id}' not found in provider_manager." + ) + return ( + Response() + .error(f"Provider with id '{provider_id}' not found") + .__dict__ + ) result = await self._test_single_provider(target) return Response().ok(result).__dict__ except Exception as e: return self._error_response( - f"Critical error checking provider {provider_id}: {e}", - 500 + f"Critical error checking provider {provider_id}: {e}", 500 ) async def get_configs(self): @@ -340,29 +540,15 @@ class ConfigRoute(Route): return Response().ok(await self._get_astrbot_config()).__dict__ return Response().ok(await self._get_plugin_config(plugin_name)).__dict__ - async def post_session_seperate(self): - """设置提供商会话隔离""" - post_config = await request.json - enable = post_config.get("enable", None) - if enable is None: - return Response().error("缺少参数 enable").__dict__ - - astrbot_config = self.core_lifecycle.astrbot_config - astrbot_config["provider_settings"]["separate_provider"] = enable - try: - astrbot_config.save_config() - except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "设置成功~").__dict__ - async def get_provider_config_list(self): provider_type = request.args.get("provider_type", None) if not provider_type: return Response().error("缺少参数 provider_type").__dict__ + provider_type_ls = provider_type.split(",") provider_list = [] astrbot_config = self.core_lifecycle.astrbot_config for provider in astrbot_config["provider"]: - if provider.get("provider_type", None) == provider_type: + if provider.get("provider_type", None) in provider_type_ls: provider_list.append(provider) return Response().ok(provider_list).__dict__ @@ -388,11 +574,21 @@ class ConfigRoute(Route): logger.error(traceback.format_exc()) return Response().error(str(e)).__dict__ + async def get_platform_list(self): + """获取所有平台的列表""" + platform_list = [] + for platform in self.config["platform"]: + platform_list.append(platform) + return Response().ok({"platforms": platform_list}).__dict__ + async def post_astrbot_configs(self): - post_configs = await request.json + data = await request.json + config = data.get("config", None) + conf_id = data.get("conf_id", None) try: - await self._save_astrbot_configs(post_configs) - return Response().ok(None, "保存成功~ 机器人正在重载配置。").__dict__ + await self._save_astrbot_configs(config, conf_id) + await self.core_lifecycle.reload_pipeline_scheduler(conf_id) + return Response().ok(None, "保存成功~").__dict__ except Exception as e: logger.error(traceback.format_exc()) return Response().error(str(e)).__dict__ @@ -509,12 +705,6 @@ class ConfigRoute(Route): return Response().error(str(e)).__dict__ return Response().ok(None, "删除成功,已经实时生效~").__dict__ - async def get_llm_tools(self): - """获取函数调用工具。包含了本地加载的以及 MCP 服务的工具""" - tool_mgr = self.core_lifecycle.provider_manager.llm_tools - tools = tool_mgr.get_func_desc_openai_style() - return Response().ok(tools).__dict__ - async def _get_astrbot_config(self): config = self.config @@ -557,10 +747,12 @@ class ConfigRoute(Route): return ret - async def _save_astrbot_configs(self, post_configs: dict): + async def _save_astrbot_configs(self, post_configs: dict, conf_id: str = None): try: - save_config(post_configs, self.config, is_core=True) - await self.core_lifecycle.restart() + if conf_id not in self.acm.confs: + raise ValueError(f"配置文件 {conf_id} 不存在") + astrbot_config = self.acm.confs[conf_id] + save_config(post_configs, astrbot_config, is_core=True) except Exception as e: raise e diff --git a/astrbot/dashboard/routes/conversation.py b/astrbot/dashboard/routes/conversation.py index dde6f9a5a..fb5d3e10e 100644 --- a/astrbot/dashboard/routes/conversation.py +++ b/astrbot/dashboard/routes/conversation.py @@ -29,6 +29,7 @@ class ConversationRoute(Route): ), } self.db_helper = db_helper + self.conv_mgr = core_lifecycle.conversation_manager self.core_lifecycle = core_lifecycle self.register_routes() @@ -54,7 +55,6 @@ class ConversationRoute(Route): exclude_platforms.split(",") if exclude_platforms else [] ) - # 限制页面大小,防止请求过大数据 if page < 1: page = 1 if page_size < 1: @@ -62,9 +62,11 @@ class ConversationRoute(Route): if page_size > 100: page_size = 100 - # 使用数据库的分页方法获取会话列表和总数,传入筛选条件 try: - conversations, total_count = self.db_helper.get_filtered_conversations( + ( + conversations, + total_count, + ) = await self.conv_mgr.get_filtered_conversations( page=page, page_size=page_size, platforms=platform_list, @@ -108,7 +110,9 @@ class ConversationRoute(Route): if not user_id or not cid: return Response().error("缺少必要参数: user_id 和 cid").__dict__ - conversation = self.db_helper.get_conversation_by_user_id(user_id, cid) + conversation = await self.conv_mgr.get_conversation( + unified_msg_origin=user_id, conversation_id=cid + ) if not conversation: return Response().error("对话不存在").__dict__ @@ -143,14 +147,18 @@ class ConversationRoute(Route): if not user_id or not cid: return Response().error("缺少必要参数: user_id 和 cid").__dict__ - conversation = self.db_helper.get_conversation_by_user_id(user_id, cid) + conversation = await self.conv_mgr.get_conversation( + unified_msg_origin=user_id, conversation_id=cid + ) if not conversation: return Response().error("对话不存在").__dict__ - if title is not None: - self.db_helper.update_conversation_title(user_id, cid, title) - if persona_id is not None: - self.db_helper.update_conversation_persona_id(user_id, cid, persona_id) - + if title is not None or persona_id is not None: + await self.conv_mgr.update_conversation( + unified_msg_origin=user_id, + conversation_id=cid, + title=title, + persona_id=persona_id, + ) return Response().ok({"message": "对话信息更新成功"}).__dict__ except Exception as e: @@ -201,11 +209,17 @@ class ConversationRoute(Route): Response().error("history 必须是有效的 JSON 字符串或数组").__dict__ ) - conversation = self.db_helper.get_conversation_by_user_id(user_id, cid) + conversation = await self.conv_mgr.get_conversation( + unified_msg_origin=user_id, conversation_id=cid + ) if not conversation: return Response().error("对话不存在").__dict__ - self.db_helper.update_conversation(user_id, cid, history) + history = json.loads(history) if isinstance(history, str) else history + + await self.conv_mgr.update_conversation( + unified_msg_origin=user_id, conversation_id=cid, history=history + ) return Response().ok({"message": "对话历史更新成功"}).__dict__ diff --git a/astrbot/dashboard/routes/persona.py b/astrbot/dashboard/routes/persona.py new file mode 100644 index 000000000..032471ee4 --- /dev/null +++ b/astrbot/dashboard/routes/persona.py @@ -0,0 +1,199 @@ +import traceback +from .route import Route, Response, RouteContext +from astrbot.core import logger +from quart import request +from astrbot.core.db import BaseDatabase +from astrbot.core.core_lifecycle import AstrBotCoreLifecycle + + +class PersonaRoute(Route): + def __init__( + self, + context: RouteContext, + db_helper: BaseDatabase, + core_lifecycle: AstrBotCoreLifecycle, + ) -> None: + super().__init__(context) + self.routes = { + "/persona/list": ("GET", self.list_personas), + "/persona/detail": ("POST", self.get_persona_detail), + "/persona/create": ("POST", self.create_persona), + "/persona/update": ("POST", self.update_persona), + "/persona/delete": ("POST", self.delete_persona), + } + self.db_helper = db_helper + self.persona_mgr = core_lifecycle.persona_mgr + self.register_routes() + + async def list_personas(self): + """获取所有人格列表""" + try: + personas = await self.persona_mgr.get_all_personas() + return ( + Response() + .ok( + [ + { + "persona_id": persona.persona_id, + "system_prompt": persona.system_prompt, + "begin_dialogs": persona.begin_dialogs or [], + "tools": persona.tools, + "created_at": persona.created_at.isoformat() + if persona.created_at + else None, + "updated_at": persona.updated_at.isoformat() + if persona.updated_at + else None, + } + for persona in personas + ] + ) + .__dict__ + ) + except Exception as e: + logger.error(f"获取人格列表失败: {str(e)}\n{traceback.format_exc()}") + return Response().error(f"获取人格列表失败: {str(e)}").__dict__ + + async def get_persona_detail(self): + """获取指定人格的详细信息""" + try: + data = await request.get_json() + persona_id = data.get("persona_id") + + if not persona_id: + return Response().error("缺少必要参数: persona_id").__dict__ + + persona = await self.persona_mgr.get_persona(persona_id) + if not persona: + return Response().error("人格不存在").__dict__ + + return ( + Response() + .ok( + { + "persona_id": persona.persona_id, + "system_prompt": persona.system_prompt, + "begin_dialogs": persona.begin_dialogs or [], + "tools": persona.tools, + "created_at": persona.created_at.isoformat() + if persona.created_at + else None, + "updated_at": persona.updated_at.isoformat() + if persona.updated_at + else None, + } + ) + .__dict__ + ) + except Exception as e: + logger.error(f"获取人格详情失败: {str(e)}\n{traceback.format_exc()}") + return Response().error(f"获取人格详情失败: {str(e)}").__dict__ + + async def create_persona(self): + """创建新人格""" + try: + data = await request.get_json() + persona_id = data.get("persona_id", "").strip() + system_prompt = data.get("system_prompt", "").strip() + begin_dialogs = data.get("begin_dialogs", []) + tools = data.get("tools") + + if not persona_id: + return Response().error("人格ID不能为空").__dict__ + + if not system_prompt: + return Response().error("系统提示词不能为空").__dict__ + + # 验证 begin_dialogs 格式 + if begin_dialogs and len(begin_dialogs) % 2 != 0: + return ( + Response() + .error("预设对话数量必须为偶数(用户和助手轮流对话)") + .__dict__ + ) + + persona = await self.persona_mgr.create_persona( + persona_id=persona_id, + system_prompt=system_prompt, + begin_dialogs=begin_dialogs if begin_dialogs else None, + tools=tools if tools else None, + ) + + return ( + Response() + .ok( + { + "message": "人格创建成功", + "persona": { + "persona_id": persona.persona_id, + "system_prompt": persona.system_prompt, + "begin_dialogs": persona.begin_dialogs or [], + "tools": persona.tools or [], + "created_at": persona.created_at.isoformat() + if persona.created_at + else None, + "updated_at": persona.updated_at.isoformat() + if persona.updated_at + else None, + }, + } + ) + .__dict__ + ) + except ValueError as e: + return Response().error(str(e)).__dict__ + except Exception as e: + logger.error(f"创建人格失败: {str(e)}\n{traceback.format_exc()}") + return Response().error(f"创建人格失败: {str(e)}").__dict__ + + async def update_persona(self): + """更新人格信息""" + try: + data = await request.get_json() + persona_id = data.get("persona_id") + system_prompt = data.get("system_prompt") + begin_dialogs = data.get("begin_dialogs") + tools = data.get("tools") + + if not persona_id: + return Response().error("缺少必要参数: persona_id").__dict__ + + # 验证 begin_dialogs 格式 + if begin_dialogs is not None and len(begin_dialogs) % 2 != 0: + return ( + Response() + .error("预设对话数量必须为偶数(用户和助手轮流对话)") + .__dict__ + ) + + await self.persona_mgr.update_persona( + persona_id=persona_id, + system_prompt=system_prompt, + begin_dialogs=begin_dialogs, + tools=tools, + ) + + return Response().ok({"message": "人格更新成功"}).__dict__ + except ValueError as e: + return Response().error(str(e)).__dict__ + except Exception as e: + logger.error(f"更新人格失败: {str(e)}\n{traceback.format_exc()}") + return Response().error(f"更新人格失败: {str(e)}").__dict__ + + async def delete_persona(self): + """删除人格""" + try: + data = await request.get_json() + persona_id = data.get("persona_id") + + if not persona_id: + return Response().error("缺少必要参数: persona_id").__dict__ + + await self.persona_mgr.delete_persona(persona_id) + + return Response().ok({"message": "人格删除成功"}).__dict__ + except ValueError as e: + return Response().error(str(e)).__dict__ + except Exception as e: + logger.error(f"删除人格失败: {str(e)}\n{traceback.format_exc()}") + return Response().error(f"删除人格失败: {str(e)}").__dict__ diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index 179b45428..849339698 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -40,8 +40,6 @@ class PluginRoute(Route): "/plugin/on": ("POST", self.on_plugin), "/plugin/reload": ("POST", self.reload_plugins), "/plugin/readme": ("GET", self.get_plugin_readme), - "/plugin/platform_enable/get": ("GET", self.get_plugin_platform_enable), - "/plugin/platform_enable/set": ("POST", self.set_plugin_platform_enable), } self.core_lifecycle = core_lifecycle self.plugin_manager = plugin_manager @@ -286,14 +284,6 @@ class PluginRoute(Route): f"{filter.parent_command_names[0]} {filter.command_name}" ) info["cmd"] = info["cmd"].strip() - if ( - self.core_lifecycle.astrbot_config["wake_prefix"] - and len(self.core_lifecycle.astrbot_config["wake_prefix"]) - > 0 - ): - info["cmd"] = ( - f"{self.core_lifecycle.astrbot_config['wake_prefix'][0]}{info['cmd']}" - ) elif isinstance(filter, CommandGroupFilter): info["type"] = "指令组" info["cmd"] = filter.get_complete_command_names()[0] @@ -301,14 +291,6 @@ class PluginRoute(Route): info["sub_command"] = filter.print_cmd_tree( filter.sub_command_filters ) - if ( - self.core_lifecycle.astrbot_config["wake_prefix"] - and len(self.core_lifecycle.astrbot_config["wake_prefix"]) - > 0 - ): - info["cmd"] = ( - f"{self.core_lifecycle.astrbot_config['wake_prefix'][0]}{info['cmd']}" - ) elif isinstance(filter, RegexFilter): info["type"] = "正则匹配" info["cmd"] = filter.regex_str @@ -498,90 +480,3 @@ class PluginRoute(Route): except Exception as e: logger.error(f"/api/plugin/readme: {traceback.format_exc()}") return Response().error(f"读取README文件失败: {str(e)}").__dict__ - - async def get_plugin_platform_enable(self): - """获取插件在各平台的可用性配置""" - try: - platform_enable = self.core_lifecycle.astrbot_config.get( - "platform_settings", {} - ).get("plugin_enable", {}) - - # 获取所有可用平台 - platforms = [] - - for platform in self.core_lifecycle.astrbot_config.get("platform", []): - platform_type = platform.get("type", "") - platform_id = platform.get("id", "") - - platforms.append( - { - "name": platform_id, # 使用type作为name,这是系统内部使用的平台名称 - "id": platform_id, # 保留id字段以便前端可以显示 - "type": platform_type, - "display_name": f"{platform_type}({platform_id})", - } - ) - - adjusted_platform_enable = {} - for platform_id, plugins in platform_enable.items(): - adjusted_platform_enable[platform_id] = plugins - - # 获取所有插件,包括系统内部插件 - plugins = [] - for plugin in self.plugin_manager.context.get_all_stars(): - plugins.append( - { - "name": plugin.name, - "desc": plugin.desc, - "reserved": plugin.reserved, # 添加reserved标志 - } - ) - - logger.debug( - f"获取插件平台配置: 原始配置={platform_enable}, 调整后={adjusted_platform_enable}" - ) - - return ( - Response() - .ok( - { - "platforms": platforms, - "plugins": plugins, - "platform_enable": adjusted_platform_enable, - } - ) - .__dict__ - ) - except Exception as e: - logger.error(f"/api/plugin/platform_enable/get: {traceback.format_exc()}") - return Response().error(str(e)).__dict__ - - async def set_plugin_platform_enable(self): - """设置插件在各平台的可用性配置""" - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - - try: - data = await request.json - platform_enable = data.get("platform_enable", {}) - - # 更新配置 - config = self.core_lifecycle.astrbot_config - platform_settings = config.get("platform_settings", {}) - platform_settings["plugin_enable"] = platform_enable - config["platform_settings"] = platform_settings - config.save_config() - - # 更新插件的平台兼容性缓存 - await self.plugin_manager.update_all_platform_compatibility() - - logger.info(f"插件平台可用性配置已更新: {platform_enable}") - - return Response().ok(None, "插件平台可用性配置已更新").__dict__ - except Exception as e: - logger.error(f"/api/plugin/platform_enable/set: {traceback.format_exc()}") - return Response().error(str(e)).__dict__ diff --git a/astrbot/dashboard/routes/route.py b/astrbot/dashboard/routes/route.py index bd94d9adf..a11fae252 100644 --- a/astrbot/dashboard/routes/route.py +++ b/astrbot/dashboard/routes/route.py @@ -21,17 +21,19 @@ class Route: @dataclass class Response: - status: str = None - message: str = None - data: dict = None + status: str | None = None + message: str | None = None + data: dict | list | None = None def error(self, message: str): self.status = "error" self.message = message return self - def ok(self, data: dict = {}, message: str = None): + def ok(self, data: dict | list | None = None, message: str | None = None): self.status = "ok" + if data is None: + data = {} self.data = data self.message = message return self diff --git a/astrbot/dashboard/routes/session_management.py b/astrbot/dashboard/routes/session_management.py index fdcbdbf73..1271a2493 100644 --- a/astrbot/dashboard/routes/session_management.py +++ b/astrbot/dashboard/routes/session_management.py @@ -24,7 +24,6 @@ class SessionManagementRoute(Route): "/session/list": ("GET", self.list_sessions), "/session/update_persona": ("POST", self.update_session_persona), "/session/update_provider": ("POST", self.update_session_provider), - "/session/get_session_info": ("POST", self.get_session_info), "/session/plugins": ("GET", self.get_session_plugins), "/session/update_plugin": ("POST", self.update_session_plugin), "/session/update_llm": ("POST", self.update_session_llm), @@ -32,24 +31,20 @@ class SessionManagementRoute(Route): "/session/update_name": ("POST", self.update_session_name), "/session/update_status": ("POST", self.update_session_status), } - self.db_helper = db_helper + self.conv_mgr = core_lifecycle.conversation_manager self.core_lifecycle = core_lifecycle self.register_routes() async def list_sessions(self): """获取所有会话的列表,包括 persona 和 provider 信息""" try: - # 获取会话对话映射 - session_conversations = sp.get("session_conversation", {}) or {} - - # 获取会话提供商偏好设置 - session_provider_perf = sp.get("session_provider_perf", {}) or {} - - # 获取可用的 personas - personas = self.core_lifecycle.star_context.provider_manager.personas - - # 获取可用的 providers - provider_manager = self.core_lifecycle.star_context.provider_manager + preferences = await sp.session_get(umo=None, key="sel_conv_id", default=[]) + session_conversations = {} + for pref in preferences: + session_conversations[pref.scope_id] = pref.value["val"] + provider_manager = self.core_lifecycle.provider_manager + persona_mgr = self.core_lifecycle.persona_mgr + personas = persona_mgr.personas_v3 sessions = [] @@ -59,13 +54,9 @@ class SessionManagementRoute(Route): "session_id": session_id, "conversation_id": conversation_id, "persona_id": None, - "persona_name": None, "chat_provider_id": None, - "chat_provider_name": None, "stt_provider_id": None, - "stt_provider_name": None, "tts_provider_id": None, - "tts_provider_name": None, "session_enabled": SessionServiceManager.is_session_enabled( session_id ), @@ -90,74 +81,46 @@ class SessionManagementRoute(Route): } # 获取对话信息 - conversation = self.db_helper.get_conversation_by_user_id( - session_id, conversation_id + conversation = await self.conv_mgr.get_conversation( + unified_msg_origin=session_id, conversation_id=conversation_id ) if conversation: session_info["persona_id"] = conversation.persona_id + # 查找 persona 名称 if conversation.persona_id and conversation.persona_id != "[%None]": for persona in personas: if persona["name"] == conversation.persona_id: - session_info["persona_name"] = persona["name"] + session_info["persona_id"] = persona["name"] break elif conversation.persona_id == "[%None]": - session_info["persona_name"] = "无人格" + session_info["persona_id"] = "无人格" else: # 使用默认人格 - default_persona = provider_manager.selected_default_persona + default_persona = persona_mgr.selected_default_persona_v3 if default_persona: session_info["persona_id"] = default_persona["name"] - session_info["persona_name"] = default_persona["name"] - # 获取会话的 provider 偏好设置 - session_perf = session_provider_perf.get(session_id, {}) - - # Chat completion provider - chat_provider_id = session_perf.get(ProviderType.CHAT_COMPLETION.value) - if chat_provider_id: - chat_provider = provider_manager.inst_map.get(chat_provider_id) - if chat_provider: - session_info["chat_provider_id"] = chat_provider_id - session_info["chat_provider_name"] = chat_provider.meta().id - else: - # 使用默认 provider - default_provider = provider_manager.curr_provider_inst - if default_provider: - session_info["chat_provider_id"] = default_provider.meta().id - session_info["chat_provider_name"] = default_provider.meta().id - - # STT provider - stt_provider_id = session_perf.get(ProviderType.SPEECH_TO_TEXT.value) - if stt_provider_id: - stt_provider = provider_manager.inst_map.get(stt_provider_id) - if stt_provider: - session_info["stt_provider_id"] = stt_provider_id - session_info["stt_provider_name"] = stt_provider.meta().id - else: - # 使用默认 STT provider - default_stt_provider = provider_manager.curr_stt_provider_inst - if default_stt_provider: - session_info["stt_provider_id"] = default_stt_provider.meta().id - session_info["stt_provider_name"] = ( - default_stt_provider.meta().id - ) - - # TTS provider - tts_provider_id = session_perf.get(ProviderType.TEXT_TO_SPEECH.value) - if tts_provider_id: - tts_provider = provider_manager.inst_map.get(tts_provider_id) - if tts_provider: - session_info["tts_provider_id"] = tts_provider_id - session_info["tts_provider_name"] = tts_provider.meta().id - else: - # 使用默认 TTS provider - default_tts_provider = provider_manager.curr_tts_provider_inst - if default_tts_provider: - session_info["tts_provider_id"] = default_tts_provider.meta().id - session_info["tts_provider_name"] = ( - default_tts_provider.meta().id - ) + # 获取 provider 信息 + provider_manager = self.core_lifecycle.provider_manager + chat_provider = provider_manager.get_using_provider( + provider_type=ProviderType.CHAT_COMPLETION, umo=session_id + ) + tts_provider = provider_manager.get_using_provider( + provider_type=ProviderType.TEXT_TO_SPEECH, umo=session_id + ) + stt_provider = provider_manager.get_using_provider( + provider_type=ProviderType.SPEECH_TO_TEXT, umo=session_id + ) + if chat_provider: + meta = chat_provider.meta() + session_info["chat_provider_id"] = meta.id + if tts_provider: + meta = tts_provider.meta() + session_info["tts_provider_id"] = meta.id + if stt_provider: + meta = stt_provider.meta() + session_info["stt_provider_id"] = meta.id sessions.append(session_info) @@ -311,133 +274,6 @@ class SessionManagementRoute(Route): logger.error(error_msg) return Response().error(f"更新会话提供商失败: {str(e)}").__dict__ - async def get_session_info(self): - """获取指定会话的详细信息""" - try: - data = await request.get_json() - session_id = data.get("session_id") - - if not session_id: - return Response().error("缺少必要参数: session_id").__dict__ - # 获取会话对话信息 - session_conversations = sp.get("session_conversation", {}) or {} - conversation_id = session_conversations.get(session_id) - - if not conversation_id: - return Response().error(f"会话 {session_id} 未找到对话").__dict__ - - session_info = { - "session_id": session_id, - "conversation_id": conversation_id, - "persona_id": None, - "persona_name": None, - "chat_provider_id": None, - "chat_provider_name": None, - "stt_provider_id": None, - "stt_provider_name": None, - "tts_provider_id": None, - "tts_provider_name": None, - "llm_enabled": SessionServiceManager.is_llm_enabled_for_session( - session_id - ), - "tts_enabled": None, # 将在下面设置 - "platform": session_id.split(":")[0] - if ":" in session_id - else "unknown", - "message_type": session_id.split(":")[1] - if session_id.count(":") >= 1 - else "unknown", - "session_name": session_id.split(":")[2] - if session_id.count(":") >= 2 - else session_id, - } - - # 获取TTS状态 - session_info["tts_enabled"] = ( - SessionServiceManager.is_tts_enabled_for_session(session_id) - ) - - # 获取对话信息 - conversation = self.db_helper.get_conversation_by_user_id( - session_id, conversation_id - ) - if conversation: - session_info["persona_id"] = conversation.persona_id - - # 查找 persona 名称 - provider_manager = self.core_lifecycle.star_context.provider_manager - personas = provider_manager.personas - - if conversation.persona_id and conversation.persona_id != "[%None]": - for persona in personas: - if persona["name"] == conversation.persona_id: - session_info["persona_name"] = persona["name"] - break - elif conversation.persona_id == "[%None]": - session_info["persona_name"] = "无人格" - else: - # 使用默认人格 - default_persona = provider_manager.selected_default_persona - if default_persona: - session_info["persona_id"] = default_persona["name"] - session_info["persona_name"] = default_persona["name"] - - # 获取会话的 provider 偏好设置 - session_provider_perf = sp.get("session_provider_perf", {}) or {} - session_perf = session_provider_perf.get(session_id, {}) - - # 获取 provider 信息 - provider_manager = self.core_lifecycle.star_context.provider_manager - - # Chat completion provider - chat_provider_id = session_perf.get(ProviderType.CHAT_COMPLETION.value) - if chat_provider_id: - chat_provider = provider_manager.inst_map.get(chat_provider_id) - if chat_provider: - session_info["chat_provider_id"] = chat_provider_id - session_info["chat_provider_name"] = chat_provider.meta().id - else: - # 使用默认 provider - default_provider = provider_manager.curr_provider_inst - if default_provider: - session_info["chat_provider_id"] = default_provider.meta().id - session_info["chat_provider_name"] = default_provider.meta().id - - # STT provider - stt_provider_id = session_perf.get(ProviderType.SPEECH_TO_TEXT.value) - if stt_provider_id: - stt_provider = provider_manager.inst_map.get(stt_provider_id) - if stt_provider: - session_info["stt_provider_id"] = stt_provider_id - session_info["stt_provider_name"] = stt_provider.meta().id - else: - # 使用默认 STT provider - default_stt_provider = provider_manager.curr_stt_provider_inst - if default_stt_provider: - session_info["stt_provider_id"] = default_stt_provider.meta().id - session_info["stt_provider_name"] = default_stt_provider.meta().id - - # TTS provider - tts_provider_id = session_perf.get(ProviderType.TEXT_TO_SPEECH.value) - if tts_provider_id: - tts_provider = provider_manager.inst_map.get(tts_provider_id) - if tts_provider: - session_info["tts_provider_id"] = tts_provider_id - session_info["tts_provider_name"] = tts_provider.meta().id - else: - # 使用默认 TTS provider - default_tts_provider = provider_manager.curr_tts_provider_inst - if default_tts_provider: - session_info["tts_provider_id"] = default_tts_provider.meta().id - session_info["tts_provider_name"] = default_tts_provider.meta().id - - return Response().ok(session_info).__dict__ - - except Exception as e: - error_msg = f"获取会话信息失败: {str(e)}\n{traceback.format_exc()}" - logger.error(error_msg) - return Response().error(f"获取会话信息失败: {str(e)}").__dict__ - async def get_session_plugins(self): """获取指定会话的插件配置信息""" try: diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/routes/stat.py index 2a8389396..d13eb802c 100644 --- a/astrbot/dashboard/routes/stat.py +++ b/astrbot/dashboard/routes/stat.py @@ -11,6 +11,7 @@ from astrbot.core.db import BaseDatabase from astrbot.core.config import VERSION from astrbot.core.utils.io import get_dashboard_version from astrbot.core import DEMO_MODE +from astrbot.core.db.migration.helper import check_migration_needed_v4 class StatRoute(Route): @@ -59,6 +60,8 @@ class StatRoute(Route): ) async def get_version(self): + need_migration = await check_migration_needed_v4(self.core_lifecycle.db) + return ( Response() .ok( @@ -66,6 +69,7 @@ class StatRoute(Route): "version": VERSION, "dashboard_version": await get_dashboard_version(), "change_pwd_hint": self.is_default_cred(), + "need_migration": need_migration, } ) .__dict__ @@ -84,7 +88,7 @@ class StatRoute(Route): message_time_based_stats = [] idx = 0 - for bucket_end in range(start_time, now, 1800): + for bucket_end in range(start_time, now, 3600): cnt = 0 while ( idx < len(stat.platform) diff --git a/astrbot/dashboard/routes/tools.py b/astrbot/dashboard/routes/tools.py index 5dad2576b..79a601b25 100644 --- a/astrbot/dashboard/routes/tools.py +++ b/astrbot/dashboard/routes/tools.py @@ -1,5 +1,3 @@ -import json -import os import traceback import aiohttp @@ -7,7 +5,7 @@ from quart import request from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.star import star_map from .route import Response, Route, RouteContext @@ -25,44 +23,17 @@ class ToolsRoute(Route): "/tools/mcp/add": ("POST", self.add_mcp_server), "/tools/mcp/update": ("POST", self.update_mcp_server), "/tools/mcp/delete": ("POST", self.delete_mcp_server), - "/tools/mcp/market": ("GET", self.get_mcp_markets), "/tools/mcp/test": ("POST", self.test_mcp_connection), + "/tools/list": ("GET", self.get_tool_list), + "/tools/toggle-tool": ("POST", self.toggle_tool), + "/tools/mcp/sync-provider": ("POST", self.sync_provider), } self.register_routes() self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools - @property - def mcp_config_path(self): - data_dir = get_astrbot_data_path() - return os.path.join(data_dir, "mcp_server.json") - - def load_mcp_config(self): - if not os.path.exists(self.mcp_config_path): - # 配置文件不存在,创建默认配置 - os.makedirs(os.path.dirname(self.mcp_config_path), exist_ok=True) - with open(self.mcp_config_path, "w", encoding="utf-8") as f: - json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4) - return DEFAULT_MCP_CONFIG - - try: - with open(self.mcp_config_path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception as e: - logger.error(f"加载 MCP 配置失败: {e}") - return DEFAULT_MCP_CONFIG - - def save_mcp_config(self, config): - try: - with open(self.mcp_config_path, "w", encoding="utf-8") as f: - json.dump(config, f, ensure_ascii=False, indent=4) - return True - except Exception as e: - logger.error(f"保存 MCP 配置失败: {e}") - return False - async def get_mcp_servers(self): try: - config = self.load_mcp_config() + config = self.tool_mgr.load_mcp_config() servers = [] # 获取所有服务器并添加它们的工具列表 @@ -125,14 +96,14 @@ class ToolsRoute(Route): if not has_valid_config: return Response().error("必须提供有效的服务器配置").__dict__ - config = self.load_mcp_config() + config = self.tool_mgr.load_mcp_config() if name in config["mcpServers"]: return Response().error(f"服务器 {name} 已存在").__dict__ config["mcpServers"][name] = server_config - if self.save_mcp_config(config): + if self.tool_mgr.save_mcp_config(config): try: await self.tool_mgr.enable_mcp_server( name, server_config, timeout=30 @@ -162,7 +133,7 @@ class ToolsRoute(Route): if not name: return Response().error("服务器名称不能为空").__dict__ - config = self.load_mcp_config() + config = self.tool_mgr.load_mcp_config() if name not in config["mcpServers"]: return Response().error(f"服务器 {name} 不存在").__dict__ @@ -198,7 +169,7 @@ class ToolsRoute(Route): config["mcpServers"][name] = server_config - if self.save_mcp_config(config): + if self.tool_mgr.save_mcp_config(config): # 处理MCP客户端状态变化 if active: if name in self.tool_mgr.mcp_client_dict or not only_update_active: @@ -266,14 +237,14 @@ class ToolsRoute(Route): if not name: return Response().error("服务器名称不能为空").__dict__ - config = self.load_mcp_config() + config = self.tool_mgr.load_mcp_config() if name not in config["mcpServers"]: return Response().error(f"服务器 {name} 不存在").__dict__ del config["mcpServers"][name] - if self.save_mcp_config(config): + if self.tool_mgr.save_mcp_config(config): if name in self.tool_mgr.mcp_client_dict: try: await self.tool_mgr.disable_mcp_server(name, timeout=10) @@ -295,31 +266,6 @@ class ToolsRoute(Route): logger.error(traceback.format_exc()) return Response().error(f"删除 MCP 服务器失败: {str(e)}").__dict__ - async def get_mcp_markets(self): - page = request.args.get("page", 1, type=int) - page_size = request.args.get("page_size", 10, type=int) - BASE_URL = ( - "https://api.soulter.top/astrbot/mcpservers?page={}&page_size={}".format( - page, - page_size, - ) - ) - try: - async with aiohttp.ClientSession() as session: - async with session.get(f"{BASE_URL}") as response: - if response.status == 200: - data = await response.json() - return Response().ok(data["data"]).__dict__ - else: - return ( - Response() - .error(f"获取市场数据失败: HTTP {response.status}") - .__dict__ - ) - except Exception as _: - logger.error(traceback.format_exc()) - return Response().error("获取市场数据失败").__dict__ - async def test_mcp_connection(self): """ 测试 MCP 服务器连接 @@ -336,3 +282,57 @@ class ToolsRoute(Route): except Exception as e: logger.error(traceback.format_exc()) return Response().error(f"测试 MCP 连接失败: {str(e)}").__dict__ + + async def get_tool_list(self): + """获取所有注册的工具列表""" + try: + tools = self.tool_mgr.func_list + tools_dict = [tool.__dict__() for tool in tools] + return Response().ok(data=tools_dict).__dict__ + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"获取工具列表失败: {str(e)}").__dict__ + + async def toggle_tool(self): + """启用或停用指定的工具""" + try: + data = await request.json + tool_name = data.get("name") + action = data.get("activate") # True or False + + if not tool_name or action is None: + return Response().error("缺少必要参数: name 或 action").__dict__ + + if action: + try: + ok = self.tool_mgr.activate_llm_tool(tool_name, star_map=star_map) + except ValueError as e: + return Response().error(f"启用工具失败: {str(e)}").__dict__ + else: + ok = self.tool_mgr.deactivate_llm_tool(tool_name) + + if ok: + return Response().ok(None, "操作成功。").__dict__ + else: + return Response().error(f"工具 {tool_name} 不存在或操作失败。").__dict__ + + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"操作工具失败: {str(e)}").__dict__ + + async def sync_provider(self): + """同步 MCP 提供者配置""" + try: + data = await request.json + provider_name = data.get("name") # modelscope, or others + match provider_name: + case "modelscope": + access_token = data.get("access_token", "") + await self.tool_mgr.sync_modelscope_mcp_servers(access_token) + case _: + return Response().error(f"未知: {provider_name}").__dict__ + + return Response().ok(message="同步成功").__dict__ + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"同步失败: {str(e)}").__dict__ diff --git a/astrbot/dashboard/routes/update.py b/astrbot/dashboard/routes/update.py index 79aa56bc8..a7b9dc5e5 100644 --- a/astrbot/dashboard/routes/update.py +++ b/astrbot/dashboard/routes/update.py @@ -7,6 +7,7 @@ from astrbot.core import logger, pip_installer from astrbot.core.utils.io import download_dashboard, get_dashboard_version from astrbot.core.config.default import VERSION from astrbot.core import DEMO_MODE +from astrbot.core.db.migration.helper import do_migration_v4, check_migration_needed_v4 class UpdateRoute(Route): @@ -23,11 +24,27 @@ class UpdateRoute(Route): "/update/do": ("POST", self.update_project), "/update/dashboard": ("POST", self.update_dashboard), "/update/pip-install": ("POST", self.install_pip_package), + "/update/migration": ("POST", self.do_migration), } self.astrbot_updator = astrbot_updator self.core_lifecycle = core_lifecycle self.register_routes() + async def do_migration(self): + need_migration = await check_migration_needed_v4(self.core_lifecycle.db) + if not need_migration: + return Response().ok(None, "不需要进行迁移。").__dict__ + try: + data = await request.json + pim = data.get("platform_id_map", {}) + await do_migration_v4( + self.core_lifecycle.db, pim, self.core_lifecycle.astrbot_config + ) + return Response().ok(None, "迁移成功。").__dict__ + except Exception as e: + logger.error(f"迁移失败: {traceback.format_exc()}") + return Response().error(f"迁移失败: {str(e)}").__dict__ + async def check_update(self): type_ = request.args.get("type", None) diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 06f6f8e60..e22b20524 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -60,6 +60,9 @@ class AstrBotDashboard: self.session_management_route = SessionManagementRoute( self.context, db, core_lifecycle ) + self.persona_route = PersonaRoute( + self.context, db, core_lifecycle + ) self.app.add_url_rule( "/api/plug/", diff --git a/changelogs/v4.0.0-beta.1.md b/changelogs/v4.0.0-beta.1.md new file mode 100644 index 000000000..461605c84 --- /dev/null +++ b/changelogs/v4.0.0-beta.1.md @@ -0,0 +1,3 @@ +# What's Changed + +> **这是 v4.0.0 的测试版本(beta.1),功能尚未完全稳定和加入**。v4.0.0 被设计为向前兼容,如有任何插件兼容性问题或者其他异常请在 GitHub 提交 [Issue](https://github.com/AstrBotDevs/AstrBot/issues)。在测试版本期间,您可以无缝回退到旧版本的 AstrBot,并且数据不受影响。 diff --git a/dashboard/src/assets/images/astrbot_banner.png b/dashboard/src/assets/images/astrbot_banner.png new file mode 100644 index 0000000000000000000000000000000000000000..f837d9fb43351fe734605a9e6c93bf65392dc1e2 GIT binary patch literal 46768 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 q^mKGI+VG*H-gI - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dashboard/src/assets/images/logo-waifu.png b/dashboard/src/assets/images/logo-waifu.png deleted file mode 100644 index cc9375e42d81aba17e8d31dfe2598ea95e050190..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 55188 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 a*%`RPubS+&?=VIHa~WN{a-me$p7MWmmkYH3 diff --git a/dashboard/src/components/shared/AstrBotConfig.vue b/dashboard/src/components/shared/AstrBotConfig.vue index 12de2561b..4374c99a0 100644 --- a/dashboard/src/components/shared/AstrBotConfig.vue +++ b/dashboard/src/components/shared/AstrBotConfig.vue @@ -2,6 +2,9 @@ import { VueMonacoEditor } from '@guolao/vue-monaco-editor' import { ref, computed } from 'vue' import ListConfigItem from './ListConfigItem.vue' +import ProviderSelector from './ProviderSelector.vue' +import PersonaSelector from './PersonaSelector.vue' +import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue' import { useI18n } from '@/i18n/composables' const props = defineProps({ @@ -48,6 +51,47 @@ function openEditorDialog(key, value, theme, language) { function saveEditedContent() { dialog.value = false } + +function getValueBySelector(obj, selector) { + const keys = selector.split('.') + let current = obj + for (const key of keys) { + if (current && typeof current === 'object' && key in current) { + current = current[key] + } else { + return undefined + } + } + return current +} + +function shouldShowItem(itemMeta, itemKey) { + if (!itemMeta?.condition) { + return true + } + for (const [conditionKey, expectedValue] of Object.entries(itemMeta.condition)) { + const actualValue = getValueBySelector(props.iterable, conditionKey) + if (actualValue !== expectedValue) { + return false + } + } + return true +} + +function hasVisibleItemsAfter(items, currentIndex) { + const itemEntries = Object.entries(items) + + // 检查当前索引之后是否还有可见的配置项 + for (let i = currentIndex + 1; i < itemEntries.length; i++) { + const [itemKey, itemValue] = itemEntries[i] + const itemMeta = props.metadata[props.metadataKey].items[itemKey] + if (!itemMeta?.invisible && shouldShowItem(itemMeta, itemKey)) { + return true + } + } + + return false +}