mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-10 15:00:15 +08:00
Compare commits
9 Commits
codex/chat
...
fix/8929
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7df91dff0 | ||
|
|
9ac0f374ba | ||
|
|
037e789deb | ||
|
|
85b653b6f0 | ||
|
|
c9eed7b65e | ||
|
|
cc0b347508 | ||
|
|
30426c4f67 | ||
|
|
041fba4df4 | ||
|
|
b43cc6dee0 |
@@ -47,11 +47,12 @@ ruff check .
|
||||
2. Do not add any report files such as xxx_SUMMARY.md.
|
||||
3. After finishing, use `ruff format .` and `ruff check .` to format and check the code.
|
||||
4. When committing, ensure to use conventional commits messages, such as `feat: add new agent for data analysis` or `fix: resolve bug in provider manager`.
|
||||
5. Use English for all new comments.
|
||||
5. Use **English** for all comments and logs.
|
||||
6. For path handling, use `pathlib.Path` instead of string paths, and use `astrbot.core.utils.path_utils` to get the AstrBot data and temp directory.
|
||||
7. When backend API routes, request/response schemas, or OpenAPI definitions change, regenerate the frontend API client by running `cd dashboard && pnpm generate:api`.
|
||||
8. When updating the project version, keep `[project].version` in `pyproject.toml` and `__version__` in `astrbot/__init__.py` in sync. `VERSION` in `astrbot/core/config/default.py` should derive from `astrbot.__version__` instead of hardcoding a separate version string.
|
||||
9. When designing WebUI dialogs, use `text-h3 pa-4 pb-0 pl-6` as the base class for dialog titles, and use `variant="text"` or `variant="tonal"` for dialog buttons.
|
||||
10. Consider cross-platform compatibility (e.g., Windows, macOS, and Linux, as well as Arm64 and x86 CPU architectures) and compatibility with Python 3.10+.
|
||||
|
||||
### KISS and First Principles
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
REPEATED_TOOL_NOTICE_L1_THRESHOLD = 3
|
||||
REPEATED_TOOL_NOTICE_L2_THRESHOLD = 4
|
||||
REPEATED_TOOL_NOTICE_L3_THRESHOLD = 5
|
||||
MALFORMED_TOOL_NAME_PLACEHOLDER = "__malformed_tool_name__"
|
||||
REPEATED_TOOL_NOTICE_L1_TEMPLATE = (
|
||||
"\n\n[SYSTEM NOTICE] By the way, you have executed the same tool "
|
||||
"`{tool_name}` {streak} times consecutively. Double-check whether another "
|
||||
@@ -532,6 +533,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
break
|
||||
|
||||
self._sanitize_malformed_tool_calls(resp)
|
||||
yield resp
|
||||
return
|
||||
|
||||
@@ -689,6 +691,22 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
streak=streak,
|
||||
)
|
||||
|
||||
def _sanitize_malformed_tool_calls(
|
||||
self,
|
||||
llm_resp: LLMResponse,
|
||||
) -> None:
|
||||
"""Normalize malformed tool call names.
|
||||
|
||||
Args:
|
||||
llm_resp: The LLM response whose tool call lists should be sanitized.
|
||||
"""
|
||||
llm_resp.tools_call_name = [
|
||||
self.MALFORMED_TOOL_NAME_PLACEHOLDER
|
||||
if tool_name is None or tool_name.strip() == ""
|
||||
else tool_name
|
||||
for tool_name in llm_resp.tools_call_name
|
||||
]
|
||||
|
||||
@override
|
||||
async def step(self):
|
||||
"""Process a single step of the agent.
|
||||
@@ -1312,6 +1330,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
if requery_resp:
|
||||
llm_resp = requery_resp
|
||||
self._sanitize_malformed_tool_calls(llm_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,
|
||||
@@ -1339,6 +1358,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
if repair_resp:
|
||||
llm_resp = repair_resp
|
||||
self._sanitize_malformed_tool_calls(llm_resp)
|
||||
|
||||
return llm_resp, subset
|
||||
|
||||
|
||||
@@ -1593,7 +1593,7 @@ CONFIG_METADATA_2 = {
|
||||
"enable": False,
|
||||
"api_key": "",
|
||||
"api_base": "https://api.xiaomimimo.com/v1",
|
||||
"model": "mimo-v2-omni",
|
||||
"model": "mimo-v2.5-asr",
|
||||
"timeout": "20",
|
||||
"proxy": "",
|
||||
},
|
||||
|
||||
@@ -30,6 +30,10 @@ from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.core.utils.media_utils import MediaResolver, file_uri_to_path, is_file_uri
|
||||
|
||||
|
||||
class APIReturnNoneError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _patch_qq_botpy_formdata() -> None:
|
||||
"""Patch qq-botpy for aiohttp>=3.12 compatibility.
|
||||
|
||||
@@ -49,21 +53,25 @@ def _patch_qq_botpy_formdata() -> None:
|
||||
|
||||
_patch_qq_botpy_formdata()
|
||||
|
||||
# Retry decorator for QQ Official API transient errors (HTTP 500/504)
|
||||
_qqofficial_retry = retry(
|
||||
retry=retry_if_exception_type(
|
||||
(
|
||||
botpy.errors.ServerError,
|
||||
botpy.errors.SequenceNumberError,
|
||||
OSError,
|
||||
asyncio.TimeoutError,
|
||||
)
|
||||
),
|
||||
stop=stop_after_attempt(5),
|
||||
wait=wait_exponential(multiplier=2, min=2, max=30),
|
||||
before_sleep=before_sleep_log(logger, logging.WARNING),
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
def _qqofficial_retry(max_attempts: int = 5):
|
||||
"""Retry decorator for QQ Official API transient errors (HTTP 500/504)"""
|
||||
return retry(
|
||||
retry=retry_if_exception_type(
|
||||
(
|
||||
botpy.errors.ServerError,
|
||||
botpy.errors.SequenceNumberError,
|
||||
OSError,
|
||||
asyncio.TimeoutError,
|
||||
APIReturnNoneError,
|
||||
)
|
||||
),
|
||||
stop=stop_after_attempt(max_attempts),
|
||||
wait=wait_exponential(multiplier=2, min=2, max=30),
|
||||
before_sleep=before_sleep_log(logger, logging.WARNING),
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
|
||||
_QQOFFICIAL_SEND_API_ERRORS = (
|
||||
botpy.errors.ForbiddenError,
|
||||
@@ -558,14 +566,13 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
"srv_send_msg": False,
|
||||
}
|
||||
|
||||
@_qqofficial_retry
|
||||
@_qqofficial_retry()
|
||||
async def _do_upload():
|
||||
if "openid" in kwargs:
|
||||
payload["openid"] = kwargs["openid"]
|
||||
route = Route(
|
||||
"POST", "/v2/users/{openid}/files", openid=kwargs["openid"]
|
||||
)
|
||||
return await self.bot.api._http.request(route, json=payload)
|
||||
elif "group_openid" in kwargs:
|
||||
payload["group_openid"] = kwargs["group_openid"]
|
||||
route = Route(
|
||||
@@ -573,11 +580,20 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
"/v2/groups/{group_openid}/files",
|
||||
group_openid=kwargs["group_openid"],
|
||||
)
|
||||
return await self.bot.api._http.request(route, json=payload)
|
||||
else:
|
||||
raise ValueError("Invalid upload parameters")
|
||||
|
||||
result = await _do_upload()
|
||||
result = await self.bot.api._http.request(route, json=payload)
|
||||
if result is None:
|
||||
err_msg = "上传图片API返回None,触发重试"
|
||||
raise APIReturnNoneError(err_msg)
|
||||
return result
|
||||
|
||||
try:
|
||||
result = await _do_upload()
|
||||
except APIReturnNoneError:
|
||||
logger.warning(f"上传图片API返回None,共尝试5次后放弃: {payload}")
|
||||
raise
|
||||
|
||||
if not isinstance(result, dict):
|
||||
raise RuntimeError(
|
||||
@@ -629,9 +645,13 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
else:
|
||||
return None
|
||||
|
||||
@_qqofficial_retry
|
||||
@_qqofficial_retry()
|
||||
async def _do_upload():
|
||||
return await self.bot.api._http.request(route, json=payload)
|
||||
result = await self.bot.api._http.request(route, json=payload)
|
||||
if result is None:
|
||||
err_msg = "上传文件API返回None,触发重试"
|
||||
raise APIReturnNoneError(err_msg)
|
||||
return result
|
||||
|
||||
try:
|
||||
result = await _do_upload()
|
||||
@@ -646,6 +666,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
file_info=result["file_info"],
|
||||
ttl=result.get("ttl", 0),
|
||||
)
|
||||
except APIReturnNoneError:
|
||||
logger.warning(f"上传文件API返回None,共尝试5次后放弃: {file_source}")
|
||||
except (botpy.errors.ServerError, botpy.errors.SequenceNumberError):
|
||||
logger.error(f"上传媒体文件失败,共尝试5次后放弃: {file_source}")
|
||||
except Exception as e:
|
||||
@@ -680,11 +702,26 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
stream_data.pop("id", None)
|
||||
payload["stream"] = stream_data
|
||||
route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
|
||||
result = await self.bot.api._http.request(route, json=payload)
|
||||
|
||||
if result is None:
|
||||
logger.warning("[QQOfficial] post_c2c_message: API 返回 None,跳过本次发送")
|
||||
retry_times = 3
|
||||
|
||||
@_qqofficial_retry(retry_times)
|
||||
async def _do_request():
|
||||
result = await self.bot.api._http.request(route, json=payload)
|
||||
if result is None:
|
||||
err_msg = "发送消息API返回None,触发重试"
|
||||
raise APIReturnNoneError(err_msg)
|
||||
return result
|
||||
|
||||
result = None
|
||||
try:
|
||||
result = await _do_request()
|
||||
except APIReturnNoneError:
|
||||
logger.warning(
|
||||
f"[QQOfficial] post_c2c_message: 发送消息失败,API 返回 None,共尝试{retry_times}次后放弃"
|
||||
)
|
||||
return None
|
||||
|
||||
if not isinstance(result, dict):
|
||||
logger.error(f"[QQOfficial] post_c2c_message: 响应不是 dict: {result}")
|
||||
return None
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -10,7 +11,16 @@ DEFAULT_MIMO_API_BASE = "https://api.xiaomimimo.com/v1"
|
||||
DEFAULT_MIMO_TTS_MODEL = "mimo-v2-tts"
|
||||
DEFAULT_MIMO_TTS_VOICE = "mimo_default"
|
||||
DEFAULT_MIMO_TTS_SEED_TEXT = "Hello, MiMo, have you had lunch?"
|
||||
DEFAULT_MIMO_STT_MODEL = "mimo-v2-omni"
|
||||
# The MiMo-V2 series went offline on 2026-06-30; mimo-v2.5-asr is the
|
||||
# dedicated speech recognition model per the official model lineup.
|
||||
DEFAULT_MIMO_STT_MODEL = "mimo-v2.5-asr"
|
||||
DEFAULT_MIMO_STT_SYSTEM_PROMPT = (
|
||||
"You are a speech transcription assistant. "
|
||||
"Transcribe the spoken content from the audio exactly and return only the transcription text."
|
||||
)
|
||||
DEFAULT_MIMO_STT_USER_PROMPT = (
|
||||
"Please transcribe the content of the audio and return only the transcription text."
|
||||
)
|
||||
|
||||
|
||||
class MiMoAPIError(Exception):
|
||||
@@ -67,9 +77,50 @@ async def prepare_audio_input(audio_source: str) -> tuple[str, list[Path]]:
|
||||
)
|
||||
if audio_data is None:
|
||||
raise ValueError(f"Invalid audio data: {describe_media_ref(audio_source)}")
|
||||
_validate_wav_payload(audio_data.base64_data, audio_source)
|
||||
return audio_data.to_data_url(), []
|
||||
|
||||
|
||||
def _decode_base64_header(base64_data: str) -> bytes:
|
||||
chunk = "".join(base64_data[:64].split())
|
||||
padding = len(chunk) % 4
|
||||
if padding:
|
||||
chunk += "=" * (4 - padding)
|
||||
return base64.b64decode(chunk)
|
||||
|
||||
|
||||
def _validate_wav_payload(base64_data: str, audio_source: str) -> None:
|
||||
"""Reject audio payloads whose bytes are not RIFF/WAVE.
|
||||
|
||||
MiMo only accepts wav/mp3 audio. When a platform voice file (e.g. Tencent
|
||||
SILK from QQ) slips through the WAV conversion chain unchanged, the API
|
||||
replies with an opaque HTTP 400, so fail locally with the real reason.
|
||||
|
||||
Args:
|
||||
base64_data: Base64-encoded audio payload about to be sent.
|
||||
audio_source: Original media reference, used in error messages.
|
||||
|
||||
Raises:
|
||||
MiMoAPIError: Raised when the payload is not valid WAV data.
|
||||
"""
|
||||
try:
|
||||
header = _decode_base64_header(base64_data)
|
||||
except Exception:
|
||||
header = b""
|
||||
if len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WAVE":
|
||||
return
|
||||
if header.startswith((b"#!SILK_V3", b"\x02#!SILK_V3")):
|
||||
raise MiMoAPIError(
|
||||
"Audio for MiMo STT is still Tencent SILK data after WAV conversion; "
|
||||
"check that the silk-python package is installed and working: "
|
||||
f"{describe_media_ref(audio_source)}"
|
||||
)
|
||||
raise MiMoAPIError(
|
||||
"Audio for MiMo STT could not be converted to WAV "
|
||||
f"(unrecognized audio bytes): {describe_media_ref(audio_source)}"
|
||||
)
|
||||
|
||||
|
||||
def cleanup_files(paths: list[Path]) -> None:
|
||||
for path in paths:
|
||||
try:
|
||||
|
||||
@@ -4,6 +4,8 @@ from ..register import register_provider_adapter
|
||||
from .mimo_api_common import (
|
||||
DEFAULT_MIMO_API_BASE,
|
||||
DEFAULT_MIMO_STT_MODEL,
|
||||
DEFAULT_MIMO_STT_SYSTEM_PROMPT,
|
||||
DEFAULT_MIMO_STT_USER_PROMPT,
|
||||
MiMoAPIError,
|
||||
build_api_url,
|
||||
build_headers,
|
||||
@@ -33,23 +35,49 @@ class ProviderMiMoSTTAPI(STTProvider):
|
||||
self.set_model(provider_config.get("model", DEFAULT_MIMO_STT_MODEL))
|
||||
self.client = create_http_client(self.timeout, self.proxy)
|
||||
|
||||
def _is_asr_model(self) -> bool:
|
||||
return "asr" in (self.model_name or "").lower()
|
||||
|
||||
def _build_messages(self, audio_data_url: str) -> list[dict]:
|
||||
audio_content = {
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": audio_data_url,
|
||||
},
|
||||
}
|
||||
if self._is_asr_model():
|
||||
# Dedicated ASR models (speech-recognition docs) take bare audio.
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [audio_content],
|
||||
},
|
||||
]
|
||||
# Multimodal models such as mimo-v2.5 (audio-understanding docs)
|
||||
# require a text instruction alongside the audio, otherwise the API
|
||||
# rejects the request.
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": DEFAULT_MIMO_STT_SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
audio_content,
|
||||
{
|
||||
"type": "text",
|
||||
"text": DEFAULT_MIMO_STT_USER_PROMPT,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async def get_text(self, audio_url: str) -> str:
|
||||
audio_data_url, cleanup_paths = await prepare_audio_input(audio_url)
|
||||
payload = {
|
||||
"model": self.model_name,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": audio_data_url,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"messages": self._build_messages(audio_data_url),
|
||||
"max_completion_tokens": 1024,
|
||||
}
|
||||
|
||||
|
||||
@@ -1196,7 +1196,11 @@ class ChatService:
|
||||
|
||||
async def get_session(self, username: str, session_id: str) -> dict:
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
platform_id = session.platform_id if session else "webchat"
|
||||
if not session:
|
||||
raise ChatServiceError(f"Session {session_id} not found")
|
||||
if session.creator != username:
|
||||
raise ChatServiceError("Permission denied")
|
||||
platform_id = session.platform_id
|
||||
|
||||
project_info = await self.db.get_project_by_session(
|
||||
session_id=session_id, creator=username
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import tempfile
|
||||
import traceback
|
||||
import uuid
|
||||
import zipfile
|
||||
@@ -22,7 +23,7 @@ from astrbot.core.desktop_runtime import (
|
||||
from astrbot.core.updator import AstrBotUpdator
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_data_path,
|
||||
get_astrbot_system_tmp_path,
|
||||
get_astrbot_temp_path,
|
||||
)
|
||||
from astrbot.core.utils.io import (
|
||||
download_dashboard as _download_dashboard,
|
||||
@@ -206,158 +207,168 @@ class UpdateService:
|
||||
reboot: Whether to restart AstrBot after applying files.
|
||||
proxy: Optional GitHub proxy URL.
|
||||
"""
|
||||
update_temp_dir = Path(get_astrbot_system_tmp_path()) / "updates"
|
||||
update_temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
update_token = uuid.uuid4().hex
|
||||
dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip"
|
||||
core_zip_path = update_temp_dir / f"{update_token}-core.zip"
|
||||
update_temp_parent = Path(get_astrbot_temp_path()) / "updates"
|
||||
try:
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dashboard",
|
||||
"running",
|
||||
"正在下载 WebUI...",
|
||||
0,
|
||||
)
|
||||
await self.download_dashboard(
|
||||
path=str(dashboard_zip_path),
|
||||
latest=latest,
|
||||
version=version,
|
||||
proxy=proxy or "",
|
||||
progress_callback=self._make_progress_callback(
|
||||
if update_temp_parent.is_symlink():
|
||||
update_temp_parent.unlink()
|
||||
update_temp_parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
update_temp_parent.chmod(0o700)
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="project-update-",
|
||||
dir=update_temp_parent,
|
||||
) as update_temp_dir_name:
|
||||
update_temp_dir = Path(update_temp_dir_name)
|
||||
update_token = uuid.uuid4().hex
|
||||
dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip"
|
||||
core_zip_path = update_temp_dir / f"{update_token}-core.zip"
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dashboard",
|
||||
"running",
|
||||
"正在下载 WebUI...",
|
||||
0,
|
||||
45,
|
||||
),
|
||||
extract=False,
|
||||
)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dashboard",
|
||||
"done",
|
||||
"WebUI 下载完成。",
|
||||
45,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"core",
|
||||
"running",
|
||||
"正在下载 AstrBot 项目代码...",
|
||||
45,
|
||||
)
|
||||
core_zip_path = Path(
|
||||
await self.astrbot_updator.download_update_package(
|
||||
)
|
||||
await self.download_dashboard(
|
||||
path=str(dashboard_zip_path),
|
||||
latest=latest,
|
||||
version=version,
|
||||
proxy=proxy or "",
|
||||
path=core_zip_path,
|
||||
progress_callback=self._make_progress_callback(
|
||||
progress_id,
|
||||
"core",
|
||||
45,
|
||||
"dashboard",
|
||||
0,
|
||||
45,
|
||||
),
|
||||
extract=False,
|
||||
)
|
||||
)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"core",
|
||||
"done",
|
||||
"项目代码下载完成。",
|
||||
90,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"verify",
|
||||
"running",
|
||||
"下载完成,正在校验更新包...",
|
||||
90,
|
||||
)
|
||||
|
||||
def _verify_update_packages() -> None:
|
||||
for zip_path in (dashboard_zip_path, core_zip_path):
|
||||
with zipfile.ZipFile(zip_path, "r") as archive:
|
||||
corrupt_member = archive.testzip()
|
||||
if corrupt_member:
|
||||
raise UpdateServiceError(f"更新包校验失败: {corrupt_member}")
|
||||
|
||||
await asyncio.to_thread(_verify_update_packages)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"verify",
|
||||
"done",
|
||||
"更新包校验完成。",
|
||||
91,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"apply",
|
||||
"running",
|
||||
"下载完成,正在应用更新...",
|
||||
91,
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
self.astrbot_updator.apply_update_package,
|
||||
core_zip_path,
|
||||
)
|
||||
await self.extract_dashboard(
|
||||
dashboard_zip_path,
|
||||
Path(get_astrbot_data_path()),
|
||||
)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"apply",
|
||||
"done",
|
||||
"更新文件应用完成。",
|
||||
92,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dependencies",
|
||||
"running",
|
||||
"正在更新依赖...",
|
||||
92,
|
||||
)
|
||||
logger.info("更新依赖中...")
|
||||
try:
|
||||
await self.pip_install(requirements_path="requirements.txt")
|
||||
except Exception as exc:
|
||||
logger.error(f"更新依赖失败: {exc}")
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dependencies",
|
||||
"done",
|
||||
"依赖更新完成。",
|
||||
96,
|
||||
)
|
||||
|
||||
if reboot:
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"restart",
|
||||
"running",
|
||||
"更新成功,正在准备重启...",
|
||||
98,
|
||||
"dashboard",
|
||||
"done",
|
||||
"WebUI 下载完成。",
|
||||
45,
|
||||
)
|
||||
await self.core_lifecycle.restart()
|
||||
message = "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。"
|
||||
else:
|
||||
message = "更新成功,AstrBot 将在下次启动时应用新的代码。"
|
||||
|
||||
self.update_progress[progress_id].update(
|
||||
{
|
||||
"status": "success",
|
||||
"stage": "done",
|
||||
"message": message,
|
||||
"overall_percent": 100,
|
||||
},
|
||||
)
|
||||
logger.info(message)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"core",
|
||||
"running",
|
||||
"正在下载 AstrBot 项目代码...",
|
||||
45,
|
||||
)
|
||||
core_zip_path = Path(
|
||||
await self.astrbot_updator.download_update_package(
|
||||
latest=latest,
|
||||
version=version,
|
||||
proxy=proxy or "",
|
||||
path=core_zip_path,
|
||||
progress_callback=self._make_progress_callback(
|
||||
progress_id,
|
||||
"core",
|
||||
45,
|
||||
45,
|
||||
),
|
||||
)
|
||||
)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"core",
|
||||
"done",
|
||||
"项目代码下载完成。",
|
||||
90,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"verify",
|
||||
"running",
|
||||
"下载完成,正在校验更新包...",
|
||||
90,
|
||||
)
|
||||
|
||||
def _verify_update_packages() -> None:
|
||||
for zip_path in (dashboard_zip_path, core_zip_path):
|
||||
with zipfile.ZipFile(zip_path, "r") as archive:
|
||||
corrupt_member = archive.testzip()
|
||||
if corrupt_member:
|
||||
raise UpdateServiceError(
|
||||
f"更新包校验失败: {corrupt_member}"
|
||||
)
|
||||
|
||||
await asyncio.to_thread(_verify_update_packages)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"verify",
|
||||
"done",
|
||||
"更新包校验完成。",
|
||||
91,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"apply",
|
||||
"running",
|
||||
"下载完成,正在应用更新...",
|
||||
91,
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
self.astrbot_updator.apply_update_package,
|
||||
core_zip_path,
|
||||
)
|
||||
await self.extract_dashboard(
|
||||
dashboard_zip_path,
|
||||
Path(get_astrbot_data_path()),
|
||||
)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"apply",
|
||||
"done",
|
||||
"更新文件应用完成。",
|
||||
92,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dependencies",
|
||||
"running",
|
||||
"正在更新依赖...",
|
||||
92,
|
||||
)
|
||||
logger.info("更新依赖中...")
|
||||
try:
|
||||
await self.pip_install(requirements_path="requirements.txt")
|
||||
except Exception as exc:
|
||||
logger.error(f"更新依赖失败: {exc}")
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dependencies",
|
||||
"done",
|
||||
"依赖更新完成。",
|
||||
96,
|
||||
)
|
||||
|
||||
if reboot:
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"restart",
|
||||
"running",
|
||||
"更新成功,正在准备重启...",
|
||||
98,
|
||||
)
|
||||
await self.core_lifecycle.restart()
|
||||
message = "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。"
|
||||
else:
|
||||
message = "更新成功,AstrBot 将在下次启动时应用新的代码。"
|
||||
|
||||
self.update_progress[progress_id].update(
|
||||
{
|
||||
"status": "success",
|
||||
"stage": "done",
|
||||
"message": message,
|
||||
"overall_percent": 100,
|
||||
},
|
||||
)
|
||||
logger.info(message)
|
||||
except asyncio.CancelledError:
|
||||
self.update_progress[progress_id].update(
|
||||
{
|
||||
@@ -376,13 +387,6 @@ class UpdateService:
|
||||
)
|
||||
logger.error(f"/api/update_project: {traceback.format_exc()}")
|
||||
logger.debug(f"Update task failed: {exc!s}")
|
||||
finally:
|
||||
for zip_path in (dashboard_zip_path, core_zip_path):
|
||||
try:
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
except Exception as cleanup_exc:
|
||||
logger.warning(f"清理更新临时文件失败: {zip_path}, {cleanup_exc}")
|
||||
|
||||
async def update_dashboard(self) -> UpdateServiceResult:
|
||||
try:
|
||||
|
||||
@@ -2217,6 +2217,51 @@ async def test_batch_delete_sessions_uses_batch_lookup(
|
||||
assert called["batch_lookup_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"path_template",
|
||||
[
|
||||
"/api/chat/get_session?session_id={session_id}",
|
||||
"/api/v1/chat/sessions/{session_id}",
|
||||
],
|
||||
)
|
||||
async def test_get_chat_session_rejects_session_owned_by_another_user(
|
||||
app: FastAPIAppAdapter,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
path_template: str,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
session_id = f"foreign_get_session_{uuid.uuid4().hex[:8]}"
|
||||
await core_lifecycle_td.db.create_platform_session(
|
||||
creator="not_dashboard_user",
|
||||
platform_id="webchat",
|
||||
session_id=session_id,
|
||||
display_name="Foreign Session",
|
||||
is_group=0,
|
||||
)
|
||||
await core_lifecycle_td.platform_message_history_manager.insert(
|
||||
platform_id="webchat",
|
||||
user_id=session_id,
|
||||
content={
|
||||
"type": "user",
|
||||
"message": [{"type": "text", "text": "foreign session secret"}],
|
||||
},
|
||||
sender_id="not_dashboard_user",
|
||||
sender_name="not_dashboard_user",
|
||||
)
|
||||
|
||||
response = await test_client.get(
|
||||
path_template.format(session_id=session_id),
|
||||
headers=authenticated_header,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "error"
|
||||
assert data["message"] == "Permission denied"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugins(
|
||||
app: FastAPIAppAdapter,
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import asyncio
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.provider.sources.mimo_api_common import (
|
||||
MiMoAPIError,
|
||||
_validate_wav_payload,
|
||||
build_headers,
|
||||
prepare_audio_input,
|
||||
)
|
||||
from astrbot.core.provider.sources.mimo_stt_api_source import ProviderMiMoSTTAPI
|
||||
from astrbot.core.provider.sources.mimo_tts_api_source import ProviderMiMoTTSAPI
|
||||
|
||||
MIMO_STT_TEST_AUDIO_DATA_URL = "data:audio/wav;base64,ZmFrZQ=="
|
||||
MIMO_STT_TEST_WAV_HEADER = b"RIFF\x24\x08\x00\x00WAVEfmt "
|
||||
MIMO_STT_TEST_AUDIO_BASE64 = base64.b64encode(MIMO_STT_TEST_WAV_HEADER).decode()
|
||||
MIMO_STT_TEST_AUDIO_DATA_URL = f"data:audio/wav;base64,{MIMO_STT_TEST_AUDIO_BASE64}"
|
||||
|
||||
|
||||
def _make_tts_provider(overrides: dict | None = None) -> ProviderMiMoTTSAPI:
|
||||
@@ -33,7 +37,7 @@ def _make_stt_provider(overrides: dict | None = None) -> ProviderMiMoSTTAPI:
|
||||
provider_config = {
|
||||
"id": "test-mimo-stt",
|
||||
"type": "mimo_stt_api",
|
||||
"model": "mimo-v2-omni",
|
||||
"model": "mimo-v2.5-asr",
|
||||
"api_key": "test-key",
|
||||
}
|
||||
if overrides:
|
||||
@@ -196,7 +200,8 @@ async def test_mimo_tts_get_audio_handles_empty_choices():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mimo_stt_payload_includes_audio_only(monkeypatch):
|
||||
async def test_mimo_stt_asr_model_payload_includes_audio_only(monkeypatch):
|
||||
"""专用 ASR 模型按官方语音识别文档只传 input_audio,不带任何提示词。"""
|
||||
provider = _make_stt_provider(
|
||||
{
|
||||
"mimo-stt-system-prompt": "system prompt",
|
||||
@@ -248,10 +253,91 @@ async def test_mimo_stt_payload_includes_audio_only(monkeypatch):
|
||||
]
|
||||
|
||||
|
||||
def test_mimo_stt_default_model_is_v25_asr():
|
||||
"""mimo-v2-omni 已于 2026-06-30 下线,默认模型应为 mimo-v2.5-asr。"""
|
||||
provider = ProviderMiMoSTTAPI(
|
||||
provider_config={
|
||||
"id": "test-mimo-stt",
|
||||
"type": "mimo_stt_api",
|
||||
"api_key": "test-key",
|
||||
},
|
||||
provider_settings={},
|
||||
)
|
||||
try:
|
||||
assert provider.model_name == "mimo-v2.5-asr"
|
||||
finally:
|
||||
asyncio.run(provider.terminate())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mimo_stt_multimodal_model_payload_includes_transcription_prompts(
|
||||
monkeypatch,
|
||||
):
|
||||
"""非 ASR 模型(如 mimo-v2.5)按官方音频理解文档要求携带 system 与 text 指令。"""
|
||||
provider = _make_stt_provider({"model": "mimo-v2.5"})
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_prepare_audio_input(_audio_source: str):
|
||||
return MIMO_STT_TEST_AUDIO_DATA_URL, []
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
text = '{"choices":[{"message":{"content":"transcribed text"}}]}'
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"choices": [{"message": {"content": "transcribed text"}}]}
|
||||
|
||||
async def fake_post(_url, headers=None, json=None):
|
||||
captured["json"] = json
|
||||
return _Response()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.provider.sources.mimo_stt_api_source.prepare_audio_input",
|
||||
fake_prepare_audio_input,
|
||||
)
|
||||
provider.client = SimpleNamespace(post=fake_post)
|
||||
|
||||
result = await provider.get_text("/tmp/test.wav")
|
||||
|
||||
assert result == "transcribed text"
|
||||
assert captured["json"]["messages"] == [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are a speech transcription assistant. "
|
||||
"Transcribe the spoken content from the audio exactly "
|
||||
"and return only the transcription text."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": MIMO_STT_TEST_AUDIO_DATA_URL,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": (
|
||||
"Please transcribe the content of the audio "
|
||||
"and return only the transcription text."
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mimo_stt_prepare_audio_input_returns_data_url(monkeypatch):
|
||||
class _ResolvedAudio:
|
||||
base64_data = "ZmFrZQ=="
|
||||
base64_data = MIMO_STT_TEST_AUDIO_BASE64
|
||||
mime_type = "audio/wav"
|
||||
format = "wav"
|
||||
|
||||
@@ -284,6 +370,41 @@ async def test_mimo_stt_prepare_audio_input_returns_data_url(monkeypatch):
|
||||
assert cleanup_paths == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mimo_stt_prepare_audio_input_rejects_non_wav_payload(monkeypatch):
|
||||
"""上游 SILK→WAV 转换静默失败时应本地报错,而不是把坏字节发给 API(#9113)。"""
|
||||
silk_base64 = base64.b64encode(b"\x02#!SILK_V3" + b"\x00" * 16).decode()
|
||||
|
||||
class _ResolvedAudio:
|
||||
base64_data = silk_base64
|
||||
mime_type = "audio/wav"
|
||||
format = "wav"
|
||||
|
||||
def to_data_url(self):
|
||||
return f"data:audio/wav;base64,{silk_base64}"
|
||||
|
||||
class _Resolver:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
async def to_base64_data(self, **_kwargs):
|
||||
return _ResolvedAudio()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.provider.sources.mimo_api_common.MediaResolver",
|
||||
_Resolver,
|
||||
)
|
||||
|
||||
with pytest.raises(MiMoAPIError, match="SILK"):
|
||||
await prepare_audio_input("/tmp/test.wav")
|
||||
|
||||
|
||||
def test_mimo_stt_wav_validation_accepts_unpadded_base64_header():
|
||||
wav_base64 = base64.b64encode(MIMO_STT_TEST_WAV_HEADER).decode().rstrip("=")
|
||||
|
||||
_validate_wav_payload(wav_base64, "/tmp/test.wav")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mimo_stt_get_text_uses_reasoning_content(monkeypatch):
|
||||
provider = _make_stt_provider()
|
||||
|
||||
Reference in New Issue
Block a user