mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-07 21:40:14 +08:00
Compare commits
19 Commits
codex/chat
...
codex/even
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58b58aaf31 | ||
|
|
a18054c8b4 | ||
|
|
f683adc74c | ||
|
|
f5b08eb135 | ||
|
|
54124979b0 | ||
|
|
4166d9ab58 | ||
|
|
ba7f8ebfb1 | ||
|
|
ab2502c174 | ||
|
|
56326c7551 | ||
|
|
18e067fab1 | ||
|
|
6627fd53c2 | ||
|
|
25cbd41e08 | ||
|
|
3b8caf37e6 | ||
|
|
85b653b6f0 | ||
|
|
c9eed7b65e | ||
|
|
cc0b347508 | ||
|
|
30426c4f67 | ||
|
|
041fba4df4 | ||
|
|
b43cc6dee0 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": "",
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -1295,8 +1295,7 @@ class PluginManager:
|
||||
star_registry.append(metadata)
|
||||
|
||||
# 禁用/启用插件
|
||||
if metadata.module_path in inactivated_plugins:
|
||||
metadata.activated = False
|
||||
metadata.activated = metadata.module_path not in inactivated_plugins
|
||||
|
||||
# Plugin logo path
|
||||
if os.path.exists(logo_path):
|
||||
@@ -1934,7 +1933,12 @@ class PluginManager:
|
||||
for func_tool in self._iter_plugin_llm_tools(plugin.module_path):
|
||||
func_tool.active = func_tool.name not in inactivated_llm_tools
|
||||
|
||||
await self.reload(plugin_name)
|
||||
success, error = await self.reload(plugin_name)
|
||||
if not success:
|
||||
raise Exception(error or f"插件 {plugin_name} 启用失败。")
|
||||
current_plugin = self.context.get_registered_star(plugin_name)
|
||||
if current_plugin:
|
||||
current_plugin.activated = True
|
||||
|
||||
async def install_plugin_from_file(
|
||||
self, zip_file_path: str, ignore_version_check: bool = False
|
||||
|
||||
216
astrbot/core/utils/event_loop_diagnostics.py
Normal file
216
astrbot/core/utils/event_loop_diagnostics.py
Normal file
@@ -0,0 +1,216 @@
|
||||
import asyncio
|
||||
import faulthandler
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
DEFAULT_LAG_MONITOR_ENABLED = True
|
||||
DEFAULT_LAG_MONITOR_INTERVAL = 5.0
|
||||
DEFAULT_LAG_MONITOR_THRESHOLD = 15.0
|
||||
DEFAULT_WATCHDOG_ENABLED = True
|
||||
DEFAULT_WATCHDOG_INTERVAL = 5.0
|
||||
DEFAULT_WATCHDOG_TIMEOUT = 30.0
|
||||
DEFAULT_WATCHDOG_LOG_RELATIVE_PATH = Path("logs") / "event_loop_watchdog.log"
|
||||
DEFAULT_WATCHDOG_LOG_MAX_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EventLoopDiagnosticSettings:
|
||||
"""Settings for event loop lag and blockage diagnostics.
|
||||
|
||||
Args:
|
||||
lag_monitor_enabled: Whether to log event loop scheduling lag.
|
||||
lag_monitor_interval: Seconds between lag monitor wakeups.
|
||||
lag_monitor_threshold: Minimum lag seconds before logging a warning.
|
||||
watchdog_enabled: Whether to arm the faulthandler watchdog.
|
||||
watchdog_interval: Seconds between faulthandler watchdog refreshes.
|
||||
watchdog_timeout: Seconds without event loop progress before dumping stacks.
|
||||
watchdog_log_path: File that receives faulthandler watchdog output.
|
||||
watchdog_log_max_bytes: Maximum watchdog log bytes before rotation.
|
||||
"""
|
||||
|
||||
lag_monitor_enabled: bool
|
||||
lag_monitor_interval: float
|
||||
lag_monitor_threshold: float
|
||||
watchdog_enabled: bool
|
||||
watchdog_interval: float
|
||||
watchdog_timeout: float
|
||||
watchdog_log_path: Path
|
||||
watchdog_log_max_bytes: int
|
||||
|
||||
|
||||
def _watchdog_log_path() -> Path:
|
||||
"""Resolve the watchdog stack dump log path.
|
||||
|
||||
Returns:
|
||||
Absolute path for watchdog stack dump output.
|
||||
"""
|
||||
return Path(get_astrbot_data_path()) / DEFAULT_WATCHDOG_LOG_RELATIVE_PATH
|
||||
|
||||
|
||||
def load_event_loop_diagnostic_settings() -> EventLoopDiagnosticSettings:
|
||||
"""Load fixed event loop diagnostic settings.
|
||||
|
||||
Returns:
|
||||
Event loop diagnostic settings.
|
||||
"""
|
||||
return EventLoopDiagnosticSettings(
|
||||
lag_monitor_enabled=DEFAULT_LAG_MONITOR_ENABLED,
|
||||
lag_monitor_interval=DEFAULT_LAG_MONITOR_INTERVAL,
|
||||
lag_monitor_threshold=DEFAULT_LAG_MONITOR_THRESHOLD,
|
||||
watchdog_enabled=DEFAULT_WATCHDOG_ENABLED,
|
||||
watchdog_interval=DEFAULT_WATCHDOG_INTERVAL,
|
||||
watchdog_timeout=DEFAULT_WATCHDOG_TIMEOUT,
|
||||
watchdog_log_path=_watchdog_log_path(),
|
||||
watchdog_log_max_bytes=DEFAULT_WATCHDOG_LOG_MAX_BYTES,
|
||||
)
|
||||
|
||||
|
||||
async def monitor_event_loop_lag(
|
||||
*,
|
||||
interval: float = DEFAULT_LAG_MONITOR_INTERVAL,
|
||||
warn_after: float = DEFAULT_LAG_MONITOR_THRESHOLD,
|
||||
) -> None:
|
||||
"""Log a warning when the event loop wakes significantly later than expected.
|
||||
|
||||
Args:
|
||||
interval: Seconds between monitor wakeups.
|
||||
warn_after: Minimum lag seconds before logging a warning.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
expected = loop.time() + interval
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
now = loop.time()
|
||||
lag = now - expected
|
||||
if lag > warn_after:
|
||||
logger.warning(
|
||||
"Event loop lag detected: %.3fs (threshold %.3fs).",
|
||||
lag,
|
||||
warn_after,
|
||||
)
|
||||
expected = now + interval
|
||||
|
||||
|
||||
def _rotate_watchdog_log_file(log_path: Path, max_bytes: int) -> None:
|
||||
"""Rotate the watchdog log when it reaches the configured size limit.
|
||||
|
||||
Args:
|
||||
log_path: Current watchdog log path.
|
||||
max_bytes: Maximum current log size before rotation.
|
||||
"""
|
||||
try:
|
||||
if not log_path.exists() or log_path.stat().st_size < max_bytes:
|
||||
return
|
||||
rotated_path = log_path.with_name(f"{log_path.name}.1")
|
||||
if rotated_path.exists():
|
||||
rotated_path.unlink()
|
||||
log_path.replace(rotated_path)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to rotate event loop watchdog log %s: %s", log_path, e)
|
||||
|
||||
|
||||
def _open_watchdog_log_file(log_path: Path, max_bytes: int) -> TextIO:
|
||||
"""Open the watchdog log file after applying size-based rotation.
|
||||
|
||||
Args:
|
||||
log_path: Current watchdog log path.
|
||||
max_bytes: Maximum current log size before rotation.
|
||||
|
||||
Returns:
|
||||
Writable text file object for faulthandler output.
|
||||
"""
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_rotate_watchdog_log_file(log_path, max_bytes)
|
||||
return log_path.open("a", encoding="utf-8")
|
||||
|
||||
|
||||
async def faulthandler_event_loop_watchdog(
|
||||
*,
|
||||
timeout: float = DEFAULT_WATCHDOG_TIMEOUT,
|
||||
interval: float = DEFAULT_WATCHDOG_INTERVAL,
|
||||
dump_file: TextIO | None = None,
|
||||
dump_path: Path | None = None,
|
||||
max_bytes: int = DEFAULT_WATCHDOG_LOG_MAX_BYTES,
|
||||
) -> None:
|
||||
"""Dump all thread stacks if the event loop is blocked for too long.
|
||||
|
||||
Args:
|
||||
timeout: Seconds without watchdog refresh before faulthandler dumps stacks.
|
||||
interval: Seconds between watchdog refreshes while the event loop is healthy.
|
||||
dump_file: File object that receives faulthandler output.
|
||||
dump_path: Path that receives faulthandler output when dump_file is unset.
|
||||
max_bytes: Maximum current log size before rotation.
|
||||
"""
|
||||
log_path = dump_path or _watchdog_log_path()
|
||||
try:
|
||||
while True:
|
||||
faulthandler.cancel_dump_traceback_later()
|
||||
output: TextIO | None = None
|
||||
should_close = False
|
||||
try:
|
||||
output = dump_file or _open_watchdog_log_file(log_path, max_bytes)
|
||||
should_close = dump_file is None
|
||||
faulthandler.dump_traceback_later(
|
||||
timeout,
|
||||
repeat=False,
|
||||
file=output,
|
||||
)
|
||||
await asyncio.sleep(interval)
|
||||
except Exception as e:
|
||||
logger.warning("Event loop faulthandler watchdog failed: %s", e)
|
||||
await asyncio.sleep(interval)
|
||||
finally:
|
||||
faulthandler.cancel_dump_traceback_later()
|
||||
if should_close and output is not None:
|
||||
output.close()
|
||||
finally:
|
||||
faulthandler.cancel_dump_traceback_later()
|
||||
|
||||
|
||||
def create_event_loop_diagnostic_tasks() -> list[asyncio.Task]:
|
||||
"""Create enabled event loop diagnostic tasks for the current loop.
|
||||
|
||||
Returns:
|
||||
A list of created asyncio tasks.
|
||||
"""
|
||||
settings = load_event_loop_diagnostic_settings()
|
||||
tasks: list[asyncio.Task] = []
|
||||
|
||||
if settings.lag_monitor_enabled:
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
monitor_event_loop_lag(
|
||||
interval=settings.lag_monitor_interval,
|
||||
warn_after=settings.lag_monitor_threshold,
|
||||
),
|
||||
name="event_loop_lag_monitor",
|
||||
)
|
||||
)
|
||||
|
||||
if settings.watchdog_enabled:
|
||||
logger.info(
|
||||
"Event loop faulthandler watchdog enabled: timeout=%.3fs interval=%.3fs. "
|
||||
"If the loop is blocked, Python thread stacks will be written to %s "
|
||||
"(rotates at %d bytes).",
|
||||
settings.watchdog_timeout,
|
||||
settings.watchdog_interval,
|
||||
settings.watchdog_log_path,
|
||||
settings.watchdog_log_max_bytes,
|
||||
)
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
faulthandler_event_loop_watchdog(
|
||||
timeout=settings.watchdog_timeout,
|
||||
interval=settings.watchdog_interval,
|
||||
dump_path=settings.watchdog_log_path,
|
||||
max_bytes=settings.watchdog_log_max_bytes,
|
||||
),
|
||||
name="event_loop_faulthandler_watchdog",
|
||||
)
|
||||
)
|
||||
|
||||
return tasks
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -78,6 +78,21 @@ export interface ProviderSchemaData {
|
||||
config_schema?: OpenConfig;
|
||||
providers?: OpenConfig[];
|
||||
provider_sources?: OpenConfig[];
|
||||
model_metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProviderListData {
|
||||
providers?: OpenConfig[];
|
||||
model_metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProviderByTypeEnvelope extends ApiEnvelope<OpenConfig[]> {
|
||||
model_metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProviderByIdData {
|
||||
provider?: OpenConfig;
|
||||
model_metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProviderSourceModelsData {
|
||||
@@ -496,11 +511,13 @@ export const providerApi = {
|
||||
);
|
||||
},
|
||||
list(params?: ProviderListParams) {
|
||||
return typed<{ providers: OpenConfig[] }>(
|
||||
return typed<ProviderListData>(
|
||||
openApiV1.listProviders({ query: generatedQuery(params) }),
|
||||
);
|
||||
},
|
||||
async listByProviderType(providerType: string): Promise<AxiosResponse<ApiEnvelope<OpenConfig[]>>> {
|
||||
async listByProviderType(
|
||||
providerType: string,
|
||||
): Promise<AxiosResponse<ProviderByTypeEnvelope>> {
|
||||
const capabilities = providerTypeToCapabilities(providerType);
|
||||
if (capabilities.length === 0) {
|
||||
const response = await providerApi.list();
|
||||
@@ -509,6 +526,7 @@ export const providerApi = {
|
||||
data: {
|
||||
...response.data,
|
||||
data: response.data.data.providers || [],
|
||||
model_metadata: response.data.data.model_metadata || {},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -517,11 +535,21 @@ export const providerApi = {
|
||||
capabilities.map((capability) => providerApi.list({ capability })),
|
||||
);
|
||||
const first = responses[0];
|
||||
const modelMetadata = responses.reduce<Record<string, unknown>>(
|
||||
(acc, response) => ({
|
||||
...acc,
|
||||
...(response.data.data.model_metadata || {}),
|
||||
}),
|
||||
{},
|
||||
);
|
||||
return {
|
||||
...first,
|
||||
data: {
|
||||
...first.data,
|
||||
data: responses.flatMap((response) => response.data.data.providers || []),
|
||||
data: responses.flatMap(
|
||||
(response) => response.data.data.providers || [],
|
||||
),
|
||||
model_metadata: modelMetadata,
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -545,7 +573,7 @@ export const providerApi = {
|
||||
);
|
||||
},
|
||||
get(providerId: string, merged = false) {
|
||||
return typed<{ provider: OpenConfig }>(
|
||||
return typed<ProviderByIdData>(
|
||||
openApiV1.getProviderById({
|
||||
query: { provider_id: providerId, merged },
|
||||
}),
|
||||
|
||||
@@ -357,6 +357,7 @@
|
||||
:is-running="
|
||||
Boolean(currSessionId && isSessionRunning(currSessionId))
|
||||
"
|
||||
:token-usage="tokenUsageIndicator"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="currentSession"
|
||||
:reply-to="chatInputReplyTarget"
|
||||
@@ -440,6 +441,7 @@
|
||||
:is-running="
|
||||
Boolean(currSessionId && isSessionRunning(currSessionId))
|
||||
"
|
||||
:token-usage="tokenUsageIndicator"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="currentSession"
|
||||
:reply-to="chatInputReplyTarget"
|
||||
@@ -561,7 +563,7 @@ import {
|
||||
Sun,
|
||||
Trash2,
|
||||
} from "@lucide/vue";
|
||||
import { chatApi } from "@/api/v1";
|
||||
import { chatApi, providerApi } from "@/api/v1";
|
||||
import StyledMenu from "@/components/shared/StyledMenu.vue";
|
||||
import ProjectDialog, {
|
||||
type ProjectFormData,
|
||||
@@ -597,6 +599,12 @@ import {
|
||||
} from "@/i18n/composables";
|
||||
import type { Locale } from "@/i18n/types";
|
||||
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
|
||||
import {
|
||||
contextLimit,
|
||||
formatTokenCount,
|
||||
type ProviderModelMetadata,
|
||||
type ProviderMetadataSource,
|
||||
} from "@/utils/providerMetadata";
|
||||
import { useToast } from "@/utils/toast";
|
||||
|
||||
const props = withDefaults(defineProps<{ chatboxMode?: boolean; active?: boolean }>(), {
|
||||
@@ -652,6 +660,11 @@ const {
|
||||
|
||||
type WorkspaceView = "chat" | "providers";
|
||||
|
||||
interface TokenProviderConfig extends ProviderMetadataSource {
|
||||
id: string;
|
||||
enable?: boolean;
|
||||
}
|
||||
|
||||
const activeWorkspace = ref<WorkspaceView>("chat");
|
||||
const projectDialogOpen = ref(false);
|
||||
const editingProject = ref<Project | null>(null);
|
||||
@@ -670,6 +683,9 @@ const projectSessionsById = ref<Record<string, Session[]>>({});
|
||||
const loadingProjectSessionIds = ref<string[]>([]);
|
||||
const loadingSessions = ref(false);
|
||||
const draft = ref("");
|
||||
const tokenProviderConfigs = ref<TokenProviderConfig[]>([]);
|
||||
const tokenModelMetadata = ref<Record<string, ProviderModelMetadata>>({});
|
||||
const selectedTokenProviderId = ref("");
|
||||
const messagesContainer = ref<HTMLElement | null>(null);
|
||||
const inputRef = ref<InstanceType<typeof ChatInput> | null>(null);
|
||||
const shouldStickToBottom = ref(true);
|
||||
@@ -841,15 +857,58 @@ const chatInputReplyTarget = computed(() =>
|
||||
selectedText: replyPreview(replyTarget.value.id, ""),
|
||||
},
|
||||
);
|
||||
const currentTokenProvider = computed(() => {
|
||||
const selectedProvider = tokenProviderConfigs.value.find(
|
||||
(provider) => provider.id === selectedTokenProviderId.value,
|
||||
);
|
||||
return selectedProvider || tokenProviderConfigs.value[0] || null;
|
||||
});
|
||||
const currentTokenMetadata = computed(() => {
|
||||
const model = currentTokenProvider.value?.model;
|
||||
return model ? tokenModelMetadata.value[model] || null : null;
|
||||
});
|
||||
const latestTokenUsageTotal = computed(() => {
|
||||
for (let index = activeMessages.value.length - 1; index >= 0; index -= 1) {
|
||||
const message = activeMessages.value[index];
|
||||
if (isUserMessage(message)) continue;
|
||||
const usage = message.content?.agentStats?.token_usage;
|
||||
if (!usage) continue;
|
||||
return (
|
||||
readTokenCount(usage.input_other) +
|
||||
readTokenCount(usage.input_cached) +
|
||||
readTokenCount(usage.output)
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
const tokenUsageIndicator = computed(() => {
|
||||
const used = latestTokenUsageTotal.value;
|
||||
const limit = contextLimit(currentTokenProvider.value, currentTokenMetadata.value);
|
||||
if (used <= 0 || limit <= 0) return null;
|
||||
|
||||
const percent = (used / limit) * 100;
|
||||
return {
|
||||
used,
|
||||
limit,
|
||||
percent: Math.min(100, Math.max(0, percent)),
|
||||
tooltip: tm("tokenUsage.tooltip", {
|
||||
used: formatTokenCount(used),
|
||||
limit: formatTokenCount(limit),
|
||||
percent: formatUsagePercent(percent),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function getSelectedProviderSelection() {
|
||||
const inputSelection = inputRef.value?.getCurrentSelection();
|
||||
if (inputSelection?.providerId) {
|
||||
selectedTokenProviderId.value = inputSelection.providerId;
|
||||
return inputSelection;
|
||||
}
|
||||
if (typeof window === "undefined") {
|
||||
return { providerId: "", modelName: "" };
|
||||
}
|
||||
syncSelectedTokenProvider();
|
||||
return {
|
||||
providerId: localStorage.getItem("selectedProvider") || "",
|
||||
modelName: localStorage.getItem("selectedProviderModel") || "",
|
||||
@@ -869,7 +928,7 @@ watch(
|
||||
onMounted(async () => {
|
||||
loadingSessions.value = true;
|
||||
try {
|
||||
await Promise.all([getSessions(), getProjects()]);
|
||||
await Promise.all([getSessions(), getProjects(), loadTokenProviders()]);
|
||||
const routeSessionId = getRouteSessionId();
|
||||
if (routeSessionId === "models") {
|
||||
activeWorkspace.value = "providers";
|
||||
@@ -954,6 +1013,40 @@ function sessionTitle(session: Session) {
|
||||
return session.display_name?.trim() || tm("conversation.newConversation");
|
||||
}
|
||||
|
||||
function syncSelectedTokenProvider() {
|
||||
if (typeof window === "undefined") return;
|
||||
selectedTokenProviderId.value = localStorage.getItem("selectedProvider") || "";
|
||||
}
|
||||
|
||||
async function loadTokenProviders() {
|
||||
syncSelectedTokenProvider();
|
||||
try {
|
||||
const response = await providerApi.listByProviderType("chat_completion");
|
||||
if (response.data.status === "ok") {
|
||||
tokenModelMetadata.value = (
|
||||
(response.data as any).model_metadata || {}
|
||||
) as Record<string, ProviderModelMetadata>;
|
||||
tokenProviderConfigs.value = (
|
||||
(response.data.data || []) as unknown as TokenProviderConfig[]
|
||||
).filter((provider) => provider.enable !== false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load provider context metadata:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function readTokenCount(value: unknown) {
|
||||
const count = Number(value || 0);
|
||||
return Number.isFinite(count) && count > 0 ? count : 0;
|
||||
}
|
||||
|
||||
function formatUsagePercent(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0";
|
||||
if (value >= 10) return String(Math.round(value));
|
||||
if (value >= 1) return String(Math.round(value * 10) / 10);
|
||||
return String(Math.round(value * 100) / 100);
|
||||
}
|
||||
|
||||
async function startNewChat() {
|
||||
showChatWorkspace();
|
||||
selectedProjectId.value = null;
|
||||
|
||||
@@ -242,6 +242,27 @@
|
||||
class="mr-1"
|
||||
width="1.5"
|
||||
/>
|
||||
<v-tooltip
|
||||
v-if="tokenUsageVisible"
|
||||
location="top"
|
||||
max-width="320"
|
||||
>
|
||||
<template #activator="{ props: tokenTooltipProps }">
|
||||
<span
|
||||
v-bind="tokenTooltipProps"
|
||||
class="token-usage-indicator"
|
||||
:style="{ '--token-usage-color': tokenUsageColor }"
|
||||
>
|
||||
<v-progress-circular
|
||||
:model-value="tokenUsagePercent"
|
||||
size="24"
|
||||
width="2.5"
|
||||
class="token-usage-progress"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<span>{{ props.tokenUsage?.tooltip }}</span>
|
||||
</v-tooltip>
|
||||
<!-- <v-btn @click="$emit('openLiveMode')"
|
||||
icon
|
||||
variant="text"
|
||||
@@ -332,6 +353,13 @@ interface ReplyInfo {
|
||||
selectedText?: string;
|
||||
}
|
||||
|
||||
interface TokenUsageInfo {
|
||||
used: number;
|
||||
limit: number;
|
||||
percent: number;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
prompt: string;
|
||||
stagedImagesUrl: string[];
|
||||
@@ -347,6 +375,7 @@ interface Props {
|
||||
replyTo?: ReplyInfo | null;
|
||||
sendShortcut?: "enter" | "shift_enter";
|
||||
showProviderSelector?: boolean;
|
||||
tokenUsage?: TokenUsageInfo | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -357,6 +386,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
replyTo: null,
|
||||
sendShortcut: "shift_enter",
|
||||
showProviderSelector: true,
|
||||
tokenUsage: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -569,6 +599,29 @@ function handleReplyAfterLeave() {
|
||||
|
||||
const { mobile } = useDisplay();
|
||||
|
||||
const tokenUsageVisible = computed(() => {
|
||||
const usage = props.tokenUsage;
|
||||
return Boolean(
|
||||
usage &&
|
||||
Number.isFinite(usage.used) &&
|
||||
Number.isFinite(usage.limit) &&
|
||||
usage.used > 0 &&
|
||||
usage.limit > 0,
|
||||
);
|
||||
});
|
||||
|
||||
const tokenUsagePercent = computed(() => {
|
||||
const percent = props.tokenUsage?.percent || 0;
|
||||
if (!Number.isFinite(percent)) return 0;
|
||||
return Math.min(100, Math.max(0, percent));
|
||||
});
|
||||
|
||||
const tokenUsageColor = computed(() =>
|
||||
isDark.value
|
||||
? "rgba(var(--v-theme-on-surface), 0.82)"
|
||||
: "rgba(var(--v-theme-on-surface), 0.72)",
|
||||
);
|
||||
|
||||
// Auto-resize textarea
|
||||
function autoResize() {
|
||||
const el = inputField.value;
|
||||
@@ -996,6 +1049,31 @@ defineExpose({
|
||||
background: rgba(var(--v-theme-on-surface), 0.04) !important;
|
||||
}
|
||||
|
||||
.token-usage-indicator {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 24px;
|
||||
border-radius: 50%;
|
||||
color: var(--token-usage-color);
|
||||
}
|
||||
|
||||
.token-usage-progress {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.token-usage-progress :deep(.v-progress-circular__underlay) {
|
||||
color: rgba(var(--v-theme-on-surface), 0.18);
|
||||
stroke: currentColor;
|
||||
opacity: 0.24;
|
||||
}
|
||||
|
||||
.token-usage-progress :deep(.v-progress-circular__overlay) {
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.input-outline-control {
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -156,6 +156,9 @@
|
||||
"duration": "Duration",
|
||||
"ttft": "Time to First Token"
|
||||
},
|
||||
"tokenUsage": {
|
||||
"tooltip": "{used} / {limit} tokens ({percent}%)"
|
||||
},
|
||||
"refs": {
|
||||
"title": "References",
|
||||
"sources": "Sources"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -156,6 +156,9 @@
|
||||
"duration": "Время",
|
||||
"ttft": "Время до первого токена"
|
||||
},
|
||||
"tokenUsage": {
|
||||
"tooltip": "{used} / {limit} токенов ({percent}%)"
|
||||
},
|
||||
"refs": {
|
||||
"title": "Ссылки",
|
||||
"sources": "Источники"
|
||||
|
||||
@@ -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 модели"
|
||||
|
||||
@@ -156,6 +156,9 @@
|
||||
"duration": "耗时",
|
||||
"ttft": "首字时间"
|
||||
},
|
||||
"tokenUsage": {
|
||||
"tooltip": "{used} / {limit} tokens({percent}%)"
|
||||
},
|
||||
"refs": {
|
||||
"title": "引用",
|
||||
"sources": "来源"
|
||||
|
||||
@@ -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"
|
||||
|
||||
102
dashboard/src/utils/providerMetadata.ts
Normal file
102
dashboard/src/utils/providerMetadata.ts
Normal 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$/, '')
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ dependencies = [
|
||||
"qrcode>=8.2",
|
||||
"quart>=0.20.0",
|
||||
"python-telegram-bot>=22.6",
|
||||
"qq-botpy>=1.2.1",
|
||||
"qq-botpy==1.2.1",
|
||||
"silk-python>=0.2.6",
|
||||
"slack-sdk>=3.35.0",
|
||||
"sqlalchemy[asyncio]>=2.0.41",
|
||||
|
||||
@@ -29,7 +29,7 @@ pydantic>=2.12.5
|
||||
pydub>=0.25.1
|
||||
pyjwt>=2.10.1
|
||||
python-telegram-bot>=22.6
|
||||
qq-botpy>=1.2.1
|
||||
qq-botpy==1.2.1
|
||||
python-socks>=2.8.0
|
||||
silk-python>=0.2.6
|
||||
slack-sdk>=3.35.0
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
@@ -1981,6 +1982,89 @@ async def test_uninstall_failed_plugin_without_plugin_id_in_record(
|
||||
# --- reload + deactivated plugin regression tests ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("inactivated_plugins", "expected_activated"),
|
||||
[
|
||||
([], True),
|
||||
(["data.plugins.demo_plugin.main"], False),
|
||||
],
|
||||
)
|
||||
async def test_load_syncs_existing_metadata_activation_from_preferences(
|
||||
plugin_manager_pm: PluginManager,
|
||||
monkeypatch,
|
||||
inactivated_plugins: list[str],
|
||||
expected_activated: bool,
|
||||
):
|
||||
"""Existing plugin metadata activation follows persisted preferences."""
|
||||
_clear_star_runtime_state()
|
||||
plugin_name = "demo_plugin"
|
||||
module_path = f"data.plugins.{plugin_name}.main"
|
||||
metadata = star_manager_module.StarMetadata(
|
||||
name=plugin_name,
|
||||
author="AstrBot Team",
|
||||
desc="Demo plugin",
|
||||
version="1.0.0",
|
||||
root_dir_name=plugin_name,
|
||||
module_path=module_path,
|
||||
activated=False,
|
||||
)
|
||||
star_manager_module.star_map[module_path] = metadata
|
||||
star_manager_module.star_registry.append(metadata)
|
||||
preferences = {
|
||||
"inactivated_plugins": inactivated_plugins,
|
||||
"inactivated_llm_tools": [],
|
||||
"alter_cmd": {},
|
||||
}
|
||||
|
||||
async def mock_global_get(key, default=None):
|
||||
return preferences.get(key, default)
|
||||
|
||||
async def mock_import_plugin_with_dependency_recovery(
|
||||
path,
|
||||
module_str,
|
||||
root_dir_name,
|
||||
requirements_path,
|
||||
*,
|
||||
reserved=False,
|
||||
):
|
||||
del module_str, root_dir_name, requirements_path, reserved
|
||||
assert path == module_path
|
||||
return ModuleType(module_path)
|
||||
|
||||
async def mock_sync_command_configs():
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(star_manager_module.sp, "global_get", mock_global_get)
|
||||
monkeypatch.setattr(
|
||||
plugin_manager_pm,
|
||||
"_get_plugin_modules",
|
||||
lambda: [{"pname": plugin_name, "module": "main"}],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugin_manager_pm,
|
||||
"_import_plugin_with_dependency_recovery",
|
||||
mock_import_plugin_with_dependency_recovery,
|
||||
)
|
||||
monkeypatch.setattr(plugin_manager_pm, "_load_plugin_metadata", lambda **_: None)
|
||||
monkeypatch.setattr(
|
||||
star_manager_module,
|
||||
"sync_command_configs",
|
||||
mock_sync_command_configs,
|
||||
)
|
||||
|
||||
try:
|
||||
success, error = await plugin_manager_pm.load(
|
||||
specified_module_path=module_path,
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert metadata.activated is expected_activated
|
||||
finally:
|
||||
_clear_star_runtime_state()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_deactivated_plugin_preserves_tools(
|
||||
plugin_manager_pm: PluginManager, monkeypatch
|
||||
@@ -2156,9 +2240,6 @@ async def test_turn_on_plugin_after_deactivated_reload_reactivates_tools(
|
||||
|
||||
async def mock_reload(plugin_name_arg):
|
||||
assert plugin_name_arg == plugin_name
|
||||
# Simulate what load() does: re-register with activated=True
|
||||
# since it's no longer in inactivated_plugins
|
||||
plugin.activated = True
|
||||
return True, None
|
||||
|
||||
monkeypatch.setattr(star_manager_module.sp, "global_get", mock_global_get)
|
||||
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
from botpy import ConnectionSession
|
||||
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, Plain
|
||||
from astrbot.api.message_components import At, Image, Plain, Reply
|
||||
from astrbot.core.message.message_event_result import (
|
||||
MessageEventResult,
|
||||
ResultContentType,
|
||||
@@ -38,8 +38,11 @@ def _make_group_payload(
|
||||
mentions: list[dict] | None = None,
|
||||
member_openid: str = "member-1",
|
||||
group_openid: str = "group-1",
|
||||
message_type: int | None = None,
|
||||
msg_elements: list[dict] | None = None,
|
||||
message_reference: dict | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
data = {
|
||||
"id": f"event-{message_id}",
|
||||
"d": {
|
||||
"id": message_id,
|
||||
@@ -50,6 +53,13 @@ def _make_group_payload(
|
||||
"attachments": [],
|
||||
},
|
||||
}
|
||||
if message_type is not None:
|
||||
data["d"]["message_type"] = message_type
|
||||
if msg_elements is not None:
|
||||
data["d"]["msg_elements"] = msg_elements
|
||||
if message_reference is not None:
|
||||
data["d"]["message_reference"] = message_reference
|
||||
return data
|
||||
|
||||
|
||||
def _dispatch_group_message(payload: dict) -> tuple[str, botpy.message.GroupMessage]:
|
||||
@@ -108,6 +118,49 @@ async def test_parse_group_message_create_plain_message_has_no_at_component():
|
||||
] == ["plain group message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_group_message_create_quoted_context():
|
||||
_, message = _dispatch_group_message(
|
||||
_make_group_payload(
|
||||
content="answer",
|
||||
message_type=103,
|
||||
message_reference={"message_id": "quoted-1"},
|
||||
msg_elements=[
|
||||
{
|
||||
"content": "quoted text",
|
||||
"attachments": [
|
||||
{
|
||||
"content_type": "image/png",
|
||||
"filename": "quoted.png",
|
||||
"url": "img.example.com/quoted.png",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
message,
|
||||
MessageType.GROUP_MESSAGE,
|
||||
)
|
||||
|
||||
assert getattr(message, "message_type") == 103
|
||||
assert getattr(message, "msg_elements")[0]["content"] == "quoted text"
|
||||
reply = abm.message[0]
|
||||
assert isinstance(reply, Reply)
|
||||
assert reply.id == "quoted-1"
|
||||
assert reply.message_str == "quoted text"
|
||||
assert isinstance(reply.chain[0], Plain)
|
||||
assert reply.chain[0].text == "quoted text"
|
||||
assert isinstance(reply.chain[1], Image)
|
||||
assert reply.chain[1].file == "https://img.example.com/quoted.png"
|
||||
assert abm.message_str == "answer"
|
||||
assert [
|
||||
component.text for component in abm.message if isinstance(component, Plain)
|
||||
][-1] == "answer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_group_message_create_bot_mention_cleans_plain_text():
|
||||
_, message = _dispatch_group_message(
|
||||
|
||||
134
tests/unit/test_event_loop_diagnostics.py
Normal file
134
tests/unit/test_event_loop_diagnostics.py
Normal file
@@ -0,0 +1,134 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.utils import event_loop_diagnostics as diagnostics
|
||||
|
||||
|
||||
def test_load_event_loop_diagnostic_settings_defaults():
|
||||
"""Default settings enable lag monitoring and stack dump watchdog."""
|
||||
settings = diagnostics.load_event_loop_diagnostic_settings()
|
||||
|
||||
assert settings.lag_monitor_enabled is True
|
||||
assert settings.lag_monitor_interval == diagnostics.DEFAULT_LAG_MONITOR_INTERVAL
|
||||
assert settings.lag_monitor_threshold == diagnostics.DEFAULT_LAG_MONITOR_THRESHOLD
|
||||
assert settings.watchdog_enabled is True
|
||||
assert settings.watchdog_interval == diagnostics.DEFAULT_WATCHDOG_INTERVAL
|
||||
assert settings.watchdog_timeout == diagnostics.DEFAULT_WATCHDOG_TIMEOUT
|
||||
assert settings.watchdog_log_max_bytes == diagnostics.DEFAULT_WATCHDOG_LOG_MAX_BYTES
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_loop_diagnostic_tasks_defaults():
|
||||
"""Default diagnostics should create both event loop diagnostic tasks."""
|
||||
tasks = diagnostics.create_event_loop_diagnostic_tasks()
|
||||
|
||||
try:
|
||||
assert [task.get_name() for task in tasks] == [
|
||||
"event_loop_lag_monitor",
|
||||
"event_loop_faulthandler_watchdog",
|
||||
]
|
||||
finally:
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_faulthandler_watchdog_cancels_pending_dump(monkeypatch):
|
||||
"""The faulthandler watchdog should cancel its pending dump on shutdown."""
|
||||
calls = []
|
||||
|
||||
class FakeFaultHandler:
|
||||
def cancel_dump_traceback_later(self):
|
||||
calls.append("cancel")
|
||||
|
||||
def dump_traceback_later(self, timeout, repeat, file):
|
||||
calls.append(("dump", timeout, repeat, file))
|
||||
|
||||
fake_faulthandler = FakeFaultHandler()
|
||||
monkeypatch.setattr(diagnostics, "faulthandler", fake_faulthandler)
|
||||
|
||||
task = asyncio.create_task(
|
||||
diagnostics.faulthandler_event_loop_watchdog(timeout=10, interval=1)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
assert any(isinstance(call, tuple) and call[0] == "dump" for call in calls)
|
||||
assert calls[-1] == "cancel"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_faulthandler_watchdog_writes_rotating_log(tmp_path, monkeypatch):
|
||||
"""The faulthandler watchdog should write to and rotate its log file."""
|
||||
log_path = tmp_path / "logs" / "event_loop_watchdog.log"
|
||||
log_path.parent.mkdir()
|
||||
log_path.write_text("x" * 8, encoding="utf-8")
|
||||
calls = []
|
||||
|
||||
class FakeFaultHandler:
|
||||
def cancel_dump_traceback_later(self):
|
||||
calls.append("cancel")
|
||||
|
||||
def dump_traceback_later(self, timeout, repeat, file):
|
||||
calls.append(("dump", timeout, repeat, file.name))
|
||||
file.write("watchdog dump\n")
|
||||
file.flush()
|
||||
|
||||
fake_faulthandler = FakeFaultHandler()
|
||||
monkeypatch.setattr(diagnostics, "faulthandler", fake_faulthandler)
|
||||
|
||||
task = asyncio.create_task(
|
||||
diagnostics.faulthandler_event_loop_watchdog(
|
||||
timeout=10,
|
||||
interval=1,
|
||||
dump_path=log_path,
|
||||
max_bytes=4,
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
assert log_path.read_text(encoding="utf-8") == "watchdog dump\n"
|
||||
assert log_path.with_name("event_loop_watchdog.log.1").read_text(
|
||||
encoding="utf-8"
|
||||
) == "x" * 8
|
||||
assert any(isinstance(call, tuple) and call[0] == "dump" for call in calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_faulthandler_watchdog_survives_dump_failure(tmp_path, monkeypatch):
|
||||
"""The watchdog should keep running after faulthandler arm failures."""
|
||||
log_path = tmp_path / "event_loop_watchdog.log"
|
||||
armed_again = asyncio.Event()
|
||||
calls = []
|
||||
|
||||
class FakeFaultHandler:
|
||||
def cancel_dump_traceback_later(self):
|
||||
calls.append("cancel")
|
||||
|
||||
def dump_traceback_later(self, timeout, repeat, file):
|
||||
calls.append(("dump", timeout, repeat, file.name))
|
||||
if len([call for call in calls if isinstance(call, tuple)]) == 1:
|
||||
raise RuntimeError("boom")
|
||||
armed_again.set()
|
||||
|
||||
fake_faulthandler = FakeFaultHandler()
|
||||
monkeypatch.setattr(diagnostics, "faulthandler", fake_faulthandler)
|
||||
|
||||
task = asyncio.create_task(
|
||||
diagnostics.faulthandler_event_loop_watchdog(
|
||||
timeout=10,
|
||||
interval=0.01,
|
||||
dump_path=log_path,
|
||||
)
|
||||
)
|
||||
await asyncio.wait_for(armed_again.wait(), timeout=1)
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
dump_calls = [call for call in calls if isinstance(call, tuple)]
|
||||
assert len(dump_calls) >= 2
|
||||
Reference in New Issue
Block a user