From 842c3c8ea9a87493dbe64f2a6fe85f29d263a0ed Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Sat, 2 Aug 2025 15:44:00 +0800 Subject: [PATCH 01/44] 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 --- astrbot/core/__init__.py | 1 - astrbot/core/config/default.py | 4 +- astrbot/core/conversation_mgr.py | 171 ++- astrbot/core/core_lifecycle.py | 8 + astrbot/core/db/__init__.py | 317 ++++-- astrbot/core/db/migration/helper.py | 51 + astrbot/core/db/migration/migra_3_to_4.py | 214 ++++ astrbot/core/db/migration/sqlite_v3.py | 493 ++++++++ astrbot/core/db/po.py | 260 +++-- astrbot/core/db/sqlite.py | 1002 ++++++++--------- astrbot/core/db/sqlite_init.sql | 50 - .../process_stage/method/llm_request.py | 2 +- astrbot/core/platform/astr_message_event.py | 5 +- astrbot/core/platform_message_history_mgr.py | 47 + astrbot/core/utils/metrics.py | 7 +- astrbot/dashboard/routes/chat.py | 124 +- astrbot/dashboard/routes/conversation.py | 38 +- .../dashboard/routes/session_management.py | 10 +- astrbot/dashboard/routes/stat.py | 2 +- dashboard/src/views/ChatPage.vue | 84 +- pyproject.toml | 3 + uv.lock | 199 +++- 22 files changed, 2157 insertions(+), 935 deletions(-) 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/sqlite_v3.py delete mode 100644 astrbot/core/db/sqlite_init.sql create mode 100644 astrbot/core/platform_message_history_mgr.py diff --git a/astrbot/core/__init__.py b/astrbot/core/__init__.py index 16f108ece..272a0f417 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 diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index d24d701c6..40a602d3d 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -6,8 +6,8 @@ import os from astrbot.core.utils.astrbot_path import get_astrbot_data_path -VERSION = "3.5.22" -DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db") +VERSION = "4.0.0" +DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") # 默认配置 DEFAULT_CONFIG = { diff --git a/astrbot/core/conversation_mgr.py b/astrbot/core/conversation_mgr.py index b665488e4..62079b344 100644 --- a/astrbot/core/conversation_mgr.py +++ b/astrbot/core/conversation_mgr.py @@ -5,13 +5,12 @@ 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: @@ -38,7 +37,29 @@ class ConversationManager: """保存会话对话映射关系到存储中""" sp.put("session_conversation", self.session_conversations) - async def new_conversation(self, unified_msg_origin: str) -> str: + 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 new_conversation( + self, + unified_msg_origin: str, + platform_id: str = None, + content: list[dict] = None, + title: str = None, + persona_id: str = None, + ) -> str: """新建对话,并将当前会话的对话转移到新对话 Args: @@ -46,11 +67,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 + 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 sp.put("session_conversation", self.session_conversations) - return conversation_id + return str(conv.conversation_id) async def switch_conversation(self, unified_msg_origin: str, conversation_id: str): """切换会话的对话 @@ -71,11 +104,16 @@ 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) + sp.put("session_conversation", self.session_conversations) async def get_curr_conversation_id(self, unified_msg_origin: str) -> str: """获取会话当前的对话 ID @@ -92,7 +130,7 @@ class ConversationManager: unified_msg_origin: str, conversation_id: str, create_if_not_exists: bool = False, - ) -> Conversation: + ) -> Conversation | None: """获取会话的对话 Args: @@ -101,27 +139,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, platform_id: str = 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, + history: list[dict] = None, + title: str = None, + persona_id: str = None, ): """更新会话的对话 @@ -130,40 +215,52 @@ class ConversationManager: conversation_id (str): 对话 ID, 是 uuid 格式的字符串 history (List[Dict]): 对话历史记录, 是一个字典列表, 每个字典包含 role 和 content 字段 """ + if not conversation_id: + # 如果没有提供 conversation_id,则从 session_conversations 中获取当前的 + conversation_id = self.session_conversations.get(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 or [], ) - 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 + ): """更新会话的对话标题 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 ): """更新会话的对话 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 eccffbd64..8412d5bea 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -29,8 +29,10 @@ from astrbot.core.updator import AstrBotUpdator from astrbot.core import logger 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.star.star_handler import star_handlers_registry, EventType from astrbot.core.star.star_handler import star_map +from astrbot.core.db.migration.helper import do_migration_v4 class AstrBotCoreLifecycle: @@ -66,6 +68,9 @@ class AstrBotCoreLifecycle: else: logger.setLevel(self.astrbot_config["log_level"]) # 设置日志级别 + await self.db.initialize() + await do_migration_v4(self.db, {}) + # 初始化事件队列 self.event_queue = Queue() @@ -78,6 +83,9 @@ class AstrBotCoreLifecycle: # 初始化对话管理器 self.conversation_manager = ConversationManager(self.db) + # 初始化平台消息历史管理器 + self.platform_message_history_manager = PlatformMessageHistoryManager(self.db) + # 初始化提供给插件的上下文 self.star_context = Context( self.event_queue, diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index 6688dcced..53fccacfc 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,226 @@ 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: + """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, platform_id: str = 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, + title: str = None, + persona_id: str = None, + cid: str = None, + created_at: datetime.datetime = None, + updated_at: datetime.datetime = 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, + persona_id: str = None, + content: list[dict] = 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, + sender_name: str = 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, + ) -> 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 insert_preference_or_update(self, key: str, value: str) -> Preference: + """Insert a new preference record.""" + ... + + @abc.abstractmethod + async def get_preference(self, key: str) -> Preference: + """Get a preference by bot ID and key.""" + ... + + # @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..d4b9c99f9 --- /dev/null +++ b/astrbot/core/db/migration/helper.py @@ -0,0 +1,51 @@ +import os +from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.db import BaseDatabase +from astrbot.api import logger +from .migra_3_to_4 import ( + migration_conversation_table, + migration_platform_table, + migration_webchat_data, +) + + +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("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]] +): + """ + 执行数据库迁移 + 迁移旧的 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_platform_table(db_helper, platform_id_map) + + # 执行 WebChat 数据迁移 + await migration_webchat_data(db_helper, platform_id_map) + + # 标记迁移完成 + await db_helper.insert_preference_or_update("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..b6de3214e --- /dev/null +++ b/astrbot/core/db/migration/migra_3_to_4.py @@ -0,0 +1,214 @@ +import json +import datetime +from .. import BaseDatabase +from .sqlite_v3 import SQLiteDatabase as SQLiteV3DatabaseV3 +from astrbot.core.config.default import DB_PATH +from astrbot.api import logger +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 conversation in conversations: + 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.warning( + f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。" + ) + if ":" not in conv.user_id: + logger.warning( + f"跳过 user_id 为 {conv.user_id} 的会话,它可能是 WebChat 的消息历史记录。" + ) + continue + session = MessageSesion.from_str(session_str=conv.user_id) + platform_id = get_platform_id( + platform_id_map, session.platform_name + ) + session.platform_name = 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) + if conv_v2: + logger.info(f"迁移旧会话 {conv.cid} 到新表成功。") + except Exception as e: + logger.error( + f"迁移旧会话 {conversation.get('cid', 'unknown')} 失败: {e}", + exc_info=True, + ) + + +async def migration_platform_table( + db_helper: BaseDatabase, platform_id_map: 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.warning("没有找到旧平台数据,跳过迁移。") + 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(): + for bucket_end in range(start_time, end_time, 3600): + 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 + ) + logger.info( + f"迁移平台统计数据: {platform_id}, {platform_type}, 时间戳: {bucket_end}, 计数: {cnt}" + ) + 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, + ) + + +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 conversation in conversations: + 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.warning( + f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。" + ) + if ":" in conv.user_id: + logger.warning( + f"跳过 user_id 为 {conv.user_id} 的会话,它不是 WebChat 的消息历史记录。" + ) + 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) + + logger.info(f"迁移旧 WebChat 会话 {conv.cid} 到新表成功。") + except Exception: + logger.error( + f"迁移旧 WebChat 会话 {conversation.get('cid', 'unknown')} 失败", + exc_info=True, + ) 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..30f7188a1 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -1,7 +1,190 @@ -"""指标数据""" +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 + + +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""" + 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 user preferences for bots.""" + + __tablename__ = "preferences" + + key: str = Field(primary_key=True, nullable=False) + value: str = Field(sa_type=Text, 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)}, + ) + + +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 = "" + persona_id: str = "" + created_at: int = 0 + updated_at: int = 0 + + +# ==== +# Deprecated, and will be removed in future versions. +# ==== @dataclass @@ -13,77 +196,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..0308807da 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -1,567 +1,461 @@ -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 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): + """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 [], + ) + 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 insert_preference_or_update(self, 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.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(key=key, value=value) + session.add(new_preference) + return existing_preference or new_preference + + async def get_preference(self, key): + """Get a preference by key.""" + async with self.get_db() as session: + session: AsyncSession + query = select(Preference).where(Preference.key == key) + result = await session.execute(query) + return result.scalar_one_or_none() + + # ==== + # 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/pipeline/process_stage/method/llm_request.py b/astrbot/core/pipeline/process_stage/method/llm_request.py index 2d9392e6d..1344d4dec 100644 --- a/astrbot/core/pipeline/process_stage/method/llm_request.py +++ b/astrbot/core/pipeline/process_stage/method/llm_request.py @@ -266,12 +266,12 @@ class LLMRequestSubStage(Stage): else: async for _ in requesting(): yield + await self._save_to_history(event, req, tool_loop_agent.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 diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py index 7a3102de5..e565c13ce 100644 --- a/astrbot/core/platform/astr_message_event.py +++ b/astrbot/core/platform/astr_message_event.py @@ -26,7 +26,7 @@ from .platform_metadata import PlatformMetadata @dataclass -class MessageSesion: +class MessageSession: platform_name: str message_type: MessageType session_id: str @@ -37,8 +37,9 @@ class MessageSesion: @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) + return MessageSession(platform_name, MessageType(message_type), session_id) +MessageSesion = MessageSession # back compatibility class AstrMessageEvent(abc.ABC): def __init__( 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/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/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index 651f1b65c..dde4c7644 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): @@ -31,13 +32,14 @@ class ChatRoute(Route): "/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"] + self.conv_mgr = core_lifecycle.conversation_manager + self.platform_history_mgr = core_lifecycle.platform_message_history_manager async def status(self): has_llm_enabled = ( @@ -131,24 +133,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 +165,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 +173,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 +187,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 +213,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 +265,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/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/session_management.py b/astrbot/dashboard/routes/session_management.py index fdcbdbf73..86b526be3 100644 --- a/astrbot/dashboard/routes/session_management.py +++ b/astrbot/dashboard/routes/session_management.py @@ -32,7 +32,7 @@ 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() @@ -90,8 +90,8 @@ 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 @@ -358,8 +358,8 @@ 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 diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/routes/stat.py index 79397290e..5bc401a0d 100644 --- a/astrbot/dashboard/routes/stat.py +++ b/astrbot/dashboard/routes/stat.py @@ -86,7 +86,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/dashboard/src/views/ChatPage.vue b/dashboard/src/views/ChatPage.vue index 099cf09c0..208a751ab 100644 --- a/dashboard/src/views/ChatPage.vue +++ b/dashboard/src/views/ChatPage.vue @@ -147,24 +147,24 @@
-
+
-
{{ msg.message }}
+
{{ msg.content.message }}
-
-
+
-
+
@@ -179,22 +179,22 @@
-
-
-
+
-
+
@@ -203,7 +203,7 @@
@@ -733,40 +733,42 @@ export default { } else { this.$router.push(`/chat/${cid[0]}`); } + return } axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(async response => { this.currCid = cid[0]; - let message = JSON.parse(response.data.data.history); - for (let i = 0; i < message.length; i++) { - if (message[i].message.startsWith('[IMAGE]')) { - let img = message[i].message.replace('[IMAGE]', ''); + let history = response.data.data.history; + for (let i = 0; i < history.length; i++) { + let content = history[i].content; + if (content.message.startsWith('[IMAGE]')) { + let img = content.message.replace('[IMAGE]', ''); const imageUrl = await this.getMediaFile(img); - if (!message[i].embedded_images) { - message[i].embedded_images = []; + if (!content.embedded_images) { + content.embedded_images = []; } - message[i].embedded_images.push(imageUrl); - message[i].message = ''; // 清空message,避免显示标记文本 + content.embedded_images.push(imageUrl); + content.message = ''; // 清空message,避免显示标记文本 } - if (message[i].message.startsWith('[RECORD]')) { - let audio = message[i].message.replace('[RECORD]', ''); + if (content.message.startsWith('[RECORD]')) { + let audio = content.message.replace('[RECORD]', ''); const audioUrl = await this.getMediaFile(audio); - message[i].embedded_audio = audioUrl; - message[i].message = ''; // 清空message,避免显示标记文本 + content.embedded_audio = audioUrl; + content.message = ''; // 清空message,避免显示标记文本 } - if (message[i].image_url && message[i].image_url.length > 0) { - for (let j = 0; j < message[i].image_url.length; j++) { - message[i].image_url[j] = await this.getMediaFile(message[i].image_url[j]); + if (content.image_url && content.image_url.length > 0) { + for (let j = 0; j < content.image_url.length; j++) { + content.image_url[j] = await this.getMediaFile(content.image_url[j]); } } - if (message[i].audio_url) { - message[i].audio_url = await this.getMediaFile(message[i].audio_url); + if (content.audio_url) { + content.audio_url = await this.getMediaFile(content.audio_url); } } - this.messages = message; + this.messages = history; this.initCodeCopyButtons(); this.initImageClickEvents(); }).catch(err => { @@ -876,7 +878,9 @@ export default { } } - this.messages.push(userMessage); + this.messages.push({ + "content": userMessage, + }); this.scrollToBottom(); this.loadingChat = true @@ -960,7 +964,9 @@ export default { message: '', embedded_images: [imageUrl] } - this.messages.push(bot_resp); + this.messages.push({ + "content": bot_resp + }); } else if (chunk_json.type === 'record') { let audio = chunk_json.data.replace('[RECORD]', ''); const audioUrl = await this.getMediaFile(audio); @@ -969,14 +975,18 @@ export default { message: '', embedded_audio: audioUrl } - this.messages.push(bot_resp); + this.messages.push({ + "content": bot_resp + }); } else if (chunk_json.type === 'plain') { if (!in_streaming) { message_obj = { type: 'bot', message: this.ref(chunk_json.data), } - this.messages.push(message_obj); + this.messages.push({ + "content": message_obj + }); in_streaming = true; } else { message_obj.message.value += chunk_json.data; @@ -1096,7 +1106,7 @@ export default { // 复制bot消息到剪贴板 copyBotMessage(message, messageIndex) { // 获取对应的消息对象 - const msgObj = this.messages[messageIndex]; + const msgObj = this.messages[messageIndex].content; let textToCopy = ''; // 如果有文本消息,添加到复制内容中 diff --git a/pyproject.toml b/pyproject.toml index ddc0bc9fb..12639b5c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "cryptography>=44.0.3", "dashscope>=1.23.2", "defusedxml>=0.7.1", + "deprecated>=1.2.18", "dingtalk-stream>=0.22.1", "docstring-parser>=0.16", "faiss-cpu>=1.10.0", @@ -42,6 +43,8 @@ dependencies = [ "readability-lxml>=0.8.4.1", "silk-python>=0.2.6", "slack-sdk>=3.35.0", + "sqlalchemy[asyncio]>=2.0.41", + "sqlmodel>=0.0.24", "telegramify-markdown>=0.5.1", "watchfiles>=1.0.5", "websockets>=15.0.1", diff --git a/uv.lock b/uv.lock index cc8a50241..b2c2b0f3d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 1 requires-python = ">=3.10" [[package]] @@ -220,6 +219,7 @@ dependencies = [ { name = "cryptography" }, { name = "dashscope" }, { name = "defusedxml" }, + { name = "deprecated" }, { name = "dingtalk-stream" }, { name = "docstring-parser" }, { name = "faiss-cpu" }, @@ -244,6 +244,8 @@ dependencies = [ { name = "readability-lxml" }, { name = "silk-python" }, { name = "slack-sdk" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "sqlmodel" }, { name = "telegramify-markdown" }, { name = "watchfiles" }, { name = "websockets" }, @@ -265,6 +267,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=44.0.3" }, { name = "dashscope", specifier = ">=1.23.2" }, { name = "defusedxml", specifier = ">=0.7.1" }, + { name = "deprecated", specifier = ">=1.2.18" }, { name = "dingtalk-stream", specifier = ">=0.22.1" }, { name = "docstring-parser", specifier = ">=0.16" }, { name = "faiss-cpu", specifier = ">=1.10.0" }, @@ -289,6 +292,8 @@ requires-dist = [ { name = "readability-lxml", specifier = ">=0.8.4.1" }, { name = "silk-python", specifier = ">=0.2.6" }, { name = "slack-sdk", specifier = ">=3.35.0" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.41" }, + { name = "sqlmodel", specifier = ">=0.0.24" }, { name = "telegramify-markdown", specifier = ">=0.5.1" }, { name = "watchfiles", specifier = ">=1.0.5" }, { name = "websockets", specifier = ">=15.0.1" }, @@ -591,6 +596,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, ] +[[package]] +name = "deprecated" +version = "1.2.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998 }, +] + [[package]] name = "dingtalk-stream" version = "0.22.1" @@ -828,6 +845,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/a6/c1fe6a46a7ac2d3b08acfe88ce3d2b12cd8351c697ee4b300bfa350b7c9a/googlesearch_python-1.3.0-py3-none-any.whl", hash = "sha256:808c4dd390dc4c6a1cfba2f5151f5ef16dceb0a200d9770b388dcd39162b4e19", size = 5563 }, ] +[[package]] +name = "greenlet" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/db/b4c12cff13ebac2786f4f217f06588bccd8b53d260453404ef22b121fc3a/greenlet-3.2.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:1afd685acd5597349ee6d7a88a8bec83ce13c106ac78c196ee9dde7c04fe87be", size = 268977 }, + { url = "https://files.pythonhosted.org/packages/52/61/75b4abd8147f13f70986df2801bf93735c1bd87ea780d70e3b3ecda8c165/greenlet-3.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:761917cac215c61e9dc7324b2606107b3b292a8349bdebb31503ab4de3f559ac", size = 627351 }, + { url = "https://files.pythonhosted.org/packages/35/aa/6894ae299d059d26254779a5088632874b80ee8cf89a88bca00b0709d22f/greenlet-3.2.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a433dbc54e4a37e4fff90ef34f25a8c00aed99b06856f0119dcf09fbafa16392", size = 638599 }, + { url = "https://files.pythonhosted.org/packages/30/64/e01a8261d13c47f3c082519a5e9dbf9e143cc0498ed20c911d04e54d526c/greenlet-3.2.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:72e77ed69312bab0434d7292316d5afd6896192ac4327d44f3d613ecb85b037c", size = 634482 }, + { url = "https://files.pythonhosted.org/packages/47/48/ff9ca8ba9772d083a4f5221f7b4f0ebe8978131a9ae0909cf202f94cd879/greenlet-3.2.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:68671180e3849b963649254a882cd544a3c75bfcd2c527346ad8bb53494444db", size = 633284 }, + { url = "https://files.pythonhosted.org/packages/e9/45/626e974948713bc15775b696adb3eb0bd708bec267d6d2d5c47bb47a6119/greenlet-3.2.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49c8cfb18fb419b3d08e011228ef8a25882397f3a859b9fe1436946140b6756b", size = 582206 }, + { url = "https://files.pythonhosted.org/packages/b1/8e/8b6f42c67d5df7db35b8c55c9a850ea045219741bb14416255616808c690/greenlet-3.2.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:efc6dc8a792243c31f2f5674b670b3a95d46fa1c6a912b8e310d6f542e7b0712", size = 1111412 }, + { url = "https://files.pythonhosted.org/packages/05/46/ab58828217349500a7ebb81159d52ca357da747ff1797c29c6023d79d798/greenlet-3.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:731e154aba8e757aedd0781d4b240f1225b075b4409f1bb83b05ff410582cf00", size = 1135054 }, + { url = "https://files.pythonhosted.org/packages/68/7f/d1b537be5080721c0f0089a8447d4ef72839039cdb743bdd8ffd23046e9a/greenlet-3.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:96c20252c2f792defe9a115d3287e14811036d51e78b3aaddbee23b69b216302", size = 296573 }, + { url = "https://files.pythonhosted.org/packages/fc/2e/d4fcb2978f826358b673f779f78fa8a32ee37df11920dc2bb5589cbeecef/greenlet-3.2.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:784ae58bba89fa1fa5733d170d42486580cab9decda3484779f4759345b29822", size = 270219 }, + { url = "https://files.pythonhosted.org/packages/16/24/929f853e0202130e4fe163bc1d05a671ce8dcd604f790e14896adac43a52/greenlet-3.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0921ac4ea42a5315d3446120ad48f90c3a6b9bb93dd9b3cf4e4d84a66e42de83", size = 630383 }, + { url = "https://files.pythonhosted.org/packages/d1/b2/0320715eb61ae70c25ceca2f1d5ae620477d246692d9cc284c13242ec31c/greenlet-3.2.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d2971d93bb99e05f8c2c0c2f4aa9484a18d98c4c3bd3c62b65b7e6ae33dfcfaf", size = 642422 }, + { url = "https://files.pythonhosted.org/packages/bd/49/445fd1a210f4747fedf77615d941444349c6a3a4a1135bba9701337cd966/greenlet-3.2.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c667c0bf9d406b77a15c924ef3285e1e05250948001220368e039b6aa5b5034b", size = 638375 }, + { url = "https://files.pythonhosted.org/packages/7e/c8/ca19760cf6eae75fa8dc32b487e963d863b3ee04a7637da77b616703bc37/greenlet-3.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:592c12fb1165be74592f5de0d70f82bc5ba552ac44800d632214b76089945147", size = 637627 }, + { url = "https://files.pythonhosted.org/packages/65/89/77acf9e3da38e9bcfca881e43b02ed467c1dedc387021fc4d9bd9928afb8/greenlet-3.2.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29e184536ba333003540790ba29829ac14bb645514fbd7e32af331e8202a62a5", size = 585502 }, + { url = "https://files.pythonhosted.org/packages/97/c6/ae244d7c95b23b7130136e07a9cc5aadd60d59b5951180dc7dc7e8edaba7/greenlet-3.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:93c0bb79844a367782ec4f429d07589417052e621aa39a5ac1fb99c5aa308edc", size = 1114498 }, + { url = "https://files.pythonhosted.org/packages/89/5f/b16dec0cbfd3070658e0d744487919740c6d45eb90946f6787689a7efbce/greenlet-3.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:751261fc5ad7b6705f5f76726567375bb2104a059454e0226e1eef6c756748ba", size = 1139977 }, + { url = "https://files.pythonhosted.org/packages/66/77/d48fb441b5a71125bcac042fc5b1494c806ccb9a1432ecaa421e72157f77/greenlet-3.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:83a8761c75312361aa2b5b903b79da97f13f556164a7dd2d5448655425bd4c34", size = 297017 }, + { url = "https://files.pythonhosted.org/packages/f3/94/ad0d435f7c48debe960c53b8f60fb41c2026b1d0fa4a99a1cb17c3461e09/greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d", size = 271992 }, + { url = "https://files.pythonhosted.org/packages/93/5d/7c27cf4d003d6e77749d299c7c8f5fd50b4f251647b5c2e97e1f20da0ab5/greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b", size = 638820 }, + { url = "https://files.pythonhosted.org/packages/c6/7e/807e1e9be07a125bb4c169144937910bf59b9d2f6d931578e57f0bce0ae2/greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d", size = 653046 }, + { url = "https://files.pythonhosted.org/packages/9d/ab/158c1a4ea1068bdbc78dba5a3de57e4c7aeb4e7fa034320ea94c688bfb61/greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264", size = 647701 }, + { url = "https://files.pythonhosted.org/packages/cc/0d/93729068259b550d6a0288da4ff72b86ed05626eaf1eb7c0d3466a2571de/greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688", size = 649747 }, + { url = "https://files.pythonhosted.org/packages/f6/f6/c82ac1851c60851302d8581680573245c8fc300253fc1ff741ae74a6c24d/greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb", size = 605461 }, + { url = "https://files.pythonhosted.org/packages/98/82/d022cf25ca39cf1200650fc58c52af32c90f80479c25d1cbf57980ec3065/greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c", size = 1121190 }, + { url = "https://files.pythonhosted.org/packages/f5/e1/25297f70717abe8104c20ecf7af0a5b82d2f5a980eb1ac79f65654799f9f/greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163", size = 1149055 }, + { url = "https://files.pythonhosted.org/packages/1f/8f/8f9e56c5e82eb2c26e8cde787962e66494312dc8cb261c460e1f3a9c88bc/greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849", size = 297817 }, + { url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732 }, + { url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033 }, + { url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999 }, + { url = "https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size = 647368 }, + { url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037 }, + { url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402 }, + { url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577 }, + { url = "https://files.pythonhosted.org/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36", size = 1147121 }, + { url = "https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size = 297603 }, + { url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479 }, + { url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952 }, + { url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917 }, + { url = "https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size = 692443 }, + { url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995 }, + { url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320 }, + { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 }, +] + [[package]] name = "h11" version = "0.14.0" @@ -1709,6 +1777,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/2a/25e0be2b509c28375c7f75c7e8d8d060773f2cce4856a1654276e3202339/pycryptodome-3.22.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d21c1eda2f42211f18a25db4eaf8056c94a8563cd39da3683f89fe0d881fb772", size = 2262255 }, { url = "https://files.pythonhosted.org/packages/41/58/60917bc4bbd91712e53ce04daf237a74a0ad731383a01288130672994328/pycryptodome-3.22.0-cp37-abi3-win32.whl", hash = "sha256:f02baa9f5e35934c6e8dcec91fcde96612bdefef6e442813b8ea34e82c84bbfb", size = 1763403 }, { url = "https://files.pythonhosted.org/packages/55/f4/244c621afcf7867e23f63cfd7a9630f14cfe946c9be7e566af6c3915bcde/pycryptodome-3.22.0-cp37-abi3-win_amd64.whl", hash = "sha256:d086aed307e96d40c23c42418cbbca22ecc0ab4a8a0e24f87932eeab26c08627", size = 1794568 }, + { url = "https://files.pythonhosted.org/packages/cd/13/16d3a83b07f949a686f6cfd7cfc60e57a769ff502151ea140ad67b118e26/pycryptodome-3.22.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:98fd9da809d5675f3a65dcd9ed384b9dc67edab6a4cda150c5870a8122ec961d", size = 1700779 }, + { url = "https://files.pythonhosted.org/packages/13/af/16d26f7dfc5fd7696ea2c91448f937b51b55312b5bed44f777563e32a4fe/pycryptodome-3.22.0-pp27-pypy_73-win32.whl", hash = "sha256:37ddcd18284e6b36b0a71ea495a4c4dca35bb09ccc9bfd5b91bfaf2321f131c1", size = 1775230 }, { url = "https://files.pythonhosted.org/packages/37/c3/e3423e72669ca09f141aae493e1feaa8b8475859898b04f57078280a61c4/pycryptodome-3.22.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b4bdce34af16c1dcc7f8c66185684be15f5818afd2a82b75a4ce6b55f9783e13", size = 1618698 }, { url = "https://files.pythonhosted.org/packages/f9/b7/35eec0b3919cafea362dcb68bb0654d9cb3cde6da6b7a9d8480ce0bf203a/pycryptodome-3.22.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2988ffcd5137dc2d27eb51cd18c0f0f68e5b009d5fec56fbccb638f90934f333", size = 1666957 }, { url = "https://files.pythonhosted.org/packages/b0/1f/f49bccdd8d61f1da4278eb0d6aee7f988f1a6ec4056b0c2dc51eda45ae27/pycryptodome-3.22.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e653519dedcd1532788547f00eeb6108cc7ce9efdf5cc9996abce0d53f95d5a9", size = 1659242 }, @@ -2088,6 +2158,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186 }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/12/d7c445b1940276a828efce7331cb0cb09d6e5f049651db22f4ebb0922b77/sqlalchemy-2.0.41-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1f09b6821406ea1f94053f346f28f8215e293344209129a9c0fcc3578598d7b", size = 2117967 }, + { url = "https://files.pythonhosted.org/packages/6f/b8/cb90f23157e28946b27eb01ef401af80a1fab7553762e87df51507eaed61/sqlalchemy-2.0.41-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1936af879e3db023601196a1684d28e12f19ccf93af01bf3280a3262c4b6b4e5", size = 2107583 }, + { url = "https://files.pythonhosted.org/packages/9e/c2/eef84283a1c8164a207d898e063edf193d36a24fb6a5bb3ce0634b92a1e8/sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2ac41acfc8d965fb0c464eb8f44995770239668956dc4cdf502d1b1ffe0d747", size = 3186025 }, + { url = "https://files.pythonhosted.org/packages/bd/72/49d52bd3c5e63a1d458fd6d289a1523a8015adedbddf2c07408ff556e772/sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81c24e0c0fde47a9723c81d5806569cddef103aebbf79dbc9fcbb617153dea30", size = 3186259 }, + { url = "https://files.pythonhosted.org/packages/4f/9e/e3ffc37d29a3679a50b6bbbba94b115f90e565a2b4545abb17924b94c52d/sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23a8825495d8b195c4aa9ff1c430c28f2c821e8c5e2d98089228af887e5d7e29", size = 3126803 }, + { url = "https://files.pythonhosted.org/packages/8a/76/56b21e363f6039978ae0b72690237b38383e4657281285a09456f313dd77/sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:60c578c45c949f909a4026b7807044e7e564adf793537fc762b2489d522f3d11", size = 3148566 }, + { url = "https://files.pythonhosted.org/packages/3b/92/11b8e1b69bf191bc69e300a99badbbb5f2f1102f2b08b39d9eee2e21f565/sqlalchemy-2.0.41-cp310-cp310-win32.whl", hash = "sha256:118c16cd3f1b00c76d69343e38602006c9cfb9998fa4f798606d28d63f23beda", size = 2086696 }, + { url = "https://files.pythonhosted.org/packages/5c/88/2d706c9cc4502654860f4576cd54f7db70487b66c3b619ba98e0be1a4642/sqlalchemy-2.0.41-cp310-cp310-win_amd64.whl", hash = "sha256:7492967c3386df69f80cf67efd665c0f667cee67032090fe01d7d74b0e19bb08", size = 2110200 }, + { url = "https://files.pythonhosted.org/packages/37/4e/b00e3ffae32b74b5180e15d2ab4040531ee1bef4c19755fe7926622dc958/sqlalchemy-2.0.41-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6375cd674fe82d7aa9816d1cb96ec592bac1726c11e0cafbf40eeee9a4516b5f", size = 2121232 }, + { url = "https://files.pythonhosted.org/packages/ef/30/6547ebb10875302074a37e1970a5dce7985240665778cfdee2323709f749/sqlalchemy-2.0.41-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f8c9fdd15a55d9465e590a402f42082705d66b05afc3ffd2d2eb3c6ba919560", size = 2110897 }, + { url = "https://files.pythonhosted.org/packages/9e/21/59df2b41b0f6c62da55cd64798232d7349a9378befa7f1bb18cf1dfd510a/sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32f9dc8c44acdee06c8fc6440db9eae8b4af8b01e4b1aee7bdd7241c22edff4f", size = 3273313 }, + { url = "https://files.pythonhosted.org/packages/62/e4/b9a7a0e5c6f79d49bcd6efb6e90d7536dc604dab64582a9dec220dab54b6/sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c11ceb9a1f482c752a71f203a81858625d8df5746d787a4786bca4ffdf71c6", size = 3273807 }, + { url = "https://files.pythonhosted.org/packages/39/d8/79f2427251b44ddee18676c04eab038d043cff0e764d2d8bb08261d6135d/sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:911cc493ebd60de5f285bcae0491a60b4f2a9f0f5c270edd1c4dbaef7a38fc04", size = 3209632 }, + { url = "https://files.pythonhosted.org/packages/d4/16/730a82dda30765f63e0454918c982fb7193f6b398b31d63c7c3bd3652ae5/sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03968a349db483936c249f4d9cd14ff2c296adfa1290b660ba6516f973139582", size = 3233642 }, + { url = "https://files.pythonhosted.org/packages/04/61/c0d4607f7799efa8b8ea3c49b4621e861c8f5c41fd4b5b636c534fcb7d73/sqlalchemy-2.0.41-cp311-cp311-win32.whl", hash = "sha256:293cd444d82b18da48c9f71cd7005844dbbd06ca19be1ccf6779154439eec0b8", size = 2086475 }, + { url = "https://files.pythonhosted.org/packages/9d/8e/8344f8ae1cb6a479d0741c02cd4f666925b2bf02e2468ddaf5ce44111f30/sqlalchemy-2.0.41-cp311-cp311-win_amd64.whl", hash = "sha256:3d3549fc3e40667ec7199033a4e40a2f669898a00a7b18a931d3efb4c7900504", size = 2110903 }, + { url = "https://files.pythonhosted.org/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9", size = 2119645 }, + { url = "https://files.pythonhosted.org/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1", size = 2107399 }, + { url = "https://files.pythonhosted.org/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70", size = 3293269 }, + { url = "https://files.pythonhosted.org/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e", size = 3303364 }, + { url = "https://files.pythonhosted.org/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078", size = 3229072 }, + { url = "https://files.pythonhosted.org/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074 }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514 }, + { url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557 }, + { url = "https://files.pythonhosted.org/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size = 2115491 }, + { url = "https://files.pythonhosted.org/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size = 2102827 }, + { url = "https://files.pythonhosted.org/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size = 3225224 }, + { url = "https://files.pythonhosted.org/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size = 3230045 }, + { url = "https://files.pythonhosted.org/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size = 3159357 }, + { url = "https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511 }, + { url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420 }, + { url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329 }, + { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224 }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "sqlmodel" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/4b/c2ad0496f5bdc6073d9b4cef52be9c04f2b37a5773441cc6600b1857648b/sqlmodel-0.0.24.tar.gz", hash = "sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423", size = 116780 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/91/484cd2d05569892b7fef7f5ceab3bc89fb0f8a8c0cde1030d383dbc5449c/sqlmodel-0.0.24-py3-none-any.whl", hash = "sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193", size = 28622 }, +] + [[package]] name = "sse-starlette" version = "2.2.1" @@ -2415,6 +2548,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 }, ] +[[package]] +name = "wrapt" +version = "1.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/d1/1daec934997e8b160040c78d7b31789f19b122110a75eca3d4e8da0049e1/wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984", size = 53307 }, + { url = "https://files.pythonhosted.org/packages/1b/7b/13369d42651b809389c1a7153baa01d9700430576c81a2f5c5e460df0ed9/wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22", size = 38486 }, + { url = "https://files.pythonhosted.org/packages/62/bf/e0105016f907c30b4bd9e377867c48c34dc9c6c0c104556c9c9126bd89ed/wrapt-1.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80dd7db6a7cb57ffbc279c4394246414ec99537ae81ffd702443335a61dbf3a7", size = 38777 }, + { url = "https://files.pythonhosted.org/packages/27/70/0f6e0679845cbf8b165e027d43402a55494779295c4b08414097b258ac87/wrapt-1.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a6e821770cf99cc586d33833b2ff32faebdbe886bd6322395606cf55153246c", size = 83314 }, + { url = "https://files.pythonhosted.org/packages/0f/77/0576d841bf84af8579124a93d216f55d6f74374e4445264cb378a6ed33eb/wrapt-1.17.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b60fb58b90c6d63779cb0c0c54eeb38941bae3ecf7a73c764c52c88c2dcb9d72", size = 74947 }, + { url = "https://files.pythonhosted.org/packages/90/ec/00759565518f268ed707dcc40f7eeec38637d46b098a1f5143bff488fe97/wrapt-1.17.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b870b5df5b71d8c3359d21be8f0d6c485fa0ebdb6477dda51a1ea54a9b558061", size = 82778 }, + { url = "https://files.pythonhosted.org/packages/f8/5a/7cffd26b1c607b0b0c8a9ca9d75757ad7620c9c0a9b4a25d3f8a1480fafc/wrapt-1.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4011d137b9955791f9084749cba9a367c68d50ab8d11d64c50ba1688c9b457f2", size = 81716 }, + { url = "https://files.pythonhosted.org/packages/7e/09/dccf68fa98e862df7e6a60a61d43d644b7d095a5fc36dbb591bbd4a1c7b2/wrapt-1.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1473400e5b2733e58b396a04eb7f35f541e1fb976d0c0724d0223dd607e0f74c", size = 74548 }, + { url = "https://files.pythonhosted.org/packages/b7/8e/067021fa3c8814952c5e228d916963c1115b983e21393289de15128e867e/wrapt-1.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3cedbfa9c940fdad3e6e941db7138e26ce8aad38ab5fe9dcfadfed9db7a54e62", size = 81334 }, + { url = "https://files.pythonhosted.org/packages/4b/0d/9d4b5219ae4393f718699ca1c05f5ebc0c40d076f7e65fd48f5f693294fb/wrapt-1.17.2-cp310-cp310-win32.whl", hash = "sha256:582530701bff1dec6779efa00c516496968edd851fba224fbd86e46cc6b73563", size = 36427 }, + { url = "https://files.pythonhosted.org/packages/72/6a/c5a83e8f61aec1e1aeef939807602fb880e5872371e95df2137142f5c58e/wrapt-1.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:58705da316756681ad3c9c73fd15499aa4d8c69f9fd38dc8a35e06c12468582f", size = 38774 }, + { url = "https://files.pythonhosted.org/packages/cd/f7/a2aab2cbc7a665efab072344a8949a71081eed1d2f451f7f7d2b966594a2/wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58", size = 53308 }, + { url = "https://files.pythonhosted.org/packages/50/ff/149aba8365fdacef52b31a258c4dc1c57c79759c335eff0b3316a2664a64/wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda", size = 38488 }, + { url = "https://files.pythonhosted.org/packages/65/46/5a917ce85b5c3b490d35c02bf71aedaa9f2f63f2d15d9949cc4ba56e8ba9/wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438", size = 38776 }, + { url = "https://files.pythonhosted.org/packages/ca/74/336c918d2915a4943501c77566db41d1bd6e9f4dbc317f356b9a244dfe83/wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a", size = 83776 }, + { url = "https://files.pythonhosted.org/packages/09/99/c0c844a5ccde0fe5761d4305485297f91d67cf2a1a824c5f282e661ec7ff/wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000", size = 75420 }, + { url = "https://files.pythonhosted.org/packages/b4/b0/9fc566b0fe08b282c850063591a756057c3247b2362b9286429ec5bf1721/wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6", size = 83199 }, + { url = "https://files.pythonhosted.org/packages/9d/4b/71996e62d543b0a0bd95dda485219856def3347e3e9380cc0d6cf10cfb2f/wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b", size = 82307 }, + { url = "https://files.pythonhosted.org/packages/39/35/0282c0d8789c0dc9bcc738911776c762a701f95cfe113fb8f0b40e45c2b9/wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662", size = 75025 }, + { url = "https://files.pythonhosted.org/packages/4f/6d/90c9fd2c3c6fee181feecb620d95105370198b6b98a0770cba090441a828/wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72", size = 81879 }, + { url = "https://files.pythonhosted.org/packages/8f/fa/9fb6e594f2ce03ef03eddbdb5f4f90acb1452221a5351116c7c4708ac865/wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317", size = 36419 }, + { url = "https://files.pythonhosted.org/packages/47/f8/fb1773491a253cbc123c5d5dc15c86041f746ed30416535f2a8df1f4a392/wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3", size = 38773 }, + { url = "https://files.pythonhosted.org/packages/a1/bd/ab55f849fd1f9a58ed7ea47f5559ff09741b25f00c191231f9f059c83949/wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", size = 53799 }, + { url = "https://files.pythonhosted.org/packages/53/18/75ddc64c3f63988f5a1d7e10fb204ffe5762bc663f8023f18ecaf31a332e/wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", size = 38821 }, + { url = "https://files.pythonhosted.org/packages/48/2a/97928387d6ed1c1ebbfd4efc4133a0633546bec8481a2dd5ec961313a1c7/wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", size = 38919 }, + { url = "https://files.pythonhosted.org/packages/73/54/3bfe5a1febbbccb7a2f77de47b989c0b85ed3a6a41614b104204a788c20e/wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d", size = 88721 }, + { url = "https://files.pythonhosted.org/packages/25/cb/7262bc1b0300b4b64af50c2720ef958c2c1917525238d661c3e9a2b71b7b/wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b", size = 80899 }, + { url = "https://files.pythonhosted.org/packages/2a/5a/04cde32b07a7431d4ed0553a76fdb7a61270e78c5fd5a603e190ac389f14/wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98", size = 89222 }, + { url = "https://files.pythonhosted.org/packages/09/28/2e45a4f4771fcfb109e244d5dbe54259e970362a311b67a965555ba65026/wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82", size = 86707 }, + { url = "https://files.pythonhosted.org/packages/c6/d2/dcb56bf5f32fcd4bd9aacc77b50a539abdd5b6536872413fd3f428b21bed/wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae", size = 79685 }, + { url = "https://files.pythonhosted.org/packages/80/4e/eb8b353e36711347893f502ce91c770b0b0929f8f0bed2670a6856e667a9/wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9", size = 87567 }, + { url = "https://files.pythonhosted.org/packages/17/27/4fe749a54e7fae6e7146f1c7d914d28ef599dacd4416566c055564080fe2/wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9", size = 36672 }, + { url = "https://files.pythonhosted.org/packages/15/06/1dbf478ea45c03e78a6a8c4be4fdc3c3bddea5c8de8a93bc971415e47f0f/wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991", size = 38865 }, + { url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800 }, + { url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824 }, + { url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920 }, + { url = "https://files.pythonhosted.org/packages/3b/24/11c4510de906d77e0cfb5197f1b1445d4fec42c9a39ea853d482698ac681/wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", size = 88690 }, + { url = "https://files.pythonhosted.org/packages/71/d7/cfcf842291267bf455b3e266c0c29dcb675b5540ee8b50ba1699abf3af45/wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", size = 80861 }, + { url = "https://files.pythonhosted.org/packages/d5/66/5d973e9f3e7370fd686fb47a9af3319418ed925c27d72ce16b791231576d/wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", size = 89174 }, + { url = "https://files.pythonhosted.org/packages/a7/d3/8e17bb70f6ae25dabc1aaf990f86824e4fd98ee9cadf197054e068500d27/wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", size = 86721 }, + { url = "https://files.pythonhosted.org/packages/6f/54/f170dfb278fe1c30d0ff864513cff526d624ab8de3254b20abb9cffedc24/wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", size = 79763 }, + { url = "https://files.pythonhosted.org/packages/4a/98/de07243751f1c4a9b15c76019250210dd3486ce098c3d80d5f729cba029c/wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", size = 87585 }, + { url = "https://files.pythonhosted.org/packages/f9/f0/13925f4bd6548013038cdeb11ee2cbd4e37c30f8bfd5db9e5a2a370d6e20/wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", size = 36676 }, + { url = "https://files.pythonhosted.org/packages/bf/ae/743f16ef8c2e3628df3ddfd652b7d4c555d12c84b53f3d8218498f4ade9b/wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", size = 38871 }, + { url = "https://files.pythonhosted.org/packages/3d/bc/30f903f891a82d402ffb5fda27ec1d621cc97cb74c16fea0b6141f1d4e87/wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", size = 56312 }, + { url = "https://files.pythonhosted.org/packages/8a/04/c97273eb491b5f1c918857cd26f314b74fc9b29224521f5b83f872253725/wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", size = 40062 }, + { url = "https://files.pythonhosted.org/packages/4e/ca/3b7afa1eae3a9e7fefe499db9b96813f41828b9fdb016ee836c4c379dadb/wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", size = 40155 }, + { url = "https://files.pythonhosted.org/packages/89/be/7c1baed43290775cb9030c774bc53c860db140397047cc49aedaf0a15477/wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", size = 113471 }, + { url = "https://files.pythonhosted.org/packages/32/98/4ed894cf012b6d6aae5f5cc974006bdeb92f0241775addad3f8cd6ab71c8/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", size = 101208 }, + { url = "https://files.pythonhosted.org/packages/ea/fd/0c30f2301ca94e655e5e057012e83284ce8c545df7661a78d8bfca2fac7a/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", size = 109339 }, + { url = "https://files.pythonhosted.org/packages/75/56/05d000de894c4cfcb84bcd6b1df6214297b8089a7bd324c21a4765e49b14/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", size = 110232 }, + { url = "https://files.pythonhosted.org/packages/53/f8/c3f6b2cf9b9277fb0813418e1503e68414cd036b3b099c823379c9575e6d/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", size = 100476 }, + { url = "https://files.pythonhosted.org/packages/a7/b1/0bb11e29aa5139d90b770ebbfa167267b1fc548d2302c30c8f7572851738/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", size = 106377 }, + { url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986 }, + { url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750 }, + { url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594 }, +] + [[package]] name = "wsproto" version = "1.2.0" From 87f05fce669dbbe1845dd3aaaa6902dfd8d98fe6 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:38:55 +0800 Subject: [PATCH 02/44] =?UTF-8?q?Fix:=20=E5=BD=93=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=E7=9B=B8=E5=90=8C=E6=B6=88=E6=81=AF=E5=B9=B3=E5=8F=B0=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E9=83=A8=E7=BD=B2=E6=97=B6=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E6=B7=B7=E4=B9=B1=EF=BC=88=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=EF=BC=89=20(#2298)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 方法的返回类型 --- astrbot/core/core_lifecycle.py | 2 +- astrbot/core/event_bus.py | 4 +-- astrbot/core/pipeline/respond/stage.py | 2 +- astrbot/core/platform/astr_message_event.py | 27 +++++++++++++++---- astrbot/core/platform/platform_metadata.py | 2 +- astrbot/core/star/context.py | 29 ++++++++++++++++++--- packages/astrbot/main.py | 2 +- 7 files changed, 54 insertions(+), 14 deletions(-) diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 8412d5bea..025bf03e5 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -232,6 +232,6 @@ 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 diff --git a/astrbot/core/event_bus.py b/astrbot/core/event_bus.py index d4caa2910..5010a0645 100644 --- a/astrbot/core/event_bus.py +++ b/astrbot/core/event_bus.py @@ -48,10 +48,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"[{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"[{event.get_platform_id()}({event.get_platform_name()})] {event.get_sender_id()}: {event.get_message_outline()}" ) diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 54ad1e63b..ffdebf276 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: diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py index 8be20be73..5efbfb2f6 100644 --- a/astrbot/core/platform/astr_message_event.py +++ b/astrbot/core/platform/astr_message_event.py @@ -27,19 +27,29 @@ from .platform_metadata import PlatformMetadata @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_name}:{self.message_type.value}:{self.session_id}" + 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_name, message_type, session_id = session_str.split(":") - return MessageSession(platform_name, MessageType(message_type), session_id) + platform_id, message_type, session_id = session_str.split(":") + return MessageSession(platform_id, MessageType(message_type), session_id) + + +MessageSesion = MessageSession # back compatibility -MessageSesion = MessageSession # back compatibility class AstrMessageEvent(abc.ABC): def __init__( @@ -65,7 +75,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, ) @@ -83,9 +93,16 @@ class AstrMessageEvent(abc.ABC): 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: 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/star/context.py b/astrbot/core/star/context.py index 0b14525d3..6d89da57b 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -2,7 +2,12 @@ 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 @@ -22,6 +27,7 @@ from astrbot.core.star.filter.platform_adapter_type import ( PlatformAdapterType, ADAPTER_NAME_2_TYPE, ) +from deprecated import deprecated class Context: @@ -201,9 +207,12 @@ 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 +226,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 +263,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/packages/astrbot/main.py b/packages/astrbot/main.py index 1dd2cbe2f..8a73945f5 100644 --- a/packages/astrbot/main.py +++ b/packages/astrbot/main.py @@ -820,7 +820,7 @@ UID: {user_id} 此 ID 可用于设置管理员。 if sid: session = str( MessageSesion( - platform_name=message.platform_meta.name, + platform_name=message.platform_meta.id, message_type=MessageType("GroupMessage"), session_id=sid, ) From b1e3018b6b971e6edd036ee63660236bb623c140 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Mon, 4 Aug 2025 00:56:26 +0800 Subject: [PATCH 03/44] =?UTF-8?q?Improve:=20=E5=BC=95=E5=85=A5=E5=85=A8?= =?UTF-8?q?=E6=96=B0=E7=9A=84=E4=BA=BA=E6=A0=BC=E7=AE=A1=E7=90=86=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E4=BB=A5=E5=8F=8A=E9=87=8D=E6=9E=84=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=AE=A1=E7=90=86=E5=99=A8=20(#2305)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add persona management * refactor: 重构函数工具管理器,引入 ToolSet,并让 Persona 支持绑定 Tools * feat: 更新 Persona 工具选择逻辑,支持全选和指定工具的切换 * feat: 更新 BaseDatabase 中的 persona 方法返回类型,支持返回 None --- astrbot/core/config/default.py | 40 +- astrbot/core/core_lifecycle.py | 23 +- astrbot/core/db/__init__.py | 17 + astrbot/core/db/migration/helper.py | 9 +- astrbot/core/db/migration/migra_3_to_4.py | 90 +- astrbot/core/db/po.py | 39 +- astrbot/core/db/sqlite.py | 42 +- astrbot/core/persona_mgr.py | 162 ++++ astrbot/core/pipeline/context.py | 10 +- .../agent_runner/tool_loop_agent.py | 133 ++- .../process_stage/method/llm_request.py | 19 +- astrbot/core/provider/entities.py | 4 +- astrbot/core/provider/func_tool_manager.py | 585 ++++++++----- astrbot/core/provider/manager.py | 97 +-- astrbot/core/provider/provider.py | 20 +- astrbot/core/star/context.py | 54 +- astrbot/dashboard/routes/__init__.py | 5 +- astrbot/dashboard/routes/config.py | 7 - astrbot/dashboard/routes/persona.py | 199 +++++ astrbot/dashboard/routes/tools.py | 40 + astrbot/dashboard/server.py | 3 + dashboard/src/i18n/loader.ts | 1 + .../i18n/locales/en-US/core/navigation.json | 1 + .../i18n/locales/en-US/features/persona.json | 67 ++ .../i18n/locales/en-US/features/tool-use.json | 5 +- .../i18n/locales/zh-CN/core/navigation.json | 1 + .../i18n/locales/zh-CN/features/persona.json | 67 ++ .../i18n/locales/zh-CN/features/tool-use.json | 7 +- dashboard/src/i18n/translations.ts | 8 +- .../full/vertical-sidebar/sidebarItem.ts | 6 +- dashboard/src/router/MainRoutes.ts | 5 + dashboard/src/views/PersonaPage.vue | 808 ++++++++++++++++++ dashboard/src/views/ToolUsePage.vue | 56 +- packages/astrbot/main.py | 62 +- 34 files changed, 2112 insertions(+), 580 deletions(-) create mode 100644 astrbot/core/persona_mgr.py create mode 100644 astrbot/dashboard/routes/persona.py create mode 100644 dashboard/src/i18n/locales/en-US/features/persona.json create mode 100644 dashboard/src/i18n/locales/zh-CN/features/persona.json create mode 100644 dashboard/src/views/PersonaPage.vue diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 41225e0ee..960ef107a 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -115,8 +115,7 @@ DEFAULT_CONFIG = { "log_level": "INFO", "pip_install_arg": "", "pypi_index_url": "https://mirrors.aliyun.com/pypi/simple/", - "knowledge_db": {}, - "persona": [], + "persona": [], # deprecated "timezone": "", "callback_api_base": "", } @@ -1701,43 +1700,6 @@ 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", diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 025bf03e5..28545a238 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -22,6 +22,7 @@ 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 @@ -69,13 +70,23 @@ class AstrBotCoreLifecycle: logger.setLevel(self.astrbot_config["log_level"]) # 设置日志级别 await self.db.initialize() - await do_migration_v4(self.db, {}) + + try: + await do_migration_v4(self.db, {}, self.astrbot_config) + except Exception as e: + logger.error(f"迁移到 v4.0.0 新版本数据格式失败: {e}") # 初始化事件队列 self.event_queue = Queue() + # 初始化人格管理器 + self.persona_mgr = PersonaManager(self.db, self.astrbot_config) + await self.persona_mgr.initialize() + # 初始化供应商管理器 - self.provider_manager = ProviderManager(self.astrbot_config, self.db) + self.provider_manager = ProviderManager( + self.astrbot_config, self.db, self.persona_mgr + ) # 初始化平台管理器 self.platform_manager = PlatformManager(self.astrbot_config, self.event_queue) @@ -94,6 +105,8 @@ class AstrBotCoreLifecycle: self.provider_manager, self.platform_manager, self.conversation_manager, + self.platform_message_history_manager, + self.persona_mgr, ) # 初始化插件管理器 @@ -110,6 +123,7 @@ class AstrBotCoreLifecycle: PipelineContext(self.astrbot_config, self.plugin_manager) ) await self.pipeline_scheduler.initialize() + self.star_context.pipeline_ctx = self.pipeline_scheduler.ctx # 初始化更新器 self.astrbot_updator = AstrBotUpdator() @@ -232,6 +246,9 @@ class AstrBotCoreLifecycle: platform_insts = self.platform_manager.get_insts() for platform_inst in platform_insts: tasks.append( - asyncio.create_task(platform_inst.run(), name=f"{platform_inst.meta().id}({platform_inst.meta().name})") + asyncio.create_task( + platform_inst.run(), + name=f"{platform_inst.meta().id}({platform_inst.meta().name})", + ) ) return tasks diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index 53fccacfc..00c42505f 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -205,6 +205,7 @@ class BaseDatabase(abc.ABC): persona_id: str, system_prompt: str, begin_dialogs: list[str] = None, + tools: list[str] = None, ) -> Persona: """Insert a new persona record.""" ... @@ -219,6 +220,22 @@ class BaseDatabase(abc.ABC): """Get all personas for a specific bot.""" ... + @abc.abstractmethod + async def update_persona( + self, + persona_id: str, + system_prompt: str = None, + begin_dialogs: list[str] = None, + tools: list[str] = 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, key: str, value: str) -> Preference: """Insert a new preference record.""" diff --git a/astrbot/core/db/migration/helper.py b/astrbot/core/db/migration/helper.py index d4b9c99f9..4eb428153 100644 --- a/astrbot/core/db/migration/helper.py +++ b/astrbot/core/db/migration/helper.py @@ -1,11 +1,13 @@ 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 from .migra_3_to_4 import ( migration_conversation_table, migration_platform_table, migration_webchat_data, + migration_persona_data, ) @@ -24,7 +26,9 @@ async def check_migration_needed_v4(db_helper: BaseDatabase) -> bool: async def do_migration_v4( - db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]] + db_helper: BaseDatabase, + platform_id_map: dict[str, dict[str, str]], + astrbot_config: AstrBotConfig, ): """ 执行数据库迁移 @@ -45,6 +49,9 @@ async def do_migration_v4( # 执行 WebChat 数据迁移 await migration_webchat_data(db_helper, platform_id_map) + # 执行人格数据迁移 + await migration_persona_data(db_helper, astrbot_config) + # 标记迁移完成 await db_helper.insert_preference_or_update("migration_done_v4", "true") diff --git a/astrbot/core/db/migration/migra_3_to_4.py b/astrbot/core/db/migration/migra_3_to_4.py index b6de3214e..e3eccd292 100644 --- a/astrbot/core/db/migration/migra_3_to_4.py +++ b/astrbot/core/db/migration/migra_3_to_4.py @@ -4,12 +4,10 @@ from .. import BaseDatabase from .sqlite_v3 import SQLiteDatabase as SQLiteV3DatabaseV3 from astrbot.core.config.default import DB_PATH from astrbot.api import logger +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 astrbot.core.db.po import ConversationV2, PlatformMessageHistory from sqlalchemy import text """ @@ -50,20 +48,21 @@ async def migration_conversation_table( async with db_helper.get_db() as dbsession: dbsession: AsyncSession async with dbsession.begin(): - for conversation in conversations: + 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.warning( + logger.info( f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。" ) if ":" not in conv.user_id: - logger.warning( - f"跳过 user_id 为 {conv.user_id} 的会话,它可能是 WebChat 的消息历史记录。" - ) continue session = MessageSesion.from_str(session_str=conv.user_id) platform_id = get_platform_id( @@ -81,13 +80,12 @@ async def migration_conversation_table( updated_at=datetime.datetime.fromtimestamp(conv.updated_at), ) dbsession.add(conv_v2) - if conv_v2: - logger.info(f"迁移旧会话 {conv.cid} 到新表成功。") 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( @@ -107,7 +105,7 @@ async def migration_platform_table( platform_stats_v3 = stats.platform if not platform_stats_v3: - logger.warning("没有找到旧平台数据,跳过迁移。") + logger.info("没有找到旧平台数据,跳过迁移。") return first_time_stamp = platform_stats_v3[0].timestamp @@ -120,7 +118,13 @@ async def migration_platform_table( async with db_helper.get_db() as dbsession: dbsession: AsyncSession async with dbsession.begin(): - for bucket_end in range(start_time, end_time, 3600): + 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) @@ -136,9 +140,6 @@ async def migration_platform_table( platform_type = get_platform_type( platform_id_map, platform_stats_v3[idx].name ) - logger.info( - f"迁移平台统计数据: {platform_id}, {platform_type}, 时间戳: {bucket_end}, 计数: {cnt}" - ) try: await dbsession.execute( text(""" @@ -161,6 +162,7 @@ async def migration_platform_table( f"迁移平台统计数据失败: {platform_id}, {platform_type}, 时间戳: {bucket_end}", exc_info=True, ) + logger.info(f"成功迁移 {len(platform_stats_v3)} 条旧的平台数据到新表。") async def migration_webchat_data( @@ -178,20 +180,21 @@ async def migration_webchat_data( async with db_helper.get_db() as dbsession: dbsession: AsyncSession async with dbsession.begin(): - for conversation in conversations: + 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.warning( + logger.info( f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。" ) if ":" in conv.user_id: - logger.warning( - f"跳过 user_id 为 {conv.user_id} 的会话,它不是 WebChat 的消息历史记录。" - ) continue platform_id = "webchat" history = json.loads(conv.history) if conv.history else [] @@ -206,9 +209,52 @@ async def migration_webchat_data( ) dbsession.add(new_history) - logger.info(f"迁移旧 WebChat 会话 {conv.cid} 到新表成功。") 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}") diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index 30f7188a1..cbc8c8d4e 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -9,7 +9,7 @@ from sqlmodel import ( UniqueConstraint, Field, ) -from typing import Optional +from typing import Optional, TypedDict class PlatformStat(SQLModel, table=True): @@ -39,12 +39,14 @@ class PlatformStat(SQLModel, table=True): class ConversationV2(SQLModel, table=True): __tablename__ = "conversations" - inner_conversation_id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True}) + 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()) + default_factory=lambda: str(uuid.uuid4()), ) platform_id: str = Field(nullable=False) user_id: str = Field(nullable=False) @@ -78,6 +80,8 @@ class Persona(SQLModel, table=True): 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), @@ -119,7 +123,9 @@ class PlatformMessageHistory(SQLModel, table=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 + 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( @@ -136,12 +142,14 @@ class Attachment(SQLModel, table=True): __tablename__ = "attachments" - inner_attachment_id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True}) + 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()) + 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') @@ -182,6 +190,25 @@ class Conversation: 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. # ==== diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index 0308807da..0ecf787e5 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -19,6 +19,7 @@ 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: @@ -99,9 +100,7 @@ class SQLiteDatabase(BaseDatabase): # Conversation Management # ==== - async def get_conversations( - self, user_id=None, platform_id=None - ): + async def get_conversations(self, user_id=None, platform_id=None): async with self.get_db() as session: session: AsyncSession query = select(ConversationV2) @@ -224,7 +223,7 @@ class SQLiteDatabase(BaseDatabase): return query = query.values(**values) await session.execute(query) - return await self.get_conversation_by_id(cid) + return await self.get_conversation_by_id(cid) async def delete_conversation(self, cid): async with self.get_db() as session: @@ -312,7 +311,9 @@ class SQLiteDatabase(BaseDatabase): result = await session.execute(query) return result.scalar_one_or_none() - async def insert_persona(self, persona_id, system_prompt, begin_dialogs=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 @@ -321,6 +322,7 @@ class SQLiteDatabase(BaseDatabase): persona_id=persona_id, system_prompt=system_prompt, begin_dialogs=begin_dialogs or [], + tools=tools, ) session.add(new_persona) return new_persona @@ -341,6 +343,36 @@ class SQLiteDatabase(BaseDatabase): 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, key, value): """Insert a new preference record or update if it exists.""" async with self.get_db() as session: diff --git a/astrbot/core/persona_mgr.py b/astrbot/core/persona_mgr.py new file mode 100644 index 000000000..dc322c789 --- /dev/null +++ b/astrbot/core/persona_mgr.py @@ -0,0 +1,162 @@ +from astrbot.core.db import BaseDatabase +from astrbot.core.db.po import Persona, Personality +from astrbot.core.config import AstrBotConfig +from astrbot import logger + + +class PersonaManager: + def __init__(self, db_helper: BaseDatabase, astrbot_config: AstrBotConfig): + self.db = db_helper + self.config = astrbot_config + _ps: dict = astrbot_config["provider_settings"] + self.default_persona: str = _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 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 = Personality( + prompt="You are a helpful and friendly assistant.", + name="default", + tools=None, + _begin_dialogs_processed=[], + ) + 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/context.py b/astrbot/core/pipeline/context.py index 0b9d9e533..932c5d5c2 100644 --- a/astrbot/core/pipeline/context.py +++ b/astrbot/core/pipeline/context.py @@ -55,7 +55,7 @@ class PipelineContext: handler: T.Awaitable, *args, **kwargs, - ) -> T.AsyncGenerator[None, None]: + ) -> T.AsyncGenerator[T.Any, None]: """执行事件处理函数并处理其返回结果 该方法负责调用处理函数并处理不同类型的返回值。它支持两种类型的处理函数: @@ -77,10 +77,16 @@ class PipelineContext: try: ready_to_call = handler(event, *args, **kwargs) except TypeError as _: + if self.plugin_manager: + context = self.plugin_manager.context + else: + raise ValueError( + "Cannot call handler without a valid context or plugin manager." + ) # 向下兼容 trace_ = traceback.format_exc() # 以前的 handler 会额外传入一个参数, 但是 context 对象实际上在插件实例中有一份 - ready_to_call = handler(event, self.plugin_manager.context, *args, **kwargs) + ready_to_call = handler(event, context, *args, **kwargs) if inspect.isasyncgen(ready_to_call): _has_yielded = False diff --git a/astrbot/core/pipeline/process_stage/agent_runner/tool_loop_agent.py b/astrbot/core/pipeline/process_stage/agent_runner/tool_loop_agent.py index c2961ded5..cd705275e 100644 --- a/astrbot/core/pipeline/process_stage/agent_runner/tool_loop_agent.py +++ b/astrbot/core/pipeline/process_stage/agent_runner/tool_loop_agent.py @@ -21,6 +21,7 @@ from mcp.types import ( EmbeddedResource, TextResourceContents, BlobResourceContents, + CallToolResult, ) from astrbot.core.star.star_handler import EventType from astrbot import logger @@ -193,50 +194,25 @@ 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}" - ) - 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): + logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}") + executor = func_tool.execute( + event=self.event, + pipeline_context=self.pipeline_ctx, + **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,41 +223,54 @@ 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("返回的数据类型不受支持。") + elif resp is None: + # 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" + ) + else: + logger.warning( + f"Tool 返回了不支持的类型: {type(resp)},将忽略。" + ) self.event.clear_result() except Exception as e: diff --git a/astrbot/core/pipeline/process_stage/method/llm_request.py b/astrbot/core/pipeline/process_stage/method/llm_request.py index ae44cf36d..505b18e8e 100644 --- a/astrbot/core/pipeline/process_stage/method/llm_request.py +++ b/astrbot/core/pipeline/process_stage/method/llm_request.py @@ -100,7 +100,8 @@ 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() @@ -274,7 +275,6 @@ class LLMRequestSubStage(Stage): if event.get_platform_name() == "webchat": asyncio.create_task(self._handle_webchat(event, req, provider)) - async def _handle_webchat( self, event: AstrMessageEvent, req: ProviderRequest, prov: Provider ): @@ -307,19 +307,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/provider/entities.py b/astrbot/core/provider/entities.py index 2d120d7f6..d0ba920c1 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 .func_tool_manager import FunctionToolManager, ToolSet from openai.types.chat.chat_completion import ChatCompletion from openai.types.chat.chat_completion_message_tool_call import ( ChatCompletionMessageToolCall, @@ -97,7 +97,7 @@ class ProviderRequest: """会话 ID""" image_urls: list[str] = field(default_factory=list) """图片 URL 列表""" - func_tool: FuncCall | None = None + func_tool: FunctionToolManager | ToolSet | None = None """可用的函数工具""" contexts: list[dict] = field(default_factory=list) """上下文。格式与 openai 的上下文格式一致: diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index 07a0fbd8f..117a03800 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -1,16 +1,17 @@ from __future__ import annotations import json -import textwrap import os import asyncio import logging from datetime import timedelta +from deprecated import deprecated -from typing import Dict, List, Awaitable, Literal, Any +from typing import Dict, List, Awaitable, Literal, Any, AsyncGenerator from dataclasses import dataclass from typing import Optional from contextlib import AsyncExitStack from astrbot import logger +from astrbot.core import sp from astrbot.core.utils.log_pipe import LogPipe from astrbot.core.utils.astrbot_path import get_astrbot_data_path @@ -28,6 +29,13 @@ except (ModuleNotFoundError, ImportError): "警告: 缺少依赖库 'mcp' 或者 mcp 库版本过低,无法使用 Streamable HTTP 连接方式。" ) +from typing_extensions import TYPE_CHECKING + +if TYPE_CHECKING: + from astrbot.core.platform.astr_message_event import AstrMessageEvent + from astrbot.core.pipeline.context import PipelineContext + + DEFAULT_MCP_CONFIG = {"mcpServers": {}} SUPPORTED_TYPES = [ @@ -39,6 +47,302 @@ SUPPORTED_TYPES = [ ] # json schema 支持的数据类型 +@dataclass +class FunctionTool: + """A class representing a function tool that can be used in function calling.""" + + 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, + event: AstrMessageEvent = None, + pipeline_context: "PipelineContext" = None, + **tool_args, + ) -> AsyncGenerator[Any | mcp.types.CallToolResult, None]: + """执行函数调用。 + + Args: + event (AstrMessageEvent): 事件对象, 当 origin 为 local 时必须提供。 + pipeline_context (PipelineContext): 流水线调度器上下文, 当 origin 为 local 时必须提供。 + **kwargs: 函数调用的参数。 + + Returns: + AsyncGenerator[None | mcp.types.CallToolResult, None] + """ + if self.origin == "local": + if not event: + raise ValueError("Event must be provided for local function tools.") + wrapper = pipeline_context.call_handler( + event=event, + handler=self.handler, + **tool_args, + ) + async for resp in wrapper: + if resp is not None: + 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 + + elif self.origin == "mcp": + if not self.mcp_client: + raise ValueError("MCP client is not available for MCP function tools.") + res = await self.mcp_client.session.call_tool( + name=self.name, + arguments=tool_args, + ) + if not res: + return + yield res + + else: + raise Exception(f"Unknown function 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, + } + + +# alias for FunctionTool +FuncTool = FunctionTool + + +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) + + def openai_schema(self, omit_empty_parameters: 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_parameters: + 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_parameters: bool = False): + return self.openai_schema(omit_empty_parameters) + + @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 _prepare_config(config: dict) -> dict: """准备配置,处理嵌套格式""" if "mcpServers" in config and config["mcpServers"]: @@ -105,55 +409,6 @@ 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 @@ -276,10 +531,9 @@ class MCPClient: 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] = {} @@ -331,11 +585,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 +814,68 @@ 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_parameters=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"]]} + 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) - result = {} + return True + return False - 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" + # 因为不想解决循环引用,所以这里直接传入 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} 已被禁用,请先在管理面板启用再激活此工具。" + ) - 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}) + func_tool.active = True - 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 + 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) - 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, - } - ) - func_definition = json.dumps(_l, ensure_ascii=False) - - prompt = textwrap.dedent(f""" - ROLE: - 你是一个 Function calling AI Agent, 你的任务是将用户的提问转化为函数调用。 - - TOOLS: - 可用的函数列表: - - {func_definition} - - LIMIT: - 1. 你返回的内容应当能够被 Python 的 json 模块解析的 Json 格式字符串。 - 2. 你的 Json 返回的格式如下:`[{{"name": "", "args": }}, ...]`。参数根据上面提供的函数列表中的参数来填写。 - 3. 允许必要时返回多个函数调用,但需保证这些函数调用的顺序正确。 - 4. 如果用户的提问中不需要用到给定的函数,请直接返回 `{{"res": False}}`。 - - EXAMPLE: - 1. `用户提问`:请问一下天气怎么样? `函数调用`:[{{"name": "get_weather", "args": {{"city": "北京"}}}}] - - 用户的提问是:{question} - """) - - _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 - - if "res" in res and not res["res"]: - return "", False - - 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.") - - ret = await func_tool.execute(**args) - if ret: - tool_call_result.append(str(ret)) - return tool_call_result, True + return True + return False def __str__(self): return str(self.func_list) def __repr__(self): return str(self.func_list) + + +FuncCall = FunctionToolManager diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index 370c5322b..7d7779e81 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -7,85 +7,27 @@ from astrbot.core.config.astrbot_config import AstrBotConfig 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, + config: AstrBotConfig, + db_helper: BaseDatabase, + persona_mgr: PersonaManager, + ): + self.persona_mgr = persona_mgr + self.astrbot_config = config 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 的实例""" @@ -113,6 +55,21 @@ class ProviderManager: 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""" + return self.persona_mgr.selected_default_persona_v3 + async def set_provider( self, provider_id: str, provider_type: ProviderType, umo: str = None ): diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py index 36401b089..2b4f81bb4 100644 --- a/astrbot/core/provider/provider.py +++ b/astrbot/core/provider/provider.py @@ -1,23 +1,13 @@ import abc from typing import List -from typing import TypedDict, AsyncGenerator -from astrbot.core.provider.func_tool_manager import FuncCall +from typing import AsyncGenerator +from astrbot.core.provider.func_tool_manager import FunctionToolManager, ToolSet from astrbot.core.provider.entities import LLMResponse, ToolCallsResult, ProviderType 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 +80,7 @@ class Provider(AbstractProvider): prompt: str, session_id: str = None, image_urls: list[str] = None, - func_tool: FuncCall = None, + func_tool: FunctionToolManager | ToolSet = None, contexts: list = None, system_prompt: str = None, tool_calls_result: ToolCallsResult | list[ToolCallsResult] = None, @@ -119,7 +109,7 @@ class Provider(AbstractProvider): prompt: str, session_id: str = None, image_urls: list[str] = None, - func_tool: FuncCall = None, + func_tool: FunctionToolManager | ToolSet = None, contexts: list = None, system_prompt: str = None, tool_calls_result: ToolCallsResult | list[ToolCallsResult] = None, diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 6d89da57b..76cdea062 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -11,12 +11,14 @@ from astrbot.core.provider.provider import ( 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.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 @@ -29,6 +31,11 @@ from astrbot.core.star.filter.platform_adapter_type import ( ) from deprecated import deprecated +from typing_extensions import TYPE_CHECKING + +if TYPE_CHECKING: + from astrbot.core.pipeline.context import PipelineContext + class Context: """ @@ -50,6 +57,8 @@ class Context: registered_web_apis: list = [] + pipeline_ctx: "PipelineContext" = None + # back compatibility _register_tasks: List[Awaitable] = [] _star_manager = None @@ -62,6 +71,8 @@ class Context: provider_manager: ProviderManager = None, platform_manager: PlatformManager = None, conversation_manager: ConversationManager = None, + message_history_manager: PlatformMessageHistoryManager = None, + persona_manager: PersonaManager = None, ): self._event_queue = event_queue self._config = config @@ -69,6 +80,8 @@ class Context: 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 def get_registered_star(self, star_name: str) -> StarMetadata: """根据插件名获取插件的 Metadata""" @@ -80,7 +93,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 @@ -90,40 +103,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): """ @@ -208,7 +195,9 @@ class Context: return self._event_queue @deprecated(version="4.0.0", reason="Use get_platform_inst instead") - def get_platform(self, platform_type: Union[PlatformAdapterType, str]) -> Platform | None: + def get_platform( + self, platform_type: Union[PlatformAdapterType, str] + ) -> Platform | None: """ 获取指定类型的平台适配器。 @@ -268,6 +257,9 @@ class Context: return True return False + def get_pipeline_context(self) -> "PipelineContext": + return self.pipeline_ctx + """ 以下的方法已经不推荐使用。请从 AstrBot 文档查看更好的注册方式。 """ 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/config.py b/astrbot/dashboard/routes/config.py index 7de720a38..11b474860 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -169,7 +169,6 @@ class ConfigRoute(Route): "/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), @@ -509,12 +508,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 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/tools.py b/astrbot/dashboard/routes/tools.py index 5dad2576b..324fc62d3 100644 --- a/astrbot/dashboard/routes/tools.py +++ b/astrbot/dashboard/routes/tools.py @@ -8,6 +8,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 @@ -27,6 +28,8 @@ class ToolsRoute(Route): "/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), } self.register_routes() self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools @@ -336,3 +339,40 @@ 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__ 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/dashboard/src/i18n/loader.ts b/dashboard/src/i18n/loader.ts index 275110ddc..ec76b2c0c 100644 --- a/dashboard/src/i18n/loader.ts +++ b/dashboard/src/i18n/loader.ts @@ -52,6 +52,7 @@ export class I18nLoader { { name: 'features/alkaid/index', path: 'features/alkaid/index.json' }, { name: 'features/alkaid/knowledge-base', path: 'features/alkaid/knowledge-base.json' }, { name: 'features/alkaid/memory', path: 'features/alkaid/memory.json' }, + { name: 'features/persona', path: 'features/persona.json' }, // 消息模块 { name: 'messages/errors', path: 'messages/errors.json' }, diff --git a/dashboard/src/i18n/locales/en-US/core/navigation.json b/dashboard/src/i18n/locales/en-US/core/navigation.json index 501383020..23276163a 100644 --- a/dashboard/src/i18n/locales/en-US/core/navigation.json +++ b/dashboard/src/i18n/locales/en-US/core/navigation.json @@ -2,6 +2,7 @@ "dashboard": "Dashboard", "platforms": "Platforms", "providers": "Providers", + "persona": "Persona", "toolUse": "MCP Tools", "config": "Config", "extension": "Extensions", diff --git a/dashboard/src/i18n/locales/en-US/features/persona.json b/dashboard/src/i18n/locales/en-US/features/persona.json new file mode 100644 index 000000000..94708ee56 --- /dev/null +++ b/dashboard/src/i18n/locales/en-US/features/persona.json @@ -0,0 +1,67 @@ +{ + "page": { + "description": "Manage and configure chat bot personality settings" + }, + "buttons": { + "create": "Create Persona", + "createFirst": "Create First Persona", + "edit": "Edit", + "delete": "Delete", + "cancel": "Cancel", + "save": "Save", + "addDialogPair": "Add Dialog Pair" + }, + "labels": { + "presetDialogs": "Preset Dialogs ({count} pairs)", + "createdAt": "Created At", + "updatedAt": "Updated At" + }, + "form": { + "personaId": "Persona ID", + "systemPrompt": "System Prompt", + "presetDialogs": "Preset Dialogs", + "presetDialogsHelp": "Add some preset dialogs to help the bot better understand the role settings. The number of dialogs must be even (users and assistants take turns).", + "userMessage": "User Message", + "assistantMessage": "Assistant Message", + "tools": "Tool Selection", + "toolsHelp": "Select available tools for this persona. Tools allow the bot to perform specific functions such as searching, calculating, getting information, etc.", + "toolsSelection": "Tool Selection Actions", + "selectAllTools": "Select All Tools", + "clearAllTools": "Clear Selection", + "allSelected": "All Selected", + "mcpServersQuickSelect": "MCP Servers Quick Select", + "searchTools": "Search Tools", + "selectedTools": "Selected Tools", + "noToolsAvailable": "No tools available", + "noToolsFound": "No matching tools found", + "loadingTools": "Loading tools...", + "allToolsAvailable": "Use all available tools", + "noToolsSelected": "No tools selected" + }, + "dialog": { + "create": { + "title": "Create New Persona" + }, + "edit": { + "title": "Edit Persona" + } + }, + "empty": { + "title": "No Persona Configured", + "description": "Create your first persona to start using personalized chatbots" + }, + "validation": { + "required": "This field is required", + "minLength": "Minimum {min} characters required", + "alphanumeric": "Only letters, numbers, underscores and hyphens are allowed", + "dialogRequired": "{type} cannot be empty" + }, + "messages": { + "loadError": "Failed to load persona list", + "saveSuccess": "Saved successfully", + "saveError": "Save failed", + "deleteConfirm": "Are you sure you want to delete persona \"{id}\"? This action cannot be undone.", + "deleteSuccess": "Deleted successfully", + "deleteError": "Delete failed" + } +} diff --git a/dashboard/src/i18n/locales/en-US/features/tool-use.json b/dashboard/src/i18n/locales/en-US/features/tool-use.json index 96c4760e8..1c4c54e97 100644 --- a/dashboard/src/i18n/locales/en-US/features/tool-use.json +++ b/dashboard/src/i18n/locales/en-US/features/tool-use.json @@ -107,6 +107,9 @@ "failed": "Import configuration failed: {error}" }, "configParseError": "Configuration parse error: {error}", - "noAvailableConfig": "No available configuration" + "noAvailableConfig": "No available configuration", + "toggleToolSuccess": "Tool status toggled successfully!", + "toggleToolError": "Failed to toggle tool status: {error}", + "testError": "Test connection failed: {error}" } } \ No newline at end of file diff --git a/dashboard/src/i18n/locales/zh-CN/core/navigation.json b/dashboard/src/i18n/locales/zh-CN/core/navigation.json index d0ed8453a..ccb4be5a1 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/navigation.json +++ b/dashboard/src/i18n/locales/zh-CN/core/navigation.json @@ -2,6 +2,7 @@ "dashboard": "统计", "platforms": "消息平台", "providers": "服务提供商", + "persona": "人格管理", "toolUse": "MCP", "config": "配置文件", "extension": "插件管理", diff --git a/dashboard/src/i18n/locales/zh-CN/features/persona.json b/dashboard/src/i18n/locales/zh-CN/features/persona.json new file mode 100644 index 000000000..619f1b607 --- /dev/null +++ b/dashboard/src/i18n/locales/zh-CN/features/persona.json @@ -0,0 +1,67 @@ +{ + "page": { + "description": "管理和配置聊天机器人的人格角色设定" + }, + "buttons": { + "create": "创建人格", + "createFirst": "创建第一个人格", + "edit": "编辑", + "delete": "删除", + "cancel": "取消", + "save": "保存", + "addDialogPair": "添加对话对" + }, + "labels": { + "presetDialogs": "预设对话 ({count} 对)", + "createdAt": "创建时间", + "updatedAt": "更新时间" + }, + "form": { + "personaId": "人格 ID", + "systemPrompt": "系统提示词", + "presetDialogs": "预设对话", + "presetDialogsHelp": "添加一些预设的对话来帮助机器人更好地理解角色设定。对话数量必须为偶数(用户和助手轮流对话)。", + "userMessage": "用户消息", + "assistantMessage": "助手消息", + "tools": "工具选择", + "toolsHelp": "为这个人格选择可用的工具。工具可以让机器人执行特定的功能,如搜索、计算、获取信息等。", + "toolsSelection": "工具选择操作", + "selectAllTools": "选择所有工具", + "clearAllTools": "清空选择", + "allSelected": "全选", + "mcpServersQuickSelect": "MCP 服务器快速选择", + "searchTools": "搜索工具", + "selectedTools": "已选择的工具", + "noToolsAvailable": "暂无可用工具", + "noToolsFound": "未找到匹配的工具", + "loadingTools": "正在加载工具...", + "allToolsAvailable": "使用所有可用工具", + "noToolsSelected": "未选择任何工具" + }, + "dialog": { + "create": { + "title": "创建新人格" + }, + "edit": { + "title": "编辑人格" + } + }, + "empty": { + "title": "暂无人格配置", + "description": "创建您的第一个人格来开始使用个性化的聊天机器人" + }, + "validation": { + "required": "此字段为必填项", + "minLength": "最少需要 {min} 个字符", + "alphanumeric": "只能包含字母、数字、下划线和连字符", + "dialogRequired": "{type}不能为空" + }, + "messages": { + "loadError": "加载人格列表失败", + "saveSuccess": "保存成功", + "saveError": "保存失败", + "deleteConfirm": "确定要删除人格 \"{id}\" 吗?此操作不可撤销。", + "deleteSuccess": "删除成功", + "deleteError": "删除失败" + } +} diff --git a/dashboard/src/i18n/locales/zh-CN/features/tool-use.json b/dashboard/src/i18n/locales/zh-CN/features/tool-use.json index 61b8691bc..663488497 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/tool-use.json +++ b/dashboard/src/i18n/locales/zh-CN/features/tool-use.json @@ -107,6 +107,9 @@ "failed": "导入配置失败: {error}" }, "configParseError": "配置解析错误: {error}", - "noAvailableConfig": "无可用配置" + "noAvailableConfig": "无可用配置", + "toggleToolSuccess": "工具状态切换成功!", + "toggleToolError": "工具状态切换失败: {error}", + "testError": "测试连接失败: {error}" } -} \ No newline at end of file +} \ No newline at end of file diff --git a/dashboard/src/i18n/translations.ts b/dashboard/src/i18n/translations.ts index c46ca4d0d..f5ea8c97e 100644 --- a/dashboard/src/i18n/translations.ts +++ b/dashboard/src/i18n/translations.ts @@ -25,6 +25,7 @@ import zhCNDashboard from './locales/zh-CN/features/dashboard.json'; import zhCNAlkaidIndex from './locales/zh-CN/features/alkaid/index.json'; import zhCNAlkaidKnowledgeBase from './locales/zh-CN/features/alkaid/knowledge-base.json'; import zhCNAlkaidMemory from './locales/zh-CN/features/alkaid/memory.json'; +import zhCNPersona from './locales/zh-CN/features/persona.json'; import zhCNErrors from './locales/zh-CN/messages/errors.json'; import zhCNSuccess from './locales/zh-CN/messages/success.json'; @@ -54,6 +55,7 @@ import enUSDashboard from './locales/en-US/features/dashboard.json'; import enUSAlkaidIndex from './locales/en-US/features/alkaid/index.json'; import enUSAlkaidKnowledgeBase from './locales/en-US/features/alkaid/knowledge-base.json'; import enUSAlkaidMemory from './locales/en-US/features/alkaid/memory.json'; +import enUSPersona from './locales/en-US/features/persona.json'; import enUSErrors from './locales/en-US/messages/errors.json'; import enUSSuccess from './locales/en-US/messages/success.json'; @@ -88,7 +90,8 @@ export const translations = { index: zhCNAlkaidIndex, 'knowledge-base': zhCNAlkaidKnowledgeBase, memory: zhCNAlkaidMemory - } + }, + persona: zhCNPersona }, messages: { errors: zhCNErrors, @@ -123,7 +126,8 @@ export const translations = { index: enUSAlkaidIndex, 'knowledge-base': enUSAlkaidKnowledgeBase, memory: enUSAlkaidMemory - } + }, + persona: enUSPersona }, messages: { errors: enUSErrors, diff --git a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts index 43f062d1c..09f0e33e0 100644 --- a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts +++ b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts @@ -63,9 +63,13 @@ const sidebarItem: menu[] = [ icon: 'mdi-account-group', to: '/session-management' }, + { + title: 'core.navigation.persona', + icon: 'mdi-heart', + to: '/persona' + }, { title: 'core.navigation.console', - icon: 'mdi-console', to: '/console' }, diff --git a/dashboard/src/router/MainRoutes.ts b/dashboard/src/router/MainRoutes.ts index 8beff1f26..9706f2b73 100644 --- a/dashboard/src/router/MainRoutes.ts +++ b/dashboard/src/router/MainRoutes.ts @@ -56,6 +56,11 @@ const MainRoutes = { path: '/session-management', component: () => import('@/views/SessionManagementPage.vue') }, + { + name: 'Persona', + path: '/persona', + component: () => import('@/views/PersonaPage.vue') + }, { name: 'Console', path: '/console', diff --git a/dashboard/src/views/PersonaPage.vue b/dashboard/src/views/PersonaPage.vue new file mode 100644 index 000000000..3094a5b09 --- /dev/null +++ b/dashboard/src/views/PersonaPage.vue @@ -0,0 +1,808 @@ + + + + + diff --git a/dashboard/src/views/ToolUsePage.vue b/dashboard/src/views/ToolUsePage.vue index 6060088a4..c89634153 100644 --- a/dashboard/src/views/ToolUsePage.vue +++ b/dashboard/src/views/ToolUsePage.vue @@ -405,24 +405,36 @@ + 复选框代表该工具是否被启用。 + + + +
- {{ tool.function.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }} + {{ tool.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }} - {{ formatToolName(tool.function.name) }} + :title="tool.name"> + {{ formatToolName(tool.name) }}
- - {{ tool.function.description }} + + {{ tool.description }}
@@ -434,9 +446,9 @@ mdi-information {{ tm('functionTools.description') }}

-

{{ tool.function.description }}

+

{{ tool.description }}

-