Compare commits

..

19 Commits

Author SHA1 Message Date
Soulter
58b58aaf31 chore: log event loop watchdog startup at info 2026-07-07 00:20:17 +08:00
Soulter
a18054c8b4 fix: keep event loop watchdog alive after dump failures 2026-07-07 00:12:05 +08:00
Soulter
f683adc74c chore: use fixed event loop diagnostic defaults 2026-07-07 00:03:39 +08:00
Soulter
f5b08eb135 fix: write event loop watchdog dumps to rotating log 2026-07-06 23:59:43 +08:00
Soulter
54124979b0 feat: add event loop diagnostics 2026-07-06 23:26:57 +08:00
Weilong Liao
4166d9ab58 fix: preserve qq official quoted message context (#9164)
* fix: preserve qq official quoted message context

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

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

* fix: standardize qq-botpy version specification in dependencies

---------

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

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

* Potential fix for pull request finding

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

---------

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

Add serialize_history_entry helper and apply it to get_session, get_thread,
and update_message. This matches the SSE real-time path and session list
path, ensuring the frontend consistently receives ISO strings with the
+00:00 suffix across all code paths.
2026-07-06 21:12:29 +08:00
Weilong Liao
18e067fab1 fix: keep model metadata separate from providers (#9161) 2026-07-06 21:10:57 +08:00
Weilong Liao
6627fd53c2 feat: show model metadata in selectors
Surface cached model metadata in model/provider selectors, show neutral capability/context badges, and add provider test actions.
2026-07-06 20:38:30 +08:00
Weilong Liao
25cbd41e08 feat: add sanitation for malformed tool call names in ToolLoopAgentRunner (#9144)
* feat: add sanitation for malformed tool call names in ToolLoopAgentRunner

fixes: #8929
closes: #8911

* Update astrbot/core/agent/runners/tool_loop_agent_runner.py

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

* fix: update docstring to clarify normalization of malformed tool call names

---------

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2026-07-05 10:53:14 +08:00
sama233
3b8caf37e6 test: cover POSIX file URI root preservation (#8906)
* test: cover POSIX file URI root preservation

* Update tests/test_media_utils.py

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

---------

Co-authored-by: Weilong Liao <37870767+Soulter@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-07-05 10:12:59 +08:00
Soulter
85b653b6f0 feat: add note on cross-platform compatibility and Python version support 2026-07-05 10:06:16 +08:00
tangtaizong666
c9eed7b65e fix: adapt MiMo STT to V2.5 models and reject non-WAV audio payloads (#9118)
* fix: adapt MiMo STT to V2.5 models and reject non-WAV audio

The MiMo-V2 series went offline on 2026-06-30, so the default STT model
mimo-v2-omni fails for every default configuration. Switch the default
to mimo-v2.5-asr, the dedicated speech recognition model whose official
docs use exactly the bare input_audio payload this provider sends.

For non-ASR multimodal models such as mimo-v2.5, the audio understanding
docs require a text instruction alongside the audio, so restore the
system/user transcription prompts for that model family only.

Also validate that the resolved audio payload really is RIFF/WAVE before
calling the API: when a platform voice file (e.g. Tencent SILK from QQ)
slips through the WAV conversion chain unchanged, fail locally with an
actionable error instead of the opaque HTTP 400 from the API.

Fixes #9113

* fix: accept unpadded MiMo wav headers

---------

Co-authored-by: tangtaizong666 <212687958+tangtaizong666@users.noreply.github.com>
2026-07-05 09:59:48 +08:00
shuiping233
cc0b347508 fix: qq_official websocket适配器发送消息收到None时现在会重试了 (#8977) (#8979)
* fix: qq_official websocket适配器发送消息收到None时现在会重试了 (#8977)

* fix: qq_official websocket适配器重试发送消息的时候不会重复打印重试告警了

* fix: qq_official websocket适配器发送媒体/文件消息时遇到api返回None也会重试了
2026-07-05 09:50:42 +08:00
LIghtJUNction
30426c4f67 fix: secure project update temp staging (#9083)
* fix: secure project update temp staging

* Update astrbot/dashboard/services/update_service.py

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

* fix: close update staging temp context

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-07-05 09:49:11 +08:00
VectorPeak
041fba4df4 fix: enforce ownership when reading ChatUI sessions (#9141) 2026-07-05 09:34:38 +08:00
Weilong Liao
b43cc6dee0 feat: improve ChatUI attachment display (#9134) 2026-07-04 17:56:33 +08:00
38 changed files with 2164 additions and 376 deletions

View File

@@ -47,11 +47,12 @@ ruff check .
2. Do not add any report files such as xxx_SUMMARY.md.
3. After finishing, use `ruff format .` and `ruff check .` to format and check the code.
4. When committing, ensure to use conventional commits messages, such as `feat: add new agent for data analysis` or `fix: resolve bug in provider manager`.
5. Use English for all new comments.
5. Use **English** for all comments and logs.
6. For path handling, use `pathlib.Path` instead of string paths, and use `astrbot.core.utils.path_utils` to get the AstrBot data and temp directory.
7. When backend API routes, request/response schemas, or OpenAPI definitions change, regenerate the frontend API client by running `cd dashboard && pnpm generate:api`.
8. When updating the project version, keep `[project].version` in `pyproject.toml` and `__version__` in `astrbot/__init__.py` in sync. `VERSION` in `astrbot/core/config/default.py` should derive from `astrbot.__version__` instead of hardcoding a separate version string.
9. When designing WebUI dialogs, use `text-h3 pa-4 pb-0 pl-6` as the base class for dialog titles, and use `variant="text"` or `variant="tonal"` for dialog buttons.
10. Consider cross-platform compatibility (e.g., Windows, macOS, and Linux, as well as Arm64 and x86 CPU architectures) and compatibility with Python 3.10+.
### KISS and First Principles

View File

@@ -145,6 +145,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
REPEATED_TOOL_NOTICE_L1_THRESHOLD = 3
REPEATED_TOOL_NOTICE_L2_THRESHOLD = 4
REPEATED_TOOL_NOTICE_L3_THRESHOLD = 5
MALFORMED_TOOL_NAME_PLACEHOLDER = "__malformed_tool_name__"
REPEATED_TOOL_NOTICE_L1_TEMPLATE = (
"\n\n[SYSTEM NOTICE] By the way, you have executed the same tool "
"`{tool_name}` {streak} times consecutively. Double-check whether another "
@@ -532,6 +533,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
break
self._sanitize_malformed_tool_calls(resp)
yield resp
return
@@ -689,6 +691,22 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
streak=streak,
)
def _sanitize_malformed_tool_calls(
self,
llm_resp: LLMResponse,
) -> None:
"""Normalize malformed tool call names.
Args:
llm_resp: The LLM response whose tool call lists should be sanitized.
"""
llm_resp.tools_call_name = [
self.MALFORMED_TOOL_NAME_PLACEHOLDER
if tool_name is None or tool_name.strip() == ""
else tool_name
for tool_name in llm_resp.tools_call_name
]
@override
async def step(self):
"""Process a single step of the agent.
@@ -1312,6 +1330,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
if requery_resp:
llm_resp = requery_resp
self._sanitize_malformed_tool_calls(llm_resp)
# If the re-query still returns no tool calls, and also does not have a meaningful assistant reply,
# we consider it as a failure of the LLM to follow the tool-use instruction,
@@ -1339,6 +1358,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
if repair_resp:
llm_resp = repair_resp
self._sanitize_malformed_tool_calls(llm_resp)
return llm_resp, subset

View File

@@ -1593,7 +1593,7 @@ CONFIG_METADATA_2 = {
"enable": False,
"api_key": "",
"api_base": "https://api.xiaomimimo.com/v1",
"model": "mimo-v2-omni",
"model": "mimo-v2.5-asr",
"timeout": "20",
"proxy": "",
},

View File

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

View File

@@ -30,6 +30,10 @@ from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.core.utils.media_utils import MediaResolver, file_uri_to_path, is_file_uri
class APIReturnNoneError(Exception):
pass
def _patch_qq_botpy_formdata() -> None:
"""Patch qq-botpy for aiohttp>=3.12 compatibility.
@@ -49,21 +53,25 @@ def _patch_qq_botpy_formdata() -> None:
_patch_qq_botpy_formdata()
# Retry decorator for QQ Official API transient errors (HTTP 500/504)
_qqofficial_retry = retry(
retry=retry_if_exception_type(
(
botpy.errors.ServerError,
botpy.errors.SequenceNumberError,
OSError,
asyncio.TimeoutError,
)
),
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=2, min=2, max=30),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
def _qqofficial_retry(max_attempts: int = 5):
"""Retry decorator for QQ Official API transient errors (HTTP 500/504)"""
return retry(
retry=retry_if_exception_type(
(
botpy.errors.ServerError,
botpy.errors.SequenceNumberError,
OSError,
asyncio.TimeoutError,
APIReturnNoneError,
)
),
stop=stop_after_attempt(max_attempts),
wait=wait_exponential(multiplier=2, min=2, max=30),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
_QQOFFICIAL_SEND_API_ERRORS = (
botpy.errors.ForbiddenError,
@@ -558,14 +566,13 @@ class QQOfficialMessageEvent(AstrMessageEvent):
"srv_send_msg": False,
}
@_qqofficial_retry
@_qqofficial_retry()
async def _do_upload():
if "openid" in kwargs:
payload["openid"] = kwargs["openid"]
route = Route(
"POST", "/v2/users/{openid}/files", openid=kwargs["openid"]
)
return await self.bot.api._http.request(route, json=payload)
elif "group_openid" in kwargs:
payload["group_openid"] = kwargs["group_openid"]
route = Route(
@@ -573,11 +580,20 @@ class QQOfficialMessageEvent(AstrMessageEvent):
"/v2/groups/{group_openid}/files",
group_openid=kwargs["group_openid"],
)
return await self.bot.api._http.request(route, json=payload)
else:
raise ValueError("Invalid upload parameters")
result = await _do_upload()
result = await self.bot.api._http.request(route, json=payload)
if result is None:
err_msg = "上传图片API返回None触发重试"
raise APIReturnNoneError(err_msg)
return result
try:
result = await _do_upload()
except APIReturnNoneError:
logger.warning(f"上传图片API返回None共尝试5次后放弃: {payload}")
raise
if not isinstance(result, dict):
raise RuntimeError(
@@ -629,9 +645,13 @@ class QQOfficialMessageEvent(AstrMessageEvent):
else:
return None
@_qqofficial_retry
@_qqofficial_retry()
async def _do_upload():
return await self.bot.api._http.request(route, json=payload)
result = await self.bot.api._http.request(route, json=payload)
if result is None:
err_msg = "上传文件API返回None触发重试"
raise APIReturnNoneError(err_msg)
return result
try:
result = await _do_upload()
@@ -646,6 +666,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
file_info=result["file_info"],
ttl=result.get("ttl", 0),
)
except APIReturnNoneError:
logger.warning(f"上传文件API返回None共尝试5次后放弃: {file_source}")
except (botpy.errors.ServerError, botpy.errors.SequenceNumberError):
logger.error(f"上传媒体文件失败共尝试5次后放弃: {file_source}")
except Exception as e:
@@ -680,11 +702,26 @@ class QQOfficialMessageEvent(AstrMessageEvent):
stream_data.pop("id", None)
payload["stream"] = stream_data
route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
result = await self.bot.api._http.request(route, json=payload)
if result is None:
logger.warning("[QQOfficial] post_c2c_message: API 返回 None跳过本次发送")
retry_times = 3
@_qqofficial_retry(retry_times)
async def _do_request():
result = await self.bot.api._http.request(route, json=payload)
if result is None:
err_msg = "发送消息API返回None触发重试"
raise APIReturnNoneError(err_msg)
return result
result = None
try:
result = await _do_request()
except APIReturnNoneError:
logger.warning(
f"[QQOfficial] post_c2c_message: 发送消息失败API 返回 None共尝试{retry_times}次后放弃"
)
return None
if not isinstance(result, dict):
logger.error(f"[QQOfficial] post_c2c_message: 响应不是 dict: {result}")
return None

View File

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

View File

@@ -1,3 +1,4 @@
import base64
from pathlib import Path
import httpx
@@ -10,7 +11,16 @@ DEFAULT_MIMO_API_BASE = "https://api.xiaomimimo.com/v1"
DEFAULT_MIMO_TTS_MODEL = "mimo-v2-tts"
DEFAULT_MIMO_TTS_VOICE = "mimo_default"
DEFAULT_MIMO_TTS_SEED_TEXT = "Hello, MiMo, have you had lunch?"
DEFAULT_MIMO_STT_MODEL = "mimo-v2-omni"
# The MiMo-V2 series went offline on 2026-06-30; mimo-v2.5-asr is the
# dedicated speech recognition model per the official model lineup.
DEFAULT_MIMO_STT_MODEL = "mimo-v2.5-asr"
DEFAULT_MIMO_STT_SYSTEM_PROMPT = (
"You are a speech transcription assistant. "
"Transcribe the spoken content from the audio exactly and return only the transcription text."
)
DEFAULT_MIMO_STT_USER_PROMPT = (
"Please transcribe the content of the audio and return only the transcription text."
)
class MiMoAPIError(Exception):
@@ -67,9 +77,50 @@ async def prepare_audio_input(audio_source: str) -> tuple[str, list[Path]]:
)
if audio_data is None:
raise ValueError(f"Invalid audio data: {describe_media_ref(audio_source)}")
_validate_wav_payload(audio_data.base64_data, audio_source)
return audio_data.to_data_url(), []
def _decode_base64_header(base64_data: str) -> bytes:
chunk = "".join(base64_data[:64].split())
padding = len(chunk) % 4
if padding:
chunk += "=" * (4 - padding)
return base64.b64decode(chunk)
def _validate_wav_payload(base64_data: str, audio_source: str) -> None:
"""Reject audio payloads whose bytes are not RIFF/WAVE.
MiMo only accepts wav/mp3 audio. When a platform voice file (e.g. Tencent
SILK from QQ) slips through the WAV conversion chain unchanged, the API
replies with an opaque HTTP 400, so fail locally with the real reason.
Args:
base64_data: Base64-encoded audio payload about to be sent.
audio_source: Original media reference, used in error messages.
Raises:
MiMoAPIError: Raised when the payload is not valid WAV data.
"""
try:
header = _decode_base64_header(base64_data)
except Exception:
header = b""
if len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WAVE":
return
if header.startswith((b"#!SILK_V3", b"\x02#!SILK_V3")):
raise MiMoAPIError(
"Audio for MiMo STT is still Tencent SILK data after WAV conversion; "
"check that the silk-python package is installed and working: "
f"{describe_media_ref(audio_source)}"
)
raise MiMoAPIError(
"Audio for MiMo STT could not be converted to WAV "
f"(unrecognized audio bytes): {describe_media_ref(audio_source)}"
)
def cleanup_files(paths: list[Path]) -> None:
for path in paths:
try:

View File

@@ -4,6 +4,8 @@ from ..register import register_provider_adapter
from .mimo_api_common import (
DEFAULT_MIMO_API_BASE,
DEFAULT_MIMO_STT_MODEL,
DEFAULT_MIMO_STT_SYSTEM_PROMPT,
DEFAULT_MIMO_STT_USER_PROMPT,
MiMoAPIError,
build_api_url,
build_headers,
@@ -33,23 +35,49 @@ class ProviderMiMoSTTAPI(STTProvider):
self.set_model(provider_config.get("model", DEFAULT_MIMO_STT_MODEL))
self.client = create_http_client(self.timeout, self.proxy)
def _is_asr_model(self) -> bool:
return "asr" in (self.model_name or "").lower()
def _build_messages(self, audio_data_url: str) -> list[dict]:
audio_content = {
"type": "input_audio",
"input_audio": {
"data": audio_data_url,
},
}
if self._is_asr_model():
# Dedicated ASR models (speech-recognition docs) take bare audio.
return [
{
"role": "user",
"content": [audio_content],
},
]
# Multimodal models such as mimo-v2.5 (audio-understanding docs)
# require a text instruction alongside the audio, otherwise the API
# rejects the request.
return [
{
"role": "system",
"content": DEFAULT_MIMO_STT_SYSTEM_PROMPT,
},
{
"role": "user",
"content": [
audio_content,
{
"type": "text",
"text": DEFAULT_MIMO_STT_USER_PROMPT,
},
],
},
]
async def get_text(self, audio_url: str) -> str:
audio_data_url, cleanup_paths = await prepare_audio_input(audio_url)
payload = {
"model": self.model_name,
"messages": [
{
"role": "user",
"content": [
{
"type": "input_audio",
"input_audio": {
"data": audio_data_url,
},
},
],
},
],
"messages": self._build_messages(audio_data_url),
"max_completion_tokens": 1024,
}

View File

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

View File

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

View File

@@ -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:
@@ -1196,7 +1213,11 @@ class ChatService:
async def get_session(self, username: str, session_id: str) -> dict:
session = await self.db.get_platform_session_by_id(session_id)
platform_id = session.platform_id if session else "webchat"
if not session:
raise ChatServiceError(f"Session {session_id} not found")
if session.creator != username:
raise ChatServiceError("Permission denied")
platform_id = session.platform_id
project_info = await self.db.get_project_by_session(
session_id=session_id, creator=username
@@ -1213,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),
}
@@ -1329,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),
}
@@ -1483,7 +1504,7 @@ class ChatService:
await self.db.update_platform_session(session_id=session_id)
updated = await self.db.get_platform_message_history_by_id(message_id)
return {
"message": updated.model_dump() if updated else None,
"message": serialize_history_entry(updated) if updated else None,
"needs_regenerate": True,
"truncated_after_message": True,
}

View File

@@ -1297,10 +1297,19 @@ class ProviderConfigService:
for provider in provider_registry:
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:
model_metadata[model_id] = LLM_METADATAS[model_id]
return {
"config_schema": config_schema,
"providers": self.config.get("provider", []),
"providers": providers,
"provider_sources": self.config.get("provider_sources", []),
"model_metadata": model_metadata,
}
def list_provider_sources(self) -> dict:
@@ -1543,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
@@ -1562,12 +1574,16 @@ class ProviderConfigService:
if provider_type and effective_type != provider_type:
continue
if provider.get("provider_source_id"):
providers.append(
self.provider_manager.get_merged_provider_config(provider)
provider_response = self.provider_manager.get_merged_provider_config(
provider
)
else:
providers.append(copy.deepcopy(provider))
return {"providers": providers}
provider_response = copy.deepcopy(provider)
model_id = provider_response.get("model")
if isinstance(model_id, str) and model_id in LLM_METADATAS:
model_metadata[model_id] = LLM_METADATAS[model_id]
providers.append(provider_response)
return {"providers": providers, "model_metadata": model_metadata}
def list_providers_for_dashboard_types(
self, provider_type: str | None
@@ -1597,7 +1613,14 @@ class ProviderConfigService:
)
if provider is None:
raise ValueError(f"Provider {provider_id} not found")
return {"provider": provider}
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:
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)

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import inspect
import tempfile
import traceback
import uuid
import zipfile
@@ -22,7 +23,7 @@ from astrbot.core.desktop_runtime import (
from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_system_tmp_path,
get_astrbot_temp_path,
)
from astrbot.core.utils.io import (
download_dashboard as _download_dashboard,
@@ -206,158 +207,168 @@ class UpdateService:
reboot: Whether to restart AstrBot after applying files.
proxy: Optional GitHub proxy URL.
"""
update_temp_dir = Path(get_astrbot_system_tmp_path()) / "updates"
update_temp_dir.mkdir(parents=True, exist_ok=True)
update_token = uuid.uuid4().hex
dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip"
core_zip_path = update_temp_dir / f"{update_token}-core.zip"
update_temp_parent = Path(get_astrbot_temp_path()) / "updates"
try:
self._set_update_stage(
progress_id,
"dashboard",
"running",
"正在下载 WebUI...",
0,
)
await self.download_dashboard(
path=str(dashboard_zip_path),
latest=latest,
version=version,
proxy=proxy or "",
progress_callback=self._make_progress_callback(
if update_temp_parent.is_symlink():
update_temp_parent.unlink()
update_temp_parent.mkdir(mode=0o700, parents=True, exist_ok=True)
update_temp_parent.chmod(0o700)
with tempfile.TemporaryDirectory(
prefix="project-update-",
dir=update_temp_parent,
) as update_temp_dir_name:
update_temp_dir = Path(update_temp_dir_name)
update_token = uuid.uuid4().hex
dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip"
core_zip_path = update_temp_dir / f"{update_token}-core.zip"
self._set_update_stage(
progress_id,
"dashboard",
"running",
"正在下载 WebUI...",
0,
45,
),
extract=False,
)
self._set_update_stage(
progress_id,
"dashboard",
"done",
"WebUI 下载完成。",
45,
)
self._set_update_stage(
progress_id,
"core",
"running",
"正在下载 AstrBot 项目代码...",
45,
)
core_zip_path = Path(
await self.astrbot_updator.download_update_package(
)
await self.download_dashboard(
path=str(dashboard_zip_path),
latest=latest,
version=version,
proxy=proxy or "",
path=core_zip_path,
progress_callback=self._make_progress_callback(
progress_id,
"core",
45,
"dashboard",
0,
45,
),
extract=False,
)
)
self._set_update_stage(
progress_id,
"core",
"done",
"项目代码下载完成。",
90,
)
self._set_update_stage(
progress_id,
"verify",
"running",
"下载完成,正在校验更新包...",
90,
)
def _verify_update_packages() -> None:
for zip_path in (dashboard_zip_path, core_zip_path):
with zipfile.ZipFile(zip_path, "r") as archive:
corrupt_member = archive.testzip()
if corrupt_member:
raise UpdateServiceError(f"更新包校验失败: {corrupt_member}")
await asyncio.to_thread(_verify_update_packages)
self._set_update_stage(
progress_id,
"verify",
"done",
"更新包校验完成。",
91,
)
self._set_update_stage(
progress_id,
"apply",
"running",
"下载完成,正在应用更新...",
91,
)
await asyncio.to_thread(
self.astrbot_updator.apply_update_package,
core_zip_path,
)
await self.extract_dashboard(
dashboard_zip_path,
Path(get_astrbot_data_path()),
)
self._set_update_stage(
progress_id,
"apply",
"done",
"更新文件应用完成。",
92,
)
self._set_update_stage(
progress_id,
"dependencies",
"running",
"正在更新依赖...",
92,
)
logger.info("更新依赖中...")
try:
await self.pip_install(requirements_path="requirements.txt")
except Exception as exc:
logger.error(f"更新依赖失败: {exc}")
self._set_update_stage(
progress_id,
"dependencies",
"done",
"依赖更新完成。",
96,
)
if reboot:
self._set_update_stage(
progress_id,
"restart",
"running",
"更新成功,正在准备重启...",
98,
"dashboard",
"done",
"WebUI 下载完成。",
45,
)
await self.core_lifecycle.restart()
message = "更新成功AstrBot 将在 2 秒内全量重启以应用新的代码。"
else:
message = "更新成功AstrBot 将在下次启动时应用新的代码。"
self.update_progress[progress_id].update(
{
"status": "success",
"stage": "done",
"message": message,
"overall_percent": 100,
},
)
logger.info(message)
self._set_update_stage(
progress_id,
"core",
"running",
"正在下载 AstrBot 项目代码...",
45,
)
core_zip_path = Path(
await self.astrbot_updator.download_update_package(
latest=latest,
version=version,
proxy=proxy or "",
path=core_zip_path,
progress_callback=self._make_progress_callback(
progress_id,
"core",
45,
45,
),
)
)
self._set_update_stage(
progress_id,
"core",
"done",
"项目代码下载完成。",
90,
)
self._set_update_stage(
progress_id,
"verify",
"running",
"下载完成,正在校验更新包...",
90,
)
def _verify_update_packages() -> None:
for zip_path in (dashboard_zip_path, core_zip_path):
with zipfile.ZipFile(zip_path, "r") as archive:
corrupt_member = archive.testzip()
if corrupt_member:
raise UpdateServiceError(
f"更新包校验失败: {corrupt_member}"
)
await asyncio.to_thread(_verify_update_packages)
self._set_update_stage(
progress_id,
"verify",
"done",
"更新包校验完成。",
91,
)
self._set_update_stage(
progress_id,
"apply",
"running",
"下载完成,正在应用更新...",
91,
)
await asyncio.to_thread(
self.astrbot_updator.apply_update_package,
core_zip_path,
)
await self.extract_dashboard(
dashboard_zip_path,
Path(get_astrbot_data_path()),
)
self._set_update_stage(
progress_id,
"apply",
"done",
"更新文件应用完成。",
92,
)
self._set_update_stage(
progress_id,
"dependencies",
"running",
"正在更新依赖...",
92,
)
logger.info("更新依赖中...")
try:
await self.pip_install(requirements_path="requirements.txt")
except Exception as exc:
logger.error(f"更新依赖失败: {exc}")
self._set_update_stage(
progress_id,
"dependencies",
"done",
"依赖更新完成。",
96,
)
if reboot:
self._set_update_stage(
progress_id,
"restart",
"running",
"更新成功,正在准备重启...",
98,
)
await self.core_lifecycle.restart()
message = "更新成功AstrBot 将在 2 秒内全量重启以应用新的代码。"
else:
message = "更新成功AstrBot 将在下次启动时应用新的代码。"
self.update_progress[progress_id].update(
{
"status": "success",
"stage": "done",
"message": message,
"overall_percent": 100,
},
)
logger.info(message)
except asyncio.CancelledError:
self.update_progress[progress_id].update(
{
@@ -376,13 +387,6 @@ class UpdateService:
)
logger.error(f"/api/update_project: {traceback.format_exc()}")
logger.debug(f"Update task failed: {exc!s}")
finally:
for zip_path in (dashboard_zip_path, core_zip_path):
try:
if zip_path.exists():
zip_path.unlink()
except Exception as cleanup_exc:
logger.warning(f"清理更新临时文件失败: {zip_path}, {cleanup_exc}")
async def update_dashboard(self) -> UpdateServiceResult:
try:

View File

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

View File

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

View File

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

View File

@@ -54,28 +54,73 @@
<v-list-item-subtitle class="provider-subtitle">
<span class="model-name">{{ provider.model }}</span>
<span class="meta-icons">
<v-icon v-if="supportsImageInput(provider)" size="13">
mdi-eye-outline
</v-icon>
<v-icon v-if="supportsAudioInput(provider)" size="13">
mdi-music-note-outline
</v-icon>
<v-icon v-if="supportsToolCall(provider)" size="13">
mdi-wrench
</v-icon>
<v-icon v-if="supportsReasoning(provider)" size="13">
mdi-brain
</v-icon>
<v-tooltip
v-for="item in capabilityBadges(provider)"
:key="item.key"
location="top"
max-width="320"
>
<template #activator="{ props: badgeTooltipProps }">
<span
v-bind="badgeTooltipProps"
class="meta-icon-badge"
:class="{ 'meta-icon-badge--disabled': !item.enabled }"
@click.stop
>
<v-icon size="13">{{ item.icon }}</v-icon>
</span>
</template>
<span>{{ item.tooltip }}</span>
</v-tooltip>
<v-tooltip
v-if="formatContextLimit(provider, metadataForProvider(provider))"
location="top"
max-width="320"
>
<template #activator="{ props: contextTooltipProps }">
<span
v-bind="contextTooltipProps"
class="meta-context-badge"
@click.stop
>
{{ formatContextLimit(provider, metadataForProvider(provider)) }}
</span>
</template>
<span>{{
tm("models.metadata.context", {
tokens: formatContextLimit(
provider,
metadataForProvider(provider),
),
})
}}</span>
</v-tooltip>
</span>
</v-list-item-subtitle>
<template #append>
<v-icon
v-if="selectedProviderId === provider.id"
class="provider-selected-icon"
size="18"
>
mdi-check
</v-icon>
<div class="provider-menu-actions" @click.stop>
<v-tooltip location="top">
<template #activator="{ props: testTooltipProps }">
<v-btn
v-bind="testTooltipProps"
icon="mdi-connection"
size="x-small"
variant="text"
:loading="testingProviderIds.includes(provider.id)"
:disabled="testingProviderIds.includes(provider.id)"
@click.stop="testProvider(provider)"
/>
</template>
<span>{{ tm("models.testButton") }}</span>
</v-tooltip>
<v-icon
v-if="selectedProviderId === provider.id"
class="provider-selected-icon"
size="18"
>
mdi-check
</v-icon>
</div>
</template>
</v-list-item>
</v-list>
@@ -94,18 +139,19 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { providerApi } from "@/api/v1";
import { useModuleI18n } from "@/i18n/composables";
import { useToast } from "@/utils/toast";
import {
formatContextLimit,
providerCapabilityBadges,
type ProviderModelMetadata,
type ProviderMetadataSource,
} from "@/utils/providerMetadata";
interface ModelMetadata {
modalities?: { input?: string[] };
tool_call?: boolean;
reasoning?: boolean;
}
interface ProviderConfig {
interface ProviderConfig extends ProviderMetadataSource {
id: string;
model: string;
api_base?: string;
model_metadata?: ModelMetadata;
enable?: boolean;
}
@@ -127,6 +173,10 @@ const searchQuery = ref("");
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();
const variant = computed(() => props.variant);
const menuLocation = computed(() =>
@@ -134,7 +184,9 @@ const menuLocation = computed(() =>
);
const selectedProvider = computed(() =>
providerConfigs.value.find((provider) => provider.id === selectedProviderId.value),
providerConfigs.value.find(
(provider) => provider.id === selectedProviderId.value,
),
);
const triggerTitle = computed(() => {
@@ -183,9 +235,12 @@ async function loadProviderConfigs(force = false) {
try {
const response = await providerApi.listByProviderType("chat_completion");
if (response.data.status === "ok") {
providerConfigs.value = ((response.data.data || []) as unknown as ProviderConfig[]).filter(
(provider: ProviderConfig) => provider.enable !== false,
);
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);
providersLoaded.value = true;
const selected = selectedProvider.value;
if (selected) {
@@ -207,22 +262,40 @@ function selectProvider(provider: ProviderConfig) {
menuOpen.value = false;
}
function supportsImageInput(provider: ProviderConfig): boolean {
const inputs = provider.model_metadata?.modalities?.input || [];
return inputs.includes("image");
function capabilityBadges(provider: ProviderConfig) {
return providerCapabilityBadges(provider, metadataForProvider(provider), tm);
}
function supportsAudioInput(provider: ProviderConfig): boolean {
const inputs = provider.model_metadata?.modalities?.input || [];
return inputs.includes("audio");
function metadataForProvider(provider: ProviderConfig) {
return provider.model ? modelMetadata.value[provider.model] || null : null;
}
function supportsToolCall(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.tool_call);
}
function supportsReasoning(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.reasoning);
async function testProvider(provider: ProviderConfig) {
if (testingProviderIds.value.includes(provider.id)) return;
testingProviderIds.value.push(provider.id);
try {
const startTime = performance.now();
const response = await providerApi.test(provider.id);
if (response.data.status === "ok" && response.data.data.error === null) {
const latency = Math.max(0, Math.round(performance.now() - startTime));
toastSuccess(
tm("models.testSuccessWithLatency", {
id: provider.id,
latency,
}),
);
} else {
throw new Error(response.data.data.error || tm("models.testError"));
}
} catch (error: any) {
toastError(
error.response?.data?.message || error.message || tm("models.testError"),
);
} finally {
testingProviderIds.value = testingProviderIds.value.filter(
(id) => id !== provider.id,
);
}
}
function getCurrentSelection() {
@@ -404,6 +477,35 @@ defineExpose({
color: rgba(var(--v-theme-on-surface), 0.5);
}
.meta-icon-badge {
display: inline-flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.72);
}
.meta-icon-badge--disabled {
color: rgba(var(--v-theme-on-surface), 0.34);
}
.meta-context-badge {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 5px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.06);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 10px;
font-weight: 650;
line-height: 16px;
}
.provider-menu-actions {
display: inline-flex;
align-items: center;
gap: 2px;
}
.provider-selected-icon {
color: rgba(var(--v-theme-on-surface), 0.72);
}

View File

@@ -76,18 +76,49 @@
<v-list-item-subtitle class="regenerate-model-subtitle">
<span class="regenerate-model-name">{{ provider.model }}</span>
<span class="regenerate-model-icons">
<v-icon v-if="supportsImageInput(provider)" size="12">
mdi-eye-outline
</v-icon>
<v-icon v-if="supportsAudioInput(provider)" size="12">
mdi-music-note-outline
</v-icon>
<v-icon v-if="supportsToolCall(provider)" size="12">
mdi-wrench
</v-icon>
<v-icon v-if="supportsReasoning(provider)" size="12">
mdi-brain
</v-icon>
<v-tooltip
v-for="item in capabilityBadges(provider)"
:key="item.key"
location="top"
max-width="320"
>
<template #activator="{ props: badgeTooltipProps }">
<span
v-bind="badgeTooltipProps"
class="regenerate-model-icon-badge"
:class="{
'regenerate-model-icon-badge--disabled': !item.enabled,
}"
@click.stop
>
<v-icon size="12">{{ item.icon }}</v-icon>
</span>
</template>
<span>{{ item.tooltip }}</span>
</v-tooltip>
<v-tooltip
v-if="formatContextLimit(provider, metadataForProvider(provider))"
location="top"
max-width="320"
>
<template #activator="{ props: contextTooltipProps }">
<span
v-bind="contextTooltipProps"
class="regenerate-model-context-badge"
@click.stop
>
{{ formatContextLimit(provider, metadataForProvider(provider)) }}
</span>
</template>
<span>{{
providerTm("models.metadata.context", {
tokens: formatContextLimit(
provider,
metadataForProvider(provider),
),
})
}}</span>
</v-tooltip>
</span>
</v-list-item-subtitle>
</v-list-item>
@@ -106,17 +137,16 @@ import { ref } from "vue";
import { providerApi } from "@/api/v1";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import { useModuleI18n } from "@/i18n/composables";
import {
formatContextLimit,
providerCapabilityBadges,
type ProviderModelMetadata,
type ProviderMetadataSource,
} from "@/utils/providerMetadata";
interface ModelMetadata {
modalities?: { input?: string[] };
tool_call?: boolean;
reasoning?: boolean;
}
interface ProviderConfig {
interface ProviderConfig extends ProviderMetadataSource {
id: string;
model: string;
model_metadata?: ModelMetadata;
enable?: boolean;
}
@@ -131,9 +161,11 @@ const emit = defineEmits<{
}>();
const { tm } = useModuleI18n("features/chat");
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;
@@ -141,9 +173,12 @@ async function loadProviderConfigs(force = false) {
try {
const response = await providerApi.listByProviderType("chat_completion");
if (response.data.status === "ok") {
providerConfigs.value = ((response.data.data || []) as unknown as ProviderConfig[]).filter(
(provider: ProviderConfig) => provider.enable !== false,
);
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);
providersLoaded.value = true;
}
} catch (error) {
@@ -172,20 +207,16 @@ function retryWithModel(provider: ProviderConfig) {
});
}
function supportsImageInput(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.modalities?.input?.includes("image"));
function capabilityBadges(provider: ProviderConfig) {
return providerCapabilityBadges(
provider,
metadataForProvider(provider),
providerTm,
);
}
function supportsAudioInput(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.modalities?.input?.includes("audio"));
}
function supportsToolCall(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.tool_call);
}
function supportsReasoning(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.reasoning);
function metadataForProvider(provider: ProviderConfig) {
return provider.model ? modelMetadata.value[provider.model] || null : null;
}
</script>
@@ -226,6 +257,29 @@ function supportsReasoning(provider: ProviderConfig): boolean {
color: var(--chat-muted, rgba(var(--v-theme-on-surface), 0.62));
}
.regenerate-model-icon-badge {
display: inline-flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.72);
}
.regenerate-model-icon-badge--disabled {
color: rgba(var(--v-theme-on-surface), 0.34);
}
.regenerate-model-context-badge {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 5px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.06);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 10px;
font-weight: 650;
line-height: 16px;
}
.regenerate-empty {
padding: 14px 16px;
color: var(--chat-muted, rgba(var(--v-theme-on-surface), 0.62));

View File

@@ -66,19 +66,47 @@
<div class="provider-model-row__title">{{ entry.provider.id }}</div>
<div class="provider-model-row__subtitle">{{ entry.provider.model }}</div>
<div class="provider-model-row__meta">
<span
v-for="item in capabilityIcons(entry.metadata)"
:key="item.icon"
class="provider-model-row__badge"
<v-tooltip
v-for="item in capabilityBadges(entry)"
:key="item.key"
location="top"
max-width="320"
>
<v-icon size="14">{{ item.icon }}</v-icon>
</span>
<span
<template #activator="{ props: badgeTooltipProps }">
<span
v-bind="badgeTooltipProps"
class="provider-model-row__badge"
:class="{
'provider-model-row__badge--enabled': item.enabled,
'provider-model-row__badge--disabled': !item.enabled
}"
@click.stop
>
<v-icon size="14">{{ item.icon }}</v-icon>
</span>
</template>
<span>{{ item.tooltip }}</span>
</v-tooltip>
<v-tooltip
v-if="formatContextLimit(entry.metadata)"
class="provider-model-row__badge provider-model-row__badge--text"
location="top"
max-width="320"
>
{{ formatContextLimit(entry.metadata) }}
</span>
<template #activator="{ props: contextTooltipProps }">
<span
v-bind="contextTooltipProps"
class="provider-model-row__badge provider-model-row__badge--text provider-model-row__badge--enabled"
@click.stop
>
{{ formatContextLimit(entry.metadata) }}
</span>
</template>
<span>{{
tm('models.metadata.context', {
tokens: formatContextLimit(entry.metadata)
})
}}</span>
</v-tooltip>
</div>
</button>
@@ -94,14 +122,20 @@
@update:modelValue="emit('toggle-provider-enable', entry.provider, $event)"
></v-switch>
<v-btn
icon="mdi-connection"
size="small"
variant="text"
:disabled="!entry.provider.enable || isProviderSaving(entry.provider.id)"
:loading="isProviderTesting(entry.provider.id)"
@click.stop="emit('test-provider', entry.provider)"
></v-btn>
<v-tooltip location="top">
<template #activator="{ props: testTooltipProps }">
<v-btn
v-bind="testTooltipProps"
icon="mdi-connection"
size="small"
variant="text"
:disabled="!entry.provider.enable || isProviderSaving(entry.provider.id)"
:loading="isProviderTesting(entry.provider.id)"
@click.stop="emit('test-provider', entry.provider)"
></v-btn>
</template>
<span>{{ tm('models.testButton') }}</span>
</v-tooltip>
<v-btn
icon="mdi-cog-outline"
size="small"
@@ -117,8 +151,14 @@
</div>
</div>
</template>
<div><strong>{{ tm('models.tooltips.providerId') }}:</strong> {{ entry.provider.id }}</div>
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.provider.model }}</div>
<div>
<strong>{{ tm('models.tooltips.providerId') }}:</strong>
{{ entry.provider.id }}
</div>
<div>
<strong>{{ tm('models.tooltips.modelId') }}:</strong>
{{ entry.provider.model }}
</div>
</v-tooltip>
</div>
@@ -152,19 +192,47 @@
>
<div class="provider-model-row__title provider-model-row__title--mono">{{ entry.model }}</div>
<div class="provider-model-row__meta">
<span
v-for="item in capabilityIcons(entry.metadata)"
:key="item.icon"
class="provider-model-row__badge"
<v-tooltip
v-for="item in capabilityBadges(entry)"
:key="item.key"
location="top"
max-width="320"
>
<v-icon size="14">{{ item.icon }}</v-icon>
</span>
<span
<template #activator="{ props: badgeTooltipProps }">
<span
v-bind="badgeTooltipProps"
class="provider-model-row__badge"
:class="{
'provider-model-row__badge--enabled': item.enabled,
'provider-model-row__badge--disabled': !item.enabled
}"
@click.stop
>
<v-icon size="14">{{ item.icon }}</v-icon>
</span>
</template>
<span>{{ item.tooltip }}</span>
</v-tooltip>
<v-tooltip
v-if="formatContextLimit(entry.metadata)"
class="provider-model-row__badge provider-model-row__badge--text"
location="top"
max-width="320"
>
{{ formatContextLimit(entry.metadata) }}
</span>
<template #activator="{ props: contextTooltipProps }">
<span
v-bind="contextTooltipProps"
class="provider-model-row__badge provider-model-row__badge--text provider-model-row__badge--enabled"
@click.stop
>
{{ formatContextLimit(entry.metadata) }}
</span>
</template>
<span>{{
tm('models.metadata.context', {
tokens: formatContextLimit(entry.metadata)
})
}}</span>
</v-tooltip>
</div>
</button>
@@ -179,7 +247,10 @@
</div>
</div>
</template>
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.model }}</div>
<div>
<strong>{{ tm('models.tooltips.modelId') }}:</strong>
{{ entry.model }}
</div>
</v-tooltip>
</div>
@@ -275,21 +346,64 @@ const availableEntries = computed(() =>
(props.entries || []).filter((entry) => entry.type === 'available')
)
const capabilityIcons = (metadata) => {
const icons = []
if (props.supportsImageInput(metadata)) {
icons.push({ icon: 'mdi-image-outline' })
}
if (props.supportsAudioInput(metadata)) {
icons.push({ icon: 'mdi-music-note-outline' })
}
if (props.supportsToolCall(metadata)) {
icons.push({ icon: 'mdi-wrench-outline' })
}
if (props.supportsReasoning(metadata)) {
icons.push({ icon: 'mdi-brain' })
}
return icons
const capabilityBadges = (entry) => {
const metadata = entry?.metadata
const provider = entry?.provider
const modalities = Array.isArray(provider?.modalities) ? provider.modalities : []
const isConfigured = entry?.type === 'configured'
const hasModelMetadata = Boolean(entry?.hasModelMetadata)
const definitions = [
{
key: 'image',
icon: 'mdi-image-outline',
supported: props.supportsImageInput(metadata),
enabled: !isConfigured || modalities.includes('image'),
label: props.tm('models.metadata.image')
},
{
key: 'audio',
icon: 'mdi-music-note-outline',
supported: props.supportsAudioInput(metadata),
enabled: !isConfigured || modalities.includes('audio'),
label: props.tm('models.metadata.audio')
},
{
key: 'tool_use',
icon: 'mdi-wrench-outline',
supported: props.supportsToolCall(metadata),
enabled: !isConfigured || modalities.includes('tool_use'),
label: props.tm('models.metadata.toolUse')
},
{
key: 'reasoning',
icon: 'mdi-brain',
supported: props.supportsReasoning(metadata),
enabled: !isConfigured || Boolean(provider?.reasoning),
label: props.tm('models.metadata.reasoning')
}
]
return definitions
.filter((item) => item.supported || (isConfigured && item.enabled))
.map((item) => {
const enabled = !isConfigured || !hasModelMetadata || item.enabled
let tooltip = props.tm('models.metadata.available', {
capability: item.label
})
if (isConfigured) {
tooltip = enabled
? props.tm('models.metadata.enabled', { capability: item.label })
: props.tm('models.metadata.supportedDisabled', {
capability: item.label
})
}
return {
key: item.key,
icon: item.icon,
enabled,
tooltip
}
})
}
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
@@ -445,13 +559,21 @@ const isProviderSaving = (providerId) => props.savingProviders.includes(provider
width: 24px;
height: 24px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.04);
color: rgba(var(--v-theme-on-surface), 0.58);
display: inline-flex;
align-items: center;
justify-content: center;
}
.provider-model-row__badge--enabled {
background: rgba(var(--v-theme-on-surface), 0.06);
color: rgba(var(--v-theme-on-surface), 0.72);
}
.provider-model-row__badge--disabled {
background: rgba(var(--v-theme-on-surface), 0.04);
color: rgba(var(--v-theme-on-surface), 0.34);
}
.provider-model-row__badge--text {
width: auto;
padding: 0 8px;

View File

@@ -106,7 +106,7 @@
<v-list-item-subtitle>{{ tm('providerSelector.clearSelectionSubtitle') }}</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="selectedProvider === ''" color="primary">mdi-check-circle</v-icon>
<v-icon v-if="selectedProvider === ''">mdi-check-circle</v-icon>
</template>
</v-list-item>
@@ -124,10 +124,69 @@
<v-list-item-subtitle>
{{ provider.type || provider.provider_type || tm('providerSelector.unknownType') }}
<span v-if="provider.model">- {{ provider.model }}</span>
<span
v-if="capabilityBadges(provider).length || formatContextLimit(provider, metadataForProvider(provider))"
class="provider-selector-meta"
>
<v-tooltip
v-for="item in capabilityBadges(provider)"
:key="item.key"
location="top"
max-width="320"
>
<template #activator="{ props: badgeTooltipProps }">
<span
v-bind="badgeTooltipProps"
class="provider-selector-meta__badge"
:class="{ 'provider-selector-meta__badge--disabled': !item.enabled }"
@click.stop
>
<v-icon size="13">{{ item.icon }}</v-icon>
</span>
</template>
<span>{{ item.tooltip }}</span>
</v-tooltip>
<v-tooltip
v-if="formatContextLimit(provider, metadataForProvider(provider))"
location="top"
max-width="320"
>
<template #activator="{ props: contextTooltipProps }">
<span
v-bind="contextTooltipProps"
class="provider-selector-meta__context"
@click.stop
>
{{ formatContextLimit(provider, metadataForProvider(provider)) }}
</span>
</template>
<span>{{
providerTm('models.metadata.context', {
tokens: formatContextLimit(provider, metadataForProvider(provider))
})
}}</span>
</v-tooltip>
</span>
</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="isProviderSelected(provider.id)" color="primary">mdi-check-circle</v-icon>
<div class="provider-selector-actions" @click.stop>
<v-tooltip location="top">
<template #activator="{ props: testTooltipProps }">
<v-btn
v-bind="testTooltipProps"
icon="mdi-connection"
size="x-small"
variant="text"
:loading="isProviderTesting(provider.id)"
:disabled="!isProviderTestable(provider)"
@click.stop="testProvider(provider)"
/>
</template>
<span>{{ providerTm('models.testButton') }}</span>
</v-tooltip>
<v-icon v-if="isProviderSelected(provider.id)">mdi-check-circle</v-icon>
</div>
</template>
</v-list-item>
</v-list>
@@ -181,6 +240,8 @@
import { computed, ref, watch } from 'vue'
import { providerApi } from '@/api/v1'
import { useModuleI18n } from '@/i18n/composables'
import { useToast } from '@/utils/toast'
import { formatContextLimit, providerCapabilityBadges } from '@/utils/providerMetadata'
import ProviderChatCompletionPanel from '@/components/provider/ProviderChatCompletionPanel.vue'
import ProviderPage from '@/views/ProviderPage.vue'
@@ -209,6 +270,8 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const { tm } = useModuleI18n('core.shared')
const { tm: providerTm } = useModuleI18n('features/provider')
const { success: toastSuccess, error: toastError } = useToast()
const dialog = ref(false)
const providerList = ref([])
@@ -216,6 +279,8 @@ const loading = ref(false)
const selectedProvider = ref('')
const selectedProviders = ref([])
const providerDrawer = ref(false)
const testingProviders = ref([])
const modelMetadata = ref({})
const hasSelection = computed(() => {
if (props.multiple) {
@@ -265,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))
@@ -333,6 +399,46 @@ function isProviderSelected(providerId) {
return selectedProvider.value === providerId
}
function capabilityBadges(provider) {
return providerCapabilityBadges(provider, metadataForProvider(provider), providerTm)
}
function metadataForProvider(provider) {
return provider?.model ? modelMetadata.value[provider.model] || null : null
}
function isProviderTesting(providerId) {
return testingProviders.value.includes(providerId)
}
function isProviderTestable(provider) {
return Boolean(provider?.id) && provider.enable !== false && !isProviderTesting(provider.id)
}
async function testProvider(provider) {
if (!isProviderTestable(provider)) {
return
}
testingProviders.value.push(provider.id)
try {
const startTime = performance.now()
const response = await providerApi.test(String(provider.id))
if (response.data.status === 'ok' && response.data.data.error === null) {
const latency = Math.max(0, Math.round(performance.now() - startTime))
toastSuccess(providerTm('models.testSuccessWithLatency', {
id: provider.id,
latency
}))
} else {
throw new Error(response.data.data.error || providerTm('models.testError'))
}
} catch (error) {
toastError(error.response?.data?.message || error.message || providerTm('models.testError'))
} finally {
testingProviders.value = testingProviders.value.filter((id) => id !== provider.id)
}
}
function removeSelected(providerId) {
const idx = selectedProviders.value.indexOf(providerId)
if (idx >= 0) {
@@ -384,6 +490,44 @@ function closeProviderDrawer() {
border-radius: 10px;
}
.provider-selector-meta {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
vertical-align: middle;
}
.provider-selector-meta__badge {
display: inline-flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.72);
}
.provider-selector-meta__badge--disabled {
color: rgba(var(--v-theme-on-surface), 0.34);
}
.provider-selector-meta__context {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 5px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.06);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 10px;
font-weight: 650;
line-height: 16px;
}
.provider-selector-actions {
display: inline-flex;
align-items: center;
gap: 2px;
color: rgba(var(--v-theme-on-surface), 0.72);
}
.v-list-item {
transition: all 0.2s ease;
}

View File

@@ -58,7 +58,8 @@ export function useProviderModelConfigDialog(options: UseProviderModelConfigDial
})
function openProviderEdit(provider: any) {
providerEditData.value = JSON.parse(JSON.stringify(provider))
const editableProvider = JSON.parse(JSON.stringify(provider))
providerEditData.value = editableProvider
providerEditOriginalId.value = provider.id
providerEditMode.value = 'edit'
showProviderEditDialog.value = true

View File

@@ -150,11 +150,15 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
}
const mergedModelEntries = computed(() => {
const configuredEntries = (sourceProviders.value || []).map((provider: any) => ({
type: 'configured',
provider,
metadata: getModelMetadata(provider.model) || buildMetadataFromProvider(provider)
}))
const configuredEntries = (sourceProviders.value || []).map((provider: any) => {
const metadata = getModelMetadata(provider.model)
return {
type: 'configured',
provider,
metadata: metadata || buildMetadataFromProvider(provider),
hasModelMetadata: Boolean(metadata)
}
})
const availableEntries = (sortedAvailableModels.value || [])
.filter((item: any) => {
@@ -166,7 +170,8 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
return {
type: 'available',
model: name,
metadata: typeof item === 'object' ? item?.metadata : getModelMetadata(name)
metadata: typeof item === 'object' ? item?.metadata : getModelMetadata(name),
hasModelMetadata: Boolean(typeof item === 'object' ? item?.metadata : getModelMetadata(name))
}
})
@@ -686,6 +691,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
providerTemplates.value = configSchema.value.provider.config_template
}
providerSources.value = response.data.data.provider_sources || []
modelMetadata.value = (response.data.data.model_metadata || {}) as Record<string, any>
providers.value = response.data.data.providers || []
}
} catch (error) {

View File

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

View File

@@ -146,6 +146,17 @@
"manualModelRequired": "Please enter a model ID",
"manualModelExists": "Model already exists",
"configure": "Configure",
"testButton": "Test model",
"metadata": {
"image": "Image input",
"audio": "Audio input",
"toolUse": "Tool use",
"reasoning": "Reasoning",
"context": "{tokens} context window",
"available": "Model metadata supports {capability}",
"enabled": "{capability} enabled",
"supportedDisabled": "Model metadata supports {capability}, but it is not enabled in this provider's modalities."
},
"tooltips": {
"providerId": "Provider ID",
"modelId": "Model ID"

View File

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

View File

@@ -136,6 +136,7 @@
"deleteSuccess": "Модель удалена",
"deleteError": "Ошибка удаления модели",
"testSuccess": "Тест модели «{id}» пройден успешно",
"testSuccessWithLatency": "Тест модели «{id}» пройден успешно, задержка {latency} мс",
"testError": "Тест модели провален",
"searchPlaceholder": "Поиск по имени или ID",
"manualAddButton": "Добавить вручную",
@@ -146,6 +147,17 @@
"manualModelRequired": "Укажите ID модели",
"manualModelExists": "Эта модель уже добавлена",
"configure": "Настроить",
"testButton": "Тестировать модель",
"metadata": {
"image": "Ввод изображений",
"audio": "Ввод аудио",
"toolUse": "Использование инструментов",
"reasoning": "Рассуждение",
"context": "Контекстное окно {tokens}",
"available": "Metadata модели поддерживает {capability}",
"enabled": "{capability} включено",
"supportedDisabled": "Metadata модели поддерживает {capability}, но эта возможность не включена в modalities текущего провайдера."
},
"tooltips": {
"providerId": "ID провайдера",
"modelId": "ID модели"

View File

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

View File

@@ -147,6 +147,17 @@
"manualModelRequired": "请输入模型 ID",
"manualModelExists": "该模型已存在",
"configure": "配置",
"testButton": "测试模型",
"metadata": {
"image": "图像输入",
"audio": "音频输入",
"toolUse": "工具使用",
"reasoning": "推理",
"context": "{tokens} 上下文窗口",
"available": "模型 metadata 支持{capability}",
"enabled": "已启用{capability}",
"supportedDisabled": "模型 metadata 支持{capability},但当前提供商的模型能力未启用。"
},
"tooltips": {
"providerId": "提供商 ID",
"modelId": "模型 ID"

View File

@@ -0,0 +1,102 @@
export interface ProviderModelMetadata {
modalities?: { input?: string[] }
tool_call?: boolean
reasoning?: boolean
limit?: { context?: number }
}
export interface ProviderMetadataSource {
model?: string
modalities?: string[]
max_context_tokens?: number
reasoning?: boolean
}
export interface ProviderCapabilityBadge {
key: string
icon: string
enabled: boolean
tooltip: string
}
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 = metadata?.modalities?.input || []
const providerModalities = provider?.modalities
const modalities = Array.isArray(providerModalities) ? providerModalities : []
const definitions = [
{
key: 'image',
icon: 'mdi-image-outline',
supported: inputs.includes('image'),
enabled: modalities.includes('image'),
label: tm('models.metadata.image')
},
{
key: 'audio',
icon: 'mdi-music-note-outline',
supported: inputs.includes('audio'),
enabled: modalities.includes('audio'),
label: tm('models.metadata.audio')
},
{
key: 'tool_use',
icon: 'mdi-wrench-outline',
supported: Boolean(metadata?.tool_call),
enabled: modalities.includes('tool_use'),
label: tm('models.metadata.toolUse')
},
{
key: 'reasoning',
icon: 'mdi-brain',
supported: Boolean(metadata?.reasoning),
enabled: Boolean(provider?.reasoning),
label: tm('models.metadata.reasoning')
}
]
return definitions
.filter((item) => item.supported || item.enabled)
.map((item) => ({
key: item.key,
icon: item.icon,
enabled: !metadata || item.enabled,
tooltip:
metadata && !item.enabled
? tm('models.metadata.supportedDisabled', { capability: item.label })
: tm('models.metadata.enabled', { capability: item.label })
}))
}
function formatCompactNumber(value: number): string {
const absValue = Math.abs(value)
const rounded = absValue >= 10 ? Math.round(value) : Math.round(value * 10) / 10
return String(rounded).replace(/\.0$/, '')
}

View File

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

View File

@@ -42,7 +42,7 @@ dependencies = [
"qrcode>=8.2",
"quart>=0.20.0",
"python-telegram-bot>=22.6",
"qq-botpy>=1.2.1",
"qq-botpy==1.2.1",
"silk-python>=0.2.6",
"slack-sdk>=3.35.0",
"sqlalchemy[asyncio]>=2.0.41",

View File

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

View File

@@ -2217,6 +2217,51 @@ async def test_batch_delete_sessions_uses_batch_lookup(
assert called["batch_lookup_count"] == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path_template",
[
"/api/chat/get_session?session_id={session_id}",
"/api/v1/chat/sessions/{session_id}",
],
)
async def test_get_chat_session_rejects_session_owned_by_another_user(
app: FastAPIAppAdapter,
authenticated_header: dict,
core_lifecycle_td: AstrBotCoreLifecycle,
path_template: str,
):
test_client = app.test_client()
session_id = f"foreign_get_session_{uuid.uuid4().hex[:8]}"
await core_lifecycle_td.db.create_platform_session(
creator="not_dashboard_user",
platform_id="webchat",
session_id=session_id,
display_name="Foreign Session",
is_group=0,
)
await core_lifecycle_td.platform_message_history_manager.insert(
platform_id="webchat",
user_id=session_id,
content={
"type": "user",
"message": [{"type": "text", "text": "foreign session secret"}],
},
sender_id="not_dashboard_user",
sender_name="not_dashboard_user",
)
response = await test_client.get(
path_template.format(session_id=session_id),
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "error"
assert data["message"] == "Permission denied"
@pytest.mark.asyncio
async def test_plugins(
app: FastAPIAppAdapter,

View File

@@ -592,6 +592,13 @@ def test_file_uri_to_path_supports_standard_and_legacy_posix_file_uris(tmp_path)
assert media_utils.file_uri_to_path(legacy_file_uri) == str(media_path)
def test_file_uri_to_path_preserves_posix_root_for_container_paths():
if os.name != "nt":
assert media_utils.file_uri_to_path("file:///AstrBot/data/cache/image.png") == (
"/AstrBot/data/cache/image.png"
)
def test_from_file_system_uses_pathlib_file_uri(tmp_path):
media_path = tmp_path / "media file.bin"
media_path.write_bytes(b"media")

View File

@@ -1,17 +1,21 @@
import asyncio
import base64
from types import SimpleNamespace
import pytest
from astrbot.core.provider.sources.mimo_api_common import (
MiMoAPIError,
_validate_wav_payload,
build_headers,
prepare_audio_input,
)
from astrbot.core.provider.sources.mimo_stt_api_source import ProviderMiMoSTTAPI
from astrbot.core.provider.sources.mimo_tts_api_source import ProviderMiMoTTSAPI
MIMO_STT_TEST_AUDIO_DATA_URL = "data:audio/wav;base64,ZmFrZQ=="
MIMO_STT_TEST_WAV_HEADER = b"RIFF\x24\x08\x00\x00WAVEfmt "
MIMO_STT_TEST_AUDIO_BASE64 = base64.b64encode(MIMO_STT_TEST_WAV_HEADER).decode()
MIMO_STT_TEST_AUDIO_DATA_URL = f"data:audio/wav;base64,{MIMO_STT_TEST_AUDIO_BASE64}"
def _make_tts_provider(overrides: dict | None = None) -> ProviderMiMoTTSAPI:
@@ -33,7 +37,7 @@ def _make_stt_provider(overrides: dict | None = None) -> ProviderMiMoSTTAPI:
provider_config = {
"id": "test-mimo-stt",
"type": "mimo_stt_api",
"model": "mimo-v2-omni",
"model": "mimo-v2.5-asr",
"api_key": "test-key",
}
if overrides:
@@ -196,7 +200,8 @@ async def test_mimo_tts_get_audio_handles_empty_choices():
@pytest.mark.asyncio
async def test_mimo_stt_payload_includes_audio_only(monkeypatch):
async def test_mimo_stt_asr_model_payload_includes_audio_only(monkeypatch):
"""专用 ASR 模型按官方语音识别文档只传 input_audio不带任何提示词。"""
provider = _make_stt_provider(
{
"mimo-stt-system-prompt": "system prompt",
@@ -248,10 +253,91 @@ async def test_mimo_stt_payload_includes_audio_only(monkeypatch):
]
def test_mimo_stt_default_model_is_v25_asr():
"""mimo-v2-omni 已于 2026-06-30 下线,默认模型应为 mimo-v2.5-asr。"""
provider = ProviderMiMoSTTAPI(
provider_config={
"id": "test-mimo-stt",
"type": "mimo_stt_api",
"api_key": "test-key",
},
provider_settings={},
)
try:
assert provider.model_name == "mimo-v2.5-asr"
finally:
asyncio.run(provider.terminate())
@pytest.mark.asyncio
async def test_mimo_stt_multimodal_model_payload_includes_transcription_prompts(
monkeypatch,
):
"""非 ASR 模型(如 mimo-v2.5)按官方音频理解文档要求携带 system 与 text 指令。"""
provider = _make_stt_provider({"model": "mimo-v2.5"})
captured: dict = {}
async def fake_prepare_audio_input(_audio_source: str):
return MIMO_STT_TEST_AUDIO_DATA_URL, []
class _Response:
status_code = 200
text = '{"choices":[{"message":{"content":"transcribed text"}}]}'
def raise_for_status(self):
return None
def json(self):
return {"choices": [{"message": {"content": "transcribed text"}}]}
async def fake_post(_url, headers=None, json=None):
captured["json"] = json
return _Response()
monkeypatch.setattr(
"astrbot.core.provider.sources.mimo_stt_api_source.prepare_audio_input",
fake_prepare_audio_input,
)
provider.client = SimpleNamespace(post=fake_post)
result = await provider.get_text("/tmp/test.wav")
assert result == "transcribed text"
assert captured["json"]["messages"] == [
{
"role": "system",
"content": (
"You are a speech transcription assistant. "
"Transcribe the spoken content from the audio exactly "
"and return only the transcription text."
),
},
{
"role": "user",
"content": [
{
"type": "input_audio",
"input_audio": {
"data": MIMO_STT_TEST_AUDIO_DATA_URL,
},
},
{
"type": "text",
"text": (
"Please transcribe the content of the audio "
"and return only the transcription text."
),
},
],
},
]
@pytest.mark.asyncio
async def test_mimo_stt_prepare_audio_input_returns_data_url(monkeypatch):
class _ResolvedAudio:
base64_data = "ZmFrZQ=="
base64_data = MIMO_STT_TEST_AUDIO_BASE64
mime_type = "audio/wav"
format = "wav"
@@ -284,6 +370,41 @@ async def test_mimo_stt_prepare_audio_input_returns_data_url(monkeypatch):
assert cleanup_paths == []
@pytest.mark.asyncio
async def test_mimo_stt_prepare_audio_input_rejects_non_wav_payload(monkeypatch):
"""上游 SILK→WAV 转换静默失败时应本地报错,而不是把坏字节发给 API#9113"""
silk_base64 = base64.b64encode(b"\x02#!SILK_V3" + b"\x00" * 16).decode()
class _ResolvedAudio:
base64_data = silk_base64
mime_type = "audio/wav"
format = "wav"
def to_data_url(self):
return f"data:audio/wav;base64,{silk_base64}"
class _Resolver:
def __init__(self, *_args, **_kwargs):
pass
async def to_base64_data(self, **_kwargs):
return _ResolvedAudio()
monkeypatch.setattr(
"astrbot.core.provider.sources.mimo_api_common.MediaResolver",
_Resolver,
)
with pytest.raises(MiMoAPIError, match="SILK"):
await prepare_audio_input("/tmp/test.wav")
def test_mimo_stt_wav_validation_accepts_unpadded_base64_header():
wav_base64 = base64.b64encode(MIMO_STT_TEST_WAV_HEADER).decode().rstrip("=")
_validate_wav_payload(wav_base64, "/tmp/test.wav")
@pytest.mark.asyncio
async def test_mimo_stt_get_text_uses_reasoning_content(monkeypatch):
provider = _make_stt_provider()

View File

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

View File

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

View File

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