mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-10 06:50:18 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afa43fc0e2 | ||
|
|
551c956107 | ||
|
|
1070804b90 | ||
|
|
7db7f4a16c | ||
|
|
77419e0bc7 | ||
|
|
971bcbad10 | ||
|
|
da1eb65afe | ||
|
|
bbec8efa0d | ||
|
|
b98bd3898f | ||
|
|
81f4bd4e67 | ||
|
|
4e9916caa4 | ||
|
|
995a318232 | ||
|
|
bcbf7dd8df | ||
|
|
fcfd6a9e1c | ||
|
|
9238ad58ff | ||
|
|
55ed0289c2 | ||
|
|
777b831691 |
@@ -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()),
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.22.1"
|
||||
__version__ = "4.22.2"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)": {
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 其他参数
|
||||
|
||||
@@ -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:
|
||||
@@ -516,6 +557,8 @@ class ProviderAnthropic(Provider):
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {"messages": new_messages, "model": model}
|
||||
if func_tool and not func_tool.empty():
|
||||
payloads["tool_choice"] = tool_choice
|
||||
|
||||
# Anthropic has a different way of handling system prompts
|
||||
if system_prompt:
|
||||
@@ -540,6 +583,7 @@ class ProviderAnthropic(Provider):
|
||||
tool_calls_result=None,
|
||||
model=None,
|
||||
extra_user_content_parts=None,
|
||||
tool_choice: Literal["auto", "required"] = "auto",
|
||||
**kwargs,
|
||||
):
|
||||
if contexts is None:
|
||||
@@ -572,6 +616,8 @@ class ProviderAnthropic(Provider):
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {"messages": new_messages, "model": model}
|
||||
if func_tool and not func_tool.empty():
|
||||
payloads["tool_choice"] = tool_choice
|
||||
|
||||
# Anthropic has a different way of handling system prompts
|
||||
if system_prompt:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -758,6 +809,8 @@ class ProviderGoogleGenAI(Provider):
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {"messages": context_query, "model": model}
|
||||
if func_tool and not func_tool.empty():
|
||||
payloads["tool_choice"] = tool_choice
|
||||
|
||||
retry = 10
|
||||
keys = self.api_keys.copy()
|
||||
@@ -783,6 +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:
|
||||
@@ -813,6 +867,8 @@ class ProviderGoogleGenAI(Provider):
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {"messages": context_query, "model": model}
|
||||
if func_tool and not func_tool.empty():
|
||||
payloads["tool_choice"] = tool_choice
|
||||
|
||||
retry = 10
|
||||
keys = self.api_keys.copy()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -26,6 +26,7 @@ from astrbot import logger
|
||||
from astrbot.api.provider import Provider
|
||||
from astrbot.core.agent.message import ContentPart, ImageURLPart, Message, TextPart
|
||||
from astrbot.core.agent.tool import ToolSet
|
||||
from astrbot.core.exceptions import EmptyModelOutputError
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
@@ -436,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 = {}
|
||||
@@ -486,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 = {}
|
||||
@@ -694,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
|
||||
@@ -712,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
|
||||
@@ -759,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
|
||||
@@ -965,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(
|
||||
@@ -977,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
|
||||
@@ -1032,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]:
|
||||
"""流式对话,与服务商交互并逐步返回结果"""
|
||||
@@ -1044,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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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_file,SSL 配置将不会生效。",
|
||||
)
|
||||
return False, {}
|
||||
|
||||
cert_path = Path(cert_file).expanduser()
|
||||
key_path = Path(key_file).expanduser()
|
||||
if not cert_path.is_file():
|
||||
logger.warning(
|
||||
f"dashboard.ssl.enable 已启用,但 SSL 证书文件不存在: {cert_path},SSL 配置将不会生效。",
|
||||
)
|
||||
return False, {}
|
||||
if not key_path.is_file():
|
||||
logger.warning(
|
||||
f"dashboard.ssl.enable 已启用,但 SSL 私钥文件不存在: {key_path},SSL 配置将不会生效。",
|
||||
)
|
||||
return False, {}
|
||||
|
||||
resolved_ssl_config = {
|
||||
"certfile": str(cert_path.resolve()),
|
||||
"keyfile": str(key_path.resolve()),
|
||||
}
|
||||
|
||||
if ca_certs:
|
||||
ca_path = Path(ca_certs).expanduser()
|
||||
if not ca_path.is_file():
|
||||
logger.warning(
|
||||
f"dashboard.ssl.enable 已启用,但 SSL CA 证书文件不存在: {ca_path},SSL 配置将不会生效。",
|
||||
)
|
||||
return False, {}
|
||||
resolved_ssl_config["ca_certs"] = str(ca_path.resolve())
|
||||
|
||||
return True, resolved_ssl_config
|
||||
|
||||
def run(self):
|
||||
ip_addr = []
|
||||
dashboard_config = self.core_lifecycle.astrbot_config.get("dashboard", {})
|
||||
@@ -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
55
changelogs/v4.22.2.md
Normal 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))
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "Отмена"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,13 +77,16 @@
|
||||
"title": "配置文件管理",
|
||||
"description": "AstrBot 支持针对不同机器人分别设置配置文件。默认会使用 `default` 配置。",
|
||||
"newConfig": "新建配置文件",
|
||||
"copyConfig": "复制配置文件",
|
||||
"editConfig": "编辑配置文件",
|
||||
"manageConfigs": "管理配置文件...",
|
||||
"configName": "名称",
|
||||
"fillConfigName": "填写配置文件名称",
|
||||
"confirmDelete": "确定要删除配置文件 \"{name}\" 吗?此操作不可恢复。",
|
||||
"pleaseEnterName": "请填写配置名称",
|
||||
"nameExists": "配置文件名称已存在,请使用其他名称",
|
||||
"createFailed": "新配置文件创建失败",
|
||||
"copyFailed": "复制配置文件失败",
|
||||
"deleteFailed": "删除配置文件失败",
|
||||
"updateFailed": "更新配置文件失败"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
AstrBot 支持通过 `个人微信` 适配器接入微信个人号。该适配器基于**腾讯微信官方** `openclaw-weixin` 接口实现,使用扫码登录和长轮询收发消息,不需要配置 Webhook 回调地址。
|
||||
|
||||
> [!NOTE]
|
||||
> 需要升级到最新的手机微信版本:>= 8.0.70
|
||||
> 需要升级到最新的手机微信版本:iOS >= 8.0.70,Android >= 8.0.69,并确保微信中包含 ClawBot 插件
|
||||
|
||||
## 支持的消息类型
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
29
tests/test_gemini_source.py
Normal file
29
tests/test_gemini_source.py
Normal 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",
|
||||
)
|
||||
113
tests/test_httpx_socks_dependency.py
Normal file
113
tests/test_httpx_socks_dependency.py
Normal 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}"
|
||||
)
|
||||
@@ -4,6 +4,7 @@ import pytest
|
||||
from openai.types.chat.chat_completion import ChatCompletion
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from astrbot.core.exceptions import EmptyModelOutputError
|
||||
from astrbot.core.provider.sources.groq_source import ProviderGroq
|
||||
from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
|
||||
|
||||
@@ -1136,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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user