diff --git a/README_zh.md b/README_zh.md
index 5a3afb4a6..21c10bb64 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -245,14 +245,17 @@ bun dev
### 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/astrbot/core/computer/tools/browser.py b/astrbot/core/computer/tools/browser.py
index 0392ed0e3..b6d56204c 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,
@@ -78,7 +70,7 @@ class BrowserExecTool(FunctionTool):
include_trace: bool = False,
**kwargs: Any,
) -> ToolExecResult:
- if err := _ensure_admin(context):
+ if err := check_admin_permission(context, "Using browser tools"):
return err
try:
browser = await _get_browser_component(context)
@@ -142,7 +134,7 @@ class BrowserBatchExecTool(FunctionTool):
include_trace: bool = False,
**kwargs: Any,
) -> ToolExecResult:
- if err := _ensure_admin(context):
+ if err := check_admin_permission(context, "Using browser tools"):
return err
try:
browser = await _get_browser_component(context)
@@ -190,7 +182,7 @@ class RunBrowserSkillTool(FunctionTool):
tags: str | None = None,
**kwargs: Any,
) -> 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 b5f960e4e..c67264ccd 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/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/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py
index 0637ca747..2e4276233 100644
--- a/astrbot/core/provider/sources/anthropic_source.py
+++ b/astrbot/core/provider/sources/anthropic_source.py
@@ -516,7 +516,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:
@@ -572,7 +572,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 1607ab1fd..9940181b0 100644
--- a/astrbot/core/provider/sources/gemini_source.py
+++ b/astrbot/core/provider/sources/gemini_source.py
@@ -758,7 +758,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()
@@ -813,7 +813,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 4ed5dd7dd..826a01548 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 aiofiles
import httpx
@@ -15,6 +19,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
@@ -134,6 +140,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,
@@ -608,7 +794,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:
@@ -626,9 +812,11 @@ class ProviderOpenAIOfficial(Provider):
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
- model = model or self.get_model()
+ if self._context_contains_image(context_query):
+ context_query = await self._materialize_context_image_parts(context_query)
- payloads = {"messages": context_query, "model": model}
+ model = model or self.get_model()
+ payloads = {**kwargs, "messages": context_query, "model": model}
self._finally_convert_payload(payloads)
@@ -726,6 +914,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)
@@ -927,22 +1127,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 = []
@@ -963,7 +1147,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:
@@ -972,7 +1158,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)
@@ -991,11 +1177,18 @@ class ProviderOpenAIOfficial(Provider):
async def encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64"""
+<<<<<<< ours
if image_url.startswith("base64://"):
return image_url.replace("base64://", "data:image/jpeg;base64,")
async with aiofiles.open(image_url, "rb") as f:
image_bs64 = base64.b64encode(await 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
+>>>>>>> theirs
async def terminate(self):
if self.client:
diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py
index bc72bcaec..67fef096a 100644
--- a/astrbot/core/skills/skill_manager.py
+++ b/astrbot/core/skills/skill_manager.py
@@ -23,7 +23,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:
@@ -548,7 +553,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}")
@@ -565,15 +576,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:
@@ -583,16 +627,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=str(astrbot_paths.temp)) as tmp_dir:
for member in zf.infolist():
@@ -600,7 +648,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 e7d9a4c94..7d8aee926 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
@@ -45,6 +44,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)
@@ -165,11 +175,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()
@@ -209,6 +232,7 @@ class SkillsRoute(Route):
succeeded = []
failed = []
+ skipped = []
skill_mgr = SkillManager()
temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
@@ -227,14 +251,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:
@@ -256,8 +308,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()
@@ -266,18 +320,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__
@@ -289,6 +360,7 @@ class SkillsRoute(Route):
"total": total,
"succeeded": succeeded,
"failed": failed,
+ "skipped": skipped,
},
message,
)
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/dashboard/scripts/subset-mdi-font.mjs b/dashboard/scripts/subset-mdi-font.mjs
index 467c76bf7..358d467d5 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 971679a0e..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 – 238 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";
}
@@ -804,10 +836,6 @@
content: "\F0832";
}
-.mdi-share-variant::before {
- content: "\F0497";
-}
-
.mdi-shield-check::before {
content: "\F0565";
}
@@ -852,6 +880,14 @@
content: "\F1C55";
}
+.mdi-star-half-full::before {
+ content: "\F04D0";
+}
+
+.mdi-star-outline::before {
+ content: "\F04D2";
+}
+
.mdi-stop::before {
content: "\F04DB";
}
@@ -908,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
deleted file mode 100644
index e3c3af713..000000000
Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff and /dev/null differ
diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2
deleted file mode 100644
index b81e4462d..000000000
Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 and /dev/null differ
diff --git a/dashboard/src/components/extension/SkillsSection.vue b/dashboard/src/components/extension/SkillsSection.vue
index 68163b89f..69a9484fc 100644
--- a/dashboard/src/components/extension/SkillsSection.vue
+++ b/dashboard/src/components/extension/SkillsSection.vue
@@ -1026,6 +1026,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);
@@ -1037,6 +1038,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": "自动跳过重复文件",
diff --git a/dashboard/src/scss/_override.scss b/dashboard/src/scss/_override.scss
index 178c37da6..176297f47 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;
@@ -34,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;
diff --git a/dashboard/src/views/ExtensionPage.vue b/dashboard/src/views/ExtensionPage.vue
index 2ce4f75a7..ce859296c 100644
--- a/dashboard/src/views/ExtensionPage.vue
+++ b/dashboard/src/views/ExtensionPage.vue
@@ -154,14 +154,8 @@ const {
-
-
+
+
@@ -170,13 +164,8 @@ const {
-
-
- {{ tm("tabs.handlersOperation") }}
-
+
+
{{ tm("tabs.handlersOperation") }}
-
-
- {{ tm("tabs.installedMcpServers") }}
-
+
+
{{ tm("tabs.installedMcpServers") }}
-
-
- {{ tm("tabs.skills") }}
-
+
+
{{ tm("tabs.skills") }}
+
-
+
+ >
-
+
-
- {{
- tm("dialogs.config.title")
- }}
-
+ {{
+ tm("dialogs.config.title")
+ }}
-
- {{ tm("dialogs.config.noConfig") }}
-
+
{{ tm("dialogs.config.noConfig") }}
-
-
- {{
- tm("buttons.saveAndClose")
- }}
-
+
+ {{
+ tm("buttons.saveAndClose")
+ }}
{{ tm("buttons.close") }}
- {{ tm("buttons.close") }}
-
-
+
-
- {{ loadingDialog.title }}
-
+ {{ loadingDialog.title }}
+ >
-
+
-
- {{ loadingDialog.result }}
-
+ >
+
{{ loadingDialog.result }}
{{ tm("dialogs.loading.logs") }}
+ >
+
-
+
-
+
{{ tm("buttons.close") }}
- {{ tm("buttons.close") }}
-
-
+
-
- {{ selectedPlugin.name }} {{ tm("buttons.viewInfo") }}
-
+ {{ selectedPlugin.name }} {{ tm("buttons.viewInfo") }}
-
-
- {{ column.title }}
-
+
+ {{ column.title }}
-
+
{{ item.event_type }}
-
+
{{ item.desc }}
-
+
{{ item.type }}
-
+
{{ item.cmd }}
-
+
{{ tm("buttons.close") }}
- {{ tm("buttons.close") }}
-
{{ snack_message }}
@@ -467,18 +414,10 @@ const {
/>
-
+
-
- mdi-update
-
+ mdi-update
{{ tm("dialogs.updateAllConfirm.title") }}
@@ -487,38 +426,26 @@ const {
-
+
- {{ tm("buttons.cancel") }}
-
+ >{{ tm("buttons.cancel") }}
- {{ tm("dialogs.updateAllConfirm.confirm") }}
-
+ >{{ tm("dialogs.updateAllConfirm.confirm") }}
-
+
-
- mdi-alert-circle
-
+ mdi-alert-circle
{{ tm("conflicts.title") }}
@@ -541,20 +468,11 @@ const {
-
-
- {{
- tm("conflicts.later")
- }}
-
-
+
+ {{
+ tm("conflicts.later")
+ }}
+
{{ tm("conflicts.goToManage") }}
@@ -562,36 +480,21 @@ const {
-
+
-
- mdi-alert-circle
-
+ mdi-alert-circle
{{ tm("dialogs.danger_warning.title") }}
{{ tm("dialogs.danger_warning.message") }}
-
-
+
+
{{ tm("dialogs.danger_warning.cancel") }}
-
+
{{ tm("dialogs.danger_warning.confirm") }}
@@ -599,41 +502,24 @@ const {
-
+
-
- mdi-alert
-
+ mdi-alert
{{ tm("dialogs.versionCompatibility.title") }}
-
- {{ tm("dialogs.versionCompatibility.message") }}
-
+ {{ tm("dialogs.versionCompatibility.message") }}
{{ versionCompatibilityDialog.message }}
-
-
+
+
{{ tm("dialogs.versionCompatibility.cancel") }}
-
+
{{ tm("dialogs.versionCompatibility.confirm") }}
@@ -641,10 +527,7 @@ const {
-
+
@@ -654,7 +537,7 @@ const {
color="primary"
height="2"
:active="loading_"
- />
+ >
@@ -662,22 +545,12 @@ const {
-
-
- {{ tm("dialogs.install.fromFile") }}
-
-
- {{ tm("dialogs.install.fromUrl") }}
-
+
+ {{ tm("dialogs.install.fromFile") }}
+ {{ tm("dialogs.install.fromUrl") }}
-
+
+ >
{{ tm("buttons.selectFile") }}
@@ -704,10 +577,7 @@ const {
{{ tm("messages.supportedFormats") }}
-
+
{{ upload_file.name }}
-
- ({{ (upload_file.size / 1024).toFixed(1) }}KB)
+
+ ({{ (upload_file.size / 1024).toFixed(1) }}KB)
@@ -733,12 +605,9 @@ const {
hide-details
class="rounded-lg mb-4"
placeholder="https://github.com/username/repo"
- />
+ >
-
-
-
- {{
- tm("buttons.cancel")
- }}
-
-
- {{
- tm("buttons.install")
- }}
-
+
+ {{
+ tm("buttons.cancel")
+ }}
+ {{
+ tm("buttons.install")
+ }}
-
+
-
- {{
- tm("market.sourceManagement")
- }}
-
+ {{
+ tm("market.sourceManagement")
+ }}
+ >
-
- {{ tm("market.availableSources") }}
-
+
{{ tm("market.availableSources") }}
-
+
-
-
+
+
{{ tm("market.defaultSource") }}
@@ -877,64 +719,47 @@ const {
:active="selectedSource === source.url"
@click="selectPluginSource(source.url)"
>
-
-
+
+
{{ source.name }}
-
- {{
- source.url
- }}
-
-
+ {{
+ source.url
+ }}
+
+ >
+ >
-
-
- {{
- tm("buttons.close")
- }}
-
+
+ {{
+ tm("buttons.close")
+ }}
-
+
-
- {{
- editingSource ? tm("market.editSource") : tm("market.addSource")
- }}
-
+ {{
+ editingSource ? tm("market.editSource") : tm("market.addSource")
+ }}
+ >
+ >
{{ tm("messages.enterJsonUrl") }}
@@ -962,115 +787,64 @@ const {
-
-
- {{
- tm("buttons.cancel")
- }}
-
-
- {{
- tm("buttons.save")
- }}
-
+
+ {{
+ tm("buttons.cancel")
+ }}
+ {{
+ tm("buttons.save")
+ }}
-
+
-
- mdi-alert-circle
-
+ mdi-alert-circle
{{ tm("dialogs.uninstall.title") }}
{{ tm("market.confirmRemoveSource") }}
-
+
{{ sourceToRemove.name }}
-
- {{ sourceToRemove.url }}
-
+
{{ sourceToRemove.url }}
-
+
{{ tm("buttons.cancel") }}
- {{ tm("buttons.cancel") }}
-
-
- {{
- tm("buttons.deleteSource")
- }}
-
+ {{
+ tm("buttons.deleteSource")
+ }}
-
+
-
- mdi-information-outline
-
+ mdi-information-outline
{{ tm("dialogs.forceUpdate.title") }}
{{ tm("dialogs.forceUpdate.message") }}
-
-
- {{
- tm("buttons.cancel")
- }}
-
-
- {{
- tm("dialogs.forceUpdate.confirm")
- }}
-
+
+ {{
+ tm("buttons.cancel")
+ }}
+ {{
+ tm("dialogs.forceUpdate.confirm")
+ }}
diff --git a/dashboard/tests/subsetMdiFont.test.mjs b/dashboard/tests/subsetMdiFont.test.mjs
index ac232df91..8bfdbb01e 100644
--- a/dashboard/tests/subsetMdiFont.test.mjs
+++ b/dashboard/tests/subsetMdiFont.test.mjs
@@ -11,6 +11,7 @@ import {
resolveUsedIcons,
extractUtilityCss,
ICON_CLASS_PATTERN,
+ REQUIRED_ICONS,
} from '../scripts/subset-mdi-font.mjs';
// ── Helper: create a temporary directory tree for file-system tests ─────────
@@ -83,9 +84,11 @@ test('scanUsedIcons extracts mdi-* icon names from files', () => {
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 });
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
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 提供了“强大”的配置解析和可视化功能。能够让用户在管理面板上直接配置插件,而不需要修改代码。
## 配置定义
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()
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(