From fbe9a38c42a53271b27ee0bf51a664c8774d0617 Mon Sep 17 00:00:00 2001 From: Strands <148874030+MostimaBridges@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:01:33 +0800 Subject: [PATCH 01/24] fix(dashboard): propagate dark mode to code blocks inside list items (#7667) --- .../shared/ThemeAwareMarkdownCodeBlock.vue | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/dashboard/src/components/shared/ThemeAwareMarkdownCodeBlock.vue b/dashboard/src/components/shared/ThemeAwareMarkdownCodeBlock.vue index f90127218..9d25b7efe 100644 --- a/dashboard/src/components/shared/ThemeAwareMarkdownCodeBlock.vue +++ b/dashboard/src/components/shared/ThemeAwareMarkdownCodeBlock.vue @@ -13,7 +13,7 @@ From b2a95713f8c340258d91a0dd24e090b45262f703 Mon Sep 17 00:00:00 2001 From: MagicSun7940 <129076248+MagicSun7940@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:04:20 +0800 Subject: [PATCH 02/24] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=20Bocha=20=E6=90=9C=E7=B4=A2=E6=97=B6=E6=8A=A5?= =?UTF-8?q?=E9=94=99=20"Can=20not=20decode=20content-encoding:=20br"?= =?UTF-8?q?=E7=9A=84bug=20(#7655)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 修复了使用 Bocha 搜索时报错 "Can not decode content-encoding: br"的bug * 添加了注释,解释为什么要限制 Accept-Encoding,方便以后的维护者理解这是针对 aiohttp brotli bug 的临时规避方案。 --- astrbot/core/tools/web_search_tools.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py index eacee9511..ca89bc17d 100644 --- a/astrbot/core/tools/web_search_tools.py +++ b/astrbot/core/tools/web_search_tools.py @@ -197,6 +197,10 @@ async def _bocha_search( header = { "Authorization": f"Bearer {bocha_key}", "Content-Type": "application/json", + # Explicitly disable brotli encoding to avoid aiohttp >= 3.13.3 brotli + # decompression incompatibility (TypeError: process() takes exactly 1 argument). + # See: https://github.com/aio-libs/aiohttp/issues/11898 + "Accept-Encoding": "gzip, deflate", } async with aiohttp.ClientSession(trust_env=True) as session: async with session.post( From fd2ca702d779d7257701d662f1cc4fd4821ad58a Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 19 Apr 2026 13:19:08 +0800 Subject: [PATCH 03/24] fix: remove default value for injected isDark in ThemeAwareMarkdownCodeBlock --- .../src/components/shared/ThemeAwareMarkdownCodeBlock.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dashboard/src/components/shared/ThemeAwareMarkdownCodeBlock.vue b/dashboard/src/components/shared/ThemeAwareMarkdownCodeBlock.vue index 9d25b7efe..902cc5111 100644 --- a/dashboard/src/components/shared/ThemeAwareMarkdownCodeBlock.vue +++ b/dashboard/src/components/shared/ThemeAwareMarkdownCodeBlock.vue @@ -26,9 +26,9 @@ const props = defineProps<{ isDark?: boolean; }>(); -const injectedIsDark = inject | boolean>("isDark", undefined); +const injectedIsDark = inject | boolean>("isDark"); const effectiveIsDark = computed( - () => props.isDark ?? (injectedIsDark instanceof Object && 'value' in injectedIsDark ? injectedIsDark.value : injectedIsDark) ?? false, + () => props.isDark ?? (injectedIsDark instanceof Object && "value" in injectedIsDark ? injectedIsDark.value : injectedIsDark) ?? false, ); const attrs = useAttrs(); From b40bcbbd86ce72d2a864774405b2b14210993bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B2=9A=E4=B9=8B=E5=A4=8F?= <108566281+Blueteemo@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:09:02 +0800 Subject: [PATCH 04/24] fix: resolve relative file paths within a local workspace root for the SendMessageToUserTool (#7668) * fix: resolve relative file paths against workspace directory * fix: add path normalization and security check for workspace resolution * chore: remove temp PR body file * chore: added some comments Added comments to clarify path resolution logic. * Update astrbot/core/tools/message_tools.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: Test User Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- astrbot/core/tools/message_tools.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/astrbot/core/tools/message_tools.py b/astrbot/core/tools/message_tools.py index 020c1ad5a..31e7ba904 100644 --- a/astrbot/core/tools/message_tools.py +++ b/astrbot/core/tools/message_tools.py @@ -77,7 +77,21 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): async def _resolve_path_from_sandbox( self, context: ContextWrapper[AstrAgentContext], path: str ) -> tuple[str, bool]: - if os.path.exists(path): + # if the path is relative, check if the file exists in user's local workspace + if not os.path.isabs(path): + unified_msg_origin = context.context.event.unified_msg_origin + if unified_msg_origin: + from astrbot.core.tools.computer_tools.util import workspace_root + + try: + ws_path = workspace_root(unified_msg_origin) + ws_candidate = (ws_path / path).resolve() + if ws_candidate.is_file() and ws_candidate.is_relative_to(ws_path): + return str(ws_candidate), False + except Exception: + pass + # check if the file exists in local environment (only allow absolute paths to prevent traversal) + elif os.path.isfile(path): return path, False try: From 1199b704a8a1d1fdeb5d447aefca67826f91767f Mon Sep 17 00:00:00 2001 From: shuiping233 <49360196+shuiping233@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:18:16 +0800 Subject: [PATCH 05/24] feat: implements support for KOOK role mentions (#7626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 实现kook适配器响应`@`角色(role)的能力 * refactor: kook适配器处理role时,`At`组件保留`@`角色的名称而不是id * fix: kook适配器处理role时,role_id的判断问题 * refactor: 移除kook适配器中的一个# type: ignore * fix: 修复kook适配器 role mention转换成`At`组件时保留不是角色名称的bug; * unittest: 给kook适配器添加带有role mention的事件消息的单测,并添加消息组件转换判断单测 * unittest: 部分重构test_kook_event.py和test_kook_types.py 单测 * unittest: 添加kook适配器的 `user/me` `user/view` 接口响应数据验证单测 * fix: 修复kook适配器接收频道权限更新消息会报错的bug * fix: 不额外处理kook的道具消息 * fix: 使用async with self._http_client.get * refactor: kook适配器转换文本内容为消息组件时,只strip mention之间的空格 * fix: 修复 role_mention_counter 计数不正确的问题 * fix: 修复kook适配器发送卡片失败的问题;区分两类kook 数据类的to_dict to_json行为 * chore: 添加注释 * refactor: 重构kook适配器的角色缓存功能,使其无锁,性能更好且具备良好的重试机制 * refactor: kook适配器的channel_id 改为 guild_id * feat: kook适配器响应频道角色更新事件时不再清空整个角色id缓存,而是只清理特定频道的角色id缓存 * unittest: 添加kook适配器的update_role事件的数据类验证单测 * refactor: 补上了一些打印的日志消息文本 refactor: 补上了一些打印的日志消息文本 refactor: 补上了一些打印的日志消息文本 * refactor: 修复kook适配器潜在可能的类型问题 * refactor: `clean_roles_cache`重命名为`clear_guild_roles_cache` --- .../platform/sources/kook/kook_adapter.py | 199 +++++++---- .../core/platform/sources/kook/kook_client.py | 24 +- .../sources/kook/kook_roles_record.py | 164 +++++++++ .../core/platform/sources/kook/kook_types.py | 325 ++++++++++++++++-- .../data/kook_api_response_user_me.json | 36 ++ .../data/kook_api_response_user_view.json | 36 ++ .../data/kook_ws_event_group_message.json | 2 +- ...k_ws_event_group_message_with_mention.json | 88 +++++ ...vent_group_system_message_update_role.json | 38 ++ tests/test_kook/shared.py | 108 +++++- tests/test_kook/test_kook_client.py | 156 +++++++++ tests/test_kook/test_kook_event.py | 65 ++-- tests/test_kook/test_kook_types.py | 62 +++- 13 files changed, 1146 insertions(+), 157 deletions(-) create mode 100644 astrbot/core/platform/sources/kook/kook_roles_record.py create mode 100644 tests/test_kook/data/kook_api_response_user_me.json create mode 100644 tests/test_kook/data/kook_api_response_user_view.json create mode 100644 tests/test_kook/data/kook_ws_event_group_message_with_mention.json create mode 100644 tests/test_kook/data/kook_ws_event_group_system_message_update_role.json create mode 100644 tests/test_kook/test_kook_client.py diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index 7095d7447..a31e30ed4 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -13,12 +13,13 @@ from astrbot.api.platform import ( PlatformMetadata, register_platform_adapter, ) -from astrbot.core.message.components import File, Record, Video +from astrbot.core.message.components import BaseMessageComponent, File, Record, Video from astrbot.core.platform.astr_message_event import MessageSesion from .kook_client import KookClient from .kook_config import KookConfig from .kook_event import KookEvent +from .kook_roles_record import KookRolesRecord from .kook_types import ( ContainerModule, FileModule, @@ -27,14 +28,18 @@ from .kook_types import ( KmarkdownElement, KookCardMessageContainer, KookChannelType, + KookMarkdownMentionRolePart, + KookMentionTagName, KookMessageEventData, KookMessageType, KookModuleType, + KookRoleExtraType, PlainTextElement, SectionModule, ) -KOOK_AT_SELECTOR_REGEX = re.compile(r"\(met\)([^()]+)\(met\)") +KOOK_AT_SELECTOR_REGEX = re.compile(r"\((met|rol)\)([^()]+)\(\1\)") +AT_MENTION_PREFIX_REGEX = re.compile(r"^@[^\s]+(\s*-\s*[^\s]+)?\s*") @register_platform_adapter( @@ -53,6 +58,7 @@ class KookPlatformAdapter(Platform): self._reconnect_task = None self.running = False self._main_task = None + self._roles_cache = KookRolesRecord("", self.client.http_client) async def send_by_session( self, session: MessageSesion, message_chain: MessageChain @@ -84,7 +90,7 @@ class KookPlatformAdapter(Platform): event_type = event.type if event_type in (KookMessageType.KMARKDOWN, KookMessageType.CARD): if self._should_ignore_event_by_bot_nickname(event.author_id): - logger.debug("[KOOK] 收到来自机器人自身的消息, 忽略此消息") + logger.debug("[KOOK] 判断此消息为来自机器人自身的消息, 忽略此消息") return try: abm = await self.convert_message(event) @@ -92,8 +98,18 @@ class KookPlatformAdapter(Platform): except Exception as e: logger.error(f"[KOOK] 消息处理异常: {e}") elif event_type == KookMessageType.SYSTEM: - logger.debug(f'[KOOK] 消息为系统通知, 通知类型为: "{event.extra.type}"') - logger.debug(f"[KOOK] 原始消息数据: {event.to_json()}") + match event.extra.type: + case KookRoleExtraType(): + # 此时 target_id 就是频道id(guild_id) + guild_id = event.target_id + logger.info( + f'[KOOK] 收到频道"{guild_id}"的角色更新通知, 类型为"{event.extra.type.value}", 刷新角色id缓存' + ) + self._roles_cache.clear_guild_roles_cache(int(guild_id)) + case _: + logger.debug( + f'[KOOK] 判断此消息为"{event.extra.type}"类型的系统通知, 因未实现此消息的处理流程而忽略此消息, 原始消息数据: {event.to_json()}' + ) async def run(self): """主运行循环""" @@ -124,6 +140,8 @@ class KookPlatformAdapter(Platform): logger.info("[KOOK] 尝试连接KOOK服务器...") # 尝试连接 + await self.client.get_bot_info() + self._roles_cache.set_bot_id(self.client.bot_id) success = await self.client.connect() if success: @@ -191,47 +209,86 @@ class KookPlatformAdapter(Platform): logger.info("[KOOK] 资源清理完成") - def _parse_kmarkdown_text_message( - self, data: KookMessageEventData, self_id: str - ) -> tuple[list, str]: - kmarkdown = data.extra.kmarkdown - content = data.content or "" - if kmarkdown is None: - logger.error( - f'[KOOK] 无法转换"{KookMessageType.KMARKDOWN.name}"消息, 消息中找不到kmarkdown字段' - ) - logger.error(f"[KOOK] 原始消息内容: {data.to_json()}") - return [], "" + async def _convert_text_message_to_component( + self, + content: str, + raw_content: str, + mention_role_part: list[KookMarkdownMentionRolePart] | None = None, + guild_id: str | None = None, + mention_name_map: dict[str, str] | None = None, + ) -> tuple[list[BaseMessageComponent], str]: + # kook平台有一个角色(role)的概念,他表示拥有某一类权限的许多用户 + # 且角色本身也有一个自己的id,与正常用户id不同 + # 而在频道中是可以`@`角色的,而想要知道bot是否属于某个角色 + # 需要通过 `/user/view` 接口获取当前bot账号的某个频道下所属角色的id + # 为了解决 https://github.com/AstrBotDevs/AstrBot/issues/7539 + # 在确定机器人需要响应某个`(rol)xxx(rol)`时,需要将角色id替换装当前的bot id + # 包装成`At`机器人自己,而`At`的name就保留角色名称 + # 如果没有查询到角色id或者bot不属于某类角色, 则不处理此`(rol)xxx(rol)` + # 暂时想不到能在不修改原有消息内容的情况下处理这个角色mention的方案 - raw_content = kmarkdown.raw_content or content - if not isinstance(content, str): - content = str(content) - if not isinstance(raw_content, str): - raw_content = str(raw_content) - - # TODO 后面的pydantic类型替换,以后再来探索吧 :( - mention_name_map: dict[str, str] = {} - mention_part = kmarkdown.mention_part - if isinstance(mention_part, list): - for item in mention_part: - if not isinstance(item, dict): - continue - mention_id = item.get("id") - if mention_id is None: - continue - mention_name_map[str(mention_id)] = str(item.get("username", "")) - - components = [] + message_str = raw_content + bot_id = self.client.bot_id + bot_nickname = self.client.bot_nickname + bot_username = self.client.bot_username + components: list[BaseMessageComponent] = [] + if mention_name_map is None: + mention_name_map = {} cursor = 0 + + role_mention_counter = -1 + for match in KOOK_AT_SELECTOR_REGEX.finditer(content): if match.start() > cursor: - plain_text = content[cursor : match.start()] + plain_text = content[cursor : match.start()].strip(" ") if plain_text: components.append(Plain(text=plain_text)) - mention_target = match.group(1).strip() - if mention_target == "all": + tag_name = match.group(1) + mention_target = match.group(2).strip() + if tag_name == KookMentionTagName.MENTION and mention_target == "all": components.append(AtAll()) + elif tag_name == KookMentionTagName.ROLE: + role_mention_counter += 1 + role_id = 0 + role_mention_name = mention_target + if mention_role_part is not None: + if len(mention_role_part) > role_mention_counter: + role_mention_name = mention_role_part[role_mention_counter].name + role_id = mention_role_part[role_mention_counter].role_id + if ( + bot_nickname == role_mention_name + or bot_username == role_mention_name + ): + components.append( + At( + qq=bot_id, + name=role_mention_name, # 保留角色名称 + ) + ) + continue + if not mention_target.isdigit() and role_id == 0: + continue + + role_id = role_id or int(mention_target) + if not guild_id: + continue + + if not guild_id.isdigit(): + continue + + if not await self._roles_cache.has_role_in_channel( + role_id, int(guild_id) + ): + continue + + components.append( + At( + qq=bot_id, + name=role_mention_name, # 保留角色名称 + ) + ) + elif mention_target: components.append( At( @@ -242,11 +299,11 @@ class KookPlatformAdapter(Platform): cursor = match.end() if cursor < len(content): - tail_text = content[cursor:] + tail_text = content[cursor:].strip(" ") if tail_text: components.append(Plain(text=tail_text)) - message_str = raw_content + message_str = raw_content.strip() if components: for comp in components: if isinstance(comp, Plain): @@ -254,9 +311,8 @@ class KookPlatformAdapter(Platform): continue break if isinstance(comp, At): - if str(comp.qq) == str(self_id): - message_str = re.sub( - r"^@[^\s]+(\s*-\s*[^\s]+)?\s*", + if str(comp.qq) == str(self.client.bot_id): + message_str = AT_MENTION_PREFIX_REGEX.sub( "", message_str, count=1, @@ -270,10 +326,44 @@ class KookPlatformAdapter(Platform): return components, message_str - def _parse_card_message(self, data: KookMessageEventData) -> tuple[list, str]: + async def _parse_kmarkdown_message( + self, data: KookMessageEventData + ) -> tuple[list[BaseMessageComponent], str]: + kmarkdown = data.extra.kmarkdown + guild_id = data.extra.guild_id + mention_role_part = None + if kmarkdown: + mention_role_part = kmarkdown.mention_role_part + # 无法处理可能会收到的道具消息content,只能保留原样 + content = str(data.content) or "" + if kmarkdown is None: + logger.error( + f'[KOOK] 无法转换"{KookMessageType.KMARKDOWN.name}"消息, 消息中找不到kmarkdown字段' + ) + logger.error(f"[KOOK] 原始消息内容: {data.to_json()}") + return [], "" + + raw_content = kmarkdown.raw_content or content + + mention_name_map: dict[str, str] = {} + mention_part = kmarkdown.mention_part + for item in mention_part: + mention_id = item.id + if mention_id is None: + continue + mention_name_map[str(mention_id)] = str(item.username) + + return await self._convert_text_message_to_component( + content, raw_content, mention_role_part, guild_id, mention_name_map + ) + + async def _parse_card_message( + self, data: KookMessageEventData + ) -> tuple[list[BaseMessageComponent], str]: content = data.content if not isinstance(content, str): content = str(content) + guild_id = data.extra.guild_id card_list = KookCardMessageContainer.from_dict(json.loads(content)) @@ -304,18 +394,13 @@ class KookPlatformAdapter(Platform): logger.debug(f"[KOOK] 跳过或未处理模块: {module.type}") text = "".join(text_parts) - message = [] + message: list[BaseMessageComponent] = [] if text: - for search in KOOK_AT_SELECTOR_REGEX.finditer(text): - search_text = search.group(1).strip() - if search_text == "all": - message.append(AtAll()) - continue - message.append(At(qq=search_text)) - text = text.replace(f"(met){search_text}(met)", "") - - message.append(Plain(text=text)) + component_parts, text = await self._convert_text_message_to_component( + text, text, guild_id=guild_id + ) + message.extend(component_parts) for img_url in images: message.append(Image(file=img_url)) @@ -387,12 +472,10 @@ class KookPlatformAdapter(Platform): abm.message_id = data.msg_id or "unknown" if data.type == KookMessageType.KMARKDOWN: - message, message_str = self._parse_kmarkdown_text_message(data, abm.self_id) - abm.message = message - abm.message_str = message_str + abm.message, abm.message_str = await self._parse_kmarkdown_message(data) elif data.type == KookMessageType.CARD: try: - abm.message, abm.message_str = self._parse_card_message(data) + abm.message, abm.message_str = await self._parse_card_message(data) except Exception as exp: logger.error(f"[KOOK] 卡片消息解析失败: {exp}") logger.error(f"[KOOK] 原始消息内容: {data.to_json()}") diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index 32874f78a..2adfe0e3b 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -3,6 +3,7 @@ import base64 import os import random import time +import traceback import zlib from pathlib import Path @@ -59,12 +60,18 @@ class KookClient: @property def bot_nickname(self): + """机器人昵称""" return self._bot_nickname @property def bot_username(self): + """机器人名称""" return self._bot_username + @property + def http_client(self): + return self._http_client + async def get_bot_info(self) -> None: """获取机器人账号信息""" url = KookApiPaths.USER_ME @@ -151,7 +158,6 @@ class KookClient: gateway_url = await self.get_gateway_url( resume=resume, sn=self.last_sn, session_id=self.session_id ) - await self.get_bot_info() if not gateway_url: return False @@ -215,8 +221,8 @@ class KookClient: except websockets.exceptions.ConnectionClosed: logger.warning("[KOOK] WebSocket连接已关闭") break - except Exception as e: - logger.error(f"[KOOK] 消息处理异常: {e}") + except Exception: + logger.error(f"[KOOK] 消息处理异常: {traceback.format_exc()}") break except Exception as e: @@ -236,11 +242,15 @@ class KookClient: await self.event_callback(data) case KookMessageSignal.HELLO: - assert isinstance(data, KookHelloEventData) + assert isinstance(data, KookHelloEventData), ( + f"期望 data 为 {KookHelloEventData.__name__}, 实际为 {type(data).__name__}," + ) await self._handle_hello(data) case KookMessageSignal.RESUME_ACK: - assert isinstance(data, KookResumeAckEventData) + assert isinstance(data, KookResumeAckEventData), ( + f"期望 data 为 {KookResumeAckEventData.__name__}, 实际为 {type(data).__name__}," + ) await self._handle_resume_ack(data) case KookMessageSignal.PONG: @@ -367,8 +377,8 @@ class KookClient: "type": kook_message_type, } if reply_message_id: - payload["quote"] = reply_message_id - payload["reply_msg_id"] = reply_message_id + payload["quote"] = str(reply_message_id) + payload["reply_msg_id"] = str(reply_message_id) try: async with self._http_client.post(url, json=payload) as resp: diff --git a/astrbot/core/platform/sources/kook/kook_roles_record.py b/astrbot/core/platform/sources/kook/kook_roles_record.py new file mode 100644 index 000000000..fca9660ca --- /dev/null +++ b/astrbot/core/platform/sources/kook/kook_roles_record.py @@ -0,0 +1,164 @@ +import asyncio +import time +from collections import OrderedDict +from dataclasses import dataclass + +import aiohttp +import pydantic + +from astrbot import logger + +from .kook_types import KookApiPaths, KookUserViewResponse + +USER_VIEW_REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=3) +ROLES_CACHE_MAX_SIZE = 2000 +MAX_RETRY_TIMES = 3 +RETRY_INTERVAL_SECOND = 1 * 60 + + +@dataclass +class RolesCache: + value: set[int] | None = None + failed_count: int = 0 + latest_update_time: float = 0 + + def update(self, roles: set[int] | None) -> None: + if roles is not None: + self.failed_count = 0 + self.value = roles + self.latest_update_time = time.time() + + def add_failed(self): + self.failed_count += 1 + + def reset(self, without_value=False): + if not without_value: + self.value = None + self.failed_count = 0 + self.latest_update_time = 0 + + +class KookRolesRecord: + """自动和缓存获取机器人所需响应的消息频道的role信息""" + + def __init__(self, bot_id: str, http_client: aiohttp.ClientSession): + # self._locks: dict[int, asyncio.Lock] = defaultdict(asyncio.Lock) + self._lock = asyncio.Lock() + self._bot_id = bot_id + self._http_client = http_client + # TODO 这个些配置后续加到适配器配置项里 + self._cache_max_size = ROLES_CACHE_MAX_SIZE + self._max_retry_times = MAX_RETRY_TIMES + self._retry_interval = RETRY_INTERVAL_SECOND + self._roles_cache: OrderedDict[int, RolesCache] = OrderedDict() + self._pending_tasks: dict[int, asyncio.Future] = {} + + def set_bot_id(self, bot_id: str): + self._bot_id = bot_id + + def clear_guild_roles_cache(self, guild_id: int): + self._roles_cache.pop(guild_id, None) + self._pending_tasks.pop(guild_id, None) + + async def _fetch_roles_by_guild_id(self, guild_id: int) -> set[int] | None: + # 由于需要判断bot账号是属于某个角色(role)才会回复消息, + # 而后续来自同一个频道的消息,在第一次查这个role的时候, + # 会一直阻塞消息接收直到请求完成或者报错, + # 所以,这里特意调低了timeout时间,避免阻塞太久 + url = KookApiPaths.USER_VIEW + try: + async with self._http_client.get( + url, + params={ + "guild_id": guild_id, + "user_id": self._bot_id, + }, + # TODO 这个超时时间后续加到适配器配置项里 + timeout=USER_VIEW_REQUEST_TIMEOUT, + ) as resp: + if resp.status != 200: + logger.error( + f'[KOOK] 获取机器人在频道"{guild_id}"的角色id信息失败,状态码: {resp.status} , {await resp.text()}' + ) + return + try: + resp_content = KookUserViewResponse.from_dict(await resp.json()) + except pydantic.ValidationError as e: + logger.error( + f'[KOOK] 获取机器人在频道"{guild_id}"的角色id信息失败, 响应数据格式错误: \n{e}' + ) + logger.error(f"[KOOK] 响应内容: {await resp.text()}") + return + + if not resp_content.success(): + logger.error( + f'[KOOK] 获取机器人在频道"{guild_id}"的角色id信息失败: {resp_content.model_dump_json()}' + ) + return + + logger.info(f'[KOOK] 获取机器人在频道"{guild_id}"的角色id成功') + return set(resp_content.data.roles) + + except Exception as e: + logger.error( + f'[KOOK] 获取机器人在频道"{guild_id}"的角色id信息时请求异常: {e}' + ) + return + + async def has_role_in_channel(self, role_id: int, guild_id: int) -> bool: + if (cache := self._roles_cache.get(guild_id)) is not None: + self._roles_cache.move_to_end(guild_id) + roles = cache.value + if roles is not None: + return role_id in roles + + new_future: asyncio.Future[set[int] | None] = asyncio.Future() + actual_future: asyncio.Future[set[int] | None] = self._pending_tasks.setdefault( + guild_id, new_future + ) + + if actual_future is not new_future: + roles = await actual_future + if roles is None: + return False + return role_id in roles + + try: + if (cache := self._roles_cache.get(guild_id)) is not None: + if ( + cache.failed_count > self._max_retry_times + and time.time() - cache.latest_update_time < self._retry_interval + ): + new_future.set_result(None) + return False + + # 简单的容量控制 (LRU) + if len(self._roles_cache) + 1 > self._cache_max_size: + self._roles_cache.popitem(last=False) + + roles_set = await self._fetch_roles_by_guild_id(guild_id) + + cache = self._roles_cache.get(guild_id) + if cache is not None: + cache.update(roles_set) + self._roles_cache.move_to_end(guild_id) + else: + cache = RolesCache(roles_set, latest_update_time=time.time()) + self._roles_cache[guild_id] = cache + + result = False + if roles_set is None: + cache.add_failed() + else: + result = role_id in roles_set + + new_future.set_result(roles_set) + return result + except Exception as e: + new_future.set_result(None) + logger.error( + f'[KOOK] 获取机器人在频道"{guild_id}"的角色id信息时发生异常: {e}' + ) + return False + finally: + self._pending_tasks.pop(guild_id, None) diff --git a/astrbot/core/platform/sources/kook/kook_types.py b/astrbot/core/platform/sources/kook/kook_types.py index 5efaf2a14..281458f86 100644 --- a/astrbot/core/platform/sources/kook/kook_types.py +++ b/astrbot/core/platform/sources/kook/kook_types.py @@ -2,7 +2,7 @@ import json from enum import Enum, IntEnum from typing import Annotated, Any, Literal -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator class KookApiPaths: @@ -13,6 +13,7 @@ class KookApiPaths: # 初始化相关 USER_ME = f"{BASE_URL}{API_VERSION_PATH}/user/me" + USER_VIEW = f"{BASE_URL}{API_VERSION_PATH}/user/view" GATEWAY_INDEX = f"{BASE_URL}{API_VERSION_PATH}/gateway/index" # 消息相关 @@ -23,6 +24,14 @@ class KookApiPaths: DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create" +class KookMentionTagName(str, Enum): + """用来匹配 `(tagName)value(tagName)` 格式里的tagName , 例如: `(met)all(met)` + 定义参见KMarkdown语法文档: https://developer.kookapp.cn/doc/kmarkdown""" + + MENTION = "met" + ROLE = "rol" + + class KookMessageType(IntEnum): """定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction""" @@ -56,6 +65,14 @@ class KookModuleType(str, Enum): CARD = "card" +class KookRoleExtraType(str, Enum): + """定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction""" + + ADDED_ROLE = "added_role" + DELETED_ROLE = "deleted_role" + UPDATED_ROLE = "updated_role" + + ThemeType = Literal[ "primary", "success", "danger", "warning", "info", "secondary", "none", "invisible" ] @@ -67,7 +84,9 @@ SectionMode = Literal["left", "right"] CountdownMode = Literal["day", "hour", "second"] -class KookBaseDataClass(BaseModel): +class KookBaseReceiveDataClass(BaseModel): + """接收数据基类,`to_dict`/`to_json`默认保证尽量json原样输出""" + model_config = ConfigDict( extra="allow", arbitrary_types_allowed=True, @@ -84,11 +103,51 @@ class KookBaseDataClass(BaseModel): def to_dict( self, - mode: Literal["json", "python"] | str = "python", + mode: Literal["json", "python"] | str = "json", + by_alias=True, + exclude_none=False, + exclude_unset=True, + ) -> dict: + """默认配置预期场景为尽量原样输出,若需要使用此数据类发送json数据, + 请`exclude_none=True, exclude_unset=False`""" + return self.model_dump( + by_alias=by_alias, + exclude_none=exclude_none, + mode=mode, + exclude_unset=exclude_unset, + ) + + def to_json( + self, + indent: int | None = None, + ensure_ascii=False, + by_alias=True, + exclude_none=False, + exclude_unset=True, + ) -> str: + """默认配置预期场景为尽量原样输出,若需要使用此数据类发送json数据, + 请`exclude_none=True, exclude_unset=False`""" + return self.model_dump_json( + indent=indent, + ensure_ascii=ensure_ascii, + by_alias=by_alias, + exclude_none=exclude_none, + exclude_unset=exclude_unset, + ) + + +class KookBaseSendDataClass(KookBaseReceiveDataClass): + """发送数据基类,`to_dict`/`to_json`保证默认输出内容格式包含接口格式所需最简格式内容""" + + def to_dict( + self, + mode: Literal["json", "python"] | str = "json", by_alias=True, exclude_none=True, exclude_unset=False, ) -> dict: + """默认配置预期场景为发送数据,若需要使用此数据类接收数据并尽量原样json输出, + 请`exclude_none=False, exclude_unset=True`""" return self.model_dump( by_alias=by_alias, exclude_none=exclude_none, @@ -104,6 +163,8 @@ class KookBaseDataClass(BaseModel): exclude_none=True, exclude_unset=False, ) -> str: + """默认配置预期场景为发送数据,若需要使用此数据类接收数据并尽量原样json输出, + 请`exclude_none=False, exclude_unset=True`""" return self.model_dump_json( indent=indent, ensure_ascii=ensure_ascii, @@ -113,7 +174,7 @@ class KookBaseDataClass(BaseModel): ) -class KookCardModelBase(KookBaseDataClass): +class KookCardModelBase(KookBaseSendDataClass): """卡片模块基类""" type: str @@ -249,10 +310,28 @@ AnyModule = Annotated[ ] -class KookCardMessage(KookBaseDataClass): +class KookCardMessage(KookBaseSendDataClass): """卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage - 此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表** - 若要发送卡片消息,请使用KookCardMessageContainer + 适用于发送单个卡片消息 + 将此消息类型放入`Json`的data字段进行卡片消息发送,适配器会自动添加顶层的列表 + 若要发送多个卡片消息,推荐使用KookCardMessageContainer进行卡片消息组装 + + 使用方法: + ```python + chain = [] + chain.append( + Json( + data=KookCardMessage( + theme="info", + size="lg", + modules=[ + HeaderModule(text=PlainTextElement(content="test1")), + ], + ).to_dict() + ) + ) + yield event.chain_result(chain) + ``` """ model_config = ConfigDict(arbitrary_types_allowed=True) @@ -269,14 +348,71 @@ class KookCardMessage(KookBaseDataClass): class KookCardMessageContainer(list[KookCardMessage]): - """卡片消息容器(列表),此类型可以直接to_json后发送出去""" + """卡片消息容器(列表),可放入多个卡片消息(KookCardMessage) + + 使用方法: + ```python + chain = [] + chain.append( + Json( + data=KookCardMessageContainer( + [ + KookCardMessage( + theme="info", + size="lg", + modules=[ + HeaderModule(text=PlainTextElement(content="test1")), + ], + ) + ] + ).to_dict() + ) + ) + yield event.chain_result(chain) + ``` + """ def append(self, object: KookCardMessage) -> None: return super().append(object) - def to_json(self, indent: int | None = None, ensure_ascii: bool = True) -> str: + def to_dict( + self, + by_alias=True, + exclude_none=True, + exclude_unset=False, + ) -> list[dict]: + """默认配置预期场景为发送数据,若需要使用此数据类接收数据并尽量原样json输出, + 请`exclude_none=False, exclude_unset=True`""" + return [ + i.to_dict( + by_alias=by_alias, + exclude_none=exclude_none, + exclude_unset=exclude_unset, + ) + for i in self + ] + + def to_json( + self, + indent: int | None = None, + ensure_ascii: bool = True, + by_alias=True, + exclude_none=True, + exclude_unset=False, + ) -> str: + """默认配置预期场景为发送数据,若需要使用此数据类接收数据并尽量原样json输出, + 请`exclude_none=False, exclude_unset=True`""" return json.dumps( - [i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii + [ + i.to_dict( + by_alias=by_alias, + exclude_none=exclude_none, + exclude_unset=exclude_unset, + ) + for i in self + ], + indent=indent, + ensure_ascii=ensure_ascii, ) @classmethod @@ -293,7 +429,7 @@ class OrderMessage(BaseModel): class KookMessageSignal(IntEnum): """KOOK WebSocket 信令类型 - ws文档: https://developer.kookapp.cn/doc/websocket""" # noqa: W291 + ws文档: https://developer.kookapp.cn/doc/websocket""" MESSAGE = 0 """server->client 消息(s包含聊天和通知消息)""" @@ -317,7 +453,7 @@ class KookChannelType(str, Enum): BROADCAST = "BROADCAST" -class KookAuthor(KookBaseDataClass): +class KookAuthor(KookBaseReceiveDataClass): id: str username: str identify_num: str @@ -330,25 +466,110 @@ class KookAuthor(KookBaseDataClass): roles: list[int] = Field(default_factory=list) -class KookKMarkdown(KookBaseDataClass): +class KookMarkdownMentionPart(KookBaseReceiveDataClass): + """ + 文档参考: https://developer.kookapp.cn/doc/event/message + """ + + id: str + username: str + full_name: str + avatar: str + + +class KookMarkdownMentionRolePart(KookBaseReceiveDataClass): + """ + 文档参考: https://developer.kookapp.cn/doc/event/message + """ + + role_id: int + name: str + color: int + color_type: int + color_map: list[Any] + position: int | None = None + hoist: int | None = None + mentionable: int | None = None + permissions: int | None = None + + +class KookKMarkdown(KookBaseReceiveDataClass): raw_content: str - mention_part: list[Any] = Field(default_factory=list) - mention_role_part: list[Any] = Field(default_factory=list) + mention_part: list[KookMarkdownMentionPart] = Field(default_factory=list) + mention_role_part: list[KookMarkdownMentionRolePart] = Field(default_factory=list) -class KookExtra(KookBaseDataClass): - type: int | str +class KookRole(KookBaseReceiveDataClass): + """服务器角色对象数据结构""" + + role_id: int = Field(alias="role_id") + name: str | None = None + color: int | None = None + position: int | None = None + hoist: int | None = 0 # 是否在成员列表中单独展示 + mentionable: int | None = 0 # 是否允许所有人提到该角色 + permissions: int | None = None + + +class KookRoleEventBody(KookBaseReceiveDataClass): + """ + 服务器角色相关事件 (added_role, updated_role, deleted_role) 的 Body 部分 + 文档参考: https://developer.kookapp.cn/doc/event/guild-role + """ + + role_id: int | None = None # 在 deleted_role 中通常只给 ID + name: str | None = None + color: int | None = None + position: int | None = None + hoist: int | None = None + mentionable: int | None = None + permissions: int | None = None + # 有些事件会将完整的 role 对象包裹在 body 里 + # 如果是 added_role 且需要处理更完整的结构,可以扩展 + + +class KookExtra(KookBaseReceiveDataClass): + """事件结构定义 + 文档参考 : https://developer.kookapp.cn/doc/event/event-introduction""" + + type: KookRoleExtraType | str | int + """当 type 非系统消息(255)时, type为int + + 当 type 为系统消息(255)时, type为str + """ + code: str | None = None - body: dict[str, Any] | None = None + body: KookRole | dict[str, Any] | None = None author: KookAuthor | None = None kmarkdown: KookKMarkdown | None = None last_msg_content: str | None = None mention: list[str] = Field(default_factory=list) mention_all: bool = False mention_here: bool = False + guild_id: str | None = None + guild_type: int | None = None + channel_name: str | None = None + visible_only: str | None = None + mention_no_at: list | None = None + mention_roles: list[int] | None = None + nav_channels: list | None = None + emoji: list | None = None + preview_content: str | None = None + channel_type: int | None = None + send_msg_device: int | None = None + + @field_validator("type", mode="before") + @classmethod + def parse_type(cls, value): + """优先尝试匹配枚举,失败则保留原值""" + if isinstance(value, str): + if value in {e.value for e in KookRoleExtraType}: + return KookRoleExtraType(value) + + return value -class KookMessageEventData(KookBaseDataClass): +class KookMessageEventData(KookBaseReceiveDataClass): signal: Literal[KookMessageSignal.MESSAGE] = Field( KookMessageSignal.MESSAGE, exclude=True ) @@ -358,7 +579,7 @@ class KookMessageEventData(KookBaseDataClass): type: KookMessageType target_id: str author_id: str - content: str | dict[str, Any] + content: str | dict[str, Any] # 道具消息时这里是dict msg_id: str msg_timestamp: int nonce: str @@ -366,7 +587,7 @@ class KookMessageEventData(KookBaseDataClass): extra: KookExtra -class KookHelloEventData(KookBaseDataClass): +class KookHelloEventData(KookBaseReceiveDataClass): signal: Literal[KookMessageSignal.HELLO] = Field( KookMessageSignal.HELLO, exclude=True ) @@ -376,28 +597,28 @@ class KookHelloEventData(KookBaseDataClass): session_id: str -class KookPingEventData(KookBaseDataClass): +class KookPingEventData(KookBaseReceiveDataClass): signal: Literal[KookMessageSignal.PING] = Field( KookMessageSignal.PING, exclude=True ) """only for type hint""" -class KookPongEventData(KookBaseDataClass): +class KookPongEventData(KookBaseReceiveDataClass): signal: Literal[KookMessageSignal.PONG] = Field( KookMessageSignal.PONG, exclude=True ) """only for type hint""" -class KookResumeEventData(KookBaseDataClass): +class KookResumeEventData(KookBaseReceiveDataClass): signal: Literal[KookMessageSignal.RESUME] = Field( KookMessageSignal.RESUME, exclude=True ) """only for type hint""" -class KookReconnectEventData(KookBaseDataClass): +class KookReconnectEventData(KookBaseReceiveDataClass): signal: Literal[KookMessageSignal.RECONNECT] = Field( KookMessageSignal.RECONNECT, exclude=True ) @@ -407,7 +628,7 @@ class KookReconnectEventData(KookBaseDataClass): err: str -class KookResumeAckEventData(KookBaseDataClass): +class KookResumeAckEventData(KookBaseReceiveDataClass): signal: Literal[KookMessageSignal.RESUME_ACK] = Field( KookMessageSignal.RESUME_ACK, exclude=True ) @@ -416,7 +637,7 @@ class KookResumeAckEventData(KookBaseDataClass): session_id: str -class KookWebsocketEvent(KookBaseDataClass): +class KookWebsocketEvent(KookBaseReceiveDataClass): """KOOK WebSocket 原始推送结构""" signal: KookMessageSignal = Field( @@ -451,22 +672,22 @@ class KookWebsocketEvent(KookBaseDataClass): return data -class KookUserTag(KookBaseDataClass): +class KookUserTag(KookBaseReceiveDataClass): color: str bg_color: str text: str -class KookApiResponseBase(KookBaseDataClass): +class KookApiResponseBase(KookBaseReceiveDataClass): code: int message: str - data: Any + data: dict # 就算请求失败了也是空dict def success(self) -> bool: return self.code == 0 -class KookUserMeData(KookBaseDataClass): +class KookUserMeData(KookBaseReceiveDataClass): """USER_ME 接口返回的 'data' 字段主体""" id: str @@ -495,7 +716,47 @@ class KookUserMeResponse(KookApiResponseBase): data: KookUserMeData -class KookGatewayIndexData(KookBaseDataClass): +class KookUserMeViewData(KookBaseReceiveDataClass): + """USER_ME 接口返回的 'data' 字段主体""" + + class KookTagInfo(KookBaseReceiveDataClass): + color: str + bg_color: str + text: str + + id: str + username: str + identify_num: str + online: bool + os: str + status: int + avatar: str + vip_avatar: str + banner: str + nickname: str + roles: list[int] + is_vip: bool + vip_amp: bool + bot: bool + kpm_vip: str | None = None + wealth_level: int + bot_status: int + tag_info: KookTagInfo + mobile_verified: bool + is_sys: bool + client_id: str + verified: bool + joined_at: int + active_time: int + + +class KookUserViewResponse(KookApiResponseBase): + """USER_VIEW 完整响应结构""" + + data: KookUserMeViewData + + +class KookGatewayIndexData(KookBaseReceiveDataClass): url: str diff --git a/tests/test_kook/data/kook_api_response_user_me.json b/tests/test_kook/data/kook_api_response_user_me.json new file mode 100644 index 000000000..8fefa63a4 --- /dev/null +++ b/tests/test_kook/data/kook_api_response_user_me.json @@ -0,0 +1,36 @@ +{ + "code": 0, + "message": "操作成功", + "data": { + "id": "573092175", + "username": "bot_username", + "identify_num": "9561", + "online": false, + "os": "Websocket", + "status": 0, + "avatar": "https://example.com", + "vip_avatar": "https://example.com", + "banner": "", + "nickname": "bot_nickname", + "roles": [], + "is_vip": false, + "vip_amp": false, + "bot": true, + "kpm_vip": null, + "wealth_level": 0, + "bot_status": 0, + "tag_info": { + "color": "#0096FF", + "bg_color": "#0096FF33", + "text": "机器人" + }, + "mobile_verified": true, + "is_sys": false, + "client_id": "g3nsxNQhNMZFKatU", + "verified": false, + "mobile_prefix": "86", + "mobile": "****", + "invited_count": 0, + "intent": 255 + } +} \ No newline at end of file diff --git a/tests/test_kook/data/kook_api_response_user_view.json b/tests/test_kook/data/kook_api_response_user_view.json new file mode 100644 index 000000000..3dfcdb08e --- /dev/null +++ b/tests/test_kook/data/kook_api_response_user_view.json @@ -0,0 +1,36 @@ +{ + "code": 0, + "message": "操作成功", + "data": { + "id": "573092175", + "username": "bot_username", + "identify_num": "9561", + "online": false, + "os": "Websocket", + "status": 0, + "avatar": "https://img.kookapp.cn/assets/bot.png?x-oss-process=style/icon", + "vip_avatar": "https://img.kookapp.cn/assets/bot.png?x-oss-process=style/icon", + "banner": "", + "nickname": "bot_nickname", + "roles": [ + 13726212 + ], + "is_vip": false, + "vip_amp": false, + "bot": true, + "kpm_vip": null, + "wealth_level": 0, + "bot_status": 0, + "tag_info": { + "color": "#0096FF", + "bg_color": "#0096FF33", + "text": "机器人" + }, + "mobile_verified": true, + "is_sys": false, + "client_id": "g3nsxNQhNMZFKatU", + "verified": false, + "joined_at": 1772260532000, + "active_time": 1776418003694 + } +} \ No newline at end of file diff --git a/tests/test_kook/data/kook_ws_event_group_message.json b/tests/test_kook/data/kook_ws_event_group_message.json index dcab6e901..53bd50481 100644 --- a/tests/test_kook/data/kook_ws_event_group_message.json +++ b/tests/test_kook/data/kook_ws_event_group_message.json @@ -26,7 +26,7 @@ "banner": "", "nickname": "some_username", "roles": [ - 63724577 + 63423577 ], "is_vip": false, "vip_amp": false, diff --git a/tests/test_kook/data/kook_ws_event_group_message_with_mention.json b/tests/test_kook/data/kook_ws_event_group_message_with_mention.json new file mode 100644 index 000000000..50f598a9e --- /dev/null +++ b/tests/test_kook/data/kook_ws_event_group_message_with_mention.json @@ -0,0 +1,88 @@ +{ + "s": 0, + "d": { + "channel_type": "GROUP", + "type": 9, + "target_id": "2732467349811313213", + "author_id": "7324688132731983", + "content": "(rol)25555643(rol) /help (met)3351526782(met) (met)all(met) ", + "msg_id": "9b047d81-40fe-41af-ad39-916ae77e6b20", + "msg_timestamp": 1776405840600, + "nonce": "r2oQkO7kRpNSgmsv7TNl2zOA", + "from_type": 1, + "extra": { + "type": 9, + "code": "", + "author": { + "id": "3351526782", + "username": "some_username", + "identify_num": "4198", + "nickname": "some_username", + "bot": false, + "online": true, + "avatar": "https://example.com", + "vip_avatar": "https://example.com", + "status": 1, + "roles": [ + 63423577 + ], + "os": "Websocket", + "banner": "", + "is_vip": false, + "vip_amp": false, + "nameplate": [], + "wealth_level": 0, + "is_sys": false + }, + "kmarkdown": { + "raw_content": "@some_role /help @some_username @全体成员", + "mention_part": [ + { + "id": "3351526782", + "username": "some_username", + "full_name": "some_username#4198", + "avatar": "https://example.com", + "wealth_level": 0 + } + ], + "mention_role_part": [ + { + "role_id": 25555643, + "name": "some_role", + "color": 0, + "color_type": 1, + "color_map": [] + } + ], + "channel_part": [], + "spl": [] + }, + "last_msg_content": "some_username:@some_role /help @some_username @全体成员", + "mention": [ + "3351526782" + ], + "mention_all": true, + "mention_here": false, + "guild_id": "1239678456780469", + "guild_type": 0, + "channel_name": "聊天大厅", + "visible_only": "", + "mention_no_at": [], + "mention_roles": [ + 25555643 + ], + "nav_channels": [], + "emoji": [], + "preview_content": "", + "channel_type": 1, + "send_msg_device": 0 + } + }, + "extra": { + "verifyToken": "kW4FH_ASHio1hosd", + "encryptKey": "", + "callbackUrl": "", + "intent": 255 + }, + "sn": 1 +} \ No newline at end of file diff --git a/tests/test_kook/data/kook_ws_event_group_system_message_update_role.json b/tests/test_kook/data/kook_ws_event_group_system_message_update_role.json new file mode 100644 index 000000000..ae8ea6c17 --- /dev/null +++ b/tests/test_kook/data/kook_ws_event_group_system_message_update_role.json @@ -0,0 +1,38 @@ +{ + "s": 0, + "d": { + "channel_type": "GROUP", + "type": 255, + "target_id": "6059671921720469", + "author_id": "1", + "content": "[系统消息]", + "msg_id": "35de353d-aa69-40a8-b329-e4498ed8d30d", + "msg_timestamp": 1776498088031, + "nonce": "", + "from_type": 1, + "extra": { + "type": "updated_role", + "body": { + "role_id": 16752184, + "name": "some_username", + "color": 0, + "position": 5, + "hoist": 0, + "mentionable": 0, + "permissions": 5571747823, + "desc": "", + "color_type": 1, + "color_map": {}, + "type": 1, + "op_permissions": 0 + } + } + }, + "sn": 4, + "extra": { + "verifyToken": "kW4FH_ASHio1hosd", + "encryptKey": "", + "callbackUrl": "", + "intent": 255 + } +} \ No newline at end of file diff --git a/tests/test_kook/shared.py b/tests/test_kook/shared.py index f5ef18b8b..366b608db 100644 --- a/tests/test_kook/shared.py +++ b/tests/test_kook/shared.py @@ -1,5 +1,111 @@ -from pathlib import Path +import json +from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock +import aiohttp +from astrbot.api.platform import AstrBotMessage, MessageType +from astrbot.core.message.components import ( + File, + Record, +) + + +from pathlib import Path + CURRENT_DIR = Path(__file__).parent TEST_DATA_DIR = CURRENT_DIR / "data" + + +class KookEventDataPath: + GROUP_MESSAGE_WITH_MENTION = ( + TEST_DATA_DIR / "kook_ws_event_group_message_with_mention.json" + ) + GROUP_MESSAGE = TEST_DATA_DIR / "kook_ws_event_group_message.json" + HELLO = TEST_DATA_DIR / "kook_ws_event_hello.json" + MESSAGE_WITH_CARD_1 = TEST_DATA_DIR / "kook_ws_event_message_with_card_1.json" + MESSAGE_WITH_CARD_2 = TEST_DATA_DIR / "kook_ws_event_message_with_card_2.json" + PING = TEST_DATA_DIR / "kook_ws_event_ping.json" + PONG = TEST_DATA_DIR / "kook_ws_event_pong.json" + PRIVATE_MESSAGE = TEST_DATA_DIR / "kook_ws_event_private_message.json" + PRIVATE_SYSTEM_MESSAGE = TEST_DATA_DIR / "kook_ws_event_private_system_message.json" + RECONNECT_ERR = TEST_DATA_DIR / "kook_ws_event_reconnect_err.json" + RESUME_ACK = TEST_DATA_DIR / "kook_ws_event_resume_ack.json" + RESUME = TEST_DATA_DIR / "kook_ws_event_resume.json" + GROUP_SYSTEM_MESSAGE_UPDATE_ROLE = TEST_DATA_DIR / "kook_ws_event_group_system_message_update_role.json" + + +class KookApiDataPath: + USER_ME = TEST_DATA_DIR / "kook_api_response_user_me.json" + USER_VIEW = TEST_DATA_DIR / "kook_api_response_user_view.json" + + +def mock_kook_client(upload_asset_return: str, send_text_return: str): + client = MagicMock() + + client.upload_asset = AsyncMock(return_value=upload_asset_return) + client.send_text = AsyncMock(return_value=send_text_return) + return client + + +def mock_http_client( + http_method: str = "get", + return_value: str | dict | list | None = None, + status: int = 200, +): + """Mock aiohttp ClientSession""" + + if isinstance(return_value, (dict, list)): + response_text = json.dumps(return_value) + else: + response_text = return_value or "{}" + + mock_response = MagicMock() + mock_response.status = status + mock_response.text = AsyncMock(return_value=response_text) + mock_response.json = AsyncMock( + return_value=json.loads(response_text) if response_text else {} + ) + mock_response.read = AsyncMock(return_value=response_text.encode()) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=None) + mock_session = MagicMock() + + async def mock_method(*args, **kwargs): + return mock_response + + setattr(mock_session, http_method.lower(), mock_method) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + mock_session.close = AsyncMock() + + return mock_session + + +def mock_file_message(input: str): + message = MagicMock(spec=File) + message.get_file = AsyncMock(return_value=input) + return message + + +def mock_record_message(input: str): + message = MagicMock(spec=Record) + message.text = input + message.convert_to_file_path = AsyncMock(return_value=input) + return message + + +def mock_astrbot_message(): + message = AstrBotMessage() + message.type = MessageType.OTHER_MESSAGE + message.group_id = "test" + message.session_id = "test" + message.message_id = "test" + return message + + +def mock_kook_roles_record(bot_id: str, http_client: aiohttp.ClientSession): + instance = AsyncMock() + instance.has_role_in_channel = AsyncMock(return_value=True) + return instance diff --git a/tests/test_kook/test_kook_client.py b/tests/test_kook/test_kook_client.py new file mode 100644 index 000000000..22556386b --- /dev/null +++ b/tests/test_kook/test_kook_client.py @@ -0,0 +1,156 @@ +import asyncio +from dataclasses import dataclass, field +import json +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from astrbot.core.message.components import ( + At, + AtAll, + BaseMessageComponent, + Plain, + Record, +) +from astrbot.core.platform.sources.kook.kook_client import KookClient +from astrbot.core.platform.sources.kook.kook_config import KookConfig +from astrbot.core.platform.sources.kook.kook_types import ( + KookMessageEventData, + KookWebsocketEvent, +) +from tests.test_kook.shared import ( + KookEventDataPath, + mock_http_client, + mock_kook_roles_record, +) + +TEST_BOT_ID = 1234567891 +TEST_BOT_USERNAME = "test_username" +TEST_BOT_NICKNAME = "test_nickname" + + +def mock_kook_client(config: KookConfig, event_callback): + class MockKookClient: + def __init__(self, config, callback): + self.bot_id = TEST_BOT_ID + self.bot_nickname = TEST_BOT_NICKNAME + self.bot_username = TEST_BOT_USERNAME + self.http_client = mock_http_client() + self.connect = AsyncMock() + self.close = AsyncMock() + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + return MockKookClient(config, event_callback) + + +def get_json_field(content: dict, json_field_path: list[str | int]) -> Any: + expend_value = content + for key in json_field_path: + expend_value = expend_value[key] + return expend_value + + +@dataclass +class JsonFieldPaths: + message_str: list[int | str] = field(default_factory=list) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "expected_json_data_path, expected_message_str, expected_message_components", + [ + ( + KookEventDataPath.GROUP_MESSAGE_WITH_MENTION, + ["d", "extra", "kmarkdown", "raw_content"], + [ + # 这里默认机器人一定属于某个角色id + At(qq=TEST_BOT_ID, name="some_role"), + Plain(text="/help"), + At(qq=3351526782, name="some_username"), + AtAll(qq="all", name=""), + ], + ), + ( + KookEventDataPath.GROUP_MESSAGE, + ["d", "extra", "kmarkdown", "raw_content"], + [Plain(text="done!")], + ), + ( + KookEventDataPath.MESSAGE_WITH_CARD_1, + "[audio]", + [ + Plain(text="[audio]"), + Record( + file="https://img.kookapp.cn/attachments/2026-03/03/69a6841c3125d.wav", + url="", + text=None, + path=None, + ), + ], + ), + ( + KookEventDataPath.MESSAGE_WITH_CARD_2, + ["d", "extra", "kmarkdown", "raw_content"], + [ + Plain(text="(met)"), + Plain(text="all(met) #hello \\*\\*world\\*\\* [audio]\n😆"), + Record( + file="https://img.kookapp.cn/attachments/2026-03/03/69a6841c3125d.wav", + url="", + text=None, + path=None, + ), + ], + ), + ( + KookEventDataPath.PRIVATE_MESSAGE, + ["d", "extra", "kmarkdown", "raw_content"], + [Plain(text="/help")], + ), + ], +) +async def test_kook_event_warp_message( + expected_json_data_path: Path, + expected_message_str: list[int | str] | str, + expected_message_components: list[BaseMessageComponent], +): + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr( + "astrbot.core.platform.sources.kook.kook_adapter.KookClient", mock_kook_client + ) + monkeypatch.setattr( + "astrbot.core.platform.sources.kook.kook_adapter.KookRolesRecord", + mock_kook_roles_record, + ) + + from astrbot.core.platform.sources.kook.kook_adapter import KookPlatformAdapter + + adapter = KookPlatformAdapter({}, {}, asyncio.Queue()) + + raw_event_str = expected_json_data_path.read_text(encoding="utf-8") + raw_event = json.loads(raw_event_str) + event = KookWebsocketEvent.from_json( + raw_event_str, + ) + assert isinstance(event.data, KookMessageEventData) + + astrbotMessage = await adapter.convert_message(event.data) + assert astrbotMessage.self_id == TEST_BOT_ID + assert astrbotMessage.sender.user_id == raw_event["d"]["author_id"] + assert ( + astrbotMessage.sender.nickname == raw_event["d"]["extra"]["author"]["username"] + ) + assert astrbotMessage.raw_message == raw_event["d"] + assert astrbotMessage.message_id == raw_event["d"]["msg_id"] + assert astrbotMessage.message == expected_message_components + if isinstance(expected_message_str, str): + assert astrbotMessage.message_str == expected_message_str + else: + assert get_json_field(raw_event, expected_message_str) diff --git a/tests/test_kook/test_kook_event.py b/tests/test_kook/test_kook_event.py index 5fe73a510..616d596c8 100644 --- a/tests/test_kook/test_kook_event.py +++ b/tests/test_kook/test_kook_event.py @@ -1,10 +1,8 @@ -from unittest.mock import AsyncMock, MagicMock +import json import pytest -from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata, Unknown -from astrbot.api.event import MessageChain +from astrbot.api.platform import PlatformMetadata, Unknown from astrbot.core.message.components import ( - File, Image, Plain, Video, @@ -12,44 +10,18 @@ from astrbot.core.message.components import ( AtAll, BaseMessageComponent, Json, - Record, Reply, ) from astrbot.core.platform.sources.kook.kook_event import KookEvent from astrbot.core.platform.sources.kook.kook_types import KookMessageType, OrderMessage - - -async def mock_kook_client(upload_asset_return: str, send_text_return: str): - # 1. Mock 掉整个 KookClient 类 - client = MagicMock() - - client.upload_asset = AsyncMock(return_value=upload_asset_return) - client.send_text = AsyncMock(return_value=send_text_return) - return client - - -def mock_file_message(input: str): - message = MagicMock(spec=File) - message.get_file = AsyncMock(return_value=input) - return message - - -def mock_record_message(input: str): - message = MagicMock(spec=Record) - message.text = input - message.convert_to_file_path = AsyncMock(return_value=input) - return message - - -def mock_astrbot_message(): - message = AstrBotMessage() - message.type = MessageType.OTHER_MESSAGE - message.group_id = "test" - message.session_id = "test" - message.message_id = "test" - return message +from tests.test_kook.shared import ( + mock_astrbot_message, + mock_file_message, + mock_kook_client, + mock_record_message, +) @pytest.mark.asyncio @@ -161,7 +133,7 @@ async def test_kook_event_warp_message( expected_output: OrderMessage, expected_error: type[BaseException] | None, ): - client = await mock_kook_client( + client = mock_kook_client( upload_asset_return, "", ) @@ -184,5 +156,20 @@ async def test_kook_event_warp_message( return result = await event._wrap_message(1, input_message) - assert result == expected_output - \ No newline at end of file + + expected_output_text: str | list | dict = expected_output.text + is_json_text = False + try: + expected_output_text = json.loads(expected_output_text) + is_json_text = True + except: + pass + + if is_json_text: + assert json.loads(result.text) == expected_output_text + else: + assert result.text == expected_output_text + + assert result.index == expected_output.index + assert result.type == expected_output.type + assert result.reply_id == expected_output.reply_id diff --git a/tests/test_kook/test_kook_types.py b/tests/test_kook/test_kook_types.py index 85c39622c..89568168a 100644 --- a/tests/test_kook/test_kook_types.py +++ b/tests/test_kook/test_kook_types.py @@ -15,16 +15,19 @@ from astrbot.core.platform.sources.kook.kook_types import ( ImageGroupModule, InviteModule, KmarkdownElement, + KookApiResponseBase, KookCardMessage, KookMessageSignal, KookModuleType, + KookUserMeResponse, + KookUserViewResponse, KookWebsocketEvent, ParagraphStructure, PlainTextElement, SectionModule, KookCardMessageContainer, ) -from tests.test_kook.shared import TEST_DATA_DIR +from tests.test_kook.shared import TEST_DATA_DIR, KookApiDataPath, KookEventDataPath def test_kook_card_message_container_append(): @@ -109,29 +112,31 @@ def test_all_kook_card_type(): ).to_json(indent=4, ensure_ascii=False) assert json_output == expect_json_data + @pytest.mark.parametrize( - "expected_json_data_filename", + "expected_json_data_path", [ - ("kook_ws_event_group_message.json"), - ("kook_ws_event_hello.json"), - ("kook_ws_event_message_with_card_1.json"), - ("kook_ws_event_message_with_card_2.json"), - ("kook_ws_event_ping.json"), - ("kook_ws_event_pong.json"), - ("kook_ws_event_private_message.json"), - ("kook_ws_event_private_system_message.json"), - ("kook_ws_event_reconnect_err.json"), - ("kook_ws_event_resume_ack.json"), - ("kook_ws_event_resume.json"), - + (KookEventDataPath.GROUP_MESSAGE_WITH_MENTION), + (KookEventDataPath.GROUP_MESSAGE), + (KookEventDataPath.HELLO), + (KookEventDataPath.MESSAGE_WITH_CARD_1), + (KookEventDataPath.MESSAGE_WITH_CARD_2), + (KookEventDataPath.PING), + (KookEventDataPath.PONG), + (KookEventDataPath.PRIVATE_MESSAGE), + (KookEventDataPath.PRIVATE_SYSTEM_MESSAGE), + (KookEventDataPath.RECONNECT_ERR), + (KookEventDataPath.RESUME_ACK), + (KookEventDataPath.RESUME), + (KookEventDataPath.GROUP_SYSTEM_MESSAGE_UPDATE_ROLE), ], ) -def test_websocket_event_type_parse(expected_json_data_filename:str): - expected_json_data_str =(TEST_DATA_DIR / expected_json_data_filename).read_text(encoding="utf-8") +def test_websocket_event_type_parse(expected_json_data_path: Path): + expected_json_data_str = (expected_json_data_path).read_text(encoding="utf-8") event = KookWebsocketEvent.from_json( expected_json_data_str, ) - event_dict = event.to_dict(mode="json",exclude_unset=True,exclude_none=False) + event_dict = event.to_dict() assert event_dict == json.loads(expected_json_data_str) @@ -141,8 +146,27 @@ def test_websocket_event_create(): data=None, sn=0, ) - assert ping_data.to_dict(mode="json")== { + assert ping_data.to_dict(exclude_none=True, exclude_unset=False) == { "s": KookMessageSignal.PING.value, "sn": 0, } - \ No newline at end of file + + +@pytest.mark.parametrize( + "expected_json_data_path, expected_dataclass", + [ + (KookApiDataPath.USER_ME, KookUserMeResponse), + (KookApiDataPath.USER_VIEW, KookUserViewResponse), + ], +) +def test_api_response_type_parse( + expected_json_data_path: Path, expected_dataclass: type[KookApiResponseBase] +): + expected_json_data_str = (expected_json_data_path).read_text(encoding="utf-8") + + response_body = expected_dataclass.from_json( + expected_json_data_str, + ) + + body_dict = response_body.to_dict() + assert body_dict == json.loads(expected_json_data_str) From 960bc21c539d8c36efc8c4ba47512e8fe5d88e62 Mon Sep 17 00:00:00 2001 From: QAQneko <72352414+xmbhjQAQ@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:12:12 +0800 Subject: [PATCH 06/24] fix: resolve EmptyModelOutputError and enhance tool fallback robustness (#7375) Improve robustness of tool call handling in OpenAI completions and agent tool loop by avoiding premature filtering and surfacing clearer errors when tools are missing. * Refactor tool call argument handling in openai_source.py * Improve error logging for missing tools Log available tools when a specified tool is not found. --- .../agent/runners/tool_loop_agent_runner.py | 4 ++- .../core/provider/sources/openai_source.py | 36 ++++++++++--------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 6e3ba40a9..c210056e9 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -924,8 +924,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): # in 'skills_like' mode, raw.func_tool is light schema, does not have handler # so we need to get the tool from the raw tool set func_tool = self._skill_like_raw_tool_set.get_tool(func_tool_name) + available_tools = self._skill_like_raw_tool_set.names() else: func_tool = req.func_tool.get_tool(func_tool_name) + available_tools = req.func_tool.names() logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}") @@ -933,7 +935,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): logger.warning(f"未找到指定的工具: {func_tool_name},将跳过。") _append_tool_call_result( func_tool_id, - f"error: Tool {func_tool_name} not found.", + f"error: Tool {func_tool_name} not found. Available tools are: {', '.join(available_tools)}", ) continue diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index b19f3460d..b24bc0885 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -854,24 +854,26 @@ class ProviderOpenAIOfficial(Provider): # 工具集未提供 # Should be unreachable raise Exception("工具集未提供") - for tool in tools.func_list: - if ( - tool_call.type == "function" - and tool.name == tool_call.function.name - ): - # workaround for #1454 - if isinstance(tool_call.function.arguments, str): - args = json.loads(tool_call.function.arguments) - else: - args = tool_call.function.arguments - args_ls.append(args) - func_name_ls.append(tool_call.function.name) - tool_call_ids.append(tool_call.id) - # gemini-2.5 / gemini-3 series extra_content handling - extra_content = getattr(tool_call, "extra_content", None) - if extra_content is not None: - tool_call_extra_content_dict[tool_call.id] = extra_content + if tool_call.type == "function": + # workaround for #1454 + if isinstance(tool_call.function.arguments, str): + try: + args = json.loads(tool_call.function.arguments) + except json.JSONDecodeError as e: + logger.error(f"解析参数失败: {e}") + args = {} + else: + args = tool_call.function.arguments + args_ls.append(args) + func_name_ls.append(tool_call.function.name) + tool_call_ids.append(tool_call.id) + + # gemini-2.5 / gemini-3 series extra_content handling + extra_content = getattr(tool_call, "extra_content", None) + if extra_content is not None: + tool_call_extra_content_dict[tool_call.id] = extra_content + llm_response.role = "tool" llm_response.tools_call_args = args_ls llm_response.tools_call_name = func_name_ls From 00689604b4d54bc8a88c87652c8e78f293f976e2 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 19 Apr 2026 17:50:03 +0800 Subject: [PATCH 07/24] chore: bump version to 4.23.2 --- .github/workflows/dashboard_ci.yml | 5 ++- .github/workflows/release.yml | 10 ++--- astrbot/cli/__init__.py | 2 +- astrbot/core/config/default.py | 2 +- changelogs/v4.23.2.md | 69 ++++++++++++++++++++++++++++++ dashboard/pnpm-lock.yaml | 4 +- pyproject.toml | 2 +- 7 files changed, 82 insertions(+), 12 deletions(-) create mode 100644 changelogs/v4.23.2.md diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml index beb7b8871..63a7f47b3 100644 --- a/.github/workflows/dashboard_ci.yml +++ b/.github/workflows/dashboard_ci.yml @@ -27,9 +27,10 @@ jobs: cache-dependency-path: dashboard/pnpm-lock.yaml - name: Install and Build + working-directory: dashboard run: | - pnpm --dir dashboard install --frozen-lockfile - pnpm --dir dashboard run build + pnpm install --frozen-lockfile + pnpm run build - name: Inject Commit SHA id: get_sha diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfe80a115..b91d1f3a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ jobs: echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Setup pnpm - uses: pnpm/action-setup@v6.0.0 + uses: pnpm/action-setup@v5.0.0 with: version: 10.28.2 @@ -64,11 +64,11 @@ jobs: - name: Build dashboard dist shell: bash + working-directory: dashboard run: | - pnpm --dir dashboard install --frozen-lockfile - pnpm --dir dashboard run build - echo "${{ steps.tag.outputs.tag }}" > dashboard/dist/assets/version - cd dashboard + pnpm install --frozen-lockfile + pnpm run build + echo "${{ steps.tag.outputs.tag }}" > dist/assets/version zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist - name: Upload dashboard artifact diff --git a/astrbot/cli/__init__.py b/astrbot/cli/__init__.py index 354868fea..f09cd959a 100644 --- a/astrbot/cli/__init__.py +++ b/astrbot/cli/__init__.py @@ -1 +1 @@ -__version__ = "4.23.1" +__version__ = "4.23.2" diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 527cfbf9b..b7ab2951d 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -5,7 +5,7 @@ from typing import Any, TypedDict from astrbot.core.utils.astrbot_path import get_astrbot_data_path -VERSION = "4.23.1" +VERSION = "4.23.2" DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") PERSONAL_WECHAT_CONFIG_METADATA = { "weixin_oc_base_url": { diff --git a/changelogs/v4.23.2.md b/changelogs/v4.23.2.md new file mode 100644 index 000000000..d6e91acea --- /dev/null +++ b/changelogs/v4.23.2.md @@ -0,0 +1,69 @@ +- [更新日志(简体中文)](#chinese) +- [Changelog(English)](#english) + + + +## What's Changed + +### 新增 + +- 知识库稀疏检索阶段新增 SQLite FTS5 支持,大幅优化万到十万级别分块时造成的召回时的显著卡顿。([#7648](https://github.com/AstrBotDevs/AstrBot/pull/7648)) +- KOOK 平台新增角色提及支持,包含角色记录、事件解析、API 类型与相关测试。([#7626](https://github.com/AstrBotDevs/AstrBot/pull/7626)) +- 新增 MiniMax Token Plan Provider。([#7609](https://github.com/AstrBotDevs/AstrBot/pull/7609)) +- 新增 `on_agent_begin`、`on_using_llm_tool`、`on_llm_tool_respond`、`on_agent_done` 插件事件钩子,并更新插件开发文档。([#7540](https://github.com/AstrBotDevs/AstrBot/pull/7540)) +- 为 t2i 模板启用 Shiki 代码高亮,并新增 VitePress 风格模板。([#7501](https://github.com/AstrBotDevs/AstrBot/pull/7501)) + +### 优化 + +- 优化 `EmptyModelOutputError` 处理,不再严格强制校验模型工具输出的名字是否符合工具集,采用引导的方式让模型输出正确的工具调用。([#7375](https://github.com/AstrBotDevs/AstrBot/pull/7375)) +- 优化 `SendMessageToUserTool` 工具在本地 workspace root 内解析相对文件路径的问题。([#7668](https://github.com/AstrBotDevs/AstrBot/pull/7668)) + +### 修复 + +- 修复 Bocha 搜索返回 Brotli 压缩内容时出现 `Can not decode content-encoding: br` 的问题。([#7655](https://github.com/AstrBotDevs/AstrBot/pull/7655)) +- 修复微信个人号主动 Cron 发送时 `context_token` 未持久化的问题。([#7595](https://github.com/AstrBotDevs/AstrBot/pull/7595)) +- 修复 Anthropic Provider 默认 `max_tokens` 偏小导致工具调用输出文件操作时造成大量截断的问题。([#7593](https://github.com/AstrBotDevs/AstrBot/pull/7593)) +- 修复 Cron `last_run_at` 在 Dashboard 中未按本地时区显示的问题。([#7625](https://github.com/AstrBotDevs/AstrBot/pull/7625)) +- 修复 Cron 工具调度失败时静默继续的问题,并在失败时返回明确错误给模型。([#7513](https://github.com/AstrBotDevs/AstrBot/pull/7513)) +- 修复 Telegram 媒体组异常被静默吞掉的问题。([#7537](https://github.com/AstrBotDevs/AstrBot/pull/7537)) +- 修复会话限流计数为 0 时可能触发 `IndexError` 的问题。([#7635](https://github.com/AstrBotDevs/AstrBot/pull/7635)) + +- 修复知识库上传错误处理与错误提示,提升失败时的可诊断性。([#7534](https://github.com/AstrBotDevs/AstrBot/pull/7534), [#7536](https://github.com/AstrBotDevs/AstrBot/pull/7536)) +- 修复 Dashboard 聊天附件 401 问题。([#7569](https://github.com/AstrBotDevs/AstrBot/pull/7569)) +- 修复 Dashboard 数字输入框在未编辑时失焦会重置为 0 的问题。([#7560](https://github.com/AstrBotDevs/AstrBot/pull/7560)) +- 修复 Dashboard 列表项内代码块暗色模式传递问题,并移除 `ThemeAwareMarkdownCodeBlock` 中注入的默认值。([#7667](https://github.com/AstrBotDevs/AstrBot/pull/7667), [commit](https://github.com/AstrBotDevs/AstrBot/commit/fd2ca702d)) +- 修复 Updater 请求不支持 SOCKS 代理的问题,并补充相关测试。([#7615](https://github.com/AstrBotDevs/AstrBot/pull/7615)) +- 回滚 SCSS import warning 修复以避免相关兼容问题。([#7616](https://github.com/AstrBotDevs/AstrBot/pull/7616)) + + + +## What's Changed (EN) + +### New Features + +- Added SQLite FTS5 support to the knowledge-base sparse retrieval stage, greatly reducing recall latency for knowledge bases with tens of thousands to hundreds of thousands of chunks. ([#7648](https://github.com/AstrBotDevs/AstrBot/pull/7648)) +- Added KOOK role mention support, including role records, event parsing, API types, and tests. ([#7626](https://github.com/AstrBotDevs/AstrBot/pull/7626)) +- Added the MiniMax Token Plan Provider. ([#7609](https://github.com/AstrBotDevs/AstrBot/pull/7609)) +- Added plugin event hooks for `on_agent_begin`, `on_using_llm_tool`, `on_llm_tool_respond`, and `on_agent_done`, with updated plugin development docs. ([#7540](https://github.com/AstrBotDevs/AstrBot/pull/7540)) +- Enabled Shiki highlighting for t2i templates and added a VitePress-style template. ([#7501](https://github.com/AstrBotDevs/AstrBot/pull/7501)) + +### Improvements + +- Improved `EmptyModelOutputError` handling by no longer strictly enforcing whether model tool output names match the tool set, and instead guiding the model toward valid tool calls. ([#7375](https://github.com/AstrBotDevs/AstrBot/pull/7375)) +- Improved relative file path resolution within the local workspace root for `SendMessageToUserTool`. ([#7668](https://github.com/AstrBotDevs/AstrBot/pull/7668)) + +### Bug Fixes + +- Fixed Bocha search failures caused by Brotli-compressed responses reporting `Can not decode content-encoding: br`. ([#7655](https://github.com/AstrBotDevs/AstrBot/pull/7655)) +- Fixed `context_token` persistence for proactive Cron sends in the Weixin OC adapter. ([#7595](https://github.com/AstrBotDevs/AstrBot/pull/7595)) +- Increased the Anthropic Provider default `max_tokens` value to avoid heavy truncation during tool-call output for file operations. ([#7593](https://github.com/AstrBotDevs/AstrBot/pull/7593)) +- Displayed Cron `last_run_at` in the local timezone in Dashboard. ([#7625](https://github.com/AstrBotDevs/AstrBot/pull/7625)) +- Returned explicit errors to the model when Cron tool scheduling fails instead of continuing silently. ([#7513](https://github.com/AstrBotDevs/AstrBot/pull/7513)) +- Prevented Telegram media group exceptions from being silently swallowed. ([#7537](https://github.com/AstrBotDevs/AstrBot/pull/7537)) +- Fixed an `IndexError` when session `rate_limit_count` is 0. ([#7635](https://github.com/AstrBotDevs/AstrBot/pull/7635)) +- Improved knowledge-base upload error handling and error messages for better diagnostics. ([#7534](https://github.com/AstrBotDevs/AstrBot/pull/7534), [#7536](https://github.com/AstrBotDevs/AstrBot/pull/7536)) +- Fixed Dashboard chat attachment 401 errors. ([#7569](https://github.com/AstrBotDevs/AstrBot/pull/7569)) +- Fixed numeric inputs resetting to 0 on blur when the value was not edited. ([#7560](https://github.com/AstrBotDevs/AstrBot/pull/7560)) +- Fixed dark-mode propagation for code blocks inside Dashboard list items and removed the injected default value from `ThemeAwareMarkdownCodeBlock`. ([#7667](https://github.com/AstrBotDevs/AstrBot/pull/7667), [commit](https://github.com/AstrBotDevs/AstrBot/commit/fd2ca702d)) +- Added SOCKS proxy support for updater requests and related tests. ([#7615](https://github.com/AstrBotDevs/AstrBot/pull/7615)) +- Reverted the SCSS import warning fix to avoid related compatibility issues. ([#7616](https://github.com/AstrBotDevs/AstrBot/pull/7616)) diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index ae65710eb..c60569271 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -5,8 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: - immutable: 4.3.8 - lodash-es: 4.17.23 + immutable: "4.3.8" + lodash-es: "4.17.23" importers: diff --git a/pyproject.toml b/pyproject.toml index da69c3311..ce59ac9ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "AstrBot" -version = "4.23.1" +version = "4.23.2" description = "Easy-to-use multi-platform LLM chatbot and development framework" readme = "README.md" license = { text = "AGPL-3.0-or-later" } From ba1e222356b94f09d8d3d24e6c07f46b4f1324ce Mon Sep 17 00:00:00 2001 From: xunxiing <110362831+xunxiing@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:17:41 +0800 Subject: [PATCH 08/24] fix: handle video attachment for llm (#7679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: handle video attachment for llm * fix: harden llm video attachment handling --------- Co-authored-by: 邹永赫 <1259085392@qq.com> --- astrbot/core/astr_main_agent.py | 33 ++++++- tests/unit/test_astr_main_agent.py | 142 ++++++++++++++++++++++++++++- video-fix.patch | Bin 0 -> 5236 bytes 3 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 video-fix.patch diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 0b74d63c6..86bbd9632 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -29,7 +29,7 @@ from astrbot.core.astr_main_agent_resources import ( TOOL_CALL_PROMPT_SKILLS_LIKE_MODE, ) from astrbot.core.conversation_mgr import Conversation -from astrbot.core.message.components import File, Image, Record, Reply +from astrbot.core.message.components import File, Image, Record, Reply, Video from astrbot.core.persona_error_reply import ( extract_persona_custom_error_message_from_persona, set_persona_custom_error_message_on_event, @@ -592,6 +592,33 @@ def _append_quoted_audio_attachment(req: ProviderRequest, audio_path: str) -> No ) +async def _append_video_attachment( + req: ProviderRequest, + video: Video, + *, + quoted: bool = False, +) -> None: + try: + video_path = await video.convert_to_file_path() + except Exception as exc: # noqa: BLE001 + if quoted: + logger.error("Error processing quoted video attachment: %s", exc) + else: + logger.error("Error processing video attachment: %s", exc) + return + + video_name = os.path.basename(video_path) + if quoted: + text = ( + f"[Video Attachment in quoted message: " + f"name {video_name}, path {video_path}]" + ) + else: + text = f"[Video Attachment: name {video_name}, path {video_path}]" + + req.extra_user_content_parts.append(TextPart(text=text)) + + def _get_quoted_message_parser_settings( provider_settings: dict[str, object] | None, ) -> QuotedMessageParserSettings: @@ -1278,6 +1305,8 @@ async def build_main_agent( text=f"[File Attachment: name {file_name}, path {file_path}]" ) ) + elif isinstance(comp, Video): + await _append_video_attachment(req, comp) # quoted message attachments reply_comps = [ comp for comp in event.message_obj.message if isinstance(comp, Reply) @@ -1316,6 +1345,8 @@ async def build_main_agent( ) ) ) + elif isinstance(reply_comp, Video): + await _append_video_attachment(req, reply_comp, quoted=True) # Fallback quoted image extraction for reply-id-only payloads, or when # embedded reply chain only contains placeholders (e.g. [Forward Message], [Image]). diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index f88df3cf6..c953815a8 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -9,7 +9,7 @@ from astrbot.core import astr_main_agent as ama from astrbot.core.agent.mcp_client import MCPTool from astrbot.core.agent.tool import FunctionTool, ToolSet from astrbot.core.conversation_mgr import Conversation -from astrbot.core.message.components import File, Image, Plain, Reply +from astrbot.core.message.components import File, Image, Plain, Reply, Video from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.platform.platform_metadata import PlatformMetadata from astrbot.core.provider import Provider @@ -1067,6 +1067,146 @@ class TestBuildMainAgent: assert result is not None + @pytest.mark.asyncio + async def test_build_main_agent_with_video_attachment( + self, mock_event, mock_context, mock_provider + ): + """Test building main agent with video attachments.""" + module = ama + mock_video = Video(file="file:///path/to/video.mp4") + mock_event.message_obj.message = [mock_video] + + mock_context.get_provider_by_id.return_value = None + mock_context.get_using_provider.return_value = mock_provider + mock_context.get_config.return_value = {} + + conv_mgr = mock_context.conversation_manager + _setup_conversation_for_build(conv_mgr) + + with ( + patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls, + patch("astrbot.core.astr_main_agent.AstrAgentContext"), + ): + mock_runner = MagicMock() + mock_runner.reset = AsyncMock() + mock_runner_cls.return_value = mock_runner + + result = await module.build_main_agent( + event=mock_event, + plugin_context=mock_context, + config=module.MainAgentBuildConfig(tool_call_timeout=60), + ) + + assert result is not None + assert [ + part.text for part in result.provider_request.extra_user_content_parts + ] == ["[Video Attachment: name video.mp4, path path/to/video.mp4]"] + + @pytest.mark.asyncio + async def test_build_main_agent_with_quoted_video_attachment( + self, mock_event, mock_context, mock_provider + ): + """Test building main agent with quoted video attachments.""" + module = ama + mock_video = Video(file="file:///path/to/quoted-video.mp4") + mock_reply = Reply( + id="reply-1", + chain=[mock_video], + sender_nickname="", + message_str="quoted message", + ) + mock_event.message_obj.message = [Plain(text="Hello"), mock_reply] + + mock_context.get_provider_by_id.return_value = None + mock_context.get_using_provider.return_value = mock_provider + mock_context.get_config.return_value = {} + + conv_mgr = mock_context.conversation_manager + _setup_conversation_for_build(conv_mgr) + + with ( + patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls, + patch("astrbot.core.astr_main_agent.AstrAgentContext"), + ): + mock_runner = MagicMock() + mock_runner.reset = AsyncMock() + mock_runner_cls.return_value = mock_runner + + result = await module.build_main_agent( + event=mock_event, + plugin_context=mock_context, + config=module.MainAgentBuildConfig(tool_call_timeout=60), + ) + + assert result is not None + assert ( + "[Video Attachment in quoted message: " + "name quoted-video.mp4, path path/to/quoted-video.mp4]" + ) in [part.text for part in result.provider_request.extra_user_content_parts] + + @pytest.mark.asyncio + async def test_build_main_agent_skips_video_attachment_when_conversion_fails( + self, mock_event, mock_context, mock_provider + ): + """Test video attachment failures do not abort request construction.""" + module = ama + mock_video = Video(file="file:///path/to/direct.mp4") + mock_quoted_video = Video(file="file:///path/to/quoted.mp4") + mock_reply = Reply( + id="reply-1", + chain=[mock_quoted_video], + sender_nickname="", + message_str="quoted message", + ) + mock_event.message_obj.message = [mock_video, mock_reply] + + mock_context.get_provider_by_id.return_value = None + mock_context.get_using_provider.return_value = mock_provider + mock_context.get_config.return_value = {} + + conv_mgr = mock_context.conversation_manager + _setup_conversation_for_build(conv_mgr) + + async def _raise_video_conversion_error(self): + if self.file.endswith("direct.mp4"): + raise RuntimeError("direct") + raise RuntimeError("quoted") + + with ( + patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls, + patch("astrbot.core.astr_main_agent.AstrAgentContext"), + patch("astrbot.core.astr_main_agent.logger") as mock_logger, + patch.object( + Video, + "convert_to_file_path", + AsyncMock(side_effect=_raise_video_conversion_error), + ), + ): + mock_runner = MagicMock() + mock_runner.reset = AsyncMock() + mock_runner_cls.return_value = mock_runner + + result = await module.build_main_agent( + event=mock_event, + plugin_context=mock_context, + config=module.MainAgentBuildConfig(tool_call_timeout=60), + ) + + assert result is not None + assert not any( + "Video Attachment" in part.text + for part in result.provider_request.extra_user_content_parts + ) + assert mock_logger.error.call_count == 2 + assert ( + "Error processing video attachment" + in mock_logger.error.call_args_list[0][0][0] + ) + assert ( + "Error processing quoted video attachment" + in mock_logger.error.call_args_list[1][0][0] + ) + @pytest.mark.asyncio async def test_build_main_agent_no_prompt_no_images( self, mock_event, mock_context, mock_provider diff --git a/video-fix.patch b/video-fix.patch new file mode 100644 index 0000000000000000000000000000000000000000..77f879683edd8351a4bf078a3b55d5a8c5786bf8 GIT binary patch literal 5236 zcmd6rTTdHD6vxkVrG5u1eF1?TU%(hzmypC(qXY#7niqstz5*>V#C94`o3GyX_n+fo zZDX*3RH;Vx%+8!S_uKA2f7h*V=k~!e*0nV&THeZxwzX{CnpUw6qs3X%Hf+-xz%&?L zD*#hurwLqxb(#MT@MU%@z!t!`3+5j4J|`E<1@<#g$lKP~eL2u7#cTu-1OXUWT1A|C?YE%ud)DhY@5A9SSDTzqI`a`VfBcHk)=E zUeCi#$0bUe0ZWbhk(=j8n_?I3O5nH#tl&j^GpuWz=$1=Tzox!xudsLxsoz7fZv!}! z{iXK;HpvGbUxYo`qlf;o=;;a@xOKF9$?j8j&aj#+{Uf(qSi2E0b~vq$X;)I;#MlSA z2mNl~BMVQx;1TVS?}(!cRy+lpFzA;J!?oDp?htxScojGDrariyg4a(HyvzAZn2$M= zG&%Ub0{34*;x+zPZXMZ?#pGdKI5`7HlvMuRA*S3A)QM;NpJ~QdGx-4zbU7b(0z^}ODgNM z73J@b z_vO&QUEY@jA02?@6RQ?C22ef4Q!j%5%Hm5SugkF3IIy>jHMxlyLr*oK-^}j~Vwh`SN@@6i+=emuCrOXZLAE)iL*{x%LRZ2`|do zg(!Xq+8B&tHP#2|rN@b4_olAuaHXhJ6ut`auBg=0J7&)b9;13z#<~i+SovRZB!1r` ziB|!))gm@hPF4<=h8oygPqJ*EwY<^gown4pe|bJvK326%xHXBjg|Et|<@u8|CM}j+ z>vS4N^uOrQ%94+xB#Fg~{7s(W6k-gk)7YSTtde__-_z$p=oTDKk$T1L7BJGhGH(-E z+i+h1wH4q&~;0vV#=EV1pAVyHu)yvaFsn_9u57I=SBc5`6sv zm?8Cd5+?3`q2qZnl~WewH)f3c!LPu{-9vRy)lR;t9z>N>(#skhGN2+?IdL*Fmg!Rz z9rN)=>gv2as^pVVOI7GL>@z>Fv5}hRoS60Gq4Ik9b<}&ru{5gNi*urQRM(qETN2_* zv6cjR<;QM!=T&{gAXJo#5R60Nr?3CtKd+cQD7})BNb#m$#|D$Ttq>+>Kg| zexr)2tgGJ8{Z@Xhx2iNl<)*k!fYdw6^uBH`78hwna}B21uSAwBk7d77cvo6wSAFWY z%YIRBah_-S2sdwjEFQnVxW`6*=4G*2KK9s*7HP+MRvfw)k7u}9ocx#BE^-%V(f@YY zFLDv*o7wPwRVCuNZC0MTPi~Fb;;t;GUSP5O`*mb_XiP+%#F+GMHtyawE63N#<7bQ} zbIX2bXHp+}ONtpkt)l8#{mmrJ!l}1sIcxL&p?*=lzw7Xd^*OlOWPHV==kCvRP^!O+ zbKHR=eer5DyfUSf<97nD11Wt~2vvIjGNFp&-%-@}oN%Vz)BA6|3F?jVn4fA=J@Bcn zr4Nk0-0AHxR>7kn$>G>r&=EaKE=Z&ACYi7*va?PV(3hSZ9gx4%pR-b@Q3^1U|6eu3 B=2ZXy literal 0 HcmV?d00001 From 43989471e15fc6a061884bfa48910b3bb3c6adcd Mon Sep 17 00:00:00 2001 From: Stable Genius Date: Sun, 19 Apr 2026 21:30:04 -0700 Subject: [PATCH 09/24] fix: normalize invalid MCP required flags in MCP schemas (#6077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: normalize invalid MCP required flags * style: format mcp schema normalization tests * style: sort mcp client imports * fix: preserve nested mcp required flags * test: cover malformed mcp required fields --------- Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com> Co-authored-by: エイカク <1259085392z@gmail.com> Co-authored-by: 邹永赫 <1259085392@qq.com> --- astrbot/core/agent/mcp_client.py | 60 +++++++++++++- tests/unit/test_mcp_client_schema.py | 117 +++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_mcp_client_schema.py diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index c149792dc..b75999ea6 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -1,4 +1,5 @@ import asyncio +import copy import logging import os import re @@ -6,7 +7,7 @@ import sys from contextlib import AsyncExitStack from datetime import timedelta from pathlib import Path, PureWindowsPath -from typing import Generic +from typing import Any, Generic from tenacity import ( before_sleep_log, @@ -325,6 +326,61 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: return False, f"{e!s}" +def _normalize_mcp_input_schema(schema: dict[str, Any]) -> dict[str, Any]: + """Normalize common non-standard MCP JSON Schema variants. + + Some MCP servers incorrectly mark required properties with a boolean + `required: true` on the property schema itself. Draft 2020-12 requires the + parent object to declare `required` as an array of property names instead. + We lift those booleans to the parent object so the schema remains usable + without disabling validation entirely. + """ + + def _normalize(node: Any) -> Any: + if isinstance(node, list): + return [_normalize(item) for item in node] + + if not isinstance(node, dict): + return node + + normalized = {key: _normalize(value) for key, value in node.items()} + + properties = normalized.get("properties") + if isinstance(properties, dict): + original_properties = ( + node.get("properties") + if isinstance(node.get("properties"), dict) + else {} + ) + required = normalized.get("required") + required_list = required[:] if isinstance(required, list) else [] + + for prop_name, prop_schema in properties.items(): + if not isinstance(prop_schema, dict): + continue + + original_prop_schema = original_properties.get(prop_name, {}) + prop_required = ( + original_prop_schema.get("required") + if isinstance(original_prop_schema, dict) + else None + ) + if isinstance(prop_required, bool): + if prop_schema.get("required") is prop_required: + prop_schema.pop("required", None) + if prop_required: + required_list.append(prop_name) + + if required_list: + normalized["required"] = list(dict.fromkeys(required_list)) + elif isinstance(required, list): + normalized.pop("required", None) + + return normalized + + return _normalize(copy.deepcopy(schema)) + + class MCPClient: def __init__(self) -> None: # Initialize session and client objects @@ -602,7 +658,7 @@ class MCPTool(FunctionTool, Generic[TContext]): super().__init__( name=mcp_tool.name, description=mcp_tool.description or "", - parameters=mcp_tool.inputSchema, + parameters=_normalize_mcp_input_schema(mcp_tool.inputSchema), ) self.mcp_tool = mcp_tool self.mcp_client = mcp_client diff --git a/tests/unit/test_mcp_client_schema.py b/tests/unit/test_mcp_client_schema.py new file mode 100644 index 000000000..0c3d9bc6a --- /dev/null +++ b/tests/unit/test_mcp_client_schema.py @@ -0,0 +1,117 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from astrbot.core.agent.mcp_client import MCPTool, _normalize_mcp_input_schema + + +class TestNormalizeMcpInputSchema: + def test_lifts_property_level_required_booleans_to_parent_required_array(self): + schema = { + "type": "object", + "properties": { + "stock_code": {"type": "string", "required": True}, + "market": {"type": "string", "required": False}, + }, + } + + normalized = _normalize_mcp_input_schema(schema) + + assert normalized["required"] == ["stock_code"] + assert "required" not in normalized["properties"]["stock_code"] + assert "required" not in normalized["properties"]["market"] + assert schema["properties"]["stock_code"]["required"] is True + + def test_preserves_existing_required_arrays_while_fixing_nested_objects(self): + schema = { + "type": "object", + "required": ["server"], + "properties": { + "server": { + "type": "object", + "required": ["transport"], + "properties": { + "transport": {"type": "string"}, + "stock_code": {"type": "string", "required": True}, + "market": {"type": "string", "required": False}, + }, + } + }, + } + + normalized = _normalize_mcp_input_schema(schema) + + assert normalized["required"] == ["server"] + assert normalized["properties"]["server"]["required"] == [ + "transport", + "stock_code", + ] + assert ( + "required" + not in normalized["properties"]["server"]["properties"]["stock_code"] + ) + assert ( + "required" not in normalized["properties"]["server"]["properties"]["market"] + ) + + def test_preserves_parent_required_flag_for_nested_object_properties(self): + schema = { + "type": "object", + "properties": { + "server": { + "type": "object", + "required": True, + "properties": { + "transport": {"type": "string", "required": True}, + }, + } + }, + } + + normalized = _normalize_mcp_input_schema(schema) + + assert normalized["required"] == ["server"] + assert normalized["properties"]["server"]["required"] == ["transport"] + assert ( + "required" + not in normalized["properties"]["server"]["properties"]["transport"] + ) + + def test_ignores_non_boolean_required_values_and_non_dict_properties(self): + schema = { + "type": "object", + "properties": { + "server": "invalid-property-schema", + "market": {"type": "string", "required": "yes"}, + "stock_code": {"type": "string", "required": True}, + }, + } + + normalized = _normalize_mcp_input_schema(schema) + + assert normalized["required"] == ["stock_code"] + assert normalized["properties"]["server"] == "invalid-property-schema" + assert normalized["properties"]["market"]["required"] == "yes" + assert "required" not in normalized["properties"]["stock_code"] + assert schema["properties"]["server"] == "invalid-property-schema" + assert schema["properties"]["market"]["required"] == "yes" + + +class TestMCPToolSchemaNormalization: + def test_mcp_tool_accepts_property_level_required_booleans(self): + mcp_tool = SimpleNamespace( + name="quote_lookup", + description="Lookup a quote", + inputSchema={ + "type": "object", + "properties": { + "stock_code": {"type": "string", "required": True}, + "market": {"type": "string", "required": False}, + }, + }, + ) + + tool = MCPTool(mcp_tool, MagicMock(), "gf-securities") + + assert tool.parameters["required"] == ["stock_code"] + assert "required" not in tool.parameters["properties"]["stock_code"] + assert "required" not in tool.parameters["properties"]["market"] From 76ee4f27dd5879b11e103c5903423bbc9b7f55df Mon Sep 17 00:00:00 2001 From: Aster <54532857+Aster-amellus@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:24:07 +0800 Subject: [PATCH 10/24] feat: add epub support for knowledge base document upload (#7594) * feat: add EPUB parsing support for knowledge base and file reader * feat: update supported file formats for document upload in knowledge base * feat: enhance EPUB parser to support spine order and generic containers * makeitdown parse epub * update parser * fix --- astrbot/core/computer/file_read_utils.py | 41 +++- astrbot/core/knowledge_base/kb_helper.py | 9 +- .../core/knowledge_base/parsers/__init__.py | 2 + .../knowledge_base/parsers/epub_parser.py | 162 ++++++++++++++ astrbot/core/knowledge_base/parsers/util.py | 4 + .../en-US/features/alkaid/knowledge-base.json | 4 +- .../en-US/features/knowledge-base/detail.json | 2 +- .../ru-RU/features/knowledge-base/detail.json | 4 +- .../zh-CN/features/alkaid/knowledge-base.json | 4 +- .../zh-CN/features/knowledge-base/detail.json | 2 +- dashboard/src/views/alkaid/KnowledgeBase.vue | 9 +- .../views/knowledge-base/DocumentDetail.vue | 2 + .../components/DocumentsTab.vue | 13 +- requirements.txt | 2 +- tests/test_computer_fs_tools.py | 119 ++++++++++ tests/test_epub_parser.py | 211 ++++++++++++++++++ 16 files changed, 566 insertions(+), 24 deletions(-) create mode 100644 astrbot/core/knowledge_base/parsers/epub_parser.py create mode 100644 tests/test_epub_parser.py diff --git a/astrbot/core/computer/file_read_utils.py b/astrbot/core/computer/file_read_utils.py index 0f4d0811c..f22c1891f 100644 --- a/astrbot/core/computer/file_read_utils.py +++ b/astrbot/core/computer/file_read_utils.py @@ -73,7 +73,7 @@ class FileProbe: @dataclass(frozen=True) class ParsedDocument: - kind: Literal["docx", "pdf"] + kind: Literal["docx", "epub", "pdf"] file_bytes: bytes text: str @@ -371,6 +371,18 @@ def _is_docx_bytes(file_bytes: bytes) -> bool: return any(name.startswith("word/") for name in names) +def _is_epub_bytes(file_bytes: bytes) -> bool: + try: + with zipfile.ZipFile(io.BytesIO(file_bytes)) as archive: + names = set(archive.namelist()) + with archive.open("mimetype") as mimetype_file: + mimetype = mimetype_file.read(64).decode("utf-8").strip() + except (KeyError, OSError, UnicodeDecodeError, zipfile.BadZipFile): + return False + + return mimetype == "application/epub+zip" and "META-INF/container.xml" in names + + async def _parse_local_docx_text(file_bytes: bytes, file_name: str) -> str: from astrbot.core.knowledge_base.parsers.markitdown_parser import ( MarkitdownParser, @@ -387,23 +399,48 @@ async def _parse_local_pdf_text(file_bytes: bytes, file_name: str) -> str: return result.text +async def _parse_local_epub_text(file_bytes: bytes, file_name: str) -> str: + from astrbot.core.knowledge_base.parsers.epub_parser import EpubParser + + result = await EpubParser().parse(file_bytes, file_name) + return result.text + + async def _parse_local_supported_document( path: str, sample: bytes, ) -> ParsedDocument | None: file_name = Path(path).name + suffix = Path(path).suffix.lower() if _looks_like_pdf(path, sample): file_bytes = await _read_local_file_bytes(path) text = await _parse_local_pdf_text(file_bytes, file_name) return ParsedDocument(kind="pdf", file_bytes=file_bytes, text=text) - if Path(path).suffix.lower() == ".docx" or _looks_like_zip_container(sample): + if suffix == ".epub": + file_bytes = await _read_local_file_bytes(path) + if not _is_epub_bytes(file_bytes): + return None + text = await _parse_local_epub_text(file_bytes, file_name) + return ParsedDocument(kind="epub", file_bytes=file_bytes, text=text) + + if suffix == ".docx": file_bytes = await _read_local_file_bytes(path) if not _is_docx_bytes(file_bytes): return None text = await _parse_local_docx_text(file_bytes, file_name) return ParsedDocument(kind="docx", file_bytes=file_bytes, text=text) + if _looks_like_zip_container(sample): + file_bytes = await _read_local_file_bytes(path) + if _is_epub_bytes(file_bytes): + text = await _parse_local_epub_text(file_bytes, file_name) + return ParsedDocument(kind="epub", file_bytes=file_bytes, text=text) + if _is_docx_bytes(file_bytes): + text = await _parse_local_docx_text(file_bytes, file_name) + return ParsedDocument(kind="docx", file_bytes=file_bytes, text=text) + return None + return None diff --git a/astrbot/core/knowledge_base/kb_helper.py b/astrbot/core/knowledge_base/kb_helper.py index fab080ea0..1f867ec27 100644 --- a/astrbot/core/knowledge_base/kb_helper.py +++ b/astrbot/core/knowledge_base/kb_helper.py @@ -109,6 +109,10 @@ Text chunk to process: return [chunk] +def _compact_chunks(chunks: list[str]) -> list[str]: + return [chunk.strip() for chunk in chunks if chunk and chunk.strip()] + + class KBHelper: vec_db: BaseVecDB kb: KnowledgeBase @@ -249,7 +253,7 @@ class KBHelper: if pre_chunked_text is not None: # 如果提供了预分块文本,直接使用 - chunks_text = pre_chunked_text + chunks_text = _compact_chunks(pre_chunked_text) file_size = sum(len(chunk) for chunk in chunks_text) logger.info(f"使用预分块文本进行上传,共 {len(chunks_text)} 个块。") else: @@ -316,6 +320,7 @@ class KBHelper: chunk_size=chunk_size, chunk_overlap=chunk_overlap, ) + chunks_text = _compact_chunks(chunks_text) except KnowledgeBaseUploadError: raise except Exception as exc: @@ -728,6 +733,8 @@ class KBHelper: elif isinstance(result, list): final_chunks.extend(result) + final_chunks = _compact_chunks(final_chunks) + logger.info( f"文本修复完成: {len(initial_chunks)} 个原始块 -> {len(final_chunks)} 个最终块。" ) diff --git a/astrbot/core/knowledge_base/parsers/__init__.py b/astrbot/core/knowledge_base/parsers/__init__.py index 184f2fd41..f3b5967cf 100644 --- a/astrbot/core/knowledge_base/parsers/__init__.py +++ b/astrbot/core/knowledge_base/parsers/__init__.py @@ -1,11 +1,13 @@ """文档解析器模块""" from .base import BaseParser, MediaItem, ParseResult +from .epub_parser import EpubParser from .pdf_parser import PDFParser from .text_parser import TextParser __all__ = [ "BaseParser", + "EpubParser", "MediaItem", "PDFParser", "ParseResult", diff --git a/astrbot/core/knowledge_base/parsers/epub_parser.py b/astrbot/core/knowledge_base/parsers/epub_parser.py new file mode 100644 index 000000000..159f6c736 --- /dev/null +++ b/astrbot/core/knowledge_base/parsers/epub_parser.py @@ -0,0 +1,162 @@ +"""EPUB document parser.""" + +import html +import re + +from astrbot.core.knowledge_base.parsers.base import BaseParser, ParseResult + +_KEYS = ( + "Title|Author|Creator|Language|Publisher|Date|Modified|Identifier|ISBN|Description|" + "Subject|Rights|Source|Series|标题|书名|作者|语言|出版社|日期|出版日期|标识符|简介|描述|" + "主题|版权|来源|系列|タイトル|書名|著者|言語|出版社|日付|識別子|説明|件名|権利|ソース|シリーズ" +) +_META_RE = re.compile(rf"^\s*(?:[-*]\s*)?\*\*(?:{_KEYS})\s*[::]\*\*\s+\S") +_TOC_HEAD_RE = re.compile( + r"^\s{0,3}(?:#{1,6}\s*)?(?:table of contents|contents|toc|目录|目次|もくじ)\s*$", + re.I, +) +_LINK_RE = re.compile(r"(? str: + return ( + html.unescape(s) + .replace("\r\n", "\n") + .replace("\r", "\n") + .replace("\ufeff", "") + .replace("\u00a0", " ") + .replace("\u200b", "") + ) + + +def _is_internal(href: str) -> bool: + href = html.unescape(href).strip().lower() + return ( + href.startswith("#") + or href.endswith(".html") + or href.endswith(".xhtml") + or ".html#" in href + or ".xhtml#" in href + ) + + +def _is_toc_line(s: str) -> bool: + s = s.strip() + if not s: + return False + s = re.sub(r"^\s*(?:[-*+]|\d+\.)\s+", "", s) + m = re.fullmatch(r"\[([^\]]+)\]\(([^)]+)\)", s) + return bool((m and _is_internal(m.group(2))) or _DOTTED_TOC_RE.match(s)) + + +def _strip_head(text: str) -> str: + lines = _n(text).split("\n") + i = 0 + while i < len(lines) and not lines[i].strip(): + i += 1 + start = i + while i < len(lines) and _META_RE.match(lines[i].strip()): + i += 1 + if i - start >= 2: + while i < len(lines) and not lines[i].strip(): + i += 1 + else: + i = start + toc0, had_head = i, False + if i < len(lines) and _TOC_HEAD_RE.match(lines[i].strip()): + had_head = True + i += 1 + while i < len(lines) and not lines[i].strip(): + i += 1 + toc = 0 + while i < len(lines) and i - toc0 < 120: + s = lines[i].strip() + if not s: + if toc and i + 1 < len(lines) and _is_toc_line(lines[i + 1]): + i += 1 + continue + break + if not _is_toc_line(s): + break + toc += 1 + i += 1 + if toc >= 2 and (had_head or toc >= 3): + while i < len(lines) and not lines[i].strip(): + i += 1 + return "\n".join(lines[i:]).strip() + return "\n".join(lines[toc0:]).strip() + + +def _strip_links(text: str) -> str: + def repl(m: re.Match[str]) -> str: + label = html.unescape(m.group(1)).strip() + href = html.unescape(m.group(2)).strip().lower() + if not _is_internal(href): + return m.group(0) + if _FOOTNOTE_HREF_RE.search(href) or ( + href.startswith("#") and _FOOTNOTE_LABEL_RE.fullmatch(label) + ): + return "" + return label + + return _LINK_RE.sub(repl, _n(text)) + + +def _img_alt(m: re.Match[str]) -> str: + alt = re.sub(r"\s+", " ", html.unescape(m.group(1)).strip()) + if not alt or _GENERIC_ALT_RE.fullmatch(alt) or _FILENAME_ALT_RE.fullmatch(alt): + return "" + return alt + + +def _sanitize(text: str) -> str: + out, prev_blank, prev = [], True, "" + for raw in _n(text).split("\n"): + line = _IMG_RE.sub(_img_alt, raw) + line = _EMPTY_IMG_LINK_RE.sub("", line).rstrip() + s = line.strip() + if not s: + if not prev_blank: + out.append("") + prev_blank = True + continue + if _SEP_RE.match(s) or _NOISE_RE.match(s): + continue + norm = re.sub(r"^\s{0,3}#{1,6}\s*", "", s).strip("*_ ").casefold() + if norm and norm == prev and len(norm) <= 120: + continue + out.append(line) + prev_blank = False + prev = norm + return "\n".join(out).strip() + + +class EpubParser(BaseParser): + """Parse EPUB files via MarkItDown.""" + + async def parse(self, file_content: bytes, file_name: str) -> ParseResult: + from .markitdown_parser import MarkitdownParser + + result = await MarkitdownParser().parse(file_content, file_name) + text = _sanitize(_strip_links(_strip_head(result.text))) + return ParseResult(text=text, media=result.media) diff --git a/astrbot/core/knowledge_base/parsers/util.py b/astrbot/core/knowledge_base/parsers/util.py index 7a4463202..52cffa5cd 100644 --- a/astrbot/core/knowledge_base/parsers/util.py +++ b/astrbot/core/knowledge_base/parsers/util.py @@ -6,6 +6,10 @@ async def select_parser(ext: str) -> BaseParser: from .markitdown_parser import MarkitdownParser return MarkitdownParser() + if ext == ".epub": + from .epub_parser import EpubParser + + return EpubParser() if ext == ".pdf": from .pdf_parser import PDFParser diff --git a/dashboard/src/i18n/locales/en-US/features/alkaid/knowledge-base.json b/dashboard/src/i18n/locales/en-US/features/alkaid/knowledge-base.json index f4d8a33fd..98d5aea37 100644 --- a/dashboard/src/i18n/locales/en-US/features/alkaid/knowledge-base.json +++ b/dashboard/src/i18n/locales/en-US/features/alkaid/knowledge-base.json @@ -70,7 +70,7 @@ }, "upload": { "title": "Upload Files to Knowledge Base", - "subtitle": "Supports txt, pdf, word, excel and other formats", + "subtitle": "Supports txt, pdf, epub, word, excel and other formats", "dropzone": "Drag and drop files here or click to upload", "chunkSettings": { "title": "Chunk Settings", @@ -152,4 +152,4 @@ "preRequisite": "Hint: Please go to the plugin market to install astrbot_plugin_url_2_knowledge_base and follow the instructions in the plugin documentation to complete the playwright installation before using this feature.", "allChunksUploaded": "All chunks uploaded successfully" } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/en-US/features/knowledge-base/detail.json b/dashboard/src/i18n/locales/en-US/features/knowledge-base/detail.json index 90d3e6158..0ef8fa3d4 100644 --- a/dashboard/src/i18n/locales/en-US/features/knowledge-base/detail.json +++ b/dashboard/src/i18n/locales/en-US/features/knowledge-base/detail.json @@ -46,7 +46,7 @@ "title": "Upload Document", "selectFile": "Select File", "dropzone": "Drop files here or click to select", - "supportedFormats": "Supported formats: ", + "supportedFormats": "Supported formats: .txt, .md, .pdf, .docx, .epub, .xls, .xlsx", "maxSize": "Max file size: 128MB", "chunkSettings": "Chunk Settings", "batchSettings": "Batch Settings", diff --git a/dashboard/src/i18n/locales/ru-RU/features/knowledge-base/detail.json b/dashboard/src/i18n/locales/ru-RU/features/knowledge-base/detail.json index 10d2109bd..0e645389c 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/knowledge-base/detail.json +++ b/dashboard/src/i18n/locales/ru-RU/features/knowledge-base/detail.json @@ -46,7 +46,7 @@ "title": "Добавление контента", "selectFile": "Файл", "dropzone": "Нажмите или перетащите файл сюда", - "supportedFormats": "Форматы: ", + "supportedFormats": "Форматы: .txt, .md, .pdf, .docx, .epub, .xls, .xlsx", "maxSize": "Максимум: 128MB", "chunkSettings": "Фрагментация", "batchSettings": "Пакетная обработка", @@ -115,4 +115,4 @@ "saveFailed": "Ошибка сохранения", "tips": "Внимание! Изменение этих параметров повлияет на будущую выдачу базы знаний." } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/zh-CN/features/alkaid/knowledge-base.json b/dashboard/src/i18n/locales/zh-CN/features/alkaid/knowledge-base.json index 2467b1efc..d4c7ea839 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/alkaid/knowledge-base.json +++ b/dashboard/src/i18n/locales/zh-CN/features/alkaid/knowledge-base.json @@ -70,7 +70,7 @@ }, "upload": { "title": "上传文件到知识库", - "subtitle": "支持 txt、pdf、word、excel 等多种格式", + "subtitle": "支持 txt、pdf、epub、word、excel 等多种格式", "dropzone": "拖放文件到这里或点击上传", "chunkSettings": { "title": "分片设置", @@ -152,4 +152,4 @@ "preRequisite": "提示:请先前往插件市场安装 astrbot_plugin_url_2_knowledge_base 并根据插件文档内的指示完成 playwright 安装后才可使用本功能", "allChunksUploaded": "所有分片上传成功" } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/zh-CN/features/knowledge-base/detail.json b/dashboard/src/i18n/locales/zh-CN/features/knowledge-base/detail.json index deb61f933..7473c6c1e 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/knowledge-base/detail.json +++ b/dashboard/src/i18n/locales/zh-CN/features/knowledge-base/detail.json @@ -46,7 +46,7 @@ "title": "上传文档", "selectFile": "选择文件", "dropzone": "拖放文件到这里或点击选择", - "supportedFormats": "支持的格式: ", + "supportedFormats": "支持的格式: .txt, .md, .pdf, .docx, .epub, .xls, .xlsx", "maxSize": "最大文件大小: 128MB", "chunkSettings": "分块设置", "batchSettings": "批处理设置", diff --git a/dashboard/src/views/alkaid/KnowledgeBase.vue b/dashboard/src/views/alkaid/KnowledgeBase.vue index 185148257..00259062b 100644 --- a/dashboard/src/views/alkaid/KnowledgeBase.vue +++ b/dashboard/src/views/alkaid/KnowledgeBase.vue @@ -830,6 +830,7 @@ export default { if (files.length > 0) { this.selectedFile = files[0]; } + event.target.value = ''; }, onFileDrop(event) { @@ -845,6 +846,8 @@ export default { switch (extension) { case 'pdf': return 'mdi-file-pdf-box'; + case 'epub': + return 'mdi-book-open-page-variant'; case 'doc': case 'docx': return 'mdi-file-word-box'; @@ -882,11 +885,7 @@ export default { formData.append('chunk_overlap', this.overlap); } - axios.post('/api/plug/alkaid/kb/collection/add_file', formData, { - headers: { - 'Content-Type': 'multipart/form-data' - } - }) + axios.post('/api/plug/alkaid/kb/collection/add_file', formData) .then(response => { if (response.data.status === 'ok') { this.showSnackbar(this.tm('messages.operationSuccess', { message: response.data.message })); diff --git a/dashboard/src/views/knowledge-base/DocumentDetail.vue b/dashboard/src/views/knowledge-base/DocumentDetail.vue index 16d9ca67e..e147d7f8c 100644 --- a/dashboard/src/views/knowledge-base/DocumentDetail.vue +++ b/dashboard/src/views/knowledge-base/DocumentDetail.vue @@ -382,6 +382,7 @@ const deleteChunk = async (chunk: any) => { const getFileIcon = (fileType: string) => { const type = fileType?.toLowerCase() || '' if (type.includes('pdf')) return 'mdi-file-pdf-box' + if (type.includes('epub')) return 'mdi-book-open-page-variant' if (type.includes('md')) return 'mdi-language-markdown' if (type.includes('txt')) return 'mdi-file-document-outline' return 'mdi-file' @@ -390,6 +391,7 @@ const getFileIcon = (fileType: string) => { const getFileColor = (fileType: string) => { const type = fileType?.toLowerCase() || '' if (type.includes('pdf')) return 'error' + if (type.includes('epub')) return 'warning' if (type.includes('md')) return 'info' if (type.includes('txt')) return 'success' return 'grey' diff --git a/dashboard/src/views/knowledge-base/components/DocumentsTab.vue b/dashboard/src/views/knowledge-base/components/DocumentsTab.vue index bf110f282..7be45efab 100644 --- a/dashboard/src/views/knowledge-base/components/DocumentsTab.vue +++ b/dashboard/src/views/knowledge-base/components/DocumentsTab.vue @@ -84,12 +84,10 @@ @dragover.prevent="isDragging = true" @dragleave="isDragging = false" @click="fileInput?.click()"> mdi-cloud-upload

{{ t('upload.dropzone') }}

-

{{ t('upload.supportedFormats') }}.txt, .md, .pdf, - .docx, - .xls, .xlsx

+

{{ t('upload.supportedFormats') }}

{{ t('upload.maxSize') }}

最多可上传 10 个文件

- @@ -369,6 +367,7 @@ const handleFileSelect = (event: Event) => { const newFiles = Array.from(target.files) addFiles(newFiles) } + target.value = '' } // 添加文件(检查数量限制) @@ -432,9 +431,7 @@ const uploadFiles = async () => { formData.append('tasks_limit', uploadSettings.value.tasks_limit.toString()) formData.append('max_retries', uploadSettings.value.max_retries.toString()) - const response = await axios.post('/api/kb/document/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }) + const response = await axios.post('/api/kb/document/upload', formData) if (response.data.status === 'ok') { const result = response.data.data @@ -717,6 +714,7 @@ const deleteDocument = async () => { const getFileIcon = (fileType: string) => { const type = fileType?.toLowerCase() || '' if (type.includes('pdf')) return 'mdi-file-pdf-box' + if (type.includes('epub')) return 'mdi-book-open-page-variant' if (type.includes('md') || type.includes('markdown')) return 'mdi-language-markdown' if (type.includes('txt')) return 'mdi-file-document-outline' if (type.includes('url')) return 'mdi-link-variant' @@ -726,6 +724,7 @@ const getFileIcon = (fileType: string) => { const getFileColor = (fileType: string) => { const type = fileType?.toLowerCase() || '' if (type.includes('pdf')) return 'error' + if (type.includes('epub')) return 'warning' if (type.includes('md')) return 'info' if (type.includes('txt')) return 'success' if (type.includes('url')) return 'primary' diff --git a/requirements.txt b/requirements.txt index a6eb84bb0..e667c6755 100644 --- a/requirements.txt +++ b/requirements.txt @@ -53,4 +53,4 @@ shipyard-python-sdk>=0.2.4 shipyard-neo-sdk>=0.2.0 packaging>=24.2 qrcode>=8.2 -python-ripgrep==0.0.8 \ No newline at end of file +python-ripgrep==0.0.8 diff --git a/tests/test_computer_fs_tools.py b/tests/test_computer_fs_tools.py index 7fe33fe21..a9f6fa16d 100644 --- a/tests/test_computer_fs_tools.py +++ b/tests/test_computer_fs_tools.py @@ -92,6 +92,95 @@ def _make_large_text() -> str: return "".join(f"line-{index:05d}-{'x' * 48}\n" for index in range(6000)) +def _make_epub_bytes(*, chapter_count: int = 1) -> bytes: + manifest_items = [ + '' + ] + spine_items = [''] + nav_links = [] + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, mode="w") as archive: + archive.writestr( + "mimetype", + "application/epub+zip", + compress_type=zipfile.ZIP_STORED, + ) + archive.writestr( + "META-INF/container.xml", + """ + + + + + +""", + ) + + for index in range(1, chapter_count + 1): + manifest_items.append( + f'' + ) + spine_items.append(f'') + nav_links.append(f'
  • Chapter {index}
  • ') + archive.writestr( + f"OEBPS/chapter{index}.xhtml", + f""" + + + Chapter {index} + + +

    Chapter {index}

    +

    Paragraph {index}

    + + +""", + ) + + archive.writestr( + "OEBPS/nav.xhtml", + """ + + + Navigation + + + + + +""".format(links="".join(nav_links)), + ) + archive.writestr( + "OEBPS/content.opf", + """ + + + test-book + Test Book + en + + + {manifest} + + + {spine} + + +""".format( + manifest="".join(manifest_items), + spine="".join(spine_items), + ), + ) + + return buffer.getvalue() + + def test_detect_text_encoding_allows_utf8_probe_cut_mid_character(): sample = '{"results": ["中文内容"]}'.encode()[:-1] @@ -230,6 +319,36 @@ async def test_file_read_tool_reads_docx_via_parser_and_magic( assert result == "doc-line-1\ndoc-line-2\n" +def test_is_epub_bytes_rejects_plain_zip_archive(): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, mode="w") as archive: + archive.writestr("README.txt", "hello") + + assert file_read_utils._is_epub_bytes(buffer.getvalue()) is False + + +@pytest.mark.asyncio +async def test_file_read_tool_reads_epub_via_parser_and_magic( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +): + workspace = _setup_local_fs_tools(monkeypatch, tmp_path) + epub_path = workspace / "novel.bin" + epub_path.write_bytes(_make_epub_bytes(chapter_count=2)) + + async def _fake_parse_epub(_file_bytes: bytes, _file_name: str) -> str: + return "# Chapter 1\n\nParagraph 1\n" + + monkeypatch.setattr(file_read_utils, "_parse_local_epub_text", _fake_parse_epub) + + result = await fs_tools.FileReadTool().call( + _make_context(), + path="novel.bin", + ) + + assert result == "# Chapter 1\n\nParagraph 1\n" + + @pytest.mark.asyncio async def test_file_read_tool_stores_long_converted_document_in_workspace( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_epub_parser.py b/tests/test_epub_parser.py new file mode 100644 index 000000000..a53f89f36 --- /dev/null +++ b/tests/test_epub_parser.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import io +import zipfile + +import pytest + +from astrbot.core.knowledge_base.parsers.epub_parser import EpubParser +from astrbot.core.knowledge_base.parsers.util import select_parser + + +def _make_epub_bytes() -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, mode="w") as archive: + archive.writestr( + "mimetype", + "application/epub+zip", + compress_type=zipfile.ZIP_STORED, + ) + archive.writestr( + "META-INF/container.xml", + """ + + + + + +""", + ) + archive.writestr( + "OEBPS/nav.xhtml", + """ + + + Navigation + + + + + +""", + ) + archive.writestr( + "OEBPS/chapter2.xhtml", + """ + + + Chapter 2 + + +

    Second

    +

    Beta paragraph.

    + + +""", + ) + archive.writestr( + "OEBPS/chapter1.xhtml", + """ + + + Chapter 1 + + +

    First

    +

    Alpha paragraph.

    +
      +
    • Point A
    • +
    • Point B
    • +
    + + +""", + ) + archive.writestr( + "OEBPS/content.opf", + """ + + + test-book + Test Book + en + + + + + + + + + + + + +""", + ) + + return buffer.getvalue() + + +def _make_epub_bytes_with_generic_content() -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, mode="w") as archive: + archive.writestr( + "mimetype", + "application/epub+zip", + compress_type=zipfile.ZIP_STORED, + ) + archive.writestr( + "META-INF/container.xml", + """ + + + + + +""", + ) + archive.writestr( + "OEBPS/chapter1.xhtml", + """ + + + Chapter 1 + + +

    First

    + Lead text +

    Piura*5, continued.

    + +
    Inside div
    +
    Inside section
    + + + + + +
    Cell ACell B
    + + +""", + ) + archive.writestr( + "OEBPS/content.opf", + """ + + + test-book + Test Book + en + + + + + + + + +""", + ) + + return buffer.getvalue() + + +@pytest.mark.asyncio +async def test_select_parser_supports_epub(): + parser = await select_parser(".epub") + + assert isinstance(parser, EpubParser) + + +@pytest.mark.asyncio +async def test_epub_parser_reads_spine_order_as_text(): + result = await EpubParser().parse(_make_epub_bytes(), "book.epub") + + assert result.media == [] + assert "**Title:**" not in result.text + assert "[Chapter 2](chapter2.xhtml)" not in result.text + assert result.text.startswith("1. Chapter 2") + assert "2. Chapter 1" in result.text + assert "Beta paragraph." in result.text + assert "# First" in result.text + assert "* Point A" in result.text + assert result.text.index("1. Chapter 2") < result.text.index("## Second") + assert result.text.index("## Second") < result.text.index("# First") + + +@pytest.mark.asyncio +async def test_epub_parser_preserves_generic_container_text(): + result = await EpubParser().parse( + _make_epub_bytes_with_generic_content(), + "book.epub", + ) + + assert "**Title:**" not in result.text + assert "# First" in result.text + assert "Lead text" in result.text + assert r"Piura\*5, continued." in result.text + assert "filepos" not in result.text + assert r"[\*5]" not in result.text + assert "Image00000.jpg" not in result.text + assert "![](" not in result.text + assert "\n\n\n" not in result.text + assert "Inside div" in result.text + assert "Inside section" in result.text + assert "| Cell A | Cell B |" in result.text From fb16e12c80cef35d80cb561df03aa50f6a2d4d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B2=9A=E4=B9=8B=E5=A4=8F?= <108566281+Blueteemo@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:21:07 +0800 Subject: [PATCH 11/24] =?UTF-8?q?feat:=20=E6=8F=92=E4=BB=B6=E6=9C=89?= =?UTF-8?q?=E6=96=B0=E7=89=88=E6=9C=AC=E6=97=B6=E7=BD=AE=E9=A1=B6=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=EF=BC=88=E5=8F=AF=E5=BC=80=E5=85=B3=EF=BC=89=20(#7665?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add pinUpdatesOnTop option to always show plugin updates at top * fix: add missing pinUpdatesOnTop destructuring in InstalledPluginsTab * fix: address AI review suggestions - add localStorage persistence and increase switch width * fix: harden extension preference storage * fix: refine extension preference sorting * fix: simplify extension preference sorting * refactor: simplify extension preference storage access --------- Co-authored-by: Test User Co-authored-by: 邹永赫 <1259085392@qq.com> --- .../locales/en-US/features/extension.json | 3 +- .../locales/ru-RU/features/extension.json | 5 +- .../locales/zh-CN/features/extension.json | 3 +- .../views/extension/InstalledPluginsTab.vue | 10 +++ .../extension/extensionPreferenceStorage.mjs | 84 +++++++++++++++++++ .../src/views/extension/useExtensionPage.js | 84 ++++++++++++------- .../tests/extensionPreferenceStorage.test.mjs | 75 +++++++++++++++++ 7 files changed, 229 insertions(+), 35 deletions(-) create mode 100644 dashboard/src/views/extension/extensionPreferenceStorage.mjs create mode 100644 dashboard/tests/extensionPreferenceStorage.test.mjs diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json index 233f28dd5..0a4a7fe48 100644 --- a/dashboard/src/i18n/locales/en-US/features/extension.json +++ b/dashboard/src/i18n/locales/en-US/features/extension.json @@ -144,7 +144,8 @@ "updated": "Last Updated", "updateStatus": "Update Status", "ascending": "Ascending", - "descending": "Descending" + "descending": "Descending", + "pinUpdatesOnTop": "Pin Updates on Top" }, "tags": { "danger": "Danger" diff --git a/dashboard/src/i18n/locales/ru-RU/features/extension.json b/dashboard/src/i18n/locales/ru-RU/features/extension.json index b51d0cf78..c085885db 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/extension.json +++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json @@ -1,4 +1,4 @@ -{ +{ "title": "Плагины", "subtitle": "Управление и настройка расширений системы", "tabs": { @@ -143,7 +143,8 @@ "updated": "Дате обновления", "updateStatus": "Статусу обновления", "ascending": "По возрастанию", - "descending": "По убыванию" + "descending": "По убыванию", + "pinUpdatesOnTop": "Обновления сверху" }, "tags": { "danger": "Опасно" diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json index 04eaa8bfa..c06f38142 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/extension.json +++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json @@ -147,7 +147,8 @@ "updated": "更新时间", "updateStatus": "更新状态", "ascending": "升序", - "descending": "降序" + "descending": "降序", + "pinUpdatesOnTop": "有更新置顶" }, "tags": { "danger": "危险" diff --git a/dashboard/src/views/extension/InstalledPluginsTab.vue b/dashboard/src/views/extension/InstalledPluginsTab.vue index f7cbda8e5..97b3a56a9 100644 --- a/dashboard/src/views/extension/InstalledPluginsTab.vue +++ b/dashboard/src/views/extension/InstalledPluginsTab.vue @@ -55,6 +55,7 @@ const { installedStatusFilter, installedSortBy, installedSortOrder, + pinUpdatesOnTop, loading_, currentPage, dangerConfirmDialog, @@ -354,6 +355,15 @@ const pinnedPlugins = computed(() => { :show-order="installedSortUsesOrder" @update:order="installedSortOrder = $event" /> + diff --git a/dashboard/src/views/extension/extensionPreferenceStorage.mjs b/dashboard/src/views/extension/extensionPreferenceStorage.mjs new file mode 100644 index 000000000..31cd90bbe --- /dev/null +++ b/dashboard/src/views/extension/extensionPreferenceStorage.mjs @@ -0,0 +1,84 @@ +export const SHOW_RESERVED_PLUGINS_STORAGE_KEY = "showReservedPlugins"; +export const PLUGIN_LIST_VIEW_MODE_STORAGE_KEY = "pluginListViewMode"; +export const PIN_UPDATES_ON_TOP_STORAGE_KEY = "pinUpdatesOnTop"; + +/** + * Resolve the storage backend for reading preferences. + * Pass `null` to explicitly disable storage access in callers/tests. + */ +const getStorageForRead = (storageOverride) => { + if (storageOverride === null) { + return null; + } + if (storageOverride !== undefined) { + return typeof storageOverride?.getItem === "function" + ? storageOverride + : null; + } + if (typeof window === "undefined") { + return null; + } + try { + const localStorage = window.localStorage ?? null; + return typeof localStorage?.getItem === "function" ? localStorage : null; + } catch { + return null; + } +}; + +/** + * Resolve the storage backend for writing preferences. + * Pass `null` to explicitly disable storage access in callers/tests. + */ +const getStorageForWrite = (storageOverride) => { + if (storageOverride === null) { + return null; + } + if (storageOverride !== undefined) { + return typeof storageOverride?.setItem === "function" + ? storageOverride + : null; + } + if (typeof window === "undefined") { + return null; + } + try { + const localStorage = window.localStorage ?? null; + return typeof localStorage?.setItem === "function" ? localStorage : null; + } catch { + return null; + } +}; + +export const readBooleanPreference = (key, fallback, storage) => { + const targetStorage = getStorageForRead(storage); + if (!targetStorage) { + return fallback; + } + + try { + const saved = targetStorage.getItem(key); + if (saved === "true") { + return true; + } + if (saved === "false") { + return false; + } + return fallback; + } catch { + return fallback; + } +}; + +export const writeBooleanPreference = (key, value, storage) => { + const targetStorage = getStorageForWrite(storage); + if (!targetStorage) { + return; + } + + try { + targetStorage.setItem(key, String(value)); + } catch { + // Ignore restricted storage environments. + } +}; diff --git a/dashboard/src/views/extension/useExtensionPage.js b/dashboard/src/views/extension/useExtensionPage.js index 994be0814..3ceecc85b 100644 --- a/dashboard/src/views/extension/useExtensionPage.js +++ b/dashboard/src/views/extension/useExtensionPage.js @@ -14,6 +14,13 @@ import { getValidHashTab, replaceTabRoute, } from "@/utils/hashRouteTabs.mjs"; +import { + PIN_UPDATES_ON_TOP_STORAGE_KEY, + PLUGIN_LIST_VIEW_MODE_STORAGE_KEY, + SHOW_RESERVED_PLUGINS_STORAGE_KEY, + readBooleanPreference, + writeBooleanPreference, +} from "./extensionPreferenceStorage.mjs"; import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; import { useDisplay } from "vuetify"; @@ -124,11 +131,7 @@ export const useExtensionPage = () => { // 从 localStorage 恢复显示系统插件的状态,默认为 false(隐藏) const getInitialShowReserved = () => { - if (typeof window !== "undefined" && window.localStorage) { - const saved = localStorage.getItem("showReservedPlugins"); - return saved === "true"; - } - return false; + return readBooleanPreference(SHOW_RESERVED_PLUGINS_STORAGE_KEY, false); }; const showReserved = ref(getInitialShowReserved()); const snack_message = ref(""); @@ -178,16 +181,20 @@ export const useExtensionPage = () => { // 新增变量支持列表视图 // 从 localStorage 恢复显示模式,默认为 false(卡片视图) const getInitialListViewMode = () => { - if (typeof window !== "undefined" && window.localStorage) { - return localStorage.getItem("pluginListViewMode") === "true"; - } - return false; + return readBooleanPreference(PLUGIN_LIST_VIEW_MODE_STORAGE_KEY, false); }; const isListView = ref(getInitialListViewMode()); const pluginSearch = ref(""); const installedStatusFilter = ref("all"); const installedSortBy = ref("default"); const installedSortOrder = ref("desc"); + const getInitialPinUpdatesOnTop = () => { + return readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true); + }; + const pinUpdatesOnTop = ref(getInitialPinUpdatesOnTop()); + watch(pinUpdatesOnTop, (val) => { + writeBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, val); + }); const loading_ = ref(false); // 分页相关 @@ -426,6 +433,17 @@ export const useExtensionPage = () => { return Number.isFinite(parsed) ? parsed : null; }; + const compareInstalledFallback = (left, right) => { + const nameCompare = compareInstalledPluginNames(left.plugin, right.plugin); + return nameCompare !== 0 ? nameCompare : left.index - right.index; + }; + + const compareInstalledUpdatePinning = (left, right) => { + const leftHasUpdate = left.plugin?.has_update ? 1 : 0; + const rightHasUpdate = right.plugin?.has_update ? 1 : 0; + return rightHasUpdate - leftHasUpdate; + }; + const sortInstalledPlugins = (plugins) => { return plugins .map((plugin, index) => ({ @@ -434,19 +452,24 @@ export const useExtensionPage = () => { installedAtTimestamp: getInstalledAtTimestamp(plugin), })) .sort((left, right) => { - const fallbackNameCompare = compareInstalledPluginNames( - left.plugin, - right.plugin, - ); - const fallbackResult = - fallbackNameCompare !== 0 ? fallbackNameCompare : left.index - right.index; + if ( + pinUpdatesOnTop.value && + installedSortBy.value !== "update_status" + ) { + // Pinning updates is a primary grouping; the selected sort order still + // applies within the "has update" and "no update" groups below. + const pinCompare = compareInstalledUpdatePinning(left, right); + if (pinCompare !== 0) { + return pinCompare; + } + } if (installedSortBy.value === "install_time") { const leftTimestamp = left.installedAtTimestamp; const rightTimestamp = right.installedAtTimestamp; if (leftTimestamp == null && rightTimestamp == null) { - return fallbackResult; + return compareInstalledFallback(left, right); } if (leftTimestamp == null) { return 1; @@ -459,7 +482,9 @@ export const useExtensionPage = () => { installedSortOrder.value === "desc" ? rightTimestamp - leftTimestamp : leftTimestamp - rightTimestamp; - return timeDiff !== 0 ? timeDiff : fallbackResult; + return timeDiff !== 0 + ? timeDiff + : compareInstalledFallback(left, right); } if (installedSortBy.value === "name") { @@ -469,7 +494,7 @@ export const useExtensionPage = () => { ? -nameCompare : nameCompare; } - return left.index - right.index; + return compareInstalledFallback(left, right); } if (installedSortBy.value === "author") { @@ -482,20 +507,20 @@ export const useExtensionPage = () => { ? -authorCompare : authorCompare; } - return fallbackResult; + return compareInstalledFallback(left, right); } if (installedSortBy.value === "update_status") { - const leftHasUpdate = left.plugin?.has_update ? 1 : 0; - const rightHasUpdate = right.plugin?.has_update ? 1 : 0; const updateDiff = installedSortOrder.value === "desc" - ? rightHasUpdate - leftHasUpdate - : leftHasUpdate - rightHasUpdate; - return updateDiff !== 0 ? updateDiff : fallbackResult; + ? compareInstalledUpdatePinning(left, right) + : compareInstalledUpdatePinning(right, left); + return updateDiff !== 0 + ? updateDiff + : compareInstalledFallback(left, right); } - return fallbackResult; + return compareInstalledFallback(left, right); }) .map((item) => item.plugin); }; @@ -636,9 +661,7 @@ export const useExtensionPage = () => { const toggleShowReserved = () => { showReserved.value = !showReserved.value; // 保存到 localStorage - if (typeof window !== "undefined" && window.localStorage) { - localStorage.setItem("showReservedPlugins", showReserved.value.toString()); - } + writeBooleanPreference(SHOW_RESERVED_PLUGINS_STORAGE_KEY, showReserved.value); }; const toast = (message, success) => { @@ -1603,9 +1626,7 @@ export const useExtensionPage = () => { // 监听显示模式变化并保存到 localStorage watch(isListView, (newVal) => { - if (typeof window !== "undefined" && window.localStorage) { - localStorage.setItem("pluginListViewMode", String(newVal)); - } + writeBooleanPreference(PLUGIN_LIST_VIEW_MODE_STORAGE_KEY, newVal); }); watch( @@ -1695,6 +1716,7 @@ export const useExtensionPage = () => { installedStatusFilter, installedSortBy, installedSortOrder, + pinUpdatesOnTop, loading_, currentPage, marketCategoryFilter, diff --git a/dashboard/tests/extensionPreferenceStorage.test.mjs b/dashboard/tests/extensionPreferenceStorage.test.mjs new file mode 100644 index 000000000..e864f5fae --- /dev/null +++ b/dashboard/tests/extensionPreferenceStorage.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + PIN_UPDATES_ON_TOP_STORAGE_KEY, + readBooleanPreference, + writeBooleanPreference, +} from '../src/views/extension/extensionPreferenceStorage.mjs'; + +test("readBooleanPreference returns fallback when storage access throws", () => { + const storage = { + getItem() { + throw new Error("SecurityError"); + }, + }; + + assert.equal( + readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, storage), + true, + ); +}); + +test("readBooleanPreference parses stored boolean strings", () => { + const storage = { + getItem(key) { + return key === PIN_UPDATES_ON_TOP_STORAGE_KEY ? "false" : null; + }, + }; + + assert.equal( + readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, storage), + false, + ); +}); + +test("readBooleanPreference treats explicit null storage as unavailable", () => { + assert.equal( + readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, null), + true, + ); +}); + +test("readBooleanPreference treats invalid storage overrides as unavailable", () => { + assert.equal( + readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, {}), + true, + ); +}); + +test("writeBooleanPreference stores boolean strings and swallows storage errors", () => { + const writes = []; + const storage = { + setItem(key, value) { + writes.push([key, value]); + throw new Error("QuotaExceededError"); + }, + }; + + assert.doesNotThrow(() => + writeBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, storage), + ); + assert.deepEqual(writes, [[PIN_UPDATES_ON_TOP_STORAGE_KEY, "true"]]); +}); + +test("writeBooleanPreference ignores explicit null storage", () => { + assert.doesNotThrow(() => + writeBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, null), + ); +}); + +test("writeBooleanPreference ignores invalid storage overrides", () => { + assert.doesNotThrow(() => + writeBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, {}), + ); +}); From 406bb6c1a768b93c63a9bc89c4f4cf3ddf3c853f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B2=9A=E4=B9=8B=E5=A4=8F?= <108566281+Blueteemo@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:24:18 +0800 Subject: [PATCH 12/24] fix: warn instead of blocking when configured model not in hardcoded list (#7692) * fix: change highspeed model block to warning instead of ValueError * fix: add highspeed models + use astrbot logger (per AI review) * style: fix ruff format (line-length, import grouping) --- .../provider/sources/minimax_token_plan_source.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/astrbot/core/provider/sources/minimax_token_plan_source.py b/astrbot/core/provider/sources/minimax_token_plan_source.py index 5a578424f..d226707fd 100644 --- a/astrbot/core/provider/sources/minimax_token_plan_source.py +++ b/astrbot/core/provider/sources/minimax_token_plan_source.py @@ -1,11 +1,15 @@ +from astrbot import logger from astrbot.core.provider.sources.anthropic_source import ProviderAnthropic from ..register import register_provider_adapter MINIMAX_TOKEN_PLAN_MODELS = [ "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", "MiniMax-M2.5", + "MiniMax-M2.5-highspeed", "MiniMax-M2.1", + "MiniMax-M2.1-highspeed", "MiniMax-M2", ] @@ -43,9 +47,13 @@ class ProviderMiniMaxTokenPlan(ProviderAnthropic): configured_model = provider_config.get("model", "MiniMax-M2.7") if configured_model not in MINIMAX_TOKEN_PLAN_MODELS: - raise ValueError( - f"Unsupported model: {configured_model!r}. " - f"Supported models: {', '.join(MINIMAX_TOKEN_PLAN_MODELS)}" + logger.warning( + f"Configured model {configured_model!r} is not in the known " + f"Token Plan model list " + f"({', '.join(MINIMAX_TOKEN_PLAN_MODELS)}). " + f"The model may still work if your plan supports it. " + f"If you encounter errors, please check your plan's " + f"model availability." ) self.set_model(configured_model) From 08392c9184253154f7f08c13dd4226366e5e58d1 Mon Sep 17 00:00:00 2001 From: hjdhnx <49803097+hjdhnx@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:31:26 +0800 Subject: [PATCH 13/24] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BA=86?= =?UTF-8?q?=E5=9B=BD=E5=86=85=E9=85=8D=E7=BD=AE=E4=B8=80=E4=BA=9B=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E4=B8=8D=E5=8F=AF=E7=94=A8=E9=97=AE=E9=A2=98=20(#7685?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 修复了国内配置一些模型不可用问题 1. 常见的openai和anthropic协议,如 智谱的codingpan https://open.bigmodel.cn/api/coding/paas/v4 2. 新出的一些没有模型列表的自定义模型提供商,如科大讯飞 https://maas-coding-api.cn-huabei-1.xf-yun.com/v2 * feat: 提高代码复用性 * fix(network): reuse shared SSL context * test(network): cover proxy and header forwarding * fix(network): support verify overrides --------- Co-authored-by: Taois Co-authored-by: 邹永赫 <1259085392@qq.com> --- .../core/provider/sources/anthropic_source.py | 17 +++---- .../core/provider/sources/openai_source.py | 2 +- astrbot/core/utils/network_utils.py | 22 ++++++-- tests/unit/test_network_utils.py | 51 +++++++++++++++++++ 4 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 tests/unit/test_network_utils.py diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 764457759..d2fce17de 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -18,6 +18,7 @@ from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.network_utils import ( + create_proxy_client, is_connection_error, log_connection_failure, ) @@ -106,15 +107,13 @@ class ProviderAnthropic(Provider): http_client=self._create_http_client(provider_config), ) - def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None: - """创建带代理的 HTTP 客户端""" - proxy = provider_config.get("proxy", "") - if proxy: - logger.info(f"[Anthropic] 使用代理: {proxy}") - return httpx.AsyncClient(proxy=proxy, headers=self.custom_headers) - if self.custom_headers: - return httpx.AsyncClient(headers=self.custom_headers) - return None + def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient: + """创建带代理的 HTTP 客户端,使用系统 SSL 证书""" + return create_proxy_client( + "Anthropic", + provider_config.get("proxy", ""), + headers=self.custom_headers, + ) def _apply_thinking_config(self, payloads: dict) -> None: thinking_type = self.thinking_config.get("type", "") diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index b24bc0885..67971a2a9 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -438,7 +438,7 @@ class ProviderOpenAIOfficial(Provider): image_fallback_used, ) - def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None: + def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient: """创建带代理的 HTTP 客户端""" proxy = provider_config.get("proxy", "") return create_proxy_client("OpenAI", proxy) diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py index 727f3762a..047529396 100644 --- a/astrbot/core/utils/network_utils.py +++ b/astrbot/core/utils/network_utils.py @@ -1,9 +1,13 @@ """Network error handling utilities for providers.""" +import ssl + import httpx from astrbot import logger +_SYSTEM_SSL_CTX = ssl.create_default_context() + def is_connection_error(exc: BaseException) -> bool: """Check if an exception is a connection/network related error. @@ -83,20 +87,30 @@ def log_connection_failure( def create_proxy_client( provider_label: str, proxy: str | None = None, -) -> httpx.AsyncClient | None: + headers: dict[str, str] | None = None, + verify: ssl.SSLContext | str | bool | None = None, +) -> httpx.AsyncClient: """Create an httpx AsyncClient with proxy configuration if provided. + Uses the system SSL certificate store instead of certifi, which avoids + SSL verification failures for endpoints whose CA chain is not in certifi + but is trusted by the operating system. + Note: The caller is responsible for closing the client when done. Consider using the client as a context manager or calling aclose() explicitly. Args: provider_label: The provider name for log prefix (e.g., "OpenAI", "Gemini") proxy: The proxy address (e.g., "http://127.0.0.1:7890"), or None/empty + headers: Optional custom headers to include in every request + verify: Optional override for TLS verification. Defaults to the shared + system SSL context when not provided. Returns: - An httpx.AsyncClient configured with the proxy, or None if no proxy + An httpx.AsyncClient created with the shared system SSL context; the proxy is applied only if one is provided. """ + resolved_verify = _SYSTEM_SSL_CTX if verify is None else verify if proxy: logger.info(f"[{provider_label}] 使用代理: {proxy}") - return httpx.AsyncClient(proxy=proxy) - return None + return httpx.AsyncClient(proxy=proxy, verify=resolved_verify, headers=headers) + return httpx.AsyncClient(verify=resolved_verify, headers=headers) diff --git a/tests/unit/test_network_utils.py b/tests/unit/test_network_utils.py new file mode 100644 index 000000000..ea3505e38 --- /dev/null +++ b/tests/unit/test_network_utils.py @@ -0,0 +1,51 @@ +import ssl + +import pytest + +from astrbot.core.utils import network_utils + + +def test_create_proxy_client_reuses_shared_ssl_context( + monkeypatch: pytest.MonkeyPatch, +): + captured_calls: list[dict] = [] + headers = {"X-Test-Header": "value"} + + class _FakeAsyncClient: + def __init__(self, **kwargs): + captured_calls.append(kwargs) + + monkeypatch.setattr(network_utils.httpx, "AsyncClient", _FakeAsyncClient) + + network_utils.create_proxy_client("OpenAI") + network_utils.create_proxy_client("OpenAI", proxy="http://127.0.0.1:7890") + network_utils.create_proxy_client("OpenAI", headers=headers) + network_utils.create_proxy_client("OpenAI", proxy="") + + assert len(captured_calls) == 4 + assert "proxy" not in captured_calls[0] + assert captured_calls[1]["proxy"] == "http://127.0.0.1:7890" + assert captured_calls[2]["headers"] is headers + assert "proxy" not in captured_calls[3] + assert isinstance(captured_calls[0]["verify"], ssl.SSLContext) + assert captured_calls[0]["verify"] is captured_calls[1]["verify"] + assert captured_calls[1]["verify"] is captured_calls[2]["verify"] + assert captured_calls[2]["verify"] is captured_calls[3]["verify"] + + +def test_create_proxy_client_allows_verify_override( + monkeypatch: pytest.MonkeyPatch, +): + captured_calls: list[dict] = [] + custom_verify = ssl.create_default_context() + + class _FakeAsyncClient: + def __init__(self, **kwargs): + captured_calls.append(kwargs) + + monkeypatch.setattr(network_utils.httpx, "AsyncClient", _FakeAsyncClient) + + network_utils.create_proxy_client("OpenAI", verify=custom_verify) + + assert len(captured_calls) == 1 + assert captured_calls[0]["verify"] is custom_verify From d9ab35348ed85e8d1461e0ef7c60fd200526effd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E2=82=82=E2=82=82H=E2=82=82=E2=82=85NO=E2=82=86?= <96930391+Sisyphbaous-DT-Project@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:27:40 +0800 Subject: [PATCH 14/24] fix: drop legacy documents_fts table if exists (#7706) * fix: recover FTS5 index from legacy documents_fts table * fix: normalize SQL whitespace when checking contentless_delete --- .../db/vec_db/faiss_impl/document_storage.py | 110 +++++++++++++----- tests/unit/test_document_storage_fts.py | 28 +++++ 2 files changed, 111 insertions(+), 27 deletions(-) diff --git a/astrbot/core/db/vec_db/faiss_impl/document_storage.py b/astrbot/core/db/vec_db/faiss_impl/document_storage.py index 116c43be1..d0310d750 100644 --- a/astrbot/core/db/vec_db/faiss_impl/document_storage.py +++ b/astrbot/core/db/vec_db/faiss_impl/document_storage.py @@ -96,36 +96,29 @@ class DocumentStorage: async def _initialize_fts5(self, executor) -> None: try: - try: - await executor.execute( - text( - f""" - CREATE VIRTUAL TABLE IF NOT EXISTS {FTS_TABLE_NAME} - USING fts5( - search_text, - content='', - contentless_delete=1, - tokenize='unicode61' - ) - """, - ), + await self._create_fts5_table(executor, if_not_exists=True) + + is_valid_fts5, has_contentless_delete = await self._inspect_fts5_table( + executor, + ) + if not is_valid_fts5: + logger.warning( + f"Detected incompatible legacy table `{FTS_TABLE_NAME}` in " + f"{self.db_path}; recreating FTS5 table.", ) - self._fts_contentless_delete = True - except Exception: - await executor.execute( - text( - f""" - CREATE VIRTUAL TABLE IF NOT EXISTS {FTS_TABLE_NAME} - USING fts5( - search_text, - content='', - tokenize='unicode61' - ) - """, - ), + await executor.execute(text(f"DROP TABLE IF EXISTS {FTS_TABLE_NAME}")) + await self._create_fts5_table(executor, if_not_exists=False) + + is_valid_fts5, has_contentless_delete = await self._inspect_fts5_table( + executor, ) - self._fts_contentless_delete = False + if not is_valid_fts5: + raise RuntimeError( + f"Failed to create a valid FTS5 table `{FTS_TABLE_NAME}`", + ) + self.fts5_available = True + self._fts_contentless_delete = has_contentless_delete except Exception as e: self.fts5_available = False self._fts_contentless_delete = False @@ -134,6 +127,69 @@ class DocumentStorage: f"falling back to in-memory BM25 sparse retrieval: {e}", ) + async def _create_fts5_table(self, executor, if_not_exists: bool) -> None: + create_clause = ( + "CREATE VIRTUAL TABLE IF NOT EXISTS" + if if_not_exists + else "CREATE VIRTUAL TABLE" + ) + try: + await executor.execute( + text( + f""" + {create_clause} {FTS_TABLE_NAME} + USING fts5( + search_text, + content='', + contentless_delete=1, + tokenize='unicode61' + ) + """, + ), + ) + except Exception: + await executor.execute( + text( + f""" + {create_clause} {FTS_TABLE_NAME} + USING fts5( + search_text, + content='', + tokenize='unicode61' + ) + """, + ), + ) + + async def _inspect_fts5_table(self, executor) -> tuple[bool, bool]: + schema_result = await executor.execute( + text( + """ + SELECT sql + FROM sqlite_master + WHERE type='table' AND name=:table_name + """, + ), + {"table_name": FTS_TABLE_NAME}, + ) + create_sql = schema_result.scalar_one_or_none() + if not create_sql: + return False, False + + normalized_sql = create_sql.lower() + if "virtual table" not in normalized_sql or "using fts5" not in normalized_sql: + return False, False + + pragma_result = await executor.execute( + text(f"PRAGMA table_info({FTS_TABLE_NAME})"), + ) + columns = {row[1] for row in pragma_result.fetchall()} + if "search_text" not in columns: + return False, False + + normalized_sql_no_whitespace = "".join(normalized_sql.split()) + return True, "contentless_delete=1" in normalized_sql_no_whitespace + async def connect(self) -> None: """Connect to the SQLite database.""" if self.engine is None: diff --git a/tests/unit/test_document_storage_fts.py b/tests/unit/test_document_storage_fts.py index 753c371ef..a7dd32c94 100644 --- a/tests/unit/test_document_storage_fts.py +++ b/tests/unit/test_document_storage_fts.py @@ -1,3 +1,5 @@ +import sqlite3 + import pytest from astrbot.core.db.vec_db.faiss_impl.document_storage import DocumentStorage @@ -73,3 +75,29 @@ async def test_document_storage_fts_delete_skips_missing_fts_row(tmp_path): assert await storage.get_document_by_doc_id("legacy-chunk") is None await storage.close() + + +@pytest.mark.asyncio +async def test_document_storage_fts_recovers_from_legacy_non_fts_table(tmp_path): + db_path = tmp_path / "doc.db" + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE documents_fts (rowid INTEGER PRIMARY KEY)") + conn.commit() + conn.close() + + storage = DocumentStorage(str(db_path)) + await storage.initialize() + + assert storage.fts5_available is True + + await storage.insert_document( + doc_id="legacy-fix", + text="legacy fts recovery text", + metadata={"kb_doc_id": "doc-1", "kb_id": "kb-1", "chunk_index": 0}, + ) + results = await storage.search_sparse(["legacy"], limit=10) + + assert results is not None + assert [result["doc_id"] for result in results] == ["legacy-fix"] + + await storage.close() From 03bbf0bf5ac23bd9e5bc625cb2101a23106ce317 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:41:31 +0800 Subject: [PATCH 15/24] feat: re-establishing /provider as a built-in command (#7691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: re-establishing /provider as a built-in command * style: format provider command --------- Co-authored-by: 邹永赫 <1259085392@qq.com> --- .../builtin_commands/commands/__init__.py | 2 + .../builtin_commands/commands/provider.py | 248 ++++++++++++++++++ .../builtin_stars/builtin_commands/main.py | 13 + 3 files changed, 263 insertions(+) create mode 100644 astrbot/builtin_stars/builtin_commands/commands/provider.py diff --git a/astrbot/builtin_stars/builtin_commands/commands/__init__.py b/astrbot/builtin_stars/builtin_commands/commands/__init__.py index 552ac4a8a..45447ec9c 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/__init__.py +++ b/astrbot/builtin_stars/builtin_commands/commands/__init__.py @@ -3,6 +3,7 @@ from .admin import AdminCommands from .conversation import ConversationCommands from .help import HelpCommand +from .provider import ProviderCommands from .setunset import SetUnsetCommands from .sid import SIDCommand @@ -10,6 +11,7 @@ __all__ = [ "AdminCommands", "ConversationCommands", "HelpCommand", + "ProviderCommands", "SetUnsetCommands", "SIDCommand", ] diff --git a/astrbot/builtin_stars/builtin_commands/commands/provider.py b/astrbot/builtin_stars/builtin_commands/commands/provider.py new file mode 100644 index 000000000..971d6ca8a --- /dev/null +++ b/astrbot/builtin_stars/builtin_commands/commands/provider.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import asyncio + +from astrbot import logger +from astrbot.api import star +from astrbot.api.event import AstrMessageEvent, MessageEventResult +from astrbot.core.provider.entities import ProviderType +from astrbot.core.utils.error_redaction import safe_error + + +class ProviderCommands: + def __init__(self, context: star.Context) -> None: + self.context = context + + def _log_reachability_failure( + self, + provider, + provider_capability_type: ProviderType | None, + err_code: str, + err_reason: str, + ) -> None: + meta = provider.meta() + logger.warning( + "Provider reachability check failed: id=%s type=%s code=%s reason=%s", + meta.id, + provider_capability_type.name if provider_capability_type else "unknown", + err_code, + err_reason, + ) + + async def _test_provider_capability(self, provider): + meta = provider.meta() + provider_capability_type = meta.provider_type + + try: + await provider.test() + return True, None, None + except Exception as e: + err_code = "TEST_FAILED" + err_reason = safe_error("", e) + self._log_reachability_failure( + provider, provider_capability_type, err_code, err_reason + ) + return False, err_code, err_reason + + async def _build_provider_display_data( + self, + providers, + provider_type: str, + reachability_check_enabled: bool, + ) -> list[dict]: + if not providers: + return [] + + if reachability_check_enabled: + check_results = await asyncio.gather( + *[self._test_provider_capability(provider) for provider in providers], + return_exceptions=True, + ) + else: + check_results = [None for _ in providers] + + display_data = [] + for provider, reachable in zip(providers, check_results): + meta = provider.meta() + id_ = meta.id + error_code = None + + if isinstance(reachable, asyncio.CancelledError): + raise reachable + if isinstance(reachable, Exception): + self._log_reachability_failure( + provider, + None, + reachable.__class__.__name__, + safe_error("", reachable), + ) + reachable_flag = False + error_code = reachable.__class__.__name__ + elif isinstance(reachable, tuple): + reachable_flag, error_code, _ = reachable + else: + reachable_flag = reachable + + if provider_type == "llm": + info = f"{id_} ({meta.model})" + else: + info = f"{id_}" + + if reachable_flag is True: + mark = " ✅" + elif reachable_flag is False: + if error_code: + mark = f" ❌(errcode: {error_code})" + else: + mark = " ❌" + else: + mark = "" + + display_data.append( + { + "info": info, + "mark": mark, + "provider": provider, + } + ) + + return display_data + + async def provider( + self, + event: AstrMessageEvent, + idx: str | int | None = None, + idx2: int | None = None, + ) -> None: + """查看或者切换 LLM Provider""" + umo = event.unified_msg_origin + cfg = self.context.get_config(umo).get("provider_settings", {}) + reachability_check_enabled = cfg.get("reachability_check", True) + + if idx is None: + parts = ["## LLM Providers\n"] + + llms = list(self.context.get_all_providers()) + ttss = self.context.get_all_tts_providers() + stts = self.context.get_all_stt_providers() + + if reachability_check_enabled and (llms or ttss or stts): + await event.send( + MessageEventResult().message("👀 Testing provider reachability...") + ) + + llm_data, tts_data, stt_data = await asyncio.gather( + self._build_provider_display_data( + llms, + "llm", + reachability_check_enabled, + ), + self._build_provider_display_data( + ttss, + "tts", + reachability_check_enabled, + ), + self._build_provider_display_data( + stts, + "stt", + reachability_check_enabled, + ), + ) + + provider_using = self.context.get_using_provider(umo=umo) + for i, d in enumerate(llm_data): + line = f"{i + 1}. {d['info']}{d['mark']}" + if ( + provider_using + and provider_using.meta().id == d["provider"].meta().id + ): + line += " 👈" + parts.append(line + "\n") + + if tts_data: + parts.append("\n## TTS Providers\n") + tts_using = self.context.get_using_tts_provider(umo=umo) + for i, d in enumerate(tts_data): + line = f"{i + 1}. {d['info']}{d['mark']}" + if tts_using and tts_using.meta().id == d["provider"].meta().id: + line += " 👈" + parts.append(line + "\n") + + if stt_data: + parts.append("\n## STT Providers\n") + stt_using = self.context.get_using_stt_provider(umo=umo) + for i, d in enumerate(stt_data): + line = f"{i + 1}. {d['info']}{d['mark']}" + if stt_using and stt_using.meta().id == d["provider"].meta().id: + line += " 👈" + parts.append(line + "\n") + + parts.append("\nUse /provider to switch LLM providers.") + ret = "".join(parts) + + if ttss: + ret += "\nUse /provider tts to switch TTS providers." + if stts: + ret += "\nUse /provider stt to switch STT providers." + + event.set_result(MessageEventResult().message(ret)) + elif idx == "tts": + if idx2 is None: + event.set_result( + MessageEventResult().message("Please enter the index.") + ) + return + if idx2 > len(self.context.get_all_tts_providers()) or idx2 < 1: + event.set_result( + MessageEventResult().message("❌ Invalid provider index.") + ) + return + provider = self.context.get_all_tts_providers()[idx2 - 1] + id_ = provider.meta().id + await self.context.provider_manager.set_provider( + provider_id=id_, + provider_type=ProviderType.TEXT_TO_SPEECH, + umo=umo, + ) + event.set_result( + MessageEventResult().message(f"✅ Successfully switched to {id_}.") + ) + elif idx == "stt": + if idx2 is None: + event.set_result( + MessageEventResult().message("Please enter the index.") + ) + return + if idx2 > len(self.context.get_all_stt_providers()) or idx2 < 1: + event.set_result( + MessageEventResult().message("❌ Invalid provider index.") + ) + return + provider = self.context.get_all_stt_providers()[idx2 - 1] + id_ = provider.meta().id + await self.context.provider_manager.set_provider( + provider_id=id_, + provider_type=ProviderType.SPEECH_TO_TEXT, + umo=umo, + ) + event.set_result( + MessageEventResult().message(f"✅ Successfully switched to {id_}.") + ) + elif isinstance(idx, int): + if idx > len(self.context.get_all_providers()) or idx < 1: + event.set_result( + MessageEventResult().message("❌ Invalid provider index.") + ) + return + provider = self.context.get_all_providers()[idx - 1] + id_ = provider.meta().id + await self.context.provider_manager.set_provider( + provider_id=id_, + provider_type=ProviderType.CHAT_COMPLETION, + umo=umo, + ) + event.set_result( + MessageEventResult().message(f"✅ Successfully switched to {id_}.") + ) + else: + event.set_result(MessageEventResult().message("❌ Invalid parameter.")) diff --git a/astrbot/builtin_stars/builtin_commands/main.py b/astrbot/builtin_stars/builtin_commands/main.py index 8e34f582d..f2e5e26d5 100644 --- a/astrbot/builtin_stars/builtin_commands/main.py +++ b/astrbot/builtin_stars/builtin_commands/main.py @@ -5,6 +5,7 @@ from .commands import ( AdminCommands, ConversationCommands, HelpCommand, + ProviderCommands, SetUnsetCommands, SIDCommand, ) @@ -17,6 +18,7 @@ class Main(star.Star): self.admin_c = AdminCommands(self.context) self.conversation_c = ConversationCommands(self.context) self.help_c = HelpCommand(self.context) + self.provider_c = ProviderCommands(self.context) self.setunset_c = SetUnsetCommands(self.context) self.sid_c = SIDCommand(self.context) @@ -45,6 +47,17 @@ class Main(star.Star): """Create new conversation""" await self.conversation_c.new_conv(message) + @filter.permission_type(filter.PermissionType.ADMIN) + @filter.command("provider") + async def provider( + self, + event: AstrMessageEvent, + idx: str | int | None = None, + idx2: int | None = None, + ) -> None: + """View or switch LLM Provider""" + await self.provider_c.provider(event, idx, idx2) + @filter.permission_type(filter.PermissionType.ADMIN) @filter.command("dashboard_update") async def update_dashboard(self, event: AstrMessageEvent) -> None: From 6b756f666fa803909cc298863c8095062e0a18f2 Mon Sep 17 00:00:00 2001 From: Rain-0x01_ <83620631+Rain-0x01-39@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:42:27 +0800 Subject: [PATCH 16/24] docs: Unify documentation links (#7709) astrbot.app -> docs.astrbot.app --- astrbot/cli/commands/cmd_plug.py | 2 +- astrbot/core/star/config.py | 2 +- astrbot/core/utils/t2i/renderer.py | 2 +- .../src/components/config/AstrBotCoreConfigWrapper.vue | 2 +- .../src/layouts/full/vertical-sidebar/VerticalSidebar.vue | 6 +++--- dashboard/src/views/SessionManagementPage.vue | 2 +- dashboard/src/views/alkaid/KnowledgeBase.vue | 4 ++-- dashboard/src/views/knowledge-base/KBList.vue | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/astrbot/cli/commands/cmd_plug.py b/astrbot/cli/commands/cmd_plug.py index 46057fc6b..462c8e8b9 100644 --- a/astrbot/cli/commands/cmd_plug.py +++ b/astrbot/cli/commands/cmd_plug.py @@ -84,7 +84,7 @@ def new(name: str) -> None: # Rewrite README.md with open(plug_path / "README.md", "w", encoding="utf-8") as f: f.write( - f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://astrbot.app)\n" + f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://docs.astrbot.app)\n" ) # Rewrite main.py diff --git a/astrbot/core/star/config.py b/astrbot/core/star/config.py index 429a05d5e..8b2ba762b 100644 --- a/astrbot/core/star/config.py +++ b/astrbot/core/star/config.py @@ -1,4 +1,4 @@ -"""此功能已过时,参考 https://astrbot.app/dev/plugin.html#%E6%B3%A8%E5%86%8C%E6%8F%92%E4%BB%B6%E9%85%8D%E7%BD%AE-beta""" +"""此功能已过时,参考 https://docs.astrbot.app/dev/plugin.html#%E6%B3%A8%E5%86%8C%E6%8F%92%E4%BB%B6%E9%85%8D%E7%BD%AE-beta""" import json import os diff --git a/astrbot/core/utils/t2i/renderer.py b/astrbot/core/utils/t2i/renderer.py index e3118d7e8..995c3d244 100644 --- a/astrbot/core/utils/t2i/renderer.py +++ b/astrbot/core/utils/t2i/renderer.py @@ -28,7 +28,7 @@ class HtmlRenderer: @return: 图片 URL 或者文件路径,取决于 return_url 参数。 - @example: 参见 https://astrbot.app 插件开发部分。 + @example: 参见 https://docs.astrbot.app 插件开发部分。 """ return await self.network_strategy.render_custom_template( tmpl_str, diff --git a/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue b/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue index 88029a25a..b485a783f 100644 --- a/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue +++ b/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue @@ -26,7 +26,7 @@
    {{ tm('help.helpPrefix') }} - {{ tm('help.documentation') }} + {{ tm('help.documentation') }} {{ tm('help.helpMiddle') }} {{ tm('help.support') }}{{ tm('help.helpSuffix') }} diff --git a/dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue b/dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue index e2636d211..a3c1da81e 100644 --- a/dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue +++ b/dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue @@ -145,7 +145,7 @@ function toggleIframe() { function openIframeLink(url) { if (typeof window !== 'undefined') { - let url_ = url || "https://astrbot.app"; + let url_ = url || "https://docs.astrbot.app"; window.open(url_, "_blank"); } } @@ -352,7 +352,7 @@ function openChangelogDialog() {
    @@ -369,7 +369,7 @@ function openChangelogDialog() {
    diff --git a/dashboard/src/views/SessionManagementPage.vue b/dashboard/src/views/SessionManagementPage.vue index 1400f4915..39d287403 100644 --- a/dashboard/src/views/SessionManagementPage.vue +++ b/dashboard/src/views/SessionManagementPage.vue @@ -4,7 +4,7 @@ {{ tm('customRules.title') }} - + {{ totalItems }} {{ tm('customRules.rulesCount') }}

    {{ tm('notInstalled.title') }} mdi-information-outline + @click="openUrl('https://docs.astrbot.app/use/knowledge-base.html')">mdi-information-outline

    @@ -31,7 +31,7 @@

    {{ tm('list.title') }} mdi-information-outline + @click="openUrl('https://docs.astrbot.app/use/knowledge-base.html')">mdi-information-outline

    diff --git a/dashboard/src/views/knowledge-base/KBList.vue b/dashboard/src/views/knowledge-base/KBList.vue index 9428c7ff9..54fc24512 100644 --- a/dashboard/src/views/knowledge-base/KBList.vue +++ b/dashboard/src/views/knowledge-base/KBList.vue @@ -7,7 +7,7 @@

    {{ t('list.subtitle') }}

    + href="https://docs.astrbot.app/use/knowledge-base.html" target="_blank" /> From 7778d8bb63567fd1bbff11578dc057528c9a90b7 Mon Sep 17 00:00:00 2001 From: Sebastion Date: Tue, 21 Apr 2026 15:52:34 +0100 Subject: [PATCH 17/24] fix: prevent path traversal in backup importer (CWE-22) (#7681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent path traversal in backup importer (CWE-22) Validate that all file write targets resolve within their expected base directories before writing. This prevents crafted backup ZIP files from writing to arbitrary filesystem locations via malicious path values in attachment records, media file paths, or directory entries. * fix: use Path.is_relative_to for robust path containment check * fix: add explicit strict=False to Path.resolve() calls * style: format backup importer --------- Co-authored-by: 邹永赫 <1259085392@qq.com> --- astrbot/core/backup/importer.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/astrbot/core/backup/importer.py b/astrbot/core/backup/importer.py index b51c7d956..e994242a8 100644 --- a/astrbot/core/backup/importer.py +++ b/astrbot/core/backup/importer.py @@ -59,6 +59,20 @@ def _get_major_version(version_str: str) -> str: return "0.0" +def _validate_path_within(target_path: Path, base_dir: Path) -> bool: + """Validate that target_path is within base_dir after resolving symlinks. + + Prevents path traversal attacks (CWE-22) by ensuring the resolved + target path is relative to the resolved base directory. + """ + try: + resolved = target_path.resolve(strict=False) + base_resolved = base_dir.resolve(strict=False) + return resolved.is_relative_to(base_resolved) + except (OSError, ValueError): + return False + + CMD_CONFIG_FILE_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json") KB_PATH = get_astrbot_knowledge_base_path() DEFAULT_PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT = 5 @@ -765,6 +779,10 @@ class AstrBotImporter: try: rel_path = name[len(media_prefix) :] target_path = kb_dir / rel_path + # Validate path is within kb directory (CWE-22) + if not _validate_path_within(target_path, kb_dir): + logger.warning(f"媒体文件路径越界,已跳过: {target_path}") + continue target_path.parent.mkdir(parents=True, exist_ok=True) with zf.open(name) as src, open(target_path, "wb") as dst: dst.write(src.read()) @@ -827,6 +845,11 @@ class AstrBotImporter: else: target_path = attachments_dir / os.path.basename(name) + # Validate path is within attachments directory (CWE-22) + if not _validate_path_within(target_path, attachments_dir): + logger.warning(f"附件路径越界,已跳过: {target_path}") + continue + target_path.parent.mkdir(parents=True, exist_ok=True) with zf.open(name) as src, open(target_path, "wb") as dst: dst.write(src.read()) @@ -904,6 +927,10 @@ class AstrBotImporter: continue target_path = target_dir / rel_path + # Validate path is within target directory (CWE-22) + if not _validate_path_within(target_path, target_dir): + result.add_warning(f"文件路径越界,已跳过: {name}") + continue target_path.parent.mkdir(parents=True, exist_ok=True) with zf.open(name) as src, open(target_path, "wb") as dst: From 17ace9b5dbe6ff58622f964f36f446420f635b14 Mon Sep 17 00:00:00 2001 From: SaintaToken Date: Tue, 21 Apr 2026 23:32:00 +0800 Subject: [PATCH 18/24] feat: add buffered intermediate messages for non-streaming agent loop (#7627) * feat: add buffered intermediate messages for non-streaming agent loop * Refactored buffering logic into helpers to reduce inline complexity. * feat: add buffer_intermediate_messages configuration for merging Agent intermediate messages --------- Co-authored-by: Soulter <905617992@qq.com> --- astrbot/core/astr_agent_run_util.py | 67 ++++++++++++++++++- astrbot/core/config/default.py | 13 ++++ .../method/agent_sub_stages/internal.py | 7 ++ .../en-US/features/config-metadata.json | 4 ++ .../ru-RU/features/config-metadata.json | 4 ++ .../zh-CN/features/config-metadata.json | 6 +- 6 files changed, 99 insertions(+), 2 deletions(-) diff --git a/astrbot/core/astr_agent_run_util.py b/astrbot/core/astr_agent_run_util.py index eca24699a..62c60a436 100644 --- a/astrbot/core/astr_agent_run_util.py +++ b/astrbot/core/astr_agent_run_util.py @@ -87,6 +87,31 @@ def _build_tool_result_status_message( return status_msg +def _should_buffer_llm_result( + buffer_intermediate_messages: bool, + stream_to_general: bool, + agent_runner: AgentRunner, +) -> bool: + return ( + buffer_intermediate_messages + and not stream_to_general + and not agent_runner.streaming + ) + + +def _merge_buffered_llm_chains( + buffered_llm_chains: list[MessageChain], +) -> MessageChain | None: + if not buffered_llm_chains: + return None + + merged_chain = MessageChain() + for chain in buffered_llm_chains: + merged_chain.chain.extend(chain.chain) + buffered_llm_chains.clear() + return merged_chain + + async def run_agent( agent_runner: AgentRunner, max_step: int = 30, @@ -94,10 +119,17 @@ async def run_agent( show_tool_call_result: bool = False, stream_to_general: bool = False, show_reasoning: bool = False, + buffer_intermediate_messages: bool = False, ) -> AsyncGenerator[MessageChain | None, None]: step_idx = 0 astr_event = agent_runner.run_context.context.event tool_name_by_call_id: dict[str, str] = {} + buffered_llm_chains: list[MessageChain] = [] + can_buffer_llm_result = _should_buffer_llm_result( + buffer_intermediate_messages, + stream_to_general, + agent_runner, + ) while step_idx < max_step + 1: step_idx += 1 @@ -126,6 +158,17 @@ async def run_agent( agent_runner.request_stop() if resp.type == "aborted": + if can_buffer_llm_result: + merged_chain = _merge_buffered_llm_chains(buffered_llm_chains) + if merged_chain: + astr_event.set_result( + MessageEventResult( + chain=merged_chain.chain, + result_content_type=ResultContentType.LLM_RESULT, + ), + ) + yield merged_chain + astr_event.clear_result() if not stop_watcher.done(): stop_watcher.cancel() try: @@ -197,6 +240,10 @@ async def run_agent( continue if stream_to_general or not agent_runner.streaming: + if can_buffer_llm_result and resp.type == "llm_result": + buffered_llm_chains.append(resp.data["chain"]) + continue + content_typ = ( ResultContentType.LLM_RESULT if resp.type == "llm_result" @@ -208,7 +255,7 @@ async def run_agent( result_content_type=content_typ, ), ) - yield + yield resp.data["chain"] astr_event.clear_result() elif resp.type == "streaming_delta": chain = resp.data["chain"] @@ -216,6 +263,19 @@ async def run_agent( # display the reasoning content only when configured continue yield resp.data["chain"] # MessageChain + + if can_buffer_llm_result and agent_runner.done(): + merged_chain = _merge_buffered_llm_chains(buffered_llm_chains) + if merged_chain: + astr_event.set_result( + MessageEventResult( + chain=merged_chain.chain, + result_content_type=ResultContentType.LLM_RESULT, + ), + ) + yield merged_chain + astr_event.clear_result() + if not stop_watcher.done(): stop_watcher.cancel() try: @@ -288,6 +348,7 @@ async def run_live_agent( show_tool_use: bool = True, show_tool_call_result: bool = False, show_reasoning: bool = False, + buffer_intermediate_messages: bool = False, ) -> AsyncGenerator[MessageChain | None, None]: """Live Mode 的 Agent 运行器,支持流式 TTS @@ -311,6 +372,7 @@ async def run_live_agent( show_tool_call_result=show_tool_call_result, stream_to_general=False, show_reasoning=show_reasoning, + buffer_intermediate_messages=buffer_intermediate_messages, ): yield chain return @@ -343,6 +405,7 @@ async def run_live_agent( show_tool_use, show_tool_call_result, show_reasoning, + buffer_intermediate_messages, ) ) @@ -430,6 +493,7 @@ async def _run_agent_feeder( show_tool_use: bool, show_tool_call_result: bool, show_reasoning: bool, + buffer_intermediate_messages: bool, ) -> None: """运行 Agent 并将文本输出分句放入队列""" buffer = "" @@ -441,6 +505,7 @@ async def _run_agent_feeder( show_tool_call_result=show_tool_call_result, stream_to_general=False, show_reasoning=show_reasoning, + buffer_intermediate_messages=buffer_intermediate_messages, ): if chain is None: continue diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index b7ab2951d..09976f7c4 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -134,6 +134,7 @@ DEFAULT_CONFIG = { "streaming_response": False, "show_tool_use_status": False, "show_tool_call_result": False, + "buffer_intermediate_messages": False, "sanitize_context_by_modalities": False, "max_quoted_fallback_images": 20, "quoted_message_parser": { @@ -2777,6 +2778,9 @@ CONFIG_METADATA_2 = { "show_tool_call_result": { "type": "bool", }, + "buffer_intermediate_messages": { + "type": "bool", + }, "unsupported_streaming_strategy": { "type": "string", }, @@ -3543,6 +3547,15 @@ CONFIG_METADATA_3 = { "provider_settings.show_tool_use_status": True, }, }, + "provider_settings.buffer_intermediate_messages": { + "description": "合并 Agent 中间消息", + "type": "bool", + "hint": "开启后,非流式模式下多步工具调用过程中产生的中间文本将缓冲,待 Agent 完成后合并为一条回复发送。", + "condition": { + "provider_settings.agent_runner_type": "local", + "provider_settings.streaming_response": False, + }, + }, "provider_settings.sanitize_context_by_modalities": { "description": "按模型能力清理历史上下文", "type": "bool", diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index e0ba2463c..0d43e4da9 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -66,6 +66,10 @@ class InternalAgentSubStage(Stage): self.max_step = 30 self.show_tool_use: bool = settings.get("show_tool_use_status", True) self.show_tool_call_result: bool = settings.get("show_tool_call_result", False) + self.buffer_intermediate_messages: bool = settings.get( + "buffer_intermediate_messages", + False, + ) self.show_reasoning = settings.get("display_reasoning_text", False) self.sanitize_context_by_modalities: bool = settings.get( "sanitize_context_by_modalities", @@ -280,6 +284,7 @@ class InternalAgentSubStage(Stage): self.show_tool_use, self.show_tool_call_result, show_reasoning=self.show_reasoning, + buffer_intermediate_messages=self.buffer_intermediate_messages, ), ), ) @@ -310,6 +315,7 @@ class InternalAgentSubStage(Stage): self.show_tool_use, self.show_tool_call_result, show_reasoning=self.show_reasoning, + buffer_intermediate_messages=self.buffer_intermediate_messages, ), ), ) @@ -340,6 +346,7 @@ class InternalAgentSubStage(Stage): self.show_tool_call_result, stream_to_general, show_reasoning=self.show_reasoning, + buffer_intermediate_messages=self.buffer_intermediate_messages, ): yield diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index ffb592ab8..6527e9576 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -280,6 +280,10 @@ "description": "Output Tool Call Results", "hint": "Only takes effect when \"Output Function Call Status\" is enabled, and shows at most 70 characters." }, + "buffer_intermediate_messages": { + "description": "Merge Agent Intermediate Messages", + "hint": "When enabled, intermediate text generated during multi-step tool calls in non-streaming mode will be buffered and sent as a single merged reply after the Agent finishes." + }, "sanitize_context_by_modalities": { "description": "Sanitize History by Modalities", "hint": "When enabled, sanitizes contexts before each LLM request by removing image blocks and tool-call structures that the current provider's modalities do not support (this changes what the model sees)." diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index e3920940a..028c1859c 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -280,6 +280,10 @@ "description": "Выводить результаты работы инструментов", "hint": "Работает только при включенном статусе вызова функций. Показывает макс. 70 символов." }, + "buffer_intermediate_messages": { + "description": "Объединять промежуточные сообщения Agent", + "hint": "Если включено, промежуточный текст, созданный во время многошаговых вызовов инструментов в непотоковом режиме, будет буферизован и отправлен одним объединенным ответом после завершения Agent." + }, "sanitize_context_by_modalities": { "description": "Очистка истории по модальностям", "hint": "Если включено, очищает контекст перед запросом, удаляя блоки (например, изображения), которые не поддерживаются выбранным провайдером." diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 935abb358..66c2e20b7 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -282,6 +282,10 @@ "description": "输出函数调用返回结果", "hint": "仅在启用“输出函数调用状态”时生效,且最多展示 70 个字符。" }, + "buffer_intermediate_messages": { + "description": "合并 Agent 中间消息", + "hint": "开启后,非流式模式下多步工具调用过程中产生的中间文本将缓冲,待 Agent 完成后合并为一条回复发送。" + }, "sanitize_context_by_modalities": { "description": "按模型能力清理历史上下文", "hint": "开启后,在每次请求 LLM 前会按当前模型提供商中所选择的模型能力删除对话中不支持的图片/工具调用结构(会改变模型看到的历史)" @@ -1643,4 +1647,4 @@ "helpMiddle": "或", "helpSuffix": "。" } -} \ No newline at end of file +} From 662b1d36784ce6e3ba902487edd8c2b31347523a Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:34:22 +0800 Subject: [PATCH 19/24] fix: accept both str and re.Pattern in RegexFilter (#7633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: accept both str and re.Pattern in RegexFilter RegexFilter.__init__ now handles compiled re.Pattern objects by extracting .pattern for regex_str, preventing TypeError during JSON serialization in the dashboard plugin API. * perf: 精简代码 --- astrbot/core/star/filter/regex.py | 4 ++-- astrbot/core/star/register/star_handler.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/astrbot/core/star/filter/regex.py b/astrbot/core/star/filter/regex.py index 605446282..0a64ee6a7 100644 --- a/astrbot/core/star/filter/regex.py +++ b/astrbot/core/star/filter/regex.py @@ -10,9 +10,9 @@ from . import HandlerFilter class RegexFilter(HandlerFilter): """正则表达式过滤器""" - def __init__(self, regex: str) -> None: - self.regex_str = regex + def __init__(self, regex: str | re.Pattern) -> None: self.regex = re.compile(regex) + self.regex_str = self.regex.pattern def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool: return bool(self.regex.search(event.get_message_str().strip())) diff --git a/astrbot/core/star/register/star_handler.py b/astrbot/core/star/register/star_handler.py index 10417c401..49a65d891 100644 --- a/astrbot/core/star/register/star_handler.py +++ b/astrbot/core/star/register/star_handler.py @@ -284,7 +284,7 @@ def register_platform_adapter_type( return decorator -def register_regex(regex: str, **kwargs): +def register_regex(regex: str | re.Pattern, **kwargs): """注册一个 Regex""" def decorator(awaitable): From e6b68e9b098a4d2a35fd70529617d465ea21cd48 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:38:40 +0800 Subject: [PATCH 20/24] perf: update FileReadTool description to mention image, PDF and docx support, and enhance modality checking in tool result case (#7506) * feat: update FileReadTool description to mention image and PDF support Add explicit mention of image (OCR) and PDF (text extraction) support to the FileReadTool description for better discoverability. * feat: update FileReadTool description to include support for docx and epub files; change base64 decoding to utf-8 * feat: enhance ToolLoopAgentRunner to support image and audio modalities; add context sanitization logic --- .../agent/runners/tool_loop_agent_runner.py | 88 +++++++++- astrbot/core/astr_main_agent.py | 132 --------------- astrbot/core/computer/file_read_utils.py | 16 +- astrbot/core/provider/modalities.py | 158 ++++++++++++++++++ astrbot/core/tools/computer_tools/fs.py | 2 +- tests/test_tool_loop_agent_runner.py | 113 +++++++++++++ tests/unit/test_astr_main_agent.py | 138 --------------- 7 files changed, 361 insertions(+), 286 deletions(-) create mode 100644 astrbot/core/provider/modalities.py diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index c210056e9..9d8cd2df2 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -7,7 +7,7 @@ import typing as T import uuid from collections.abc import AsyncIterator from contextlib import suppress -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from mcp.types import ( @@ -42,6 +42,10 @@ from astrbot.core.provider.entities import ( ProviderRequest, ToolCallsResult, ) +from astrbot.core.provider.modalities import ( + log_context_sanitize_stats, + sanitize_contexts_by_modalities, +) from astrbot.core.provider.provider import Provider from ..context.compressor import ContextCompressor @@ -300,8 +304,13 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): if isinstance(msg, dict) and msg.get("_no_save"): m._no_save = True messages.append(m) - if request.prompt is not None: - m = await request.assemble_context() + if ( + request.prompt is not None + or request.image_urls + or request.audio_urls + or request.extra_user_content_parts + ): + m = await self._assemble_request_context_for_provider(request) messages.append(Message.model_validate(m)) if request.system_prompt: messages.insert( @@ -318,6 +327,42 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): return f"`{self.read_tool.name}`" return "the available file-read tool" + async def _assemble_request_context_for_provider( + self, + request: ProviderRequest, + ) -> dict[str, T.Any]: + modalities = self.provider.provider_config.get("modalities", None) + if not isinstance(modalities, list): + return await request.assemble_context() + + supports_image = "image" in modalities + supports_audio = "audio" in modalities + if supports_image and supports_audio: + return await request.assemble_context() + + adjusted_request = replace( + request, + image_urls=request.image_urls if supports_image else [], + audio_urls=request.audio_urls if supports_audio else [], + ) + context = await adjusted_request.assemble_context() + content = context.get("content") + if isinstance(content, str): + content_blocks: list[dict[str, T.Any]] = [{"type": "text", "text": content}] + elif isinstance(content, list): + content_blocks = content + else: + content_blocks = [] + + if not supports_image: + for _ in request.image_urls: + content_blocks.append({"type": "text", "text": "[Image]"}) + if not supports_audio: + for _ in request.audio_urls: + content_blocks.append({"type": "text", "text": "[Audio]"}) + + return {"role": "user", "content": content_blocks} + async def _write_tool_result_overflow_file( self, *, @@ -415,8 +460,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): ) -> T.AsyncGenerator[LLMResponse, None]: """Yields chunks *and* a final LLMResponse.""" payload = { - "contexts": self.run_context.messages, # list[Message] - "func_tool": self.req.func_tool, + "contexts": self._sanitize_contexts_for_provider(self.run_context.messages), + "func_tool": self._func_tool_for_provider(), "session_id": self.req.session_id, "extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart] "abort_signal": self._abort_signal, @@ -532,6 +577,35 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): completion_text="All available chat models are unavailable.", ) + def _sanitize_contexts_for_provider( + self, + contexts: list[Message] | list[dict[str, T.Any]], + ) -> list[Message] | list[dict[str, T.Any]]: + if not self._should_fix_modalities_for_provider(): + return contexts + sanitized_contexts, stats = sanitize_contexts_by_modalities( + contexts, + self.provider.provider_config.get("modalities", None), + ) + log_context_sanitize_stats(stats) + return sanitized_contexts + + def _should_fix_modalities_for_provider(self) -> bool: + modalities = self.provider.provider_config.get("modalities", None) + return isinstance(modalities, list) + + def _func_tool_for_provider(self) -> ToolSet | None: + if not self.req.func_tool: + return None + modalities = self.provider.provider_config.get("modalities", None) + if isinstance(modalities, list) and "tool_use" not in modalities: + logger.debug( + "Provider %s does not support tool_use, clearing tools for request.", + self.provider, + ) + return None + return self.req.func_tool + def _simple_print_message_role(self, tag: str = ""): roles = [] for message in self.run_context.messages: @@ -1196,7 +1270,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): if param_subset.tools and tool_names: contexts = self._build_tool_requery_context(tool_names) requery_resp = await self.provider.text_chat( - contexts=contexts, + contexts=self._sanitize_contexts_for_provider(contexts), func_tool=param_subset, model=self.req.model, session_id=self.req.session_id, @@ -1222,7 +1296,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): extra_instruction=self.SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION, ) repair_resp = await self.provider.text_chat( - contexts=repair_contexts, + contexts=self._sanitize_contexts_for_provider(repair_contexts), func_tool=param_subset, model=self.req.model, session_id=self.req.session_id, diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 86bbd9632..fcbd16826 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -850,136 +850,6 @@ async def _decorate_llm_request( _apply_workspace_extra_prompt(event, req) -def _modalities_fix(provider: Provider, req: ProviderRequest) -> None: - if req.image_urls: - provider_cfg = provider.provider_config.get("modalities", ["image"]) - if "image" not in provider_cfg: - logger.debug( - "Provider %s does not support image, using placeholder.", provider - ) - image_count = len(req.image_urls) - placeholder = " ".join(["[Image]"] * image_count) - if req.prompt: - req.prompt = f"{placeholder} {req.prompt}" - else: - req.prompt = placeholder - req.image_urls = [] - if req.audio_urls: - provider_cfg = provider.provider_config.get("modalities", ["audio"]) - if "audio" not in provider_cfg: - logger.debug( - "Provider %s does not support audio, using placeholder.", provider - ) - audio_count = len(req.audio_urls) - placeholder = " ".join(["[Audio]"] * audio_count) - if req.prompt: - req.prompt = f"{placeholder} {req.prompt}" - else: - req.prompt = placeholder - req.audio_urls = [] - if req.func_tool: - provider_cfg = provider.provider_config.get("modalities", ["tool_use"]) - if "tool_use" not in provider_cfg: - logger.debug( - "Provider %s does not support tool_use, clearing tools.", provider - ) - req.func_tool = None - - -def _sanitize_context_by_modalities( - config: MainAgentBuildConfig, - provider: Provider, - req: ProviderRequest, -) -> None: - if not config.sanitize_context_by_modalities: - return - if not isinstance(req.contexts, list) or not req.contexts: - return - modalities = provider.provider_config.get("modalities", None) - if not modalities or not isinstance(modalities, list): - return - supports_image = bool("image" in modalities) - supports_audio = bool("audio" in modalities) - supports_tool_use = bool("tool_use" in modalities) - if supports_image and supports_audio and supports_tool_use: - return - - sanitized_contexts: list[dict] = [] - removed_image_blocks = 0 - removed_audio_blocks = 0 - removed_tool_messages = 0 - removed_tool_calls = 0 - - for msg in req.contexts: - if not isinstance(msg, dict): - continue - role = msg.get("role") - if not role: - continue - - new_msg = msg - if not supports_tool_use: - if role == "tool": - removed_tool_messages += 1 - continue - if role == "assistant" and "tool_calls" in new_msg: - if "tool_calls" in new_msg: - removed_tool_calls += 1 - new_msg.pop("tool_calls", None) - new_msg.pop("tool_call_id", None) - - if not supports_image or not supports_audio: - content = new_msg.get("content") - if isinstance(content, list): - filtered_parts: list = [] - removed_any_multimodal = False - for part in content: - if isinstance(part, dict): - part_type = str(part.get("type", "")).lower() - if not supports_image and part_type in {"image_url", "image"}: - removed_any_multimodal = True - removed_image_blocks += 1 - continue - if not supports_audio and part_type in { - "audio_url", - "input_audio", - }: - removed_any_multimodal = True - removed_audio_blocks += 1 - continue - filtered_parts.append(part) - if removed_any_multimodal: - new_msg["content"] = filtered_parts - - if role == "assistant": - content = new_msg.get("content") - has_tool_calls = bool(new_msg.get("tool_calls")) - if not has_tool_calls: - if not content: - continue - if isinstance(content, str) and not content.strip(): - continue - - sanitized_contexts.append(new_msg) - - if ( - removed_image_blocks - or removed_audio_blocks - or removed_tool_messages - or removed_tool_calls - ): - logger.debug( - "sanitize_context_by_modalities applied: " - "removed_image_blocks=%s, removed_audio_blocks=%s, " - "removed_tool_messages=%s, removed_tool_calls=%s", - removed_image_blocks, - removed_audio_blocks, - removed_tool_messages, - removed_tool_calls, - ) - req.contexts = sanitized_contexts - - def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None: """根据事件中的插件设置,过滤请求中的工具列表。 @@ -1424,10 +1294,8 @@ async def build_main_agent( if not req.session_id: req.session_id = event.unified_msg_origin - _modalities_fix(provider, req) _plugin_tool_fix(event, req) await _apply_web_search_tools(event, req, plugin_context) - _sanitize_context_by_modalities(config, provider, req) if config.llm_safety_mode: _apply_llm_safety_mode(config, req) diff --git a/astrbot/core/computer/file_read_utils.py b/astrbot/core/computer/file_read_utils.py index f22c1891f..5b5fd9fc8 100644 --- a/astrbot/core/computer/file_read_utils.py +++ b/astrbot/core/computer/file_read_utils.py @@ -91,7 +91,7 @@ print( json.dumps( {{ "size_bytes": path.stat().st_size, - "sample_b64": base64.b64encode(sample).decode("ascii"), + "sample_b64": base64.b64encode(sample).decode("utf-8"), }} ) ) @@ -140,7 +140,7 @@ print( json.dumps( {{ "size_bytes": len(data), - "base64": base64.b64encode(data).decode("ascii"), + "base64": base64.b64encode(data).decode("utf-8"), }} ) ) @@ -278,7 +278,7 @@ async def _probe_local_file(path: str) -> dict[str, str | int]: sample = file_obj.read(_FILE_SNIFF_BYTES) return { "size_bytes": file_path.stat().st_size, - "sample_b64": base64.b64encode(sample).decode("ascii"), + "sample_b64": base64.b64encode(sample).decode("utf-8"), } return await to_thread(_run) @@ -289,7 +289,7 @@ async def _read_local_image_base64(path: str) -> dict[str, str | int]: data = Path(path).read_bytes() return { "size_bytes": len(data), - "base64": base64.b64encode(data).decode("ascii"), + "base64": base64.b64encode(data).decode("utf-8"), } return await to_thread(_run) @@ -319,7 +319,7 @@ async def _compress_image_bytes_to_base64(data: bytes) -> dict[str, str | int]: return { "size_bytes": len(compressed_bytes), - "base64": base64.b64encode(compressed_bytes).decode("ascii"), + "base64": base64.b64encode(compressed_bytes).decode("utf-8"), "mime_type": "image/jpeg", } @@ -696,14 +696,14 @@ async def read_file_tool_result( return "Error reading file: image payload is empty." raw_bytes = base64.b64decode(raw_base64_data) compressed_payload = await _compress_image_bytes_to_base64(raw_bytes) - base64_data = str(compressed_payload.get("base64", "") or "") - if not base64_data: + compressed_base64_data = str(compressed_payload.get("base64", "") or "") + if not compressed_base64_data: return "Error reading file: compressed image payload is empty." return mcp.types.CallToolResult( content=[ mcp.types.ImageContent( type="image", - data=base64_data, + data=compressed_base64_data, mimeType=str( compressed_payload.get("mime_type", "") or "image/jpeg" ), diff --git a/astrbot/core/provider/modalities.py b/astrbot/core/provider/modalities.py new file mode 100644 index 000000000..66ac74e9b --- /dev/null +++ b/astrbot/core/provider/modalities.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import copy +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any + +from astrbot import logger +from astrbot.core.agent.message import Message + + +@dataclass(slots=True) +class ContextSanitizeStats: + fixed_image_blocks: int = 0 + fixed_audio_blocks: int = 0 + fixed_tool_messages: int = 0 + removed_tool_calls: int = 0 + + @property + def changed(self) -> bool: + return bool( + self.fixed_image_blocks + or self.fixed_audio_blocks + or self.fixed_tool_messages + or self.removed_tool_calls + ) + + +def _message_to_dict(message: dict[str, Any] | Message) -> dict[str, Any] | None: + if isinstance(message, Message): + return dict(message.model_dump()) + if isinstance(message, dict): + return dict(copy.deepcopy(message)) + return None + + +def sanitize_contexts_by_modalities( + contexts: Sequence[dict[str, Any] | Message], + modalities: list[str] | None, +) -> tuple[list[dict[str, Any]], ContextSanitizeStats]: + if not contexts: + return [], ContextSanitizeStats() + if not modalities or not isinstance(modalities, list): + copied_contexts = [] + for msg in contexts: + copied_msg = _message_to_dict(msg) + if copied_msg: + copied_contexts.append(copied_msg) + return copied_contexts, ContextSanitizeStats() + + supports_image = "image" in modalities + supports_audio = "audio" in modalities + supports_tool_use = "tool_use" in modalities + if supports_image and supports_audio and supports_tool_use: + copied_contexts = [] + for msg in contexts: + copied_msg = _message_to_dict(msg) + if copied_msg: + copied_contexts.append(copied_msg) + return copied_contexts, ContextSanitizeStats() + + sanitized_contexts: list[dict[str, Any]] = [] + stats = ContextSanitizeStats() + + for raw_msg in contexts: + msg = _message_to_dict(raw_msg) + if not msg: + continue + role = msg.get("role") + if not role: + continue + + if not supports_tool_use: + if role == "tool": + stats.fixed_tool_messages += 1 + fixed_msg: dict[str, Any] = { + "role": "user", + "content": _tool_result_placeholder(msg.get("content")), + } + msg = fixed_msg + if role == "assistant" and "tool_calls" in msg: + stats.removed_tool_calls += 1 + msg.pop("tool_calls", None) + msg.pop("tool_call_id", None) + + if not supports_image or not supports_audio: + content = msg.get("content") + if isinstance(content, list): + filtered_parts: list[Any] = [] + removed_any_multimodal = False + for part in content: + if isinstance(part, dict): + part_type = str(part.get("type", "")).lower() + if not supports_image and part_type in {"image_url", "image"}: + removed_any_multimodal = True + stats.fixed_image_blocks += 1 + filtered_parts.append({"type": "text", "text": "[Image]"}) + continue + if not supports_audio and part_type in { + "audio_url", + "input_audio", + }: + removed_any_multimodal = True + stats.fixed_audio_blocks += 1 + filtered_parts.append({"type": "text", "text": "[Audio]"}) + continue + filtered_parts.append(part) + if removed_any_multimodal: + msg["content"] = filtered_parts + + if role == "assistant": + content = msg.get("content") + has_tool_calls = bool(msg.get("tool_calls")) + if not has_tool_calls: + if not content: + continue + if isinstance(content, str) and not content.strip(): + continue + + sanitized_contexts.append(msg) + + return sanitized_contexts, stats + + +def _tool_result_placeholder(content: Any) -> str: + if isinstance(content, str): + content_text = content.strip() + elif isinstance(content, list): + text_parts: list[str] = [] + for part in content: + if isinstance(part, dict): + part_type = str(part.get("type", "")).lower() + if part_type == "text": + text_parts.append(str(part.get("text", ""))) + elif part_type in {"image_url", "image"}: + text_parts.append("[Image]") + elif part_type in {"audio_url", "input_audio"}: + text_parts.append("[Audio]") + content_text = "\n".join(part for part in text_parts if part).strip() + else: + content_text = "" + if not content_text: + return "[Tool result]" + return f"[Tool result]\n{content_text}" + + +def log_context_sanitize_stats(stats: ContextSanitizeStats) -> None: + if not stats.changed: + return + logger.debug( + "context modality fix applied: " + "fixed_image_blocks=%s, fixed_audio_blocks=%s, " + "fixed_tool_messages=%s, removed_tool_calls=%s", + stats.fixed_image_blocks, + stats.fixed_audio_blocks, + stats.fixed_tool_messages, + stats.removed_tool_calls, + ) diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py index 8d3160ff5..60c21d949 100644 --- a/astrbot/core/tools/computer_tools/fs.py +++ b/astrbot/core/tools/computer_tools/fs.py @@ -171,7 +171,7 @@ def _decode_escaped_text(value: str) -> str: @dataclass class FileReadTool(FunctionTool): name: str = "astrbot_file_read_tool" - description: str = "read file content." + description: str = "read file content. Supports text, image, and PDF (text extraction), docx and epub files." parameters: dict = field( default_factory=lambda: { "type": "object", diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py index 00caed67d..74d069108 100644 --- a/tests/test_tool_loop_agent_runner.py +++ b/tests/test_tool_loop_agent_runner.py @@ -14,6 +14,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..") from astrbot.core.agent.agent import Agent from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.hooks import BaseAgentRunHooks +from astrbot.core.agent.message import ImageURLPart, Message, TextPart from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner from astrbot.core.agent.tool import FunctionTool, ToolSet @@ -156,6 +157,25 @@ class MockErrProvider(MockProvider): ) +class CapturingProvider(MockProvider): + def __init__(self, modalities: list[str]): + super().__init__() + self.provider_config["modalities"] = modalities + self.received_contexts = [] + self.received_func_tools = [] + self.should_call_tools = False + + async def text_chat(self, **kwargs) -> LLMResponse: + self.call_count += 1 + self.received_contexts.append(kwargs.get("contexts")) + self.received_func_tools.append(kwargs.get("func_tool")) + return LLMResponse( + role="assistant", + completion_text="final", + usage=TokenUsage(input_other=10, output=5), + ) + + class MockEmptyOutputThenSuccessProvider(MockProvider): def __init__(self, failures_before_success: int = 1): super().__init__() @@ -615,6 +635,99 @@ async def test_tool_result_includes_all_calltoolresult_content( ] +@pytest.mark.asyncio +async def test_runner_replaces_runtime_image_context_before_provider_call( + runner, provider_request, mock_hooks +): + provider = CapturingProvider(modalities=["tool_use"]) + + await runner.reset( + provider=provider, + request=provider_request, + run_context=ContextWrapper(context=None), + tool_executor=MockToolExecutor, + agent_hooks=mock_hooks, + streaming=False, + ) + + runner.run_context.messages.append( + Message( + role="user", + content=[ + TextPart(text="Review this image"), + ImageURLPart( + image_url=ImageURLPart.ImageURL( + url="data:image/png;base64,dGVzdA==" + ) + ), + ], + ) + ) + + async for _ in runner.step_until_done(1): + pass + + assert provider.received_contexts + sent_context = provider.received_contexts[0] + assert sent_context[-1]["content"] == [ + {"type": "text", "text": "Review this image"}, + {"type": "text", "text": "[Image]"}, + ] + assert len(runner.run_context.messages[-2].content) == 2 + + +@pytest.mark.asyncio +async def test_runner_builds_placeholder_for_unsupported_request_image( + runner, mock_hooks, tool_set +): + provider = CapturingProvider(modalities=["tool_use"]) + request = ProviderRequest( + prompt="Describe it", + image_urls=["/path/that/should/not/be/read.jpg"], + func_tool=tool_set, + contexts=[], + ) + + await runner.reset( + provider=provider, + request=request, + run_context=ContextWrapper(context=None), + tool_executor=MockToolExecutor, + agent_hooks=mock_hooks, + streaming=False, + ) + + async for _ in runner.step_until_done(1): + pass + + sent_context = provider.received_contexts[0] + assert sent_context[-1]["content"] == [ + {"type": "text", "text": "Describe it"}, + {"type": "text", "text": "[Image]"}, + ] + + +@pytest.mark.asyncio +async def test_runner_clears_tools_for_provider_without_tool_use( + runner, provider_request, mock_hooks, mock_tool_executor +): + provider = CapturingProvider(modalities=["text"]) + + await runner.reset( + provider=provider, + request=provider_request, + run_context=ContextWrapper(context=None), + tool_executor=mock_tool_executor, + agent_hooks=mock_hooks, + streaming=False, + ) + + async for _ in runner.step_until_done(1): + pass + + assert provider.received_func_tools == [None] + + @pytest.mark.asyncio async def test_same_tool_consecutive_results_include_escalating_guidance( runner, mock_tool_executor, mock_hooks diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index c953815a8..6ca1a3f2a 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -713,144 +713,6 @@ class TestDecorateLlmRequest: assert req.prompt == "Hello" -class TestModalitiesFix: - """Tests for _modalities_fix function.""" - - def test_modalities_fix_image_not_supported(self, mock_provider): - """Test modality fix when image is not supported.""" - module = ama - mock_provider.provider_config = {"modalities": ["text"]} - req = ProviderRequest(prompt="Hello", image_urls=["/path/to/image.jpg"]) - - module._modalities_fix(mock_provider, req) - - assert "[Image]" in req.prompt - assert req.image_urls == [] - - def test_modalities_fix_tool_not_supported(self, mock_provider): - """Test modality fix when tool is not supported.""" - module = ama - mock_provider.provider_config = {"modalities": ["text", "image"]} - req = ProviderRequest(prompt="Hello") - req.func_tool = ToolSet() - req.func_tool.add_tool( - FunctionTool( - name="dummy_tool", - description="dummy", - parameters={"type": "object", "properties": {}}, - ) - ) - - module._modalities_fix(mock_provider, req) - - assert req.func_tool is None - - def test_modalities_fix_all_supported(self, mock_provider): - """Test modality fix when all features are supported.""" - module = ama - mock_provider.provider_config = {"modalities": ["image", "tool_use"]} - tool_set = ToolSet() - tool_set.add_tool( - FunctionTool( - name="dummy_tool", - description="dummy", - parameters={"type": "object", "properties": {}}, - ) - ) - req = ProviderRequest( - prompt="Hello", - image_urls=["/path/to/image.jpg"], - func_tool=tool_set, - ) - - module._modalities_fix(mock_provider, req) - - assert req.prompt == "Hello" - assert len(req.image_urls) == 1 - assert req.func_tool is not None - - -class TestSanitizeContextByModalities: - """Tests for _sanitize_context_by_modalities function.""" - - def test_sanitize_no_op(self, mock_provider): - """Test sanitize when disabled or modalities support everything.""" - module = ama - config = module.MainAgentBuildConfig( - tool_call_timeout=60, sanitize_context_by_modalities=False - ) - mock_provider.provider_config = {"modalities": ["image", "tool_use"]} - req = ProviderRequest(contexts=[{"role": "user", "content": "Hello"}]) - - module._sanitize_context_by_modalities(config, mock_provider, req) - - assert len(req.contexts) == 1 - - def test_sanitize_removes_tool_messages(self, mock_provider): - """Test sanitize removes tool messages when tool_use not supported.""" - module = ama - config = module.MainAgentBuildConfig( - tool_call_timeout=60, sanitize_context_by_modalities=True - ) - mock_provider.provider_config = {"modalities": ["image"]} - req = ProviderRequest( - contexts=[ - {"role": "user", "content": "Hello"}, - {"role": "tool", "content": "Tool result"}, - ] - ) - - module._sanitize_context_by_modalities(config, mock_provider, req) - - assert len(req.contexts) == 1 - assert req.contexts[0]["role"] == "user" - - def test_sanitize_removes_tool_calls(self, mock_provider): - """Test sanitize removes tool_calls from assistant messages.""" - module = ama - config = module.MainAgentBuildConfig( - tool_call_timeout=60, sanitize_context_by_modalities=True - ) - mock_provider.provider_config = {"modalities": ["image"]} - req = ProviderRequest( - contexts=[ - { - "role": "assistant", - "content": "Response", - "tool_calls": [{"name": "tool"}], - } - ] - ) - - module._sanitize_context_by_modalities(config, mock_provider, req) - - assert "tool_calls" not in req.contexts[0] - - def test_sanitize_removes_image_blocks(self, mock_provider): - """Test sanitize removes image blocks when image not supported.""" - module = ama - config = module.MainAgentBuildConfig( - tool_call_timeout=60, sanitize_context_by_modalities=True - ) - mock_provider.provider_config = {"modalities": ["tool_use"]} - req = ProviderRequest( - contexts=[ - { - "role": "user", - "content": [ - {"type": "text", "text": "Hello"}, - {"type": "image_url", "url": "image.jpg"}, - ], - } - ] - ) - - module._sanitize_context_by_modalities(config, mock_provider, req) - - assert len(req.contexts[0]["content"]) == 1 - assert req.contexts[0]["content"][0]["type"] == "text" - - class TestPluginToolFix: """Tests for _plugin_tool_fix function.""" From 36d6f3b67ecab7cc6b473072818cde618d191bc3 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:51:12 +0800 Subject: [PATCH 21/24] feat: add inline message editing and regeneration functionality for webui (#7673) * feat: add inline message editing and regeneration functionality for webui - Implemented inline editing for user messages in the chat component. - Added a regenerate menu for retrying messages with different models. - Enhanced message handling to include llm_checkpoint_id for better tracking. - Updated localization files to include new actions for retrying and model selection. - Introduced tests for checkpoint message handling and chat route functionality. * feat: thread mode in webui * feat: enhance message editing functionality to allow only the latest user message to be edited * feat: add error handling and user feedback for thread creation in chat component * feat: add thread count display and localization support in chat component * feat: add RefsSidebar component and integrate reference management in chat UI * feat: improve message editing validation and cleanup for bot messages * feat: enhance checkpoint message handling with binding and dumping functionality --- astrbot/core/agent/message.py | 106 +- .../agent/runners/coze/coze_agent_runner.py | 3 + .../agent/runners/tool_loop_agent_runner.py | 14 +- astrbot/core/astr_main_agent.py | 11 + astrbot/core/backup/constants.py | 2 + astrbot/core/db/__init__.py | 79 ++ astrbot/core/db/po.py | 31 + astrbot/core/db/sqlite.py | 189 +++ .../method/agent_sub_stages/internal.py | 20 +- .../sources/webchat/webchat_adapter.py | 4 + astrbot/core/platform_message_history_mgr.py | 19 + astrbot/core/provider/entities.py | 3 + astrbot/core/provider/provider.py | 4 +- astrbot/dashboard/routes/chat.py | 680 +++++++++- astrbot/dashboard/routes/live_chat.py | 21 +- dashboard/src/components/chat/Chat.vue | 981 ++++---------- dashboard/src/components/chat/ChatInput.vue | 89 +- .../src/components/chat/ChatMessageList.vue | 1138 +++++++++++++++++ .../src/components/chat/ProviderModelMenu.vue | 24 +- .../src/components/chat/RegenerateMenu.vue | 237 ++++ dashboard/src/components/chat/ThreadPanel.vue | 543 ++++++++ .../chat/ThreadedMarkdownMessagePart.vue | 89 ++ .../chat/message_list_comps/ActionRef.vue | 20 +- .../chat/message_list_comps/RefsSidebar.vue | 9 +- .../chat/message_list_comps/ThreadNode.vue | 63 + .../src/components/shared/StyledMenu.vue | 19 +- dashboard/src/composables/useMessages.ts | 239 +++- .../src/i18n/locales/en-US/features/chat.json | 12 + .../src/i18n/locales/ru-RU/features/chat.json | 12 + .../src/i18n/locales/zh-CN/features/chat.json | 12 + tests/test_conversation_checkpoint.py | 110 ++ 31 files changed, 3996 insertions(+), 787 deletions(-) create mode 100644 dashboard/src/components/chat/ChatMessageList.vue create mode 100644 dashboard/src/components/chat/RegenerateMenu.vue create mode 100644 dashboard/src/components/chat/ThreadPanel.vue create mode 100644 dashboard/src/components/chat/ThreadedMarkdownMessagePart.vue create mode 100644 dashboard/src/components/chat/message_list_comps/ThreadNode.vue create mode 100644 tests/test_conversation_checkpoint.py diff --git a/astrbot/core/agent/message.py b/astrbot/core/agent/message.py index bde6353ff..ad3b57cb2 100644 --- a/astrbot/core/agent/message.py +++ b/astrbot/core/agent/message.py @@ -7,6 +7,7 @@ from pydantic import ( BaseModel, GetCoreSchemaHandler, PrivateAttr, + ValidationError, model_serializer, model_validator, ) @@ -165,6 +166,15 @@ class ToolCallPart(BaseModel): """A part of the arguments of the tool call.""" +class CheckpointData(BaseModel): + """Internal checkpoint data for linking LLM turns to platform history.""" + + id: str + + +CHECKPOINT_ROLE = "_checkpoint" + + class Message(BaseModel): """A message in a conversation.""" @@ -173,9 +183,10 @@ class Message(BaseModel): "user", "assistant", "tool", + "_checkpoint", ] - content: str | list[ContentPart] | None = None + content: str | list[ContentPart] | CheckpointData | None = None """The content of the message.""" tool_calls: list[ToolCall] | list[dict] | None = None @@ -185,9 +196,18 @@ class Message(BaseModel): """The ID of the tool call.""" _no_save: bool = PrivateAttr(default=False) + _checkpoint_after: CheckpointData | None = PrivateAttr(default=None) @model_validator(mode="after") def check_content_required(self): + if self.role == CHECKPOINT_ROLE: + if not isinstance(self.content, CheckpointData): + raise ValueError("checkpoint message content must be CheckpointData") + return self + + if isinstance(self.content, CheckpointData): + raise ValueError("CheckpointData is only allowed for role='_checkpoint'") + # assistant + tool_calls is not None: allow content to be None if self.role == "assistant" and self.tool_calls is not None: return self @@ -231,3 +251,87 @@ class SystemMessageSegment(Message): """A message segment from the system.""" role: Literal["system"] = "system" + + +class CheckpointMessageSegment(Message): + """Internal checkpoint segment for persisted conversation history.""" + + role: Literal["_checkpoint"] = "_checkpoint" + content: CheckpointData | None = None + + +def is_checkpoint_message(message: Message | dict) -> bool: + """Return whether a message is an internal checkpoint.""" + if isinstance(message, Message): + return message.role == CHECKPOINT_ROLE + return isinstance(message, dict) and message.get("role") == CHECKPOINT_ROLE + + +def get_checkpoint_id(message: Message | dict) -> str | None: + """Return the checkpoint id from an internal checkpoint message.""" + if not is_checkpoint_message(message): + return None + + content = ( + message.content if isinstance(message, Message) else message.get("content") + ) + if isinstance(content, CheckpointData): + return content.id + if isinstance(content, dict): + checkpoint_id = content.get("id") + return ( + checkpoint_id if isinstance(checkpoint_id, str) and checkpoint_id else None + ) + return None + + +def strip_checkpoint_messages(history: list[dict]) -> list[dict]: + """Remove internal checkpoint messages from provider-facing history.""" + return [message for message in history if not is_checkpoint_message(message)] + + +def _get_checkpoint_data(message: Message | dict) -> CheckpointData | None: + if not is_checkpoint_message(message): + return None + + content = ( + message.content if isinstance(message, Message) else message.get("content") + ) + if isinstance(content, CheckpointData): + return content + if isinstance(content, dict): + try: + return CheckpointData.model_validate(content) + except ValidationError: + return None + return None + + +def bind_checkpoint_messages(history: list[dict]) -> list[Message]: + """Load persisted history and bind checkpoint segments to prior messages.""" + messages: list[Message] = [] + for item in history: + if is_checkpoint_message(item): + checkpoint = _get_checkpoint_data(item) + if checkpoint is not None and messages: + messages[-1]._checkpoint_after = checkpoint + continue + + message = Message.model_validate(item) + if item.get("_no_save"): + message._no_save = True + messages.append(message) + + return messages + + +def dump_messages_with_checkpoints(messages: list[Message]) -> list[dict]: + """Dump runtime messages and reinsert bound checkpoint segments.""" + dumped: list[dict] = [] + for message in messages: + dumped.append(message.model_dump()) + if message._checkpoint_after is not None: + dumped.append( + CheckpointMessageSegment(content=message._checkpoint_after).model_dump() + ) + return dumped diff --git a/astrbot/core/agent/runners/coze/coze_agent_runner.py b/astrbot/core/agent/runners/coze/coze_agent_runner.py index a8300bb71..e3e7f2c51 100644 --- a/astrbot/core/agent/runners/coze/coze_agent_runner.py +++ b/astrbot/core/agent/runners/coze/coze_agent_runner.py @@ -13,6 +13,7 @@ from astrbot.core.provider.entities import ( ) from ...hooks import BaseAgentRunHooks +from ...message import is_checkpoint_message from ...response import AgentResponseData from ...run_context import ContextWrapper, TContext from ..base import AgentResponse, AgentState, BaseAgentRunner @@ -148,6 +149,8 @@ class CozeAgentRunner(BaseAgentRunner[TContext]): # 处理历史上下文 if not self.auto_save_history and contexts: for ctx in contexts: + if is_checkpoint_message(ctx): + continue if isinstance(ctx, dict) and "role" in ctx and "content" in ctx: # 处理上下文中的图片 content = ctx["content"] diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 9d8cd2df2..132ca8192 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -53,7 +53,12 @@ from ..context.config import ContextConfig from ..context.manager import ContextManager from ..context.token_counter import EstimateTokenCounter, TokenCounter from ..hooks import BaseAgentRunHooks -from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment +from ..message import ( + AssistantMessageSegment, + Message, + ToolCallMessageSegment, + bind_checkpoint_messages, +) from ..response import AgentResponseData, AgentStats from ..run_context import ContextWrapper, TContext from ..tool_executor import BaseFunctionToolExecutor @@ -297,13 +302,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): # MODIFIE the req.func_tool to use light tool schemas self.req.func_tool = light_set - messages = [] # append existing messages in the run context - for msg in request.contexts: - m = Message.model_validate(msg) - if isinstance(msg, dict) and msg.get("_no_save"): - m._no_save = True - messages.append(m) + messages = bind_checkpoint_messages(request.contexts or []) if ( request.prompt is not None or request.image_urls diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index fcbd16826..a9ddb2e7b 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -1272,6 +1272,17 @@ async def build_main_agent( if isinstance(req.contexts, str): req.contexts = json.loads(req.contexts) + thread_selected_text = event.get_extra("thread_selected_text") + if isinstance(thread_selected_text, str) and thread_selected_text.strip(): + req.extra_user_content_parts.append( + TextPart( + text=( + "The user is asking in a side thread about this selected " + "excerpt from the previous assistant answer:\n" + f"{thread_selected_text.strip()}" + ) + ) + ) req.image_urls = normalize_and_dedupe_strings(req.image_urls) req.audio_urls = normalize_and_dedupe_strings(req.audio_urls) diff --git a/astrbot/core/backup/constants.py b/astrbot/core/backup/constants.py index b832a1b72..493d2670c 100644 --- a/astrbot/core/backup/constants.py +++ b/astrbot/core/backup/constants.py @@ -18,6 +18,7 @@ from astrbot.core.db.po import ( PlatformStat, Preference, SessionProjectRelation, + WebChatThread, ) from astrbot.core.knowledge_base.models import ( KBDocument, @@ -46,6 +47,7 @@ MAIN_DB_MODELS: dict[str, type[SQLModel]] = { "preferences": Preference, "platform_message_history": PlatformMessageHistory, "platform_sessions": PlatformSession, + "webchat_threads": WebChatThread, "chatui_projects": ChatUIProject, "session_project_relations": SessionProjectRelation, "attachments": Attachment, diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index 087aa625b..1800887fb 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -24,6 +24,7 @@ from astrbot.core.db.po import ( ProviderStat, SessionProjectRelation, Stats, + WebChatThread, ) @@ -204,10 +205,26 @@ class BaseDatabase(abc.ABC): content: dict, sender_id: str | None = None, sender_name: str | None = None, + llm_checkpoint_id: str | None = None, ) -> PlatformMessageHistory: """Insert a new platform message history record.""" ... + @abc.abstractmethod + async def update_platform_message_history( + self, + message_id: int, + content: dict | None = None, + llm_checkpoint_id: str | None = None, + ) -> None: + """Update a platform message history record.""" + ... + + @abc.abstractmethod + async def delete_platform_message_history_by_id(self, message_id: int) -> None: + """Delete a platform message history record by its ID.""" + ... + @abc.abstractmethod async def delete_platform_message_offset( self, @@ -237,6 +254,68 @@ class BaseDatabase(abc.ABC): """Get a platform message history record by its ID.""" ... + @abc.abstractmethod + async def create_webchat_thread( + self, + creator: str, + parent_session_id: str, + parent_message_id: int, + base_checkpoint_id: str, + selected_text: str, + ) -> WebChatThread: + """Create a WebChat side thread.""" + ... + + @abc.abstractmethod + async def get_webchat_thread_by_id( + self, + thread_id: str, + ) -> WebChatThread | None: + """Get a WebChat side thread by thread_id.""" + ... + + @abc.abstractmethod + async def get_webchat_threads_by_parent_session( + self, + parent_session_id: str, + creator: str | None = None, + ) -> list[WebChatThread]: + """Get side threads for a parent WebChat session.""" + ... + + @abc.abstractmethod + async def get_webchat_thread_by_parent_message_and_text( + self, + parent_session_id: str, + parent_message_id: int, + selected_text: str, + creator: str | None = None, + ) -> WebChatThread | None: + """Get an existing side thread for the same selected text.""" + ... + + @abc.abstractmethod + async def delete_webchat_thread(self, thread_id: str) -> None: + """Delete a WebChat side thread.""" + ... + + @abc.abstractmethod + async def delete_webchat_threads_by_parent_session( + self, + parent_session_id: str, + ) -> list[str]: + """Delete side threads for a parent WebChat session.""" + ... + + @abc.abstractmethod + async def delete_webchat_threads_by_parent_message_ids( + self, + parent_session_id: str, + parent_message_ids: list[int], + ) -> list[str]: + """Delete side threads linked to parent message IDs.""" + ... + @abc.abstractmethod async def insert_attachment( self, diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index cabc3432c..0d3b9822a 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -244,6 +244,37 @@ class PlatformMessageHistory(TimestampMixin, SQLModel, table=True): default=None, ) # Name of the sender in the platform content: dict = Field(sa_type=JSON, nullable=False) # a message chain list + llm_checkpoint_id: str | None = Field(default=None, index=True) + + +class WebChatThread(TimestampMixin, SQLModel, table=True): + """A side thread created from a selected WebChat assistant response.""" + + __tablename__: str = "webchat_threads" + + id: int | None = Field( + primary_key=True, + sa_column_kwargs={"autoincrement": True}, + default=None, + ) + thread_id: str = Field( + max_length=36, + nullable=False, + unique=True, + default_factory=lambda: str(uuid.uuid4()), + ) + creator: str = Field(nullable=False, index=True) + parent_session_id: str = Field(nullable=False, index=True) + parent_message_id: int = Field(nullable=False, index=True) + base_checkpoint_id: str = Field(nullable=False, index=True) + selected_text: str = Field(sa_type=Text, nullable=False) + + __table_args__ = ( + UniqueConstraint( + "thread_id", + name="uix_webchat_thread_id", + ), + ) class PlatformSession(TimestampMixin, SQLModel, table=True): diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index fd6668c0c..d79ac9d70 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -26,6 +26,7 @@ from astrbot.core.db.po import ( ProviderStat, SessionProjectRelation, SQLModel, + WebChatThread, ) from astrbot.core.db.po import ( Platform as DeprecatedPlatformStat, @@ -60,6 +61,7 @@ class SQLiteDatabase(BaseDatabase): await self._ensure_persona_folder_columns(conn) await self._ensure_persona_skills_column(conn) await self._ensure_persona_custom_error_message_column(conn) + await self._ensure_platform_message_history_checkpoint_column(conn) await conn.commit() async def _ensure_persona_folder_columns(self, conn) -> None: @@ -104,6 +106,26 @@ class SQLiteDatabase(BaseDatabase): text("ALTER TABLE personas ADD COLUMN custom_error_message TEXT") ) + async def _ensure_platform_message_history_checkpoint_column(self, conn) -> None: + """Ensure platform_message_history has llm_checkpoint_id.""" + result = await conn.execute(text("PRAGMA table_info(platform_message_history)")) + columns = {row[1] for row in result.fetchall()} + + if "llm_checkpoint_id" not in columns: + await conn.execute( + text( + "ALTER TABLE platform_message_history " + "ADD COLUMN llm_checkpoint_id VARCHAR DEFAULT NULL" + ) + ) + await conn.execute( + text( + "CREATE INDEX IF NOT EXISTS " + "ix_platform_message_history_llm_checkpoint_id " + "ON platform_message_history (llm_checkpoint_id)" + ) + ) + # ==== # Platform Statistics # ==== @@ -499,6 +521,7 @@ class SQLiteDatabase(BaseDatabase): content, sender_id=None, sender_name=None, + llm_checkpoint_id=None, ): """Insert a new platform message history record.""" async with self.get_db() as session: @@ -510,10 +533,46 @@ class SQLiteDatabase(BaseDatabase): content=content, sender_id=sender_id, sender_name=sender_name, + llm_checkpoint_id=llm_checkpoint_id, ) session.add(new_history) return new_history + async def update_platform_message_history( + self, + message_id: int, + content: dict | None = None, + llm_checkpoint_id: str | None = None, + ) -> None: + """Update a platform message history record.""" + values = {} + if content is not None: + values["content"] = content + if llm_checkpoint_id is not None: + values["llm_checkpoint_id"] = llm_checkpoint_id + if not values: + return + + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + await session.execute( + update(PlatformMessageHistory) + .where(PlatformMessageHistory.id == message_id) + .values(**values) + ) + + async def delete_platform_message_history_by_id(self, message_id: int) -> None: + """Delete a platform message history record by ID.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + await session.execute( + delete(PlatformMessageHistory).where( + PlatformMessageHistory.id == message_id + ) + ) + async def delete_platform_message_offset( self, platform_id, @@ -568,6 +627,136 @@ class SQLiteDatabase(BaseDatabase): result = await session.execute(query) return result.scalar_one_or_none() + async def create_webchat_thread( + self, + creator: str, + parent_session_id: str, + parent_message_id: int, + base_checkpoint_id: str, + selected_text: str, + ) -> WebChatThread: + """Create a WebChat side thread.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + thread = WebChatThread( + creator=creator, + parent_session_id=parent_session_id, + parent_message_id=parent_message_id, + base_checkpoint_id=base_checkpoint_id, + selected_text=selected_text, + ) + session.add(thread) + await session.flush() + await session.refresh(thread) + return thread + + async def get_webchat_thread_by_id( + self, + thread_id: str, + ) -> WebChatThread | None: + """Get a WebChat side thread by thread_id.""" + async with self.get_db() as session: + session: AsyncSession + result = await session.execute( + select(WebChatThread).where(WebChatThread.thread_id == thread_id) + ) + return result.scalar_one_or_none() + + async def get_webchat_threads_by_parent_session( + self, + parent_session_id: str, + creator: str | None = None, + ) -> list[WebChatThread]: + """Get side threads for a parent WebChat session.""" + async with self.get_db() as session: + session: AsyncSession + query = select(WebChatThread).where( + WebChatThread.parent_session_id == parent_session_id + ) + if creator is not None: + query = query.where(WebChatThread.creator == creator) + query = query.order_by(WebChatThread.created_at) + result = await session.execute(query) + return list(result.scalars().all()) + + async def get_webchat_thread_by_parent_message_and_text( + self, + parent_session_id: str, + parent_message_id: int, + selected_text: str, + creator: str | None = None, + ) -> WebChatThread | None: + """Get an existing side thread for the same selected text.""" + async with self.get_db() as session: + session: AsyncSession + query = select(WebChatThread).where( + WebChatThread.parent_session_id == parent_session_id, + WebChatThread.parent_message_id == parent_message_id, + WebChatThread.selected_text == selected_text, + ) + if creator is not None: + query = query.where(WebChatThread.creator == creator) + result = await session.execute(query) + return result.scalar_one_or_none() + + async def delete_webchat_thread(self, thread_id: str) -> None: + """Delete a WebChat side thread.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + await session.execute( + delete(WebChatThread).where(WebChatThread.thread_id == thread_id) + ) + + async def delete_webchat_threads_by_parent_session( + self, + parent_session_id: str, + ) -> list[str]: + """Delete side threads for a parent WebChat session.""" + threads = await self.get_webchat_threads_by_parent_session(parent_session_id) + thread_ids = [thread.thread_id for thread in threads] + if not thread_ids: + return [] + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + await session.execute( + delete(WebChatThread).where( + col(WebChatThread.thread_id).in_(thread_ids) + ) + ) + return thread_ids + + async def delete_webchat_threads_by_parent_message_ids( + self, + parent_session_id: str, + parent_message_ids: list[int], + ) -> list[str]: + """Delete side threads linked to parent message IDs.""" + if not parent_message_ids: + return [] + async with self.get_db() as session: + session: AsyncSession + result = await session.execute( + select(WebChatThread.thread_id).where( + WebChatThread.parent_session_id == parent_session_id, + col(WebChatThread.parent_message_id).in_(parent_message_ids), + ) + ) + thread_ids = list(result.scalars().all()) + if not thread_ids: + return [] + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + await session.execute( + delete(WebChatThread).where( + col(WebChatThread.thread_id).in_(thread_ids) + ) + ) + return thread_ids + async def insert_attachment(self, path, type, mime_type): """Insert a new attachment record.""" async with self.get_db() as session: diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index 0d43e4da9..c1d882656 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -6,7 +6,12 @@ from collections.abc import AsyncGenerator from dataclasses import replace from astrbot.core import db_helper, logger -from astrbot.core.agent.message import Message +from astrbot.core.agent.message import ( + CheckpointData, + CheckpointMessageSegment, + Message, + dump_messages_with_checkpoints, +) from astrbot.core.agent.response import AgentStats from astrbot.core.astr_main_agent import ( MainAgentBuildConfig, @@ -444,7 +449,7 @@ class InternalAgentSubStage(Stage): logger.debug("LLM 响应为空,不保存记录。") return - message_to_save = [] + messages_to_save: list[Message] = [] skipped_initial_system = False for message in all_messages: if message.role == "system" and not skipped_initial_system: @@ -452,7 +457,16 @@ class InternalAgentSubStage(Stage): continue if message.role in ["assistant", "user"] and message._no_save: continue - message_to_save.append(message.model_dump()) + messages_to_save.append(message) + + checkpoint_id = event.get_extra("llm_checkpoint_id") + message_to_save = dump_messages_with_checkpoints(messages_to_save) + if isinstance(checkpoint_id, str) and checkpoint_id: + message_to_save.append( + CheckpointMessageSegment( + content=CheckpointData(id=checkpoint_id), + ).model_dump() + ) # if user_aborted: # message_to_save.append( diff --git a/astrbot/core/platform/sources/webchat/webchat_adapter.py b/astrbot/core/platform/sources/webchat/webchat_adapter.py index 26b434573..b4d494b34 100644 --- a/astrbot/core/platform/sources/webchat/webchat_adapter.py +++ b/astrbot/core/platform/sources/webchat/webchat_adapter.py @@ -250,6 +250,10 @@ class WebChatAdapter(Platform): "enable_streaming", payload.get("enable_streaming", True) ) message_event.set_extra("action_type", payload.get("action_type")) + message_event.set_extra("llm_checkpoint_id", payload.get("llm_checkpoint_id")) + message_event.set_extra( + "thread_selected_text", payload.get("thread_selected_text") + ) self.commit_event(message_event) diff --git a/astrbot/core/platform_message_history_mgr.py b/astrbot/core/platform_message_history_mgr.py index ad8bb44f6..3356ce99c 100644 --- a/astrbot/core/platform_message_history_mgr.py +++ b/astrbot/core/platform_message_history_mgr.py @@ -13,6 +13,7 @@ class PlatformMessageHistoryManager: content: dict, # TODO: parse from message chain sender_id: str | None = None, sender_name: str | None = None, + llm_checkpoint_id: str | None = None, ) -> PlatformMessageHistory: """Insert a new platform message history record.""" return await self.db.insert_platform_message_history( @@ -21,6 +22,7 @@ class PlatformMessageHistoryManager: content=content, sender_id=sender_id, sender_name=sender_name, + llm_checkpoint_id=llm_checkpoint_id, ) async def get( @@ -49,3 +51,20 @@ class PlatformMessageHistoryManager: user_id=user_id, offset_sec=offset_sec, ) + + async def update( + self, + message_id: int, + content: dict | None = None, + llm_checkpoint_id: str | None = None, + ) -> None: + """Update a platform message history record.""" + await self.db.update_platform_message_history( + message_id=message_id, + content=content, + llm_checkpoint_id=llm_checkpoint_id, + ) + + async def delete_by_id(self, message_id: int) -> None: + """Delete a platform message history record by ID.""" + await self.db.delete_platform_message_history_by_id(message_id) diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index b27775cdd..d4bf8814d 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -20,6 +20,7 @@ from astrbot.core.agent.message import ( ContentPart, ToolCall, ToolCallMessageSegment, + is_checkpoint_message, ) from astrbot.core.agent.tool import ToolSet from astrbot.core.db.po import Conversation @@ -150,6 +151,8 @@ class ProviderRequest: result_parts = [] for ctx in self.contexts: + if is_checkpoint_message(ctx): + continue role = ctx.get("role", "unknown") content = ctx.get("content", "") diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py index f2571b506..efe9e2e47 100644 --- a/astrbot/core/provider/provider.py +++ b/astrbot/core/provider/provider.py @@ -4,7 +4,7 @@ import os from collections.abc import AsyncGenerator from typing import Literal, TypeAlias, Union -from astrbot.core.agent.message import ContentPart, Message +from astrbot.core.agent.message import ContentPart, Message, is_checkpoint_message from astrbot.core.agent.tool import ToolSet from astrbot.core.provider.entities import ( LLMResponse, @@ -191,6 +191,8 @@ class Provider(AbstractProvider): return [] dicts: list[dict] = [] for message in messages: + if is_checkpoint_message(message): + continue if isinstance(message, Message): dicts.append(message.model_dump()) else: diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index 5e6f77db9..d7d4777ac 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -4,12 +4,14 @@ import os import re import uuid from contextlib import asynccontextmanager +from copy import deepcopy from typing import cast from quart import Response as QuartResponse from quart import g, make_response, request, send_file from astrbot.core import logger, sp +from astrbot.core.agent.message import get_checkpoint_id, is_checkpoint_message from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db import BaseDatabase from astrbot.core.platform.message_type import MessageType @@ -76,6 +78,12 @@ class ChatRoute(Route): "POST", self.update_session_display_name, ), + "/chat/message/edit": ("POST", self.update_message), + "/chat/message/regenerate": ("POST", self.regenerate_message), + "/chat/thread/create": ("POST", self.create_thread), + "/chat/thread/get": ("GET", self.get_thread), + "/chat/thread/send": ("POST", self.send_thread_message), + "/chat/thread/delete": ("POST", self.delete_thread), "/chat/get_file": ("GET", self.get_file), "/chat/get_attachment": ("GET", self.get_attachment), "/chat/post_file": ("POST", self.post_file), @@ -276,6 +284,238 @@ class ChatRoute(Route): return {"used": used_refs} if used_refs else {} + def _sanitize_message_content(self, content: dict) -> dict: + """Normalize editable WebChat message content before persisting.""" + if not isinstance(content, dict): + raise ValueError("Missing key: content") + + normalized = deepcopy(content) + message_type = normalized.get("type") + if message_type not in {"user", "bot"}: + raise ValueError("Invalid key: content.type") + + message_parts = normalized.get("message") + if not isinstance(message_parts, list): + raise ValueError("Missing key: content.message") + normalized["message"] = strip_message_parts_path_fields(message_parts) + return normalized + + def _extract_platform_message_text(self, content: dict | None) -> str: + if not isinstance(content, dict): + return "" + message_parts = content.get("message") + if not isinstance(message_parts, list): + return "" + texts: list[str] = [] + for part in message_parts: + if isinstance(part, dict) and part.get("type") == "plain": + text = part.get("text") + if isinstance(text, str): + texts.append(text) + return "".join(texts) + + def _build_webchat_unified_msg_origin(self, session) -> str: + message_type = ( + MessageType.GROUP_MESSAGE.value + if session.is_group + else MessageType.FRIEND_MESSAGE.value + ) + return ( + f"{session.platform_id}:{message_type}:" + f"{session.platform_id}!{session.creator}!{session.session_id}" + ) + + def _build_thread_unified_msg_origin(self, creator: str, thread_id: str) -> str: + return ( + f"webchat:{MessageType.FRIEND_MESSAGE.value}:webchat!{creator}!{thread_id}" + ) + + def _serialize_thread(self, thread) -> dict: + return { + "thread_id": thread.thread_id, + "parent_session_id": thread.parent_session_id, + "parent_message_id": thread.parent_message_id, + "base_checkpoint_id": thread.base_checkpoint_id, + "selected_text": thread.selected_text, + "created_at": to_utc_isoformat(thread.created_at), + "updated_at": to_utc_isoformat(thread.updated_at), + } + + async def _delete_threads_by_ids(self, thread_ids: list[str], creator: str) -> None: + for thread_id in thread_ids: + unified_msg_origin = self._build_thread_unified_msg_origin( + creator, thread_id + ) + active_event_registry.request_agent_stop_all(unified_msg_origin) + await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin) + await self.platform_history_mgr.delete( + platform_id="webchat_thread", + user_id=thread_id, + offset_sec=99999999, + ) + webchat_queue_mgr.remove_queues(thread_id) + self.running_convs.pop(thread_id, None) + + async def _load_current_conversation_history(self, session) -> tuple[str, list]: + unified_msg_origin = self._build_webchat_unified_msg_origin(session) + conversation_id = await self.conv_mgr.get_curr_conversation_id( + unified_msg_origin + ) + if not conversation_id: + return "", [] + + conversation = await self.conv_mgr.get_conversation( + unified_msg_origin=unified_msg_origin, + conversation_id=conversation_id, + ) + if not conversation: + return "", [] + + try: + history = json.loads(conversation.history or "[]") + except json.JSONDecodeError: + return "", [] + return conversation_id, history if isinstance(history, list) else [] + + def _find_checkpoint_index( + self, history: list[dict], checkpoint_id: str + ) -> int | None: + for index, message in enumerate(history): + if get_checkpoint_id(message) == checkpoint_id: + return index + return None + + def _find_turn_range( + self, history: list[dict], checkpoint_id: str + ) -> tuple[int, int] | None: + checkpoint_index = self._find_checkpoint_index(history, checkpoint_id) + if checkpoint_index is None: + return None + + start = 0 + for index in range(checkpoint_index - 1, -1, -1): + if is_checkpoint_message(history[index]): + start = index + 1 + break + return start, checkpoint_index + + def _is_latest_checkpoint(self, history: list[dict], checkpoint_id: str) -> bool: + for message in reversed(history): + current_checkpoint_id = get_checkpoint_id(message) + if current_checkpoint_id: + return current_checkpoint_id == checkpoint_id + return False + + def _replace_user_conversation_content(self, original_content, edited_text: str): + if isinstance(original_content, str): + return edited_text + if not isinstance(original_content, list): + return edited_text + + result: list[dict] = [] + inserted_text = False + for part in original_content: + if not isinstance(part, dict): + result.append(part) + continue + if part.get("type") != "text": + result.append(part) + continue + text = part.get("text") + if isinstance(text, str) and text.startswith(""): + result.append(part) + continue + if not inserted_text and edited_text: + result.append({"type": "text", "text": edited_text}) + inserted_text = True + + if not inserted_text and edited_text: + result.insert(0, {"type": "text", "text": edited_text}) + return result + + def _replace_assistant_conversation_content( + self, + original_content, + edited_text: str, + reasoning: str, + ): + if isinstance(original_content, str): + return edited_text + if not isinstance(original_content, list): + return [{"type": "text", "text": edited_text}] if edited_text else [] + + result: list[dict] = [] + inserted_text = False + inserted_think = False + for part in original_content: + if not isinstance(part, dict): + result.append(part) + continue + if part.get("type") == "text": + if not inserted_text and edited_text: + result.append({"type": "text", "text": edited_text}) + inserted_text = True + continue + if part.get("type") == "think": + if not inserted_think and reasoning: + result.append({"type": "think", "think": reasoning}) + inserted_think = True + continue + result.append(part) + + if reasoning and not inserted_think: + result.insert(0, {"type": "think", "think": reasoning}) + if edited_text and not inserted_text: + result.append({"type": "text", "text": edited_text}) + return result + + def _find_turn_user_index( + self, history: list[dict], start: int, end: int + ) -> int | None: + for index in range(start, end): + message = history[index] + if isinstance(message, dict) and message.get("role") == "user": + return index + return None + + def _find_turn_final_assistant_index( + self, history: list[dict], start: int, end: int + ) -> int | None: + for index in range(end - 1, start - 1, -1): + message = history[index] + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + if message.get("tool_calls") and not message.get("content"): + continue + return index + return None + + async def _get_sorted_platform_history(self, session) -> list: + history_list = await self.platform_history_mgr.get( + platform_id=session.platform_id, + user_id=session.session_id, + page=1, + page_size=100000, + ) + history_list.sort(key=lambda item: (item.created_at, item.id)) + return history_list + + async def _delete_platform_history_after( + self, session, message_id: int + ) -> list[int]: + history_list = await self._get_sorted_platform_history(session) + should_delete = False + deleted_ids: list[int] = [] + for item in history_list: + if should_delete: + if item.id is not None: + deleted_ids.append(item.id) + await self.platform_history_mgr.delete_by_id(item.id) + continue + if item.id == message_id: + should_delete = True + return deleted_ids + async def _save_bot_message( self, webchat_conv_id: str, @@ -284,6 +524,8 @@ class ChatRoute(Route): reasoning: str, agent_stats: dict, refs: dict, + llm_checkpoint_id: str | None = None, + platform_history_id: str = "webchat", ): """保存 bot 消息到历史记录,返回保存的记录""" bot_message_parts = [] @@ -300,11 +542,12 @@ class ChatRoute(Route): new_his["refs"] = refs record = await self.platform_history_mgr.insert( - platform_id="webchat", + platform_id=platform_history_id, user_id=webchat_conv_id, content=new_his, sender_id="bot", sender_name="bot", + llm_checkpoint_id=llm_checkpoint_id, ) return record @@ -328,6 +571,8 @@ class ChatRoute(Route): selected_provider = post_data.get("selected_provider") selected_model = post_data.get("selected_model") enable_streaming = post_data.get("enable_streaming", True) + platform_history_id = post_data.get("_platform_history_id") or "webchat" + thread_selected_text = post_data.get("_thread_selected_text") if not session_id: return Response().error("session_id is empty").__dict__ @@ -344,10 +589,13 @@ class ChatRoute(Route): ) message_id = str(uuid.uuid4()) + llm_checkpoint_id = post_data.get("_llm_checkpoint_id") or str(uuid.uuid4()) + skip_user_history = bool(post_data.get("_skip_user_history")) back_queue = webchat_queue_mgr.get_or_create_back_queue( message_id, webchat_conv_id, ) + saved_user_record = None async def stream(): client_disconnected = False @@ -365,6 +613,18 @@ class ChatRoute(Route): "session_id": webchat_conv_id, } yield f"data: {json.dumps(session_info, ensure_ascii=False)}\n\n" + if saved_user_record and not client_disconnected: + user_saved_info = { + "type": "user_message_saved", + "data": { + "id": saved_user_record.id, + "created_at": to_utc_isoformat( + saved_user_record.created_at + ), + "llm_checkpoint_id": llm_checkpoint_id, + }, + } + yield f"data: {json.dumps(user_saved_info, ensure_ascii=False)}\n\n" async with track_conversation(self.running_convs, webchat_conv_id): while True: @@ -507,6 +767,8 @@ class ChatRoute(Route): accumulated_reasoning, agent_stats, refs, + llm_checkpoint_id, + platform_history_id, ) # 发送保存的消息信息给前端 if saved_record and not client_disconnected: @@ -517,6 +779,7 @@ class ChatRoute(Route): "created_at": to_utc_isoformat( saved_record.created_at ), + "llm_checkpoint_id": llm_checkpoint_id, }, } try: @@ -546,19 +809,23 @@ class ChatRoute(Route): "selected_model": selected_model, "enable_streaming": enable_streaming, "message_id": message_id, + "llm_checkpoint_id": llm_checkpoint_id, + "thread_selected_text": thread_selected_text, }, ), ) message_parts_for_storage = strip_message_parts_path_fields(message_parts) - await self.platform_history_mgr.insert( - platform_id="webchat", - user_id=webchat_conv_id, - content={"type": "user", "message": message_parts_for_storage}, - sender_id=username, - sender_name=username, - ) + if not skip_user_history: + saved_user_record = await self.platform_history_mgr.insert( + platform_id=platform_history_id, + user_id=webchat_conv_id, + content={"type": "user", "message": message_parts_for_storage}, + sender_id=username, + sender_name=username, + llm_checkpoint_id=llm_checkpoint_id, + ) response = cast( QuartResponse, @@ -631,6 +898,8 @@ class ChatRoute(Route): user_id=session_id, offset_sec=99999999, ) + thread_ids = await self.db.delete_webchat_threads_by_parent_session(session_id) + await self._delete_threads_by_ids(thread_ids, username) # 删除与会话关联的配置路由 try: @@ -832,9 +1101,14 @@ class ChatRoute(Route): ) history_res = [history.model_dump() for history in history_ls] + threads = await self.db.get_webchat_threads_by_parent_session( + parent_session_id=session_id, + creator=username, + ) response_data = { "history": history_res, + "threads": [self._serialize_thread(thread) for thread in threads], "is_running": self.running_convs.get(session_id, False), } @@ -848,6 +1122,396 @@ class ChatRoute(Route): return Response().ok(data=response_data).__dict__ + async def create_thread(self): + """Create or reuse a side thread from a selected assistant message.""" + post_data = await request.json + if post_data is None: + return Response().error("Missing JSON body").__dict__ + + session_id = post_data.get("session_id") + parent_message_id = post_data.get("parent_message_id") + selected_text = str(post_data.get("selected_text") or "").strip() + if not session_id: + return Response().error("Missing key: session_id").__dict__ + if parent_message_id is None: + return Response().error("Missing key: parent_message_id").__dict__ + if not selected_text: + return Response().error("Missing key: selected_text").__dict__ + + try: + parent_message_id = int(parent_message_id) + except (TypeError, ValueError): + return Response().error("Invalid key: parent_message_id").__dict__ + + username = g.get("username", "guest") + session = await self.db.get_platform_session_by_id(session_id) + if not session: + return Response().error(f"Session {session_id} not found").__dict__ + if session.creator != username: + return Response().error("Permission denied").__dict__ + + parent_record = await self.db.get_platform_message_history_by_id( + parent_message_id + ) + if ( + not parent_record + or parent_record.platform_id != session.platform_id + or parent_record.user_id != session_id + ): + return Response().error("Parent message not found").__dict__ + if not isinstance(parent_record.content, dict): + return Response().error("Invalid parent message content").__dict__ + if parent_record.content.get("type") != "bot": + return Response().error("Only bot messages can create threads").__dict__ + + checkpoint_id = parent_record.llm_checkpoint_id + if not checkpoint_id: + return ( + Response().error("Parent message is not linked to LLM history").__dict__ + ) + + existing = await self.db.get_webchat_thread_by_parent_message_and_text( + parent_session_id=session_id, + parent_message_id=parent_message_id, + selected_text=selected_text, + creator=username, + ) + if existing: + return Response().ok(data=self._serialize_thread(existing)).__dict__ + + conversation_id, history = await self._load_current_conversation_history( + session + ) + turn_range = self._find_turn_range(history, checkpoint_id) + if not conversation_id or not turn_range: + return Response().error("Linked checkpoint not found").__dict__ + + _start, end = turn_range + base_history = history[: end + 1] + thread = await self.db.create_webchat_thread( + creator=username, + parent_session_id=session_id, + parent_message_id=parent_message_id, + base_checkpoint_id=checkpoint_id, + selected_text=selected_text, + ) + await self.conv_mgr.new_conversation( + unified_msg_origin=self._build_thread_unified_msg_origin( + username, + thread.thread_id, + ), + platform_id="webchat", + content=base_history, + ) + return Response().ok(data=self._serialize_thread(thread)).__dict__ + + async def get_thread(self): + """Get a side thread and its message history.""" + thread_id = request.args.get("thread_id") + if not thread_id: + return Response().error("Missing key: thread_id").__dict__ + + username = g.get("username", "guest") + thread = await self.db.get_webchat_thread_by_id(thread_id) + if not thread: + return Response().error(f"Thread {thread_id} not found").__dict__ + if thread.creator != username: + return Response().error("Permission denied").__dict__ + + history_ls = await self.platform_history_mgr.get( + platform_id="webchat_thread", + user_id=thread_id, + page=1, + page_size=1000, + ) + return ( + Response() + .ok( + data={ + "thread": self._serialize_thread(thread), + "history": [history.model_dump() for history in history_ls], + "is_running": self.running_convs.get(thread_id, False), + } + ) + .__dict__ + ) + + async def send_thread_message(self): + """Send a message inside a WebChat side thread.""" + post_data = await request.json + if post_data is None: + return Response().error("Missing JSON body").__dict__ + + thread_id = post_data.get("thread_id") + if not thread_id: + return Response().error("Missing key: thread_id").__dict__ + + username = g.get("username", "guest") + thread = await self.db.get_webchat_thread_by_id(thread_id) + if not thread: + return Response().error(f"Thread {thread_id} not found").__dict__ + if thread.creator != username: + return Response().error("Permission denied").__dict__ + + return await self.chat( + { + "session_id": thread.thread_id, + "message": post_data.get("message", []), + "enable_streaming": post_data.get("enable_streaming", True), + "selected_provider": post_data.get("selected_provider"), + "selected_model": post_data.get("selected_model"), + "_platform_history_id": "webchat_thread", + "_thread_selected_text": thread.selected_text, + } + ) + + async def delete_thread(self): + """Delete a WebChat side thread and its isolated history.""" + post_data = await request.json + if post_data is None: + return Response().error("Missing JSON body").__dict__ + + thread_id = post_data.get("thread_id") + if not thread_id: + return Response().error("Missing key: thread_id").__dict__ + + username = g.get("username", "guest") + thread = await self.db.get_webchat_thread_by_id(thread_id) + if not thread: + return Response().error(f"Thread {thread_id} not found").__dict__ + if thread.creator != username: + return Response().error("Permission denied").__dict__ + + await self.db.delete_webchat_thread(thread_id) + await self._delete_threads_by_ids([thread_id], username) + return Response().ok(data={"thread_id": thread_id}).__dict__ + + async def update_message(self): + """Update a persisted WebChat message and its linked LLM turn.""" + post_data = await request.json + if post_data is None: + return Response().error("Missing JSON body").__dict__ + + session_id = post_data.get("session_id") + message_id = post_data.get("message_id") + content = post_data.get("content") + if not session_id: + return Response().error("Missing key: session_id").__dict__ + if message_id is None: + return Response().error("Missing key: message_id").__dict__ + + try: + message_id = int(message_id) + content = self._sanitize_message_content(content) + except (TypeError, ValueError) as exc: + return Response().error(str(exc)).__dict__ + + username = g.get("username", "guest") + session = await self.db.get_platform_session_by_id(session_id) + if not session: + return Response().error(f"Session {session_id} not found").__dict__ + if session.creator != username: + return Response().error("Permission denied").__dict__ + + record = await self.db.get_platform_message_history_by_id(message_id) + if not record: + return Response().error(f"Message {message_id} not found").__dict__ + if record.platform_id != session.platform_id or record.user_id != session_id: + return Response().error("Message does not belong to the session").__dict__ + if not isinstance(record.content, dict): + return Response().error("Invalid message content").__dict__ + if record.content.get("type") != content.get("type"): + return Response().error("Message type cannot be changed").__dict__ + if content.get("type") != "user": + return Response().error("Only user messages can be edited").__dict__ + + platform_history = await self._get_sorted_platform_history(session) + latest_user_record = next( + ( + item + for item in reversed(platform_history) + if isinstance(item.content, dict) and item.content.get("type") == "user" + ), + None, + ) + if not latest_user_record or latest_user_record.id != message_id: + return ( + Response().error("Only the latest user message can be edited").__dict__ + ) + + checkpoint_id = record.llm_checkpoint_id + if not checkpoint_id: + return ( + Response() + .error("This message is not linked to LLM history and cannot be edited") + .__dict__ + ) + + conversation_id, history = await self._load_current_conversation_history( + session + ) + turn_range = self._find_turn_range(history, checkpoint_id) + if not conversation_id or not turn_range: + return Response().error("Linked checkpoint not found").__dict__ + if not self._is_latest_checkpoint(history, checkpoint_id): + return Response().error("Only the latest turn can be edited").__dict__ + + start, _end = turn_range + + target_index = self._find_turn_user_index(history, start, _end) + if target_index is None: + return Response().error("Linked user message not found").__dict__ + + new_checkpoint_id = str(uuid.uuid4()) + truncated_history = history[:start] + await self.platform_history_mgr.update( + message_id=message_id, + content=content, + llm_checkpoint_id=new_checkpoint_id, + ) + deleted_message_ids = await self._delete_platform_history_after( + session, message_id + ) + thread_ids = await self.db.delete_webchat_threads_by_parent_message_ids( + session_id, + deleted_message_ids, + ) + await self._delete_threads_by_ids(thread_ids, username) + await self.conv_mgr.update_conversation( + unified_msg_origin=self._build_webchat_unified_msg_origin(session), + conversation_id=conversation_id, + history=truncated_history, + ) + await self.db.update_platform_session(session_id=session_id) + updated = await self.db.get_platform_message_history_by_id(message_id) + return ( + Response() + .ok( + data={ + "message": updated.model_dump() if updated else None, + "needs_regenerate": True, + "truncated_after_message": True, + } + ) + .__dict__ + ) + + async def regenerate_message(self): + """Regenerate the latest bot message linked to an LLM checkpoint.""" + post_data = await request.json + if post_data is None: + return Response().error("Missing JSON body").__dict__ + + session_id = post_data.get("session_id") + message_id = post_data.get("message_id") + if not session_id: + return Response().error("Missing key: session_id").__dict__ + if message_id is None: + return Response().error("Missing key: message_id").__dict__ + + try: + message_id = int(message_id) + except (TypeError, ValueError): + return Response().error("Invalid key: message_id").__dict__ + + username = g.get("username", "guest") + session = await self.db.get_platform_session_by_id(session_id) + if not session: + return Response().error(f"Session {session_id} not found").__dict__ + if session.creator != username: + return Response().error("Permission denied").__dict__ + + target_record = await self.db.get_platform_message_history_by_id(message_id) + if not target_record: + return Response().error(f"Message {message_id} not found").__dict__ + if ( + target_record.platform_id != session.platform_id + or target_record.user_id != session_id + ): + return Response().error("Message does not belong to the session").__dict__ + if not isinstance(target_record.content, dict): + return Response().error("Invalid message content").__dict__ + if target_record.content.get("type") != "bot": + return Response().error("Only bot messages can be regenerated").__dict__ + + checkpoint_id = target_record.llm_checkpoint_id + if not checkpoint_id: + return Response().error("Message is not linked to LLM history").__dict__ + + conversation_id, history = await self._load_current_conversation_history( + session + ) + turn_range = self._find_turn_range(history, checkpoint_id) + if not conversation_id or not turn_range: + return Response().error("Linked checkpoint not found").__dict__ + if not self._is_latest_checkpoint(history, checkpoint_id): + return ( + Response().error("Regenerating older turns requires branching").__dict__ + ) + + start, end = turn_range + user_index = self._find_turn_user_index(history, start, end) + if user_index is None: + return Response().error("Linked user message not found").__dict__ + + platform_history = await self._get_sorted_platform_history(session) + source_user_record = next( + ( + item + for item in reversed(platform_history) + if item.llm_checkpoint_id == checkpoint_id + and isinstance(item.content, dict) + and item.content.get("type") == "user" + ), + None, + ) + if not source_user_record: + return Response().error("Linked user display message not found").__dict__ + + old_bot_record_ids = [ + item.id + for item in platform_history + if item.id is not None + and item.llm_checkpoint_id == checkpoint_id + and isinstance(item.content, dict) + and item.content.get("type") == "bot" + ] + if not old_bot_record_ids: + return Response().error("Linked bot display message not found").__dict__ + + new_checkpoint_id = str(uuid.uuid4()) + # The WebChat send path adds the current user message from the prompt. + # Remove the whole old turn here to avoid duplicating that user message. + new_history = history[:start] + history[end + 1 :] + await self.conv_mgr.update_conversation( + unified_msg_origin=self._build_webchat_unified_msg_origin(session), + conversation_id=conversation_id, + history=new_history, + ) + thread_ids = await self.db.delete_webchat_threads_by_parent_message_ids( + session_id, + old_bot_record_ids, + ) + await self._delete_threads_by_ids(thread_ids, username) + for old_bot_record_id in old_bot_record_ids: + await self.platform_history_mgr.delete_by_id(old_bot_record_id) + await self.platform_history_mgr.update( + message_id=source_user_record.id, + llm_checkpoint_id=new_checkpoint_id, + ) + + return await self.chat( + { + "session_id": session_id, + "message": source_user_record.content.get("message", []), + "enable_streaming": post_data.get("enable_streaming", True), + "selected_provider": post_data.get("selected_provider"), + "selected_model": post_data.get("selected_model"), + "_skip_user_history": True, + "_llm_checkpoint_id": new_checkpoint_id, + } + ) + async def update_session_display_name(self): """Update a Platform session's display name.""" post_data = await request.json diff --git a/astrbot/dashboard/routes/live_chat.py b/astrbot/dashboard/routes/live_chat.py index dafb3c2f8..16c605848 100644 --- a/astrbot/dashboard/routes/live_chat.py +++ b/astrbot/dashboard/routes/live_chat.py @@ -255,6 +255,7 @@ class LiveChatRoute(Route): reasoning: str, agent_stats: dict, refs: dict, + llm_checkpoint_id: str | None = None, ): """保存 bot 消息到历史记录。""" bot_message_parts = [] @@ -276,6 +277,7 @@ class LiveChatRoute(Route): content=new_his, sender_id="bot", sender_name="bot", + llm_checkpoint_id=llm_checkpoint_id, ) async def _send_chat_payload(self, session: LiveChatSession, payload: dict) -> None: @@ -452,6 +454,7 @@ class LiveChatRoute(Route): session.is_processing = True session.should_interrupt = False back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, session_id) + llm_checkpoint_id = str(uuid.uuid4()) try: chat_queue = webchat_queue_mgr.get_or_create_queue(session_id) @@ -469,17 +472,31 @@ class LiveChatRoute(Route): "show_reasoning": show_reasoning, "enable_streaming": enable_streaming, "message_id": message_id, + "llm_checkpoint_id": llm_checkpoint_id, }, ), ) message_parts_for_storage = strip_message_parts_path_fields(message_parts) - await self.platform_history_mgr.insert( + saved_user_record = await self.platform_history_mgr.insert( platform_id="webchat", user_id=session_id, content={"type": "user", "message": message_parts_for_storage}, sender_id=session.username, sender_name=session.username, + llm_checkpoint_id=llm_checkpoint_id, + ) + await self._send_chat_payload( + session, + { + "ct": "chat", + "type": "user_message_saved", + "data": { + "id": saved_user_record.id, + "created_at": to_utc_isoformat(saved_user_record.created_at), + "llm_checkpoint_id": llm_checkpoint_id, + }, + }, ) accumulated_parts = [] @@ -618,6 +635,7 @@ class LiveChatRoute(Route): accumulated_reasoning, agent_stats, refs, + llm_checkpoint_id, ) if saved_record: await self._send_chat_payload( @@ -630,6 +648,7 @@ class LiveChatRoute(Route): "created_at": to_utc_isoformat( saved_record.created_at ), + "llm_checkpoint_id": llm_checkpoint_id, }, }, ) diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index b24aeed04..ecf61a937 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -344,236 +344,32 @@
    -
    - - - - - -
    -
    -
    - {{ tm("message.loading") }} -
    - - -
    - -
    - {{ - formatTime(msg.created_at) - }} - - - - - -
    - {{ tm("stats.inputTokens") }} - {{ - inputTokens(messageContent(msg).agentStats) - }} -
    -
    - {{ tm("stats.outputTokens") }} - {{ - outputTokens(messageContent(msg).agentStats) - }} -
    -
    - {{ tm("stats.ttft") }} - {{ - agentTtft(messageContent(msg).agentStats) - }} -
    -
    - {{ tm("stats.duration") }} - {{ - agentDuration(messageContent(msg).agentStats) - }} -
    -
    -
    -
    - -
    -
    -
    -
    +
    @@ -610,6 +406,23 @@ +
    + +
    +
    + - - - preview - @@ -678,8 +485,6 @@ import { import { useRoute, useRouter } from "vue-router"; import { useDisplay } from "vuetify"; import axios from "axios"; -import { setCustomComponents } from "markstream-vue"; -import "markstream-vue/index.css"; import StyledMenu from "@/components/shared/StyledMenu.vue"; import ProviderConfigDialog from "@/components/chat/ProviderConfigDialog.vue"; import ProjectDialog, { @@ -688,19 +493,15 @@ import ProjectDialog, { import ProjectList, { type Project } from "@/components/chat/ProjectList.vue"; import ProjectView from "@/components/chat/ProjectView.vue"; import ChatInput from "@/components/chat/ChatInput.vue"; -import ReasoningBlock from "@/components/chat/message_list_comps/ReasoningBlock.vue"; -import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue"; -import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue"; -import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue"; +import ChatMessageList from "@/components/chat/ChatMessageList.vue"; +import type { RegenerateModelSelection } from "@/components/chat/RegenerateMenu.vue"; +import ThreadPanel from "@/components/chat/ThreadPanel.vue"; import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue"; -import RefNode from "@/components/chat/message_list_comps/RefNode.vue"; -import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue"; -import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue"; -import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue"; import { useSessions, type Session } from "@/composables/useSessions"; import { useMessages, type ChatRecord, + type ChatThread, type MessagePart, type TransportMode, } from "@/composables/useMessages"; @@ -714,16 +515,12 @@ import { } from "@/i18n/composables"; import type { Locale } from "@/i18n/types"; import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog"; +import { useToast } from "@/utils/toast"; const props = withDefaults(defineProps<{ chatboxMode?: boolean }>(), { chatboxMode: false, }); -setCustomComponents("chat-message", { - ref: RefNode, - code_block: ThemeAwareMarkdownCodeBlock, -}); - const route = useRoute(); const router = useRouter(); const { lgAndUp } = useDisplay(); @@ -731,6 +528,7 @@ const customizer = useCustomizerStore(); const { t } = useI18n(); const { tm } = useModuleI18n("features/chat"); const confirmDialog = useConfirmDialog(); +const toast = useToast(); const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher(); const { @@ -777,17 +575,34 @@ const sessionTitleDraft = ref(""); const editingSessionTitleId = ref(""); const refreshProjectSessionsAfterTitleSave = ref(false); const savingSessionTitle = ref(false); +const messageEditDraft = ref(""); +const editingMessage = ref(null); +const savingMessageEdit = ref(false); const projectSessions = ref([]); const loadingSessions = ref(false); const draft = ref(""); -const downloadingFiles = ref(new Set()); const messagesContainer = ref(null); const inputRef = ref | null>(null); const shouldStickToBottom = ref(true); const replyTarget = ref(null); -const imagePreview = reactive({ visible: false, url: "" }); +const threadPanelOpen = ref(false); +const activeThread = ref(null); +const deletingThread = ref(false); const refsSidebarOpen = ref(false); const selectedRefs = ref | null>(null); +const threadSelection = reactive<{ + visible: boolean; + left: number; + top: number; + message: ChatRecord | null; + selectedText: string; +}>({ + visible: false, + left: 0, + top: 0, + message: null, + selectedText: "", +}); const enableStreaming = ref(true); const isRecording = ref(false); const sendShortcut = ref<"enter" | "shift_enter">("enter"); @@ -811,12 +626,13 @@ const { activeMessages, isSessionRunning, isUserMessage, - isMessageStreaming, - messageContent, messageParts, loadSessionMessages, createLocalExchange, sendMessageStream, + editMessage, + continueEditedMessage, + regenerateMessage, stopSession, } = useMessages({ currentSessionId: currSessionId, @@ -853,7 +669,6 @@ const canSend = computed( () => Boolean(draft.value.trim() || stagedFiles.value.length) && !sending.value, ); -const customMarkdownTags = ["ref"]; const currentSession = computed( () => sessions.value.find( @@ -1109,7 +924,7 @@ async function sendCurrentMessage() { const messageId = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`; const outgoingParts = buildOutgoingParts(text); const selection = inputRef.value?.getCurrentSelection(); - const { botRecord } = createLocalExchange({ + const { userRecord, botRecord } = createLocalExchange({ sessionId, messageId, parts: outgoingParts, @@ -1129,6 +944,7 @@ async function sendCurrentMessage() { enableStreaming: enableStreaming.value, selectedProvider: selection?.providerId || "", selectedModel: selection?.modelName || "", + userRecord, botRecord, }); } catch (error) { @@ -1161,45 +977,12 @@ function buildOutgoingParts(text: string): MessagePart[] { return parts; } -function hasNonReasoningContent(message: ChatRecord) { - return messageParts(message).some((part) => { - if (part.type === "reply") return false; - if (part.type === "plain") return Boolean(String(part.text || "").trim()); - return true; - }); -} - function updateTitleFromText(sessionId: string, text: string) { const session = sessions.value.find((item) => item.session_id === sessionId); if (!session || session.display_name || !text) return; updateSessionTitle(sessionId, text.slice(0, 40)); } -function partUrl(part: MessagePart) { - if (part.embedded_url) return part.embedded_url; - if (part.embedded_file?.url) return part.embedded_file.url; - if (part.attachment_id) - return `/api/chat/get_attachment?attachment_id=${encodeURIComponent( - part.attachment_id, - )}`; - if (part.filename) - return `/api/chat/get_file?filename=${encodeURIComponent(part.filename)}`; - return ""; -} - -function formatJson(value: unknown) { - if (typeof value === "string") { - const parsed = parseJsonSafe(value); - if (parsed !== value) return JSON.stringify(parsed, null, 2); - return value; - } - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value ?? ""); - } -} - function replyPreview(messageId?: string | number, fallback?: string) { if (fallback) return truncate(fallback, 80); const found = activeMessages.value.find( @@ -1230,48 +1013,141 @@ function scrollToMessage(messageId?: string | number) { rows?.[index]?.scrollIntoView({ behavior: "smooth", block: "center" }); } -function setReplyTarget(message: ChatRecord) { - replyTarget.value = message; - nextTick(() => inputRef.value?.focusInput?.()); +function openMessageEdit(message: ChatRecord) { + messageEditDraft.value = plainTextFromMessage(message); + editingMessage.value = message; + nextTick(() => scrollToMessage(message.id)); } -function showMessageMeta(message: ChatRecord, msgIndex: number) { - return ( - !messageContent(message).isLoading && !isMessageStreaming(message, msgIndex) +function cancelMessageEdit() { + editingMessage.value = null; + messageEditDraft.value = ""; +} + +async function saveMessageEdit() { + if (!currSessionId.value || !editingMessage.value) return; + savingMessageEdit.value = true; + try { + const target = editingMessage.value; + const result = await editMessage( + currSessionId.value, + target, + messageEditDraft.value, + ); + cancelMessageEdit(); + + if (result.needsRegenerate && result.truncatedAfterMessage) { + const selection = inputRef.value?.getCurrentSelection(); + continueEditedMessage({ + sessionId: currSessionId.value, + sourceRecord: target, + enableStreaming: enableStreaming.value, + selectedProvider: selection?.providerId || "", + selectedModel: selection?.modelName || "", + }); + scrollToBottom(); + } else if (result.needsRegenerate) { + const index = activeMessages.value.findIndex( + (message) => String(message.id) === String(target.id), + ); + const nextBot = activeMessages.value + .slice(index + 1) + .find((message) => !isUserMessage(message)); + if (nextBot) { + await handleRegenerateMessage(nextBot); + } + } + } catch (error) { + console.error("Failed to edit message:", error); + } finally { + savingMessageEdit.value = false; + } +} + +async function handleRegenerateMessage( + message: ChatRecord, + selection?: RegenerateModelSelection, +) { + if (!currSessionId.value || isUserMessage(message)) return; + message.threads = []; + await regenerateMessage( + currSessionId.value, + message, + selection?.providerId || "", + selection?.modelName || "", ); } -function messageRefs(message: ChatRecord) { - return resolvedMessageRefs(message).used; +function handleBotTextSelection(event: MouseEvent, message: ChatRecord) { + if (message.id == null || String(message.id).startsWith("local-")) return; + const container = event.currentTarget as HTMLElement | null; + window.setTimeout(() => { + const selection = window.getSelection(); + const selectedText = selection?.toString().trim() || ""; + if (!selection || !selectedText) { + threadSelection.visible = false; + return; + } + if ( + !container || + !container.contains(selection.anchorNode) || + !container.contains(selection.focusNode) + ) { + threadSelection.visible = false; + return; + } + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + threadSelection.message = message; + threadSelection.selectedText = selectedText; + threadSelection.left = Math.min( + window.innerWidth - 180, + Math.max(12, rect.left + rect.width / 2 - 70), + ); + threadSelection.top = Math.max(12, rect.top - 42); + threadSelection.visible = true; + }, 0); } -function resolvedMessageRefs(message: ChatRecord) { - return normalizeRefs(messageContent(message).refs); +async function createThreadFromSelection() { + const message = threadSelection.message; + if (!currSessionId.value || !message?.id || !threadSelection.selectedText) return; + try { + const response = await axios.post("/api/chat/thread/create", { + session_id: currSessionId.value, + parent_message_id: message.id, + selected_text: threadSelection.selectedText, + }); + if (response.data?.status !== "ok") { + toast.error(response.data?.message || tm("thread.createFailed")); + return; + } + const thread = response.data?.data as ChatThread | undefined; + if (!thread) { + toast.error(tm("thread.createFailed")); + return; + } + message.threads = message.threads || []; + if (!message.threads.some((item) => item.thread_id === thread.thread_id)) { + message.threads.push(thread); + } + openThreadPanel(thread); + window.getSelection()?.removeAllRanges(); + } catch (error) { + toast.error( + axios.isAxiosError(error) + ? error.response?.data?.message || error.message + : tm("thread.createFailed"), + ); + console.error("Failed to create thread:", error); + } finally { + threadSelection.visible = false; + } } -function normalizeRefs(refs: unknown) { - if (!refs) return { used: [] as Array> }; - const used = Array.isArray((refs as any)?.used) - ? (refs as any).used - : Array.isArray(refs) - ? refs - : []; - - return { - used: normalizeRefItems(used), - }; -} - -function normalizeRefItems(items: unknown[]) { - return items - .map((item: any) => ({ - index: item?.index, - title: item?.title || item?.url || tm("refs.title"), - url: item?.url, - snippet: item?.snippet, - favicon: item?.favicon, - })) - .filter((item) => item.url); +function openThreadPanel(thread: ChatThread) { + activeThread.value = thread; + threadPanelOpen.value = true; } function openRefsSidebar(refs: unknown) { @@ -1280,68 +1156,33 @@ function openRefsSidebar(refs: unknown) { refsSidebarOpen.value = true; } -function normalizeToolCall(tool: Record) { - const normalized = { ...tool }; - normalized.args = normalized.args ?? normalized.arguments ?? {}; - normalized.ts = normalized.ts ?? Date.now() / 1000; - if (normalized.result && typeof normalized.result === "object") { - normalized.result = JSON.stringify(normalized.result, null, 2); - } - return normalized; -} - -function isIPythonToolCall(tool: Record) { - const name = String(tool.name || "").toLowerCase(); - return name.includes("python") || name.includes("ipython"); -} - -function toolCallStatusText(tool: Record) { - if (tool.finished_ts) return tm("toolStatus.done"); - return tm("toolStatus.running"); -} - -function parseJsonSafe(value: unknown) { - if (typeof value !== "string") return value; +async function deleteThread(thread: ChatThread) { + if (deletingThread.value) return; + if (!(await askForConfirmation(tm("thread.confirmDelete"), confirmDialog))) return; + deletingThread.value = true; try { - return JSON.parse(value); - } catch { - return value; - } -} - -async function copyMessage(message: ChatRecord) { - const text = plainTextFromMessage(message); - if (!text) return; - await navigator.clipboard?.writeText(text); -} - -async function downloadPart(part: MessagePart) { - const key = part.attachment_id || part.filename || ""; - if (!key) return; - downloadingFiles.value = new Set(downloadingFiles.value).add(key); - try { - const response = await axios.get(partUrl(part), { responseType: "blob" }); - const url = URL.createObjectURL(response.data); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = part.filename || "file"; - anchor.click(); - URL.revokeObjectURL(url); + await axios.post("/api/chat/thread/delete", { + thread_id: thread.thread_id, + }); + removeThreadFromMessages(thread.thread_id); + if (activeThread.value?.thread_id === thread.thread_id) { + threadPanelOpen.value = false; + activeThread.value = null; + } + } catch (error) { + console.error("Failed to delete thread:", error); } finally { - const next = new Set(downloadingFiles.value); - next.delete(key); - downloadingFiles.value = next; + deletingThread.value = false; } } -function openImage(url: string) { - imagePreview.url = url; - imagePreview.visible = true; -} - -function closeImage() { - imagePreview.visible = false; - imagePreview.url = ""; +function removeThreadFromMessages(threadId: string) { + for (const message of activeMessages.value) { + if (!message.threads?.length) continue; + message.threads = message.threads.filter( + (thread) => thread.thread_id !== threadId, + ); + } } async function handleFilesSelected(files: FileList) { @@ -1368,6 +1209,7 @@ function stopRecording() { } function handleMessagesScroll() { + threadSelection.visible = false; const container = messagesContainer.value; if (!container) return; const distance = @@ -1396,60 +1238,6 @@ async function stopCurrentSession() { function toggleTheme() { customizer.SET_UI_THEME(isDark.value ? "PurpleTheme" : "PurpleThemeDark"); } - -function formatTime(value: string) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return ""; - return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); -} - -function inputTokens(stats: any) { - const usage = stats?.token_usage || {}; - return (usage.input_other || 0) + (usage.input_cached || 0); -} - -function outputTokens(stats: any) { - return stats?.token_usage?.output || 0; -} - -function agentDuration(stats: any) { - const directDuration = readPositiveNumber(stats, [ - "duration", - "total_duration", - ]); - if (directDuration !== null) return formatDuration(directDuration); - - const startTime = readPositiveNumber(stats, ["start_time"]); - const endTime = readPositiveNumber(stats, ["end_time"]); - if (startTime === null || endTime === null || endTime < startTime) return "-"; - return formatDuration(endTime - startTime); -} - -function agentTtft(stats: any) { - const ttft = readPositiveNumber(stats, [ - "time_to_first_token", - "ttft", - "first_token_latency", - ]); - if (ttft === null) return ""; - return formatDuration(ttft); -} - -function readPositiveNumber(source: any, keys: string[]) { - for (const key of keys) { - const value = Number(source?.[key]); - if (Number.isFinite(value) && value > 0) return value; - } - return null; -} - -function formatDuration(seconds: number) { - if (seconds < 1) return `${Math.round(seconds * 1000)}ms`; - if (seconds < 60) return `${seconds.toFixed(1)}s`; - const minutes = Math.floor(seconds / 60); - const restSeconds = Math.round(seconds % 60); - return `${minutes}m ${restSeconds}s`; -} diff --git a/dashboard/src/components/chat/ChatInput.vue b/dashboard/src/components/chat/ChatInput.vue index 7f2117377..0b0aeb3d2 100644 --- a/dashboard/src/components/chat/ChatInput.vue +++ b/dashboard/src/components/chat/ChatInput.vue @@ -102,8 +102,8 @@ @@ -193,8 +193,7 @@ @click="handleRecordClick" icon variant="text" - :color="isRecording ? 'error' : 'primary'" - class="record-btn" + class="record-btn input-icon-btn" > @@ -222,10 +221,10 @@ @@ -601,6 +600,43 @@ defineExpose({ background: #e7e7e7; } +.input-action-btn { + background: #5594c6 !important; + color: #fff !important; +} + +.input-action-btn:hover { + background: #4c86b3 !important; +} + +.input-action-btn:disabled { + background: rgba(85, 148, 198, 0.24) !important; + color: rgba(255, 255, 255, 0.72) !important; +} + +.input-icon-btn { + background: transparent !important; + color: rgb(var(--v-theme-on-surface)) !important; + margin-right: 8px; +} + +.input-icon-btn:hover { + background: rgba(var(--v-theme-on-surface), 0.04) !important; +} + +.input-outline-control { + width: 36px !important; + height: 36px !important; + min-width: 36px !important; + border-color: rgba(var(--v-theme-on-surface), 0.18) !important; + background: transparent !important; +} + +.input-outline-control:hover { + border-color: rgba(var(--v-theme-on-surface), 0.34) !important; + background: rgba(var(--v-theme-on-surface), 0.04) !important; +} + .input-area.is-dark .input-neutral-btn { color: rgba(255, 255, 255, 0.78) !important; } @@ -610,6 +646,30 @@ defineExpose({ background: rgba(255, 255, 255, 0.1); } +.input-area.is-dark .input-outline-control { + border-color: rgba(255, 255, 255, 0.22) !important; + background: transparent !important; +} + +.input-area.is-dark .input-outline-control:hover { + border-color: rgba(255, 255, 255, 0.42) !important; + background: rgba(255, 255, 255, 0.06) !important; +} + +.input-area.is-dark .input-action-btn { + background: rgb(var(--v-theme-on-surface)) !important; + color: rgb(var(--v-theme-surface)) !important; +} + +.input-area.is-dark .input-action-btn:hover { + background: rgba(var(--v-theme-on-surface), 0.86) !important; +} + +.input-area.is-dark .input-action-btn:disabled { + background: rgba(var(--v-theme-on-surface), 0.14) !important; + color: rgba(var(--v-theme-on-surface), 0.4) !important; +} + /* 拖拽上传遮罩 */ .drop-overlay { position: absolute; @@ -811,14 +871,23 @@ defineExpose({ .input-container { width: 100% !important; max-width: 100% !important; + border-bottom-left-radius: 0 !important; + border-bottom-right-radius: 0 !important; + } + + .input-outline-control { + width: 32px !important; + height: 32px !important; + min-width: 32px !important; } .input-area textarea, .chat-textarea { - min-height: 30px !important; - max-height: 160px !important; + min-height: 28px !important; + max-height: 140px !important; font-size: 16px !important; - padding: 12px 16px 10px 16px !important; + line-height: 20px !important; + padding: 8px 14px 7px !important; } } diff --git a/dashboard/src/components/chat/ChatMessageList.vue b/dashboard/src/components/chat/ChatMessageList.vue new file mode 100644 index 000000000..1962ac404 --- /dev/null +++ b/dashboard/src/components/chat/ChatMessageList.vue @@ -0,0 +1,1138 @@ + + + + + diff --git a/dashboard/src/components/chat/ProviderModelMenu.vue b/dashboard/src/components/chat/ProviderModelMenu.vue index ced65dd55..e9a2f4f76 100644 --- a/dashboard/src/components/chat/ProviderModelMenu.vue +++ b/dashboard/src/components/chat/ProviderModelMenu.vue @@ -1,7 +1,7 @@ @@ -397,6 +400,72 @@ const canSend = computed(() => { ); }); +const hasStagedAttachments = computed(() => { + return ( + props.stagedImagesUrl.length > 0 || + props.stagedAudioUrl || + (props.stagedFiles && props.stagedFiles.length > 0) + ); +}); + +const fileTypeStyles: Record< + string, + { color: string; icon: string; label: string } +> = { + pdf: { color: "#d32f2f", icon: "mdi-file-pdf-box", label: "PDF" }, + txt: { color: "#1976d2", icon: "mdi-file-document-outline", label: "TXT" }, + md: { color: "#1976d2", icon: "mdi-language-markdown-outline", label: "MD" }, + markdown: { + color: "#1976d2", + icon: "mdi-language-markdown-outline", + label: "MD", + }, + doc: { color: "#2b579a", icon: "mdi-file-word-box", label: "DOC" }, + docx: { color: "#2b579a", icon: "mdi-file-word-box", label: "DOCX" }, + xls: { color: "#217346", icon: "mdi-file-excel-box", label: "XLS" }, + xlsx: { color: "#217346", icon: "mdi-file-excel-box", label: "XLSX" }, + csv: { color: "#217346", icon: "mdi-file-delimited-outline", label: "CSV" }, + ppt: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPT" }, + pptx: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPTX" }, + zip: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "ZIP" }, + rar: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "RAR" }, + "7z": { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "7Z" }, + tar: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "TAR" }, + gz: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "GZ" }, + json: { color: "#6a1b9a", icon: "mdi-code-json", label: "JSON" }, + yaml: { color: "#6a1b9a", icon: "mdi-code-braces", label: "YAML" }, + yml: { color: "#6a1b9a", icon: "mdi-code-braces", label: "YML" }, + js: { color: "#b8860b", icon: "mdi-language-javascript", label: "JS" }, + ts: { color: "#3178c6", icon: "mdi-language-typescript", label: "TS" }, + html: { color: "#e34c26", icon: "mdi-language-html5", label: "HTML" }, + css: { color: "#264de4", icon: "mdi-language-css3", label: "CSS" }, + py: { color: "#3776ab", icon: "mdi-language-python", label: "PY" }, + java: { color: "#b07219", icon: "mdi-language-java", label: "JAVA" }, + mp3: { color: "#00897b", icon: "mdi-file-music-outline", label: "MP3" }, + wav: { color: "#00897b", icon: "mdi-file-music-outline", label: "WAV" }, + flac: { color: "#00897b", icon: "mdi-file-music-outline", label: "FLAC" }, + mp4: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "MP4" }, + mov: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "MOV" }, + webm: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "WEBM" }, +}; + +function fileExtension(file: StagedFileInfo) { + const name = file.original_name || file.filename || ""; + const extension = name.split(".").pop()?.toLowerCase() || ""; + return extension === name.toLowerCase() ? "" : extension; +} + +function filePresentation(file: StagedFileInfo) { + const extension = fileExtension(file); + return ( + fileTypeStyles[extension] || { + color: "#607d8b", + icon: "mdi-file-document-outline", + label: extension ? extension.slice(0, 4).toUpperCase() : "FILE", + } + ); +} + // Ctrl+B 长按录音相关 const ctrlKeyDown = ref(false); const ctrlKeyTimer = ref(null); @@ -800,45 +869,89 @@ defineExpose({ .attachments-preview { display: flex; - gap: 8px; - margin-top: 8px; - max-width: 900px; - margin: 8px auto 0; - flex-wrap: wrap; + gap: 10px; + margin: 10px 12px 0; + padding: 2px 2px 4px; + flex-wrap: nowrap; + align-items: center; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + max-height: 72px; } -.image-preview, -.audio-preview, -.file-preview { +.attachment-card { position: relative; display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + width: 220px; + height: 64px; + flex: 0 0 auto; + min-width: 0; + padding: 8px 34px 8px 10px; + overflow: hidden; + color: rgb(var(--v-theme-on-surface)); + background: rgba(var(--v-theme-on-surface), 0.04); + border: 1px solid rgba(var(--v-theme-on-surface), 0.1); + border-radius: 12px; +} + +.image-preview { + width: 64px; + flex-basis: 64px; + padding: 0; + background: rgba(var(--v-theme-on-surface), 0.06); } .preview-image { - width: 60px; - height: 60px; + width: 100%; + height: 100%; object-fit: cover; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border-radius: 11px; } -.audio-chip, -.file-chip { - height: 36px; - border-radius: 18px; +.attachment-icon { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1px; + flex-shrink: 0; + min-width: 34px; } -.file-name-preview { - max-width: 120px; +.attachment-icon--audio { + color: #00897b; +} + +.attachment-ext { + max-width: 58px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 10px; + font-weight: 700; + line-height: 12px; +} + +.attachment-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + line-height: 18px; } .remove-attachment-btn { position: absolute; - top: -8px; - right: -8px; + top: 4px; + right: 4px; + width: 22px !important; + height: 22px !important; + min-width: 22px !important; opacity: 0.8; transition: opacity 0.2s; } @@ -851,6 +964,27 @@ defineExpose({ animation: fadeIn 0.3s ease-in-out; } +.attachments-enter-active, +.attachments-leave-active { + overflow: hidden; + transition: + max-height 0.2s ease, + margin 0.2s ease, + padding 0.2s ease, + opacity 0.16s ease, + transform 0.2s ease; +} + +.attachments-enter-from, +.attachments-leave-to { + max-height: 0; + margin-top: 0; + padding-top: 0; + padding-bottom: 0; + opacity: 0; + transform: translateY(6px); +} + @keyframes fadeIn { from { opacity: 0; @@ -889,5 +1023,20 @@ defineExpose({ line-height: 20px !important; padding: 8px 14px 7px !important; } + + .attachments-preview { + margin: 8px 10px 0; + gap: 8px; + } + + .attachment-card { + width: min(220px, calc(100vw - 28px)); + height: 58px; + } + + .image-preview { + width: 58px; + flex-basis: 58px; + } } diff --git a/dashboard/src/components/chat/ChatMessageList.vue b/dashboard/src/components/chat/ChatMessageList.vue index 1962ac404..a0e70d65f 100644 --- a/dashboard/src/components/chat/ChatMessageList.vue +++ b/dashboard/src/components/chat/ChatMessageList.vue @@ -24,6 +24,54 @@
    + +
    + +