-
-
{{ loadingDialog.result }}
-
+
{{ tm("dialogs.loading.logs") }}
diff --git a/dashboard/src/views/extension/useExtensionPage.js b/dashboard/src/views/extension/useExtensionPage.js
index 0693ce01df..ba72924c73 100644
--- a/dashboard/src/views/extension/useExtensionPage.js
+++ b/dashboard/src/views/extension/useExtensionPage.js
@@ -1139,16 +1139,23 @@ export const useExtensionPage = () => {
};
const updateConfig = async () => {
+ loadingDialog.title = tm("status.loading");
+ loadingDialog.statusCode = 0;
+ loadingDialog.result = "";
+ loadingDialog.show = true;
try {
const res = await pluginApi.updateConfig(
curr_namespace.value,
extension_config.config,
);
- if (res.data.status === "ok") {
- toast(res.data.message, "success");
- } else {
+ if (res.data.status !== "ok") {
toast(res.data.message, "error");
+ onLoadingDialogResult(2, res.data.message, -1);
+ return;
}
+
+ toast(res.data.message, "success");
+ onLoadingDialogResult(1, res.data.message);
configDialog.value = false;
currentConfigPlugin.value = "";
extension_config.metadata = {};
@@ -1157,7 +1164,9 @@ export const useExtensionPage = () => {
extension_config.log_level = null;
getExtensions();
} catch (err) {
- toast(err, "error");
+ const errMsg = resolveErrorMessage(err, tm("messages.operationFailed"));
+ toast(errMsg, "error");
+ onLoadingDialogResult(2, errMsg, -1);
}
};
From d5620d94d76af34cccacfe1cc88e3d34252a6fd7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=B8=9C=E4=BA=91?=
Date: Fri, 24 Jul 2026 22:35:26 +0800
Subject: [PATCH 12/46] fix: check quoted text for content safety (#9232)
* fix: check quoted text for content safety
* fix: initialize content safety result
* fix: combine content safety check text
---------
Co-authored-by: JIANZHOU
---
.../pipeline/content_safety_check/stage.py | 18 +++-
tests/test_content_safety_check.py | 94 +++++++++++++++++++
2 files changed, 110 insertions(+), 2 deletions(-)
create mode 100644 tests/test_content_safety_check.py
diff --git a/astrbot/core/pipeline/content_safety_check/stage.py b/astrbot/core/pipeline/content_safety_check/stage.py
index de56265807..e341d3bde6 100644
--- a/astrbot/core/pipeline/content_safety_check/stage.py
+++ b/astrbot/core/pipeline/content_safety_check/stage.py
@@ -1,8 +1,10 @@
from collections.abc import AsyncGenerator
from astrbot.core import logger
+from astrbot.core.message.components import Reply
from astrbot.core.message.message_event_result import MessageEventResult
from astrbot.core.platform.astr_message_event import AstrMessageEvent
+from astrbot.core.utils.quoted_message.chain_parser import ReplyChainParser
from ..context import PipelineContext
from ..stage import Stage, register_stage
@@ -26,8 +28,20 @@ async def process(
check_text: str | None = None,
) -> AsyncGenerator[None, None]:
"""检查内容安全"""
- text = check_text if check_text else event.get_message_str()
- ok, info = self.strategy_selector.check(text)
+ if check_text is None:
+ texts = [event.get_message_str()]
+ reply_parser = ReplyChainParser()
+ for component in event.get_messages():
+ if isinstance(component, Reply) and (
+ quoted_text := reply_parser.extract_text_from_reply_component(
+ component
+ )
+ ):
+ texts.append(quoted_text)
+ else:
+ texts = [check_text]
+
+ ok, info = self.strategy_selector.check("\n".join(texts))
if not ok:
if event.is_at_or_wake_command:
event.set_result(
diff --git a/tests/test_content_safety_check.py b/tests/test_content_safety_check.py
new file mode 100644
index 0000000000..ff7bd13f64
--- /dev/null
+++ b/tests/test_content_safety_check.py
@@ -0,0 +1,94 @@
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+
+from astrbot.core.message.components import Plain, Reply
+from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage
+from astrbot.core.pipeline.content_safety_check.strategies.strategy import (
+ StrategySelector,
+)
+
+
+@pytest.mark.asyncio
+async def test_content_safety_checks_combined_message_text_once():
+ event = SimpleNamespace(
+ is_at_or_wake_command=False,
+ get_message_str=lambda: "current message",
+ get_messages=lambda: [Reply(id="1", message_str="quoted message")],
+ stop_event=Mock(),
+ )
+ stage = ContentSafetyCheckStage()
+ stage.strategy_selector = SimpleNamespace(check=Mock(return_value=(True, "")))
+
+ async for _ in stage.process(event):
+ pass
+
+ stage.strategy_selector.check.assert_called_once_with(
+ "current message\nquoted message"
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("reply", "keyword", "check_text", "expected_stopped"),
+ [
+ (
+ Reply(id="1", message_str="引用中包含淀粉砖"),
+ "淀粉砖",
+ None,
+ True,
+ ),
+ (
+ Reply(id="1", message_str="", chain=[Plain("引用中包含淀粉砖")]),
+ "淀粉砖",
+ None,
+ True,
+ ),
+ (
+ Reply(id="1", message_str="引用中包含淀粉砖"),
+ "^你说呢\n引用中包含淀粉砖$",
+ None,
+ True,
+ ),
+ (
+ Reply(id="1", message_str="引用中包含淀粉砖"),
+ "淀粉砖",
+ "",
+ False,
+ ),
+ ],
+)
+async def test_content_safety_checks_quoted_text_only_for_inbound_messages(
+ reply: Reply,
+ keyword: str,
+ check_text: str | None,
+ expected_stopped: bool,
+):
+ stopped = False
+
+ def stop_event() -> None:
+ nonlocal stopped
+ stopped = True
+
+ event = SimpleNamespace(
+ is_at_or_wake_command=False,
+ get_message_str=lambda: "你说呢",
+ get_messages=lambda: [reply],
+ stop_event=stop_event,
+ )
+ stage = ContentSafetyCheckStage()
+ stage.strategy_selector = StrategySelector(
+ {
+ "internal_keywords": {
+ "enable": True,
+ "extra_keywords": [keyword],
+ },
+ "baidu_aip": {"enable": False},
+ }
+ )
+
+ async for _ in stage.process(event, check_text=check_text):
+ pass
+
+ assert stopped is expected_stopped
From ff3b74d411e6cdee923e30b923d1e1cb4b97aaaa Mon Sep 17 00:00:00 2001
From: w33d
Date: Fri, 24 Jul 2026 22:37:55 +0800
Subject: [PATCH 13/46] =?UTF-8?q?fix:=20=E9=98=B2=E6=AD=A2=E4=BA=BA?=
=?UTF-8?q?=E6=A0=BC=E7=BC=96=E8=BE=91=E5=BC=B9=E7=AA=97=E8=AF=AF=E8=A7=A6?=
=?UTF-8?q?=E5=85=B3=E9=97=AD=20(#9238)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: w33d
---
dashboard/src/components/shared/PersonaForm.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dashboard/src/components/shared/PersonaForm.vue b/dashboard/src/components/shared/PersonaForm.vue
index abbec8e8ff..51ff95ac0f 100644
--- a/dashboard/src/components/shared/PersonaForm.vue
+++ b/dashboard/src/components/shared/PersonaForm.vue
@@ -1,5 +1,5 @@
-
+
{{ editingPersona ? tm('dialog.edit.title') : tm('dialog.create.title') }}
From 11a5672efa1d5ed8a07ee0047bb4739df4acc91c Mon Sep 17 00:00:00 2001
From: lxfight <1686540385@qq.com>
Date: Fri, 24 Jul 2026 22:38:34 +0800
Subject: [PATCH 14/46] fix: preserve embedding batch result order (#9241)
---
astrbot/core/provider/provider.py | 7 +++++--
tests/unit/test_faiss_vec_db.py | 31 +++++++++++++++++++++++++++++++
2 files changed, 36 insertions(+), 2 deletions(-)
diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py
index 0cc9f1ca1c..891bfdea9e 100644
--- a/astrbot/core/provider/provider.py
+++ b/astrbot/core/provider/provider.py
@@ -363,7 +363,7 @@ async def get_embeddings_batch(
"""
semaphore = asyncio.Semaphore(tasks_limit)
- all_embeddings: list[list[float]] = []
+ batch_results: dict[int, list[list[float]]] = {}
failed_batches: list[tuple[int, list[str]]] = []
completed_count = 0
total_count = len(texts)
@@ -374,7 +374,7 @@ async def process_batch(batch_idx: int, batch_texts: list[str]) -> None:
for attempt in range(max_retries):
try:
batch_embeddings = await self.get_embeddings(batch_texts)
- all_embeddings.extend(batch_embeddings)
+ batch_results[batch_idx] = batch_embeddings
completed_count += len(batch_texts)
if progress_callback:
await progress_callback(completed_count, total_count)
@@ -406,6 +406,9 @@ async def process_batch(batch_idx: int, batch_texts: list[str]) -> None:
)
raise Exception(error_msg)
+ all_embeddings: list[list[float]] = []
+ for batch_idx in range(len(tasks)):
+ all_embeddings.extend(batch_results[batch_idx])
return all_embeddings
diff --git a/tests/unit/test_faiss_vec_db.py b/tests/unit/test_faiss_vec_db.py
index d294d51cd3..7f84f90fd2 100644
--- a/tests/unit/test_faiss_vec_db.py
+++ b/tests/unit/test_faiss_vec_db.py
@@ -1,9 +1,27 @@
+import asyncio
from unittest.mock import AsyncMock
import pytest
from astrbot.core.db.vec_db.faiss_impl.vec_db import FaissVecDB
from astrbot.core.exceptions import KnowledgeBaseUploadError
+from astrbot.core.provider.provider import EmbeddingProvider
+
+
+class DelayedEmbeddingProvider(EmbeddingProvider):
+ def __init__(self) -> None:
+ super().__init__({}, {})
+
+ async def get_embedding(self, text: str) -> list[float]:
+ return [float(text.removeprefix("chunk-"))]
+
+ async def get_embeddings(self, text: list[str]) -> list[list[float]]:
+ if text[0] == "chunk-0":
+ await asyncio.sleep(0.02)
+ return [[float(item.removeprefix("chunk-"))] for item in text]
+
+ def get_dim(self) -> int:
+ return 1
@pytest.mark.asyncio
@@ -44,3 +62,16 @@ async def test_insert_batch_raises_friendly_error_for_embedding_count_mismatch()
assert "期望 2,实际 1" in str(exc_info.value)
vec_db.document_storage.insert_documents_batch.assert_not_awaited()
vec_db.embedding_storage.insert_batch.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_get_embeddings_batch_preserves_input_order_when_batches_finish_out_of_order():
+ provider = DelayedEmbeddingProvider()
+
+ embeddings = await provider.get_embeddings_batch(
+ ["chunk-0", "chunk-1", "chunk-2", "chunk-3"],
+ batch_size=2,
+ tasks_limit=2,
+ )
+
+ assert embeddings == [[0.0], [1.0], [2.0], [3.0]]
From fb02c7273e7df5fc69dc415cd379cab4549730c6 Mon Sep 17 00:00:00 2001
From: w33d
Date: Fri, 24 Jul 2026 22:50:26 +0800
Subject: [PATCH 15/46] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86=20Tavily=20?=
=?UTF-8?q?=E6=97=A5=E6=9C=9F=E7=AD=9B=E9=80=89=E5=8F=82=E6=95=B0=E5=86=B2?=
=?UTF-8?q?=E7=AA=81=20(#9234)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: w33d
---
astrbot/core/tools/web_search_tools.py | 18 ++++---
tests/unit/test_web_search_tools.py | 65 ++++++++++++++++++++++++++
2 files changed, 76 insertions(+), 7 deletions(-)
diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py
index 8b6adbb773..0d85c40dc6 100644
--- a/astrbot/core/tools/web_search_tools.py
+++ b/astrbot/core/tools/web_search_tools.py
@@ -653,13 +653,17 @@ async def call(self, context, **kwargs) -> ToolExecResult:
if topic == "news":
payload["days"] = kwargs.get("days", 3)
- time_range = kwargs.get("time_range", "")
- if time_range in ["day", "week", "month", "year"]:
- payload["time_range"] = time_range
- if kwargs.get("start_date"):
- payload["start_date"] = kwargs["start_date"]
- if kwargs.get("end_date"):
- payload["end_date"] = kwargs["end_date"]
+ start_date = str(kwargs.get("start_date") or "").strip()
+ end_date = str(kwargs.get("end_date") or "").strip()
+ if start_date or end_date:
+ if start_date:
+ payload["start_date"] = start_date
+ if end_date:
+ payload["end_date"] = end_date
+ else:
+ time_range = kwargs.get("time_range", "")
+ if time_range in ["day", "week", "month", "year"]:
+ payload["time_range"] = time_range
results = await _tavily_search(provider_settings, payload)
if not results:
diff --git a/tests/unit/test_web_search_tools.py b/tests/unit/test_web_search_tools.py
index b3f51acbc3..fc8d1bb56a 100644
--- a/tests/unit/test_web_search_tools.py
+++ b/tests/unit/test_web_search_tools.py
@@ -607,6 +607,71 @@ def _context_with_provider_settings(provider_settings):
return SimpleNamespace(context=agent_context)
+# --- Tavily tool tests ---
+
+
+@pytest.mark.parametrize(
+ ("date_filters", "expected_filters"),
+ [
+ ({"time_range": "week"}, {"time_range": "week"}),
+ (
+ {"time_range": "week", "start_date": "2026-05-10"},
+ {"start_date": "2026-05-10"},
+ ),
+ (
+ {"time_range": "week", "end_date": "2026-05-11"},
+ {"end_date": "2026-05-11"},
+ ),
+ (
+ {
+ "time_range": "week",
+ "start_date": "2026-05-10",
+ "end_date": "2026-05-11",
+ },
+ {"start_date": "2026-05-10", "end_date": "2026-05-11"},
+ ),
+ (
+ {"time_range": "week", "start_date": "", "end_date": ""},
+ {"time_range": "week"},
+ ),
+ (
+ {"time_range": "week", "start_date": " ", "end_date": "\t"},
+ {"time_range": "week"},
+ ),
+ ],
+)
+@pytest.mark.asyncio
+async def test_tavily_search_tool_normalizes_date_filters(
+ monkeypatch,
+ date_filters,
+ expected_filters,
+):
+ captured_payload = {}
+
+ async def fake_tavily_search(provider_settings, payload):
+ captured_payload.update(payload)
+ return [
+ tools.SearchResult(
+ title="AstrBot",
+ url="https://example.com",
+ snippet="Search result",
+ )
+ ]
+
+ monkeypatch.setattr(tools, "_tavily_search", fake_tavily_search)
+ tool = tools.TavilyWebSearchTool()
+ context = _context_with_provider_settings({"websearch_tavily_key": ["tavily-key"]})
+
+ await tool.call(context, query="AstrBot", **date_filters)
+
+ actual_filters = {
+ key: captured_payload[key]
+ for key in ("time_range", "start_date", "end_date")
+ if key in captured_payload
+ }
+ assert actual_filters == expected_filters
+
+
# --- Exa tests ---
From e8819ac2bdf333504293f88378049e5c837f1c8b Mon Sep 17 00:00:00 2001
From: Wei Chengqian
Date: Sat, 25 Jul 2026 12:30:36 +0800
Subject: [PATCH 16/46] test: prevent updater path tests from creating stray
dirs (#9376)
---
tests/test_updator_socks.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/tests/test_updator_socks.py b/tests/test_updator_socks.py
index 77e4f7f35b..2e15917903 100644
--- a/tests/test_updator_socks.py
+++ b/tests/test_updator_socks.py
@@ -227,6 +227,7 @@ def fake_listdir(path: str) -> list[str]:
return [".dockerignore"]
monkeypatch.setattr(updater_module.os, "makedirs", lambda path, exist_ok=True: None)
+ monkeypatch.setattr(updater_module, "ensure_dir", lambda path: None)
monkeypatch.setattr(updater_module.os.path, "join", ntpath.join)
monkeypatch.setattr(updater_module.os.path, "normpath", ntpath.normpath)
monkeypatch.setattr(updater_module.os.path, "commonpath", ntpath.commonpath)
@@ -984,6 +985,7 @@ def test_repo_unzip_file_rejects_archive_roots_outside_target_dir(
monkeypatch.setattr(
zip_updator_module.os, "makedirs", lambda path, exist_ok=True: None
)
+ monkeypatch.setattr(zip_updator_module, "ensure_dir", lambda path: None)
monkeypatch.setattr(zip_updator_module.os.path, "join", ntpath.join)
monkeypatch.setattr(zip_updator_module.os.path, "normpath", ntpath.normpath)
monkeypatch.setattr(zip_updator_module.os.path, "commonpath", ntpath.commonpath)
@@ -1020,6 +1022,7 @@ def fake_listdir(path: str) -> list[str]:
monkeypatch.setattr(
zip_updator_module.os, "makedirs", lambda path, exist_ok=True: None
)
+ monkeypatch.setattr(zip_updator_module, "ensure_dir", lambda path: None)
monkeypatch.setattr(zip_updator_module.os.path, "join", ntpath.join)
monkeypatch.setattr(zip_updator_module.os.path, "normpath", ntpath.normpath)
monkeypatch.setattr(zip_updator_module.os.path, "commonpath", ntpath.commonpath)
From f9c6129b9eecdd0a5c4069954baffc27bea02a0a Mon Sep 17 00:00:00 2001
From: w33d
Date: Sat, 25 Jul 2026 16:15:32 +0800
Subject: [PATCH 17/46] feat: honor Telegram partial reply quotes (#9236)
Co-authored-by: w33d
---
.../platform/sources/telegram/tg_adapter.py | 13 +++-
tests/fixtures/helpers.py | 3 +
tests/test_telegram_adapter.py | 73 +++++++++++++++++++
3 files changed, 86 insertions(+), 3 deletions(-)
diff --git a/astrbot/core/platform/sources/telegram/tg_adapter.py b/astrbot/core/platform/sources/telegram/tg_adapter.py
index 8e6722ccff..d8efd7f5d8 100644
--- a/astrbot/core/platform/sources/telegram/tg_adapter.py
+++ b/astrbot/core/platform/sources/telegram/tg_adapter.py
@@ -506,15 +506,22 @@ def _apply_caption() -> None:
reply_abm = await self.convert_message(reply_update, context, False)
if reply_abm:
+ quote_text = update.message.quote.text if update.message.quote else None
+ reply_chain = reply_abm.message
+ reply_message_str = reply_abm.message_str
+ if quote_text:
+ reply_chain = [Comp.Plain(quote_text)]
+ reply_message_str = quote_text
+
message.message.append(
Comp.Reply(
id=reply_abm.message_id,
- chain=reply_abm.message,
+ chain=reply_chain,
sender_id=reply_abm.sender.user_id,
sender_nickname=reply_abm.sender.nickname,
time=reply_abm.timestamp,
- message_str=reply_abm.message_str,
- text=reply_abm.message_str,
+ message_str=reply_message_str,
+ text=reply_message_str,
qq=reply_abm.sender.user_id,
),
)
diff --git a/tests/fixtures/helpers.py b/tests/fixtures/helpers.py
index f290caff52..68d5e342a4 100644
--- a/tests/fixtures/helpers.py
+++ b/tests/fixtures/helpers.py
@@ -100,6 +100,7 @@ def create_mock_update(
voice: MagicMock | None = None,
sticker: MagicMock | None = None,
reply_to_message: MagicMock | None = None,
+ quote: MagicMock | None = None,
caption: str | None = None,
entities: list | None = None,
caption_entities: list | None = None,
@@ -122,6 +123,7 @@ def create_mock_update(
voice: 语音对象
sticker: 贴纸对象
reply_to_message: 回复的消息
+ quote: 回复消息中的部分引用
caption: 说明文字
entities: 实体列表
caption_entities: 说明实体列表
@@ -158,6 +160,7 @@ def create_mock_update(
message.voice = voice
message.sticker = sticker
message.reply_to_message = reply_to_message
+ message.quote = quote
message.caption = caption
message.entities = entities
message.caption_entities = caption_entities
diff --git a/tests/test_telegram_adapter.py b/tests/test_telegram_adapter.py
index 948b84f5ba..17fe60f111 100644
--- a/tests/test_telegram_adapter.py
+++ b/tests/test_telegram_adapter.py
@@ -82,6 +82,79 @@ def _build_context() -> MagicMock:
return context
+@pytest.mark.asyncio
+async def test_telegram_partial_quote_uses_exact_quote_text():
+ TelegramPlatformAdapter = _load_telegram_adapter()
+ adapter = TelegramPlatformAdapter(
+ make_platform_config("telegram"),
+ {},
+ asyncio.Queue(),
+ )
+ original_text = "😀 prefix target suffix"
+ quoted_text = "target"
+ reply_update = create_mock_update(
+ message_text=original_text,
+ message_id=42,
+ user_id=1001,
+ username="original_sender",
+ )
+ quote = MagicMock(text=quoted_text, position=10)
+ update = create_mock_update(
+ message_text="What does this mean?",
+ reply_to_message=reply_update.message,
+ quote=quote,
+ )
+
+ result = await adapter.convert_message(update, _build_context())
+
+ assert result is not None
+ reply = result.message[0]
+ assert isinstance(reply, Comp.Reply)
+ assert reply.id == "42"
+ assert reply.message_str == quoted_text
+ assert reply.text == quoted_text
+ assert reply.chain is not None
+ assert len(reply.chain) == 1
+ assert isinstance(reply.chain[0], Comp.Plain)
+ assert reply.chain[0].text == quoted_text
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("quote_text", [None, ""])
+async def test_telegram_reply_without_quote_text_uses_full_message(quote_text):
+ TelegramPlatformAdapter = _load_telegram_adapter()
+ adapter = TelegramPlatformAdapter(
+ make_platform_config("telegram"),
+ {},
+ asyncio.Queue(),
+ )
+ original_text = "Use the complete replied message"
+ reply_update = create_mock_update(
+ message_text=original_text,
+ message_id=43,
+ user_id=1002,
+ username="original_sender",
+ )
+ quote = MagicMock(text=quote_text) if quote_text is not None else None
+ update = create_mock_update(
+ message_text="Follow-up question",
+ reply_to_message=reply_update.message,
+ quote=quote,
+ )
+
+ result = await adapter.convert_message(update, _build_context())
+
+ assert result is not None
+ reply = result.message[0]
+ assert isinstance(reply, Comp.Reply)
+ assert reply.message_str == original_text
+ assert reply.text == original_text
+ assert reply.chain is not None
+ assert len(reply.chain) == 1
+ assert isinstance(reply.chain[0], Comp.Plain)
+ assert reply.chain[0].text == original_text
+
+
@pytest.mark.asyncio
async def test_telegram_document_caption_populates_message_text_and_plain():
TelegramPlatformAdapter = _load_telegram_adapter()
From b4459953a6429c67f07325508b917426e0f5e796 Mon Sep 17 00:00:00 2001
From: Baolin Zhu
Date: Sun, 26 Jul 2026 16:15:42 +0800
Subject: [PATCH 18/46] fix(dingtalk): handle command errors and rich-text
mentions (#9389)
---
astrbot/core/pipeline/waking_check/stage.py | 6 +-
.../sources/dingtalk/dingtalk_adapter.py | 33 ++--
astrbot/core/star/filter/command.py | 2 +-
tests/test_command_filter.py | 39 +++++
tests/test_dingtalk_adapter.py | 143 ++++++++++++++++++
5 files changed, 211 insertions(+), 12 deletions(-)
create mode 100644 tests/test_command_filter.py
diff --git a/astrbot/core/pipeline/waking_check/stage.py b/astrbot/core/pipeline/waking_check/stage.py
index be792384ef..fd4692354c 100644
--- a/astrbot/core/pipeline/waking_check/stage.py
+++ b/astrbot/core/pipeline/waking_check/stage.py
@@ -189,9 +189,11 @@ async def process(
break
except Exception as e:
await event.send(
- MessageEventResult().message(
+ MessageEventResult()
+ .message(
f"插件 {star_map[handler.handler_module_path].name}: {e}",
- ),
+ )
+ .use_markdown(False),
)
event.stop_event()
passed = False
diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py
index 9a07608c3c..bae98a8076 100644
--- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py
+++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py
@@ -182,12 +182,15 @@ async def convert_msg(
abm.message_id = cast(str, message.message_id)
abm.raw_message = message
+ leading_at_is_self = False
if abm.type == MessageType.GROUP_MESSAGE:
# 处理所有被 @ 的用户(包括机器人自己,因 at_users 已包含)
if message.at_users:
- for user in message.at_users:
+ for index, user in enumerate(message.at_users):
if id := self._id_to_sid(user.dingtalk_id):
abm.message.append(At(qq=id))
+ if index == 0 and id == abm.self_id:
+ leading_at_is_self = True
abm.group_id = message.conversation_id
abm.session_id = abm.group_id
else:
@@ -232,10 +235,18 @@ async def convert_msg(
)
contents: list[dict] = cast(list[dict], rtc.rich_text_list)
plain_parts: list[str] = []
- for content in contents:
+ for index, content in enumerate(contents):
if "text" in content:
plain_text = cast(str, content.get("text") or "")
if plain_text:
+ # HarmonyOS repeats the leading bot mention as a text
+ # segment even though atUsers already represents it.
+ if (
+ index == 0
+ and leading_at_is_self
+ and plain_text.lstrip().startswith("@")
+ ):
+ continue
plain_parts.append(plain_text)
abm.message.append(Plain(plain_text))
elif "type" in content and content["type"] == "picture":
@@ -577,13 +588,17 @@ async def send_message(msg_key: str, msg_param: dict) -> None:
text = segment.text.strip()
if not text and not at_str:
continue
- await send_message(
- msg_key="sampleMarkdown",
- msg_param={
- "title": "AstrBot",
- "text": f"{at_str} {text}".strip(),
- },
- )
+ text = f"{at_str} {text}".strip()
+ if message_chain.use_markdown_ is False:
+ await send_message(
+ msg_key="sampleText",
+ msg_param={"content": text},
+ )
+ else:
+ await send_message(
+ msg_key="sampleMarkdown",
+ msg_param={"title": "AstrBot", "text": text},
+ )
elif isinstance(segment, Image):
photo_url = segment.file or segment.url or ""
if photo_url.startswith(("http://", "https://")):
diff --git a/astrbot/core/star/filter/command.py b/astrbot/core/star/filter/command.py
index 31949b674c..abf339fda7 100755
--- a/astrbot/core/star/filter/command.py
+++ b/astrbot/core/star/filter/command.py
@@ -65,7 +65,7 @@ def print_types(self):
def init_handler_md(self, handle_md: StarHandlerMetadata) -> None:
self.handler_md = handle_md
- signature = inspect.signature(self.handler_md.handler)
+ signature = inspect.signature(self.handler_md.handler, eval_str=True)
self.handler_params = {} # 参数名 -> 参数类型,如果有默认值则为默认值
idx = 0
for k, v in signature.parameters.items():
diff --git a/tests/test_command_filter.py b/tests/test_command_filter.py
new file mode 100644
index 0000000000..afd37a7c40
--- /dev/null
+++ b/tests/test_command_filter.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+
+from astrbot.core.star.filter.command import CommandFilter
+
+
+async def postponed_annotations_handler(
+ self,
+ event,
+ machine: str,
+ retries: int = 1,
+) -> None:
+ pass
+
+
+def test_command_filter_resolves_postponed_annotations():
+ command_filter = CommandFilter(
+ "probe",
+ handler_md=SimpleNamespace(handler=postponed_annotations_handler),
+ )
+
+ assert command_filter.handler_params == {"machine": str, "retries": 1}
+ assert command_filter.validate_and_convert_params(
+ ["server-1", "2"],
+ command_filter.handler_params,
+ ) == {"machine": "server-1", "retries": 2}
+
+
+def test_command_filter_rejects_missing_postponed_required_param():
+ command_filter = CommandFilter(
+ "probe",
+ handler_md=SimpleNamespace(handler=postponed_annotations_handler),
+ )
+
+ with pytest.raises(ValueError, match="必要参数缺失"):
+ command_filter.validate_and_convert_params([], command_filter.handler_params)
diff --git a/tests/test_dingtalk_adapter.py b/tests/test_dingtalk_adapter.py
index aa1e638c8c..818ebd1156 100644
--- a/tests/test_dingtalk_adapter.py
+++ b/tests/test_dingtalk_adapter.py
@@ -1,8 +1,11 @@
import asyncio
import threading
+import dingtalk_stream
import pytest
+from astrbot.api.message_components import At, Plain
+from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform.sources.dingtalk import dingtalk_adapter
from astrbot.core.platform.sources.dingtalk.dingtalk_adapter import (
DINGTALK_RECONNECT_INITIAL_DELAY,
@@ -12,6 +15,29 @@
)
+def _dingtalk_group_message(**payload) -> dingtalk_stream.ChatbotMessage:
+ """Build a DingTalk group callback message for adapter tests.
+
+ Args:
+ **payload: Callback fields that vary between test cases.
+
+ Returns:
+ A parsed DingTalk chatbot message.
+ """
+ return dingtalk_stream.ChatbotMessage.from_dict(
+ {
+ "conversationId": "conversation",
+ "conversationType": "2",
+ "createAt": 1_700_000_000_000,
+ "msgId": "message",
+ "senderId": "sender",
+ "senderNick": "sender",
+ "chatbotUserId": "bot",
+ **payload,
+ }
+ )
+
+
def test_dingtalk_reconnect_delay_uses_exponential_backoff():
assert [_dingtalk_reconnect_delay(i) for i in range(1, 5)] == [
10,
@@ -76,3 +102,120 @@ async def start(self) -> None:
await adapter.terminate()
run_task.cancel()
await asyncio.gather(run_task, return_exceptions=True)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("use_markdown", "expected_key", "expected_param"),
+ [
+ (None, "sampleMarkdown", {"title": "AstrBot", "text": "first\nsecond"}),
+ (False, "sampleText", {"content": "first\nsecond"}),
+ ],
+)
+async def test_dingtalk_text_respects_markdown_mode(
+ use_markdown,
+ expected_key,
+ expected_param,
+):
+ sent = []
+ adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter)
+
+ async def capture_message(open_conversation_id, robot_code, msg_key, msg_param):
+ sent.append((open_conversation_id, robot_code, msg_key, msg_param))
+
+ adapter._send_group_message = capture_message
+ chain = MessageChain().message("first\nsecond").use_markdown(use_markdown)
+
+ await adapter._send_message_chain("group", "conversation", "robot", chain)
+
+ assert sent == [("conversation", "robot", expected_key, expected_param)]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "payload",
+ [
+ {
+ "atUsers": [{"dingtalkId": "bot"}],
+ "isInAtList": True,
+ "msgtype": "text",
+ "text": {"content": " /server"},
+ },
+ {
+ "atUsers": [{"dingtalkId": "bot"}],
+ "isInAtList": True,
+ "msgtype": "richText",
+ "content": {
+ "richText": [
+ {"text": "@ExampleBot"},
+ {"text": "/server"},
+ ]
+ },
+ },
+ ],
+)
+async def test_dingtalk_self_mention_produces_consistent_command_text(payload):
+ adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter)
+
+ result = await adapter.convert_msg(_dingtalk_group_message(**payload))
+
+ assert result.message_str == "/server"
+ assert len(result.message) == 2
+ assert isinstance(result.message[0], At)
+ assert result.message[0].qq == "bot"
+ assert isinstance(result.message[1], Plain)
+ assert result.message[1].text == "/server"
+
+
+@pytest.mark.asyncio
+async def test_dingtalk_rich_text_preserves_non_self_mention_text():
+ adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter)
+ message = _dingtalk_group_message(
+ atUsers=[{"dingtalkId": "another-user"}],
+ isInAtList=False,
+ msgtype="richText",
+ content={
+ "richText": [
+ {"text": "@AnotherUser"},
+ {"text": "/server"},
+ ]
+ },
+ )
+
+ result = await adapter.convert_msg(message)
+
+ assert result.message_str == "@AnotherUser/server"
+ assert len(result.message) == 3
+ assert isinstance(result.message[0], At)
+ assert result.message[0].qq == "another-user"
+ assert isinstance(result.message[1], Plain)
+ assert result.message[1].text == "@AnotherUser"
+ assert isinstance(result.message[2], Plain)
+ assert result.message[2].text == "/server"
+
+
+@pytest.mark.asyncio
+async def test_dingtalk_rich_text_preserves_other_leading_mention():
+ adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter)
+ message = _dingtalk_group_message(
+ atUsers=[{"dingtalkId": "another-user"}, {"dingtalkId": "bot"}],
+ isInAtList=True,
+ msgtype="richText",
+ content={
+ "richText": [
+ {"text": "@AnotherUser"},
+ {"text": "@ExampleBot"},
+ {"text": "/server"},
+ ]
+ },
+ )
+
+ result = await adapter.convert_msg(message)
+
+ assert result.message_str == "@AnotherUser@ExampleBot/server"
+ assert isinstance(result.message[0], At)
+ assert result.message[0].qq == "another-user"
+ assert isinstance(result.message[1], At)
+ assert result.message[1].qq == "bot"
+ assert isinstance(result.message[2], Plain)
+ assert result.message[2].text == "@AnotherUser"
From d1ae378e2ca9cfb0b7e41f32a91fc44327f750ef Mon Sep 17 00:00:00 2001
From: Amir Fathi
Date: Sun, 26 Jul 2026 02:18:06 -0600
Subject: [PATCH 19/46] fix(kb): reject a zero/negative embedding dimension
when creating a fresh FAISS index (#9385)
Fixes #9375
---
.../db/vec_db/faiss_impl/embedding_storage.py | 5 +++++
tests/unit/test_faiss_vec_db.py | 17 +++++++++++++++++
2 files changed, 22 insertions(+)
diff --git a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py
index 0b42259a1b..c7684506d7 100644
--- a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py
+++ b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py
@@ -73,6 +73,11 @@ def __init__(self, dimension: int, path: str | None = None) -> None:
if path and os.path.exists(path):
self.index = self._read_index(path)
else:
+ if dimension <= 0:
+ raise ValueError(
+ f"无效的嵌入向量维度: {dimension}。请检查该知识库使用的 Embedding "
+ "Provider 是否正确配置了 embedding_dimensions。",
+ )
base_index = faiss.IndexFlatL2(dimension)
self.index = faiss.IndexIDMap(base_index)
diff --git a/tests/unit/test_faiss_vec_db.py b/tests/unit/test_faiss_vec_db.py
index 7f84f90fd2..2d0d7f9dc6 100644
--- a/tests/unit/test_faiss_vec_db.py
+++ b/tests/unit/test_faiss_vec_db.py
@@ -3,6 +3,7 @@
import pytest
+from astrbot.core.db.vec_db.faiss_impl.embedding_storage import EmbeddingStorage
from astrbot.core.db.vec_db.faiss_impl.vec_db import FaissVecDB
from astrbot.core.exceptions import KnowledgeBaseUploadError
from astrbot.core.provider.provider import EmbeddingProvider
@@ -64,6 +65,22 @@ async def test_insert_batch_raises_friendly_error_for_embedding_count_mismatch()
vec_db.embedding_storage.insert_batch.assert_not_awaited()
+def test_embedding_storage_rejects_zero_dimension_for_a_fresh_index(tmp_path) -> None:
+ with pytest.raises(ValueError, match="无效的嵌入向量维度"):
+ EmbeddingStorage(0, str(tmp_path / "index.faiss"))
+
+
+def test_embedding_storage_rejects_negative_dimension_for_a_fresh_index() -> None:
+ with pytest.raises(ValueError, match="无效的嵌入向量维度"):
+ EmbeddingStorage(-1)
+
+
+def test_embedding_storage_accepts_a_valid_dimension_for_a_fresh_index() -> None:
+ storage = EmbeddingStorage(4)
+
+ assert storage.index.d == 4
+
+
@pytest.mark.asyncio
async def test_get_embeddings_batch_preserves_input_order_when_batches_finish_out_of_order():
provider = DelayedEmbeddingProvider()
From 7c2a2e9d8779363096c7d7680f1520eee4d2d608 Mon Sep 17 00:00:00 2001
From: Foolllll <62875591+Foolllll-J@users.noreply.github.com>
Date: Sun, 26 Jul 2026 16:21:16 +0800
Subject: [PATCH 20/46] fix: respect zero values for forward parser depth/fetch
limits (#9236) (#9394)
---
astrbot/core/utils/quoted_message/extractor.py | 2 +-
astrbot/core/utils/quoted_message/settings.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/astrbot/core/utils/quoted_message/extractor.py b/astrbot/core/utils/quoted_message/extractor.py
index 83570d66c0..c1bf136c5f 100644
--- a/astrbot/core/utils/quoted_message/extractor.py
+++ b/astrbot/core/utils/quoted_message/extractor.py
@@ -53,7 +53,7 @@ async def _collect_text_and_images_from_forward_ids(
if nested_id not in seen:
pending.append(nested_id)
- if pending:
+ if pending and max_fetch > 0:
logger.warning(
"quoted_message_parser: stop fetching nested forward messages after %d hops",
max_fetch,
diff --git a/astrbot/core/utils/quoted_message/settings.py b/astrbot/core/utils/quoted_message/settings.py
index 2f74f41b69..db7ca64fb0 100644
--- a/astrbot/core/utils/quoted_message/settings.py
+++ b/astrbot/core/utils/quoted_message/settings.py
@@ -21,7 +21,7 @@ def _read_int_mapping(
value = int(raw)
except (TypeError, ValueError):
return default
- if value <= 0:
+ if value < 0:
return default
return value
From 5e68ee76743d9fb1856ad088577bbf6b81610a11 Mon Sep 17 00:00:00 2001
From: shentry <111497882+shentry@users.noreply.github.com>
Date: Sun, 26 Jul 2026 16:23:24 +0800
Subject: [PATCH 21/46] fix: drop trailing separator from message outline
(#9390)
* fix: drop trailing separator from message outline
`_outline_chain` appended a space after every component, so the outline
always ended with a stray separator: a chain of `Hello` and `world`
produced `"Hello world "` instead of `"Hello world"`.
Join the parts with a space instead of appending one per iteration. This
only removes the separator, so whitespace that belongs to the message
itself is preserved (a lone `Plain("Hello ")` still outlines to
`"Hello "`), and it matches how the respond-stage test double already
builds its outline.
`get_message_outline` is public API used by plugins, and the outline is
also stored on TraceSpan and used by the follow-up stage, which already
called `.strip()` on it.
Closes #9112
* Update test_astr_message_event.py
---------
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
---
astrbot/core/platform/astr_message_event.py | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py
index c82332770b..bd9ae48b20 100644
--- a/astrbot/core/platform/astr_message_event.py
+++ b/astrbot/core/platform/astr_message_event.py
@@ -166,8 +166,7 @@ def _outline_chain(self, chain: list[BaseMessageComponent] | None) -> str:
parts.append("[引用消息]")
else:
parts.append(f"[{i.type}]")
- parts.append(" ")
- return "".join(parts)
+ return " ".join(parts)
def get_message_outline(self) -> str:
"""获取消息概要。
From 7f1b6997ea27b517915e3763d9058367841d1e2b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=9C=88=E5=87=8C?=
<155465805+qiyueling2716@users.noreply.github.com>
Date: Sun, 26 Jul 2026 16:26:18 +0800
Subject: [PATCH 22/46] fix: add model config for FishAudio TTS provider
(#9381)
* Add files via upload
* Add files via upload
---
astrbot/core/config/default.py | 1 +
astrbot/core/provider/sources/fishaudio_tts_api_source.py | 5 ++++-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index 52e7036320..5c7c8cdf9a 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -1725,6 +1725,7 @@
"enable": False,
"api_key": "",
"api_base": "https://api.fish.audio/v1",
+ "model": "s2-pro",
"fishaudio-tts-character": "可莉",
"fishaudio-tts-reference-id": "",
"timeout": "20",
diff --git a/astrbot/core/provider/sources/fishaudio_tts_api_source.py b/astrbot/core/provider/sources/fishaudio_tts_api_source.py
index 35945b7b6f..4e9deafe98 100644
--- a/astrbot/core/provider/sources/fishaudio_tts_api_source.py
+++ b/astrbot/core/provider/sources/fishaudio_tts_api_source.py
@@ -67,7 +67,10 @@ def __init__(
self.headers = {
"Authorization": f"Bearer {self.chosen_api_key}",
}
- self.set_model(provider_config.get("model", ""))
+ # FishAudio API 要求 model 作为 HTTP header 发送,而非请求体字段
+ # 参考: https://github.com/fishaudio/fish-audio-python/blob/main/src/fishaudio/resources/tts.py
+ self.set_model(provider_config.get("model", "s2-pro"))
+ self.headers["model"] = self.get_model()
async def _get_reference_id_by_character(self, character: str) -> str | None:
"""获取角色的reference_id
From e732e9564e909b987ec52a045245a0907f9cbcce Mon Sep 17 00:00:00 2001
From: VIOLET
Date: Sun, 26 Jul 2026 16:28:05 +0800
Subject: [PATCH 23/46] docs: add Windows Docker Desktop deployment guide
(#9339)
* docs: add Windows Docker Desktop deployment guide
* docs: improve Windows Docker Desktop deployment guide
- Change default image to official registry (soulter/astrbot:latest)
- Move DaoCloud mirror to TIP section
- Update PowerShell code block language tag to powershell
- Synchronize Chinese and English versions
* docs: fix incorrect docker run commands in Windows Docker Desktop examples
---
docs/en/deploy/astrbot/docker.md | 35 ++++++++++++++++++++++++++++++
docs/zh/deploy/astrbot/docker.md | 37 ++++++++++++++++++++++++++++++++
2 files changed, 72 insertions(+)
diff --git a/docs/en/deploy/astrbot/docker.md b/docs/en/deploy/astrbot/docker.md
index e4a3472148..5b3d6a117c 100644
--- a/docs/en/deploy/astrbot/docker.md
+++ b/docs/en/deploy/astrbot/docker.md
@@ -75,6 +75,41 @@ View AstrBot logs with the following command:
sudo docker logs -f astrbot
```
+
+## Deploy via Docker Desktop on Windows
+
+### For Windows CMD
+
+Set `TZ` to the standard IANA time zone format (Region/City). Use `Asia/Shanghai` for China.
+
+```bash
+docker run -itd -p 6185:6185 -p 6199:6199 -e TZ=Asia/Shanghai -v "%cd%\data:/AstrBot/data" --name astrbot soulter/astrbot:latest
+```
+> [!TIP]
+> If your network environment is in mainland China, the above command will not pull properly. Please use the following command to pull the image:
+>
+> ```bash
+> docker run -itd -p 6185:6185 -p 6199:6199 -e TZ=Asia/Shanghai -v "%cd%\data:/AstrBot/data" --name astrbot m.daocloud.io/docker.io/soulter/astrbot:latest
+> ```
+>
+> (Thanks to DaoCloud ❤️)
+
+### For PowerShell
+
+Set `TZ` to the standard IANA time zone format (Region/City). Use `Asia/Shanghai` for China.
+
+```powershell
+docker run -itd -p 6185:6185 -p 6199:6199 -e TZ=Asia/Shanghai -v "${PWD}\data:/AstrBot/data" --name astrbot soulter/astrbot:latest
+```
+> [!TIP]
+> If your network environment is in mainland China, the above command will not pull properly. Please use the following command to pull the image:
+>
+> ```powershell
+> docker run -itd -p 6185:6185 -p 6199:6199 -e TZ=Asia/Shanghai -v "${PWD}\data:/AstrBot/data" --name astrbot m.daocloud.io/docker.io/soulter/astrbot:latest
+> ```
+>
+> (Thanks to DaoCloud ❤️)
+
## 🎉 All Done
If everything goes well, you will see logs printed by AstrBot.
diff --git a/docs/zh/deploy/astrbot/docker.md b/docs/zh/deploy/astrbot/docker.md
index 8eb911d750..daf616aa0a 100644
--- a/docs/zh/deploy/astrbot/docker.md
+++ b/docs/zh/deploy/astrbot/docker.md
@@ -89,6 +89,43 @@ Windows 同步 Host Time(需要WSL2)
sudo docker logs -f astrbot
```
+## 通过 Windows Docker Desktop 部署
+
+### 使用`Windows CMD`
+
+`TZ` 的值请设置为 **IANA 时区标准格式**(地区/城市),例如中国为 `Asia/Shanghai`
+
+```bash
+docker run -itd -p 6185:6185 -p 6199:6199 -e TZ=Asia/Shanghai -v "%cd%\data:/AstrBot/data" --name astrbot soulter/astrbot:latest
+```
+> [!TIP]
+> 如果您的网络环境在中国大陆境内,上述命令将无法正常拉取。请使用以下命令拉取镜像:
+>
+> ```bash
+> docker run -itd -p 6185:6185 -p 6199:6199 -e TZ=Asia/Shanghai -v "%cd%\data:/AstrBot/data" --name astrbot m.daocloud.io/docker.io/soulter/astrbot:latest
+> ```
+>
+> (感谢 DaoCloud ❤️)
+>
+### 使用`PowerShell`
+
+`TZ` 的值请设置为 **IANA 时区标准格式**(地区/城市),例如中国为 `Asia/Shanghai`
+
+```powershell
+docker run -itd -p 6185:6185 -p 6199:6199 -e TZ=Asia/Shanghai -v "${PWD}\data:/AstrBot/data" --name astrbot soulter/astrbot:latest
+```
+> [!TIP]
+> 如果您的网络环境在中国大陆境内,上述命令将无法正常拉取。请使用以下命令拉取镜像:
+>
+> ```powershell
+> docker run -itd -p 6185:6185 -p 6199:6199 -e TZ=Asia/Shanghai -v "${PWD}\data:/AstrBot/data" --name astrbot m.daocloud.io/docker.io/soulter/astrbot:latest
+> ```
+>
+> (感谢 DaoCloud ❤️)
+>
+
+
+
## 🎉 大功告成
如果一切顺利,你会看到 AstrBot 打印出的日志。
From 44b0db48eb4d98e143161ca1fcc75f9d0d1d921b Mon Sep 17 00:00:00 2001
From: VectorPeak
Date: Sun, 26 Jul 2026 16:50:28 +0800
Subject: [PATCH 24/46] fix: return correct WebChat image MIME types (#9319)
* fix: return correct WebChat image MIME types
Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
* fix: centralize WebChat image MIME mapping
Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
---------
Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
---
astrbot/dashboard/services/chat_service.py | 12 ++++--
.../unit/test_webchat_upload_image_format.py | 37 ++++++++++++++++++-
2 files changed, 45 insertions(+), 4 deletions(-)
diff --git a/astrbot/dashboard/services/chat_service.py b/astrbot/dashboard/services/chat_service.py
index 36b0700b92..a1bd86883d 100644
--- a/astrbot/dashboard/services/chat_service.py
+++ b/astrbot/dashboard/services/chat_service.py
@@ -36,6 +36,13 @@
SSE_HEARTBEAT = ": heartbeat\n\n"
CHAT_RUN_SUBSCRIBER_QUEUE_SIZE = 256
+WEBCHAT_IMAGE_MIME_TYPES = {
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".png": "image/png",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+}
def sanitize_upload_filename(filename: str | None) -> str:
@@ -504,7 +511,6 @@ def __init__(
self.webchat_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
os.makedirs(self.attachments_dir, exist_ok=True)
- self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
self.conv_mgr = core_lifecycle.conversation_manager
self.platform_history_mgr = core_lifecycle.platform_message_history_manager
self.umop_config_router = core_lifecycle.umop_config_router
@@ -557,8 +563,8 @@ async def resolve_webchat_file(
filename_ext = file_path.suffix.lower()
if filename_ext == ".wav":
return str(file_path), "audio/wav"
- if filename_ext[1:] in self.supported_imgs:
- return str(file_path), "image/jpeg"
+ if filename_ext in WEBCHAT_IMAGE_MIME_TYPES:
+ return str(file_path), WEBCHAT_IMAGE_MIME_TYPES[filename_ext]
return str(file_path), None
async def resolve_webchat_file_from_dashboard_query(
diff --git a/tests/unit/test_webchat_upload_image_format.py b/tests/unit/test_webchat_upload_image_format.py
index 14cce65501..7125c6961f 100644
--- a/tests/unit/test_webchat_upload_image_format.py
+++ b/tests/unit/test_webchat_upload_image_format.py
@@ -4,7 +4,10 @@
import pytest
from PIL import Image as PILImage
-from astrbot.dashboard.services.chat_service import ChatService
+from astrbot.dashboard.services.chat_service import (
+ WEBCHAT_IMAGE_MIME_TYPES,
+ ChatService,
+)
@pytest.mark.asyncio
@@ -44,3 +47,35 @@ async def insert_attachment(self, path, type, mime_type):
assert fake_db.inserted["type"] == "image"
assert (tmp_path / result["filename"]).exists()
assert not (tmp_path / "pasted.png").exists()
+
+
+@pytest.mark.parametrize(
+ ("filename", "expected_mime_type"),
+ [(f"photo{ext}", mime_type) for ext, mime_type in WEBCHAT_IMAGE_MIME_TYPES.items()],
+)
+@pytest.mark.asyncio
+async def test_resolve_webchat_file_uses_image_extension_mime_type(
+ tmp_path,
+ monkeypatch,
+ filename,
+ expected_mime_type,
+):
+ monkeypatch.setattr(
+ "astrbot.dashboard.services.chat_service.get_astrbot_data_path",
+ lambda: str(tmp_path),
+ )
+ service = ChatService(
+ SimpleNamespace(),
+ SimpleNamespace(
+ conversation_manager=None,
+ platform_message_history_manager=None,
+ umop_config_router=None,
+ ),
+ )
+ file_path = tmp_path / "attachments" / filename
+ file_path.write_bytes(b"image-bytes")
+
+ resolved_path, mime_type = await service.resolve_webchat_file(filename)
+
+ assert resolved_path == str(file_path.resolve(strict=False))
+ assert mime_type == expected_mime_type
From e36e161abb23a206fc29c8dd796f8c3205e2b900 Mon Sep 17 00:00:00 2001
From: Wei Chengqian
Date: Sun, 26 Jul 2026 16:53:06 +0800
Subject: [PATCH 25/46] fix: handle nested OpenAI completion choices (#9386)
* fix: handle nested OpenAI completion choices (#9374)
* fix: preserve nested OpenAI completion metadata (#9374)
* Update openai_source.py
---------
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
---
.../core/provider/sources/openai_source.py | 9 ++++
tests/test_openai_source.py | 44 +++++++++++++++++++
2 files changed, 53 insertions(+)
diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py
index b1594d608a..f7870b7137 100644
--- a/astrbot/core/provider/sources/openai_source.py
+++ b/astrbot/core/provider/sources/openai_source.py
@@ -836,6 +836,15 @@ async def _parse_openai_completion(
"""Parse OpenAI ChatCompletion into LLMResponse"""
llm_response = LLMResponse("assistant")
+ # workaround for #9374
+ if not completion.choices:
+ data = getattr(completion, "data", None)
+ if isinstance(data, dict):
+ try:
+ completion = ChatCompletion.model_validate(data)
+ except (TypeError, ValueError):
+ pass
+
if not completion.choices:
raise EmptyModelOutputError(
f"OpenAI completion has no choices. response_id={completion.id}"
diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py
index a45a232938..cf15e28846 100644
--- a/tests/test_openai_source.py
+++ b/tests/test_openai_source.py
@@ -1455,6 +1455,50 @@ async def test_parse_openai_completion_raises_empty_model_output_error():
await provider.terminate()
+@pytest.mark.asyncio
+async def test_parse_openai_completion_reads_nested_data_choices():
+ provider = _make_provider()
+ try:
+ completion = ChatCompletion.model_construct(
+ id=None,
+ object="chat.completion",
+ created=None,
+ model=None,
+ choices=None,
+ data={
+ "id": "gen_test",
+ "object": "chat.completion",
+ "created": 0,
+ "model": "deepseek/deepseek-v4-flash",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "PONG",
+ },
+ "finish_reason": "stop",
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 12,
+ "completion_tokens": 38,
+ "total_tokens": 50,
+ },
+ },
+ )
+
+ response = await provider._parse_openai_completion(completion, tools=None)
+
+ assert response.completion_text == "PONG"
+ assert response.id == "gen_test"
+ assert response.usage is not None
+ assert response.usage.input_other == 12
+ assert response.usage.output == 38
+ finally:
+ await provider.terminate()
+
+
@pytest.mark.asyncio
async def test_query_stream_extracts_usage_from_empty_choices_chunk(monkeypatch):
provider = _make_provider()
From 9e3497e6b49f39d62e6105a8beb9df6131435b1e Mon Sep 17 00:00:00 2001
From: Rain-0x01_ <83620631+Rain-0x01-39@users.noreply.github.com>
Date: Sun, 26 Jul 2026 16:53:51 +0800
Subject: [PATCH 26/46] fix: resolve double scrollbar in conversation detail
and console page (#9382)
* fix: resolve double scrollbar in conversation detail dialog (#9361)
* fix: resolve double scrollbar in console page (#9361)
---
dashboard/src/views/ConsolePage.vue | 8 +++--
dashboard/src/views/ConversationPage.vue | 39 +++++++++---------------
2 files changed, 20 insertions(+), 27 deletions(-)
diff --git a/dashboard/src/views/ConsolePage.vue b/dashboard/src/views/ConsolePage.vue
index 5ad67b77ba..a47cda4cd9 100644
--- a/dashboard/src/views/ConsolePage.vue
+++ b/dashboard/src/views/ConsolePage.vue
@@ -108,7 +108,9 @@ export default {
diff --git a/dashboard/src/i18n/locales/en-US/features/chat.json b/dashboard/src/i18n/locales/en-US/features/chat.json
index 9f8401a134..2f6ca6fc91 100644
--- a/dashboard/src/i18n/locales/en-US/features/chat.json
+++ b/dashboard/src/i18n/locales/en-US/features/chat.json
@@ -144,6 +144,24 @@
"noProjects": "No projects",
"confirmDelete": "Are you sure you want to delete project \"{title}\"? Conversations in this project will not be deleted."
},
+ "workspaceFiles": {
+ "title": "Workspace Files",
+ "open": "Open workspace files",
+ "close": "Close workspace files",
+ "refresh": "Refresh file tree",
+ "filter": "Filter files...",
+ "clearFilter": "Clear file filter",
+ "empty": "This workspace is empty",
+ "noMatches": "No matching files",
+ "loadFailed": "Failed to load workspace files",
+ "previewFailed": "Failed to read this file",
+ "tooLarge": "This file is too large to preview",
+ "download": "Download file",
+ "downloadFailed": "Failed to download this file",
+ "dialogPreview": "Open larger preview",
+ "closePreview": "Close file preview",
+ "closeDialogPreview": "Close larger preview"
+ },
"time": {
"today": "Today",
"yesterday": "Yesterday"
diff --git a/dashboard/src/i18n/locales/ru-RU/features/chat.json b/dashboard/src/i18n/locales/ru-RU/features/chat.json
index 7031cd48e9..bfa925c2cc 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/chat.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/chat.json
@@ -144,6 +144,24 @@
"noProjects": "Проектов пока нет",
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
},
+ "workspaceFiles": {
+ "title": "Файлы рабочей области",
+ "open": "Открыть файлы рабочей области",
+ "close": "Закрыть файлы рабочей области",
+ "refresh": "Обновить дерево файлов",
+ "filter": "Фильтр файлов...",
+ "clearFilter": "Очистить фильтр файлов",
+ "empty": "Рабочая область пуста",
+ "noMatches": "Подходящие файлы не найдены",
+ "loadFailed": "Не удалось загрузить файлы рабочей области",
+ "previewFailed": "Не удалось прочитать файл",
+ "tooLarge": "Файл слишком большой для предпросмотра",
+ "download": "Скачать файл",
+ "downloadFailed": "Не удалось скачать файл",
+ "dialogPreview": "Открыть увеличенный просмотр",
+ "closePreview": "Закрыть предпросмотр файла",
+ "closeDialogPreview": "Закрыть увеличенный просмотр"
+ },
"time": {
"today": "Сегодня",
"yesterday": "Вчера"
diff --git a/dashboard/src/i18n/locales/zh-CN/features/chat.json b/dashboard/src/i18n/locales/zh-CN/features/chat.json
index 58a9264b8a..69584fb217 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/chat.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/chat.json
@@ -144,6 +144,24 @@
"noProjects": "暂无项目",
"confirmDelete": "确定要删除项目 \"{title}\" 吗?项目中的对话不会被删除。"
},
+ "workspaceFiles": {
+ "title": "工作区文件",
+ "open": "打开工作区文件",
+ "close": "关闭工作区文件",
+ "refresh": "刷新文件树",
+ "filter": "筛选文件...",
+ "clearFilter": "清除文件筛选",
+ "empty": "工作区暂无文件",
+ "noMatches": "没有匹配的文件",
+ "loadFailed": "加载工作区文件失败",
+ "previewFailed": "读取文件失败",
+ "tooLarge": "文件过大,无法预览",
+ "download": "下载文件",
+ "downloadFailed": "下载文件失败",
+ "dialogPreview": "放大预览",
+ "closePreview": "关闭文件预览",
+ "closeDialogPreview": "关闭放大预览"
+ },
"time": {
"today": "今天",
"yesterday": "昨天"
diff --git a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue
index e5d89180f3..d5a1020d1e 100644
--- a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue
+++ b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue
@@ -10,7 +10,7 @@ import { MarkdownRender, enableKatex, enableMermaid } from "markstream-vue";
import "markstream-vue/index.css";
import "katex/dist/katex.min.css";
import "highlight.js/styles/github.css";
-import { useI18n } from "@/i18n/composables";
+import { useI18n, useModuleI18n } from "@/i18n/composables";
import { router } from "@/router";
import { useRoute } from "vue-router";
import { useDisplay, useTheme } from "vuetify";
@@ -31,6 +31,7 @@ const chatHeader = useChatHeaderStore();
const theme = useTheme();
const { lgAndUp } = useDisplay();
const { t } = useI18n();
+const { tm } = useModuleI18n("features/chat");
const route = useRoute();
const LAST_BOT_ROUTE_KEY = "astrbot:last_bot_route";
const LAST_CHAT_ROUTE_KEY = "astrbot:last_chat_route";
@@ -1141,6 +1142,28 @@ onMounted(async () => {