diff --git a/astrbot/builtin_stars/session_controller/main.py b/astrbot/builtin_stars/session_controller/main.py index 1e78b1e7f..88bee2ae5 100644 --- a/astrbot/builtin_stars/session_controller/main.py +++ b/astrbot/builtin_stars/session_controller/main.py @@ -91,6 +91,8 @@ class Main(Star): controller: SessionController, event: AstrMessageEvent, ) -> None: + if not event.message_str or not event.message_str.strip(): + return event.message_obj.message.insert( 0, Comp.At(qq=event.get_self_id(), name=event.get_self_id()), diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index bc457f865..1c70b40ba 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -906,7 +906,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): ) def _build_tool_requery_context( - self, tool_names: list[str] + self, tool_names: list[str], extra_instruction: str | None = None ) -> list[dict[str, Any]]: """Build contexts for re-querying LLM with param-only tool schemas.""" contexts: list[dict[str, Any]] = [] @@ -921,6 +921,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): + ". Now call the tool(s) with required arguments using the tool schema, " "and follow the existing tool-use rules." ) + if extra_instruction: + instruction = f"{instruction}\n{extra_instruction}" if contexts and contexts[0].get("role") == "system": content = contexts[0].get("content") or "" contexts[0]["content"] = f"{content}\n{instruction}" diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index fbded9f21..faf00bd85 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -21,6 +21,7 @@ from astrbot.core.db.po import ( PlatformSession, PlatformStat, Preference, + ProviderStat, SessionProjectRelation, Stats, ) @@ -105,6 +106,21 @@ class BaseDatabase(abc.ABC): """Get platform statistics within the specified offset in seconds and group by platform_id.""" ... + @abc.abstractmethod + async def insert_provider_stat( + self, + *, + umo: str, + provider_id: str, + provider_model: str | None = None, + conversation_id: str | None = None, + status: str = "completed", + stats: dict | None = None, + agent_type: str = "internal", + ) -> ProviderStat: + """Insert a per-response provider stat record.""" + ... + @abc.abstractmethod async def get_conversations( self, diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index 4b62522c0..fce0705f8 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -38,6 +38,30 @@ class PlatformStat(SQLModel, table=True): ) +class ProviderStat(TimestampMixin, SQLModel, table=True): + """Per-response provider stats for internal agent runs.""" + + __tablename__: str = "provider_stats" + + id: int | None = Field( + default=None, + primary_key=True, + sa_column_kwargs={"autoincrement": True}, + ) + agent_type: str = Field(default="internal", nullable=False, index=True) + status: str = Field(default="completed", nullable=False, index=True) + umo: str = Field(nullable=False, index=True) + conversation_id: str | None = Field(default=None, index=True) + provider_id: str = Field(nullable=False, index=True) + provider_model: str | None = Field(default=None, index=True) + token_input_other: int = Field(default=0, nullable=False) + token_input_cached: int = Field(default=0, nullable=False) + token_output: int = Field(default=0, nullable=False) + start_time: float = Field(default=0.0, nullable=False) + end_time: float = Field(default=0.0, nullable=False) + time_to_first_token: float = Field(default=0.0, nullable=False) + + class ConversationV2(TimestampMixin, SQLModel, table=True): __tablename__: str = "conversations" diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index 3d414b849..42c3a892a 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -23,6 +23,7 @@ from astrbot.core.db.po import ( PlatformSession, PlatformStat, Preference, + ProviderStat, SessionProjectRelation, SQLModel, ) @@ -169,6 +170,51 @@ class SQLiteDatabase(BaseDatabase): ) return list(result.scalars().all()) + async def insert_provider_stat( + self, + *, + umo: str, + provider_id: str, + provider_model: str | None = None, + conversation_id: str | None = None, + status: str = "completed", + stats: dict | None = None, + agent_type: str = "internal", + ) -> ProviderStat: + """Insert a provider stat record for a single agent response.""" + stats = stats or {} + token_usage = stats.get("token_usage", {}) + + token_input_other = int(token_usage.get("input_other", 0) or 0) + token_input_cached = int(token_usage.get("input_cached", 0) or 0) + token_output = int(token_usage.get("output", 0) or 0) + + start_time = float(stats.get("start_time", 0.0) or 0.0) + end_time = float(stats.get("end_time", 0.0) or 0.0) + time_to_first_token = float(stats.get("time_to_first_token", 0.0) or 0.0) + + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + record = ProviderStat( + agent_type=agent_type, + status=status, + umo=umo, + conversation_id=conversation_id, + provider_id=provider_id, + provider_model=provider_model, + token_input_other=token_input_other, + token_input_cached=token_input_cached, + token_output=token_output, + start_time=start_time, + end_time=end_time, + time_to_first_token=time_to_first_token, + ) + session.add(record) + await session.flush() + await session.refresh(record) + return record + # ==== # Conversation Management # ==== diff --git a/astrbot/core/exceptions.py b/astrbot/core/exceptions.py index e637d4930..f10af57ea 100644 --- a/astrbot/core/exceptions.py +++ b/astrbot/core/exceptions.py @@ -7,3 +7,7 @@ class AstrBotError(Exception): class ProviderNotFoundError(AstrBotError): """Raised when a specified provider is not found.""" + + +class EmptyModelOutputError(AstrBotError): + """Raised when the model response contains no usable assistant output.""" diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index e75b837b0..559eb7b15 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -131,7 +131,7 @@ class Record(BaseMessageComponent): # Original text content (e.g. TTS source text), used as caption in fallback scenarios text: str | None = None # 额外 - path: str | None + path: str | None = None def __init__(self, file: str | None, **_) -> None: for k in _: diff --git a/astrbot/core/pipeline/preprocess_stage/stage.py b/astrbot/core/pipeline/preprocess_stage/stage.py index 9dc0b4dbb..56171a211 100644 --- a/astrbot/core/pipeline/preprocess_stage/stage.py +++ b/astrbot/core/pipeline/preprocess_stage/stage.py @@ -75,8 +75,8 @@ class PreProcessStage(Stage): return message_chain = event.get_messages() for idx, component in enumerate(message_chain): - if isinstance(component, Record) and component.url: - path = component.url.removeprefix("file://") + if isinstance(component, Record): + path = await component.convert_to_file_path() retry = 5 for i in range(retry): try: diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index 9ca8c2ce9..78ab38ce2 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -393,7 +393,16 @@ class InternalAgentSubStage(Stage): resp=final_resp.completion_text if final_resp else None, ) - # 检查事件是否被停止,如果被停止则不保存历史记录 + asyncio.create_task( + _record_internal_agent_stats( + event, + req, + agent_runner, + final_resp, + ) + ) + + # 检查事件是否被停止,如果被停止则不保存历史记录 if not event.is_stopped() or agent_runner.was_aborted(): await self._save_to_history( event, diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 9a0deb72b..1267d87a9 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -5,6 +5,8 @@ import logging import os import random import time +import uuid +from pathlib import Path from types import SimpleNamespace from typing import Any, cast @@ -25,6 +27,8 @@ from astrbot.api.platform import ( from astrbot.core.message.components import BaseMessageComponent from astrbot.core.platform.astr_message_event import MessageSesion from astrbot.core.platform.register import register_platform_adapter +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.core.utils.io import download_file from .qqofficial_message_event import QQOfficialMessageEvent @@ -42,7 +46,7 @@ class botClient(Client): async def on_group_at_message_create( self, message: botpy.message.GroupMessage ) -> None: - abm = QQOfficialPlatformAdapter._parse_from_qqofficial( + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.GROUP_MESSAGE, ) @@ -53,7 +57,7 @@ class botClient(Client): # 收到频道消息 async def on_at_message_create(self, message: botpy.message.Message) -> None: - abm = QQOfficialPlatformAdapter._parse_from_qqofficial( + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.GROUP_MESSAGE, ) @@ -66,7 +70,7 @@ class botClient(Client): async def on_direct_message_create( self, message: botpy.message.DirectMessage ) -> None: - abm = QQOfficialPlatformAdapter._parse_from_qqofficial( + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.FRIEND_MESSAGE, ) @@ -76,7 +80,7 @@ class botClient(Client): # 收到 C2C 消息 async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None: - abm = QQOfficialPlatformAdapter._parse_from_qqofficial( + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.FRIEND_MESSAGE, ) @@ -336,7 +340,22 @@ class QQOfficialPlatformAdapter(Platform): return f"https://{url}" @staticmethod - def _append_attachments( + async def _prepare_audio_attachment( + url: str, + filename: str, + ) -> Record: + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + + ext = Path(filename).suffix.lower() + source_ext = ext or ".audio" + source_path = temp_dir / f"qqofficial_{uuid.uuid4().hex}{source_ext}" + await download_file(url, str(source_path)) + + return Record(file=str(source_path), url=str(source_path)) + + @staticmethod + async def _append_attachments( msg: list[BaseMessageComponent], attachments: list | None, ) -> None: @@ -363,7 +382,7 @@ class QQOfficialPlatformAdapter(Platform): or getattr(attachment, "name", None) or "attachment", ) - ext = os.path.splitext(filename)[1].lower() + ext = Path(filename).suffix.lower() image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"} audio_exts = { ".mp3", @@ -381,8 +400,21 @@ class QQOfficialPlatformAdapter(Platform): ".webm", } - if content_type.startswith("audio") or ext in audio_exts: - msg.append(Record.fromURL(url)) + if content_type.startswith("voice") or ext in audio_exts: + try: + msg.append( + await QQOfficialPlatformAdapter._prepare_audio_attachment( + url, + filename, + ) + ) + except Exception as e: + logger.warning( + "[QQOfficial] Failed to prepare audio attachment %s: %s", + url, + e, + ) + msg.append(Record.fromURL(url)) elif content_type.startswith("video") or ext in video_exts: msg.append(Video.fromURL(url)) elif content_type.startswith("image") or ext in image_exts: @@ -432,13 +464,13 @@ class QQOfficialPlatformAdapter(Platform): return re.sub(r"]*>", replace_face, content) @staticmethod - def _parse_from_qqofficial( + async def _parse_from_qqofficial( message: botpy.message.Message | botpy.message.GroupMessage | botpy.message.DirectMessage | botpy.message.C2CMessage, message_type: MessageType, - ): + ) -> AstrBotMessage: abm = AstrBotMessage() abm.type = message_type abm.timestamp = int(time.time()) @@ -463,7 +495,9 @@ class QQOfficialPlatformAdapter(Platform): abm.self_id = "unknown_selfid" msg.append(At(qq="qq_official")) msg.append(Plain(abm.message_str)) - QQOfficialPlatformAdapter._append_attachments(msg, message.attachments) + await QQOfficialPlatformAdapter._append_attachments( + msg, message.attachments + ) abm.message = msg elif isinstance(message, botpy.message.Message) or isinstance( @@ -482,7 +516,9 @@ class QQOfficialPlatformAdapter(Platform): ).strip() ) - QQOfficialPlatformAdapter._append_attachments(msg, message.attachments) + await QQOfficialPlatformAdapter._append_attachments( + msg, message.attachments + ) abm.message = msg abm.message_str = plain_content abm.sender = MessageMember( diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py index bed9502dc..11c1fc846 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -33,7 +33,7 @@ class botClient(Client): async def on_group_at_message_create( self, message: botpy.message.GroupMessage ) -> None: - abm = QQOfficialPlatformAdapter._parse_from_qqofficial( + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.GROUP_MESSAGE, ) @@ -44,7 +44,7 @@ class botClient(Client): # 收到频道消息 async def on_at_message_create(self, message: botpy.message.Message) -> None: - abm = QQOfficialPlatformAdapter._parse_from_qqofficial( + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.GROUP_MESSAGE, ) @@ -57,7 +57,7 @@ class botClient(Client): async def on_direct_message_create( self, message: botpy.message.DirectMessage ) -> None: - abm = QQOfficialPlatformAdapter._parse_from_qqofficial( + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.FRIEND_MESSAGE, ) @@ -67,7 +67,7 @@ class botClient(Client): # 收到 C2C 消息 async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None: - abm = QQOfficialPlatformAdapter._parse_from_qqofficial( + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.FRIEND_MESSAGE, ) diff --git a/astrbot/core/platform/sources/telegram/tg_adapter.py b/astrbot/core/platform/sources/telegram/tg_adapter.py index 00fe6598d..4952e0565 100644 --- a/astrbot/core/platform/sources/telegram/tg_adapter.py +++ b/astrbot/core/platform/sources/telegram/tg_adapter.py @@ -490,9 +490,9 @@ class TelegramPlatformAdapter(Platform): ) path_wav = await convert_audio_to_wav(temp_path, path_wav) - message.message = [ - Comp.Record(file=path_wav, url=path_wav), - ] + record = Comp.Record(file=path_wav, url=path_wav) + record.path = path_wav + message.message = [record] elif update.message.photo: photo = update.message.photo[-1] # get the largest photo diff --git a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py index ab0f090fc..3cb215f19 100644 --- a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py +++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py @@ -593,10 +593,10 @@ class WeixinOCAdapter(Platform): len(str(payload.get("upload_param", ""))), ) upload_param = str(payload.get("upload_param", "")).strip() - if not upload_param: - raise RuntimeError("getuploadurl returned empty upload_param") + upload_full_url = str(payload.get("upload_full_url", "")).strip() encrypted_query_param = await self.client.upload_to_cdn( + upload_full_url, upload_param, file_key, aes_key_hex, diff --git a/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py b/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py index 1e1205614..2d23a9771 100644 --- a/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py +++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py @@ -109,11 +109,21 @@ class WeixinOCClient: async def upload_to_cdn( self, + upload_full_url: str, upload_param: str, file_key: str, aes_key_hex: str, media_path: Path, ) -> str: + if upload_full_url: + cdn_url = upload_full_url + elif upload_param: + cdn_url = self._build_cdn_upload_url(upload_param, file_key) + else: + raise ValueError( + "CDN upload URL missing (need upload_full_url or upload_param)" + ) + raw_data = await asyncio.to_thread(media_path.read_bytes) logger.debug( "weixin_oc(%s): prepare CDN upload file=%s size=%s md5=%s filekey=%s", @@ -136,7 +146,6 @@ class WeixinOCClient: await self.ensure_http_session() assert self._http_session is not None timeout = aiohttp.ClientTimeout(total=self.api_timeout_ms / 1000) - cdn_url = self._build_cdn_upload_url(upload_param, file_key) async with self._http_session.post( cdn_url, diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py index 679fc9504..daa07e320 100644 --- a/astrbot/core/provider/provider.py +++ b/astrbot/core/provider/provider.py @@ -2,7 +2,7 @@ import abc import asyncio import os from collections.abc import AsyncGenerator -from typing import TypeAlias, Union, cast +from typing import Literal, TypeAlias, Union, cast import aiofiles import anyio @@ -107,6 +107,7 @@ class Provider(AbstractProvider): tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None, model: str | None = None, extra_user_content_parts: list[ContentPart] | None = None, + tool_choice: Literal["auto", "required"] = "auto", **kwargs, ) -> LLMResponse: """获得 LLM 的文本对话结果。会使用当前的模型进行对话。 @@ -116,9 +117,11 @@ class Provider(AbstractProvider): session_id: 会话 ID(此属性已经被废弃) image_urls: 图片 URL 列表 tools: tool set +<<<<<<< HEAD contexts: 上下文,和 prompt 二选一使用 tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling - extra_user_content_parts: 额外的内容块列表,用于在用户消息后添加额外的文本块(如系统提醒、指令等) + tool_choice: 工具调用策略,`auto` 表示由模型自行决定,`required` 表示要求模型必须调用工具 + extra_user_content_parts: 额外的内容块列表,用于在用户消息后添加额外的文本块(如系统提醒。指令等) kwargs: 其他参数 Notes: @@ -138,6 +141,7 @@ class Provider(AbstractProvider): system_prompt: str | None = None, tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None, model: str | None = None, + tool_choice: Literal["auto", "required"] = "auto", **kwargs, ) -> AsyncGenerator[LLMResponse, None]: """获得 LLM 的流式文本对话结果。会使用当前的模型进行对话。在生成的最后会返回一次完整的结果。 @@ -147,8 +151,10 @@ class Provider(AbstractProvider): session_id: 会话 ID(此属性已经被废弃) image_urls: 图片 URL 列表 tools: tool set +<<<<<<< HEAD contexts: 上下文,和 prompt 二选一使用 tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling + tool_choice: 工具调用策略,`auto` 表示由模型自行决定,`required` 表示要求模型必须调用工具 kwargs: 其他参数 Notes: diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 014d416d6..e6eb3ffd2 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -1,6 +1,7 @@ import base64 import json from collections.abc import AsyncGenerator +from typing import Literal import aiofiles import anthropic @@ -13,6 +14,7 @@ from anthropic.types.usage import Usage from astrbot import logger from astrbot.api.provider import Provider from astrbot.core.agent.message import ContentPart, ImageURLPart, TextPart +from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.provider.register import register_provider_adapter @@ -28,6 +30,23 @@ from astrbot.core.utils.network_utils import ( "Anthropic Claude API 提供商适配器", ) class ProviderAnthropic(Provider): + @staticmethod + def _ensure_usable_response( + llm_response: LLMResponse, + *, + completion_id: str | None = None, + stop_reason: str | None = None, + ) -> None: + has_text_output = bool((llm_response.completion_text or "").strip()) + has_reasoning_output = bool(llm_response.reasoning_content.strip()) + has_tool_output = bool(llm_response.tools_call_args) + if has_text_output or has_reasoning_output or has_tool_output: + return + raise EmptyModelOutputError( + "Anthropic completion has no usable output. " + f"completion_id={completion_id}, stop_reason={stop_reason}" + ) + @staticmethod def _normalize_custom_headers(provider_config: dict) -> dict[str, str] | None: custom_headers = provider_config.get("custom_headers", {}) @@ -258,6 +277,11 @@ class ProviderAnthropic(Provider): if tools: if tool_list := tools.get_func_desc_anthropic_style(): payloads["tools"] = tool_list + payloads["tool_choice"] = { + "type": "any" + if payloads.get("tool_choice") == "required" + else "auto" + } extra_body = self.provider_config.get("custom_extra_body", {}) @@ -283,7 +307,9 @@ class ProviderAnthropic(Provider): logger.debug(f"completion: {completion}") if len(completion.content) == 0: - raise Exception("API 返回的 completion 为空。") + raise EmptyModelOutputError( + f"Anthropic completion is empty. completion_id={completion.id}" + ) llm_response = LLMResponse(role="assistant") @@ -311,10 +337,9 @@ class ProviderAnthropic(Provider): if not llm_response.completion_text and not llm_response.tools_call_args: # Guard clause: raise early if no valid content at all if not llm_response.reasoning_content: - raise ValueError( - f"Anthropic API returned unparsable completion: " - f"no text, tool_use, or thinking content found. " - f"Completion: {completion}" + raise EmptyModelOutputError( + "Anthropic completion has no usable output. " + f"completion_id={completion.id}, stop_reason={completion.stop_reason}" ) # We have reasoning content (ThinkingBlock) - this is valid @@ -324,6 +349,11 @@ class ProviderAnthropic(Provider): ) llm_response.completion_text = "" # Ensure empty string, not None + self._ensure_usable_response( + llm_response, + completion_id=completion.id, + stop_reason=completion.stop_reason, + ) return llm_response async def _query_stream( @@ -334,6 +364,11 @@ class ProviderAnthropic(Provider): if tools: if tool_list := tools.get_func_desc_anthropic_style(): payloads["tools"] = tool_list + payloads["tool_choice"] = { + "type": "any" + if payloads.get("tool_choice") == "required" + else "auto" + } # 用于累积工具调用信息 tool_use_buffer = {} @@ -470,6 +505,11 @@ class ProviderAnthropic(Provider): final_response.tools_call_name = [call["name"] for call in final_tool_calls] final_response.tools_call_ids = [call["id"] for call in final_tool_calls] + self._ensure_usable_response( + final_response, + completion_id=id, + stop_reason=None, + ) yield final_response async def text_chat( @@ -483,6 +523,7 @@ class ProviderAnthropic(Provider): tool_calls_result=None, model=None, extra_user_content_parts=None, + tool_choice: Literal["auto", "required"] = "auto", **kwargs, ) -> LLMResponse: if contexts is None: @@ -516,6 +557,8 @@ class ProviderAnthropic(Provider): model = model or self.get_model() payloads = {"messages": new_messages, "model": model} + if func_tool and not func_tool.empty(): + payloads["tool_choice"] = tool_choice # Anthropic has a different way of handling system prompts if system_prompt: @@ -540,6 +583,7 @@ class ProviderAnthropic(Provider): tool_calls_result=None, model=None, extra_user_content_parts=None, + tool_choice: Literal["auto", "required"] = "auto", **kwargs, ): if contexts is None: @@ -572,6 +616,8 @@ class ProviderAnthropic(Provider): model = model or self.get_model() payloads = {"messages": new_messages, "model": model} + if func_tool and not func_tool.empty(): + payloads["tool_choice"] = tool_choice # Anthropic has a different way of handling system prompts if system_prompt: diff --git a/astrbot/core/provider/sources/bailian_rerank_source.py b/astrbot/core/provider/sources/bailian_rerank_source.py index a58176a5f..c28936c90 100644 --- a/astrbot/core/provider/sources/bailian_rerank_source.py +++ b/astrbot/core/provider/sources/bailian_rerank_source.py @@ -32,6 +32,8 @@ class BailianNetworkError(BailianRerankError): class BailianRerankProvider(RerankProvider): """阿里云百炼文本重排序适配器.""" + QWEN3_RERANK_MODEL = "qwen3-rerank" + def __init__(self, provider_config: dict, provider_settings: dict) -> None: super().__init__(provider_config, provider_settings) self.provider_config = provider_config @@ -82,19 +84,35 @@ class BailianRerankProvider(RerankProvider): Returns: 请求载荷字典 """ + normalized_model = self.model.strip().lower() + normalized_top_n = top_n if top_n is not None and top_n > 0 else None + + # qwen3-rerank follows a model-specific payload: + # query/documents/top_n/instruct should be at the top level. + if normalized_model == self.QWEN3_RERANK_MODEL: + payload = { + "model": self.model, + "query": query, + "documents": documents, + } + if normalized_top_n is not None: + payload["top_n"] = normalized_top_n + if self.instruct: + payload["instruct"] = self.instruct + if self.return_documents: + logger.warning( + "qwen3-rerank does not support return_documents; " + "this option will be ignored." + ) + return payload + base = {"model": self.model, "input": {"query": query, "documents": documents}} params = { k: v for k, v in [ - ("top_n", top_n if top_n is not None and top_n > 0 else None), + ("top_n", normalized_top_n), ("return_documents", True if self.return_documents else None), - ( - "instruct", - self.instruct - if self.instruct and self.model == "qwen3-rerank" - else None, - ), ] if v is not None } diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index 9614f81fe..225ea28c3 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -4,7 +4,7 @@ import json import logging import random from collections.abc import AsyncGenerator -from typing import ClassVar, cast +from typing import ClassVar, Literal, cast import aiofiles from google import genai @@ -15,6 +15,7 @@ import astrbot.core.message.components as Comp from astrbot import logger from astrbot.api.provider import Provider from astrbot.core.agent.message import ContentPart, ImageURLPart, TextPart +from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.message.message_event_result import MessageChain from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.func_tool_manager import ToolSet @@ -131,6 +132,7 @@ class ProviderGoogleGenAI(Provider): self, payloads: dict, tools: ToolSet | None = None, + tool_choice: Literal["auto", "required"] = "auto", system_instruction: str | None = None, modalities: list[str] | None = None, temperature: float = 0.7, @@ -205,6 +207,18 @@ class ProviderGoogleGenAI(Provider): types.Tool(function_declarations=func_desc["function_declarations"]), ] + tool_config = None + if tools and tool_list: + tool_config = types.ToolConfig( + function_calling_config=types.FunctionCallingConfig( + mode=( + types.FunctionCallingConfigMode.ANY + if tool_choice == "required" + else types.FunctionCallingConfigMode.AUTO + ) + ) + ) + # oper thinking config thinking_config = None if model_name in [ @@ -270,6 +284,7 @@ class ProviderGoogleGenAI(Provider): seed=payloads.get("seed"), response_modalities=modalities, tools=cast(types.ToolListUnion | None, tool_list), + tool_config=tool_config, safety_settings=self.safety_settings if self.safety_settings else None, thinking_config=thinking_config, automatic_function_calling=types.AutomaticFunctionCallingConfig( @@ -428,6 +443,23 @@ class ProviderGoogleGenAI(Provider): output=usage_metadata.candidates_token_count or 0, ) + @staticmethod + def _ensure_usable_response( + llm_response: LLMResponse, + *, + response_id: str | None = None, + finish_reason: str | None = None, + ) -> None: + has_text_output = bool((llm_response.completion_text or "").strip()) + has_reasoning_output = bool(llm_response.reasoning_content.strip()) + has_tool_output = bool(llm_response.tools_call_args) + if has_text_output or has_reasoning_output or has_tool_output: + return + raise EmptyModelOutputError( + "Gemini completion has no usable output. " + f"response_id={response_id}, finish_reason={finish_reason}" + ) + def _process_content_parts( self, candidate: types.Candidate, @@ -436,7 +468,11 @@ class ProviderGoogleGenAI(Provider): """处理内容部分并构建消息链""" if not candidate.content: logger.warning(f"收到的 candidate.content 为空: {candidate}") - raise Exception("API 返回的 candidate.content 为空。") +<<<<<<< HEAD + raise EmptyModelOutputError( + "Gemini candidate content is empty. " + f"finish_reason={candidate.finish_reason}" + ) finish_reason = candidate.finish_reason result_parts: list[types.Part] | None = candidate.content.parts @@ -458,7 +494,11 @@ class ProviderGoogleGenAI(Provider): if not result_parts: logger.warning(f"收到的 candidate.content.parts 为空: {candidate}") - raise Exception("API 返回的 candidate.content.parts 为空。") +<<<<<<< HEAD + raise EmptyModelOutputError( + "Gemini candidate content parts are empty. " + f"finish_reason={candidate.finish_reason}" + ) # 提取 reasoning content reasoning = self._extract_reasoning_content(candidate) @@ -509,7 +549,14 @@ class ProviderGoogleGenAI(Provider): if ts := part.thought_signature: # only keep the last thinking signature llm_response.reasoning_signature = base64.b64encode(ts).decode("utf-8") - return MessageChain(chain=chain) + chain_result = MessageChain(chain=chain) + llm_response.result_chain = chain_result + self._ensure_usable_response( + llm_response, + response_id=None, + finish_reason=str(finish_reason) if finish_reason is not None else None, + ) + return chain_result async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: """非流式请求 Gemini API""" @@ -533,6 +580,7 @@ class ProviderGoogleGenAI(Provider): config = await self._prepare_query_config( payloads, tools, + payloads.get("tool_choice", "auto"), system_instruction, modalities, temperature, @@ -615,6 +663,7 @@ class ProviderGoogleGenAI(Provider): config = await self._prepare_query_config( payloads, tools, + payloads.get("tool_choice", "auto"), system_instruction, streaming=True, ) @@ -711,9 +760,12 @@ class ProviderGoogleGenAI(Provider): final_response.result_chain = MessageChain( chain=[Comp.Plain(accumulated_text)], ) - elif not final_response.result_chain: - # If no text was accumulated and no final response was set, provide empty space - final_response.result_chain = MessageChain(chain=[Comp.Plain(" ")]) + + self._ensure_usable_response( + final_response, + response_id=getattr(final_response, "id", None), + finish_reason=None, + ) yield final_response @@ -728,6 +780,7 @@ class ProviderGoogleGenAI(Provider): tool_calls_result=None, model=None, extra_user_content_parts=None, + tool_choice: Literal["auto", "required"] = "auto", **kwargs, ) -> LLMResponse: if contexts is None: @@ -758,6 +811,8 @@ class ProviderGoogleGenAI(Provider): model = model or self.get_model() payloads = {"messages": context_query, "model": model} + if func_tool and not func_tool.empty(): + payloads["tool_choice"] = tool_choice retry = 10 keys = self.api_keys.copy() @@ -783,6 +838,7 @@ class ProviderGoogleGenAI(Provider): tool_calls_result=None, model=None, extra_user_content_parts=None, + tool_choice: Literal["auto", "required"] = "auto", **kwargs, ) -> AsyncGenerator[LLMResponse, None]: if contexts is None: @@ -813,6 +869,8 @@ class ProviderGoogleGenAI(Provider): model = model or self.get_model() payloads = {"messages": context_query, "model": model} + if func_tool and not func_tool.empty(): + payloads["tool_choice"] = tool_choice retry = 10 keys = self.api_keys.copy() diff --git a/astrbot/core/provider/sources/gsvi_tts_source.py b/astrbot/core/provider/sources/gsvi_tts_source.py index 14b010382..edd934621 100644 --- a/astrbot/core/provider/sources/gsvi_tts_source.py +++ b/astrbot/core/provider/sources/gsvi_tts_source.py @@ -1,59 +1,76 @@ -import os -import urllib.parse -import uuid - -import aiofiles -import aiohttp - -from astrbot.core.provider.entities import ProviderType -from astrbot.core.provider.provider import TTSProvider -from astrbot.core.provider.register import register_provider_adapter -from astrbot.core.utils.astrbot_path import get_astrbot_temp_path - - -@register_provider_adapter( - "gsvi_tts_api", - "GSVI TTS API", - provider_type=ProviderType.TEXT_TO_SPEECH, -) -class ProviderGSVITTS(TTSProvider): - def __init__( - self, - provider_config: dict, - provider_settings: dict, - ) -> None: - super().__init__(provider_config, provider_settings) - self.api_base = provider_config.get("api_base", "http://127.0.0.1:5000") - self.api_base = self.api_base.removesuffix("/") - self.character = provider_config.get("character") - self.emotion = provider_config.get("emotion") - - async def get_audio(self, text: str) -> str: - temp_dir = get_astrbot_temp_path() - path = os.path.join(temp_dir, f"gsvi_tts_{uuid.uuid4()}.wav") - params = {"text": text} - - if self.character: - params["character"] = self.character - if self.emotion: - params["emotion"] = self.emotion - - query_parts = [] - for key, value in params.items(): - encoded_value = urllib.parse.quote(str(value)) - query_parts.append(f"{key}={encoded_value}") - - url = f"{self.api_base}/tts?{'&'.join(query_parts)}" - - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - if response.status == 200: - async with aiofiles.open(path, "wb") as f: - await f.write(await response.read()) - else: - error_text = await response.text() - raise Exception( - f"GSVI TTS API 请求失败,状态码: {response.status},错误: {error_text}", - ) - - return path +import uuid +from pathlib import Path + +import aiofiles +import aiohttp + +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import TTSProvider +from astrbot.core.provider.register import register_provider_adapter +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path + + +@register_provider_adapter( + "gsvi_tts_api", + "GSVI TTS API", + provider_type=ProviderType.TEXT_TO_SPEECH, +) +class ProviderGSVITTS(TTSProvider): + def __init__( + self, + provider_config: dict, + provider_settings: dict, + ) -> None: + super().__init__(provider_config, provider_settings) + self.api_key = provider_config.get("api_key", "") + self.api_base = provider_config.get("api_base", "http://127.0.0.1:8000") + self.api_base = self.api_base.removesuffix("/") + self.version = provider_config.get("version", "v4") + self.character = provider_config.get("character") + self.prompt_text_lang = provider_config.get("prompt_text_lang", "中文") + self.emotion = provider_config.get("emotion", "默认") + self.text_lang = provider_config.get("text_lang", "中文") + + async def get_audio(self, text: str) -> str: + temp_dir = get_astrbot_temp_path() + path = Path(temp_dir) / f"gsvi_tts_{uuid.uuid4()}.wav" + url = f"{self.api_base}/infer_single" + + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + data = { + "dl_url": self.api_base, + "version": self.version, + "model_name": self.character, + "prompt_text_lang": self.prompt_text_lang, + "emotion": self.emotion, + "text": text, + "text_lang": self.text_lang, + } + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=data, headers=headers) as response: + if response.status == 200: + resp_json = await response.json() + msg = resp_json.get("msg") + audio_url = resp_json.get("audio_url") + if not msg or msg != "合成成功": + raise Exception(f"GSVI TTS API 合成失败: {msg}") + async with session.get(audio_url) as audio_response: + if audio_response.status == 200: + with open(path, "wb") as f: + f.write(await audio_response.read()) + else: + error_text = await audio_response.text() + raise Exception( + f"GSVI TTS API 下载音频失败,状态码: {audio_response.status},错误: {error_text}", + ) + else: + error_text = await response.text() + raise Exception( + f"GSVI TTS API 请求失败,状态码: {response.status},错误: {error_text}", + ) + + return str(path) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 05e48ae40..6b37eb4c3 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -26,6 +26,7 @@ from astrbot import logger from astrbot.api.provider import Provider from astrbot.core.agent.message import ContentPart, ImageURLPart, Message, TextPart from astrbot.core.agent.tool import ToolSet +from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.message.message_event_result import MessageChain from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult from astrbot.core.provider.register import register_provider_adapter @@ -435,6 +436,7 @@ class ProviderOpenAIOfficial(Provider): ) if tool_list: payloads["tools"] = tool_list + payloads["tool_choice"] = payloads.get("tool_choice", "auto") # 不在默认参数中的参数放在 extra_body 中 extra_body = {} @@ -485,6 +487,7 @@ class ProviderOpenAIOfficial(Provider): ) if tool_list: payloads["tools"] = tool_list + payloads["tool_choice"] = payloads.get("tool_choice", "auto") # 不在默认参数中的参数放在 extra_body 中 extra_body = {} @@ -696,7 +699,10 @@ class ProviderOpenAIOfficial(Provider): llm_response = LLMResponse("assistant") if not completion.choices: - raise Exception("API 返回的 completion 为空。") +<<<<<<< HEAD + raise EmptyModelOutputError( + f"OpenAI completion has no choices. response_id={completion.id}" + ) choice = completion.choices[0] # parse the text completion @@ -714,6 +720,10 @@ class ProviderOpenAIOfficial(Provider): # Also clean up orphan tags that may leak from some models completion_text = re.sub(r"\s*$", "", completion_text).strip() llm_response.result_chain = MessageChain().message(completion_text) + elif refusal := getattr(choice.message, "refusal", None): + refusal_text = self._normalize_content(refusal) + if refusal_text: + llm_response.result_chain = MessageChain().message(refusal_text) # parse the reasoning content if any # the priority is higher than the tag extraction @@ -766,9 +776,19 @@ class ProviderOpenAIOfficial(Provider): raise Exception( "API 返回的 completion 由于内容安全过滤被拒绝(非 AstrBot)。", ) - if llm_response.completion_text is None and not llm_response.tools_call_args: - logger.error(f"API 返回的 completion 无法解析:{completion}。") - raise Exception(f"API 返回的 completion 无法解析:{completion}。") +<<<<<<< HEAD + has_text_output = bool((llm_response.completion_text or "").strip()) + has_reasoning_output = bool(llm_response.reasoning_content.strip()) + if ( + not has_text_output + and not has_reasoning_output + and not llm_response.tools_call_args + ): + logger.error(f"OpenAI completion has no usable output: {completion}.") + raise EmptyModelOutputError( + "OpenAI completion has no usable output. " + f"response_id={completion.id}, finish_reason={choice.finish_reason}" + ) llm_response.raw_completion = completion llm_response.id = completion.id @@ -972,6 +992,7 @@ class ProviderOpenAIOfficial(Provider): tool_calls_result=None, model=None, extra_user_content_parts=None, + tool_choice: Literal["auto", "required"] = "auto", **kwargs, ) -> LLMResponse: payloads, context_query = await self._prepare_chat_payload( @@ -984,6 +1005,8 @@ class ProviderOpenAIOfficial(Provider): extra_user_content_parts=extra_user_content_parts, **kwargs, ) + if func_tool and not func_tool.empty(): + payloads["tool_choice"] = tool_choice llm_response = None max_retries = 10 @@ -1039,6 +1062,7 @@ class ProviderOpenAIOfficial(Provider): system_prompt=None, tool_calls_result=None, model=None, + tool_choice: Literal["auto", "required"] = "auto", **kwargs, ) -> AsyncGenerator[LLMResponse, None]: """流式对话,与服务商交互并逐步返回结果""" @@ -1051,6 +1075,8 @@ class ProviderOpenAIOfficial(Provider): model=model, **kwargs, ) + if func_tool and not func_tool.empty(): + payloads["tool_choice"] = tool_choice max_retries = 10 available_api_keys = self.api_keys.copy() diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index 67fef096a..d0b60de1a 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -566,6 +566,8 @@ class SkillManager: if not zipfile.is_zipfile(zip_path): raise ValueError("Uploaded file is not a valid zip archive.") + installed_skills = [] + with zipfile.ZipFile(zip_path) as zf: names = [ name @@ -591,34 +593,6 @@ class SkillManager: ): raise ValueError("Invalid skill name.") - if root_mode: - archive_hint = _normalize_skill_name( - archive_skill_name or zip_path_obj.stem - ) - if not archive_hint or not _SKILL_NAME_RE.fullmatch(archive_hint): - raise ValueError("Invalid skill name.") - skill_name = archive_hint - else: - top_dirs = { - PurePosixPath(name).parts[0] for name in file_names if name.strip() - } - if len(top_dirs) != 1: - raise ValueError( - "Zip archive must contain a single top-level folder." - ) - archive_root_name = next(iter(top_dirs)) - archive_root_name_normalized = _normalize_skill_name(archive_root_name) - if archive_root_name in {".", "..", ""} or not _SKILL_NAME_RE.fullmatch( - archive_root_name_normalized - ): - raise ValueError("Invalid skill folder name.") - if archive_skill_name: - if not _SKILL_NAME_RE.fullmatch(archive_skill_name): - raise ValueError("Invalid skill name.") - skill_name = archive_skill_name - else: - skill_name = archive_root_name_normalized - for name in names: if not name: continue @@ -627,20 +601,38 @@ class SkillManager: parts = PurePosixPath(name).parts if ".." in parts: raise ValueError("Zip archive contains invalid relative paths.") - if (not root_mode) and parts and parts[0] != archive_root_name: - raise ValueError( - "Zip archive contains unexpected top-level entries." - ) - if root_mode: - if "SKILL.md" not in file_names and "skill.md" not in file_names: - raise ValueError("SKILL.md not found in the skill folder.") - else: - if ( - f"{archive_root_name}/SKILL.md" not in file_names - and f"{archive_root_name}/skill.md" not in file_names - ): - raise ValueError("SKILL.md not found in the skill folder.") + if not root_mode and not overwrite: + top_dirs = {PurePosixPath(n).parts[0] for n in file_names if n.strip()} + conflict_dirs: list[str] = [] + for src_dir_name in top_dirs: + if ( + f"{src_dir_name}/SKILL.md" not in file_names + and f"{src_dir_name}/skill.md" not in file_names + ): + continue + + candidate_name = _normalize_skill_name(src_dir_name) + if not candidate_name or not _SKILL_NAME_RE.fullmatch( + candidate_name + ): + continue + + if archive_skill_name and len(top_dirs) == 1: + target_name = archive_skill_name + else: + target_name = candidate_name + + dest_dir = Path(self.skills_root) / target_name + if dest_dir.exists(): + conflict_dirs.append(str(dest_dir)) + + if conflict_dirs: + raise FileExistsError( + "One or more skills from the archive already exist and " + "overwrite=False. No skills were installed. Conflicting " + f"paths: {', '.join(conflict_dirs)}" + ) with tempfile.TemporaryDirectory(dir=str(astrbot_paths.temp)) as tmp_dir: for member in zf.infolist(): @@ -648,21 +640,78 @@ class SkillManager: if not member_name or _is_ignored_zip_entry(member_name): continue zf.extract(member, tmp_dir) - src_dir = ( - Path(tmp_dir) if root_mode else Path(tmp_dir) / archive_root_name - ) - normalized_path = _normalize_skill_markdown_path(src_dir) - if normalized_path is None: - raise ValueError("SKILL.md not found in the skill folder.") - _normalize_skill_markdown_path(src_dir) - if not src_dir.exists(): - raise ValueError("Skill folder not found after extraction.") - dest_dir = Path(self.skills_root) / skill_name - if dest_dir.exists(): - if not overwrite: - raise FileExistsError("Skill already exists.") - shutil.rmtree(dest_dir) - shutil.move(str(src_dir), str(dest_dir)) - self.set_skill_active(skill_name, True) - return skill_name + if root_mode: + archive_hint = _normalize_skill_name( + archive_skill_name or zip_path_obj.stem + ) + if not archive_hint or not _SKILL_NAME_RE.fullmatch(archive_hint): + raise ValueError("Invalid skill name.") + skill_name = archive_hint + + src_dir = Path(tmp_dir) + normalized_path = _normalize_skill_markdown_path(src_dir) + if normalized_path is None: + raise ValueError( + "SKILL.md not found in the root of the zip archive." + ) + + dest_dir = Path(self.skills_root) / skill_name + if dest_dir.exists() and overwrite: + shutil.rmtree(dest_dir) + elif dest_dir.exists() and not overwrite: + raise FileExistsError(f"Skill {skill_name} already exists.") + + shutil.move(str(src_dir), str(dest_dir)) + self.set_skill_active(skill_name, True) + installed_skills.append(skill_name) + + else: + top_dirs = { + PurePosixPath(n).parts[0] for n in file_names if n.strip() + } + + for archive_root_name in top_dirs: + archive_root_name_normalized = _normalize_skill_name( + archive_root_name + ) + + if ( + f"{archive_root_name}/SKILL.md" not in file_names + and f"{archive_root_name}/skill.md" not in file_names + ): + continue + + if archive_root_name in {".", "..", ""} or not ( + _SKILL_NAME_RE.fullmatch(archive_root_name_normalized) + ): + continue + + if archive_skill_name and len(top_dirs) == 1: + skill_name = archive_skill_name + else: + skill_name = archive_root_name_normalized + + src_dir = Path(tmp_dir) / archive_root_name + normalized_path = _normalize_skill_markdown_path(src_dir) + if normalized_path is None: + continue + + dest_dir = Path(self.skills_root) / skill_name + if dest_dir.exists(): + if not overwrite: + raise FileExistsError( + f"Skill {skill_name} already exists." + ) + shutil.rmtree(dest_dir) + + shutil.move(str(src_dir), str(dest_dir)) + self.set_skill_active(skill_name, True) + installed_skills.append(skill_name) + + if not installed_skills: + raise ValueError( + "No valid SKILL.md found in any folder of the zip archive." + ) + + return ", ".join(installed_skills) diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index 0deea2144..089e632bc 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -28,6 +28,9 @@ from astrbot.core.utils.datetime_utils import to_utc_isoformat from .route import Response, Route, RouteContext +# SSE heartbeat message to keep the connection alive during long-running operations +SSE_HEARTBEAT = ": heartbeat\n\n" + @asynccontextmanager async def track_conversation(convs: dict, conv_id: str): @@ -42,6 +45,9 @@ async def _poll_webchat_stream_result(back_queue, username: str): try: result = await asyncio.wait_for(back_queue.get(), timeout=1) except asyncio.TimeoutError: + # Return a sentinel so the caller can send an SSE heartbeat to + # keep the connection alive during long-running operations (e.g. + # context compression with reasoning models). See #6938. return None, False except asyncio.CancelledError: logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。") @@ -372,6 +378,11 @@ class ChatRoute(Route): client_disconnected = True break if not result: + # Send an SSE comment as keep-alive so the client + # doesn't time out during slow backend ops like + # context compression with reasoning models (#6938). + if not client_disconnected: + yield SSE_HEARTBEAT continue if ( diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/routes/stat.py index 9aae6f844..61152418e 100644 --- a/astrbot/dashboard/routes/stat.py +++ b/astrbot/dashboard/routes/stat.py @@ -5,6 +5,8 @@ import threading import time import traceback from dataclasses import asdict +from collections import defaultdict +from datetime import datetime, timedelta, timezone from functools import cmp_to_key from pathlib import Path @@ -12,12 +14,14 @@ import aiohttp import anyio import psutil from quart import request +from sqlmodel import select from astrbot.core import DEMO_MODE, logger from astrbot.core.config import VERSION from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db import BaseDatabase from astrbot.core.db.migration.helper import check_migration_needed_v4 +from astrbot.core.db.po import ProviderStat from astrbot.core.utils.astrbot_path import get_astrbot_path from astrbot.core.utils.io import get_dashboard_version from astrbot.core.utils.storage_cleaner import StorageCleaner @@ -47,6 +51,7 @@ class StatRoute(Route): super().__init__(context) self.routes = { "/stat/get": ("GET", self.get_stat), + "/stat/provider-tokens": ("GET", self.get_provider_token_stats), "/stat/version": ("GET", self.get_version), "/stat/runtime-status": ("GET", self.get_runtime_status), "/stat/start-time": ("GET", self.get_start_time), @@ -204,6 +209,207 @@ class StatRoute(Route): logger.error(traceback.format_exc()) return Response().error(e.__str__()).to_json() + @staticmethod + def _ensure_aware_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + async def get_provider_token_stats(self): + try: + try: + days = int(request.args.get("days", 1)) + except (TypeError, ValueError): + days = 1 + if days not in (1, 3, 7): + days = 1 + + local_tz = datetime.now().astimezone().tzinfo or timezone.utc + now_local = datetime.now(local_tz) + range_start_local = (now_local - timedelta(days=days)).replace( + minute=0, second=0, microsecond=0 + ) + today_start_local = now_local.replace( + hour=0, minute=0, second=0, microsecond=0 + ) + query_start_local = min(range_start_local, today_start_local) + query_start_utc = query_start_local.astimezone(timezone.utc) + + async with self.db_helper.get_db() as session: + result = await session.execute( + select(ProviderStat) + .where( + ProviderStat.agent_type == "internal", + ProviderStat.created_at >= query_start_utc, + ) + .order_by(ProviderStat.created_at.asc()) + ) + records = result.scalars().all() + + bucket_timestamps: list[int] = [] + bucket_cursor = range_start_local + while bucket_cursor <= now_local: + bucket_timestamps.append(int(bucket_cursor.timestamp() * 1000)) + bucket_cursor += timedelta(hours=1) + + trend_by_provider: dict[str, dict[int, int]] = defaultdict( + lambda: defaultdict(int) + ) + total_by_provider: dict[str, int] = defaultdict(int) + total_by_umo: dict[str, int] = defaultdict(int) + total_by_bucket: dict[int, int] = defaultdict(int) + range_total_tokens = 0 + range_total_calls = 0 + range_success_calls = 0 + range_ttft_total_ms = 0.0 + range_ttft_samples = 0 + range_duration_total_ms = 0.0 + range_duration_samples = 0 + today_by_model: dict[str, int] = defaultdict(int) + today_by_provider: dict[str, int] = defaultdict(int) + today_total_tokens = 0 + today_total_calls = 0 + + for record in records: + created_at_utc = self._ensure_aware_utc(record.created_at) + created_at_local = created_at_utc.astimezone(local_tz) + token_total = ( + record.token_input_other + + record.token_input_cached + + record.token_output + ) + provider_id = record.provider_id or "unknown" + provider_model = record.provider_model or "Unknown" + + if created_at_local >= range_start_local: + bucket_local = created_at_local.replace( + minute=0, second=0, microsecond=0 + ) + bucket_ts = int(bucket_local.timestamp() * 1000) + trend_by_provider[provider_id][bucket_ts] += token_total + total_by_provider[provider_id] += token_total + total_by_umo[record.umo or "unknown"] += token_total + total_by_bucket[bucket_ts] += token_total + range_total_tokens += token_total + range_total_calls += 1 + if record.status != "error": + range_success_calls += 1 + if record.time_to_first_token > 0: + range_ttft_total_ms += record.time_to_first_token * 1000 + range_ttft_samples += 1 + if record.end_time > record.start_time: + range_duration_total_ms += ( + record.end_time - record.start_time + ) * 1000 + range_duration_samples += 1 + + if created_at_local >= today_start_local: + today_total_calls += 1 + today_total_tokens += token_total + today_by_model[provider_model] += token_total + today_by_provider[provider_id] += token_total + + sorted_provider_ids = sorted( + total_by_provider.keys(), + key=lambda item: total_by_provider[item], + reverse=True, + ) + + series = [ + { + "name": provider_id, + "data": [ + [bucket_ts, trend_by_provider[provider_id].get(bucket_ts, 0)] + for bucket_ts in bucket_timestamps + ], + "total_tokens": total_by_provider[provider_id], + } + for provider_id in sorted_provider_ids + ] + + total_series = [ + [bucket_ts, total_by_bucket.get(bucket_ts, 0)] + for bucket_ts in bucket_timestamps + ] + + today_by_model_data = [ + {"provider_model": model_name, "tokens": tokens} + for model_name, tokens in sorted( + today_by_model.items(), + key=lambda item: item[1], + reverse=True, + ) + ] + today_by_provider_data = [ + {"provider_id": provider_id, "tokens": tokens} + for provider_id, tokens in sorted( + today_by_provider.items(), + key=lambda item: item[1], + reverse=True, + ) + ] + range_by_provider_data = [ + {"provider_id": provider_id, "tokens": tokens} + for provider_id, tokens in sorted( + total_by_provider.items(), + key=lambda item: item[1], + reverse=True, + ) + ] + range_by_umo_data = [ + {"umo": umo, "tokens": tokens} + for umo, tokens in sorted( + total_by_umo.items(), + key=lambda item: item[1], + reverse=True, + ) + ] + + return ( + Response() + .ok( + { + "days": days, + "trend": { + "series": series, + "total_series": total_series, + }, + "range_total_tokens": range_total_tokens, + "range_total_calls": range_total_calls, + "range_avg_ttft_ms": ( + range_ttft_total_ms / range_ttft_samples + if range_ttft_samples + else 0 + ), + "range_avg_duration_ms": ( + range_duration_total_ms / range_duration_samples + if range_duration_samples + else 0 + ), + "range_avg_tpm": ( + range_total_tokens / (range_duration_total_ms / 1000 / 60) + if range_duration_total_ms > 0 + else 0 + ), + "range_success_rate": ( + range_success_calls / range_total_calls + if range_total_calls + else 0 + ), + "range_by_provider": range_by_provider_data, + "range_by_umo": range_by_umo_data, + "today_total_tokens": today_total_tokens, + "today_total_calls": today_total_calls, + "today_by_model": today_by_model_data, + "today_by_provider": today_by_provider_data, + } + ) + .__dict__ + ) + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"Error: {e!s}").__dict__ + async def test_ghproxy_connection(self): """测试 GitHub 代理连接是否可用。""" try: diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 997fa1a76..598717cda 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -591,6 +591,90 @@ class AstrBotDashboard: host = _resolve_dashboard_value(host_value, field_name="host") if not isinstance(host, str) or not host: raise ValueError("Dashboard host must be a non-empty string") + @staticmethod + def _resolve_dashboard_ssl_config( + ssl_config: dict, + ) -> tuple[bool, dict[str, str]]: + cert_file = ( + os.environ.get("DASHBOARD_SSL_CERT") + or os.environ.get("ASTRBOT_DASHBOARD_SSL_CERT") + or ssl_config.get("cert_file", "") + ) + key_file = ( + os.environ.get("DASHBOARD_SSL_KEY") + or os.environ.get("ASTRBOT_DASHBOARD_SSL_KEY") + or ssl_config.get("key_file", "") + ) + ca_certs = ( + os.environ.get("DASHBOARD_SSL_CA_CERTS") + or os.environ.get("ASTRBOT_DASHBOARD_SSL_CA_CERTS") + or ssl_config.get("ca_certs", "") + ) + + if not cert_file or not key_file: + logger.warning( + "dashboard.ssl.enable 已启用,但未同时配置 cert_file 和 key_file,SSL 配置将不会生效。", + ) + return False, {} + + cert_path = Path(cert_file).expanduser() + key_path = Path(key_file).expanduser() + if not cert_path.is_file(): + logger.warning( + f"dashboard.ssl.enable 已启用,但 SSL 证书文件不存在: {cert_path},SSL 配置将不会生效。", + ) + return False, {} + if not key_path.is_file(): + logger.warning( + f"dashboard.ssl.enable 已启用,但 SSL 私钥文件不存在: {key_path},SSL 配置将不会生效。", + ) + return False, {} + + resolved_ssl_config = { + "certfile": str(cert_path.resolve()), + "keyfile": str(key_path.resolve()), + } + + if ca_certs: + ca_path = Path(ca_certs).expanduser() + if not ca_path.is_file(): + logger.warning( + f"dashboard.ssl.enable 已启用,但 SSL CA 证书文件不存在: {ca_path},SSL 配置将不会生效。", + ) + return False, {} + resolved_ssl_config["ca_certs"] = str(ca_path.resolve()) + + return True, resolved_ssl_config + + def run(self): + ip_addr = [] + dashboard_config = self.core_lifecycle.astrbot_config.get("dashboard", {}) + port = ( + os.environ.get("DASHBOARD_PORT") + or os.environ.get("ASTRBOT_DASHBOARD_PORT") + or dashboard_config.get("port", 6185) + ) + host = ( + os.environ.get("DASHBOARD_HOST") + or os.environ.get("ASTRBOT_DASHBOARD_HOST") + or dashboard_config.get("host", "0.0.0.0") + ) + enable = dashboard_config.get("enable", True) + ssl_config = dashboard_config.get("ssl", {}) + if not isinstance(ssl_config, dict): + ssl_config = {} + ssl_enable = _parse_env_bool( + os.environ.get("DASHBOARD_SSL_ENABLE") + or os.environ.get("ASTRBOT_DASHBOARD_SSL_ENABLE"), + bool(ssl_config.get("enable", False)), + ) + resolved_ssl_config: dict[str, str] = {} + if ssl_enable: + ssl_enable, resolved_ssl_config = self._resolve_dashboard_ssl_config( + ssl_config, + ) + scheme = "https" if ssl_enable else "http" +>>>>>>> origin/master # Port priority: ASTRBOT_PORT env var > cmd_config.json dashboard.port > default 6185 env_port = os.environ.get("ASTRBOT_PORT") @@ -648,6 +732,7 @@ class AstrBotDashboard: config.bind = binds if ssl_enable: +<<<<<<< HEAD cert_file = os.environ.get("ASTRBOT_SSL_CERT") or ssl_config.get( "cert_file", "" ) @@ -677,6 +762,10 @@ class AstrBotDashboard: if not await ca_path.is_file(): raise ValueError(f"SSL CA 证书文件不存在: {ca_path}") config.ca_certs = str(await ca_path.resolve()) + config.certfile = resolved_ssl_config["certfile"] + config.keyfile = resolved_ssl_config["keyfile"] + if "ca_certs" in resolved_ssl_config: + config.ca_certs = resolved_ssl_config["ca_certs"] # 根据配置决定是否禁用访问日志 disable_access_log = dashboard_config.get("disable_access_log", True) diff --git a/changelogs/v4.22.2.md b/changelogs/v4.22.2.md new file mode 100644 index 000000000..c727bff2d --- /dev/null +++ b/changelogs/v4.22.2.md @@ -0,0 +1,55 @@ +## What's Changed + +### 新增 + +- 微信个人号开放平台适配器新增“对方正在输入...”状态控制能力。([#6977](https://github.com/AstrBotDevs/AstrBot/pull/6977)) +- 配置管理页支持从现有配置复制生成新配置,减少重复创建成本。([#6785](https://github.com/AstrBotDevs/AstrBot/pull/6785)) + +### 优化 + +- Chat Provider text_chat 接口新增 `tool_choice` 参数,并为模型空响应引入统一的 `EmptyModelOutputError` 与增强重试逻辑,改善 “skills-like” 工具调用模式下的稳定性。([#7101](https://github.com/AstrBotDevs/AstrBot/pull/7101)、[#7104](https://github.com/AstrBotDevs/AstrBot/pull/7104)) +- SSL 证书配置错误时自动回退到非 SSL 模式,而不是程序直接退出。([#7102](https://github.com/AstrBotDevs/AstrBot/pull/7102)) + +### 修复 + +- 修复 个人微信 Bot 无法发送多媒体文件与 CDN 上传地址兼容性问题,适配新的 `upload_full_url` 返回格式。[#7066](https://github.com/AstrBotDevs/AstrBot/pull/7066)) +- 修复 个人微信 连接超时 / 中断后直接导致微信适配器实例退出的问题,增强了重试机制。([#7041](https://github.com/AstrBotDevs/AstrBot/pull/7041) +- 修复 WebUI Skills 上传对多技能压缩包的处理,允许单个 zip 包内包含多个技能。([#7070](https://github.com/AstrBotDevs/AstrBot/pull/7070)) +- 修复 OpenAI 嵌入模型接口处理相关问题。[#7026](https://github.com/AstrBotDevs/AstrBot/pull/7026)) +- QQ 机器人发送语音或音频文件没有响应 ([#7007](https://github.com/AstrBotDevs/AstrBot/pull/7007)) +- ChatUI 切换到某个会话,刷新后错误地跳转到最新会话 ([#6535](https://github.com/AstrBotDevs/AstrBot/pull/6535)) +- 修复百炼 `qwen3-rerank` 的请求负载兼容性问题,并忽略不受支持的 `return_documents` 参数。([#6222](https://github.com/AstrBotDevs/AstrBot/pull/6222)) +- 修复 QQ 下,发送唤醒前缀后,多次空触发 LLM 的问题。([#6893](https://github.com/AstrBotDevs/AstrBot/pull/6893)) +- 修复 GSVI TTS 调用方式与鉴权头构造问题,并补充默认配置值,适配新的 `/infer_single` API。([#7083](https://github.com/AstrBotDevs/AstrBot/pull/7083)) +- 修复缺失 `httpx` SOCKS 代理依赖的问题,恢复 SOCKS 代理支持。([#7093](https://github.com/AstrBotDevs/AstrBot/pull/7093)) +- 修复企业微信客服发送失败时无法回退普通消息接口的问题。([#7012](https://github.com/AstrBotDevs/AstrBot/pull/7012)) +- 修复 WebUI 对话管理详情页无法滚动的问题。[#6972](https://github.com/AstrBotDevs/AstrBot/pull/6972)) +- 修复 Telegram 附件消息 caption 丢失的问题。([#7020](https://github.com/AstrBotDevs/AstrBot/pull/7020)) + +## What's Changed (EN) + +### New Features + +- Added `tool_choice` to Tool Loop Agent Runner and introduced `EmptyModelOutputError` with stronger retry handling, improving stability for "skills-like" tool-call workflows.([#7101](https://github.com/AstrBotDevs/AstrBot/pull/7101)、[#7104](https://github.com/AstrBotDevs/AstrBot/pull/7104)) +- Added SSL config resolution and validation for Dashboard, with automatic fallback to non-SSL mode when the certificate setup is invalid.([#7102](https://github.com/AstrBotDevs/AstrBot/pull/7102)) +- Added the ability to create a new config by copying an existing one in the config management page.([#6785](https://github.com/AstrBotDevs/AstrBot/pull/6785)) +- Updated the QQ Official adapter to support asynchronous message parsing and attachment preparation.([#7007](https://github.com/AstrBotDevs/AstrBot/pull/7007)) +- Added typing-state control ("user is typing...") for the Weixin OC adapter.([#6977](https://github.com/AstrBotDevs/AstrBot/pull/6977)) + +### Improvements + +- Refactored Chat UI routing and layout so the UI mode is URL-driven and related state is scoped to `sessionStorage`.([#6535](https://github.com/AstrBotDevs/AstrBot/pull/6535)) +- Improved WebUI polish by centering extension-page toast hints and making code blocks more readable in dark mode.([#6043](https://github.com/AstrBotDevs/AstrBot/pull/6043)、[#7014](https://github.com/AstrBotDevs/AstrBot/pull/7014)) + +### Bug Fixes + +- Fixed skill installer handling so a single zip archive can contain multiple skills.([#7070](https://github.com/AstrBotDevs/AstrBot/pull/7070)) +- Fixed Bailian `qwen3-rerank` payload compatibility and ignored unsupported `return_documents` parameters.([#6222](https://github.com/AstrBotDevs/AstrBot/pull/6222)) +- Fixed pipeline wake-up and request flow on empty messages.([#6893](https://github.com/AstrBotDevs/AstrBot/pull/6893)) +- Fixed GSVI TTS invocation and authorization-header construction, and added safer default config values.([#7083](https://github.com/AstrBotDevs/AstrBot/pull/7083)) +- Fixed missing `httpx` SOCKS proxy dependency to restore SOCKS proxy support.([#7093](https://github.com/AstrBotDevs/AstrBot/pull/7093)) +- Fixed Weixin OC inbound polling retry behavior and CDN upload compatibility with the new `upload_full_url` response field.([#7041](https://github.com/AstrBotDevs/AstrBot/pull/7041)、[#7066](https://github.com/AstrBotDevs/AstrBot/pull/7066)) +- Fixed WeCom fallback behavior so sending can retry through the regular message API when the KF API returns `40096`.([#7012](https://github.com/AstrBotDevs/AstrBot/pull/7012)) +- Fixed missing attachment captions in Telegram messages.([#7020](https://github.com/AstrBotDevs/AstrBot/pull/7020)) +- Hardened OpenAI attachment recovery and fixed Embedding URL suffix trimming to avoid recovery failures and accidental URL character removal.([#7004](https://github.com/AstrBotDevs/AstrBot/pull/7004)、[#7026](https://github.com/AstrBotDevs/AstrBot/pull/7026)) +- Fixed Dashboard regressions around list-option label mapping, missing icons, and mouse-wheel handling in the preview container.([#6844](https://github.com/AstrBotDevs/AstrBot/pull/6844)、[#6970](https://github.com/AstrBotDevs/AstrBot/pull/6970)、[#6972](https://github.com/AstrBotDevs/AstrBot/pull/6972)) diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff new file mode 100644 index 000000000..6131c0411 Binary files /dev/null and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff differ diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 new file mode 100644 index 000000000..2ac3e9228 Binary files /dev/null and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 differ diff --git a/dashboard/src/i18n/locales/en-US/features/config.json b/dashboard/src/i18n/locales/en-US/features/config.json index 7b3cf78b1..4cf9ea5c4 100644 --- a/dashboard/src/i18n/locales/en-US/features/config.json +++ b/dashboard/src/i18n/locales/en-US/features/config.json @@ -45,7 +45,8 @@ "advanced": "Advanced Settings", "security": "Security Settings", "appearance": "Appearance Settings", - "notification": "Notification Settings" + "notification": "Notification Settings", + "moreConfig": "More Settings" }, "general": { "botName": "Bot Name", @@ -80,13 +81,16 @@ "title": "Configuration Management", "description": "AstrBot supports separate configuration files for different bots. The `default` configuration is used by default.", "newConfig": "New Configuration", + "copyConfig": "Copy Configuration", "editConfig": "Edit Configuration", "manageConfigs": "Manage Configurations...", "configName": "Name", "fillConfigName": "Enter configuration name", "confirmDelete": "Are you sure you want to delete the configuration \"{name}\"? This action cannot be undone.", "pleaseEnterName": "Please enter a configuration name", + "nameExists": "Configuration name already exists. Please use another name.", "createFailed": "Failed to create new configuration", + "copyFailed": "Failed to copy configuration", "deleteFailed": "Failed to delete configuration", "updateFailed": "Failed to update configuration" }, diff --git a/dashboard/src/i18n/locales/en-US/features/stats.json b/dashboard/src/i18n/locales/en-US/features/stats.json new file mode 100644 index 000000000..b6349011e --- /dev/null +++ b/dashboard/src/i18n/locales/en-US/features/stats.json @@ -0,0 +1,104 @@ +{ + "header": { + "eyebrow": "Dashboard", + "title": "System Statistics", + "subtitle": "A unified view of platforms, messages, and model calls.", + "notUpdated": "Not updated yet" + }, + "ranges": { + "oneDay": "1 Day", + "threeDays": "3 Days", + "oneWeek": "1 Week" + }, + "rangeLabels": { + "oneDay": "Last 1 day", + "threeDays": "Last 3 days", + "oneWeek": "Last 1 week" + }, + "overviewCards": { + "platformCount": { + "label": "Platform Instances", + "note": "Configured and currently running platform instances" + }, + "messageCount": { + "label": "Messages", + "note": "Total accumulated messages" + }, + "todayModelCalls": { + "label": "Today's Model Calls", + "note": "Tokens" + }, + "cpu": { + "label": "CPU", + "note": "AstrBot CPU usage" + }, + "memory": { + "label": "Process Memory", + "note": "System memory {systemMemory}" + }, + "uptime": { + "label": "Uptime", + "note": "Started at {startTime}" + } + }, + "messageOverview": { + "title": "Message Overview", + "subtitle": "Robot message statistics" + }, + "messageTrend": { + "title": "Message Trend", + "subtitle": "{range}, aggregated by hour", + "totalMessages": "Total Messages" + }, + "platformRanking": { + "title": "Platform Message Ranking", + "subtitle": "{range}, sorted by aggregated message volume" + }, + "modelCalls": { + "title": "Model Calls", + "subtitle": "Model call statistics" + }, + "modelTrend": { + "title": "Model Call Trend", + "subtitle": "Token trend for model calls" + }, + "modelTotal": { + "title": "{range} total model calls", + "callCount": "{count} calls", + "avgTtft": "Average TTFT", + "avgDuration": "Average Response Time", + "avgTpm": "Average TPM", + "successRate": "Success Rate" + }, + "modelRanking": { + "title": "{range} model call ranking", + "subtitle": "Ranked by model call tokens" + }, + "sessionRanking": { + "title": "{range} session token top 10", + "subtitle": "Sessions ranked by token usage" + }, + "empty": { + "platformStats": "No platform message statistics available.", + "modelCalls": "No model call data for {range}.", + "sessionCalls": "No session call data for {range}." + }, + "chart": { + "messages": "Messages", + "others": "Others" + }, + "units": { + "tokens": "Tokens", + "tpm": "TPM", + "gb": "GB", + "mb": "MB", + "ms": "ms", + "hoursShort": "h", + "minutesShort": "m", + "secondsShort": "s" + }, + "errors": { + "loadFailed": "Failed to load statistics. Please try again later.", + "rangeFailed": "Failed to switch the statistics range. Please try again later." + } +} diff --git a/dashboard/src/i18n/locales/ru-RU/features/config.json b/dashboard/src/i18n/locales/ru-RU/features/config.json index 69d3ca0eb..dc6a9ff60 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config.json @@ -45,7 +45,8 @@ "advanced": "Расширенные", "security": "Безопасность", "appearance": "Внешний вид", - "notification": "Уведомления" + "notification": "Уведомления", + "moreConfig": "Дополнительные настройки" }, "general": { "botName": "Имя бота", @@ -80,13 +81,16 @@ "title": "Управление конфигурациями", "description": "AstrBot поддерживает несколько конфигураций для разных ботов. По умолчанию используется «default».", "newConfig": "Новая конфигурация", + "copyConfig": "Копировать конфигурацию", "editConfig": "Изменить конфигурацию", "manageConfigs": "Управление файлами...", "configName": "Имя", "fillConfigName": "Введите имя конфигурации", "confirmDelete": "Вы уверены, что хотите удалить конфигурацию «{name}»? Это действие необратимо.", "pleaseEnterName": "Пожалуйста, введите имя", + "nameExists": "Имя конфигурации уже существует. Используйте другое имя.", "createFailed": "Ошибка создания конфигурации", + "copyFailed": "Ошибка копирования конфигурации", "deleteFailed": "Ошибка удаления", "updateFailed": "Ошибка обновления" }, diff --git a/dashboard/src/i18n/locales/ru-RU/features/stats.json b/dashboard/src/i18n/locales/ru-RU/features/stats.json new file mode 100644 index 000000000..45cc3ed6c --- /dev/null +++ b/dashboard/src/i18n/locales/ru-RU/features/stats.json @@ -0,0 +1,104 @@ +{ + "header": { + "eyebrow": "Dashboard", + "title": "Статистика системы", + "subtitle": "Единый обзор платформ, сообщений и вызовов моделей.", + "notUpdated": "Еще не обновлялось" + }, + "ranges": { + "oneDay": "1 день", + "threeDays": "3 дня", + "oneWeek": "1 неделя" + }, + "rangeLabels": { + "oneDay": "За 1 день", + "threeDays": "За 3 дня", + "oneWeek": "За 1 неделю" + }, + "overviewCards": { + "platformCount": { + "label": "Платформы", + "note": "Настроенные и запущенные экземпляры платформ" + }, + "messageCount": { + "label": "Сообщения", + "note": "Общее число сообщений" + }, + "todayModelCalls": { + "label": "Вызовы моделей сегодня", + "note": "Tokens" + }, + "cpu": { + "label": "CPU", + "note": "Использование CPU AstrBot" + }, + "memory": { + "label": "Память процесса", + "note": "Системная память {systemMemory}" + }, + "uptime": { + "label": "Время работы", + "note": "Запущен в {startTime}" + } + }, + "messageOverview": { + "title": "Обзор сообщений", + "subtitle": "Статистика сообщений бота" + }, + "messageTrend": { + "title": "Динамика сообщений", + "subtitle": "{range}, агрегация по часам", + "totalMessages": "Всего сообщений" + }, + "platformRanking": { + "title": "Рейтинг платформ по сообщениям", + "subtitle": "{range}, сортировка по общему числу сообщений" + }, + "modelCalls": { + "title": "Вызовы моделей", + "subtitle": "Статистика вызовов моделей" + }, + "modelTrend": { + "title": "Динамика вызовов моделей", + "subtitle": "Динамика токенов вызовов моделей" + }, + "modelTotal": { + "title": "{range} всего вызовов моделей", + "callCount": "{count} вызовов", + "avgTtft": "Средний TTFT", + "avgDuration": "Среднее время ответа", + "avgTpm": "Средний TPM", + "successRate": "Доля успешных вызовов" + }, + "modelRanking": { + "title": "{range} рейтинг вызовов моделей", + "subtitle": "Рейтинг по токенам вызовов моделей" + }, + "sessionRanking": { + "title": "{range} топ-10 сессий по токенам", + "subtitle": "Сессии с наибольшим расходом токенов" + }, + "empty": { + "platformStats": "Статистика сообщений по платформам отсутствует.", + "modelCalls": "Нет данных по вызовам моделей за период {range}.", + "sessionCalls": "Нет данных по сессиям за период {range}." + }, + "chart": { + "messages": "Сообщения", + "others": "Другое" + }, + "units": { + "tokens": "Tokens", + "tpm": "TPM", + "gb": "ГБ", + "mb": "МБ", + "ms": "мс", + "hoursShort": "ч", + "minutesShort": "м", + "secondsShort": "с" + }, + "errors": { + "loadFailed": "Не удалось загрузить статистику. Повторите попытку позже.", + "rangeFailed": "Не удалось переключить диапазон статистики. Повторите попытку позже." + } +} diff --git a/dashboard/src/i18n/locales/zh-CN/features/config.json b/dashboard/src/i18n/locales/zh-CN/features/config.json index b1d842035..1ad12ad13 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config.json @@ -45,7 +45,8 @@ "advanced": "高级设置", "security": "安全设置", "appearance": "外观设置", - "notification": "通知设置" + "notification": "通知设置", + "moreConfig": "更多配置" }, "general": { "botName": "机器人名称", @@ -80,13 +81,16 @@ "title": "配置文件管理", "description": "AstrBot 支持针对不同机器人分别设置配置文件。默认会使用 `default` 配置。", "newConfig": "新建配置文件", + "copyConfig": "复制配置文件", "editConfig": "编辑配置文件", "manageConfigs": "管理配置文件...", "configName": "名称", "fillConfigName": "填写配置文件名称", "confirmDelete": "确定要删除配置文件 \"{name}\" 吗?此操作不可恢复。", "pleaseEnterName": "请填写配置名称", + "nameExists": "配置文件名称已存在,请使用其他名称", "createFailed": "新配置文件创建失败", + "copyFailed": "复制配置文件失败", "deleteFailed": "删除配置文件失败", "updateFailed": "更新配置文件失败" }, diff --git a/dashboard/src/i18n/locales/zh-CN/features/stats.json b/dashboard/src/i18n/locales/zh-CN/features/stats.json new file mode 100644 index 000000000..64c8bb84a --- /dev/null +++ b/dashboard/src/i18n/locales/zh-CN/features/stats.json @@ -0,0 +1,104 @@ +{ + "header": { + "eyebrow": "Dashboard", + "title": "系统统计", + "subtitle": "平台、消息与模型调用的统一视图。", + "notUpdated": "尚未更新" + }, + "ranges": { + "oneDay": "1 天", + "threeDays": "3 天", + "oneWeek": "1 周" + }, + "rangeLabels": { + "oneDay": "最近 1 天", + "threeDays": "最近 3 天", + "oneWeek": "最近 1 周" + }, + "overviewCards": { + "platformCount": { + "label": "平台实例数", + "note": "当前配置并已运行的平台实例" + }, + "messageCount": { + "label": "消息总数", + "note": "累计消息量" + }, + "todayModelCalls": { + "label": "今日模型调用", + "note": "词元(Tokens)" + }, + "cpu": { + "label": "CPU", + "note": "程序 CPU 占用" + }, + "memory": { + "label": "进程内存", + "note": "系统内存 {systemMemory}" + }, + "uptime": { + "label": "运行时间", + "note": "启动于 {startTime}" + } + }, + "messageOverview": { + "title": "消息概览", + "subtitle": "机器人消息数据统计" + }, + "messageTrend": { + "title": "消息趋势", + "subtitle": "{range}按小时聚合", + "totalMessages": "总消息数" + }, + "platformRanking": { + "title": "平台消息排名", + "subtitle": "{range}按聚合消息总量排序" + }, + "modelCalls": { + "title": "模型调用", + "subtitle": "模型调用数据统计" + }, + "modelTrend": { + "title": "模型调用趋势", + "subtitle": "模型调用词元(Tokens)趋势统计" + }, + "modelTotal": { + "title": "{range}模型调用总量", + "callCount": "共 {count} 次调用", + "avgTtft": "平均首字延迟(TTFT)", + "avgDuration": "平均响应时间", + "avgTpm": "平均每分钟词元数(TPM)", + "successRate": "调用成功率" + }, + "modelRanking": { + "title": "{range}模型调用排名", + "subtitle": "模型调用词元(Tokens)排名" + }, + "sessionRanking": { + "title": "{range}会话词元(Tokens)Top 10", + "subtitle": "词元(Tokens)用量最大的会话排名" + }, + "empty": { + "platformStats": "当前没有平台消息统计数据。", + "modelCalls": "{range}还没有模型调用数据。", + "sessionCalls": "{range}还没有会话调用数据。" + }, + "chart": { + "messages": "消息", + "others": "其他" + }, + "units": { + "tokens": "词元(Tokens)", + "tpm": "TPM", + "gb": "GB", + "mb": "MB", + "ms": "ms", + "hoursShort": "h", + "minutesShort": "m", + "secondsShort": "s" + }, + "errors": { + "loadFailed": "统计数据加载失败,请稍后重试。", + "rangeFailed": "统计范围切换失败,请稍后重试。" + } +} diff --git a/dashboard/src/views/dashboards/default/DefaultDashboard.vue b/dashboard/src/views/dashboards/default/DefaultDashboard.vue deleted file mode 100644 index 6167790dc..000000000 --- a/dashboard/src/views/dashboards/default/DefaultDashboard.vue +++ /dev/null @@ -1,234 +0,0 @@ - - - - - diff --git a/dashboard/src/views/dashboards/default/components/MemoryUsage.vue b/dashboard/src/views/dashboards/default/components/MemoryUsage.vue deleted file mode 100644 index dad27bf32..000000000 --- a/dashboard/src/views/dashboards/default/components/MemoryUsage.vue +++ /dev/null @@ -1,212 +0,0 @@ - - - - - diff --git a/dashboard/src/views/dashboards/default/components/MessageStat.vue b/dashboard/src/views/dashboards/default/components/MessageStat.vue deleted file mode 100644 index 3ecc2f824..000000000 --- a/dashboard/src/views/dashboards/default/components/MessageStat.vue +++ /dev/null @@ -1,428 +0,0 @@ - - - - - diff --git a/dashboard/src/views/dashboards/default/components/OnlinePlatform.vue b/dashboard/src/views/dashboards/default/components/OnlinePlatform.vue deleted file mode 100644 index 25e61c701..000000000 --- a/dashboard/src/views/dashboards/default/components/OnlinePlatform.vue +++ /dev/null @@ -1,179 +0,0 @@ - - - - - diff --git a/dashboard/src/views/dashboards/default/components/OnlineTime.vue b/dashboard/src/views/dashboards/default/components/OnlineTime.vue deleted file mode 100644 index 3b1564933..000000000 --- a/dashboard/src/views/dashboards/default/components/OnlineTime.vue +++ /dev/null @@ -1,260 +0,0 @@ - - - - - diff --git a/dashboard/src/views/dashboards/default/components/PlatformStat.vue b/dashboard/src/views/dashboards/default/components/PlatformStat.vue deleted file mode 100644 index eff60cb71..000000000 --- a/dashboard/src/views/dashboards/default/components/PlatformStat.vue +++ /dev/null @@ -1,299 +0,0 @@ - - - - - diff --git a/dashboard/src/views/dashboards/default/components/RunningTime.vue b/dashboard/src/views/dashboards/default/components/RunningTime.vue deleted file mode 100644 index 9ba079531..000000000 --- a/dashboard/src/views/dashboards/default/components/RunningTime.vue +++ /dev/null @@ -1,146 +0,0 @@ - - - - - diff --git a/dashboard/src/views/dashboards/default/components/TotalMessage.vue b/dashboard/src/views/dashboards/default/components/TotalMessage.vue deleted file mode 100644 index 97da00bba..000000000 --- a/dashboard/src/views/dashboards/default/components/TotalMessage.vue +++ /dev/null @@ -1,116 +0,0 @@ - - - - - diff --git a/dashboard/src/views/stats/StatsPage.vue b/dashboard/src/views/stats/StatsPage.vue new file mode 100644 index 000000000..ca6ae1ec9 --- /dev/null +++ b/dashboard/src/views/stats/StatsPage.vue @@ -0,0 +1,1170 @@ + + + + + diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 5b92e97d2..a4f104de0 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -222,10 +222,13 @@ export default defineConfig({ ], }, { - text: "开源之夏", - base: "/ospp", - collapsed: true, - items: [{ text: "OSPP 2025", link: "/2025" }], + text: "社区活动", + base: "/community-events", + collapsed: false, + items: [ + { text: "开源之夏 2025", link: "/ospp-2025" }, + { text: "桐谷霁屿 x AstrBot 插件奖励活动", link: "/tonggujiyu-astrbot-plugin-reward-program" }, + ], }, ], outline: { diff --git a/docs/en/dev/star/guides/plugin-config.md b/docs/en/dev/star/guides/plugin-config.md index 538f872b9..3bb681141 100644 --- a/docs/en/dev/star/guides/plugin-config.md +++ b/docs/en/dev/star/guides/plugin-config.md @@ -62,7 +62,15 @@ When the code editor is enabled, it looks like this: ![editor_mode_fullscreen](https://files.astrbot.app/docs/source/images/plugin/image-7.png) -The **_special** field is only available after v4.0.0. Currently supports `select_provider`, `select_provider_tts`, `select_provider_stt`, `select_persona`, allowing users to quickly select model providers, personas, and other data already configured in the WebUI. Results are all strings. Using select_provider as an example, it will present the following effect: +The **_special** field is only available after v4.0.0. Common values include `select_provider`, `select_provider_tts`, `select_provider_stt`, `select_persona`, and `select_knowledgebase`, allowing users to quickly select model providers, personas, knowledge bases, and other data already configured in the WebUI. + +- `select_provider`, `select_provider_tts`, `select_provider_stt`, and `select_persona` return strings. +- `select_knowledgebase` returns a `list` and supports multiple selection, so the corresponding config item should use `type: list` with a default value of `[]`. + +> [!NOTE] +> For reference, AstrBot Core also uses other internal `_special` values, such as `select_providers`, `provider_pool`, `persona_pool`, `select_plugin_set`, `t2i_template`, `get_embedding_dim`, and `select_agent_runner_provider:*` (where `*` is a placeholder for the runner type). These are internal implementations and may change at any time — please avoid using them in plugins. + +Using `select_provider` as an example, it will display as follows: ![image](https://files.astrbot.app/docs/source/images/plugin/image-select-provider.png) diff --git a/docs/zh/ospp/2025.md b/docs/zh/community-events/ospp-2025.md similarity index 100% rename from docs/zh/ospp/2025.md rename to docs/zh/community-events/ospp-2025.md diff --git a/docs/zh/community-events/tonggujiyu-astrbot-plugin-reward-program.md b/docs/zh/community-events/tonggujiyu-astrbot-plugin-reward-program.md new file mode 100644 index 000000000..d682e34ec --- /dev/null +++ b/docs/zh/community-events/tonggujiyu-astrbot-plugin-reward-program.md @@ -0,0 +1,13 @@ +# 桐谷霁屿团队 x AstrBot 插件奖励活动 + +> 发布于 2026/03/30 + +为进一步促进 AstrBot 社区生态发展,鼓励优质插件的创作与分享,桐谷霁屿团队邀请到了 AstrBot 社区发起本次社区合作计划。 + +本计划采用「社区投票 + 评审筛选」的方式:参与插件将进入公开投票环节,由社区选出得票数最高的五个插件;若当期无主动报名项目,将从官方插件库中选取优质插件补充入选;随后由评审团基于实用性、创新性与社区价值等维度进行综合评估,最终确定本期获得支持的插件。 + +对于入选项目,我们将联系作者提供一定形式的支持,包括「周四 50 元」赞助、社区曝光或其他资源协助等。该计划为社区之间的合作尝试,并非商业性评选,旨在以轻量、持续的方式推动优秀项目的成长。 + +本次活动地址:https://abponsor.tongujiyu.cn/ + +欢迎开发者参与,也欢迎社区成员积极投票与推荐优秀插件。 \ No newline at end of file diff --git a/docs/zh/dev/star/guides/plugin-config.md b/docs/zh/dev/star/guides/plugin-config.md index 26077c142..38e3ee4ad 100644 --- a/docs/zh/dev/star/guides/plugin-config.md +++ b/docs/zh/dev/star/guides/plugin-config.md @@ -62,7 +62,15 @@ AstrBot 提供了“强大”的配置解析和可视化功能。能够让用户 ![editor_mode_fullscreen](https://files.astrbot.app/docs/source/images/plugin/image-7.png) -**_special** 字段仅 v4.0.0 之后可用。目前支持填写 `select_provider`, `select_provider_tts`, `select_provider_stt`, `select_persona`,用于让用户快速选择用户在 WebUI 上已经配置好的模型提供商、人设等数据。结果均为字符串。以 select_provider 为例,将呈现以下效果: +**_special** 字段仅 v4.0.0 之后可用。常用可填写值包括 `select_provider`, `select_provider_tts`, `select_provider_stt`, `select_persona`, `select_knowledgebase`,用于让用户快速选择在 WebUI 上已经配置好的模型提供商、人设、知识库等数据。 + +- `select_provider`、`select_provider_tts`、`select_provider_stt`、`select_persona` 的结果为字符串。 +- `select_knowledgebase` 的结果为 `list` 类型,支持多选,建议将对应配置项的 `type` 设为 `list`,默认值设为 `[]`。 + +> [!NOTE] +> 此外,AstrBot Core 内部还使用了 `select_providers`、`provider_pool`、`persona_pool`、`select_plugin_set`、`t2i_template`、`get_embedding_dim`、`select_agent_runner_provider:*`(`*` 为运行器类型占位符)等 `_special` 值。这些属于内部实现,随时可能变动,请勿在插件中使用。 + +以 `select_provider` 为例,将呈现以下效果: ![image](https://files.astrbot.app/docs/source/images/plugin/image-select-provider.png) diff --git a/docs/zh/platform/weixin_oc.md b/docs/zh/platform/weixin_oc.md index 6ca7e92ce..7fc64dbb7 100644 --- a/docs/zh/platform/weixin_oc.md +++ b/docs/zh/platform/weixin_oc.md @@ -5,7 +5,7 @@ AstrBot 支持通过 `个人微信` 适配器接入微信个人号。该适配器基于**腾讯微信官方** `openclaw-weixin` 接口实现,使用扫码登录和长轮询收发消息,不需要配置 Webhook 回调地址。 > [!NOTE] -> 需要升级到最新的手机微信版本:>= 8.0.70 +> 需要升级到最新的手机微信版本:iOS >= 8.0.70,Android >= 8.0.69,并确保微信中包含 ClawBot 插件 ## 支持的消息类型 diff --git a/tests/test_anthropic_kimi_code_provider.py b/tests/test_anthropic_kimi_code_provider.py index ea22e376c..a46953f22 100644 --- a/tests/test_anthropic_kimi_code_provider.py +++ b/tests/test_anthropic_kimi_code_provider.py @@ -1,7 +1,10 @@ import httpx +import pytest import astrbot.core.provider.sources.anthropic_source as anthropic_source import astrbot.core.provider.sources.kimi_code_source as kimi_code_source +from astrbot.core.exceptions import EmptyModelOutputError +from astrbot.core.provider.entities import LLMResponse class _FakeAsyncAnthropic: @@ -79,3 +82,14 @@ def test_kimi_code_provider_restores_required_user_agent_when_blank(monkeypatch) assert provider.custom_headers == { "User-Agent": kimi_code_source.KIMI_CODE_USER_AGENT, } + + +def test_anthropic_empty_output_raises_empty_model_output_error(): + llm_response = LLMResponse(role="assistant") + + with pytest.raises(EmptyModelOutputError): + anthropic_source.ProviderAnthropic._ensure_usable_response( + llm_response, + completion_id="msg_empty", + stop_reason="end_turn", + ) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 46aa3bc97..3ee4a867c 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -452,6 +452,51 @@ async def test_apikey_delete_not_found( assert data["status"] == "error" +@pytest.mark.asyncio +async def test_dashboard_ssl_missing_cert_and_key_falls_back_to_http( + core_lifecycle_td: AstrBotCoreLifecycle, + monkeypatch, +): + shutdown_event = asyncio.Event() + server = AstrBotDashboard(core_lifecycle_td, core_lifecycle_td.db, shutdown_event) + original_dashboard_config = copy.deepcopy( + core_lifecycle_td.astrbot_config.get("dashboard", {}), + ) + warning_messages = [] + info_messages = [] + + async def fake_serve(app, config, shutdown_trigger): + return config + + try: + core_lifecycle_td.astrbot_config["dashboard"]["ssl"] = { + "enable": True, + "cert_file": "", + "key_file": "", + } + monkeypatch.setattr(server, "check_port_in_use", lambda port: False) + monkeypatch.setattr("astrbot.dashboard.server.serve", fake_serve) + monkeypatch.setattr( + "astrbot.dashboard.server.logger.warning", + lambda message: warning_messages.append(message), + ) + monkeypatch.setattr( + "astrbot.dashboard.server.logger.info", + lambda message: info_messages.append(message), + ) + + config = await server.run() + + assert getattr(config, "certfile", None) is None + assert getattr(config, "keyfile", None) is None + assert any("cert_file 和 key_file" in message for message in warning_messages) + assert any( + "正在启动 WebUI, 监听地址: http://" in message for message in info_messages + ) + finally: + core_lifecycle_td.astrbot_config["dashboard"] = original_dashboard_config + + @pytest.mark.asyncio async def test_subagent_config_accepts_default_persona( app: Quart, diff --git a/tests/test_gemini_source.py b/tests/test_gemini_source.py new file mode 100644 index 000000000..4db8e92bf --- /dev/null +++ b/tests/test_gemini_source.py @@ -0,0 +1,29 @@ +import pytest + +from astrbot.core.exceptions import EmptyModelOutputError +from astrbot.core.provider.entities import LLMResponse +from astrbot.core.provider.sources.gemini_source import ProviderGoogleGenAI + + +def test_gemini_empty_output_raises_empty_model_output_error(): + llm_response = LLMResponse(role="assistant") + + with pytest.raises(EmptyModelOutputError): + ProviderGoogleGenAI._ensure_usable_response( + llm_response, + response_id="resp_empty", + finish_reason="STOP", + ) + + +def test_gemini_reasoning_only_output_is_allowed(): + llm_response = LLMResponse( + role="assistant", + reasoning_content="chain of thought placeholder", + ) + + ProviderGoogleGenAI._ensure_usable_response( + llm_response, + response_id="resp_reasoning", + finish_reason="STOP", + ) diff --git a/tests/test_httpx_socks_dependency.py b/tests/test_httpx_socks_dependency.py new file mode 100644 index 000000000..499921253 --- /dev/null +++ b/tests/test_httpx_socks_dependency.py @@ -0,0 +1,113 @@ +import re +from pathlib import Path + +import pytest +import tomllib + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +REQUIREMENTS_PATH = PROJECT_ROOT / "requirements.txt" +PYPROJECT_PATH = PROJECT_ROOT / "pyproject.toml" +HTTPX_SOCKS_PATTERN = re.compile(r"^httpx\[socks\](?:\s*[<>=!~][^;]*)?(?:\s*;.*)?$") + + +def _read_httpx_socks_dependency(entries: list[str]) -> str | None: + for entry in entries: + candidate = entry.strip() + if HTTPX_SOCKS_PATTERN.match(candidate): + return candidate + return None + + +def _read_requirements() -> list[str]: + entries = [] + for line in REQUIREMENTS_PATH.read_text(encoding="utf-8").splitlines(): + candidate = line.split("#", 1)[0].strip() + if candidate: + entries.append(candidate) + return entries + + +def _read_pyproject_dependencies() -> list[str]: + with PYPROJECT_PATH.open("rb") as file: + pyproject = tomllib.load(file) + return pyproject["project"]["dependencies"] + + +def test_requirements_include_httpx_socks_dependency() -> None: + requirements_dependency = _read_httpx_socks_dependency(_read_requirements()) + + assert requirements_dependency is not None, ( + "Expected httpx[socks] dependency in requirements.txt for SOCKS proxy support" + ) + + +def test_pyproject_declares_httpx_socks_dependency() -> None: + pyproject_dependency = _read_httpx_socks_dependency(_read_pyproject_dependencies()) + + assert pyproject_dependency is not None, ( + "Expected httpx[socks] dependency in pyproject.toml for SOCKS proxy support" + ) + + +def test_httpx_socks_dependency_spec_matches_between_dependency_files() -> None: + requirements_dependency = _read_httpx_socks_dependency(_read_requirements()) + pyproject_dependency = _read_httpx_socks_dependency(_read_pyproject_dependencies()) + + assert requirements_dependency is not None, ( + "Expected httpx[socks] dependency in requirements.txt for SOCKS proxy support" + ) + assert pyproject_dependency is not None, ( + "Expected httpx[socks] dependency in pyproject.toml for SOCKS proxy support" + ) + assert requirements_dependency == pyproject_dependency, ( + "Expected httpx[socks] dependency spec to match between requirements.txt " + "and pyproject.toml for SOCKS proxy support" + ) + + +@pytest.mark.parametrize( + "entry", + [ + "httpx[socks]", + "httpx[socks]==0.27.0", + "httpx[socks]==0.28.1", + "httpx[socks]>=0.27.0,<0.28.0", + "httpx[socks]>=0.27,<0.29", + 'httpx[socks]; python_version >= "3.11"', + 'httpx[socks]>=0.27.0 ; python_version < "3.13"', + 'httpx[socks] ; python_version < "3.13"', + 'httpx[socks] >=0.27 ; python_version < "3.13"', + ], +) +def test_httpx_socks_pattern_matches_valid_variants(entry: str) -> None: + match = HTTPX_SOCKS_PATTERN.match(entry) + + assert match is not None, ( + f"Expected httpx[socks] dependency pattern to match valid entry for " + f"SOCKS proxy support: {entry}" + ) + assert match.group(0) == entry, ( + f"Expected httpx[socks] dependency pattern to fully match valid entry " + f"for SOCKS proxy support: {entry}" + ) + + +@pytest.mark.parametrize( + "entry", + [ + "httpx", + "httpx==0.27.0", + "httpx[http2]", + "httpx[socks-extra]", + "httpx [socks]", + "someprefix httpx[socks]", + "httpx[socks] trailing-text", + "httpx[socks] extra ; markers", + "httpx[socks]andmore", + ], +) +def test_httpx_socks_pattern_rejects_invalid_variants(entry: str) -> None: + assert HTTPX_SOCKS_PATTERN.match(entry) is None, ( + f"Expected httpx[socks] dependency pattern to reject invalid entry for " + f"SOCKS proxy support: {entry}" + ) diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index cc8672803..d78ba3ff9 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -4,6 +4,7 @@ import pytest from openai.types.chat.chat_completion import ChatCompletion from PIL import Image as PILImage +from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.provider.sources.groq_source import ProviderGroq from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial @@ -1158,3 +1159,39 @@ async def test_prepare_chat_payload_strips_non_json_serializable_kwargs(): assert payloads.get("max_tokens") == 1024 finally: await provider.terminate() + + +@pytest.mark.asyncio +async def test_parse_openai_completion_raises_empty_model_output_error(): + provider = _make_provider() + try: + completion = ChatCompletion.model_validate( + { + "id": "chatcmpl-empty", + "object": "chat.completion", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": None, + "refusal": None, + "tool_calls": None, + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 0, + "total_tokens": 1, + }, + } + ) + + with pytest.raises(EmptyModelOutputError): + await provider._parse_openai_completion(completion, tools=None) + finally: + await provider.terminate() diff --git a/tests/test_telegram_adapter.py b/tests/test_telegram_adapter.py index f7ae4d4be..480d6f131 100644 --- a/tests/test_telegram_adapter.py +++ b/tests/test_telegram_adapter.py @@ -1,7 +1,7 @@ import asyncio import importlib import sys -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -106,3 +106,37 @@ async def test_telegram_video_caption_populates_message_text_and_plain(): isinstance(component, Comp.Plain) and component.text == "这段视频讲了什么" for component in result.message ) + + +@pytest.mark.asyncio +async def test_telegram_voice_message_creates_record_component(tmp_path): + TelegramPlatformAdapter = _load_telegram_adapter() + adapter = TelegramPlatformAdapter( + make_platform_config("telegram"), + {}, + asyncio.Queue(), + ) + voice = create_mock_file("https://api.telegram.org/file/test/voice.oga") + update = create_mock_update( + message_text=None, + voice=voice, + ) + wav_path = tmp_path / "voice.oga.wav" + convert_message_globals = adapter.convert_message.__func__.__globals__ + + with patch.dict( + convert_message_globals, + { + "get_astrbot_temp_path": MagicMock(return_value=str(tmp_path)), + "download_file": AsyncMock(), + "convert_audio_to_wav": AsyncMock(return_value=str(wav_path)), + }, + ): + result = await adapter.convert_message(update, _build_context()) + + assert result is not None + assert len(result.message) == 1 + assert isinstance(result.message[0], Comp.Record) + assert result.message[0].file == str(wav_path) + assert result.message[0].path == str(wav_path) + assert result.message[0].url == str(wav_path) diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py index be4be2260..990b11638 100644 --- a/tests/test_tool_loop_agent_runner.py +++ b/tests/test_tool_loop_agent_runner.py @@ -17,6 +17,7 @@ from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner from astrbot.core.agent.tool import FunctionTool, ToolSet from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor +from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.provider.entities import LLMResponse, ProviderRequest, TokenUsage from astrbot.core.provider.provider import Provider @@ -134,6 +135,22 @@ class MockErrProvider(MockProvider): ) +class MockEmptyOutputThenSuccessProvider(MockProvider): + def __init__(self, failures_before_success: int = 1): + super().__init__() + self.failures_before_success = failures_before_success + + async def text_chat(self, **kwargs) -> LLMResponse: + self.call_count += 1 + if self.call_count <= self.failures_before_success: + raise EmptyModelOutputError("model returned no usable output") + return LLMResponse( + role="assistant", + completion_text="这是重试后的最终回答", + usage=TokenUsage(input_other=10, output=5), + ) + + class MockAbortableStreamProvider(MockProvider): async def text_chat_stream(self, **kwargs): abort_signal = kwargs.get("abort_signal") @@ -579,6 +596,67 @@ async def test_fallback_provider_used_when_primary_returns_err( assert fallback_provider.call_count == 1 +@pytest.mark.asyncio +async def test_empty_output_is_retried_before_succeeding( + runner, provider_request, mock_tool_executor, mock_hooks, monkeypatch +): + monkeypatch.setattr(runner, "EMPTY_OUTPUT_RETRY_WAIT_MIN_S", 0) + monkeypatch.setattr(runner, "EMPTY_OUTPUT_RETRY_WAIT_MAX_S", 0) + + provider = MockEmptyOutputThenSuccessProvider(failures_before_success=1) + await runner.reset( + provider=provider, + request=provider_request, + run_context=ContextWrapper(context=None), + tool_executor=mock_tool_executor, + agent_hooks=mock_hooks, + streaming=False, + ) + + async for _ in runner.step_until_done(5): + pass + + final_resp = runner.get_final_llm_resp() + assert final_resp is not None + assert final_resp.role == "assistant" + assert final_resp.completion_text == "这是重试后的最终回答" + assert provider.call_count == 2 + + +@pytest.mark.asyncio +async def test_empty_output_retries_exhausted_then_uses_fallback_provider( + runner, provider_request, mock_tool_executor, mock_hooks, monkeypatch +): + monkeypatch.setattr(runner, "EMPTY_OUTPUT_RETRY_WAIT_MIN_S", 0) + monkeypatch.setattr(runner, "EMPTY_OUTPUT_RETRY_WAIT_MAX_S", 0) + + primary_provider = MockEmptyOutputThenSuccessProvider( + failures_before_success=runner.EMPTY_OUTPUT_RETRY_ATTEMPTS + ) + fallback_provider = MockProvider() + fallback_provider.should_call_tools = False + + await runner.reset( + provider=primary_provider, + request=provider_request, + run_context=ContextWrapper(context=None), + tool_executor=mock_tool_executor, + agent_hooks=mock_hooks, + streaming=False, + fallback_providers=[fallback_provider], + ) + + async for _ in runner.step_until_done(5): + pass + + final_resp = runner.get_final_llm_resp() + assert final_resp is not None + assert final_resp.role == "assistant" + assert final_resp.completion_text == "这是我的最终回答" + assert primary_provider.call_count == runner.EMPTY_OUTPUT_RETRY_ATTEMPTS + assert fallback_provider.call_count == 1 + + @pytest.mark.asyncio async def test_stop_signal_returns_aborted_and_persists_partial_message( runner, provider_request, mock_tool_executor, mock_hooks diff --git a/tests/unit/test_provider_stats.py b/tests/unit/test_provider_stats.py new file mode 100644 index 000000000..c306c3f82 --- /dev/null +++ b/tests/unit/test_provider_stats.py @@ -0,0 +1,65 @@ +from types import SimpleNamespace + +import pytest +from sqlmodel import select + +from astrbot.core.agent.response import AgentStats +from astrbot.core.db.po import ProviderStat +from astrbot.core.pipeline.process_stage.method.agent_sub_stages import internal +from astrbot.core.provider.entities import ProviderRequest, TokenUsage + + +@pytest.mark.asyncio +async def test_record_internal_agent_stats_persists_provider_stat( + temp_db, + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setattr(internal, "db_helper", temp_db) + + event = SimpleNamespace(unified_msg_origin="webchat:FriendMessage:session-42") + req = ProviderRequest( + conversation=SimpleNamespace(cid="conv-123"), + ) + stats = AgentStats( + token_usage=TokenUsage(input_other=11, input_cached=3, output=7), + start_time=100.0, + end_time=108.5, + time_to_first_token=0.6, + ) + provider = SimpleNamespace( + provider_config={"id": "provider-1"}, + meta=lambda: SimpleNamespace(id="provider-1", type="openai"), + get_model=lambda: "gpt-4.1", + ) + agent_runner = SimpleNamespace( + provider=provider, + stats=stats, + was_aborted=lambda: False, + ) + final_resp = SimpleNamespace(role="assistant") + + await internal._record_internal_agent_stats( + event, + req, + agent_runner, + final_resp, + ) + + async with temp_db.get_db() as session: + result = await session.execute(select(ProviderStat)) + records = result.scalars().all() + + assert len(records) == 1 + record = records[0] + assert record.agent_type == "internal" + assert record.status == "completed" + assert record.umo == "webchat:FriendMessage:session-42" + assert record.conversation_id == "conv-123" + assert record.provider_id == "provider-1" + assert record.provider_model == "gpt-4.1" + assert record.token_input_other == 11 + assert record.token_input_cached == 3 + assert record.token_output == 7 + assert record.start_time == 100.0 + assert record.end_time == 108.5 + assert record.time_to_first_token == 0.6