Compare commits

...

25 Commits

Author SHA1 Message Date
Soulter
afa43fc0e2 chore: update version to 4.22.2 in multiple files 2026-03-29 00:32:31 +08:00
Soulter
551c956107 feat: implement EmptyModelOutputError for handling empty responses across providers and enhance retry logic in ToolLoopAgentRunner (#7104)
closes: #7044
2026-03-29 00:03:05 +08:00
Soulter
1070804b90 feat(dashboard): add SSL configuration resolution for dashboard (#7102)
fixes: #7058

program will not exit but fallback to non-ssl mode when ssl config is wrong
2026-03-28 23:22:24 +08:00
Soulter
7db7f4a16c feat(agent-runner): add tool_choice parameter to fix empty tool calls response in "skills-like" tool call mode (#7101)
fixes: #7049
2026-03-28 23:06:06 +08:00
Rainor_da!
77419e0bc7 fix(webui): handle history wheel on preview container (#6972) 2026-03-28 21:38:27 +08:00
Yokami
971bcbad10 fix(provider): fix Bailian rerank payload compatibility for qwen3-rerank (#6222)
* fix(provider): align bailian qwen3 rerank payload with latest API schema

* fix(provider): explicitly ignore unsupported return_documents for qwen3 rerank
2026-03-28 21:27:53 +08:00
Flartiny
da1eb65afe feat: allow copy config from existing configs (#6785)
* feat: allow copy config from existing configs

* fix: issues mentioned by reviewer bot

- duplicated logic for initializing/resetting the config
- check whitespace-only names
2026-03-28 21:25:19 +08:00
Foolllll
bbec8efa0d fix(dashboard): apply labels mapping for list options in config renderer (#6844) 2026-03-28 21:22:00 +08:00
Soulter
b98bd3898f feat: update QQOfficialPlatformAdapter to support async parsing and attachment preparation (#7007)
fixes: #6853
2026-03-28 21:18:33 +08:00
秋月白
81f4bd4e67 fix: allow multiple skills in a single zip archive (#7070)
* fix: allow multiple skills in a single zip archive

* refactor: address bot review comments

* fix: apply ruff format and fix return value
2026-03-28 21:16:40 +08:00
Stardust
4e9916caa4 fix(pipeline): skip waking on empty messages (#6893)
* fix(pipeline): skip waking on empty messages

* fix(pipeline): skip empty message LLM request even with provider_request

* fix(pipeline): skip empty messages in empty_mention_waiter
2026-03-28 20:50:42 +08:00
Rain-0x01_
995a318232 fix(gsvi_tts): Use the correct calling method (#7083)
* fix(gsvi_tts): Use the correct calling method (#5638)
Add some configuration items for GSVI

* fix(gsvi_tts): add default value for api_key in provider configuration

* fix(gsvi_tts): Adjust wherever the Authorization header is built to only include it when `self.api_key` is truthy
Delete some comments

* chore: ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-28 20:48:43 +08:00
エイカク
bcbf7dd8df fix: bundle httpx SOCKS proxy support (#7093)
* fix: bundle httpx SOCKS proxy support

* test: tighten SOCKS dependency regression coverage

* test: allow SOCKS dependency environment markers

* test: broaden SOCKS dependency regex coverage
2026-03-28 21:31:52 +09:00
奉眠
fcfd6a9e1c fix(weixin_oc): allow CDN uploads to use upload_full_url when provided (#7066)
* fix(weixin_oc): 处理微信开放平台CDN上传URL的新格式

适配微信开放平台`getuploadurl`接口返回的新格式,该接口现在可能返回`upload_full_url`字段。
优先使用`upload_full_url`作为上传地址,以保持上传功能正常。

* fix(weixin_oc): 改进CDN上传URL缺失时的错误信息

- 在适配器中,将通用错误信息具体化为“CDN上传URL缺失”
- 在客户端中,移除冗余的参数处理逻辑,使参数验证更清晰

* fix(weixin_oc): 调整CDN上传参数顺序并移除冗余检查

移除对weixin_oc_adapter中upload_param和upload_full_url同时为空的检查,因为逻辑上已由底层方法保证。
调整upload_to_cdn方法的参数顺序以匹配其内部实现,确保正确传递。
2026-03-28 14:45:58 +08:00
tlw00988
9238ad58ff docs: corrent weixin_oc wechat version requirements (#7068)
* docs:corrent weixin_oc wechat version requirements 

Update WeChat version requirements for the adapter.

* Update docs/zh/platform/weixin_oc.md

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

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-28 14:37:06 +08:00
Haoyuan Li
55ed0289c2 feat(weixin_oc): Add "typing" ("对方正在输入...") state control for weixin_oc plateform (#6977)
* feat: Add "typing" state control for weixin_oc plateform

* fix: avoid typing state mutation during cleanup

* fix: preserve typing error tracebacks in logs

* refactor: simplify typing task cancellation flow

* chore: remove tests

* fix: remove unnecessary platform check for stopping typing

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-27 15:54:41 +08:00
Soulter
777b831691 fix(weixin_oc): add error handling and retry logic for inbound updates polling (#7041)
fixes: #7022
2026-03-27 15:36:31 +08:00
一袋米要扛幾樓
383df74e34 fix(chatui): refactor routing and layout to drive UI mode from URL and scope state to sessionStorage (#6535)
* 移除所有使用 localStorage 的路由,改为使用 sessionStorage

* 增加修正

* 增加修正

* 增加修正

* 增加修正

* 增加修正

* 回退修正 就這樣吧

* 小修正
2026-03-27 14:01:18 +08:00
silwings1986
26627887d1 fix(wecom): fallback to message API when kf returns 40096 (#7012)
* fix(wecom): fallback to regular message API when kf API returns 40096

When sending WeCom messages via kf/send_msg, if the API returns error
40096 (invalid external userid), fall back to the regular message/send
API. This handles internal employees who don't have external userids.

Fixes the issue where internal WeCom users (e.g. WangCong) would cause
kf API to fail with 'invalid external userid' error.

* fix(wecom): improve error handling for kf API fallback to regular message API

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-27 11:34:18 +08:00
sanyekana
a5e86c8b94 fix(telegram): preserve attachment captions (#7020)
* fix(telegram): preserve attachment captions

* Update astrbot/core/platform/sources/telegram/tg_adapter.py

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

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-27 11:29:03 +08:00
Izayoi9
af6f9cfc5e fix: 使用 removesuffix 替代 rstrip 修复 URL 字符误删问题 (#7026)
之前在 #6863 中我提交的修复使用了 rstrip() 来移除末尾的 /embeddings,
但 rstrip() 是字符集操作,会误删 URL 末尾属于该字符集的字符。

例如 siliconflow.cn 的末尾 n 会被误删,导致 URL 变成 siliconflow.c

改用 removesuffix() 可以正确处理这种情况,只在字符串以指定后缀结尾时才移除。

closes #7025
2026-03-27 11:24:06 +08:00
SJ
8986d05309 fix(dashboard): update aiocqhttp tutorial links (#7038)
Co-authored-by: idiotsj <idiotsj@users.noreply.github.com>
2026-03-27 11:16:21 +08:00
Soulter
045be7943d revert: "fix(provider): restore parameter transparency in core LLM provider ad…" (#7023)
This reverts commit 1ad7e10c0f.
2026-03-27 01:58:04 +08:00
エイカク
cd4e999526 fix: harden OpenAI attachment recovery (#7004)
* fix: harden OpenAI attachment recovery

* fix: refine OpenAI image loading

* fix: restore OpenAI image encoding errors

* refactor: streamline OpenAI image helpers

* refactor: simplify OpenAI attachment helpers

* refactor: simplify OpenAI helper flow

* refactor: clarify OpenAI image modes

* refactor: reduce OpenAI materialization copies
2026-03-27 00:49:19 +09:00
M1LKT
6db9aef3ea Feat(webui): improve code block readability in dark mode(iss#6963) (#7014)
* Feat(webui): improve code block readability in dark mode

* fix(dashboard): use theme variable for code text

---------

Co-authored-by: RC-CHN <1051989940@qq.com>
2026-03-26 23:34:33 +08:00
51 changed files with 2675 additions and 407 deletions

View File

@@ -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()),

View File

@@ -1 +1 @@
__version__ = "4.22.1"
__version__ = "4.22.2"

View File

@@ -16,11 +16,18 @@ from mcp.types import (
TextContent,
TextResourceContents,
)
from tenacity import (
AsyncRetrying,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from astrbot import logger
from astrbot.core.agent.message import ImageURLPart, TextPart, ThinkPart
from astrbot.core.agent.tool import ToolSet
from astrbot.core.agent.tool_image_cache import tool_image_cache
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.message.components import Json
from astrbot.core.message.message_event_result import (
MessageChain,
@@ -95,11 +102,41 @@ USER_INTERRUPTION_MESSAGE = (
class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
EMPTY_OUTPUT_RETRY_ATTEMPTS = 3
EMPTY_OUTPUT_RETRY_WAIT_MIN_S = 1
EMPTY_OUTPUT_RETRY_WAIT_MAX_S = 4
def _get_persona_custom_error_message(self) -> str | None:
"""Read persona-level custom error message from event extras when available."""
event = getattr(self.run_context.context, "event", None)
return extract_persona_custom_error_message_from_event(event)
async def _complete_with_assistant_response(self, llm_resp: LLMResponse) -> None:
"""Finalize the current step as a plain assistant response with no tool calls."""
self.final_llm_resp = llm_resp
self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
parts = []
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
think=llm_resp.reasoning_content,
encrypted=llm_resp.reasoning_signature,
)
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
if len(parts) == 0:
logger.warning("LLM returned empty assistant message with no tool calls.")
self.run_context.messages.append(Message(role="assistant", content=parts))
try:
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
except Exception as e:
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
self._resolve_unconsumed_follow_ups()
@override
async def reset(
self,
@@ -253,31 +290,61 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
candidate_id,
)
self.provider = candidate
has_stream_output = False
try:
async for resp in self._iter_llm_responses(include_model=idx == 0):
if resp.is_chunk:
has_stream_output = True
yield resp
continue
retrying = AsyncRetrying(
retry=retry_if_exception_type(EmptyModelOutputError),
stop=stop_after_attempt(self.EMPTY_OUTPUT_RETRY_ATTEMPTS),
wait=wait_exponential(
multiplier=1,
min=self.EMPTY_OUTPUT_RETRY_WAIT_MIN_S,
max=self.EMPTY_OUTPUT_RETRY_WAIT_MAX_S,
),
reraise=True,
)
if (
resp.role == "err"
and not has_stream_output
and (not is_last_candidate)
):
last_err_response = resp
logger.warning(
"Chat Model %s returns error response, trying fallback to next provider.",
candidate_id,
)
break
async for attempt in retrying:
has_stream_output = False
with attempt:
try:
async for resp in self._iter_llm_responses(
include_model=idx == 0
):
if resp.is_chunk:
has_stream_output = True
yield resp
continue
yield resp
return
if (
resp.role == "err"
and not has_stream_output
and (not is_last_candidate)
):
last_err_response = resp
logger.warning(
"Chat Model %s returns error response, trying fallback to next provider.",
candidate_id,
)
break
if has_stream_output:
return
yield resp
return
if has_stream_output:
return
except EmptyModelOutputError:
if has_stream_output:
logger.warning(
"Chat Model %s returned empty output after streaming started; skipping empty-output retry.",
candidate_id,
)
else:
logger.warning(
"Chat Model %s returned empty output on attempt %s/%s.",
candidate_id,
attempt.retry_state.attempt_number,
self.EMPTY_OUTPUT_RETRY_ATTEMPTS,
)
raise
except Exception as exc: # noqa: BLE001
last_exception = exc
logger.warning(
@@ -463,34 +530,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
return
if not llm_resp.tools_call_name:
# 如果没有工具调用,转换到完成状态
self.final_llm_resp = llm_resp
self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
# record the final assistant message
parts = []
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
think=llm_resp.reasoning_content,
encrypted=llm_resp.reasoning_signature,
)
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
if len(parts) == 0:
logger.warning(
"LLM returned empty assistant message with no tool calls."
)
self.run_context.messages.append(Message(role="assistant", content=parts))
# call the on_agent_done hook
try:
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
except Exception as e:
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
self._resolve_unconsumed_follow_ups()
await self._complete_with_assistant_response(llm_resp)
# 返回 LLM 结果
if llm_resp.result_chain:
@@ -510,6 +550,24 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if llm_resp.tools_call_name:
if self.tool_schema_mode == "skills_like":
llm_resp, _ = await self._resolve_tool_exec(llm_resp)
if not llm_resp.tools_call_name:
logger.warning(
"skills_like tool re-query returned no tool calls; fallback to assistant response."
)
if llm_resp.result_chain:
yield AgentResponse(
type="llm_result",
data=AgentResponseData(chain=llm_resp.result_chain),
)
elif llm_resp.completion_text:
yield AgentResponse(
type="llm_result",
data=AgentResponseData(
chain=MessageChain().message(llm_resp.completion_text),
),
)
await self._complete_with_assistant_response(llm_resp)
return
tool_call_result_blocks = []
cached_images = [] # Collect cached images for LLM visibility
@@ -873,7 +931,9 @@ 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, T.Any]]:
"""Build contexts for re-querying LLM with param-only tool schemas."""
contexts: list[dict[str, T.Any]] = []
@@ -888,6 +948,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}"
@@ -895,6 +957,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
contexts.insert(0, {"role": "system", "content": instruction})
return contexts
@staticmethod
def _has_meaningful_assistant_reply(llm_resp: LLMResponse) -> bool:
text = (llm_resp.completion_text or "").strip()
return bool(text)
def _build_tool_subset(self, tool_set: ToolSet, tool_names: list[str]) -> ToolSet:
"""Build a subset of tools from the given tool set based on tool names."""
subset = ToolSet()
@@ -932,11 +999,45 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
model=self.req.model,
session_id=self.req.session_id,
extra_user_content_parts=self.req.extra_user_content_parts,
tool_choice="required",
abort_signal=self._abort_signal,
)
if requery_resp:
llm_resp = requery_resp
# If the re-query still returns no tool calls, and also does not have a meaningful assistant reply,
# we consider it as a failure of the LLM to follow the tool-use instruction,
# and we will retry once with a stronger instruction that explicitly requires the LLM to either call the tool or give an explanation.
if (
not llm_resp.tools_call_name
and not self._has_meaningful_assistant_reply(llm_resp)
):
logger.warning(
"skills_like tool re-query returned no tool calls and no explanation; retrying with stronger instruction."
)
repair_contexts = self._build_tool_requery_context(
tool_names,
extra_instruction=(
"This is the second-stage tool execution step. "
"You must do exactly one of the following: "
"1. Call one of the selected tools using the provided tool schema. "
"2. If calling a tool is no longer possible or appropriate, reply to the user with a brief explanation of why. "
"Do not return an empty response. "
"Do not ignore the selected tools without explanation."
),
)
repair_resp = await self.provider.text_chat(
contexts=repair_contexts,
func_tool=param_subset,
model=self.req.model,
session_id=self.req.session_id,
extra_user_content_parts=self.req.extra_user_content_parts,
tool_choice="required",
abort_signal=self._abort_signal,
)
if repair_resp:
llm_resp = repair_resp
return llm_resp, subset
def done(self) -> bool:

View File

@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.22.1"
VERSION = "4.22.2"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {
@@ -1623,10 +1623,14 @@ CONFIG_METADATA_2 = {
"type": "gsvi_tts_api",
"provider": "gpt_sovits_inference",
"provider_type": "text_to_speech",
"api_base": "http://127.0.0.1:5000",
"character": "",
"emotion": "default",
"enable": False,
"api_key": "",
"api_base": "http://127.0.0.1:8000",
"version": "v4",
"character": "",
"prompt_text_lang": "中文",
"emotion": "默认",
"text_lang": "中文",
"timeout": 20,
},
"FishAudio TTS(API)": {

View File

@@ -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."""

View File

@@ -76,8 +76,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:

View File

@@ -144,6 +144,7 @@ class InternalAgentSubStage(Stage):
follow_up_capture: FollowUpCapture | None = None
follow_up_consumed_marked = False
follow_up_activated = False
typing_requested = False
try:
streaming_response = self.streaming_response
if (enable_streaming := event.get_extra("enable_streaming")) is not None:
@@ -178,7 +179,11 @@ class InternalAgentSubStage(Stage):
)
return
await event.send_typing()
try:
typing_requested = True
await event.send_typing()
except Exception:
logger.warning("send_typing failed", exc_info=True)
await call_event_hook(event, EventType.OnWaitingLLMRequestEvent)
async with session_lock_manager.acquire_lock(event.unified_msg_origin):
@@ -377,6 +382,11 @@ class InternalAgentSubStage(Stage):
)
await event.send(MessageChain().message(error_text))
finally:
if typing_requested:
try:
await event.stop_typing()
except Exception:
logger.warning("stop_typing failed", exc_info=True)
if follow_up_capture:
await finalize_follow_up_capture(
follow_up_capture,

View File

@@ -293,6 +293,12 @@ class AstrMessageEvent(abc.ABC):
默认实现为空,由具体平台按需重写。
"""
async def stop_typing(self) -> None:
"""停止输入中状态。
默认实现为空,由具体平台按需重写。
"""
async def _pre_send(self) -> None:
"""调度器会在执行 send() 前调用该方法 deprecated in v3.5.18"""

View File

@@ -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
@@ -24,6 +26,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.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_file
from ...register import register_platform_adapter
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"<faceType=\d+[^>]*>", 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(

View File

@@ -31,7 +31,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,
)
@@ -42,7 +42,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,
)
@@ -55,7 +55,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,
)
@@ -65,7 +65,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,
)

View File

@@ -335,6 +335,18 @@ class TelegramPlatformAdapter(Platform):
logger.warning("Received an update without a message.")
return None
def _apply_caption() -> None:
if update.message.caption:
message.message_str = update.message.caption
message.message.append(Comp.Plain(message.message_str))
if update.message.caption and update.message.caption_entities:
for entity in update.message.caption_entities:
if entity.type == "mention":
name = update.message.caption[
entity.offset + 1 : entity.offset + entity.length
]
message.message.append(Comp.At(qq=name, name=name))
message = AstrBotMessage()
message.session_id = str(update.message.chat.id)
@@ -454,16 +466,7 @@ class TelegramPlatformAdapter(Platform):
photo = update.message.photo[-1] # get the largest photo
file = await photo.get_file()
message.message.append(Comp.Image(file=file.file_path, url=file.file_path))
if update.message.caption:
message.message_str = update.message.caption
message.message.append(Comp.Plain(message.message_str))
if update.message.caption_entities:
for entity in update.message.caption_entities:
if entity.type == "mention":
name = message.message_str[
entity.offset + 1 : entity.offset + entity.length
]
message.message.append(Comp.At(qq=name, name=name))
_apply_caption()
elif update.message.sticker:
# 将sticker当作图片处理
@@ -486,6 +489,7 @@ class TelegramPlatformAdapter(Platform):
message.message.append(
Comp.File(file=file_path, name=file_name, url=file_path)
)
_apply_caption()
elif update.message.video:
file = await update.message.video.get_file()
@@ -497,6 +501,7 @@ class TelegramPlatformAdapter(Platform):
)
else:
message.message.append(Comp.Video(file=file_path, path=file.file_path))
_apply_caption()
return message

View File

@@ -2,6 +2,7 @@ import asyncio
import os
from wechatpy.enterprise import WeChatClient
from wechatpy.exceptions import WeChatClientException
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -95,7 +96,19 @@ class WecomPlatformEvent(AstrMessageEvent):
# Split long text messages if needed
plain_chunks = await self.split_plain(comp.text)
for chunk in plain_chunks:
kf_message_api.send_text(user_id, self.get_self_id(), chunk)
try:
kf_message_api.send_text(user_id, self.get_self_id(), chunk)
except WeChatClientException as e:
if getattr(e, "errcode", None) == 40096:
# 40096: invalid external userid, fallback to regular message API
logger.warning(
f"kf API error 40096 for user {user_id}, falling back to regular message API"
)
self.client.message.send_text(
self.get_self_id(), user_id, chunk
)
else:
raise
await asyncio.sleep(0.5) # Avoid sending too fast
elif isinstance(comp, Image):
img_path = await comp.convert_to_file_path()

View File

@@ -6,7 +6,7 @@ import hashlib
import io
import time
import uuid
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import quote
@@ -49,6 +49,17 @@ class OpenClawLoginSession:
error: str | None = None
@dataclass
class TypingSessionState:
ticket: str | None = None
ticket_context_token: str | None = None
refresh_after: float = 0.0
keepalive_task: asyncio.Task | None = None
cancel_task: asyncio.Task | None = None
owners: set[str] = field(default_factory=set)
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
@register_platform_adapter(
"weixin_oc",
"个人微信",
@@ -105,7 +116,16 @@ class WeixinOCAdapter(Platform):
self._sync_buf = ""
self._qr_expired_count = 0
self._context_tokens: dict[str, str] = {}
self._typing_states: dict[str, TypingSessionState] = {}
self._last_inbound_error = ""
self._typing_keepalive_interval_s = max(
1,
int(platform_config.get("weixin_oc_typing_keepalive_interval", 5)),
)
self._typing_ticket_ttl_s = max(
5,
int(platform_config.get("weixin_oc_typing_ticket_ttl", 60)),
)
self.token = str(platform_config.get("weixin_oc_token", "")).strip() or None
self.account_id = (
@@ -132,6 +152,316 @@ class WeixinOCAdapter(Platform):
self.client.api_timeout_ms = self.api_timeout_ms
self.client.token = self.token
def _get_typing_state(self, user_id: str) -> TypingSessionState:
state = self._typing_states.get(user_id)
if state is None:
state = TypingSessionState()
self._typing_states[user_id] = state
return state
def _typing_supported_for(self, user_id: str) -> bool:
if not self.token:
return False
return bool(self._context_tokens.get(user_id))
async def _cancel_task_safely(
self,
task: asyncio.Task | None,
*,
log_message: str | None = None,
log_args: tuple[Any, ...] = (),
) -> None:
if task is None or task.done():
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception:
if log_message is not None:
logger.warning(log_message, *log_args, exc_info=True)
async def _ensure_typing_ticket(
self,
user_id: str,
state: TypingSessionState,
) -> str | None:
now = time.monotonic()
context_token = self._context_tokens.get(user_id)
if not context_token:
return None
if (
state.ticket
and state.ticket_context_token == context_token
and state.refresh_after > now
):
return state.ticket
payload = await self.client.get_typing_config(user_id, context_token)
if int(payload.get("ret") or 0) != 0:
logger.warning(
"weixin_oc(%s): getconfig failed for %s: %s",
self.meta().id,
user_id,
payload.get("errmsg", ""),
)
return None
ticket = str(payload.get("typing_ticket", "")).strip()
if not ticket:
return None
state.ticket = ticket
state.ticket_context_token = context_token
state.refresh_after = time.monotonic() + self._typing_ticket_ttl_s
return ticket
async def _send_typing_state(
self,
user_id: str,
ticket: str,
*,
cancel: bool,
) -> None:
payload = await self.client.send_typing_state(user_id, ticket, cancel=cancel)
if int(payload.get("ret") or 0) != 0:
raise RuntimeError(
f"sendtyping failed for {user_id}: {payload.get('errmsg', '')}"
)
async def _run_typing_keepalive(self, user_id: str) -> None:
restart_needed = False
try:
await self._typing_keepalive_loop(user_id)
except asyncio.CancelledError:
raise
except Exception as e:
state = self._typing_states.get(user_id)
if state is not None:
async with state.lock:
state.refresh_after = 0.0
restart_needed = (
bool(state.owners) and not self._shutdown_event.is_set()
)
logger.warning(
"weixin_oc(%s): typing keepalive failed for %s: %s",
self.meta().id,
user_id,
e,
)
finally:
state = self._typing_states.get(user_id)
current_task = asyncio.current_task()
if state is not None and state.keepalive_task is current_task:
state.keepalive_task = None
if not restart_needed:
return
await asyncio.sleep(self._typing_keepalive_interval_s)
state = self._typing_states.get(user_id)
if state is None or self._shutdown_event.is_set():
return
async with state.lock:
if not state.owners or state.keepalive_task is not None:
return
state.keepalive_task = asyncio.create_task(
self._run_typing_keepalive(user_id)
)
async def _typing_keepalive_loop(self, user_id: str) -> None:
while not self._shutdown_event.is_set():
await asyncio.sleep(self._typing_keepalive_interval_s)
state = self._typing_states.get(user_id)
if state is None:
return
async with state.lock:
if not state.owners:
return
try:
ticket = await self._ensure_typing_ticket(user_id, state)
except Exception as e:
state.refresh_after = 0.0
logger.warning(
"weixin_oc(%s): refresh typing ticket failed for %s: %s",
self.meta().id,
user_id,
e,
)
continue
if not ticket:
continue
try:
await self._send_typing_state(user_id, ticket, cancel=False)
except Exception as e:
state.refresh_after = 0.0
logger.warning(
"weixin_oc(%s): typing keepalive send failed for %s: %s",
self.meta().id,
user_id,
e,
)
async def _delayed_cancel_typing(self, user_id: str, ticket: str) -> None:
await asyncio.sleep(0)
state = self._typing_states.get(user_id)
if state is None:
return
current_task = asyncio.current_task()
async with state.lock:
if state.cancel_task is not current_task:
return
if state.owners or state.keepalive_task is not None:
state.cancel_task = None
return
try:
await self._send_typing_state(user_id, ticket, cancel=True)
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(
"weixin_oc(%s): cancel typing failed for %s: %s",
self.meta().id,
user_id,
e,
)
finally:
state = self._typing_states.get(user_id)
if state is None:
return
async with state.lock:
if state.cancel_task is current_task:
state.cancel_task = None
async def start_typing(self, user_id: str, owner_id: str) -> None:
state = self._get_typing_state(user_id)
cancel_task: asyncio.Task | None = None
async with state.lock:
if owner_id in state.owners:
return
if not self._typing_supported_for(user_id):
return
if state.cancel_task is not None and not state.cancel_task.done():
cancel_task = state.cancel_task
cancel_task.cancel()
state.cancel_task = None
try:
ticket = await self._ensure_typing_ticket(user_id, state)
except Exception as e:
logger.warning(
"weixin_oc(%s): ensure typing ticket failed for %s: %s",
self.meta().id,
user_id,
e,
)
return
if not ticket:
return
state.ticket = ticket
state.owners.add(owner_id)
if state.keepalive_task is not None and not state.keepalive_task.done():
return
try:
await self._send_typing_state(user_id, ticket, cancel=False)
except Exception as e:
state.refresh_after = 0.0
logger.warning(
"weixin_oc(%s): send typing failed for %s: %s",
self.meta().id,
user_id,
e,
)
task = asyncio.create_task(self._run_typing_keepalive(user_id))
state.keepalive_task = task
if cancel_task is not None:
await self._cancel_task_safely(
cancel_task,
log_message="weixin_oc(%s): ignored error from cancelled typing task",
log_args=(self.meta().id,),
)
async def stop_typing(self, user_id: str, owner_id: str) -> None:
state = self._typing_states.get(user_id)
if state is None:
return
task: asyncio.Task | None = None
async with state.lock:
if owner_id not in state.owners:
return
state.owners.remove(owner_id)
if state.owners:
return
task = state.keepalive_task
state.keepalive_task = None
await self._cancel_task_safely(
task,
log_message="weixin_oc(%s): typing keepalive stop failed for %s",
log_args=(self.meta().id, user_id),
)
async with state.lock:
if state.owners:
return
ticket = state.ticket
if ticket:
if state.cancel_task is None or state.cancel_task.done():
state.cancel_task = asyncio.create_task(
self._delayed_cancel_typing(user_id, ticket)
)
async def _cleanup_typing_tasks(self) -> None:
tasks: list[asyncio.Task] = []
cancels: list[tuple[str, str]] = []
for user_id, state in list(self._typing_states.items()):
if state.ticket and (
state.owners
or state.keepalive_task is not None
or state.cancel_task is not None
):
cancels.append((user_id, state.ticket))
state.owners.clear()
if state.keepalive_task is not None and not state.keepalive_task.done():
tasks.append(state.keepalive_task)
state.keepalive_task.cancel()
state.keepalive_task = None
if state.cancel_task is not None and not state.cancel_task.done():
tasks.append(state.cancel_task)
state.cancel_task.cancel()
state.cancel_task = None
for task in tasks:
await self._cancel_task_safely(
task,
log_message="weixin_oc(%s): typing cleanup failed",
log_args=(self.meta().id,),
)
for user_id, ticket in cancels:
try:
await self._send_typing_state(user_id, ticket, cancel=True)
except Exception as e:
logger.warning(
"weixin_oc(%s): typing cleanup cancel failed for %s: %s",
self.meta().id,
user_id,
e,
)
def _load_account_state(self) -> None:
if not self.token:
token = str(self.config.get("weixin_oc_token", "")).strip()
@@ -263,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,
@@ -902,15 +1232,24 @@ class WeixinOCAdapter(Platform):
"weixin_oc(%s): inbound long-poll timeout",
self.meta().id,
)
except Exception as e:
logger.error(
"weixin_oc(%s): poll inbound updates failed, will retry after 5 seconds: %s",
self.meta().id,
e,
)
await asyncio.sleep(5)
except asyncio.CancelledError:
raise
except Exception as e:
logger.exception("weixin_oc(%s): run failed: %s", self.meta().id, e)
finally:
await self._cleanup_typing_tasks()
await self.client.close()
async def terminate(self) -> None:
self._shutdown_event.set()
await self._cleanup_typing_tasks()
def get_stats(self) -> dict:
stat = super().get_stats()

View File

@@ -108,11 +108,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 = media_path.read_bytes()
logger.debug(
"weixin_oc(%s): prepare CDN upload file=%s size=%s md5=%s filekey=%s",
@@ -135,7 +145,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,
@@ -226,3 +235,44 @@ class WeixinOCClient:
if not text:
return {}
return cast(dict[str, Any], json.loads(text))
async def get_typing_config(
self,
user_id: str,
context_token: str,
) -> dict[str, Any]:
return await self.request_json(
"POST",
"ilink/bot/getconfig",
payload={
"ilink_user_id": user_id,
"context_token": context_token,
"base_info": {
"channel_version": "astrbot",
},
},
token_required=True,
timeout_ms=self.api_timeout_ms,
)
async def send_typing_state(
self,
user_id: str,
typing_ticket: str,
*,
cancel: bool,
) -> dict[str, Any]:
return await self.request_json(
"POST",
"ilink/bot/sendtyping",
payload={
"ilink_user_id": user_id,
"typing_ticket": typing_ticket,
"status": 2 if cancel else 1,
"base_info": {
"channel_version": "astrbot",
},
},
token_required=True,
timeout_ms=self.api_timeout_ms,
)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import uuid
from typing import TYPE_CHECKING
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -29,6 +30,12 @@ class WeixinOCMessageEvent(AstrMessageEvent):
) -> None:
super().__init__(message_str, message_obj, platform_meta, session_id)
self.platform = platform
self._typing_owner_id: str | None = None
def _get_typing_owner_id(self) -> str:
if not self._typing_owner_id:
self._typing_owner_id = uuid.uuid4().hex
return self._typing_owner_id
@staticmethod
def _segment_to_text(segment: BaseMessageComponent) -> str:
@@ -58,6 +65,18 @@ class WeixinOCMessageEvent(AstrMessageEvent):
await self.platform.send_by_session(self.session, message)
await super().send(message)
async def send_typing(self) -> None:
await self.platform.start_typing(
self.session.session_id,
self._get_typing_owner_id(),
)
async def stop_typing(self) -> None:
await self.platform.stop_typing(
self.session.session_id,
self._get_typing_owner_id(),
)
async def send_streaming(self, generator, use_fallback: bool = False):
if not use_fallback:
buffer = None

View File

@@ -2,7 +2,7 @@ import abc
import asyncio
import os
from collections.abc import AsyncGenerator
from typing import TypeAlias, Union
from typing import Literal, TypeAlias, Union
from astrbot.core.agent.message import ContentPart, Message
from astrbot.core.agent.tool import ToolSet
@@ -104,6 +104,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 的文本对话结果。会使用当前的模型进行对话。
@@ -113,6 +114,7 @@ class Provider(AbstractProvider):
session_id: 会话 ID(此属性已经被废弃)
image_urls: 图片 URL 列表
tools: tool set
tool_choice: 工具调用策略,`auto` 表示由模型自行决定,`required` 表示要求模型必须调用工具
contexts: 上下文,和 prompt 二选一使用
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
extra_user_content_parts: 额外的内容块列表,用于在用户消息后添加额外的文本块(如系统提醒、指令等)
@@ -135,6 +137,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 的流式文本对话结果。会使用当前的模型进行对话。在生成的最后会返回一次完整的结果。
@@ -144,6 +147,7 @@ class Provider(AbstractProvider):
session_id: 会话 ID(此属性已经被废弃)
image_urls: 图片 URL 列表
tools: tool set
tool_choice: 工具调用策略,`auto` 表示由模型自行决定,`required` 表示要求模型必须调用工具
contexts: 上下文,和 prompt 二选一使用
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
kwargs: 其他参数

View File

@@ -1,6 +1,7 @@
import base64
import json
from collections.abc import AsyncGenerator
from typing import Literal
import anthropic
import httpx
@@ -12,6 +13,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.utils.io import download_image_by_url
@@ -28,6 +30,23 @@ from ..register import register_provider_adapter
"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:
@@ -515,7 +556,9 @@ class ProviderAnthropic(Provider):
model = model or self.get_model()
payloads = {**kwargs, "messages": new_messages, "model": 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:
@@ -571,7 +615,9 @@ class ProviderAnthropic(Provider):
model = model or self.get_model()
payloads = {**kwargs, "messages": new_messages, "model": 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:

View File

@@ -33,6 +33,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
@@ -83,19 +85,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
}

View File

@@ -4,7 +4,7 @@ import json
import logging
import random
from collections.abc import AsyncGenerator
from typing import cast
from typing import Literal, cast
from google import genai
from google.genai import types
@@ -14,6 +14,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,
@@ -207,6 +209,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 [
@@ -272,6 +286,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(
@@ -430,6 +445,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,
@@ -438,7 +470,10 @@ class ProviderGoogleGenAI(Provider):
"""处理内容部分并构建消息链"""
if not candidate.content:
logger.warning(f"收到的 candidate.content 为空: {candidate}")
raise Exception("API 返回的 candidate.content 为空。")
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
@@ -460,7 +495,10 @@ class ProviderGoogleGenAI(Provider):
if not result_parts:
logger.warning(f"收到的 candidate.content.parts 为空: {candidate}")
raise Exception("API 返回的 candidate.content.parts 为空。")
raise EmptyModelOutputError(
"Gemini candidate content parts are empty. "
f"finish_reason={candidate.finish_reason}"
)
# 提取 reasoning content
reasoning = self._extract_reasoning_content(candidate)
@@ -511,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"""
@@ -535,6 +580,7 @@ class ProviderGoogleGenAI(Provider):
config = await self._prepare_query_config(
payloads,
tools,
payloads.get("tool_choice", "auto"),
system_instruction,
modalities,
temperature,
@@ -616,6 +662,7 @@ class ProviderGoogleGenAI(Provider):
config = await self._prepare_query_config(
payloads,
tools,
payloads.get("tool_choice", "auto"),
system_instruction,
)
result = await self.client.models.generate_content_stream(
@@ -711,9 +758,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 +778,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:
@@ -757,7 +808,9 @@ class ProviderGoogleGenAI(Provider):
model = model or self.get_model()
payloads = {**kwargs, "messages": context_query, "model": 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 +836,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:
@@ -812,7 +866,9 @@ class ProviderGoogleGenAI(Provider):
model = model or self.get_model()
payloads = {**kwargs, "messages": context_query, "model": 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()

View File

@@ -1,6 +1,5 @@
import os
import urllib.parse
import uuid
from pathlib import Path
import aiohttp
@@ -23,37 +22,55 @@ class ProviderGSVITTS(TTSProvider):
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_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.emotion = provider_config.get("emotion")
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 = os.path.join(temp_dir, f"gsvi_tts_{uuid.uuid4()}.wav")
params = {"text": text}
path = Path(temp_dir) / f"gsvi_tts_{uuid.uuid4()}.wav"
url = f"{self.api_base}/infer_single"
if self.character:
params["character"] = self.character
if self.emotion:
params["emotion"] = self.emotion
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
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)}"
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.get(url) as response:
async with session.post(url, json=data, headers=headers) as response:
if response.status == 200:
with open(path, "wb") as f:
f.write(await response.read())
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 path
return str(path)

View File

@@ -27,8 +27,8 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
api_base = (
provider_config.get("embedding_api_base", "https://api.openai.com/v1")
.strip()
.rstrip("/")
.rstrip("/embeddings")
.removesuffix("/")
.removesuffix("/embeddings")
)
if api_base and not api_base.endswith("/v1") and not api_base.endswith("/v4"):
# /v4 see #5699

View File

@@ -1,11 +1,15 @@
import asyncio
import base64
import copy
import inspect
import json
import random
import re
from collections.abc import AsyncGenerator
from typing import Any
from io import BytesIO
from pathlib import Path
from typing import Any, Literal
from urllib.parse import unquote, urlparse
import httpx
from openai import AsyncAzureOpenAI, AsyncOpenAI
@@ -14,12 +18,15 @@ from openai.lib.streaming.chat._completions import ChatCompletionStreamState
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.completion_usage import CompletionUsage
from PIL import Image as PILImage
from PIL import UnidentifiedImageError
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, 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.utils.io import download_image_by_url
@@ -133,6 +140,186 @@ class ProviderOpenAIOfficial(Provider):
return True
return False
def _is_invalid_attachment_error(self, error: Exception) -> bool:
body = getattr(error, "body", None)
code: str | None = None
message: str | None = None
if isinstance(body, dict):
err_obj = body.get("error")
if isinstance(err_obj, dict):
raw_code = err_obj.get("code")
raw_message = err_obj.get("message")
code = raw_code.lower() if isinstance(raw_code, str) else None
message = raw_message.lower() if isinstance(raw_message, str) else None
if code == "invalid_attachment":
return True
text_sources: list[str] = []
if message:
text_sources.append(message)
if code:
text_sources.append(code)
text_sources.extend(map(str, self._extract_error_text_candidates(error)))
error_text = " ".join(text.lower() for text in text_sources if text)
if "invalid_attachment" in error_text:
return True
if "download attachment" in error_text and "404" in error_text:
return True
return False
@classmethod
def _encode_image_file_to_data_url(
cls,
image_path: str,
*,
mode: Literal["safe", "strict"],
) -> str | None:
try:
image_bytes = Path(image_path).read_bytes()
except OSError:
if mode == "strict":
raise
return None
try:
with PILImage.open(BytesIO(image_bytes)) as image:
image.verify()
image_format = str(image.format or "").upper()
except (OSError, UnidentifiedImageError):
if mode == "strict":
raise ValueError(f"Invalid image file: {image_path}")
return None
mime_type = {
"JPEG": "image/jpeg",
"PNG": "image/png",
"GIF": "image/gif",
"WEBP": "image/webp",
"BMP": "image/bmp",
}.get(image_format, "image/jpeg")
image_bs64 = base64.b64encode(image_bytes).decode("utf-8")
return f"data:{mime_type};base64,{image_bs64}"
@staticmethod
def _file_uri_to_path(file_uri: str) -> str:
"""Normalize file URIs to paths.
`file://localhost/...` and drive-letter forms are treated as local paths.
Other non-empty hosts are preserved as UNC-style paths.
"""
parsed = urlparse(file_uri)
if parsed.scheme != "file":
return file_uri
netloc = unquote(parsed.netloc or "")
path = unquote(parsed.path or "")
if re.fullmatch(r"[A-Za-z]:", netloc):
return str(Path(f"{netloc}{path}"))
if re.match(r"^/[A-Za-z]:/", path):
path = path[1:]
if netloc and netloc != "localhost":
path = f"//{netloc}{path}"
return str(Path(path))
async def _image_ref_to_data_url(
self,
image_ref: str,
*,
mode: Literal["safe", "strict"] = "safe",
) -> str | None:
if image_ref.startswith("base64://"):
return image_ref.replace("base64://", "data:image/jpeg;base64,")
if image_ref.startswith("http"):
image_path = await download_image_by_url(image_ref)
elif image_ref.startswith("file://"):
image_path = self._file_uri_to_path(image_ref)
else:
image_path = image_ref
return self._encode_image_file_to_data_url(
image_path,
mode=mode,
)
async def _resolve_image_part(
self,
image_url: str,
*,
image_detail: str | None = None,
) -> dict | None:
if image_url.startswith("data:"):
image_payload = {"url": image_url}
else:
image_data = await self._image_ref_to_data_url(image_url, mode="safe")
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
return None
image_payload = {"url": image_data}
if image_detail:
image_payload["detail"] = image_detail
return {
"type": "image_url",
"image_url": image_payload,
}
def _extract_image_part_info(self, part: dict) -> tuple[str | None, str | None]:
if not isinstance(part, dict) or part.get("type") != "image_url":
return None, None
image_url_data = part.get("image_url")
if not isinstance(image_url_data, dict):
logger.warning("图片内容块格式无效,将保留原始内容。")
return None, None
url = image_url_data.get("url")
if not isinstance(url, str) or not url:
logger.warning("图片内容块缺少有效 URL将保留原始内容。")
return None, None
image_detail = image_url_data.get("detail")
if not isinstance(image_detail, str):
image_detail = None
return url, image_detail
async def _transform_content_part(self, part: dict) -> dict:
url, image_detail = self._extract_image_part_info(part)
if not url:
return part
try:
resolved_part = await self._resolve_image_part(
url, image_detail=image_detail
)
except Exception as exc:
logger.warning(
"图片 %s 预处理失败,将保留原始内容。错误: %s",
url,
exc,
)
return part
return resolved_part or part
async def _materialize_message_image_parts(self, message: dict) -> dict:
content = message.get("content")
if not isinstance(content, list):
return {**message}
new_content = [await self._transform_content_part(part) for part in content]
return {**message, "content": new_content}
async def _materialize_context_image_parts(
self, context_query: list[dict]
) -> list[dict]:
return [
await self._materialize_message_image_parts(message)
for message in context_query
]
async def _fallback_to_text_only_and_retry(
self,
payloads: dict,
@@ -250,6 +437,7 @@ class ProviderOpenAIOfficial(Provider):
)
if tool_list:
payloads["tools"] = tool_list
payloads["tool_choice"] = payloads.get("tool_choice", "auto")
# 不在默认参数中的参数放在 extra_body 中
extra_body = {}
@@ -300,6 +488,7 @@ class ProviderOpenAIOfficial(Provider):
)
if tool_list:
payloads["tools"] = tool_list
payloads["tool_choice"] = payloads.get("tool_choice", "auto")
# 不在默认参数中的参数放在 extra_body 中
extra_body = {}
@@ -508,7 +697,9 @@ class ProviderOpenAIOfficial(Provider):
llm_response = LLMResponse("assistant")
if not completion.choices:
raise Exception("API 返回的 completion 为空。")
raise EmptyModelOutputError(
f"OpenAI completion has no choices. response_id={completion.id}"
)
choice = completion.choices[0]
# parse the text completion
@@ -526,6 +717,10 @@ class ProviderOpenAIOfficial(Provider):
# Also clean up orphan </think> tags that may leak from some models
completion_text = re.sub(r"</think>\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 <think> tag extraction
@@ -573,9 +768,18 @@ 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}")
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
@@ -604,7 +808,7 @@ class ProviderOpenAIOfficial(Provider):
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
context_query = self._ensure_message_to_dicts(contexts)
context_query = copy.deepcopy(self._ensure_message_to_dicts(contexts))
if new_record:
context_query.append(new_record)
if system_prompt:
@@ -622,8 +826,12 @@ class ProviderOpenAIOfficial(Provider):
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
if self._context_contains_image(context_query):
context_query = await self._materialize_context_image_parts(context_query)
model = model or self.get_model()
payloads = {**kwargs, "messages": context_query, "model": model}
payloads = {"messages": context_query, "model": model}
self._finally_convert_payload(payloads)
@@ -721,6 +929,18 @@ class ProviderOpenAIOfficial(Provider):
"image_content_moderated",
image_fallback_used=True,
)
if self._is_invalid_attachment_error(e):
if image_fallback_used or not self._context_contains_image(context_query):
raise e
return await self._fallback_to_text_only_and_retry(
payloads,
context_query,
chosen_key,
available_api_keys,
func_tool,
"invalid_attachment",
image_fallback_used=True,
)
if (
"Function calling is not enabled" in str(e)
@@ -763,6 +983,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(
@@ -775,6 +996,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
@@ -830,6 +1053,7 @@ class ProviderOpenAIOfficial(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
tool_choice: Literal["auto", "required"] = "auto",
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
"""流式对话,与服务商交互并逐步返回结果"""
@@ -842,6 +1066,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()
@@ -922,23 +1148,6 @@ class ProviderOpenAIOfficial(Provider):
) -> dict:
"""组装成符合 OpenAI 格式的 role 为 user 的消息段"""
async def resolve_image_part(image_url: str) -> dict | None:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
image_data = await self.encode_image_bs64(image_path)
elif image_url.startswith("file:///"):
image_path = image_url.replace("file:///", "")
image_data = await self.encode_image_bs64(image_path)
else:
image_data = await self.encode_image_bs64(image_url)
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
return None
return {
"type": "image_url",
"image_url": {"url": image_data},
}
# 构建内容块列表
content_blocks = []
@@ -958,7 +1167,9 @@ class ProviderOpenAIOfficial(Provider):
if isinstance(part, TextPart):
content_blocks.append({"type": "text", "text": part.text})
elif isinstance(part, ImageURLPart):
image_part = await resolve_image_part(part.image_url.url)
image_part = await self._resolve_image_part(
part.image_url.url,
)
if image_part:
content_blocks.append(image_part)
else:
@@ -967,7 +1178,7 @@ class ProviderOpenAIOfficial(Provider):
# 3. 图片内容
if image_urls:
for image_url in image_urls:
image_part = await resolve_image_part(image_url)
image_part = await self._resolve_image_part(image_url)
if image_part:
content_blocks.append(image_part)
@@ -986,11 +1197,10 @@ class ProviderOpenAIOfficial(Provider):
async def encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64"""
if image_url.startswith("base64://"):
return image_url.replace("base64://", "data:image/jpeg;base64,")
with open(image_url, "rb") as f:
image_bs64 = base64.b64encode(f.read()).decode("utf-8")
return "data:image/jpeg;base64," + image_bs64
image_data = await self._image_ref_to_data_url(image_url, mode="strict")
if image_data is None:
raise RuntimeError(f"Failed to encode image data: {image_url}")
return image_data
async def terminate(self):
if self.client:

View File

@@ -548,6 +548,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
@@ -573,34 +575,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
@@ -609,20 +583,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=get_astrbot_temp_path()) as tmp_dir:
for member in zf.infolist():
@@ -630,21 +622,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)

View File

@@ -300,6 +300,61 @@ class AstrBotDashboard:
logger.info("Initialized random JWT secret for dashboard.")
self._jwt_secret = self.config["dashboard"]["jwt_secret"]
@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_fileSSL 配置将不会生效。",
)
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", {})
@@ -322,6 +377,11 @@ class AstrBotDashboard:
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"
if not enable:
@@ -373,41 +433,10 @@ class AstrBotDashboard:
config = HyperConfig()
config.bind = [f"{host}:{port}"]
if ssl_enable:
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", "")
)
cert_path = Path(cert_file).expanduser()
key_path = Path(key_file).expanduser()
if not cert_file or not key_file:
raise ValueError(
"dashboard.ssl.enable 为 true 时,必须配置 cert_file 和 key_file。",
)
if not cert_path.is_file():
raise ValueError(f"SSL 证书文件不存在: {cert_path}")
if not key_path.is_file():
raise ValueError(f"SSL 私钥文件不存在: {key_path}")
config.certfile = str(cert_path.resolve())
config.keyfile = str(key_path.resolve())
if ca_certs:
ca_path = Path(ca_certs).expanduser()
if not ca_path.is_file():
raise ValueError(f"SSL CA 证书文件不存在: {ca_path}")
config.ca_certs = str(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)

55
changelogs/v4.22.2.md Normal file
View File

@@ -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)

View File

@@ -259,7 +259,7 @@
<v-card-text class="py-4">
<p>{{ tm('dialog.securityWarning.aiocqhttpTokenMissing') }}</p>
<span><a
href="https://docs.astrbot.app/deploy/platform/aiocqhttp/napcat.html#%E9%99%84%E5%BD%95-%E5%A2%9E%E5%BC%BA%E8%BF%9E%E6%8E%A5%E5%AE%89%E5%85%A8%E6%80%A7"
href="https://docs.astrbot.app/platform/aiocqhttp.html"
target="_blank">{{ tm('dialog.securityWarning.learnMore') }}</a></span>
</v-card-text>
<v-card-actions class="px-4 pb-4">

View File

@@ -86,11 +86,13 @@
></v-checkbox>
</div>
<v-combobox
<v-select
v-else-if="itemMeta?.type === 'list' && itemMeta?.options"
:model-value="modelValue"
@update:model-value="emitUpdate"
:items="itemMeta.options"
:items="getSelectItems(itemMeta)"
item-title="title"
item-value="value"
:disabled="itemMeta?.readonly"
density="compact"
variant="outlined"
@@ -98,7 +100,7 @@
hide-details
chips
multiple
></v-combobox>
></v-select>
<v-select
v-else-if="itemMeta?.options"

View File

@@ -19,7 +19,6 @@ export function useSessions(chatboxMode: boolean = false) {
const selectedSessions = ref<string[]>([]);
const currSessionId = ref('');
const pendingSessionId = ref<string | null>(null);
// 编辑标题相关
const editTitleDialog = ref(false);
const editingTitle = ref('');
@@ -30,29 +29,16 @@ export function useSessions(chatboxMode: boolean = false) {
return sessions.value.find(s => s.session_id === currSessionId.value);
});
async function getSessions() {
try {
const response = await axios.get('/api/chat/sessions');
sessions.value = response.data.data;
// 处理待加载的会话
if (pendingSessionId.value) {
const session = sessions.value.find(s => s.session_id === pendingSessionId.value);
if (session) {
selectedSessions.value = [pendingSessionId.value];
pendingSessionId.value = null;
}
} else if (currSessionId.value) {
// 如果当前有选中的会话,确保它在列表中并被选中
const session = sessions.value.find(s => s.session_id === currSessionId.value);
if (session) {
selectedSessions.value = [currSessionId.value];
}
} else if (sessions.value.length > 0) {
// 默认选择第一个会话
const firstSession = sessions.value[0];
selectedSessions.value = [firstSession.session_id];
}
} catch (err: any) {
if (err.response?.status === 401) {
router.push('/auth/login?redirect=/chatbox');

View File

@@ -77,13 +77,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"
},

View File

@@ -77,13 +77,16 @@
"title": "Управление конфигурациями",
"description": "AstrBot поддерживает несколько конфигураций для разных ботов. По умолчанию используется «default».",
"newConfig": "Новая конфигурация",
"copyConfig": "Копировать конфигурацию",
"editConfig": "Изменить конфигурацию",
"manageConfigs": "Управление файлами...",
"configName": "Имя",
"fillConfigName": "Введите имя конфигурации",
"confirmDelete": "Вы уверены, что хотите удалить конфигурацию «{name}»? Это действие необратимо.",
"pleaseEnterName": "Пожалуйста, введите имя",
"nameExists": "Имя конфигурации уже существует. Используйте другое имя.",
"createFailed": "Ошибка создания конфигурации",
"copyFailed": "Ошибка копирования конфигурации",
"deleteFailed": "Ошибка удаления",
"updateFailed": "Ошибка обновления"
},
@@ -126,4 +129,4 @@
"cancel": "Отмена"
}
}
}
}

View File

@@ -77,13 +77,16 @@
"title": "配置文件管理",
"description": "AstrBot 支持针对不同机器人分别设置配置文件。默认会使用 `default` 配置。",
"newConfig": "新建配置文件",
"copyConfig": "复制配置文件",
"editConfig": "编辑配置文件",
"manageConfigs": "管理配置文件...",
"configName": "名称",
"fillConfigName": "填写配置文件名称",
"confirmDelete": "确定要删除配置文件 \"{name}\" 吗?此操作不可恢复。",
"pleaseEnterName": "请填写配置名称",
"nameExists": "配置文件名称已存在,请使用其他名称",
"createFailed": "新配置文件创建失败",
"copyFailed": "复制配置文件失败",
"deleteFailed": "删除配置文件失败",
"updateFailed": "更新配置文件失败"
},

View File

@@ -17,18 +17,10 @@ const customizer = useCustomizerStore();
const { locale } = useI18n();
const route = useRoute();
const routerLoadingStore = useRouterLoadingStore();
const isCurrentChatRoute = computed(() => route.path === '/chat' || route.path.startsWith('/chat/'));
const isChatPage = computed(() => {
return route.path.startsWith('/chat');
});
const showSidebar = computed(() => {
return customizer.viewMode === 'bot';
});
const showChatPage = computed(() => {
return customizer.viewMode === 'chat';
});
const showSidebar = computed(() => !isCurrentChatRoute.value)
const migrationDialog = ref<InstanceType<typeof MigrationDialog> | null>(null);
const showFirstNoticeDialog = ref(false);
@@ -111,20 +103,20 @@ onMounted(() => {
<VerticalHeaderVue />
<VerticalSidebarVue v-if="showSidebar" />
<v-main :style="{
height: showChatPage ? 'calc(100vh - 55px)' : undefined,
overflow: showChatPage ? 'hidden' : undefined
height: isCurrentChatRoute ? 'calc(100vh - 55px)' : undefined,
overflow: isCurrentChatRoute ? 'hidden' : undefined
}">
<v-container
fluid
class="page-wrapper"
:class="{ 'chat-mode-container': showChatPage }"
:class="{ 'chat-mode-container': isCurrentChatRoute }"
:style="{
height: showChatPage ? '100%' : 'calc(100% - 8px)',
padding: (isChatPage || showChatPage) ? '0' : undefined,
minHeight: showChatPage ? 'unset' : undefined
height: isCurrentChatRoute ? '100%' : 'calc(100% - 8px)',
padding: isCurrentChatRoute ? '0' : undefined,
minHeight: isCurrentChatRoute ? 'unset' : undefined
}">
<div :style="{ height: '100%', width: '100%', overflow: showChatPage ? 'hidden' : undefined }">
<div v-if="showChatPage" style="height: 100%; width: 100%; overflow: hidden;">
<div :style="{ height: '100%', width: '100%', overflow: isCurrentChatRoute ? 'hidden' : undefined }">
<div v-if="isCurrentChatRoute" style="height: 100%; width: 100%; overflow: hidden;">
<Chat />
</div>
<RouterView v-else />

View File

@@ -28,6 +28,7 @@ const theme = useTheme();
const { t } = useI18n();
const route = useRoute();
const LAST_BOT_ROUTE_KEY = 'astrbot:last_bot_route';
const LAST_CHAT_ROUTE_KEY = 'astrbot:last_chat_route';
let dialog = ref(false);
let accountWarning = ref(false)
let updateStatusDialog = ref(false);
@@ -58,7 +59,9 @@ const desktopUpdateHasNewVersion = ref(false);
const desktopUpdateCurrentVersion = ref('-');
const desktopUpdateLatestVersion = ref('-');
const desktopUpdateStatus = ref('');
const isChatPath = computed(() =>
route.path === '/chat' || route.path.startsWith('/chat/')
);
const getAppUpdaterBridge = (): AstrBotAppUpdaterBridge | null => {
if (typeof window === 'undefined') {
return null;
@@ -380,7 +383,7 @@ function openReleaseNotesDialog(body: string, tag: string) {
}
function handleLogoClick() {
if (customizer.viewMode === 'chat') {
if (isChatPath.value) {
aboutDialog.value = true;
} else {
router.push('/about');
@@ -395,10 +398,22 @@ commonStore.createEventSource(); // log
commonStore.getStartTime();
// 视图模式切换
const viewMode = computed({
get: () => customizer.viewMode,
set: (value: 'bot' | 'chat') => {
customizer.SET_VIEW_MODE(value);
onMounted(() => {
// 初次加載時保存當前路由
if (typeof window !== 'undefined') {
if (isChatPath.value) {
// 保存 chat ID
const parts = route.fullPath.split('/');
const sessionId = parts[2];
if (sessionId) {
sessionStorage.setItem(LAST_CHAT_ROUTE_KEY, sessionId);
console.log('Initial save chat ID:', sessionId);
}
} else {
// 保存 bot 路由(非 chat 頁面)
sessionStorage.setItem(LAST_BOT_ROUTE_KEY, route.fullPath);
console.log('Initial save bot route:', route.fullPath);
}
}
});
@@ -406,26 +421,61 @@ const viewMode = computed({
// 保存 bot 模式的最後路由
// 監聽 route 變化,保存最後一次 bot 路由
watch(() => route.fullPath, (newPath) => {
if (customizer.viewMode === 'bot' && typeof window !== 'undefined') {
try {
localStorage.setItem(LAST_BOT_ROUTE_KEY, newPath);
} catch (e) {
console.error('Failed to save last bot route to localStorage:', e);
if (typeof window === 'undefined') return;
console.log('Route changed:', {
newPath,
isChat: isChatPath.value,
currentChatId: route.params.id
});
try {
// 使用現有的 isChatPath 計算屬性來避免名稱衝突
const isChat = isChatPath.value; // 這裡使用已經計算好的 isChatPath
// ✅ bot只存「非 chat 頁」
if (!isChat) {
sessionStorage.setItem(LAST_BOT_ROUTE_KEY, newPath);
}
// ✅ chat只存 sessionId
if (isChat) {
const parts = newPath.split('/');
const sessionId = parts[2];
if (sessionId) {
sessionStorage.setItem(LAST_CHAT_ROUTE_KEY, sessionId);
}
}
} catch (e) {
console.error('Failed to save route:', e);
}
});
// 監聽 viewMode 切換
watch(() => customizer.viewMode, (newMode, oldMode) => {
if (newMode === 'bot' && oldMode === 'chat' && typeof window !== 'undefined') {
// 從 chat 切換回 bot跳轉到最後一次的 bot 路由
let lastBotRoute = '/';
const currentMode = computed({
get: () => (isChatPath.value ? 'chat' : 'bot'),
set: (val: 'chat' | 'bot') => {
try {
lastBotRoute = localStorage.getItem(LAST_BOT_ROUTE_KEY) || '/';
// 檢查 window 和 sessionStorage 是否存在
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
// 如果在非瀏覽器環境中,不做任何 sessionStorage 操作
console.warn('sessionStorage is not available in this environment');
return;
}
if (val === 'chat') {
const lastSessionId = sessionStorage.getItem(LAST_CHAT_ROUTE_KEY);
router.push(lastSessionId ? `/chat/${lastSessionId}` : '/chat');
} else {
let lastBotRoute = sessionStorage.getItem(LAST_BOT_ROUTE_KEY) || '/';
if (lastBotRoute.startsWith('/chat')) {
lastBotRoute = '/';
}
router.push(lastBotRoute);
}
} catch (e) {
console.error('Failed to read last bot route from localStorage:', e);
// 在受限隱私模式等環境中sessionStorage 操作可能會拋出 SecurityError
console.warn('Failed to access sessionStorage in currentMode setter:', e);
}
router.push(lastBotRoute);
}
});
@@ -465,29 +515,46 @@ onMounted(async () => {
<v-app-bar elevation="0" height="50" class="top-header">
<!-- 桌面端 menu 按钮 - 仅在 bot 模式下显示 -->
<v-btn v-if="customizer.viewMode === 'bot'"
style="margin-left: 16px;"
class="hidden-md-and-down" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)">
<v-icon>mdi-menu</v-icon>
</v-btn>
<!-- 移动端 menu 按钮 - 仅在 bot 模式下显示 -->
<v-btn v-if="customizer.viewMode === 'bot'" class="hidden-lg-and-up ms-3" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_SIDEBAR_DRAWER">
<v-icon>mdi-menu</v-icon>
</v-btn>
<v-btn
v-if="!isChatPath"
style="margin-left: 16px;"
class="hidden-md-and-down"
icon
rounded="sm"
variant="flat"
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)"
>
<v-icon>mdi-menu</v-icon>
</v-btn>
<!-- 移动端 chat sidebar 展开按钮 - 仅在 chat 模式下的小屏幕显示 -->
<v-btn v-if="customizer.viewMode === 'chat'" class="hidden-lg-and-up ms-1" icon rounded="sm" variant="flat"
@click.stop="customizer.TOGGLE_CHAT_SIDEBAR()">
<v-icon>mdi-menu</v-icon>
</v-btn>
<!-- 移动端 menu 按钮 -->
<v-btn
v-if="!isChatPath"
class="hidden-lg-and-up ms-3"
icon
rounded="sm"
variant="flat"
@click.stop="customizer.SET_SIDEBAR_DRAWER"
>
<v-icon>mdi-menu</v-icon>
</v-btn>
<div class="logo-container" :class="{ 'mobile-logo': $vuetify.display.xs, 'chat-mode-logo': customizer.viewMode === 'chat' }" @click="handleLogoClick">
<v-btn
v-if="isChatPath"
class="hidden-lg-and-up ms-1"
icon
rounded="sm"
variant="flat"
@click.stop="customizer.TOGGLE_CHAT_SIDEBAR()"
>
<v-icon>mdi-menu</v-icon>
</v-btn>
<div class="logo-container" :class="{ 'mobile-logo': $vuetify.display.xs, 'chat-mode-logo': isChatPath }" @click="handleLogoClick">
<span class="logo-text Outfit">Astr<span class="logo-text bot-text-wrapper">Bot
<img v-if="isChristmas" src="@/assets/images/xmas-hat.png" alt="Christmas hat" class="xmas-hat" />
</span></span>
<span class="logo-text logo-text-light Outfit" style="color: grey;" v-if="customizer.viewMode === 'chat'">ChatUI</span>
<span class="logo-text logo-text-light Outfit" style="color: grey;" v-if="isChatPath">ChatUI</span>
<span class="version-text hidden-xs">{{ botCurrVersion }}</span>
</div>
@@ -504,23 +571,23 @@ onMounted(async () => {
</div>
<!-- Bot/Chat 模式切换按钮 - 手机端隐藏移入 ... 菜单 -->
<v-btn-toggle
v-model="viewMode"
mandatory
variant="outlined"
density="compact"
class="mr-4 hidden-xs"
color="primary"
>
<v-btn value="bot" size="small">
<v-icon start>mdi-robot</v-icon>
Bot
</v-btn>
<v-btn value="chat" size="small">
<v-icon start>mdi-chat</v-icon>
Chat
</v-btn>
</v-btn-toggle>
<v-btn-toggle
v-model="currentMode"
mandatory
variant="outlined"
density="compact"
class="mr-4 hidden-xs"
color="primary"
>
<v-btn value="bot" size="small">
<v-icon start>mdi-robot</v-icon>
Bot
</v-btn>
<v-btn value="chat" size="small">
<v-icon start>mdi-chat</v-icon>
Chat
</v-btn>
</v-btn-toggle>
<!-- 功能菜单 -->
@@ -542,14 +609,14 @@ onMounted(async () => {
<!-- Bot/Chat 模式切换 - 仅在手机端显示 -->
<template v-if="$vuetify.display.xs">
<div class="mobile-mode-toggle-wrapper">
<v-btn-toggle
v-model="viewMode"
mandatory
variant="outlined"
density="compact"
color="primary"
class="mobile-mode-toggle"
>
<v-btn-toggle
v-model="currentMode"
mandatory
variant="outlined"
density="compact"
class="mobile-mode-toggle"
color="primary"
>
<v-btn value="bot" size="small">
<v-icon start>mdi-robot</v-icon>
Bot

View File

@@ -39,3 +39,10 @@ html {
transform: rotate(270deg);
}
}
pre, code, .markdown pre, .markdown code, .release-notes pre, .release-notes code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Helvetica Neue", monospace;
color: var(--astrbot-code-color);
}

View File

@@ -11,10 +11,12 @@ $font-size-root: 1rem;
$border-radius-root: 8px;
$cjk-sans-fallback: 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans CJK SC', 'Microsoft YaHei' !default;
$cjk-mono-fallback: 'PingFang SC', 'PingFang TC', 'Hiragino Sans GB', 'Noto Sans CJK SC', 'Microsoft YaHei' !default;
$code-text-color: #111827 !default;
:root {
--astrbot-font-cjk-sans: #{$cjk-sans-fallback};
--astrbot-font-cjk-mono: #{$cjk-mono-fallback};
--astrbot-code-color: #{$code-text-color};
}
$body-font-family: 'Roboto', $cjk-sans-fallback, sans-serif !default;

View File

@@ -10,7 +10,6 @@ export const useCustomizerStore = defineStore({
fontTheme: "Poppins",
uiTheme: config.uiTheme,
inputBg: config.inputBg,
viewMode: (localStorage.getItem('viewMode') as 'bot' | 'chat') || 'bot', // 'bot' 或 'chat'
chatSidebarOpen: false // chat mode mobile sidebar state
}),
@@ -29,10 +28,7 @@ export const useCustomizerStore = defineStore({
this.uiTheme = payload;
localStorage.setItem("uiTheme", payload);
},
SET_VIEW_MODE(payload: 'bot' | 'chat') {
this.viewMode = payload;
localStorage.setItem('viewMode', payload);
},
TOGGLE_CHAT_SIDEBAR() {
this.chatSidebarOpen = !this.chatSidebarOpen;
},

View File

@@ -50,7 +50,7 @@ export function getTutorialLink(platformType) {
const tutorialMap = {
"qq_official_webhook": "https://docs.astrbot.app/platform/qqofficial/webhook.html",
"qq_official": "https://docs.astrbot.app/platform/qqofficial/websockets.html",
"aiocqhttp": "https://docs.astrbot.app/platform/aiocqhttp/napcat.html",
"aiocqhttp": "https://docs.astrbot.app/platform/aiocqhttp.html",
"wecom": "https://docs.astrbot.app/platform/wecom.html",
"weixin_oc": "https://docs.astrbot.app/platform/weixin_oc.html",
"wecom_ai_bot": "https://docs.astrbot.app/platform/wecom_ai_bot.html",

View File

@@ -126,11 +126,15 @@
<!-- Config List -->
<v-list lines="two">
<v-list-item v-for="config in configInfoList" :key="config.id" :title="config.name">
<template v-slot:append v-if="config.id !== 'default'">
<template v-slot:append>
<div class="d-flex align-center" style="gap: 8px;">
<v-btn icon="mdi-content-copy" size="small" variant="text" color="primary"
@click="startCopyConfig(config)"></v-btn>
<v-btn icon="mdi-pencil" size="small" variant="text" color="warning"
v-if="config.id !== 'default'"
@click="startEditConfig(config)"></v-btn>
<v-btn icon="mdi-delete" size="small" variant="text" color="error"
v-if="config.id !== 'default'"
@click="confirmDeleteConfig(config)"></v-btn>
</div>
</template>
@@ -141,7 +145,7 @@
<v-divider v-if="showConfigForm" class="my-6"></v-divider>
<div v-if="showConfigForm">
<h3 class="mb-4">{{ isEditingConfig ? tm('configManagement.editConfig') : tm('configManagement.newConfig') }}</h3>
<h3 class="mb-4">{{ configFormTitle }}</h3>
<h4>{{ tm('configManagement.configName') }}</h4>
@@ -151,7 +155,7 @@
<div class="d-flex justify-end mt-4" style="gap: 8px;">
<v-btn variant="text" @click="cancelConfigForm">{{ tm('buttons.cancel') }}</v-btn>
<v-btn color="primary" @click="saveConfigForm"
:disabled="!configFormData.name">
:disabled="isConfigFormSaveDisabled">
{{ isEditingConfig ? tm('buttons.update') : tm('buttons.create') }}
</v-btn>
</div>
@@ -297,6 +301,19 @@ export default {
selectedConfigInfo() {
return this.configInfoList.find(info => info.id === this.selectedConfigID) || {};
},
configFormTitle() {
if (this.isEditingConfig) {
return this.tm('configManagement.editConfig');
}
if (this.isCopyingConfig) {
return this.tm('configManagement.copyConfig');
}
return this.tm('configManagement.newConfig');
},
isConfigFormSaveDisabled() {
const isNameEmpty = !this.normalizeConfigName(this.configFormData.name);
return isNameEmpty || (this.isCopyingConfig && !this.copySourceConfigId);
},
configSelectItems() {
const items = [...this.configInfoList];
items.push({
@@ -343,6 +360,7 @@ export default {
configManageDialog: false,
showConfigForm: false,
isEditingConfig: false,
isCopyingConfig: false,
config_data_has_changed: false,
config_data_str: "",
config_data: {
@@ -371,6 +389,7 @@ export default {
name: '',
},
editingConfigId: null,
copySourceConfigId: '',
// 测试聊天
testChatDrawer: false,
@@ -567,9 +586,9 @@ export default {
this.save_message_snack = true;
}
},
createNewConfig() {
createNewConfig(configName) {
axios.post('/api/config/abconf/new', {
name: this.configFormData.name
name: configName
}).then((res) => {
if (res.data.status === "ok") {
this.save_message = res.data.message;
@@ -589,6 +608,24 @@ export default {
this.save_message_success = "error";
});
},
normalizeConfigName(name) {
return typeof name === 'string' ? name.trim() : '';
},
hasDuplicateConfigName(name, excludeId = null) {
const normalizedName = this.normalizeConfigName(name);
if (!normalizedName) {
return false;
}
return this.configInfoList.some((config) => {
if (!config || !config.name) {
return false;
}
if (excludeId && config.id === excludeId) {
return false;
}
return this.normalizeConfigName(config.name) === normalizedName;
});
},
async onConfigSelect(value) {
if (value === '_%manage%_') {
this.configManageDialog = true;
@@ -638,44 +675,91 @@ export default {
}
}
},
setConfigFormState({ mode = 'create', config = null, visible = true } = {}) {
this.showConfigForm = visible;
this.isEditingConfig = mode === 'edit';
this.isCopyingConfig = mode === 'copy';
this.editingConfigId = this.isEditingConfig && config ? config.id : null;
this.copySourceConfigId = this.isCopyingConfig && config ? config.id : '';
let name = '';
if (this.isEditingConfig && config) {
name = config.name || '';
} else if (this.isCopyingConfig && config) {
name = `${config.name || ''}-copy`;
}
this.configFormData = { name };
},
startCreateConfig() {
this.showConfigForm = true;
this.isEditingConfig = false;
this.configFormData = {
name: '',
};
this.editingConfigId = null;
this.setConfigFormState({ mode: 'create' });
},
startEditConfig(config) {
this.showConfigForm = true;
this.isEditingConfig = true;
this.editingConfigId = config.id;
this.configFormData = {
name: config.name || '',
};
this.setConfigFormState({ mode: 'edit', config });
},
startCopyConfig(config) {
this.setConfigFormState({ mode: 'copy', config });
},
cancelConfigForm() {
this.showConfigForm = false;
this.isEditingConfig = false;
this.editingConfigId = null;
this.configFormData = {
name: '',
};
this.setConfigFormState({ visible: false });
},
saveConfigForm() {
if (!this.configFormData.name) {
const normalizedName = this.normalizeConfigName(this.configFormData.name);
if (!normalizedName) {
this.save_message = this.tm('configManagement.pleaseEnterName');
this.save_message_snack = true;
this.save_message_success = "error";
return;
}
if (this.isEditingConfig) {
this.updateConfigInfo();
} else {
this.createNewConfig();
const excludeId = this.isEditingConfig ? this.editingConfigId : null;
if (this.hasDuplicateConfigName(normalizedName, excludeId)) {
this.save_message = this.tm('configManagement.nameExists');
this.save_message_snack = true;
this.save_message_success = "error";
return;
}
this.configFormData.name = normalizedName;
if (this.isEditingConfig) {
this.updateConfigInfo(normalizedName);
} else if (this.isCopyingConfig) {
this.copyConfig(normalizedName);
} else {
this.createNewConfig(normalizedName);
}
},
copyConfig(configName) {
axios.get('/api/config/abconf', {
params: { id: this.copySourceConfigId }
}).then((res) => {
const sourceConfig = res.data?.data?.config;
if (!sourceConfig) {
this.save_message = this.tm('configManagement.copyFailed');
this.save_message_snack = true;
this.save_message_success = "error";
return;
}
return axios.post('/api/config/abconf/new', {
name: configName,
config: sourceConfig
});
}).then((res) => {
if (!res) return;
if (res.data.status === "ok") {
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
this.getConfigInfoList(res.data.data.conf_id);
this.cancelConfigForm();
} else {
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "error";
}
}).catch((err) => {
console.error(err);
this.save_message = err?.response?.data?.message || this.tm('configManagement.copyFailed');
this.save_message_snack = true;
this.save_message_success = "error";
});
},
async confirmDeleteConfig(config) {
const message = this.tm('configManagement.confirmDelete').replace('{name}', config.name);
@@ -706,10 +790,10 @@ export default {
this.save_message_success = "error";
});
},
updateConfigInfo() {
updateConfigInfo(configName) {
axios.post('/api/config/abconf/update', {
id: this.editingConfigId,
name: this.configFormData.name
name: configName
}).then((res) => {
if (res.data.status === "ok") {
this.save_message = res.data.message;

View File

@@ -198,7 +198,9 @@
</div>
<!-- 预览模式 - 聊天界面 -->
<div v-else class="conversation-messages-container" style="background-color: var(--v-theme-surface);">
<div v-else class="conversation-messages-container" style="background-color: var(--v-theme-surface);"
ref="messagesContainer"
@wheel.prevent="onContainerWheel">
<!-- 空对话提示 -->
<div v-if="conversationHistory.length === 0" class="text-center py-5">
<v-icon size="48" color="grey">mdi-chat-remove</v-icon>
@@ -1052,6 +1054,13 @@ export default {
return parts;
},
// Manually handle wheel scrolling inside the dialog preview container.
onContainerWheel(event) {
const el = this.$refs.messagesContainer;
if (!el) return;
el.scrollTop += event.deltaY;
},
// 从内容中提取文本(保留用于其他用途)
extractTextFromContent(content) {
if (typeof content === 'string') {

View File

@@ -5,7 +5,7 @@
AstrBot 支持通过 `个人微信` 适配器接入微信个人号。该适配器基于**腾讯微信官方** `openclaw-weixin` 接口实现,使用扫码登录和长轮询收发消息,不需要配置 Webhook 回调地址。
> [!NOTE]
> 需要升级到最新的手机微信版本:>= 8.0.70
> 需要升级到最新的手机微信版本:iOS >= 8.0.70Android >= 8.0.69,并确保微信中包含 ClawBot 插件
## 支持的消息类型

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.22.1"
version = "4.22.2"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.12"
@@ -27,6 +27,7 @@ dependencies = [
"faiss-cpu>=1.12.0",
"filelock>=3.18.0",
"google-genai>=1.56.0",
"httpx[socks]>=0.28.1",
"lark-oapi>=1.4.15",
"lxml-html-clean>=0.4.2",
"mcp>=1.8.0",

View File

@@ -17,6 +17,7 @@ docstring-parser>=0.16
faiss-cpu>=1.12.0
filelock>=3.18.0
google-genai>=1.56.0
httpx[socks]>=0.28.1
lark-oapi>=1.4.15
lxml-html-clean>=0.4.2
mcp>=1.8.0

View File

@@ -33,7 +33,8 @@ def create_mock_telegram_modules():
mock_telegram_ext = MagicMock()
mock_telegram_ext.ApplicationBuilder = MagicMock
mock_telegram_ext.ContextTypes = MagicMock
mock_telegram_ext.ContextTypes = MagicMock()
mock_telegram_ext.ContextTypes.DEFAULT_TYPE = MagicMock
mock_telegram_ext.ExtBot = MagicMock
mock_telegram_ext.filters = MagicMock()
mock_telegram_ext.filters.ALL = MagicMock()

View File

@@ -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",
)

View File

@@ -108,6 +108,51 @@ async def test_get_stat(app: Quart, authenticated_header: dict):
assert data["status"] == "ok" and "platform" in data["data"]
@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,

View File

@@ -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",
)

View File

@@ -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}"
)

View File

@@ -2,7 +2,9 @@ from types import SimpleNamespace
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
@@ -234,7 +236,9 @@ async def test_openai_payload_keeps_reasoning_content_in_assistant_history():
provider._finally_convert_payload(payloads)
assistant_message = payloads["messages"][0]
assert assistant_message["content"] == [{"type": "text", "text": "final answer"}]
assert assistant_message["content"] == [
{"type": "text", "text": "final answer"}
]
assert assistant_message["reasoning_content"] == "step 1"
finally:
await provider.terminate()
@@ -259,7 +263,9 @@ async def test_groq_payload_drops_reasoning_content_from_assistant_history():
provider._finally_convert_payload(payloads)
assistant_message = payloads["messages"][0]
assert assistant_message["content"] == [{"type": "text", "text": "final answer"}]
assert assistant_message["content"] == [
{"type": "text", "text": "final answer"}
]
assert "reasoning_content" not in assistant_message
assert "reasoning" not in assistant_message
finally:
@@ -450,6 +456,604 @@ async def test_handle_api_error_unknown_image_error_raises():
await provider.terminate()
@pytest.mark.asyncio
async def test_handle_api_error_invalid_attachment_removes_images_and_retries_text_only():
provider = _make_provider()
try:
payloads = {
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "hello"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,abcd"},
},
],
}
]
}
context_query = payloads["messages"]
err = _ErrorWithBody(
"upstream error",
{
"error": {
"code": "INVALID_ATTACHMENT",
"message": "download attachment: unexpected status 404",
}
},
)
success, *_rest = await provider._handle_api_error(
err,
payloads=payloads,
context_query=context_query,
func_tool=None,
chosen_key="test-key",
available_api_keys=["test-key"],
retry_cnt=0,
max_retries=10,
)
assert success is False
assert payloads["messages"][0]["content"] == [{"type": "text", "text": "hello"}]
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_handle_api_error_invalid_attachment_without_images_raises():
provider = _make_provider()
try:
payloads = {
"messages": [
{
"role": "user",
"content": [{"type": "text", "text": "hello"}],
}
]
}
context_query = payloads["messages"]
err = _ErrorWithBody(
"upstream error",
{
"error": {
"code": "INVALID_ATTACHMENT",
"message": "download attachment: unexpected status 404",
}
},
)
with pytest.raises(_ErrorWithBody, match="upstream error"):
await provider._handle_api_error(
err,
payloads=payloads,
context_query=context_query,
func_tool=None,
chosen_key="test-key",
available_api_keys=["test-key"],
retry_cnt=0,
max_retries=10,
)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_handle_api_error_invalid_attachment_after_fallback_raises():
provider = _make_provider()
try:
payloads = {
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "hello"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,abcd"},
},
],
}
]
}
context_query = payloads["messages"]
err = _ErrorWithBody(
"upstream error",
{
"error": {
"code": "INVALID_ATTACHMENT",
"message": "download attachment: unexpected status 404",
}
},
)
with pytest.raises(_ErrorWithBody, match="upstream error"):
await provider._handle_api_error(
err,
payloads=payloads,
context_query=context_query,
func_tool=None,
chosen_key="test-key",
available_api_keys=["test-key"],
retry_cnt=1,
max_retries=10,
image_fallback_used=True,
)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_materializes_context_http_image_urls(monkeypatch):
provider = _make_provider()
try:
async def fake_download(url: str) -> str:
assert url == "https://example.com/quoted.png"
return "/tmp/quoted.png"
def fake_encode(image_path: str, **_kwargs) -> str:
assert image_path == "/tmp/quoted.png"
return "data:image/png;base64,abcd"
monkeypatch.setattr(
"astrbot.core.provider.sources.openai_source.download_image_by_url",
fake_download,
)
monkeypatch.setattr(provider, "_encode_image_file_to_data_url", fake_encode)
contexts = [
{
"role": "user",
"metadata": {"source": "quoted"},
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/quoted.png",
"id": "ctx-img",
"detail": "high",
},
},
],
}
]
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=contexts,
)
assert payloads["messages"][0]["content"] == [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,abcd",
"detail": "high",
},
},
]
assert payloads["messages"][0]["content"][1]["image_url"].get("id") is None
assert contexts[0]["content"][1]["image_url"] == {
"url": "https://example.com/quoted.png",
"id": "ctx-img",
"detail": "high",
}
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_skips_materialization_for_text_only_context(
monkeypatch,
):
provider = _make_provider()
try:
async def fail_if_called(_context_query):
raise AssertionError("materialization should be skipped")
monkeypatch.setattr(
provider, "_materialize_context_image_parts", fail_if_called
)
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[{"role": "user", "content": "hello"}],
)
assert payloads["messages"] == [{"role": "user", "content": "hello"}]
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_skips_materialization_for_text_only_parts(
monkeypatch,
):
provider = _make_provider()
try:
async def fail_if_called(_context_query):
raise AssertionError("materialization should be skipped")
monkeypatch.setattr(
provider, "_materialize_context_image_parts", fail_if_called
)
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[
{
"role": "user",
"content": [{"type": "text", "text": "hello"}],
}
],
)
assert payloads["messages"] == [
{
"role": "user",
"content": [{"type": "text", "text": "hello"}],
}
]
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_materializes_context_http_image_urls_with_detected_mime(
monkeypatch, tmp_path
):
provider = _make_provider()
try:
image_path = tmp_path / "quoted-image.png"
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
async def fake_download(url: str) -> str:
assert url == "https://example.com/quoted.png"
return str(image_path)
monkeypatch.setattr(
"astrbot.core.provider.sources.openai_source.download_image_by_url",
fake_download,
)
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/quoted.png",
},
},
],
}
],
)
image_payload = payloads["messages"][0]["content"][1]["image_url"]
assert image_payload["url"].startswith("data:image/png;base64,")
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_materializes_context_file_uri_image_urls(tmp_path):
provider = _make_provider()
try:
image_path = tmp_path / "quoted-image.png"
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": image_path.as_uri(),
},
},
],
}
],
)
image_payload = payloads["messages"][0]["content"][1]["image_url"]
assert image_payload["url"].startswith("data:image/png;base64,")
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_file_uri_to_path_preserves_windows_drive_letter():
provider = _make_provider()
try:
assert provider._file_uri_to_path("file:///C:/tmp/quoted-image.png") == (
"C:/tmp/quoted-image.png"
)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_file_uri_to_path_preserves_windows_netloc_drive_letter():
provider = _make_provider()
try:
assert provider._file_uri_to_path("file://C:/tmp/quoted-image.png") == (
"C:/tmp/quoted-image.png"
)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_file_uri_to_path_preserves_remote_netloc_as_unc_path():
provider = _make_provider()
try:
assert provider._file_uri_to_path("file://server/share/quoted-image.png") == (
"//server/share/quoted-image.png"
)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_resolve_image_part_rejects_invalid_local_file(tmp_path):
provider = _make_provider()
try:
invalid_file = tmp_path / "not-image.txt"
invalid_file.write_text("not an image")
assert await provider._resolve_image_part(str(invalid_file)) is None
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_resolve_image_part_rejects_invalid_file_uri(tmp_path):
provider = _make_provider()
try:
invalid_file = tmp_path / "not-image.txt"
invalid_file.write_text("not an image")
assert await provider._resolve_image_part(invalid_file.as_uri()) is None
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_image_ref_to_data_url_mode_controls_invalid_file_behavior(tmp_path):
provider = _make_provider()
try:
invalid_file = tmp_path / "not-image.txt"
invalid_file.write_text("not an image")
assert (
await provider._image_ref_to_data_url(str(invalid_file), mode="safe")
is None
)
with pytest.raises(ValueError, match="Invalid image file"):
await provider._image_ref_to_data_url(str(invalid_file), mode="strict")
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_materialize_context_image_parts_returns_new_messages(monkeypatch):
provider = _make_provider()
try:
context_query = [
{
"role": "user",
"metadata": {"source": "quoted"},
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/quoted.png",
"detail": "high",
},
},
],
},
{"role": "assistant", "content": "plain text"},
]
async def fake_resolve(image_url: str, *, image_detail: str | None = None):
assert image_url == "https://example.com/quoted.png"
assert image_detail == "high"
return {
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,abcd",
"detail": "high",
},
}
monkeypatch.setattr(provider, "_resolve_image_part", fake_resolve)
materialized = await provider._materialize_context_image_parts(context_query)
assert materialized is not context_query
assert materialized[0] is not context_query[0]
assert materialized[0]["metadata"] is context_query[0]["metadata"]
assert materialized[0]["content"][0] is context_query[0]["content"][0]
assert (
materialized[0]["content"][1]["image_url"]["url"]
== "data:image/png;base64,abcd"
)
assert (
context_query[0]["content"][1]["image_url"]["url"]
== "https://example.com/quoted.png"
)
assert materialized[1] is not context_query[1]
assert materialized[1]["content"] == "plain text"
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_encode_image_bs64_missing_file_raises(tmp_path):
provider = _make_provider()
try:
missing_path = tmp_path / "missing-image.png"
with pytest.raises(FileNotFoundError):
await provider.encode_image_bs64(str(missing_path))
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_encode_image_bs64_invalid_file_raises(tmp_path):
provider = _make_provider()
try:
invalid_file = tmp_path / "not-image.txt"
invalid_file.write_text("not an image")
with pytest.raises(ValueError, match="Invalid image file"):
await provider.encode_image_bs64(str(invalid_file))
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_encode_image_bs64_supports_base64_scheme():
provider = _make_provider()
try:
image_data = await provider.encode_image_bs64("base64://abcd")
assert image_data == "data:image/jpeg;base64,abcd"
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_encode_image_bs64_supports_file_uri(tmp_path):
provider = _make_provider()
try:
image_path = tmp_path / "quoted-image.png"
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
image_data = await provider.encode_image_bs64(image_path.as_uri())
assert image_data.startswith("data:image/png;base64,")
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_resolve_image_part_supports_base64_scheme():
provider = _make_provider()
try:
assert await provider._resolve_image_part("base64://abcd") == {
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,abcd"},
}
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_materializes_context_localhost_file_uri_image_urls(
tmp_path,
):
provider = _make_provider()
try:
image_path = tmp_path / "quoted-image.png"
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
localhost_uri = f"file://localhost{image_path.as_posix()}"
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": localhost_uri,
},
},
],
}
],
)
image_payload = payloads["messages"][0]["content"][1]["image_url"]
assert image_payload["url"].startswith("data:image/png;base64,")
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_keeps_original_context_image_when_materialization_fails(
monkeypatch,
):
provider = _make_provider()
try:
async def fake_download(url: str) -> str:
assert url == "https://example.com/expired.png"
return "/tmp/not-an-image"
monkeypatch.setattr(
"astrbot.core.provider.sources.openai_source.download_image_by_url",
fake_download,
)
monkeypatch.setattr(
provider,
"_encode_image_file_to_data_url",
lambda _image_path, **_kwargs: None,
)
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/expired.png",
},
},
],
}
],
)
assert payloads["messages"][0]["content"] == [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/expired.png",
},
},
]
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_apply_provider_specific_extra_body_overrides_disables_ollama_thinking():
provider = _make_provider(
@@ -533,3 +1137,39 @@ async def test_query_injects_reasoning_effort_none_for_ollama(monkeypatch):
assert extra_body["temperature"] == 0.1
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()

View File

@@ -0,0 +1,108 @@
import asyncio
import importlib
import sys
from unittest.mock import MagicMock, patch
import pytest
import astrbot.api.message_components as Comp
from tests.fixtures.helpers import (
create_mock_file,
create_mock_update,
make_platform_config,
)
from tests.fixtures.mocks.telegram import create_mock_telegram_modules
_TELEGRAM_PLATFORM_ADAPTER = None
def _load_telegram_adapter():
global _TELEGRAM_PLATFORM_ADAPTER
if _TELEGRAM_PLATFORM_ADAPTER is not None:
return _TELEGRAM_PLATFORM_ADAPTER
mocks = create_mock_telegram_modules()
patched_modules = {
"telegram": mocks["telegram"],
"telegram.constants": mocks["telegram"].constants,
"telegram.error": mocks["telegram"].error,
"telegram.ext": mocks["telegram.ext"],
"telegramify_markdown": mocks["telegramify_markdown"],
"apscheduler": mocks["apscheduler"],
"apscheduler.schedulers": mocks["apscheduler"].schedulers,
"apscheduler.schedulers.asyncio": mocks["apscheduler"].schedulers.asyncio,
"apscheduler.schedulers.background": mocks["apscheduler"].schedulers.background,
}
with patch.dict(sys.modules, patched_modules):
sys.modules.pop("astrbot.core.platform.sources.telegram.tg_adapter", None)
module = importlib.import_module("astrbot.core.platform.sources.telegram.tg_adapter")
_TELEGRAM_PLATFORM_ADAPTER = module.TelegramPlatformAdapter
return _TELEGRAM_PLATFORM_ADAPTER
def _build_context() -> MagicMock:
context = MagicMock()
context.bot.username = "test_bot"
context.bot.id = 12345678
return context
@pytest.mark.asyncio
async def test_telegram_document_caption_populates_message_text_and_plain():
TelegramPlatformAdapter = _load_telegram_adapter()
adapter = TelegramPlatformAdapter(
make_platform_config("telegram"),
{},
asyncio.Queue(),
)
document = create_mock_file("https://api.telegram.org/file/test/report.md")
document.file_name = "report.md"
mention = MagicMock(type="mention", offset=0, length=6)
update = create_mock_update(
message_text=None,
document=document,
caption="@alice 请总结这份文档",
caption_entities=[mention],
)
result = await adapter.convert_message(update, _build_context())
assert result is not None
assert result.message_str == "@alice 请总结这份文档"
assert any(isinstance(component, Comp.File) for component in result.message)
assert any(
isinstance(component, Comp.Plain)
and component.text == "@alice 请总结这份文档"
for component in result.message
)
assert any(
isinstance(component, Comp.At) and component.qq == "alice"
for component in result.message
)
@pytest.mark.asyncio
async def test_telegram_video_caption_populates_message_text_and_plain():
TelegramPlatformAdapter = _load_telegram_adapter()
adapter = TelegramPlatformAdapter(
make_platform_config("telegram"),
{},
asyncio.Queue(),
)
video = create_mock_file("https://api.telegram.org/file/test/lesson.mp4")
video.file_name = "lesson.mp4"
update = create_mock_update(
message_text=None,
video=video,
caption="这段视频讲了什么",
)
result = await adapter.convert_message(update, _build_context())
assert result is not None
assert result.message_str == "这段视频讲了什么"
assert any(isinstance(component, Comp.Video) for component in result.message)
assert any(
isinstance(component, Comp.Plain) and component.text == "这段视频讲了什么"
for component in result.message
)

View File

@@ -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

View File

@@ -651,6 +651,15 @@ class TestSendTyping:
await astr_message_event.send_typing()
class TestStopTyping:
"""Tests for stop_typing method."""
@pytest.mark.asyncio
async def test_stop_typing_default_empty(self, astr_message_event):
"""Test stop_typing default implementation is empty."""
await astr_message_event.stop_typing()
class TestReact:
"""Tests for react method."""
@@ -772,10 +781,12 @@ class TestDefensiveGetattr:
def test_get_message_type_with_non_enum_type(self, astr_message_event):
"""get_message_type should handle message_obj.type that is not a MessageType."""
class DummyMessage:
def __init__(self):
self.type = "not_an_enum"
self.message = []
astr_message_event.message_obj = DummyMessage()
message_type = astr_message_event.get_message_type()
assert isinstance(message_type, MessageType)