mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-11 07:20:15 +08:00
Compare commits
20 Commits
codex/prov
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12f2f5a09c | ||
|
|
afb898e61f | ||
|
|
0be821afc6 | ||
|
|
dcc40ede1d | ||
|
|
adebd2958e | ||
|
|
331444b41f | ||
|
|
39090a74ba | ||
|
|
bf18fadbe6 | ||
|
|
1124dfe295 | ||
|
|
510e833695 | ||
|
|
0a5d6485a1 | ||
|
|
0dee40bd9d | ||
|
|
2a7c02af8e | ||
|
|
56f2533951 | ||
|
|
afb0079116 | ||
|
|
4166d9ab58 | ||
|
|
ba7f8ebfb1 | ||
|
|
ab2502c174 | ||
|
|
56326c7551 | ||
|
|
18e067fab1 |
62
README.md
62
README.md
@@ -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 ☁️:
|
||||
|
||||
[](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 ☁️:
|
||||
|
||||
[](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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import logging
|
||||
|
||||
__version__ = "4.26.4"
|
||||
__version__ = "4.26.5"
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] 使用主动发送接口发送成功。")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
216
astrbot/core/utils/event_loop_diagnostics.py
Normal file
216
astrbot/core/utils/event_loop_diagnostics.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
41
changelogs/v4.26.5.md
Normal 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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -746,7 +746,7 @@
|
||||
v-model="snackbar.show"
|
||||
:timeout="3500"
|
||||
:color="snackbar.color"
|
||||
elevation="24"
|
||||
elevation="6"
|
||||
>
|
||||
{{ snackbar.message }}
|
||||
</v-snackbar>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -156,6 +156,9 @@
|
||||
"duration": "Duration",
|
||||
"ttft": "Time to First Token"
|
||||
},
|
||||
"tokenUsage": {
|
||||
"tooltip": "{used} / {limit} tokens ({percent}%)"
|
||||
},
|
||||
"refs": {
|
||||
"title": "References",
|
||||
"sources": "Sources"
|
||||
|
||||
@@ -156,6 +156,9 @@
|
||||
"duration": "Время",
|
||||
"ttft": "Время до первого токена"
|
||||
},
|
||||
"tokenUsage": {
|
||||
"tooltip": "{used} / {limit} токенов ({percent}%)"
|
||||
},
|
||||
"refs": {
|
||||
"title": "Ссылки",
|
||||
"sources": "Источники"
|
||||
|
||||
@@ -156,6 +156,9 @@
|
||||
"duration": "耗时",
|
||||
"ttft": "首字时间"
|
||||
},
|
||||
"tokenUsage": {
|
||||
"tooltip": "{used} / {limit} tokens({percent}%)"
|
||||
},
|
||||
"refs": {
|
||||
"title": "引用",
|
||||
"sources": "来源"
|
||||
|
||||
@@ -21,6 +21,10 @@ export default createVuetify({
|
||||
VCard: {
|
||||
rounded: 'lg'
|
||||
},
|
||||
VSnackbar: {
|
||||
elevation: 6,
|
||||
rounded: 'lg'
|
||||
},
|
||||
VTextField: {
|
||||
rounded: 'lg'
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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$/, '')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
83
docs/en/others/diagnostics.md
Normal file
83
docs/en/others/diagnostics.md
Normal 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.
|
||||
83
docs/zh/others/diagnostics.md
Normal file
83
docs/zh/others/diagnostics.md
Normal 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、私聊内容等敏感信息。
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
134
tests/unit/test_event_loop_diagnostics.py
Normal file
134
tests/unit/test_event_loop_diagnostics.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user