mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-05 20:30:14 +08:00
Compare commits
31 Commits
feat/opena
...
fix/7022
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd53e0e751 | ||
|
|
383df74e34 | ||
|
|
26627887d1 | ||
|
|
a5e86c8b94 | ||
|
|
af6f9cfc5e | ||
|
|
8986d05309 | ||
|
|
045be7943d | ||
|
|
cd4e999526 | ||
|
|
6db9aef3ea | ||
|
|
22e24e5f7b | ||
|
|
e5e8bd5d31 | ||
|
|
1ad7e10c0f | ||
|
|
b241b46970 | ||
|
|
d6b1709108 | ||
|
|
c1fa05e18f | ||
|
|
2b5d86b35c | ||
|
|
b2718b07b6 | ||
|
|
c55f2546e2 | ||
|
|
e4ce090db2 | ||
|
|
11c7591b17 | ||
|
|
d7f8af5d42 | ||
|
|
adc252a343 | ||
|
|
2031f3da74 | ||
|
|
5e63635d52 | ||
|
|
273bcac32a | ||
|
|
4c7525c611 | ||
|
|
cc28bc435f | ||
|
|
c6f4dd1d26 | ||
|
|
364b62008c | ||
|
|
2e16281338 | ||
|
|
212c681459 |
19
README.md
19
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
|
||||
|
||||
15
README_fr.md
15
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
|
||||
|
||||
|
||||
15
README_ja.md
15
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
|
||||
|
||||
|
||||
15
README_ru.md
15
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
19
README_zh.md
19
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
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.22.0"
|
||||
__version__ = "4.22.1"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -238,6 +238,9 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
)
|
||||
|
||||
elif session.message_type == MessageType.FRIEND_MESSAGE:
|
||||
# 参考 https://bot.q.qq.com/wiki/develop/pythonsdk/api/message/post_message.html
|
||||
# msg_id 缺失时认为是主动推送,而似乎至少在私聊上主动推送是没有被限制的,这里直接移除 msg_id 可以避免越权或 msg_id 不可用的bug
|
||||
payload.pop("msg_id", None)
|
||||
payload["msg_seq"] = random.randint(1, 10000)
|
||||
if image_base64:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
|
||||
@@ -268,9 +271,6 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
# QQ API rejects msg_id for media (video/file) messages sent
|
||||
# via the proactive tool-call path; remove it to avoid 越权 error.
|
||||
payload.pop("msg_id", None)
|
||||
if file_source:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
@@ -282,7 +282,6 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("msg_id", None)
|
||||
|
||||
ret = await QQOfficialMessageEvent.post_c2c_message(
|
||||
send_helper, # type: ignore
|
||||
|
||||
@@ -335,6 +335,18 @@ class TelegramPlatformAdapter(Platform):
|
||||
logger.warning("Received an update without a message.")
|
||||
return None
|
||||
|
||||
def _apply_caption() -> None:
|
||||
if update.message.caption:
|
||||
message.message_str = update.message.caption
|
||||
message.message.append(Comp.Plain(message.message_str))
|
||||
if update.message.caption and update.message.caption_entities:
|
||||
for entity in update.message.caption_entities:
|
||||
if entity.type == "mention":
|
||||
name = update.message.caption[
|
||||
entity.offset + 1 : entity.offset + entity.length
|
||||
]
|
||||
message.message.append(Comp.At(qq=name, name=name))
|
||||
|
||||
message = AstrBotMessage()
|
||||
message.session_id = str(update.message.chat.id)
|
||||
|
||||
@@ -454,16 +466,7 @@ class TelegramPlatformAdapter(Platform):
|
||||
photo = update.message.photo[-1] # get the largest photo
|
||||
file = await photo.get_file()
|
||||
message.message.append(Comp.Image(file=file.file_path, url=file.file_path))
|
||||
if update.message.caption:
|
||||
message.message_str = update.message.caption
|
||||
message.message.append(Comp.Plain(message.message_str))
|
||||
if update.message.caption_entities:
|
||||
for entity in update.message.caption_entities:
|
||||
if entity.type == "mention":
|
||||
name = message.message_str[
|
||||
entity.offset + 1 : entity.offset + entity.length
|
||||
]
|
||||
message.message.append(Comp.At(qq=name, name=name))
|
||||
_apply_caption()
|
||||
|
||||
elif update.message.sticker:
|
||||
# 将sticker当作图片处理
|
||||
@@ -486,6 +489,7 @@ class TelegramPlatformAdapter(Platform):
|
||||
message.message.append(
|
||||
Comp.File(file=file_path, name=file_name, url=file_path)
|
||||
)
|
||||
_apply_caption()
|
||||
|
||||
elif update.message.video:
|
||||
file = await update.message.video.get_file()
|
||||
@@ -497,6 +501,7 @@ class TelegramPlatformAdapter(Platform):
|
||||
)
|
||||
else:
|
||||
message.message.append(Comp.Video(file=file_path, path=file.file_path))
|
||||
_apply_caption()
|
||||
|
||||
return message
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import os
|
||||
|
||||
from wechatpy.enterprise import WeChatClient
|
||||
from wechatpy.exceptions import WeChatClientException
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
@@ -95,7 +96,19 @@ class WecomPlatformEvent(AstrMessageEvent):
|
||||
# Split long text messages if needed
|
||||
plain_chunks = await self.split_plain(comp.text)
|
||||
for chunk in plain_chunks:
|
||||
kf_message_api.send_text(user_id, self.get_self_id(), chunk)
|
||||
try:
|
||||
kf_message_api.send_text(user_id, self.get_self_id(), chunk)
|
||||
except WeChatClientException as e:
|
||||
if getattr(e, "errcode", None) == 40096:
|
||||
# 40096: invalid external userid, fallback to regular message API
|
||||
logger.warning(
|
||||
f"kf API error 40096 for user {user_id}, falling back to regular message API"
|
||||
)
|
||||
self.client.message.send_text(
|
||||
self.get_self_id(), user_id, chunk
|
||||
)
|
||||
else:
|
||||
raise
|
||||
await asyncio.sleep(0.5) # Avoid sending too fast
|
||||
elif isinstance(comp, Image):
|
||||
img_path = await comp.convert_to_file_path()
|
||||
|
||||
@@ -895,7 +895,20 @@ class WeixinOCAdapter(Platform):
|
||||
await asyncio.sleep(self.qr_poll_interval)
|
||||
continue
|
||||
|
||||
await self._poll_inbound_updates()
|
||||
try:
|
||||
await self._poll_inbound_updates()
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug(
|
||||
"weixin_oc(%s): inbound long-poll timeout",
|
||||
self.meta().id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"weixin_oc(%s): poll inbound updates failed, will retry after 5 seconds: %s",
|
||||
self.meta().id,
|
||||
e,
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -24,9 +24,15 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
if proxy:
|
||||
logger.info(f"[OpenAI Embedding] {provider_id} Using proxy: {proxy}")
|
||||
http_client = httpx.AsyncClient(proxy=proxy)
|
||||
api_base = provider_config.get(
|
||||
"embedding_api_base", "https://api.openai.com/v1"
|
||||
).strip()
|
||||
api_base = (
|
||||
provider_config.get("embedding_api_base", "https://api.openai.com/v1")
|
||||
.strip()
|
||||
.removesuffix("/")
|
||||
.removesuffix("/embeddings")
|
||||
)
|
||||
if api_base and not api_base.endswith("/v1") and not api_base.endswith("/v4"):
|
||||
# /v4 see #5699
|
||||
api_base = api_base + "/v1"
|
||||
logger.info(f"[OpenAI Embedding] {provider_id} Using API Base: {api_base}")
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=provider_config.get("embedding_api_key"),
|
||||
|
||||
@@ -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,
|
||||
@@ -334,11 +520,15 @@ class ProviderOpenAIOfficial(Provider):
|
||||
choice = chunk.choices[0]
|
||||
delta = choice.delta
|
||||
|
||||
# siliconflow workaround
|
||||
if dtcs := delta.tool_calls:
|
||||
for tc in dtcs:
|
||||
for idx, tc in enumerate(dtcs):
|
||||
# siliconflow workaround
|
||||
if tc.function and tc.function.arguments:
|
||||
tc.type = "function"
|
||||
# Fix for #6661: Add missing 'index' field to tool_call deltas
|
||||
# Gemini and some OpenAI-compatible proxies omit this field
|
||||
if not hasattr(tc, "index") or tc.index is None:
|
||||
tc.index = idx
|
||||
try:
|
||||
state.handle_chunk(chunk)
|
||||
except Exception as e:
|
||||
@@ -600,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:
|
||||
@@ -618,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 = {"messages": context_query, "model": model}
|
||||
@@ -718,6 +911,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)
|
||||
@@ -919,23 +1124,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 = []
|
||||
|
||||
@@ -955,7 +1143,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:
|
||||
@@ -964,7 +1154,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)
|
||||
|
||||
@@ -983,11 +1173,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:
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -41,8 +41,8 @@ class OpenApiRoute(Route):
|
||||
"/v1/chat/sessions": ("GET", self.get_chat_sessions),
|
||||
"/v1/configs": ("GET", self.get_chat_configs),
|
||||
"/v1/file": [
|
||||
("POST", self.upload_file),
|
||||
("GET", self.get_file),
|
||||
("POST", self.openapi_upload_file),
|
||||
("GET", self.openapi_get_file),
|
||||
],
|
||||
"/v1/im/message": ("POST", self.send_message),
|
||||
"/v1/im/bots": ("GET", self.get_bots),
|
||||
@@ -537,10 +537,10 @@ class OpenApiRoute(Route):
|
||||
except Exception as e:
|
||||
logger.debug("Open API WS connection closed: %s", e)
|
||||
|
||||
async def upload_file(self):
|
||||
async def openapi_upload_file(self):
|
||||
return await self.chat_route.post_file()
|
||||
|
||||
async def get_file(self):
|
||||
async def openapi_get_file(self):
|
||||
return await self.chat_route.get_attachment()
|
||||
|
||||
async def get_chat_sessions(self):
|
||||
|
||||
@@ -328,37 +328,87 @@ class SessionManagementRoute(Route):
|
||||
|
||||
请求体:
|
||||
{
|
||||
"umos": ["平台:消息类型:会话ID", ...] // umo 列表
|
||||
"umos": ["平台:消息类型:会话ID", ...], // 可选
|
||||
"scope": "all" | "group" | "private" | "custom_group", // 可选,批量范围
|
||||
"group_id": "分组ID", // 当 scope 为 custom_group 时必填
|
||||
"rule_key": "session_service_config" | ... (可选,不传则删除所有规则)
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
data = await request.get_json()
|
||||
umos = data.get("umos", [])
|
||||
scope = data.get("scope", "")
|
||||
group_id = data.get("group_id", "")
|
||||
rule_key = data.get("rule_key")
|
||||
|
||||
# 如果指定了 scope,获取符合条件的所有 umo
|
||||
if scope and not umos:
|
||||
# 如果是自定义分组
|
||||
if scope == "custom_group":
|
||||
if not group_id:
|
||||
return Response().error("请指定分组 ID").__dict__
|
||||
groups = self._get_groups()
|
||||
if group_id not in groups:
|
||||
return Response().error(f"分组 '{group_id}' 不存在").__dict__
|
||||
umos = groups[group_id].get("umos", [])
|
||||
else:
|
||||
async with self.db_helper.get_db() as session:
|
||||
session: AsyncSession
|
||||
result = await session.execute(
|
||||
select(ConversationV2.user_id).distinct()
|
||||
)
|
||||
all_umos = [row[0] for row in result.fetchall()]
|
||||
|
||||
if scope == "group":
|
||||
umos = [
|
||||
u
|
||||
for u in all_umos
|
||||
if ":group:" in u.lower() or ":groupmessage:" in u.lower()
|
||||
]
|
||||
elif scope == "private":
|
||||
umos = [
|
||||
u
|
||||
for u in all_umos
|
||||
if ":private:" in u.lower() or ":friend" in u.lower()
|
||||
]
|
||||
elif scope == "all":
|
||||
umos = all_umos
|
||||
|
||||
if not umos:
|
||||
return Response().error("缺少必要参数: umos").__dict__
|
||||
return Response().error("缺少必要参数: umos 或有效的 scope").__dict__
|
||||
|
||||
if not isinstance(umos, list):
|
||||
return Response().error("参数 umos 必须是数组").__dict__
|
||||
|
||||
if rule_key and rule_key not in AVAILABLE_SESSION_RULE_KEYS:
|
||||
return Response().error(f"不支持的规则键: {rule_key}").__dict__
|
||||
|
||||
# 批量删除
|
||||
deleted_count = 0
|
||||
success_count = 0
|
||||
failed_umos = []
|
||||
for umo in umos:
|
||||
try:
|
||||
await sp.clear_async("umo", umo)
|
||||
deleted_count += 1
|
||||
if rule_key:
|
||||
await sp.session_remove(umo, rule_key)
|
||||
else:
|
||||
await sp.clear_async("umo", umo)
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"删除 umo {umo} 的规则失败: {e!s}")
|
||||
failed_umos.append(umo)
|
||||
|
||||
message = f"已删除 {success_count} 条规则"
|
||||
if rule_key:
|
||||
message = f"已删除 {success_count} 条 {rule_key} 规则"
|
||||
|
||||
if failed_umos:
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"message": f"已删除 {deleted_count} 条规则,{len(failed_umos)} 条删除失败",
|
||||
"deleted_count": deleted_count,
|
||||
"message": f"{message},{len(failed_umos)} 条删除失败",
|
||||
"success_count": success_count,
|
||||
"failed_umos": failed_umos,
|
||||
}
|
||||
)
|
||||
@@ -369,8 +419,8 @@ class SessionManagementRoute(Route):
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"message": f"已删除 {deleted_count} 条规则",
|
||||
"deleted_count": deleted_count,
|
||||
"message": message,
|
||||
"success_count": success_count,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
49
changelogs/v4.22.1.md
Normal file
49
changelogs/v4.22.1.md
Normal file
@@ -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))
|
||||
@@ -33,6 +33,45 @@ const UTILITY_CLASSES = new Set([
|
||||
"mdi-18px", "mdi-24px", "mdi-36px", "mdi-48px",
|
||||
]);
|
||||
|
||||
// Icons used indirectly by Vuetify internals, so they won't appear in src/ static scans.
|
||||
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
|
||||
export const ICON_CLASS_PATTERN = /\.(mdi-[a-z][a-z0-9-]*)::before\s*\{\s*content:\s*"\\([0-9A-Fa-f]+)"\s*;?\s*}/g;
|
||||
|
||||
@@ -53,7 +92,7 @@ export function* collectFiles(dir, exts) {
|
||||
/** Scan source files and return a Set of used mdi-* icon names. */
|
||||
export function scanUsedIcons(sourceFiles) {
|
||||
const iconPattern = /mdi-[a-z][a-z0-9-]*/g;
|
||||
const usedIcons = new Set();
|
||||
const usedIcons = new Set(REQUIRED_ICONS);
|
||||
for (const file of sourceFiles) {
|
||||
const content = readFileSync(file, "utf-8");
|
||||
for (const match of content.matchAll(iconPattern)) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Auto-generated MDI subset – 235 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";
|
||||
}
|
||||
@@ -744,6 +776,14 @@
|
||||
content: "\F0432";
|
||||
}
|
||||
|
||||
.mdi-radiobox-blank::before {
|
||||
content: "\F043D";
|
||||
}
|
||||
|
||||
.mdi-radiobox-marked::before {
|
||||
content: "\F043E";
|
||||
}
|
||||
|
||||
.mdi-refresh::before {
|
||||
content: "\F0450";
|
||||
}
|
||||
@@ -840,6 +880,14 @@
|
||||
content: "\F1C55";
|
||||
}
|
||||
|
||||
.mdi-star-half-full::before {
|
||||
content: "\F04D0";
|
||||
}
|
||||
|
||||
.mdi-star-outline::before {
|
||||
content: "\F04D2";
|
||||
}
|
||||
|
||||
.mdi-stop::before {
|
||||
content: "\F04DB";
|
||||
}
|
||||
@@ -896,6 +944,10 @@
|
||||
content: "\F0A7A";
|
||||
}
|
||||
|
||||
.mdi-unfold-more-horizontal::before {
|
||||
content: "\F054F";
|
||||
}
|
||||
|
||||
.mdi-update::before {
|
||||
content: "\F06B0";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -39,6 +39,16 @@ const emit = defineEmits([
|
||||
const handlePinnedImgError = (e) => {
|
||||
e.target.src = defaultPluginIcon;
|
||||
};
|
||||
|
||||
const authorDisplay = computed(() => {
|
||||
const p = props.plugin || {};
|
||||
if (typeof p.author === 'string' && p.author.trim()) return p.author;
|
||||
if (Array.isArray(p.authors) && p.authors.length) return p.authors.join(', ');
|
||||
if (typeof p.author_name === 'string' && p.author_name.trim()) return p.author_name;
|
||||
if (typeof p.owner === 'string' && p.owner.trim()) return p.owner;
|
||||
if (p.author && typeof p.author === 'object' && p.author.name) return p.author.name;
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -70,6 +80,22 @@ const handlePinnedImgError = (e) => {
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-card-title class="d-flex" style="gap:8px; padding:12px; align-items:center;">
|
||||
<div style="display:flex; align-items:center; gap:8px; min-width:0;">
|
||||
<v-avatar size="40" class="pinned-avatar" style="width:40px; height:40px;">
|
||||
<img
|
||||
:src="(typeof plugin.logo === 'string' && plugin.logo.trim()) ? plugin.logo : defaultPluginIcon"
|
||||
:alt="plugin.name"
|
||||
@error="handlePinnedImgError"
|
||||
/>
|
||||
</v-avatar>
|
||||
<div style="min-width:0; overflow:hidden;">
|
||||
<div style="font-weight:600; font-size:0.95rem; white-space:nowrap; text-overflow:ellipsis; overflow:hidden;">{{ plugin.display_name || plugin.name }}</div>
|
||||
<div style="font-size:0.8rem; color:var(--v-theme-on-surface); opacity:0.8; white-space:nowrap; text-overflow:ellipsis; overflow:hidden;">{{ authorDisplay || (plugin.author || '') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text class="d-flex" style="gap:8px; padding:12px;">
|
||||
<v-tooltip location="top" :text="tm('buttons.viewDocs')">
|
||||
<template #activator="{ props: a }">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -259,7 +259,7 @@
|
||||
<v-card-text class="py-4">
|
||||
<p>{{ tm('dialog.securityWarning.aiocqhttpTokenMissing') }}</p>
|
||||
<span><a
|
||||
href="https://docs.astrbot.app/deploy/platform/aiocqhttp/napcat.html#%E9%99%84%E5%BD%95-%E5%A2%9E%E5%BC%BA%E8%BF%9E%E6%8E%A5%E5%AE%89%E5%85%A8%E6%80%A7"
|
||||
href="https://docs.astrbot.app/platform/aiocqhttp.html"
|
||||
target="_blank">{{ tm('dialog.securityWarning.learnMore') }}</a></span>
|
||||
</v-card-text>
|
||||
<v-card-actions class="px-4 pb-4">
|
||||
|
||||
@@ -68,6 +68,17 @@ const astrbotVersionRequirement = computed(() => {
|
||||
: "";
|
||||
});
|
||||
|
||||
// 作者显示(兼容多种字段名)
|
||||
const authorDisplay = computed(() => {
|
||||
const ext = props.extension || {};
|
||||
if (typeof ext.author === 'string' && ext.author.trim()) return ext.author;
|
||||
if (Array.isArray(ext.authors) && ext.authors.length) return ext.authors.join(', ');
|
||||
if (typeof ext.author_name === 'string' && ext.author_name.trim()) return ext.author_name;
|
||||
if (typeof ext.owner === 'string' && ext.owner.trim()) return ext.owner;
|
||||
if (ext.author && typeof ext.author === 'object' && ext.author.name) return ext.author.name;
|
||||
return '';
|
||||
});
|
||||
|
||||
const logoLoadFailed = ref(false);
|
||||
|
||||
const logoSrc = computed(() => {
|
||||
@@ -345,6 +356,10 @@ const viewChangelog = () => {
|
||||
{{ tag === "danger" ? tm("tags.danger") : tag }}
|
||||
</v-chip>
|
||||
<PluginPlatformChip :platforms="supportPlatforms" />
|
||||
<v-chip v-if="authorDisplay" color="info" label size="small">
|
||||
<v-icon icon="mdi-account" start></v-icon>
|
||||
{{ authorDisplay }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="astrbotVersionRequirement"
|
||||
color="secondary"
|
||||
|
||||
@@ -19,7 +19,6 @@ export function useSessions(chatboxMode: boolean = false) {
|
||||
const selectedSessions = ref<string[]>([]);
|
||||
const currSessionId = ref('');
|
||||
const pendingSessionId = ref<string | null>(null);
|
||||
|
||||
// 编辑标题相关
|
||||
const editTitleDialog = ref(false);
|
||||
const editingTitle = ref('');
|
||||
@@ -30,29 +29,16 @@ export function useSessions(chatboxMode: boolean = false) {
|
||||
return sessions.value.find(s => s.session_id === currSessionId.value);
|
||||
});
|
||||
|
||||
|
||||
|
||||
async function getSessions() {
|
||||
try {
|
||||
const response = await axios.get('/api/chat/sessions');
|
||||
sessions.value = response.data.data;
|
||||
|
||||
// 处理待加载的会话
|
||||
if (pendingSessionId.value) {
|
||||
const session = sessions.value.find(s => s.session_id === pendingSessionId.value);
|
||||
if (session) {
|
||||
selectedSessions.value = [pendingSessionId.value];
|
||||
pendingSessionId.value = null;
|
||||
}
|
||||
} else if (currSessionId.value) {
|
||||
// 如果当前有选中的会话,确保它在列表中并被选中
|
||||
const session = sessions.value.find(s => s.session_id === currSessionId.value);
|
||||
if (session) {
|
||||
selectedSessions.value = [currSessionId.value];
|
||||
}
|
||||
} else if (sessions.value.length > 0) {
|
||||
// 默认选择第一个会话
|
||||
const firstSession = sessions.value[0];
|
||||
selectedSessions.value = [firstSession.session_id];
|
||||
}
|
||||
|
||||
|
||||
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 401) {
|
||||
router.push('/auth/login?redirect=/chatbox');
|
||||
|
||||
@@ -1237,7 +1237,7 @@
|
||||
"description": "API Base URL"
|
||||
},
|
||||
"openai_embedding": {
|
||||
"hint": "OpenAI Embedding automatically appends /v1 at request time."
|
||||
"hint": "If testing fails, try adding /v1 at the end to be compatible with some OpenAI API versions."
|
||||
},
|
||||
"gemini_embedding": {
|
||||
"hint": "Gemini Embedding does not require manually adding /v1beta."
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1234,7 +1234,7 @@
|
||||
"description": "Адрес прокси-сервера"
|
||||
},
|
||||
"openai_embedding": {
|
||||
"hint": "OpenAI Embedding автоматически добавляет /v1 при запросе."
|
||||
"hint": "Если тест не проходит, попробуйте добавить /v1 в конец embedding_api_base для совместимости с некоторыми версиями OpenAI API."
|
||||
},
|
||||
"gemini_embedding": {
|
||||
"hint": "Gemini Embedding не требует ручного добавления /v1beta."
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
"emptyHint": "Пожалуйста, загрузите архив с навыками",
|
||||
"uploadDialogTitle": "Загрузка навыков",
|
||||
"uploadHint": "Поддерживается массовая загрузка zip-архивов. Вы также можете перетащить файлы в это окно. Система автоматически проверит структуру каждого архива.",
|
||||
"structureRequirement": "Архив должен содержать одну корневую папку (например, `skillname/`), внутри которой обязательно должен находиться файл `SKILL.md`.",
|
||||
"structureRequirement": "Поддерживаются архивы с несколькими папками skills.",
|
||||
"abilityMultiple": "Поддержка массовой загрузки",
|
||||
"abilityValidate": "Автопроверка `SKILL.md`",
|
||||
"abilitySkip": "Пропуск дубликатов",
|
||||
|
||||
@@ -1239,7 +1239,7 @@
|
||||
"description": "API Base URL"
|
||||
},
|
||||
"openai_embedding": {
|
||||
"hint": "OpenAI Embedding 会在请求时自动补上 /v1。"
|
||||
"hint": "如果测试不通过,可以尝试添加 /v1 在末尾以兼容部分 OpenAI API 版本。"
|
||||
},
|
||||
"gemini_embedding": {
|
||||
"hint": "Gemini Embedding 无需手动添加 /v1beta。"
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
"emptyHint": "请上传 Skills 压缩包",
|
||||
"uploadDialogTitle": "上传 Skills",
|
||||
"uploadHint": "支持批量上传 zip 技能包,也支持拖拽批量上传 zip 技能包。系统会自动校验目录结构,并给出逐个文件的结果。",
|
||||
"structureRequirement": "常见失败原因是压缩包结构不正确。每个 zip 必须只包含一个顶层目录,例如 `skillname/`,且该目录下必须存在 `SKILL.md`。",
|
||||
"structureRequirement": "支持压缩包内含多个 skills 文件夹。",
|
||||
"abilityMultiple": "支持一次上传多个zip文件",
|
||||
"abilityValidate": "自动校验 `SKILL.md`",
|
||||
"abilitySkip": "自动跳过重复文件",
|
||||
|
||||
@@ -17,18 +17,10 @@ const customizer = useCustomizerStore();
|
||||
const { locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const routerLoadingStore = useRouterLoadingStore();
|
||||
const isCurrentChatRoute = computed(() => route.path === '/chat' || route.path.startsWith('/chat/'));
|
||||
|
||||
const isChatPage = computed(() => {
|
||||
return route.path.startsWith('/chat');
|
||||
});
|
||||
|
||||
const showSidebar = computed(() => {
|
||||
return customizer.viewMode === 'bot';
|
||||
});
|
||||
|
||||
const showChatPage = computed(() => {
|
||||
return customizer.viewMode === 'chat';
|
||||
});
|
||||
const showSidebar = computed(() => !isCurrentChatRoute.value)
|
||||
|
||||
const migrationDialog = ref<InstanceType<typeof MigrationDialog> | null>(null);
|
||||
const showFirstNoticeDialog = ref(false);
|
||||
@@ -111,20 +103,20 @@ onMounted(() => {
|
||||
<VerticalHeaderVue />
|
||||
<VerticalSidebarVue v-if="showSidebar" />
|
||||
<v-main :style="{
|
||||
height: showChatPage ? 'calc(100vh - 55px)' : undefined,
|
||||
overflow: showChatPage ? 'hidden' : undefined
|
||||
height: isCurrentChatRoute ? 'calc(100vh - 55px)' : undefined,
|
||||
overflow: isCurrentChatRoute ? 'hidden' : undefined
|
||||
}">
|
||||
<v-container
|
||||
fluid
|
||||
class="page-wrapper"
|
||||
:class="{ 'chat-mode-container': showChatPage }"
|
||||
:class="{ 'chat-mode-container': isCurrentChatRoute }"
|
||||
:style="{
|
||||
height: showChatPage ? '100%' : 'calc(100% - 8px)',
|
||||
padding: (isChatPage || showChatPage) ? '0' : undefined,
|
||||
minHeight: showChatPage ? 'unset' : undefined
|
||||
height: isCurrentChatRoute ? '100%' : 'calc(100% - 8px)',
|
||||
padding: isCurrentChatRoute ? '0' : undefined,
|
||||
minHeight: isCurrentChatRoute ? 'unset' : undefined
|
||||
}">
|
||||
<div :style="{ height: '100%', width: '100%', overflow: showChatPage ? 'hidden' : undefined }">
|
||||
<div v-if="showChatPage" style="height: 100%; width: 100%; overflow: hidden;">
|
||||
<div :style="{ height: '100%', width: '100%', overflow: isCurrentChatRoute ? 'hidden' : undefined }">
|
||||
<div v-if="isCurrentChatRoute" style="height: 100%; width: 100%; overflow: hidden;">
|
||||
<Chat />
|
||||
</div>
|
||||
<RouterView v-else />
|
||||
|
||||
@@ -28,6 +28,7 @@ const theme = useTheme();
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const LAST_BOT_ROUTE_KEY = 'astrbot:last_bot_route';
|
||||
const LAST_CHAT_ROUTE_KEY = 'astrbot:last_chat_route';
|
||||
let dialog = ref(false);
|
||||
let accountWarning = ref(false)
|
||||
let updateStatusDialog = ref(false);
|
||||
@@ -58,7 +59,9 @@ const desktopUpdateHasNewVersion = ref(false);
|
||||
const desktopUpdateCurrentVersion = ref('-');
|
||||
const desktopUpdateLatestVersion = ref('-');
|
||||
const desktopUpdateStatus = ref('');
|
||||
|
||||
const isChatPath = computed(() =>
|
||||
route.path === '/chat' || route.path.startsWith('/chat/')
|
||||
);
|
||||
const getAppUpdaterBridge = (): AstrBotAppUpdaterBridge | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
@@ -380,7 +383,7 @@ function openReleaseNotesDialog(body: string, tag: string) {
|
||||
}
|
||||
|
||||
function handleLogoClick() {
|
||||
if (customizer.viewMode === 'chat') {
|
||||
if (isChatPath.value) {
|
||||
aboutDialog.value = true;
|
||||
} else {
|
||||
router.push('/about');
|
||||
@@ -395,10 +398,22 @@ commonStore.createEventSource(); // log
|
||||
commonStore.getStartTime();
|
||||
|
||||
// 视图模式切换
|
||||
const viewMode = computed({
|
||||
get: () => customizer.viewMode,
|
||||
set: (value: 'bot' | 'chat') => {
|
||||
customizer.SET_VIEW_MODE(value);
|
||||
onMounted(() => {
|
||||
// 初次加載時保存當前路由
|
||||
if (typeof window !== 'undefined') {
|
||||
if (isChatPath.value) {
|
||||
// 保存 chat ID
|
||||
const parts = route.fullPath.split('/');
|
||||
const sessionId = parts[2];
|
||||
if (sessionId) {
|
||||
sessionStorage.setItem(LAST_CHAT_ROUTE_KEY, sessionId);
|
||||
console.log('Initial save chat ID:', sessionId);
|
||||
}
|
||||
} else {
|
||||
// 保存 bot 路由(非 chat 頁面)
|
||||
sessionStorage.setItem(LAST_BOT_ROUTE_KEY, route.fullPath);
|
||||
console.log('Initial save bot route:', route.fullPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -406,26 +421,61 @@ const viewMode = computed({
|
||||
// 保存 bot 模式的最後路由
|
||||
// 監聽 route 變化,保存最後一次 bot 路由
|
||||
watch(() => route.fullPath, (newPath) => {
|
||||
if (customizer.viewMode === 'bot' && typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(LAST_BOT_ROUTE_KEY, newPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to save last bot route to localStorage:', e);
|
||||
if (typeof window === 'undefined') return;
|
||||
console.log('Route changed:', {
|
||||
newPath,
|
||||
isChat: isChatPath.value,
|
||||
currentChatId: route.params.id
|
||||
});
|
||||
try {
|
||||
// 使用現有的 isChatPath 計算屬性來避免名稱衝突
|
||||
const isChat = isChatPath.value; // 這裡使用已經計算好的 isChatPath
|
||||
|
||||
// ✅ bot:只存「非 chat 頁」
|
||||
if (!isChat) {
|
||||
sessionStorage.setItem(LAST_BOT_ROUTE_KEY, newPath);
|
||||
}
|
||||
|
||||
// ✅ chat:只存 sessionId
|
||||
if (isChat) {
|
||||
const parts = newPath.split('/');
|
||||
const sessionId = parts[2];
|
||||
|
||||
if (sessionId) {
|
||||
sessionStorage.setItem(LAST_CHAT_ROUTE_KEY, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to save route:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// 監聽 viewMode 切換
|
||||
watch(() => customizer.viewMode, (newMode, oldMode) => {
|
||||
if (newMode === 'bot' && oldMode === 'chat' && typeof window !== 'undefined') {
|
||||
// 從 chat 切換回 bot,跳轉到最後一次的 bot 路由
|
||||
let lastBotRoute = '/';
|
||||
const currentMode = computed({
|
||||
get: () => (isChatPath.value ? 'chat' : 'bot'),
|
||||
set: (val: 'chat' | 'bot') => {
|
||||
try {
|
||||
lastBotRoute = localStorage.getItem(LAST_BOT_ROUTE_KEY) || '/';
|
||||
// 檢查 window 和 sessionStorage 是否存在
|
||||
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
|
||||
// 如果在非瀏覽器環境中,不做任何 sessionStorage 操作
|
||||
console.warn('sessionStorage is not available in this environment');
|
||||
return;
|
||||
}
|
||||
|
||||
if (val === 'chat') {
|
||||
const lastSessionId = sessionStorage.getItem(LAST_CHAT_ROUTE_KEY);
|
||||
router.push(lastSessionId ? `/chat/${lastSessionId}` : '/chat');
|
||||
} else {
|
||||
let lastBotRoute = sessionStorage.getItem(LAST_BOT_ROUTE_KEY) || '/';
|
||||
if (lastBotRoute.startsWith('/chat')) {
|
||||
lastBotRoute = '/';
|
||||
}
|
||||
router.push(lastBotRoute);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to read last bot route from localStorage:', e);
|
||||
// 在受限隱私模式等環境中,sessionStorage 操作可能會拋出 SecurityError
|
||||
console.warn('Failed to access sessionStorage in currentMode setter:', e);
|
||||
}
|
||||
router.push(lastBotRoute);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -465,29 +515,46 @@ onMounted(async () => {
|
||||
<v-app-bar elevation="0" height="50" class="top-header">
|
||||
|
||||
<!-- 桌面端 menu 按钮 - 仅在 bot 模式下显示 -->
|
||||
<v-btn v-if="customizer.viewMode === 'bot'"
|
||||
style="margin-left: 16px;"
|
||||
class="hidden-md-and-down" icon rounded="sm" variant="flat"
|
||||
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
<!-- 移动端 menu 按钮 - 仅在 bot 模式下显示 -->
|
||||
<v-btn v-if="customizer.viewMode === 'bot'" class="hidden-lg-and-up ms-3" icon rounded="sm" variant="flat"
|
||||
@click.stop="customizer.SET_SIDEBAR_DRAWER">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!isChatPath"
|
||||
style="margin-left: 16px;"
|
||||
class="hidden-md-and-down"
|
||||
icon
|
||||
rounded="sm"
|
||||
variant="flat"
|
||||
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)"
|
||||
>
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<!-- 移动端 chat sidebar 展开按钮 - 仅在 chat 模式下的小屏幕显示 -->
|
||||
<v-btn v-if="customizer.viewMode === 'chat'" class="hidden-lg-and-up ms-1" icon rounded="sm" variant="flat"
|
||||
@click.stop="customizer.TOGGLE_CHAT_SIDEBAR()">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
<!-- 移动端 menu 按钮 -->
|
||||
<v-btn
|
||||
v-if="!isChatPath"
|
||||
class="hidden-lg-and-up ms-3"
|
||||
icon
|
||||
rounded="sm"
|
||||
variant="flat"
|
||||
@click.stop="customizer.SET_SIDEBAR_DRAWER"
|
||||
>
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<div class="logo-container" :class="{ 'mobile-logo': $vuetify.display.xs, 'chat-mode-logo': customizer.viewMode === 'chat' }" @click="handleLogoClick">
|
||||
<v-btn
|
||||
v-if="isChatPath"
|
||||
class="hidden-lg-and-up ms-1"
|
||||
icon
|
||||
rounded="sm"
|
||||
variant="flat"
|
||||
@click.stop="customizer.TOGGLE_CHAT_SIDEBAR()"
|
||||
>
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<div class="logo-container" :class="{ 'mobile-logo': $vuetify.display.xs, 'chat-mode-logo': isChatPath }" @click="handleLogoClick">
|
||||
<span class="logo-text Outfit">Astr<span class="logo-text bot-text-wrapper">Bot
|
||||
<img v-if="isChristmas" src="@/assets/images/xmas-hat.png" alt="Christmas hat" class="xmas-hat" />
|
||||
</span></span>
|
||||
<span class="logo-text logo-text-light Outfit" style="color: grey;" v-if="customizer.viewMode === 'chat'">ChatUI</span>
|
||||
<span class="logo-text logo-text-light Outfit" style="color: grey;" v-if="isChatPath">ChatUI</span>
|
||||
<span class="version-text hidden-xs">{{ botCurrVersion }}</span>
|
||||
</div>
|
||||
|
||||
@@ -504,23 +571,23 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- Bot/Chat 模式切换按钮 - 手机端隐藏,移入 ... 菜单 -->
|
||||
<v-btn-toggle
|
||||
v-model="viewMode"
|
||||
mandatory
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mr-4 hidden-xs"
|
||||
color="primary"
|
||||
>
|
||||
<v-btn value="bot" size="small">
|
||||
<v-icon start>mdi-robot</v-icon>
|
||||
Bot
|
||||
</v-btn>
|
||||
<v-btn value="chat" size="small">
|
||||
<v-icon start>mdi-chat</v-icon>
|
||||
Chat
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
<v-btn-toggle
|
||||
v-model="currentMode"
|
||||
mandatory
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mr-4 hidden-xs"
|
||||
color="primary"
|
||||
>
|
||||
<v-btn value="bot" size="small">
|
||||
<v-icon start>mdi-robot</v-icon>
|
||||
Bot
|
||||
</v-btn>
|
||||
<v-btn value="chat" size="small">
|
||||
<v-icon start>mdi-chat</v-icon>
|
||||
Chat
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
|
||||
<!-- 功能菜单 -->
|
||||
@@ -542,14 +609,14 @@ onMounted(async () => {
|
||||
<!-- Bot/Chat 模式切换 - 仅在手机端显示 -->
|
||||
<template v-if="$vuetify.display.xs">
|
||||
<div class="mobile-mode-toggle-wrapper">
|
||||
<v-btn-toggle
|
||||
v-model="viewMode"
|
||||
mandatory
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="mobile-mode-toggle"
|
||||
>
|
||||
<v-btn-toggle
|
||||
v-model="currentMode"
|
||||
mandatory
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mobile-mode-toggle"
|
||||
color="primary"
|
||||
>
|
||||
<v-btn value="bot" size="small">
|
||||
<v-icon start>mdi-robot</v-icon>
|
||||
Bot
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,7 +10,6 @@ export const useCustomizerStore = defineStore({
|
||||
fontTheme: "Poppins",
|
||||
uiTheme: config.uiTheme,
|
||||
inputBg: config.inputBg,
|
||||
viewMode: (localStorage.getItem('viewMode') as 'bot' | 'chat') || 'bot', // 'bot' 或 'chat'
|
||||
chatSidebarOpen: false // chat mode mobile sidebar state
|
||||
}),
|
||||
|
||||
@@ -29,10 +28,7 @@ export const useCustomizerStore = defineStore({
|
||||
this.uiTheme = payload;
|
||||
localStorage.setItem("uiTheme", payload);
|
||||
},
|
||||
SET_VIEW_MODE(payload: 'bot' | 'chat') {
|
||||
this.viewMode = payload;
|
||||
localStorage.setItem('viewMode', payload);
|
||||
},
|
||||
|
||||
TOGGLE_CHAT_SIDEBAR() {
|
||||
this.chatSidebarOpen = !this.chatSidebarOpen;
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@ export function getTutorialLink(platformType) {
|
||||
const tutorialMap = {
|
||||
"qq_official_webhook": "https://docs.astrbot.app/platform/qqofficial/webhook.html",
|
||||
"qq_official": "https://docs.astrbot.app/platform/qqofficial/websockets.html",
|
||||
"aiocqhttp": "https://docs.astrbot.app/platform/aiocqhttp/napcat.html",
|
||||
"aiocqhttp": "https://docs.astrbot.app/platform/aiocqhttp.html",
|
||||
"wecom": "https://docs.astrbot.app/platform/wecom.html",
|
||||
"weixin_oc": "https://docs.astrbot.app/platform/weixin_oc.html",
|
||||
"wecom_ai_bot": "https://docs.astrbot.app/platform/wecom_ai_bot.html",
|
||||
|
||||
@@ -227,7 +227,8 @@ const {
|
||||
<v-btn
|
||||
variant="text"
|
||||
prepend-icon="mdi-book-open-variant"
|
||||
href="https://astrbot.app/dev/plugin.html"
|
||||
href="https://docs.astrbot.app/dev/star/plugin-new.html"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
color="primary"
|
||||
class="text-none"
|
||||
@@ -387,6 +388,7 @@ const {
|
||||
elevation="24"
|
||||
:color="snack_success"
|
||||
v-model="snack_show"
|
||||
location="bottom center"
|
||||
>
|
||||
{{ snack_message }}
|
||||
</v-snackbar>
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6" lg="3">
|
||||
<v-select v-model="batchChatProvider" :items="chatProviderOptions" item-title="label" item-value="value"
|
||||
<v-select v-model="batchChatProvider" :items="batchChatProviderOptions" item-title="label" item-value="value"
|
||||
:label="tm('batchOperations.chatProvider')" hide-details clearable variant="solo-filled" flat density="comfortable">
|
||||
</v-select>
|
||||
</v-col>
|
||||
@@ -527,6 +527,8 @@ import {
|
||||
useConfirmDialog
|
||||
} from '@/utils/confirmDialog'
|
||||
|
||||
const FOLLOW_CONFIG_VALUE = '__astrbot_follow_config__'
|
||||
|
||||
export default {
|
||||
name: 'SessionManagementPage',
|
||||
setup() {
|
||||
@@ -584,9 +586,9 @@ export default {
|
||||
|
||||
// Provider 配置
|
||||
providerConfig: {
|
||||
chat_completion: null,
|
||||
speech_to_text: null,
|
||||
text_to_speech: null,
|
||||
chat_completion: FOLLOW_CONFIG_VALUE,
|
||||
speech_to_text: FOLLOW_CONFIG_VALUE,
|
||||
text_to_speech: FOLLOW_CONFIG_VALUE,
|
||||
},
|
||||
|
||||
// 插件配置
|
||||
@@ -671,7 +673,7 @@ export default {
|
||||
|
||||
chatProviderOptions() {
|
||||
return [
|
||||
{ label: this.tm('provider.followConfig'), value: null },
|
||||
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
|
||||
...this.availableChatProviders.map(p => ({
|
||||
label: `${p.name} (${p.model})`,
|
||||
value: p.id
|
||||
@@ -681,7 +683,7 @@ export default {
|
||||
|
||||
sttProviderOptions() {
|
||||
return [
|
||||
{ label: this.tm('provider.followConfig'), value: null },
|
||||
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
|
||||
...this.availableSttProviders.map(p => ({
|
||||
label: `${p.name} (${p.model})`,
|
||||
value: p.id
|
||||
@@ -691,7 +693,27 @@ export default {
|
||||
|
||||
ttsProviderOptions() {
|
||||
return [
|
||||
{ label: this.tm('provider.followConfig'), value: null },
|
||||
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
|
||||
...this.availableTtsProviders.map(p => ({
|
||||
label: `${p.name} (${p.model})`,
|
||||
value: p.id
|
||||
}))
|
||||
]
|
||||
},
|
||||
|
||||
batchChatProviderOptions() {
|
||||
return [
|
||||
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
|
||||
...this.availableChatProviders.map(p => ({
|
||||
label: `${p.name} (${p.model})`,
|
||||
value: p.id
|
||||
}))
|
||||
]
|
||||
},
|
||||
|
||||
batchTtsProviderOptions() {
|
||||
return [
|
||||
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
|
||||
...this.availableTtsProviders.map(p => ({
|
||||
label: `${p.name} (${p.model})`,
|
||||
value: p.id
|
||||
@@ -914,9 +936,9 @@ export default {
|
||||
|
||||
// 初始化 Provider 配置
|
||||
this.providerConfig = {
|
||||
chat_completion: this.editingRules['provider_perf_chat_completion'] || null,
|
||||
speech_to_text: this.editingRules['provider_perf_speech_to_text'] || null,
|
||||
text_to_speech: this.editingRules['provider_perf_text_to_speech'] || null,
|
||||
chat_completion: this.editingRules['provider_perf_chat_completion'] || FOLLOW_CONFIG_VALUE,
|
||||
speech_to_text: this.editingRules['provider_perf_speech_to_text'] || FOLLOW_CONFIG_VALUE,
|
||||
text_to_speech: this.editingRules['provider_perf_text_to_speech'] || FOLLOW_CONFIG_VALUE,
|
||||
}
|
||||
|
||||
// 初始化插件配置
|
||||
@@ -997,7 +1019,7 @@ export default {
|
||||
|
||||
for (const type of providerTypes) {
|
||||
const value = this.providerConfig[type]
|
||||
if (value) {
|
||||
if (value && value !== FOLLOW_CONFIG_VALUE) {
|
||||
// 有值时更新
|
||||
updateTasks.push(
|
||||
axios.post('/api/session/update-rule', {
|
||||
@@ -1007,7 +1029,7 @@ export default {
|
||||
})
|
||||
)
|
||||
} else if (this.editingRules[`provider_perf_${type}`]) {
|
||||
// 选择了"跟随配置文件"(null)且之前有配置,则删除
|
||||
// 选择了"跟随配置文件" (__astrbot_follow_config__) 且之前有配置,则删除
|
||||
deleteTasks.push(
|
||||
axios.post('/api/session/delete-rule', {
|
||||
umo: this.selectedUmo.umo,
|
||||
@@ -1035,9 +1057,10 @@ export default {
|
||||
this.rulesList.push(item)
|
||||
}
|
||||
for (const type of providerTypes) {
|
||||
if (this.providerConfig[type]) {
|
||||
item.rules[`provider_perf_${type}`] = this.providerConfig[type]
|
||||
this.editingRules[`provider_perf_${type}`] = this.providerConfig[type]
|
||||
const val = this.providerConfig[type]
|
||||
if (val && val !== FOLLOW_CONFIG_VALUE) {
|
||||
item.rules[`provider_perf_${type}`] = val
|
||||
this.editingRules[`provider_perf_${type}`] = val
|
||||
} else {
|
||||
// 删除本地数据
|
||||
delete item.rules[`provider_perf_${type}`]
|
||||
@@ -1354,23 +1377,41 @@ export default {
|
||||
}
|
||||
|
||||
if (this.batchChatProvider !== null) {
|
||||
tasks.push(axios.post('/api/session/batch-update-provider', {
|
||||
scope,
|
||||
umos,
|
||||
group_id: groupId,
|
||||
provider_type: 'chat_completion',
|
||||
provider_id: this.batchChatProvider || null
|
||||
}))
|
||||
if (this.batchChatProvider === FOLLOW_CONFIG_VALUE) {
|
||||
tasks.push(axios.post('/api/session/batch-delete-rule', {
|
||||
scope,
|
||||
umos,
|
||||
group_id: groupId,
|
||||
rule_key: 'provider_perf_chat_completion'
|
||||
}))
|
||||
} else {
|
||||
tasks.push(axios.post('/api/session/batch-update-provider', {
|
||||
scope,
|
||||
umos,
|
||||
group_id: groupId,
|
||||
provider_type: 'chat_completion',
|
||||
provider_id: this.batchChatProvider
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if (this.batchTtsProvider !== null) {
|
||||
tasks.push(axios.post('/api/session/batch-update-provider', {
|
||||
scope,
|
||||
umos,
|
||||
group_id: groupId,
|
||||
provider_type: 'text_to_speech',
|
||||
provider_id: this.batchTtsProvider || null
|
||||
}))
|
||||
if (this.batchTtsProvider === FOLLOW_CONFIG_VALUE) {
|
||||
tasks.push(axios.post('/api/session/batch-delete-rule', {
|
||||
scope,
|
||||
umos,
|
||||
group_id: groupId,
|
||||
rule_key: 'provider_perf_text_to_speech'
|
||||
}))
|
||||
} else {
|
||||
tasks.push(axios.post('/api/session/batch-update-provider', {
|
||||
scope,
|
||||
umos,
|
||||
group_id: groupId,
|
||||
provider_type: 'text_to_speech',
|
||||
provider_id: this.batchTtsProvider
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
|
||||
@@ -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,7 +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.equal(icons.size, 2); // mdi-home deduplicated
|
||||
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 });
|
||||
});
|
||||
@@ -101,12 +106,30 @@ test('scanUsedIcons excludes utility classes', () => {
|
||||
rmSync(tmp, { recursive: true });
|
||||
});
|
||||
|
||||
test('scanUsedIcons returns empty set when no icons found', () => {
|
||||
test('scanUsedIcons includes all required icons even when no mdi-* icons are found in source', () => {
|
||||
const tmp = makeTmpDir();
|
||||
writeFileSync(join(tmp, 'A.vue'), '<div>Hello</div>');
|
||||
|
||||
const icons = scanUsedIcons(collectFiles(tmp, ['.vue']));
|
||||
assert.equal(icons.size, 0);
|
||||
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 icons when source already references them', () => {
|
||||
const tmp = makeTmpDir();
|
||||
const requiredIcon = [...REQUIRED_ICONS][0];
|
||||
writeFileSync(join(tmp, 'A.vue'), `<v-icon>${requiredIcon}</v-icon><v-icon>mdi-home</v-icon>`);
|
||||
|
||||
const icons = [...scanUsedIcons(collectFiles(tmp, ['.vue']))];
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -86,4 +86,4 @@ AstrBot 支持接入优云智算提供的模型 API。
|
||||
|
||||
## 更多功能
|
||||
|
||||
更多功能情参考 [AstrBot 官方文档](https://docs.astrbot.app)。
|
||||
更多功能请参考 [AstrBot 官方文档](https://docs.astrbot.app)。
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
随着插件功能的增加,可能需要定义一些配置以让用户自定义插件的行为。
|
||||
|
||||
AstrBot 提供了”强大“的配置解析和可视化功能。能够让用户在管理面板上直接配置插件,而不需要修改代码。
|
||||
AstrBot 提供了“强大”的配置解析和可视化功能。能够让用户在管理面板上直接配置插件,而不需要修改代码。
|
||||
|
||||
## 配置定义
|
||||
|
||||
|
||||
@@ -88,10 +88,12 @@
|
||||
|
||||
再点击上面的`保存`按钮。
|
||||
|
||||
接下来,点击权限管理,点击开通权限,输入 `im:message:send,im:message,im:message:send_as_bot`。添加筛选到的权限。
|
||||
接下来,点击权限管理,点击开通权限,输入 `im:message,im:message:send_as_bot`。添加筛选到的权限。
|
||||
|
||||
再次输入 `im:resource:upload,im:resource` 开通上传图片相关的权限。
|
||||
|
||||
如果需要在群聊里使用,请额外开通 `im:message.group_at_msg:readonly` 和 `im:message.group_msg` 权限。
|
||||
|
||||
如果需要使用流式输出,请额外开通 `创建与更新卡片(cardkit:card:write)` 权限。
|
||||
|
||||
最终开通的权限如下图:
|
||||
@@ -118,4 +120,4 @@
|
||||
|
||||
在群内发送一个 `/help` 指令,机器人将做出响应。
|
||||
|
||||

|
||||

|
||||
|
||||
@@ -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"
|
||||
|
||||
3
tests/fixtures/mocks/telegram.py
vendored
3
tests/fixtures/mocks/telegram.py
vendored
@@ -33,7 +33,8 @@ def create_mock_telegram_modules():
|
||||
|
||||
mock_telegram_ext = MagicMock()
|
||||
mock_telegram_ext.ApplicationBuilder = MagicMock
|
||||
mock_telegram_ext.ContextTypes = MagicMock
|
||||
mock_telegram_ext.ContextTypes = MagicMock()
|
||||
mock_telegram_ext.ContextTypes.DEFAULT_TYPE = MagicMock
|
||||
mock_telegram_ext.ExtBot = MagicMock
|
||||
mock_telegram_ext.filters = MagicMock()
|
||||
mock_telegram_ext.filters.ALL = MagicMock()
|
||||
|
||||
98
tests/test_computer_tool_permissions.py
Normal file
98
tests/test_computer_tool_permissions.py
Normal file
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
108
tests/test_telegram_adapter.py
Normal file
108
tests/test_telegram_adapter.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import astrbot.api.message_components as Comp
|
||||
from tests.fixtures.helpers import (
|
||||
create_mock_file,
|
||||
create_mock_update,
|
||||
make_platform_config,
|
||||
)
|
||||
from tests.fixtures.mocks.telegram import create_mock_telegram_modules
|
||||
|
||||
_TELEGRAM_PLATFORM_ADAPTER = None
|
||||
|
||||
|
||||
def _load_telegram_adapter():
|
||||
global _TELEGRAM_PLATFORM_ADAPTER
|
||||
if _TELEGRAM_PLATFORM_ADAPTER is not None:
|
||||
return _TELEGRAM_PLATFORM_ADAPTER
|
||||
|
||||
mocks = create_mock_telegram_modules()
|
||||
patched_modules = {
|
||||
"telegram": mocks["telegram"],
|
||||
"telegram.constants": mocks["telegram"].constants,
|
||||
"telegram.error": mocks["telegram"].error,
|
||||
"telegram.ext": mocks["telegram.ext"],
|
||||
"telegramify_markdown": mocks["telegramify_markdown"],
|
||||
"apscheduler": mocks["apscheduler"],
|
||||
"apscheduler.schedulers": mocks["apscheduler"].schedulers,
|
||||
"apscheduler.schedulers.asyncio": mocks["apscheduler"].schedulers.asyncio,
|
||||
"apscheduler.schedulers.background": mocks["apscheduler"].schedulers.background,
|
||||
}
|
||||
with patch.dict(sys.modules, patched_modules):
|
||||
sys.modules.pop("astrbot.core.platform.sources.telegram.tg_adapter", None)
|
||||
module = importlib.import_module("astrbot.core.platform.sources.telegram.tg_adapter")
|
||||
_TELEGRAM_PLATFORM_ADAPTER = module.TelegramPlatformAdapter
|
||||
return _TELEGRAM_PLATFORM_ADAPTER
|
||||
|
||||
|
||||
def _build_context() -> MagicMock:
|
||||
context = MagicMock()
|
||||
context.bot.username = "test_bot"
|
||||
context.bot.id = 12345678
|
||||
return context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_document_caption_populates_message_text_and_plain():
|
||||
TelegramPlatformAdapter = _load_telegram_adapter()
|
||||
adapter = TelegramPlatformAdapter(
|
||||
make_platform_config("telegram"),
|
||||
{},
|
||||
asyncio.Queue(),
|
||||
)
|
||||
document = create_mock_file("https://api.telegram.org/file/test/report.md")
|
||||
document.file_name = "report.md"
|
||||
mention = MagicMock(type="mention", offset=0, length=6)
|
||||
update = create_mock_update(
|
||||
message_text=None,
|
||||
document=document,
|
||||
caption="@alice 请总结这份文档",
|
||||
caption_entities=[mention],
|
||||
)
|
||||
|
||||
result = await adapter.convert_message(update, _build_context())
|
||||
|
||||
assert result is not None
|
||||
assert result.message_str == "@alice 请总结这份文档"
|
||||
assert any(isinstance(component, Comp.File) for component in result.message)
|
||||
assert any(
|
||||
isinstance(component, Comp.Plain)
|
||||
and component.text == "@alice 请总结这份文档"
|
||||
for component in result.message
|
||||
)
|
||||
assert any(
|
||||
isinstance(component, Comp.At) and component.qq == "alice"
|
||||
for component in result.message
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_video_caption_populates_message_text_and_plain():
|
||||
TelegramPlatformAdapter = _load_telegram_adapter()
|
||||
adapter = TelegramPlatformAdapter(
|
||||
make_platform_config("telegram"),
|
||||
{},
|
||||
asyncio.Queue(),
|
||||
)
|
||||
video = create_mock_file("https://api.telegram.org/file/test/lesson.mp4")
|
||||
video.file_name = "lesson.mp4"
|
||||
update = create_mock_update(
|
||||
message_text=None,
|
||||
video=video,
|
||||
caption="这段视频讲了什么",
|
||||
)
|
||||
|
||||
result = await adapter.convert_message(update, _build_context())
|
||||
|
||||
assert result is not None
|
||||
assert result.message_str == "这段视频讲了什么"
|
||||
assert any(isinstance(component, Comp.Video) for component in result.message)
|
||||
assert any(
|
||||
isinstance(component, Comp.Plain) and component.text == "这段视频讲了什么"
|
||||
for component in result.message
|
||||
)
|
||||
Reference in New Issue
Block a user