Compare commits

...

20 Commits

Author SHA1 Message Date
Cooper
12f2f5a09c fix: Added Try Except to Filter Handling (#9183)
* Added Try Except to Filter Handling

* Avoid Redundant Error Processing
2026-07-09 15:48:52 +08:00
SpencerFang0617
afb898e61f fix(line): remove unexpected force argument from get_json (#9187) 2026-07-09 15:47:57 +08:00
Soulter
0be821afc6 chore: update readme 2026-07-08 00:39:31 +08:00
VectorPeak
dcc40ede1d fix: validate dashboard account username updates (#9175)
Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
2026-07-08 00:27:52 +08:00
Soulter
adebd2958e chore: refine README
SengokuCola 必定女装
2026-07-07 22:00:19 +08:00
Soulter
331444b41f docs: update v4.26.5 changelog format 2026-07-07 10:12:25 +08:00
Weilong Liao
39090a74ba chore: bump version to 4.26.5 (#9174) 2026-07-07 09:54:43 +08:00
Weilong Liao
bf18fadbe6 style: polish snackbar appearance (#9173) 2026-07-07 09:43:59 +08:00
Soulter
1124dfe295 docs: broaden diagnostics guide 2026-07-07 00:26:03 +08:00
Soulter
510e833695 chore: log event loop watchdog startup at info 2026-07-07 00:21:01 +08:00
Soulter
0a5d6485a1 docs: add diagnostics guide 2026-07-07 00:19:40 +08:00
Weilong Liao
0dee40bd9d feat: add event loop diagnostics (#9168)
* feat: add event loop diagnostics

* fix: write event loop watchdog dumps to rotating log

* chore: use fixed event loop diagnostic defaults

* fix: keep event loop watchdog alive after dump failures
2026-07-07 00:14:03 +08:00
Weilong Liao
2a7c02af8e fix: remove qq official reply id on proactive fallback so oversized passive replies can succeed via proactive sending (#9169) 2026-07-06 23:48:30 +08:00
lingyun14
56f2533951 fix(mcp): attempt to fix epoll busy-wait caused by leaked SSE exit stack (#8307)
* fix(mcp): prevent epoll busy-wait caused by leaked SSE exit stack

* fix(mcp): address review feedback on owner task connection lifecycle

* Update mcp_client.py
2026-07-06 23:06:48 +08:00
killer-qert
afb0079116 fix: exit MCP anyio contexts in the lifecycle task on disable (#9132)
* fix: exit MCP anyio contexts in the lifecycle task on disable

#9070 moved MCP shutdown cleanup into the per-server lifecycle task so
that the anyio cancel scopes entered in connect_to_server() are exited
from the task that entered them. Running the cleanup through
asyncio.shield() defeats that: shield wraps the coroutine in a new
task, so disabling a server still fails with "Attempted to exit cancel
scope in a different task than it was entered in" and leaves the scope
state corrupted -- the failure mode behind the CPU spin reported in
#9068.

Await _terminate_mcp_client() directly instead. The graceful disable
path has no cancellation in flight, and on forced shutdown the
lifecycle task still runs its finally block in-task, so the shield only
served to break task affinity.

Closes #9068

* fix: absorb late cancellations during MCP shutdown cleanup

A cancellation delivered while the lifecycle task is already running its
finally-block cleanup would abort _terminate_mcp_client() halfway,
stranding the runtime entry and the MCP transport. Retry the cleanup in
the same task and uncancel() the absorbed request instead, so a forced
shutdown can no longer skip it.

* fix: guard Task.uncancel() for Python 3.10 runtimes

Task.uncancel() only exists on Python 3.11+. On 3.10 absorbing the
CancelledError is sufficient, so skip the call when unavailable.
2026-07-06 23:04:41 +08:00
Weilong Liao
4166d9ab58 fix: preserve qq official quoted message context (#9164)
* fix: preserve qq official quoted message context

* Update astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix: standardize qq-botpy version specification in dependencies

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-07-06 22:55:21 +08:00
Glmg
ba7f8ebfb1 fix(webui): sync plugin activation status on enable (#9156)
* fix(webui): fix plugin activation status sync issue in turn_on_plugin #9149

* fix(webui): sync plugin activation status after enabling

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-06 21:16:14 +08:00
Weilong Liao
ab2502c174 feat: add chat token usage indicator
Show a neutral token usage ring in ChatUI when the current model has context-window metadata.
2026-07-06 21:14:35 +08:00
Fiber
56326c7551 fix: serialize webchat history timestamps as UTC-aware ISO strings (#9159)
ChatService serialized PlatformMessageHistory records via bare model_dump(),
causing created_at/updated_at to be emitted as naive ISO strings without a
timezone suffix. The frontend new Date() parses such strings as local time
per ES2015+, so UTC values were displayed as-is instead of converted.

Add serialize_history_entry helper and apply it to get_session, get_thread,
and update_message. This matches the SSE real-time path and session list
path, ensuring the frontend consistently receives ISO strings with the
+00:00 suffix across all code paths.
2026-07-06 21:12:29 +08:00
Weilong Liao
18e067fab1 fix: keep model metadata separate from providers (#9161) 2026-07-06 21:10:57 +08:00
50 changed files with 1657 additions and 166 deletions

View File

@@ -75,6 +75,10 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
### One-Click Deployment
> [!NOTE]
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
> For macOS users: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s).
For users who want to quickly experience AstrBot, are familiar with command-line usage, and can install a `uv` environment on their own, we recommend the `uv` one-click deployment method ⚡️:
```bash
@@ -83,20 +87,17 @@ astrbot init # Only execute this command for the first time to initialize the en
astrbot run
```
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
> AstrBot requires Python 3.12 or later. The `--python 3.12` option ensures that `uv` creates the tool environment with Python 3.12.
> [!NOTE]
> For macOS users: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s).
Update `astrbot`:
```bash
uv tool upgrade astrbot --python 3.12
```
> [!WARNING]
> AstrBot deployed via `uv` **does not support upgrading through the WebUI**. To update, please run the command above from the command line.
### One-Click Cloud Deployment (RainYun)
For users who want one-click deployment and do not want to manage servers themselves, we recommend RainYun's one-click cloud deployment service ☁️:
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
### Docker Deployment
@@ -104,12 +105,6 @@ For users familiar with containers and looking for a more stable, production-rea
Please refer to the official documentation: [Deploy AstrBot with Docker](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
### Deploy on RainYun
For users who want one-click deployment and do not want to manage servers themselves, we recommend RainYun's one-click cloud deployment service ☁️:
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
### Desktop Application Deployment
For users who want to use AstrBot on desktop and mainly use ChatUI, we recommend AstrBot App.
@@ -142,6 +137,18 @@ yay -S astrbot-git
If you need panel-based management or deeper customization, see [BT-Panel Deployment](https://docs.astrbot.app/deploy/astrbot/btpanel.html) for BT Panel app-store setup, [1Panel Deployment](https://docs.astrbot.app/deploy/astrbot/1panel.html) for 1Panel app-market deployment, [CasaOS Deployment](https://docs.astrbot.app/deploy/astrbot/casaos.html) for NAS/home-server visual deployment, and [Manual Deployment](https://docs.astrbot.app/deploy/astrbot/cli.html) for fully custom source-based installation with `uv`.
## ❤️ Sponsors
Welcome to sponsor us via [Afdian](https://afdian.com/a/astrbot_team) or [contact us](mailto:community@astrbot.app).
<p align="center">
<a target="_blank" href="https://astrbot.app/#/sponsors">
<img alt="sponsors" src="https://sponsors.astrbot.app/?v=1">
</a>
</p>
## Supported Messaging Platforms
Connect AstrBot to your favorite chat platform.
@@ -205,13 +212,6 @@ Connect AstrBot to your favorite chat platform.
| Xiaomi MiMo TTS | Text-to-Speech Services |
| Volcano Engine TTS | Text-to-Speech Services |
## ❤️ Sponsors
<p align="center">
<img alt="sponsors" src="https://sponsors.astrbot.app/?v=1">
</p>
## ❤️ Contributing
Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
@@ -235,22 +235,7 @@ pre-commit install
### QQ Groups
- Group 1: 322154837 (Full)
- Group 3: 630166526 (Full)
- Group 4: 1077826412 (Full)
- Group 5: 822130018 (Full)
- Group 6: 753075035 (Full)
- Group 7: 743746109 (Full)
- Group 8: 1030353265 (Full)
- Group 9: 1076659624 (Full)
- Group 10: 1078079676 (Full)
- Group 11: 704659519 (Full)
- Group 12: 916228568 (Full)
- Group 13: 1092185289
- Group 14: 1103419483
- Developer Group(Chit-chat): 975206796
- Developer Group(Formal): 1039761811
We have 15+ chat groups, please see: [Community](https://docs.astrbot.app/community.html) for details.
### Discord Server
@@ -264,9 +249,10 @@ Special thanks to all Contributors and plugin developers for their contributions
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
</a>
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
Open Source Friends ❤️
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - The amazing cat framework
- [Mai-with-u/MaiBot](https://github.com/Mai-with-u/MaiBot) - The powerful "digital life" in your QQ!
## ⭐ Star History

View File

@@ -1,4 +1,4 @@
import logging
__version__ = "4.26.4"
__version__ = "4.26.5"
logger = logging.getLogger("astrbot")

View File

@@ -392,10 +392,16 @@ def _normalize_mcp_input_schema(schema: dict[str, Any]) -> dict[str, Any]:
class MCPClient:
def __init__(self) -> None:
# 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
# Each connection runs in its own task so that anyio cancel scopes
# are always exited from the task that entered them, preventing
# RuntimeError: Attempted to exit cancel scope in a different task
self._connection_task: asyncio.Task | None = None
self._old_connection_tasks: list[asyncio.Task] = []
# Internal; managed exclusively by _run_connection.
self.exit_stack: AsyncExitStack | None = None
self.name: str | None = None
self.active: bool = True
@@ -403,14 +409,67 @@ class MCPClient:
self.server_errlogs: list[str] = []
self.running_event = asyncio.Event()
# 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
self._reconnecting: bool = False
async def _run_connection(
self,
mcp_server_config: dict,
name: str,
ready: asyncio.Future,
) -> None:
"""Own the full lifetime of one MCP connection.
This coroutine is always run inside a dedicated asyncio.Task
(_connection_task). Because *this task* is the one that enters every
anyio cancel scope (via sse_client / streamablehttp_client), anyio's
_host_task check is always satisfied when the stack is later closed —
either in the task's own finally block (normal path) or when the task
is cancelled from outside (cleanup / reconnect path).
This avoids the
RuntimeError: Attempted to exit cancel scope in a different task
that previously occurred when aclose() was called from a different task
or from the asyncio async-generator GC finalizer.
"""
# Capture the stack in a local variable so that if self.exit_stack is
# overwritten by a concurrent _run_connection (during reconnect), this
# task's finally block still closes only the resources it opened.
stack = self.exit_stack = AsyncExitStack()
try:
try:
await self._do_connect(mcp_server_config, name)
except Exception as exc:
if not ready.done():
ready.set_exception(exc)
raise
else:
if not ready.done():
ready.set_result(None)
# Hold the connection open until cancelled.
await asyncio.Event().wait()
finally:
try:
await stack.aclose()
except Exception as e:
logger.debug(f"Error closing exit stack for {name}: {e}")
# Clear the instance reference only if it still points to this task's
# stack; a concurrent reconnect may have already replaced it.
if self.exit_stack is stack:
self.exit_stack = None
# Guard against the task exiting before ready was resolved.
if not ready.done():
ready.set_exception(RuntimeError("Connection task exited early"))
async def connect_to_server(self, mcp_server_config: dict, name: str) -> None:
"""Connect to MCP server
"""Connect to MCP server by spawning a dedicated owner task.
The owner task (_connection_task) holds the AsyncExitStack and all
anyio cancel scopes for the lifetime of this connection. To disconnect,
cancel _connection_task — the finally block in _run_connection will call
aclose() from within the correct task context.
If `url` parameter exists:
1. When transport is specified as `streamable_http`, use Streamable HTTP connection.
@@ -421,10 +480,47 @@ class MCPClient:
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
ready: asyncio.Future = asyncio.get_running_loop().create_future()
# Defensively cancel any existing connection task that was not cleaned
# up before this call (e.g. if connect_to_server is called twice).
if self._connection_task and not self._connection_task.done():
self._cancel_connection_task(self._connection_task)
self._connection_task = None
self._connection_task = asyncio.create_task(
self._run_connection(mcp_server_config, name, ready),
name=f"mcp-conn:{name}",
)
try:
await ready
except asyncio.CancelledError:
# Caller was cancelled while waiting — tear down the connection task.
# cancel() is asynchronous; the task will not finish until the next
# event-loop iteration, so we track it in _old_connection_tasks so
# that cleanup() can await it later.
if self._connection_task and not self._connection_task.done():
self._cancel_connection_task(self._connection_task)
self._connection_task = None
raise
except Exception:
# _do_connect raised; the connection task's finally block may still
# be running (e.g. awaiting stack.aclose()). Track it so that
# cleanup() can await it, but do NOT cancel it — we want the
# finally block to finish cleaning up resources naturally.
if self._connection_task and not self._connection_task.done():
self._old_connection_tasks.append(self._connection_task)
self._connection_task = None
raise
async def _do_connect(self, mcp_server_config: dict, name: str) -> None:
"""Internal: perform the actual connection inside _run_connection's task."""
# exit_stack is always set by _run_connection before _do_connect is called.
assert self.exit_stack is not None
cfg = _prepare_config(mcp_server_config.copy())
def logging_callback(
@@ -563,16 +659,32 @@ class MCPClient:
self.tools = response.tools
return response
def _cancel_connection_task(self, task: asyncio.Task) -> None:
"""Cancel a connection owner task and track it until it finishes."""
# Prune already-finished tasks to avoid accumulating references over
# many reconnections in a long-running process.
self._old_connection_tasks = [
t for t in self._old_connection_tasks if not t.done()
]
if task.done():
return
task.cancel()
self._old_connection_tasks.append(task)
async def _reconnect(self) -> None:
"""Reconnect to the MCP server using the stored configuration.
Cancels the current _connection_task (which owns the exit_stack and all
anyio cancel scopes) and starts a fresh one. Because each connection
task enters and exits its own anyio cancel scope, there is no
cross-task cancel-scope violation and no GC finalizer surprise.
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"
@@ -588,17 +700,16 @@ class MCPClient:
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
# Cancel the old connection task. Its finally block will call
# exit_stack.aclose() from within the correct task context, so
# anyio cancel scopes are exited cleanly without triggering the
# GC-finalizer busy-spin bug.
if self._connection_task and not self._connection_task.done():
self._cancel_connection_task(self._connection_task)
self._connection_task = None
self.session = None
# Create new exit stack for new connection
self.exit_stack = AsyncExitStack()
# Reconnect using stored config
# Reconnect — this creates a new _connection_task.
await self.connect_to_server(self._mcp_server_config, self._server_name)
await self.list_tools_and_save()
@@ -663,19 +774,20 @@ class MCPClient:
return await _call_with_retry()
async def cleanup(self) -> None:
"""Clean up resources including old exit stacks from reconnections"""
# Close current exit stack
try:
await self.exit_stack.aclose()
except Exception as e:
logger.debug(f"Error closing current exit stack: {e}")
"""Clean up resources by cancelling the connection owner task."""
# Cancel current and any old connection tasks via the shared helper so
# all cancellation + tracking behaviour goes through one code path.
if self._connection_task:
self._cancel_connection_task(self._connection_task)
self._connection_task = None
# 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()
if self._old_connection_tasks:
pending = [t for t in self._old_connection_tasks if not t.done()]
if pending:
await asyncio.gather(*pending, return_exceptions=True)
self._old_connection_tasks.clear()
# Set running_event first to unblock any waiting tasks
# Set running_event to unblock any waiting tasks
self.running_event.set()

View File

@@ -35,6 +35,9 @@ from astrbot.core.star.star_manager import PluginManager
from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
from astrbot.core.umop_config_router import UmopConfigRouter
from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils.event_loop_diagnostics import (
create_event_loop_diagnostic_tasks,
)
from astrbot.core.utils.llm_metadata import update_llm_metadata
from astrbot.core.utils.migra_helper import migra
from astrbot.core.utils.temp_dir_cleaner import TempDirCleaner
@@ -296,13 +299,18 @@ class AstrBotCoreLifecycle:
self.temp_dir_cleaner.run(),
name="temp_dir_cleaner",
)
diagnostic_tasks = create_event_loop_diagnostic_tasks()
# 把插件中注册的所有协程函数注册到事件总线中并执行
extra_tasks = []
for task in self.star_context._register_tasks:
extra_tasks.append(asyncio.create_task(task, name=task.__name__)) # type: ignore
tasks_ = [event_bus_task, *(extra_tasks if extra_tasks else [])]
tasks_ = [
event_bus_task,
*diagnostic_tasks,
*(extra_tasks if extra_tasks else []),
]
if cron_task:
tasks_.append(cron_task)
if temp_dir_cleaner_task:

View File

@@ -243,7 +243,13 @@ class ResultDecorateStage(Stage):
continue
for seg in split_response:
if self.content_cleanup_rule:
seg = re.sub(self.content_cleanup_rule, "", seg)
try:
seg = re.sub(self.content_cleanup_rule, "", seg)
except re.error:
logger.error(
f"分段回复过滤表达式失败,无法成功过滤:{traceback.format_exc()}"
)
self.content_cleanup_rule = None
seg = seg.strip()
if seg:
new_chain.append(Plain(seg))

View File

@@ -133,7 +133,7 @@ class LinePlatformAdapter(Platform):
return "invalid signature", 400
try:
payload = await request.get_json(force=True, silent=False)
payload = await request.get_json(silent=False)
except Exception as e:
logger.warning("[LINE] invalid webhook body: %s", e)
return "bad request", 400

View File

@@ -502,6 +502,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
logger.info("[QQOfficial] 回复消息失败: %s, 尝试使用主动发送接口。", err)
if payload.get("msg_id"):
fallback_payload = payload.copy()
fallback_payload.pop("msg_id", None)
try:
ret = await send_func(fallback_payload)
logger.info("[QQOfficial] 使用主动发送接口发送成功。")

View File

@@ -17,7 +17,7 @@ from botpy.gateway import BotWebSocket
from astrbot import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
@@ -37,7 +37,75 @@ for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
def _set_raw_message_fields(message: Any, data: dict[str, Any]) -> None:
"""Preserve QQ message fields that qq-botpy does not expose.
Args:
message: Patched qq-botpy message object.
data: Raw message payload from QQ.
Returns:
None.
"""
if not isinstance(data, dict):
data = {}
message.raw_data = data
message.message_type = data.get("message_type")
msg_elements = data.get("msg_elements")
message.msg_elements = msg_elements if isinstance(msg_elements, list) else []
class PatchedMessage(botpy.message.Message):
__slots__ = ("raw_data", "message_type", "msg_elements")
def __init__(
self,
api: Any,
event_id: str | None,
data: dict[str, Any],
) -> None:
super().__init__(api, event_id, data) # type: ignore
_set_raw_message_fields(self, data)
class PatchedDirectMessage(botpy.message.DirectMessage):
__slots__ = ("raw_data", "message_type", "msg_elements")
def __init__(
self,
api: Any,
event_id: str | None,
data: dict[str, Any],
) -> None:
super().__init__(api, event_id, data) # type: ignore
_set_raw_message_fields(self, data)
class PatchedC2CMessage(botpy.message.C2CMessage):
__slots__ = ("raw_data", "message_type", "msg_elements")
def __init__(
self,
api: Any,
event_id: str | None,
data: dict[str, Any],
) -> None:
super().__init__(api, event_id, data) # type: ignore
_set_raw_message_fields(self, data)
class PatchedGroupMessage(botpy.message.GroupMessage):
__slots__ = ("raw_data", "message_type", "msg_elements")
def __init__(
self,
api: Any,
event_id: str | None,
data: dict[str, Any],
) -> None:
super().__init__(api, event_id, data) # type: ignore
_set_raw_message_fields(self, data)
class _User:
def __init__(self, data: dict[str, Any]) -> None:
self.id = data.get("id", None)
@@ -53,21 +121,43 @@ class PatchedGroupMessage(botpy.message.GroupMessage):
def _ensure_group_message_create_parser() -> None:
"""Register the missing qq-botpy parser for GROUP_MESSAGE_CREATE."""
"""Register qq-botpy message parsers with QQ quote payload preservation."""
if hasattr(ConnectionState, "parse_group_message_create"):
return
def build_parser(event_name: str, message_cls: type) -> Any:
"""Build a ConnectionState parser for one QQ message event.
def parse_group_message_create(self, payload: dict[str, Any]) -> None:
group_message = PatchedGroupMessage(
self.api,
payload.get("id", None),
payload.get("d", {}),
Args:
event_name: botpy dispatch event name.
message_cls: Patched message class used to retain raw fields.
Returns:
Parser function bound by qq-botpy's ConnectionState.
"""
def parse_message(self, payload: dict[str, Any]) -> None:
qq_message = message_cls(
self.api,
payload.get("id", None),
payload.get("d", {}),
)
self._dispatch(event_name, qq_message)
return parse_message
parser_specs = {
"message_create": ("message_create", PatchedMessage),
"at_message_create": ("at_message_create", PatchedMessage),
"direct_message_create": ("direct_message_create", PatchedDirectMessage),
"group_at_message_create": ("group_at_message_create", PatchedGroupMessage),
"c2c_message_create": ("c2c_message_create", PatchedC2CMessage),
"group_message_create": ("group_message_create", PatchedGroupMessage),
}
for parser_name, (event_name, message_cls) in parser_specs.items():
setattr(
ConnectionState,
f"parse_{parser_name}",
build_parser(event_name, message_cls),
)
logger.debug("[QQOfficial] Received group message: %s", group_message)
self._dispatch("group_message_create", group_message)
setattr(ConnectionState, "parse_group_message_create", parse_group_message_create)
class ManagedBotWebSocket(BotWebSocket):
@@ -491,25 +581,41 @@ class QQOfficialPlatformAdapter(Platform):
return
for attachment in attachments:
content_type = cast(
str,
getattr(attachment, "content_type", "") or "",
).lower()
url = QQOfficialPlatformAdapter._normalize_attachment_url(
cast(str | None, getattr(attachment, "url", None))
)
if not url:
continue
if content_type.startswith("image"):
msg.append(Image.fromURL(url))
if isinstance(attachment, dict):
content_type = str(
attachment.get("content_type")
or attachment.get("contentType")
or "",
).lower()
url = QQOfficialPlatformAdapter._normalize_attachment_url(
cast(str | None, attachment.get("url"))
)
filename = cast(
str,
attachment.get("filename")
or attachment.get("name")
or "attachment",
)
else:
content_type = cast(
str,
getattr(attachment, "content_type", "") or "",
).lower()
url = QQOfficialPlatformAdapter._normalize_attachment_url(
cast(str | None, getattr(attachment, "url", None))
)
filename = cast(
str,
getattr(attachment, "filename", None)
or getattr(attachment, "name", None)
or "attachment",
)
if not url:
continue
if content_type.startswith("image"):
msg.append(Image.fromURL(url))
else:
ext = Path(filename).suffix.lower()
image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
audio_exts = {
@@ -607,6 +713,53 @@ class QQOfficialPlatformAdapter(Platform):
abm.message_id = message.id
# abm.tag = "qq_official"
msg: list[BaseMessageComponent] = []
message_reference = getattr(message, "message_reference", None)
quoted_message_id = getattr(message_reference, "message_id", None)
raw_message_type = getattr(message, "message_type", None)
try:
is_quoted_message = int(raw_message_type or 0) == 103
except (TypeError, ValueError):
is_quoted_message = False
msg_elements = getattr(message, "msg_elements", None)
quoted_message_str = ""
quoted_element_message_id = ""
quoted_chain: list[BaseMessageComponent] = []
if is_quoted_message and isinstance(msg_elements, list) and msg_elements:
quoted_element = msg_elements[0]
if isinstance(quoted_element, dict):
quoted_content = quoted_element.get("content")
quoted_attachments = quoted_element.get("attachments")
quoted_element_message_id = str(
quoted_element.get("id") or quoted_element.get("message_id") or "",
)
else:
quoted_content = getattr(quoted_element, "content", None)
quoted_attachments = getattr(quoted_element, "attachments", None)
quoted_element_message_id = str(
getattr(quoted_element, "id", None)
or getattr(quoted_element, "message_id", None)
or "",
)
quoted_message_str = QQOfficialPlatformAdapter._parse_face_message(
str(quoted_content or "").strip()
)
if quoted_message_str:
quoted_chain.append(Plain(quoted_message_str))
if isinstance(quoted_attachments, list):
await QQOfficialPlatformAdapter._append_attachments(
quoted_chain,
quoted_attachments,
)
if quoted_message_id or quoted_element_message_id or quoted_chain:
msg.append(
Reply(
id=str(quoted_message_id or quoted_element_message_id or ""),
chain=quoted_chain,
message_str=quoted_message_str,
text=quoted_message_str,
)
)
if isinstance(message, botpy.message.GroupMessage) or isinstance(
message,

View File

@@ -723,8 +723,21 @@ class FunctionToolManager:
logger.debug(f"MCP client {name} task was cancelled")
raise
finally:
# Cleanup in the same task that entered the anyio contexts
await asyncio.shield(self._terminate_mcp_client(name))
# Cleanup in the same task that entered the anyio contexts:
# asyncio.shield() would schedule the coroutine as a separate
# Task, and anyio cancel scopes cannot exit across tasks (#9068).
# Absorb late cancellations so a forced shutdown cannot abort
# the cleanup halfway.
task = asyncio.current_task()
while True:
try:
await self._terminate_mcp_client(name)
break
except asyncio.CancelledError:
# Task.uncancel() is 3.11+; on 3.10 absorbing the
# cancellation is sufficient.
if task is not None and hasattr(task, "uncancel"):
task.uncancel()
lifecycle_task = asyncio.create_task(
connect_and_lifecycle(), name=f"mcp-client:{name}"

View File

@@ -1295,8 +1295,7 @@ class PluginManager:
star_registry.append(metadata)
# 禁用/启用插件
if metadata.module_path in inactivated_plugins:
metadata.activated = False
metadata.activated = metadata.module_path not in inactivated_plugins
# Plugin logo path
if os.path.exists(logo_path):
@@ -1934,7 +1933,12 @@ class PluginManager:
for func_tool in self._iter_plugin_llm_tools(plugin.module_path):
func_tool.active = func_tool.name not in inactivated_llm_tools
await self.reload(plugin_name)
success, error = await self.reload(plugin_name)
if not success:
raise Exception(error or f"插件 {plugin_name} 启用失败。")
current_plugin = self.context.get_registered_star(plugin_name)
if current_plugin:
current_plugin.activated = True
async def install_plugin_from_file(
self, zip_file_path: str, ignore_version_check: bool = False

View File

@@ -0,0 +1,216 @@
import asyncio
import faulthandler
from dataclasses import dataclass
from pathlib import Path
from typing import TextIO
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
DEFAULT_LAG_MONITOR_ENABLED = True
DEFAULT_LAG_MONITOR_INTERVAL = 5.0
DEFAULT_LAG_MONITOR_THRESHOLD = 15.0
DEFAULT_WATCHDOG_ENABLED = True
DEFAULT_WATCHDOG_INTERVAL = 5.0
DEFAULT_WATCHDOG_TIMEOUT = 30.0
DEFAULT_WATCHDOG_LOG_RELATIVE_PATH = Path("logs") / "event_loop_watchdog.log"
DEFAULT_WATCHDOG_LOG_MAX_BYTES = 1024 * 1024
@dataclass(frozen=True)
class EventLoopDiagnosticSettings:
"""Settings for event loop lag and blockage diagnostics.
Args:
lag_monitor_enabled: Whether to log event loop scheduling lag.
lag_monitor_interval: Seconds between lag monitor wakeups.
lag_monitor_threshold: Minimum lag seconds before logging a warning.
watchdog_enabled: Whether to arm the faulthandler watchdog.
watchdog_interval: Seconds between faulthandler watchdog refreshes.
watchdog_timeout: Seconds without event loop progress before dumping stacks.
watchdog_log_path: File that receives faulthandler watchdog output.
watchdog_log_max_bytes: Maximum watchdog log bytes before rotation.
"""
lag_monitor_enabled: bool
lag_monitor_interval: float
lag_monitor_threshold: float
watchdog_enabled: bool
watchdog_interval: float
watchdog_timeout: float
watchdog_log_path: Path
watchdog_log_max_bytes: int
def _watchdog_log_path() -> Path:
"""Resolve the watchdog stack dump log path.
Returns:
Absolute path for watchdog stack dump output.
"""
return Path(get_astrbot_data_path()) / DEFAULT_WATCHDOG_LOG_RELATIVE_PATH
def load_event_loop_diagnostic_settings() -> EventLoopDiagnosticSettings:
"""Load fixed event loop diagnostic settings.
Returns:
Event loop diagnostic settings.
"""
return EventLoopDiagnosticSettings(
lag_monitor_enabled=DEFAULT_LAG_MONITOR_ENABLED,
lag_monitor_interval=DEFAULT_LAG_MONITOR_INTERVAL,
lag_monitor_threshold=DEFAULT_LAG_MONITOR_THRESHOLD,
watchdog_enabled=DEFAULT_WATCHDOG_ENABLED,
watchdog_interval=DEFAULT_WATCHDOG_INTERVAL,
watchdog_timeout=DEFAULT_WATCHDOG_TIMEOUT,
watchdog_log_path=_watchdog_log_path(),
watchdog_log_max_bytes=DEFAULT_WATCHDOG_LOG_MAX_BYTES,
)
async def monitor_event_loop_lag(
*,
interval: float = DEFAULT_LAG_MONITOR_INTERVAL,
warn_after: float = DEFAULT_LAG_MONITOR_THRESHOLD,
) -> None:
"""Log a warning when the event loop wakes significantly later than expected.
Args:
interval: Seconds between monitor wakeups.
warn_after: Minimum lag seconds before logging a warning.
"""
loop = asyncio.get_running_loop()
expected = loop.time() + interval
while True:
await asyncio.sleep(interval)
now = loop.time()
lag = now - expected
if lag > warn_after:
logger.warning(
"Event loop lag detected: %.3fs (threshold %.3fs).",
lag,
warn_after,
)
expected = now + interval
def _rotate_watchdog_log_file(log_path: Path, max_bytes: int) -> None:
"""Rotate the watchdog log when it reaches the configured size limit.
Args:
log_path: Current watchdog log path.
max_bytes: Maximum current log size before rotation.
"""
try:
if not log_path.exists() or log_path.stat().st_size < max_bytes:
return
rotated_path = log_path.with_name(f"{log_path.name}.1")
if rotated_path.exists():
rotated_path.unlink()
log_path.replace(rotated_path)
except OSError as e:
logger.warning("Failed to rotate event loop watchdog log %s: %s", log_path, e)
def _open_watchdog_log_file(log_path: Path, max_bytes: int) -> TextIO:
"""Open the watchdog log file after applying size-based rotation.
Args:
log_path: Current watchdog log path.
max_bytes: Maximum current log size before rotation.
Returns:
Writable text file object for faulthandler output.
"""
log_path.parent.mkdir(parents=True, exist_ok=True)
_rotate_watchdog_log_file(log_path, max_bytes)
return log_path.open("a", encoding="utf-8")
async def faulthandler_event_loop_watchdog(
*,
timeout: float = DEFAULT_WATCHDOG_TIMEOUT,
interval: float = DEFAULT_WATCHDOG_INTERVAL,
dump_file: TextIO | None = None,
dump_path: Path | None = None,
max_bytes: int = DEFAULT_WATCHDOG_LOG_MAX_BYTES,
) -> None:
"""Dump all thread stacks if the event loop is blocked for too long.
Args:
timeout: Seconds without watchdog refresh before faulthandler dumps stacks.
interval: Seconds between watchdog refreshes while the event loop is healthy.
dump_file: File object that receives faulthandler output.
dump_path: Path that receives faulthandler output when dump_file is unset.
max_bytes: Maximum current log size before rotation.
"""
log_path = dump_path or _watchdog_log_path()
try:
while True:
faulthandler.cancel_dump_traceback_later()
output: TextIO | None = None
should_close = False
try:
output = dump_file or _open_watchdog_log_file(log_path, max_bytes)
should_close = dump_file is None
faulthandler.dump_traceback_later(
timeout,
repeat=False,
file=output,
)
await asyncio.sleep(interval)
except Exception as e:
logger.warning("Event loop faulthandler watchdog failed: %s", e)
await asyncio.sleep(interval)
finally:
faulthandler.cancel_dump_traceback_later()
if should_close and output is not None:
output.close()
finally:
faulthandler.cancel_dump_traceback_later()
def create_event_loop_diagnostic_tasks() -> list[asyncio.Task]:
"""Create enabled event loop diagnostic tasks for the current loop.
Returns:
A list of created asyncio tasks.
"""
settings = load_event_loop_diagnostic_settings()
tasks: list[asyncio.Task] = []
if settings.lag_monitor_enabled:
tasks.append(
asyncio.create_task(
monitor_event_loop_lag(
interval=settings.lag_monitor_interval,
warn_after=settings.lag_monitor_threshold,
),
name="event_loop_lag_monitor",
)
)
if settings.watchdog_enabled:
logger.info(
"Event loop faulthandler watchdog enabled: timeout=%.3fs interval=%.3fs. "
"If the loop is blocked, Python thread stacks will be written to %s "
"(rotates at %d bytes).",
settings.watchdog_timeout,
settings.watchdog_interval,
settings.watchdog_log_path,
settings.watchdog_log_max_bytes,
)
tasks.append(
asyncio.create_task(
faulthandler_event_loop_watchdog(
timeout=settings.watchdog_timeout,
interval=settings.watchdog_interval,
dump_path=settings.watchdog_log_path,
max_bytes=settings.watchdog_log_max_bytes,
),
name="event_loop_faulthandler_watchdog",
)
)
return tasks

View File

@@ -369,6 +369,12 @@ class AuthService:
if not new_pwd and not new_username:
return self.error("新用户名和新密码不能同时为空")
username_to_save = None
if new_username is not None and new_username != "":
if not isinstance(new_username, str) or len(new_username.strip()) < 3:
return self.error("用户名长度至少3位")
username_to_save = new_username.strip()
if new_pwd:
if not isinstance(new_pwd, str):
return self.error("新密码无效")
@@ -384,8 +390,8 @@ class AuthService:
await set_password_change_required(self.db, self.config, False)
if is_totp_enabled(self.config):
await revoke_user_trusted_devices(self.db)
if new_username:
self.config["dashboard"]["username"] = new_username
if username_to_save:
self.config["dashboard"]["username"] = username_to_save
self.config.save_config()

View File

@@ -345,6 +345,23 @@ def serialize_thread(thread) -> dict:
}
def serialize_history_entry(history) -> dict:
"""Serialize a PlatformMessageHistory record with UTC-aware timestamps.
Args:
history: A PlatformMessageHistory instance. Must not be None.
Returns:
Dict with all model fields plus created_at/updated_at serialized as
UTC-aware ISO strings (e.g. ``2026-07-06T04:00:00+00:00``).
"""
return {
**history.model_dump(),
"created_at": to_utc_isoformat(history.created_at),
"updated_at": to_utc_isoformat(history.updated_at),
}
def find_checkpoint_index(history: list[dict], checkpoint_id: str) -> int | None:
for index, message in enumerate(history):
if get_checkpoint_id(message) == checkpoint_id:
@@ -1217,7 +1234,7 @@ class ChatService:
)
response_data = {
"history": [history.model_dump() for history in history_ls],
"history": [serialize_history_entry(history) for history in history_ls],
"threads": [serialize_thread(thread) for thread in threads],
"is_running": self.running_convs.get(session_id, False),
}
@@ -1333,7 +1350,7 @@ class ChatService:
)
return {
"thread": serialize_thread(thread),
"history": [history.model_dump() for history in history_ls],
"history": [serialize_history_entry(history) for history in history_ls],
"is_running": self.running_convs.get(thread_id, False),
}
@@ -1487,7 +1504,7 @@ class ChatService:
await self.db.update_platform_session(session_id=session_id)
updated = await self.db.get_platform_message_history_by_id(message_id)
return {
"message": updated.model_dump() if updated else None,
"message": serialize_history_entry(updated) if updated else None,
"needs_regenerate": True,
"truncated_after_message": True,
}

View File

@@ -24,7 +24,6 @@ from astrbot.core.platform.register import platform_cls_map, platform_registry
from astrbot.core.provider.register import provider_registry
from astrbot.core.star.star import star_registry
from astrbot.core.utils.astrbot_path import get_astrbot_plugin_data_path
from astrbot.core.utils.llm_metadata import LLM_METADATAS
from astrbot.core.utils.totp import (
is_totp_enabled,
revoke_user_trusted_devices,
@@ -1299,14 +1298,18 @@ class ProviderConfigService:
if provider.default_config_tmpl:
provider_default_tmpl[provider.type] = provider.default_config_tmpl
providers = copy.deepcopy(self.config.get("provider", []))
from astrbot.core.utils.llm_metadata import LLM_METADATAS
model_metadata = {}
for provider in providers:
model_id = provider.get("model")
if isinstance(model_id, str) and model_id in LLM_METADATAS:
provider["model_metadata"] = LLM_METADATAS[model_id]
model_metadata[model_id] = LLM_METADATAS[model_id]
return {
"config_schema": config_schema,
"providers": providers,
"provider_sources": self.config.get("provider_sources", []),
"model_metadata": model_metadata,
}
def list_provider_sources(self) -> dict:
@@ -1549,8 +1552,11 @@ class ProviderConfigService:
source_id: str | None = None,
enabled: bool | None = None,
) -> dict:
from astrbot.core.utils.llm_metadata import LLM_METADATAS
provider_type = self._resolve_provider_type(capability)
providers = []
model_metadata = {}
source_provider_type = {
source["id"]: source.get("provider_type", "chat_completion")
for source in self.provider_manager.provider_sources_config
@@ -1575,9 +1581,9 @@ class ProviderConfigService:
provider_response = copy.deepcopy(provider)
model_id = provider_response.get("model")
if isinstance(model_id, str) and model_id in LLM_METADATAS:
provider_response["model_metadata"] = LLM_METADATAS[model_id]
model_metadata[model_id] = LLM_METADATAS[model_id]
providers.append(provider_response)
return {"providers": providers}
return {"providers": providers, "model_metadata": model_metadata}
def list_providers_for_dashboard_types(
self, provider_type: str | None
@@ -1607,10 +1613,14 @@ class ProviderConfigService:
)
if provider is None:
raise ValueError(f"Provider {provider_id} not found")
model_id = provider.get("model")
provider_response = copy.deepcopy(provider)
from astrbot.core.utils.llm_metadata import LLM_METADATAS
model_id = provider_response.get("model")
model_metadata = {}
if isinstance(model_id, str) and model_id in LLM_METADATAS:
provider["model_metadata"] = LLM_METADATAS[model_id]
return {"provider": provider}
model_metadata[model_id] = LLM_METADATAS[model_id]
return {"provider": provider_response, "model_metadata": model_metadata}
async def create_provider(self, config: dict, source_id: str | None = None) -> None:
config = copy.deepcopy(config)

41
changelogs/v4.26.5.md Normal file
View File

@@ -0,0 +1,41 @@
## [4.26.5] - 2026-07-07
### Added
- Added support for setting a custom bot ID while creating a platform by scanning a QR code. (#9067)
- Added ChatUI project workspaces. (#9066)
- Added improved ChatUI attachment display. (#9134)
- Added malformed tool-call name sanitation in `ToolLoopAgentRunner`. (#9144)
- Added model metadata in selectors and a chat token usage indicator. (#9161)
- Added event-loop diagnostics and expanded diagnostics documentation. (#9168)
- Added documentation for cross-platform compatibility and Python version support.
- Added test coverage for POSIX file URI root preservation. (#8906)
### Changed
- Log event-loop watchdog startup at the info level.
- Keep model metadata separate from providers. (#9161)
- Polish snackbar styling in the WebUI. (#9173)
### Fixed
- Enabled only synced ModelScope MCP servers. (#9084)
- Improved MCP client startup and shutdown handling to avoid idle spinning and clean up on errors, timeouts, and cancellation. (#9070)
- Replaced fragile knowledge-base duplicate-name error matching with a pre-check. (#9121)
- Improved knowledge-base retrieval resilience on per-knowledge-base failures and rerank selection. (#9122)
- Cleaned up `KBMedia` records on document deletion and fixed knowledge-base stats updates. (#9120)
- Aligned the knowledge-base CRUD API contract with `kb_name`, canonical payload fields, and OpenAPI types. (#9000)
- Constrained custom workspace paths and preserved absolute custom workspace paths.
- Improved web search API key failover.
- Enforced ownership when reading ChatUI sessions. (#9141)
- Secured project update temporary staging. (#9083)
- Improved QQ Official retry behavior when websocket send returns `None`. (#8977)
- Preserved QQ Official quoted message context. (#9164)
- Removed QQ Official reply IDs on proactive fallback so oversized passive replies can succeed via proactive sending. (#9169)
- Adapted MiMo STT to V2.5 models and rejected non-WAV audio payloads. (#9118)
- Serialized webchat history timestamps as UTC-aware ISO strings. (#9159)
- Synced plugin activation status on enable in the WebUI. (#9156)
- Exited MCP `anyio` contexts in the lifecycle task on disable. (#9132)
- Fixed leaked SSE exit stack handling that could cause MCP epoll busy-wait. (#8307)
[4.26.5]: https://github.com/AstrBotDevs/AstrBot/compare/v4.26.4...v4.26.5

View File

@@ -7,9 +7,12 @@
<v-snackbar v-if="toastStore.current" v-model="snackbarShow" :color="toastStore.current.color"
:timeout="toastStore.current.timeout" :multi-line="toastStore.current.multiLine"
:location="toastStore.current.location" close-on-back>
{{ toastStore.current.message }}
<div class="app-snackbar-message">
<v-icon v-if="toastIcon" :icon="toastIcon" size="24" />
<span>{{ toastStore.current.message }}</span>
</div>
<template #actions v-if="toastStore.current.closable">
<v-btn variant="text" @click="snackbarShow = false">关闭</v-btn>
<v-btn icon="mdi-close" variant="text" size="small" aria-label="Close notification" @click="snackbarShow = false" />
</template>
</v-snackbar>
</template>
@@ -32,6 +35,14 @@ const snackbarShow = computed({
}
})
const toastIcon = computed(() => ({
success: 'mdi-check-circle-outline',
error: 'mdi-alert-circle-outline',
warning: 'mdi-alert-outline',
info: 'mdi-information-outline',
primary: 'mdi-information-outline'
})[toastStore.current?.color] || '')
onMounted(() => {
const desktopBridge = window.astrbotDesktop
if (!desktopBridge?.onTrayRestartBackend) {

View File

@@ -78,6 +78,21 @@ export interface ProviderSchemaData {
config_schema?: OpenConfig;
providers?: OpenConfig[];
provider_sources?: OpenConfig[];
model_metadata?: Record<string, unknown>;
}
export interface ProviderListData {
providers?: OpenConfig[];
model_metadata?: Record<string, unknown>;
}
export interface ProviderByTypeEnvelope extends ApiEnvelope<OpenConfig[]> {
model_metadata?: Record<string, unknown>;
}
export interface ProviderByIdData {
provider?: OpenConfig;
model_metadata?: Record<string, unknown>;
}
export interface ProviderSourceModelsData {
@@ -496,11 +511,13 @@ export const providerApi = {
);
},
list(params?: ProviderListParams) {
return typed<{ providers: OpenConfig[] }>(
return typed<ProviderListData>(
openApiV1.listProviders({ query: generatedQuery(params) }),
);
},
async listByProviderType(providerType: string): Promise<AxiosResponse<ApiEnvelope<OpenConfig[]>>> {
async listByProviderType(
providerType: string,
): Promise<AxiosResponse<ProviderByTypeEnvelope>> {
const capabilities = providerTypeToCapabilities(providerType);
if (capabilities.length === 0) {
const response = await providerApi.list();
@@ -509,6 +526,7 @@ export const providerApi = {
data: {
...response.data,
data: response.data.data.providers || [],
model_metadata: response.data.data.model_metadata || {},
},
};
}
@@ -517,11 +535,21 @@ export const providerApi = {
capabilities.map((capability) => providerApi.list({ capability })),
);
const first = responses[0];
const modelMetadata = responses.reduce<Record<string, unknown>>(
(acc, response) => ({
...acc,
...(response.data.data.model_metadata || {}),
}),
{},
);
return {
...first,
data: {
...first.data,
data: responses.flatMap((response) => response.data.data.providers || []),
data: responses.flatMap(
(response) => response.data.data.providers || [],
),
model_metadata: modelMetadata,
},
};
},
@@ -545,7 +573,7 @@ export const providerApi = {
);
},
get(providerId: string, merged = false) {
return typed<{ provider: OpenConfig }>(
return typed<ProviderByIdData>(
openApiV1.getProviderById({
query: { provider_id: providerId, merged },
}),

View File

@@ -357,6 +357,7 @@
:is-running="
Boolean(currSessionId && isSessionRunning(currSessionId))
"
:token-usage="tokenUsageIndicator"
:session-id="currSessionId || null"
:current-session="currentSession"
:reply-to="chatInputReplyTarget"
@@ -440,6 +441,7 @@
:is-running="
Boolean(currSessionId && isSessionRunning(currSessionId))
"
:token-usage="tokenUsageIndicator"
:session-id="currSessionId || null"
:current-session="currentSession"
:reply-to="chatInputReplyTarget"
@@ -561,7 +563,7 @@ import {
Sun,
Trash2,
} from "@lucide/vue";
import { chatApi } from "@/api/v1";
import { chatApi, providerApi } from "@/api/v1";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import ProjectDialog, {
type ProjectFormData,
@@ -597,6 +599,12 @@ import {
} from "@/i18n/composables";
import type { Locale } from "@/i18n/types";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
import {
contextLimit,
formatTokenCount,
type ProviderModelMetadata,
type ProviderMetadataSource,
} from "@/utils/providerMetadata";
import { useToast } from "@/utils/toast";
const props = withDefaults(defineProps<{ chatboxMode?: boolean; active?: boolean }>(), {
@@ -652,6 +660,11 @@ const {
type WorkspaceView = "chat" | "providers";
interface TokenProviderConfig extends ProviderMetadataSource {
id: string;
enable?: boolean;
}
const activeWorkspace = ref<WorkspaceView>("chat");
const projectDialogOpen = ref(false);
const editingProject = ref<Project | null>(null);
@@ -670,6 +683,9 @@ const projectSessionsById = ref<Record<string, Session[]>>({});
const loadingProjectSessionIds = ref<string[]>([]);
const loadingSessions = ref(false);
const draft = ref("");
const tokenProviderConfigs = ref<TokenProviderConfig[]>([]);
const tokenModelMetadata = ref<Record<string, ProviderModelMetadata>>({});
const selectedTokenProviderId = ref("");
const messagesContainer = ref<HTMLElement | null>(null);
const inputRef = ref<InstanceType<typeof ChatInput> | null>(null);
const shouldStickToBottom = ref(true);
@@ -841,15 +857,58 @@ const chatInputReplyTarget = computed(() =>
selectedText: replyPreview(replyTarget.value.id, ""),
},
);
const currentTokenProvider = computed(() => {
const selectedProvider = tokenProviderConfigs.value.find(
(provider) => provider.id === selectedTokenProviderId.value,
);
return selectedProvider || tokenProviderConfigs.value[0] || null;
});
const currentTokenMetadata = computed(() => {
const model = currentTokenProvider.value?.model;
return model ? tokenModelMetadata.value[model] || null : null;
});
const latestTokenUsageTotal = computed(() => {
for (let index = activeMessages.value.length - 1; index >= 0; index -= 1) {
const message = activeMessages.value[index];
if (isUserMessage(message)) continue;
const usage = message.content?.agentStats?.token_usage;
if (!usage) continue;
return (
readTokenCount(usage.input_other) +
readTokenCount(usage.input_cached) +
readTokenCount(usage.output)
);
}
return 0;
});
const tokenUsageIndicator = computed(() => {
const used = latestTokenUsageTotal.value;
const limit = contextLimit(currentTokenProvider.value, currentTokenMetadata.value);
if (used <= 0 || limit <= 0) return null;
const percent = (used / limit) * 100;
return {
used,
limit,
percent: Math.min(100, Math.max(0, percent)),
tooltip: tm("tokenUsage.tooltip", {
used: formatTokenCount(used),
limit: formatTokenCount(limit),
percent: formatUsagePercent(percent),
}),
};
});
function getSelectedProviderSelection() {
const inputSelection = inputRef.value?.getCurrentSelection();
if (inputSelection?.providerId) {
selectedTokenProviderId.value = inputSelection.providerId;
return inputSelection;
}
if (typeof window === "undefined") {
return { providerId: "", modelName: "" };
}
syncSelectedTokenProvider();
return {
providerId: localStorage.getItem("selectedProvider") || "",
modelName: localStorage.getItem("selectedProviderModel") || "",
@@ -869,7 +928,7 @@ watch(
onMounted(async () => {
loadingSessions.value = true;
try {
await Promise.all([getSessions(), getProjects()]);
await Promise.all([getSessions(), getProjects(), loadTokenProviders()]);
const routeSessionId = getRouteSessionId();
if (routeSessionId === "models") {
activeWorkspace.value = "providers";
@@ -954,6 +1013,40 @@ function sessionTitle(session: Session) {
return session.display_name?.trim() || tm("conversation.newConversation");
}
function syncSelectedTokenProvider() {
if (typeof window === "undefined") return;
selectedTokenProviderId.value = localStorage.getItem("selectedProvider") || "";
}
async function loadTokenProviders() {
syncSelectedTokenProvider();
try {
const response = await providerApi.listByProviderType("chat_completion");
if (response.data.status === "ok") {
tokenModelMetadata.value = (
(response.data as any).model_metadata || {}
) as Record<string, ProviderModelMetadata>;
tokenProviderConfigs.value = (
(response.data.data || []) as unknown as TokenProviderConfig[]
).filter((provider) => provider.enable !== false);
}
} catch (error) {
console.error("Failed to load provider context metadata:", error);
}
}
function readTokenCount(value: unknown) {
const count = Number(value || 0);
return Number.isFinite(count) && count > 0 ? count : 0;
}
function formatUsagePercent(value: number) {
if (!Number.isFinite(value) || value <= 0) return "0";
if (value >= 10) return String(Math.round(value));
if (value >= 1) return String(Math.round(value * 10) / 10);
return String(Math.round(value * 100) / 100);
}
async function startNewChat() {
showChatWorkspace();
selectedProjectId.value = null;

View File

@@ -242,6 +242,27 @@
class="mr-1"
width="1.5"
/>
<v-tooltip
v-if="tokenUsageVisible"
location="top"
max-width="320"
>
<template #activator="{ props: tokenTooltipProps }">
<span
v-bind="tokenTooltipProps"
class="token-usage-indicator"
:style="{ '--token-usage-color': tokenUsageColor }"
>
<v-progress-circular
:model-value="tokenUsagePercent"
size="24"
width="2.5"
class="token-usage-progress"
/>
</span>
</template>
<span>{{ props.tokenUsage?.tooltip }}</span>
</v-tooltip>
<!-- <v-btn @click="$emit('openLiveMode')"
icon
variant="text"
@@ -332,6 +353,13 @@ interface ReplyInfo {
selectedText?: string;
}
interface TokenUsageInfo {
used: number;
limit: number;
percent: number;
tooltip: string;
}
interface Props {
prompt: string;
stagedImagesUrl: string[];
@@ -347,6 +375,7 @@ interface Props {
replyTo?: ReplyInfo | null;
sendShortcut?: "enter" | "shift_enter";
showProviderSelector?: boolean;
tokenUsage?: TokenUsageInfo | null;
}
const props = withDefaults(defineProps<Props>(), {
@@ -357,6 +386,7 @@ const props = withDefaults(defineProps<Props>(), {
replyTo: null,
sendShortcut: "shift_enter",
showProviderSelector: true,
tokenUsage: null,
});
const emit = defineEmits<{
@@ -569,6 +599,29 @@ function handleReplyAfterLeave() {
const { mobile } = useDisplay();
const tokenUsageVisible = computed(() => {
const usage = props.tokenUsage;
return Boolean(
usage &&
Number.isFinite(usage.used) &&
Number.isFinite(usage.limit) &&
usage.used > 0 &&
usage.limit > 0,
);
});
const tokenUsagePercent = computed(() => {
const percent = props.tokenUsage?.percent || 0;
if (!Number.isFinite(percent)) return 0;
return Math.min(100, Math.max(0, percent));
});
const tokenUsageColor = computed(() =>
isDark.value
? "rgba(var(--v-theme-on-surface), 0.82)"
: "rgba(var(--v-theme-on-surface), 0.72)",
);
// Auto-resize textarea
function autoResize() {
const el = inputField.value;
@@ -996,6 +1049,31 @@ defineExpose({
background: rgba(var(--v-theme-on-surface), 0.04) !important;
}
.token-usage-indicator {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 24px;
border-radius: 50%;
color: var(--token-usage-color);
}
.token-usage-progress {
color: currentColor;
}
.token-usage-progress :deep(.v-progress-circular__underlay) {
color: rgba(var(--v-theme-on-surface), 0.18);
stroke: currentColor;
opacity: 0.24;
}
.token-usage-progress :deep(.v-progress-circular__overlay) {
stroke: currentColor;
}
.input-outline-control {
width: 36px !important;
height: 36px !important;

View File

@@ -73,7 +73,7 @@
<span>{{ item.tooltip }}</span>
</v-tooltip>
<v-tooltip
v-if="formatContextLimit(provider)"
v-if="formatContextLimit(provider, metadataForProvider(provider))"
location="top"
max-width="320"
>
@@ -83,12 +83,15 @@
class="meta-context-badge"
@click.stop
>
{{ formatContextLimit(provider) }}
{{ formatContextLimit(provider, metadataForProvider(provider)) }}
</span>
</template>
<span>{{
tm("models.metadata.context", {
tokens: formatContextLimit(provider),
tokens: formatContextLimit(
provider,
metadataForProvider(provider),
),
})
}}</span>
</v-tooltip>
@@ -141,6 +144,7 @@ import { useToast } from "@/utils/toast";
import {
formatContextLimit,
providerCapabilityBadges,
type ProviderModelMetadata,
type ProviderMetadataSource,
} from "@/utils/providerMetadata";
@@ -170,6 +174,7 @@ const menuOpen = ref(false);
const loadingProviders = ref(false);
const providersLoaded = ref(false);
const testingProviderIds = ref<string[]>([]);
const modelMetadata = ref<Record<string, ProviderModelMetadata>>({});
const { tm } = useModuleI18n("features/provider");
const { success: toastSuccess, error: toastError } = useToast();
@@ -230,6 +235,9 @@ async function loadProviderConfigs(force = false) {
try {
const response = await providerApi.listByProviderType("chat_completion");
if (response.data.status === "ok") {
modelMetadata.value = (
response.data.model_metadata || {}
) as Record<string, ProviderModelMetadata>;
providerConfigs.value = (
(response.data.data || []) as unknown as ProviderConfig[]
).filter((provider: ProviderConfig) => provider.enable !== false);
@@ -255,7 +263,11 @@ function selectProvider(provider: ProviderConfig) {
}
function capabilityBadges(provider: ProviderConfig) {
return providerCapabilityBadges(provider, tm);
return providerCapabilityBadges(provider, metadataForProvider(provider), tm);
}
function metadataForProvider(provider: ProviderConfig) {
return provider.model ? modelMetadata.value[provider.model] || null : null;
}
async function testProvider(provider: ProviderConfig) {

View File

@@ -97,7 +97,7 @@
<span>{{ item.tooltip }}</span>
</v-tooltip>
<v-tooltip
v-if="formatContextLimit(provider)"
v-if="formatContextLimit(provider, metadataForProvider(provider))"
location="top"
max-width="320"
>
@@ -107,12 +107,15 @@
class="regenerate-model-context-badge"
@click.stop
>
{{ formatContextLimit(provider) }}
{{ formatContextLimit(provider, metadataForProvider(provider)) }}
</span>
</template>
<span>{{
providerTm("models.metadata.context", {
tokens: formatContextLimit(provider),
tokens: formatContextLimit(
provider,
metadataForProvider(provider),
),
})
}}</span>
</v-tooltip>
@@ -137,6 +140,7 @@ import { useModuleI18n } from "@/i18n/composables";
import {
formatContextLimit,
providerCapabilityBadges,
type ProviderModelMetadata,
type ProviderMetadataSource,
} from "@/utils/providerMetadata";
@@ -161,6 +165,7 @@ const { tm: providerTm } = useModuleI18n("features/provider");
const providerConfigs = ref<ProviderConfig[]>([]);
const loadingProviders = ref(false);
const providersLoaded = ref(false);
const modelMetadata = ref<Record<string, ProviderModelMetadata>>({});
async function loadProviderConfigs(force = false) {
if (loadingProviders.value || (providersLoaded.value && !force)) return;
@@ -168,6 +173,9 @@ async function loadProviderConfigs(force = false) {
try {
const response = await providerApi.listByProviderType("chat_completion");
if (response.data.status === "ok") {
modelMetadata.value = (
response.data.model_metadata || {}
) as Record<string, ProviderModelMetadata>;
providerConfigs.value = (
(response.data.data || []) as unknown as ProviderConfig[]
).filter((provider: ProviderConfig) => provider.enable !== false);
@@ -200,7 +208,15 @@ function retryWithModel(provider: ProviderConfig) {
}
function capabilityBadges(provider: ProviderConfig) {
return providerCapabilityBadges(provider, providerTm);
return providerCapabilityBadges(
provider,
metadataForProvider(provider),
providerTm,
);
}
function metadataForProvider(provider: ProviderConfig) {
return provider.model ? modelMetadata.value[provider.model] || null : null;
}
</script>

View File

@@ -292,7 +292,7 @@
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack" location="top">
<v-snackbar :timeout="3000" elevation="6" :color="save_message_success" v-model="save_message_snack" location="top">
{{ save_message }}
</v-snackbar>
</div>

View File

@@ -746,7 +746,7 @@
v-model="snackbar.show"
:timeout="3500"
:color="snackbar.color"
elevation="24"
elevation="6"
>
{{ snackbar.message }}
</v-snackbar>

View File

@@ -314,7 +314,7 @@ watch(viewMode, async (mode) => {
/>
<!-- Snackbar -->
<v-snackbar :timeout="2000" elevation="24" :color="snackbar.color" v-model="snackbar.show">
<v-snackbar :timeout="2000" elevation="6" :color="snackbar.color" v-model="snackbar.show">
{{ snackbar.message }}
</v-snackbar>
</template>

View File

@@ -125,7 +125,7 @@
{{ provider.type || provider.provider_type || tm('providerSelector.unknownType') }}
<span v-if="provider.model">- {{ provider.model }}</span>
<span
v-if="capabilityBadges(provider).length || formatContextLimit(provider)"
v-if="capabilityBadges(provider).length || formatContextLimit(provider, metadataForProvider(provider))"
class="provider-selector-meta"
>
<v-tooltip
@@ -147,7 +147,7 @@
<span>{{ item.tooltip }}</span>
</v-tooltip>
<v-tooltip
v-if="formatContextLimit(provider)"
v-if="formatContextLimit(provider, metadataForProvider(provider))"
location="top"
max-width="320"
>
@@ -157,12 +157,12 @@
class="provider-selector-meta__context"
@click.stop
>
{{ formatContextLimit(provider) }}
{{ formatContextLimit(provider, metadataForProvider(provider)) }}
</span>
</template>
<span>{{
providerTm('models.metadata.context', {
tokens: formatContextLimit(provider)
tokens: formatContextLimit(provider, metadataForProvider(provider))
})
}}</span>
</v-tooltip>
@@ -280,6 +280,7 @@ const selectedProvider = ref('')
const selectedProviders = ref([])
const providerDrawer = ref(false)
const testingProviders = ref([])
const modelMetadata = ref({})
const hasSelection = computed(() => {
if (props.multiple) {
@@ -329,6 +330,7 @@ async function loadProviders() {
try {
const response = await providerApi.listByProviderType(props.providerType)
if (response.data.status === 'ok') {
modelMetadata.value = response.data.model_metadata || {}
const providers = response.data.data || []
providerList.value = props.providerSubtype
? providers.filter((provider) => matchesProviderSubtype(provider, props.providerSubtype))
@@ -398,7 +400,11 @@ function isProviderSelected(providerId) {
}
function capabilityBadges(provider) {
return providerCapabilityBadges(provider, providerTm)
return providerCapabilityBadges(provider, metadataForProvider(provider), providerTm)
}
function metadataForProvider(provider) {
return provider?.model ? modelMetadata.value[provider.model] || null : null
}
function isProviderTesting(providerId) {

View File

@@ -59,7 +59,6 @@ export function useProviderModelConfigDialog(options: UseProviderModelConfigDial
function openProviderEdit(provider: any) {
const editableProvider = JSON.parse(JSON.stringify(provider))
delete editableProvider.model_metadata
providerEditData.value = editableProvider
providerEditOriginalId.value = provider.id
providerEditMode.value = 'edit'
@@ -91,7 +90,6 @@ export function useProviderModelConfigDialog(options: UseProviderModelConfigDial
savingProviders.value.push(providerEditData.value.id)
try {
delete providerEditData.value.model_metadata
const isAdding = providerEditMode.value === 'add'
const sourceId = providerEditData.value.provider_source_id || selectedProviderSource.value?.id
if (isAdding && !sourceId) {

View File

@@ -151,7 +151,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
const mergedModelEntries = computed(() => {
const configuredEntries = (sourceProviders.value || []).map((provider: any) => {
const metadata = getModelMetadata(provider.model) || provider.model_metadata || null
const metadata = getModelMetadata(provider.model)
return {
type: 'configured',
provider,
@@ -691,6 +691,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
providerTemplates.value = configSchema.value.provider.config_template
}
providerSources.value = response.data.data.provider_sources || []
modelMetadata.value = (response.data.data.model_metadata || {}) as Record<string, any>
providers.value = response.data.data.providers || []
}
} catch (error) {

View File

@@ -156,6 +156,9 @@
"duration": "Duration",
"ttft": "Time to First Token"
},
"tokenUsage": {
"tooltip": "{used} / {limit} tokens ({percent}%)"
},
"refs": {
"title": "References",
"sources": "Sources"

View File

@@ -156,6 +156,9 @@
"duration": "Время",
"ttft": "Время до первого токена"
},
"tokenUsage": {
"tooltip": "{used} / {limit} токенов ({percent}%)"
},
"refs": {
"title": "Ссылки",
"sources": "Источники"

View File

@@ -156,6 +156,9 @@
"duration": "耗时",
"ttft": "首字时间"
},
"tokenUsage": {
"tooltip": "{used} / {limit} tokens{percent}%"
},
"refs": {
"title": "引用",
"sources": "来源"

View File

@@ -21,6 +21,10 @@ export default createVuetify({
VCard: {
rounded: 'lg'
},
VSnackbar: {
elevation: 6,
rounded: 'lg'
},
VTextField: {
rounded: 'lg'
},

View File

@@ -20,6 +20,42 @@ html {
.v-overlay.v-snackbar {
--v-layout-left: 0px !important;
--v-layout-right: 0px !important;
.v-snackbar__wrapper {
min-height: 52px;
border-radius: 8px !important;
box-shadow: 0 10px 24px rgba(16, 24, 40, 0.16) !important;
}
.v-snackbar__content {
padding: 14px 18px;
line-height: 1.35;
font-weight: 600;
}
.v-snackbar__actions {
margin-inline-end: 6px;
}
.v-snackbar__actions .v-btn {
color: inherit;
}
.v-snackbar__wrapper.bg-success {
color: #ffffff !important;
background-color: #457c3c !important;
}
}
.app-snackbar-message {
display: flex;
align-items: center;
gap: 14px;
.v-icon {
flex: 0 0 auto;
opacity: 0.95;
}
}
.customizer-btn .icon {

View File

@@ -6,8 +6,8 @@ export interface ProviderModelMetadata {
}
export interface ProviderMetadataSource {
model?: string
modalities?: string[]
model_metadata?: ProviderModelMetadata | null
max_context_tokens?: number
reasoning?: boolean
}
@@ -19,19 +19,36 @@ export interface ProviderCapabilityBadge {
tooltip: string
}
export function formatContextLimit(provider: ProviderMetadataSource | null | undefined): string {
const context = provider?.model_metadata?.limit?.context || provider?.max_context_tokens
if (!context || typeof context !== 'number') return ''
if (context >= 1_000_000) return `${Math.round(context / 1_000_000)}M`
if (context >= 1_000) return `${Math.round(context / 1_000)}K`
return `${context}`
export function contextLimit(
provider: ProviderMetadataSource | null | undefined,
metadata?: ProviderModelMetadata | null
): number {
const context = Number(metadata?.limit?.context || provider?.max_context_tokens || 0)
return Number.isFinite(context) && context > 0 ? context : 0
}
export function formatTokenCount(value: number): string {
if (!Number.isFinite(value)) return ''
const absValue = Math.abs(value)
if (absValue >= 1_000_000) return `${formatCompactNumber(value / 1_000_000)}M`
if (absValue >= 1_000) return `${formatCompactNumber(value / 1_000)}K`
return `${Math.round(value)}`
}
export function formatContextLimit(
provider: ProviderMetadataSource | null | undefined,
metadata?: ProviderModelMetadata | null
): string {
const context = contextLimit(provider, metadata)
return context ? formatTokenCount(context) : ''
}
export function providerCapabilityBadges(
provider: ProviderMetadataSource | null | undefined,
metadata: ProviderModelMetadata | null | undefined,
tm: (key: string, params?: Record<string, string>) => string
): ProviderCapabilityBadge[] {
const inputs = provider?.model_metadata?.modalities?.input || []
const inputs = metadata?.modalities?.input || []
const providerModalities = provider?.modalities
const modalities = Array.isArray(providerModalities) ? providerModalities : []
const definitions = [
@@ -52,14 +69,14 @@ export function providerCapabilityBadges(
{
key: 'tool_use',
icon: 'mdi-wrench-outline',
supported: Boolean(provider?.model_metadata?.tool_call),
supported: Boolean(metadata?.tool_call),
enabled: modalities.includes('tool_use'),
label: tm('models.metadata.toolUse')
},
{
key: 'reasoning',
icon: 'mdi-brain',
supported: Boolean(provider?.model_metadata?.reasoning),
supported: Boolean(metadata?.reasoning),
enabled: Boolean(provider?.reasoning),
label: tm('models.metadata.reasoning')
}
@@ -70,10 +87,16 @@ export function providerCapabilityBadges(
.map((item) => ({
key: item.key,
icon: item.icon,
enabled: !provider?.model_metadata || item.enabled,
enabled: !metadata || item.enabled,
tooltip:
provider?.model_metadata && !item.enabled
metadata && !item.enabled
? tm('models.metadata.supportedDisabled', { capability: item.label })
: tm('models.metadata.enabled', { capability: item.label })
}))
}
function formatCompactNumber(value: number): string {
const absValue = Math.abs(value)
const rounded = absValue >= 10 ? Math.round(value) : Math.round(value * 10) / 10
return String(rounded).replace(/\.0$/, '')
}

View File

@@ -166,7 +166,7 @@
</v-card>
</v-dialog>
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
<v-snackbar :timeout="3000" elevation="6" :color="save_message_success" v-model="save_message_snack">
{{ save_message }}
</v-snackbar>

View File

@@ -377,7 +377,7 @@
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="messageType" v-model="showMessage" location="top">
<v-snackbar :timeout="3000" elevation="6" :color="messageType" v-model="showMessage" location="top">
{{ message }}
</v-snackbar>
</div>

View File

@@ -510,7 +510,7 @@ const updateDialogPluginLogo = computed(() => {
<v-snackbar
:timeout="2000"
elevation="24"
elevation="6"
:color="snack_success"
v-model="snack_show"
location="bottom center"

View File

@@ -220,7 +220,7 @@
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
<v-snackbar :timeout="3000" elevation="6" :color="save_message_success" v-model="save_message_snack"
location="top">
{{ save_message }}
</v-snackbar>

View File

@@ -650,7 +650,7 @@
</v-dialog>
<!-- 提示信息 -->
<v-snackbar v-model="snackbar" :timeout="3000" elevation="24" :color="snackbarColor" location="top">
<v-snackbar v-model="snackbar" :timeout="3000" elevation="6" :color="snackbarColor" location="top">
{{ snackbarText }}
</v-snackbar>

View File

@@ -1043,9 +1043,12 @@ export const useExtensionPage = () => {
};
const pluginOn = async (extension) => {
const previousActivated = extension.activated;
extension.activated = true;
try {
const res = await pluginApi.setEnabled(extension.name, true);
if (res.data.status === "error") {
extension.activated = previousActivated;
toast(res.data.message, "error");
return;
}
@@ -1054,20 +1057,25 @@ export const useExtensionPage = () => {
await checkAndPromptConflicts();
} catch (err) {
extension.activated = previousActivated;
toast(err, "error");
}
};
const pluginOff = async (extension) => {
const previousActivated = extension.activated;
extension.activated = false;
try {
const res = await pluginApi.setEnabled(extension.name, false);
if (res.data.status === "error") {
extension.activated = previousActivated;
toast(res.data.message, "error");
return;
}
toast(res.data.message, "success");
getExtensions();
await getExtensions();
} catch (err) {
extension.activated = previousActivated;
toast(err, "error");
}
};

View File

@@ -257,7 +257,7 @@
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="messageType" v-model="showMessage" location="top">
<v-snackbar :timeout="3000" elevation="6" :color="messageType" v-model="showMessage" location="top">
{{ message }}
</v-snackbar>
</div>

View File

@@ -231,6 +231,7 @@ export default defineConfig({
base: "/others",
collapsed: true,
items: [
{ text: "异常诊断", link: "/diagnostics" },
{ text: "自部署文转图", link: "/self-host-t2i" },
{ text: "插件下载不了?试试自建 GitHub 加速服务", link: "/github-proxy" },
],
@@ -484,6 +485,7 @@ export default defineConfig({
base: "/en/others",
collapsed: true,
items: [
{ text: "Diagnostics", link: "/diagnostics" },
{ text: "Self-hosted HTML to Image", link: "/self-host-t2i" },
],
},

View File

@@ -0,0 +1,83 @@
# Diagnostics
This page provides a general checklist for diagnosing AstrBot issues. When something goes wrong, first identify which stage is affected, then collect the relevant logs. This makes issue reports easier to reproduce and investigate.
## Common Issue Types
- Startup failures: the process exits after startup, WebUI is unavailable, or database/config loading fails.
- Platform connection issues: QQ, OneBot, Telegram, WeCom, or other platforms cannot connect, receive messages, or send messages.
- Model request issues: replies take too long, requests time out, 429/5xx errors appear, or the proxy/API base is unavailable.
- Plugin or MCP issues: enabling fails, tool calls fail, dependencies fail to install, or an MCP service does not respond.
- Slow tasks or abnormal CPU usage: messages, proactive tasks, or scheduled tasks start but continue much later; or the process keeps unusually high CPU usage.
## Logs to Check First
Start with the main AstrBot log:
```text
data/logs/astrbot.log
```
For Docker deployments, also check container logs:
```bash
docker logs <container-name>
```
If the issue involves slow tasks, abnormal CPU usage, or multiple sessions becoming slow at the same time, also check the event loop diagnostic logs:
```text
data/logs/event_loop_watchdog.log
data/logs/event_loop_watchdog.log.1
```
`event_loop_watchdog.log` rotates to `.1` after it exceeds 1 MB.
## General Checklist
1. Identify the time of the incident, then collect logs from 1 to 3 minutes before and after it.
2. Check the scope: all platforms, one platform, one group, one user, or one plugin.
3. For slow or missing model replies, check the model provider status, API key, API base, proxy, network, request timeout, and retry logs.
4. For plugin or MCP issues, disable recently installed or updated plugins first, then check plugin dependencies and MCP service logs.
5. For platform messaging issues, check whether the adapter is connected, platform-side settings are correct, and callback/WebSocket URLs are reachable.
6. For slow tasks or abnormal CPU usage, see "Event Loop Lag Diagnostics" below.
## Event Loop Lag Diagnostics
The event loop schedules messages, plugins, scheduled jobs, model requests, and tool calls. If it is blocked by synchronous code, many features can look delayed at once.
Common symptoms:
- Logs stop after `ready to request llm provider`, `acquired session lock for llm request`, or a tool result, then continue much later.
- A proactive or scheduled task starts, but one step in the middle takes a long time to continue.
- Multiple platforms or sessions become slow at the same time.
- CPU usage stays at 100%, or CPU usage is low but requests keep waiting for an external service.
If the main log contains the following entry, the event loop experienced visible scheduling delay:
```text
Event loop lag detected: 18.432s (threshold 15.000s).
```
If the event loop does not resume for a long time, AstrBot writes Python thread stacks to:
```text
data/logs/event_loop_watchdog.log
```
When reading this file, focus on the top frames. Useful clues often include plugin functions, platform adapters, MCP tools, synchronous network requests, `time.sleep()`, `subprocess.run()`, or CPU-heavy loops.
## What to Include in an Issue
When filing an issue, include as much of the following as possible:
- Approximate time of the incident and timezone.
- AstrBot version, deployment method (Docker, manual deployment, desktop client, etc.), and operating system.
- Trigger path: startup, normal chat, group chat, platform callback, scheduled task, MCP tool, plugin feature, etc.
- Scope: all sessions, one platform, one group, one user, or one plugin.
- Logs from `data/logs/astrbot.log` for 1 to 3 minutes around the incident.
- If the issue involves lag or abnormal CPU usage, include `data/logs/event_loop_watchdog.log` and `data/logs/event_loop_watchdog.log.1`.
- For Docker deployments, include the matching `docker logs` output.
- Installed plugin list, and whether the issue still happens after disabling third-party plugins.
Before sharing logs, redact API keys, tokens, cookies, private chat content, and other sensitive information.

View File

@@ -0,0 +1,83 @@
# 异常诊断
本文用于整理 AstrBot 出现异常时的通用排查方法。遇到问题时,先确定问题发生在哪个阶段,再收集对应日志;这样提交 Issue 时更容易复现和定位。
## 常见问题类型
- 启动失败进程启动后退出、WebUI 打不开、数据库或配置加载失败。
- 平台连接异常QQ、OneBot、Telegram、企业微信等平台无法连接、收不到消息或无法发送消息。
- 模型请求异常:长时间无回复、频繁超时、报 429/5xx、代理或 API Base 不可用。
- 插件或 MCP 异常启用失败、工具调用失败、依赖安装失败、MCP 服务无响应。
- 任务卡顿或 CPU 异常:消息、主动任务、定时任务已经触发,但中间步骤很久才继续;或进程 CPU 长时间异常升高。
## 先看哪些日志
优先查看 AstrBot 主日志:
```text
data/logs/astrbot.log
```
如果使用 Docker 部署,也可以查看容器日志:
```bash
docker logs <container-name>
```
如果问题和任务卡顿、CPU 异常、多个会话同时变慢有关,还要查看事件循环诊断日志:
```text
data/logs/event_loop_watchdog.log
data/logs/event_loop_watchdog.log.1
```
`event_loop_watchdog.log` 超过 1MB 后会轮转为 `.1`
## 通用排查步骤
1. 确认问题发生的时间点,并截取该时间前后 1 到 3 分钟的日志。
2. 确认问题范围:是所有平台都异常,还是只有某个平台、某个群、某个用户、某个插件异常。
3. 如果是模型无回复或很慢检查模型服务商状态、API Key、API Base、代理、网络、请求超时和重试日志。
4. 如果是插件或 MCP 问题,先禁用最近安装或更新的插件,观察问题是否消失;同时检查插件依赖和 MCP 服务日志。
5. 如果是平台收发消息异常,检查平台适配器是否已连接、平台后台配置是否正确、回调地址或 WebSocket 地址是否可访问。
6. 如果是卡顿或 CPU 异常,参考下方“事件循环卡顿诊断”。
## 事件循环卡顿诊断
事件循环负责调度消息、插件、定时任务、模型请求和工具调用。如果它被同步代码阻塞,很多功能都会表现为延迟。
常见现象:
- 日志停在 `ready to request llm provider``acquired session lock for llm request`、工具调用结果之后,很久才继续。
- 主动任务或定时任务已经触发,但中间某一步迟迟不继续。
- 多个平台或多个会话同时变慢。
- CPU 长时间 100%,或 CPU 不高但请求一直等待外部服务返回。
如果主日志出现以下内容,说明事件循环经历了明显延迟:
```text
Event loop lag detected: 18.432s (threshold 15.000s).
```
如果事件循环长时间没有恢复AstrBot 会把 Python 线程栈写入:
```text
data/logs/event_loop_watchdog.log
```
查看该文件时重点关注栈顶附近正在执行的代码。常见线索包括插件函数、平台适配器、MCP 工具、同步网络请求、`time.sleep()``subprocess.run()`、CPU 密集循环等。
## 提交 Issue 时请附带
提交问题时,请尽量提供以下信息:
- 问题发生的大致时间点和时区。
- AstrBot 版本、部署方式Docker、手动部署、桌面客户端等、操作系统。
- 触发方式启动、普通聊天、群聊、平台回调、定时任务、MCP 工具、插件功能等。
- 影响范围:所有会话、某个平台、某个群、某个用户,还是某个插件。
- `data/logs/astrbot.log` 中问题发生前后 1 到 3 分钟的日志。
- 如果存在卡顿或 CPU 异常,请附带 `data/logs/event_loop_watchdog.log``data/logs/event_loop_watchdog.log.1`
- 如果使用 Docker请附带对应时间段的 `docker logs`
- 已安装插件列表,以及问题是否在禁用第三方插件后仍然出现。
提交日志前请先检查并遮盖 API Key、Token、Cookie、私聊内容等敏感信息。

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.26.4"
version = "4.26.5"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
license = { text = "AGPL-3.0-or-later" }
@@ -42,7 +42,7 @@ dependencies = [
"qrcode>=8.2",
"quart>=0.20.0",
"python-telegram-bot>=22.6",
"qq-botpy>=1.2.1",
"qq-botpy==1.2.1",
"silk-python>=0.2.6",
"slack-sdk>=3.35.0",
"sqlalchemy[asyncio]>=2.0.41",

View File

@@ -29,7 +29,7 @@ pydantic>=2.12.5
pydub>=0.25.1
pyjwt>=2.10.1
python-telegram-bot>=22.6
qq-botpy>=1.2.1
qq-botpy==1.2.1
python-socks>=2.8.0
silk-python>=0.2.6
slack-sdk>=3.35.0

View File

@@ -1360,6 +1360,105 @@ async def test_generated_password_requires_password_change_until_changed(
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("endpoint", "method"),
[
("/api/auth/account/edit", "post"),
("/api/v1/auth/account", "patch"),
],
)
@pytest.mark.parametrize("new_username", ["ab", " "])
async def test_account_edit_rejects_invalid_username(
app: FastAPIAppAdapter,
core_lifecycle_td: AstrBotCoreLifecycle,
endpoint: str,
method: str,
new_username: str,
):
original_dashboard_config = copy.deepcopy(
core_lifecycle_td.astrbot_config["dashboard"]
)
test_client = app.test_client()
current_username = core_lifecycle_td.astrbot_config["dashboard"]["username"]
current_password = _resolve_dashboard_password(core_lifecycle_td)
try:
login_response = await test_client.post(
"/api/auth/login",
json={"username": current_username, "password": current_password},
)
login_data = await login_response.get_json()
assert login_data["status"] == "ok"
headers = {"Authorization": f"Bearer {login_data['data']['token']}"}
payload = {
"password": current_password,
"new_password": "",
"confirm_password": "",
"new_username": new_username,
}
request = getattr(test_client, method)
response = await request(endpoint, headers=headers, json=payload)
data = await response.get_json()
assert data["status"] == "error"
assert data["message"] == "用户名长度至少3位"
assert (
core_lifecycle_td.astrbot_config["dashboard"]["username"]
== (original_dashboard_config["username"])
)
finally:
await _restore_dashboard_password_state(
core_lifecycle_td,
original_dashboard_config,
)
@pytest.mark.asyncio
async def test_account_edit_trims_valid_username(
app: FastAPIAppAdapter,
core_lifecycle_td: AstrBotCoreLifecycle,
):
original_dashboard_config = copy.deepcopy(
core_lifecycle_td.astrbot_config["dashboard"]
)
test_client = app.test_client()
current_username = core_lifecycle_td.astrbot_config["dashboard"]["username"]
current_password = _resolve_dashboard_password(core_lifecycle_td)
try:
login_response = await test_client.post(
"/api/auth/login",
json={"username": current_username, "password": current_password},
)
login_data = await login_response.get_json()
assert login_data["status"] == "ok"
headers = {"Authorization": f"Bearer {login_data['data']['token']}"}
response = await test_client.post(
"/api/auth/account/edit",
headers=headers,
json={
"password": current_password,
"new_password": "",
"confirm_password": "",
"new_username": " astrbot-admin ",
},
)
data = await response.get_json()
assert data["status"] == "ok"
assert core_lifecycle_td.astrbot_config["dashboard"]["username"] == (
"astrbot-admin"
)
finally:
await _restore_dashboard_password_state(
core_lifecycle_td,
original_dashboard_config,
)
@pytest.mark.asyncio
async def test_local_setup_can_skip_default_password_auth(
app: FastAPIAppAdapter,

View File

@@ -2,6 +2,7 @@ import asyncio
import json
import os
from pathlib import Path
from types import ModuleType
from typing import Any, cast
import pytest
@@ -1981,6 +1982,89 @@ async def test_uninstall_failed_plugin_without_plugin_id_in_record(
# --- reload + deactivated plugin regression tests ---
@pytest.mark.asyncio
@pytest.mark.parametrize(
("inactivated_plugins", "expected_activated"),
[
([], True),
(["data.plugins.demo_plugin.main"], False),
],
)
async def test_load_syncs_existing_metadata_activation_from_preferences(
plugin_manager_pm: PluginManager,
monkeypatch,
inactivated_plugins: list[str],
expected_activated: bool,
):
"""Existing plugin metadata activation follows persisted preferences."""
_clear_star_runtime_state()
plugin_name = "demo_plugin"
module_path = f"data.plugins.{plugin_name}.main"
metadata = star_manager_module.StarMetadata(
name=plugin_name,
author="AstrBot Team",
desc="Demo plugin",
version="1.0.0",
root_dir_name=plugin_name,
module_path=module_path,
activated=False,
)
star_manager_module.star_map[module_path] = metadata
star_manager_module.star_registry.append(metadata)
preferences = {
"inactivated_plugins": inactivated_plugins,
"inactivated_llm_tools": [],
"alter_cmd": {},
}
async def mock_global_get(key, default=None):
return preferences.get(key, default)
async def mock_import_plugin_with_dependency_recovery(
path,
module_str,
root_dir_name,
requirements_path,
*,
reserved=False,
):
del module_str, root_dir_name, requirements_path, reserved
assert path == module_path
return ModuleType(module_path)
async def mock_sync_command_configs():
return None
monkeypatch.setattr(star_manager_module.sp, "global_get", mock_global_get)
monkeypatch.setattr(
plugin_manager_pm,
"_get_plugin_modules",
lambda: [{"pname": plugin_name, "module": "main"}],
)
monkeypatch.setattr(
plugin_manager_pm,
"_import_plugin_with_dependency_recovery",
mock_import_plugin_with_dependency_recovery,
)
monkeypatch.setattr(plugin_manager_pm, "_load_plugin_metadata", lambda **_: None)
monkeypatch.setattr(
star_manager_module,
"sync_command_configs",
mock_sync_command_configs,
)
try:
success, error = await plugin_manager_pm.load(
specified_module_path=module_path,
)
assert success is True
assert error is None
assert metadata.activated is expected_activated
finally:
_clear_star_runtime_state()
@pytest.mark.asyncio
async def test_reload_deactivated_plugin_preserves_tools(
plugin_manager_pm: PluginManager, monkeypatch
@@ -2156,9 +2240,6 @@ async def test_turn_on_plugin_after_deactivated_reload_reactivates_tools(
async def mock_reload(plugin_name_arg):
assert plugin_name_arg == plugin_name
# Simulate what load() does: re-register with activated=True
# since it's no longer in inactivated_plugins
plugin.activated = True
return True, None
monkeypatch.setattr(star_manager_module.sp, "global_get", mock_global_get)

View File

@@ -10,7 +10,7 @@ import pytest
from botpy import ConnectionSession
from astrbot.api.event import MessageChain
from astrbot.api.message_components import At, Plain
from astrbot.api.message_components import At, Image, Plain, Reply
from astrbot.core.message.message_event_result import (
MessageEventResult,
ResultContentType,
@@ -38,8 +38,11 @@ def _make_group_payload(
mentions: list[dict] | None = None,
member_openid: str = "member-1",
group_openid: str = "group-1",
message_type: int | None = None,
msg_elements: list[dict] | None = None,
message_reference: dict | None = None,
) -> dict:
return {
data = {
"id": f"event-{message_id}",
"d": {
"id": message_id,
@@ -50,6 +53,13 @@ def _make_group_payload(
"attachments": [],
},
}
if message_type is not None:
data["d"]["message_type"] = message_type
if msg_elements is not None:
data["d"]["msg_elements"] = msg_elements
if message_reference is not None:
data["d"]["message_reference"] = message_reference
return data
def _dispatch_group_message(payload: dict) -> tuple[str, botpy.message.GroupMessage]:
@@ -108,6 +118,49 @@ async def test_parse_group_message_create_plain_message_has_no_at_component():
] == ["plain group message"]
@pytest.mark.asyncio
async def test_parse_group_message_create_quoted_context():
_, message = _dispatch_group_message(
_make_group_payload(
content="answer",
message_type=103,
message_reference={"message_id": "quoted-1"},
msg_elements=[
{
"content": "quoted text",
"attachments": [
{
"content_type": "image/png",
"filename": "quoted.png",
"url": "img.example.com/quoted.png",
}
],
}
],
)
)
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
)
assert getattr(message, "message_type") == 103
assert getattr(message, "msg_elements")[0]["content"] == "quoted text"
reply = abm.message[0]
assert isinstance(reply, Reply)
assert reply.id == "quoted-1"
assert reply.message_str == "quoted text"
assert isinstance(reply.chain[0], Plain)
assert reply.chain[0].text == "quoted text"
assert isinstance(reply.chain[1], Image)
assert reply.chain[1].file == "https://img.example.com/quoted.png"
assert abm.message_str == "answer"
assert [
component.text for component in abm.message if isinstance(component, Plain)
][-1] == "answer"
@pytest.mark.asyncio
async def test_parse_group_message_create_bot_mention_cleans_plain_text():
_, message = _dispatch_group_message(

View File

@@ -0,0 +1,134 @@
import asyncio
import pytest
from astrbot.core.utils import event_loop_diagnostics as diagnostics
def test_load_event_loop_diagnostic_settings_defaults():
"""Default settings enable lag monitoring and stack dump watchdog."""
settings = diagnostics.load_event_loop_diagnostic_settings()
assert settings.lag_monitor_enabled is True
assert settings.lag_monitor_interval == diagnostics.DEFAULT_LAG_MONITOR_INTERVAL
assert settings.lag_monitor_threshold == diagnostics.DEFAULT_LAG_MONITOR_THRESHOLD
assert settings.watchdog_enabled is True
assert settings.watchdog_interval == diagnostics.DEFAULT_WATCHDOG_INTERVAL
assert settings.watchdog_timeout == diagnostics.DEFAULT_WATCHDOG_TIMEOUT
assert settings.watchdog_log_max_bytes == diagnostics.DEFAULT_WATCHDOG_LOG_MAX_BYTES
@pytest.mark.asyncio
async def test_create_event_loop_diagnostic_tasks_defaults():
"""Default diagnostics should create both event loop diagnostic tasks."""
tasks = diagnostics.create_event_loop_diagnostic_tasks()
try:
assert [task.get_name() for task in tasks] == [
"event_loop_lag_monitor",
"event_loop_faulthandler_watchdog",
]
finally:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
@pytest.mark.asyncio
async def test_faulthandler_watchdog_cancels_pending_dump(monkeypatch):
"""The faulthandler watchdog should cancel its pending dump on shutdown."""
calls = []
class FakeFaultHandler:
def cancel_dump_traceback_later(self):
calls.append("cancel")
def dump_traceback_later(self, timeout, repeat, file):
calls.append(("dump", timeout, repeat, file))
fake_faulthandler = FakeFaultHandler()
monkeypatch.setattr(diagnostics, "faulthandler", fake_faulthandler)
task = asyncio.create_task(
diagnostics.faulthandler_event_loop_watchdog(timeout=10, interval=1)
)
await asyncio.sleep(0)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert any(isinstance(call, tuple) and call[0] == "dump" for call in calls)
assert calls[-1] == "cancel"
@pytest.mark.asyncio
async def test_faulthandler_watchdog_writes_rotating_log(tmp_path, monkeypatch):
"""The faulthandler watchdog should write to and rotate its log file."""
log_path = tmp_path / "logs" / "event_loop_watchdog.log"
log_path.parent.mkdir()
log_path.write_text("x" * 8, encoding="utf-8")
calls = []
class FakeFaultHandler:
def cancel_dump_traceback_later(self):
calls.append("cancel")
def dump_traceback_later(self, timeout, repeat, file):
calls.append(("dump", timeout, repeat, file.name))
file.write("watchdog dump\n")
file.flush()
fake_faulthandler = FakeFaultHandler()
monkeypatch.setattr(diagnostics, "faulthandler", fake_faulthandler)
task = asyncio.create_task(
diagnostics.faulthandler_event_loop_watchdog(
timeout=10,
interval=1,
dump_path=log_path,
max_bytes=4,
)
)
await asyncio.sleep(0)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert log_path.read_text(encoding="utf-8") == "watchdog dump\n"
assert log_path.with_name("event_loop_watchdog.log.1").read_text(
encoding="utf-8"
) == "x" * 8
assert any(isinstance(call, tuple) and call[0] == "dump" for call in calls)
@pytest.mark.asyncio
async def test_faulthandler_watchdog_survives_dump_failure(tmp_path, monkeypatch):
"""The watchdog should keep running after faulthandler arm failures."""
log_path = tmp_path / "event_loop_watchdog.log"
armed_again = asyncio.Event()
calls = []
class FakeFaultHandler:
def cancel_dump_traceback_later(self):
calls.append("cancel")
def dump_traceback_later(self, timeout, repeat, file):
calls.append(("dump", timeout, repeat, file.name))
if len([call for call in calls if isinstance(call, tuple)]) == 1:
raise RuntimeError("boom")
armed_again.set()
fake_faulthandler = FakeFaultHandler()
monkeypatch.setattr(diagnostics, "faulthandler", fake_faulthandler)
task = asyncio.create_task(
diagnostics.faulthandler_event_loop_watchdog(
timeout=10,
interval=0.01,
dump_path=log_path,
)
)
await asyncio.wait_for(armed_again.wait(), timeout=1)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
dump_calls = [call for call in calls if isinstance(call, tuple)]
assert len(dump_calls) >= 2

View File

@@ -1,3 +1,4 @@
import asyncio
import json
import pytest
@@ -348,6 +349,65 @@ def test_firecrawl_tools_are_registered_as_builtin_tools():
assert manager.is_builtin_tool("firecrawl_extract_web_page") is True
@pytest.mark.asyncio
async def test_mcp_shutdown_cleanup_runs_in_lifecycle_task(monkeypatch):
"""Disabling an MCP server must clean up in the task that connected.
anyio cancel scopes entered in connect_to_server() can only be exited
from the same task, otherwise the scope state is corrupted and its
cancellation loop spins at 100% CPU (#9068).
"""
manager = FunctionToolManager()
seen = {}
async def fake_connect(self, config, name):
seen["connect_task"] = asyncio.current_task()
async def fake_list_tools(self):
self.tools = []
async def fake_cleanup(self):
seen["cleanup_task"] = asyncio.current_task()
monkeypatch.setattr(ftm.MCPClient, "connect_to_server", fake_connect)
monkeypatch.setattr(ftm.MCPClient, "list_tools_and_save", fake_list_tools)
monkeypatch.setattr(ftm.MCPClient, "cleanup", fake_cleanup)
await manager.enable_mcp_server("dummy", {"command": "python"}, timeout=5)
await manager.disable_mcp_server("dummy", timeout=5)
assert seen["cleanup_task"] is seen["connect_task"]
assert "dummy" not in manager.mcp_client_dict
@pytest.mark.asyncio
async def test_mcp_shutdown_cleanup_survives_late_cancellation(monkeypatch):
"""A cancellation arriving mid-cleanup must not abort the cleanup."""
manager = FunctionToolManager()
cleanup_calls = []
async def fake_connect(self, config, name):
pass
async def fake_list_tools(self):
self.tools = []
async def fake_cleanup(self):
cleanup_calls.append(asyncio.current_task())
if len(cleanup_calls) == 1:
raise asyncio.CancelledError()
monkeypatch.setattr(ftm.MCPClient, "connect_to_server", fake_connect)
monkeypatch.setattr(ftm.MCPClient, "list_tools_and_save", fake_list_tools)
monkeypatch.setattr(ftm.MCPClient, "cleanup", fake_cleanup)
await manager.enable_mcp_server("dummy", {"command": "python"}, timeout=5)
await manager.disable_mcp_server("dummy", timeout=5)
assert len(cleanup_calls) == 2
assert "dummy" not in manager.mcp_client_dict
@pytest.mark.asyncio
async def test_modelscope_sync_enables_only_synced_servers(monkeypatch):
class FakeResponse: