From cd2556ab9430c93f6fd91865cf8f8c0b298fa783 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Wed, 19 Nov 2025 15:40:41 +0800 Subject: [PATCH 1/6] fix: build docker ci failed --- .github/workflows/docker-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index dfef5136..02bff6a5 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest env: DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} - GHCR_OWNER: ${{ github.repository_owner }} + GHCR_OWNER: soulter HAS_GHCR_TOKEN: ${{ secrets.GHCR_GITHUB_TOKEN != '' }} steps: From afb56cf707c8b7e29855969fb03de310aa1de43d Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Wed, 19 Nov 2025 18:54:56 +0800 Subject: [PATCH 2/6] feat: add supports for gemini-3 series thought signature (#3698) * feat: add supports for gemini-3 series thought signature * feat: refactor tools_call_extra_content to use a dictionary for better structure --- astrbot/core/agent/message.py | 7 ++++ astrbot/core/db/po.py | 20 ++++------- astrbot/core/provider/entities.py | 31 ++++++++++++----- .../core/provider/sources/gemini_source.py | 33 ++++++++++++++----- .../core/provider/sources/openai_source.py | 25 +++++--------- 5 files changed, 70 insertions(+), 46 deletions(-) diff --git a/astrbot/core/agent/message.py b/astrbot/core/agent/message.py index 4a2e1b14..4c65c32f 100644 --- a/astrbot/core/agent/message.py +++ b/astrbot/core/agent/message.py @@ -119,6 +119,13 @@ class ToolCall(BaseModel): """The ID of the tool call.""" function: FunctionBody """The function body of the tool call.""" + extra_content: dict[str, Any] | None = None + """Extra metadata for the tool call.""" + + def model_dump(self, **kwargs: Any) -> dict[str, Any]: + if self.extra_content is None: + kwargs.setdefault("exclude", set()).add("extra_content") + return super().model_dump(**kwargs) class ToolCallPart(BaseModel): diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index 1e724597..5cf25ec1 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -3,13 +3,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from typing import TypedDict -from sqlmodel import ( - JSON, - Field, - SQLModel, - Text, - UniqueConstraint, -) +from sqlmodel import JSON, Field, SQLModel, Text, UniqueConstraint class PlatformStat(SQLModel, table=True): @@ -18,7 +12,7 @@ class PlatformStat(SQLModel, table=True): Note: In astrbot v4, we moved `platform` table to here. """ - __tablename__ = "platform_stats" + __tablename__ = "platform_stats" # type: ignore id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True}) timestamp: datetime = Field(nullable=False) @@ -37,7 +31,7 @@ class PlatformStat(SQLModel, table=True): class ConversationV2(SQLModel, table=True): - __tablename__ = "conversations" + __tablename__ = "conversations" # type: ignore inner_conversation_id: int = Field( primary_key=True, @@ -74,7 +68,7 @@ class Persona(SQLModel, table=True): It can be used to customize the behavior of LLMs. """ - __tablename__ = "personas" + __tablename__ = "personas" # type: ignore id: int | None = Field( primary_key=True, @@ -104,7 +98,7 @@ class Persona(SQLModel, table=True): class Preference(SQLModel, table=True): """This class represents preferences for bots.""" - __tablename__ = "preferences" + __tablename__ = "preferences" # type: ignore id: int | None = Field( default=None, @@ -140,7 +134,7 @@ class PlatformMessageHistory(SQLModel, table=True): or platform-specific messages. """ - __tablename__ = "platform_message_history" + __tablename__ = "platform_message_history" # type: ignore id: int | None = Field( primary_key=True, @@ -167,7 +161,7 @@ class Attachment(SQLModel, table=True): Attachments can be images, files, or other media types. """ - __tablename__ = "attachments" + __tablename__ = "attachments" # type: ignore inner_attachment_id: int | None = Field( primary_key=True, diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index c6978e7b..dc188f14 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -211,6 +211,8 @@ class LLMResponse: """Tool call names.""" tools_call_ids: list[str] = field(default_factory=list) """Tool call IDs.""" + tools_call_extra_content: dict[str, dict[str, Any]] = field(default_factory=dict) + """Tool call extra content. tool_call_id -> extra_content dict""" reasoning_content: str = "" """The reasoning content extracted from the LLM, if any.""" @@ -233,6 +235,7 @@ class LLMResponse: tools_call_args: list[dict[str, Any]] | None = None, tools_call_name: list[str] | None = None, tools_call_ids: list[str] | None = None, + tools_call_extra_content: dict[str, dict[str, Any]] | None = None, raw_completion: ChatCompletion | GenerateContentResponse | AnthropicMessage @@ -256,6 +259,8 @@ class LLMResponse: tools_call_name = [] if tools_call_ids is None: tools_call_ids = [] + if tools_call_extra_content is None: + tools_call_extra_content = {} self.role = role self.completion_text = completion_text @@ -263,6 +268,7 @@ class LLMResponse: self.tools_call_args = tools_call_args self.tools_call_name = tools_call_name self.tools_call_ids = tools_call_ids + self.tools_call_extra_content = tools_call_extra_content self.raw_completion = raw_completion self.is_chunk = is_chunk @@ -288,16 +294,19 @@ class LLMResponse: """Convert to OpenAI tool calls format. Deprecated, use to_openai_to_calls_model instead.""" ret = [] for idx, tool_call_arg in enumerate(self.tools_call_args): - ret.append( - { - "id": self.tools_call_ids[idx], - "function": { - "name": self.tools_call_name[idx], - "arguments": json.dumps(tool_call_arg), - }, - "type": "function", + payload = { + "id": self.tools_call_ids[idx], + "function": { + "name": self.tools_call_name[idx], + "arguments": json.dumps(tool_call_arg), }, - ) + "type": "function", + } + if self.tools_call_extra_content.get(self.tools_call_ids[idx]): + payload["extra_content"] = self.tools_call_extra_content[ + self.tools_call_ids[idx] + ] + ret.append(payload) return ret def to_openai_to_calls_model(self) -> list[ToolCall]: @@ -311,6 +320,10 @@ class LLMResponse: name=self.tools_call_name[idx], arguments=json.dumps(tool_call_arg), ), + # the extra_content will not serialize if it's None when calling ToolCall.model_dump() + extra_content=self.tools_call_extra_content.get( + self.tools_call_ids[idx] + ), ), ) return ret diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index b9159eec..e14140d4 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -290,13 +290,24 @@ class ProviderGoogleGenAI(Provider): parts = [types.Part.from_text(text=content)] append_or_extend(gemini_contents, parts, types.ModelContent) elif not native_tool_enabled and "tool_calls" in message: - parts = [ - types.Part.from_function_call( + parts = [] + for tool in message["tool_calls"]: + part = types.Part.from_function_call( name=tool["function"]["name"], args=json.loads(tool["function"]["arguments"]), ) - for tool in message["tool_calls"] - ] + # we should set thought_signature back to part if exists + # for more info about thought_signature, see: + # https://ai.google.dev/gemini-api/docs/thought-signatures + if "extra_content" in tool: + ts_bs64 = ( + tool["extra_content"] + .get("google", {}) + .get("thought_signature") + ) + if ts_bs64: + part.thought_signature = base64.b64decode(ts_bs64) + parts.append(part) append_or_extend(gemini_contents, parts, types.ModelContent) else: logger.warning("assistant 角色的消息内容为空,已添加空格占位") @@ -393,10 +404,15 @@ class ProviderGoogleGenAI(Provider): llm_response.role = "tool" llm_response.tools_call_name.append(part.function_call.name) llm_response.tools_call_args.append(part.function_call.args) - # gemini 返回的 function_call.id 可能为 None - llm_response.tools_call_ids.append( - part.function_call.id or part.function_call.name, - ) + # function_call.id might be None, use name as fallback + tool_call_id = part.function_call.id or part.function_call.name + llm_response.tools_call_ids.append(tool_call_id) + # extra_content + if part.thought_signature: + ts_bs64 = base64.b64encode(part.thought_signature).decode("utf-8") + llm_response.tools_call_extra_content[tool_call_id] = { + "google": {"thought_signature": ts_bs64} + } elif ( part.inline_data and part.inline_data.mime_type @@ -435,6 +451,7 @@ class ProviderGoogleGenAI(Provider): contents=conversation, config=config, ) + logger.debug(f"genai result: {result}") if not result.candidates: logger.error(f"请求失败, 返回的 candidates 为空: {result}") diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index da2ce68f..3f1d283c 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -8,7 +8,7 @@ import re from collections.abc import AsyncGenerator from openai import AsyncAzureOpenAI, AsyncOpenAI -from openai._exceptions import NotFoundError, UnprocessableEntityError +from openai._exceptions import NotFoundError from openai.lib.streaming.chat._completions import ChatCompletionStreamState from openai.types.chat.chat_completion import ChatCompletion from openai.types.chat.chat_completion_chunk import ChatCompletionChunk @@ -279,6 +279,7 @@ class ProviderOpenAIOfficial(Provider): args_ls = [] func_name_ls = [] tool_call_ids = [] + tool_call_extra_content_dict = {} for tool_call in choice.message.tool_calls: if isinstance(tool_call, str): # workaround for #1359 @@ -296,11 +297,16 @@ class ProviderOpenAIOfficial(Provider): 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 llm_response.tools_call_ids = tool_call_ids - + llm_response.tools_call_extra_content = tool_call_extra_content_dict # specially handle finish reason if choice.finish_reason == "content_filter": raise Exception( @@ -353,7 +359,7 @@ class ProviderOpenAIOfficial(Provider): payloads = {"messages": context_query, **model_config} - # xAI 原生搜索参数(最小侵入地在此处注入) + # xAI origin search tool inject self._maybe_inject_xai_search(payloads, **kwargs) return payloads, context_query @@ -475,12 +481,6 @@ class ProviderOpenAIOfficial(Provider): self.client.api_key = chosen_key llm_response = await self._query(payloads, func_tool) break - except UnprocessableEntityError as e: - logger.warning(f"不可处理的实体错误:{e},尝试删除图片。") - # 尝试删除所有 image - new_contexts = await self._remove_image_from_context(context_query) - payloads["messages"] = new_contexts - context_query = new_contexts except Exception as e: last_exception = e ( @@ -545,12 +545,6 @@ class ProviderOpenAIOfficial(Provider): async for response in self._query_stream(payloads, func_tool): yield response break - except UnprocessableEntityError as e: - logger.warning(f"不可处理的实体错误:{e},尝试删除图片。") - # 尝试删除所有 image - new_contexts = await self._remove_image_from_context(context_query) - payloads["messages"] = new_contexts - context_query = new_contexts except Exception as e: last_exception = e ( @@ -646,4 +640,3 @@ class ProviderOpenAIOfficial(Provider): with open(image_url, "rb") as f: image_bs64 = base64.b64encode(f.read()).decode("utf-8") return "data:image/jpeg;base64," + image_bs64 - return "" From 676f9fd4ff158f9d68a8ca3310be6256599b5d03 Mon Sep 17 00:00:00 2001 From: Dt8333 <25431943+Dt8333@users.noreply.github.com> Date: Wed, 19 Nov 2025 21:36:34 +0800 Subject: [PATCH 3/6] fix(dashboard.i18n): complete the missing i18n keys(#3699) #3679 --- .../src/i18n/locales/en-US/core/common.json | 3 +- .../src/i18n/locales/en-US/core/header.json | 3 +- .../i18n/locales/en-US/core/navigation.json | 4 +- .../i18n/locales/en-US/features/config.json | 8 +++- .../en-US/features/knowledge-base/detail.json | 24 ++++++++++++ .../features/knowledge-base/document.json | 5 ++- .../i18n/locales/en-US/features/tool-use.json | 38 ++++++++++++++++++- 7 files changed, 76 insertions(+), 9 deletions(-) diff --git a/dashboard/src/i18n/locales/en-US/core/common.json b/dashboard/src/i18n/locales/en-US/core/common.json index 4aff4100..37b38419 100644 --- a/dashboard/src/i18n/locales/en-US/core/common.json +++ b/dashboard/src/i18n/locales/en-US/core/common.json @@ -72,7 +72,8 @@ "enabled": "Enabled", "disabled": "Disabled", "delete": "Delete", + "copy": "Copy", "edit": "Edit", "noData": "No data available" } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/en-US/core/header.json b/dashboard/src/i18n/locales/en-US/core/header.json index 41c5ac0d..718cc60e 100644 --- a/dashboard/src/i18n/locales/en-US/core/header.json +++ b/dashboard/src/i18n/locales/en-US/core/header.json @@ -32,7 +32,6 @@ "issueLink": "GitHub Issues" }, "tip": "💡 TIP:", - "tipLink": "", "tipContinue": "By default, the corresponding version of the WebUI files will be downloaded when switching versions. The WebUI code is located in the dashboard directory of the project, and you can use npm to build it yourself.", "dockerTip": "When switching versions, it will try to update both the bot main program and the dashboard. If you are using Docker deployment, you can also re-pull the image or use", "dockerTipLink": "watchtower", @@ -91,4 +90,4 @@ "updateFailed": "Update failed, please try again" } } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/en-US/core/navigation.json b/dashboard/src/i18n/locales/en-US/core/navigation.json index 809b1018..9351d1da 100644 --- a/dashboard/src/i18n/locales/en-US/core/navigation.json +++ b/dashboard/src/i18n/locales/en-US/core/navigation.json @@ -5,8 +5,8 @@ "persona": "Persona", "toolUse": "MCP Tools", "config": "Config", - "extension": "Extensions", "chat": "Chat", + "extension": "Extensions", "conversation": "Conversations", "sessionManagement": "Session Management", "console": "Console", @@ -20,4 +20,4 @@ "groups": { "more": "More Features" } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/en-US/features/config.json b/dashboard/src/i18n/locales/en-US/features/config.json index c6fee467..eebab4a7 100644 --- a/dashboard/src/i18n/locales/en-US/features/config.json +++ b/dashboard/src/i18n/locales/en-US/features/config.json @@ -30,7 +30,11 @@ "configApplyError": "Configuration not applied, JSON format error.", "saveSuccess": "Configuration saved successfully", "saveError": "Failed to save configuration", - "loadError": "Failed to load configuration" + "loadError": "Failed to load configuration", + "deleteSuccess": "Deleted successfully", + "deleteError": "Failed to delete", + "updateSuccess": "Updated successfully", + "updateError": "Failed to update" }, "sections": { "general": "General Settings", @@ -59,4 +63,4 @@ "rateLimit": "Rate Limit", "encryption": "Encryption Settings" } -} \ 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 1ae3c09d..90d3e615 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 @@ -74,6 +74,30 @@ "urlHint": "The main content will be automatically extracted from the target URL as a document. Currently supports {supported} pages. Before use, please ensure that the target web page allows crawler access.", "beta": "Beta" }, + "retrieval": { + "title": "Retrieval", + "subtitle": "Test the knowledge base using dense and sparse retrieval methods", + "query": "Query", + "queryPlaceholder": "Enter a query...", + "search": "Search", + "searching": "Searching...", + "results": "Results", + "noResults": "No results found", + "tryDifferentQuery": "Try a different query", + "settings": "Retrieval Settings", + "topK": "Number of Results", + "topKHint": "Maximum number of results to return", + "enableRerank": "Enable Rerank", + "enableRerankHint": "Use a rerank model to improve retrieval quality", + "score": "Relevance Score", + "document": "Document", + "chunk": "Chunk #{index}", + "content": "Content", + "charCount": "{count} characters", + "searchSuccess": "Search completed, found {count} results", + "searchFailed": "Search failed", + "queryRequired": "Please enter a query" + }, "settings": { "title": "Knowledge Base Settings", "basic": "Basic Settings", diff --git a/dashboard/src/i18n/locales/en-US/features/knowledge-base/document.json b/dashboard/src/i18n/locales/en-US/features/knowledge-base/document.json index 35c430aa..d3a3b65c 100644 --- a/dashboard/src/i18n/locales/en-US/features/knowledge-base/document.json +++ b/dashboard/src/i18n/locales/en-US/features/knowledge-base/document.json @@ -22,7 +22,10 @@ "preview": "Preview", "search": "Search Chunks", "searchPlaceholder": "Enter keywords to search chunks...", - "showing": "Showing" + "showing": "Showing", + "deleteConfirm": "Are you sure you want to delete this chunk?", + "deleteSuccess": "Chunk deleted successfully", + "deleteFailed": "Failed to delete chunk" }, "edit": { "title": "Edit Chunk", diff --git a/dashboard/src/i18n/locales/en-US/features/tool-use.json b/dashboard/src/i18n/locales/en-US/features/tool-use.json index 2887d78f..8a6ccd49 100644 --- a/dashboard/src/i18n/locales/en-US/features/tool-use.json +++ b/dashboard/src/i18n/locales/en-US/features/tool-use.json @@ -96,6 +96,42 @@ }, "confirmDelete": "Are you sure you want to delete server {name}?" }, + "syncProvider": { + "title": "Sync MCP Servers", + "subtitle": "Sync MCP server configurations from providers to local", + "steps": { + "selectProvider": "Step 1: Select Provider", + "configureAuth": "Step 2: Configure Authentication", + "syncServers": "Step 3: Sync Servers" + }, + "providers": { + "modelscope": "ModelScope", + "description": "ModelScope is an open model community providing MCP servers for various machine learning and AI services" + }, + "fields": { + "provider": "Select Provider", + "accessToken": "Access Token", + "tokenRequired": "Access token is required", + "tokenHint": "Please enter your ModelScope access token" + }, + "buttons": { + "cancel": "Cancel", + "previous": "Previous", + "next": "Next", + "sync": "Start Sync", + "getToken": "Get Token" + }, + "status": { + "selectProvider": "Please select an MCP server provider", + "enterToken": "Please enter the access token to continue", + "readyToSync": "Ready to sync server configurations" + }, + "messages": { + "syncSuccess": "MCP servers synced successfully!", + "syncError": "Sync failed: {error}", + "tokenHelp": "How to get a ModelScope access token? Click the button on the right for instructions" + } + }, "messages": { "getServersError": "Failed to get MCP server list: {error}", "getToolsError": "Failed to get function tools list: {error}", @@ -117,4 +153,4 @@ "toggleToolError": "Failed to toggle tool status: {error}", "testError": "Test connection failed: {error}" } -} \ No newline at end of file +} From 8488c9aeab59dd158d030b439da42f7c397f6949 Mon Sep 17 00:00:00 2001 From: Dt8333 <25431943+Dt8333@users.noreply.github.com> Date: Wed, 19 Nov 2025 21:44:38 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(core.platform):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=90=AF=E7=94=A8=E5=A4=9A=E4=B8=AA=E4=BC=81=E4=B8=9A=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E6=99=BA=E8=83=BD=E6=9C=BA=E5=99=A8=E4=BA=BA=E9=80=82?= =?UTF-8?q?=E9=85=8D=E5=99=A8=E6=97=B6=E6=B6=88=E6=81=AF=E6=B7=B7=E4=B9=B1?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98=20(#3693)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(core.platform): 修复启用多个企业微信智能机器人适配器时消息混乱的问题 移除了全局的消息队列,改为每个适配器处理自己的队列。修改相关方法适应该更改。 #3673 * chore: apply suggestions from code review Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- .../sources/wecom_ai_bot/wecomai_adapter.py | 23 +++++++++++-------- .../sources/wecom_ai_bot/wecomai_event.py | 12 ++++++---- .../sources/wecom_ai_bot/wecomai_queue_mgr.py | 4 ---- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py index 29ac0265..9c13cfef 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py @@ -30,7 +30,7 @@ from .wecomai_api import ( WecomAIBotStreamMessageBuilder, ) from .wecomai_event import WecomAIBotMessageEvent -from .wecomai_queue_mgr import WecomAIQueueMgr, wecomai_queue_mgr +from .wecomai_queue_mgr import WecomAIQueueMgr from .wecomai_server import WecomAIBotServer from .wecomai_utils import ( WecomAIBotConstants, @@ -144,9 +144,12 @@ class WecomAIBotAdapter(Platform): # 事件循环和关闭信号 self.shutdown_event = asyncio.Event() + # 队列管理器 + self.queue_mgr = WecomAIQueueMgr() + # 队列监听器 self.queue_listener = WecomAIQueueListener( - wecomai_queue_mgr, + self.queue_mgr, self._handle_queued_message, ) @@ -189,7 +192,7 @@ class WecomAIBotAdapter(Platform): stream_id, session_id, ) - wecomai_queue_mgr.set_pending_response(stream_id, callback_params) + self.queue_mgr.set_pending_response(stream_id, callback_params) resp = WecomAIBotStreamMessageBuilder.make_text_stream( stream_id, @@ -207,7 +210,7 @@ class WecomAIBotAdapter(Platform): elif msgtype == "stream": # wechat server is requesting for updates of a stream stream_id = message_data["stream"]["id"] - if not wecomai_queue_mgr.has_back_queue(stream_id): + if not self.queue_mgr.has_back_queue(stream_id): logger.error(f"Cannot find back queue for stream_id: {stream_id}") # 返回结束标志,告诉微信服务器流已结束 @@ -222,7 +225,7 @@ class WecomAIBotAdapter(Platform): callback_params["timestamp"], ) return resp - queue = wecomai_queue_mgr.get_or_create_back_queue(stream_id) + queue = self.queue_mgr.get_or_create_back_queue(stream_id) if queue.empty(): logger.debug( f"No new messages in back queue for stream_id: {stream_id}", @@ -242,10 +245,9 @@ class WecomAIBotAdapter(Platform): elif msg["type"] == "end": # stream end finish = True - wecomai_queue_mgr.remove_queues(stream_id) + self.queue_mgr.remove_queues(stream_id) break - else: - pass + logger.debug( f"Aggregated content: {latest_plain_content}, image: {len(image_base64)}, finish: {finish}", ) @@ -313,8 +315,8 @@ class WecomAIBotAdapter(Platform): session_id: str, ): """将消息放入队列进行异步处理""" - input_queue = wecomai_queue_mgr.get_or_create_queue(stream_id) - _ = wecomai_queue_mgr.get_or_create_back_queue(stream_id) + input_queue = self.queue_mgr.get_or_create_queue(stream_id) + _ = self.queue_mgr.get_or_create_back_queue(stream_id) message_payload = { "message_data": message_data, "callback_params": callback_params, @@ -453,6 +455,7 @@ class WecomAIBotAdapter(Platform): platform_meta=self.meta(), session_id=message.session_id, api_client=self.api_client, + queue_mgr=self.queue_mgr, ) self.commit_event(message_event) diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py index 130182b4..0091783a 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py @@ -8,7 +8,7 @@ from astrbot.api.message_components import ( ) from .wecomai_api import WecomAIBotAPIClient -from .wecomai_queue_mgr import wecomai_queue_mgr +from .wecomai_queue_mgr import WecomAIQueueMgr class WecomAIBotMessageEvent(AstrMessageEvent): @@ -21,6 +21,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent): platform_meta, session_id: str, api_client: WecomAIBotAPIClient, + queue_mgr: WecomAIQueueMgr, ): """初始化消息事件 @@ -34,14 +35,16 @@ class WecomAIBotMessageEvent(AstrMessageEvent): """ super().__init__(message_str, message_obj, platform_meta, session_id) self.api_client = api_client + self.queue_mgr = queue_mgr @staticmethod async def _send( message_chain: MessageChain, stream_id: str, + queue_mgr: WecomAIQueueMgr, streaming: bool = False, ): - back_queue = wecomai_queue_mgr.get_or_create_back_queue(stream_id) + back_queue = queue_mgr.get_or_create_back_queue(stream_id) if not message_chain: await back_queue.put( @@ -94,7 +97,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent): "wecom_ai_bot platform event raw_message should be a dict" ) stream_id = raw.get("stream_id", self.session_id) - await WecomAIBotMessageEvent._send(message, stream_id) + await WecomAIBotMessageEvent._send(message, stream_id, self.queue_mgr) await super().send(message) async def send_streaming(self, generator, use_fallback=False): @@ -105,7 +108,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent): "wecom_ai_bot platform event raw_message should be a dict" ) stream_id = raw.get("stream_id", self.session_id) - back_queue = wecomai_queue_mgr.get_or_create_back_queue(stream_id) + back_queue = self.queue_mgr.get_or_create_back_queue(stream_id) # 企业微信智能机器人不支持增量发送,因此我们需要在这里将增量内容累积起来,积累发送 increment_plain = "" @@ -134,6 +137,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent): final_data += await WecomAIBotMessageEvent._send( chain, stream_id=stream_id, + queue_mgr=self.queue_mgr, streaming=True, ) diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py index eb345529..3a982bdf 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py @@ -151,7 +151,3 @@ class WecomAIQueueMgr: "output_queues": len(self.back_queues), "pending_responses": len(self.pending_responses), } - - -# 全局队列管理器实例 -wecomai_queue_mgr = WecomAIQueueMgr() From 6d6fefc4355ce71cbe935449a6bc763f0cb8a83a Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Thu, 20 Nov 2025 16:01:22 +0800 Subject: [PATCH 5/6] fix: anyio.ClosedResourceError when calling mcp tools (#3700) * fix: anyio.ClosedResourceError when calling mcp tools added reconnect mechanism fixes: 3676 * fix(mcp_client): implement thread-safe reconnection using asyncio.Lock --- astrbot/core/agent/mcp_client.py | 180 +++++++++++++++++---- astrbot/core/provider/func_tool_manager.py | 21 +-- pyproject.toml | 3 +- requirements.txt | 1 + 4 files changed, 168 insertions(+), 37 deletions(-) diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index 05980b21..88cab486 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -4,6 +4,14 @@ from contextlib import AsyncExitStack from datetime import timedelta from typing import Generic +from tenacity import ( + before_sleep_log, + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + from astrbot import logger from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.utils.log_pipe import LogPipe @@ -12,21 +20,24 @@ from .run_context import TContext from .tool import FunctionTool try: + import anyio import mcp from mcp.client.sse import sse_client except (ModuleNotFoundError, ImportError): - logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。") + logger.warning( + "Warning: Missing 'mcp' dependency, MCP services will be unavailable." + ) try: from mcp.client.streamable_http import streamablehttp_client except (ModuleNotFoundError, ImportError): logger.warning( - "警告: 缺少依赖库 'mcp' 或者 mcp 库版本过低,无法使用 Streamable HTTP 连接方式。", + "Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.", ) def _prepare_config(config: dict) -> dict: - """准备配置,处理嵌套格式""" + """Prepare configuration, handle nested format""" if config.get("mcpServers"): first_key = next(iter(config["mcpServers"])) config = config["mcpServers"][first_key] @@ -35,7 +46,7 @@ def _prepare_config(config: dict) -> dict: async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: - """快速测试 MCP 服务器可达性""" + """Quick test MCP server connectivity""" import aiohttp cfg = _prepare_config(config.copy()) @@ -50,7 +61,7 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: elif "type" in cfg: transport_type = cfg["type"] else: - raise Exception("MCP 连接配置缺少 transport 或 type 字段") + raise Exception("MCP connection config missing transport or type field") async with aiohttp.ClientSession() as session: if transport_type == "streamable_http": @@ -91,7 +102,7 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: return False, f"HTTP {response.status}: {response.reason}" except asyncio.TimeoutError: - return False, f"连接超时: {timeout}秒" + return False, f"Connection timeout: {timeout} seconds" except Exception as e: return False, f"{e!s}" @@ -101,6 +112,7 @@ class MCPClient: # Initialize session and client objects self.session: mcp.ClientSession | None = None self.exit_stack = AsyncExitStack() + self._old_exit_stacks: list[AsyncExitStack] = [] # Track old stacks for cleanup self.name: str | None = None self.active: bool = True @@ -108,22 +120,32 @@ class MCPClient: self.server_errlogs: list[str] = [] self.running_event = asyncio.Event() - async def connect_to_server(self, mcp_server_config: dict, name: str): - """连接到 MCP 服务器 + # Store connection config for reconnection + self._mcp_server_config: dict | None = None + self._server_name: str | None = None + self._reconnect_lock = asyncio.Lock() # Lock for thread-safe reconnection + self._reconnecting: bool = False # For logging and debugging - 如果 `url` 参数存在: - 1. 当 transport 指定为 `streamable_http` 时,使用 Streamable HTTP 连接方式。 - 1. 当 transport 指定为 `sse` 时,使用 SSE 连接方式。 - 2. 如果没有指定,默认使用 SSE 的方式连接到 MCP 服务。 + async def connect_to_server(self, mcp_server_config: dict, name: str): + """Connect to MCP server + + If `url` parameter exists: + 1. When transport is specified as `streamable_http`, use Streamable HTTP connection. + 2. When transport is specified as `sse`, use SSE connection. + 3. If not specified, default to SSE connection to MCP service. Args: mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server """ + # Store config for reconnection + self._mcp_server_config = mcp_server_config + self._server_name = name + cfg = _prepare_config(mcp_server_config.copy()) def logging_callback(msg: str): - # 处理 MCP 服务的错误日志 + # Handle MCP service error logs print(f"MCP Server {name} Error: {msg}") self.server_errlogs.append(msg) @@ -137,7 +159,7 @@ class MCPClient: elif "type" in cfg: transport_type = cfg["type"] else: - raise Exception("MCP 连接配置缺少 transport 或 type 字段") + raise Exception("MCP connection config missing transport or type field") if transport_type != "streamable_http": # SSE transport method @@ -193,7 +215,7 @@ class MCPClient: ) def callback(msg: str): - # 处理 MCP 服务的错误日志 + # Handle MCP service error logs self.server_errlogs.append(msg) stdio_transport = await self.exit_stack.enter_async_context( @@ -222,10 +244,120 @@ class MCPClient: self.tools = response.tools return response + async def _reconnect(self) -> None: + """Reconnect to the MCP server using the stored configuration. + + Uses asyncio.Lock to ensure thread-safe reconnection in concurrent environments. + + Raises: + Exception: raised when reconnection fails + """ + async with self._reconnect_lock: + # Check if already reconnecting (useful for logging) + if self._reconnecting: + logger.debug( + f"MCP Client {self._server_name} is already reconnecting, skipping" + ) + return + + if not self._mcp_server_config or not self._server_name: + raise Exception("Cannot reconnect: missing connection configuration") + + self._reconnecting = True + try: + logger.info( + f"Attempting to reconnect to MCP server {self._server_name}..." + ) + + # Save old exit_stack for later cleanup (don't close it now to avoid cancel scope issues) + if self.exit_stack: + self._old_exit_stacks.append(self.exit_stack) + + # Mark old session as invalid + self.session = None + + # Create new exit stack for new connection + self.exit_stack = AsyncExitStack() + + # Reconnect using stored config + await self.connect_to_server(self._mcp_server_config, self._server_name) + await self.list_tools_and_save() + + logger.info( + f"Successfully reconnected to MCP server {self._server_name}" + ) + except Exception as e: + logger.error( + f"Failed to reconnect to MCP server {self._server_name}: {e}" + ) + raise + finally: + self._reconnecting = False + + async def call_tool_with_reconnect( + self, + tool_name: str, + arguments: dict, + read_timeout_seconds: timedelta, + ) -> mcp.types.CallToolResult: + """Call MCP tool with automatic reconnection on failure, max 2 retries. + + Args: + tool_name: tool name + arguments: tool arguments + read_timeout_seconds: read timeout + + Returns: + MCP tool call result + + Raises: + ValueError: MCP session is not available + anyio.ClosedResourceError: raised after reconnection failure + """ + + @retry( + retry=retry_if_exception_type(anyio.ClosedResourceError), + stop=stop_after_attempt(2), + wait=wait_exponential(multiplier=1, min=1, max=3), + before_sleep=before_sleep_log(logger, logging.WARNING), + reraise=True, + ) + async def _call_with_retry(): + if not self.session: + raise ValueError("MCP session is not available for MCP function tools.") + + try: + return await self.session.call_tool( + name=tool_name, + arguments=arguments, + read_timeout_seconds=read_timeout_seconds, + ) + except anyio.ClosedResourceError: + logger.warning( + f"MCP tool {tool_name} call failed (ClosedResourceError), attempting to reconnect..." + ) + # Attempt to reconnect + await self._reconnect() + # Reraise the exception to trigger tenacity retry + raise + + return await _call_with_retry() + async def cleanup(self): - """Clean up resources""" - await self.exit_stack.aclose() - self.running_event.set() # Set the running event to indicate cleanup is done + """Clean up resources including old exit stacks from reconnections""" + # Set running_event first to unblock any waiting tasks + self.running_event.set() + + # Close current exit stack + try: + await self.exit_stack.aclose() + except Exception as e: + logger.debug(f"Error closing current exit stack: {e}") + + # Don't close old exit stacks as they may be in different task contexts + # They will be garbage collected naturally + # Just clear the list to release references + self._old_exit_stacks.clear() class MCPTool(FunctionTool, Generic[TContext]): @@ -246,14 +378,8 @@ class MCPTool(FunctionTool, Generic[TContext]): async def call( self, context: ContextWrapper[TContext], **kwargs ) -> mcp.types.CallToolResult: - session = self.mcp_client.session - if not session: - raise ValueError("MCP session is not available for MCP function tools.") - res = await session.call_tool( - name=self.mcp_tool.name, + return await self.mcp_client.call_tool_with_reconnect( + tool_name=self.mcp_tool.name, arguments=kwargs, - read_timeout_seconds=timedelta( - seconds=context.tool_call_timeout, - ), + read_timeout_seconds=timedelta(seconds=context.tool_call_timeout), ) - return res diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index 7cdbeec0..8e04423e 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -280,19 +280,22 @@ class FunctionToolManager: async def _terminate_mcp_client(self, name: str) -> None: """关闭并清理MCP客户端""" if name in self.mcp_client_dict: + client = self.mcp_client_dict[name] try: # 关闭MCP连接 - await self.mcp_client_dict[name].cleanup() - self.mcp_client_dict.pop(name) + await client.cleanup() except Exception as e: logger.error(f"清空 MCP 客户端资源 {name}: {e}。") - # 移除关联的FuncTool - self.func_list = [ - f - for f in self.func_list - if not (isinstance(f, MCPTool) and f.mcp_server_name == name) - ] - logger.info(f"已关闭 MCP 服务 {name}") + finally: + # Remove client from dict after cleanup attempt (successful or not) + self.mcp_client_dict.pop(name, None) + # 移除关联的FuncTool + self.func_list = [ + f + for f in self.func_list + if not (isinstance(f, MCPTool) and f.mcp_server_name == name) + ] + logger.info(f"已关闭 MCP 服务 {name}") @staticmethod async def test_mcp_server_connection(config: dict) -> list[str]: diff --git a/pyproject.toml b/pyproject.toml index 576bc196..70758184 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ dependencies = [ "jieba>=0.42.1", "markitdown-no-magika[docx,xls,xlsx]>=0.1.2", "xinference-client", + "tenacity>=9.1.2", ] [dependency-groups] @@ -107,4 +108,4 @@ exclude = ["dashboard", "node_modules", "dist", "data", "tests"] [build-system] requires = ["hatchling"] -build-backend = "hatchling.build" \ No newline at end of file +build-backend = "hatchling.build" diff --git a/requirements.txt b/requirements.txt index e8b3dee3..b5674119 100644 --- a/requirements.txt +++ b/requirements.txt @@ -52,3 +52,4 @@ rank-bm25>=0.2.2 jieba>=0.42.1 markitdown-no-magika[docx,xls,xlsx]>=0.1.2 xinference-client +tenacity>=9.1.2 \ No newline at end of file From 164a4226ea0daa971a08f939dba19f81cce053e4 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Thu, 20 Nov 2025 16:07:09 +0800 Subject: [PATCH 6/6] feat(chat): refactor chat component structure and add new features (#3701) - Introduced `ConversationSidebar.vue` for improved conversation management and sidebar functionality. - Enhanced `MessageList.vue` to handle loading states and improved message rendering. - Created new composables: `useConversations`, `useMessages`, `useMediaHandling`, `useRecording` for better code organization and reusability. - Added loading indicators and improved user experience during message processing. - Ensured backward compatibility and maintained existing functionalities. --- dashboard/src/components/chat/Chat.vue | 1700 ++++------------- dashboard/src/components/chat/ChatInput.vue | 283 +++ .../components/chat/ConversationSidebar.vue | 310 +++ dashboard/src/components/chat/MessageList.vue | 92 +- dashboard/src/composables/useConversations.ts | 145 ++ dashboard/src/composables/useMediaHandling.ts | 104 + dashboard/src/composables/useMessages.ts | 303 +++ dashboard/src/composables/useRecording.ts | 74 + 8 files changed, 1615 insertions(+), 1396 deletions(-) create mode 100644 dashboard/src/components/chat/ChatInput.vue create mode 100644 dashboard/src/components/chat/ConversationSidebar.vue create mode 100644 dashboard/src/composables/useConversations.ts create mode 100644 dashboard/src/composables/useMediaHandling.ts create mode 100644 dashboard/src/composables/useMessages.ts create mode 100644 dashboard/src/composables/useRecording.ts diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index d671b15b..bb3418d6 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -5,89 +5,20 @@