mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-07 05:10:16 +08:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afa43fc0e2 | ||
|
|
551c956107 | ||
|
|
1070804b90 | ||
|
|
7db7f4a16c | ||
|
|
77419e0bc7 | ||
|
|
971bcbad10 | ||
|
|
da1eb65afe | ||
|
|
bbec8efa0d | ||
|
|
b98bd3898f | ||
|
|
81f4bd4e67 | ||
|
|
4e9916caa4 | ||
|
|
995a318232 | ||
|
|
bcbf7dd8df | ||
|
|
fcfd6a9e1c | ||
|
|
9238ad58ff | ||
|
|
55ed0289c2 | ||
|
|
777b831691 | ||
|
|
383df74e34 | ||
|
|
26627887d1 | ||
|
|
a5e86c8b94 | ||
|
|
af6f9cfc5e | ||
|
|
8986d05309 | ||
|
|
045be7943d | ||
|
|
cd4e999526 | ||
|
|
6db9aef3ea |
@@ -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,
|
||||
)
|
||||
|
||||
@@ -335,6 +335,18 @@ class TelegramPlatformAdapter(Platform):
|
||||
logger.warning("Received an update without a message.")
|
||||
return None
|
||||
|
||||
def _apply_caption() -> None:
|
||||
if update.message.caption:
|
||||
message.message_str = update.message.caption
|
||||
message.message.append(Comp.Plain(message.message_str))
|
||||
if update.message.caption and update.message.caption_entities:
|
||||
for entity in update.message.caption_entities:
|
||||
if entity.type == "mention":
|
||||
name = update.message.caption[
|
||||
entity.offset + 1 : entity.offset + entity.length
|
||||
]
|
||||
message.message.append(Comp.At(qq=name, name=name))
|
||||
|
||||
message = AstrBotMessage()
|
||||
message.session_id = str(update.message.chat.id)
|
||||
|
||||
@@ -454,16 +466,7 @@ class TelegramPlatformAdapter(Platform):
|
||||
photo = update.message.photo[-1] # get the largest photo
|
||||
file = await photo.get_file()
|
||||
message.message.append(Comp.Image(file=file.file_path, url=file.file_path))
|
||||
if update.message.caption:
|
||||
message.message_str = update.message.caption
|
||||
message.message.append(Comp.Plain(message.message_str))
|
||||
if update.message.caption_entities:
|
||||
for entity in update.message.caption_entities:
|
||||
if entity.type == "mention":
|
||||
name = message.message_str[
|
||||
entity.offset + 1 : entity.offset + entity.length
|
||||
]
|
||||
message.message.append(Comp.At(qq=name, name=name))
|
||||
_apply_caption()
|
||||
|
||||
elif update.message.sticker:
|
||||
# 将sticker当作图片处理
|
||||
@@ -486,6 +489,7 @@ class TelegramPlatformAdapter(Platform):
|
||||
message.message.append(
|
||||
Comp.File(file=file_path, name=file_name, url=file_path)
|
||||
)
|
||||
_apply_caption()
|
||||
|
||||
elif update.message.video:
|
||||
file = await update.message.video.get_file()
|
||||
@@ -497,6 +501,7 @@ class TelegramPlatformAdapter(Platform):
|
||||
)
|
||||
else:
|
||||
message.message.append(Comp.Video(file=file_path, path=file.file_path))
|
||||
_apply_caption()
|
||||
|
||||
return message
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import os
|
||||
|
||||
from wechatpy.enterprise import WeChatClient
|
||||
from wechatpy.exceptions import WeChatClientException
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
@@ -95,7 +96,19 @@ class WecomPlatformEvent(AstrMessageEvent):
|
||||
# Split long text messages if needed
|
||||
plain_chunks = await self.split_plain(comp.text)
|
||||
for chunk in plain_chunks:
|
||||
kf_message_api.send_text(user_id, self.get_self_id(), chunk)
|
||||
try:
|
||||
kf_message_api.send_text(user_id, self.get_self_id(), chunk)
|
||||
except WeChatClientException as e:
|
||||
if getattr(e, "errcode", None) == 40096:
|
||||
# 40096: invalid external userid, fallback to regular message API
|
||||
logger.warning(
|
||||
f"kf API error 40096 for user {user_id}, falling back to regular message API"
|
||||
)
|
||||
self.client.message.send_text(
|
||||
self.get_self_id(), user_id, chunk
|
||||
)
|
||||
else:
|
||||
raise
|
||||
await asyncio.sleep(0.5) # Avoid sending too fast
|
||||
elif isinstance(comp, Image):
|
||||
img_path = await comp.convert_to_file_path()
|
||||
|
||||
@@ -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:
|
||||
@@ -515,7 +556,9 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {**kwargs, "messages": new_messages, "model": model}
|
||||
payloads = {"messages": new_messages, "model": model}
|
||||
if func_tool and not func_tool.empty():
|
||||
payloads["tool_choice"] = tool_choice
|
||||
|
||||
# Anthropic has a different way of handling system prompts
|
||||
if system_prompt:
|
||||
@@ -540,6 +583,7 @@ class ProviderAnthropic(Provider):
|
||||
tool_calls_result=None,
|
||||
model=None,
|
||||
extra_user_content_parts=None,
|
||||
tool_choice: Literal["auto", "required"] = "auto",
|
||||
**kwargs,
|
||||
):
|
||||
if contexts is None:
|
||||
@@ -571,7 +615,9 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {**kwargs, "messages": new_messages, "model": model}
|
||||
payloads = {"messages": new_messages, "model": model}
|
||||
if func_tool and not func_tool.empty():
|
||||
payloads["tool_choice"] = tool_choice
|
||||
|
||||
# Anthropic has a different way of handling system prompts
|
||||
if system_prompt:
|
||||
|
||||
@@ -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:
|
||||
@@ -757,7 +808,9 @@ class ProviderGoogleGenAI(Provider):
|
||||
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {**kwargs, "messages": context_query, "model": model}
|
||||
payloads = {"messages": context_query, "model": model}
|
||||
if func_tool and not func_tool.empty():
|
||||
payloads["tool_choice"] = tool_choice
|
||||
|
||||
retry = 10
|
||||
keys = self.api_keys.copy()
|
||||
@@ -783,6 +836,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
tool_calls_result=None,
|
||||
model=None,
|
||||
extra_user_content_parts=None,
|
||||
tool_choice: Literal["auto", "required"] = "auto",
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
if contexts is None:
|
||||
@@ -812,7 +866,9 @@ class ProviderGoogleGenAI(Provider):
|
||||
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {**kwargs, "messages": context_query, "model": model}
|
||||
payloads = {"messages": context_query, "model": model}
|
||||
if func_tool and not func_tool.empty():
|
||||
payloads["tool_choice"] = tool_choice
|
||||
|
||||
retry = 10
|
||||
keys = self.api_keys.copy()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -27,8 +27,8 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
api_base = (
|
||||
provider_config.get("embedding_api_base", "https://api.openai.com/v1")
|
||||
.strip()
|
||||
.rstrip("/")
|
||||
.rstrip("/embeddings")
|
||||
.removesuffix("/")
|
||||
.removesuffix("/embeddings")
|
||||
)
|
||||
if api_base and not api_base.endswith("/v1") and not api_base.endswith("/v4"):
|
||||
# /v4 see #5699
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import copy
|
||||
import inspect
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
import httpx
|
||||
from openai import AsyncAzureOpenAI, AsyncOpenAI
|
||||
@@ -14,12 +18,15 @@ from openai.lib.streaming.chat._completions import ChatCompletionStreamState
|
||||
from openai.types.chat.chat_completion import ChatCompletion
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
from PIL import Image as PILImage
|
||||
from PIL import UnidentifiedImageError
|
||||
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot import logger
|
||||
from astrbot.api.provider import Provider
|
||||
from astrbot.core.agent.message import ContentPart, ImageURLPart, Message, TextPart
|
||||
from astrbot.core.agent.tool import ToolSet
|
||||
from astrbot.core.exceptions import EmptyModelOutputError
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
@@ -133,6 +140,186 @@ class ProviderOpenAIOfficial(Provider):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_invalid_attachment_error(self, error: Exception) -> bool:
|
||||
body = getattr(error, "body", None)
|
||||
code: str | None = None
|
||||
message: str | None = None
|
||||
if isinstance(body, dict):
|
||||
err_obj = body.get("error")
|
||||
if isinstance(err_obj, dict):
|
||||
raw_code = err_obj.get("code")
|
||||
raw_message = err_obj.get("message")
|
||||
code = raw_code.lower() if isinstance(raw_code, str) else None
|
||||
message = raw_message.lower() if isinstance(raw_message, str) else None
|
||||
|
||||
if code == "invalid_attachment":
|
||||
return True
|
||||
|
||||
text_sources: list[str] = []
|
||||
if message:
|
||||
text_sources.append(message)
|
||||
if code:
|
||||
text_sources.append(code)
|
||||
text_sources.extend(map(str, self._extract_error_text_candidates(error)))
|
||||
|
||||
error_text = " ".join(text.lower() for text in text_sources if text)
|
||||
if "invalid_attachment" in error_text:
|
||||
return True
|
||||
if "download attachment" in error_text and "404" in error_text:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _encode_image_file_to_data_url(
|
||||
cls,
|
||||
image_path: str,
|
||||
*,
|
||||
mode: Literal["safe", "strict"],
|
||||
) -> str | None:
|
||||
try:
|
||||
image_bytes = Path(image_path).read_bytes()
|
||||
except OSError:
|
||||
if mode == "strict":
|
||||
raise
|
||||
return None
|
||||
|
||||
try:
|
||||
with PILImage.open(BytesIO(image_bytes)) as image:
|
||||
image.verify()
|
||||
image_format = str(image.format or "").upper()
|
||||
except (OSError, UnidentifiedImageError):
|
||||
if mode == "strict":
|
||||
raise ValueError(f"Invalid image file: {image_path}")
|
||||
return None
|
||||
|
||||
mime_type = {
|
||||
"JPEG": "image/jpeg",
|
||||
"PNG": "image/png",
|
||||
"GIF": "image/gif",
|
||||
"WEBP": "image/webp",
|
||||
"BMP": "image/bmp",
|
||||
}.get(image_format, "image/jpeg")
|
||||
image_bs64 = base64.b64encode(image_bytes).decode("utf-8")
|
||||
return f"data:{mime_type};base64,{image_bs64}"
|
||||
|
||||
@staticmethod
|
||||
def _file_uri_to_path(file_uri: str) -> str:
|
||||
"""Normalize file URIs to paths.
|
||||
|
||||
`file://localhost/...` and drive-letter forms are treated as local paths.
|
||||
Other non-empty hosts are preserved as UNC-style paths.
|
||||
"""
|
||||
parsed = urlparse(file_uri)
|
||||
if parsed.scheme != "file":
|
||||
return file_uri
|
||||
|
||||
netloc = unquote(parsed.netloc or "")
|
||||
path = unquote(parsed.path or "")
|
||||
if re.fullmatch(r"[A-Za-z]:", netloc):
|
||||
return str(Path(f"{netloc}{path}"))
|
||||
if re.match(r"^/[A-Za-z]:/", path):
|
||||
path = path[1:]
|
||||
if netloc and netloc != "localhost":
|
||||
path = f"//{netloc}{path}"
|
||||
return str(Path(path))
|
||||
|
||||
async def _image_ref_to_data_url(
|
||||
self,
|
||||
image_ref: str,
|
||||
*,
|
||||
mode: Literal["safe", "strict"] = "safe",
|
||||
) -> str | None:
|
||||
if image_ref.startswith("base64://"):
|
||||
return image_ref.replace("base64://", "data:image/jpeg;base64,")
|
||||
|
||||
if image_ref.startswith("http"):
|
||||
image_path = await download_image_by_url(image_ref)
|
||||
elif image_ref.startswith("file://"):
|
||||
image_path = self._file_uri_to_path(image_ref)
|
||||
else:
|
||||
image_path = image_ref
|
||||
|
||||
return self._encode_image_file_to_data_url(
|
||||
image_path,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
async def _resolve_image_part(
|
||||
self,
|
||||
image_url: str,
|
||||
*,
|
||||
image_detail: str | None = None,
|
||||
) -> dict | None:
|
||||
if image_url.startswith("data:"):
|
||||
image_payload = {"url": image_url}
|
||||
else:
|
||||
image_data = await self._image_ref_to_data_url(image_url, mode="safe")
|
||||
if not image_data:
|
||||
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
|
||||
return None
|
||||
image_payload = {"url": image_data}
|
||||
|
||||
if image_detail:
|
||||
image_payload["detail"] = image_detail
|
||||
return {
|
||||
"type": "image_url",
|
||||
"image_url": image_payload,
|
||||
}
|
||||
|
||||
def _extract_image_part_info(self, part: dict) -> tuple[str | None, str | None]:
|
||||
if not isinstance(part, dict) or part.get("type") != "image_url":
|
||||
return None, None
|
||||
|
||||
image_url_data = part.get("image_url")
|
||||
if not isinstance(image_url_data, dict):
|
||||
logger.warning("图片内容块格式无效,将保留原始内容。")
|
||||
return None, None
|
||||
|
||||
url = image_url_data.get("url")
|
||||
if not isinstance(url, str) or not url:
|
||||
logger.warning("图片内容块缺少有效 URL,将保留原始内容。")
|
||||
return None, None
|
||||
|
||||
image_detail = image_url_data.get("detail")
|
||||
if not isinstance(image_detail, str):
|
||||
image_detail = None
|
||||
return url, image_detail
|
||||
|
||||
async def _transform_content_part(self, part: dict) -> dict:
|
||||
url, image_detail = self._extract_image_part_info(part)
|
||||
if not url:
|
||||
return part
|
||||
|
||||
try:
|
||||
resolved_part = await self._resolve_image_part(
|
||||
url, image_detail=image_detail
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"图片 %s 预处理失败,将保留原始内容。错误: %s",
|
||||
url,
|
||||
exc,
|
||||
)
|
||||
return part
|
||||
|
||||
return resolved_part or part
|
||||
|
||||
async def _materialize_message_image_parts(self, message: dict) -> dict:
|
||||
content = message.get("content")
|
||||
if not isinstance(content, list):
|
||||
return {**message}
|
||||
|
||||
new_content = [await self._transform_content_part(part) for part in content]
|
||||
return {**message, "content": new_content}
|
||||
|
||||
async def _materialize_context_image_parts(
|
||||
self, context_query: list[dict]
|
||||
) -> list[dict]:
|
||||
return [
|
||||
await self._materialize_message_image_parts(message)
|
||||
for message in context_query
|
||||
]
|
||||
|
||||
async def _fallback_to_text_only_and_retry(
|
||||
self,
|
||||
payloads: dict,
|
||||
@@ -250,6 +437,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
)
|
||||
if tool_list:
|
||||
payloads["tools"] = tool_list
|
||||
payloads["tool_choice"] = payloads.get("tool_choice", "auto")
|
||||
|
||||
# 不在默认参数中的参数放在 extra_body 中
|
||||
extra_body = {}
|
||||
@@ -300,6 +488,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
)
|
||||
if tool_list:
|
||||
payloads["tools"] = tool_list
|
||||
payloads["tool_choice"] = payloads.get("tool_choice", "auto")
|
||||
|
||||
# 不在默认参数中的参数放在 extra_body 中
|
||||
extra_body = {}
|
||||
@@ -508,7 +697,9 @@ class ProviderOpenAIOfficial(Provider):
|
||||
llm_response = LLMResponse("assistant")
|
||||
|
||||
if not completion.choices:
|
||||
raise Exception("API 返回的 completion 为空。")
|
||||
raise EmptyModelOutputError(
|
||||
f"OpenAI completion has no choices. response_id={completion.id}"
|
||||
)
|
||||
choice = completion.choices[0]
|
||||
|
||||
# parse the text completion
|
||||
@@ -526,6 +717,10 @@ class ProviderOpenAIOfficial(Provider):
|
||||
# Also clean up orphan </think> tags that may leak from some models
|
||||
completion_text = re.sub(r"</think>\s*$", "", completion_text).strip()
|
||||
llm_response.result_chain = MessageChain().message(completion_text)
|
||||
elif refusal := getattr(choice.message, "refusal", None):
|
||||
refusal_text = self._normalize_content(refusal)
|
||||
if refusal_text:
|
||||
llm_response.result_chain = MessageChain().message(refusal_text)
|
||||
|
||||
# parse the reasoning content if any
|
||||
# the priority is higher than the <think> tag extraction
|
||||
@@ -573,9 +768,18 @@ class ProviderOpenAIOfficial(Provider):
|
||||
raise Exception(
|
||||
"API 返回的 completion 由于内容安全过滤被拒绝(非 AstrBot)。",
|
||||
)
|
||||
if llm_response.completion_text is None and not llm_response.tools_call_args:
|
||||
logger.error(f"API 返回的 completion 无法解析:{completion}。")
|
||||
raise Exception(f"API 返回的 completion 无法解析:{completion}。")
|
||||
has_text_output = bool((llm_response.completion_text or "").strip())
|
||||
has_reasoning_output = bool(llm_response.reasoning_content.strip())
|
||||
if (
|
||||
not has_text_output
|
||||
and not has_reasoning_output
|
||||
and not llm_response.tools_call_args
|
||||
):
|
||||
logger.error(f"OpenAI completion has no usable output: {completion}.")
|
||||
raise EmptyModelOutputError(
|
||||
"OpenAI completion has no usable output. "
|
||||
f"response_id={completion.id}, finish_reason={choice.finish_reason}"
|
||||
)
|
||||
|
||||
llm_response.raw_completion = completion
|
||||
llm_response.id = completion.id
|
||||
@@ -604,7 +808,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
new_record = await self.assemble_context(
|
||||
prompt, image_urls, extra_user_content_parts
|
||||
)
|
||||
context_query = self._ensure_message_to_dicts(contexts)
|
||||
context_query = copy.deepcopy(self._ensure_message_to_dicts(contexts))
|
||||
if new_record:
|
||||
context_query.append(new_record)
|
||||
if system_prompt:
|
||||
@@ -622,8 +826,12 @@ class ProviderOpenAIOfficial(Provider):
|
||||
for tcr in tool_calls_result:
|
||||
context_query.extend(tcr.to_openai_messages())
|
||||
|
||||
if self._context_contains_image(context_query):
|
||||
context_query = await self._materialize_context_image_parts(context_query)
|
||||
|
||||
model = model or self.get_model()
|
||||
payloads = {**kwargs, "messages": context_query, "model": model}
|
||||
|
||||
payloads = {"messages": context_query, "model": model}
|
||||
|
||||
self._finally_convert_payload(payloads)
|
||||
|
||||
@@ -721,6 +929,18 @@ class ProviderOpenAIOfficial(Provider):
|
||||
"image_content_moderated",
|
||||
image_fallback_used=True,
|
||||
)
|
||||
if self._is_invalid_attachment_error(e):
|
||||
if image_fallback_used or not self._context_contains_image(context_query):
|
||||
raise e
|
||||
return await self._fallback_to_text_only_and_retry(
|
||||
payloads,
|
||||
context_query,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
func_tool,
|
||||
"invalid_attachment",
|
||||
image_fallback_used=True,
|
||||
)
|
||||
|
||||
if (
|
||||
"Function calling is not enabled" in str(e)
|
||||
@@ -763,6 +983,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
tool_calls_result=None,
|
||||
model=None,
|
||||
extra_user_content_parts=None,
|
||||
tool_choice: Literal["auto", "required"] = "auto",
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
payloads, context_query = await self._prepare_chat_payload(
|
||||
@@ -775,6 +996,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
extra_user_content_parts=extra_user_content_parts,
|
||||
**kwargs,
|
||||
)
|
||||
if func_tool and not func_tool.empty():
|
||||
payloads["tool_choice"] = tool_choice
|
||||
|
||||
llm_response = None
|
||||
max_retries = 10
|
||||
@@ -830,6 +1053,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
model=None,
|
||||
tool_choice: Literal["auto", "required"] = "auto",
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
"""流式对话,与服务商交互并逐步返回结果"""
|
||||
@@ -842,6 +1066,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
model=model,
|
||||
**kwargs,
|
||||
)
|
||||
if func_tool and not func_tool.empty():
|
||||
payloads["tool_choice"] = tool_choice
|
||||
|
||||
max_retries = 10
|
||||
available_api_keys = self.api_keys.copy()
|
||||
@@ -922,23 +1148,6 @@ class ProviderOpenAIOfficial(Provider):
|
||||
) -> dict:
|
||||
"""组装成符合 OpenAI 格式的 role 为 user 的消息段"""
|
||||
|
||||
async def resolve_image_part(image_url: str) -> dict | None:
|
||||
if image_url.startswith("http"):
|
||||
image_path = await download_image_by_url(image_url)
|
||||
image_data = await self.encode_image_bs64(image_path)
|
||||
elif image_url.startswith("file:///"):
|
||||
image_path = image_url.replace("file:///", "")
|
||||
image_data = await self.encode_image_bs64(image_path)
|
||||
else:
|
||||
image_data = await self.encode_image_bs64(image_url)
|
||||
if not image_data:
|
||||
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
|
||||
return None
|
||||
return {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_data},
|
||||
}
|
||||
|
||||
# 构建内容块列表
|
||||
content_blocks = []
|
||||
|
||||
@@ -958,7 +1167,9 @@ class ProviderOpenAIOfficial(Provider):
|
||||
if isinstance(part, TextPart):
|
||||
content_blocks.append({"type": "text", "text": part.text})
|
||||
elif isinstance(part, ImageURLPart):
|
||||
image_part = await resolve_image_part(part.image_url.url)
|
||||
image_part = await self._resolve_image_part(
|
||||
part.image_url.url,
|
||||
)
|
||||
if image_part:
|
||||
content_blocks.append(image_part)
|
||||
else:
|
||||
@@ -967,7 +1178,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
# 3. 图片内容
|
||||
if image_urls:
|
||||
for image_url in image_urls:
|
||||
image_part = await resolve_image_part(image_url)
|
||||
image_part = await self._resolve_image_part(image_url)
|
||||
if image_part:
|
||||
content_blocks.append(image_part)
|
||||
|
||||
@@ -986,11 +1197,10 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
async def encode_image_bs64(self, image_url: str) -> str:
|
||||
"""将图片转换为 base64"""
|
||||
if image_url.startswith("base64://"):
|
||||
return image_url.replace("base64://", "data:image/jpeg;base64,")
|
||||
with open(image_url, "rb") as f:
|
||||
image_bs64 = base64.b64encode(f.read()).decode("utf-8")
|
||||
return "data:image/jpeg;base64," + image_bs64
|
||||
image_data = await self._image_ref_to_data_url(image_url, mode="strict")
|
||||
if image_data is None:
|
||||
raise RuntimeError(f"Failed to encode image data: {image_url}")
|
||||
return image_data
|
||||
|
||||
async def terminate(self):
|
||||
if self.client:
|
||||
|
||||
@@ -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))
|
||||
@@ -259,7 +259,7 @@
|
||||
<v-card-text class="py-4">
|
||||
<p>{{ tm('dialog.securityWarning.aiocqhttpTokenMissing') }}</p>
|
||||
<span><a
|
||||
href="https://docs.astrbot.app/deploy/platform/aiocqhttp/napcat.html#%E9%99%84%E5%BD%95-%E5%A2%9E%E5%BC%BA%E8%BF%9E%E6%8E%A5%E5%AE%89%E5%85%A8%E6%80%A7"
|
||||
href="https://docs.astrbot.app/platform/aiocqhttp.html"
|
||||
target="_blank">{{ tm('dialog.securityWarning.learnMore') }}</a></span>
|
||||
</v-card-text>
|
||||
<v-card-actions class="px-4 pb-4">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -19,7 +19,6 @@ export function useSessions(chatboxMode: boolean = false) {
|
||||
const selectedSessions = ref<string[]>([]);
|
||||
const currSessionId = ref('');
|
||||
const pendingSessionId = ref<string | null>(null);
|
||||
|
||||
// 编辑标题相关
|
||||
const editTitleDialog = ref(false);
|
||||
const editingTitle = ref('');
|
||||
@@ -30,29 +29,16 @@ export function useSessions(chatboxMode: boolean = false) {
|
||||
return sessions.value.find(s => s.session_id === currSessionId.value);
|
||||
});
|
||||
|
||||
|
||||
|
||||
async function getSessions() {
|
||||
try {
|
||||
const response = await axios.get('/api/chat/sessions');
|
||||
sessions.value = response.data.data;
|
||||
|
||||
// 处理待加载的会话
|
||||
if (pendingSessionId.value) {
|
||||
const session = sessions.value.find(s => s.session_id === pendingSessionId.value);
|
||||
if (session) {
|
||||
selectedSessions.value = [pendingSessionId.value];
|
||||
pendingSessionId.value = null;
|
||||
}
|
||||
} else if (currSessionId.value) {
|
||||
// 如果当前有选中的会话,确保它在列表中并被选中
|
||||
const session = sessions.value.find(s => s.session_id === currSessionId.value);
|
||||
if (session) {
|
||||
selectedSessions.value = [currSessionId.value];
|
||||
}
|
||||
} else if (sessions.value.length > 0) {
|
||||
// 默认选择第一个会话
|
||||
const firstSession = sessions.value[0];
|
||||
selectedSessions.value = [firstSession.session_id];
|
||||
}
|
||||
|
||||
|
||||
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 401) {
|
||||
router.push('/auth/login?redirect=/chatbox');
|
||||
|
||||
@@ -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": "更新配置文件失败"
|
||||
},
|
||||
|
||||
@@ -17,18 +17,10 @@ const customizer = useCustomizerStore();
|
||||
const { locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const routerLoadingStore = useRouterLoadingStore();
|
||||
const isCurrentChatRoute = computed(() => route.path === '/chat' || route.path.startsWith('/chat/'));
|
||||
|
||||
const isChatPage = computed(() => {
|
||||
return route.path.startsWith('/chat');
|
||||
});
|
||||
|
||||
const showSidebar = computed(() => {
|
||||
return customizer.viewMode === 'bot';
|
||||
});
|
||||
|
||||
const showChatPage = computed(() => {
|
||||
return customizer.viewMode === 'chat';
|
||||
});
|
||||
const showSidebar = computed(() => !isCurrentChatRoute.value)
|
||||
|
||||
const migrationDialog = ref<InstanceType<typeof MigrationDialog> | null>(null);
|
||||
const showFirstNoticeDialog = ref(false);
|
||||
@@ -111,20 +103,20 @@ onMounted(() => {
|
||||
<VerticalHeaderVue />
|
||||
<VerticalSidebarVue v-if="showSidebar" />
|
||||
<v-main :style="{
|
||||
height: showChatPage ? 'calc(100vh - 55px)' : undefined,
|
||||
overflow: showChatPage ? 'hidden' : undefined
|
||||
height: isCurrentChatRoute ? 'calc(100vh - 55px)' : undefined,
|
||||
overflow: isCurrentChatRoute ? 'hidden' : undefined
|
||||
}">
|
||||
<v-container
|
||||
fluid
|
||||
class="page-wrapper"
|
||||
:class="{ 'chat-mode-container': showChatPage }"
|
||||
:class="{ 'chat-mode-container': isCurrentChatRoute }"
|
||||
:style="{
|
||||
height: showChatPage ? '100%' : 'calc(100% - 8px)',
|
||||
padding: (isChatPage || showChatPage) ? '0' : undefined,
|
||||
minHeight: showChatPage ? 'unset' : undefined
|
||||
height: isCurrentChatRoute ? '100%' : 'calc(100% - 8px)',
|
||||
padding: isCurrentChatRoute ? '0' : undefined,
|
||||
minHeight: isCurrentChatRoute ? 'unset' : undefined
|
||||
}">
|
||||
<div :style="{ height: '100%', width: '100%', overflow: showChatPage ? 'hidden' : undefined }">
|
||||
<div v-if="showChatPage" style="height: 100%; width: 100%; overflow: hidden;">
|
||||
<div :style="{ height: '100%', width: '100%', overflow: isCurrentChatRoute ? 'hidden' : undefined }">
|
||||
<div v-if="isCurrentChatRoute" style="height: 100%; width: 100%; overflow: hidden;">
|
||||
<Chat />
|
||||
</div>
|
||||
<RouterView v-else />
|
||||
|
||||
@@ -28,6 +28,7 @@ const theme = useTheme();
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const LAST_BOT_ROUTE_KEY = 'astrbot:last_bot_route';
|
||||
const LAST_CHAT_ROUTE_KEY = 'astrbot:last_chat_route';
|
||||
let dialog = ref(false);
|
||||
let accountWarning = ref(false)
|
||||
let updateStatusDialog = ref(false);
|
||||
@@ -58,7 +59,9 @@ const desktopUpdateHasNewVersion = ref(false);
|
||||
const desktopUpdateCurrentVersion = ref('-');
|
||||
const desktopUpdateLatestVersion = ref('-');
|
||||
const desktopUpdateStatus = ref('');
|
||||
|
||||
const isChatPath = computed(() =>
|
||||
route.path === '/chat' || route.path.startsWith('/chat/')
|
||||
);
|
||||
const getAppUpdaterBridge = (): AstrBotAppUpdaterBridge | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
@@ -380,7 +383,7 @@ function openReleaseNotesDialog(body: string, tag: string) {
|
||||
}
|
||||
|
||||
function handleLogoClick() {
|
||||
if (customizer.viewMode === 'chat') {
|
||||
if (isChatPath.value) {
|
||||
aboutDialog.value = true;
|
||||
} else {
|
||||
router.push('/about');
|
||||
@@ -395,10 +398,22 @@ commonStore.createEventSource(); // log
|
||||
commonStore.getStartTime();
|
||||
|
||||
// 视图模式切换
|
||||
const viewMode = computed({
|
||||
get: () => customizer.viewMode,
|
||||
set: (value: 'bot' | 'chat') => {
|
||||
customizer.SET_VIEW_MODE(value);
|
||||
onMounted(() => {
|
||||
// 初次加載時保存當前路由
|
||||
if (typeof window !== 'undefined') {
|
||||
if (isChatPath.value) {
|
||||
// 保存 chat ID
|
||||
const parts = route.fullPath.split('/');
|
||||
const sessionId = parts[2];
|
||||
if (sessionId) {
|
||||
sessionStorage.setItem(LAST_CHAT_ROUTE_KEY, sessionId);
|
||||
console.log('Initial save chat ID:', sessionId);
|
||||
}
|
||||
} else {
|
||||
// 保存 bot 路由(非 chat 頁面)
|
||||
sessionStorage.setItem(LAST_BOT_ROUTE_KEY, route.fullPath);
|
||||
console.log('Initial save bot route:', route.fullPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -406,26 +421,61 @@ const viewMode = computed({
|
||||
// 保存 bot 模式的最後路由
|
||||
// 監聽 route 變化,保存最後一次 bot 路由
|
||||
watch(() => route.fullPath, (newPath) => {
|
||||
if (customizer.viewMode === 'bot' && typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(LAST_BOT_ROUTE_KEY, newPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to save last bot route to localStorage:', e);
|
||||
if (typeof window === 'undefined') return;
|
||||
console.log('Route changed:', {
|
||||
newPath,
|
||||
isChat: isChatPath.value,
|
||||
currentChatId: route.params.id
|
||||
});
|
||||
try {
|
||||
// 使用現有的 isChatPath 計算屬性來避免名稱衝突
|
||||
const isChat = isChatPath.value; // 這裡使用已經計算好的 isChatPath
|
||||
|
||||
// ✅ bot:只存「非 chat 頁」
|
||||
if (!isChat) {
|
||||
sessionStorage.setItem(LAST_BOT_ROUTE_KEY, newPath);
|
||||
}
|
||||
|
||||
// ✅ chat:只存 sessionId
|
||||
if (isChat) {
|
||||
const parts = newPath.split('/');
|
||||
const sessionId = parts[2];
|
||||
|
||||
if (sessionId) {
|
||||
sessionStorage.setItem(LAST_CHAT_ROUTE_KEY, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to save route:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// 監聽 viewMode 切換
|
||||
watch(() => customizer.viewMode, (newMode, oldMode) => {
|
||||
if (newMode === 'bot' && oldMode === 'chat' && typeof window !== 'undefined') {
|
||||
// 從 chat 切換回 bot,跳轉到最後一次的 bot 路由
|
||||
let lastBotRoute = '/';
|
||||
const currentMode = computed({
|
||||
get: () => (isChatPath.value ? 'chat' : 'bot'),
|
||||
set: (val: 'chat' | 'bot') => {
|
||||
try {
|
||||
lastBotRoute = localStorage.getItem(LAST_BOT_ROUTE_KEY) || '/';
|
||||
// 檢查 window 和 sessionStorage 是否存在
|
||||
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
|
||||
// 如果在非瀏覽器環境中,不做任何 sessionStorage 操作
|
||||
console.warn('sessionStorage is not available in this environment');
|
||||
return;
|
||||
}
|
||||
|
||||
if (val === 'chat') {
|
||||
const lastSessionId = sessionStorage.getItem(LAST_CHAT_ROUTE_KEY);
|
||||
router.push(lastSessionId ? `/chat/${lastSessionId}` : '/chat');
|
||||
} else {
|
||||
let lastBotRoute = sessionStorage.getItem(LAST_BOT_ROUTE_KEY) || '/';
|
||||
if (lastBotRoute.startsWith('/chat')) {
|
||||
lastBotRoute = '/';
|
||||
}
|
||||
router.push(lastBotRoute);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to read last bot route from localStorage:', e);
|
||||
// 在受限隱私模式等環境中,sessionStorage 操作可能會拋出 SecurityError
|
||||
console.warn('Failed to access sessionStorage in currentMode setter:', e);
|
||||
}
|
||||
router.push(lastBotRoute);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -465,29 +515,46 @@ onMounted(async () => {
|
||||
<v-app-bar elevation="0" height="50" class="top-header">
|
||||
|
||||
<!-- 桌面端 menu 按钮 - 仅在 bot 模式下显示 -->
|
||||
<v-btn v-if="customizer.viewMode === 'bot'"
|
||||
style="margin-left: 16px;"
|
||||
class="hidden-md-and-down" icon rounded="sm" variant="flat"
|
||||
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
<!-- 移动端 menu 按钮 - 仅在 bot 模式下显示 -->
|
||||
<v-btn v-if="customizer.viewMode === 'bot'" class="hidden-lg-and-up ms-3" icon rounded="sm" variant="flat"
|
||||
@click.stop="customizer.SET_SIDEBAR_DRAWER">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!isChatPath"
|
||||
style="margin-left: 16px;"
|
||||
class="hidden-md-and-down"
|
||||
icon
|
||||
rounded="sm"
|
||||
variant="flat"
|
||||
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)"
|
||||
>
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<!-- 移动端 chat sidebar 展开按钮 - 仅在 chat 模式下的小屏幕显示 -->
|
||||
<v-btn v-if="customizer.viewMode === 'chat'" class="hidden-lg-and-up ms-1" icon rounded="sm" variant="flat"
|
||||
@click.stop="customizer.TOGGLE_CHAT_SIDEBAR()">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
<!-- 移动端 menu 按钮 -->
|
||||
<v-btn
|
||||
v-if="!isChatPath"
|
||||
class="hidden-lg-and-up ms-3"
|
||||
icon
|
||||
rounded="sm"
|
||||
variant="flat"
|
||||
@click.stop="customizer.SET_SIDEBAR_DRAWER"
|
||||
>
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<div class="logo-container" :class="{ 'mobile-logo': $vuetify.display.xs, 'chat-mode-logo': customizer.viewMode === 'chat' }" @click="handleLogoClick">
|
||||
<v-btn
|
||||
v-if="isChatPath"
|
||||
class="hidden-lg-and-up ms-1"
|
||||
icon
|
||||
rounded="sm"
|
||||
variant="flat"
|
||||
@click.stop="customizer.TOGGLE_CHAT_SIDEBAR()"
|
||||
>
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<div class="logo-container" :class="{ 'mobile-logo': $vuetify.display.xs, 'chat-mode-logo': isChatPath }" @click="handleLogoClick">
|
||||
<span class="logo-text Outfit">Astr<span class="logo-text bot-text-wrapper">Bot
|
||||
<img v-if="isChristmas" src="@/assets/images/xmas-hat.png" alt="Christmas hat" class="xmas-hat" />
|
||||
</span></span>
|
||||
<span class="logo-text logo-text-light Outfit" style="color: grey;" v-if="customizer.viewMode === 'chat'">ChatUI</span>
|
||||
<span class="logo-text logo-text-light Outfit" style="color: grey;" v-if="isChatPath">ChatUI</span>
|
||||
<span class="version-text hidden-xs">{{ botCurrVersion }}</span>
|
||||
</div>
|
||||
|
||||
@@ -504,23 +571,23 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- Bot/Chat 模式切换按钮 - 手机端隐藏,移入 ... 菜单 -->
|
||||
<v-btn-toggle
|
||||
v-model="viewMode"
|
||||
mandatory
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mr-4 hidden-xs"
|
||||
color="primary"
|
||||
>
|
||||
<v-btn value="bot" size="small">
|
||||
<v-icon start>mdi-robot</v-icon>
|
||||
Bot
|
||||
</v-btn>
|
||||
<v-btn value="chat" size="small">
|
||||
<v-icon start>mdi-chat</v-icon>
|
||||
Chat
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
<v-btn-toggle
|
||||
v-model="currentMode"
|
||||
mandatory
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mr-4 hidden-xs"
|
||||
color="primary"
|
||||
>
|
||||
<v-btn value="bot" size="small">
|
||||
<v-icon start>mdi-robot</v-icon>
|
||||
Bot
|
||||
</v-btn>
|
||||
<v-btn value="chat" size="small">
|
||||
<v-icon start>mdi-chat</v-icon>
|
||||
Chat
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
|
||||
<!-- 功能菜单 -->
|
||||
@@ -542,14 +609,14 @@ onMounted(async () => {
|
||||
<!-- Bot/Chat 模式切换 - 仅在手机端显示 -->
|
||||
<template v-if="$vuetify.display.xs">
|
||||
<div class="mobile-mode-toggle-wrapper">
|
||||
<v-btn-toggle
|
||||
v-model="viewMode"
|
||||
mandatory
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="mobile-mode-toggle"
|
||||
>
|
||||
<v-btn-toggle
|
||||
v-model="currentMode"
|
||||
mandatory
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mobile-mode-toggle"
|
||||
color="primary"
|
||||
>
|
||||
<v-btn value="bot" size="small">
|
||||
<v-icon start>mdi-robot</v-icon>
|
||||
Bot
|
||||
|
||||
@@ -39,3 +39,10 @@ html {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
}
|
||||
|
||||
pre, code, .markdown pre, .markdown code, .release-notes pre, .release-notes code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Helvetica Neue", monospace;
|
||||
color: var(--astrbot-code-color);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,10 +11,12 @@ $font-size-root: 1rem;
|
||||
$border-radius-root: 8px;
|
||||
$cjk-sans-fallback: 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans CJK SC', 'Microsoft YaHei' !default;
|
||||
$cjk-mono-fallback: 'PingFang SC', 'PingFang TC', 'Hiragino Sans GB', 'Noto Sans CJK SC', 'Microsoft YaHei' !default;
|
||||
$code-text-color: #111827 !default;
|
||||
|
||||
:root {
|
||||
--astrbot-font-cjk-sans: #{$cjk-sans-fallback};
|
||||
--astrbot-font-cjk-mono: #{$cjk-mono-fallback};
|
||||
--astrbot-code-color: #{$code-text-color};
|
||||
}
|
||||
|
||||
$body-font-family: 'Roboto', $cjk-sans-fallback, sans-serif !default;
|
||||
|
||||
@@ -10,7 +10,6 @@ export const useCustomizerStore = defineStore({
|
||||
fontTheme: "Poppins",
|
||||
uiTheme: config.uiTheme,
|
||||
inputBg: config.inputBg,
|
||||
viewMode: (localStorage.getItem('viewMode') as 'bot' | 'chat') || 'bot', // 'bot' 或 'chat'
|
||||
chatSidebarOpen: false // chat mode mobile sidebar state
|
||||
}),
|
||||
|
||||
@@ -29,10 +28,7 @@ export const useCustomizerStore = defineStore({
|
||||
this.uiTheme = payload;
|
||||
localStorage.setItem("uiTheme", payload);
|
||||
},
|
||||
SET_VIEW_MODE(payload: 'bot' | 'chat') {
|
||||
this.viewMode = payload;
|
||||
localStorage.setItem('viewMode', payload);
|
||||
},
|
||||
|
||||
TOGGLE_CHAT_SIDEBAR() {
|
||||
this.chatSidebarOpen = !this.chatSidebarOpen;
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@ export function getTutorialLink(platformType) {
|
||||
const tutorialMap = {
|
||||
"qq_official_webhook": "https://docs.astrbot.app/platform/qqofficial/webhook.html",
|
||||
"qq_official": "https://docs.astrbot.app/platform/qqofficial/websockets.html",
|
||||
"aiocqhttp": "https://docs.astrbot.app/platform/aiocqhttp/napcat.html",
|
||||
"aiocqhttp": "https://docs.astrbot.app/platform/aiocqhttp.html",
|
||||
"wecom": "https://docs.astrbot.app/platform/wecom.html",
|
||||
"weixin_oc": "https://docs.astrbot.app/platform/weixin_oc.html",
|
||||
"wecom_ai_bot": "https://docs.astrbot.app/platform/wecom_ai_bot.html",
|
||||
|
||||
@@ -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
|
||||
|
||||
3
tests/fixtures/mocks/telegram.py
vendored
3
tests/fixtures/mocks/telegram.py
vendored
@@ -33,7 +33,8 @@ def create_mock_telegram_modules():
|
||||
|
||||
mock_telegram_ext = MagicMock()
|
||||
mock_telegram_ext.ApplicationBuilder = MagicMock
|
||||
mock_telegram_ext.ContextTypes = MagicMock
|
||||
mock_telegram_ext.ContextTypes = MagicMock()
|
||||
mock_telegram_ext.ContextTypes.DEFAULT_TYPE = MagicMock
|
||||
mock_telegram_ext.ExtBot = MagicMock
|
||||
mock_telegram_ext.filters = MagicMock()
|
||||
mock_telegram_ext.filters.ALL = MagicMock()
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -2,7 +2,9 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from openai.types.chat.chat_completion import ChatCompletion
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from astrbot.core.exceptions import EmptyModelOutputError
|
||||
from astrbot.core.provider.sources.groq_source import ProviderGroq
|
||||
from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
|
||||
|
||||
@@ -234,7 +236,9 @@ async def test_openai_payload_keeps_reasoning_content_in_assistant_history():
|
||||
provider._finally_convert_payload(payloads)
|
||||
|
||||
assistant_message = payloads["messages"][0]
|
||||
assert assistant_message["content"] == [{"type": "text", "text": "final answer"}]
|
||||
assert assistant_message["content"] == [
|
||||
{"type": "text", "text": "final answer"}
|
||||
]
|
||||
assert assistant_message["reasoning_content"] == "step 1"
|
||||
finally:
|
||||
await provider.terminate()
|
||||
@@ -259,7 +263,9 @@ async def test_groq_payload_drops_reasoning_content_from_assistant_history():
|
||||
provider._finally_convert_payload(payloads)
|
||||
|
||||
assistant_message = payloads["messages"][0]
|
||||
assert assistant_message["content"] == [{"type": "text", "text": "final answer"}]
|
||||
assert assistant_message["content"] == [
|
||||
{"type": "text", "text": "final answer"}
|
||||
]
|
||||
assert "reasoning_content" not in assistant_message
|
||||
assert "reasoning" not in assistant_message
|
||||
finally:
|
||||
@@ -450,6 +456,604 @@ async def test_handle_api_error_unknown_image_error_raises():
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_api_error_invalid_attachment_removes_images_and_retries_text_only():
|
||||
provider = _make_provider()
|
||||
try:
|
||||
payloads = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "hello"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/jpeg;base64,abcd"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
context_query = payloads["messages"]
|
||||
err = _ErrorWithBody(
|
||||
"upstream error",
|
||||
{
|
||||
"error": {
|
||||
"code": "INVALID_ATTACHMENT",
|
||||
"message": "download attachment: unexpected status 404",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
success, *_rest = await provider._handle_api_error(
|
||||
err,
|
||||
payloads=payloads,
|
||||
context_query=context_query,
|
||||
func_tool=None,
|
||||
chosen_key="test-key",
|
||||
available_api_keys=["test-key"],
|
||||
retry_cnt=0,
|
||||
max_retries=10,
|
||||
)
|
||||
|
||||
assert success is False
|
||||
assert payloads["messages"][0]["content"] == [{"type": "text", "text": "hello"}]
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_api_error_invalid_attachment_without_images_raises():
|
||||
provider = _make_provider()
|
||||
try:
|
||||
payloads = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "hello"}],
|
||||
}
|
||||
]
|
||||
}
|
||||
context_query = payloads["messages"]
|
||||
err = _ErrorWithBody(
|
||||
"upstream error",
|
||||
{
|
||||
"error": {
|
||||
"code": "INVALID_ATTACHMENT",
|
||||
"message": "download attachment: unexpected status 404",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(_ErrorWithBody, match="upstream error"):
|
||||
await provider._handle_api_error(
|
||||
err,
|
||||
payloads=payloads,
|
||||
context_query=context_query,
|
||||
func_tool=None,
|
||||
chosen_key="test-key",
|
||||
available_api_keys=["test-key"],
|
||||
retry_cnt=0,
|
||||
max_retries=10,
|
||||
)
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_api_error_invalid_attachment_after_fallback_raises():
|
||||
provider = _make_provider()
|
||||
try:
|
||||
payloads = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "hello"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/jpeg;base64,abcd"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
context_query = payloads["messages"]
|
||||
err = _ErrorWithBody(
|
||||
"upstream error",
|
||||
{
|
||||
"error": {
|
||||
"code": "INVALID_ATTACHMENT",
|
||||
"message": "download attachment: unexpected status 404",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(_ErrorWithBody, match="upstream error"):
|
||||
await provider._handle_api_error(
|
||||
err,
|
||||
payloads=payloads,
|
||||
context_query=context_query,
|
||||
func_tool=None,
|
||||
chosen_key="test-key",
|
||||
available_api_keys=["test-key"],
|
||||
retry_cnt=1,
|
||||
max_retries=10,
|
||||
image_fallback_used=True,
|
||||
)
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_chat_payload_materializes_context_http_image_urls(monkeypatch):
|
||||
provider = _make_provider()
|
||||
try:
|
||||
|
||||
async def fake_download(url: str) -> str:
|
||||
assert url == "https://example.com/quoted.png"
|
||||
return "/tmp/quoted.png"
|
||||
|
||||
def fake_encode(image_path: str, **_kwargs) -> str:
|
||||
assert image_path == "/tmp/quoted.png"
|
||||
return "data:image/png;base64,abcd"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.provider.sources.openai_source.download_image_by_url",
|
||||
fake_download,
|
||||
)
|
||||
monkeypatch.setattr(provider, "_encode_image_file_to_data_url", fake_encode)
|
||||
|
||||
contexts = [
|
||||
{
|
||||
"role": "user",
|
||||
"metadata": {"source": "quoted"},
|
||||
"content": [
|
||||
{"type": "text", "text": "look"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "https://example.com/quoted.png",
|
||||
"id": "ctx-img",
|
||||
"detail": "high",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
payloads, _ = await provider._prepare_chat_payload(
|
||||
prompt=None,
|
||||
contexts=contexts,
|
||||
)
|
||||
|
||||
assert payloads["messages"][0]["content"] == [
|
||||
{"type": "text", "text": "look"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "data:image/png;base64,abcd",
|
||||
"detail": "high",
|
||||
},
|
||||
},
|
||||
]
|
||||
assert payloads["messages"][0]["content"][1]["image_url"].get("id") is None
|
||||
assert contexts[0]["content"][1]["image_url"] == {
|
||||
"url": "https://example.com/quoted.png",
|
||||
"id": "ctx-img",
|
||||
"detail": "high",
|
||||
}
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_chat_payload_skips_materialization_for_text_only_context(
|
||||
monkeypatch,
|
||||
):
|
||||
provider = _make_provider()
|
||||
try:
|
||||
|
||||
async def fail_if_called(_context_query):
|
||||
raise AssertionError("materialization should be skipped")
|
||||
|
||||
monkeypatch.setattr(
|
||||
provider, "_materialize_context_image_parts", fail_if_called
|
||||
)
|
||||
|
||||
payloads, _ = await provider._prepare_chat_payload(
|
||||
prompt=None,
|
||||
contexts=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
assert payloads["messages"] == [{"role": "user", "content": "hello"}]
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_chat_payload_skips_materialization_for_text_only_parts(
|
||||
monkeypatch,
|
||||
):
|
||||
provider = _make_provider()
|
||||
try:
|
||||
|
||||
async def fail_if_called(_context_query):
|
||||
raise AssertionError("materialization should be skipped")
|
||||
|
||||
monkeypatch.setattr(
|
||||
provider, "_materialize_context_image_parts", fail_if_called
|
||||
)
|
||||
|
||||
payloads, _ = await provider._prepare_chat_payload(
|
||||
prompt=None,
|
||||
contexts=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "hello"}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert payloads["messages"] == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "hello"}],
|
||||
}
|
||||
]
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_chat_payload_materializes_context_http_image_urls_with_detected_mime(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
provider = _make_provider()
|
||||
try:
|
||||
image_path = tmp_path / "quoted-image.png"
|
||||
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
|
||||
|
||||
async def fake_download(url: str) -> str:
|
||||
assert url == "https://example.com/quoted.png"
|
||||
return str(image_path)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.provider.sources.openai_source.download_image_by_url",
|
||||
fake_download,
|
||||
)
|
||||
|
||||
payloads, _ = await provider._prepare_chat_payload(
|
||||
prompt=None,
|
||||
contexts=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "look"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "https://example.com/quoted.png",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
image_payload = payloads["messages"][0]["content"][1]["image_url"]
|
||||
assert image_payload["url"].startswith("data:image/png;base64,")
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_chat_payload_materializes_context_file_uri_image_urls(tmp_path):
|
||||
provider = _make_provider()
|
||||
try:
|
||||
image_path = tmp_path / "quoted-image.png"
|
||||
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
|
||||
|
||||
payloads, _ = await provider._prepare_chat_payload(
|
||||
prompt=None,
|
||||
contexts=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "look"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": image_path.as_uri(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
image_payload = payloads["messages"][0]["content"][1]["image_url"]
|
||||
assert image_payload["url"].startswith("data:image/png;base64,")
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_uri_to_path_preserves_windows_drive_letter():
|
||||
provider = _make_provider()
|
||||
try:
|
||||
assert provider._file_uri_to_path("file:///C:/tmp/quoted-image.png") == (
|
||||
"C:/tmp/quoted-image.png"
|
||||
)
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_uri_to_path_preserves_windows_netloc_drive_letter():
|
||||
provider = _make_provider()
|
||||
try:
|
||||
assert provider._file_uri_to_path("file://C:/tmp/quoted-image.png") == (
|
||||
"C:/tmp/quoted-image.png"
|
||||
)
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_uri_to_path_preserves_remote_netloc_as_unc_path():
|
||||
provider = _make_provider()
|
||||
try:
|
||||
assert provider._file_uri_to_path("file://server/share/quoted-image.png") == (
|
||||
"//server/share/quoted-image.png"
|
||||
)
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_image_part_rejects_invalid_local_file(tmp_path):
|
||||
provider = _make_provider()
|
||||
try:
|
||||
invalid_file = tmp_path / "not-image.txt"
|
||||
invalid_file.write_text("not an image")
|
||||
|
||||
assert await provider._resolve_image_part(str(invalid_file)) is None
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_image_part_rejects_invalid_file_uri(tmp_path):
|
||||
provider = _make_provider()
|
||||
try:
|
||||
invalid_file = tmp_path / "not-image.txt"
|
||||
invalid_file.write_text("not an image")
|
||||
|
||||
assert await provider._resolve_image_part(invalid_file.as_uri()) is None
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_ref_to_data_url_mode_controls_invalid_file_behavior(tmp_path):
|
||||
provider = _make_provider()
|
||||
try:
|
||||
invalid_file = tmp_path / "not-image.txt"
|
||||
invalid_file.write_text("not an image")
|
||||
|
||||
assert (
|
||||
await provider._image_ref_to_data_url(str(invalid_file), mode="safe")
|
||||
is None
|
||||
)
|
||||
with pytest.raises(ValueError, match="Invalid image file"):
|
||||
await provider._image_ref_to_data_url(str(invalid_file), mode="strict")
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_materialize_context_image_parts_returns_new_messages(monkeypatch):
|
||||
provider = _make_provider()
|
||||
try:
|
||||
context_query = [
|
||||
{
|
||||
"role": "user",
|
||||
"metadata": {"source": "quoted"},
|
||||
"content": [
|
||||
{"type": "text", "text": "look"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "https://example.com/quoted.png",
|
||||
"detail": "high",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": "plain text"},
|
||||
]
|
||||
|
||||
async def fake_resolve(image_url: str, *, image_detail: str | None = None):
|
||||
assert image_url == "https://example.com/quoted.png"
|
||||
assert image_detail == "high"
|
||||
return {
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "data:image/png;base64,abcd",
|
||||
"detail": "high",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(provider, "_resolve_image_part", fake_resolve)
|
||||
|
||||
materialized = await provider._materialize_context_image_parts(context_query)
|
||||
|
||||
assert materialized is not context_query
|
||||
assert materialized[0] is not context_query[0]
|
||||
assert materialized[0]["metadata"] is context_query[0]["metadata"]
|
||||
assert materialized[0]["content"][0] is context_query[0]["content"][0]
|
||||
assert (
|
||||
materialized[0]["content"][1]["image_url"]["url"]
|
||||
== "data:image/png;base64,abcd"
|
||||
)
|
||||
assert (
|
||||
context_query[0]["content"][1]["image_url"]["url"]
|
||||
== "https://example.com/quoted.png"
|
||||
)
|
||||
assert materialized[1] is not context_query[1]
|
||||
assert materialized[1]["content"] == "plain text"
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_encode_image_bs64_missing_file_raises(tmp_path):
|
||||
provider = _make_provider()
|
||||
try:
|
||||
missing_path = tmp_path / "missing-image.png"
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await provider.encode_image_bs64(str(missing_path))
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_encode_image_bs64_invalid_file_raises(tmp_path):
|
||||
provider = _make_provider()
|
||||
try:
|
||||
invalid_file = tmp_path / "not-image.txt"
|
||||
invalid_file.write_text("not an image")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid image file"):
|
||||
await provider.encode_image_bs64(str(invalid_file))
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_encode_image_bs64_supports_base64_scheme():
|
||||
provider = _make_provider()
|
||||
try:
|
||||
image_data = await provider.encode_image_bs64("base64://abcd")
|
||||
|
||||
assert image_data == "data:image/jpeg;base64,abcd"
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_encode_image_bs64_supports_file_uri(tmp_path):
|
||||
provider = _make_provider()
|
||||
try:
|
||||
image_path = tmp_path / "quoted-image.png"
|
||||
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
|
||||
|
||||
image_data = await provider.encode_image_bs64(image_path.as_uri())
|
||||
|
||||
assert image_data.startswith("data:image/png;base64,")
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_image_part_supports_base64_scheme():
|
||||
provider = _make_provider()
|
||||
try:
|
||||
assert await provider._resolve_image_part("base64://abcd") == {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/jpeg;base64,abcd"},
|
||||
}
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_chat_payload_materializes_context_localhost_file_uri_image_urls(
|
||||
tmp_path,
|
||||
):
|
||||
provider = _make_provider()
|
||||
try:
|
||||
image_path = tmp_path / "quoted-image.png"
|
||||
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
|
||||
|
||||
localhost_uri = f"file://localhost{image_path.as_posix()}"
|
||||
payloads, _ = await provider._prepare_chat_payload(
|
||||
prompt=None,
|
||||
contexts=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "look"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": localhost_uri,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
image_payload = payloads["messages"][0]["content"][1]["image_url"]
|
||||
assert image_payload["url"].startswith("data:image/png;base64,")
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_chat_payload_keeps_original_context_image_when_materialization_fails(
|
||||
monkeypatch,
|
||||
):
|
||||
provider = _make_provider()
|
||||
try:
|
||||
|
||||
async def fake_download(url: str) -> str:
|
||||
assert url == "https://example.com/expired.png"
|
||||
return "/tmp/not-an-image"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.provider.sources.openai_source.download_image_by_url",
|
||||
fake_download,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_encode_image_file_to_data_url",
|
||||
lambda _image_path, **_kwargs: None,
|
||||
)
|
||||
|
||||
payloads, _ = await provider._prepare_chat_payload(
|
||||
prompt=None,
|
||||
contexts=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "look"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "https://example.com/expired.png",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert payloads["messages"][0]["content"] == [
|
||||
{"type": "text", "text": "look"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "https://example.com/expired.png",
|
||||
},
|
||||
},
|
||||
]
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_provider_specific_extra_body_overrides_disables_ollama_thinking():
|
||||
provider = _make_provider(
|
||||
@@ -533,3 +1137,39 @@ async def test_query_injects_reasoning_effort_none_for_ollama(monkeypatch):
|
||||
assert extra_body["temperature"] == 0.1
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_openai_completion_raises_empty_model_output_error():
|
||||
provider = _make_provider()
|
||||
try:
|
||||
completion = ChatCompletion.model_validate(
|
||||
{
|
||||
"id": "chatcmpl-empty",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"refusal": None,
|
||||
"tool_calls": None,
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 1,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(EmptyModelOutputError):
|
||||
await provider._parse_openai_completion(completion, tools=None)
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
108
tests/test_telegram_adapter.py
Normal file
108
tests/test_telegram_adapter.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import astrbot.api.message_components as Comp
|
||||
from tests.fixtures.helpers import (
|
||||
create_mock_file,
|
||||
create_mock_update,
|
||||
make_platform_config,
|
||||
)
|
||||
from tests.fixtures.mocks.telegram import create_mock_telegram_modules
|
||||
|
||||
_TELEGRAM_PLATFORM_ADAPTER = None
|
||||
|
||||
|
||||
def _load_telegram_adapter():
|
||||
global _TELEGRAM_PLATFORM_ADAPTER
|
||||
if _TELEGRAM_PLATFORM_ADAPTER is not None:
|
||||
return _TELEGRAM_PLATFORM_ADAPTER
|
||||
|
||||
mocks = create_mock_telegram_modules()
|
||||
patched_modules = {
|
||||
"telegram": mocks["telegram"],
|
||||
"telegram.constants": mocks["telegram"].constants,
|
||||
"telegram.error": mocks["telegram"].error,
|
||||
"telegram.ext": mocks["telegram.ext"],
|
||||
"telegramify_markdown": mocks["telegramify_markdown"],
|
||||
"apscheduler": mocks["apscheduler"],
|
||||
"apscheduler.schedulers": mocks["apscheduler"].schedulers,
|
||||
"apscheduler.schedulers.asyncio": mocks["apscheduler"].schedulers.asyncio,
|
||||
"apscheduler.schedulers.background": mocks["apscheduler"].schedulers.background,
|
||||
}
|
||||
with patch.dict(sys.modules, patched_modules):
|
||||
sys.modules.pop("astrbot.core.platform.sources.telegram.tg_adapter", None)
|
||||
module = importlib.import_module("astrbot.core.platform.sources.telegram.tg_adapter")
|
||||
_TELEGRAM_PLATFORM_ADAPTER = module.TelegramPlatformAdapter
|
||||
return _TELEGRAM_PLATFORM_ADAPTER
|
||||
|
||||
|
||||
def _build_context() -> MagicMock:
|
||||
context = MagicMock()
|
||||
context.bot.username = "test_bot"
|
||||
context.bot.id = 12345678
|
||||
return context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_document_caption_populates_message_text_and_plain():
|
||||
TelegramPlatformAdapter = _load_telegram_adapter()
|
||||
adapter = TelegramPlatformAdapter(
|
||||
make_platform_config("telegram"),
|
||||
{},
|
||||
asyncio.Queue(),
|
||||
)
|
||||
document = create_mock_file("https://api.telegram.org/file/test/report.md")
|
||||
document.file_name = "report.md"
|
||||
mention = MagicMock(type="mention", offset=0, length=6)
|
||||
update = create_mock_update(
|
||||
message_text=None,
|
||||
document=document,
|
||||
caption="@alice 请总结这份文档",
|
||||
caption_entities=[mention],
|
||||
)
|
||||
|
||||
result = await adapter.convert_message(update, _build_context())
|
||||
|
||||
assert result is not None
|
||||
assert result.message_str == "@alice 请总结这份文档"
|
||||
assert any(isinstance(component, Comp.File) for component in result.message)
|
||||
assert any(
|
||||
isinstance(component, Comp.Plain)
|
||||
and component.text == "@alice 请总结这份文档"
|
||||
for component in result.message
|
||||
)
|
||||
assert any(
|
||||
isinstance(component, Comp.At) and component.qq == "alice"
|
||||
for component in result.message
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_video_caption_populates_message_text_and_plain():
|
||||
TelegramPlatformAdapter = _load_telegram_adapter()
|
||||
adapter = TelegramPlatformAdapter(
|
||||
make_platform_config("telegram"),
|
||||
{},
|
||||
asyncio.Queue(),
|
||||
)
|
||||
video = create_mock_file("https://api.telegram.org/file/test/lesson.mp4")
|
||||
video.file_name = "lesson.mp4"
|
||||
update = create_mock_update(
|
||||
message_text=None,
|
||||
video=video,
|
||||
caption="这段视频讲了什么",
|
||||
)
|
||||
|
||||
result = await adapter.convert_message(update, _build_context())
|
||||
|
||||
assert result is not None
|
||||
assert result.message_str == "这段视频讲了什么"
|
||||
assert any(isinstance(component, Comp.Video) for component in result.message)
|
||||
assert any(
|
||||
isinstance(component, Comp.Plain) and component.text == "这段视频讲了什么"
|
||||
for component in result.message
|
||||
)
|
||||
@@ -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