From c665b6e3e509971b3be4127aab0a395221c40df8 Mon Sep 17 00:00:00 2001 From: Dale Null Date: Fri, 15 May 2026 16:31:37 +0800 Subject: [PATCH 001/113] chore: reduce pyright type errors --- astrbot/core/agent/mcp_client.py | 8 +++----- .../agent/runners/tool_loop_agent_runner.py | 3 +-- astrbot/core/computer/booters/cua.py | 15 +++++++++++---- astrbot/core/db/sqlite.py | 10 ++++++---- .../sources/dingtalk/dingtalk_adapter.py | 6 +++++- .../qqofficial/qqofficial_message_event.py | 2 +- .../core/provider/sources/anthropic_source.py | 7 +++---- astrbot/core/star/context.py | 1 + astrbot/core/star/updator.py | 2 +- astrbot/core/tools/computer_tools/cua.py | 3 ++- astrbot/core/tools/registry.py | 18 +++++++++++++++++- astrbot/dashboard/routes/plugin.py | 2 +- 12 files changed, 52 insertions(+), 25 deletions(-) diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index b75999ea6..6cedc2202 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -347,11 +347,9 @@ def _normalize_mcp_input_schema(schema: dict[str, Any]) -> dict[str, Any]: properties = normalized.get("properties") if isinstance(properties, dict): - original_properties = ( - node.get("properties") - if isinstance(node.get("properties"), dict) - else {} - ) + original_properties = node.get("properties") + if not isinstance(original_properties, dict): + original_properties = {} required = normalized.get("required") required_list = required[:] if isinstance(required, list) else [] diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 1b9f5a592..172fd0ab0 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -5,7 +5,6 @@ import time import traceback import typing as T import uuid -from collections.abc import AsyncIterator from contextlib import suppress from dataclasses import dataclass, field, replace from pathlib import Path @@ -1396,7 +1395,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): async def _iter_tool_executor_results( self, - executor: AsyncIterator[ToolExecutorResultT], + executor: T.AsyncGenerator[ToolExecutorResultT, None], ) -> T.AsyncGenerator[ToolExecutorResultT, None]: while True: if self._is_stop_requested(): diff --git a/astrbot/core/computer/booters/cua.py b/astrbot/core/computer/booters/cua.py index 151b4c0e0..db6d1d94e 100644 --- a/astrbot/core/computer/booters/cua.py +++ b/astrbot/core/computer/booters/cua.py @@ -75,12 +75,14 @@ def _maybe_model_dump(value: Any) -> dict[str, Any]: return value if is_dataclass(value) and not isinstance(value, type): return asdict(value) - if hasattr(value, "model_dump"): - dumped = value.model_dump() + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + dumped = model_dump() if isinstance(dumped, dict): return dumped - if hasattr(value, "dict"): - dumped = value.dict() + dict_attr = getattr(value, "dict", None) + if callable(dict_attr): + dumped = dict_attr() if isinstance(dumped, dict): return dumped attr_payload = { @@ -134,6 +136,11 @@ def _normalize_process_result(raw: Any) -> ProcessResult: exit_code = payload.get("returncode") if exit_code is None: exit_code = payload.get("return_code") + if exit_code is not None: + try: + exit_code = int(exit_code) + except Exception: + exit_code = None if exit_code is None: exit_code = 0 if not stderr else 1 success = bool(payload.get("success", not stderr and exit_code in (0, None))) diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index d79ac9d70..2f837868b 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -558,7 +558,7 @@ class SQLiteDatabase(BaseDatabase): async with session.begin(): await session.execute( update(PlatformMessageHistory) - .where(PlatformMessageHistory.id == message_id) + .where(col(PlatformMessageHistory.id) == message_id) .values(**values) ) @@ -569,7 +569,7 @@ class SQLiteDatabase(BaseDatabase): async with session.begin(): await session.execute( delete(PlatformMessageHistory).where( - PlatformMessageHistory.id == message_id + col(PlatformMessageHistory.id) == message_id ) ) @@ -676,7 +676,7 @@ class SQLiteDatabase(BaseDatabase): ) if creator is not None: query = query.where(WebChatThread.creator == creator) - query = query.order_by(WebChatThread.created_at) + query = query.order_by(col(WebChatThread.created_at)) result = await session.execute(query) return list(result.scalars().all()) @@ -706,7 +706,9 @@ class SQLiteDatabase(BaseDatabase): session: AsyncSession async with session.begin(): await session.execute( - delete(WebChatThread).where(WebChatThread.thread_id == thread_id) + delete(WebChatThread).where( + col(WebChatThread.thread_id) == thread_id + ) ) async def delete_webchat_threads_by_parent_session( diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index b1a8156a4..5337cb065 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -774,7 +774,11 @@ class DingtalkPlatformAdapter(Platform): self._shutdown_event = threading.Event() task = loop.create_task(self.client_.start()) # 当 task 完成时唤醒线程(无论是正常退出还是异常退出) - task.add_done_callback(lambda _: self._shutdown_event.set()) + task.add_done_callback( + lambda _: self._shutdown_event.set() + if self._shutdown_event + else None + ) self._shutdown_event.wait() if task.done(): try: diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index fa10d2876..6f6068be9 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -595,7 +595,7 @@ class QQOfficialMessageEvent(AstrMessageEvent): markdown: message.MarkdownPayload | None = None, keyboard: message.Keyboard | None = None, stream: dict | None = None, - ) -> message.Message: + ) -> message.Message | None: payload = locals() payload.pop("self", None) # QQ API does not accept stream.id=None; remove it when not yet assigned diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 5caef0b26..1e2dbfe8c 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -226,7 +226,8 @@ class ProviderAnthropic(Provider): if isinstance(last_message, dict) else None ) - can_append_to_previous_tool_results = ( + + if ( last_message is not None and last_message.get("role") == "user" and isinstance(last_content, list) @@ -235,9 +236,7 @@ class ProviderAnthropic(Provider): isinstance(block, dict) and block.get("type") == "tool_result" for block in last_content ) - ) - - if can_append_to_previous_tool_results: + ): last_content.append(tool_result_block) else: new_messages.append( diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 593bad936..62124f7f3 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -55,6 +55,7 @@ RegisteredWebApi = tuple[str, WebApiHandler, list[str], str] class PlatformManagerProtocol(Protocol): platform_insts: list[Platform] + get_insts: Callable[[], list[Platform]] class Context: diff --git a/astrbot/core/star/updator.py b/astrbot/core/star/updator.py index c64777906..9eeda1cb8 100644 --- a/astrbot/core/star/updator.py +++ b/astrbot/core/star/updator.py @@ -55,7 +55,7 @@ class PluginUpdator(RepoZipUpdator): f"Downloading plugin update archive for {plugin.name}: {download_url}" ) await self._download_file(download_url, plugin_path + ".zip") - else: + elif repo_url: await self.download_from_repo_url(plugin_path, repo_url, proxy=proxy) try: diff --git a/astrbot/core/tools/computer_tools/cua.py b/astrbot/core/tools/computer_tools/cua.py index 7b37a5508..913bec00b 100644 --- a/astrbot/core/tools/computer_tools/cua.py +++ b/astrbot/core/tools/computer_tools/cua.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any import mcp +from mcp.types import ContentBlock from astrbot.api import FunctionTool from astrbot.core.agent.run_context import ContextWrapper @@ -88,7 +89,7 @@ class CuaScreenshotTool(FunctionTool): await context.context.event.send(MessageChain().file_image(path)) payload["sent_to_user"] = True image_data = payload.pop("base64", "") - content: list[mcp.types.TextContent | mcp.types.ImageContent] = [ + content: list[ContentBlock] = [ mcp.types.TextContent(type="text", text=_to_json(payload)) ] if return_image_to_llm: diff --git a/astrbot/core/tools/registry.py b/astrbot/core/tools/registry.py index c3b10d229..c76fb556e 100644 --- a/astrbot/core/tools/registry.py +++ b/astrbot/core/tools/registry.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from importlib import import_module -from typing import Any, TypeVar +from typing import Any, TypeVar, overload from astrbot.core.agent.tool import FunctionTool @@ -213,6 +213,22 @@ def _resolve_builtin_tool_name(tool_cls: type[FunctionTool]) -> str: ) +@overload +def builtin_tool( + tool_cls: None = None, + *, + config: dict[str, Any] | None = None, +) -> Callable[[TFunctionTool], TFunctionTool]: ... + + +@overload +def builtin_tool( + tool_cls: TFunctionTool, + *, + config: dict[str, Any] | None = None, +) -> TFunctionTool: ... + + def builtin_tool( tool_cls: TFunctionTool | None = None, *, diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index 10d87eabe..8775dd945 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -1548,7 +1548,7 @@ class PluginRoute(Route): self._build_command_group_child(sub_filter) for sub_filter in command_group_filter.sub_command_filters ] - component = { + component: dict[str, object] = { "type": "command", "name": parts[-1], "description": self._get_command_description( From 98acd9f0daebcd20eb68d36370741797f4c74d82 Mon Sep 17 00:00:00 2001 From: Dale Null Date: Fri, 15 May 2026 16:41:51 +0800 Subject: [PATCH 002/113] chore: reformat with ruff --- astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index 5337cb065..7a79a7a41 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -775,9 +775,9 @@ class DingtalkPlatformAdapter(Platform): task = loop.create_task(self.client_.start()) # 当 task 完成时唤醒线程(无论是正常退出还是异常退出) task.add_done_callback( - lambda _: self._shutdown_event.set() - if self._shutdown_event - else None + lambda _: ( + self._shutdown_event.set() if self._shutdown_event else None + ) ) self._shutdown_event.wait() if task.done(): From 8f5178d2652b1a11f17d7434fec84866e05fc023 Mon Sep 17 00:00:00 2001 From: Weilong Liao <37870767+Soulter@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:24:45 +0800 Subject: [PATCH 003/113] fix: restore star context typing (#8659) --- astrbot/core/star/base.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/astrbot/core/star/base.py b/astrbot/core/star/base.py index dd3ae3f0e..b375abb82 100644 --- a/astrbot/core/star/base.py +++ b/astrbot/core/star/base.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import Any, Protocol +from typing import TYPE_CHECKING, Any from astrbot.core import html_renderer from astrbot.core.utils.command_parser import CommandParserMixin @@ -9,6 +9,9 @@ from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin from .star import StarMetadata, star_map, star_registry +if TYPE_CHECKING: + from .context import Context + logger = logging.getLogger("astrbot") @@ -17,11 +20,9 @@ class Star(CommandParserMixin, PluginKVStoreMixin): author: str name: str + context: Context - class _ContextLike(Protocol): - def get_config(self, umo: str | None = None) -> Any: ... - - def __init__(self, context: _ContextLike, config: dict | None = None) -> None: + def __init__(self, context: Context, config: dict | None = None) -> None: self.context = context def _get_context_config(self) -> Any: From 66ec415e566f60bd2971c77d7ca88a589f2ee238 Mon Sep 17 00:00:00 2001 From: Weilong Liao <37870767+Soulter@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:20:47 +0800 Subject: [PATCH 004/113] fix: restrict local file paths in message tools (#8660) * fix: restrict local file paths in message tool * Update astrbot/core/tools/message_tools.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * fix: rf --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- astrbot/core/tools/message_tools.py | 84 +++++++++++++++++++++++++---- tests/unit/test_message_tools.py | 76 +++++++++++++++++++++++++- 2 files changed, 149 insertions(+), 11 deletions(-) diff --git a/astrbot/core/tools/message_tools.py b/astrbot/core/tools/message_tools.py index 5393b45cd..40516d529 100644 --- a/astrbot/core/tools/message_tools.py +++ b/astrbot/core/tools/message_tools.py @@ -2,6 +2,7 @@ import json import os import shlex import uuid +from pathlib import Path from pydantic import Field from pydantic.dataclasses import dataclass @@ -14,9 +15,55 @@ from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.computer.computer_client import get_booter from astrbot.core.message.message_event_result import MessageChain from astrbot.core.platform.message_session import MessageSession -from astrbot.core.tools.computer_tools.util import check_admin_permission +from astrbot.core.tools.computer_tools.util import ( + check_admin_permission, + is_local_runtime, + workspace_root, +) from astrbot.core.tools.registry import builtin_tool -from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.core.utils.astrbot_path import ( + get_astrbot_system_tmp_path, + get_astrbot_temp_path, +) + + +def _file_send_allowed_roots(umo: str | None) -> tuple[Path, ...]: + roots = [] + if umo: + roots.append(workspace_root(umo)) + roots.extend( + [ + Path(get_astrbot_temp_path()).resolve(strict=False), + Path(get_astrbot_system_tmp_path()).resolve(strict=False), + ] + ) + return tuple(roots) + + +def _is_path_within(path: Path, roots: tuple[Path, ...]) -> bool: + return any(path == root or path.is_relative_to(root) for root in roots) + + +def _is_restricted_local_env(context: ContextWrapper[AstrAgentContext]) -> bool: + if not is_local_runtime(context): + return False + cfg = context.context.context.get_config( + umo=context.context.event.unified_msg_origin + ) + provider_settings = cfg.get("provider_settings", {}) + require_admin = provider_settings.get("computer_use_require_admin", True) + return require_admin and context.context.event.role != "admin" + + +def _can_send_local_file( + context: ContextWrapper[AstrAgentContext], + local_path: Path, +) -> bool: + umo = context.context.event.unified_msg_origin + allowed_roots = _file_send_allowed_roots(umo) + if _is_path_within(local_path, allowed_roots): + return True + return is_local_runtime(context) and not _is_restricted_local_env(context) @builtin_tool @@ -85,23 +132,38 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): *, component_type: str = "file", ) -> tuple[str, bool]: - path = str(path) - # if the path is relative, check if the file exists in user's local workspace + path = str(path).strip() + if not path: + raise FileNotFoundError(f"{component_type} path is empty") + + # Relative host paths are resolved only inside the user's workspace. if not os.path.isabs(path): unified_msg_origin = context.context.event.unified_msg_origin if unified_msg_origin: - from astrbot.core.tools.computer_tools.util import workspace_root - try: ws_path = workspace_root(unified_msg_origin) - ws_candidate = (ws_path / path).resolve() + ws_candidate = (ws_path / path).resolve(strict=False) if ws_candidate.is_file() and ws_candidate.is_relative_to(ws_path): return str(ws_candidate), False except Exception: pass - # check if the file exists in local environment (only allow absolute paths to prevent traversal) - elif os.path.isfile(path): - return path, False + else: + local_candidate = Path(path).expanduser().resolve(strict=False) + if local_candidate.is_file(): + if _can_send_local_file(context, local_candidate): + return str(local_candidate), False + if is_local_runtime(context): + allowed = ", ".join( + str(root) + for root in _file_send_allowed_roots( + context.context.event.unified_msg_origin + ) + ) + raise PermissionError( + "Local file send is restricted for this user. " + f"Allowed directories: {allowed}. " + f"Blocked path: {local_candidate}." + ) try: sb = await get_booter( @@ -221,6 +283,8 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): ) except FileNotFoundError as exc: return f"error: {exc}" + except PermissionError as exc: + return f"error: {exc}" except Exception as exc: return f"error: failed to build messages[{idx}] component: {exc}" diff --git a/tests/unit/test_message_tools.py b/tests/unit/test_message_tools.py index 28896b6d2..2b4659f5b 100644 --- a/tests/unit/test_message_tools.py +++ b/tests/unit/test_message_tools.py @@ -12,9 +12,15 @@ def _make_context( current_session="feishu:GroupMessage:oc_xxx", role="admin", require_admin=True, + runtime="local", ): """Build a minimal ContextWrapper for SendMessageToUserTool.""" - cfg = {"provider_settings": {"computer_use_require_admin": require_admin}} + cfg = { + "provider_settings": { + "computer_use_require_admin": require_admin, + "computer_use_runtime": runtime, + } + } return SimpleNamespace( context=SimpleNamespace( event=SimpleNamespace( @@ -161,3 +167,71 @@ async def test_send_message_missing_image_path_stops_before_send(tmp_path, monke assert "error: failed to build messages[1] component: sandbox unavailable" in result ctx.context.context.send_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_non_admin_cannot_send_arbitrary_local_absolute_file(tmp_path): + """Non-admin users cannot send host files outside the allowed local roots.""" + tool = SendMessageToUserTool() + ctx = _make_context(role="member", require_admin=True) + secret_path = tmp_path / "secret.txt" + secret_path.write_text("secret", encoding="utf-8") + + result = await tool.call( + ctx, + messages=[{"type": "file", "path": str(secret_path)}], + ) + + assert "error: Local file send is restricted for this user" in result + assert str(secret_path) in result + ctx.context.context.send_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_non_admin_can_send_workspace_file(tmp_path, monkeypatch): + """Non-admin users can send files inside their per-session workspace.""" + tool = SendMessageToUserTool() + ctx = _make_context( + current_session="feishu:GroupMessage:oc_workspace", + role="member", + require_admin=True, + ) + workspace_root = tmp_path / "workspaces" + workspace_file = workspace_root / "feishu_GroupMessage_oc_workspace" / "result.txt" + workspace_file.parent.mkdir(parents=True) + workspace_file.write_text("result", encoding="utf-8") + monkeypatch.setattr( + "astrbot.core.tools.computer_tools.util.get_astrbot_workspaces_path", + lambda: str(workspace_root), + ) + + result = await tool.call( + ctx, + messages=[{"type": "file", "path": "result.txt"}], + ) + + assert "Message sent to session" in result + ctx.context.context.send_message.assert_called_once() + + +@pytest.mark.asyncio +async def test_non_admin_can_send_temp_file(tmp_path, monkeypatch): + """Non-admin users can send generated files under AstrBot temp.""" + tool = SendMessageToUserTool() + ctx = _make_context(role="member", require_admin=True) + temp_root = tmp_path / "temp" + temp_root.mkdir() + output_path = temp_root / "output.txt" + output_path.write_text("output", encoding="utf-8") + monkeypatch.setattr( + "astrbot.core.tools.message_tools.get_astrbot_temp_path", + lambda: str(temp_root), + ) + + result = await tool.call( + ctx, + messages=[{"type": "file", "path": str(output_path)}], + ) + + assert "Message sent to session" in result + ctx.context.context.send_message.assert_called_once() From af70151ff82fbd035f8a93d912699400e15ea5e4 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 8 Jun 2026 01:23:06 +0800 Subject: [PATCH 005/113] chore: bump version to 4.25.5 --- astrbot/core/config/default.py | 2 +- changelogs/v4.25.5.md | 30 ++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 changelogs/v4.25.5.md diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 20ecb07e3..b60c7f230 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -5,7 +5,7 @@ import os from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG from astrbot.core.utils.astrbot_path import get_astrbot_data_path -VERSION = "4.25.4" +VERSION = "4.25.5" DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") PERSONAL_WECHAT_CONFIG_METADATA = { "weixin_oc_base_url": { diff --git a/changelogs/v4.25.5.md b/changelogs/v4.25.5.md new file mode 100644 index 000000000..4fb624d6c --- /dev/null +++ b/changelogs/v4.25.5.md @@ -0,0 +1,30 @@ +- [更新日志(简体中文)](#chinese) +- [Changelog(English)](#english) + + + +## What's Changed + +### 修复 + +- 收紧消息工具对本地文件路径的处理边界,减少非预期路径被用于消息附件的情况。([#8660](https://github.com/AstrBotDevs/AstrBot/pull/8660)) +- 修复 Star Context 类型定义,恢复相关 SDK 类型提示与运行兼容性。([#8659](https://github.com/AstrBotDevs/AstrBot/pull/8659)) +- 修复 QQ 官方 Webhook 模式无法正常重启的问题。 + +### 优化 + +- 改进 Anthropic 在内容过滤响应中缺失 `usage` 字段时的处理,避免相关请求结果解析异常。([#8647](https://github.com/AstrBotDevs/AstrBot/pull/8647)) + + + +## What's Changed (EN) + +### Bug Fixes + +- Tightened local file path handling in the message tool to avoid unintended attachment path usage. ([#8660](https://github.com/AstrBotDevs/AstrBot/pull/8660)) +- Fixed Star Context typing to restore related SDK type hints and runtime compatibility. ([#8659](https://github.com/AstrBotDevs/AstrBot/pull/8659)) +- Fixed QQ Official Webhook mode not restarting correctly. + +### Improvements + +- Improved Anthropic response parsing when content-filtered responses omit the `usage` field. ([#8647](https://github.com/AstrBotDevs/AstrBot/pull/8647)) diff --git a/pyproject.toml b/pyproject.toml index 46b532d2f..6326aeaa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "AstrBot" -version = "4.25.4" +version = "4.25.5" description = "Easy-to-use multi-platform LLM chatbot and development framework" readme = "README.md" license = { text = "AGPL-3.0-or-later" } From 4b562689eed6560f33c75d27234a693f8b293a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=82=E5=A3=B9?= <137363396+KBVsent@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:00:26 +0900 Subject: [PATCH 006/113] fix: dispose database engine on shutdown (#8650) --- astrbot/core/core_lifecycle.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 725b17000..c325a2ea3 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -391,6 +391,12 @@ class AstrBotCoreLifecycle: except Exception as e: logger.error(f"任务 {task.get_name()} 发生错误: {e}") + # 释放数据库引擎连接池 + try: + await self.db.engine.dispose() + except Exception as e: + logger.warning(f"释放数据库引擎失败: {e}") + async def restart(self) -> None: """重启 AstrBot 核心生命周期管理类, 终止各个管理器并重新加载平台实例""" await self.provider_manager.terminate() From 736bc93b2a6dfd295f36efa81626a5e784e70ae0 Mon Sep 17 00:00:00 2001 From: MUHAMED FAZAL PS <107547381+muhamedfazalps@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:21:19 +0530 Subject: [PATCH 007/113] fix: consistent handler_module_path for subdirectory tools (#8578) --- astrbot/core/star/context.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 593bad936..7bcffc627 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -493,12 +493,18 @@ class Context: _parts = [] module_part = tool.__module__.split(".") flags = ["builtin_stars", "plugins"] + found_flag = False for i, part in enumerate(module_part): _parts.append(part) if part in flags and i + 1 < len(module_part): _parts.append(module_part[i + 1]) _parts.append("main") + found_flag = True break + if not found_flag: + # Subdirectory tool: construct path matching star_manager format + plugin_name = module_part[0] + _parts = ["data", "plugins", plugin_name, "main"] tool.handler_module_path = ".".join(_parts) module_path = tool.handler_module_path else: From 992aea9869f400c9c1f1e0093d5c144c76ddffbc Mon Sep 17 00:00:00 2001 From: MUHAMED FAZAL PS <107547381+muhamedfazalps@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:27:00 +0530 Subject: [PATCH 008/113] fix: address review feedback - add plugin naming pattern check and edge case handling Address feedback from Sourcery AI and Gemini Code Assist: - Only apply fallback path to known plugin naming patterns (astrbot_plugin_*) - Add defensive check for None/empty tool.__module__ - Fallback to tool.__module__ if _parts is empty --- astrbot/core/star/context.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 7bcffc627..56007edf0 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -491,7 +491,7 @@ class Context: for tool in tools: if not module_path: _parts = [] - module_part = tool.__module__.split(".") + module_part = tool.__module__.split(".") if tool.__module__ else [] flags = ["builtin_stars", "plugins"] found_flag = False for i, part in enumerate(module_part): @@ -501,11 +501,13 @@ class Context: _parts.append("main") found_flag = True break - if not found_flag: + if not found_flag and module_part: # Subdirectory tool: construct path matching star_manager format + # Only apply to known plugin naming patterns (astrbot_plugin_*) plugin_name = module_part[0] - _parts = ["data", "plugins", plugin_name, "main"] - tool.handler_module_path = ".".join(_parts) + if plugin_name.startswith("astrbot_plugin_"): + _parts = ["data", "plugins", plugin_name, "main"] + tool.handler_module_path = ".".join(_parts) if _parts else tool.__module__ or "" module_path = tool.handler_module_path else: tool.handler_module_path = module_path From 0fb3f5eb93ee2e78da0ddad47282044c0200b881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E2=82=82=E2=82=82H=E2=82=82=E2=82=85NO=E2=82=86?= <96930391+Sisyphbaous-DT-Project@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:11:20 +0800 Subject: [PATCH 009/113] feat: display current weekday information (#8669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在现实世界时间感知提示中追加 Weekday 字段,让模型能直接获取今天是星期几。\n\n使用固定英文星期表避免受系统 locale 影响,并补充单测覆盖输出格式。 Co-authored-by: C₂₂H₂₅NO₆ --- astrbot/core/astr_main_agent.py | 22 +++++++++++++------ tests/unit/test_astr_main_agent.py | 34 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index ee007140b..cee6e9e27 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -115,6 +115,15 @@ from astrbot.core.utils.quoted_message_parser import ( from astrbot.core.utils.string_utils import normalize_and_dedupe_strings LLM_ERROR_MESSAGE_EXTRA_KEY = "_llm_error_message" +WEEKDAY_NAMES = ( + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +) WEB_SEARCH_CITATION_TOOL_NAMES = frozenset( { "web_search_baidu", @@ -874,18 +883,17 @@ def _append_system_reminders( system_parts.append(f"Group name: {group_name}") if cfg.get("datetime_system_prompt"): - current_time = None + now = None if timezone: try: now = datetime.datetime.now(zoneinfo.ZoneInfo(timezone)) - current_time = now.strftime("%Y-%m-%d %H:%M (%Z)") except Exception as exc: # noqa: BLE001 logger.error("时区设置错误: %s, 使用本地时区", exc) - if not current_time: - current_time = ( - datetime.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M (%Z)") - ) - system_parts.append(f"Current datetime: {current_time}") + if now is None: + now = datetime.datetime.now().astimezone() + current_time = now.strftime("%Y-%m-%d %H:%M (%Z)") + weekday = WEEKDAY_NAMES[now.weekday()] + system_parts.append(f"Current datetime: {current_time}, Weekday: {weekday}") if system_parts: system_content = ( diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 84f354894..31c80e09e 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -1,5 +1,6 @@ """Tests for astr_main_agent module.""" +import datetime import os from unittest.mock import AsyncMock, MagicMock, patch @@ -134,6 +135,39 @@ def _setup_conversation_for_build(conv_mgr, cid: str = "conv-id") -> MagicMock: return conversation +def test_append_system_reminders_includes_weekday(mock_event): + """Test datetime reminder includes weekday information.""" + req = ProviderRequest(prompt="Hello") + fixed_now = datetime.datetime( + 2026, + 6, + 8, + 12, + 34, + tzinfo=datetime.timezone.utc, + ) + + class FixedDateTime(datetime.datetime): + @classmethod + def now(cls, tz=None): + if tz: + return fixed_now.astimezone(tz) + return fixed_now + + with patch("astrbot.core.astr_main_agent.datetime.datetime", FixedDateTime): + ama._append_system_reminders( + mock_event, + req, + {"datetime_system_prompt": True}, + "UTC", + ) + + assert [part.text for part in req.extra_user_content_parts] == [ + "Current datetime: " + "2026-06-08 12:34 (UTC), Weekday: Monday" + ] + + class TestMainAgentBuildConfig: """Tests for MainAgentBuildConfig dataclass.""" From ae44b912fc9153f6ecca1ecb0ac3bf0a6065e753 Mon Sep 17 00:00:00 2001 From: Ruochen Pan Date: Tue, 9 Jun 2026 14:47:41 +0800 Subject: [PATCH 010/113] feat(dashboard): add per-tool permission management for function tools (#8693) * feat(func-tool): add per-tool permission management * feat(dashboard): add permission column to function tool table * refactor(dashboard): encapsulate tool actions into composable and unify permission UI * style: format code * fix: guard sync handler, simplify sp access, skip builtin instantiation --- astrbot/core/provider/func_tool_manager.py | 110 ++++- astrbot/dashboard/routes/tools.py | 75 ++- .../componentPanel/components/ToolTable.vue | 71 ++- .../composables/useToolActions.ts | 127 +++++ .../extension/componentPanel/index.vue | 94 ++-- .../extension/componentPanel/types.ts | 11 + .../i18n/locales/en-US/features/tool-use.json | 16 + .../i18n/locales/ru-RU/features/tool-use.json | 16 + .../i18n/locales/zh-CN/features/tool-use.json | 16 + tests/unit/test_tool_conflict_resolution.py | 12 +- tests/unit/test_tool_permission.py | 434 ++++++++++++++++++ 11 files changed, 936 insertions(+), 46 deletions(-) create mode 100644 dashboard/src/components/extension/componentPanel/composables/useToolActions.ts create mode 100644 tests/unit/test_tool_permission.py diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index ab6dd037f..f3b3cb77c 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -210,6 +210,65 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: return False, f"{e!s}" +class _PermissionGuardedTool(FunctionTool): + """Transparent proxy that checks per-tool permissions before delegating. + + Only wraps non-builtin tools. Builtin tools are added to the tool set + without wrapping, so their existing hardcoded permission logic + (``check_admin_permission`` / ``_is_restricted_env``) is unaffected. + + The ``handler`` field is intentionally kept ``None`` so that + ``FunctionToolExecutor._execute_local`` falls through to the + ``is_override_call`` branch and invokes our ``call()`` instead of + calling the raw handler directly. This ensures the permission + check runs for *every* invocation path. + """ + + def __init__( + self, + tool: FunctionTool, + manager: FunctionToolManager, + ) -> None: + # Do NOT pass handler to the parent — keep self.handler = None + # so the tool executor always routes through our call(). + super().__init__( + name=tool.name, + description=tool.description, + parameters=getattr(tool, "parameters", {}), + ) + self._wrapped = tool + self._mgr = manager + # Mirror mutable state from the underlying tool + self.active = getattr(tool, "active", True) + self.handler_module_path = getattr(tool, "handler_module_path", None) + + async def call(self, context: Any, **kwargs: Any) -> Any: + import inspect as _inspect + + error = self._mgr._check_tool_permission(self.name, context) + if error is not None: + return error + + # Delegate to handler first (plugin tools). + if self._wrapped.handler is not None: + result = self._wrapped.handler(context, **kwargs) + if _inspect.isasyncgen(result): + last: Any = None + async for item in result: + last = item + return last + if _inspect.isawaitable(result): + return await result + return result + + # Fall back to overridden call() on subclasses (e.g. MCPTool). + call_override = getattr(type(self._wrapped), "call", None) + if call_override is not None and call_override is not FunctionTool.call: + return await self._wrapped.call(context, **kwargs) + + return "error: tool has no callable handler" + + class FunctionToolManager: def __init__(self) -> None: self.func_list: list[FuncTool] = [] @@ -374,6 +433,51 @@ class FunctionToolManager: ensure_builtin_tools_loaded() return get_builtin_tool_class(name) is not None + def _default_permission(self, tool_name: str) -> str: + """Compute the fallback permission for a non-builtin tool. + + All non-builtin tools default to ``"member"`` (no restriction). + Builtin tools are never routed through this method.""" + return "member" + + def _check_tool_permission( + self, + tool_name: str, + context: Any, + ) -> str | None: + """Return an error string if the caller lacks permission, or None. + + Only non-builtin tools are guarded. Permission is resolved from + ``tool_permissions`` in SharedPreferences (``_default`` key). When + no explicit entry exists the tool inherits the fallback + ``_default_permission``.""" + try: + perms_raw = sp.get( + "tool_permissions", {}, scope="global", scope_id="global" + ) + except Exception: + perms_raw = {} + defaults = perms_raw.get("_default", {}) if isinstance(perms_raw, dict) else {} + effective = defaults.get(tool_name) + if effective is None: + effective = self._default_permission(tool_name) + + if effective != "admin": + return None # member or unknown → pass + + try: + event = context.context.event + except AttributeError: + event = None + if event is None or not event.is_admin(): + sender_id = getattr(event, "get_sender_id", lambda: "unknown")() + return ( + f"error: Permission denied. The tool '{tool_name}' requires admin " + f"privileges. Your ID: {sender_id}. " + "Ask admin to configure in WebUI → Extension → Components." + ) + return None + def get_full_tool_set(self) -> ToolSet: """获取完整工具集 @@ -383,10 +487,14 @@ class FunctionToolManager: 因此,后加载的 inactive 工具不会覆盖已激活的工具; 同时,MCP 工具在需要时仍可覆盖被禁用的内置工具。 + + Non-builtin tools are wrapped with ``_PermissionGuardedTool`` so that + every invocation checks the per-tool permission configured via the + dashboard. """ tool_set = ToolSet() for tool in self.func_list: - tool_set.add_tool(tool) + tool_set.add_tool(_PermissionGuardedTool(tool, self)) return tool_set @staticmethod diff --git a/astrbot/dashboard/routes/tools.py b/astrbot/dashboard/routes/tools.py index 157b4d75b..2ad80687e 100644 --- a/astrbot/dashboard/routes/tools.py +++ b/astrbot/dashboard/routes/tools.py @@ -2,7 +2,7 @@ import traceback from quart import request -from astrbot.core import logger +from astrbot.core import logger, sp from astrbot.core.agent.mcp_client import MCPTool, validate_mcp_stdio_config from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.star import star_map @@ -55,11 +55,27 @@ class ToolsRoute(Route): "/tools/mcp/test": ("POST", self.test_mcp_connection), "/tools/list": ("GET", self.get_tool_list), "/tools/toggle-tool": ("POST", self.toggle_tool), + "/tools/permission": ("POST", self.update_tool_permission), "/tools/mcp/sync-provider": ("POST", self.sync_provider), } self.register_routes() self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools + @staticmethod + def _get_tool_permission(tool_name: str) -> tuple[str, bool]: + """Return (effective_permission, configured) for a tool. + + ``configured`` is True when the permission was explicitly set via the + dashboard rather than being a fallback default. + """ + perms_store = sp.get("tool_permissions", {}, scope="global", scope_id="global") + defaults = ( + perms_store.get("_default", {}) if isinstance(perms_store, dict) else {} + ) + if tool_name in defaults: + return defaults[tool_name], True + return "member", False + def _rollback_mcp_server(self, name: str) -> bool: try: rollback_config = self.tool_mgr.load_mcp_config() @@ -504,6 +520,10 @@ class ToolsRoute(Route): "builtin_config_statuses": builtin_config_statuses, "builtin_config_tags": builtin_config_tags, } + if not readonly: + perm, configured = self._get_tool_permission(tool.name) + tool_info["permission"] = perm + tool_info["permission_configured"] = configured tools_dict.append(tool_info) return Response().ok(data=tools_dict).__dict__ except Exception as e: @@ -569,3 +589,56 @@ class ToolsRoute(Route): except Exception as e: logger.error(traceback.format_exc()) return Response().error(f"Sync failed: {e!s}").__dict__ + + async def update_tool_permission(self): + """Set or remove the permission level of a registered tool.""" + try: + data = await request.json + tool_name = data.get("name") + permission = data.get("permission") # "admin" | "member" + + if not tool_name or permission not in ("admin", "member"): + return ( + Response() + .error("name and permission (admin or member) are required") + .__dict__ + ) + + if self.tool_mgr.is_builtin_tool(tool_name): + return ( + Response() + .error( + "Builtin tools do not support per-tool permission configuration." + ) + .__dict__ + ) + + # Verify the tool is known + if not any(t.name == tool_name for t in self.tool_mgr.func_list): + return Response().error(f"Tool '{tool_name}' not found").__dict__ + + perms_store = sp.get( + "tool_permissions", {}, scope="global", scope_id="global" + ) + if not isinstance(perms_store, dict): + perms_store = {} + defaults = perms_store.get("_default", {}) + if not isinstance(defaults, dict): + defaults = {} + defaults[tool_name] = permission + perms_store["_default"] = defaults + sp.put( + "tool_permissions", + perms_store, + scope="global", + scope_id="global", + ) + + return ( + Response() + .ok(None, f"Tool '{tool_name}' permission set to {permission}") + .__dict__ + ) + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"Failed to update tool permission: {e!s}").__dict__ diff --git a/dashboard/src/components/extension/componentPanel/components/ToolTable.vue b/dashboard/src/components/extension/componentPanel/components/ToolTable.vue index 8a42f33fe..75e59f30a 100644 --- a/dashboard/src/components/extension/componentPanel/components/ToolTable.vue +++ b/dashboard/src/components/extension/componentPanel/components/ToolTable.vue @@ -12,14 +12,16 @@ const props = defineProps<{ const emit = defineEmits<{ (e: 'toggle-tool', tool: ToolItem): void; + (e: 'update-permission', tool: ToolItem, permission: 'admin' | 'member'): void; }>(); const toolHeaders = computed(() => [ { title: tmTool('functionTools.title'), key: 'name', minWidth: '320px' }, { title: tmTool('functionTools.description'), key: 'description' }, - { title: tmTool('functionTools.table.origin'), key: 'origin', sortable: false, width: '120px' }, - { title: tmTool('functionTools.table.originName'), key: 'origin_name', sortable: false, width: '160px' }, - { title: tmTool('functionTools.table.actions'), key: 'actions', sortable: false, width: '120px' } + { title: tmTool('functionTools.table.origin'), key: 'origin', sortable: false, width: '100px' }, + { title: tmTool('functionTools.table.originName'), key: 'origin_name', sortable: false, width: '140px' }, + { title: tmTool('functionTools.table.permission'), key: 'permission', sortable: false, width: '110px' }, + { title: tmTool('functionTools.table.actions'), key: 'actions', sortable: false, width: '100px' } ]); const parameterEntries = (tool: ToolItem) => Object.entries(tool.parameters?.properties || {}); @@ -69,6 +71,24 @@ const enabledConfigTags = (tool: ToolItem): BuiltinToolConfigTag[] => { if (tool.origin !== 'builtin') return []; return (tool.builtin_config_tags || []).filter(tag => tag.enabled); }; + +const getPermissionColor = (permission?: string): string => { + switch (permission) { + case 'admin': + return 'error'; + default: + return 'success'; + } +}; + +const getPermissionLabel = (permission?: string): string => { + switch (permission) { + case 'admin': + return tmTool('functionTools.table.permissionAdmin'); + default: + return tmTool('functionTools.table.permissionEveryone'); + } +}; + + + + diff --git a/dashboard/src/components/extension/componentPanel/types.ts b/dashboard/src/components/extension/componentPanel/types.ts index 4784cc8ee..cb96f33a7 100644 --- a/dashboard/src/components/extension/componentPanel/types.ts +++ b/dashboard/src/components/extension/componentPanel/types.ts @@ -37,6 +37,13 @@ export interface CommandSummary { conflicts: number; } +/** 工具摘要统计 */ +export interface ToolSummary { + total: number; + active: number; + inactive: number; +} + /** 过滤器状态 */ export interface FilterState { searchQuery: string; @@ -119,4 +126,8 @@ export interface ToolItem { origin_name?: string; builtin_config_statuses?: BuiltinToolConfigTag[]; builtin_config_tags?: BuiltinToolConfigTag[]; + /** Per-tool permission level ("admin" | "member"). Builtin tools omit this. */ + permission?: 'admin' | 'member'; + /** True when permission was explicitly configured rather than a fallback default. */ + permission_configured?: boolean; } diff --git a/dashboard/src/i18n/locales/en-US/features/tool-use.json b/dashboard/src/i18n/locales/en-US/features/tool-use.json index 858f50272..52c27bbfe 100644 --- a/dashboard/src/i18n/locales/en-US/features/tool-use.json +++ b/dashboard/src/i18n/locales/en-US/features/tool-use.json @@ -39,6 +39,9 @@ "description": "Function Description", "parameters": "Parameter List", "noParameters": "This tool has no parameters", + "filter": { + "showBuiltin": "Show builtin tools" + }, "table": { "paramName": "Parameter Name", "type": "Type", @@ -47,6 +50,11 @@ "origin": "Origin", "originName": "Origin Name", "readonly": "Read-only", + "permission": "Permission", + "permissionAdmin": "Admin", + "permissionMember": "All Users", + "permissionEveryone": "All Users", + "permissionBuiltin": "System Builtin", "actions": "Actions" }, "configTags": { @@ -57,6 +65,11 @@ "in": "{key} matched {expected}", "fallback": "{key} is currently {actual}" } + }, + "summary": { + "total": "Total", + "active": "Active", + "inactive": "Inactive" } }, "marketplace": { @@ -167,6 +180,9 @@ "toggleToolSuccess": "Tool status toggled successfully!", "toggleToolReadonly": "Builtin tools are read-only and cannot be enabled or disabled.", "toggleToolError": "Failed to toggle tool status: {error}", + "updateToolPermissionSuccess": "Permission for {name} updated", + "updateToolPermissionFailed": "Failed to update tool permission", + "updateToolPermissionBuiltin": "Builtin tools do not support per-tool permission configuration.", "testError": "Test connection failed: {error}" } } diff --git a/dashboard/src/i18n/locales/ru-RU/features/tool-use.json b/dashboard/src/i18n/locales/ru-RU/features/tool-use.json index f945d5356..a4e2a583e 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/tool-use.json +++ b/dashboard/src/i18n/locales/ru-RU/features/tool-use.json @@ -39,6 +39,9 @@ "description": "Описание функции", "parameters": "Параметры", "noParameters": "У этого инструмента нет параметров", + "filter": { + "showBuiltin": "Показать встроенные" + }, "table": { "paramName": "Параметр", "type": "Тип", @@ -47,6 +50,11 @@ "origin": "Источник", "originName": "Имя источника", "readonly": "Только чтение", + "permission": "Доступ", + "permissionAdmin": "Админ", + "permissionMember": "Все", + "permissionEveryone": "Все", + "permissionBuiltin": "Встроенный", "actions": "Действия" }, "configTags": { @@ -57,6 +65,11 @@ "in": "{key} соответствует {expected}", "fallback": "Текущее значение {key}: {actual}" } + }, + "summary": { + "total": "Всего", + "active": "Активно", + "inactive": "Неактивно" } }, "marketplace": { @@ -167,6 +180,9 @@ "toggleToolSuccess": "Статус инструмента изменен!", "toggleToolReadonly": "Встроенные инструменты доступны только для чтения и не могут быть включены или выключены.", "toggleToolError": "Не удалось изменить статус: {error}", + "updateToolPermissionSuccess": "Доступ к {name} обновлён", + "updateToolPermissionFailed": "Не удалось обновить доступ к инструменту", + "updateToolPermissionBuiltin": "Встроенные инструменты не поддерживают настройку доступа.", "testError": "Ошибка теста связи: {error}" }, "syncProvider": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/tool-use.json b/dashboard/src/i18n/locales/zh-CN/features/tool-use.json index 7b717d06c..a2f140261 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/tool-use.json +++ b/dashboard/src/i18n/locales/zh-CN/features/tool-use.json @@ -39,6 +39,9 @@ "description": "功能描述", "parameters": "参数列表", "noParameters": "此工具没有参数", + "filter": { + "showBuiltin": "显示内置工具" + }, "table": { "paramName": "参数名", "type": "类型", @@ -47,6 +50,11 @@ "origin": "来源", "originName": "来源名称", "readonly": "只读", + "permission": "权限", + "permissionAdmin": "管理员", + "permissionMember": "全部用户", + "permissionEveryone": "全部用户", + "permissionBuiltin": "系统内置", "actions": "操作" }, "configTags": { @@ -57,6 +65,11 @@ "in": "{key} 命中了 {expected}", "fallback": "{key} 当前值为 {actual}" } + }, + "summary": { + "total": "总数", + "active": "已启用", + "inactive": "未启用" } }, "marketplace": { @@ -167,6 +180,9 @@ "toggleToolSuccess": "工具状态切换成功!", "toggleToolReadonly": "内置工具为只读,无法进行启用或停用操作。", "toggleToolError": "工具状态切换失败: {error}", + "updateToolPermissionSuccess": "工具 {name} 权限已更新", + "updateToolPermissionFailed": "工具权限更新失败", + "updateToolPermissionBuiltin": "内置工具不支持单独配置权限", "testError": "测试连接失败: {error}" } } diff --git a/tests/unit/test_tool_conflict_resolution.py b/tests/unit/test_tool_conflict_resolution.py index 7fd04d448..8146ad887 100644 --- a/tests/unit/test_tool_conflict_resolution.py +++ b/tests/unit/test_tool_conflict_resolution.py @@ -180,16 +180,20 @@ class TestFunctionToolManagerGetFullToolSet: assert web_search is not None assert web_search.active is True - def test_no_deepcopy_preserves_identity(self): - """Should not deep copy tools, preserving object identity.""" + def test_wrapping_preserves_tool_name_and_description(self): + """_PermissionGuardedTool wrapping should preserve name and description.""" + from astrbot.core.provider.func_tool_manager import _PermissionGuardedTool + manager = FunctionToolManager() tool = make_tool("web_search") manager.func_list = [tool] toolset = manager.get_full_tool_set() - # Same object reference (no deepcopy) - assert toolset.tools[0] is tool + wrapped = toolset.tools[0] + assert wrapped.name == "web_search" + assert wrapped.description == "Test tool web_search" + assert isinstance(wrapped, _PermissionGuardedTool) def test_mcp_tool_overrides_disabled_builtin(self): """ diff --git a/tests/unit/test_tool_permission.py b/tests/unit/test_tool_permission.py new file mode 100644 index 000000000..1a3a8a376 --- /dev/null +++ b/tests/unit/test_tool_permission.py @@ -0,0 +1,434 @@ +"""Tests for per-tool permission management.""" + +import asyncio +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from astrbot.core import sp +from astrbot.core.agent.tool import FunctionTool, ToolSet +from astrbot.core.provider.func_tool_manager import ( + FunctionToolManager, + _PermissionGuardedTool, +) +from astrbot.core.tools.computer_tools.shell import ExecuteShellTool + + +# ── helpers ────────────────────────────────────────────────────────── + + +def _make_coro(value: object): + """Return a fresh coroutine object that resolves to ``value``. + + Used to mock Quart's ``await request.json`` where ``json`` is an + async property — assigning a coroutine lets ``await`` work directly.""" + + async def _inner(): + return value + + return _inner() + + +def _make_context(role: str = "member", sender_id: str = "user_123"): + """Return a mock context object suitable for tool permission checks.""" + + class FakeEvent: + unified_msg_origin = "aiocqhttp:GroupMessage:g1" + + def is_admin(self) -> bool: + return role == "admin" + + def get_sender_id(self) -> str: + return sender_id + + class FakeConfig: + def get_config(self, umo: str | None = None): + return {} + + class FakeAstrContext: + context = FakeConfig() + event = FakeEvent() + + class FakeWrapper: + context = FakeAstrContext() + + return FakeWrapper() + + +def _dummy_tool(name: str = "test_tool") -> FunctionTool: + return FunctionTool( + name=name, + description="A test tool", + parameters={"type": "object", "properties": {}}, + handler=None, + ) + + +def _clear_tool_permissions() -> None: + sp.put("tool_permissions", {}, scope="global", scope_id="global") + + +# ── _default_permission ────────────────────────────────────────────── + + +def test_default_permission_is_member(): + mgr = FunctionToolManager() + assert mgr._default_permission("any_mcp_tool") == "member" + + +# ── _check_tool_permission ─────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_check_permission_passes_when_no_config(): + _clear_tool_permissions() + mgr = FunctionToolManager() + context = _make_context(role="member") + + error = mgr._check_tool_permission("no_such_tool", context) + assert error is None + + +@pytest.mark.asyncio +async def test_check_permission_passes_for_admin_with_admin_tool(): + sp.put( + "tool_permissions", + {"_default": {"dangerous_tool": "admin"}}, + scope="global", + scope_id="global", + ) + try: + mgr = FunctionToolManager() + context = _make_context(role="admin", sender_id="admin_001") + error = mgr._check_tool_permission("dangerous_tool", context) + assert error is None + finally: + _clear_tool_permissions() + + +@pytest.mark.asyncio +async def test_check_permission_denies_member_for_admin_tool(): + sp.put( + "tool_permissions", + {"_default": {"dangerous_tool": "admin"}}, + scope="global", + scope_id="global", + ) + try: + mgr = FunctionToolManager() + context = _make_context(role="member", sender_id="user_999") + error = mgr._check_tool_permission("dangerous_tool", context) + assert error is not None + assert "dangerous_tool" in str(error) + assert "admin" in str(error).lower() + assert "user_999" in str(error) + finally: + _clear_tool_permissions() + + +@pytest.mark.asyncio +async def test_check_permission_denies_when_no_event(): + sp.put( + "tool_permissions", + {"_default": {"dangerous_tool": "admin"}}, + scope="global", + scope_id="global", + ) + try: + mgr = FunctionToolManager() + + class FakeWrapper: + pass # no .context.event + + error = mgr._check_tool_permission("dangerous_tool", FakeWrapper()) + assert error is not None + assert "admin" in str(error).lower() + finally: + _clear_tool_permissions() + + +@pytest.mark.asyncio +async def test_check_permission_passes_for_member_when_configured_member(): + sp.put( + "tool_permissions", + {"_default": {"safe_tool": "member"}}, + scope="global", + scope_id="global", + ) + try: + mgr = FunctionToolManager() + context = _make_context(role="member") + error = mgr._check_tool_permission("safe_tool", context) + assert error is None + finally: + _clear_tool_permissions() + + +# ── _PermissionGuardedTool ─────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_guarded_tool_delegates_when_permission_passes(): + _clear_tool_permissions() + mgr = FunctionToolManager() + + called = False + + async def handler(ctx, **kw): + nonlocal called + called = True + return "ok" + + wrapped = FunctionTool( + name="delegated", + description="desc", + parameters={}, + handler=handler, + ) + guarded = _PermissionGuardedTool(wrapped, mgr) + context = _make_context(role="member") + + result = await guarded.call(context) + assert called + assert result == "ok" + + +@pytest.mark.asyncio +async def test_guarded_tool_blocks_when_permission_denied(): + sp.put( + "tool_permissions", + {"_default": {"blocked_tool": "admin"}}, + scope="global", + scope_id="global", + ) + try: + mgr = FunctionToolManager() + called = False + + async def handler(ctx, **kw): + nonlocal called + called = True + return "should not reach" + + wrapped = FunctionTool( + name="blocked_tool", + description="desc", + parameters={}, + handler=handler, + ) + guarded = _PermissionGuardedTool(wrapped, mgr) + context = _make_context(role="member") + + result = await guarded.call(context) + assert not called + assert isinstance(result, str) + assert "Permission denied" in result + finally: + _clear_tool_permissions() + + +@pytest.mark.asyncio +async def test_guarded_tool_delegates_to_wrapped_call(): + _clear_tool_permissions() + mgr = FunctionToolManager() + + class CallableTool(FunctionTool): + async def call(self, context, **kwargs): + return "from call()" + + wrapped = CallableTool( + name="has_call", + description="desc", + parameters={}, + ) + guarded = _PermissionGuardedTool(wrapped, mgr) + context = _make_context() + + result = await guarded.call(context) + assert result == "from call()" + + +@pytest.mark.asyncio +async def test_guarded_tool_handles_async_generator_handler(): + _clear_tool_permissions() + mgr = FunctionToolManager() + + async def gen_handler(ctx, **kw): # type: ignore[misc] + yield "A" + yield "B" + yield "C" + + wrapped = FunctionTool( + name="gen_tool", + description="desc", + parameters={}, + handler=gen_handler, + ) + guarded = _PermissionGuardedTool(wrapped, mgr) + context = _make_context() + + result = await guarded.call(context) + # should return the last yielded value + assert result == "C" + + +# ── get_full_tool_set ──────────────────────────────────────────────── + + +def test_get_full_tool_set_excludes_builtin_tools(): + """Builtin tools are added separately by astr_main_agent.py, not through + get_full_tool_set().""" + mgr = FunctionToolManager() + tool_set = mgr.get_full_tool_set() + + names = {t.name for t in tool_set.tools} + # Builtin tools are injected individually by the agent builder — + # they must NOT appear in the generic tool set. + assert "astrbot_execute_shell" not in names + + +def test_get_full_tool_set_wraps_non_builtin(): + mgr = FunctionToolManager() + _clear_tool_permissions() + + mgr.func_list.append(_dummy_tool("my_plugin_tool")) + tool_set = mgr.get_full_tool_set() + + plugin_tools = [t for t in tool_set.tools if t.name == "my_plugin_tool"] + assert plugin_tools + assert isinstance( + plugin_tools[0], _PermissionGuardedTool + ), "non-builtin tools must be wrapped" + + +# ── API: get_tool_list permission fields ────────────────────────────── + + +class TestGetToolListPermission: + @pytest.mark.asyncio + async def test_list_includes_permission_fields_for_non_builtin(self): + from astrbot.dashboard.routes.tools import ToolsRoute + + # Minimal stubs to avoid full core lifecycle init + route = ToolsRoute.__new__(ToolsRoute) + route.core_lifecycle = MagicMock() + route.core_lifecycle.astrbot_config_mgr = MagicMock() + route.core_lifecycle.astrbot_config_mgr.get_conf_list.return_value = [] + route.core_lifecycle.astrbot_config_mgr.confs = {} + route.tool_mgr = FunctionToolManager() + + sp.put( + "tool_permissions", + {"_default": {"my_plugin_tool": "admin"}}, + scope="global", + scope_id="global", + ) + try: + route.tool_mgr.func_list.append(_dummy_tool("my_plugin_tool")) + resp = await route.get_tool_list() + data = json.loads(json.dumps(resp)) # simulate json serialisation + tools = data["data"] + + target = next(t for t in tools if t["name"] == "my_plugin_tool") + assert target["permission"] == "admin" + assert target["permission_configured"] is True + assert target["readonly"] is False + finally: + _clear_tool_permissions() + + @pytest.mark.asyncio + async def test_list_no_permission_fields_for_builtin(self): + from astrbot.dashboard.routes.tools import ToolsRoute + + route = ToolsRoute.__new__(ToolsRoute) + route.core_lifecycle = MagicMock() + route.core_lifecycle.astrbot_config_mgr = MagicMock() + route.core_lifecycle.astrbot_config_mgr.get_conf_list.return_value = [] + route.core_lifecycle.astrbot_config_mgr.confs = {} + route.tool_mgr = FunctionToolManager() + + resp = await route.get_tool_list() + data = json.loads(json.dumps(resp)) + tools = data["data"] + + target = next(t for t in tools if t["name"] == "astrbot_execute_shell") + assert "permission" not in target + assert "permission_configured" not in target + assert target["readonly"] is True + + +# ── API: update_tool_permission ────────────────────────────────────── + + +class TestUpdateToolPermission: + @pytest.mark.asyncio + async def test_set_admin_permission(self): + from astrbot.dashboard.routes.tools import ToolsRoute + + route = ToolsRoute.__new__(ToolsRoute) + route.core_lifecycle = MagicMock() + route.tool_mgr = FunctionToolManager() + route.tool_mgr.func_list.append(_dummy_tool("target_tool")) + _clear_tool_permissions() + + mock_req = MagicMock() + mock_req.json = _make_coro({"name": "target_tool", "permission": "admin"}) + with patch("astrbot.dashboard.routes.tools.request", mock_req): + resp = await route.update_tool_permission() + data = json.loads(json.dumps(resp)) + assert data["status"] == "ok" + + stored = sp.get( + "tool_permissions", {}, scope="global", scope_id="global" + ) + assert stored["_default"]["target_tool"] == "admin" + + @pytest.mark.asyncio + async def test_reject_builtin_tool(self): + from astrbot.dashboard.routes.tools import ToolsRoute + + route = ToolsRoute.__new__(ToolsRoute) + route.core_lifecycle = MagicMock() + route.tool_mgr = FunctionToolManager() + + mock_req = MagicMock() + mock_req.json = _make_coro({"name": "astrbot_execute_shell", "permission": "admin"}) + with patch("astrbot.dashboard.routes.tools.request", mock_req): + resp = await route.update_tool_permission() + data = json.loads(json.dumps(resp)) + assert data["status"] == "error" + assert "builtin" in str(data["message"]).lower() + + @pytest.mark.asyncio + async def test_reject_unknown_tool(self): + from astrbot.dashboard.routes.tools import ToolsRoute + + route = ToolsRoute.__new__(ToolsRoute) + route.core_lifecycle = MagicMock() + route.tool_mgr = FunctionToolManager() + + mock_req = MagicMock() + mock_req.json = _make_coro({"name": "ghost_tool", "permission": "admin"}) + with patch("astrbot.dashboard.routes.tools.request", mock_req): + resp = await route.update_tool_permission() + data = json.loads(json.dumps(resp)) + assert data["status"] == "error" + assert "not found" in str(data["message"]).lower() + + @pytest.mark.asyncio + async def test_reject_invalid_permission_value(self): + from astrbot.dashboard.routes.tools import ToolsRoute + + route = ToolsRoute.__new__(ToolsRoute) + route.core_lifecycle = MagicMock() + route.tool_mgr = FunctionToolManager() + route.tool_mgr.func_list.append(_dummy_tool("target_tool")) + + mock_req = MagicMock() + mock_req.json = _make_coro({"name": "target_tool", "permission": "everyone"}) + with patch("astrbot.dashboard.routes.tools.request", mock_req): + resp = await route.update_tool_permission() + data = json.loads(json.dumps(resp)) + assert data["status"] == "error" From 60dfd565a694ab3ce8e8282c613992255359431e Mon Sep 17 00:00:00 2001 From: liliiiliili Date: Wed, 10 Jun 2026 08:52:55 +0800 Subject: [PATCH 011/113] docs: fix LM Studio public IP typo (#8707) * docs: fix LM Studio public IP typo * docs: refine LM Studio Docker wording --- docs/zh/providers/provider-lmstudio.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/zh/providers/provider-lmstudio.md b/docs/zh/providers/provider-lmstudio.md index 969f7d7c8..8c0046f73 100644 --- a/docs/zh/providers/provider-lmstudio.md +++ b/docs/zh/providers/provider-lmstudio.md @@ -26,8 +26,8 @@ API Base URL 填写 `http://localhost:1234/v1` API Key 填写 `lm-studio` -> 对于 Mac/Windows 使用 Docker Desktop 部署 AstrBot 部署的用户,API Base URL 请填写为 `http://host.docker.internal:1234/v1`。 -> 对于 Linux 使用 Docker 部署 AstrBot 部署的用户,API Base URL 请填写为 `http://172.17.0.1:1234/v1`,或者将 `172.17.0.1` 替换为你的公网 IP IP(确保宿主机系统放行了 1234 端口)。 +> 对于 Mac/Windows 使用 Docker Desktop 部署 AstrBot 的用户,API Base URL 请填写为 `http://host.docker.internal:1234/v1`。 +> 对于 Linux 使用 Docker 部署 AstrBot 的用户,API Base URL 请填写为 `http://172.17.0.1:1234/v1`,或者将 `172.17.0.1` 替换为你的公网 IP(确保宿主机系统放行了 1234 端口)。 如果 LM Studio 使用了 Docker 部署,请确保 1234 端口已经映射到宿主机。 @@ -35,4 +35,4 @@ API Key 填写 `lm-studio` 保存配置即可。 -> 输入 /provider 查看 AstrBot 配置的模型 \ No newline at end of file +> 输入 /provider 查看 AstrBot 配置的模型 From bec0de2e2b680736ae9b744a9bf0f1aff7c20b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B2=9A=E4=B9=8B=E5=A4=8F?= <108566281+Blueteemo@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:31:48 +0800 Subject: [PATCH 012/113] fix: defer faiss C library import to prevent process hang on startup (#8696) * fix: defer faiss C library import to prevent process hang on startup Move top-level `import faiss` from embedding_storage.py to __init__() method, and make the EmbeddingStorage import in vec_db.py lazy. This prevents the faiss C library from being loaded at module import time, which can cause process hang (SIGILL or deadlock) with faiss-cpu 1.14.2 on certain CPU architectures. Ref #8695 * chore: apply review suggestions - Preserve original exception context with `from e` when re-raising ImportError - Move EmbeddingStorage import back to top level in vec_db.py (safe now that faiss is no longer imported at module level in embedding_storage.py) * ci: trigger re-run --------- Co-authored-by: Blueteemo --- astrbot/core/db/vec_db/faiss_impl/__init__.py | 8 +++++++- .../db/vec_db/faiss_impl/embedding_storage.py | 17 +++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/astrbot/core/db/vec_db/faiss_impl/__init__.py b/astrbot/core/db/vec_db/faiss_impl/__init__.py index 41f60466c..d46823f5f 100644 --- a/astrbot/core/db/vec_db/faiss_impl/__init__.py +++ b/astrbot/core/db/vec_db/faiss_impl/__init__.py @@ -1,3 +1,9 @@ -from .vec_db import FaissVecDB +def __getattr__(name: str): + if name == "FaissVecDB": + from .vec_db import FaissVecDB + + return FaissVecDB + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + __all__ = ["FaissVecDB"] 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 dc6977cf8..873fbbfca 100644 --- a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +++ b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py @@ -1,9 +1,3 @@ -try: - import faiss -except ModuleNotFoundError: - raise ImportError( - "faiss 未安装。请使用 'pip install faiss-cpu' 或 'pip install faiss-gpu' 安装。", - ) import os import numpy as np @@ -11,6 +5,13 @@ import numpy as np class EmbeddingStorage: def __init__(self, dimension: int, path: str | None = None) -> None: + try: + import faiss + except ModuleNotFoundError as e: + raise ImportError( + "faiss 未安装。请使用 'pip install faiss-cpu' 或 'pip install faiss-gpu' 安装。", + ) from e + self._faiss = faiss self.dimension = dimension self.path = path self.index = None @@ -67,7 +68,7 @@ class EmbeddingStorage: """ assert self.index is not None, "FAISS index is not initialized." - faiss.normalize_L2(vector) + self._faiss.normalize_L2(vector) distances, indices = self.index.search(vector, k) return distances, indices @@ -92,4 +93,4 @@ class EmbeddingStorage: """ if self.index is None: return - faiss.write_index(self.index, self.path) + self._faiss.write_index(self.index, self.path) From a3c25ec2c7e4395492999a927e8a9953fa463b56 Mon Sep 17 00:00:00 2001 From: HIU Date: Wed, 10 Jun 2026 16:42:12 +0800 Subject: [PATCH 013/113] fix: preserve repo source on plugin reinstall * fix: preserve repo source on plugin reinstall * fix(dashboard): align extension market matching with repo identity Use normalized repo keys as the primary identity when matching installed extensions with marketplace plugins in useExtensionPage. Only fall back to name matching for installed extensions that do not have a repo, and normalize name lookups consistently on both sides. Also clear stale online_version when no marketplace plugin is matched. --- .../src/views/extension/useExtensionPage.js | 75 ++++++++++--------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/dashboard/src/views/extension/useExtensionPage.js b/dashboard/src/views/extension/useExtensionPage.js index cf681e933..7476d252a 100644 --- a/dashboard/src/views/extension/useExtensionPage.js +++ b/dashboard/src/views/extension/useExtensionPage.js @@ -1,19 +1,19 @@ -import axios from "axios"; -import { useCommonStore } from "@/stores/common"; -import { useI18n, useModuleI18n } from "@/i18n/composables"; -import { getPlatformDisplayName } from "@/utils/platformUtils"; -import { resolveErrorMessage } from "@/utils/errorUtils"; -import { - buildSearchQuery, - matchesPluginSearch, - normalizeStr, - toInitials, - toPinyinText, -} from "@/utils/pluginSearch"; -import { getValidHashTab, replaceTabRoute } from "@/utils/hashRouteTabs.mjs"; -import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue"; -import { useRoute, useRouter } from "vue-router"; import { pluginSidebarState } from "@/composables/usePluginSidebarItems"; +import { useI18n, useModuleI18n } from "@/i18n/composables"; +import { useCommonStore } from "@/stores/common"; +import { resolveErrorMessage } from "@/utils/errorUtils"; +import { getValidHashTab, replaceTabRoute } from "@/utils/hashRouteTabs.mjs"; +import { getPlatformDisplayName } from "@/utils/platformUtils"; +import { + buildSearchQuery, + matchesPluginSearch, + normalizeStr, + toInitials, + toPinyinText, +} from "@/utils/pluginSearch"; +import axios from "axios"; +import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue"; +import { useRoute, useRouter } from "vue-router"; const buildFailedPluginItems = (raw) => { return Object.entries(raw || {}).map(([dirName, info]) => { @@ -624,12 +624,16 @@ export const useExtensionPage = () => { const findMarketPluginForExtension = (extension) => { if (!extension) return null; const repo = normalizeInstallUrl(extension.repo).toLowerCase(); + + if (repo) { + return ( + pluginMarketData.value.find( + (plugin) => normalizeInstallUrl(plugin?.repo).toLowerCase() === repo, + ) || null + ); + } + return ( - pluginMarketData.value.find( - (plugin) => - repo && - normalizeInstallUrl(plugin?.repo).toLowerCase() === repo, - ) || pluginMarketData.value.find((plugin) => plugin.name === extension.name) || null ); @@ -655,12 +659,12 @@ export const useExtensionPage = () => { data.forEach((extension) => { const repoKey = extension.repo ? normalizeInstallUrl(extension.repo).toLowerCase() : undefined; const onlinePlugin = repoKey ? onlinePluginsMap.get(repoKey) : null; - + // 使用 marketplace_name 进行市场匹配(后端已统一为减号格式) const normalizedExtensionName = normalizeStr(extension.marketplace_name); const onlinePluginByName = onlinePluginsNameMap.get(normalizedExtensionName); - - const matchedPlugin = onlinePlugin || onlinePluginByName; + + const matchedPlugin = repoKey ? onlinePlugin : onlinePluginByName; if (matchedPlugin) { extension.online_version = matchedPlugin.version; @@ -668,6 +672,7 @@ export const useExtensionPage = () => { extension.version !== matchedPlugin.version && matchedPlugin.version !== tm("status.unknown"); } else { + extension.online_version = ""; extension.has_update = false; } }); @@ -1243,27 +1248,25 @@ export const useExtensionPage = () => { const checkAlreadyInstalled = () => { const data = Array.isArray(extension_data?.data) ? extension_data.data : []; - // repo 匹配:两边统一使用 normalizeInstallUrl - const installedRepos = new Set( - data - .filter((ext) => ext.repo) - .map((ext) => normalizeInstallUrl(ext.repo).toLowerCase()), - ); // 使用 marketplace_name 进行市场匹配(后端已统一为减号格式) - const installedNames = new Set(data.map((ext) => normalizeStr(ext.marketplace_name))); // 创建映射用于查询已安装插件的详细信息 const installedByRepo = new Map( data .filter((ext) => ext.repo) .map((ext) => [normalizeInstallUrl(ext.repo).toLowerCase(), ext]), ); - const installedByName = new Map(data.map((ext) => [ext.marketplace_name, ext])); - + const installedByName = new Map( + data + .filter((ext) => !ext.repo) + .map((ext) => [normalizeStr(ext.marketplace_name || ext.name), ext]), + ); + for (let i = 0; i < pluginMarketData.value.length; i++) { const plugin = pluginMarketData.value[i]; + const repoKey = plugin.repo ? normalizeInstallUrl(plugin.repo).toLowerCase() : undefined; const matchedInstalled = - (plugin.repo && installedByRepo.get(normalizeInstallUrl(plugin.repo).toLowerCase())) || - installedByName.get(plugin.name); + (repoKey && installedByRepo.get(repoKey)) || + installedByName.get(normalizeStr(plugin.name)); // 兜底:市场源未提供字段时,回填本地已安装插件中的元数据,便于在市场页直接展示 if (matchedInstalled) { @@ -1279,9 +1282,7 @@ export const useExtensionPage = () => { } } - plugin.installed = - installedRepos.has(normalizeInstallUrl(plugin.repo).toLowerCase()) || - installedNames.has(normalizeStr(plugin.name)); + plugin.installed = !!matchedInstalled; } let installed = []; From b321499e0037b0d11a9181f2be67e09d7139621d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:43:47 +0800 Subject: [PATCH 014/113] chore(deps): bump codecov/codecov-action from 6 to 7 in the github-actions group Bumps the github-actions group with 1 update: [codecov/codecov-action](https://github.com/codecov/codecov-action). Updates `codecov/codecov-action` from 6 to 7 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v6...v7) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/coverage_test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage_test.yml b/.github/workflows/coverage_test.yml index 9d4c6a8e8..3e5a37528 100644 --- a/.github/workflows/coverage_test.yml +++ b/.github/workflows/coverage_test.yml @@ -41,6 +41,6 @@ jobs: - name: Upload results to Codecov if: github.repository == 'AstrBotDevs/AstrBot' - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} From 56d2b3fb55be8fd0f8f4ad5d1d4d9a2b891f8ef3 Mon Sep 17 00:00:00 2001 From: micaiguai <1399880823@qq.com> Date: Wed, 10 Jun 2026 16:46:11 +0800 Subject: [PATCH 015/113] fix(dashboard): allow creating folder by pressing Enter (#8597) * fix(dashboard): allow creating folder by pressing Enter * fix(dashboard): disable form while creating folder to prevent duplicate submissions --- dashboard/src/components/folder/BaseCreateFolderDialog.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/components/folder/BaseCreateFolderDialog.vue b/dashboard/src/components/folder/BaseCreateFolderDialog.vue index 59075f06f..8937bef27 100644 --- a/dashboard/src/components/folder/BaseCreateFolderDialog.vue +++ b/dashboard/src/components/folder/BaseCreateFolderDialog.vue @@ -6,7 +6,7 @@ {{ labels.title }} - + From 0b2234936331bca1dd07b193fb60dcf68a0fa3c2 Mon Sep 17 00:00:00 2001 From: Zayn Date: Wed, 10 Jun 2026 16:47:14 +0800 Subject: [PATCH 016/113] feat: add ElevenLabs TTS API provider --- astrbot/core/config/default.py | 19 ++ astrbot/core/provider/manager.py | 4 + .../provider/sources/elevenlabs_tts_source.py | 173 ++++++++++++++++++ .../en-US/features/config-metadata.json | 24 +++ .../ru-RU/features/config-metadata.json | 24 +++ .../zh-CN/features/config-metadata.json | 24 +++ 6 files changed, 268 insertions(+) create mode 100644 astrbot/core/provider/sources/elevenlabs_tts_source.py diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index b60c7f230..d060ce1c3 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1807,6 +1807,25 @@ CONFIG_METADATA_2 = { "gemini_tts_voice_name": "Leda", "proxy": "", }, + "ElevenLabs TTS(API)": { + "hint": "API Key 从 https://elevenlabs.io/app/settings/api-keys 获取。Voice ID 可在 https://elevenlabs.io/app/voice-library 浏览选择。", + "id": "elevenlabs_tts", + "type": "elevenlabs_tts_api", + "provider": "elevenlabs", + "provider_type": "text_to_speech", + "enable": False, + "api_key": "", + "api_base": "https://api.elevenlabs.io/v1", + "model": "eleven_multilingual_v2", + "elevenlabs-tts-voice-id": "JBFqnCBsd6RMkjVDRZzb", + "elevenlabs-tts-output-format": "mp3_44100_128", + "elevenlabs-tts-stability": "", + "elevenlabs-tts-similarity-boost": "", + "elevenlabs-tts-style": "", + "elevenlabs-tts-use-speaker-boost": True, + "timeout": "20", + "proxy": "", + }, "OpenAI Embedding": { "id": "openai_embedding", "type": "openai_embedding", diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index 2544736e7..ae4001fcd 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -467,6 +467,10 @@ class ProviderManager: from .sources.gemini_tts_source import ( ProviderGeminiTTSAPI as ProviderGeminiTTSAPI, ) + case "elevenlabs_tts_api": + from .sources.elevenlabs_tts_source import ( + ProviderElevenLabsTTSAPI as ProviderElevenLabsTTSAPI, + ) case "openai_embedding": from .sources.openai_embedding_source import ( OpenAIEmbeddingProvider as OpenAIEmbeddingProvider, diff --git a/astrbot/core/provider/sources/elevenlabs_tts_source.py b/astrbot/core/provider/sources/elevenlabs_tts_source.py new file mode 100644 index 000000000..7ea0fcf61 --- /dev/null +++ b/astrbot/core/provider/sources/elevenlabs_tts_source.py @@ -0,0 +1,173 @@ +import uuid +from pathlib import Path + +import httpx + +from astrbot import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path + +from ..entities import ProviderType +from ..provider import TTSProvider +from ..register import register_provider_adapter + +SUPPORTED_CONTAINER_OUTPUT_PREFIXES = ("mp3", "wav", "opus") +RAW_AUDIO_OUTPUT_PREFIXES = ("pcm", "ulaw", "alaw") + + +def _parse_optional_float( + provider_config: dict, + cfg_name: str, +) -> float | None: + value = provider_config.get(cfg_name, "") + if value in ("", None): + return None + try: + parsed = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{cfg_name} must be a number between 0 and 1.") from exc + if not 0 <= parsed <= 1: + raise ValueError(f"{cfg_name} must be between 0 and 1.") + return parsed + + +def _parse_bool(provider_config: dict, cfg_name: str) -> bool: + value = provider_config[cfg_name] + if isinstance(value, bool): + return value + if isinstance(value, int): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes", "y", "on"}: + return True + if normalized in {"false", "0", "no", "n", "off"}: + return False + raise ValueError(f"{cfg_name} must be a boolean value.") + + +def _normalize_timeout(value: int | str | None) -> int: + if value in ("", None): + return 20 + try: + timeout = int(value) + except (TypeError, ValueError) as exc: + raise ValueError("timeout must be a positive integer.") from exc + if timeout <= 0: + raise ValueError("timeout must be a positive integer.") + return timeout + + +def _validate_output_format(output_format: str) -> None: + fmt = output_format.lower() + if fmt.startswith(RAW_AUDIO_OUTPUT_PREFIXES): + raise ValueError( + "ElevenLabs raw audio output formats are not supported by this provider. " + "Use an mp3, wav, or opus output format instead." + ) + if not fmt.startswith(SUPPORTED_CONTAINER_OUTPUT_PREFIXES): + raise ValueError( + "Unsupported ElevenLabs output format. " + "Use an mp3, wav, or opus output format." + ) + + +@register_provider_adapter( + "elevenlabs_tts_api", + "ElevenLabs TTS API", + provider_type=ProviderType.TEXT_TO_SPEECH, +) +class ProviderElevenLabsTTSAPI(TTSProvider): + def __init__( + self, + provider_config: dict, + provider_settings: dict, + ) -> None: + super().__init__(provider_config, provider_settings) + self.api_key = provider_config.get("api_key", "") + self.api_base = provider_config.get( + "api_base", "https://api.elevenlabs.io/v1" + ).removesuffix("/") + self.voice_id = provider_config.get( + "elevenlabs-tts-voice-id", "JBFqnCBsd6RMkjVDRZzb" + ) + self.model_id = provider_config.get("model", "eleven_multilingual_v2") + self.set_model(self.model_id) + self.output_format = provider_config.get( + "elevenlabs-tts-output-format", "mp3_44100_128" + ) + _validate_output_format(self.output_format) + + # Only send explicitly configured voice settings so the API can apply defaults. + self.voice_settings: dict = {} + for key, cfg_name in ( + ("stability", "elevenlabs-tts-stability"), + ("similarity_boost", "elevenlabs-tts-similarity-boost"), + ("style", "elevenlabs-tts-style"), + ): + value = _parse_optional_float(provider_config, cfg_name) + if value is not None: + self.voice_settings[key] = value + if "elevenlabs-tts-use-speaker-boost" in provider_config: + self.voice_settings["use_speaker_boost"] = _parse_bool( + provider_config, + "elevenlabs-tts-use-speaker-boost", + ) + + timeout = _normalize_timeout(provider_config.get("timeout", 20)) + + proxy = provider_config.get("proxy", "") + if proxy: + logger.info(f"[ElevenLabs TTS] 使用代理: {proxy}") + self.client = httpx.AsyncClient( + timeout=timeout, + proxy=proxy or None, + trust_env=False, + ) + + def _output_extension(self) -> str: + """Infer the audio file extension from the configured output format.""" + fmt = self.output_format.lower() + if fmt.startswith("mp3"): + return "mp3" + if fmt.startswith("opus"): + return "opus" + if fmt.startswith("wav"): + return "wav" + return "mp3" + + async def get_audio(self, text: str) -> str: + url = f"{self.api_base}/text-to-speech/{self.voice_id}" + headers = { + "xi-api-key": self.api_key, + "Content-Type": "application/json", + } + payload: dict = { + "text": text, + "model_id": self.model_name, + } + if self.voice_settings: + payload["voice_settings"] = self.voice_settings + + response = await self.client.post( + url, + headers=headers, + params={"output_format": self.output_format}, + json=payload, + ) + if response.status_code != 200: + error_text = response.text[:1024] + raise Exception( + f"ElevenLabs TTS API 请求失败: {response.status_code}, {error_text}" + ) + + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + path = ( + temp_dir / f"elevenlabs_tts_api_{uuid.uuid4()}.{self._output_extension()}" + ) + path.write_bytes(response.content) + return str(path) + + async def terminate(self): + if self.client: + await self.client.aclose() diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index be6293274..a79746c9d 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1590,6 +1590,30 @@ "description": "voice", "hint": "OpenAI TTS voice. OpenAI defaults: 'alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'." }, + "elevenlabs-tts-voice-id": { + "description": "Voice ID", + "hint": "ElevenLabs voice ID. Browse and copy voice IDs at https://elevenlabs.io/app/voice-library. Default 'JBFqnCBsd6RMkjVDRZzb' (George)." + }, + "elevenlabs-tts-output-format": { + "description": "Output format", + "hint": "Audio output format, e.g. 'mp3_44100_128', 'mp3_22050_32', 'wav_44100', or 'opus_48000_128'. Raw PCM/u-law/a-law formats are not supported. Default 'mp3_44100_128'." + }, + "elevenlabs-tts-stability": { + "description": "Stability", + "hint": "Voice stability, range [0, 1]. Higher is more consistent, lower is more expressive. Leave empty to use the server default." + }, + "elevenlabs-tts-similarity-boost": { + "description": "Similarity boost", + "hint": "How closely the output matches the original voice, range [0, 1]. Leave empty to use the server default." + }, + "elevenlabs-tts-style": { + "description": "Style exaggeration", + "hint": "Style exaggeration of the voice, range [0, 1]. Higher values increase latency. Leave empty to use the server default." + }, + "elevenlabs-tts-use-speaker-boost": { + "description": "Speaker boost", + "hint": "Boost similarity to the original speaker. May slightly increase latency." + }, "mimo-tts-voice": { "description": "Voice", "hint": "MiMo TTS voice name. Supported values include 'mimo_default', 'default_en', and 'default_zh'." diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index ccacea553..b08662b7b 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -1587,6 +1587,30 @@ "description": "API Base URL", "hint": "Голоса OpenAI TTS: alloy, echo и др." }, + "elevenlabs-tts-voice-id": { + "description": "ID голоса", + "hint": "ID голоса ElevenLabs. Просмотрите и скопируйте ID на https://elevenlabs.io/app/voice-library. По умолчанию 'JBFqnCBsd6RMkjVDRZzb' (George)." + }, + "elevenlabs-tts-output-format": { + "description": "Формат вывода", + "hint": "Формат аудио, например 'mp3_44100_128', 'mp3_22050_32', 'wav_44100' или 'opus_48000_128'. Raw PCM/u-law/a-law форматы не поддерживаются. По умолчанию 'mp3_44100_128'." + }, + "elevenlabs-tts-stability": { + "description": "Стабильность", + "hint": "Стабильность голоса, диапазон [0, 1]. Оставьте пустым для значения по умолчанию." + }, + "elevenlabs-tts-similarity-boost": { + "description": "Усиление сходства", + "hint": "Насколько вывод соответствует исходному голосу, диапазон [0, 1]. Оставьте пустым для значения по умолчанию." + }, + "elevenlabs-tts-style": { + "description": "Выразительность стиля", + "hint": "Выразительность стиля голоса, диапазон [0, 1]. Высокие значения увеличивают задержку. Оставьте пустым для значения по умолчанию." + }, + "elevenlabs-tts-use-speaker-boost": { + "description": "Усиление диктора", + "hint": "Усиливает сходство с исходным диктором. Может немного увеличить задержку." + }, "mimo-tts-voice": { "description": "Голос", "hint": "Имя голоса MiMo TTS. Поддерживаются значения 'mimo_default', 'default_en' и 'default_zh'." diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index d506083a8..75ce4fd93 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1592,6 +1592,30 @@ "description": "voice", "hint": "OpenAI TTS 的声音。OpenAI 默认支持:'alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'" }, + "elevenlabs-tts-voice-id": { + "description": "音色 ID", + "hint": "ElevenLabs 音色 ID。可在 https://elevenlabs.io/app/voice-library 浏览并复制音色 ID。默认 'JBFqnCBsd6RMkjVDRZzb'(George)。" + }, + "elevenlabs-tts-output-format": { + "description": "输出格式", + "hint": "音频输出格式,例如 'mp3_44100_128'、'mp3_22050_32'、'wav_44100'、'opus_48000_128'。不支持裸 PCM/u-law/a-law 格式。默认 'mp3_44100_128'。" + }, + "elevenlabs-tts-stability": { + "description": "稳定性", + "hint": "音色稳定性,范围 [0, 1]。值越高越稳定,越低越富有表现力。留空则使用服务端默认值。" + }, + "elevenlabs-tts-similarity-boost": { + "description": "相似度增强", + "hint": "输出与原始音色的接近程度,范围 [0, 1]。留空则使用服务端默认值。" + }, + "elevenlabs-tts-style": { + "description": "风格夸张度", + "hint": "音色风格的夸张程度,范围 [0, 1]。值越高延迟越大。留空则使用服务端默认值。" + }, + "elevenlabs-tts-use-speaker-boost": { + "description": "说话人增强", + "hint": "增强与原始说话人的相似度,可能略微增加延迟。" + }, "mimo-tts-voice": { "description": "音色", "hint": "MiMo TTS 的音色名称。可选值包括 'mimo_default'、'default_en'、'default_zh'。" From eeabdb982959b32f60ac5227a812faa2989eb327 Mon Sep 17 00:00:00 2001 From: yzy123908944141 <255488545@qq.com> Date: Wed, 10 Jun 2026 16:47:22 +0800 Subject: [PATCH 017/113] style: apply pyupgrade updates (#8684) --- astrbot/core/computer/booters/shipyard_search_file_util.py | 2 +- astrbot/core/pipeline/stage.py | 2 +- astrbot/utils/__init__.py | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/astrbot/core/computer/booters/shipyard_search_file_util.py b/astrbot/core/computer/booters/shipyard_search_file_util.py index 1227244de..cdd41de82 100644 --- a/astrbot/core/computer/booters/shipyard_search_file_util.py +++ b/astrbot/core/computer/booters/shipyard_search_file_util.py @@ -74,7 +74,7 @@ def _build_grep_command( def _quote_command(command: list[str]) -> str: - return " ".join(shlex.quote(part) for part in command) + return shlex.join(command) def build_search_command( diff --git a/astrbot/core/pipeline/stage.py b/astrbot/core/pipeline/stage.py index 74aca4ef1..380c072b9 100644 --- a/astrbot/core/pipeline/stage.py +++ b/astrbot/core/pipeline/stage.py @@ -33,7 +33,7 @@ class Stage(abc.ABC): async def process( self, event: AstrMessageEvent, - ) -> None | AsyncGenerator[None, None]: + ) -> None | AsyncGenerator[None]: """处理事件 Args: diff --git a/astrbot/utils/__init__.py b/astrbot/utils/__init__.py index 8b1378917..e69de29bb 100644 --- a/astrbot/utils/__init__.py +++ b/astrbot/utils/__init__.py @@ -1 +0,0 @@ - From fb8f4d68e1696978e645293b46f4563375d2d814 Mon Sep 17 00:00:00 2001 From: lingyun14 Date: Wed, 10 Jun 2026 16:51:29 +0800 Subject: [PATCH 018/113] feat: backup skills directory (#8700) * feat: include skills directory in backup * Update settings.json * Update settings.json * Update settings.json * Update settings.json * Update settings.json --- astrbot/core/backup/constants.py | 2 ++ dashboard/src/i18n/locales/en-US/features/settings.json | 6 +++--- dashboard/src/i18n/locales/ru-RU/features/settings.json | 8 ++++---- dashboard/src/i18n/locales/zh-CN/features/settings.json | 6 +++--- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/astrbot/core/backup/constants.py b/astrbot/core/backup/constants.py index 493d2670c..041fa407b 100644 --- a/astrbot/core/backup/constants.py +++ b/astrbot/core/backup/constants.py @@ -29,6 +29,7 @@ from astrbot.core.utils.astrbot_path import ( get_astrbot_config_path, get_astrbot_plugin_data_path, get_astrbot_plugin_path, + get_astrbot_skills_path, get_astrbot_t2i_templates_path, get_astrbot_temp_path, get_astrbot_webchat_path, @@ -78,6 +79,7 @@ def get_backup_directories() -> dict[str, str]: "t2i_templates": get_astrbot_t2i_templates_path(), # T2I 模板 "webchat": get_astrbot_webchat_path(), # WebChat 数据 "temp": get_astrbot_temp_path(), # 临时文件 + "skills": get_astrbot_skills_path(), # Skills } diff --git a/dashboard/src/i18n/locales/en-US/features/settings.json b/dashboard/src/i18n/locales/en-US/features/settings.json index b497659ee..c561e9d8c 100644 --- a/dashboard/src/i18n/locales/en-US/features/settings.json +++ b/dashboard/src/i18n/locales/en-US/features/settings.json @@ -99,8 +99,8 @@ }, "export": { "title": "Create Backup", - "description": "Export all data as a ZIP backup file, including database, knowledge base, config and attachments.", - "includes": "Backup includes: Main database, Knowledge bases (metadata + vector index + documents), Config files, Attachment files", + "description": "Export all data as a ZIP backup file, including database, knowledge base, config, attachments and skills.", + "includes": "Backup includes: Main database, Knowledge bases (metadata + vector index + documents), Config files, Attachment files, Skills", "button": "Start Export", "processing": "Exporting...", "wait": "Please wait, packaging data...", @@ -140,7 +140,7 @@ "currentVersion": "Current Version", "backupTime": "Backup Time", "matchTitle": "✅ Version Match", - "matchMessage": "Import will clear and overwrite all existing data, including:\n• Main database (conversations, settings, etc.)\n• Knowledge bases\n• Plugins and plugin data\n• Configuration files\n\nThis action cannot be undone! Do you want to continue?", + "matchMessage": "Import will clear and overwrite all existing data, including:\n• Main database (conversations, settings, etc.)\n• Knowledge bases\n• Plugins and plugin data\n• Configuration files\n• Skills\n\nThis action cannot be undone! Do you want to continue?", "minorDiffTitle": "⚠️ Version Difference Warning", "minorDiffMessage": "Minor version differences are usually compatible, but there may be some data structure changes.\nImport will clear and overwrite all existing data!\n\nDo you want to continue?", "majorDiffTitle": "⛔ Cannot Import", diff --git a/dashboard/src/i18n/locales/ru-RU/features/settings.json b/dashboard/src/i18n/locales/ru-RU/features/settings.json index d1100435f..eb78ec687 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/settings.json +++ b/dashboard/src/i18n/locales/ru-RU/features/settings.json @@ -1,4 +1,4 @@ -{ +{ "network": { "title": "Сеть", "githubProxy": { @@ -99,8 +99,8 @@ }, "export": { "title": "Создать резервную копию", - "description": "Экспорт всех данных в ZIP-архив, включая базы данных, базу знаний, конфигурации и вложения.", - "includes": "Включает: основную БД, векторные индексы знаний, файлы конфигурации, медиа-вложения.", + "description": "Экспорт всех данных в ZIP-архив, включая базы данных, базу знаний, конфигурации, вложения и Skills.", + "includes": "Включает: основную БД, векторные индексы знаний, файлы конфигурации, медиа-вложения, Skills.", "button": "Начать экспорт", "processing": "Экспорт...", "wait": "Пожалуйста, подождите, мы упаковываем данные...", @@ -140,7 +140,7 @@ "currentVersion": "Текущая версия", "backupTime": "Дата создания", "matchTitle": "✅ Версии совпадают", - "matchMessage": "Импорт перезапишет все текущие данные, включая:\n• Основную БД (чаты, настройки)\n• Базы знаний\n• Плагины и их данные\n• Файлы конфигурации\n\nЭто действие необратимо! Продолжить?", + "matchMessage": "Импорт перезапишет все текущие данные, включая:\n• Основную БД (чаты, настройки)\n• Базы знаний\n• Плагины и их данные\n• Файлы конфигурации\n• Skills\n\nЭто действие необратимо! Продолжить?", "minorDiffTitle": "⚠️ Разница в минорной версии", "minorDiffMessage": "Разница в минорных версиях обычно допустима, но структура данных могла немного измениться. Все текущие данные будут удалены!\n\nПродолжить импорт?", "majorDiffTitle": "⛔ Импорт невозможен", diff --git a/dashboard/src/i18n/locales/zh-CN/features/settings.json b/dashboard/src/i18n/locales/zh-CN/features/settings.json index 092b4a5d9..de5748f2c 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/settings.json +++ b/dashboard/src/i18n/locales/zh-CN/features/settings.json @@ -99,8 +99,8 @@ }, "export": { "title": "创建备份", - "description": "将所有数据导出为 ZIP 备份文件,包括数据库、知识库、配置和附件。", - "includes": "备份包含:主数据库、知识库(元数据+向量索引+文档)、配置文件、附件文件", + "description": "将所有数据导出为 ZIP 备份文件,包括数据库、知识库、配置、附件和 Skills。", + "includes": "备份包含:主数据库、知识库(元数据+向量索引+文档)、配置文件、附件文件、Skills", "button": "开始导出", "processing": "正在导出...", "wait": "请稍候,正在打包数据...", @@ -140,7 +140,7 @@ "currentVersion": "当前版本", "backupTime": "备份时间", "matchTitle": "✅ 版本匹配", - "matchMessage": "导入将会清空并覆盖现有的所有数据,包括:\n• 主数据库(对话记录、配置等)\n• 知识库数据\n• 插件及插件数据\n• 配置文件\n\n此操作不可撤销!是否确认继续?", + "matchMessage": "导入将会清空并覆盖现有的所有数据,包括:\n• 主数据库(对话记录、配置等)\n• 知识库数据\n• 插件及插件数据\n• 配置文件\n• Skills\n\n此操作不可撤销!是否确认继续?", "minorDiffTitle": "⚠️ 版本差异警告", "minorDiffMessage": "小版本差异通常是兼容的,但可能存在少量数据结构变化。\n导入将会清空并覆盖现有的所有数据!\n\n是否确认继续导入?", "majorDiffTitle": "⛔ 无法导入", From 32cfcbf52db6bfe490957e0a16d0e4378f59b918 Mon Sep 17 00:00:00 2001 From: Tang Yulong <63840759+ScarletPupil@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:58:18 +0800 Subject: [PATCH 019/113] feat: show quoted message content in group chat context * feat: show quoted message content in group chat context Include Reply component content in _format_message so the LLM can see what message was quoted when someone replies to the bot. - Add Reply import - Handle Reply in _format_message with message_str or chain fallback - Add _describe_chain helper for non-text quoted content (images, etc.) Co-Authored-By: Claude Opus 4.8 * fix: use isinstance, add truncation, unify language to English - Replace string class-name checks with isinstance() for robustness - Add _truncate_reply_text to prevent long quoted content inflating context - Unify markers to English ([Image], [Voice], [Quote] etc.) - Import Record, Video, File for isinstance checks Co-Authored-By: Claude Opus 4.8 * fix: add Forward, AtAll, Face to _describe_chain for completeness Co-Authored-By: Claude Opus 4.8 * style: format group chat context --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Soulter <905617992@qq.com> --- .../astrbot/group_chat_context.py | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/astrbot/builtin_stars/astrbot/group_chat_context.py b/astrbot/builtin_stars/astrbot/group_chat_context.py index 7fee3c0df..0dae34842 100644 --- a/astrbot/builtin_stars/astrbot/group_chat_context.py +++ b/astrbot/builtin_stars/astrbot/group_chat_context.py @@ -7,7 +7,18 @@ from collections import defaultdict, deque from astrbot import logger from astrbot.api import star from astrbot.api.event import AstrMessageEvent -from astrbot.api.message_components import At, Image, Plain +from astrbot.api.message_components import ( + At, + AtAll, + Face, + File, + Forward, + Image, + Plain, + Record, + Reply, + Video, +) from astrbot.api.platform import MessageType from astrbot.api.provider import Provider, ProviderRequest from astrbot.core.agent.message import TextPart @@ -214,10 +225,60 @@ class GroupChatContext: if is_at_self: parts.insert(1, "⚠️[DIRECTED AT YOU] ") parts.append(f" [At: {comp.name}]") + elif isinstance(comp, Reply): + if comp.message_str: + parts.append( + f" [Quote({comp.sender_nickname}: {_truncate_reply_text(comp.message_str)})]" + ) + elif comp.chain: + chain_desc = _describe_chain(comp.chain) + parts.append(f" [Quote({comp.sender_nickname}: {chain_desc})]") + else: + parts.append(" [Quote]") return "".join(parts) +_MAX_REPLY_TEXT_LENGTH = 200 + + +def _describe_chain(chain: list) -> str: + """Summarize message chain content for quoted reply display.""" + desc = [] + for c in chain: + if isinstance(c, Plain) and getattr(c, "text", None): + desc.append(c.text) + elif isinstance(c, Image): + desc.append("[Image]") + elif isinstance(c, At): + name = getattr(c, "name", "") or getattr(c, "qq", "") + desc.append(f"[At: {name}]") + elif isinstance(c, Record): + desc.append("[Voice]") + elif isinstance(c, Video): + desc.append("[Video]") + elif isinstance(c, File): + desc.append(f"[File: {getattr(c, 'name', '') or ''}]") + elif isinstance(c, Forward): + desc.append("[Forward]") + elif isinstance(c, AtAll): + desc.append("[At: All]") + elif isinstance(c, Face): + desc.append(f"[Sticker: {getattr(c, 'id', '')}]") + elif isinstance(c, Reply): + desc.append("[Quote]") + else: + desc.append(f"[{c.__class__.__name__}]") + return "".join(desc) or "[Unknown]" + + +def _truncate_reply_text(text: str) -> str: + """Truncate overly long quoted reply text.""" + if len(text) <= _MAX_REPLY_TEXT_LENGTH: + return text + return text[:_MAX_REPLY_TEXT_LENGTH] + "..." + + def _positive_int(value, fallback: int) -> int: try: parsed = int(value) From f19f623a2639e3d1c73fe7d32456d3311a0c54a9 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:51:32 +0800 Subject: [PATCH 020/113] fix: handle changelog anchor links (#8750) Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com> --- .../src/components/shared/ChangelogDialog.vue | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/dashboard/src/components/shared/ChangelogDialog.vue b/dashboard/src/components/shared/ChangelogDialog.vue index cb0f5cce4..55971785d 100644 --- a/dashboard/src/components/shared/ChangelogDialog.vue +++ b/dashboard/src/components/shared/ChangelogDialog.vue @@ -32,6 +32,7 @@ const changelogVersion = ref(''); const selectedVersion = ref(''); const availableVersions = ref([]); const loadingVersions = ref(false); +const scrollContainer = ref(null); // 获取当前版本号(从版本信息中提取) async function getCurrentVersion() { @@ -104,6 +105,24 @@ function onVersionChange() { } } +function handleChangelogClick(event) { + const targetElement = event.target instanceof Element ? event.target : null; + const anchor = targetElement?.closest('a[href^="#"]'); + if (!anchor) return; + + const rawHref = anchor.getAttribute('href'); + const targetId = rawHref ? decodeURIComponent(rawHref.slice(1)) : ''; + if (!targetId) return; + + const target = scrollContainer.value?.querySelector( + `#${CSS.escape(targetId)}`, + ); + if (!target) return; + + event.preventDefault(); + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); +} + // 监听对话框打开,初始化数据 watch(dialog, async (newValue) => { if (newValue) { @@ -178,7 +197,11 @@ getCurrentVersion(); -
+
{{ t('core.navigation.changelogDialog.loading') }}
From 690b184a62201b3cb409e9724746d98b9140a027 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:57:36 +0800 Subject: [PATCH 021/113] fix: preserve embedding api version suffixes (#8736) --- .../sources/openai_embedding_source.py | 17 ++++++++++------- tests/test_openai_embedding_source.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 tests/test_openai_embedding_source.py diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index 2be0165bb..126a41fda 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -1,3 +1,5 @@ +import re + import httpx from openai import AsyncOpenAI @@ -8,6 +10,13 @@ from ..provider import EmbeddingProvider from ..register import register_provider_adapter +def _normalize_api_base(api_base: str) -> str: + api_base = api_base.strip().removesuffix("/").removesuffix("/embeddings") + if api_base and not re.search(r"/v\d+$", api_base): + api_base = api_base + "/v1" + return api_base + + @register_provider_adapter( "openai_embedding", "OpenAI API Embedding 提供商适配器", @@ -24,15 +33,9 @@ class OpenAIEmbeddingProvider(EmbeddingProvider): if proxy: logger.info(f"[OpenAI Embedding] {provider_id} Using proxy: {proxy}") http_client = httpx.AsyncClient(proxy=proxy) - api_base = ( + api_base = _normalize_api_base( provider_config.get("embedding_api_base", "https://api.openai.com/v1") - .strip() - .removesuffix("/") - .removesuffix("/embeddings") ) - if api_base and not api_base.endswith("/v1") and not api_base.endswith("/v4"): - # /v4 see #5699 - api_base = api_base + "/v1" logger.info(f"[OpenAI Embedding] {provider_id} Using API Base: {api_base}") self.client = AsyncOpenAI( api_key=provider_config.get("embedding_api_key"), diff --git a/tests/test_openai_embedding_source.py b/tests/test_openai_embedding_source.py new file mode 100644 index 000000000..39a38d945 --- /dev/null +++ b/tests/test_openai_embedding_source.py @@ -0,0 +1,18 @@ +from astrbot.core.provider.sources.openai_embedding_source import _normalize_api_base + + +def test_openai_embedding_api_base_keeps_version_suffixes(): + assert ( + _normalize_api_base("https://ark.cn-beijing.volces.com/api/plan/v3") + == "https://ark.cn-beijing.volces.com/api/plan/v3" + ) + assert _normalize_api_base("https://example.test/v4") == "https://example.test/v4" + + +def test_openai_embedding_api_base_adds_default_version(): + assert _normalize_api_base("https://example.test/openai") == ( + "https://example.test/openai/v1" + ) + assert _normalize_api_base("https://example.test/v1/embeddings") == ( + "https://example.test/v1" + ) From d4e7aa0489a8febe4a12ba6f8b4c24f57b667994 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:12:54 +0800 Subject: [PATCH 022/113] fix: avoid duplicate quoted image captions (#8718) --- astrbot/core/astr_main_agent.py | 111 ++++++++++++++++------------- tests/unit/test_astr_main_agent.py | 71 ++++++++++++++++++ 2 files changed, 134 insertions(+), 48 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index cee6e9e27..b1d7416d8 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -776,6 +776,7 @@ async def _process_quote_message( quoted_message_settings: QuotedMessageParserSettings = DEFAULT_QUOTED_MESSAGE_SETTINGS, config: MainAgentBuildConfig | None = None, main_provider_supports_image: bool = False, + skip_quote_image_caption: bool = False, ) -> None: quote = None for comp in event.message_obj.message: @@ -805,54 +806,63 @@ async def _process_quote_message( image_seg = comp break - if image_seg and main_provider_supports_image: - logger.debug( - "Skipping quote image captioning because the main provider supports image input." - ) - elif image_seg and not img_cap_prov_id: - logger.debug( - "No dedicated image caption provider configured. " - "Skipping quote image captioning." - ) - elif image_seg: - try: - prov = None - path = None - compress_path = None - prov = plugin_context.get_provider_by_id(img_cap_prov_id) - if prov is None: - prov = plugin_context.get_using_provider(event.unified_msg_origin) + if image_seg: + if skip_quote_image_caption: + logger.debug( + "Skipping quote image captioning because image captioning already handled this request." + ) + elif main_provider_supports_image: + logger.debug( + "Skipping quote image captioning because the main provider supports image input." + ) + elif not img_cap_prov_id: + logger.debug( + "No dedicated image caption provider configured. " + "Skipping quote image captioning." + ) + else: + try: + prov = None + path = None + compress_path = None + prov = plugin_context.get_provider_by_id(img_cap_prov_id) + if prov is None: + prov = plugin_context.get_using_provider(event.unified_msg_origin) - if prov and isinstance(prov, Provider): - path = await image_seg.convert_to_file_path() - compress_path = await _compress_image_for_provider( - path, - config.provider_settings if config else None, - ) - if path and _is_generated_compressed_image_path(path, compress_path): - event.track_temporary_local_file(compress_path) - llm_resp = await prov.text_chat( - prompt="Please describe the image content.", - image_urls=[compress_path], - ) - if llm_resp.completion_text: - content_parts.append( - f"[Image Caption in quoted message]: {llm_resp.completion_text}" + if prov and isinstance(prov, Provider): + path = await image_seg.convert_to_file_path() + compress_path = await _compress_image_for_provider( + path, + config.provider_settings if config else None, ) - else: - logger.warning("No provider found for image captioning in quote.") - except BaseException as exc: - logger.error("处理引用图片失败: %s", exc) - finally: - if ( - compress_path - and compress_path != path - and os.path.exists(compress_path) - ): - try: - os.remove(compress_path) - except Exception as exc: # noqa: BLE001 - logger.warning("Fail to remove temporary compressed image: %s", exc) + if path and _is_generated_compressed_image_path( + path, compress_path + ): + event.track_temporary_local_file(compress_path) + llm_resp = await prov.text_chat( + prompt="Please describe the image content.", + image_urls=[compress_path], + ) + if llm_resp.completion_text: + content_parts.append( + f"[Image Caption in quoted message]: {llm_resp.completion_text}" + ) + else: + logger.warning("No provider found for image captioning in quote.") + except BaseException as exc: + logger.error("处理引用图片失败: %s", exc) + finally: + if ( + compress_path + and compress_path != path + and os.path.exists(compress_path) + ): + try: + os.remove(compress_path) + except Exception as exc: # noqa: BLE001 + logger.warning( + "Fail to remove temporary compressed image: %s", exc + ) quoted_content = "\n".join(content_parts) quoted_text = f"\n{quoted_content}\n" @@ -918,11 +928,12 @@ async def _decorate_llm_request( main_provider_supports_image = provider is not None and _provider_supports_modality( provider, "image" ) + img_cap_prov_id: str = cfg.get("default_image_caption_provider_id") or "" + quote_images_already_captioned = False if req.conversation: await _ensure_persona_and_skills(req, cfg, plugin_context, event) - img_cap_prov_id: str = cfg.get("default_image_caption_provider_id") or "" if img_cap_prov_id and req.image_urls and not main_provider_supports_image: await _ensure_img_caption( event, @@ -931,8 +942,11 @@ async def _decorate_llm_request( plugin_context, img_cap_prov_id, ) + quote_images_already_captioned = any( + "" in getattr(part, "text", "") + for part in req.extra_user_content_parts + ) - img_cap_prov_id = cfg.get("default_image_caption_provider_id") or "" quoted_message_settings = _get_quoted_message_parser_settings(cfg) await _process_quote_message( event, @@ -942,6 +956,7 @@ async def _decorate_llm_request( quoted_message_settings, config, main_provider_supports_image=main_provider_supports_image, + skip_quote_image_caption=quote_images_already_captioned, ) tz = config.timezone diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 31c80e09e..e243abbd8 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -1247,6 +1247,77 @@ class TestBuildMainAgent: ) mock_provider.text_chat.assert_not_called() + @pytest.mark.asyncio + async def test_build_main_agent_does_not_caption_quoted_image_twice( + self, mock_event, mock_context + ): + """Quoted images should not be captioned again after request image captioning.""" + module = ama + text_provider = MagicMock(spec=Provider) + text_provider.provider_config = { + "id": "text-provider", + "modalities": ["text", "tool_use"], + } + text_provider.get_model.return_value = "text-model" + + caption_provider = MagicMock(spec=Provider) + caption_provider.text_chat = AsyncMock( + return_value=MagicMock(completion_text="quoted image caption") + ) + + mock_reply = Reply( + id="reply-1", + chain=[Plain(text="quoted text"), Image(file="file:///tmp/quoted.jpg")], + sender_nickname="Alice", + message_str="quoted text", + ) + mock_event.message_obj.message = [Plain(text="Hello"), mock_reply] + + mock_context.get_provider_by_id.return_value = caption_provider + mock_context.get_using_provider.return_value = text_provider + mock_context.get_config.return_value = {} + + conv_mgr = mock_context.conversation_manager + _setup_conversation_for_build(conv_mgr) + + with ( + patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls, + patch("astrbot.core.astr_main_agent.AstrAgentContext"), + patch.object( + Image, + "convert_to_file_path", + AsyncMock(return_value="/tmp/quoted.jpg"), + ), + patch( + "astrbot.core.astr_main_agent._compress_image_for_provider", + AsyncMock(side_effect=lambda path, _settings: path), + ), + ): + mock_runner = MagicMock() + mock_runner.reset = AsyncMock() + mock_runner_cls.return_value = mock_runner + + result = await module.build_main_agent( + event=mock_event, + plugin_context=mock_context, + config=module.MainAgentBuildConfig( + tool_call_timeout=60, + provider_settings={ + "default_image_caption_provider_id": "caption-provider", + }, + ), + provider=text_provider, + ) + + assert result is not None + assert caption_provider.text_chat.await_count == 1 + + extra_text = "\n".join( + part.text for part in result.provider_request.extra_user_content_parts + ) + assert "quoted image caption" in extra_text + assert "[Image Caption in quoted message]" not in extra_text + @pytest.mark.asyncio async def test_build_main_agent_uses_image_fallback_provider( self, mock_event, mock_context From 80af9e0c1df547f2ff785271f7117bd3bbcbb86c Mon Sep 17 00:00:00 2001 From: Rat Date: Sat, 13 Jun 2026 16:36:06 +0800 Subject: [PATCH 023/113] fix cli version source (#8692) Co-authored-by: Rat0323 <261020116+Rat0323@users.noreply.github.com> --- astrbot/cli/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/astrbot/cli/__init__.py b/astrbot/cli/__init__.py index 5e6913fc1..f913b329b 100644 --- a/astrbot/cli/__init__.py +++ b/astrbot/cli/__init__.py @@ -1 +1,3 @@ -__version__ = "4.25.3" +from astrbot.core.config.default import VERSION + +__version__ = VERSION From 26beaaa9382389112c4b7db8d36287ad0a06c9ba Mon Sep 17 00:00:00 2001 From: AnegasakiNene <990126341@qq.com> Date: Sat, 13 Jun 2026 16:39:02 +0800 Subject: [PATCH 024/113] feat(ci): allow conventional PR title prefixes (#8665) * feat(ci): allow conventional PR title prefixes * fix(ci): clarify PR title examples --- .github/workflows/pr-title-check.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index c7d6024b6..e8bb3883e 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -18,10 +18,14 @@ jobs: with: script: | const title = (context.payload.pull_request.title || "").trim(); - // allow only: + // Allow Conventional Commit style PR titles. + // Examples: // feat: xxx // feat(scope): xxx - const pattern = /^(feat)(\([a-z0-9-]+\))?:\s.+$/i; + // fix: xxx + // fix(scope): xxx + const allowedTypes = "feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert"; + const pattern = new RegExp(`^(${allowedTypes})(\\([a-z0-9-]+\\))?:\\s.+$`, "i"); const isValid = pattern.test(title); const isSameRepo = context.payload.pull_request.head.repo.full_name === context.payload.repository.full_name; @@ -38,6 +42,12 @@ jobs: "Required formats:", "- `feat: xxx`", "- `feat(scope): xxx`", + "- `fix: xxx`", + "- `fix(scope): xxx`", + "- `chore: xxx`", + "", + "Allowed prefixes:", + "`feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`", "Please update your PR title and push again." ].join("\n") }); @@ -50,5 +60,5 @@ jobs: } if (!isValid) { - core.setFailed("Invalid PR title. Expected format: feat: xxx or feat(scope): xxx."); + core.setFailed("Invalid PR title. Expected Conventional Commit format, e.g. feat: xxx, feat(scope): xxx, or fix: xxx."); } From d2b86c59917f885e4cffdb37a2f90ec437af2a96 Mon Sep 17 00:00:00 2001 From: lingyun14 Date: Sat, 13 Jun 2026 16:41:54 +0800 Subject: [PATCH 025/113] feat(dashboard): add a configurable theme mode with light, dark, and system options and centralize theme synchronization with system preferences. (#8648) * Update main.ts * Update config.ts * Update header.json * Update auth.json * Update header.json * Update auth.json * Update header.json * Update auth.json * Update VerticalHeader.vue * Update customizer.ts * Update LoginPage.vue * Update SetupPage.vue * Update config.ts * Update customizer.ts * Update plugin-pages.md * docs: add Follow System note to plugin-pages theme section --- dashboard/src/config.ts | 45 +++++-- .../src/i18n/locales/en-US/core/header.json | 4 +- .../src/i18n/locales/en-US/features/auth.json | 8 +- .../src/i18n/locales/ru-RU/core/header.json | 4 +- .../src/i18n/locales/ru-RU/features/auth.json | 6 +- .../src/i18n/locales/zh-CN/core/header.json | 4 +- .../src/i18n/locales/zh-CN/features/auth.json | 6 +- .../full/vertical-header/VerticalHeader.vue | 115 +++++++++++++----- dashboard/src/main.ts | 89 ++++++++------ dashboard/src/stores/customizer.ts | 27 +++- .../views/authentication/auth/LoginPage.vue | 77 ++++++++++-- .../views/authentication/auth/SetupPage.vue | 78 ++++++++++-- docs/en/dev/star/guides/plugin-pages.md | 2 + docs/zh/dev/star/guides/plugin-pages.md | 2 + 14 files changed, 355 insertions(+), 112 deletions(-) diff --git a/dashboard/src/config.ts b/dashboard/src/config.ts index f52812ad8..99d598276 100644 --- a/dashboard/src/config.ts +++ b/dashboard/src/config.ts @@ -1,28 +1,55 @@ +export type ThemeMode = 'light' | 'dark' | 'system'; + export type ConfigProps = { Sidebar_drawer: boolean; Customizer_drawer: boolean; mini_sidebar: boolean; fontTheme: string; uiTheme: string; + themeMode: ThemeMode; inputBg: boolean; }; -function checkUITheme() { - /* 检查localStorage有无记忆的主题选项,如有则使用,否则使用默认值 */ - const theme = localStorage.getItem("uiTheme"); - if (!theme || !(['PurpleTheme', 'PurpleThemeDark'].includes(theme))) { - localStorage.setItem("uiTheme", "PurpleTheme"); // todo: 这部分可以根据vuetify.ts的默认主题动态调整 - return 'PurpleTheme'; - } else return theme; +function checkThemeMode(): ThemeMode { + const mode = localStorage.getItem('themeMode') as ThemeMode | null; + if (mode === 'light' || mode === 'dark' || mode === 'system') return mode; + + const legacyTheme = localStorage.getItem('uiTheme'); + if (legacyTheme === 'PurpleThemeDark') { + localStorage.setItem('themeMode', 'dark'); + return 'dark'; + } + if (legacyTheme === 'PurpleTheme') { + localStorage.setItem('themeMode', 'light'); + return 'light'; + } + + localStorage.setItem('themeMode', 'system'); + return 'system'; } +export function resolveUiTheme(mode: ThemeMode): string { + if (mode === 'dark') return 'PurpleThemeDark'; + if (mode === 'light') return 'PurpleTheme'; + const prefersDark = + typeof window !== 'undefined' && + window.matchMedia('(prefers-color-scheme: dark)').matches; + return prefersDark ? 'PurpleThemeDark' : 'PurpleTheme'; +} + +const themeMode = checkThemeMode(); +const uiTheme = resolveUiTheme(themeMode); + +localStorage.setItem('uiTheme', uiTheme); + const config: ConfigProps = { Sidebar_drawer: true, Customizer_drawer: false, mini_sidebar: false, fontTheme: 'Roboto', - uiTheme: checkUITheme(), - inputBg: false + uiTheme, + themeMode, + inputBg: false, }; export default config; diff --git a/dashboard/src/i18n/locales/en-US/core/header.json b/dashboard/src/i18n/locales/en-US/core/header.json index d83f06152..082bdd7e1 100644 --- a/dashboard/src/i18n/locales/en-US/core/header.json +++ b/dashboard/src/i18n/locales/en-US/core/header.json @@ -8,8 +8,10 @@ "update": "Update", "account": "Account", "theme": { + "title": "Theme", "light": "Light Mode", - "dark": "Dark Mode" + "dark": "Dark Mode", + "system": "Follow System" } }, "updateDialog": { diff --git a/dashboard/src/i18n/locales/en-US/features/auth.json b/dashboard/src/i18n/locales/en-US/features/auth.json index 526c080be..f85cc81e9 100644 --- a/dashboard/src/i18n/locales/en-US/features/auth.json +++ b/dashboard/src/i18n/locales/en-US/features/auth.json @@ -57,7 +57,11 @@ "subtitle": "Welcome" }, "theme": { + "light": "Light Mode", + "dark": "Dark Mode", + "system": "Follow System", "switchToDark": "Switch to Dark Theme", - "switchToLight": "Switch to Light Theme" + "switchToLight": "Switch to Light Theme", + "title": "Theme" } -} +} diff --git a/dashboard/src/i18n/locales/ru-RU/core/header.json b/dashboard/src/i18n/locales/ru-RU/core/header.json index 3124cd971..a851897df 100644 --- a/dashboard/src/i18n/locales/ru-RU/core/header.json +++ b/dashboard/src/i18n/locales/ru-RU/core/header.json @@ -8,8 +8,10 @@ "update": "Обновить", "account": "Аккаунт", "theme": { + "title": "Тема", "light": "Светлая тема", - "dark": "Темная тема" + "dark": "Темная тема", + "system": "Как в системе" } }, "updateDialog": { diff --git a/dashboard/src/i18n/locales/ru-RU/features/auth.json b/dashboard/src/i18n/locales/ru-RU/features/auth.json index 97f2a71f7..9c5f9f638 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/auth.json +++ b/dashboard/src/i18n/locales/ru-RU/features/auth.json @@ -57,7 +57,11 @@ "subtitle": "Добро пожаловать" }, "theme": { + "light": "Светлая тема", + "dark": "Темная тема", + "system": "Как в системе", "switchToDark": "Перейти на темную тему", - "switchToLight": "Перейти на светлую тему" + "switchToLight": "Перейти на светлую тему", + "title": "Тема" } } diff --git a/dashboard/src/i18n/locales/zh-CN/core/header.json b/dashboard/src/i18n/locales/zh-CN/core/header.json index 8897ad0af..611213b78 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/header.json +++ b/dashboard/src/i18n/locales/zh-CN/core/header.json @@ -8,8 +8,10 @@ "update": "更新", "account": "账户", "theme": { + "title": "主题", "light": "浅色模式", - "dark": "深色模式" + "dark": "深色模式", + "system": "跟随系统" } }, "updateDialog": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/auth.json b/dashboard/src/i18n/locales/zh-CN/features/auth.json index ad886f105..163143713 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/auth.json +++ b/dashboard/src/i18n/locales/zh-CN/features/auth.json @@ -57,7 +57,11 @@ "subtitle": "欢迎使用" }, "theme": { + "light": "浅色模式", + "dark": "深色模式", + "system": "跟随系统", "switchToDark": "切换到深色主题", - "switchToLight": "切换到浅色主题" + "switchToLight": "切换到浅色主题", + "title": "主题" } } diff --git a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue index c41b566e7..c34f2c426 100644 --- a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue +++ b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue @@ -725,13 +725,16 @@ function updateDashboard() { }); } -function toggleDarkMode() { - const newTheme = - customizer.uiTheme === "PurpleThemeDark" - ? "PurpleTheme" - : "PurpleThemeDark"; - customizer.SET_UI_THEME(newTheme); - theme.global.name.value = newTheme; +// 主题选项配置 +const themeOptions = [ + { mode: 'light' as const, icon: 'mdi-white-balance-sunny', labelKey: 'core.header.buttons.theme.light' }, + { mode: 'dark' as const, icon: 'mdi-weather-night', labelKey: 'core.header.buttons.theme.dark' }, + { mode: 'system' as const, icon: 'mdi-sync', labelKey: 'core.header.buttons.theme.system' }, +] as const; + +function setThemeMode(mode: 'light' | 'dark' | 'system') { + customizer.SET_THEME_MODE(mode); + theme.global.name.value = customizer.uiTheme; } function openReleaseNotesDialog(body: string, tag: string) { @@ -1077,29 +1080,68 @@ onMounted(async () => { - - + -