mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-05 04:10:15 +08:00
Compare commits
15 Commits
codex/prep
...
v4.26.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6913833d4 | ||
|
|
f9d4082217 | ||
|
|
ddc4e142c7 | ||
|
|
d36987dd19 | ||
|
|
0a0c677404 | ||
|
|
da7f53d5eb | ||
|
|
a7533aacda | ||
|
|
46a846b88b | ||
|
|
2d98d38078 | ||
|
|
1b0f5cb0d3 | ||
|
|
cdfb0bdf91 | ||
|
|
3760abb39b | ||
|
|
272242e407 | ||
|
|
dd36979eca | ||
|
|
143f846b92 |
@@ -16,8 +16,11 @@ venv*/
|
||||
ENV/
|
||||
.conda/
|
||||
dashboard/
|
||||
!astrbot/dashboard/
|
||||
!astrbot/dashboard/dist/
|
||||
!astrbot/dashboard/dist/**
|
||||
data/
|
||||
tests/
|
||||
.ruff_cache/
|
||||
.astrbot
|
||||
astrbot.lock
|
||||
astrbot.lock
|
||||
|
||||
20
.github/workflows/docker-image.yml
vendored
20
.github/workflows/docker-image.yml
vendored
@@ -46,14 +46,21 @@ jobs:
|
||||
|
||||
- name: Build Dashboard
|
||||
run: |
|
||||
dashboard_version=$(python3 - <<'PY'
|
||||
import tomllib
|
||||
with open("pyproject.toml", "rb") as f:
|
||||
print("v" + tomllib.load(f)["project"]["version"])
|
||||
PY
|
||||
)
|
||||
cd dashboard
|
||||
npm install
|
||||
npm run build
|
||||
mkdir -p dist/assets
|
||||
echo $(git rev-parse HEAD) > dist/assets/version
|
||||
echo "$dashboard_version" > dist/assets/version
|
||||
cd ..
|
||||
mkdir -p data
|
||||
cp -r dashboard/dist data/
|
||||
mkdir -p astrbot/dashboard
|
||||
rm -rf astrbot/dashboard/dist
|
||||
cp -r dashboard/dist astrbot/dashboard/dist
|
||||
|
||||
- name: Determine test image tags
|
||||
id: test-meta
|
||||
@@ -157,10 +164,11 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
mkdir -p dist/assets
|
||||
echo $(git rev-parse HEAD) > dist/assets/version
|
||||
echo "${{ steps.release-meta.outputs.version }}" > dist/assets/version
|
||||
cd ..
|
||||
mkdir -p data
|
||||
cp -r dashboard/dist data/
|
||||
mkdir -p astrbot/dashboard
|
||||
rm -rf astrbot/dashboard/dist
|
||||
cp -r dashboard/dist astrbot/dashboard/dist
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v4.1.0
|
||||
|
||||
42
AGENTS.md
42
AGENTS.md
@@ -50,6 +50,13 @@ 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/core/config/default.py` in sync.
|
||||
|
||||
### KISS and First Principles
|
||||
|
||||
Follow the KISS principle and reason from first principles during development. Start by identifying the real problem, required behavior, and smallest useful change before adding code. Do not pile on features, configuration switches, abstractions, dependencies, or compatibility layers unless they directly solve the current problem and have clear evidence of need.
|
||||
|
||||
Prefer the simplest implementation that is correct, maintainable, and consistent with the existing codebase. If a broader design seems attractive, reduce it to the essential behavior needed now and leave optional expansion for a later, explicit requirement.
|
||||
|
||||
### No Unnecessary Helpers
|
||||
|
||||
@@ -94,7 +101,34 @@ def calculate_metrics(user_id: int, force_refresh: bool = False) -> dict:
|
||||
|
||||
## Release versions
|
||||
|
||||
1. Replace current version name to specific version name.
|
||||
2. Write changelog in `changelogs/`, you can refer to the full commit messages between the latest tag to the latest commit.
|
||||
3. Make and push a commit into master branch with message format like: `chore: bump version to 4.25.0`
|
||||
4. Create a tag and push the tag. For example: `git tag v4.25.0 && git push origin v4.25.0`
|
||||
Use a short-lived `release/*` branch for each release. The release branch is the stabilization area for version bumps, changelog updates, release-blocking fixes, and final validation only. Do not add unrelated features or broad refactors to a release branch.
|
||||
|
||||
Prepare a release from a clean worktree with:
|
||||
|
||||
```bash
|
||||
uv run python scripts/prepare_release.py 4.25.0
|
||||
```
|
||||
|
||||
The script updates `pyproject.toml` and `astrbot/core/config/default.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
|
||||
uv run python scripts/prepare_release.py 4.25.0 --dashboard-build
|
||||
uv run python scripts/prepare_release.py 4.25.0 --commit --push
|
||||
```
|
||||
|
||||
Open a PR from `release/4.25.0` to `master`. The PR title must use the conventional commit format, for example `chore: bump version to 4.25.0`. After the release PR is merged, create and push the tag from the updated `master` branch so the tag points to the exact code that was merged:
|
||||
|
||||
```bash
|
||||
git checkout master
|
||||
git pull --ff-only origin master
|
||||
git tag v4.25.0
|
||||
git push origin v4.25.0
|
||||
```
|
||||
|
||||
For one-off release candidate branches, delete the release branch after the tag is pushed and verified. For maintained release lines, use a branch such as `release/4.25` and keep it until that line reaches EOL.
|
||||
|
||||
```bash
|
||||
git branch -d release/4.25.0
|
||||
git push origin --delete release/4.25.0
|
||||
```
|
||||
|
||||
10
README.md
10
README.md
@@ -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
|
||||
|
||||
12
README_zh.md
12
README_zh.md
@@ -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
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from datetime import timedelta
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from typing import Any, Generic
|
||||
|
||||
import httpx
|
||||
from tenacity import (
|
||||
before_sleep_log,
|
||||
retry,
|
||||
@@ -102,12 +103,22 @@ except (ModuleNotFoundError, ImportError):
|
||||
"Warning: Missing 'mcp' dependency, MCP services will be unavailable."
|
||||
)
|
||||
|
||||
streamable_http_client_legacy = None
|
||||
streamable_http_client = None
|
||||
|
||||
try:
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
logger.warning(
|
||||
"Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.",
|
||||
from mcp.client.streamable_http import (
|
||||
streamablehttp_client as streamable_http_client_legacy,
|
||||
)
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
try:
|
||||
from mcp.client.streamable_http import (
|
||||
streamable_http_client as streamable_http_client,
|
||||
)
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
logger.warning(
|
||||
"Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.",
|
||||
)
|
||||
|
||||
|
||||
def _prepare_config(config: dict) -> dict:
|
||||
@@ -459,17 +470,38 @@ class MCPClient:
|
||||
),
|
||||
)
|
||||
else:
|
||||
timeout = timedelta(seconds=cfg.get("timeout", 30))
|
||||
sse_read_timeout = timedelta(
|
||||
seconds=cfg.get("sse_read_timeout", 60 * 5),
|
||||
)
|
||||
self._streams_context = streamablehttp_client(
|
||||
url=cfg["url"],
|
||||
headers=cfg.get("headers", {}),
|
||||
timeout=timeout,
|
||||
sse_read_timeout=sse_read_timeout,
|
||||
terminate_on_close=cfg.get("terminate_on_close", True),
|
||||
)
|
||||
timeout_seconds = cfg.get("timeout", 30)
|
||||
sse_read_timeout_seconds = cfg.get("sse_read_timeout", 60 * 5)
|
||||
if streamable_http_client_legacy:
|
||||
timeout = timedelta(seconds=timeout_seconds)
|
||||
sse_read_timeout = timedelta(seconds=sse_read_timeout_seconds)
|
||||
self._streams_context = streamable_http_client_legacy(
|
||||
url=cfg["url"],
|
||||
headers=cfg.get("headers", {}),
|
||||
timeout=timeout,
|
||||
sse_read_timeout=sse_read_timeout,
|
||||
terminate_on_close=cfg.get("terminate_on_close", True),
|
||||
)
|
||||
elif streamable_http_client:
|
||||
http_client = await self.exit_stack.enter_async_context(
|
||||
httpx.AsyncClient(
|
||||
headers=cfg.get("headers", {}),
|
||||
timeout=httpx.Timeout(
|
||||
timeout_seconds,
|
||||
read=sse_read_timeout_seconds,
|
||||
),
|
||||
follow_redirects=True,
|
||||
),
|
||||
)
|
||||
self._streams_context = streamable_http_client(
|
||||
url=cfg["url"],
|
||||
http_client=http_client,
|
||||
terminate_on_close=cfg.get("terminate_on_close", True),
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Streamable HTTP transport is not available in the installed MCP library version."
|
||||
)
|
||||
read_s, write_s, _ = await self.exit_stack.enter_async_context(
|
||||
self._streams_context,
|
||||
)
|
||||
|
||||
@@ -224,6 +224,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
custom_compressor: ContextCompressor | None = None,
|
||||
tool_schema_mode: str | None = "full",
|
||||
fallback_providers: list[Provider] | None = None,
|
||||
request_max_retries: int | None = None,
|
||||
tool_result_overflow_dir: str | None = None,
|
||||
read_tool: FunctionTool | None = None,
|
||||
**kwargs: T.Any,
|
||||
@@ -237,6 +238,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.truncate_turns = truncate_turns
|
||||
self.custom_token_counter = custom_token_counter
|
||||
self.custom_compressor = custom_compressor
|
||||
self.request_max_retries = request_max_retries
|
||||
self.tool_result_overflow_dir = tool_result_overflow_dir
|
||||
self.read_tool = read_tool
|
||||
self._tool_result_token_counter = EstimateTokenCounter()
|
||||
@@ -463,6 +465,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
"session_id": self.req.session_id,
|
||||
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
|
||||
"abort_signal": self._abort_signal,
|
||||
"request_max_retries": self.request_max_retries,
|
||||
}
|
||||
if include_model:
|
||||
# For primary provider we keep explicit model selection if provided.
|
||||
@@ -1305,6 +1308,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
extra_user_content_parts=self.req.extra_user_content_parts,
|
||||
# tool_choice="required",
|
||||
abort_signal=self._abort_signal,
|
||||
request_max_retries=self.request_max_retries,
|
||||
)
|
||||
if requery_resp:
|
||||
llm_resp = requery_resp
|
||||
@@ -1331,6 +1335,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
extra_user_content_parts=self.req.extra_user_content_parts,
|
||||
# tool_choice="required",
|
||||
abort_signal=self._abort_signal,
|
||||
request_max_retries=self.request_max_retries,
|
||||
)
|
||||
if repair_resp:
|
||||
llm_resp = repair_resp
|
||||
|
||||
@@ -278,10 +278,11 @@ async def _apply_kb(
|
||||
)
|
||||
if not kb_result:
|
||||
return
|
||||
if req.system_prompt is not None:
|
||||
req.system_prompt += (
|
||||
f"\n\n[Related Knowledge Base Results]:\n{kb_result}"
|
||||
)
|
||||
req.extra_user_content_parts.append(
|
||||
TextPart(
|
||||
text=f"[Related Knowledge Base Results]:\n{kb_result}",
|
||||
).mark_as_temp()
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Error occurred while retrieving knowledge base: %s", exc)
|
||||
else:
|
||||
@@ -456,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,
|
||||
@@ -526,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"]:
|
||||
@@ -613,7 +612,6 @@ async def _ensure_persona_and_skills(
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return persona_allowed_tools
|
||||
|
||||
|
||||
async def _request_img_caption(
|
||||
@@ -946,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"
|
||||
@@ -961,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(
|
||||
@@ -992,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:
|
||||
@@ -1521,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)
|
||||
|
||||
@@ -1559,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
|
||||
)
|
||||
@@ -1629,6 +1616,7 @@ async def build_main_agent(
|
||||
enforce_max_turns=config.max_context_length,
|
||||
tool_schema_mode=config.tool_schema_mode,
|
||||
fallback_providers=fallback_providers,
|
||||
request_max_retries=config.provider_settings.get("request_max_retries", 5),
|
||||
tool_result_overflow_dir=(
|
||||
get_astrbot_system_tmp_path()
|
||||
if req.func_tool and req.func_tool.get_tool("astrbot_file_read_tool")
|
||||
|
||||
@@ -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.26.0-beta.8"
|
||||
VERSION = "4.26.0-beta.11"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
PERSONAL_WECHAT_CONFIG_METADATA = {
|
||||
"weixin_oc_base_url": {
|
||||
@@ -101,6 +101,7 @@ DEFAULT_CONFIG = {
|
||||
"enable": True,
|
||||
"default_provider_id": "",
|
||||
"fallback_chat_models": [],
|
||||
"request_max_retries": 5,
|
||||
"default_image_caption_provider_id": "",
|
||||
"image_caption_prompt": "Please describe the image using Chinese.",
|
||||
"provider_pool": ["*"], # "*" 表示使用所有可用的提供者
|
||||
@@ -2808,6 +2809,9 @@ CONFIG_METADATA_2 = {
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"request_max_retries": {
|
||||
"type": "int",
|
||||
},
|
||||
"wake_prefix": {
|
||||
"type": "string",
|
||||
},
|
||||
@@ -3167,6 +3171,11 @@ CONFIG_METADATA_3 = {
|
||||
"_special": "select_providers",
|
||||
"hint": "主聊天模型请求失败时,按顺序切换到这些模型。",
|
||||
},
|
||||
"provider_settings.request_max_retries": {
|
||||
"description": "请求最大重试次数",
|
||||
"type": "int",
|
||||
"hint": "单次模型请求遇到可重试错误时的最大尝试次数。",
|
||||
},
|
||||
"provider_settings.default_image_caption_provider_id": {
|
||||
"description": "默认图片转述模型",
|
||||
"type": "string",
|
||||
|
||||
@@ -106,6 +106,7 @@ class Provider(AbstractProvider):
|
||||
model: str | None = None,
|
||||
extra_user_content_parts: list[ContentPart] | None = None,
|
||||
tool_choice: Literal["auto", "required"] = "auto",
|
||||
request_max_retries: int | None = None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
"""获得 LLM 的文本对话结果。会使用当前的模型进行对话。
|
||||
@@ -120,6 +121,7 @@ class Provider(AbstractProvider):
|
||||
contexts: 上下文,和 prompt 二选一使用
|
||||
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
|
||||
extra_user_content_parts: 额外的内容块列表,用于在用户消息后添加额外的文本块(如系统提醒、指令等)
|
||||
request_max_retries: 可重试请求错误的最大尝试次数,包含首次请求。
|
||||
kwargs: 其他参数
|
||||
|
||||
Notes:
|
||||
@@ -142,6 +144,7 @@ class Provider(AbstractProvider):
|
||||
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
|
||||
model: str | None = None,
|
||||
tool_choice: Literal["auto", "required"] = "auto",
|
||||
request_max_retries: int | None = None,
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
"""获得 LLM 的流式文本对话结果。会使用当前的模型进行对话。在生成的最后会返回一次完整的结果。
|
||||
@@ -155,6 +158,7 @@ class Provider(AbstractProvider):
|
||||
tool_choice: 工具调用策略,`auto` 表示由模型自行决定,`required` 表示要求模型必须调用工具
|
||||
contexts: 上下文,和 prompt 二选一使用
|
||||
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
|
||||
request_max_retries: 可重试请求错误的最大尝试次数,包含首次请求。
|
||||
kwargs: 其他参数
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -27,6 +27,7 @@ from astrbot.core.utils.network_utils import (
|
||||
)
|
||||
|
||||
from ..register import register_provider_adapter
|
||||
from .request_retry import retry_provider_request, retry_provider_request_context
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
@@ -353,7 +354,13 @@ class ProviderAnthropic(Provider):
|
||||
logger.warning(f"未知的 tool_choice 值: {tool_choice},已回退为 'auto'")
|
||||
return {"type": "auto"}
|
||||
|
||||
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
|
||||
async def _query(
|
||||
self,
|
||||
payloads: dict,
|
||||
tools: ToolSet | None,
|
||||
*,
|
||||
request_max_retries: int | None = None,
|
||||
) -> LLMResponse:
|
||||
if tools:
|
||||
if tool_list := tools.get_func_desc_anthropic_style():
|
||||
payloads["tools"] = tool_list
|
||||
@@ -368,8 +375,12 @@ class ProviderAnthropic(Provider):
|
||||
self._apply_thinking_config(payloads)
|
||||
|
||||
try:
|
||||
completion = await self.client.messages.create(
|
||||
**payloads, stream=False, extra_body=extra_body
|
||||
completion = await retry_provider_request(
|
||||
"Anthropic",
|
||||
lambda: self.client.messages.create(
|
||||
**payloads, stream=False, extra_body=extra_body
|
||||
),
|
||||
max_attempts=request_max_retries,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
proxy = self.provider_config.get("proxy", "")
|
||||
@@ -438,6 +449,8 @@ class ProviderAnthropic(Provider):
|
||||
self,
|
||||
payloads: dict,
|
||||
tools: ToolSet | None,
|
||||
*,
|
||||
request_max_retries: int | None = None,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
if tools:
|
||||
if tool_list := tools.get_func_desc_anthropic_style():
|
||||
@@ -461,8 +474,10 @@ class ProviderAnthropic(Provider):
|
||||
payloads["max_tokens"] = 65536
|
||||
self._apply_thinking_config(payloads)
|
||||
|
||||
async with self.client.messages.stream(
|
||||
**payloads, extra_body=extra_body
|
||||
async with retry_provider_request_context(
|
||||
"Anthropic",
|
||||
lambda: self.client.messages.stream(**payloads, extra_body=extra_body),
|
||||
max_attempts=request_max_retries,
|
||||
) as stream:
|
||||
assert isinstance(stream, anthropic.AsyncMessageStream)
|
||||
async for event in stream:
|
||||
@@ -601,6 +616,7 @@ class ProviderAnthropic(Provider):
|
||||
model=None,
|
||||
extra_user_content_parts=None,
|
||||
tool_choice: Literal["auto", "any", "tool", "none"] | dict[str, str] = "auto",
|
||||
request_max_retries: int | None = None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
if contexts is None:
|
||||
@@ -650,7 +666,11 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
llm_response = None
|
||||
try:
|
||||
llm_response = await self._query(payloads, func_tool)
|
||||
llm_response = await self._query(
|
||||
payloads,
|
||||
func_tool,
|
||||
request_max_retries=request_max_retries,
|
||||
)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
@@ -669,6 +689,7 @@ class ProviderAnthropic(Provider):
|
||||
model=None,
|
||||
extra_user_content_parts=None,
|
||||
tool_choice: Literal["auto", "any", "tool", "none"] | dict[str, str] = "auto",
|
||||
request_max_retries: int | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
if contexts is None:
|
||||
@@ -715,7 +736,11 @@ class ProviderAnthropic(Provider):
|
||||
else system_prompt
|
||||
)
|
||||
|
||||
async for llm_response in self._query_stream(payloads, func_tool):
|
||||
async for llm_response in self._query_stream(
|
||||
payloads,
|
||||
func_tool,
|
||||
request_max_retries=request_max_retries,
|
||||
):
|
||||
yield llm_response
|
||||
|
||||
def _detect_image_mime_type(self, data: bytes) -> str:
|
||||
@@ -827,7 +852,10 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
async def get_models(self) -> list[str]:
|
||||
models_str = []
|
||||
models = await self.client.models.list()
|
||||
models = await retry_provider_request(
|
||||
"Anthropic",
|
||||
lambda: self.client.models.list(),
|
||||
)
|
||||
models = sorted(models.data, key=lambda x: x.id)
|
||||
for model in models:
|
||||
models_str.append(model.id)
|
||||
|
||||
@@ -26,6 +26,7 @@ from astrbot.core.utils.media_utils import (
|
||||
from astrbot.core.utils.network_utils import is_connection_error, log_connection_failure
|
||||
|
||||
from ..register import register_provider_adapter
|
||||
from .request_retry import retry_provider_request
|
||||
|
||||
|
||||
class SuppressNonTextPartsWarning(logging.Filter):
|
||||
@@ -577,7 +578,13 @@ class ProviderGoogleGenAI(Provider):
|
||||
)
|
||||
return chain_result
|
||||
|
||||
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
|
||||
async def _query(
|
||||
self,
|
||||
payloads: dict,
|
||||
tools: ToolSet | None,
|
||||
*,
|
||||
request_max_retries: int | None = None,
|
||||
) -> LLMResponse:
|
||||
"""非流式请求 Gemini API"""
|
||||
system_instruction = next(
|
||||
(msg["content"] for msg in payloads["messages"] if msg["role"] == "system"),
|
||||
@@ -604,10 +611,14 @@ class ProviderGoogleGenAI(Provider):
|
||||
modalities,
|
||||
temperature,
|
||||
)
|
||||
result = await self.client.models.generate_content(
|
||||
model=model,
|
||||
contents=cast(types.ContentListUnion, conversation),
|
||||
config=config,
|
||||
result = await retry_provider_request(
|
||||
"Gemini",
|
||||
lambda: self.client.models.generate_content(
|
||||
model=model,
|
||||
contents=cast(types.ContentListUnion, conversation),
|
||||
config=config,
|
||||
),
|
||||
max_attempts=request_max_retries,
|
||||
)
|
||||
logger.debug(f"genai result: {result}")
|
||||
|
||||
@@ -672,6 +683,8 @@ class ProviderGoogleGenAI(Provider):
|
||||
self,
|
||||
payloads: dict,
|
||||
tools: ToolSet | None,
|
||||
*,
|
||||
request_max_retries: int | None = None,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
"""流式请求 Gemini API"""
|
||||
system_instruction = next(
|
||||
@@ -690,10 +703,14 @@ class ProviderGoogleGenAI(Provider):
|
||||
payloads.get("tool_choice", "auto"),
|
||||
system_instruction,
|
||||
)
|
||||
result = await self.client.models.generate_content_stream(
|
||||
model=model,
|
||||
contents=cast(types.ContentListUnion, conversation),
|
||||
config=config,
|
||||
result = await retry_provider_request(
|
||||
"Gemini",
|
||||
lambda: self.client.models.generate_content_stream(
|
||||
model=model,
|
||||
contents=cast(types.ContentListUnion, conversation),
|
||||
config=config,
|
||||
),
|
||||
max_attempts=request_max_retries,
|
||||
)
|
||||
break
|
||||
except APIError as e:
|
||||
@@ -809,6 +826,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
model=None,
|
||||
extra_user_content_parts=None,
|
||||
tool_choice: Literal["auto", "required"] = "auto",
|
||||
request_max_retries: int | None = None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
if contexts is None:
|
||||
@@ -850,7 +868,11 @@ class ProviderGoogleGenAI(Provider):
|
||||
|
||||
for _ in range(retry):
|
||||
try:
|
||||
return await self._query(payloads, func_tool)
|
||||
return await self._query(
|
||||
payloads,
|
||||
func_tool,
|
||||
request_max_retries=request_max_retries,
|
||||
)
|
||||
except APIError as e:
|
||||
if await self._handle_api_error(e, keys):
|
||||
continue
|
||||
@@ -871,6 +893,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
model=None,
|
||||
extra_user_content_parts=None,
|
||||
tool_choice: Literal["auto", "required"] = "auto",
|
||||
request_max_retries: int | None = None,
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
if contexts is None:
|
||||
@@ -912,7 +935,11 @@ class ProviderGoogleGenAI(Provider):
|
||||
|
||||
for _ in range(retry):
|
||||
try:
|
||||
async for response in self._query_stream(payloads, func_tool):
|
||||
async for response in self._query_stream(
|
||||
payloads,
|
||||
func_tool,
|
||||
request_max_retries=request_max_retries,
|
||||
):
|
||||
yield response
|
||||
break
|
||||
except APIError as e:
|
||||
@@ -922,7 +949,10 @@ class ProviderGoogleGenAI(Provider):
|
||||
|
||||
async def get_models(self):
|
||||
try:
|
||||
models = await self.client.models.list()
|
||||
models = await retry_provider_request(
|
||||
"Gemini",
|
||||
lambda: self.client.models.list(),
|
||||
)
|
||||
return [
|
||||
m.name.replace("models/", "")
|
||||
for m in models
|
||||
|
||||
@@ -41,6 +41,7 @@ from astrbot.core.utils.network_utils import (
|
||||
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
|
||||
|
||||
from ..register import register_provider_adapter
|
||||
from .request_retry import retry_provider_request
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
@@ -420,7 +421,10 @@ class ProviderOpenAIOfficial(Provider):
|
||||
async def get_models(self):
|
||||
try:
|
||||
models_str = []
|
||||
models = await self.client.models.list()
|
||||
models = await retry_provider_request(
|
||||
"OpenAI",
|
||||
lambda: self.client.models.list(),
|
||||
)
|
||||
models = sorted(models.data, key=lambda x: x.id)
|
||||
for model in models:
|
||||
models_str.append(model.id)
|
||||
@@ -465,7 +469,13 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
payloads["messages"] = cleaned
|
||||
|
||||
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
|
||||
async def _query(
|
||||
self,
|
||||
payloads: dict,
|
||||
tools: ToolSet | None,
|
||||
*,
|
||||
request_max_retries: int | None = None,
|
||||
) -> LLMResponse:
|
||||
if tools:
|
||||
model = payloads.get("model", "").lower()
|
||||
omit_empty_param_field = "gemini" in model
|
||||
@@ -496,10 +506,14 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
self._sanitize_assistant_messages(payloads)
|
||||
|
||||
completion = await self.client.chat.completions.create(
|
||||
**payloads,
|
||||
stream=False,
|
||||
extra_body=extra_body,
|
||||
completion = await retry_provider_request(
|
||||
"OpenAI",
|
||||
lambda: self.client.chat.completions.create(
|
||||
**payloads,
|
||||
stream=False,
|
||||
extra_body=extra_body,
|
||||
),
|
||||
max_attempts=request_max_retries,
|
||||
)
|
||||
|
||||
if not isinstance(completion, ChatCompletion):
|
||||
@@ -517,6 +531,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
self,
|
||||
payloads: dict,
|
||||
tools: ToolSet | None,
|
||||
*,
|
||||
request_max_retries: int | None = None,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
"""流式查询API,逐步返回结果"""
|
||||
if tools:
|
||||
@@ -548,11 +564,15 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
self._sanitize_assistant_messages(payloads)
|
||||
|
||||
stream = await self.client.chat.completions.create(
|
||||
**payloads,
|
||||
stream=True,
|
||||
extra_body=extra_body,
|
||||
stream_options={"include_usage": True},
|
||||
stream = await retry_provider_request(
|
||||
"OpenAI",
|
||||
lambda: self.client.chat.completions.create(
|
||||
**payloads,
|
||||
stream=True,
|
||||
extra_body=extra_body,
|
||||
stream_options={"include_usage": True},
|
||||
),
|
||||
max_attempts=request_max_retries,
|
||||
)
|
||||
|
||||
llm_response = LLMResponse("assistant", is_chunk=True)
|
||||
@@ -1104,6 +1124,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
model=None,
|
||||
extra_user_content_parts=None,
|
||||
tool_choice: Literal["auto", "required"] = "auto",
|
||||
request_max_retries: int | None = None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
payloads, context_query = await self._prepare_chat_payload(
|
||||
@@ -1131,7 +1152,11 @@ class ProviderOpenAIOfficial(Provider):
|
||||
for retry_cnt in range(max_retries):
|
||||
try:
|
||||
self.client.api_key = chosen_key
|
||||
llm_response = await self._query(payloads, func_tool)
|
||||
llm_response = await self._query(
|
||||
payloads,
|
||||
func_tool,
|
||||
request_max_retries=request_max_retries,
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
@@ -1176,6 +1201,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
tool_calls_result=None,
|
||||
model=None,
|
||||
tool_choice: Literal["auto", "required"] = "auto",
|
||||
request_max_retries: int | None = None,
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
"""流式对话,与服务商交互并逐步返回结果"""
|
||||
@@ -1202,7 +1228,11 @@ class ProviderOpenAIOfficial(Provider):
|
||||
for retry_cnt in range(max_retries):
|
||||
try:
|
||||
self.client.api_key = chosen_key
|
||||
async for response in self._query_stream(payloads, func_tool):
|
||||
async for response in self._query_stream(
|
||||
payloads,
|
||||
func_tool,
|
||||
request_max_retries=request_max_retries,
|
||||
):
|
||||
yield response
|
||||
break
|
||||
except Exception as e:
|
||||
|
||||
163
astrbot/core/provider/sources/request_retry.py
Normal file
163
astrbot/core/provider/sources/request_retry.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from typing import TypeVar
|
||||
|
||||
from tenacity import (
|
||||
AsyncRetrying,
|
||||
RetryCallState,
|
||||
retry_if_exception,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.utils.config_number import coerce_int_config
|
||||
from astrbot.core.utils.network_utils import is_connection_error
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
REQUEST_RETRY_ATTEMPTS = 5 # default value
|
||||
REQUEST_RETRY_WAIT_MIN_S = 0.2
|
||||
REQUEST_RETRY_WAIT_MAX_S = 30
|
||||
REQUEST_RETRY_STATUS_CODES = {408, 409, 429, 500, 502, 503, 504, 529}
|
||||
|
||||
|
||||
def _get_status_code(error: BaseException) -> int | None:
|
||||
for attr in ("status_code", "status", "code"):
|
||||
value = getattr(error, attr, None)
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
|
||||
response = getattr(error, "response", None)
|
||||
if response is not None:
|
||||
status_code = getattr(response, "status_code", None)
|
||||
if isinstance(status_code, int):
|
||||
return status_code
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _is_retryable_provider_request_error(
|
||||
error: BaseException,
|
||||
*,
|
||||
retry_rate_limits: bool,
|
||||
) -> bool:
|
||||
if is_connection_error(error):
|
||||
return True
|
||||
|
||||
error_type_name = type(error).__name__
|
||||
if error_type_name in {"APIConnectionError", "APITimeoutError"}:
|
||||
return True
|
||||
|
||||
status_code = _get_status_code(error)
|
||||
if status_code is None:
|
||||
return False
|
||||
|
||||
if status_code == 429 and not retry_rate_limits:
|
||||
return False
|
||||
|
||||
return status_code in REQUEST_RETRY_STATUS_CODES or 500 <= status_code <= 599
|
||||
|
||||
|
||||
def _log_retry(
|
||||
provider_label: str,
|
||||
retry_state: RetryCallState,
|
||||
max_attempts: int,
|
||||
) -> None:
|
||||
error = retry_state.outcome.exception() if retry_state.outcome else None
|
||||
logger.warning(
|
||||
f"[{provider_label}] Request failed with retryable error; "
|
||||
f"retrying ({retry_state.attempt_number + 1}/{max_attempts}): "
|
||||
f"{error}"
|
||||
)
|
||||
|
||||
|
||||
def _build_retrying(
|
||||
provider_label: str,
|
||||
*,
|
||||
retry_rate_limits: bool,
|
||||
max_attempts: int | None = None,
|
||||
) -> AsyncRetrying:
|
||||
max_attempts = coerce_int_config(
|
||||
max_attempts if max_attempts is not None else REQUEST_RETRY_ATTEMPTS,
|
||||
default=REQUEST_RETRY_ATTEMPTS,
|
||||
min_value=1,
|
||||
field_name="request_max_retries",
|
||||
source=provider_label,
|
||||
)
|
||||
|
||||
return AsyncRetrying(
|
||||
retry=retry_if_exception(
|
||||
lambda error: _is_retryable_provider_request_error(
|
||||
error,
|
||||
retry_rate_limits=retry_rate_limits,
|
||||
)
|
||||
),
|
||||
stop=stop_after_attempt(max_attempts),
|
||||
wait=wait_exponential(
|
||||
multiplier=1,
|
||||
min=REQUEST_RETRY_WAIT_MIN_S,
|
||||
max=REQUEST_RETRY_WAIT_MAX_S,
|
||||
),
|
||||
before_sleep=lambda retry_state: _log_retry(
|
||||
provider_label,
|
||||
retry_state,
|
||||
max_attempts,
|
||||
),
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
|
||||
async def retry_provider_request(
|
||||
provider_label: str,
|
||||
request_factory: Callable[[], Awaitable[T]],
|
||||
*,
|
||||
retry_rate_limits: bool = True,
|
||||
max_attempts: int | None = None,
|
||||
) -> T:
|
||||
retrying = _build_retrying(
|
||||
provider_label,
|
||||
retry_rate_limits=retry_rate_limits,
|
||||
max_attempts=max_attempts,
|
||||
)
|
||||
|
||||
async for attempt in retrying:
|
||||
with attempt:
|
||||
return await request_factory()
|
||||
|
||||
raise RuntimeError("Provider request retry loop exited unexpectedly.")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def retry_provider_request_context(
|
||||
provider_label: str,
|
||||
context_manager_factory: Callable[[], AbstractAsyncContextManager[T]],
|
||||
*,
|
||||
retry_rate_limits: bool = True,
|
||||
max_attempts: int | None = None,
|
||||
) -> AsyncIterator[T]:
|
||||
manager: AbstractAsyncContextManager[T] | None = None
|
||||
|
||||
async def _enter_context() -> T:
|
||||
nonlocal manager
|
||||
manager = context_manager_factory()
|
||||
return await manager.__aenter__()
|
||||
|
||||
value = await retry_provider_request(
|
||||
provider_label,
|
||||
_enter_context,
|
||||
retry_rate_limits=retry_rate_limits,
|
||||
max_attempts=max_attempts,
|
||||
)
|
||||
|
||||
if manager is None:
|
||||
raise RuntimeError("Provider request context was not created.")
|
||||
|
||||
try:
|
||||
yield value
|
||||
except BaseException as error:
|
||||
if await manager.__aexit__(type(error), error, error.__traceback__):
|
||||
return
|
||||
raise
|
||||
else:
|
||||
await manager.__aexit__(None, None, None)
|
||||
@@ -183,8 +183,22 @@ async def download_file(
|
||||
path: str,
|
||||
show_progress: bool = False,
|
||||
progress_callback=None,
|
||||
allow_insecure_ssl_fallback: bool = True,
|
||||
) -> None:
|
||||
"""从指定 url 下载文件到指定路径 path"""
|
||||
"""Download a remote file to a local path.
|
||||
|
||||
Args:
|
||||
url: Remote URL to download.
|
||||
path: Local destination path.
|
||||
show_progress: Whether to print progress to stdout.
|
||||
progress_callback: Optional callback for progress payloads.
|
||||
allow_insecure_ssl_fallback: Whether certificate failures may retry with
|
||||
TLS certificate verification disabled.
|
||||
|
||||
Returns:
|
||||
None.
|
||||
"""
|
||||
|
||||
try:
|
||||
ssl_context = ssl.create_default_context(
|
||||
cafile=certifi.where(),
|
||||
@@ -259,6 +273,8 @@ async def download_file(
|
||||
},
|
||||
)
|
||||
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
|
||||
if not allow_insecure_ssl_fallback:
|
||||
raise
|
||||
# 关闭SSL验证(仅在证书验证失败时作为fallback)
|
||||
logger.warning(
|
||||
f"SSL certificate verification failed for {_safe_url_for_log(url)}. "
|
||||
@@ -355,10 +371,22 @@ def get_local_ip_addresses():
|
||||
return network_ips
|
||||
|
||||
|
||||
def _read_dashboard_dist_version(dist_dir: str | Path) -> str | None:
|
||||
def get_dashboard_dist_version(dist_dir: str | Path) -> str | None:
|
||||
"""Read the WebUI version from a dashboard dist directory.
|
||||
|
||||
Args:
|
||||
dist_dir: Dashboard dist directory path.
|
||||
|
||||
Returns:
|
||||
The version string from assets/version, or None when unavailable.
|
||||
"""
|
||||
|
||||
version_file = Path(dist_dir) / "assets" / "version"
|
||||
if version_file.exists():
|
||||
return version_file.read_text(encoding="utf-8").strip()
|
||||
try:
|
||||
if version_file.exists():
|
||||
return version_file.read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
logger.warning("Failed to read WebUI version from %s: %s", version_file, exc)
|
||||
return None
|
||||
|
||||
|
||||
@@ -380,42 +408,106 @@ def _normalize_dashboard_version(version: str) -> str:
|
||||
return version
|
||||
|
||||
|
||||
def should_use_bundled_dashboard_dist(
|
||||
user_dist: str | Path, current_version: str
|
||||
def is_dashboard_version_compatible(
|
||||
dashboard_version: str | None, current_version: str
|
||||
) -> bool:
|
||||
user_version = _read_dashboard_dist_version(user_dist)
|
||||
bundled_dist = get_bundled_dashboard_dist_path()
|
||||
if user_version is None or not bundled_dist.exists():
|
||||
"""Check whether a WebUI version matches the current core version.
|
||||
|
||||
Args:
|
||||
dashboard_version: Version read from the WebUI assets/version file.
|
||||
current_version: Current AstrBot core version.
|
||||
|
||||
Returns:
|
||||
True when both versions are valid SemVer values and compare equal.
|
||||
"""
|
||||
|
||||
if dashboard_version is None:
|
||||
return False
|
||||
try:
|
||||
return (
|
||||
VersionComparator.compare_version(
|
||||
_normalize_dashboard_version(dashboard_version),
|
||||
_normalize_dashboard_version(current_version),
|
||||
_normalize_dashboard_version(user_version),
|
||||
)
|
||||
> 0
|
||||
== 0
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def is_dashboard_dist_compatible(dist_dir: str | Path, current_version: str) -> bool:
|
||||
"""Check whether a WebUI dist is complete and matches the core version.
|
||||
|
||||
Args:
|
||||
dist_dir: Dashboard dist directory path.
|
||||
current_version: Current AstrBot core version.
|
||||
|
||||
Returns:
|
||||
True when the dist has an index file and a compatible assets/version.
|
||||
"""
|
||||
|
||||
dist_path = Path(dist_dir)
|
||||
return (dist_path / "index.html").is_file() and is_dashboard_version_compatible(
|
||||
get_dashboard_dist_version(dist_path),
|
||||
current_version,
|
||||
)
|
||||
|
||||
|
||||
def should_use_bundled_dashboard_dist(
|
||||
user_dist: str | Path, current_version: str
|
||||
) -> bool:
|
||||
"""Decide whether bundled WebUI should replace a user data dist.
|
||||
|
||||
Args:
|
||||
user_dist: Runtime dashboard dist directory under data/.
|
||||
current_version: Current AstrBot core version.
|
||||
|
||||
Returns:
|
||||
True when user_dist exists but is missing or mismatched against the
|
||||
current core version, and bundled WebUI matches the current core version.
|
||||
"""
|
||||
|
||||
user_dist = Path(user_dist)
|
||||
user_version = get_dashboard_dist_version(user_dist)
|
||||
bundled_dist = get_bundled_dashboard_dist_path()
|
||||
if not user_dist.exists() or not is_dashboard_dist_compatible(
|
||||
bundled_dist,
|
||||
current_version,
|
||||
):
|
||||
return False
|
||||
if user_version is None or not (user_dist / "index.html").is_file():
|
||||
return True
|
||||
try:
|
||||
return not is_dashboard_version_compatible(user_version, current_version)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
async def get_dashboard_version():
|
||||
"""Return the effective WebUI version for the current runtime.
|
||||
|
||||
Returns:
|
||||
The matching data/dist version, matching bundled version, or the raw
|
||||
data/dist version when no compatible bundled WebUI is available.
|
||||
"""
|
||||
|
||||
from astrbot.core.config.default import VERSION
|
||||
|
||||
# First check user data directory (manually updated / downloaded dashboard).
|
||||
dist_dir = os.path.join(get_astrbot_data_path(), "dist")
|
||||
if os.path.exists(dist_dir):
|
||||
from astrbot.core.config.default import VERSION
|
||||
user_version = get_dashboard_dist_version(dist_dir)
|
||||
if is_dashboard_dist_compatible(dist_dir, VERSION):
|
||||
return user_version
|
||||
|
||||
if should_use_bundled_dashboard_dist(dist_dir, VERSION):
|
||||
bundled_version = _read_dashboard_dist_version(
|
||||
get_bundled_dashboard_dist_path()
|
||||
)
|
||||
if bundled_version is not None:
|
||||
return bundled_version
|
||||
return _read_dashboard_dist_version(dist_dir)
|
||||
bundled = get_bundled_dashboard_dist_path()
|
||||
if is_dashboard_dist_compatible(bundled, VERSION):
|
||||
return get_dashboard_dist_version(bundled)
|
||||
return user_version
|
||||
|
||||
bundled = get_bundled_dashboard_dist_path()
|
||||
if bundled.exists():
|
||||
return _read_dashboard_dist_version(bundled)
|
||||
if is_dashboard_dist_compatible(bundled, VERSION):
|
||||
return get_dashboard_dist_version(bundled)
|
||||
return None
|
||||
|
||||
|
||||
@@ -427,6 +519,7 @@ async def download_dashboard(
|
||||
proxy: str | None = None,
|
||||
progress_callback=None,
|
||||
extract: bool = True,
|
||||
allow_insecure_ssl_fallback: bool = True,
|
||||
) -> None:
|
||||
"""Download dashboard assets and optionally extract them.
|
||||
|
||||
@@ -438,6 +531,8 @@ async def download_dashboard(
|
||||
proxy: Optional download proxy prefix.
|
||||
progress_callback: Optional callback for download progress payloads.
|
||||
extract: Whether to extract the archive after download.
|
||||
allow_insecure_ssl_fallback: Whether certificate failures may retry with
|
||||
TLS certificate verification disabled.
|
||||
|
||||
Returns:
|
||||
None.
|
||||
@@ -460,6 +555,7 @@ async def download_dashboard(
|
||||
str(zip_path),
|
||||
show_progress=True,
|
||||
progress_callback=progress_callback,
|
||||
allow_insecure_ssl_fallback=allow_insecure_ssl_fallback,
|
||||
)
|
||||
if not zipfile.is_zipfile(zip_path):
|
||||
raise RuntimeError(
|
||||
@@ -491,6 +587,7 @@ async def download_dashboard(
|
||||
str(zip_path),
|
||||
show_progress=True,
|
||||
progress_callback=progress_callback,
|
||||
allow_insecure_ssl_fallback=allow_insecure_ssl_fallback,
|
||||
)
|
||||
if not zipfile.is_zipfile(zip_path):
|
||||
raise RuntimeError(
|
||||
@@ -506,6 +603,7 @@ async def download_dashboard(
|
||||
str(zip_path),
|
||||
show_progress=True,
|
||||
progress_callback=progress_callback,
|
||||
allow_insecure_ssl_fallback=allow_insecure_ssl_fallback,
|
||||
)
|
||||
if not zipfile.is_zipfile(zip_path):
|
||||
raise RuntimeError("Downloaded dashboard package is not a valid ZIP file")
|
||||
|
||||
144
astrbot/core/utils/toml_parser.py
Normal file
144
astrbot/core/utils/toml_parser.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Small TOML readers for bootstrapping paths without parser dependencies."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _read_quoted_value(value: str, field_name: str) -> tuple[str, str]:
|
||||
"""Read one quoted TOML string value and return its tail.
|
||||
|
||||
Args:
|
||||
value: Raw value text that starts with a quoted string.
|
||||
field_name: Field name used in error messages.
|
||||
|
||||
Returns:
|
||||
A tuple containing the unquoted string and the remaining text.
|
||||
|
||||
Raises:
|
||||
ValueError: The value is not a supported quoted string.
|
||||
"""
|
||||
value = value.strip()
|
||||
if len(value) < 2 or value[0] not in ("'", '"'):
|
||||
raise ValueError(f"Unsupported {field_name} value")
|
||||
|
||||
quote = value[0]
|
||||
end_index = value.find(quote, 1)
|
||||
if end_index == -1:
|
||||
raise ValueError(f"Unterminated {field_name} string")
|
||||
|
||||
result = value[1:end_index]
|
||||
if not result:
|
||||
raise ValueError(f"Empty {field_name} value")
|
||||
return result, value[end_index + 1 :].strip()
|
||||
|
||||
|
||||
def _read_dependency_array(raw_value: str) -> list[str]:
|
||||
"""Read a simple inline TOML string array.
|
||||
|
||||
Args:
|
||||
raw_value: Raw dependency array text, including the surrounding brackets.
|
||||
|
||||
Returns:
|
||||
Parsed dependency strings.
|
||||
|
||||
Raises:
|
||||
ValueError: The array is missing brackets or contains unsupported entries.
|
||||
"""
|
||||
value = raw_value.strip()
|
||||
if not value.startswith("["):
|
||||
raise ValueError("Unsupported project.dependencies value")
|
||||
|
||||
dependencies = []
|
||||
value = value[1:].strip()
|
||||
while value:
|
||||
if value.startswith("]"):
|
||||
tail = value[1:].strip()
|
||||
if tail and not tail.startswith("#"):
|
||||
raise ValueError("Unsupported content after project.dependencies")
|
||||
return dependencies
|
||||
|
||||
dependency, tail = _read_quoted_value(value, "project.dependencies entry")
|
||||
dependencies.append(dependency)
|
||||
|
||||
if tail.startswith(","):
|
||||
value = tail[1:].strip()
|
||||
continue
|
||||
if tail.startswith("]"):
|
||||
value = tail
|
||||
continue
|
||||
if tail:
|
||||
raise ValueError("Unsupported content after project.dependencies entry")
|
||||
raise ValueError("Unterminated project.dependencies array")
|
||||
|
||||
raise ValueError("Unterminated project.dependencies array")
|
||||
|
||||
|
||||
def read_pyproject_project_dependencies(pyproject_path: Path) -> list[str]:
|
||||
"""Read project dependencies from a pyproject.toml file.
|
||||
|
||||
Args:
|
||||
pyproject_path: Path to the pyproject.toml file.
|
||||
|
||||
Returns:
|
||||
The values in the project.dependencies array.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: The pyproject.toml file does not exist.
|
||||
ValueError: The project.dependencies field is missing or unsupported.
|
||||
"""
|
||||
dependencies = []
|
||||
in_project_section = False
|
||||
in_dependencies_array = 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 in_dependencies_array:
|
||||
if line.startswith("]"):
|
||||
tail = line[1:].strip()
|
||||
if tail and not tail.startswith("#"):
|
||||
raise ValueError("Unsupported content after project.dependencies")
|
||||
return dependencies
|
||||
|
||||
dependency, tail = _read_quoted_value(
|
||||
line,
|
||||
"project.dependencies entry",
|
||||
)
|
||||
if tail.startswith(","):
|
||||
tail = tail[1:].strip()
|
||||
if tail.startswith("]"):
|
||||
tail = tail[1:].strip()
|
||||
dependencies.append(dependency)
|
||||
if tail and not tail.startswith("#"):
|
||||
raise ValueError("Unsupported content after project.dependencies")
|
||||
return dependencies
|
||||
if tail and not tail.startswith("#"):
|
||||
raise ValueError("Unsupported content after project.dependencies entry")
|
||||
|
||||
dependencies.append(dependency)
|
||||
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() != "dependencies":
|
||||
continue
|
||||
if not separator:
|
||||
raise ValueError("Unsupported project.dependencies value")
|
||||
raw_value = raw_value.strip()
|
||||
if raw_value == "[" or raw_value.startswith("[ #"):
|
||||
in_dependencies_array = True
|
||||
continue
|
||||
if raw_value.startswith("["):
|
||||
return _read_dependency_array(raw_value)
|
||||
raise ValueError("Unsupported project.dependencies value")
|
||||
|
||||
if in_dependencies_array:
|
||||
raise ValueError("Unterminated project.dependencies array")
|
||||
raise ValueError("Missing project.dependencies")
|
||||
@@ -22,7 +22,9 @@ from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.io import (
|
||||
get_bundled_dashboard_dist_path,
|
||||
get_dashboard_dist_version,
|
||||
get_local_ip_addresses,
|
||||
is_dashboard_dist_compatible,
|
||||
should_use_bundled_dashboard_dist,
|
||||
)
|
||||
from astrbot.dashboard.asgi_runtime import (
|
||||
@@ -182,21 +184,41 @@ class AstrBotDashboard:
|
||||
|
||||
# Path priority:
|
||||
# 1. Explicit webui_dir argument
|
||||
# 2. data/dist/ (user-installed / manually updated dashboard)
|
||||
# 3. astrbot/dashboard/dist/ (bundled with the wheel)
|
||||
# 2. data/dist/ when it matches the core version
|
||||
# 3. astrbot/dashboard/dist/ when it matches the core version
|
||||
if webui_dir and os.path.exists(webui_dir):
|
||||
self.data_path = os.path.abspath(webui_dir)
|
||||
else:
|
||||
user_dist = os.path.join(get_astrbot_data_path(), "dist")
|
||||
bundled_dist = get_bundled_dashboard_dist_path()
|
||||
if os.path.exists(user_dist) and not should_use_bundled_dashboard_dist(
|
||||
user_version = get_dashboard_dist_version(user_dist)
|
||||
if os.path.exists(user_dist) and is_dashboard_dist_compatible(
|
||||
user_dist,
|
||||
VERSION,
|
||||
):
|
||||
self.data_path = os.path.abspath(user_dist)
|
||||
elif bundled_dist.exists():
|
||||
elif should_use_bundled_dashboard_dist(
|
||||
user_dist,
|
||||
VERSION,
|
||||
) 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 files are incomplete for core v%s.",
|
||||
VERSION,
|
||||
)
|
||||
self.data_path = None
|
||||
else:
|
||||
# Fall back to expected user path (will fail gracefully later)
|
||||
self.data_path = os.path.abspath(user_dist)
|
||||
@@ -545,7 +567,7 @@ class AstrBotDashboard:
|
||||
|
||||
raise Exception(f"端口 {port} 已被占用")
|
||||
|
||||
if (Path(self.data_path) / "index.html").is_file():
|
||||
if self.data_path and (Path(self.data_path) / "index.html").is_file():
|
||||
webui_status = "WebUI is ready"
|
||||
else:
|
||||
webui_status = (
|
||||
|
||||
22
changelogs/v4.26.0-beta.10.md
Normal file
22
changelogs/v4.26.0-beta.10.md
Normal 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))
|
||||
10
changelogs/v4.26.0-beta.11.md
Normal file
10
changelogs/v4.26.0-beta.11.md
Normal 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)
|
||||
|
||||
54
changelogs/v4.26.0-beta.9.md
Normal file
54
changelogs/v4.26.0-beta.9.md
Normal file
@@ -0,0 +1,54 @@
|
||||
- [更新日志(简体中文)](#chinese)
|
||||
- [Changelog(English)](#english)
|
||||
|
||||
<a id="chinese"></a>
|
||||
|
||||
## What's Changed
|
||||
|
||||
### 重点更新
|
||||
|
||||
- 为 OpenAI、Gemini、Anthropic 等模型请求加入可配置的重试机制,并新增请求最大重试次数配置,提升临时网络错误与 5xx 服务端错误下的稳定性。([#8893](https://github.com/AstrBotDevs/AstrBot/pull/8893))
|
||||
- 新增托管 Core 包下载能力,并加强 Core 与 Dashboard 包下载归档校验。([#8888](https://github.com/AstrBotDevs/AstrBot/pull/8888))
|
||||
- 支持在请求中加载 workspace skills,并加固 workspace skill 发现流程。([#8884](https://github.com/AstrBotDevs/AstrBot/pull/8884))
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 OpenAPI 文件上传能力,恢复 `/api/v1/file` OpenAPI 暴露、文件范围 API Key 与相关文档/客户端产物。
|
||||
- 修复新版 MCP 中 Streamable HTTP client 重命名导致的兼容问题,并保持 `mcp` 依赖小于 2。
|
||||
- 加固人格工具边界,确保人格限定的工具范围在主 Agent 请求中正确生效。([#8786](https://github.com/AstrBotDevs/AstrBot/pull/8786))
|
||||
- 加强 Future Task 所有者校验,避免越权访问定时任务。([#8881](https://github.com/AstrBotDevs/AstrBot/pull/8881))
|
||||
- 在受限本地文件系统工具中拒绝 hardlink 文件,避免通过工作区 hardlink 别名读写允许目录外的文件。
|
||||
|
||||
### 发布流程
|
||||
|
||||
- 新增 `scripts/prepare_release.py`,统一 release 分支、版本号、changelog 与校验流程。([#8891](https://github.com/AstrBotDevs/AstrBot/pull/8891))
|
||||
|
||||
### 文档
|
||||
|
||||
- 明确 OpenAPI Chat 中 `username` 字段的身份含义。([#8880](https://github.com/AstrBotDevs/AstrBot/pull/8880))
|
||||
|
||||
<a id="english"></a>
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### Highlights
|
||||
|
||||
- Added configurable retry handling for OpenAI, Gemini, Anthropic, and related provider requests, including a maximum request retry setting to improve stability for transient network failures and 5xx server errors. ([#8893](https://github.com/AstrBotDevs/AstrBot/pull/8893))
|
||||
- Added hosted Core package downloads and strengthened archive validation for hosted Core and Dashboard packages. ([#8888](https://github.com/AstrBotDevs/AstrBot/pull/8888))
|
||||
- Added workspace skills support in requests and hardened workspace skill discovery. ([#8884](https://github.com/AstrBotDevs/AstrBot/pull/8884))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Restored OpenAPI file uploads by exposing `/api/v1/file`, enabling file-scoped API keys, and regenerating docs/client artifacts.
|
||||
- Fixed compatibility with the renamed MCP Streamable HTTP client while keeping the `mcp` dependency below 2.
|
||||
- Hardened persona tool boundaries so persona-restricted tool scopes are enforced correctly in main Agent requests. ([#8786](https://github.com/AstrBotDevs/AstrBot/pull/8786))
|
||||
- Enforced Future Task owner checks to prevent unauthorized scheduled-task access. ([#8881](https://github.com/AstrBotDevs/AstrBot/pull/8881))
|
||||
- Rejected hardlinked files in restricted local filesystem tools to prevent workspace hardlink aliases from reading or overwriting files outside allowed directories.
|
||||
|
||||
### Release Process
|
||||
|
||||
- Added `scripts/prepare_release.py` to standardize release branches, version bumps, changelog generation, and validation. ([#8891](https://github.com/AstrBotDevs/AstrBot/pull/8891))
|
||||
|
||||
### Docs
|
||||
|
||||
- Clarified the identity semantics of the `username` field in OpenAPI Chat. ([#8880](https://github.com/AstrBotDevs/AstrBot/pull/8880))
|
||||
@@ -48,6 +48,55 @@ function attachAxiosHeaders(config: InternalAxiosRequestConfig) {
|
||||
}
|
||||
|
||||
function normalizeAxiosError(error: AxiosError) {
|
||||
if (error.response?.status === 401) {
|
||||
let requestPath = '';
|
||||
try {
|
||||
const url = error.config?.url || '';
|
||||
const baseURL = error.config?.baseURL;
|
||||
const resolvedUrl =
|
||||
url && baseURL && !/^([a-z][a-z\d+\-.]*:)?\/\//i.test(url)
|
||||
? `${baseURL.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}`
|
||||
: url;
|
||||
const requestUrl = new URL(resolvedUrl || '/', window.location.origin);
|
||||
if (requestUrl.origin === window.location.origin) {
|
||||
requestPath = requestUrl.pathname;
|
||||
}
|
||||
} catch {
|
||||
requestPath = '';
|
||||
}
|
||||
|
||||
const isAuthChallenge =
|
||||
[
|
||||
'/api/auth/login',
|
||||
'/api/auth/setup',
|
||||
'/api/auth/setup-status',
|
||||
'/api/v1/auth/login',
|
||||
'/api/v1/auth/setup',
|
||||
'/api/v1/auth/setup-status',
|
||||
].includes(requestPath) ||
|
||||
Boolean(
|
||||
(
|
||||
error.response.data as
|
||||
| { data?: { totp_required?: boolean } }
|
||||
| undefined
|
||||
)?.data?.totp_required,
|
||||
);
|
||||
|
||||
if (requestPath.startsWith('/api/') && !isAuthChallenge) {
|
||||
[
|
||||
'user',
|
||||
'token',
|
||||
'change_pwd_hint',
|
||||
'md5_pwd_hint',
|
||||
'password_upgrade_required',
|
||||
].forEach((key) => localStorage.removeItem(key));
|
||||
|
||||
if (!window.location.hash.startsWith('#/auth/login')) {
|
||||
window.location.hash = '/auth/login';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error.response?.status === 429) {
|
||||
const data = error.response.data as { message?: string } | undefined;
|
||||
if (data?.message) {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
"description": "Fallback chat model IDs",
|
||||
"hint": "When the primary chat model request fails, fallback to these chat models in order."
|
||||
},
|
||||
"request_max_retries": {
|
||||
"description": "Request Max Retries",
|
||||
"hint": "Maximum attempts for a single model request when retryable errors occur."
|
||||
},
|
||||
"default_image_caption_provider_id": {
|
||||
"description": "Default Image Caption Model",
|
||||
"hint": "Leave empty to disable; useful for non-multimodal models"
|
||||
|
||||
@@ -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": "Позже",
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
"release": "😊 Релиз"
|
||||
},
|
||||
"advancedSettings": "Расширенные настройки",
|
||||
"releases": "Релизы",
|
||||
"updateToLatest": "Обновить до последней версии",
|
||||
"showPreReleases": "Показывать предварительные версии",
|
||||
"preRelease": "Предварительная версия",
|
||||
"preReleaseWarning": {
|
||||
"title": "Внимание: предварительная версия",
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
"description": "Резервные модели чата (ID)",
|
||||
"hint": "Если текущая модель недоступна, запрос будет перенаправлен на эти модели по порядку."
|
||||
},
|
||||
"request_max_retries": {
|
||||
"description": "Максимум повторов запроса",
|
||||
"hint": "Максимальное число попыток для одного запроса модели при повторяемых ошибках."
|
||||
},
|
||||
"default_image_caption_provider_id": {
|
||||
"description": "Модель описания изображений",
|
||||
"hint": "Оставьте пустым для отключения; полезно для моделей без поддержки мультимодальности"
|
||||
|
||||
@@ -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": "稍后处理",
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
"release": "😊 正式版"
|
||||
},
|
||||
"advancedSettings": "高级设置",
|
||||
"releases": "版本列表",
|
||||
"updateToLatest": "更新到最新版本",
|
||||
"showPreReleases": "显示预发布版本",
|
||||
"preRelease": "预发布",
|
||||
"preReleaseWarning": {
|
||||
"title": "预发布版本提醒",
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
"description": "回退对话模型列表",
|
||||
"hint": "主对话模型请求失败时,按顺序切换到这些对话模型。"
|
||||
},
|
||||
"request_max_retries": {
|
||||
"description": "请求最大重试次数",
|
||||
"hint": "单次模型请求遇到可重试错误时的最大尝试次数。"
|
||||
},
|
||||
"default_image_caption_provider_id": {
|
||||
"description": "默认图片转述模型",
|
||||
"hint": "留空代表不使用,可用于非多模态模型"
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
@@ -883,6 +894,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 +1472,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 +1520,7 @@ onMounted(async () => {
|
||||
|
||||
<v-data-table
|
||||
:headers="releasesHeader"
|
||||
:items="releases"
|
||||
:items="visibleReleases"
|
||||
item-key="name"
|
||||
:items-per-page="6"
|
||||
density="comfortable"
|
||||
@@ -1913,6 +1944,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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`.
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 (人满)
|
||||
- 3 群:630166526 (人满)
|
||||
- 4 群:1077826412 (人满)
|
||||
- 5 群:822130018 (人满)
|
||||
- 6 群:753075035 (人满)
|
||||
- 7 群:743746109 (人满)
|
||||
- 8 群:1030353265 (人满)
|
||||
- 9 群:1076659624 (人满)
|
||||
- 10 群:1078079676 (人满)
|
||||
- 11 群:704659519 (人满)
|
||||
- 12 群:916228568 (人满)
|
||||
- 13 群:1092185289
|
||||
- 14 群:1103419483
|
||||
- **AstrBot 核心开发交流群: 975206796**(AstrBot 开发成员通常活跃于此,欢迎任何对编程/AI 技术感兴趣的同学加入~)
|
||||
|
||||
### Discord
|
||||
|
||||
@@ -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 Search(MCP)。
|
||||
- `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`。
|
||||
|
||||
@@ -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` 按钮提交,即可完成插件发布。
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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)时中文乱码
|
||||
|
||||
|
||||
@@ -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 使用的 Provider(LLM / 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。
|
||||
|
||||
该指令需要管理员权限。
|
||||
|
||||
## 内置指令扩展
|
||||
|
||||
除上述基础指令外,其他原本随主程序提供的内置指令已经迁移到独立插件:
|
||||
|
||||
@@ -13,11 +13,11 @@ AstrBot 内置的网页搜索功能依赖大模型提供 `函数调用` 能力
|
||||
|
||||
等等带有搜索意味的提示让大模型触发调用搜索工具。
|
||||
|
||||
AstrBot 当前支持 4 种网页搜索源接入方式:`Tavily`、`BoCha`、`百度 AI 搜索`、`Brave`。
|
||||
AstrBot 当前支持 5 种网页搜索源接入方式:`Tavily`、`BoCha`、`百度 AI 搜索`、`Brave`、`Firecrawl`。
|
||||
|
||||

|
||||
|
||||
进入 `配置`,下拉找到网页搜索,您可选择 `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 上将会获得更好的体验优化,包括引用来源展示等:
|
||||
|
||||

|
||||
|
||||
@@ -119,3 +119,6 @@ ChatUI 支持以下常用能力:
|
||||
## 忘记密码
|
||||
|
||||
修改 data/cmd_config.json 文件内 `dashboard` 配置中的 `password`,将 password 整个键值对删除。
|
||||
|
||||
> [!TIP]
|
||||
> 详细说明请参阅 [FAQ - 管理面板的密码忘记了](/faq.md#管理面板的密码忘记了)。
|
||||
|
||||
97
main.py
97
main.py
@@ -2,6 +2,7 @@ import argparse
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -46,7 +47,10 @@ from astrbot.core.utils.astrbot_path import ( # noqa: E402
|
||||
from astrbot.core.utils.io import ( # noqa: E402
|
||||
download_dashboard,
|
||||
get_bundled_dashboard_dist_path,
|
||||
get_dashboard_version,
|
||||
get_dashboard_dist_version,
|
||||
is_dashboard_dist_compatible,
|
||||
is_dashboard_version_compatible,
|
||||
remove_dir,
|
||||
should_use_bundled_dashboard_dist,
|
||||
)
|
||||
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime # noqa: E402
|
||||
@@ -91,7 +95,15 @@ def check_env() -> None:
|
||||
|
||||
|
||||
async def check_dashboard_files(webui_dir: str | None = None):
|
||||
"""下载管理面板文件"""
|
||||
"""Resolve and repair dashboard static files for startup.
|
||||
|
||||
Args:
|
||||
webui_dir: Optional explicit WebUI directory path from CLI.
|
||||
|
||||
Returns:
|
||||
The directory path to serve, or None when no usable WebUI can be prepared.
|
||||
"""
|
||||
|
||||
# 指定webui目录
|
||||
if webui_dir:
|
||||
if os.path.exists(webui_dir):
|
||||
@@ -99,40 +111,89 @@ async def check_dashboard_files(webui_dir: str | None = None):
|
||||
return webui_dir
|
||||
logger.warning("WebUI directory not found: %s. Using default.", webui_dir)
|
||||
|
||||
data_dist_path = os.path.join(get_astrbot_data_path(), "dist")
|
||||
if os.path.exists(data_dist_path):
|
||||
v = await get_dashboard_version()
|
||||
data_dist_path = Path(get_astrbot_data_path()) / "dist"
|
||||
bundled_dist = get_bundled_dashboard_dist_path()
|
||||
if data_dist_path.exists():
|
||||
v = get_dashboard_dist_version(data_dist_path)
|
||||
if is_dashboard_dist_compatible(data_dist_path, VERSION):
|
||||
logger.info("WebUI is up to date.")
|
||||
return str(data_dist_path)
|
||||
|
||||
if should_use_bundled_dashboard_dist(data_dist_path, VERSION):
|
||||
bundled_dist = get_bundled_dashboard_dist_path()
|
||||
logger.info(
|
||||
"Using bundled WebUI because data/dist is older than core version v%s.",
|
||||
"Replacing data/dist with bundled WebUI because its version does not match core version v%s.",
|
||||
VERSION,
|
||||
)
|
||||
return str(bundled_dist)
|
||||
if v is not None:
|
||||
# 存在文件
|
||||
if v == f"v{VERSION}":
|
||||
logger.info("WebUI is up to date.")
|
||||
else:
|
||||
try:
|
||||
remove_dir(str(data_dist_path))
|
||||
shutil.copytree(bundled_dist, data_dist_path)
|
||||
return str(data_dist_path)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"WebUI version mismatch: %s, expected v%s.",
|
||||
v,
|
||||
"Failed to replace data/dist with bundled WebUI: %s. Using bundled WebUI directly.",
|
||||
e,
|
||||
)
|
||||
return str(bundled_dist)
|
||||
|
||||
if is_dashboard_version_compatible(v, VERSION):
|
||||
logger.warning(
|
||||
"WebUI files are incomplete for v%s. Re-downloading WebUI.",
|
||||
VERSION,
|
||||
)
|
||||
elif v is not None:
|
||||
logger.warning(
|
||||
"WebUI version mismatch: %s, expected v%s. Re-downloading WebUI.",
|
||||
v,
|
||||
VERSION,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"WebUI version file is missing. Re-downloading WebUI v%s.",
|
||||
VERSION,
|
||||
)
|
||||
|
||||
try:
|
||||
await download_dashboard(
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
allow_insecure_ssl_fallback=False,
|
||||
)
|
||||
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 data_dist_path
|
||||
return str(data_dist_path)
|
||||
return None
|
||||
logger.info("管理面板下载完成。")
|
||||
return str(data_dist_path)
|
||||
|
||||
if is_dashboard_dist_compatible(bundled_dist, VERSION):
|
||||
logger.info(
|
||||
"Using bundled WebUI v%s.", get_dashboard_dist_version(bundled_dist)
|
||||
)
|
||||
return str(bundled_dist)
|
||||
|
||||
logger.info(
|
||||
"Downloading WebUI. If it fails, download dist.zip from https://github.com/AstrBotDevs/AstrBot/releases/latest and extract dist to data/.",
|
||||
)
|
||||
|
||||
try:
|
||||
await download_dashboard(version=f"v{VERSION}", latest=False)
|
||||
await download_dashboard(
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
allow_insecure_ssl_fallback=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.critical(f"下载管理面板文件失败: {e}。")
|
||||
return None
|
||||
|
||||
logger.info("管理面板下载完成。")
|
||||
return data_dist_path
|
||||
return str(data_dist_path)
|
||||
|
||||
|
||||
async def main_async(webui_dir_arg: str | None) -> None:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.26.0-beta.8"
|
||||
version = "4.26.0-beta.11"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
@@ -29,7 +29,7 @@ dependencies = [
|
||||
"google-genai>=1.56.0",
|
||||
"httpx[socks]>=0.28.1",
|
||||
"lark-oapi>=1.4.15",
|
||||
"mcp>=1.8.0",
|
||||
"mcp>=1.8.0,<2",
|
||||
"openai>=1.78.0",
|
||||
"ormsgpack>=1.9.1",
|
||||
"pillow>=11.2.1",
|
||||
|
||||
@@ -18,7 +18,7 @@ filelock>=3.18.0
|
||||
google-genai>=1.56.0
|
||||
httpx[socks]>=0.28.1
|
||||
lark-oapi>=1.4.15
|
||||
mcp>=1.8.0
|
||||
mcp>=1.8.0,<2
|
||||
openai>=1.78.0
|
||||
ormsgpack>=1.9.1
|
||||
pillow>=11.2.1
|
||||
|
||||
472
scripts/prepare_release.py
Normal file
472
scripts/prepare_release.py
Normal file
@@ -0,0 +1,472 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare an AstrBot release branch and release metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+(?:[-+._a-zA-Z0-9]+)?$")
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
"""Error raised when a release preparation step cannot continue."""
|
||||
|
||||
|
||||
def run_command(
|
||||
args: list[str],
|
||||
*,
|
||||
cwd: Path = REPO_ROOT,
|
||||
capture_output: bool = False,
|
||||
) -> str:
|
||||
"""Run a command and return captured stdout when requested.
|
||||
|
||||
Args:
|
||||
args: Command and arguments to run.
|
||||
cwd: Working directory for the command.
|
||||
capture_output: Whether to capture and return stdout instead of streaming it.
|
||||
|
||||
Returns:
|
||||
Captured stdout without surrounding whitespace when capture_output is true;
|
||||
otherwise an empty string.
|
||||
|
||||
Raises:
|
||||
ReleaseError: The command is missing or exits with a non-zero status.
|
||||
"""
|
||||
printable = " ".join(args)
|
||||
print(f"$ {printable}")
|
||||
try:
|
||||
if capture_output:
|
||||
result = subprocess.run(
|
||||
args,
|
||||
cwd=cwd,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
subprocess.run(args, cwd=cwd, check=True)
|
||||
return ""
|
||||
except FileNotFoundError as exc:
|
||||
raise ReleaseError(f"Command not found: {args[0]}") from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
if capture_output and exc.stderr:
|
||||
print(exc.stderr.strip(), file=sys.stderr)
|
||||
raise ReleaseError(f"Command failed ({exc.returncode}): {printable}") from exc
|
||||
|
||||
|
||||
def git(args: list[str], *, capture_output: bool = False) -> str:
|
||||
"""Run a git command in the repository root.
|
||||
|
||||
Args:
|
||||
args: Arguments to pass after `git`.
|
||||
capture_output: Whether to capture and return stdout.
|
||||
|
||||
Returns:
|
||||
Captured stdout when capture_output is true; otherwise an empty string.
|
||||
|
||||
Raises:
|
||||
ReleaseError: Git exits with a non-zero status.
|
||||
"""
|
||||
return run_command(["git", *args], capture_output=capture_output)
|
||||
|
||||
|
||||
def ensure_clean_worktree() -> None:
|
||||
"""Ensure the release starts from a clean worktree.
|
||||
|
||||
Raises:
|
||||
ReleaseError: The repository contains tracked or untracked changes.
|
||||
"""
|
||||
status = git(["status", "--porcelain"], capture_output=True)
|
||||
if status:
|
||||
raise ReleaseError(
|
||||
"Working tree must be clean before preparing a release.\n"
|
||||
"Commit, stash, or remove these changes first:\n"
|
||||
f"{status}"
|
||||
)
|
||||
|
||||
|
||||
def validate_version(version: str) -> str:
|
||||
"""Validate a release version string.
|
||||
|
||||
Args:
|
||||
version: Version string without the leading tag prefix.
|
||||
|
||||
Returns:
|
||||
The validated version string.
|
||||
|
||||
Raises:
|
||||
ReleaseError: The version is empty, starts with `v`, or has an unsupported
|
||||
shape.
|
||||
"""
|
||||
if version.startswith("v"):
|
||||
raise ReleaseError(
|
||||
"Pass the version without the tag prefix, for example 4.25.0"
|
||||
)
|
||||
if not VERSION_PATTERN.fullmatch(version):
|
||||
raise ReleaseError(
|
||||
"Unsupported version format. Expected a value like 4.25.0 or 4.26.0-beta.8"
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
def latest_tag() -> str:
|
||||
"""Return the most recent reachable tag, if one exists.
|
||||
|
||||
Returns:
|
||||
The latest tag name, or an empty string when the repository has no tags.
|
||||
"""
|
||||
try:
|
||||
return git(["describe", "--tags", "--abbrev=0"], capture_output=True)
|
||||
except ReleaseError:
|
||||
return ""
|
||||
|
||||
|
||||
def release_commits(tag: str) -> list[str]:
|
||||
"""Read commit subjects for the release range.
|
||||
|
||||
Args:
|
||||
tag: Latest tag to use as the lower bound. When empty, all reachable
|
||||
commits are considered.
|
||||
|
||||
Returns:
|
||||
Commit subjects formatted for changelog draft entries.
|
||||
|
||||
Raises:
|
||||
ReleaseError: Git log fails.
|
||||
"""
|
||||
log_range = f"{tag}..HEAD" if tag else "HEAD"
|
||||
output = git(
|
||||
["log", "--reverse", "--pretty=format:%s (%h)", log_range],
|
||||
capture_output=True,
|
||||
)
|
||||
return [line for line in output.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def update_pyproject_version(version: str) -> Path:
|
||||
"""Update `[project].version` in pyproject.toml.
|
||||
|
||||
Args:
|
||||
version: Release version to write.
|
||||
|
||||
Returns:
|
||||
Path to the modified pyproject.toml file.
|
||||
|
||||
Raises:
|
||||
ReleaseError: The project version field cannot be found or parsed.
|
||||
"""
|
||||
pyproject_path = REPO_ROOT / "pyproject.toml"
|
||||
lines = pyproject_path.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
in_project_section = False
|
||||
|
||||
for index, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("[") and stripped.endswith("]"):
|
||||
in_project_section = stripped == "[project]"
|
||||
continue
|
||||
if not in_project_section:
|
||||
continue
|
||||
|
||||
key, separator, _raw_value = stripped.partition("=")
|
||||
if key.strip() != "version":
|
||||
continue
|
||||
if not separator:
|
||||
raise ReleaseError("Unsupported pyproject.toml project.version format")
|
||||
|
||||
match = re.match(
|
||||
r"^(\s*version\s*=\s*)([\"'])(.*?)(\2)(\s*(?:#.*)?)(\n?)$",
|
||||
line,
|
||||
)
|
||||
if not match:
|
||||
raise ReleaseError("Unsupported pyproject.toml project.version format")
|
||||
|
||||
prefix, quote, _current, _closing_quote, suffix, newline = match.groups()
|
||||
lines[index] = f"{prefix}{quote}{version}{quote}{suffix}{newline}"
|
||||
pyproject_path.write_text("".join(lines), encoding="utf-8")
|
||||
return pyproject_path
|
||||
|
||||
raise ReleaseError("Missing [project].version in pyproject.toml")
|
||||
|
||||
|
||||
def update_default_config_version(version: str) -> Path:
|
||||
"""Update the hard-coded runtime version in default.py.
|
||||
|
||||
Args:
|
||||
version: Release version to write.
|
||||
|
||||
Returns:
|
||||
Path to the modified default.py file.
|
||||
|
||||
Raises:
|
||||
ReleaseError: The runtime version constant cannot be found or parsed.
|
||||
"""
|
||||
default_config_path = REPO_ROOT / "astrbot" / "core" / "config" / "default.py"
|
||||
lines = default_config_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}"
|
||||
default_config_path.write_text("".join(lines), encoding="utf-8")
|
||||
return default_config_path
|
||||
|
||||
raise ReleaseError("Missing VERSION in astrbot/core/config/default.py")
|
||||
|
||||
|
||||
def write_changelog(version: str, commits: list[str]) -> Path:
|
||||
"""Write a changelog draft for the release.
|
||||
|
||||
Args:
|
||||
version: Release version without the leading `v`.
|
||||
commits: Commit subject lines to include as the first changelog draft.
|
||||
|
||||
Returns:
|
||||
Path to the created changelog file.
|
||||
|
||||
Raises:
|
||||
ReleaseError: The changelog file already exists.
|
||||
"""
|
||||
changelog_path = REPO_ROOT / "changelogs" / f"v{version}.md"
|
||||
if changelog_path.exists():
|
||||
raise ReleaseError(f"Changelog already exists: {changelog_path}")
|
||||
|
||||
changelog_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
entries = [f"- {commit}" for commit in commits] or ["- "]
|
||||
changelog_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"## What's Changed",
|
||||
"",
|
||||
"<!-- Review, group, and polish these entries before publishing. -->",
|
||||
"",
|
||||
*entries,
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return changelog_path
|
||||
|
||||
|
||||
def create_release_branch(version: str, base_branch: str, remote: str) -> str:
|
||||
"""Create a release branch from the updated base branch.
|
||||
|
||||
Args:
|
||||
version: Release version without the leading `v`.
|
||||
base_branch: Base branch to release from.
|
||||
remote: Remote name used for fetching and fast-forward pulls.
|
||||
|
||||
Returns:
|
||||
Created release branch name.
|
||||
|
||||
Raises:
|
||||
ReleaseError: The branch already exists or Git cannot create it.
|
||||
"""
|
||||
branch = f"release/{version}"
|
||||
git(["checkout", base_branch])
|
||||
git(["pull", "--ff-only", remote, base_branch])
|
||||
git(["fetch", "--tags", remote])
|
||||
|
||||
local_branch = git(["branch", "--list", branch], capture_output=True)
|
||||
if local_branch:
|
||||
raise ReleaseError(f"Local branch already exists: {branch}")
|
||||
|
||||
remote_branch = git(["ls-remote", "--heads", remote, branch], capture_output=True)
|
||||
if remote_branch:
|
||||
raise ReleaseError(f"Remote branch already exists: {remote}/{branch}")
|
||||
|
||||
git(["switch", "-c", branch])
|
||||
return branch
|
||||
|
||||
|
||||
def run_validation(args: argparse.Namespace) -> None:
|
||||
"""Run release validation commands selected by CLI flags.
|
||||
|
||||
Args:
|
||||
args: Parsed CLI arguments.
|
||||
|
||||
Raises:
|
||||
ReleaseError: A validation command fails.
|
||||
"""
|
||||
if args.generate_api_client:
|
||||
run_command(["pnpm", "generate:api"], cwd=REPO_ROOT / "dashboard")
|
||||
|
||||
if not args.skip_checks:
|
||||
run_command(["uv", "run", "ruff", "format", "--check", "."])
|
||||
run_command(["uv", "run", "ruff", "check", "."])
|
||||
|
||||
if args.dashboard_build:
|
||||
run_command(["pnpm", "install"], cwd=REPO_ROOT / "dashboard")
|
||||
run_command(["pnpm", "build"], cwd=REPO_ROOT / "dashboard")
|
||||
|
||||
|
||||
def commit_and_maybe_push(
|
||||
version: str,
|
||||
branch: str,
|
||||
changelog_path: Path,
|
||||
args: argparse.Namespace,
|
||||
) -> None:
|
||||
"""Commit release preparation changes and optionally push the branch.
|
||||
|
||||
Args:
|
||||
version: Release version without the leading `v`.
|
||||
branch: Release branch name.
|
||||
changelog_path: Changelog file created for this release.
|
||||
args: Parsed CLI arguments.
|
||||
|
||||
Raises:
|
||||
ReleaseError: Git add, commit, or push fails.
|
||||
"""
|
||||
git(
|
||||
[
|
||||
"add",
|
||||
"pyproject.toml",
|
||||
"astrbot/core/config/default.py",
|
||||
str(changelog_path.relative_to(REPO_ROOT)),
|
||||
]
|
||||
)
|
||||
if args.generate_api_client:
|
||||
git(["add", "dashboard/src/api/generated"])
|
||||
|
||||
git(["commit", "-m", f"chore: bump version to {version}"])
|
||||
if args.push:
|
||||
git(["push", "-u", args.remote, branch])
|
||||
|
||||
|
||||
def print_next_steps(
|
||||
version: str,
|
||||
branch: str,
|
||||
changelog_path: Path,
|
||||
args: argparse.Namespace,
|
||||
) -> None:
|
||||
"""Print the manual steps that remain after preparation.
|
||||
|
||||
Args:
|
||||
version: Release version without the leading `v`.
|
||||
branch: Release branch name.
|
||||
changelog_path: Changelog file created for this release.
|
||||
args: Parsed CLI arguments.
|
||||
"""
|
||||
changelog_rel = changelog_path.relative_to(REPO_ROOT)
|
||||
print("\nRelease preparation complete.")
|
||||
print(f"Branch: {branch}")
|
||||
print(f"Changelog: {changelog_rel}")
|
||||
|
||||
if args.commit:
|
||||
if not args.push:
|
||||
print(f"Next: git push -u {args.remote} {branch}")
|
||||
else:
|
||||
print("Next:")
|
||||
print(f"1. Review and polish {changelog_rel}")
|
||||
print(
|
||||
f"2. git add pyproject.toml astrbot/core/config/default.py {changelog_rel}"
|
||||
)
|
||||
print(f'3. git commit -m "chore: bump version to {version}"')
|
||||
print(f"4. git push -u {args.remote} {branch}")
|
||||
|
||||
print(f"Open a PR from {branch} to {args.base_branch}.")
|
||||
print(
|
||||
"After the PR is merged, tag from the updated base branch with "
|
||||
f"`git tag v{version}` and `git push {args.remote} v{version}`."
|
||||
)
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
"""Parse command-line arguments.
|
||||
|
||||
Args:
|
||||
argv: Raw command-line arguments excluding the executable name.
|
||||
|
||||
Returns:
|
||||
Parsed CLI arguments.
|
||||
|
||||
Raises:
|
||||
ReleaseError: Push is requested without commit.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Prepare an AstrBot release branch, version bump, and changelog.",
|
||||
)
|
||||
parser.add_argument("version", help="Release version without the leading v")
|
||||
parser.add_argument("--base-branch", default="master", help="Release base branch")
|
||||
parser.add_argument("--remote", default="origin", help="Git remote name")
|
||||
parser.add_argument(
|
||||
"--generate-api-client",
|
||||
action="store_true",
|
||||
help="Run dashboard API client generation before validation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dashboard-build",
|
||||
action="store_true",
|
||||
help="Run dashboard install and build validation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-checks",
|
||||
action="store_true",
|
||||
help="Skip ruff format and ruff check",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Commit the generated release preparation changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--push",
|
||||
action="store_true",
|
||||
help="Push the release branch after committing; requires --commit",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if args.push and not args.commit:
|
||||
raise ReleaseError("--push requires --commit")
|
||||
return args
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Run the release preparation workflow.
|
||||
|
||||
Args:
|
||||
argv: Optional command-line arguments for tests or programmatic calls.
|
||||
|
||||
Returns:
|
||||
Process exit code.
|
||||
"""
|
||||
try:
|
||||
args = parse_args(sys.argv[1:] if argv is None else argv)
|
||||
version = validate_version(args.version)
|
||||
ensure_clean_worktree()
|
||||
|
||||
branch = create_release_branch(version, args.base_branch, args.remote)
|
||||
tag = latest_tag()
|
||||
if tag:
|
||||
print(f"Latest tag: {tag}")
|
||||
else:
|
||||
print("No existing tags found; changelog will use all reachable commits.")
|
||||
|
||||
commits = release_commits(tag)
|
||||
update_pyproject_version(version)
|
||||
update_default_config_version(version)
|
||||
changelog_path = write_changelog(version, commits)
|
||||
run_validation(args)
|
||||
|
||||
if args.commit:
|
||||
commit_and_maybe_push(version, branch, changelog_path, args)
|
||||
|
||||
print_next_steps(version, branch, changelog_path, args)
|
||||
return 0
|
||||
except ReleaseError as exc:
|
||||
print(f"prepare-release: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,9 +1,12 @@
|
||||
import builtins
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import astrbot.core.provider.sources.anthropic_source as anthropic_source
|
||||
import astrbot.core.provider.sources.kimi_code_source as kimi_code_source
|
||||
import astrbot.core.provider.sources.request_retry as request_retry
|
||||
from astrbot.core.exceptions import EmptyModelOutputError
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
|
||||
@@ -171,6 +174,36 @@ def test_create_http_client_falls_back_to_global_httpx_module(monkeypatch):
|
||||
assert captured["httpx_module"] is anthropic_source.httpx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_get_models_retries_transient_request_error(monkeypatch):
|
||||
monkeypatch.setattr(request_retry, "REQUEST_RETRY_WAIT_MIN_S", 0)
|
||||
monkeypatch.setattr(request_retry, "REQUEST_RETRY_WAIT_MAX_S", 0)
|
||||
|
||||
class FakeModels:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
async def list(self):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise httpx.ConnectError("temporary connection failure")
|
||||
return SimpleNamespace(
|
||||
data=[
|
||||
SimpleNamespace(id="claude-b"),
|
||||
SimpleNamespace(id="claude-a"),
|
||||
]
|
||||
)
|
||||
|
||||
models = FakeModels()
|
||||
provider = anthropic_source.ProviderAnthropic.__new__(
|
||||
anthropic_source.ProviderAnthropic
|
||||
)
|
||||
provider.client = SimpleNamespace(models=models)
|
||||
|
||||
assert await provider.get_models() == ["claude-a", "claude-b"]
|
||||
assert models.calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_chat_wraps_string_system_prompt_as_list(monkeypatch):
|
||||
monkeypatch.setattr(anthropic_source, "AsyncAnthropic", _FakeAsyncAnthropic)
|
||||
@@ -187,7 +220,7 @@ async def test_text_chat_wraps_string_system_prompt_as_list(monkeypatch):
|
||||
|
||||
captured_payloads: dict[str, object] = {}
|
||||
|
||||
async def fake_query(payloads, tools):
|
||||
async def fake_query(payloads, tools, *, request_max_retries=None):
|
||||
captured_payloads.update(payloads)
|
||||
return LLMResponse(role="assistant", completion_text="ok")
|
||||
|
||||
@@ -214,7 +247,7 @@ async def test_text_chat_passes_through_list_system_prompt(monkeypatch):
|
||||
|
||||
captured_payloads: dict[str, object] = {}
|
||||
|
||||
async def fake_query(payloads, tools):
|
||||
async def fake_query(payloads, tools, *, request_max_retries=None):
|
||||
captured_payloads.update(payloads)
|
||||
return LLMResponse(role="assistant", completion_text="ok")
|
||||
|
||||
|
||||
@@ -273,6 +273,7 @@ def test_dashboard_uses_bundled_dist_when_data_dist_is_stale(
|
||||
bundled_dist = tmp_path / "bundled-dist"
|
||||
user_dist.mkdir(parents=True)
|
||||
bundled_dist.mkdir()
|
||||
(bundled_dist / "index.html").write_text("bundled", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.dashboard.server.get_astrbot_data_path",
|
||||
@@ -293,6 +294,59 @@ def test_dashboard_uses_bundled_dist_when_data_dist_is_stale(
|
||||
assert server.data_path == str(bundled_dist)
|
||||
|
||||
|
||||
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,
|
||||
):
|
||||
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")
|
||||
|
||||
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 is None
|
||||
|
||||
|
||||
async def _set_dashboard_password_change_required(
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
required: bool,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from astrbot.core.exceptions import EmptyModelOutputError
|
||||
import astrbot.core.provider.sources.request_retry as request_retry
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
from astrbot.core.provider.sources.gemini_source import ProviderGoogleGenAI
|
||||
|
||||
@@ -27,3 +31,35 @@ def test_gemini_reasoning_only_output_is_allowed():
|
||||
response_id="resp_reasoning",
|
||||
finish_reason="STOP",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_get_models_retries_transient_request_error(monkeypatch):
|
||||
monkeypatch.setattr(request_retry, "REQUEST_RETRY_WAIT_MIN_S", 0)
|
||||
monkeypatch.setattr(request_retry, "REQUEST_RETRY_WAIT_MAX_S", 0)
|
||||
|
||||
class FakeModels:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
async def list(self):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise httpx.ConnectError("temporary connection failure")
|
||||
return [
|
||||
SimpleNamespace(
|
||||
name="models/gemini-a",
|
||||
supported_actions=["generateContent"],
|
||||
),
|
||||
SimpleNamespace(
|
||||
name="models/gemini-b",
|
||||
supported_actions=["embedContent"],
|
||||
),
|
||||
]
|
||||
|
||||
models = FakeModels()
|
||||
provider = ProviderGoogleGenAI.__new__(ProviderGoogleGenAI)
|
||||
provider.client = SimpleNamespace(models=models)
|
||||
|
||||
assert await provider.get_models() == ["gemini-a"]
|
||||
assert models.calls == 2
|
||||
|
||||
@@ -2,7 +2,8 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import tomllib
|
||||
|
||||
from astrbot.core.utils.toml_parser import read_pyproject_project_dependencies
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
REQUIREMENTS_PATH = PROJECT_ROOT / "requirements.txt"
|
||||
@@ -28,9 +29,7 @@ def _read_requirements() -> list[str]:
|
||||
|
||||
|
||||
def _read_pyproject_dependencies() -> list[str]:
|
||||
with PYPROJECT_PATH.open("rb") as file:
|
||||
pyproject = tomllib.load(file)
|
||||
return pyproject["project"]["dependencies"]
|
||||
return read_pyproject_project_dependencies(PYPROJECT_PATH)
|
||||
|
||||
|
||||
def test_requirements_include_httpx_socks_dependency() -> None:
|
||||
|
||||
@@ -9,7 +9,7 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.utils.io import should_use_bundled_dashboard_dist
|
||||
from astrbot.core.utils.io import get_dashboard_version, should_use_bundled_dashboard_dist
|
||||
from main import (
|
||||
DASHBOARD_RESET_PASSWORD_ENV,
|
||||
_apply_startup_env_flags,
|
||||
@@ -173,49 +173,146 @@ def test_version_info_comparisons():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_dashboard_files_not_exists(monkeypatch):
|
||||
async def test_check_dashboard_files_not_exists(tmp_path):
|
||||
"""Tests dashboard download when files do not exist."""
|
||||
monkeypatch.setattr(os.path, "exists", lambda x: False)
|
||||
data_dir = tmp_path / "data"
|
||||
bundled_dist = tmp_path / "bundled-dist"
|
||||
|
||||
with mock.patch("main.download_dashboard") as mock_download:
|
||||
await check_dashboard_files()
|
||||
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") as mock_download:
|
||||
result = await check_dashboard_files()
|
||||
|
||||
from main import VERSION
|
||||
|
||||
assert result == str(data_dir / "dist")
|
||||
mock_download.assert_called_once()
|
||||
mock_download.assert_called_once_with(
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
allow_insecure_ssl_fallback=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_dashboard_files_exists_and_version_match(monkeypatch):
|
||||
async def test_check_dashboard_files_exists_and_version_match(tmp_path):
|
||||
"""Tests that dashboard is not downloaded when it exists and version matches."""
|
||||
# Mock os.path.exists to return True
|
||||
monkeypatch.setattr(os.path, "exists", lambda x: True)
|
||||
from main import VERSION
|
||||
|
||||
# Mock get_dashboard_version to return the current version
|
||||
with mock.patch("main.get_dashboard_version") as mock_get_version:
|
||||
# We need to import VERSION from main's context
|
||||
from main import VERSION
|
||||
|
||||
mock_get_version.return_value = f"v{VERSION}"
|
||||
data_dir = tmp_path / "data"
|
||||
data_dist = data_dir / "dist"
|
||||
(data_dist / "assets").mkdir(parents=True)
|
||||
(data_dist / "assets" / "version").write_text(f"v{VERSION}", encoding="utf-8")
|
||||
(data_dist / "index.html").write_text("user", encoding="utf-8")
|
||||
|
||||
with mock.patch("main.get_astrbot_data_path", return_value=str(data_dir)):
|
||||
with mock.patch("main.download_dashboard") as mock_download:
|
||||
await check_dashboard_files()
|
||||
# Assert that download_dashboard was NOT called
|
||||
result = await check_dashboard_files()
|
||||
assert result == str(data_dist)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_dashboard_files_exists_but_version_mismatch(monkeypatch):
|
||||
"""Tests that a warning is logged when dashboard version mismatches."""
|
||||
monkeypatch.setattr(os.path, "exists", lambda x: True)
|
||||
async def test_check_dashboard_files_exists_but_version_mismatch_downloads(tmp_path):
|
||||
"""Tests that a mismatched dashboard is downloaded on startup."""
|
||||
from main import VERSION
|
||||
|
||||
with mock.patch(
|
||||
"main.get_dashboard_version", mock.AsyncMock(return_value="v0.0.1")
|
||||
):
|
||||
with mock.patch("main.logger.warning") as mock_logger_warning:
|
||||
await check_dashboard_files()
|
||||
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")
|
||||
|
||||
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") 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,
|
||||
)
|
||||
mock_logger_warning.assert_called_once()
|
||||
call_args, _ = mock_logger_warning.call_args
|
||||
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,
|
||||
):
|
||||
"""Tests that a version match alone is not enough to serve WebUI."""
|
||||
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(f"v{VERSION}", 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") as mock_download:
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def test_should_use_bundled_dashboard_dist_when_data_dist_is_stale(tmp_path):
|
||||
user_dist = tmp_path / "user-dist"
|
||||
bundled_dist = tmp_path / "bundled-dist"
|
||||
@@ -223,6 +320,7 @@ def test_should_use_bundled_dashboard_dist_when_data_dist_is_stale(tmp_path):
|
||||
(bundled_dist / "assets").mkdir(parents=True)
|
||||
(user_dist / "assets" / "version").write_text("v4.24.2", encoding="utf-8")
|
||||
(bundled_dist / "assets" / "version").write_text("v4.24.4", encoding="utf-8")
|
||||
(bundled_dist / "index.html").write_text("bundled", encoding="utf-8")
|
||||
|
||||
with mock.patch(
|
||||
"astrbot.core.utils.io.get_bundled_dashboard_dist_path",
|
||||
@@ -231,46 +329,94 @@ def test_should_use_bundled_dashboard_dist_when_data_dist_is_stale(tmp_path):
|
||||
assert should_use_bundled_dashboard_dist(user_dist, "v4.24.4") is True
|
||||
|
||||
|
||||
def test_should_keep_data_dist_when_version_file_is_malformed(tmp_path):
|
||||
def test_should_use_bundled_dashboard_dist_when_version_file_is_malformed(tmp_path):
|
||||
user_dist = tmp_path / "user-dist"
|
||||
bundled_dist = tmp_path / "bundled-dist"
|
||||
(user_dist / "assets").mkdir(parents=True)
|
||||
(bundled_dist / "assets").mkdir(parents=True)
|
||||
(user_dist / "assets" / "version").write_text("not-a-version", encoding="utf-8")
|
||||
(bundled_dist / "assets" / "version").write_text("v4.24.4", encoding="utf-8")
|
||||
(bundled_dist / "index.html").write_text("bundled", encoding="utf-8")
|
||||
|
||||
with mock.patch(
|
||||
"astrbot.core.utils.io.get_bundled_dashboard_dist_path",
|
||||
return_value=bundled_dist,
|
||||
):
|
||||
assert should_use_bundled_dashboard_dist(user_dist, "4.24.4") is False
|
||||
assert should_use_bundled_dashboard_dist(user_dist, "4.24.4") is True
|
||||
|
||||
|
||||
def test_should_use_bundled_dashboard_dist_when_data_version_file_is_missing(tmp_path):
|
||||
user_dist = tmp_path / "user-dist"
|
||||
bundled_dist = tmp_path / "bundled-dist"
|
||||
(user_dist / "assets").mkdir(parents=True)
|
||||
(bundled_dist / "assets").mkdir(parents=True)
|
||||
(bundled_dist / "assets" / "version").write_text("v4.24.4", encoding="utf-8")
|
||||
(bundled_dist / "index.html").write_text("bundled", encoding="utf-8")
|
||||
|
||||
with mock.patch(
|
||||
"astrbot.core.utils.io.get_bundled_dashboard_dist_path",
|
||||
return_value=bundled_dist,
|
||||
):
|
||||
assert should_use_bundled_dashboard_dist(user_dist, "4.24.4") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_dashboard_files_uses_bundled_dist_when_data_dist_is_stale(
|
||||
async def test_get_dashboard_version_uses_bundled_dist_when_data_dist_is_missing(
|
||||
tmp_path,
|
||||
):
|
||||
"""Tests that a stale data/dist does not override bundled dashboard assets."""
|
||||
"""Tests bundled WebUI version lookup when data/dist is absent."""
|
||||
from main import VERSION
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
bundled_dist = tmp_path / "bundled-dist"
|
||||
(bundled_dist / "assets").mkdir(parents=True)
|
||||
(bundled_dist / "assets" / "version").write_text(f"v{VERSION}", encoding="utf-8")
|
||||
(bundled_dist / "index.html").write_text("bundled", encoding="utf-8")
|
||||
|
||||
with mock.patch(
|
||||
"astrbot.core.utils.io.get_astrbot_data_path",
|
||||
return_value=str(data_dir),
|
||||
):
|
||||
with mock.patch(
|
||||
"astrbot.core.utils.io.get_bundled_dashboard_dist_path",
|
||||
return_value=bundled_dist,
|
||||
):
|
||||
assert await get_dashboard_version() == f"v{VERSION}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_dashboard_files_replaces_stale_data_dist_with_bundled_dist(
|
||||
tmp_path,
|
||||
):
|
||||
"""Tests that a stale data/dist is repaired from bundled dashboard assets."""
|
||||
from main import VERSION
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
data_dist = data_dir / "dist"
|
||||
bundled_dist = tmp_path / "bundled-dist"
|
||||
data_dist.mkdir(parents=True)
|
||||
bundled_dist.mkdir()
|
||||
(data_dist / "assets").mkdir(parents=True)
|
||||
(bundled_dist / "assets").mkdir(parents=True)
|
||||
(data_dist / "assets" / "version").write_text("v0.0.1", encoding="utf-8")
|
||||
(data_dist / "old.txt").write_text("old", encoding="utf-8")
|
||||
(bundled_dist / "assets" / "version").write_text(f"v{VERSION}", encoding="utf-8")
|
||||
(bundled_dist / "index.html").write_text("bundled", encoding="utf-8")
|
||||
|
||||
with mock.patch("main.get_astrbot_data_path", return_value=str(data_dir)):
|
||||
with mock.patch(
|
||||
"main.get_dashboard_version", mock.AsyncMock(return_value="v0.0.1")
|
||||
"main.get_bundled_dashboard_dist_path",
|
||||
return_value=Path(bundled_dist),
|
||||
):
|
||||
with mock.patch(
|
||||
"main.should_use_bundled_dashboard_dist", return_value=True
|
||||
"astrbot.core.utils.io.get_bundled_dashboard_dist_path",
|
||||
return_value=Path(bundled_dist),
|
||||
):
|
||||
with mock.patch(
|
||||
"main.get_bundled_dashboard_dist_path",
|
||||
return_value=Path(bundled_dist),
|
||||
):
|
||||
with mock.patch("main.download_dashboard") as mock_download:
|
||||
result = await check_dashboard_files()
|
||||
with mock.patch("main.download_dashboard") as mock_download:
|
||||
result = await check_dashboard_files()
|
||||
|
||||
assert result == str(bundled_dist)
|
||||
assert result == str(data_dist)
|
||||
assert (data_dist / "assets" / "version").read_text(encoding="utf-8") == f"v{VERSION}"
|
||||
assert (data_dist / "index.html").read_text(encoding="utf-8") == "bundled"
|
||||
assert not (data_dist / "old.txt").exists()
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
@@ -281,7 +427,7 @@ async def test_check_dashboard_files_with_webui_dir_arg(monkeypatch):
|
||||
monkeypatch.setattr(os.path, "exists", lambda path: path == valid_dir)
|
||||
|
||||
with mock.patch("main.download_dashboard") as mock_download:
|
||||
with mock.patch("main.get_dashboard_version") as mock_get_version:
|
||||
with mock.patch("main.get_dashboard_dist_version") as mock_get_version:
|
||||
result = await check_dashboard_files(webui_dir=valid_dir)
|
||||
assert result == valid_dir
|
||||
mock_download.assert_not_called()
|
||||
|
||||
@@ -3,13 +3,16 @@ import builtins
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from openai.types.chat.chat_completion import ChatCompletion
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
|
||||
from PIL import Image as PILImage
|
||||
|
||||
import astrbot.core.provider.sources.openai_source as openai_source_module
|
||||
import astrbot.core.provider.sources.request_retry as request_retry
|
||||
from astrbot.core.exceptions import EmptyModelOutputError
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
from astrbot.core.provider.sources.groq_source import ProviderGroq
|
||||
from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
|
||||
from astrbot.core.utils.media_utils import ResolvedMediaData, file_uri_to_path
|
||||
@@ -117,6 +120,57 @@ def test_create_http_client_falls_back_to_global_httpx_module(monkeypatch):
|
||||
assert captured["httpx_module"] is openai_source_module.httpx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_models_retries_transient_request_error(monkeypatch):
|
||||
monkeypatch.setattr(request_retry, "REQUEST_RETRY_WAIT_MIN_S", 0)
|
||||
monkeypatch.setattr(request_retry, "REQUEST_RETRY_WAIT_MAX_S", 0)
|
||||
|
||||
class FakeModels:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
async def list(self):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise httpx.ConnectError("temporary connection failure")
|
||||
return SimpleNamespace(
|
||||
data=[
|
||||
SimpleNamespace(id="gpt-b"),
|
||||
SimpleNamespace(id="gpt-a"),
|
||||
]
|
||||
)
|
||||
|
||||
models = FakeModels()
|
||||
provider = ProviderOpenAIOfficial.__new__(ProviderOpenAIOfficial)
|
||||
provider.client = SimpleNamespace(models=models)
|
||||
|
||||
assert await provider.get_models() == ["gpt-a", "gpt-b"]
|
||||
assert models.calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_chat_passes_request_max_retries_to_query():
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
provider = ProviderOpenAIOfficial.__new__(ProviderOpenAIOfficial)
|
||||
provider.api_keys = ["test-key"]
|
||||
provider.client = SimpleNamespace(api_key=None)
|
||||
|
||||
async def fake_prepare_chat_payload(*args, **kwargs):
|
||||
return {"messages": [], "model": "gpt-4o-mini"}, []
|
||||
|
||||
async def fake_query(payloads, func_tool, *, request_max_retries=None):
|
||||
captured["request_max_retries"] = request_max_retries
|
||||
return LLMResponse(role="assistant", completion_text="ok")
|
||||
|
||||
provider._prepare_chat_payload = fake_prepare_chat_payload
|
||||
provider._query = fake_query
|
||||
|
||||
await provider.text_chat(prompt="hello", request_max_retries=2)
|
||||
|
||||
assert captured["request_max_retries"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_api_error_content_moderated_removes_images():
|
||||
provider = _make_provider(
|
||||
|
||||
27
tests/test_request_retry.py
Normal file
27
tests/test_request_retry.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import astrbot.core.provider.sources.request_retry as request_retry
|
||||
from astrbot.core.provider.sources.request_retry import retry_provider_request
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_provider_request_uses_configured_max_retries(monkeypatch):
|
||||
monkeypatch.setattr(request_retry, "REQUEST_RETRY_WAIT_MIN_S", 0)
|
||||
monkeypatch.setattr(request_retry, "REQUEST_RETRY_WAIT_MAX_S", 0)
|
||||
|
||||
calls = 0
|
||||
|
||||
async def request():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise httpx.ConnectError("temporary connection failure")
|
||||
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await retry_provider_request(
|
||||
"Test",
|
||||
request,
|
||||
max_attempts=2,
|
||||
)
|
||||
|
||||
assert calls == 2
|
||||
96
tests/test_toml_parser.py
Normal file
96
tests/test_toml_parser.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.utils.toml_parser import read_pyproject_project_dependencies
|
||||
|
||||
|
||||
def test_read_pyproject_project_dependencies_reads_multiline_array(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
pyproject_path = tmp_path / "pyproject.toml"
|
||||
pyproject_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"[project]",
|
||||
"dependencies = [",
|
||||
' "aiohttp>=3.11.18",',
|
||||
" \"audioop-lts ; python_full_version >= '3.13'\", # marker",
|
||||
"] # end dependencies",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert read_pyproject_project_dependencies(pyproject_path) == [
|
||||
"aiohttp>=3.11.18",
|
||||
"audioop-lts ; python_full_version >= '3.13'",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("dependency_line", "expected"),
|
||||
[
|
||||
("dependencies = []", []),
|
||||
('dependencies = ["aiohttp>=3.11.18"]', ["aiohttp>=3.11.18"]),
|
||||
(
|
||||
'dependencies = ["psutil>=5.8.0,<7.2.0", "httpx[socks]>=0.28.1"]',
|
||||
["psutil>=5.8.0,<7.2.0", "httpx[socks]>=0.28.1"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_read_pyproject_project_dependencies_accepts_inline_arrays(
|
||||
tmp_path: Path,
|
||||
dependency_line: str,
|
||||
expected: list[str],
|
||||
) -> None:
|
||||
pyproject_path = tmp_path / "pyproject.toml"
|
||||
pyproject_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"[project]",
|
||||
dependency_line,
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert read_pyproject_project_dependencies(pyproject_path) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("project_lines", "message"),
|
||||
[
|
||||
(["[project]", 'name = "AstrBot"'], "Missing project.dependencies"),
|
||||
(
|
||||
["[project]", "dependencies = ["],
|
||||
"Unterminated project.dependencies array",
|
||||
),
|
||||
(
|
||||
["[project]", 'dependencies = "aiohttp>=3.11.18"'],
|
||||
"Unsupported project.dependencies value",
|
||||
),
|
||||
(
|
||||
["[project]", "dependencies = [", " aiohttp>=3.11.18,", "]"],
|
||||
"Unsupported project.dependencies entry value",
|
||||
),
|
||||
(
|
||||
["[project]", "dependencies = [", ' "aiohttp>=3.11.18" extra', "]"],
|
||||
"Unsupported content after project.dependencies entry",
|
||||
),
|
||||
(
|
||||
["[project]", "dependencies = [", ' ""', "]"],
|
||||
"Empty project.dependencies entry value",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_read_pyproject_project_dependencies_rejects_invalid_values(
|
||||
tmp_path: Path,
|
||||
project_lines: list[str],
|
||||
message: str,
|
||||
) -> None:
|
||||
pyproject_path = tmp_path / "pyproject.toml"
|
||||
pyproject_path.write_text("\n".join(project_lines), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match=message):
|
||||
read_pyproject_project_dependencies(pyproject_path)
|
||||
@@ -440,6 +440,7 @@ async def test_download_dashboard_falls_back_when_hosted_package_is_not_zip(
|
||||
path: str,
|
||||
show_progress: bool = False, # noqa: ARG001
|
||||
progress_callback=None, # noqa: ARG001
|
||||
allow_insecure_ssl_fallback: bool = True, # noqa: ARG001
|
||||
) -> None:
|
||||
calls.append(url)
|
||||
parsed = urlparse(url)
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytest
|
||||
|
||||
from astrbot.core import astr_main_agent as ama
|
||||
from astrbot.core.agent.mcp_client import MCPTool
|
||||
from astrbot.core.agent.message import Message, dump_messages_with_checkpoints
|
||||
from astrbot.core.agent.tool import FunctionTool, ToolSet
|
||||
from astrbot.core.conversation_mgr import Conversation
|
||||
from astrbot.core.message.components import File, Image, Plain, Reply, Video
|
||||
@@ -377,8 +378,18 @@ class TestApplyKb:
|
||||
):
|
||||
await module._apply_kb(mock_event, req, mock_context, config)
|
||||
|
||||
assert "[Related Knowledge Base Results]:" in req.system_prompt
|
||||
assert "KB result" in req.system_prompt
|
||||
assert req.system_prompt == "System prompt"
|
||||
assert len(req.extra_user_content_parts) == 1
|
||||
kb_part = req.extra_user_content_parts[0]
|
||||
assert kb_part.text == "[Related Knowledge Base Results]:\nKB result"
|
||||
|
||||
message = Message.model_validate(await req.assemble_context())
|
||||
assert isinstance(message.content, list)
|
||||
assert message.content[0].text == "test question"
|
||||
assert message.content[1].text == "[Related Knowledge Base Results]:\nKB result"
|
||||
assert dump_messages_with_checkpoints([message]) == [
|
||||
{"role": "user", "content": [{"type": "text", "text": "test question"}]}
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_kb_with_agentic_mode(self, mock_event, mock_context):
|
||||
@@ -998,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
|
||||
@@ -1006,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,
|
||||
@@ -1019,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="[]")
|
||||
@@ -1041,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()
|
||||
|
||||
Reference in New Issue
Block a user