mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-06 21:00:14 +08:00
Compare commits
11 Commits
pr-5943-de
...
v4.22.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2718b07b6 | ||
|
|
c55f2546e2 | ||
|
|
e4ce090db2 | ||
|
|
11c7591b17 | ||
|
|
d7f8af5d42 | ||
|
|
adc252a343 | ||
|
|
2031f3da74 | ||
|
|
5e63635d52 | ||
|
|
273bcac32a | ||
|
|
4c7525c611 | ||
|
|
cc28bc435f |
@@ -1 +1 @@
|
||||
__version__ = "4.22.0"
|
||||
__version__ = "4.22.1"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -895,7 +895,13 @@ 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 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()
|
||||
.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"
|
||||
logger.info(f"[OpenAI Embedding] {provider_id} Using API Base: {api_base}")
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=provider_config.get("embedding_api_key"),
|
||||
|
||||
@@ -334,11 +334,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:
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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))
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "自动跳过重复文件",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -86,4 +86,4 @@ AstrBot 支持接入优云智算提供的模型 API。
|
||||
|
||||
## 更多功能
|
||||
|
||||
更多功能情参考 [AstrBot 官方文档](https://docs.astrbot.app)。
|
||||
更多功能请参考 [AstrBot 官方文档](https://docs.astrbot.app)。
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user