Compare commits

...

20 Commits

Author SHA1 Message Date
Soulter
2ec7eab8e2 fix(provider): handle audio parts in OpenAI Responses adapter 2026-06-27 16:58:13 +08:00
DolphinZZZZZ
777d67d323 test(provider): cover OpenAI Responses adapter 2026-06-22 15:30:14 +08:00
DolphinZZZZZ
77eed09e9e feat(provider): add OpenAI Responses provider 2026-06-22 15:12:09 +08:00
Soulter
756469a39f fix: remove unused vocechat logo and update xmas hat image 2026-06-21 23:44:33 +08:00
Weilong Liao
16beb9a9d7 fix: remove redundant font family from stylesheet link (#8942) 2026-06-21 23:36:21 +08:00
Soulter
e30655b04e fix: refresh WebUI with cache buster 2026-06-21 18:12:07 +08:00
Soulter
1283421339 fix: update workflow names to improve readability 2026-06-21 14:17:36 +08:00
Soulter
7980ed28ad fix: restore cli init dashboard monkeypatch hook 2026-06-21 14:14:45 +08:00
Weilong Liao
c59ef11128 chore: bump version to 4.26.0-beta.12 (#8934) 2026-06-21 14:12:07 +08:00
Weilong Liao
05148dfdd9 fix: add sdist build artifact path to allow dashboard artifact to be included (#8933)
* fix: add sdist build artifact path to allow dashboard artifact to be included

* Update pyproject.toml

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-06-21 14:08:48 +08:00
Weilong Liao
39d425316f fix: created unnecessary data dir when executing astrbot command (#8932)
* fix: created unnecessary data dir when executing astrbot command

fixes: #8853

* fix: 更新包版本更新函数以修改 astrbot/__init__.py 中的版本号
2026-06-21 14:04:49 +08:00
lingyun14
42ca89d6c8 fix: 修复提供商源修改 ID 后保存被静默还原的问题 (#8915)
* Update schemas.py

* Update schemas.py
2026-06-21 11:55:54 +08:00
Weilong Liao
b6913833d4 chore: bump version to 4.26.0-beta.11
Release 4.26.0-beta.11
2026-06-20 22:32:56 +08:00
Soulter
f9d4082217 feat: add prerelease visibility toggle 2026-06-20 22:23:25 +08:00
Soulter
ddc4e142c7 fix: clarify WebUI recovery hint 2026-06-20 22:11:38 +08:00
Weilong Liao
d36987dd19 fix: restore static runtime version
Fixes #8924
2026-06-20 22:06:24 +08:00
NayukiChiba
0a0c677404 docs: 文档更新 - 指令、FAQ、网页搜索、插件发布等 (#8912)
* docs(community): 更新 QQ 群组信息
- 移除了旧的 QQ 群组信息并添加了新的群组。
- 确保所有群组信息的准确性和完整性。
- 提升文档的可读性和用户体验。

* docs(webui): 为忘记密码章节添加 FAQ 引用链接

- 在英文和中文文档的"忘记密码"章节末尾添加 TIP 提示框
- 提示框链接至对应 FAQ 条目,提供更详细的操作说明
- 增强文档之间的关联性,提升用户查阅体验

* docs(command): 新增 /stats 和 /provider 指令文档,更新 FAQ 管理员指令列表

- 在英文和中文的命令文档中添加 /stats 和 /provider 指令的详细使用说明
- 更新中英文 FAQ 页面的管理员指令列表,与最新的默认指令保持一致
- 确保中英文文档内容同步,提升文档准确性和用户查阅体验

* docs(websearch): 新增 Firecrawl 网页搜索提供商文档支持

- 在开发版配置文档中补充 websearch_provider 选项,加入 firecrawl 及对应密钥配置项
- 更新中英文网页搜索使用指南,将支持搜索源数量同步为五种并提供 Firecrawl 注册指引
- 确保中英文文档内容一致,提升配置说明的完整性和用户查阅体验

* docs(plugin-publish): 更新插件提交方式至新仓库

- 废弃通过 AstrBot 主仓库 Issue 提交插件的方式
- 新增警告框提示正确提交入口为 AstrBot_Plugins_Collection
- 同步更新中英文版插件发布文档
2026-06-20 16:43:58 +08:00
Weilong Liao
da7f53d5eb fix: keep system tools with persona tool lists (#8908) 2026-06-20 01:15:31 +08:00
Soulter
a7533aacda fix: fall back to stale WebUI when repair fails 2026-06-20 00:01:29 +08:00
Weilong Liao
46a846b88b chore: bump version to 4.26.0-beta.10 (#8905) 2026-06-19 23:59:39 +08:00
59 changed files with 1222 additions and 320 deletions

View File

@@ -1,4 +1,4 @@
name: release
name: Build and Deploy AstrBot Docs
on:
push:

View File

@@ -1,64 +0,0 @@
name: PR Title Check
on:
pull_request_target:
types: [opened, edited, reopened, synchronize]
jobs:
title-format:
if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Validate PR title
uses: actions/github-script@v9
with:
script: |
const title = (context.payload.pull_request.title || "").trim();
// Allow Conventional Commit style PR titles.
// Examples:
// feat: xxx
// feat(scope): xxx
// fix: xxx
// fix(scope): xxx
const allowedTypes = "feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert";
const pattern = new RegExp(`^(${allowedTypes})(\\([a-z0-9-]+\\))?:\\s.+$`, "i");
const isValid = pattern.test(title);
const isSameRepo =
context.payload.pull_request.head.repo.full_name === context.payload.repository.full_name;
if (!isValid) {
if (isSameRepo) {
try {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: [
"⚠️ PR title format check failed.",
"Required formats:",
"- `feat: xxx`",
"- `feat(scope): xxx`",
"- `fix: xxx`",
"- `fix(scope): xxx`",
"- `chore: xxx`",
"",
"Allowed prefixes:",
"`feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`",
"Please update your PR title and push again."
].join("\n")
});
} catch (e) {
core.warning(`Failed to post PR title comment: ${e.message}`);
}
} else {
core.warning("Fork PR: comment permission is restricted; skip posting review comment.");
}
}
if (!isValid) {
core.setFailed("Invalid PR title. Expected Conventional Commit format, e.g. feat: xxx, feat(scope): xxx, or fix: xxx.");
}

View File

@@ -1,4 +1,4 @@
name: Release
name: Release AstrBot
on:
push:

View File

@@ -1,4 +1,4 @@
name: sync wiki
name: Sync AstrBot Docs to GitHub Wiki
on:
workflow_dispatch:

View File

@@ -50,6 +50,7 @@ ruff check .
5. Use English for all new comments.
6. For path handling, use `pathlib.Path` instead of string paths, and use `astrbot.core.utils.path_utils` to get the AstrBot data and temp directory.
7. When backend API routes, request/response schemas, or OpenAPI definitions change, regenerate the frontend API client by running `cd dashboard && pnpm generate:api`.
8. When updating the project version, keep `[project].version` in `pyproject.toml` and `__version__` in `astrbot/__init__.py` in sync. `VERSION` in `astrbot/core/config/default.py` should derive from `astrbot.__version__` instead of hardcoding a separate version string.
### KISS and First Principles
@@ -108,7 +109,7 @@ Prepare a release from a clean worktree with:
uv run python scripts/prepare_release.py 4.25.0
```
The script updates `pyproject.toml`, creates `changelogs/v4.25.0.md`, runs the required Python checks, and prints the remaining steps. Use these flags when needed:
The script updates `pyproject.toml` and `astrbot/__init__.py`, creates `changelogs/v4.25.0.md`, runs the required Python checks, and prints the remaining steps. Use these flags when needed:
```bash
uv run python scripts/prepare_release.py 4.25.0 --generate-api-client

View File

@@ -234,10 +234,6 @@ pre-commit install
### QQ Groups
- Group 12: 916228568 (New)
- Group 9: 1076659624 (Full)
- Group 10: 1078079676 (Full)
- Group 11: 704659519 (Full)
- Group 1: 322154837 (Full)
- Group 3: 630166526 (Full)
- Group 4: 1077826412 (Full)
@@ -245,6 +241,12 @@ pre-commit install
- Group 6: 753075035 (Full)
- Group 7: 743746109 (Full)
- Group 8: 1030353265 (Full)
- Group 9: 1076659624 (Full)
- Group 10: 1078079676 (Full)
- Group 11: 704659519 (Full)
- Group 12: 916228568 (Full)
- Group 13: 1092185289
- Group 14: 1103419483
- Developer Group(Chit-chat): 975206796
- Developer Group(Formal): 1039761811

View File

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

View File

@@ -1,3 +1,4 @@
from .core.log import LogManager
import logging
logger = LogManager.GetLogger(log_name="astrbot")
__version__ = "4.26.0-beta.12"
logger = logging.getLogger("astrbot")

View File

@@ -1,3 +1,3 @@
from astrbot.core.config.default import VERSION
from astrbot import __version__
__version__ = VERSION
__all__ = ["__version__"]

View File

@@ -5,12 +5,6 @@ from typing import Any
import click
from astrbot.core.utils.auth_password import (
hash_dashboard_password,
hash_md5_dashboard_password,
validate_dashboard_password,
)
from ..utils import check_astrbot_root, get_astrbot_root
@@ -44,6 +38,8 @@ def _validate_dashboard_username(value: str) -> str:
def _validate_dashboard_password(value: str) -> str:
"""Validate Dashboard password"""
from astrbot.core.utils.auth_password import validate_dashboard_password
try:
validate_dashboard_password(value)
except ValueError as e:
@@ -139,6 +135,11 @@ def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
def _set_dashboard_password(config: dict[str, Any], raw_password: str) -> None:
"""Set dashboard password hashes and clear password migration flags."""
from astrbot.core.utils.auth_password import (
hash_dashboard_password,
hash_md5_dashboard_password,
)
_set_nested_item(
config,
"dashboard.pbkdf2_password",

View File

@@ -5,11 +5,20 @@ from pathlib import Path
import click
from filelock import FileLock, Timeout
from ..utils import check_dashboard, get_astrbot_root
DASHBOARD_INITIAL_PASSWORD_ENV = "ASTRBOT_DASHBOARD_INITIAL_PASSWORD"
async def check_dashboard(astrbot_root: Path) -> None:
"""Check whether dashboard assets are available.
Args:
astrbot_root: AstrBot data directory path.
"""
from ..utils import check_dashboard as _check_dashboard
await _check_dashboard(astrbot_root)
def _initialize_config_from_env(astrbot_root: Path) -> None:
if DASHBOARD_INITIAL_PASSWORD_ENV not in os.environ:
return
@@ -52,7 +61,10 @@ async def initialize_astrbot(astrbot_root: Path) -> None:
@click.command()
def init() -> None:
"""Initialize AstrBot"""
from ..utils import get_astrbot_root
click.echo("Initializing AstrBot...")
astrbot_root = get_astrbot_root()
lock_file = astrbot_root / "astrbot.lock"
lock = FileLock(lock_file, timeout=5)

View File

@@ -42,7 +42,6 @@ async def check_dashboard(astrbot_root: Path) -> None:
if click.confirm(
"Install dashboard?",
default=True,
abort=True,
):
click.echo("Installing dashboard...")
await download_dashboard(

View File

@@ -457,10 +457,10 @@ async def _ensure_persona_and_skills(
cfg: dict,
plugin_context: Context,
event: AstrMessageEvent,
) -> set[str] | None:
) -> None:
"""Ensure persona and skills are applied to the request's system prompt or user prompt."""
if not req.conversation:
return None
return
(
persona_id,
@@ -527,13 +527,11 @@ async def _ensure_persona_and_skills(
# inject toolset in the persona
if (persona and persona.get("tools") is None) or not persona:
persona_allowed_tools = None
persona_toolset = tmgr.get_full_tool_set()
for tool in list(persona_toolset):
if not tool.active:
persona_toolset.remove_tool(tool.name)
else:
persona_allowed_tools = {str(tool_name) for tool_name in persona["tools"]}
persona_toolset = ToolSet()
if persona["tools"]:
for tool_name in persona["tools"]:
@@ -614,7 +612,6 @@ async def _ensure_persona_and_skills(
)
except Exception:
pass
return persona_allowed_tools
async def _request_img_caption(
@@ -947,13 +944,12 @@ async def _decorate_llm_request(
plugin_context: Context,
config: MainAgentBuildConfig,
provider: Provider | None = None,
) -> set[str] | None:
) -> None:
cfg = config.provider_settings or plugin_context.get_config(
umo=event.unified_msg_origin
).get("provider_settings", {})
_apply_prompt_prefix(req, cfg)
persona_allowed_tools = None
main_provider_supports_image = provider is not None and _provider_supports_modality(
provider, "image"
@@ -962,9 +958,7 @@ async def _decorate_llm_request(
quote_images_already_captioned = False
if req.conversation:
persona_allowed_tools = await _ensure_persona_and_skills(
req, cfg, plugin_context, event
)
await _ensure_persona_and_skills(req, cfg, plugin_context, event)
if img_cap_prov_id and req.image_urls and not main_provider_supports_image:
await _ensure_img_caption(
@@ -993,7 +987,6 @@ async def _decorate_llm_request(
tz = plugin_context.get_config().get("timezone")
_append_system_reminders(event, req, cfg, tz)
_apply_workspace_extra_prompt(event, req)
return persona_allowed_tools
def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
@@ -1522,9 +1515,7 @@ async def build_main_agent(
else:
return None
persona_allowed_tools = await _decorate_llm_request(
event, req, plugin_context, config, provider=provider
)
await _decorate_llm_request(event, req, plugin_context, config, provider=provider)
await _apply_kb(event, req, plugin_context, config)
@@ -1560,11 +1551,6 @@ async def build_main_agent(
)
)
if persona_allowed_tools is not None and req.func_tool:
req.func_tool.tools = [
tool for tool in req.func_tool.tools if tool.name in persona_allowed_tools
]
fallback_providers = _get_fallback_chat_providers(
provider, plugin_context, config.provider_settings
)

View File

@@ -1,38 +1,12 @@
"""如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。"""
import os
import re
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as package_version
from pathlib import Path
from astrbot import __version__
from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.toml_parser import read_pyproject_project_version
try:
import tomllib
except ModuleNotFoundError:
# <= Python 3.10 compatibility
tomllib = None
try:
pyproject_path = Path(__file__).resolve().parents[3] / "pyproject.toml"
if tomllib is None:
VERSION = read_pyproject_project_version(pyproject_path)
else:
with pyproject_path.open("rb") as f:
VERSION = tomllib.load(f)["project"]["version"]
except (FileNotFoundError, IndexError, KeyError, TypeError, ValueError):
try:
VERSION = package_version("astrbot") # PEP 440 version style, e.g. 1.2.3a4
match = re.match(r"^(\d+(?:\.\d+)*)(a|b|rc)(\d+)$", VERSION)
if match:
release, prerelease, number = match.groups()
prerelease = {"a": "alpha", "b": "beta", "rc": "rc"}[prerelease]
VERSION = f"{release}-{prerelease}.{number}"
except PackageNotFoundError:
VERSION = "0.0.0"
VERSION = __version__
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
@@ -1194,6 +1168,18 @@ CONFIG_METADATA_2 = {
"proxy": "",
"custom_headers": {},
},
"OpenAI Response": {
"id": "openai_response",
"provider": "openai",
"type": "openai_responses",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.openai.com/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"Google Gemini": {
"id": "google_gemini",
"provider": "google",

View File

@@ -361,6 +361,10 @@ class ProviderManager:
from .sources.openai_source import (
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
)
case "openai_responses":
from .sources.openai_responses_source import (
ProviderOpenAIResponses as ProviderOpenAIResponses,
)
case "longcat_chat_completion":
from .sources.longcat_source import ProviderLongCat as ProviderLongCat
case "minimax_token_plan":

View File

@@ -0,0 +1,467 @@
import inspect
import json
from collections.abc import AsyncGenerator
from typing import Any
import astrbot.core.message.components as Comp
from astrbot.core.agent.tool import ToolSet
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage
from ..register import register_provider_adapter
from .openai_source import ProviderOpenAIOfficial
from .request_retry import retry_provider_request
@register_provider_adapter(
"openai_responses",
"OpenAI Responses API 提供商适配器",
)
class ProviderOpenAIResponses(ProviderOpenAIOfficial):
"""OpenAI-compatible provider that calls the Responses API."""
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config, provider_settings)
self.default_params = inspect.signature(
self.client.responses.create,
).parameters.keys()
@staticmethod
def _get_field(obj: Any, name: str, default: Any = None) -> Any:
if isinstance(obj, dict):
return obj.get(name, default)
return getattr(obj, name, default)
@staticmethod
def _arguments_to_json_string(arguments: Any) -> str:
if arguments is None:
return ""
if isinstance(arguments, str):
return arguments
try:
return json.dumps(arguments, ensure_ascii=False)
except TypeError:
return str(arguments)
@staticmethod
def _message_content_to_response_content(content: Any, role: str) -> Any:
if isinstance(content, str) or content is None:
return content or ""
if not isinstance(content, list):
return content
converted: list[dict[str, Any]] = []
text_type = "output_text" if role == "assistant" else "input_text"
for part in content:
if not isinstance(part, dict):
converted.append({"type": text_type, "text": str(part)})
continue
part_type = part.get("type")
if part_type == "text":
converted.append({"type": text_type, "text": part.get("text", "")})
elif part_type == "image_url":
image_url = part.get("image_url", {})
if isinstance(image_url, dict):
url = image_url.get("url")
detail = image_url.get("detail")
else:
url = image_url
detail = None
image_part = {"type": "input_image", "image_url": url}
if detail:
image_part["detail"] = detail
converted.append(image_part)
elif part_type in {"audio_url", "input_audio"}:
converted.append({"type": text_type, "text": "[Audio]"})
elif part_type == "think":
continue
else:
converted.append(part)
return converted
@staticmethod
def _is_empty_response_content(content: Any) -> bool:
if content is None:
return True
if isinstance(content, str):
return not content
if isinstance(content, list):
return not content
return False
@staticmethod
def _chat_tool_call_to_response_function_call(tool_call: Any) -> dict:
function = ProviderOpenAIResponses._get_field(tool_call, "function", {})
call_id = (
ProviderOpenAIResponses._get_field(tool_call, "id")
or ProviderOpenAIResponses._get_field(tool_call, "call_id")
or ""
)
name = ProviderOpenAIResponses._get_field(function, "name", "")
arguments = ProviderOpenAIResponses._get_field(function, "arguments", "")
return {
"type": "function_call",
"call_id": call_id,
"name": name or "",
"arguments": ProviderOpenAIResponses._arguments_to_json_string(arguments),
"status": "completed",
}
@classmethod
def _message_to_response_input_items(cls, message: dict) -> list[dict]:
role = message.get("role", "user")
if role == "tool":
return [
{
"type": "function_call_output",
"call_id": message.get("tool_call_id", ""),
"output": message.get("content", ""),
},
]
content = cls._message_content_to_response_content(
message.get("content", ""),
role,
)
item = {
"role": role,
"content": content,
}
if role != "assistant" or not message.get("tool_calls"):
return [item]
items = [] if cls._is_empty_response_content(content) else [item]
items.extend(
cls._chat_tool_call_to_response_function_call(tool_call)
for tool_call in message["tool_calls"]
)
return items
@classmethod
def _messages_to_response_input(cls, messages: list[dict]) -> list[dict]:
items: list[dict] = []
for message in messages:
items.extend(cls._message_to_response_input_items(message))
return items
@classmethod
def _chat_payload_to_responses_payload(cls, payloads: dict) -> dict:
response_payload = dict(payloads)
messages = response_payload.pop("messages", [])
if isinstance(messages, list):
response_payload["input"] = cls._messages_to_response_input(messages)
return response_payload
@staticmethod
def _responses_function_tools(tools: ToolSet | None) -> list[dict]:
if not tools:
return []
converted: list[dict] = []
for tool in tools.openai_schema():
if tool.get("type") != "function":
converted.append(tool)
continue
function = tool.get("function", {})
item = {
"type": "function",
"name": function.get("name", ""),
"strict": False,
}
if function.get("description"):
item["description"] = function["description"]
if "parameters" in function:
item["parameters"] = function["parameters"]
converted.append(item)
return converted
@staticmethod
def _response_usage_to_token_usage(usage: Any) -> TokenUsage | None:
if not usage:
return None
def _get(name: str) -> int:
value = ProviderOpenAIResponses._get_field(usage, name, 0)
return value if isinstance(value, int) else 0
input_tokens = _get("input_tokens")
output_tokens = _get("output_tokens")
cached = 0
details = ProviderOpenAIResponses._get_field(usage, "input_tokens_details")
if details is not None:
cached = ProviderOpenAIResponses._get_field(details, "cached_tokens", 0)
cached = cached or 0
cached = cached if isinstance(cached, int) else 0
return TokenUsage(
input_other=max(input_tokens - cached, 0),
input_cached=cached,
output=output_tokens,
)
@staticmethod
def _extract_response_output_text(response: Any) -> str:
output_text = ProviderOpenAIResponses._get_field(response, "output_text")
if isinstance(output_text, str):
return output_text.strip()
output = ProviderOpenAIResponses._get_field(response, "output", [])
parts: list[str] = []
if isinstance(output, list):
for item in output:
content = ProviderOpenAIResponses._get_field(item, "content", [])
if not isinstance(content, list):
continue
for part in content:
part_type = ProviderOpenAIResponses._get_field(part, "type")
if part_type not in {"output_text", "text"}:
continue
text = ProviderOpenAIResponses._get_field(part, "text")
if isinstance(text, str):
parts.append(text)
return "".join(parts).strip()
@staticmethod
def _iter_response_output_items(response: Any) -> list[Any]:
output = ProviderOpenAIResponses._get_field(response, "output", [])
return output if isinstance(output, list) else []
@classmethod
def _iter_function_calls(cls, response: Any) -> list[dict[str, Any]]:
calls: list[dict[str, Any]] = []
for item in cls._iter_response_output_items(response):
if cls._get_field(item, "type") != "function_call":
continue
calls.append(
{
"name": cls._get_field(item, "name"),
"arguments": cls._get_field(item, "arguments"),
"call_id": cls._get_field(item, "call_id"),
}
)
return calls
@staticmethod
def _parse_function_call_arguments(arguments: Any) -> dict:
if isinstance(arguments, str):
try:
parsed_args = json.loads(arguments)
except json.JSONDecodeError:
return {}
return parsed_args if isinstance(parsed_args, dict) else {}
if isinstance(arguments, dict):
return arguments
return {}
async def _parse_responses_completion(
self,
response: Any,
tools: ToolSet | None,
) -> LLMResponse:
llm_response = LLMResponse("assistant")
response_id = self._get_field(response, "id")
if tools is not None:
args_ls: list[dict] = []
func_name_ls: list[str] = []
tool_call_ids: list[str] = []
for call in self._iter_function_calls(response):
name = call["name"]
if not name:
continue
args_ls.append(self._parse_function_call_arguments(call["arguments"]))
func_name_ls.append(name)
tool_call_ids.append(call["call_id"] or response_id or "")
if args_ls:
llm_response.role = "tool"
llm_response.tools_call_args = args_ls
llm_response.tools_call_name = func_name_ls
llm_response.tools_call_ids = tool_call_ids
completion_text = self._extract_response_output_text(response)
if completion_text:
llm_response.result_chain = MessageChain().message(completion_text)
llm_response.raw_completion = response
llm_response.id = response_id
usage = self._get_field(response, "usage")
llm_response.usage = self._response_usage_to_token_usage(usage)
return llm_response
def _split_responses_extra_body(self, payloads: dict) -> tuple[dict, dict]:
request_payload = dict(payloads)
extra_body = {}
custom_extra_body = self.provider_config.get("custom_extra_body", {})
if isinstance(custom_extra_body, dict):
extra_body.update(custom_extra_body)
for key in list(request_payload):
if key not in self.default_params:
extra_body[key] = request_payload.pop(key)
self._apply_provider_specific_extra_body_overrides(extra_body)
return request_payload, extra_body
def _build_responses_request(
self,
payloads: dict,
tools: ToolSet | None,
) -> tuple[dict, dict]:
self._sanitize_assistant_messages(payloads)
response_payload = self._chat_payload_to_responses_payload(payloads)
response_tools = self._responses_function_tools(tools)
if response_tools:
response_payload["tools"] = response_tools
if tools and not tools.empty():
response_payload["tool_choice"] = response_payload.get(
"tool_choice", "auto"
)
else:
response_payload.pop("tool_choice", None)
return self._split_responses_extra_body(response_payload)
async def _query(
self,
payloads: dict,
tools: ToolSet | None,
*,
request_max_retries: int | None = None,
) -> LLMResponse:
request_payload, extra_body = self._build_responses_request(payloads, tools)
response = await retry_provider_request(
"OpenAI",
lambda: self.client.responses.create(
**request_payload,
stream=False,
extra_body=extra_body,
),
max_attempts=request_max_retries,
)
return await self._parse_responses_completion(response, tools)
@staticmethod
def _event_value(event: Any, name: str, default: Any = None) -> Any:
return ProviderOpenAIResponses._get_field(event, name, default)
@classmethod
def _stream_function_call_key(
cls,
event: Any,
function_calls: dict[str, dict[str, Any]],
) -> str:
item = cls._event_value(event, "item")
for value in (
cls._event_value(event, "output_index"),
cls._event_value(event, "item_id"),
cls._get_field(item, "id"),
cls._get_field(item, "call_id"),
):
if value is not None:
return str(value)
return str(len(function_calls))
@classmethod
def _merge_stream_function_call_event(
cls,
event: Any,
function_calls: dict[str, dict[str, Any]],
) -> None:
event_type = cls._event_value(event, "type", "")
item = cls._event_value(event, "item")
call_key = cls._stream_function_call_key(event, function_calls)
if event_type in {"response.output_item.added", "response.output_item.done"}:
if cls._get_field(item, "type") != "function_call":
return
call = function_calls.setdefault(call_key, {})
call["name"] = cls._get_field(item, "name", call.get("name"))
call["call_id"] = cls._get_field(item, "call_id", call.get("call_id"))
arguments = cls._get_field(item, "arguments")
if arguments is not None:
call["arguments"] = arguments
return
if event_type == "response.function_call_arguments.delta":
delta = cls._event_value(event, "delta", "")
if delta:
call = function_calls.setdefault(call_key, {})
call["arguments"] = f"{call.get('arguments', '')}{delta}"
return
if event_type == "response.function_call_arguments.done":
arguments = cls._event_value(event, "arguments", "")
function_calls.setdefault(call_key, {})["arguments"] = arguments
async def _stream_function_calls_to_response(
self,
function_calls: dict[str, dict[str, Any]],
tools: ToolSet | None,
) -> LLMResponse:
output = []
for call in function_calls.values():
if not call.get("name"):
continue
output.append(
{
"type": "function_call",
"name": call.get("name", ""),
"call_id": call.get("call_id", ""),
"arguments": call.get("arguments", ""),
}
)
return await self._parse_responses_completion({"output": output}, tools)
async def _query_stream(
self,
payloads: dict,
tools: ToolSet | None,
*,
request_max_retries: int | None = None,
) -> AsyncGenerator[LLMResponse, None]:
request_payload, extra_body = self._build_responses_request(payloads, tools)
stream = await retry_provider_request(
"OpenAI",
lambda: self.client.responses.create(
**request_payload,
stream=True,
extra_body=extra_body,
),
max_attempts=request_max_retries,
)
output_text = ""
final_response = None
function_calls: dict[str, dict[str, Any]] = {}
async for event in stream:
event_type = self._event_value(event, "type", "")
if event_type == "response.output_text.delta":
delta = self._event_value(event, "delta", "")
if not delta:
continue
output_text += str(delta)
yield LLMResponse(
"assistant",
result_chain=MessageChain(chain=[Comp.Plain(str(delta))]),
is_chunk=True,
)
elif event_type == "response.output_text.done":
text = self._event_value(event, "text", "")
if text:
output_text = str(text)
elif event_type == "response.completed":
final_response = self._event_value(event, "response")
else:
self._merge_stream_function_call_event(event, function_calls)
if final_response is not None:
llm_response = await self._parse_responses_completion(final_response, tools)
if not llm_response.completion_text and output_text:
llm_response.result_chain = MessageChain().message(output_text)
elif function_calls:
llm_response = await self._stream_function_calls_to_response(
function_calls,
tools,
)
else:
llm_response = LLMResponse(
"assistant",
result_chain=MessageChain().message(output_text),
)
yield llm_response

View File

@@ -72,46 +72,6 @@ def _read_dependency_array(raw_value: str) -> list[str]:
raise ValueError("Unterminated project.dependencies array")
def read_pyproject_project_version(pyproject_path: Path) -> str:
"""Read the project version from a pyproject.toml file.
Args:
pyproject_path: Path to the pyproject.toml file.
Returns:
The value of the project.version field.
Raises:
FileNotFoundError: The pyproject.toml file does not exist.
ValueError: The project.version field is missing or unsupported.
"""
in_project_section = False
for raw_line in pyproject_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
in_project_section = line == "[project]"
continue
if not in_project_section:
continue
key, separator, raw_value = line.partition("=")
if key.strip() != "version":
continue
if not separator:
raise ValueError("Missing value separator for project.version")
version, tail = _read_quoted_value(raw_value, "project.version")
if tail and not tail.startswith("#"):
raise ValueError("Unsupported content after project.version")
return version
raise ValueError("Missing project.version")
def read_pyproject_project_dependencies(pyproject_path: Path) -> list[str]:
"""Read project dependencies from a pyproject.toml file.

View File

@@ -418,10 +418,10 @@ class ProviderSourceRequest(OpenModel):
self.config
or self.model_dump(exclude={"source_id", "config"}, exclude_none=True)
)
if fallback_id:
config["id"] = fallback_id
elif self.id and "id" not in config:
config["id"] = self.id
if not config.get("id"):
# 不覆盖已有 idself.id显式指定优先于 fallback_id(旧值兜底)
if fallback := (self.id or fallback_id):
config["id"] = fallback
return config

View File

@@ -203,10 +203,19 @@ class AstrBotDashboard:
) or is_dashboard_dist_compatible(bundled_dist, VERSION):
self.data_path = str(bundled_dist)
logger.info("Using bundled dashboard dist: %s", self.data_path)
elif (
os.path.exists(user_dist) and (Path(user_dist) / "index.html").is_file()
):
logger.warning(
"Using existing data/dist as a fallback even though WebUI version mismatches core: %s, expected v%s. "
"Some dashboard features may not work until the matching WebUI is available.",
user_version,
VERSION,
)
self.data_path = os.path.abspath(user_dist)
elif os.path.exists(user_dist):
logger.warning(
"Ignoring data/dist because WebUI version mismatches core: %s, expected v%s.",
user_version,
"Ignoring data/dist because WebUI files are incomplete for core v%s.",
VERSION,
)
self.data_path = None

View File

@@ -0,0 +1,22 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)
<a id="chinese"></a>
## What's Changed
### 修复
- 恢复 WebUI 在接口返回 401 时跳转登录页,避免会话失效后停留在异常状态。([#8903](https://github.com/AstrBotDevs/AstrBot/pull/8903))
- 保持 Core 版本与 WebUI 静态资源版本同步,修复打包或升级后可能加载旧 dist、资源版本错配的问题。([#8901](https://github.com/AstrBotDevs/AstrBot/pull/8901))
- 将知识库上下文作为临时 user 内容注入,修复模型请求中知识库上下文角色不准确的问题。([#8904](https://github.com/AstrBotDevs/AstrBot/pull/8904))
<a id="english"></a>
## What's Changed (EN)
### Bug Fixes
- Restored the WebUI login redirect when API requests return 401, preventing expired sessions from staying in a broken state. ([#8903](https://github.com/AstrBotDevs/AstrBot/pull/8903))
- Kept Core and WebUI static asset versions in sync, fixing stale dist loading and asset version mismatches after packaging or upgrades. ([#8901](https://github.com/AstrBotDevs/AstrBot/pull/8901))
- Injected knowledge base context as temporary user content, fixing the role used for knowledge context in model requests. ([#8904](https://github.com/AstrBotDevs/AstrBot/pull/8904))

View File

@@ -0,0 +1,10 @@
## What's Changed
<!-- Review, group, and polish these entries before publishing. -->
- fix: keep system tools with persona tool lists (#8908) (da7f53d5e)
- docs: 文档更新 - 指令、FAQ、网页搜索、插件发布等 (#8912) (0a0c67740)
- fix: restore static runtime version (d36987dd1)
- fix: clarify WebUI recovery hint (ddc4e142c)
- feat: add prerelease visibility toggle (f9d408221)

View File

@@ -0,0 +1,7 @@
## What's Changed
<!-- Review, group, and polish these entries before publishing. -->
- fix: 修复提供商源修改 ID 后保存被静默还原的问题 (#8915) (42ca89d6c)
- fix: created unnecessary data dir when executing astrbot command (#8932) (39d425316)
- fix: add sdist build artifact path to allow dashboard artifact to be included (#8933) (05148dfdd)

View File

@@ -9,7 +9,7 @@
<meta name="robots" content="noindex, nofollow" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Outfit&family=Noto+Sans:wght@100..900&family=Noto+Sans+SC:wght@100..900&display=swap"
href="https://fonts.googleapis.com/css2?family=Outfit&family=Noto+Sans:wght@100..900&display=swap"
/>
<!-- VAD (Voice Activity Detection) Libraries -->
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/dist/ort.wasm.min.js"></script>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 280 icons */
/* Auto-generated MDI subset 279 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -312,10 +312,6 @@
content: "\F0193";
}
.mdi-content-save-check-outline::before {
content: "\F18EB";
}
.mdi-content-save-outline::before {
content: "\F0818";
}

View File

@@ -156,6 +156,12 @@ function dismiss() {
visible.value = false;
}
function reloadWithCacheBuster() {
const url = new URL(window.location.href);
url.searchParams.set('_r', Date.now().toString());
window.location.replace(url.toString());
}
function waitForRestart() {
clearRestartTimer();
let attempts = 0;
@@ -169,7 +175,7 @@ function waitForRestart() {
) {
clearRestartTimer();
sessionStorage.removeItem(UPGRADE_RECOVERY_TOKEN_KEY);
window.location.reload();
reloadWithCacheBuster();
}
} catch (_error) {
// The backend may be temporarily unavailable during restart.

View File

@@ -31,6 +31,11 @@ export default {
}
},
methods: {
reloadWithCacheBuster() {
const url = new URL(window.location.href)
url.searchParams.set('_r', Date.now().toString())
window.location.replace(url.toString())
},
async check(initialStartTime = null) {
this.newStartTime = -1
this.cnt = 0
@@ -83,8 +88,7 @@ export default {
this.newStartTime = newStartTime
console.log('wfr: restarted')
this.visible = false
// reload
window.location.reload()
this.reloadWithCacheBuster()
}
} catch (_error) {
// backend may be unavailable during restart window

View File

@@ -50,7 +50,7 @@
},
"upgradeRecovery": {
"title": "Upgrade Needs Restart",
"description": "The WebUI has been updated to {dashboardVersion}, but AstrBot Core is still {coreVersion}. This usually means the restart flow was interrupted by refreshing the page during upgrade.",
"description": "The WebUI has been updated to {dashboardVersion}, but AstrBot Core is still {coreVersion}. This usually means the restart flow was interrupted by refreshing the page during upgrade. If restarting still does not resolve it, try deleting the data/dist folder under the AstrBot runtime directory, then restart AstrBot.",
"hint": "Restart the backend to finish the upgrade. This page will reload automatically after AstrBot is back.",
"restartButton": "Restart Backend",
"laterButton": "Later",

View File

@@ -26,7 +26,9 @@
"release": "😊 Release"
},
"advancedSettings": "Advanced settings",
"releases": "Releases",
"updateToLatest": "Update to Latest Version",
"showPreReleases": "Show pre-release versions",
"preRelease": "Pre-release",
"preReleaseWarning": {
"title": "Pre-release Version Notice",

View File

@@ -50,7 +50,7 @@
},
"upgradeRecovery": {
"title": "Требуется завершить обновление",
"description": "WebUI обновлен до {dashboardVersion}, но AstrBot Core все еще {coreVersion}. Обычно это означает, что процесс перезапуска был прерван обновлением страницы во время обновления.",
"description": "WebUI обновлен до {dashboardVersion}, но AstrBot Core все еще {coreVersion}. Обычно это означает, что процесс перезапуска был прерван обновлением страницы во время обновления. Если перезапуск не решит проблему, попробуйте удалить папку data/dist в runtime-директории AstrBot, затем перезапустите AstrBot.",
"hint": "Перезапустите backend, чтобы завершить обновление. Страница автоматически обновится после восстановления AstrBot.",
"restartButton": "Перезапустить backend",
"laterButton": "Позже",

View File

@@ -26,7 +26,9 @@
"release": "😊 Релиз"
},
"advancedSettings": "Расширенные настройки",
"releases": "Релизы",
"updateToLatest": "Обновить до последней версии",
"showPreReleases": "Показывать предварительные версии",
"preRelease": "Предварительная версия",
"preReleaseWarning": {
"title": "Внимание: предварительная версия",

View File

@@ -50,7 +50,7 @@
},
"upgradeRecovery": {
"title": "检测到升级未完成",
"description": "当前 WebUI 已更新到 {dashboardVersion},但 AstrBot Core 仍是 {coreVersion}。这通常是升级过程中刷新页面导致重启流程被打断。",
"description": "当前 WebUI 已更新到 {dashboardVersion},但 AstrBot Core 仍是 {coreVersion}。这通常是升级过程中刷新页面导致重启流程被打断。如果重启后仍未解决,请尝试删除 AstrBot 运行时目录下的 data/dist 文件夹后重启 AstrBot。",
"hint": "请重启后端以完成升级,重启完成后页面会自动刷新。",
"restartButton": "立即重启后端",
"laterButton": "稍后处理",

View File

@@ -26,7 +26,9 @@
"release": "😊 正式版"
},
"advancedSettings": "高级设置",
"releases": "版本列表",
"updateToLatest": "更新到最新版本",
"showPreReleases": "显示预发布版本",
"preRelease": "预发布",
"preReleaseWarning": {
"title": "预发布版本提醒",

View File

@@ -30,6 +30,7 @@ const { t } = useI18n();
const route = useRoute();
const LAST_BOT_ROUTE_KEY = "astrbot:last_bot_route";
const LAST_CHAT_ROUTE_KEY = "astrbot:last_chat_route";
const SHOW_PRE_RELEASES_KEY = "astrbot:updateDialog:showPreReleases";
let dialog = ref(false);
let accountWarning = ref(false);
let accountWarningMd5 = ref(false);
@@ -50,6 +51,11 @@ let dashboardHasNewVersion = ref(false);
let dashboardCurrentVersion = ref("");
let releases = ref<any[]>([]);
let releasesLoading = ref(false);
const showPreReleases = ref(
typeof window === "undefined"
? false
: localStorage.getItem(SHOW_PRE_RELEASES_KEY) === "true",
);
let updatingDashboardLoading = ref(false);
let installLoading = ref(false);
let showAdvancedUpdateSettings = ref(false);
@@ -150,7 +156,12 @@ const releasesHeader = computed(() => [
{ title: t("core.header.updateDialog.table.content"), key: "body" },
{ title: t("core.header.updateDialog.table.actions"), key: "switch" },
]);
const firstReleasePageItems = computed(() => releases.value.slice(0, 6));
const visibleReleases = computed(() =>
showPreReleases.value
? releases.value
: releases.value.filter((item: any) => !isPreRelease(item.tag_name)),
);
const firstReleasePageItems = computed(() => visibleReleases.value.slice(0, 6));
const firstReleasePageHasPreRelease = computed(() =>
firstReleasePageItems.value.some((item: any) => isPreRelease(item.tag_name)),
);
@@ -600,7 +611,13 @@ async function fetchAstrBotStartTime() {
function reloadAfterUpdate() {
stopRestartReloadTimer();
window.location.reload();
reloadWithCacheBuster();
}
function reloadWithCacheBuster() {
const url = new URL(window.location.href);
url.searchParams.set("_r", Date.now().toString());
window.location.replace(url.toString());
}
function showRestartCompleted() {
@@ -808,7 +825,7 @@ function updateDashboard() {
updateStatus.value = res.data.message || "";
if (res.data.status == "ok") {
setTimeout(() => {
window.location.reload();
reloadWithCacheBuster();
}, 1000);
}
})
@@ -883,6 +900,11 @@ onMounted(() => {
// 监听 viewMode 变化,切换到 bot 模式时跳转到首页
// 保存 bot 模式的最後路由
// 監聽 route 變化,保存最後一次 bot 路由
watch(showPreReleases, (value) => {
if (typeof window === "undefined") return;
localStorage.setItem(SHOW_PRE_RELEASES_KEY, value ? "true" : "false");
});
watch(
() => route.fullPath,
(newPath) => {
@@ -1456,6 +1478,21 @@ onMounted(async () => {
<!-- 发行版 -->
<div class="mt-5">
<div class="release-table-toolbar mb-3">
<div class="text-subtitle-1 font-weight-medium">
{{ t("core.header.updateDialog.releases") }}
</div>
<v-switch
v-model="showPreReleases"
class="release-prerelease-switch"
color="warning"
density="compact"
hide-details
inset
:label="t('core.header.updateDialog.showPreReleases')"
></v-switch>
</div>
<v-alert
v-if="!installLoading && firstReleasePageHasPreRelease"
type="warning"
@@ -1489,7 +1526,7 @@ onMounted(async () => {
<v-data-table
:headers="releasesHeader"
:items="releases"
:items="visibleReleases"
item-key="name"
:items-per-page="6"
density="comfortable"
@@ -1913,6 +1950,18 @@ onMounted(async () => {
margin-left: 0;
}
.release-table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.release-prerelease-switch {
flex: 0 1 auto;
}
/* 响应式布局样式 */
.logo-container {
margin-left: 10px;

View File

@@ -16,10 +16,6 @@ Welcome to submit Issues or Pull Requests:
### Tencent QQ Groups
- Group 12: 916228568 (New)
- Group 9: 1076659624 (Full)
- Group 10: 1078079676 (Full)
- Group 11: 704659519 (Full)
- Group 1: 322154837 (Full)
- Group 3: 630166526 (Full)
- Group 4: 1077826412 (Full)
@@ -27,6 +23,12 @@ Welcome to submit Issues or Pull Requests:
- Group 6: 753075035 (Full)
- Group 7: 743746109 (Full)
- Group 8: 1030353265 (Full)
- Group 9: 1076659624 (Full)
- Group 10: 1078079676 (Full)
- Group 11: 704659519 (Full)
- Group 12: 916228568 (Full)
- Group 13: 1092185289
- Group 14: 1103419483
- **AstrBot Core Development Group: 975206796** (AstrBot development members are usually active here. Welcome to anyone interested in programming/AI technology~)
## Become an AstrBot Organization Member

View File

@@ -288,12 +288,13 @@ Whether to enable AstrBot's built-in web search capability. Default is `false`.
#### `provider_settings.websearch_provider`
Web search provider type. Default is `tavily`. Currently supports `tavily`, `bocha`, `baidu_ai_search`, and `brave`.
Web search provider type. Default is `tavily`. Currently supports `tavily`, `bocha`, `baidu_ai_search`, `brave`, and `firecrawl`.
- `tavily`: Uses the Tavily search engine.
- `bocha`: Uses the BoCha search engine.
- `baidu_ai_search`: Uses Baidu AI Search (MCP).
- `brave`: Uses Brave Search API.
- `firecrawl`: Uses the Firecrawl Search API.
#### `provider_settings.websearch_tavily_key`
@@ -307,6 +308,10 @@ API Key list for the BoCha search engine. Required when using `bocha` as the web
API Key list for the Brave search engine. Required when using `brave` as the web search provider.
#### `provider_settings.websearch_firecrawl_key`
API Key list for the Firecrawl search engine. Required when using `firecrawl` as the web search provider.
#### `provider_settings.web_search_link`
Whether to prompt the model to include links to search results in the reply. Default is `false`.

View File

@@ -4,7 +4,12 @@ After completing your plugin development, you can choose to publish it to the As
AstrBot uses GitHub to host plugins, so you'll need to push your plugin code to the GitHub plugin repository you created earlier.
You can submit your plugin by visiting the [AstrBot Plugin Marketplace](https://plugins.astrbot.app). Once on the website, click the `+` button in the bottom-right corner, fill in the basic information, author details, repository information, and other required fields. Then click the `Submit to GITHUB` button. You will be redirected to the AstrBot repository's Issue submission page. Please verify that all information is correct, then click the `Create` button to complete the plugin publication process.
You can submit your plugin by visiting the [AstrBot Plugin Marketplace](https://plugins.astrbot.app). Once on the website, click the `+` button in the bottom-right corner, fill in the basic information, author details, repository information, and other required fields. Then click the `Submit to GITHUB` button.
> [!WARNING]
> **Main repository Issue submission is deprecated**: The previous method of submitting plugins via Issues in the AstrBot main repository is no longer used. Please submit your plugin at the **[AstrBot_Plugins_Collection](https://github.com/AstrBotDevs/AstrBot_Plugins_Collection)** repository.
You will be redirected to the AstrBot_Plugins_Collection repository's Issue submission page. Please verify that all information is correct, then click the `Create` button to complete the plugin publication process.
![fill out the form](https://files.astrbot.app/docs/source/images/plugin-publish/image.png)

View File

@@ -100,7 +100,7 @@ After restart, AstrBot will reload or download WebUI files that match the curren
### No Permission to Execute Admin Commands
1. `/reset, /persona, /dashboard_update, /op, /deop, /wl, /dewl` are the default admin commands. You can use the `/sid` command to get a user's ID, then add it to the admin ID list in Settings -> Other Settings.
1. `/name, /provider, /dashboard_update, /op, /deop, /persona, /llm, /plugin, /model, /groupnew` are the default admin commands. You can use the `/sid` command to get a user's ID, then add it to the admin ID list in Settings -> Other Settings.
### Chinese Characters Garbled When Locally Rendering Markdown Images (t2i)

View File

@@ -18,6 +18,8 @@ The following commands are shipped with AstrBot and loaded by default:
- `/reset`: Reset the current conversation's LLM context.
- `/stop`: Stop Agent tasks currently running in the current session.
- `/new`: Create and switch to a new conversation.
- `/stats`: View token usage statistics for the current conversation.
- `/provider`: View or switch LLM Provider. This command requires admin permission.
- `/dashboard_update`: Update AstrBot WebUI. This command requires admin permission.
- `/set`: Set a session variable, commonly used for Agent Runner input variables such as Dify, Coze, or DashScope.
- `/unset`: Remove a session variable.
@@ -102,6 +104,45 @@ For third-party Agent Runners such as `dify`, `coze`, `dashscope`, and `deerflow
If there are no running tasks in the current session, AstrBot will report that no task is running.
### `/stats`
`/stats` shows token usage statistics for the current conversation.
It queries the database for all Provider call records in the current conversation and displays:
- Total tokens (input + output).
- Input tokens (cached) — input tokens that were cached by the provider and skipped for billing.
- Input tokens (other) — input tokens that were not cached and billed normally.
- Output tokens — tokens generated by the model.
If you are not in a conversation, AstrBot will prompt you to create one with `/new`.
### `/provider`
`/provider` views or switches the Provider (LLM / TTS / STT) used by the current UMO.
**Viewing the Provider list:**
With no arguments, `/provider` lists all configured Providers grouped by LLM, TTS, and STT. Each Provider shows:
- An index number for switching.
- Provider ID and the model currently in use (LLM type).
- Reachability status: `✅` means the connection is healthy, `❌` means a connection failure (with an error code).
- The currently active Provider is marked with `(currently in use)` at the end.
> [!NOTE]
> Reachability checks must be enabled in WebUI under `Config -> General Config -> AI Config`, expand the "More Settings" section at the bottom, and enable "Provider Reachability Check". When disabled, reachability markers are not shown and the list loads faster.
**Switching Providers:**
Use `/provider <index>` to switch the current session's LLM Provider to the Provider at the given index in the list.
- `/provider <index>`: Switch to the LLM Provider at the given index.
- `/provider tts <index>`: Switch to the TTS Provider at the given index.
- `/provider stt <index>`: Switch to the STT Provider at the given index.
This command requires admin permission.
## Built-in Commands Extension
Other commands that were previously shipped with the core have been moved to a separate plugin:

View File

@@ -14,11 +14,11 @@ When using a large language model that supports function calling with the web se
And other prompts with search intent to trigger the model to invoke the search tool.
AstrBot currently supports 4 web search providers: `Tavily`, `BoCha`, `Baidu AI Search`, and `Brave`.
AstrBot currently supports 5 web search providers: `Tavily`, `BoCha`, `Baidu AI Search`, `Brave`, and `Firecrawl`.
![image](https://files.astrbot.app/docs/source/images/websearch/image.png)
Go to `Configuration`, scroll down to find Web Search, where you can select `Tavily`, `BoCha`, `Baidu AI Search`, or `Brave`.
Go to `Configuration`, scroll down to find Web Search, where you can select `Tavily`, `BoCha`, `Baidu AI Search`, `Brave`, or `Firecrawl`.
### Tavily
@@ -36,6 +36,10 @@ Get an API Key from Baidu Qianfan APP Builder, then fill it in the corresponding
Get an API Key from Brave Search, then fill it in the corresponding configuration item.
### Firecrawl
Go to [Firecrawl](https://firecrawl.dev) to get an API Key, then fill it in the corresponding configuration item.
If you use Tavily as your web search source, you will get a better experience optimization on AstrBot ChatUI, including citation source display and more:
![](https://files.astrbot.app/docs/source/images/websearch/image1.png)

View File

@@ -119,3 +119,6 @@ Modify the `port` in the `dashboard` configuration in the data/cmd_config.json f
## Forgot Password
Modify the `password` in the `dashboard` configuration in the data/cmd_config.json file and delete the entire password key-value pair.
> [!TIP]
> For more details, see [FAQ - Forgot Dashboard Password](/en/faq.md#forgot-dashboard-password).

View File

@@ -6,17 +6,19 @@
### QQ 群
- 12: 916228568 (新)
- 9: 1076659624 (人满)
- 10 群: 1078079676 (人满)
- 11 群: 704659519 (人满)
- 1: 322154837 (人满)
- 3: 630166526 (人满)
- 4: 1077826412 (人满)
- 5: 822130018 (人满)
- 6 群: 753075035 (人满)
- 7 群: 743746109 (人满)
- 8 群: 1030353265 (人满)
- 1 群322154837 (人满)
- 3630166526 (人满)
- 4 群1077826412 (人满)
- 5 群822130018 (人满)
- 6753075035 (人满)
- 7743746109 (人满)
- 81030353265 (人满)
- 91076659624 (人满)
- 10 群1078079676 (人满)
- 11 群704659519 (人满)
- 12 群916228568 (人满)
- 13 群1092185289
- 14 群1103419483
- **AstrBot 核心开发交流群: 975206796**AstrBot 开发成员通常活跃于此,欢迎任何对编程/AI 技术感兴趣的同学加入~
### Discord

View File

@@ -288,12 +288,13 @@ ID 白名单。填写后,将只处理所填写的 ID 发来的消息事件。
#### `provider_settings.websearch_provider`
网页搜索提供商类型。默认为 `tavily`。目前支持 `tavily``bocha``baidu_ai_search``brave`
网页搜索提供商类型。默认为 `tavily`。目前支持 `tavily``bocha``baidu_ai_search``brave``firecrawl`
- `tavily`:使用 Tavily 搜索引擎。
- `bocha`:使用 BoCha 搜索引擎。
- `baidu_ai_search`:使用百度 AI SearchMCP
- `brave`:使用 Brave Search API。
- `firecrawl`:使用 Firecrawl Search API。
#### `provider_settings.websearch_tavily_key`
@@ -307,6 +308,10 @@ BoCha 搜索引擎的 API Key 列表。使用 `bocha` 作为网页搜索提供
Brave 搜索引擎的 API Key 列表。使用 `brave` 作为网页搜索提供商时需要填写。
#### `provider_settings.websearch_firecrawl_key`
Firecrawl 搜索引擎的 API Key 列表。使用 `firecrawl` 作为网页搜索提供商时需要填写。
#### `provider_settings.web_search_link`
是否在回复中提示模型附上搜索结果的链接。默认为 `false`

View File

@@ -4,7 +4,12 @@
AstrBot 使用 GitHub 托管插件,因此你需要先将插件代码推送到之前创建的 GitHub 插件仓库中。
你可以前往 [AstrBot 插件市场](https://plugins.astrbot.app) 提交你的插件。进入该网站后,点击右下角的 `+` 按钮,填写好基本信息、作者信息、仓库信息等内容后,点击 `提交到 GITHUB` 按钮,你将会被导航到 AstrBot 仓库的 Issue 提交页面,请确认信息无误后点击 `Create` 按钮提交,即可完成插件发布
你可以前往 [AstrBot 插件市场](https://plugins.astrbot.app) 提交你的插件。进入该网站后,点击右下角的 `+` 按钮,填写好基本信息、作者信息、仓库信息等内容后,点击 `提交到 GITHUB` 按钮。
> [!WARNING]
> **主仓库 Issue 提交方式已废弃**:此前通过 AstrBot 主仓库 Issue 提交插件的方式已不再使用。现在请前往 **[AstrBot_Plugins_Collection](https://github.com/AstrBotDevs/AstrBot_Plugins_Collection)** 仓库提交你的插件。
你将会被导航到 AstrBot_Plugins_Collection 仓库的 Issue 提交页面,请确认信息无误后点击 `Create` 按钮提交,即可完成插件发布。
![fill out the form](https://files.astrbot.app/docs/source/images/plugin-publish/image.png)

View File

@@ -118,7 +118,7 @@ Set dashboard.host in data/cmd_config.json to enable remote access.
### 没有权限操作管理员指令
1. `/reset, /persona, /dashboard_update, /op, /deop, /wl, /dewl` 是默认的管理员指令。可以通过 `/sid` 指令得到用户的 ID然后在 `配置` -> `其他配置` 中添加到管理员 ID 名单中。
1. `/name, /provider, /dashboard_update, /op, /deop, /persona, /llm, /plugin, /model, /groupnew` 是默认的管理员指令。可以通过 `/sid` 指令得到用户的 ID然后在 `配置` -> `其他配置` 中添加到管理员 ID 名单中。
### 本地渲染 Markdown 图片t2i时中文乱码

View File

@@ -18,6 +18,8 @@ AstrBot 的指令通过插件机制注册。为了保持主程序轻量,当前
- `/reset`:重置当前会话的 LLM 上下文。
- `/stop`:停止当前会话中正在运行的 Agent 任务。
- `/new`:创建并切换到一个新对话。
- `/stats`:查看当前会话的 Token 用量统计。
- `/provider`:查看或切换 LLM Provider。该指令需要管理员权限。
- `/dashboard_update`:更新 AstrBot WebUI。该指令需要管理员权限。
- `/set`:设置当前会话变量,常用于 Dify、Coze、DashScope 等 Agent 执行器的输入变量。
- `/unset`:移除当前会话变量。
@@ -96,6 +98,45 @@ AstrBot 的指令通过插件机制注册。为了保持主程序轻量,当前
如果当前会话没有正在运行的任务AstrBot 会提示当前会话没有运行中的任务。
### `/stats`
`/stats` 用于查看当前会话的 Token 用量统计。
它从数据库中查询当前对话的所有 Provider 调用记录,汇总并展示:
- 总 Token 用量(输入 Token + 输出 Token
- 输入 Token缓存命中即被提供商缓存并跳过计费的输入 Token。
- 输入 Token其他即未被缓存、正常计费的输入 Token。
- 输出 Token即模型生成的输出 Token。
如果当前不在任何对话中AstrBot 会提示先使用 `/new` 创建对话。
### `/provider`
`/provider` 用于查看或切换当前 UMO 使用的 ProviderLLM / TTS / STT
**查看 Provider 列表:**
不带参数时,`/provider` 会列出所有已配置的 Provider按 LLM、TTS、STT 分类展示。每个 Provider 旁会显示:
- 序号,用于后续切换。
- Provider ID 和当前使用的模型LLM 类型)。
- 可达性标记:`✅` 表示连接正常,`❌` 表示连接失败(附带错误码)。
- 当前正在使用的 Provider 末尾会标注 `(当前使用)`
> [!NOTE]
> 可达性检测需要在 WebUI 的 `配置 -> 普通配置 -> AI 配置` 中,展开底部的「更多配置」,开启「提供商可达性检测」后才会生效。关闭后不显示可达性标记,列表加载更快。
**切换 Provider**
使用 `/provider <序号>` 可以将当前会话的 LLM Provider 切换为列表中对应序号的 Provider。
- `/provider <序号>`:切换到指定序号的 LLM Provider。
- `/provider tts <序号>`:切换到指定序号的 TTS Provider。
- `/provider stt <序号>`:切换到指定序号的 STT Provider。
该指令需要管理员权限。
## 内置指令扩展
除上述基础指令外,其他原本随主程序提供的内置指令已经迁移到独立插件:

View File

@@ -13,11 +13,11 @@ AstrBot 内置的网页搜索功能依赖大模型提供 `函数调用` 能力
等等带有搜索意味的提示让大模型触发调用搜索工具。
AstrBot 当前支持 4 种网页搜索源接入方式:`Tavily``BoCha``百度 AI 搜索``Brave`
AstrBot 当前支持 5 种网页搜索源接入方式:`Tavily``BoCha``百度 AI 搜索``Brave``Firecrawl`
![image](https://files.astrbot.app/docs/source/images/websearch/image.png)
进入 `配置`,下拉找到网页搜索,您可选择 `Tavily``BoCha``百度 AI 搜索``Brave`
进入 `配置`,下拉找到网页搜索,您可选择 `Tavily``BoCha``百度 AI 搜索``Brave``Firecrawl`
### Tavily
@@ -35,6 +35,10 @@ AstrBot 当前支持 4 种网页搜索源接入方式:`Tavily`、`BoCha`、`
前往 Brave Search 获取 API Key然后填写在相应的配置项。
### Firecrawl
前往 [Firecrawl](https://firecrawl.dev) 获取 API Key然后填写在相应的配置项。
如果您使用 Tavily 作为网页搜索源,在 AstrBot ChatUI 上将会获得更好的体验优化,包括引用来源展示等:
![](https://files.astrbot.app/docs/source/images/websearch/image1.png)

View File

@@ -119,3 +119,6 @@ ChatUI 支持以下常用能力:
## 忘记密码
修改 data/cmd_config.json 文件内 `dashboard` 配置中的 `password`,将 password 整个键值对删除。
> [!TIP]
> 详细说明请参阅 [FAQ - 管理面板的密码忘记了](/faq.md#管理面板的密码忘记了)。

View File

@@ -160,6 +160,14 @@ async def check_dashboard_files(webui_dir: str | None = None):
)
except Exception as e:
logger.critical(f"下载管理面板文件失败: {e}")
if (data_dist_path / "index.html").is_file():
logger.warning(
"Falling back to existing data/dist WebUI %s even though core expects v%s. "
"Some dashboard features may not work until the matching WebUI is available.",
v or "unknown",
VERSION,
)
return str(data_dist_path)
return None
logger.info("管理面板下载完成。")
return str(data_dist_path)

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.26.0-beta.9"
version = "4.26.0-beta.12"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
license = { text = "AGPL-3.0-or-later" }
@@ -121,7 +121,7 @@ exclude = ["dashboard", "node_modules", "dist", "data", "tests"]
allow-direct-references = true
# Include bundled dashboard dist even though it is not tracked by VCS.
[tool.hatch.build.targets.wheel]
[tool.hatch.build]
artifacts = ["astrbot/dashboard/dist/**"]
# Custom build hook: builds the Vue dashboard and copies dist into the package.

View File

@@ -193,6 +193,37 @@ def update_pyproject_version(version: str) -> Path:
raise ReleaseError("Missing [project].version in pyproject.toml")
def update_package_version(version: str) -> Path:
"""Update the package version in astrbot/__init__.py.
Args:
version: Release version to write.
Returns:
Path to the modified astrbot/__init__.py file.
Raises:
ReleaseError: The package version constant cannot be found or parsed.
"""
package_init_path = REPO_ROOT / "astrbot" / "__init__.py"
lines = package_init_path.read_text(encoding="utf-8").splitlines(keepends=True)
for index, line in enumerate(lines):
match = re.match(
r"^(\s*__version__\s*=\s*)([\"'])(.*?)(\2)(\s*(?:#.*)?)(\n?)$",
line,
)
if not match:
continue
prefix, quote, _current, _closing_quote, suffix, newline = match.groups()
lines[index] = f"{prefix}{quote}{version}{quote}{suffix}{newline}"
package_init_path.write_text("".join(lines), encoding="utf-8")
return package_init_path
raise ReleaseError("Missing __version__ in astrbot/__init__.py")
def write_changelog(version: str, commits: list[str]) -> Path:
"""Write a changelog draft for the release.
@@ -297,7 +328,14 @@ def commit_and_maybe_push(
Raises:
ReleaseError: Git add, commit, or push fails.
"""
git(["add", "pyproject.toml", str(changelog_path.relative_to(REPO_ROOT))])
git(
[
"add",
"pyproject.toml",
"astrbot/__init__.py",
str(changelog_path.relative_to(REPO_ROOT)),
]
)
if args.generate_api_client:
git(["add", "dashboard/src/api/generated"])
@@ -331,7 +369,7 @@ def print_next_steps(
else:
print("Next:")
print(f"1. Review and polish {changelog_rel}")
print(f"2. git add pyproject.toml {changelog_rel}")
print(f"2. git add pyproject.toml astrbot/__init__.py {changelog_rel}")
print(f'3. git commit -m "chore: bump version to {version}"')
print(f"4. git push -u {args.remote} {branch}")
@@ -414,6 +452,7 @@ def main(argv: list[str] | None = None) -> int:
commits = release_commits(tag)
update_pyproject_version(version)
update_package_version(version)
changelog_path = write_changelog(version, commits)
run_validation(args)

View File

@@ -294,7 +294,34 @@ def test_dashboard_uses_bundled_dist_when_data_dist_is_stale(
assert server.data_path == str(bundled_dist)
def test_dashboard_ignores_mismatched_data_dist_without_bundled(
def test_dashboard_falls_back_to_mismatched_data_dist_without_bundled(
core_lifecycle_td: AstrBotCoreLifecycle,
monkeypatch,
tmp_path,
):
data_dir = tmp_path / "data"
user_dist = data_dir / "dist"
bundled_dist = tmp_path / "bundled-dist"
(user_dist / "assets").mkdir(parents=True)
(user_dist / "assets" / "version").write_text("v0.0.1", encoding="utf-8")
(user_dist / "index.html").write_text("stale", encoding="utf-8")
monkeypatch.setattr(
"astrbot.dashboard.server.get_astrbot_data_path",
lambda: str(data_dir),
)
monkeypatch.setattr(
"astrbot.dashboard.server.get_bundled_dashboard_dist_path",
lambda: bundled_dist,
)
shutdown_event = asyncio.Event()
server = AstrBotDashboard(core_lifecycle_td, core_lifecycle_td.db, shutdown_event)
assert server.data_path == str(user_dist)
def test_dashboard_ignores_incomplete_mismatched_data_dist_without_bundled(
core_lifecycle_td: AstrBotCoreLifecycle,
monkeypatch,
tmp_path,

View File

@@ -246,6 +246,44 @@ async def test_check_dashboard_files_exists_but_version_mismatch_downloads(tmp_p
assert "WebUI version mismatch" in call_args[0]
@pytest.mark.asyncio
async def test_check_dashboard_files_falls_back_to_stale_dist_when_download_fails(
tmp_path,
):
"""Tests stale dashboard fallback when the matching WebUI cannot be downloaded."""
from main import VERSION
data_dir = tmp_path / "data"
data_dist = data_dir / "dist"
bundled_dist = tmp_path / "bundled-dist"
(data_dist / "assets").mkdir(parents=True)
(data_dist / "assets" / "version").write_text("v0.0.1", encoding="utf-8")
(data_dist / "index.html").write_text("stale", encoding="utf-8")
with mock.patch("main.get_astrbot_data_path", return_value=str(data_dir)):
with mock.patch(
"main.get_bundled_dashboard_dist_path",
return_value=bundled_dist,
):
with mock.patch(
"main.download_dashboard",
side_effect=RuntimeError("missing dashboard asset"),
) as mock_download:
with mock.patch("main.logger.warning") as mock_logger_warning:
result = await check_dashboard_files()
assert result == str(data_dist)
mock_download.assert_called_once_with(
version=f"v{VERSION}",
latest=False,
allow_insecure_ssl_fallback=False,
)
assert any(
"Falling back to existing data/dist WebUI" in call.args[0]
for call in mock_logger_warning.call_args_list
)
@pytest.mark.asyncio
async def test_check_dashboard_files_downloads_when_matching_dist_is_incomplete(
tmp_path,

View File

@@ -0,0 +1,245 @@
from types import SimpleNamespace
import pytest
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.provider.sources.openai_responses_source import (
ProviderOpenAIResponses,
)
class _Responses:
async def create(self, **kwargs):
return SimpleNamespace(output_text="ok", output=[], usage=None)
class _Client:
def __init__(self) -> None:
self.responses = _Responses()
def _make_provider() -> ProviderOpenAIResponses:
provider = ProviderOpenAIResponses.__new__(ProviderOpenAIResponses)
provider.client = _Client()
provider.default_params = {
"model",
"input",
"tools",
"tool_choice",
"stream",
"extra_body",
}
provider.provider_config = {"custom_extra_body": {"metadata": {"test": True}}}
provider._apply_provider_specific_extra_body_overrides = lambda extra_body: None
return provider
def _make_tool_set() -> ToolSet:
return ToolSet(
tools=[
FunctionTool(
name="lookup_weather",
description="Look up weather",
parameters={
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
)
]
)
def test_chat_payload_to_responses_payload_converts_messages_and_tool_calls():
payload = {
"model": "gpt-4.1",
"messages": [
{"role": "system", "content": "Be brief."},
{
"role": "user",
"content": [
{"type": "text", "text": "weather"},
{"type": "image_url", "image_url": {"url": "data:image/png,abc"}},
],
},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_1",
"function": {
"name": "lookup_weather",
"arguments": {"city": "Shanghai"},
},
}
],
},
{"role": "tool", "tool_call_id": "call_1", "content": "sunny"},
],
}
converted = ProviderOpenAIResponses._chat_payload_to_responses_payload(payload)
assert "messages" not in converted
assert converted["model"] == "gpt-4.1"
assert converted["input"] == [
{"role": "system", "content": "Be brief."},
{
"role": "user",
"content": [
{"type": "input_text", "text": "weather"},
{"type": "input_image", "image_url": "data:image/png,abc"},
],
},
{
"type": "function_call",
"call_id": "call_1",
"name": "lookup_weather",
"arguments": '{"city": "Shanghai"}',
"status": "completed",
},
{"type": "function_call_output", "call_id": "call_1", "output": "sunny"},
]
def test_chat_payload_to_responses_payload_replaces_audio_parts_with_placeholder():
payload = {
"model": "gpt-4.1",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "listen"},
{
"type": "input_audio",
"input_audio": {"data": "abc", "format": "wav"},
},
{"type": "audio_url", "audio_url": {"url": "data:audio/wav,abc"}},
],
},
],
}
converted = ProviderOpenAIResponses._chat_payload_to_responses_payload(payload)
assert converted["input"] == [
{
"role": "user",
"content": [
{"type": "input_text", "text": "listen"},
{"type": "input_text", "text": "[Audio]"},
{"type": "input_text", "text": "[Audio]"},
],
},
]
def test_build_responses_request_shares_tool_and_extra_body_handling():
provider = _make_provider()
payload = {
"model": "gpt-4.1",
"messages": [{"role": "user", "content": "hello"}],
"unknown_param": "kept-in-extra-body",
}
request_payload, extra_body = provider._build_responses_request(
payload,
_make_tool_set(),
)
assert request_payload["input"] == [{"role": "user", "content": "hello"}]
assert request_payload["tool_choice"] == "auto"
assert request_payload["tools"] == [
{
"type": "function",
"name": "lookup_weather",
"description": "Look up weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
"strict": False,
}
]
assert extra_body == {
"metadata": {"test": True},
"unknown_param": "kept-in-extra-body",
}
@pytest.mark.asyncio
async def test_parse_responses_completion_extracts_function_call_and_usage():
provider = _make_provider()
response = SimpleNamespace(
id="resp_1",
output=[
SimpleNamespace(
type="function_call",
name="lookup_weather",
call_id="call_1",
arguments='{"city":"Guangzhou"}',
)
],
usage=SimpleNamespace(
input_tokens=10,
output_tokens=3,
input_tokens_details=SimpleNamespace(cached_tokens=4),
),
)
result = await provider._parse_responses_completion(response, _make_tool_set())
assert result.role == "tool"
assert result.tools_call_name == ["lookup_weather"]
assert result.tools_call_ids == ["call_1"]
assert result.tools_call_args == [{"city": "Guangzhou"}]
assert result.usage.input_other == 6
assert result.usage.input_cached == 4
assert result.usage.output == 3
@pytest.mark.asyncio
async def test_stream_function_call_events_are_converted_to_tool_response():
provider = _make_provider()
function_calls: dict[str, dict] = {}
ProviderOpenAIResponses._merge_stream_function_call_event(
{
"type": "response.output_item.added",
"output_index": 0,
"item": {
"type": "function_call",
"name": "lookup_weather",
"call_id": "call_1",
},
},
function_calls,
)
ProviderOpenAIResponses._merge_stream_function_call_event(
{
"type": "response.function_call_arguments.delta",
"output_index": 0,
"delta": '{"city"',
},
function_calls,
)
ProviderOpenAIResponses._merge_stream_function_call_event(
{
"type": "response.function_call_arguments.delta",
"output_index": 0,
"delta": ':"Shanghai"}',
},
function_calls,
)
result = await provider._stream_function_calls_to_response(
function_calls,
_make_tool_set(),
)
assert result.role == "tool"
assert result.tools_call_name == ["lookup_weather"]
assert result.tools_call_ids == ["call_1"]
assert result.tools_call_args == [{"city": "Shanghai"}]

View File

@@ -2,87 +2,7 @@ from pathlib import Path
import pytest
from astrbot.core.utils.toml_parser import (
read_pyproject_project_dependencies,
read_pyproject_project_version,
)
def test_read_pyproject_project_version_reads_project_section(tmp_path: Path) -> None:
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text(
"\n".join(
[
'version = "ignored"',
"[project]",
'name = "AstrBot"',
'version = "1.2.3-beta.4" # release version',
"[tool.example]",
'version = "ignored-again"',
]
),
encoding="utf-8",
)
assert read_pyproject_project_version(pyproject_path) == "1.2.3-beta.4"
@pytest.mark.parametrize(
("version_line", "expected"),
[
('version = "1.2.3"', "1.2.3"),
("version='1.2.3-beta.4'", "1.2.3-beta.4"),
(' version = "1.2.3-rc.1" ', "1.2.3-rc.1"),
],
)
def test_read_pyproject_project_version_accepts_simple_variants(
tmp_path: Path,
version_line: str,
expected: str,
) -> None:
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text(
"\n".join(
[
"[project]",
'name = "AstrBot"',
version_line,
]
),
encoding="utf-8",
)
assert read_pyproject_project_version(pyproject_path) == expected
@pytest.mark.parametrize(
("version_line", "message"),
[
("version", "Missing value separator for project.version"),
('version = "1.2.3', "Unterminated project.version string"),
('version = "1.2.3" extra', "Unsupported content after project.version"),
('version = ""', "Empty project.version value"),
],
)
def test_read_pyproject_project_version_rejects_invalid_values(
tmp_path: Path,
version_line: str,
message: str,
) -> None:
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text(
"\n".join(
[
"[project]",
'name = "AstrBot"',
version_line,
]
),
encoding="utf-8",
)
with pytest.raises(ValueError, match=message):
read_pyproject_project_version(pyproject_path)
from astrbot.core.utils.toml_parser import read_pyproject_project_dependencies
def test_read_pyproject_project_dependencies_reads_multiline_array(
@@ -174,11 +94,3 @@ def test_read_pyproject_project_dependencies_rejects_invalid_values(
with pytest.raises(ValueError, match=message):
read_pyproject_project_dependencies(pyproject_path)
def test_read_pyproject_project_version_raises_when_missing(tmp_path: Path) -> None:
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text('[project]\nname = "AstrBot"\n', encoding="utf-8")
with pytest.raises(ValueError, match="Missing project.version"):
read_pyproject_project_version(pyproject_path)

View File

@@ -1009,7 +1009,7 @@ class TestEnsurePersonaAndSkills:
assert req.func_tool is not None
@pytest.mark.asyncio
async def test_persona_empty_tools_filters_late_builtin_tools(
async def test_persona_empty_tools_keeps_late_builtin_tools(
self, mock_event, mock_context, mock_provider
):
module = ama
@@ -1017,6 +1017,7 @@ class TestEnsurePersonaAndSkills:
mock_context.persona_manager.resolve_selected_persona = AsyncMock(
return_value=("locked", persona, None, False)
)
mock_event.platform_meta.support_proactive_message = False
mock_context.get_config.return_value = {
"provider_settings": {
"web_search": True,
@@ -1030,6 +1031,7 @@ class TestEnsurePersonaAndSkills:
"websearch_provider": "baidu_ai_search",
},
computer_use_runtime="none",
add_cron_tools=False,
)
req = ProviderRequest(prompt="hello")
req.conversation = MagicMock(persona_id="locked", history="[]")
@@ -1052,9 +1054,52 @@ class TestEnsurePersonaAndSkills:
)
assert result is not None
try:
assert result.provider_request.func_tool is None or (
result.provider_request.func_tool.empty()
assert result.provider_request.func_tool is not None
assert result.provider_request.func_tool.names() == ["web_search_baidu"]
finally:
if result.reset_coro:
result.reset_coro.close()
@pytest.mark.asyncio
async def test_persona_empty_tools_keeps_local_runtime_builtin_tools(
self, mock_event, mock_context, mock_provider
):
module = ama
persona = {"name": "locked", "prompt": "No tools.", "tools": []}
mock_context.persona_manager.resolve_selected_persona = AsyncMock(
return_value=("locked", persona, None, False)
)
mock_event.platform_meta.support_proactive_message = False
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
computer_use_runtime="local",
add_cron_tools=False,
)
req = ProviderRequest(prompt="hello")
req.conversation = MagicMock(persona_id="locked", history="[]")
with (
patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls,
patch("astrbot.core.astr_main_agent.AstrAgentContext"),
):
mock_runner = MagicMock()
mock_runner.reset = AsyncMock()
mock_runner_cls.return_value = mock_runner
result = await module.build_main_agent(
event=mock_event,
plugin_context=mock_context,
config=config,
provider=mock_provider,
req=req,
apply_reset=False,
)
assert result is not None
try:
assert result.provider_request.func_tool is not None
tool_names = result.provider_request.func_tool.names()
assert "astrbot_execute_shell" in tool_names
assert "astrbot_execute_python" in tool_names
finally:
if result.reset_coro:
result.reset_coro.close()