Compare commits

..

1 Commits

Author SHA1 Message Date
Soulter
2949572110 feat(api): add GET file endpoint and update file route to support multiple methods 2026-03-24 10:23:11 +08:00
48 changed files with 202 additions and 820 deletions

View File

@@ -225,17 +225,14 @@ pre-commit install
### QQ Groups
- 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)
- 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
- Developer Group(Chit-chat): 975206796
- Developer Group(Formal): 1039761811

View File

@@ -217,17 +217,10 @@ pre-commit install
### Groupes QQ
- 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 1 : 322154837
- Groupe 3 : 630166526
- Groupe 5 : 822130018
- Groupe 6 : 753075035
- Groupe développeurs : 975206796
- Groupe développeurs (officiel) : 1039761811

View File

@@ -218,17 +218,10 @@ pre-commit install
### QQ グループ
- 12群: 916228568 (新)
- 9群: 1076659624 (満員)
- 10群: 1078079676 (満員)
- 11群: 704659519 (満員)
- 1群: 322154837 (満員)
- 3群: 630166526 (満員)
- 4群: 1077826412 (満員)
- 5群: 822130018 (満員)
- 6群: 753075035 (満員)
- 7群: 743746109 (満員)
- 8群: 1030353265 (満員)
- 1群: 322154837
- 3群: 630166526
- 5群: 822130018
- 6群: 753075035
- 開発者群: 975206796
- 開発者群(正式): 1039761811

View File

@@ -217,17 +217,10 @@ pre-commit install
### Группы QQ
- Группа 12: 916228568 (новая)
- Группа 9: 1076659624 (полная)
- Группа 10: 1078079676 (полная)
- Группа 11: 704659519 (полная)
- Группа 1: 322154837 (полная)
- Группа 3: 630166526 (полная)
- Группа 4: 1077826412 (полная)
- Группа 5: 822130018 (полная)
- Группа 6: 753075035 (полная)
- Группа 7: 743746109 (полная)
- Группа 8: 1030353265 (полная)
- Группа 1: 322154837
- Группа 3: 630166526
- Группа 5: 822130018
- Группа 6: 753075035
- Группа разработчиков: 975206796
- Группа разработчиков (официальная): 1039761811

View File

@@ -217,17 +217,14 @@ pre-commit install
### QQ 群組
- 12 群916228568 (新)
- 9 群1076659624 (人滿)
- 10 群:1078079676 (人滿)
- 11 群:704659519 (人滿)
- 1 群:322154837 (人滿)
- 3 群:630166526 (人滿)
- 4 群:1077826412 (人滿)
- 5 群:822130018 (人滿)
- 6 群753075035 (人滿)
- 7 群743746109 (人滿)
- 8 群1030353265 (人滿)
- 9 群: 1076659624 (新)
- 10 群: 1078079676 (新)
- 1 群:322154837
- 3 群:630166526
- 5 群:822130018
- 6 群:753075035
- 7 群:743746109
- 8 群:1030353265
- 開發者群闲聊吹水975206796
- 開發者群正式1039761811

View File

@@ -218,17 +218,14 @@ pre-commit install
### QQ 群组
- 12 群916228568 (新)
- 9 群1076659624 (人满)
- 10 群:1078079676 (人满)
- 11 群:704659519 (人满)
- 1 群:322154837 (人满)
- 3 群:630166526 (人满)
- 4 群:1077826412 (人满)
- 5 群:822130018 (人满)
- 6 群753075035 (人满)
- 7 群743746109 (人满)
- 8 群1030353265 (人满)
- 9 群: 1076659624 (新)
- 10 群: 1078079676 (新)
- 1 群:322154837
- 3 群:630166526
- 5 群:822130018
- 6 群:753075035
- 7 群:743746109
- 8 群:1030353265
- 开发者群偏闲聊吹水975206796
- 开发者群正式1039761811

View File

@@ -1 +1 @@
__version__ = "4.22.1"
__version__ = "4.22.0"

View File

@@ -8,13 +8,21 @@ 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,
@@ -69,7 +77,7 @@ class BrowserExecTool(FunctionTool):
learn: bool = False,
include_trace: bool = False,
) -> ToolExecResult:
if err := check_admin_permission(context, "Using browser tools"):
if err := _ensure_admin(context):
return err
try:
browser = await _get_browser_component(context)
@@ -132,7 +140,7 @@ class BrowserBatchExecTool(FunctionTool):
learn: bool = False,
include_trace: bool = False,
) -> ToolExecResult:
if err := check_admin_permission(context, "Using browser tools"):
if err := _ensure_admin(context):
return err
try:
browser = await _get_browser_component(context)
@@ -179,7 +187,7 @@ class RunBrowserSkillTool(FunctionTool):
description: str | None = None,
tags: str | None = None,
) -> ToolExecResult:
if err := check_admin_permission(context, "Using browser tools"):
if err := _ensure_admin(context):
return err
try:
browser = await _get_browser_component(context)

View File

@@ -10,7 +10,6 @@ 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:
@@ -27,6 +26,12 @@ 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]:
@@ -54,7 +59,7 @@ class NeoSkillToolBase(FunctionTool):
neo_call: Callable[[Any, Any], Awaitable[Any]],
error_action: str,
) -> ToolExecResult:
if err := check_admin_permission(context, "Using skill lifecycle tools"):
if err := _ensure_admin(context):
return err
try:
client, sandbox = await _get_neo_context(context)
@@ -387,7 +392,7 @@ class PromoteSkillCandidateTool(NeoSkillToolBase):
stage: str = "canary",
sync_to_local: bool = True,
) -> ToolExecResult:
if err := check_admin_permission(context, "Using skill lifecycle tools"):
if err := _ensure_admin(context):
return err
if stage not in {"canary", "stable"}:
return "Error promoting skill candidate: stage must be canary or stable."

View File

@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.22.1"
VERSION = "4.22.0"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {

View File

@@ -76,8 +76,8 @@ class PreProcessStage(Stage):
return
message_chain = event.get_messages()
for idx, component in enumerate(message_chain):
if isinstance(component, Record):
path = await component.convert_to_file_path()
if isinstance(component, Record) and component.url:
path = component.url.removeprefix("file://")
retry = 5
for i in range(retry):
try:

View File

@@ -5,8 +5,6 @@ import logging
import os
import random
import time
import uuid
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
@@ -26,8 +24,6 @@ from astrbot.api.platform import (
)
from astrbot.core.message.components import BaseMessageComponent
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_file
from ...register import register_platform_adapter
from .qqofficial_message_event import QQOfficialMessageEvent
@@ -46,7 +42,7 @@ class botClient(Client):
async def on_group_at_message_create(
self, message: botpy.message.GroupMessage
) -> None:
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
)
@@ -57,7 +53,7 @@ class botClient(Client):
# 收到频道消息
async def on_at_message_create(self, message: botpy.message.Message) -> None:
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
)
@@ -70,7 +66,7 @@ class botClient(Client):
async def on_direct_message_create(
self, message: botpy.message.DirectMessage
) -> None:
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.FRIEND_MESSAGE,
)
@@ -80,7 +76,7 @@ class botClient(Client):
# 收到 C2C 消息
async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None:
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.FRIEND_MESSAGE,
)
@@ -242,9 +238,6 @@ 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(
@@ -275,6 +268,9 @@ 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
@@ -286,6 +282,7 @@ 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
@@ -340,22 +337,7 @@ class QQOfficialPlatformAdapter(Platform):
return f"https://{url}"
@staticmethod
async def _prepare_audio_attachment(
url: str,
filename: str,
) -> Record:
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
ext = Path(filename).suffix.lower()
source_ext = ext or ".audio"
source_path = temp_dir / f"qqofficial_{uuid.uuid4().hex}{source_ext}"
await download_file(url, str(source_path))
return Record(file=str(source_path), url=str(source_path))
@staticmethod
async def _append_attachments(
def _append_attachments(
msg: list[BaseMessageComponent],
attachments: list | None,
) -> None:
@@ -382,7 +364,7 @@ class QQOfficialPlatformAdapter(Platform):
or getattr(attachment, "name", None)
or "attachment",
)
ext = Path(filename).suffix.lower()
ext = os.path.splitext(filename)[1].lower()
image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
audio_exts = {
".mp3",
@@ -400,21 +382,8 @@ class QQOfficialPlatformAdapter(Platform):
".webm",
}
if content_type.startswith("voice") or ext in audio_exts:
try:
msg.append(
await QQOfficialPlatformAdapter._prepare_audio_attachment(
url,
filename,
)
)
except Exception as e:
logger.warning(
"[QQOfficial] Failed to prepare audio attachment %s: %s",
url,
e,
)
msg.append(Record.fromURL(url))
if content_type.startswith("audio") or ext in audio_exts:
msg.append(Record.fromURL(url))
elif content_type.startswith("video") or ext in video_exts:
msg.append(Video.fromURL(url))
elif content_type.startswith("image") or ext in image_exts:
@@ -464,13 +433,13 @@ class QQOfficialPlatformAdapter(Platform):
return re.sub(r"<faceType=\d+[^>]*>", replace_face, content)
@staticmethod
async def _parse_from_qqofficial(
def _parse_from_qqofficial(
message: botpy.message.Message
| botpy.message.GroupMessage
| botpy.message.DirectMessage
| botpy.message.C2CMessage,
message_type: MessageType,
) -> AstrBotMessage:
):
abm = AstrBotMessage()
abm.type = message_type
abm.timestamp = int(time.time())
@@ -495,9 +464,7 @@ class QQOfficialPlatformAdapter(Platform):
abm.self_id = "unknown_selfid"
msg.append(At(qq="qq_official"))
msg.append(Plain(abm.message_str))
await QQOfficialPlatformAdapter._append_attachments(
msg, message.attachments
)
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
abm.message = msg
elif isinstance(message, botpy.message.Message) or isinstance(
@@ -516,9 +483,7 @@ class QQOfficialPlatformAdapter(Platform):
).strip()
)
await QQOfficialPlatformAdapter._append_attachments(
msg, message.attachments
)
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
abm.message = msg
abm.message_str = plain_content
abm.sender = MessageMember(

View File

@@ -31,7 +31,7 @@ class botClient(Client):
async def on_group_at_message_create(
self, message: botpy.message.GroupMessage
) -> None:
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
)
@@ -42,7 +42,7 @@ class botClient(Client):
# 收到频道消息
async def on_at_message_create(self, message: botpy.message.Message) -> None:
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
)
@@ -55,7 +55,7 @@ class botClient(Client):
async def on_direct_message_create(
self, message: botpy.message.DirectMessage
) -> None:
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.FRIEND_MESSAGE,
)
@@ -65,7 +65,7 @@ class botClient(Client):
# 收到 C2C 消息
async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None:
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.FRIEND_MESSAGE,
)

View File

@@ -895,13 +895,7 @@ class WeixinOCAdapter(Platform):
await asyncio.sleep(self.qr_poll_interval)
continue
try:
await self._poll_inbound_updates()
except asyncio.TimeoutError:
logger.debug(
"weixin_oc(%s): inbound long-poll timeout",
self.meta().id,
)
await self._poll_inbound_updates()
except asyncio.CancelledError:
raise
except Exception as e:

View File

@@ -515,7 +515,7 @@ class ProviderAnthropic(Provider):
model = model or self.get_model()
payloads = {**kwargs, "messages": new_messages, "model": model}
payloads = {"messages": new_messages, "model": model}
# Anthropic has a different way of handling system prompts
if system_prompt:
@@ -571,7 +571,7 @@ class ProviderAnthropic(Provider):
model = model or self.get_model()
payloads = {**kwargs, "messages": new_messages, "model": model}
payloads = {"messages": new_messages, "model": model}
# Anthropic has a different way of handling system prompts
if system_prompt:

View File

@@ -757,7 +757,7 @@ class ProviderGoogleGenAI(Provider):
model = model or self.get_model()
payloads = {**kwargs, "messages": context_query, "model": model}
payloads = {"messages": context_query, "model": model}
retry = 10
keys = self.api_keys.copy()
@@ -812,7 +812,7 @@ class ProviderGoogleGenAI(Provider):
model = model or self.get_model()
payloads = {**kwargs, "messages": context_query, "model": model}
payloads = {"messages": context_query, "model": model}
retry = 10
keys = self.api_keys.copy()

View File

@@ -24,15 +24,9 @@ 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()
.rstrip("/")
.rstrip("/embeddings")
)
if api_base and not api_base.endswith("/v1") and not api_base.endswith("/v4"):
# /v4 see #5699
api_base = api_base + "/v1"
api_base = provider_config.get(
"embedding_api_base", "https://api.openai.com/v1"
).strip()
logger.info(f"[OpenAI Embedding] {provider_id} Using API Base: {api_base}")
self.client = AsyncOpenAI(
api_key=provider_config.get("embedding_api_key"),

View File

@@ -334,15 +334,11 @@ class ProviderOpenAIOfficial(Provider):
choice = chunk.choices[0]
delta = choice.delta
# siliconflow workaround
if dtcs := delta.tool_calls:
for idx, tc in enumerate(dtcs):
# siliconflow workaround
for tc in dtcs:
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:
@@ -623,7 +619,8 @@ class ProviderOpenAIOfficial(Provider):
context_query.extend(tcr.to_openai_messages())
model = model or self.get_model()
payloads = {**kwargs, "messages": context_query, "model": model}
payloads = {"messages": context_query, "model": model}
self._finally_convert_payload(payloads)

View File

@@ -27,12 +27,7 @@ SANDBOX_SKILLS_ROOT = "skills"
SANDBOX_WORKSPACE_ROOT = "/workspace"
_SANDBOX_SKILLS_CACHE_VERSION = 1
_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())
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
def _default_sandbox_skill_path(name: str) -> str:
@@ -535,13 +530,7 @@ class SkillManager:
config["skills"].pop(name, None)
self._save_config(config)
def install_skill_from_zip(
self,
zip_path: str,
*,
overwrite: bool = True,
skill_name_hint: str | None = None,
) -> str:
def install_skill_from_zip(self, zip_path: str, *, overwrite: bool = True) -> str:
zip_path_obj = Path(zip_path)
if not zip_path_obj.exists():
raise FileNotFoundError(f"Zip file not found: {zip_path}")
@@ -558,48 +547,15 @@ class SkillManager:
if not file_names:
raise ValueError("Zip archive is empty.")
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
top_dirs = {
PurePosixPath(name).parts[0] for name in file_names if name.strip()
}
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
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.")
for name in names:
if not name:
@@ -609,20 +565,16 @@ class SkillManager:
parts = PurePosixPath(name).parts
if ".." in parts:
raise ValueError("Zip archive contains invalid relative paths.")
if (not root_mode) and parts and parts[0] != archive_root_name:
if parts and parts[0] != skill_name:
raise ValueError(
"Zip archive contains unexpected top-level entries."
)
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.")
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.")
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
for member in zf.infolist():
@@ -630,12 +582,7 @@ class SkillManager:
if not member_name or _is_ignored_zip_entry(member_name):
continue
zf.extract(member, tmp_dir)
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.")
src_dir = Path(tmp_dir) / skill_name
_normalize_skill_markdown_path(src_dir)
if not src_dir.exists():
raise ValueError("Skill folder not found after extraction.")

View File

@@ -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.openapi_upload_file),
("GET", self.openapi_get_file),
("POST", self.upload_file),
("GET", self.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 openapi_upload_file(self):
async def upload_file(self):
return await self.chat_route.post_file()
async def openapi_get_file(self):
async def get_file(self):
return await self.chat_route.get_attachment()
async def get_chat_sessions(self):

View File

@@ -328,87 +328,37 @@ class SessionManagementRoute(Route):
请求体:
{
"umos": ["平台:消息类型:会话ID", ...], // 可选
"scope": "all" | "group" | "private" | "custom_group", // 可选,批量范围
"group_id": "分组ID", // 当 scope 为 custom_group 时必填
"rule_key": "session_service_config" | ... (可选,不传则删除所有规则)
"umos": ["平台:消息类型:会话ID", ...] // umo 列表
}
"""
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 或有效的 scope").__dict__
return Response().error("缺少必要参数: umos").__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__
# 批量删除
success_count = 0
deleted_count = 0
failed_umos = []
for umo in umos:
try:
if rule_key:
await sp.session_remove(umo, rule_key)
else:
await sp.clear_async("umo", umo)
success_count += 1
await sp.clear_async("umo", umo)
deleted_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"{message}{len(failed_umos)} 条删除失败",
"success_count": success_count,
"message": f"已删除 {deleted_count} 条规则{len(failed_umos)} 条删除失败",
"deleted_count": deleted_count,
"failed_umos": failed_umos,
}
)
@@ -419,8 +369,8 @@ class SessionManagementRoute(Route):
Response()
.ok(
{
"message": message,
"success_count": success_count,
"message": f"已删除 {deleted_count} 条规则",
"deleted_count": deleted_count,
}
)
.__dict__

View File

@@ -2,6 +2,7 @@ import os
import re
import shutil
import traceback
import uuid
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
@@ -43,17 +44,6 @@ 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)
@@ -174,24 +164,11 @@ class SkillsRoute(Route):
temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
skill_mgr = SkillManager()
temp_path = _next_available_temp_path(temp_dir, filename)
temp_path = os.path.join(temp_dir, filename)
await file.save(temp_path)
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
skill_mgr = SkillManager()
skill_name = skill_mgr.install_skill_from_zip(temp_path, overwrite=True)
try:
await sync_skills_to_active_sandboxes()
@@ -231,7 +208,6 @@ class SkillsRoute(Route):
succeeded = []
failed = []
skipped = []
skill_mgr = SkillManager()
temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
@@ -250,42 +226,14 @@ class SkillsRoute(Route):
)
continue
temp_path = _next_available_temp_path(temp_dir, filename)
temp_path = os.path.join(
temp_dir, f"batch_{uuid.uuid4().hex}_{filename}"
)
await file.save(temp_path)
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
skill_name = skill_mgr.install_skill_from_zip(
temp_path, overwrite=True
)
succeeded.append({"filename": filename, "name": skill_name})
except Exception as e:
@@ -307,10 +255,8 @@ class SkillsRoute(Route):
total = len(file_list)
success_count = len(succeeded)
skipped_count = len(skipped)
failed_count = len(failed)
if failed_count == 0 and success_count == total:
if success_count == total:
message = f"All {total} skill(s) uploaded successfully."
return (
Response()
@@ -319,35 +265,18 @@ class SkillsRoute(Route):
"total": total,
"succeeded": succeeded,
"failed": failed,
"skipped": skipped,
},
message,
)
.__dict__
)
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:
if success_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__
@@ -359,7 +288,6 @@ class SkillsRoute(Route):
"total": total,
"succeeded": succeeded,
"failed": failed,
"skipped": skipped,
},
message,
)

View File

@@ -1,49 +0,0 @@
## 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)

View File

@@ -33,45 +33,6 @@ 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;
@@ -92,7 +53,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(REQUIRED_ICONS);
const usedIcons = new Set();
for (const file of sourceFiles) {
const content = readFileSync(file, "utf-8");
for (const match of content.matchAll(iconPattern)) {

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 248 icons */
/* Auto-generated MDI subset 235 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -120,10 +120,6 @@
content: "\F00E4";
}
.mdi-cached::before {
content: "\F00E8";
}
.mdi-calendar::before {
content: "\F00ED";
}
@@ -368,10 +364,6 @@
content: "\F06D0";
}
.mdi-eyedropper::before {
content: "\F020A";
}
.mdi-file::before {
content: "\F0214";
}
@@ -628,14 +620,6 @@
content: "\F035C";
}
.mdi-menu-down::before {
content: "\F035D";
}
.mdi-menu-right::before {
content: "\F035F";
}
.mdi-message-off-outline::before {
content: "\F164E";
}
@@ -660,10 +644,6 @@
content: "\F0374";
}
.mdi-minus-box::before {
content: "\F0375";
}
.mdi-note-text-outline::before {
content: "\F11D7";
}
@@ -696,18 +676,6 @@
content: "\F03D6";
}
.mdi-page-first::before {
content: "\F0600";
}
.mdi-page-last::before {
content: "\F0601";
}
.mdi-paperclip::before {
content: "\F03E2";
}
.mdi-pause::before {
content: "\F03E4";
}
@@ -776,14 +744,6 @@
content: "\F0432";
}
.mdi-radiobox-blank::before {
content: "\F043D";
}
.mdi-radiobox-marked::before {
content: "\F043E";
}
.mdi-refresh::before {
content: "\F0450";
}
@@ -880,14 +840,6 @@
content: "\F1C55";
}
.mdi-star-half-full::before {
content: "\F04D0";
}
.mdi-star-outline::before {
content: "\F04D2";
}
.mdi-stop::before {
content: "\F04DB";
}
@@ -944,10 +896,6 @@
content: "\F0A7A";
}
.mdi-unfold-more-horizontal::before {
content: "\F054F";
}
.mdi-update::before {
content: "\F06B0";
}

View File

@@ -39,16 +39,6 @@ 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>
@@ -80,22 +70,6 @@ const authorDisplay = computed(() => {
</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 }">

View File

@@ -900,7 +900,6 @@ 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);
@@ -912,14 +911,6 @@ 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;

View File

@@ -68,17 +68,6 @@ 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(() => {
@@ -356,10 +345,6 @@ 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"

View File

@@ -1237,7 +1237,7 @@
"description": "API Base URL"
},
"openai_embedding": {
"hint": "If testing fails, try adding /v1 at the end to be compatible with some OpenAI API versions."
"hint": "OpenAI Embedding automatically appends /v1 at request time."
},
"gemini_embedding": {
"hint": "Gemini Embedding does not require manually adding /v1beta."

View File

@@ -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 archive supports multiple skills folders.",
"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`.",
"abilityMultiple": "Upload multiple zip files at once",
"abilityValidate": "Validate `SKILL.md` automatically",
"abilitySkip": "Automatically skip duplicate files.",

View File

@@ -1234,7 +1234,7 @@
"description": "Адрес прокси-сервера"
},
"openai_embedding": {
"hint": "Если тест не проходит, попробуйте добавить /v1 в конец embedding_api_base для совместимости с некоторыми версиями OpenAI API."
"hint": "OpenAI Embedding автоматически добавляет /v1 при запросе."
},
"gemini_embedding": {
"hint": "Gemini Embedding не требует ручного добавления /v1beta."

View File

@@ -241,7 +241,7 @@
"emptyHint": "Пожалуйста, загрузите архив с навыками",
"uploadDialogTitle": "Загрузка навыков",
"uploadHint": "Поддерживается массовая загрузка zip-архивов. Вы также можете перетащить файлы в это окно. Система автоматически проверит структуру каждого архива.",
"structureRequirement": "Поддерживаются архивы с несколькими папками skills.",
"structureRequirement": "Архив должен содержать одну корневую папку (например, `skillname/`), внутри которой обязательно должен находиться файл `SKILL.md`.",
"abilityMultiple": "Поддержка массовой загрузки",
"abilityValidate": "Автопроверка `SKILL.md`",
"abilitySkip": "Пропуск дубликатов",

View File

@@ -1239,7 +1239,7 @@
"description": "API Base URL"
},
"openai_embedding": {
"hint": "如果测试不通过,可以尝试添加 /v1 在末尾以兼容部分 OpenAI API 版本。"
"hint": "OpenAI Embedding 会在请求时自动补上 /v1。"
},
"gemini_embedding": {
"hint": "Gemini Embedding 无需手动添加 /v1beta。"

View File

@@ -245,7 +245,7 @@
"emptyHint": "请上传 Skills 压缩包",
"uploadDialogTitle": "上传 Skills",
"uploadHint": "支持批量上传 zip 技能包,也支持拖拽批量上传 zip 技能包。系统会自动校验目录结构,并给出逐个文件的结果。",
"structureRequirement": "支持压缩包内含多个 skills 文件夹。",
"structureRequirement": "常见失败原因是压缩包结构不正确。每个 zip 必须只包含一个顶层目录,例如 `skillname/`,且该目录下必须存在 `SKILL.md`。",
"abilityMultiple": "支持一次上传多个zip文件",
"abilityValidate": "自动校验 `SKILL.md`",
"abilitySkip": "自动跳过重复文件",

View File

@@ -17,11 +17,6 @@ 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;

View File

@@ -227,8 +227,7 @@ const {
<v-btn
variant="text"
prepend-icon="mdi-book-open-variant"
href="https://docs.astrbot.app/dev/star/plugin-new.html"
rel="noopener noreferrer"
href="https://astrbot.app/dev/plugin.html"
target="_blank"
color="primary"
class="text-none"
@@ -388,7 +387,6 @@ const {
elevation="24"
:color="snack_success"
v-model="snack_show"
location="bottom center"
>
{{ snack_message }}
</v-snackbar>

View File

@@ -137,7 +137,7 @@
</v-select>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-select v-model="batchChatProvider" :items="batchChatProviderOptions" item-title="label" item-value="value"
<v-select v-model="batchChatProvider" :items="chatProviderOptions" item-title="label" item-value="value"
:label="tm('batchOperations.chatProvider')" hide-details clearable variant="solo-filled" flat density="comfortable">
</v-select>
</v-col>
@@ -527,8 +527,6 @@ import {
useConfirmDialog
} from '@/utils/confirmDialog'
const FOLLOW_CONFIG_VALUE = '__astrbot_follow_config__'
export default {
name: 'SessionManagementPage',
setup() {
@@ -586,9 +584,9 @@ export default {
// Provider 配置
providerConfig: {
chat_completion: FOLLOW_CONFIG_VALUE,
speech_to_text: FOLLOW_CONFIG_VALUE,
text_to_speech: FOLLOW_CONFIG_VALUE,
chat_completion: null,
speech_to_text: null,
text_to_speech: null,
},
// 插件配置
@@ -673,7 +671,7 @@ export default {
chatProviderOptions() {
return [
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
{ label: this.tm('provider.followConfig'), value: null },
...this.availableChatProviders.map(p => ({
label: `${p.name} (${p.model})`,
value: p.id
@@ -683,7 +681,7 @@ export default {
sttProviderOptions() {
return [
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
{ label: this.tm('provider.followConfig'), value: null },
...this.availableSttProviders.map(p => ({
label: `${p.name} (${p.model})`,
value: p.id
@@ -693,27 +691,7 @@ export default {
ttsProviderOptions() {
return [
{ 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 },
{ label: this.tm('provider.followConfig'), value: null },
...this.availableTtsProviders.map(p => ({
label: `${p.name} (${p.model})`,
value: p.id
@@ -936,9 +914,9 @@ export default {
// 初始化 Provider 配置
this.providerConfig = {
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,
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,
}
// 初始化插件配置
@@ -1019,7 +997,7 @@ export default {
for (const type of providerTypes) {
const value = this.providerConfig[type]
if (value && value !== FOLLOW_CONFIG_VALUE) {
if (value) {
// 有值时更新
updateTasks.push(
axios.post('/api/session/update-rule', {
@@ -1029,7 +1007,7 @@ export default {
})
)
} else if (this.editingRules[`provider_perf_${type}`]) {
// 选择了"跟随配置文件" (__astrbot_follow_config__) 且之前有配置,则删除
// 选择了"跟随配置文件"null且之前有配置,则删除
deleteTasks.push(
axios.post('/api/session/delete-rule', {
umo: this.selectedUmo.umo,
@@ -1057,10 +1035,9 @@ export default {
this.rulesList.push(item)
}
for (const type of providerTypes) {
const val = this.providerConfig[type]
if (val && val !== FOLLOW_CONFIG_VALUE) {
item.rules[`provider_perf_${type}`] = val
this.editingRules[`provider_perf_${type}`] = val
if (this.providerConfig[type]) {
item.rules[`provider_perf_${type}`] = this.providerConfig[type]
this.editingRules[`provider_perf_${type}`] = this.providerConfig[type]
} else {
// 删除本地数据
delete item.rules[`provider_perf_${type}`]
@@ -1377,41 +1354,23 @@ export default {
}
if (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
}))
}
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.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
}))
}
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 (tasks.length === 0) {

View File

@@ -11,7 +11,6 @@ import {
resolveUsedIcons,
extractUtilityCss,
ICON_CLASS_PATTERN,
REQUIRED_ICONS,
} from '../scripts/subset-mdi-font.mjs';
// ── Helper: create a temporary directory tree for file-system tests ─────────
@@ -84,11 +83,7 @@ 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'));
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);
assert.equal(icons.size, 2); // mdi-home deduplicated
rmSync(tmp, { recursive: true });
});
@@ -106,30 +101,12 @@ test('scanUsedIcons excludes utility classes', () => {
rmSync(tmp, { recursive: true });
});
test('scanUsedIcons includes all required icons even when no mdi-* icons are found in source', () => {
test('scanUsedIcons returns empty set when no icons found', () => {
const tmp = makeTmpDir();
writeFileSync(join(tmp, 'A.vue'), '<div>Hello</div>');
const icons = scanUsedIcons(collectFiles(tmp, ['.vue']));
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'));
assert.equal(icons.size, 0);
rmSync(tmp, { recursive: true });
});

View File

@@ -16,17 +16,15 @@ Welcome to submit Issues or Pull Requests:
### Tencent QQ Groups
- 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)
> - 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)
- **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

View File

@@ -6,17 +6,17 @@
### QQ 群
- 12 群: 916228568 (新)
- 9 群: 1076659624 (人满)
- 10 群: 1078079676 (人满)
- 11 群: 704659519 (人满)
- 1 群: 322154837 (人满)
- 3 群: 630166526 (人满)
- 4 群: 1077826412 (人满)
- 5 群: 822130018 (人满)
- 6 群: 753075035 (人满)
- 7 群: 743746109 (人满)
- 8 群: 1030353265 (人满)
> 所有群都可以插空加入,如果您发现群人数小于上限,请尝试加入。
- 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 人群, 人满)
- **AstrBot 核心开发交流群: 975206796**AstrBot 开发成员通常活跃于此,欢迎任何对编程/AI 技术感兴趣的同学加入~
### Discord

View File

@@ -86,4 +86,4 @@ AstrBot 支持接入优云智算提供的模型 API。
## 更多功能
更多功能参考 [AstrBot 官方文档](https://docs.astrbot.app)。
更多功能参考 [AstrBot 官方文档](https://docs.astrbot.app)。

View File

@@ -3,7 +3,7 @@
随着插件功能的增加,可能需要定义一些配置以让用户自定义插件的行为。
AstrBot 提供了强大的配置解析和可视化功能。能够让用户在管理面板上直接配置插件,而不需要修改代码。
AstrBot 提供了强大的配置解析和可视化功能。能够让用户在管理面板上直接配置插件,而不需要修改代码。
## 配置定义

View File

@@ -88,12 +88,10 @@
再点击上面的`保存`按钮。
接下来,点击权限管理,点击开通权限,输入 `im:message,im:message:send_as_bot`。添加筛选到的权限。
接下来,点击权限管理,点击开通权限,输入 `im:message:send,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)` 权限。
最终开通的权限如下图:
@@ -120,4 +118,4 @@
在群内发送一个 `/help` 指令,机器人将做出响应。
![成功](https://files.astrbot.app/docs/source/images/lark/image-13.png)
![成功](https://files.astrbot.app/docs/source/images/lark/image-13.png)

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.22.1"
version = "4.22.0"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.12"

View File

@@ -1,98 +0,0 @@
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

View File

@@ -54,21 +54,8 @@ def test_promote_stable_sync_failure_auto_rolls_back(monkeypatch):
_fake_sync_release,
)
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,
)
event = SimpleNamespace(role="admin", unified_msg_origin="session-1")
astr_ctx = SimpleNamespace(context=SimpleNamespace(), event=event)
run_ctx = ContextWrapper(context=astr_ctx)
tool = PromoteSkillCandidateTool()