Compare commits

..

13 Commits

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

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

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

* fix: standardize qq-botpy version specification in dependencies

---------

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

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

* Potential fix for pull request finding

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

---------

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

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

fixes: #8929
closes: #8911

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

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

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

---------

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

* Update tests/test_media_utils.py

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

---------

Co-authored-by: Weilong Liao <37870767+Soulter@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-07-05 10:12:59 +08:00
29 changed files with 1664 additions and 187 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1297,10 +1297,19 @@ class ProviderConfigService:
for provider in provider_registry:
if provider.default_config_tmpl:
provider_default_tmpl[provider.type] = provider.default_config_tmpl
providers = copy.deepcopy(self.config.get("provider", []))
from astrbot.core.utils.llm_metadata import LLM_METADATAS
model_metadata = {}
for provider in providers:
model_id = provider.get("model")
if isinstance(model_id, str) and model_id in LLM_METADATAS:
model_metadata[model_id] = LLM_METADATAS[model_id]
return {
"config_schema": config_schema,
"providers": self.config.get("provider", []),
"providers": providers,
"provider_sources": self.config.get("provider_sources", []),
"model_metadata": model_metadata,
}
def list_provider_sources(self) -> dict:
@@ -1543,8 +1552,11 @@ class ProviderConfigService:
source_id: str | None = None,
enabled: bool | None = None,
) -> dict:
from astrbot.core.utils.llm_metadata import LLM_METADATAS
provider_type = self._resolve_provider_type(capability)
providers = []
model_metadata = {}
source_provider_type = {
source["id"]: source.get("provider_type", "chat_completion")
for source in self.provider_manager.provider_sources_config
@@ -1562,12 +1574,16 @@ class ProviderConfigService:
if provider_type and effective_type != provider_type:
continue
if provider.get("provider_source_id"):
providers.append(
self.provider_manager.get_merged_provider_config(provider)
provider_response = self.provider_manager.get_merged_provider_config(
provider
)
else:
providers.append(copy.deepcopy(provider))
return {"providers": providers}
provider_response = copy.deepcopy(provider)
model_id = provider_response.get("model")
if isinstance(model_id, str) and model_id in LLM_METADATAS:
model_metadata[model_id] = LLM_METADATAS[model_id]
providers.append(provider_response)
return {"providers": providers, "model_metadata": model_metadata}
def list_providers_for_dashboard_types(
self, provider_type: str | None
@@ -1597,7 +1613,14 @@ class ProviderConfigService:
)
if provider is None:
raise ValueError(f"Provider {provider_id} not found")
return {"provider": provider}
provider_response = copy.deepcopy(provider)
from astrbot.core.utils.llm_metadata import LLM_METADATAS
model_id = provider_response.get("model")
model_metadata = {}
if isinstance(model_id, str) and model_id in LLM_METADATAS:
model_metadata[model_id] = LLM_METADATAS[model_id]
return {"provider": provider_response, "model_metadata": model_metadata}
async def create_provider(self, config: dict, source_id: str | None = None) -> None:
config = copy.deepcopy(config)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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