From c55f2546e218c0d2071d0bc57d354b376dd77041 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:51:44 +0800 Subject: [PATCH 01/11] feat(skills): enhance skill installation to support multiple top-level folders and add duplicate handling, and Chinese skill name support (#6952) * feat(skills): enhance skill installation to support multiple top-level folders and add duplicate handling closes: #6949 * refactor(skill_manager): streamline skill name normalization and validation logic * fix(skill_manager): update skill name regex to allow underscores in skill names * fix(skill_manager): improve skill name normalization and validation logic --- astrbot/core/skills/skill_manager.py | 87 +++++++++++++---- astrbot/dashboard/routes/skills.py | 96 ++++++++++++++++--- .../components/extension/SkillsSection.vue | 9 ++ .../locales/en-US/features/extension.json | 2 +- .../locales/ru-RU/features/extension.json | 2 +- .../locales/zh-CN/features/extension.json | 2 +- 6 files changed, 166 insertions(+), 32 deletions(-) diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index ec3ba8f03..1b71e5371 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -27,7 +27,12 @@ SANDBOX_SKILLS_ROOT = "skills" SANDBOX_WORKSPACE_ROOT = "/workspace" _SANDBOX_SKILLS_CACHE_VERSION = 1 -_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") +_SKILL_NAME_RE = re.compile(r"^[\w.-]+$") + + +def _normalize_skill_name(name: str | None) -> str: + raw = str(name or "") + return re.sub(r"\s+", "_", raw.strip()) def _default_sandbox_skill_path(name: str) -> str: @@ -530,7 +535,13 @@ class SkillManager: config["skills"].pop(name, None) self._save_config(config) - def install_skill_from_zip(self, zip_path: str, *, overwrite: bool = True) -> str: + def install_skill_from_zip( + self, + zip_path: str, + *, + overwrite: bool = True, + skill_name_hint: str | None = None, + ) -> str: zip_path_obj = Path(zip_path) if not zip_path_obj.exists(): raise FileNotFoundError(f"Zip file not found: {zip_path}") @@ -547,15 +558,48 @@ class SkillManager: if not file_names: raise ValueError("Zip archive is empty.") - top_dirs = { - PurePosixPath(name).parts[0] for name in file_names if name.strip() - } + has_root_skill_md = any( + len(parts := PurePosixPath(name).parts) == 1 + and parts[0] in {"SKILL.md", "skill.md"} + for name in file_names + ) + root_mode = has_root_skill_md - if len(top_dirs) != 1: - raise ValueError("Zip archive must contain a single top-level folder.") - skill_name = next(iter(top_dirs)) - if skill_name in {".", "..", ""} or not _SKILL_NAME_RE.match(skill_name): - raise ValueError("Invalid skill folder name.") + archive_skill_name = None + if skill_name_hint is not None: + archive_skill_name = _normalize_skill_name(skill_name_hint) + if archive_skill_name and not _SKILL_NAME_RE.fullmatch( + archive_skill_name + ): + raise ValueError("Invalid skill name.") + + if root_mode: + archive_hint = _normalize_skill_name( + archive_skill_name or zip_path_obj.stem + ) + if not archive_hint or not _SKILL_NAME_RE.fullmatch(archive_hint): + raise ValueError("Invalid skill name.") + skill_name = archive_hint + else: + top_dirs = { + PurePosixPath(name).parts[0] for name in file_names if name.strip() + } + if len(top_dirs) != 1: + raise ValueError( + "Zip archive must contain a single top-level folder." + ) + archive_root_name = next(iter(top_dirs)) + archive_root_name_normalized = _normalize_skill_name(archive_root_name) + if archive_root_name in {".", "..", ""} or not _SKILL_NAME_RE.fullmatch( + archive_root_name_normalized + ): + raise ValueError("Invalid skill folder name.") + if archive_skill_name: + if not _SKILL_NAME_RE.fullmatch(archive_skill_name): + raise ValueError("Invalid skill name.") + skill_name = archive_skill_name + else: + skill_name = archive_root_name_normalized for name in names: if not name: @@ -565,16 +609,20 @@ class SkillManager: parts = PurePosixPath(name).parts if ".." in parts: raise ValueError("Zip archive contains invalid relative paths.") - if parts and parts[0] != skill_name: + if (not root_mode) and parts and parts[0] != archive_root_name: raise ValueError( "Zip archive contains unexpected top-level entries." ) - if ( - f"{skill_name}/SKILL.md" not in file_names - and f"{skill_name}/skill.md" not in file_names - ): - raise ValueError("SKILL.md not found in the skill folder.") + if root_mode: + if "SKILL.md" not in file_names and "skill.md" not in file_names: + raise ValueError("SKILL.md not found in the skill folder.") + else: + if ( + f"{archive_root_name}/SKILL.md" not in file_names + and f"{archive_root_name}/skill.md" not in file_names + ): + raise ValueError("SKILL.md not found in the skill folder.") with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir: for member in zf.infolist(): @@ -582,7 +630,12 @@ class SkillManager: if not member_name or _is_ignored_zip_entry(member_name): continue zf.extract(member, tmp_dir) - src_dir = Path(tmp_dir) / skill_name + src_dir = ( + Path(tmp_dir) if root_mode else Path(tmp_dir) / archive_root_name + ) + normalized_path = _normalize_skill_markdown_path(src_dir) + if normalized_path is None: + raise ValueError("SKILL.md not found in the skill folder.") _normalize_skill_markdown_path(src_dir) if not src_dir.exists(): raise ValueError("Skill folder not found after extraction.") diff --git a/astrbot/dashboard/routes/skills.py b/astrbot/dashboard/routes/skills.py index 42ba7fd80..abae13e33 100644 --- a/astrbot/dashboard/routes/skills.py +++ b/astrbot/dashboard/routes/skills.py @@ -2,7 +2,6 @@ import os import re import shutil import traceback -import uuid from collections.abc import Awaitable, Callable from pathlib import Path from typing import Any @@ -44,6 +43,17 @@ def _to_bool(value: Any, default: bool = False) -> bool: _SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") +def _next_available_temp_path(temp_dir: str, filename: str) -> str: + stem = Path(filename).stem + suffix = Path(filename).suffix + candidate = filename + index = 1 + while os.path.exists(os.path.join(temp_dir, candidate)): + candidate = f"{stem}_{index}{suffix}" + index += 1 + return os.path.join(temp_dir, candidate) + + class SkillsRoute(Route): def __init__(self, context: RouteContext, core_lifecycle) -> None: super().__init__(context) @@ -164,11 +174,24 @@ class SkillsRoute(Route): temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) - temp_path = os.path.join(temp_dir, filename) + skill_mgr = SkillManager() + temp_path = _next_available_temp_path(temp_dir, filename) await file.save(temp_path) - skill_mgr = SkillManager() - skill_name = skill_mgr.install_skill_from_zip(temp_path, overwrite=True) + try: + try: + skill_name = skill_mgr.install_skill_from_zip( + temp_path, overwrite=False, skill_name_hint=Path(filename).stem + ) + except TypeError: + # Backward compatibility for callers that do not accept skill_name_hint + skill_name = skill_mgr.install_skill_from_zip( + temp_path, overwrite=False + ) + except Exception: + # Keep behavior consistent with previous implementation + # and bubble up install errors (including duplicates). + raise try: await sync_skills_to_active_sandboxes() @@ -208,6 +231,7 @@ class SkillsRoute(Route): succeeded = [] failed = [] + skipped = [] skill_mgr = SkillManager() temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) @@ -226,14 +250,42 @@ class SkillsRoute(Route): ) continue - temp_path = os.path.join( - temp_dir, f"batch_{uuid.uuid4().hex}_{filename}" - ) + temp_path = _next_available_temp_path(temp_dir, filename) await file.save(temp_path) - skill_name = skill_mgr.install_skill_from_zip( - temp_path, overwrite=True - ) + try: + skill_name = skill_mgr.install_skill_from_zip( + temp_path, + overwrite=False, + skill_name_hint=Path(filename).stem, + ) + except TypeError: + # Backward compatibility for monkeypatched implementations in tests + try: + skill_name = skill_mgr.install_skill_from_zip( + temp_path, overwrite=False + ) + except FileExistsError: + skipped.append( + { + "filename": filename, + "name": Path(filename).stem, + "error": "Skill already exists.", + } + ) + skill_name = None + except FileExistsError: + skipped.append( + { + "filename": filename, + "name": Path(filename).stem, + "error": "Skill already exists.", + } + ) + skill_name = None + + if skill_name is None: + continue succeeded.append({"filename": filename, "name": skill_name}) except Exception as e: @@ -255,8 +307,10 @@ class SkillsRoute(Route): total = len(file_list) success_count = len(succeeded) + skipped_count = len(skipped) + failed_count = len(failed) - if success_count == total: + if failed_count == 0 and success_count == total: message = f"All {total} skill(s) uploaded successfully." return ( Response() @@ -265,18 +319,35 @@ class SkillsRoute(Route): "total": total, "succeeded": succeeded, "failed": failed, + "skipped": skipped, }, message, ) .__dict__ ) - if success_count == 0: + if failed_count == 0 and success_count == 0: + message = f"All {total} file(s) were skipped." + return ( + Response() + .ok( + { + "total": total, + "succeeded": succeeded, + "failed": failed, + "skipped": skipped, + }, + message, + ) + .__dict__ + ) + if success_count == 0 and skipped_count == 0: message = f"Upload failed for all {total} file(s)." resp = Response().error(message) resp.data = { "total": total, "succeeded": succeeded, "failed": failed, + "skipped": skipped, } return resp.__dict__ @@ -288,6 +359,7 @@ class SkillsRoute(Route): "total": total, "succeeded": succeeded, "failed": failed, + "skipped": skipped, }, message, ) diff --git a/dashboard/src/components/extension/SkillsSection.vue b/dashboard/src/components/extension/SkillsSection.vue index b46b4e296..33d5177bf 100644 --- a/dashboard/src/components/extension/SkillsSection.vue +++ b/dashboard/src/components/extension/SkillsSection.vue @@ -900,6 +900,7 @@ export default { const applyUploadResults = (attemptedItems, payload) => { const succeededMap = buildResultMap(payload?.succeeded); const failedMap = buildResultMap(payload?.failed); + const skippedMap = buildResultMap(payload?.skipped); for (const item of attemptedItems) { const successEntry = takeFirstMatch(succeededMap, item.filenameKey); @@ -911,6 +912,14 @@ export default { continue; } + const skippedEntry = takeFirstMatch(skippedMap, item.filenameKey); + if (skippedEntry) { + item.status = STATUS_SKIPPED; + item.validationMessage = + skippedEntry.error || tm("skills.validationDuplicate"); + continue; + } + const failedEntry = takeFirstMatch(failedMap, item.filenameKey); if (failedEntry) { item.status = STATUS_ERROR; diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json index 9ad049db5..233f28dd5 100644 --- a/dashboard/src/i18n/locales/en-US/features/extension.json +++ b/dashboard/src/i18n/locales/en-US/features/extension.json @@ -242,7 +242,7 @@ "emptyHint": "Upload a Skills zip to get started", "uploadDialogTitle": "Upload Skills", "uploadHint": "Upload multiple zip skill packages or drag them in. The system validates the structure automatically and shows a result for each file.", - "structureRequirement": "The most common failure is an invalid archive structure. Each zip must contain exactly one top-level folder such as `skillname/`, and that folder must include `SKILL.md`.", + "structureRequirement": "The archive supports multiple skills folders.", "abilityMultiple": "Upload multiple zip files at once", "abilityValidate": "Validate `SKILL.md` automatically", "abilitySkip": "Automatically skip duplicate files.", diff --git a/dashboard/src/i18n/locales/ru-RU/features/extension.json b/dashboard/src/i18n/locales/ru-RU/features/extension.json index 7d3e0602f..b51d0cf78 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/extension.json +++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json @@ -241,7 +241,7 @@ "emptyHint": "Пожалуйста, загрузите архив с навыками", "uploadDialogTitle": "Загрузка навыков", "uploadHint": "Поддерживается массовая загрузка zip-архивов. Вы также можете перетащить файлы в это окно. Система автоматически проверит структуру каждого архива.", - "structureRequirement": "Архив должен содержать одну корневую папку (например, `skillname/`), внутри которой обязательно должен находиться файл `SKILL.md`.", + "structureRequirement": "Поддерживаются архивы с несколькими папками skills.", "abilityMultiple": "Поддержка массовой загрузки", "abilityValidate": "Автопроверка `SKILL.md`", "abilitySkip": "Пропуск дубликатов", diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json index 6f38591f4..04eaa8bfa 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/extension.json +++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json @@ -245,7 +245,7 @@ "emptyHint": "请上传 Skills 压缩包", "uploadDialogTitle": "上传 Skills", "uploadHint": "支持批量上传 zip 技能包,也支持拖拽批量上传 zip 技能包。系统会自动校验目录结构,并给出逐个文件的结果。", - "structureRequirement": "常见失败原因是压缩包结构不正确。每个 zip 必须只包含一个顶层目录,例如 `skillname/`,且该目录下必须存在 `SKILL.md`。", + "structureRequirement": "支持压缩包内含多个 skills 文件夹。", "abilityMultiple": "支持一次上传多个zip文件", "abilityValidate": "自动校验 `SKILL.md`", "abilitySkip": "自动跳过重复文件", From b2718b07b68bf19846d1371df8a951fcfda8a3c3 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 26 Mar 2026 00:03:23 +0800 Subject: [PATCH 02/11] chore: bump version to 4.22.1 --- astrbot/cli/__init__.py | 2 +- astrbot/core/config/default.py | 2 +- changelogs/v4.22.1.md | 49 ++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 4 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 changelogs/v4.22.1.md diff --git a/astrbot/cli/__init__.py b/astrbot/cli/__init__.py index 322b33f92..f9ee51140 100644 --- a/astrbot/cli/__init__.py +++ b/astrbot/cli/__init__.py @@ -1 +1 @@ -__version__ = "4.22.0" +__version__ = "4.22.1" diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 0f43dbd06..246914ee0 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -5,7 +5,7 @@ from typing import Any, TypedDict from astrbot.core.utils.astrbot_path import get_astrbot_data_path -VERSION = "4.22.0" +VERSION = "4.22.1" 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.22.1.md b/changelogs/v4.22.1.md new file mode 100644 index 000000000..ee9807c2a --- /dev/null +++ b/changelogs/v4.22.1.md @@ -0,0 +1,49 @@ +## What's Changed + +### 新增 + +- 增强 Skills 安装流程,不再限制上传的压缩包顶级必须是一个目录。并支持中文技能名称显示。([#6952](https://github.com/AstrBotDevs/AstrBot/pull/6952)) +- OpenAI Embedding 模型配置支持自动补齐 `/v1` 基础路径。([#6863](https://github.com/AstrBotDevs/AstrBot/pull/6863)) +- 为 `/api/file` 新增 GET 端点并支持多种请求方式。([#6874](https://github.com/AstrBotDevs/AstrBot/pull/6874)) +- WebUI 设置页新增日志与缓存清理能力。([#6822](https://github.com/AstrBotDevs/AstrBot/pull/6822)) +- Lark 平台新增可折叠 Thinking 面板能力与消息处理优化。([#6831](https://github.com/AstrBotDevs/AstrBot/pull/6831)) + +### 修复 + +- 修复 QQ 官方机器人中,在 Cron Job 或者主动发送消息时的 `msg_id` 相关负载处理问题。([#6604](https://github.com/AstrBotDevs/AstrBot/pull/6604)) +- 修复 个人微信 在轮询超时后停止轮询的问题。([#6915](https://github.com/AstrBotDevs/AstrBot/pull/6915)) +- 修复 硅基流动 提供商无法正确使用工具调用能力的问题。([#6829](https://github.com/AstrBotDevs/AstrBot/pull/6829)) +- 修复部分提供商工具调用流式增量返回缺少 index 导致的异常。([#6661](https://github.com/AstrBotDevs/AstrBot/pull/6661) +- 修复 WebUI 中 `ObjectEditor` 的 `updateKey` 错误索引导致的“键已存在”误判。([#6825](https://github.com/AstrBotDevs/AstrBot/pull/6825)) +- 修复 UI 图标集合及测试一致性导致的展示异常。([#6894](https://github.com/AstrBotDevs/AstrBot/pull/6894)、[#6892](https://github.com/AstrBotDevs/AstrBot/pull/6892)) +- 修复 T2I 配置间未同步生效模板的问题。([#6824](https://github.com/AstrBotDevs/AstrBot/pull/6824)) +- 修复 MIMO TTS 样式参数以对齐官方文档约定。([#6814](https://github.com/AstrBotDevs/AstrBot/pull/6814)) + +## What's Changed (EN) + +### New Features + +- Enhanced skill installation to support multiple top-level folders, duplicate handling, and Chinese skill names.([#6952](https://github.com/AstrBotDevs/AstrBot/pull/6952)) +- Automatically append `/v1` to `embedding_api_base` for OpenAI embedding compatibility.([#6863](https://github.com/AstrBotDevs/AstrBot/pull/6863)) +- Added plugin author display and pinned plugin card support in WebUI.([#6875](https://github.com/AstrBotDevs/AstrBot/pull/6875)) +- Added GET endpoint for `/api/file` and support for multiple HTTP methods.([#6874](https://github.com/AstrBotDevs/AstrBot/pull/6874)) +- Added log and cache cleanup in Dashboard settings.([#6822](https://github.com/AstrBotDevs/AstrBot/pull/6822)) +- Added collapsible reasoning panel and message handling improvements for Lark.([#6831](https://github.com/AstrBotDevs/AstrBot/pull/6831)) + +### Improvements + +- Validate `config_path` before existence checks to avoid false negatives.([#6722](https://github.com/AstrBotDevs/AstrBot/pull/6722)) +- Improved Provider batch reset behavior and "follow" configuration handling in WebUI.([#6825](https://github.com/AstrBotDevs/AstrBot/pull/6825)) +- Updated OpenAI-related guidance in i18n docs for clearer compatibility hints.([adc252a3](https://github.com/AstrBotDevs/AstrBot/commit/adc252a3a2f9f6a4b3fcf6f7d5f4c7d5b9d9a1)) + +### Bug Fixes + +- Fixed missing index field in streaming `tool_call` deltas.([#6661](https://github.com/AstrBotDevs/AstrBot/pull/6661)) +- Fixed `msg_id` payload handling for QQ API.([#6604](https://github.com/AstrBotDevs/AstrBot/pull/6604)) +- Kept Weixin OC polling active after inbound timeout.([#6915](https://github.com/AstrBotDevs/AstrBot/pull/6915)) +- Fixed `updateKey` index bug in WebUI `ObjectEditor` that caused false “key exists” errors.([#6825](https://github.com/AstrBotDevs/AstrBot/pull/6825)) +- Fixed icon regressions in UI and related icon scan tests.([#6894](https://github.com/AstrBotDevs/AstrBot/pull/6894)、[#6892](https://github.com/AstrBotDevs/AstrBot/pull/6892)) +- Fixed SiliconFlow provider tools compatibility issue.([#6829](https://github.com/AstrBotDevs/AstrBot/pull/6829)) +- Synchronized active T2I template across all configs.([#6824](https://github.com/AstrBotDevs/AstrBot/pull/6824)) +- Aligned MIMO TTS payload style with official docs.([#6814](https://github.com/AstrBotDevs/AstrBot/pull/6814)) +- Removed privacy-sensitive data left in tests.([#6803](https://github.com/AstrBotDevs/AstrBot/pull/6803)) diff --git a/pyproject.toml b/pyproject.toml index 5bad7e323..2f12b7597 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "AstrBot" -version = "4.22.0" +version = "4.22.1" description = "Easy-to-use multi-platform LLM chatbot and development framework" readme = "README.md" requires-python = ">=3.12" From 2b5d86b35c6c08e25cdfacc02c0b4b10ae42b368 Mon Sep 17 00:00:00 2001 From: Rainor_da! <51012640+1zzxy1@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:32:19 +0800 Subject: [PATCH 03/11] fix: honor computer_use_require_admin in shipyard_neo tools (#6951) --- astrbot/core/computer/tools/browser.py | 16 +--- astrbot/core/computer/tools/neo_skills.py | 11 +-- tests/test_computer_tool_permissions.py | 98 +++++++++++++++++++++++ tests/test_neo_skill_tools.py | 17 +++- 4 files changed, 120 insertions(+), 22 deletions(-) create mode 100644 tests/test_computer_tool_permissions.py diff --git a/astrbot/core/computer/tools/browser.py b/astrbot/core/computer/tools/browser.py index 70061ac31..cd8484acb 100644 --- a/astrbot/core/computer/tools/browser.py +++ b/astrbot/core/computer/tools/browser.py @@ -8,21 +8,13 @@ from astrbot.core.agent.tool import ToolExecResult from astrbot.core.astr_agent_context import AstrAgentContext from ..computer_client import get_booter +from .permissions import check_admin_permission def _to_json(data: Any) -> str: return json.dumps(data, ensure_ascii=False, default=str) -def _ensure_admin(context: ContextWrapper[AstrAgentContext]) -> str | None: - if context.context.event.role != "admin": - return ( - "error: Permission denied. Browser and skill lifecycle tools are only allowed " - "for admin users." - ) - return None - - async def _get_browser_component(context: ContextWrapper[AstrAgentContext]) -> Any: booter = await get_booter( context.context.context, @@ -77,7 +69,7 @@ class BrowserExecTool(FunctionTool): learn: bool = False, include_trace: bool = False, ) -> ToolExecResult: - if err := _ensure_admin(context): + if err := check_admin_permission(context, "Using browser tools"): return err try: browser = await _get_browser_component(context) @@ -140,7 +132,7 @@ class BrowserBatchExecTool(FunctionTool): learn: bool = False, include_trace: bool = False, ) -> ToolExecResult: - if err := _ensure_admin(context): + if err := check_admin_permission(context, "Using browser tools"): return err try: browser = await _get_browser_component(context) @@ -187,7 +179,7 @@ class RunBrowserSkillTool(FunctionTool): description: str | None = None, tags: str | None = None, ) -> ToolExecResult: - if err := _ensure_admin(context): + if err := check_admin_permission(context, "Using browser tools"): return err try: browser = await _get_browser_component(context) diff --git a/astrbot/core/computer/tools/neo_skills.py b/astrbot/core/computer/tools/neo_skills.py index e60648144..327f14472 100644 --- a/astrbot/core/computer/tools/neo_skills.py +++ b/astrbot/core/computer/tools/neo_skills.py @@ -10,6 +10,7 @@ from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager from ..computer_client import get_booter +from .permissions import check_admin_permission def _to_jsonable(model_like: Any) -> Any: @@ -26,12 +27,6 @@ def _to_json_text(data: Any) -> str: return json.dumps(_to_jsonable(data), ensure_ascii=False, default=str) -def _ensure_admin(context: ContextWrapper[AstrAgentContext]) -> str | None: - if context.context.event.role != "admin": - return "error: Permission denied. Skill lifecycle tools are only allowed for admin users." - return None - - async def _get_neo_context( context: ContextWrapper[AstrAgentContext], ) -> tuple[Any, Any]: @@ -59,7 +54,7 @@ class NeoSkillToolBase(FunctionTool): neo_call: Callable[[Any, Any], Awaitable[Any]], error_action: str, ) -> ToolExecResult: - if err := _ensure_admin(context): + if err := check_admin_permission(context, "Using skill lifecycle tools"): return err try: client, sandbox = await _get_neo_context(context) @@ -392,7 +387,7 @@ class PromoteSkillCandidateTool(NeoSkillToolBase): stage: str = "canary", sync_to_local: bool = True, ) -> ToolExecResult: - if err := _ensure_admin(context): + if err := check_admin_permission(context, "Using skill lifecycle tools"): return err if stage not in {"canary", "stable"}: return "Error promoting skill candidate: stage must be canary or stable." diff --git a/tests/test_computer_tool_permissions.py b/tests/test_computer_tool_permissions.py new file mode 100644 index 000000000..669cd6b8a --- /dev/null +++ b/tests/test_computer_tool_permissions.py @@ -0,0 +1,98 @@ +import json +from types import SimpleNamespace + +import pytest + +from astrbot.core.agent.run_context import ContextWrapper +from astrbot.core.computer.tools.browser import BrowserExecTool +from astrbot.core.computer.tools.neo_skills import GetExecutionHistoryTool + + +class _FakeBrowser: + async def exec(self, **kwargs): + return { + "ok": True, + "cmd": kwargs["cmd"], + } + + +class _FakeSandbox: + async def get_execution_history(self, **kwargs): + return { + "items": [], + "limit": kwargs["limit"], + } + + +def _make_run_context(require_admin: bool, role: str = "member") -> ContextWrapper: + config_holder = SimpleNamespace( + get_config=lambda umo: { # noqa: ARG005 + "provider_settings": { + "computer_use_require_admin": require_admin, + } + } + ) + event = SimpleNamespace( + role=role, + unified_msg_origin="qq_official:friend:user-1", + get_sender_id=lambda: "user-1", + ) + astr_ctx = SimpleNamespace(context=config_holder, event=event) + return ContextWrapper(context=astr_ctx) + + +@pytest.mark.asyncio +async def test_browser_tool_allows_non_admin_when_admin_requirement_disabled( + monkeypatch, +): + async def _fake_get_booter(_ctx, _session_id): + return SimpleNamespace(browser=_FakeBrowser()) + + monkeypatch.setattr( + "astrbot.core.computer.tools.browser.get_booter", + _fake_get_booter, + ) + + result = await BrowserExecTool().call( + _make_run_context(require_admin=False), + cmd="open https://example.com", + ) + + assert json.loads(result)["ok"] is True + + +@pytest.mark.asyncio +async def test_neo_skill_tool_allows_non_admin_when_admin_requirement_disabled( + monkeypatch, +): + async def _fake_get_booter(_ctx, _session_id): + return SimpleNamespace( + bay_client=object(), + sandbox=_FakeSandbox(), + ) + + monkeypatch.setattr( + "astrbot.core.computer.tools.neo_skills.get_booter", + _fake_get_booter, + ) + + result = await GetExecutionHistoryTool().call( + _make_run_context(require_admin=False), + limit=5, + ) + + payload = json.loads(result) + assert payload["items"] == [] + assert payload["limit"] == 5 + + +@pytest.mark.asyncio +async def test_browser_tool_still_denies_non_admin_when_admin_requirement_enabled(): + result = await BrowserExecTool().call( + _make_run_context(require_admin=True), + cmd="open https://example.com", + ) + + assert "Permission denied" in result + assert "Using browser tools is only allowed for admin users" in result + assert "User's ID is: user-1" in result diff --git a/tests/test_neo_skill_tools.py b/tests/test_neo_skill_tools.py index 3c147bb3b..218ada57b 100644 --- a/tests/test_neo_skill_tools.py +++ b/tests/test_neo_skill_tools.py @@ -54,8 +54,21 @@ def test_promote_stable_sync_failure_auto_rolls_back(monkeypatch): _fake_sync_release, ) - event = SimpleNamespace(role="admin", unified_msg_origin="session-1") - astr_ctx = SimpleNamespace(context=SimpleNamespace(), event=event) + event = SimpleNamespace( + role="admin", + unified_msg_origin="session-1", + get_sender_id=lambda: "admin-user", + ) + astr_ctx = SimpleNamespace( + context=SimpleNamespace( + get_config=lambda umo: { # noqa: ARG005 + "provider_settings": { + "computer_use_require_admin": True, + } + } + ), + event=event, + ) run_ctx = ContextWrapper(context=astr_ctx) tool = PromoteSkillCandidateTool() From c1fa05e18fe726aea8c26032319b600de6bd0331 Mon Sep 17 00:00:00 2001 From: Ruochen Pan Date: Thu, 26 Mar 2026 09:37:52 +0800 Subject: [PATCH 04/11] fix(dashboard): include missing vuetify mdi icons (#6970) Export the required icon set and expand it with icons used by Vuetify internals that are not detected by static source scans. Regenerate the MDI subset assets and update tests to assert that all required icons are always included and deduplicated. --- dashboard/scripts/subset-mdi-font.mjs | 35 ++++++++++++- .../mdi-subset/materialdesignicons-subset.css | 46 +++++++++++++++++- .../materialdesignicons-webfont-subset.woff | Bin 16704 -> 17152 bytes .../materialdesignicons-webfont-subset.woff2 | Bin 13416 -> 13768 bytes dashboard/tests/subsetMdiFont.test.mjs | 29 ++++++----- 5 files changed, 97 insertions(+), 13 deletions(-) diff --git a/dashboard/scripts/subset-mdi-font.mjs b/dashboard/scripts/subset-mdi-font.mjs index 1eec374e8..6dd4f578e 100644 --- a/dashboard/scripts/subset-mdi-font.mjs +++ b/dashboard/scripts/subset-mdi-font.mjs @@ -34,9 +34,42 @@ const UTILITY_CLASSES = new Set([ ]); // Icons used indirectly by Vuetify internals, so they won't appear in src/ static scans. -const REQUIRED_ICONS = new Set([ +export const REQUIRED_ICONS = new Set([ "mdi-radiobox-blank", "mdi-radiobox-marked", + "mdi-menu-down", + "mdi-menu-right", + "mdi-check-circle", + "mdi-information", + "mdi-alert-circle", + "mdi-close-circle", + "mdi-chevron-down", + "mdi-chevron-up", + "mdi-chevron-left", + "mdi-chevron-right", + "mdi-check", + "mdi-close", + "mdi-checkbox-marked", + "mdi-checkbox-blank-outline", + "mdi-minus-box", + "mdi-circle", + "mdi-arrow-up", + "mdi-arrow-down", + "mdi-menu", + "mdi-pencil", + "mdi-star-outline", + "mdi-star", + "mdi-star-half-full", + "mdi-cached", + "mdi-page-first", + "mdi-page-last", + "mdi-unfold-more-horizontal", + "mdi-paperclip", + "mdi-plus", + "mdi-minus", + "mdi-calendar", + "mdi-eyedropper", + "mdi-cloud-upload", ]); // Regex to match individual icon class definitions in MDI CSS diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index 787e510a5..c7121fde4 100644 --- a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css +++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css @@ -1,4 +1,4 @@ -/* Auto-generated MDI subset – 237 icons */ +/* Auto-generated MDI subset – 248 icons */ /* Do not edit manually. Run: pnpm run subset-icons */ @font-face { @@ -120,6 +120,10 @@ content: "\F00E4"; } +.mdi-cached::before { + content: "\F00E8"; +} + .mdi-calendar::before { content: "\F00ED"; } @@ -364,6 +368,10 @@ content: "\F06D0"; } +.mdi-eyedropper::before { + content: "\F020A"; +} + .mdi-file::before { content: "\F0214"; } @@ -620,6 +628,14 @@ content: "\F035C"; } +.mdi-menu-down::before { + content: "\F035D"; +} + +.mdi-menu-right::before { + content: "\F035F"; +} + .mdi-message-off-outline::before { content: "\F164E"; } @@ -644,6 +660,10 @@ content: "\F0374"; } +.mdi-minus-box::before { + content: "\F0375"; +} + .mdi-note-text-outline::before { content: "\F11D7"; } @@ -676,6 +696,18 @@ content: "\F03D6"; } +.mdi-page-first::before { + content: "\F0600"; +} + +.mdi-page-last::before { + content: "\F0601"; +} + +.mdi-paperclip::before { + content: "\F03E2"; +} + .mdi-pause::before { content: "\F03E4"; } @@ -848,6 +880,14 @@ content: "\F1C55"; } +.mdi-star-half-full::before { + content: "\F04D0"; +} + +.mdi-star-outline::before { + content: "\F04D2"; +} + .mdi-stop::before { content: "\F04DB"; } @@ -904,6 +944,10 @@ content: "\F0A7A"; } +.mdi-unfold-more-horizontal::before { + content: "\F054F"; +} + .mdi-update::before { content: "\F06B0"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index 0bec68760c17dc2d01b6452a8728d25d200dfc40..415cd06e41145031548829b5f1eb175b684b8838 100644 GIT binary patch delta 16901 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�OOk>4*H~;1^Kfjm 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 delta 16444 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 { assert.ok(icons instanceof Set); assert.ok(icons.has('mdi-home')); assert.ok(icons.has('mdi-close')); - assert.ok(icons.has('mdi-radiobox-blank')); - assert.ok(icons.has('mdi-radiobox-marked')); - assert.equal(icons.size, 4); // source icons + required radio icons + for (const requiredIcon of REQUIRED_ICONS) { + assert.ok(icons.has(requiredIcon)); + } + const expectedIcons = new Set([...REQUIRED_ICONS, 'mdi-home', 'mdi-close']); + assert.deepEqual(icons, expectedIcons); rmSync(tmp, { recursive: true }); }); @@ -103,25 +106,29 @@ test('scanUsedIcons excludes utility classes', () => { rmSync(tmp, { recursive: true }); }); -test('scanUsedIcons includes required radio icons even when no mdi-* icons are found in source', () => { +test('scanUsedIcons includes all required icons even when no mdi-* icons are found in source', () => { const tmp = makeTmpDir(); writeFileSync(join(tmp, 'A.vue'), '
Hello
'); const icons = scanUsedIcons(collectFiles(tmp, ['.vue'])); - assert.ok(icons.has('mdi-radiobox-blank')); - assert.ok(icons.has('mdi-radiobox-marked')); - assert.equal(icons.size, 2); + for (const requiredIcon of REQUIRED_ICONS) { + assert.ok(icons.has(requiredIcon)); + } + assert.equal(icons.size, REQUIRED_ICONS.size); rmSync(tmp, { recursive: true }); }); -test('scanUsedIcons deduplicates required radio icons when source already references them', () => { +test('scanUsedIcons deduplicates required icons when source already references them', () => { const tmp = makeTmpDir(); - writeFileSync(join(tmp, 'A.vue'), 'mdi-radiobox-markedmdi-home'); + const requiredIcon = [...REQUIRED_ICONS][0]; + writeFileSync(join(tmp, 'A.vue'), `${requiredIcon}mdi-home`); const icons = [...scanUsedIcons(collectFiles(tmp, ['.vue']))]; - assert.equal(icons.filter(icon => icon === 'mdi-radiobox-marked').length, 1); - assert.ok(icons.includes('mdi-radiobox-blank')); + assert.equal(icons.filter(icon => icon === requiredIcon).length, 1); + for (const builtInRequiredIcon of REQUIRED_ICONS) { + assert.ok(icons.includes(builtInRequiredIcon)); + } assert.ok(icons.includes('mdi-home')); rmSync(tmp, { recursive: true }); From d6b170910867eff38b9c53f37dab8acdd7a5387d Mon Sep 17 00:00:00 2001 From: Yeyin Hu Date: Thu, 26 Mar 2026 10:04:57 +0800 Subject: [PATCH 05/11] Fix typo in plugin-config.md (#6971) --- docs/zh/dev/star/guides/plugin-config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/zh/dev/star/guides/plugin-config.md b/docs/zh/dev/star/guides/plugin-config.md index bf2b1f261..26077c142 100644 --- a/docs/zh/dev/star/guides/plugin-config.md +++ b/docs/zh/dev/star/guides/plugin-config.md @@ -3,7 +3,7 @@ 随着插件功能的增加,可能需要定义一些配置以让用户自定义插件的行为。 -AstrBot 提供了”强大“的配置解析和可视化功能。能够让用户在管理面板上直接配置插件,而不需要修改代码。 +AstrBot 提供了“强大”的配置解析和可视化功能。能够让用户在管理面板上直接配置插件,而不需要修改代码。 ## 配置定义 From b241b46970dc67cb8564735cfa0f55cdb57a3de9 Mon Sep 17 00:00:00 2001 From: SJ Date: Thu, 26 Mar 2026 11:17:34 +0800 Subject: [PATCH 06/11] docs: normalize QQ group listings in READMEs and community docs (#6976) * docs: update QQ group listings across readmes and community pages * docs: align QQ group status labels across docs --------- Co-authored-by: idiotsj --- README.md | 19 +++++++++++-------- README_fr.md | 15 +++++++++++---- README_ja.md | 15 +++++++++++---- README_ru.md | 15 +++++++++++---- README_zh-TW.md | 19 +++++++++++-------- README_zh.md | 19 +++++++++++-------- docs/en/community.md | 20 +++++++++++--------- docs/zh/community.md | 22 +++++++++++----------- 8 files changed, 88 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 2b6de087b..469b8c402 100644 --- a/README.md +++ b/README.md @@ -225,14 +225,17 @@ pre-commit install ### QQ Groups -- Group 9: 1076659624 (New) -- Group 10: 1078079676 (New) -- Group 1: 322154837 -- Group 3: 630166526 -- Group 5: 822130018 -- Group 6: 753075035 -- Group 7: 743746109 -- Group 8: 1030353265 +- Group 12: 916228568 (New) +- Group 9: 1076659624 (Full) +- Group 10: 1078079676 (Full) +- Group 11: 704659519 (Full) +- Group 1: 322154837 (Full) +- Group 3: 630166526 (Full) +- Group 4: 1077826412 (Full) +- Group 5: 822130018 (Full) +- Group 6: 753075035 (Full) +- Group 7: 743746109 (Full) +- Group 8: 1030353265 (Full) - Developer Group(Chit-chat): 975206796 - Developer Group(Formal): 1039761811 diff --git a/README_fr.md b/README_fr.md index 98e7f9955..31ab1f383 100644 --- a/README_fr.md +++ b/README_fr.md @@ -217,10 +217,17 @@ pre-commit install ### Groupes QQ -- Groupe 1 : 322154837 -- Groupe 3 : 630166526 -- Groupe 5 : 822130018 -- Groupe 6 : 753075035 +- Groupe 12 : 916228568 (nouveau) +- Groupe 9 : 1076659624 (complet) +- Groupe 10 : 1078079676 (complet) +- Groupe 11 : 704659519 (complet) +- Groupe 1 : 322154837 (complet) +- Groupe 3 : 630166526 (complet) +- Groupe 4 : 1077826412 (complet) +- Groupe 5 : 822130018 (complet) +- Groupe 6 : 753075035 (complet) +- Groupe 7 : 743746109 (complet) +- Groupe 8 : 1030353265 (complet) - Groupe développeurs : 975206796 - Groupe développeurs (officiel) : 1039761811 diff --git a/README_ja.md b/README_ja.md index 2b7c43d48..bf114b2d8 100644 --- a/README_ja.md +++ b/README_ja.md @@ -218,10 +218,17 @@ pre-commit install ### QQ グループ -- 1群: 322154837 -- 3群: 630166526 -- 5群: 822130018 -- 6群: 753075035 +- 12群: 916228568 (新) +- 9群: 1076659624 (満員) +- 10群: 1078079676 (満員) +- 11群: 704659519 (満員) +- 1群: 322154837 (満員) +- 3群: 630166526 (満員) +- 4群: 1077826412 (満員) +- 5群: 822130018 (満員) +- 6群: 753075035 (満員) +- 7群: 743746109 (満員) +- 8群: 1030353265 (満員) - 開発者群: 975206796 - 開発者群(正式): 1039761811 diff --git a/README_ru.md b/README_ru.md index 29d077b45..ed309a9ee 100644 --- a/README_ru.md +++ b/README_ru.md @@ -217,10 +217,17 @@ pre-commit install ### Группы QQ -- Группа 1: 322154837 -- Группа 3: 630166526 -- Группа 5: 822130018 -- Группа 6: 753075035 +- Группа 12: 916228568 (новая) +- Группа 9: 1076659624 (полная) +- Группа 10: 1078079676 (полная) +- Группа 11: 704659519 (полная) +- Группа 1: 322154837 (полная) +- Группа 3: 630166526 (полная) +- Группа 4: 1077826412 (полная) +- Группа 5: 822130018 (полная) +- Группа 6: 753075035 (полная) +- Группа 7: 743746109 (полная) +- Группа 8: 1030353265 (полная) - Группа разработчиков: 975206796 - Группа разработчиков (официальная): 1039761811 diff --git a/README_zh-TW.md b/README_zh-TW.md index 20749a077..89cce05e7 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -217,14 +217,17 @@ pre-commit install ### QQ 群組 -- 9 群: 1076659624 (新) -- 10 群: 1078079676 (新) -- 1 群:322154837 -- 3 群:630166526 -- 5 群:822130018 -- 6 群:753075035 -- 7 群:743746109 -- 8 群:1030353265 +- 12 群:916228568 (新) +- 9 群:1076659624 (人滿) +- 10 群:1078079676 (人滿) +- 11 群:704659519 (人滿) +- 1 群:322154837 (人滿) +- 3 群:630166526 (人滿) +- 4 群:1077826412 (人滿) +- 5 群:822130018 (人滿) +- 6 群:753075035 (人滿) +- 7 群:743746109 (人滿) +- 8 群:1030353265 (人滿) - 開發者群(闲聊吹水):975206796 - 開發者群(正式):1039761811 diff --git a/README_zh.md b/README_zh.md index 1e7c6b7f3..41099b069 100644 --- a/README_zh.md +++ b/README_zh.md @@ -218,14 +218,17 @@ pre-commit install ### QQ 群组 -- 9 群: 1076659624 (新) -- 10 群: 1078079676 (新) -- 1 群:322154837 -- 3 群:630166526 -- 5 群:822130018 -- 6 群:753075035 -- 7 群:743746109 -- 8 群:1030353265 +- 12 群:916228568 (新) +- 9 群:1076659624 (人满) +- 10 群:1078079676 (人满) +- 11 群:704659519 (人满) +- 1 群:322154837 (人满) +- 3 群:630166526 (人满) +- 4 群:1077826412 (人满) +- 5 群:822130018 (人满) +- 6 群:753075035 (人满) +- 7 群:743746109 (人满) +- 8 群:1030353265 (人满) - 开发者群(偏闲聊吹水):975206796 - 开发者群(正式):1039761811 diff --git a/docs/en/community.md b/docs/en/community.md index bc649d1bc..7aeb334b8 100644 --- a/docs/en/community.md +++ b/docs/en/community.md @@ -16,15 +16,17 @@ Welcome to submit Issues or Pull Requests: ### Tencent QQ Groups -> - All groups are available to join. If you find that the group size is below the limit, please feel free to join. - -- Group 1: 322154837 (2000-member group) -- Group 3: 630166526 (2000-member group) -- Group 4: 1077826412 (1000-member group) -- Group 5: 822130018 (2000-member group) -- Group 6: 753075035 (2000-member group) -- Group 7: 743746109 (500-member group) -- Group 8: 1030353265 (500-member group) +- Group 12: 916228568 (New) +- Group 9: 1076659624 (Full) +- Group 10: 1078079676 (Full) +- Group 11: 704659519 (Full) +- Group 1: 322154837 (Full) +- Group 3: 630166526 (Full) +- Group 4: 1077826412 (Full) +- Group 5: 822130018 (Full) +- Group 6: 753075035 (Full) +- Group 7: 743746109 (Full) +- Group 8: 1030353265 (Full) - **AstrBot Core Development Group: 975206796** (AstrBot development members are usually active here. Welcome to anyone interested in programming/AI technology~) ## Become an AstrBot Organization Member diff --git a/docs/zh/community.md b/docs/zh/community.md index 396f4cc24..bb2279f64 100644 --- a/docs/zh/community.md +++ b/docs/zh/community.md @@ -6,17 +6,17 @@ ### QQ 群 -> 所有群都可以插空加入,如果您发现群人数小于上限,请尝试加入。 - -- 9 群: 1076659624 (500 人群, 优先加此群) -- 10 群: 1078079676 (500 人群, 优先加此群) -- 1 群: 322154837 (2000 人群, 人满) -- 3 群: 630166526 (2000 人群, 人满) -- 4 群: 1077826412 (1000 人群, 人满) -- 5 群: 822130018 (2000 人群, 人满) -- 6 群: 753075035 (2000 人群, 人满) -- 7 群: 743746109 (500 人群, 人满) -- 8 群: 1030353265 (500 人群, 人满) +- 12 群: 916228568 (新) +- 9 群: 1076659624 (人满) +- 10 群: 1078079676 (人满) +- 11 群: 704659519 (人满) +- 1 群: 322154837 (人满) +- 3 群: 630166526 (人满) +- 4 群: 1077826412 (人满) +- 5 群: 822130018 (人满) +- 6 群: 753075035 (人满) +- 7 群: 743746109 (人满) +- 8 群: 1030353265 (人满) - **AstrBot 核心开发交流群: 975206796**(AstrBot 开发成员通常活跃于此,欢迎任何对编程/AI 技术感兴趣的同学加入~) ### Discord From 1ad7e10c0fe86624f32b6661dd914373204f8e39 Mon Sep 17 00:00:00 2001 From: Helian Nuits Date: Thu, 26 Mar 2026 19:32:27 +0800 Subject: [PATCH 07/11] fix(provider): restore parameter transparency in core LLM provider adapters (#6934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(provider): restore parameter transparency in core LLM provider adapters 核心对话适配器(OpenAI, Anthropic, Gemini)在准备请求 Payload 时未对 kwargs 进行合并,导致插件层传入的自定义参数(如 max_tokens, temperature, timeout 等)失效,回退到提供商的保守默认值。本次修复确保了各主流模型适配器对请求参数的完整透传。 * fix(payloads): 使用字典解包 --- astrbot/core/provider/sources/anthropic_source.py | 4 ++-- astrbot/core/provider/sources/gemini_source.py | 4 ++-- astrbot/core/provider/sources/openai_source.py | 3 +-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 203d0610f..b6d6ebeaf 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -515,7 +515,7 @@ class ProviderAnthropic(Provider): model = model or self.get_model() - payloads = {"messages": new_messages, "model": model} + payloads = {**kwargs, "messages": new_messages, "model": model} # Anthropic has a different way of handling system prompts if system_prompt: @@ -571,7 +571,7 @@ class ProviderAnthropic(Provider): model = model or self.get_model() - payloads = {"messages": new_messages, "model": model} + payloads = {**kwargs, "messages": new_messages, "model": model} # Anthropic has a different way of handling system prompts if system_prompt: diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index 9557f3dbc..c66a9a7e9 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -757,7 +757,7 @@ class ProviderGoogleGenAI(Provider): model = model or self.get_model() - payloads = {"messages": context_query, "model": model} + payloads = {**kwargs, "messages": context_query, "model": model} retry = 10 keys = self.api_keys.copy() @@ -812,7 +812,7 @@ class ProviderGoogleGenAI(Provider): model = model or self.get_model() - payloads = {"messages": context_query, "model": model} + payloads = {**kwargs, "messages": context_query, "model": model} retry = 10 keys = self.api_keys.copy() diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 68fad067b..d22d4c5a0 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -623,8 +623,7 @@ class ProviderOpenAIOfficial(Provider): context_query.extend(tcr.to_openai_messages()) model = model or self.get_model() - - payloads = {"messages": context_query, "model": model} + payloads = {**kwargs, "messages": context_query, "model": model} self._finally_convert_payload(payloads) From e5e8bd5d31d47638aa5ff5c8110453611f8e9a7e Mon Sep 17 00:00:00 2001 From: Gargantua <124801228+catDforD@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:36:05 +0800 Subject: [PATCH 08/11] feat(dashboard): center extension page toast hints with the global UI (#6043) * fix(dashboard): align extension page snackbar with full UI center (#6022) * fix(dashboard): align snackbars with full UI center (#6022) --------- Co-authored-by: Gargantua <22532097@zju.edu.cn> --- dashboard/src/scss/_override.scss | 5 +++++ dashboard/src/views/ExtensionPage.vue | 1 + 2 files changed, 6 insertions(+) diff --git a/dashboard/src/scss/_override.scss b/dashboard/src/scss/_override.scss index 178c37da6..25f8ec86b 100644 --- a/dashboard/src/scss/_override.scss +++ b/dashboard/src/scss/_override.scss @@ -17,6 +17,11 @@ html { flex: unset; } +.v-overlay.v-snackbar { + --v-layout-left: 0px !important; + --v-layout-right: 0px !important; +} + .customizer-btn .icon { animation: progress-circular-rotate 1.4s linear infinite; transform-origin: center center; diff --git a/dashboard/src/views/ExtensionPage.vue b/dashboard/src/views/ExtensionPage.vue index 05a79a532..13d4eac3b 100644 --- a/dashboard/src/views/ExtensionPage.vue +++ b/dashboard/src/views/ExtensionPage.vue @@ -387,6 +387,7 @@ const { elevation="24" :color="snack_success" v-model="snack_show" + location="bottom center" > {{ snack_message }} From 22e24e5f7b55d82d9fdf1febad62f5e24f519e29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B7=E7=9F=B3Boy?= Date: Thu, 26 Mar 2026 19:49:33 +0800 Subject: [PATCH 09/11] docs: update plugin dev link in webui (#6978) --- dashboard/src/views/ExtensionPage.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dashboard/src/views/ExtensionPage.vue b/dashboard/src/views/ExtensionPage.vue index 13d4eac3b..ce859296c 100644 --- a/dashboard/src/views/ExtensionPage.vue +++ b/dashboard/src/views/ExtensionPage.vue @@ -227,7 +227,8 @@ const { Date: Thu, 26 Mar 2026 23:34:33 +0800 Subject: [PATCH 10/11] =?UTF-8?q?Feat(webui):=20improve=20code=20block=20r?= =?UTF-8?q?eadability=20in=20dark=20mode=EF=BC=88iss#6963)=20(#7014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat(webui): improve code block readability in dark mode * fix(dashboard): use theme variable for code text --------- Co-authored-by: RC-CHN <1051989940@qq.com> --- dashboard/src/scss/_override.scss | 7 +++++++ dashboard/src/scss/_variables.scss | 2 ++ 2 files changed, 9 insertions(+) diff --git a/dashboard/src/scss/_override.scss b/dashboard/src/scss/_override.scss index 25f8ec86b..176297f47 100644 --- a/dashboard/src/scss/_override.scss +++ b/dashboard/src/scss/_override.scss @@ -39,3 +39,10 @@ html { transform: rotate(270deg); } } + +pre, code, .markdown pre, .markdown code, .release-notes pre, .release-notes code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Helvetica Neue", monospace; + color: var(--astrbot-code-color); +} + + diff --git a/dashboard/src/scss/_variables.scss b/dashboard/src/scss/_variables.scss index 7993bb860..fbc6ec55a 100644 --- a/dashboard/src/scss/_variables.scss +++ b/dashboard/src/scss/_variables.scss @@ -11,10 +11,12 @@ $font-size-root: 1rem; $border-radius-root: 8px; $cjk-sans-fallback: 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans CJK SC', 'Microsoft YaHei' !default; $cjk-mono-fallback: 'PingFang SC', 'PingFang TC', 'Hiragino Sans GB', 'Noto Sans CJK SC', 'Microsoft YaHei' !default; +$code-text-color: #111827 !default; :root { --astrbot-font-cjk-sans: #{$cjk-sans-fallback}; --astrbot-font-cjk-mono: #{$cjk-mono-fallback}; + --astrbot-code-color: #{$code-text-color}; } $body-font-family: 'Roboto', $cjk-sans-fallback, sans-serif !default; From cd4e9995269dd357766a7a113d8312b9105caf1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=82=A4=E3=82=AB=E3=82=AF?= <1259085392z@gmail.com> Date: Fri, 27 Mar 2026 00:49:19 +0900 Subject: [PATCH 11/11] fix: harden OpenAI attachment recovery (#7004) * fix: harden OpenAI attachment recovery * fix: refine OpenAI image loading * fix: restore OpenAI image encoding errors * refactor: streamline OpenAI image helpers * refactor: simplify OpenAI attachment helpers * refactor: simplify OpenAI helper flow * refactor: clarify OpenAI image modes * refactor: reduce OpenAI materialization copies --- .../core/provider/sources/openai_source.py | 237 ++++++- tests/test_openai_source.py | 607 +++++++++++++++++- 2 files changed, 816 insertions(+), 28 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index d22d4c5a0..7e09d1615 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -1,11 +1,15 @@ import asyncio import base64 +import copy import inspect import json import random import re from collections.abc import AsyncGenerator -from typing import Any +from io import BytesIO +from pathlib import Path +from typing import Any, Literal +from urllib.parse import unquote, urlparse import httpx from openai import AsyncAzureOpenAI, AsyncOpenAI @@ -14,6 +18,8 @@ from openai.lib.streaming.chat._completions import ChatCompletionStreamState from openai.types.chat.chat_completion import ChatCompletion from openai.types.chat.chat_completion_chunk import ChatCompletionChunk from openai.types.completion_usage import CompletionUsage +from PIL import Image as PILImage +from PIL import UnidentifiedImageError import astrbot.core.message.components as Comp from astrbot import logger @@ -133,6 +139,186 @@ class ProviderOpenAIOfficial(Provider): return True return False + def _is_invalid_attachment_error(self, error: Exception) -> bool: + body = getattr(error, "body", None) + code: str | None = None + message: str | None = None + if isinstance(body, dict): + err_obj = body.get("error") + if isinstance(err_obj, dict): + raw_code = err_obj.get("code") + raw_message = err_obj.get("message") + code = raw_code.lower() if isinstance(raw_code, str) else None + message = raw_message.lower() if isinstance(raw_message, str) else None + + if code == "invalid_attachment": + return True + + text_sources: list[str] = [] + if message: + text_sources.append(message) + if code: + text_sources.append(code) + text_sources.extend(map(str, self._extract_error_text_candidates(error))) + + error_text = " ".join(text.lower() for text in text_sources if text) + if "invalid_attachment" in error_text: + return True + if "download attachment" in error_text and "404" in error_text: + return True + return False + + @classmethod + def _encode_image_file_to_data_url( + cls, + image_path: str, + *, + mode: Literal["safe", "strict"], + ) -> str | None: + try: + image_bytes = Path(image_path).read_bytes() + except OSError: + if mode == "strict": + raise + return None + + try: + with PILImage.open(BytesIO(image_bytes)) as image: + image.verify() + image_format = str(image.format or "").upper() + except (OSError, UnidentifiedImageError): + if mode == "strict": + raise ValueError(f"Invalid image file: {image_path}") + return None + + mime_type = { + "JPEG": "image/jpeg", + "PNG": "image/png", + "GIF": "image/gif", + "WEBP": "image/webp", + "BMP": "image/bmp", + }.get(image_format, "image/jpeg") + image_bs64 = base64.b64encode(image_bytes).decode("utf-8") + return f"data:{mime_type};base64,{image_bs64}" + + @staticmethod + def _file_uri_to_path(file_uri: str) -> str: + """Normalize file URIs to paths. + + `file://localhost/...` and drive-letter forms are treated as local paths. + Other non-empty hosts are preserved as UNC-style paths. + """ + parsed = urlparse(file_uri) + if parsed.scheme != "file": + return file_uri + + netloc = unquote(parsed.netloc or "") + path = unquote(parsed.path or "") + if re.fullmatch(r"[A-Za-z]:", netloc): + return str(Path(f"{netloc}{path}")) + if re.match(r"^/[A-Za-z]:/", path): + path = path[1:] + if netloc and netloc != "localhost": + path = f"//{netloc}{path}" + return str(Path(path)) + + async def _image_ref_to_data_url( + self, + image_ref: str, + *, + mode: Literal["safe", "strict"] = "safe", + ) -> str | None: + if image_ref.startswith("base64://"): + return image_ref.replace("base64://", "data:image/jpeg;base64,") + + if image_ref.startswith("http"): + image_path = await download_image_by_url(image_ref) + elif image_ref.startswith("file://"): + image_path = self._file_uri_to_path(image_ref) + else: + image_path = image_ref + + return self._encode_image_file_to_data_url( + image_path, + mode=mode, + ) + + async def _resolve_image_part( + self, + image_url: str, + *, + image_detail: str | None = None, + ) -> dict | None: + if image_url.startswith("data:"): + image_payload = {"url": image_url} + else: + image_data = await self._image_ref_to_data_url(image_url, mode="safe") + if not image_data: + logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。") + return None + image_payload = {"url": image_data} + + if image_detail: + image_payload["detail"] = image_detail + return { + "type": "image_url", + "image_url": image_payload, + } + + def _extract_image_part_info(self, part: dict) -> tuple[str | None, str | None]: + if not isinstance(part, dict) or part.get("type") != "image_url": + return None, None + + image_url_data = part.get("image_url") + if not isinstance(image_url_data, dict): + logger.warning("图片内容块格式无效,将保留原始内容。") + return None, None + + url = image_url_data.get("url") + if not isinstance(url, str) or not url: + logger.warning("图片内容块缺少有效 URL,将保留原始内容。") + return None, None + + image_detail = image_url_data.get("detail") + if not isinstance(image_detail, str): + image_detail = None + return url, image_detail + + async def _transform_content_part(self, part: dict) -> dict: + url, image_detail = self._extract_image_part_info(part) + if not url: + return part + + try: + resolved_part = await self._resolve_image_part( + url, image_detail=image_detail + ) + except Exception as exc: + logger.warning( + "图片 %s 预处理失败,将保留原始内容。错误: %s", + url, + exc, + ) + return part + + return resolved_part or part + + async def _materialize_message_image_parts(self, message: dict) -> dict: + content = message.get("content") + if not isinstance(content, list): + return {**message} + + new_content = [await self._transform_content_part(part) for part in content] + return {**message, "content": new_content} + + async def _materialize_context_image_parts( + self, context_query: list[dict] + ) -> list[dict]: + return [ + await self._materialize_message_image_parts(message) + for message in context_query + ] + async def _fallback_to_text_only_and_retry( self, payloads: dict, @@ -604,7 +790,7 @@ class ProviderOpenAIOfficial(Provider): new_record = await self.assemble_context( prompt, image_urls, extra_user_content_parts ) - context_query = self._ensure_message_to_dicts(contexts) + context_query = copy.deepcopy(self._ensure_message_to_dicts(contexts)) if new_record: context_query.append(new_record) if system_prompt: @@ -622,6 +808,9 @@ class ProviderOpenAIOfficial(Provider): for tcr in tool_calls_result: context_query.extend(tcr.to_openai_messages()) + if self._context_contains_image(context_query): + context_query = await self._materialize_context_image_parts(context_query) + model = model or self.get_model() payloads = {**kwargs, "messages": context_query, "model": model} @@ -721,6 +910,18 @@ class ProviderOpenAIOfficial(Provider): "image_content_moderated", image_fallback_used=True, ) + if self._is_invalid_attachment_error(e): + if image_fallback_used or not self._context_contains_image(context_query): + raise e + return await self._fallback_to_text_only_and_retry( + payloads, + context_query, + chosen_key, + available_api_keys, + func_tool, + "invalid_attachment", + image_fallback_used=True, + ) if ( "Function calling is not enabled" in str(e) @@ -922,23 +1123,6 @@ class ProviderOpenAIOfficial(Provider): ) -> dict: """组装成符合 OpenAI 格式的 role 为 user 的消息段""" - async def resolve_image_part(image_url: str) -> dict | None: - if image_url.startswith("http"): - image_path = await download_image_by_url(image_url) - image_data = await self.encode_image_bs64(image_path) - elif image_url.startswith("file:///"): - image_path = image_url.replace("file:///", "") - image_data = await self.encode_image_bs64(image_path) - else: - image_data = await self.encode_image_bs64(image_url) - if not image_data: - logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。") - return None - return { - "type": "image_url", - "image_url": {"url": image_data}, - } - # 构建内容块列表 content_blocks = [] @@ -958,7 +1142,9 @@ class ProviderOpenAIOfficial(Provider): if isinstance(part, TextPart): content_blocks.append({"type": "text", "text": part.text}) elif isinstance(part, ImageURLPart): - image_part = await resolve_image_part(part.image_url.url) + image_part = await self._resolve_image_part( + part.image_url.url, + ) if image_part: content_blocks.append(image_part) else: @@ -967,7 +1153,7 @@ class ProviderOpenAIOfficial(Provider): # 3. 图片内容 if image_urls: for image_url in image_urls: - image_part = await resolve_image_part(image_url) + image_part = await self._resolve_image_part(image_url) if image_part: content_blocks.append(image_part) @@ -986,11 +1172,10 @@ class ProviderOpenAIOfficial(Provider): async def encode_image_bs64(self, image_url: str) -> str: """将图片转换为 base64""" - if image_url.startswith("base64://"): - return image_url.replace("base64://", "data:image/jpeg;base64,") - with open(image_url, "rb") as f: - image_bs64 = base64.b64encode(f.read()).decode("utf-8") - return "data:image/jpeg;base64," + image_bs64 + image_data = await self._image_ref_to_data_url(image_url, mode="strict") + if image_data is None: + raise RuntimeError(f"Failed to encode image data: {image_url}") + return image_data async def terminate(self): if self.client: diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index 0040f0be6..2454c09c1 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -2,6 +2,7 @@ from types import SimpleNamespace import pytest from openai.types.chat.chat_completion import ChatCompletion +from PIL import Image as PILImage from astrbot.core.provider.sources.groq_source import ProviderGroq from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial @@ -234,7 +235,9 @@ async def test_openai_payload_keeps_reasoning_content_in_assistant_history(): provider._finally_convert_payload(payloads) assistant_message = payloads["messages"][0] - assert assistant_message["content"] == [{"type": "text", "text": "final answer"}] + assert assistant_message["content"] == [ + {"type": "text", "text": "final answer"} + ] assert assistant_message["reasoning_content"] == "step 1" finally: await provider.terminate() @@ -259,7 +262,9 @@ async def test_groq_payload_drops_reasoning_content_from_assistant_history(): provider._finally_convert_payload(payloads) assistant_message = payloads["messages"][0] - assert assistant_message["content"] == [{"type": "text", "text": "final answer"}] + assert assistant_message["content"] == [ + {"type": "text", "text": "final answer"} + ] assert "reasoning_content" not in assistant_message assert "reasoning" not in assistant_message finally: @@ -450,6 +455,604 @@ async def test_handle_api_error_unknown_image_error_raises(): await provider.terminate() +@pytest.mark.asyncio +async def test_handle_api_error_invalid_attachment_removes_images_and_retries_text_only(): + provider = _make_provider() + try: + payloads = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hello"}, + { + "type": "image_url", + "image_url": {"url": "data:image/jpeg;base64,abcd"}, + }, + ], + } + ] + } + context_query = payloads["messages"] + err = _ErrorWithBody( + "upstream error", + { + "error": { + "code": "INVALID_ATTACHMENT", + "message": "download attachment: unexpected status 404", + } + }, + ) + + success, *_rest = await provider._handle_api_error( + err, + payloads=payloads, + context_query=context_query, + func_tool=None, + chosen_key="test-key", + available_api_keys=["test-key"], + retry_cnt=0, + max_retries=10, + ) + + assert success is False + assert payloads["messages"][0]["content"] == [{"type": "text", "text": "hello"}] + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_handle_api_error_invalid_attachment_without_images_raises(): + provider = _make_provider() + try: + payloads = { + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "hello"}], + } + ] + } + context_query = payloads["messages"] + err = _ErrorWithBody( + "upstream error", + { + "error": { + "code": "INVALID_ATTACHMENT", + "message": "download attachment: unexpected status 404", + } + }, + ) + + with pytest.raises(_ErrorWithBody, match="upstream error"): + await provider._handle_api_error( + err, + payloads=payloads, + context_query=context_query, + func_tool=None, + chosen_key="test-key", + available_api_keys=["test-key"], + retry_cnt=0, + max_retries=10, + ) + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_handle_api_error_invalid_attachment_after_fallback_raises(): + provider = _make_provider() + try: + payloads = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hello"}, + { + "type": "image_url", + "image_url": {"url": "data:image/jpeg;base64,abcd"}, + }, + ], + } + ] + } + context_query = payloads["messages"] + err = _ErrorWithBody( + "upstream error", + { + "error": { + "code": "INVALID_ATTACHMENT", + "message": "download attachment: unexpected status 404", + } + }, + ) + + with pytest.raises(_ErrorWithBody, match="upstream error"): + await provider._handle_api_error( + err, + payloads=payloads, + context_query=context_query, + func_tool=None, + chosen_key="test-key", + available_api_keys=["test-key"], + retry_cnt=1, + max_retries=10, + image_fallback_used=True, + ) + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_prepare_chat_payload_materializes_context_http_image_urls(monkeypatch): + provider = _make_provider() + try: + + async def fake_download(url: str) -> str: + assert url == "https://example.com/quoted.png" + return "/tmp/quoted.png" + + def fake_encode(image_path: str, **_kwargs) -> str: + assert image_path == "/tmp/quoted.png" + return "data:image/png;base64,abcd" + + monkeypatch.setattr( + "astrbot.core.provider.sources.openai_source.download_image_by_url", + fake_download, + ) + monkeypatch.setattr(provider, "_encode_image_file_to_data_url", fake_encode) + + contexts = [ + { + "role": "user", + "metadata": {"source": "quoted"}, + "content": [ + {"type": "text", "text": "look"}, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/quoted.png", + "id": "ctx-img", + "detail": "high", + }, + }, + ], + } + ] + + payloads, _ = await provider._prepare_chat_payload( + prompt=None, + contexts=contexts, + ) + + assert payloads["messages"][0]["content"] == [ + {"type": "text", "text": "look"}, + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64,abcd", + "detail": "high", + }, + }, + ] + assert payloads["messages"][0]["content"][1]["image_url"].get("id") is None + assert contexts[0]["content"][1]["image_url"] == { + "url": "https://example.com/quoted.png", + "id": "ctx-img", + "detail": "high", + } + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_prepare_chat_payload_skips_materialization_for_text_only_context( + monkeypatch, +): + provider = _make_provider() + try: + + async def fail_if_called(_context_query): + raise AssertionError("materialization should be skipped") + + monkeypatch.setattr( + provider, "_materialize_context_image_parts", fail_if_called + ) + + payloads, _ = await provider._prepare_chat_payload( + prompt=None, + contexts=[{"role": "user", "content": "hello"}], + ) + + assert payloads["messages"] == [{"role": "user", "content": "hello"}] + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_prepare_chat_payload_skips_materialization_for_text_only_parts( + monkeypatch, +): + provider = _make_provider() + try: + + async def fail_if_called(_context_query): + raise AssertionError("materialization should be skipped") + + monkeypatch.setattr( + provider, "_materialize_context_image_parts", fail_if_called + ) + + payloads, _ = await provider._prepare_chat_payload( + prompt=None, + contexts=[ + { + "role": "user", + "content": [{"type": "text", "text": "hello"}], + } + ], + ) + + assert payloads["messages"] == [ + { + "role": "user", + "content": [{"type": "text", "text": "hello"}], + } + ] + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_prepare_chat_payload_materializes_context_http_image_urls_with_detected_mime( + monkeypatch, tmp_path +): + provider = _make_provider() + try: + image_path = tmp_path / "quoted-image.png" + PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path) + + async def fake_download(url: str) -> str: + assert url == "https://example.com/quoted.png" + return str(image_path) + + monkeypatch.setattr( + "astrbot.core.provider.sources.openai_source.download_image_by_url", + fake_download, + ) + + payloads, _ = await provider._prepare_chat_payload( + prompt=None, + contexts=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "look"}, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/quoted.png", + }, + }, + ], + } + ], + ) + + image_payload = payloads["messages"][0]["content"][1]["image_url"] + assert image_payload["url"].startswith("data:image/png;base64,") + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_prepare_chat_payload_materializes_context_file_uri_image_urls(tmp_path): + provider = _make_provider() + try: + image_path = tmp_path / "quoted-image.png" + PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path) + + payloads, _ = await provider._prepare_chat_payload( + prompt=None, + contexts=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "look"}, + { + "type": "image_url", + "image_url": { + "url": image_path.as_uri(), + }, + }, + ], + } + ], + ) + + image_payload = payloads["messages"][0]["content"][1]["image_url"] + assert image_payload["url"].startswith("data:image/png;base64,") + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_file_uri_to_path_preserves_windows_drive_letter(): + provider = _make_provider() + try: + assert provider._file_uri_to_path("file:///C:/tmp/quoted-image.png") == ( + "C:/tmp/quoted-image.png" + ) + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_file_uri_to_path_preserves_windows_netloc_drive_letter(): + provider = _make_provider() + try: + assert provider._file_uri_to_path("file://C:/tmp/quoted-image.png") == ( + "C:/tmp/quoted-image.png" + ) + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_file_uri_to_path_preserves_remote_netloc_as_unc_path(): + provider = _make_provider() + try: + assert provider._file_uri_to_path("file://server/share/quoted-image.png") == ( + "//server/share/quoted-image.png" + ) + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_resolve_image_part_rejects_invalid_local_file(tmp_path): + provider = _make_provider() + try: + invalid_file = tmp_path / "not-image.txt" + invalid_file.write_text("not an image") + + assert await provider._resolve_image_part(str(invalid_file)) is None + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_resolve_image_part_rejects_invalid_file_uri(tmp_path): + provider = _make_provider() + try: + invalid_file = tmp_path / "not-image.txt" + invalid_file.write_text("not an image") + + assert await provider._resolve_image_part(invalid_file.as_uri()) is None + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_image_ref_to_data_url_mode_controls_invalid_file_behavior(tmp_path): + provider = _make_provider() + try: + invalid_file = tmp_path / "not-image.txt" + invalid_file.write_text("not an image") + + assert ( + await provider._image_ref_to_data_url(str(invalid_file), mode="safe") + is None + ) + with pytest.raises(ValueError, match="Invalid image file"): + await provider._image_ref_to_data_url(str(invalid_file), mode="strict") + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_materialize_context_image_parts_returns_new_messages(monkeypatch): + provider = _make_provider() + try: + context_query = [ + { + "role": "user", + "metadata": {"source": "quoted"}, + "content": [ + {"type": "text", "text": "look"}, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/quoted.png", + "detail": "high", + }, + }, + ], + }, + {"role": "assistant", "content": "plain text"}, + ] + + async def fake_resolve(image_url: str, *, image_detail: str | None = None): + assert image_url == "https://example.com/quoted.png" + assert image_detail == "high" + return { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64,abcd", + "detail": "high", + }, + } + + monkeypatch.setattr(provider, "_resolve_image_part", fake_resolve) + + materialized = await provider._materialize_context_image_parts(context_query) + + assert materialized is not context_query + assert materialized[0] is not context_query[0] + assert materialized[0]["metadata"] is context_query[0]["metadata"] + assert materialized[0]["content"][0] is context_query[0]["content"][0] + assert ( + materialized[0]["content"][1]["image_url"]["url"] + == "data:image/png;base64,abcd" + ) + assert ( + context_query[0]["content"][1]["image_url"]["url"] + == "https://example.com/quoted.png" + ) + assert materialized[1] is not context_query[1] + assert materialized[1]["content"] == "plain text" + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_encode_image_bs64_missing_file_raises(tmp_path): + provider = _make_provider() + try: + missing_path = tmp_path / "missing-image.png" + with pytest.raises(FileNotFoundError): + await provider.encode_image_bs64(str(missing_path)) + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_encode_image_bs64_invalid_file_raises(tmp_path): + provider = _make_provider() + try: + invalid_file = tmp_path / "not-image.txt" + invalid_file.write_text("not an image") + + with pytest.raises(ValueError, match="Invalid image file"): + await provider.encode_image_bs64(str(invalid_file)) + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_encode_image_bs64_supports_base64_scheme(): + provider = _make_provider() + try: + image_data = await provider.encode_image_bs64("base64://abcd") + + assert image_data == "data:image/jpeg;base64,abcd" + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_encode_image_bs64_supports_file_uri(tmp_path): + provider = _make_provider() + try: + image_path = tmp_path / "quoted-image.png" + PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path) + + image_data = await provider.encode_image_bs64(image_path.as_uri()) + + assert image_data.startswith("data:image/png;base64,") + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_resolve_image_part_supports_base64_scheme(): + provider = _make_provider() + try: + assert await provider._resolve_image_part("base64://abcd") == { + "type": "image_url", + "image_url": {"url": "data:image/jpeg;base64,abcd"}, + } + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_prepare_chat_payload_materializes_context_localhost_file_uri_image_urls( + tmp_path, +): + provider = _make_provider() + try: + image_path = tmp_path / "quoted-image.png" + PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path) + + localhost_uri = f"file://localhost{image_path.as_posix()}" + payloads, _ = await provider._prepare_chat_payload( + prompt=None, + contexts=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "look"}, + { + "type": "image_url", + "image_url": { + "url": localhost_uri, + }, + }, + ], + } + ], + ) + + image_payload = payloads["messages"][0]["content"][1]["image_url"] + assert image_payload["url"].startswith("data:image/png;base64,") + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_prepare_chat_payload_keeps_original_context_image_when_materialization_fails( + monkeypatch, +): + provider = _make_provider() + try: + + async def fake_download(url: str) -> str: + assert url == "https://example.com/expired.png" + return "/tmp/not-an-image" + + monkeypatch.setattr( + "astrbot.core.provider.sources.openai_source.download_image_by_url", + fake_download, + ) + monkeypatch.setattr( + provider, + "_encode_image_file_to_data_url", + lambda _image_path, **_kwargs: None, + ) + + payloads, _ = await provider._prepare_chat_payload( + prompt=None, + contexts=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "look"}, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/expired.png", + }, + }, + ], + } + ], + ) + + assert payloads["messages"][0]["content"] == [ + {"type": "text", "text": "look"}, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/expired.png", + }, + }, + ] + finally: + await provider.terminate() + + @pytest.mark.asyncio async def test_apply_provider_specific_extra_body_overrides_disables_ollama_thinking(): provider = _make_provider(