Compare commits

...

11 Commits

Author SHA1 Message Date
LIghtJUNction
f2a685d6e0 Update astrbot/cli/commands/cmd_plug.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-31 00:45:10 +08:00
LIghtJUNction
422ac6be67 Update tests/test_cli_plugin.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-31 00:44:54 +08:00
LIghtJUNction
34a5498af3 fix: harden local plugin install 2026-05-30 23:50:56 +08:00
LIghtJUNction
748901decd fix: make editable plugin install symlink 2026-05-30 23:25:59 +08:00
LIghtJUNction
f65172f278 Merge remote-tracking branch 'origin/master' into feat/plugin-install-editable-local 2026-05-30 23:09:52 +08:00
LIghtJUNction
a0644d1588 feat: support local plugin install 2026-05-30 23:06:29 +08:00
Misaka Mikoto
90a3a2171a fix: Template config optimization (#8228)
* fix: improve template list config handling

* feat(webui): show template list display item

* feat(webui): allow hiding template list hints

* docs: document template list metadata fields

* fix: support file fields in template list configs
2026-05-30 22:13:09 +08:00
Soulter
0e973bd4d4 chore: bump version to 4.25.2 2026-05-30 20:10:51 +08:00
Soulter
b0bb5c7477 fix(chatui): reasoning summary 2026-05-30 20:06:20 +08:00
Weilong Liao
0da17485bd fix(plugin_manager): improve plugin state cleanup and add tests for unbinding and loading plugins (#8441)
fixes: #8439
2026-05-30 18:45:33 +08:00
Weilong Liao
b8cf2ef552 fix: recording issue on chatui (#8440)
#8364
2026-05-30 18:04:25 +08:00
27 changed files with 1050 additions and 107 deletions

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@ uv.lock
# IDE and editors
.vscode
.idea
.zed/
# Logs and temporary files
botpy.log

View File

@@ -1 +1 @@
__version__ = "4.25.1"
__version__ = "4.25.2"

View File

@@ -10,6 +10,7 @@ from ..utils import (
check_astrbot_root,
get_astrbot_root,
get_git_repo,
install_local_plugin,
manage_plugin,
)
@@ -143,12 +144,32 @@ def list(all: bool) -> None:
@plug.command()
@click.argument("name")
@click.argument("name", required=False)
@click.option(
"--editable",
"-e",
"local_path",
type=click.Path(exists=True, file_okay=False, path_type=Path),
help="Install a plugin from a local directory as a symlink",
)
@click.option("--proxy", help="Proxy server address")
def install(name: str, proxy: str | None) -> None:
def install(name: str | None, local_path: Path | None, proxy: str | None) -> None:
"""Install a plugin"""
base_path = _get_data_path()
plug_path = base_path / "plugins"
if local_path is not None:
install_local_plugin(local_path, plug_path, editable=True)
return
if name is None:
raise click.ClickException("Missing plugin name or local plugin path")
local_name_path = Path(name).expanduser()
if local_name_path.exists() and local_name_path.is_dir():
install_local_plugin(local_name_path, plug_path, editable=False)
return
plugins = build_plug_list(base_path / "plugins")
plugin = next(

View File

@@ -3,7 +3,13 @@ from .basic import (
check_dashboard,
get_astrbot_root,
)
from .plugin import PluginStatus, build_plug_list, get_git_repo, manage_plugin
from .plugin import (
PluginStatus,
build_plug_list,
get_git_repo,
install_local_plugin,
manage_plugin,
)
from .version_comparator import VersionComparator
__all__ = [
@@ -14,5 +20,6 @@ __all__ = [
"check_dashboard",
"get_astrbot_root",
"get_git_repo",
"install_local_plugin",
"manage_plugin",
]

View File

@@ -1,5 +1,6 @@
import shutil
import tempfile
import uuid
from enum import Enum
from io import BytesIO
from pathlib import Path
@@ -19,6 +20,35 @@ class PluginStatus(str, Enum):
NOT_PUBLISHED = "unpublished"
LOCAL_PLUGIN_COPY_IGNORE = shutil.ignore_patterns(
".git",
"__pycache__",
"*.pyc",
".venv",
"venv",
".idea",
".vscode",
".zed",
)
def _validate_plugin_dir_name(plugin_name: str, source_path: Path) -> str:
plugin_name = plugin_name.strip()
plugin_path = Path(plugin_name)
has_separator = "/" in plugin_name or "\\" in plugin_name
if (
not plugin_name
or plugin_name in {".", ".."}
or plugin_path.is_absolute()
or has_separator
or plugin_path.name != plugin_name
):
raise click.ClickException(
f"Local plugin {source_path} metadata.yaml has invalid name: {plugin_name}"
)
return plugin_name
def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
"""Download code from a Git repository and extract to the specified path"""
temp_dir = Path(tempfile.mkdtemp())
@@ -190,6 +220,78 @@ def build_plug_list(plugins_dir: Path) -> list:
return result
def _cleanup_local_plugin_target(target_path: Path) -> None:
if target_path.is_symlink() or target_path.is_file():
target_path.unlink(missing_ok=True)
elif target_path.exists():
shutil.rmtree(target_path, ignore_errors=True)
def _copy_local_plugin(source_path: Path, plugins_dir: Path, target_path: Path) -> None:
temp_target = plugins_dir / f".{target_path.name}.tmp-{uuid.uuid4().hex}"
try:
shutil.copytree(source_path, temp_target, ignore=LOCAL_PLUGIN_COPY_IGNORE)
temp_target.rename(target_path)
except FileExistsError:
raise click.ClickException(
f"Plugin {target_path.name} already exists"
) from None
except Exception:
raise
finally:
if temp_target.exists() or temp_target.is_symlink():
_cleanup_local_plugin_target(temp_target)
def install_local_plugin(
source_path: Path,
plugins_dir: Path,
editable: bool = False,
) -> None:
"""Install a plugin from a local directory."""
source_path = source_path.expanduser().resolve()
plugins_dir = plugins_dir.resolve()
if not source_path.exists() or not source_path.is_dir():
raise click.ClickException(f"Local plugin path does not exist: {source_path}")
metadata = load_yaml_metadata(source_path)
plugin_name = metadata.get("name")
if not isinstance(plugin_name, str) or not plugin_name.strip():
raise click.ClickException(
f"Local plugin {source_path} must contain metadata.yaml with a valid name"
)
plugin_name = _validate_plugin_dir_name(plugin_name, source_path)
target_path = plugins_dir / plugin_name
if target_path.exists():
raise click.ClickException(f"Plugin {plugin_name} already exists")
try:
plugins_dir.mkdir(parents=True, exist_ok=True)
if editable:
try:
target_path.symlink_to(source_path, target_is_directory=True)
except OSError as e:
raise click.ClickException(
f"Failed to create symlink for editable install: {e}. "
"On Windows, you may need to run as Administrator or enable Developer Mode."
) from e
else:
_copy_local_plugin(source_path, plugins_dir, target_path)
click.echo(f"Plugin {plugin_name} installed successfully from {source_path}")
except FileExistsError:
raise click.ClickException(f"Plugin {plugin_name} already exists") from None
except click.ClickException:
raise
except Exception as e:
if editable and target_path.is_symlink():
_cleanup_local_plugin_target(target_path)
raise click.ClickException(
f"Error installing local plugin {plugin_name}: {e}"
) from e
def manage_plugin(
plugin: dict,
plugins_dir: Path,

View File

@@ -5,7 +5,7 @@ import os
from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.25.1"
VERSION = "4.25.2"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {

View File

@@ -654,12 +654,23 @@ class PluginManager:
"""
prefix = "astrbot.builtin_stars." if is_reserved else "data.plugins."
module_prefix = f"{prefix}{plugin_root_dir}"
return [
key
for key in list(sys.modules.keys())
if key.startswith(f"{prefix}{plugin_root_dir}")
if PluginManager._is_plugin_module_path(key, module_prefix)
]
@staticmethod
def _is_plugin_module_path(module_path: str | None, module_prefix: str) -> bool:
return bool(
module_path
and (
module_path == module_prefix
or module_path.startswith(f"{module_prefix}.")
)
)
def _purge_modules(
self,
module_patterns: list[str] | None = None,
@@ -694,33 +705,49 @@ class PluginManager:
except KeyError:
logger.warning(f"模块 {module_name} 未载入")
def _cleanup_plugin_state(self, dir_name: str) -> None:
plugin_root_name = "data.plugins."
def _cleanup_plugin_state(self, dir_name: str, is_reserved: bool = False) -> None:
plugin_root_name = "astrbot.builtin_stars." if is_reserved else "data.plugins."
module_prefix = f"{plugin_root_name}{dir_name}"
# 清理 sys.modules
for key in list(sys.modules.keys()):
if key.startswith(f"{plugin_root_name}{dir_name}"):
if self._is_plugin_module_path(key, module_prefix):
logger.info(f"清除了插件{dir_name}中的{key}模块")
del sys.modules[key]
possible_paths = [
f"{plugin_root_name}{dir_name}.main",
f"{plugin_root_name}{dir_name}.{dir_name}",
]
# Clean plugin metadata registered before a failed load completes.
for module_path, metadata in list(star_map.items()):
if self._is_plugin_module_path(module_path, module_prefix) or (
metadata.root_dir_name == dir_name and metadata.reserved == is_reserved
):
star_map.pop(module_path, None)
if metadata in star_registry:
star_registry.remove(metadata)
logger.info(f"清理插件元数据: {module_path}")
for metadata in list(star_registry):
if self._is_plugin_module_path(metadata.module_path, module_prefix) or (
metadata.root_dir_name == dir_name and metadata.reserved == is_reserved
):
star_registry.remove(metadata)
logger.info(f"清理插件注册项: {metadata.name or dir_name}")
# 清理 handlers
for path in possible_paths:
handlers = star_handlers_registry.get_handlers_by_module_name(path)
for handler in handlers:
for handler in list(star_handlers_registry):
if self._is_plugin_module_path(handler.handler_module_path, module_prefix):
star_handlers_registry.remove(handler)
logger.info(f"清理处理器: {handler.handler_name}")
# 清理工具
for tool in list(llm_tools.func_list):
if tool.handler_module_path in possible_paths:
handler_module_path = getattr(tool, "handler_module_path", None)
if self._is_plugin_module_path(handler_module_path, module_prefix):
llm_tools.func_list.remove(tool)
logger.info(f"清理工具: {tool.name}")
for adapter_name in unregister_platform_adapters_by_module(module_prefix):
logger.info(f"清理平台适配器: {adapter_name}")
def _build_failed_plugin_record(
self,
*,
@@ -836,7 +863,7 @@ class PluginManager:
# 终止插件
if not specified_module_path:
# 重载所有插件
for smd in star_registry:
for smd in list(star_registry):
try:
await self._terminate_plugin(smd)
except Exception as e:
@@ -948,11 +975,7 @@ class PluginManager:
error_trace=error_trace,
)
)
if path in star_map:
logger.info("失败插件依旧在插件列表中,正在清理...")
metadata = star_map.pop(path)
if metadata in star_registry:
star_registry.remove(metadata)
self._cleanup_plugin_state(root_dir_name, reserved)
continue
# 检查 _conf_schema.json
@@ -1097,21 +1120,29 @@ class PluginManager:
f"插件 {path} 未通过装饰器注册。尝试通过旧版本方式载入。",
)
classes = self._get_classes(module)
if not classes:
raise Exception(
f"插件 {root_dir_name} 未通过 Star 注册,也没有找到旧版插件类。"
"请确认插件主类继承 astrbot.api.star.Star或类名以 Plugin 结尾 / 命名为 Main。",
)
plugin_cls = getattr(module, classes[0])
obj = None
if path not in inactivated_plugins:
# 只有没有禁用插件时才实例化插件类
if plugin_config:
try:
obj = getattr(module, classes[0])(
obj = plugin_cls(
context=self.context,
config=plugin_config,
) # 实例化插件类
except TypeError as _:
obj = getattr(module, classes[0])(
obj = plugin_cls(
context=self.context,
) # 实例化插件类
else:
obj = getattr(module, classes[0])(
obj = plugin_cls(
context=self.context,
) # 实例化插件类
@@ -1139,7 +1170,7 @@ class PluginManager:
metadata.module = module
metadata.root_dir_name = root_dir_name
metadata.reserved = reserved
metadata.star_cls_type = obj.__class__
metadata.star_cls_type = plugin_cls
metadata.module_path = path
star_map[path] = metadata
star_registry.append(metadata)
@@ -1226,12 +1257,7 @@ class PluginManager:
error_trace=errors,
)
)
# 记录注册失败的插件名称,以便后续重载插件
if path in star_map:
logger.info("失败插件依旧在插件列表中,正在清理...")
metadata = star_map.pop(path)
if metadata in star_registry:
star_registry.remove(metadata)
self._cleanup_plugin_state(root_dir_name, reserved)
# 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]:

View File

@@ -95,7 +95,7 @@ def _validate_template_list(value, meta, path_key, errors, validate_fn) -> None:
validate_fn(
item,
template_meta.get("items", {}),
path=f"{item_path}.",
path=f"{path_key}.templates.{template_key}.",
)

View File

@@ -16,6 +16,7 @@ def get_schema_item(schema: dict | None, key_path: str) -> dict | None:
同时支持:
- 扁平 schema直接 key 命中)
- 嵌套 object schema{type: "object", items: {...}}
- template_list schema<field>.templates.<template>.items
"""
if not isinstance(schema, dict) or not key_path:
@@ -23,17 +24,31 @@ def get_schema_item(schema: dict | None, key_path: str) -> dict | None:
if key_path in schema:
return schema.get(key_path)
current = schema
parts = key_path.split(".")
for idx, part in enumerate(parts):
current = schema
idx = 0
while idx < len(parts):
part = parts[idx]
if part not in current:
return None
meta = current.get(part)
if idx == len(parts) - 1:
return meta
if not isinstance(meta, dict) or meta.get("type") != "object":
return None
if not isinstance(meta, dict) or meta.get("type") != "template_list":
return None
if idx + 2 >= len(parts) or parts[idx + 1] != "templates":
return None
template_meta = meta.get("templates", {}).get(parts[idx + 2])
if not isinstance(template_meta, dict):
return None
if idx + 2 == len(parts) - 1:
return template_meta
current = template_meta.get("items", {})
idx += 3
continue
current = meta.get("items", {})
idx += 1
return None

110
changelogs/v4.25.2.md Normal file
View File

@@ -0,0 +1,110 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)
<a id="chinese"></a>
## What's Changed
### 新增与优化
- 为知识库加入 Markdown 感知分块器,提升结构化文档导入后的分块质量。([#8151](https://github.com/AstrBotDevs/AstrBot/pull/8151))
- 新增长期记忆上下文压缩重构,提高记忆整理与续接体验。([#8226](https://github.com/AstrBotDevs/AstrBot/pull/8226))
- 为 ChatUI 添加指令候选能力,支持自定义唤醒词和悬浮提示。([#8279](https://github.com/AstrBotDevs/AstrBot/pull/8279), [#8353](https://github.com/AstrBotDevs/AstrBot/pull/8353))
- 为已配置模型增加能力图标,便于识别模型支持的能力。([#8405](https://github.com/AstrBotDevs/AstrBot/pull/8405))
- 新增小米和 Xiaomi Token Plan LLM 提供商。([#7744](https://github.com/AstrBotDevs/AstrBot/pull/7744))
- 优化 QQ 官方适配器的消息链媒体拆分和发送逻辑,并透传 QQ webhook 扩展字段。([#8376](https://github.com/AstrBotDevs/AstrBot/pull/8376), [#6274](https://github.com/AstrBotDevs/AstrBot/pull/6274))
- 为首次通知增加 EULA 提示。([#7955](https://github.com/AstrBotDevs/AstrBot/pull/7955))
- WebUI 插件卡片新增直接访问按钮,并改善内嵌页面高度。([#8369](https://github.com/AstrBotDevs/AstrBot/pull/8369))
- 启用平滑 Markdown 流式渲染。([#8371](https://github.com/AstrBotDevs/AstrBot/pull/8371))
- Dashboard 开发和构建流程自动生成 MDI 图标字体子集。([#8264](https://github.com/AstrBotDevs/AstrBot/pull/8264))
- 新增修改 AstrBot Dashboard 密码的命令。([#8272](https://github.com/AstrBotDevs/AstrBot/pull/8272))
- 优化默认 LLM 压缩提示词,使上下文续接更自然。([#8424](https://github.com/AstrBotDevs/AstrBot/pull/8424))
### 修复
- 修复 ChatUI 语音输入录制与上传失效的问题。([#8440](https://github.com/AstrBotDevs/AstrBot/pull/8440))
- 修复 ChatUI 思考过程标题摘要展示,并让右侧图标保持垂直居中。
- 修复特定情况下单个插件加载失败会影响其他插件重载的问题,增强失败插件状态清理。([#8441](https://github.com/AstrBotDevs/AstrBot/pull/8441))
- 修复空 LLM 摘要处理问题。([#8195](https://github.com/AstrBotDevs/AstrBot/pull/8195))
- 修复 RST 和 AsciiDoc 知识库上传支持。([#8255](https://github.com/AstrBotDevs/AstrBot/pull/8255))
- 修复 T2I Shiki 相关问题。([#8013](https://github.com/AstrBotDevs/AstrBot/pull/8013))
- 修复流式响应 `delta=None` 分块导致 SDK `to_dict()` 报错的问题。([#8244](https://github.com/AstrBotDevs/AstrBot/pull/8244))
- 修复 skills_like 工具重新查询时未保留原始 `completion_text` 的问题。([#8240](https://github.com/AstrBotDevs/AstrBot/pull/8240))
- 修复 OpenAI 流式响应末尾 usage 信息丢失的问题。([#8306](https://github.com/AstrBotDevs/AstrBot/pull/8306))
- 修复 macOS 上 SQLAlchemy 兼容性问题。([#7724](https://github.com/AstrBotDevs/AstrBot/pull/7724))
- 修复 WebUI 移动端 Provider Source 删除能力。([#8321](https://github.com/AstrBotDevs/AstrBot/pull/8321))
- 修复 Mimo voice design 模型请求包含无效 voice 参数的问题。([#8326](https://github.com/AstrBotDevs/AstrBot/pull/8326))
- 修复 Mimo reasoning content 相关问题。([#8327](https://github.com/AstrBotDevs/AstrBot/pull/8327))
- 修复 Anthropic API `tool_choice` schema 转换问题。([#8328](https://github.com/AstrBotDevs/AstrBot/pull/8328))
- 修复插件 metadata repo 字段类型保护。([#8207](https://github.com/AstrBotDevs/AstrBot/pull/8207))
- 修复核心图片请求路由到视觉 fallback 的问题。([#8089](https://github.com/AstrBotDevs/AstrBot/pull/8089))
- 修复文件组件中的文件名清理。([#8318](https://github.com/AstrBotDevs/AstrBot/pull/8318))
- 修复 Dashboard 子命令数量标签和插件重复展示问题。([#8388](https://github.com/AstrBotDevs/AstrBot/pull/8388), [#8389](https://github.com/AstrBotDevs/AstrBot/pull/8389))
- 修复无专用图片描述 provider 时,多模态主 provider 重复处理引用图片的问题。([#8401](https://github.com/AstrBotDevs/AstrBot/pull/8401))
- 修复分段回复中多余空行问题。([#8304](https://github.com/AstrBotDevs/AstrBot/pull/8304))
- 修复插件市场插件名称与本地插件名称不一致的问题。([#8276](https://github.com/AstrBotDevs/AstrBot/pull/8276))
- 修复 Dashboard 列表配置项无法输入空格的问题。([#8403](https://github.com/AstrBotDevs/AstrBot/pull/8403))
- 修复 stale command hints 相关问题。([#8245](https://github.com/AstrBotDevs/AstrBot/pull/8245))
- 改善 `SendMessageToUserTool` 的描述。([commit](https://github.com/AstrBotDevs/AstrBot/commit/49036f8f9))
### 文档与工程
- 更新 release 版本发布说明。([commit](https://github.com/AstrBotDevs/AstrBot/commit/dceacd5a8))
- 更新 FAQ 中 Dashboard 下载链接、强制刷新说明和删除数量说明。([#8359](https://github.com/AstrBotDevs/AstrBot/pull/8359), [#8235](https://github.com/AstrBotDevs/AstrBot/pull/8235), [commit](https://github.com/AstrBotDevs/AstrBot/commit/61b6813dc))
- 修正文档中的 QQ 官方 WebSocket 机器人中文说明 typo。([#8351](https://github.com/AstrBotDevs/AstrBot/pull/8351))
- 更新 GitHub Actions 依赖。([#8233](https://github.com/AstrBotDevs/AstrBot/pull/8233), [#8335](https://github.com/AstrBotDevs/AstrBot/pull/8335))
- 移除无用日志。([commit](https://github.com/AstrBotDevs/AstrBot/commit/9fc03fa95))
<a id="english"></a>
## What's Changed (EN)
### Features and Improvements
- Added a Markdown-aware knowledge base chunker for better structured document ingestion. ([#8151](https://github.com/AstrBotDevs/AstrBot/pull/8151))
- Redesigned long-term memory with context compaction for better memory continuity. ([#8226](https://github.com/AstrBotDevs/AstrBot/pull/8226))
- Added ChatUI command suggestions, including custom wake-up words and hover information. ([#8279](https://github.com/AstrBotDevs/AstrBot/pull/8279), [#8353](https://github.com/AstrBotDevs/AstrBot/pull/8353))
- Added capability icons for configured models. ([#8405](https://github.com/AstrBotDevs/AstrBot/pull/8405))
- Added Xiaomi and Xiaomi Token Plan LLM providers. ([#7744](https://github.com/AstrBotDevs/AstrBot/pull/7744))
- Improved QQ Official media-chain splitting and message sending, and passed through QQ webhook extra fields. ([#8376](https://github.com/AstrBotDevs/AstrBot/pull/8376), [#6274](https://github.com/AstrBotDevs/AstrBot/pull/6274))
- Added an EULA hint for the first notification. ([#7955](https://github.com/AstrBotDevs/AstrBot/pull/7955))
- Added direct access buttons on WebUI plugin cards and improved embedded page height. ([#8369](https://github.com/AstrBotDevs/AstrBot/pull/8369))
- Enabled smooth Markdown streaming. ([#8371](https://github.com/AstrBotDevs/AstrBot/pull/8371))
- Automated MDI icon font subsetting during Dashboard dev and build workflows. ([#8264](https://github.com/AstrBotDevs/AstrBot/pull/8264))
- Added a command to change the AstrBot Dashboard password. ([#8272](https://github.com/AstrBotDevs/AstrBot/pull/8272))
- Improved the default LLM compression prompt for better continuity. ([#8424](https://github.com/AstrBotDevs/AstrBot/pull/8424))
### Bug Fixes
- Fixed ChatUI voice recording and upload. ([#8440](https://github.com/AstrBotDevs/AstrBot/pull/8440))
- Fixed ChatUI reasoning summary labels and centered the right-side icon vertically.
- Fixed plugin reload failures where one plugin load failure could leave stale state and break other plugin reloads. ([#8441](https://github.com/AstrBotDevs/AstrBot/pull/8441))
- Fixed empty LLM summaries. ([#8195](https://github.com/AstrBotDevs/AstrBot/pull/8195))
- Fixed RST and AsciiDoc knowledge uploads. ([#8255](https://github.com/AstrBotDevs/AstrBot/pull/8255))
- Fixed T2I Shiki issues. ([#8013](https://github.com/AstrBotDevs/AstrBot/pull/8013))
- Fixed `delta=None` streaming chunks that could trigger SDK `to_dict()` errors. ([#8244](https://github.com/AstrBotDevs/AstrBot/pull/8244))
- Preserved original `completion_text` in skills_like tool re-query. ([#8240](https://github.com/AstrBotDevs/AstrBot/pull/8240))
- Fixed missing final usage metadata in OpenAI streaming responses. ([#8306](https://github.com/AstrBotDevs/AstrBot/pull/8306))
- Fixed SQLAlchemy compatibility issues on macOS. ([#7724](https://github.com/AstrBotDevs/AstrBot/pull/7724))
- Restored mobile Provider Source deletion in the WebUI. ([#8321](https://github.com/AstrBotDevs/AstrBot/pull/8321))
- Fixed invalid voice parameters in Mimo voice design model requests. ([#8326](https://github.com/AstrBotDevs/AstrBot/pull/8326))
- Fixed Mimo reasoning content handling. ([#8327](https://github.com/AstrBotDevs/AstrBot/pull/8327))
- Fixed Anthropic API `tool_choice` schema conversion. ([#8328](https://github.com/AstrBotDevs/AstrBot/pull/8328))
- Added a type guard for plugin metadata repo values. ([#8207](https://github.com/AstrBotDevs/AstrBot/pull/8207))
- Fixed core image request routing to vision fallback. ([#8089](https://github.com/AstrBotDevs/AstrBot/pull/8089))
- Sanitized file names in file components. ([#8318](https://github.com/AstrBotDevs/AstrBot/pull/8318))
- Fixed Dashboard sub-command count labels and duplicate plugin display. ([#8388](https://github.com/AstrBotDevs/AstrBot/pull/8388), [#8389](https://github.com/AstrBotDevs/AstrBot/pull/8389))
- Prevented duplicate processing of quoted images by multimodal main providers when no dedicated image caption provider is configured. ([#8401](https://github.com/AstrBotDevs/AstrBot/pull/8401))
- Removed extra blank lines in segmented replies. ([#8304](https://github.com/AstrBotDevs/AstrBot/pull/8304))
- Fixed plugin name mismatches between the marketplace and local plugins. ([#8276](https://github.com/AstrBotDevs/AstrBot/pull/8276))
- Fixed Dashboard list config items being unable to input spaces. ([#8403](https://github.com/AstrBotDevs/AstrBot/pull/8403))
- Fixed stale command hints. ([#8245](https://github.com/AstrBotDevs/AstrBot/pull/8245))
- Improved the `SendMessageToUserTool` description. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/49036f8f9))
### Documentation and Maintenance
- Updated release version instructions. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/dceacd5a8))
- Updated FAQ Dashboard download links, hard refresh instructions, and deletion count wording. ([#8359](https://github.com/AstrBotDevs/AstrBot/pull/8359), [#8235](https://github.com/AstrBotDevs/AstrBot/pull/8235), [commit](https://github.com/AstrBotDevs/AstrBot/commit/61b6813dc))
- Fixed a typo in the Chinese QQ Official WebSocket bot setup documentation. ([#8351](https://github.com/AstrBotDevs/AstrBot/pull/8351))
- Updated GitHub Actions dependencies. ([#8233](https://github.com/AstrBotDevs/AstrBot/pull/8233), [#8335](https://github.com/AstrBotDevs/AstrBot/pull/8335))
- Removed unused logs. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/9fc03fa95))

View File

@@ -526,6 +526,7 @@ import {
type TransportMode,
} from "@/composables/useMessages";
import { useMediaHandling } from "@/composables/useMediaHandling";
import { useRecording } from "@/composables/useRecording";
import { useProjects } from "@/composables/useProjects";
import { useCustomizerStore } from "@/stores/customizer";
import ProviderChatCompletionPanel from "@/components/provider/ProviderChatCompletionPanel.vue";
@@ -633,8 +634,12 @@ const threadSelection = reactive<{
selectedText: "",
});
const enableStreaming = ref(true);
const isRecording = ref(false);
const sendShortcut = ref<"enter" | "shift_enter">("enter");
const {
isRecording,
startRecording: startRecorder,
stopRecording: stopRecorder,
} = useRecording();
const chatSidebarDrawer = computed({
get: () => lgAndUp.value || customizer.chatSidebarOpen,
set: (value: boolean) => {
@@ -1303,12 +1308,26 @@ function toggleStreaming() {
enableStreaming.value = !enableStreaming.value;
}
function startRecording() {
isRecording.value = true;
async function startRecording() {
try {
await startRecorder();
} catch (error) {
console.error("Failed to start recording:", error);
toast.error(tm("voice.error"));
}
}
function stopRecording() {
isRecording.value = false;
async function stopRecording() {
try {
const audioFile = await stopRecorder();
const uploaded = await processAndUploadFile(audioFile);
if (!uploaded) {
toast.error(tm("voice.error"));
}
} catch (error) {
console.error("Failed to stop recording:", error);
toast.error(tm("voice.error"));
}
}
function handleMessagesScroll() {
@@ -1520,7 +1539,12 @@ function toggleTheme() {
}
.session-progress {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
flex-shrink: 0;
transition: right 0.16s ease;
}
.session-actions {
@@ -1544,6 +1568,11 @@ function toggleTheme() {
visibility: visible;
}
.session-item:hover .session-progress,
.session-item:focus-within .session-progress {
right: 62px;
}
.session-action-btn {
color: var(--chat-muted);
}

View File

@@ -2,7 +2,7 @@
<transition name="slide-left">
<aside v-if="modelValue" class="reasoning-sidebar">
<div class="reasoning-sidebar-header">
<div class="reasoning-sidebar-title">{{ tm("reasoning.thinking") }}</div>
<div class="reasoning-sidebar-title">{{ reasoningTitle }}</div>
<v-btn icon="mdi-close" size="small" variant="text" @click="close" />
</div>
@@ -14,7 +14,7 @@
:is-dark="isDark"
/>
<div v-else class="reasoning-sidebar-empty">
{{ tm("reasoning.thinking") }}
{{ reasoningTitle }}
</div>
</div>
</aside>
@@ -22,11 +22,16 @@
</template>
<script setup lang="ts">
import type { MessagePart } from "@/composables/useMessages";
import { computed } from "vue";
import {
reasoningActivityCounts,
reasoningActivityTitle,
type MessagePart,
} from "@/composables/useMessages";
import { useModuleI18n } from "@/i18n/composables";
import ReasoningTimeline from "@/components/chat/message_list_comps/ReasoningTimeline.vue";
defineProps<{
const props = defineProps<{
modelValue: boolean;
parts: MessagePart[];
reasoning?: string;
@@ -39,6 +44,14 @@ const emit = defineEmits<{
const { tm } = useModuleI18n("features/chat");
const activityCounts = computed(() =>
reasoningActivityCounts(props.parts, props.reasoning || ""),
);
const reasoningTitle = computed(() =>
reasoningActivityTitle(activityCounts.value, tm),
);
function close() {
emit("update:modelValue", false);
}

View File

@@ -7,7 +7,7 @@
@click="handlePrimaryAction"
>
<span class="reasoning-title">
{{ tm("reasoning.thinking") }}
{{ reasoningTitle }}
</span>
<v-icon
size="22"
@@ -40,7 +40,11 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import type { MessagePart } from "@/composables/useMessages";
import {
reasoningActivityCounts,
reasoningActivityTitle,
type MessagePart,
} from "@/composables/useMessages";
import { useModuleI18n } from "@/i18n/composables";
import ReasoningTimeline from "@/components/chat/message_list_comps/ReasoningTimeline.vue";
@@ -75,6 +79,14 @@ const renderParts = computed<MessagePart[]>(() => {
const openInSidebar = computed(() => Boolean(props.openInSidebar));
const activityCounts = computed(() =>
reasoningActivityCounts(renderParts.value, props.reasoning || ""),
);
const reasoningTitle = computed(() =>
reasoningActivityTitle(activityCounts.value, tm),
);
const thinkingText = computed(() =>
renderParts.value
.filter((part) => part.type === "think")
@@ -214,14 +226,11 @@ onBeforeUnmount(() => {
color: rgba(var(--v-theme-on-surface), 0.88);
}
.reasoning-header--trigger {
align-items: flex-start;
}
.reasoning-icon {
color: currentcolor;
transition: transform 0.2s ease;
flex-shrink: 0;
align-self: center;
}
.reasoning-title {

View File

@@ -25,13 +25,25 @@ const props = defineProps({
searchKeyword: {
type: String,
default: ''
},
pluginName: {
type: String,
default: ''
},
pluginI18n: {
type: Object,
default: () => ({})
},
pathPrefix: {
type: String,
default: ''
}
})
const { t } = useI18n()
const { getRaw } = useModuleI18n('features/config-metadata')
const { tm: tmConfig } = useModuleI18n('features/config')
const { translateIfKey } = useConfigTextResolver()
const { translateIfKey, resolveConfigText } = useConfigTextResolver(props)
const hintMarkdown = new MarkdownIt({
linkify: true,
@@ -114,6 +126,18 @@ function createSelectorModel(selector) {
})
}
function getItemPath(key) {
return props.pathPrefix ? `${props.pathPrefix}.${key}` : key
}
function getItemDescription(itemKey, itemMeta) {
return resolveConfigText(getItemPath(itemKey), 'description', itemMeta?.description) || itemKey
}
function getItemHint(itemKey, itemMeta) {
return resolveConfigText(getItemPath(itemKey), 'hint', itemMeta?.hint)
}
function openEditorDialog(key, value, theme, language) {
currentEditingKey.value = key
currentEditingLanguage.value = language || 'json'
@@ -143,8 +167,8 @@ function shouldShowItem(itemMeta, itemKey) {
const searchableText = [
itemKey,
translateIfKey(itemMeta?.description || ''),
translateIfKey(itemMeta?.hint || '')
getItemDescription(itemKey, itemMeta),
getItemHint(itemKey, itemMeta)
].join(' ').toLowerCase()
return searchableText.includes(keyword)
@@ -259,13 +283,13 @@ function getSpecialSubtype(value) {
<v-col cols="12" sm="6" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
{{ translateIfKey(itemMeta?.description) || itemKey }}
{{ getItemDescription(itemKey, itemMeta) }}
<span class="property-key">({{ itemKey }})</span>
</v-list-item-title>
<v-list-item-subtitle class="property-hint">
<span v-if="itemMeta?.obvious_hint && itemMeta?.hint" class="important-hint"></span>
<span v-html="renderHint(itemMeta?.hint)"></span>
<span v-html="renderHint(getItemHint(itemKey, itemMeta))"></span>
</v-list-item-subtitle>
</v-list-item>
</v-col>
@@ -274,12 +298,18 @@ function getSpecialSubtype(value) {
v-if="itemMeta?.type === 'template_list'"
v-model="createSelectorModel(itemKey).value"
:templates="itemMeta?.templates || {}"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-path="getItemPath(itemKey)"
class="config-field"
/>
<ConfigItemRenderer
v-else
v-model="createSelectorModel(itemKey).value"
:item-meta="itemMeta || null"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-key="getItemPath(itemKey)"
:show-fullscreen-btn="!!itemMeta?.editor_mode"
@open-fullscreen="openEditorDialog(itemKey, iterable, itemMeta?.editor_theme, itemMeta?.editor_language)"
/>
@@ -339,13 +369,13 @@ function getSpecialSubtype(value) {
<v-col cols="12" sm="6" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
{{ translateIfKey(itemMeta?.description) || itemKey }}
{{ getItemDescription(itemKey, itemMeta) }}
<span class="property-key">({{ itemKey }})</span>
</v-list-item-title>
<v-list-item-subtitle class="property-hint">
<span v-if="itemMeta?.obvious_hint && itemMeta?.hint" class="important-hint"></span>
<span v-html="renderHint(itemMeta?.hint)"></span>
<span v-html="renderHint(getItemHint(itemKey, itemMeta))"></span>
</v-list-item-subtitle>
</v-list-item>
</v-col>
@@ -354,12 +384,18 @@ function getSpecialSubtype(value) {
v-if="itemMeta?.type === 'template_list'"
v-model="createSelectorModel(itemKey).value"
:templates="itemMeta?.templates || {}"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-path="getItemPath(itemKey)"
class="config-field"
/>
<ConfigItemRenderer
v-else
v-model="createSelectorModel(itemKey).value"
:item-meta="itemMeta || null"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-key="getItemPath(itemKey)"
:show-fullscreen-btn="!!itemMeta?.editor_mode"
@open-fullscreen="openEditorDialog(itemKey, iterable, itemMeta?.editor_theme, itemMeta?.editor_language)"
/>

View File

@@ -57,8 +57,11 @@
</v-btn>
<div class="d-flex flex-column">
<v-list-item-title class="property-name">{{ templateLabel(entry.__template_key) }}</v-list-item-title>
<v-list-item-subtitle class="property-hint" v-if="getTemplate(entry)?.hint || getTemplate(entry)?.description">
{{ templateText(entry.__template_key, 'hint', getTemplate(entry)?.hint || getTemplate(entry)?.description) }}
<v-list-item-subtitle class="property-hint entry-display-text" v-if="templateDisplayText(entry)">
{{ templateDisplayText(entry) }}
</v-list-item-subtitle>
<v-list-item-subtitle class="property-hint" v-if="templateHintText(entry)">
{{ templateHintText(entry) }}
</v-list-item-subtitle>
</div>
</div>
@@ -201,6 +204,7 @@ const defaultValueMap = {
string: '',
text: '',
list: [],
file: [],
object: {},
template_list: []
}
@@ -348,6 +352,49 @@ function getTemplate(entry) {
return props.templates?.[key] || null
}
function templateHintText(entry) {
const template = getTemplate(entry)
if (!template || template.hide_hint_in_list) return ''
return templateText(entry.__template_key, 'hint', template.hint || template.description || '')
}
function getItemMetaBySelector(itemsMeta = {}, selector = '') {
const keys = selector.split('.').filter(Boolean)
let currentItems = itemsMeta
let currentMeta = null
for (let i = 0; i < keys.length; i++) {
currentMeta = currentItems?.[keys[i]]
if (!currentMeta) return null
if (i < keys.length - 1) {
if (currentMeta.type !== 'object') return null
currentItems = currentMeta.items || {}
}
}
return currentMeta
}
function templateDisplayText(entry) {
const template = getTemplate(entry)
const displayItem = template?.display_item
if (!template || typeof displayItem !== 'string' || !displayItem) return ''
const displayMeta = getItemMetaBySelector(template.items || {}, displayItem)
if (displayMeta?.type !== 'string') return ''
const value = getValueBySelector(entry, displayItem)
if (typeof value !== 'string' || !value.trim()) return ''
const label = templateItemText(
entry.__template_key,
displayItem,
'description',
displayMeta.description || displayItem,
)
return `${label}: ${value.trim()}`
}
function getValueBySelector(obj, selector) {
const keys = selector.split('.')
let current = obj
@@ -450,6 +497,11 @@ function hasVisibleItemsAfter(entries, currentIndex, entry) {
margin-top: 2px;
}
.entry-display-text {
color: var(--v-theme-primary);
font-weight: 500;
}
.property-key {
font-size: 0.85em;
opacity: 0.7;

View File

@@ -11,7 +11,6 @@ export interface StagedFileInfo {
}
export function useMediaHandling() {
const stagedAudioUrl = ref<string>('');
const stagedFiles = ref<StagedFileInfo[]>([]);
const mediaCache = ref<Record<string, string>>({});
const pendingFileSignatures = new Set<string>();
@@ -56,9 +55,9 @@ export function useMediaHandling() {
}
}
async function uploadStagedFile(file: File) {
async function uploadStagedFile(file: File): Promise<StagedFileInfo | undefined> {
const signature = await getFileSignature(file);
if (isDuplicateFile(signature)) return;
if (isDuplicateFile(signature)) return undefined;
pendingFileSignatures.add(signature);
const formData = new FormData();
@@ -72,27 +71,30 @@ export function useMediaHandling() {
});
const { attachment_id, filename, type } = response.data.data;
stagedFiles.value.push({
const stagedFile = {
attachment_id,
filename,
original_name: file.name,
url: URL.createObjectURL(file),
type,
signature
});
};
stagedFiles.value.push(stagedFile);
return stagedFile;
} catch (err) {
console.error('Error uploading file:', err);
return undefined;
} finally {
pendingFileSignatures.delete(signature);
}
}
async function processAndUploadImage(file: File) {
await uploadStagedFile(file);
return uploadStagedFile(file);
}
async function processAndUploadFile(file: File) {
await uploadStagedFile(file);
return uploadStagedFile(file);
}
async function handlePaste(event: ClipboardEvent) {
@@ -128,14 +130,25 @@ export function useMediaHandling() {
}
function removeAudio() {
stagedAudioUrl.value = '';
for (let i = stagedFiles.value.length - 1; i >= 0; i--) {
if (stagedFiles.value[i].type !== 'record') continue;
const fileToRemove = stagedFiles.value[i];
if (fileToRemove.url.startsWith('blob:')) {
URL.revokeObjectURL(fileToRemove.url);
}
stagedFiles.value.splice(i, 1);
}
}
function removeFile(index: number) {
// 找到第 index 个非图片类型的文件
// Find the requested non-image, non-audio attachment.
let fileCount = 0;
for (let i = 0; i < stagedFiles.value.length; i++) {
if (stagedFiles.value[i].type !== 'image') {
if (
stagedFiles.value[i].type !== 'image' &&
stagedFiles.value[i].type !== 'record'
) {
if (fileCount === index) {
const fileToRemove = stagedFiles.value[i];
if (fileToRemove.url.startsWith('blob:')) {
@@ -151,7 +164,6 @@ export function useMediaHandling() {
function clearStaged(options: { revokeUrls?: boolean } = {}) {
const { revokeUrls = true } = options;
stagedAudioUrl.value = '';
if (revokeUrls) {
// 清理文件的 blob URLs
stagedFiles.value.forEach(file => {
@@ -177,9 +189,13 @@ export function useMediaHandling() {
stagedFiles.value.filter(f => f.type === 'image').map(f => f.url)
);
const stagedAudioUrl = computed(() =>
stagedFiles.value.find(f => f.type === 'record')?.url || ''
);
// 计算属性:获取非图片文件列表
const stagedNonImageFiles = computed(() =>
stagedFiles.value.filter(f => f.type !== 'image')
stagedFiles.value.filter(f => f.type !== 'image' && f.type !== 'record')
);
return {

View File

@@ -794,6 +794,44 @@ export function extractReasoningText(
return text || legacyReasoning;
}
export function reasoningActivityCounts(
parts: MessagePart[] | unknown,
legacyReasoning = "",
) {
const normalizedParts = Array.isArray(parts)
? parts
: normalizeMessageParts(parts, legacyReasoning);
let thinkCount = 0;
let toolCount = 0;
for (const part of normalizedParts) {
if (part.type === "think" && String(part.think || "").trim()) {
thinkCount += 1;
}
if (part.type === "tool_call" && Array.isArray(part.tool_calls)) {
toolCount += part.tool_calls.length;
}
}
return { thinkCount, toolCount };
}
export function reasoningActivityTitle(
counts: ReturnType<typeof reasoningActivityCounts>,
tm: (key: string, params?: Record<string, string | number>) => string,
) {
return [
counts.thinkCount > 0
? tm("reasoning.thinkSummary", { count: counts.thinkCount })
: "",
counts.toolCount > 0
? tm("reasoning.toolSummary", { count: counts.toolCount })
: "",
]
.filter(Boolean)
.join(tm("reasoning.summarySeparator")) || tm("reasoning.thinking");
}
export function thinkingParts(content: ChatContent): MessagePart[] {
const firstThinkingBlock = messageBlocks(content).find(
(block) => block.kind === "thinking",

View File

@@ -1,11 +1,27 @@
import { ref } from 'vue';
import axios from 'axios';
export function useRecording() {
const isRecording = ref(false);
const audioChunks = ref<Blob[]>([]);
const mediaRecorder = ref<MediaRecorder | null>(null);
function getSupportedMimeType(): string {
const candidates = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
'audio/ogg',
'audio/mp4',
'audio/wav'
];
if (typeof MediaRecorder === 'undefined' || !MediaRecorder.isTypeSupported) {
return '';
}
return candidates.find(type => MediaRecorder.isTypeSupported(type)) || '';
}
function getRecordingMimeType(): string {
const chunkType = audioChunks.value.find(chunk => chunk.type)?.type;
return chunkType || mediaRecorder.value?.mimeType || 'audio/webm';
@@ -23,16 +39,30 @@ export function useRecording() {
};
const normalizedMimeType = mimeType.toLowerCase();
const extension = extensionMap[normalizedMimeType] || normalizedMimeType.split('/')[1]?.split(';')[0] || 'webm';
return `${crypto.randomUUID()}.${extension}`;
const id = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${id}.${extension}`;
}
async function startRecording(onStart?: (label: string) => void) {
try {
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
throw new Error('Audio recording is not supported in this browser');
}
mediaRecorder.value?.stream.getTracks().forEach(track => track.stop());
audioChunks.value = [];
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder.value = new MediaRecorder(stream);
const mimeType = getSupportedMimeType();
mediaRecorder.value = new MediaRecorder(
stream,
mimeType ? { mimeType } : undefined
);
mediaRecorder.value.ondataavailable = (event) => {
audioChunks.value.push(event.data);
if (event.data.size > 0) {
audioChunks.value.push(event.data);
}
};
mediaRecorder.value.start();
@@ -43,13 +73,16 @@ export function useRecording() {
}
} catch (error) {
console.error('Failed to start recording:', error);
isRecording.value = false;
throw error;
}
}
async function stopRecording(onStop?: (label: string) => void): Promise<string> {
async function stopRecording(onStop?: (label: string) => void): Promise<File> {
return new Promise((resolve, reject) => {
if (!mediaRecorder.value) {
reject('No media recorder');
const recorder = mediaRecorder.value;
if (!recorder) {
reject(new Error('No media recorder'));
return;
}
@@ -58,33 +91,45 @@ export function useRecording() {
onStop('聊天输入框');
}
mediaRecorder.value.stop();
mediaRecorder.value.onstop = async () => {
recorder.onstop = () => {
const mimeType = getRecordingMimeType();
const audioBlob = new Blob(audioChunks.value, { type: mimeType });
const filename = getRecordingFilename(mimeType);
audioChunks.value = [];
mediaRecorder.value?.stream.getTracks().forEach(track => track.stop());
const formData = new FormData();
formData.append('file', audioBlob, filename);
try {
const response = await axios.post('/api/chat/post_file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
const attachmentId = response.data.data.attachment_id;
console.log('Audio uploaded:', attachmentId);
resolve(attachmentId);
} catch (err) {
console.error('Error uploading audio:', err);
reject(err);
recorder.stream.getTracks().forEach(track => track.stop());
if (mediaRecorder.value === recorder) {
mediaRecorder.value = null;
}
if (!audioBlob.size) {
reject(new Error('Recording is empty'));
return;
}
const filename = getRecordingFilename(mimeType);
const audioFile = new File([audioBlob], filename, {
type: mimeType,
lastModified: Date.now()
});
resolve(audioFile);
};
recorder.onerror = (event) => {
recorder.stream.getTracks().forEach(track => track.stop());
if (mediaRecorder.value === recorder) {
mediaRecorder.value = null;
}
reject(event);
};
try {
recorder.stop();
} catch (error) {
recorder.stream.getTracks().forEach(track => track.stop());
if (mediaRecorder.value === recorder) {
mediaRecorder.value = null;
}
reject(error);
}
});
}

View File

@@ -114,6 +114,9 @@
},
"reasoning": {
"thinking": "Thinking Process",
"summarySeparator": ", ",
"thinkSummary": "Thought {count} times",
"toolSummary": "used {count} tools",
"think": "Thinking",
"toolUsed": "Using Tool"
},

View File

@@ -114,6 +114,9 @@
},
"reasoning": {
"thinking": "Рассуждение",
"summarySeparator": ", ",
"thinkSummary": "Размышлений: {count}",
"toolSummary": "использований инструментов: {count}",
"think": "Размышление",
"toolUsed": "Использование инструмента"
},

View File

@@ -114,6 +114,9 @@
},
"reasoning": {
"thinking": "思考过程",
"summarySeparator": "",
"thinkSummary": "思考了 {count} 次",
"toolSummary": "使用了 {count} 次工具",
"think": "思考",
"toolUsed": "使用工具"
},

View File

@@ -146,7 +146,14 @@ Plugin developers can add a template-style configuration to `_conf_schema` in th
"template_1": {
"name": "Template One",
"hint":"hint",
"display_item": "attr_name",
"hide_hint_in_list": true,
"items": {
"attr_name": {
"description": "Attribute Name",
"type": "string",
"default": ""
},
"attr_a": {
"description": "Attribute A",
"type": "int",
@@ -187,6 +194,7 @@ Saved config example:
"field_id": [
{
"__template_key": "template_1",
"attr_name": "",
"attr_a": 10,
"attr_b": true
},
@@ -198,6 +206,11 @@ Saved config example:
]
```
Templates also support these optional fields:
- `display_item`: Specifies the key of a `string` item inside the template `items`. When set, the WebUI shows that field's current value in the collapsed list of added template entries, for example `Attribute Name: my-adapter`, making it easier to distinguish multiple entries created from the same template. Dot paths are supported for fields inside nested objects, for example `meta.name`.
- `hide_hint_in_list`: When set to `true`, the WebUI hides the template `hint` in the collapsed list of added template entries. The template selection dropdown still shows the `hint`, and hints for fields inside the expanded entry are not affected.
<img width="1000" alt="image" src="https://github.com/user-attachments/assets/74876d30-11a4-491b-a7a0-8ebe8d603782" />

View File

@@ -146,7 +146,14 @@ AstrBot 提供了“强大”的配置解析和可视化功能。能够让用户
"template_1": {
"name": "Template One",
"hint":"hint",
"display_item": "attr_name",
"hide_hint_in_list": true,
"items": {
"attr_name": {
"description": "Attribute Name",
"type": "string",
"default": ""
},
"attr_a": {
"description": "Attribute A",
"type": "int",
@@ -187,6 +194,7 @@ AstrBot 提供了“强大”的配置解析和可视化功能。能够让用户
"field_id": [
{
"__template_key": "template_1",
"attr_name": "",
"attr_a": 10,
"attr_b": true
},
@@ -198,6 +206,11 @@ AstrBot 提供了“强大”的配置解析和可视化功能。能够让用户
]
```
模板本身还支持以下可选字段:
- `display_item`: 指定模板 `items` 中一个 `string` 类型字段的 key。设置后WebUI 会在已添加模板条目的折叠列表中显示该字段当前值,例如 `Attribute Name: my-adapter`,便于添加多个同类型模板时快速区分。支持用点号选择嵌套 object 中的字段,例如 `meta.name`
- `hide_hint_in_list`: 设置为 `true`WebUI 会在已添加模板条目的折叠列表中隐藏该模板的 `hint`。添加模板时的下拉菜单仍会显示 `hint`,展开条目后各配置项自己的 `hint` 也不受影响。
<img width="1000" alt="image" src="https://github.com/user-attachments/assets/74876d30-11a4-491b-a7a0-8ebe8d603782" />
## 在插件中使用配置

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.25.1"
version = "4.25.2"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
license = { text = "AGPL-3.0-or-later" }

190
tests/test_cli_plugin.py Normal file
View File

@@ -0,0 +1,190 @@
from pathlib import Path
import pytest
from click import ClickException
from click.testing import CliRunner
import astrbot.cli.utils.plugin as plugin_utils
from astrbot.cli.commands.cmd_plug import plug
def _write_plugin(path: Path, name: str = "astrbot_plugin_local_demo") -> None:
path.mkdir(parents=True)
(path / "metadata.yaml").write_text(
"\n".join(
[
f"name: {name}",
"desc: Local plugin",
"version: 1.0.0",
"author: AstrBot",
"repo: https://example.com/local-plugin",
],
),
encoding="utf-8",
)
(path / "main.py").write_text("PLUGIN_LOADED = True\n", encoding="utf-8")
def _write_ignored_plugin_files(path: Path) -> None:
for ignored_dir in [".git", ".venv", "__pycache__", ".idea", ".vscode", ".zed"]:
ignored_path = path / ignored_dir
ignored_path.mkdir()
(ignored_path / "ignored.txt").write_text("ignored\n", encoding="utf-8")
(path / "__pycache__" / "main.pyc").write_bytes(b"ignored")
def _write_astrbot_root(path: Path) -> None:
(path / ".astrbot").touch()
(path / "data" / "plugins").mkdir(parents=True)
def test_plugin_install_editable_symlinks_local_plugin(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
root = tmp_path / "root"
source = tmp_path / "source-plugin"
root.mkdir()
_write_astrbot_root(root)
_write_plugin(source)
monkeypatch.chdir(root)
result = CliRunner().invoke(
plug,
["install", "-e", str(source)],
catch_exceptions=False,
)
target = root / "data" / "plugins" / "astrbot_plugin_local_demo"
target = root / "data" / "plugins" / "astrbot_plugin_local_demo"
assert result.exit_code == 0
assert target.is_symlink()
assert (target / "metadata.yaml").exists()
assert (target / "main.py").read_text(encoding="utf-8") == "PLUGIN_LOADED = True\n"
def test_plugin_install_accepts_local_path_without_editable_flag(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
root = tmp_path / "root"
source = tmp_path / "source-plugin"
root.mkdir()
_write_astrbot_root(root)
_write_plugin(source)
_write_ignored_plugin_files(source)
monkeypatch.chdir(root)
result = CliRunner().invoke(plug, ["install", str(source)])
target = root / "data" / "plugins" / "astrbot_plugin_local_demo"
assert result.exit_code == 0
assert not target.is_symlink()
assert (target / "metadata.yaml").exists()
assert not (target / ".git").exists()
assert not (target / ".venv").exists()
assert not (target / "__pycache__").exists()
assert not (target / ".idea").exists()
assert not (target / ".vscode").exists()
assert not (target / ".zed").exists()
def test_plugin_install_editable_rejects_existing_plugin(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
root = tmp_path / "root"
source = tmp_path / "source-plugin"
root.mkdir()
_write_astrbot_root(root)
_write_plugin(source)
_write_plugin(root / "data" / "plugins" / "astrbot_plugin_local_demo")
monkeypatch.chdir(root)
result = CliRunner().invoke(plug, ["install", "-e", str(source)])
assert result.exit_code != 0
assert "already exists" in result.output
def test_plugin_install_rejects_plugin_name_with_path_separator(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
root = tmp_path / "root"
source = tmp_path / "source-plugin"
root.mkdir()
_write_astrbot_root(root)
_write_plugin(source, name="../bad_plugin")
monkeypatch.chdir(root)
result = CliRunner().invoke(plug, ["install", str(source)])
assert result.exit_code != 0
assert "invalid name" in result.output
assert not (root / "data" / "bad_plugin").exists()
def test_plugin_install_copy_does_not_delete_existing_target_on_race(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
root = tmp_path / "root"
source = tmp_path / "source-plugin"
root.mkdir()
_write_astrbot_root(root)
_write_plugin(source)
monkeypatch.chdir(root)
target = root / "data" / "plugins" / "astrbot_plugin_local_demo"
target.mkdir()
marker = target / "keep.txt"
marker.write_text("keep\n", encoding="utf-8")
result = CliRunner().invoke(plug, ["install", str(source)])
assert result.exit_code != 0
assert "already exists" in result.output
assert marker.read_text(encoding="utf-8") == "keep\n"
def test_plugin_install_copy_does_not_delete_concurrently_created_target(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source = tmp_path / "source-plugin"
plugins_dir = tmp_path / "plugins"
_write_plugin(source)
target = plugins_dir / "astrbot_plugin_local_demo"
def create_target_then_fail(
_source_path: Path,
_plugins_dir: Path,
_target_path: Path,
) -> None:
target.mkdir(parents=True)
(target / "keep.txt").write_text("keep\n", encoding="utf-8")
raise FileExistsError
monkeypatch.setattr(plugin_utils, "_copy_local_plugin", create_target_then_fail)
with pytest.raises(ClickException, match="already exists"):
plugin_utils.install_local_plugin(source, plugins_dir)
assert (target / "keep.txt").read_text(encoding="utf-8") == "keep\n"
def test_plugin_install_requires_name_or_editable_path(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
root = tmp_path / "root"
root.mkdir()
_write_astrbot_root(root)
monkeypatch.chdir(root)
result = CliRunner().invoke(plug, ["install"])
assert result.exit_code != 0
assert "Missing plugin name or local plugin path" in result.output

View File

@@ -28,9 +28,7 @@ class MockStar:
self.info = {"repo": TEST_PLUGIN_REPO, "readme": ""}
def _write_local_test_plugin(
plugin_path: Path, repo_url: str, version: str = "1.0.0"
):
def _write_local_test_plugin(plugin_path: Path, repo_url: str, version: str = "1.0.0"):
"""Creates a minimal valid plugin structure."""
plugin_path.mkdir(parents=True, exist_ok=True)
metadata = {
@@ -148,11 +146,22 @@ def _clear_module_cache():
"""Clear test-specific modules from sys.modules to allow reloading."""
import sys
to_del = [m for m in sys.modules if m.startswith("data.plugins.helloworld")]
to_del = [
m
for m in sys.modules
if m.startswith("data.plugins.helloworld")
or m.startswith("data.plugins.broken_plugin")
]
for m in to_del:
del sys.modules[m]
def _clear_star_runtime_state():
star_manager_module.star_map.clear()
star_manager_module.star_registry.clear()
star_manager_module.star_handlers_registry.clear()
def _build_load_mock(events):
async def mock_load(specified_dir_name=None, ignore_version_check=False):
del ignore_version_check
@@ -467,6 +476,107 @@ async def test_reload_failed_plugin_dependency_install_flow(
assert events[1] == ("load", TEST_PLUGIN_DIR)
@pytest.mark.asyncio
async def test_reload_all_unbinds_every_registered_plugin(
plugin_manager_pm: PluginManager, monkeypatch
):
_clear_star_runtime_state()
plugin_names = ["plugin_one", "plugin_two", "plugin_three"]
for plugin_name in plugin_names:
module_path = f"data.plugins.{plugin_name}.main"
metadata = star_manager_module.StarMetadata(
name=plugin_name,
root_dir_name=plugin_name,
module_path=module_path,
)
star_manager_module.star_map[module_path] = metadata
star_manager_module.star_registry.append(metadata)
terminated = []
unbound = []
async def mock_terminate(plugin):
terminated.append(plugin.name)
async def mock_unbind(plugin_name, plugin_module_path):
unbound.append(plugin_name)
star_manager_module.star_map.pop(plugin_module_path, None)
for index, metadata in enumerate(star_manager_module.star_registry):
if metadata.name == plugin_name:
del star_manager_module.star_registry[index]
break
async def mock_load(
specified_module_path=None,
specified_dir_name=None,
ignore_version_check=False,
):
del specified_module_path, specified_dir_name, ignore_version_check
return True, None
monkeypatch.setattr(plugin_manager_pm, "_terminate_plugin", mock_terminate)
monkeypatch.setattr(plugin_manager_pm, "_unbind_plugin", mock_unbind)
monkeypatch.setattr(plugin_manager_pm, "load", mock_load)
try:
await plugin_manager_pm.reload()
finally:
_clear_star_runtime_state()
assert terminated == plugin_names
assert unbound == plugin_names
@pytest.mark.asyncio
async def test_load_reports_unregistered_plugin_without_index_error(
plugin_manager_pm: PluginManager, monkeypatch
):
_clear_star_runtime_state()
plugin_root = Path(plugin_manager_pm.plugin_store_path).parents[1]
plugin_name = "broken_plugin"
plugin_path = Path(plugin_manager_pm.plugin_store_path) / plugin_name
plugin_path.mkdir(parents=True)
(plugin_path / "metadata.yaml").write_text(
yaml.dump(
{
"name": plugin_name,
"author": "AstrBot Team",
"desc": "Broken test plugin",
"version": "1.0.0",
}
),
encoding="utf-8",
)
(plugin_path / "main.py").write_text("VALUE = 1\n", encoding="utf-8")
async def mock_global_get(key, default=None):
del key
return default
async def mock_sync_command_configs():
return None
monkeypatch.syspath_prepend(str(plugin_root))
monkeypatch.setattr(star_manager_module.sp, "global_get", mock_global_get)
monkeypatch.setattr(
star_manager_module,
"sync_command_configs",
mock_sync_command_configs,
)
try:
success, error = await plugin_manager_pm.load(specified_dir_name=plugin_name)
finally:
_clear_star_runtime_state()
_clear_module_cache()
assert success is False
assert error is not None
assert "未通过 Star 注册" in error
assert "list index out of range" not in error
assert plugin_name in plugin_manager_pm.failed_plugin_dict
@pytest.mark.asyncio
async def test_ensure_plugin_requirements_reraises_cancelled_error(
plugin_manager_pm: PluginManager, local_updator: Path, monkeypatch

View File

@@ -0,0 +1,88 @@
"""Tests for dashboard route utility helpers."""
from astrbot.dashboard.routes.config import validate_config
from astrbot.dashboard.routes.util import get_schema_item
def test_get_schema_item_template_list_file_item():
schema = {
"demo_templates": {
"type": "template_list",
"templates": {
"api_provider": {
"items": {
"tls_certificate_files": {"type": "file"},
},
},
},
},
}
meta = get_schema_item(
schema,
"demo_templates.templates.api_provider.tls_certificate_files",
)
assert meta == {"type": "file"}
def test_get_schema_item_nested_template_list_file_item():
schema = {
"group": {
"type": "object",
"items": {
"demo_templates": {
"type": "template_list",
"templates": {
"nested_profile": {
"items": {
"profile": {
"type": "object",
"items": {
"attachments": {"type": "file"},
},
},
},
},
},
},
},
},
}
meta = get_schema_item(
schema,
"group.demo_templates.templates.nested_profile.profile.attachments",
)
assert meta == {"type": "file"}
def test_validate_config_template_list_file_path_uses_template_schema_path():
schema = {
"demo_templates": {
"type": "template_list",
"templates": {
"api_provider": {
"items": {
"tls_certificate_files": {"type": "file"},
},
},
},
},
}
data = {
"demo_templates": [
{
"__template_key": "api_provider",
"tls_certificate_files": [
"files/demo_templates/templates/api_provider/tls_certificate_files/cert.pem"
],
}
]
}
errors, validated = validate_config(data, schema, is_core=False)
assert errors == []
assert validated == data