Compare commits

...

9 Commits

Author SHA1 Message Date
Soulter
b7df91dff0 fix: update docstring to clarify normalization of malformed tool call names 2026-07-05 10:52:40 +08:00
Weilong Liao
9ac0f374ba Update astrbot/core/agent/runners/tool_loop_agent_runner.py
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2026-07-05 10:50:57 +08:00
Soulter
037e789deb feat: add sanitation for malformed tool call names in ToolLoopAgentRunner
fixes: #8929
closes: #8911
2026-07-05 10:49:38 +08:00
Soulter
85b653b6f0 feat: add note on cross-platform compatibility and Python version support 2026-07-05 10:06:16 +08:00
tangtaizong666
c9eed7b65e fix: adapt MiMo STT to V2.5 models and reject non-WAV audio payloads (#9118)
* fix: adapt MiMo STT to V2.5 models and reject non-WAV audio

The MiMo-V2 series went offline on 2026-06-30, so the default STT model
mimo-v2-omni fails for every default configuration. Switch the default
to mimo-v2.5-asr, the dedicated speech recognition model whose official
docs use exactly the bare input_audio payload this provider sends.

For non-ASR multimodal models such as mimo-v2.5, the audio understanding
docs require a text instruction alongside the audio, so restore the
system/user transcription prompts for that model family only.

Also validate that the resolved audio payload really is RIFF/WAVE before
calling the API: when a platform voice file (e.g. Tencent SILK from QQ)
slips through the WAV conversion chain unchanged, fail locally with an
actionable error instead of the opaque HTTP 400 from the API.

Fixes #9113

* fix: accept unpadded MiMo wav headers

---------

Co-authored-by: tangtaizong666 <212687958+tangtaizong666@users.noreply.github.com>
2026-07-05 09:59:48 +08:00
shuiping233
cc0b347508 fix: qq_official websocket适配器发送消息收到None时现在会重试了 (#8977) (#8979)
* fix: qq_official websocket适配器发送消息收到None时现在会重试了 (#8977)

* fix: qq_official websocket适配器重试发送消息的时候不会重复打印重试告警了

* fix: qq_official websocket适配器发送媒体/文件消息时遇到api返回None也会重试了
2026-07-05 09:50:42 +08:00
LIghtJUNction
30426c4f67 fix: secure project update temp staging (#9083)
* fix: secure project update temp staging

* Update astrbot/dashboard/services/update_service.py

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

* fix: close update staging temp context

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-07-05 09:49:11 +08:00
VectorPeak
041fba4df4 fix: enforce ownership when reading ChatUI sessions (#9141) 2026-07-05 09:34:38 +08:00
Weilong Liao
b43cc6dee0 feat: improve ChatUI attachment display (#9134) 2026-07-04 17:56:33 +08:00
26 changed files with 1144 additions and 421 deletions

View File

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

View File

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

View File

@@ -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": "",
},

View File

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

View File

@@ -3,7 +3,7 @@ import mimetypes
import shutil
import uuid
from collections.abc import Awaitable, Callable, Sequence
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import Any
from astrbot.core.db.po import Attachment
@@ -29,6 +29,23 @@ ReplyHistoryGetter = Callable[
MEDIA_PART_TYPES = {"image", "record", "file", "video"}
def _safe_display_filename(filename: str | None) -> str:
"""Return a safe basename for display-only filenames.
Args:
filename: Candidate filename from a message payload or component.
Returns:
Sanitized basename, or an empty string when the value is unusable.
"""
if not filename:
return ""
basename = (
PurePosixPath(str(filename).replace("\\", "/")).name.replace("\x00", "").strip()
)
return "" if basename in {"", ".", ".."} else basename
def strip_message_parts_path_fields(message_parts: list[dict]) -> list[dict]:
return [{k: v for k, v in part.items() if k != "path"} for part in message_parts]
@@ -229,14 +246,19 @@ async def build_webchat_message_parts(
continue
attachment_path = Path(attachment.path)
display_name = (
_safe_display_filename(part.get("filename")) or attachment_path.name
)
message_parts.append(
{
"type": attachment.type,
"attachment_id": attachment.attachment_id,
"filename": attachment_path.name,
"filename": display_name,
"path": str(attachment_path),
}
)
if display_name != attachment_path.name:
message_parts[-1]["stored_filename"] = attachment_path.name
return message_parts
@@ -340,6 +362,7 @@ async def create_attachment_part_from_existing_file(
insert_attachment: AttachmentInserter,
attachments_dir: str | Path,
fallback_dirs: Sequence[str | Path] = (),
display_name: str | None = None,
) -> dict | None:
basename = Path(filename).name
candidate_paths = [Path(attachments_dir) / basename]
@@ -358,11 +381,15 @@ async def create_attachment_part_from_existing_file(
if not attachment:
return None
return {
safe_display_name = _safe_display_filename(display_name)
part = {
"type": attach_type,
"attachment_id": attachment.attachment_id,
"filename": file_path.name,
"filename": safe_display_name or file_path.name,
}
if part["filename"] != file_path.name:
part["stored_filename"] = file_path.name
return part
async def message_chain_to_storage_message_parts(
@@ -464,8 +491,11 @@ async def _copy_file_to_attachment_part(
if not attachment:
return None
return {
part = {
"type": attach_type,
"attachment_id": attachment.attachment_id,
"filename": display_name or src_path.name,
"filename": _safe_display_filename(display_name) or src_path.name,
}
if part["filename"] != target_path.name:
part["stored_filename"] = target_path.name
return part

View File

@@ -4,7 +4,7 @@ import json
import os
import shutil
import uuid
from pathlib import Path
from pathlib import Path, PurePosixPath
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -122,12 +122,19 @@ class WebChatMessageEvent(AstrMessageEvent):
elif isinstance(comp, File):
# save file to local
file_path = await comp.get_file()
original_name = comp.name or os.path.basename(file_path)
raw_original_name = comp.name or os.path.basename(file_path)
original_name = (
PurePosixPath(str(raw_original_name).replace("\\", "/"))
.name.replace("\x00", "")
.strip()
)
if original_name in {"", ".", ".."}:
original_name = os.path.basename(file_path) or "file"
ext = os.path.splitext(original_name)[1] or ""
filename = f"{uuid.uuid4()!s}{ext}"
dest_path = os.path.join(attachments_dir, filename)
shutil.copy2(file_path, dest_path)
data = f"[FILE]{filename}"
data = f"[FILE]{filename}|{original_name}"
await web_chat_back_queue.put(
{
"type": "file",

View File

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

View File

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

View File

@@ -501,7 +501,7 @@ class ChatService:
)
async def create_attachment_from_file(
self, filename: str, attach_type: str
self, filename: str, attach_type: str, display_name: str | None = None
) -> dict | None:
return await create_attachment_part_from_existing_file(
filename,
@@ -509,6 +509,7 @@ class ChatService:
insert_attachment=self.db.insert_attachment,
attachments_dir=self.attachments_dir,
fallback_dirs=[self.webchat_img_dir],
display_name=display_name,
)
async def resolve_webchat_file(
@@ -897,9 +898,14 @@ class ChatService:
):
yield attachment_saved_event
elif msg_type == "file":
filename = result_text.replace("[FILE]", "")
filename = result_text.replace("[FILE]", "", 1)
display_name = None
if "|" in filename:
filename, display_name = filename.split("|", 1)
part = await self.create_attachment_from_file(
filename, "file"
filename,
"file",
display_name=display_name,
)
message_accumulator.add_attachment(part)
if attachment_saved_event := build_attachment_saved_event(
@@ -1190,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

View File

@@ -205,7 +205,7 @@ class LiveChatService:
logger.info(f"[Live Chat] WebSocket 连接关闭: {username}")
async def create_attachment_from_file(
self, filename: str, attach_type: str
self, filename: str, attach_type: str, display_name: str | None = None
) -> dict | None:
return await create_attachment_part_from_existing_file(
filename,
@@ -213,6 +213,7 @@ class LiveChatService:
insert_attachment=self.db.insert_attachment,
attachments_dir=self.attachments_dir,
fallback_dirs=[self.webchat_img_dir],
display_name=display_name,
)
@staticmethod
@@ -650,13 +651,27 @@ class LiveChatService:
message_accumulator.add_attachment(part)
await send_attachment_saved_event(part)
elif result_type == "file":
filename = str(result_text).replace("[FILE]", "").split("|", 1)[0]
part = await self.create_attachment_from_file(filename, "file")
filename = str(result_text).replace("[FILE]", "", 1)
display_name = None
if "|" in filename:
filename, display_name = filename.split("|", 1)
part = await self.create_attachment_from_file(
filename,
"file",
display_name=display_name,
)
message_accumulator.add_attachment(part)
await send_attachment_saved_event(part)
elif result_type == "video":
filename = str(result_text).replace("[VIDEO]", "").split("|", 1)[0]
part = await self.create_attachment_from_file(filename, "video")
filename = str(result_text).replace("[VIDEO]", "", 1)
display_name = None
if "|" in filename:
filename, display_name = filename.split("|", 1)
part = await self.create_attachment_from_file(
filename,
"video",
display_name=display_name,
)
message_accumulator.add_attachment(part)
await send_attachment_saved_event(part)

View File

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

View File

@@ -329,6 +329,7 @@ export type MessagePart = {
attachment_id?: string;
url?: string;
filename?: string;
stored_filename?: string;
mime_type?: string;
[key: string]: unknown | string;
};

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 279 icons */
/* Auto-generated MDI subset 273 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -256,10 +256,6 @@
content: "\F0167";
}
.mdi-code-braces::before {
content: "\F0169";
}
.mdi-code-json::before {
content: "\F0626";
}
@@ -452,6 +448,10 @@
content: "\F021C";
}
.mdi-file-image::before {
content: "\F021F";
}
.mdi-file-music-outline::before {
content: "\F0E2A";
}
@@ -496,10 +496,6 @@
content: "\F024B";
}
.mdi-folder-cog-outline::before {
content: "\F1080";
}
.mdi-folder-move::before {
content: "\F0252";
}
@@ -628,22 +624,6 @@
content: "\F0318";
}
.mdi-language-css3::before {
content: "\F031C";
}
.mdi-language-html5::before {
content: "\F031D";
}
.mdi-language-java::before {
content: "\F0B37";
}
.mdi-language-javascript::before {
content: "\F031E";
}
.mdi-language-markdown::before {
content: "\F0354";
}
@@ -652,14 +632,6 @@
content: "\F0F5B";
}
.mdi-language-python::before {
content: "\F0320";
}
.mdi-language-typescript::before {
content: "\F06E6";
}
.mdi-layers-outline::before {
content: "\F09FE";
}
@@ -1016,6 +988,10 @@
content: "\F060D";
}
.mdi-svg::before {
content: "\F0721";
}
.mdi-sync::before {
content: "\F04E6";
}

View File

@@ -92,7 +92,7 @@
>
<div
class="attachment-icon"
:style="{ color: filePresentation(file).color }"
:style="{ '--attachment-color': filePresentation(file).color }"
>
<v-icon :icon="filePresentation(file).icon" size="24"></v-icon>
<span class="attachment-ext">{{
@@ -315,6 +315,7 @@ import ConfigSelector from "./ConfigSelector.vue";
import ProviderModelMenu from "./ProviderModelMenu.vue";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import CommandSuggestion from "./CommandSuggestion.vue";
import { attachmentPresentation } from "./attachmentPresentation";
import type { Session } from "@/composables/useSessions";
import type { SuggestionCommand } from "./CommandSuggestion.vue";
@@ -546,62 +547,8 @@ const hasStagedAttachments = computed(() => {
);
});
const fileTypeStyles: Record<
string,
{ color: string; icon: string; label: string }
> = {
pdf: { color: "#d32f2f", icon: "mdi-file-pdf-box", label: "PDF" },
txt: { color: "#1976d2", icon: "mdi-file-document-outline", label: "TXT" },
md: { color: "#1976d2", icon: "mdi-language-markdown-outline", label: "MD" },
markdown: {
color: "#1976d2",
icon: "mdi-language-markdown-outline",
label: "MD",
},
doc: { color: "#2b579a", icon: "mdi-file-word-box", label: "DOC" },
docx: { color: "#2b579a", icon: "mdi-file-word-box", label: "DOCX" },
xls: { color: "#217346", icon: "mdi-file-excel-box", label: "XLS" },
xlsx: { color: "#217346", icon: "mdi-file-excel-box", label: "XLSX" },
csv: { color: "#217346", icon: "mdi-file-delimited-outline", label: "CSV" },
ppt: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPT" },
pptx: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPTX" },
zip: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "ZIP" },
rar: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "RAR" },
"7z": { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "7Z" },
tar: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "TAR" },
gz: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "GZ" },
json: { color: "#6a1b9a", icon: "mdi-code-json", label: "JSON" },
yaml: { color: "#6a1b9a", icon: "mdi-code-braces", label: "YAML" },
yml: { color: "#6a1b9a", icon: "mdi-code-braces", label: "YML" },
js: { color: "#b8860b", icon: "mdi-language-javascript", label: "JS" },
ts: { color: "#3178c6", icon: "mdi-language-typescript", label: "TS" },
html: { color: "#e34c26", icon: "mdi-language-html5", label: "HTML" },
css: { color: "#264de4", icon: "mdi-language-css3", label: "CSS" },
py: { color: "#3776ab", icon: "mdi-language-python", label: "PY" },
java: { color: "#b07219", icon: "mdi-language-java", label: "JAVA" },
mp3: { color: "#00897b", icon: "mdi-file-music-outline", label: "MP3" },
wav: { color: "#00897b", icon: "mdi-file-music-outline", label: "WAV" },
flac: { color: "#00897b", icon: "mdi-file-music-outline", label: "FLAC" },
mp4: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "MP4" },
mov: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "MOV" },
webm: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "WEBM" },
};
function fileExtension(file: StagedFileInfo) {
const name = file.original_name || file.filename || "";
const extension = name.split(".").pop()?.toLowerCase() || "";
return extension === name.toLowerCase() ? "" : extension;
}
function filePresentation(file: StagedFileInfo) {
const extension = fileExtension(file);
return (
fileTypeStyles[extension] || {
color: "#607d8b",
icon: "mdi-file-document-outline",
label: extension ? extension.slice(0, 4).toUpperCase() : "FILE",
}
);
return attachmentPresentation(file);
}
// Ctrl+B 长按录音相关
@@ -1418,6 +1365,7 @@ defineExpose({
}
.attachment-card {
--attachment-color: #607d8b;
position: relative;
display: inline-flex;
align-items: center;
@@ -1430,9 +1378,18 @@ defineExpose({
padding: 7px 32px 7px 10px;
overflow: hidden;
color: rgb(var(--v-theme-on-surface));
background: rgba(var(--v-theme-on-surface), 0.035);
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 14px;
background: rgba(var(--v-theme-on-surface), 0.055);
border: 0;
border-radius: 8px;
}
.file-preview {
background: rgba(var(--v-theme-on-surface), 0.055);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--attachment-color) 14%, transparent),
rgba(var(--v-theme-on-surface), 0.055) 62%
);
}
.image-preview {
@@ -1446,7 +1403,7 @@ defineExpose({
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 13px;
border-radius: 8px;
}
.attachment-icon {
@@ -1457,6 +1414,7 @@ defineExpose({
gap: 1px;
flex-shrink: 0;
min-width: 34px;
color: var(--attachment-color);
}
.attachment-icon--audio {
@@ -1471,6 +1429,7 @@ defineExpose({
font-size: 10px;
font-weight: 700;
line-height: 12px;
color: var(--attachment-color);
}
.attachment-name {

View File

@@ -44,9 +44,15 @@
<div v-else class="sent-attachment-card sent-file-card">
<div
class="sent-attachment-icon"
:style="{ color: attachmentPresentation(part).color }"
:style="{
'--attachment-color': attachmentPresentation(part).color,
}"
>
<v-icon :icon="attachmentPresentation(part).icon" size="24" />
<v-icon
class="sent-attachment-icon-symbol"
:icon="attachmentPresentation(part).icon"
size="24"
/>
<span class="sent-attachment-ext">
{{ attachmentPresentation(part).label }}
</span>
@@ -61,7 +67,10 @@
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id || part.filename || '',
part.attachment_id ||
part.stored_filename ||
part.filename ||
'',
)
"
@click="downloadPart(part)"
@@ -205,16 +214,37 @@
:src="partUrl(part)"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
<div
v-else-if="part.type === 'file'"
class="file-part"
:style="{
'--attachment-color': attachmentPresentation(part).color,
}"
>
<v-icon
class="file-part-icon"
:icon="attachmentPresentation(part).icon"
size="24"
/>
<div class="file-part-meta">
<span class="file-part-name">
{{ attachmentName(part) }}
</span>
<span class="file-part-kind">
{{ attachmentPresentation(part).label }}
</span>
</div>
<v-btn
class="file-part-action"
icon="mdi-download"
size="x-small"
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id || part.filename || '',
part.attachment_id ||
part.stored_filename ||
part.filename ||
'',
)
"
@click="downloadPart(part)"
@@ -407,6 +437,10 @@ import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import {
attachmentName,
attachmentPresentation,
} from "@/components/chat/attachmentPresentation";
import {
displayParts as displayMessageParts,
messageBlocks as buildMessageBlocks,
@@ -604,74 +638,6 @@ function hasFollowingContentBlock(message: ChatRecord, blockIndex: number) {
.some((block) => block.kind === "content");
}
const attachmentTypeStyles: Record<
string,
{ color: string; icon: string; label: string }
> = {
pdf: { color: "#d32f2f", icon: "mdi-file-pdf-box", label: "PDF" },
txt: { color: "#1976d2", icon: "mdi-file-document-outline", label: "TXT" },
md: { color: "#1976d2", icon: "mdi-language-markdown-outline", label: "MD" },
markdown: {
color: "#1976d2",
icon: "mdi-language-markdown-outline",
label: "MD",
},
doc: { color: "#2b579a", icon: "mdi-file-word-box", label: "DOC" },
docx: { color: "#2b579a", icon: "mdi-file-word-box", label: "DOCX" },
xls: { color: "#217346", icon: "mdi-file-excel-box", label: "XLS" },
xlsx: { color: "#217346", icon: "mdi-file-excel-box", label: "XLSX" },
csv: { color: "#217346", icon: "mdi-file-delimited-outline", label: "CSV" },
ppt: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPT" },
pptx: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPTX" },
zip: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "ZIP" },
rar: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "RAR" },
"7z": { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "7Z" },
tar: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "TAR" },
gz: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "GZ" },
json: { color: "#6a1b9a", icon: "mdi-code-json", label: "JSON" },
yaml: { color: "#6a1b9a", icon: "mdi-code-braces", label: "YAML" },
yml: { color: "#6a1b9a", icon: "mdi-code-braces", label: "YML" },
js: { color: "#b8860b", icon: "mdi-language-javascript", label: "JS" },
ts: { color: "#3178c6", icon: "mdi-language-typescript", label: "TS" },
html: { color: "#e34c26", icon: "mdi-language-html5", label: "HTML" },
css: { color: "#264de4", icon: "mdi-language-css3", label: "CSS" },
py: { color: "#3776ab", icon: "mdi-language-python", label: "PY" },
java: { color: "#b07219", icon: "mdi-language-java", label: "JAVA" },
mp3: { color: "#00897b", icon: "mdi-file-music-outline", label: "MP3" },
wav: { color: "#00897b", icon: "mdi-file-music-outline", label: "WAV" },
flac: { color: "#00897b", icon: "mdi-file-music-outline", label: "FLAC" },
mp4: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "MP4" },
mov: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "MOV" },
webm: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "WEBM" },
};
function attachmentName(part: MessagePart) {
return part.embedded_file?.filename || part.filename || part.type || "file";
}
function attachmentExtension(part: MessagePart) {
const name = attachmentName(part);
const extension = name.split(".").pop()?.toLowerCase() || "";
return extension === name.toLowerCase() ? "" : extension;
}
function attachmentPresentation(part: MessagePart) {
if (part.type === "record") {
return { color: "#00897b", icon: "mdi-microphone", label: "AUDIO" };
}
if (part.type === "video") {
return { color: "#5e35b1", icon: "mdi-file-video-outline", label: "VIDEO" };
}
const extension = attachmentExtension(part);
return (
attachmentTypeStyles[extension] || {
color: "#607d8b",
icon: "mdi-file-document-outline",
label: extension ? extension.slice(0, 4).toUpperCase() : "FILE",
}
);
}
function handleMouseUp(event: MouseEvent, message: ChatRecord) {
if (props.enableThreadSelection && !isUserMessage(message)) {
emit("selectBotText", event, message);
@@ -696,8 +662,9 @@ function partUrl(part: MessagePart) {
if (part.attachment_id) {
return fileApi.contentUrl(part.attachment_id);
}
if (part.filename) {
return fileApi.byNameUrl(part.filename);
const lookupFilename = part.stored_filename || part.filename;
if (lookupFilename) {
return fileApi.byNameUrl(lookupFilename);
}
return "";
}
@@ -824,7 +791,7 @@ async function copyMessage(message: ChatRecord) {
}
async function downloadPart(part: MessagePart) {
const key = part.attachment_id || part.filename || "";
const key = part.attachment_id || part.stored_filename || part.filename || "";
if (!key) return;
downloadingFiles.value = new Set(downloadingFiles.value).add(key);
try {
@@ -962,17 +929,18 @@ function formatDuration(seconds: number) {
}
.sent-attachment-card {
--attachment-color: #607d8b;
position: relative;
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: flex-start;
gap: 8px;
height: 64px;
height: 60px;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 12px;
background: rgba(var(--v-theme-on-surface), 0.04);
border: 0;
border-radius: 8px;
background: rgba(var(--v-theme-on-surface), 0.055);
color: rgb(var(--v-theme-on-surface));
}
@@ -986,7 +954,7 @@ function formatDuration(seconds: number) {
.sent-image-card img {
width: 100%;
height: 100%;
border-radius: 11px;
border-radius: 8px;
object-fit: cover;
}
@@ -1005,18 +973,29 @@ function formatDuration(seconds: number) {
}
.sent-file-card {
width: 220px;
width: 236px;
padding: 8px 10px;
background: rgba(var(--v-theme-on-surface), 0.055);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--attachment-color) 14%, transparent),
rgba(var(--v-theme-on-surface), 0.055) 62%
);
}
.sent-attachment-icon {
display: inline-flex;
flex-shrink: 0;
min-width: 34px;
min-width: 36px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
color: var(--attachment-color);
}
.sent-attachment-icon-symbol {
color: var(--attachment-color);
}
.sent-attachment-ext {
@@ -1027,6 +1006,7 @@ function formatDuration(seconds: number) {
font-size: 10px;
font-weight: 700;
line-height: 12px;
color: var(--attachment-color);
}
.sent-attachment-name {
@@ -1187,21 +1167,61 @@ function formatDuration(seconds: number) {
}
.file-part {
display: flex;
--attachment-color: #607d8b;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
gap: 10px;
width: min(420px, 100%);
margin-top: 8px;
padding: 8px 10px;
border: 1px solid var(--chat-border);
padding: 9px 8px 9px 10px;
border: 0;
border-radius: 8px;
background: rgba(var(--v-theme-on-surface), 0.055);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--attachment-color) 13%, transparent),
rgba(var(--v-theme-on-surface), 0.055) 58%
);
}
.file-part span {
.file-part-icon {
color: var(--attachment-color);
}
.file-part-meta {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
}
.file-part-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.file-part-kind {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--attachment-color);
font-size: 11px;
font-weight: 700;
line-height: 14px;
}
.file-part-action {
color: rgb(var(--v-theme-on-surface));
opacity: 0.72;
}
.file-part:hover .file-part-action {
opacity: 1;
}
.tool-call-block {

View File

@@ -106,16 +106,37 @@
:src="partUrl(part)"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
<div
v-else-if="part.type === 'file'"
class="file-part"
:style="{
'--attachment-color': attachmentPresentation(part).color,
}"
>
<v-icon
class="file-part-icon"
:icon="attachmentPresentation(part).icon"
size="24"
/>
<div class="file-part-meta">
<span class="file-part-name">
{{ attachmentName(part) }}
</span>
<span class="file-part-kind">
{{ attachmentPresentation(part).label }}
</span>
</div>
<v-btn
class="file-part-action"
icon="mdi-download"
size="x-small"
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id || part.filename || '',
part.attachment_id ||
part.stored_filename ||
part.filename ||
'',
)
"
@click="downloadPart(part)"
@@ -268,6 +289,10 @@ import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue"
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import {
attachmentName,
attachmentPresentation,
} from "@/components/chat/attachmentPresentation";
import {
displayParts as displayMessageParts,
messageBlocks as buildMessageBlocks,
@@ -350,8 +375,9 @@ function partUrl(part: MessagePart) {
if (part.attachment_id) {
return fileApi.contentUrl(part.attachment_id);
}
if (part.filename) {
return fileApi.byNameUrl(part.filename);
const lookupFilename = part.stored_filename || part.filename;
if (lookupFilename) {
return fileApi.byNameUrl(lookupFilename);
}
return "";
}
@@ -484,7 +510,7 @@ async function copyMessage(message: ChatRecord) {
}
async function downloadPart(part: MessagePart) {
const key = part.attachment_id || part.filename || "";
const key = part.attachment_id || part.stored_filename || part.filename || "";
if (!key) return;
downloadingFiles.value = new Set(downloadingFiles.value).add(key);
try {
@@ -712,21 +738,61 @@ function formatDuration(seconds: number) {
}
.file-part {
display: flex;
--attachment-color: #607d8b;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
gap: 10px;
width: min(420px, 100%);
margin-top: 8px;
padding: 8px 10px;
border: 1px solid var(--chat-border);
padding: 9px 8px 9px 10px;
border: 0;
border-radius: 8px;
background: rgba(var(--v-theme-on-surface), 0.055);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--attachment-color) 13%, transparent),
rgba(var(--v-theme-on-surface), 0.055) 58%
);
}
.file-part span {
.file-part-icon {
color: var(--attachment-color);
}
.file-part-meta {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
}
.file-part-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.file-part-kind {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--attachment-color);
font-size: 11px;
font-weight: 700;
line-height: 14px;
}
.file-part-action {
color: rgb(var(--v-theme-on-surface));
opacity: 0.72;
}
.file-part:hover .file-part-action {
opacity: 1;
}
.tool-call-block {

View File

@@ -85,9 +85,27 @@
:src="partUrl(part)"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
<div
v-else-if="part.type === 'file'"
class="file-part"
:style="{
'--attachment-color':
attachmentPresentation(part).color,
}"
>
<v-icon
class="file-part-icon"
:icon="attachmentPresentation(part).icon"
size="24"
/>
<div class="file-part-meta">
<span class="file-part-name">
{{ attachmentName(part) }}
</span>
<span class="file-part-kind">
{{ attachmentPresentation(part).label }}
</span>
</div>
</div>
<div
@@ -194,6 +212,10 @@ import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import {
attachmentName,
attachmentPresentation,
} from "@/components/chat/attachmentPresentation";
import { useMediaHandling } from "@/composables/useMediaHandling";
import {
displayParts as displayMessageParts,
@@ -411,7 +433,8 @@ function partUrl(part: MessagePart) {
if (part.embedded_url) return part.embedded_url;
if (part.embedded_file?.url) return part.embedded_file.url;
if (part.attachment_id) return fileApi.contentUrl(part.attachment_id);
if (part.filename) return fileApi.byNameUrl(part.filename);
const lookupFilename = part.stored_filename || part.filename;
if (lookupFilename) return fileApi.byNameUrl(lookupFilename);
return "";
}
@@ -575,10 +598,51 @@ function closeImage() {
}
.file-part {
display: flex;
--attachment-color: #607d8b;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
gap: 10px;
width: min(420px, 100%);
margin-top: 8px;
padding: 9px 10px;
border-radius: 8px;
background: rgba(var(--v-theme-on-surface), 0.055);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--attachment-color) 13%, transparent),
rgba(var(--v-theme-on-surface), 0.055) 58%
);
}
.file-part-icon {
color: var(--attachment-color);
}
.file-part-meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.file-part-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.file-part-kind {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--attachment-color);
font-size: 11px;
font-weight: 700;
line-height: 14px;
}
.tool-call-block {

View File

@@ -70,6 +70,7 @@ import {
payloadText,
upsertToolCall,
type ChatRecord,
type MessagePart,
type ChatThread,
} from "@/composables/useMessages";
import { useModuleI18n } from "@/i18n/composables";
@@ -305,13 +306,22 @@ function processPayload(botRecord: ChatRecord, userRecord: ChatRecord, payload:
if (["image", "record", "file", "video"].includes(type)) {
markMessageStarted(botRecord);
const filename = String(data)
const rawFilename = String(data)
.replace("[IMAGE]", "")
.replace("[RECORD]", "")
.replace("[FILE]", "")
.replace("[VIDEO]", "")
.split("|", 1)[0];
botRecord.content.message.push({ type, filename });
.replace("[VIDEO]", "");
const separatorIndex = rawFilename.indexOf("|");
const storedFilename =
separatorIndex >= 0 ? rawFilename.slice(0, separatorIndex) : rawFilename;
const displayFilename =
separatorIndex >= 0 ? rawFilename.slice(separatorIndex + 1) : storedFilename;
const filename = displayFilename || storedFilename;
const mediaPart: MessagePart = { type, filename };
if (storedFilename && storedFilename !== filename) {
mediaPart.stored_filename = storedFilename;
}
botRecord.content.message.push(mediaPart);
}
}

View File

@@ -0,0 +1,130 @@
export interface AttachmentPresentationInput {
type?: string;
filename?: string;
original_name?: string;
embedded_file?: {
filename?: string;
};
}
export interface AttachmentPresentation {
color: string;
icon: string;
label: string;
}
const fileTypeStyles: Record<string, AttachmentPresentation> = {
pdf: { color: "#c43b3b", icon: "mdi-file-pdf-box", label: "PDF" },
doc: { color: "#2b579a", icon: "mdi-file-word-box", label: "WORD" },
docx: { color: "#2b579a", icon: "mdi-file-word-box", label: "WORD" },
xls: { color: "#217346", icon: "mdi-file-excel-box", label: "XLS" },
xlsx: { color: "#217346", icon: "mdi-file-excel-box", label: "XLSX" },
csv: { color: "#217346", icon: "mdi-file-delimited-outline", label: "CSV" },
ppt: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPT" },
pptx: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPT" },
jpg: { color: "#c1467a", icon: "mdi-file-image", label: "IMAGE" },
jpeg: { color: "#c1467a", icon: "mdi-file-image", label: "IMAGE" },
png: { color: "#c1467a", icon: "mdi-file-image", label: "IMAGE" },
gif: { color: "#c1467a", icon: "mdi-file-image", label: "IMAGE" },
webp: { color: "#c1467a", icon: "mdi-file-image", label: "IMAGE" },
svg: { color: "#c1467a", icon: "mdi-svg", label: "SVG" },
heic: { color: "#c1467a", icon: "mdi-file-image", label: "IMAGE" },
bmp: { color: "#c1467a", icon: "mdi-file-image", label: "IMAGE" },
mp3: { color: "#00897b", icon: "mdi-file-music-outline", label: "AUDIO" },
wav: { color: "#00897b", icon: "mdi-file-music-outline", label: "AUDIO" },
flac: { color: "#00897b", icon: "mdi-file-music-outline", label: "AUDIO" },
m4a: { color: "#00897b", icon: "mdi-file-music-outline", label: "AUDIO" },
ogg: { color: "#00897b", icon: "mdi-file-music-outline", label: "AUDIO" },
mp4: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "VIDEO" },
mov: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "VIDEO" },
webm: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "VIDEO" },
zip: { color: "#8a6f00", icon: "mdi-folder-zip-outline", label: "ZIP" },
rar: { color: "#8a6f00", icon: "mdi-folder-zip-outline", label: "RAR" },
"7z": { color: "#8a6f00", icon: "mdi-folder-zip-outline", label: "7Z" },
tar: { color: "#8a6f00", icon: "mdi-folder-zip-outline", label: "TAR" },
gz: { color: "#8a6f00", icon: "mdi-folder-zip-outline", label: "GZ" },
txt: { color: "#607d8b", icon: "mdi-file-document-outline", label: "TXT" },
md: { color: "#607d8b", icon: "mdi-language-markdown-outline", label: "MD" },
markdown: {
color: "#607d8b",
icon: "mdi-language-markdown-outline",
label: "MD",
},
};
const codeFileTypes = new Set([
"c",
"cc",
"cpp",
"cs",
"css",
"go",
"h",
"hpp",
"html",
"java",
"js",
"json",
"jsx",
"kt",
"php",
"py",
"rb",
"rs",
"scss",
"sh",
"sql",
"swift",
"ts",
"tsx",
"vue",
"xml",
"yaml",
"yml",
]);
export function attachmentName(part: AttachmentPresentationInput) {
return (
part.embedded_file?.filename ||
part.original_name ||
part.filename ||
part.type ||
"file"
);
}
export function attachmentExtension(part: AttachmentPresentationInput) {
const name = attachmentName(part);
const extension = name.split(".").pop()?.toLowerCase() || "";
return extension === name.toLowerCase() ? "" : extension;
}
export function attachmentPresentation(
part: AttachmentPresentationInput,
): AttachmentPresentation {
if (part.type === "image") {
return { color: "#c1467a", icon: "mdi-file-image", label: "IMAGE" };
}
if (part.type === "record") {
return { color: "#00897b", icon: "mdi-file-music-outline", label: "AUDIO" };
}
if (part.type === "video") {
return { color: "#5e35b1", icon: "mdi-file-video-outline", label: "VIDEO" };
}
const extension = attachmentExtension(part);
if (codeFileTypes.has(extension)) {
return {
color: "#6a4fb3",
icon: extension === "json" ? "mdi-code-json" : "mdi-file-code-outline",
label: extension ? extension.slice(0, 4).toUpperCase() : "CODE",
};
}
return (
fileTypeStyles[extension] || {
color: "#607d8b",
icon: "mdi-file-document-outline",
label: extension ? extension.slice(0, 4).toUpperCase() : "FILE",
}
);
}

View File

@@ -14,6 +14,7 @@ export interface MessagePart {
embedded_file?: { url?: string; filename?: string; attachment_id?: string };
attachment_id?: string;
filename?: string;
stored_filename?: string;
tool_calls?: ToolCall[];
[key: string]: unknown;
}
@@ -174,20 +175,23 @@ export function useMessages(options: UseMessagesOptions) {
if (part.embedded_url) return;
let url: string;
let cacheKey: string;
const storedFilename =
typeof part.stored_filename === "string" ? part.stored_filename : "";
const lookupFilename = storedFilename || part.filename || "";
if (part.attachment_id) {
cacheKey = `att:${part.attachment_id}`;
url = fileApi.contentUrl(part.attachment_id);
} else if (part.filename) {
cacheKey = `file:${part.filename}`;
} else if (lookupFilename) {
cacheKey = `file:${lookupFilename}`;
url = "";
} else {
return;
}
let promise = attachmentBlobCache.get(cacheKey);
if (!promise) {
if (part.filename) {
if (!part.attachment_id && lookupFilename) {
promise = fileApi
.getByName(part.filename)
.getByName(lookupFilename)
.then((resp) => URL.createObjectURL(resp.data));
} else {
promise = fetchWithAuth(url).then(async (resp) => {
@@ -210,7 +214,11 @@ export function useMessages(options: UseMessagesOptions) {
const tasks: Promise<void>[] = [];
for (const record of records) {
for (const part of record.content?.message || []) {
if (mediaTypes.includes(part.type) && !part.embedded_url && (part.attachment_id || part.filename)) {
if (
mediaTypes.includes(part.type) &&
!part.embedded_url &&
(part.attachment_id || part.stored_filename || part.filename)
) {
tasks.push(resolvePartMedia(part));
}
}
@@ -796,13 +804,21 @@ export function useMessages(options: UseMessagesOptions) {
if (["image", "record", "file", "video"].includes(msgType)) {
markMessageStarted(botRecord);
const filename = String(data)
const rawFilename = String(data)
.replace("[IMAGE]", "")
.replace("[RECORD]", "")
.replace("[FILE]", "")
.replace("[VIDEO]", "")
.split("|", 1)[0];
.replace("[VIDEO]", "");
const separatorIndex = rawFilename.indexOf("|");
const storedFilename =
separatorIndex >= 0 ? rawFilename.slice(0, separatorIndex) : rawFilename;
const displayFilename =
separatorIndex >= 0 ? rawFilename.slice(separatorIndex + 1) : storedFilename;
const filename = displayFilename || storedFilename;
const mediaPart: MessagePart = { type: msgType, filename };
if (storedFilename && storedFilename !== filename) {
mediaPart.stored_filename = storedFilename;
}
if (msgType !== "file") {
resolvePartMedia(mediaPart).then(() => {
messageContent(botRecord).message.push(mediaPart);

View File

@@ -5455,6 +5455,8 @@ components:
type: string
filename:
type: string
stored_filename:
type: string
mime_type:
type: string
additionalProperties: true

View File

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

View File

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

View File

@@ -0,0 +1,110 @@
import asyncio
from types import SimpleNamespace
import pytest
from astrbot.api.event import MessageChain
from astrbot.api.message_components import File
from astrbot.core.platform.sources.webchat import webchat_event
from astrbot.core.platform.sources.webchat.message_parts_helper import (
build_webchat_message_parts,
create_attachment_part_from_existing_file,
)
@pytest.mark.asyncio
async def test_webchat_file_send_keeps_original_filename(tmp_path, monkeypatch):
"""WebChat file payloads should carry both stored and display filenames."""
queue = asyncio.Queue()
attachments_dir = tmp_path / "attachments"
attachments_dir.mkdir()
source_file = tmp_path / "source.txt"
source_file.write_text("hello", encoding="utf-8")
monkeypatch.setattr(webchat_event, "attachments_dir", str(attachments_dir))
monkeypatch.setattr(
webchat_event.webchat_queue_mgr,
"get_or_create_back_queue",
lambda *_args: queue,
)
await webchat_event.WebChatMessageEvent._send(
"message-1",
MessageChain([File(name="report.txt", file=str(source_file))]),
"webchat!user!conversation-1",
)
payload = await queue.get()
stored_name, display_name = payload["data"].removeprefix("[FILE]").split("|", 1)
assert payload["type"] == "file"
assert display_name == "report.txt"
assert stored_name != display_name
assert (attachments_dir / stored_name).exists()
@pytest.mark.asyncio
async def test_attachment_part_uses_display_filename_with_stored_filename(tmp_path):
"""Attachment parts should show the display name while keeping the stored name."""
stored_file = tmp_path / "uuid.txt"
stored_file.write_text("payload", encoding="utf-8")
async def insert_attachment(path, type, mime_type):
return SimpleNamespace(
attachment_id="attachment-1",
path=path,
type=type,
mime_type=mime_type,
)
part = await create_attachment_part_from_existing_file(
stored_file.name,
attach_type="file",
insert_attachment=insert_attachment,
attachments_dir=tmp_path,
display_name="../nested/report.txt",
)
assert part == {
"type": "file",
"attachment_id": "attachment-1",
"filename": "report.txt",
"stored_filename": "uuid.txt",
}
@pytest.mark.asyncio
async def test_build_webchat_message_parts_preserves_payload_filename(tmp_path):
"""Attachment lookup should not overwrite the payload filename with disk name."""
stored_file = tmp_path / "uuid.txt"
stored_file.write_text("payload", encoding="utf-8")
attachment = SimpleNamespace(
attachment_id="attachment-1",
path=str(stored_file),
type="file",
)
async def get_attachment_by_id(attachment_id):
assert attachment_id == "attachment-1"
return attachment
parts = await build_webchat_message_parts(
[
{
"type": "file",
"attachment_id": "attachment-1",
"filename": r"C:\fakepath\report.txt",
}
],
get_attachment_by_id=get_attachment_by_id,
strict=True,
)
assert parts == [
{
"type": "file",
"attachment_id": "attachment-1",
"filename": "report.txt",
"path": str(stored_file),
"stored_filename": "uuid.txt",
}
]