mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-10 15:00:15 +08:00
Compare commits
5 Commits
master
...
codex/even
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58b58aaf31 | ||
|
|
a18054c8b4 | ||
|
|
f683adc74c | ||
|
|
f5b08eb135 | ||
|
|
54124979b0 |
62
README.md
62
README.md
@@ -75,10 +75,6 @@ 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
|
||||
@@ -87,17 +83,20 @@ 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
|
||||
```
|
||||
|
||||
### 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 ☁️:
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
> [!WARNING]
|
||||
> AstrBot deployed via `uv` **does not support upgrading through the WebUI**. To update, please run the command above from the command line.
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
@@ -105,6 +104,12 @@ 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 ☁️:
|
||||
|
||||
[](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.
|
||||
@@ -137,18 +142,6 @@ 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.
|
||||
@@ -212,6 +205,13 @@ 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,7 +235,22 @@ pre-commit install
|
||||
|
||||
### QQ Groups
|
||||
|
||||
We have 15+ chat groups, please see: [Community](https://docs.astrbot.app/community.html) for details.
|
||||
- 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
|
||||
|
||||
### Discord Server
|
||||
|
||||
@@ -249,10 +264,9 @@ 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>
|
||||
|
||||
Open Source Friends ❤️
|
||||
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
|
||||
|
||||
- [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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import logging
|
||||
|
||||
__version__ = "4.26.5"
|
||||
__version__ = "4.26.4"
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
@@ -392,16 +392,10 @@ 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
|
||||
|
||||
# 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.exit_stack = AsyncExitStack()
|
||||
self._old_exit_stacks: list[AsyncExitStack] = [] # Track old stacks for cleanup
|
||||
|
||||
self.name: str | None = None
|
||||
self.active: bool = True
|
||||
@@ -409,67 +403,14 @@ 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
|
||||
|
||||
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"))
|
||||
self._reconnecting: bool = False # For logging and debugging
|
||||
|
||||
async def connect_to_server(self, mcp_server_config: dict, name: str) -> None:
|
||||
"""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.
|
||||
"""Connect to MCP server
|
||||
|
||||
If `url` parameter exists:
|
||||
1. When transport is specified as `streamable_http`, use Streamable HTTP connection.
|
||||
@@ -480,47 +421,10 @@ 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(
|
||||
@@ -659,32 +563,16 @@ 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"
|
||||
@@ -700,16 +588,17 @@ class MCPClient:
|
||||
f"Attempting to reconnect to MCP server {self._server_name}..."
|
||||
)
|
||||
|
||||
# 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
|
||||
# Save old exit_stack for later cleanup (don't close it now to avoid cancel scope issues)
|
||||
if self.exit_stack:
|
||||
self._old_exit_stacks.append(self.exit_stack)
|
||||
|
||||
# Mark old session as invalid
|
||||
self.session = None
|
||||
|
||||
# Reconnect — this creates a new _connection_task.
|
||||
# Create new exit stack for new connection
|
||||
self.exit_stack = AsyncExitStack()
|
||||
|
||||
# Reconnect using stored config
|
||||
await self.connect_to_server(self._mcp_server_config, self._server_name)
|
||||
await self.list_tools_and_save()
|
||||
|
||||
@@ -774,20 +663,19 @@ class MCPClient:
|
||||
return await _call_with_retry()
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""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
|
||||
"""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}")
|
||||
|
||||
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()
|
||||
# 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()
|
||||
|
||||
# Set running_event to unblock any waiting tasks
|
||||
# Set running_event first to unblock any waiting tasks
|
||||
self.running_event.set()
|
||||
|
||||
|
||||
|
||||
@@ -243,13 +243,7 @@ class ResultDecorateStage(Stage):
|
||||
continue
|
||||
for seg in split_response:
|
||||
if self.content_cleanup_rule:
|
||||
try:
|
||||
seg = re.sub(self.content_cleanup_rule, "", seg)
|
||||
except re.error:
|
||||
logger.error(
|
||||
f"分段回复过滤表达式失败,无法成功过滤:{traceback.format_exc()}"
|
||||
)
|
||||
self.content_cleanup_rule = None
|
||||
seg = re.sub(self.content_cleanup_rule, "", seg)
|
||||
seg = seg.strip()
|
||||
if seg:
|
||||
new_chain.append(Plain(seg))
|
||||
|
||||
@@ -133,7 +133,7 @@ class LinePlatformAdapter(Platform):
|
||||
return "invalid signature", 400
|
||||
|
||||
try:
|
||||
payload = await request.get_json(silent=False)
|
||||
payload = await request.get_json(force=True, silent=False)
|
||||
except Exception as e:
|
||||
logger.warning("[LINE] invalid webhook body: %s", e)
|
||||
return "bad request", 400
|
||||
|
||||
@@ -502,7 +502,6 @@ 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] 使用主动发送接口发送成功。")
|
||||
|
||||
@@ -723,21 +723,8 @@ class FunctionToolManager:
|
||||
logger.debug(f"MCP client {name} task was cancelled")
|
||||
raise
|
||||
finally:
|
||||
# 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()
|
||||
# Cleanup in the same task that entered the anyio contexts
|
||||
await asyncio.shield(self._terminate_mcp_client(name))
|
||||
|
||||
lifecycle_task = asyncio.create_task(
|
||||
connect_and_lifecycle(), name=f"mcp-client:{name}"
|
||||
|
||||
@@ -369,12 +369,6 @@ 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("新密码无效")
|
||||
@@ -390,8 +384,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 username_to_save:
|
||||
self.config["dashboard"]["username"] = username_to_save
|
||||
if new_username:
|
||||
self.config["dashboard"]["username"] = new_username
|
||||
|
||||
self.config.save_config()
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
## [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
|
||||
@@ -7,12 +7,9 @@
|
||||
<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>
|
||||
<div class="app-snackbar-message">
|
||||
<v-icon v-if="toastIcon" :icon="toastIcon" size="24" />
|
||||
<span>{{ toastStore.current.message }}</span>
|
||||
</div>
|
||||
{{ toastStore.current.message }}
|
||||
<template #actions v-if="toastStore.current.closable">
|
||||
<v-btn icon="mdi-close" variant="text" size="small" aria-label="Close notification" @click="snackbarShow = false" />
|
||||
<v-btn variant="text" @click="snackbarShow = false">关闭</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</template>
|
||||
@@ -35,14 +32,6 @@ 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) {
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar :timeout="3000" elevation="6" :color="save_message_success" v-model="save_message_snack" location="top">
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack" location="top">
|
||||
{{ save_message }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
|
||||
@@ -746,7 +746,7 @@
|
||||
v-model="snackbar.show"
|
||||
:timeout="3500"
|
||||
:color="snackbar.color"
|
||||
elevation="6"
|
||||
elevation="24"
|
||||
>
|
||||
{{ snackbar.message }}
|
||||
</v-snackbar>
|
||||
|
||||
@@ -314,7 +314,7 @@ watch(viewMode, async (mode) => {
|
||||
/>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<v-snackbar :timeout="2000" elevation="6" :color="snackbar.color" v-model="snackbar.show">
|
||||
<v-snackbar :timeout="2000" elevation="24" :color="snackbar.color" v-model="snackbar.show">
|
||||
{{ snackbar.message }}
|
||||
</v-snackbar>
|
||||
</template>
|
||||
|
||||
@@ -21,10 +21,6 @@ export default createVuetify({
|
||||
VCard: {
|
||||
rounded: 'lg'
|
||||
},
|
||||
VSnackbar: {
|
||||
elevation: 6,
|
||||
rounded: 'lg'
|
||||
},
|
||||
VTextField: {
|
||||
rounded: 'lg'
|
||||
},
|
||||
|
||||
@@ -20,42 +20,6 @@ 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 {
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar :timeout="3000" elevation="6" :color="save_message_success" v-model="save_message_snack">
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
|
||||
{{ save_message }}
|
||||
</v-snackbar>
|
||||
|
||||
|
||||
@@ -377,7 +377,7 @@
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar :timeout="3000" elevation="6" :color="messageType" v-model="showMessage" location="top">
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="messageType" v-model="showMessage" location="top">
|
||||
{{ message }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
|
||||
@@ -510,7 +510,7 @@ const updateDialogPluginLogo = computed(() => {
|
||||
|
||||
<v-snackbar
|
||||
:timeout="2000"
|
||||
elevation="6"
|
||||
elevation="24"
|
||||
:color="snack_success"
|
||||
v-model="snack_show"
|
||||
location="bottom center"
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar :timeout="3000" elevation="6" :color="save_message_success" v-model="save_message_snack"
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
|
||||
location="top">
|
||||
{{ save_message }}
|
||||
</v-snackbar>
|
||||
|
||||
@@ -650,7 +650,7 @@
|
||||
</v-dialog>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<v-snackbar v-model="snackbar" :timeout="3000" elevation="6" :color="snackbarColor" location="top">
|
||||
<v-snackbar v-model="snackbar" :timeout="3000" elevation="24" :color="snackbarColor" location="top">
|
||||
{{ snackbarText }}
|
||||
</v-snackbar>
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar :timeout="3000" elevation="6" :color="messageType" v-model="showMessage" location="top">
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="messageType" v-model="showMessage" location="top">
|
||||
{{ message }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
|
||||
@@ -231,7 +231,6 @@ export default defineConfig({
|
||||
base: "/others",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "异常诊断", link: "/diagnostics" },
|
||||
{ text: "自部署文转图", link: "/self-host-t2i" },
|
||||
{ text: "插件下载不了?试试自建 GitHub 加速服务", link: "/github-proxy" },
|
||||
],
|
||||
@@ -485,7 +484,6 @@ export default defineConfig({
|
||||
base: "/en/others",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "Diagnostics", link: "/diagnostics" },
|
||||
{ text: "Self-hosted HTML to Image", link: "/self-host-t2i" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,83 +0,0 @@
|
||||
# 异常诊断
|
||||
|
||||
本文用于整理 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、私聊内容等敏感信息。
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.26.5"
|
||||
version = "4.26.4"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
|
||||
@@ -1360,105 +1360,6 @@ 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,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
@@ -349,65 +348,6 @@ 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:
|
||||
|
||||
Reference in New Issue
Block a user