Compare commits

..

2 Commits

Author SHA1 Message Date
Soulter
e258d5ea10 fix: 更新包版本更新函数以修改 astrbot/__init__.py 中的版本号 2026-06-21 14:04:23 +08:00
Soulter
05e4849e0e fix: created unnecessary data dir when executing astrbot command
fixes: #8853
2026-06-21 14:01:19 +08:00
58 changed files with 156 additions and 1372 deletions

View File

@@ -1,4 +1,4 @@
name: Build and Deploy AstrBot Docs
name: release
on:
push:
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest # 运行环境
steps:
- name: checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
with:

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -56,7 +56,7 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9

View File

@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tag: true
@@ -125,7 +125,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tag: true

64
.github/workflows/pr-title-check.yml vendored Normal file
View File

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

View File

@@ -1,4 +1,4 @@
name: Release AstrBot
name: Release
on:
push:
@@ -28,7 +28,7 @@ jobs:
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
@@ -128,7 +128,7 @@ jobs:
- build-dashboard
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
@@ -208,7 +208,7 @@ jobs:
- publish-release
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}

View File

@@ -32,7 +32,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -1,4 +1,4 @@
name: Sync AstrBot Docs to GitHub Wiki
name: sync wiki
on:
workflow_dispatch:
@@ -31,7 +31,7 @@ jobs:
exit 1
- name: Check out docs repository
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -1,4 +1,4 @@
import logging
__version__ = "4.26.0-beta.12"
__version__ = "4.26.0-beta.11"
logger = logging.getLogger("astrbot")

View File

@@ -8,17 +8,6 @@ from filelock import FileLock, Timeout
DASHBOARD_INITIAL_PASSWORD_ENV = "ASTRBOT_DASHBOARD_INITIAL_PASSWORD"
async def check_dashboard(astrbot_root: Path) -> None:
"""Check whether dashboard assets are available.
Args:
astrbot_root: AstrBot data directory path.
"""
from ..utils import check_dashboard as _check_dashboard
await _check_dashboard(astrbot_root)
def _initialize_config_from_env(astrbot_root: Path) -> None:
if DASHBOARD_INITIAL_PASSWORD_ENV not in os.environ:
return
@@ -55,6 +44,8 @@ async def initialize_astrbot(astrbot_root: Path) -> None:
_initialize_config_from_env(astrbot_root)
from ..utils import check_dashboard
await check_dashboard(astrbot_root / "data")

View File

@@ -130,7 +130,6 @@ class LLMSummaryCompressor:
instruction_text: str | None = None,
compression_threshold: float = 0.82,
token_counter: TokenCounter | None = None,
max_recent_rounds: int | None = None,
) -> None:
"""Initialize the LLM summary compressor.
@@ -140,18 +139,11 @@ class LLMSummaryCompressor:
exact context. Clamped to 0-0.3.
instruction_text: Custom instruction for summary generation.
compression_threshold: The compression trigger threshold (default: 0.82).
token_counter: Optional custom token counter.
max_recent_rounds: Maximum exact recent rounds to preserve after
summarization. If None, only the token ratio limits recent rounds.
"""
self.provider = provider
self.keep_recent_ratio = min(max(float(keep_recent_ratio), 0.0), 0.3)
self.compression_threshold = compression_threshold
self.token_counter = token_counter or EstimateTokenCounter()
self.max_recent_rounds = (
None if max_recent_rounds is None else max(1, int(max_recent_rounds))
)
self.last_call_failed = False
self.instruction_text = instruction_text or (
"Based on our full conversation history, produce a concise summary of key takeaways and/or project progress.\n"
@@ -220,8 +212,6 @@ class LLMSummaryCompressor:
"""
from .round_utils import split_into_rounds
self.last_call_failed = False
rounds = split_into_rounds(messages)
message_rounds = [
[seg for seg in rnd if isinstance(seg, Message)] for rnd in rounds
@@ -241,14 +231,6 @@ class LLMSummaryCompressor:
old_rounds = old_rounds[:-1]
recent_rounds = [latest_old_round, *recent_rounds]
if (
self.max_recent_rounds is not None
and len(recent_rounds) > self.max_recent_rounds
):
excess_count = len(recent_rounds) - self.max_recent_rounds
old_rounds = old_rounds + recent_rounds[:excess_count]
recent_rounds = recent_rounds[excess_count:]
if not old_rounds:
if recent_rounds and messages and messages[-1].role == "user":
return messages
@@ -294,19 +276,13 @@ class LLMSummaryCompressor:
response = await self.provider.text_chat(
contexts=sanitized_summary_contexts,
)
if response.role == "err":
logger.error(f"Failed to generate summary: {response.completion_text}")
self.last_call_failed = True
return messages
summary_content = (response.completion_text or "").strip()
except Exception as e:
logger.error(f"Failed to generate summary: {e}")
self.last_call_failed = True
return messages
if not summary_content:
logger.warning("LLM context compression returned an empty summary.")
self.last_call_failed = True
return messages
# Build result: system messages + summary pair + recent rounds

View File

@@ -3,7 +3,6 @@ from astrbot import logger
from ..message import Message
from .compressor import LLMSummaryCompressor, TruncateByTurnsCompressor
from .config import ContextConfig
from .round_utils import count_conversation_rounds
from .token_counter import EstimateTokenCounter
from .truncator import ContextTruncator
@@ -37,11 +36,6 @@ class ContextManager:
keep_recent_ratio=config.llm_compress_keep_recent_ratio,
instruction_text=config.llm_compress_instruction,
token_counter=self.token_counter,
max_recent_rounds=(
max(1, config.enforce_max_turns - 1)
if config.enforce_max_turns != -1
else None
),
)
else:
self.compressor = TruncateByTurnsCompressor(
@@ -62,33 +56,15 @@ class ContextManager:
try:
result = messages
# 1. 基于轮次的截断 (Enforce max turns)
if self.config.enforce_max_turns != -1:
turn_count = count_conversation_rounds(result)
if turn_count > self.config.enforce_max_turns:
should_truncate_by_turns = True
if isinstance(self.compressor, LLMSummaryCompressor):
logger.debug(
"Turn limit (%s) exceeded (%s turns), "
"trying LLM summary compression first.",
self.config.enforce_max_turns,
turn_count,
)
compressed = await self.compressor(result)
if self.compressor.last_call_failed or compressed == result:
logger.warning(
"LLM summary compression failed; falling back "
"to turn-based truncation.",
)
else:
result = compressed
should_truncate_by_turns = False
if should_truncate_by_turns:
result = self.truncator.truncate_by_turns(
result,
keep_most_recent_turns=self.config.enforce_max_turns,
drop_turns=self.config.truncate_turns,
)
result = self.truncator.truncate_by_turns(
result,
keep_most_recent_turns=self.config.enforce_max_turns,
drop_turns=self.config.truncate_turns,
)
# 2. 基于 token 的压缩
if self.config.max_context_tokens > 0:
total_tokens = self.token_counter.count_tokens(
result, trusted_token_usage
@@ -119,17 +95,7 @@ class ContextManager:
"""
logger.debug("Compress triggered, starting compression...")
compressed = await self.compressor(messages)
if isinstance(self.compressor, LLMSummaryCompressor):
if self.compressor.last_call_failed:
logger.warning(
"LLM summary compression failed; falling back to hard "
"truncation to keep the request within the token limit.",
)
else:
messages = compressed
else:
messages = compressed
messages = await self.compressor(messages)
# double check
tokens_after_summary = self.token_counter.count_tokens(messages)
@@ -147,23 +113,9 @@ class ContextManager:
messages, tokens_after_summary, self.config.max_context_tokens
):
logger.info(
"Context still exceeds max tokens after compression, applying hard truncation..."
"Context still exceeds max tokens after compression, applying halving truncation..."
)
while self.compressor.should_compress(
messages, tokens_after_summary, self.config.max_context_tokens
):
truncated = self.truncator.truncate_by_dropping_oldest_turns(
messages,
drop_turns=self.config.truncate_turns,
)
if truncated == messages:
truncated = self.truncator.truncate_by_halving(messages)
if truncated == messages:
break
next_tokens = self.token_counter.count_tokens(truncated)
if next_tokens >= tokens_after_summary:
break
messages = truncated
tokens_after_summary = next_tokens
# still need compress, truncate by half
messages = self.truncator.truncate_by_halving(messages)
return messages

View File

@@ -35,22 +35,6 @@ def split_into_rounds(
return rounds
def count_conversation_rounds(contexts: Sequence[RoundSegment]) -> int:
"""Count logical user conversation rounds.
Args:
contexts: Flat message contexts.
Returns:
Number of rounds that contain a user message.
"""
return sum(
1
for round_segments in split_into_rounds(contexts)
if any(_segment_role(seg) == "user" for seg in round_segments)
)
def _content_to_text(content: Any) -> str:
if isinstance(content, list):
normalized = [

View File

@@ -1,5 +1,4 @@
from ..message import Message
from .round_utils import split_into_rounds
class ContextTruncator:
@@ -121,25 +120,15 @@ class ContextTruncator:
return messages
system_messages, non_system_messages = self._split_system_rest(messages)
rounds = split_into_rounds(non_system_messages)
round_count = sum(
1
for round_segments in rounds
if any(segment.role == "user" for segment in round_segments)
)
if round_count <= keep_most_recent_turns:
if len(non_system_messages) // 2 <= keep_most_recent_turns:
return messages
num_to_keep = keep_most_recent_turns - drop_turns + 1
if num_to_keep <= 0:
truncated_contexts = []
else:
truncated_contexts = [
segment
for round_segments in rounds[-num_to_keep:]
for segment in round_segments
]
truncated_contexts = non_system_messages[-num_to_keep * 2 :]
# Find the first user message
index = next(
@@ -164,21 +153,11 @@ class ContextTruncator:
return messages
system_messages, non_system_messages = self._split_system_rest(messages)
rounds = split_into_rounds(non_system_messages)
round_count = sum(
1
for round_segments in rounds
if any(segment.role == "user" for segment in round_segments)
)
if round_count <= drop_turns:
if len(non_system_messages) // 2 <= drop_turns:
truncated_non_system = []
else:
truncated_non_system = [
segment
for round_segments in rounds[drop_turns:]
for segment in round_segments
]
truncated_non_system = non_system_messages[drop_turns * 2 :]
# Find the first user message
index = next(

View File

@@ -85,8 +85,6 @@ from astrbot.core.tools.web_search_tools import (
BaiduWebSearchTool,
BochaWebSearchTool,
BraveWebSearchTool,
ExaGetContentsTool,
ExaWebSearchTool,
FirecrawlExtractWebPageTool,
FirecrawlWebSearchTool,
TavilyExtractWebPageTool,
@@ -132,7 +130,6 @@ WEB_SEARCH_CITATION_TOOL_NAMES = frozenset(
"web_search_tavily",
"web_search_bocha",
"web_search_brave",
"web_search_exa",
}
)
WEB_SEARCH_CITATION_PROMPT = (
@@ -1210,9 +1207,6 @@ async def _apply_web_search_tools(
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FirecrawlExtractWebPageTool))
elif provider == "baidu_ai_search":
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BaiduWebSearchTool))
elif provider == "exa":
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExaWebSearchTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExaGetContentsTool))
def _apply_web_search_citation_prompt(

View File

@@ -115,7 +115,6 @@ DEFAULT_CONFIG = {
"websearch_brave_key": [],
"websearch_baidu_app_builder_key": "",
"websearch_firecrawl_key": [],
"websearch_exa_key": [],
"web_search_link": False,
"display_reasoning_text": False,
"identifier": False,
@@ -3296,7 +3295,6 @@ CONFIG_METADATA_3 = {
"bocha",
"brave",
"firecrawl",
"exa",
],
"condition": {
"provider_settings.web_search": True,
@@ -3351,16 +3349,6 @@ CONFIG_METADATA_3 = {
"provider_settings.web_search": True,
},
},
"provider_settings.websearch_exa_key": {
"description": "Exa API Key",
"type": "list",
"items": {"type": "string"},
"hint": "可添加多个 Key 进行轮询。Get a key at https://dashboard.exa.ai",
"condition": {
"provider_settings.websearch_provider": "exa",
"provider_settings.web_search": True,
},
},
"provider_settings.web_search_link": {
"description": "显示来源引用",
"type": "bool",

View File

@@ -1,6 +1,5 @@
import asyncio
import json
import re
from collections.abc import Awaitable, Callable
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
@@ -24,70 +23,6 @@ if TYPE_CHECKING:
from astrbot.core.star.context import Context
_CRONTAB_WEEKDAY_NAMES = ("sun", "mon", "tue", "wed", "thu", "fri", "sat")
_CRONTAB_WEEKDAY_PATTERN = re.compile(r"^(?:(\*)|(\d+)(?:-(\d+))?)(?:/(\d+))?$")
def _normalize_crontab_day_of_week(day_of_week: str) -> str:
"""Normalize standard crontab weekdays for APScheduler.
APScheduler treats numeric weekdays as Monday=0, while standard crontab and
AstrBot's WebUI use Sunday=0/7. Numeric weekday fields are expanded to
weekday names so the scheduled day remains unambiguous.
Args:
day_of_week: The day-of-week field from a five-part crontab expression.
Returns:
A day-of-week field compatible with APScheduler.
Raises:
ValueError: If a numeric weekday value or step is outside the supported
crontab range.
"""
normalized_parts: list[str] = []
for raw_part in day_of_week.split(","):
part = raw_part.strip().lower()
match = _CRONTAB_WEEKDAY_PATTERN.fullmatch(part)
if not match:
normalized_parts.append(part)
continue
wildcard, start_text, end_text, step_text = match.groups()
step = int(step_text or "1")
if step < 1:
raise ValueError("day_of_week step must be greater than 0")
if wildcard:
if step == 1:
normalized_parts.append("*")
continue
values = range(0, 7, step)
else:
start = int(start_text)
end = int(end_text) if end_text is not None else None
if start < 0 or start > 7 or (end is not None and (end < 0 or end > 7)):
raise ValueError("day_of_week values must be between 0 and 7")
if end is not None and start > end:
raise ValueError("day_of_week range start must not exceed end")
if end is None:
end = 7 if step_text else start
values = range(start, end + 1, step)
weekdays: list[int] = []
for value in values:
weekday = 0 if value == 7 else value
if weekday not in weekdays:
weekdays.append(weekday)
if len(weekdays) == 7:
normalized_parts.append("*")
else:
normalized_parts.extend(_CRONTAB_WEEKDAY_NAMES[value] for value in weekdays)
return ",".join(normalized_parts)
class CronJobSchedulingError(Exception):
"""Raised when a cron job fails to be scheduled."""
@@ -242,21 +177,7 @@ class CronJobManager:
run_at = run_at.replace(tzinfo=tzinfo)
trigger = DateTrigger(run_date=run_at, timezone=tzinfo)
else:
if not job.cron_expression:
raise ValueError("recurring job missing cron_expression")
minute, hour, day, month, day_of_week = job.cron_expression.split()
normalized_cron_expression = " ".join(
[
minute,
hour,
day,
month,
_normalize_crontab_day_of_week(day_of_week),
]
)
trigger = CronTrigger.from_crontab(
normalized_cron_expression, timezone=tzinfo
)
trigger = CronTrigger.from_crontab(job.cron_expression, timezone=tzinfo)
self.scheduler.add_job(
self._run_job,
id=job.job_id,

View File

@@ -25,7 +25,7 @@ from tenacity import (
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
from astrbot.api.message_components import File, Image, Plain, Record, Video
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.core.utils.media_utils import MediaResolver, file_uri_to_path, is_file_uri
@@ -747,10 +747,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
file_source = file_path
elif i.url:
file_source = i.url
elif isinstance(i, At):
qq_id = getattr(i, "qq", "")
if qq_id and qq_id != "all":
plain_text += f"<@{qq_id}>"
else:
logger.debug(f"qq_official 忽略 {i.type}")
return (

View File

@@ -21,8 +21,6 @@ WEB_SEARCH_TOOL_NAMES = [
"web_search_brave",
"web_search_firecrawl",
"firecrawl_extract_web_page",
"web_search_exa",
"exa_get_contents",
]
_TAVILY_WEB_SEARCH_TOOL_CONFIG = {
"provider_settings.web_search": True,
@@ -44,10 +42,6 @@ _BAIDU_WEB_SEARCH_TOOL_CONFIG = {
"provider_settings.web_search": True,
"provider_settings.websearch_provider": "baidu_ai_search",
}
_EXA_WEB_SEARCH_TOOL_CONFIG = {
"provider_settings.web_search": True,
"provider_settings.websearch_provider": "exa",
}
@std_dataclass
@@ -82,7 +76,6 @@ _TAVILY_KEY_ROTATOR = _KeyRotator("websearch_tavily_key", "Tavily")
_BOCHA_KEY_ROTATOR = _KeyRotator("websearch_bocha_key", "BoCha")
_BRAVE_KEY_ROTATOR = _KeyRotator("websearch_brave_key", "Brave")
_FIRECRAWL_KEY_ROTATOR = _KeyRotator("websearch_firecrawl_key", "Firecrawl")
_EXA_KEY_ROTATOR = _KeyRotator("websearch_exa_key", "Exa")
def normalize_legacy_web_search_config(cfg) -> None:
@@ -106,7 +99,6 @@ def normalize_legacy_web_search_config(cfg) -> None:
"websearch_bocha_key",
"websearch_brave_key",
"websearch_firecrawl_key",
"websearch_exa_key",
):
value = provider_settings.get(setting_name)
if isinstance(value, str):
@@ -811,231 +803,10 @@ class BaiduWebSearchTool(FunctionTool[AstrAgentContext]):
return _search_result_payload(results)
async def _exa_search(
provider_settings: dict,
payload: dict,
) -> list[SearchResult]:
"""Call the Exa /search endpoint and return normalized results."""
exa_key = await _EXA_KEY_ROTATOR.get(provider_settings)
headers = {
"x-api-key": exa_key,
"Content-Type": "application/json",
}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
"https://api.exa.ai/search",
json=payload,
headers=headers,
) as response:
if response.status != 200:
reason = await response.text()
raise Exception(
f"Exa web search failed: {reason}, status: {response.status}",
)
data = await response.json()
return [
SearchResult(
title=item.get("title", ""),
url=item.get("url", ""),
snippet=(
item.get("text")
or (item.get("highlights") or [""])[0]
or item.get("summary", "")
),
)
for item in data.get("results", [])
if item.get("url")
]
async def _exa_get_contents(
provider_settings: dict,
payload: dict,
) -> list[dict]:
"""Call the Exa /contents endpoint and return raw result dicts."""
exa_key = await _EXA_KEY_ROTATOR.get(provider_settings)
headers = {
"x-api-key": exa_key,
"Content-Type": "application/json",
}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
"https://api.exa.ai/contents",
json=payload,
headers=headers,
) as response:
if response.status != 200:
reason = await response.text()
raise Exception(
f"Exa get contents failed: {reason}, status: {response.status}",
)
data = await response.json()
return data.get("results", [])
@builtin_tool(config=_EXA_WEB_SEARCH_TOOL_CONFIG)
@pydantic_dataclass
class ExaWebSearchTool(FunctionTool[AstrAgentContext]):
"""Web search tool powered by the Exa Search API."""
name: str = "web_search_exa"
description: str = (
"A web search tool powered by Exa, an AI-native search engine. "
"Supports keyword and semantic search with domain, date, and category filters."
)
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Required. Search query."},
"num_results": {
"type": "integer",
"description": "Optional. Number of results to return. Default is 10.",
},
"type": {
"type": "string",
"description": (
'Optional. Search type. One of "auto", "keyword", "neural". '
'Default is "auto".'
),
},
"category": {
"type": "string",
"description": (
"Optional. Category filter. One of "
'"company", "research paper", "news", "github", '
'"tweet", "personal site", "pdf", "linkedin profile".'
),
},
"include_domains": {
"type": "string",
"description": "Optional. Comma-separated domains to restrict results to.",
},
"exclude_domains": {
"type": "string",
"description": "Optional. Comma-separated domains to exclude from results.",
},
"start_published_date": {
"type": "string",
"description": "Optional. Start date filter in ISO 8601 format (e.g. 2024-01-01T00:00:00.000Z).",
},
"end_published_date": {
"type": "string",
"description": "Optional. End date filter in ISO 8601 format.",
},
},
"required": ["query"],
}
)
async def call(self, context, **kwargs) -> ToolExecResult:
_, provider_settings, _ = _get_runtime(context)
if not provider_settings.get("websearch_exa_key", []):
return "Error: Exa API key is not configured in AstrBot."
try:
num_results = int(kwargs.get("num_results", 10))
except (TypeError, ValueError):
num_results = 10
if num_results < 1:
num_results = 1
search_type = kwargs.get("type", "auto")
if search_type not in ("auto", "keyword", "neural"):
search_type = "auto"
payload: dict = {
"query": kwargs["query"],
"numResults": num_results,
"type": search_type,
"contents": {"text": {"maxCharacters": 500}},
}
category = kwargs.get("category", "")
if category:
payload["category"] = category
include_domains = str(kwargs.get("include_domains", "")).strip()
if include_domains:
payload["includeDomains"] = [
d.strip() for d in include_domains.split(",") if d.strip()
]
exclude_domains = str(kwargs.get("exclude_domains", "")).strip()
if exclude_domains:
payload["excludeDomains"] = [
d.strip() for d in exclude_domains.split(",") if d.strip()
]
if kwargs.get("start_published_date"):
payload["startPublishedDate"] = kwargs["start_published_date"]
if kwargs.get("end_published_date"):
payload["endPublishedDate"] = kwargs["end_published_date"]
results = await _exa_search(provider_settings, payload)
if not results:
return "Error: Exa web search does not return any results."
return _search_result_payload(results)
@builtin_tool(config=_EXA_WEB_SEARCH_TOOL_CONFIG)
@pydantic_dataclass
class ExaGetContentsTool(FunctionTool[AstrAgentContext]):
"""Extract full page content from URLs using the Exa Contents API."""
name: str = "exa_get_contents"
description: str = "Extract the content of a web page using Exa."
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Required. A URL to extract content from.",
},
"max_characters": {
"type": "integer",
"description": "Optional. Maximum number of characters to return. Default is 3000.",
},
},
"required": ["url"],
}
)
async def call(self, context, **kwargs) -> ToolExecResult:
_, provider_settings, _ = _get_runtime(context)
if not provider_settings.get("websearch_exa_key", []):
return "Error: Exa API key is not configured in AstrBot."
url = str(kwargs.get("url", "")).strip()
if not url:
return "Error: url must be a non-empty string."
try:
max_characters = int(kwargs.get("max_characters", 3000))
except (TypeError, ValueError):
max_characters = 3000
results = await _exa_get_contents(
provider_settings,
{
"ids": [url],
"text": {"maxCharacters": max_characters},
},
)
ret_ls = []
for result in results:
ret_ls.append(f"URL: {result.get('url', 'No URL')}")
ret_ls.append(f"Content: {result.get('text', 'No content')}")
ret = "\n".join(ret_ls)
return ret or "Error: Exa get contents does not return any results."
__all__ = [
"BaiduWebSearchTool",
"BochaWebSearchTool",
"BraveWebSearchTool",
"ExaGetContentsTool",
"ExaWebSearchTool",
"TavilyExtractWebPageTool",
"TavilyWebSearchTool",
"WEB_SEARCH_TOOL_NAMES",

View File

@@ -74,18 +74,6 @@ async def get_version(
return await _run(service.get_version())
@router.get("/stats/versions")
async def get_public_versions(
request: Request,
service: StatService = Depends(get_service),
):
return ok(
await service.get_public_versions(
getattr(request.app.state, "dashboard_static_folder", None)
)
)
@router.get("/stats/first-notice")
async def get_first_notice(
locale: str | None = None,
@@ -179,18 +167,6 @@ async def get_dashboard_version(
return await _run(service.get_version())
@legacy_router.get("/versions")
async def get_dashboard_public_versions(
request: Request,
service: StatService = Depends(get_service),
):
return ok(
await service.get_public_versions(
getattr(request.app.state, "dashboard_static_folder", None)
)
)
@legacy_router.get("/start-time")
async def get_dashboard_start_time(
service: StatService = Depends(get_service),

View File

@@ -265,7 +265,6 @@ class AstrBotDashboard:
"/api/auth/logout",
"/api/auth/setup-status",
"/api/auth/setup",
"/api/stat/versions",
}
allowed_endpoint_prefixes = [
"/api/file",
@@ -279,74 +278,37 @@ class AstrBotDashboard:
):
return None
is_plugin_page_path = PluginPageAuth.is_protected_path(path)
dashboard_token = self._extract_dashboard_jwt(current_request)
asset_token = (
PluginPageAuth.extract_asset_token(current_request.query_params)
if is_plugin_page_path
else None
)
token_candidates = []
if dashboard_token:
token_candidates.append(dashboard_token)
if asset_token and asset_token != dashboard_token:
token_candidates.append(asset_token)
if not token_candidates:
token = self._extract_dashboard_jwt(current_request)
if not token and is_plugin_page_path:
token = PluginPageAuth.extract_asset_token(current_request.query_params)
if not token:
r = JSONResponse(error("未授权"))
r.status_code = 401
return r
token_errors: list[str] = []
for token in token_candidates:
payload, token_error = self._validate_dashboard_token(token, path)
if payload is not None:
current_request.state.dashboard_g.username = cast(
str, payload["username"]
)
return None
token_errors.append(token_error)
error_message = (
"Token 过期"
if token_errors and all(item == "Token 过期" for item in token_errors)
else "Token 无效"
)
r = JSONResponse(error(error_message))
r.status_code = 401
return r
def _validate_dashboard_token(
self,
token: str,
path: str,
) -> tuple[dict[str, Any] | None, str]:
"""Validate a dashboard JWT or scoped plugin page asset token.
Args:
token: JWT value from the Authorization header, cookie, or query string.
path: Current request path used for plugin page asset token scope checks.
Returns:
A tuple of the decoded payload and an error message. The payload is
present only when the token is valid for the current request path.
"""
try:
payload = jwt.decode(token, self._jwt_secret, algorithms=["HS256"])
if PluginPageAuth.is_asset_token(
payload
) and not PluginPageAuth.is_scope_valid(
payload,
path,
):
r = JSONResponse(error("Token 无效"))
r.status_code = 401
return r
username = payload.get("username")
if not isinstance(username, str) or not username.strip():
raise jwt.InvalidTokenError("missing username in token payload")
current_request.state.dashboard_g.username = username
except jwt.ExpiredSignatureError:
return None, "Token 过期"
r = JSONResponse(error("Token 过期"))
r.status_code = 401
return r
except jwt.InvalidTokenError:
return None, "Token 无效"
if PluginPageAuth.is_asset_token(payload) and not PluginPageAuth.is_scope_valid(
payload,
path,
):
return None, "Token 无效"
username = payload.get("username")
if not isinstance(username, str) or not username.strip():
return None, "Token 无效"
return payload, ""
r = JSONResponse(error("Token 无效"))
r.status_code = 401
return r
async def _apply_auth_rate_limit(
self,

View File

@@ -54,7 +54,6 @@ ALL_OPEN_API_SCOPES = (
"im",
"config",
"chat",
"data",
"file",
"plugin",
"mcp",

View File

@@ -495,9 +495,7 @@ class KnowledgeBaseService:
files_to_upload = []
for file in file_list:
file_name = Path(str(file.filename or "document").replace("\\", "/")).name
if file_name in {"", ".", ".."}:
file_name = "document"
file_name = file.filename
temp_file_path = (
Path(get_astrbot_temp_path()) / f"kb_upload_{uuid.uuid4()}_{file_name}"
)

View File

@@ -872,10 +872,9 @@ class PluginService:
) -> tuple[dict, str]:
self._ensure_not_demo()
logger.info(f"正在安装用户上传的插件 {upload_file.filename}")
filename = str(upload_file.filename or "plugin.zip").replace("\\", "/")
file_path = os.path.join(
get_astrbot_temp_path(),
f"plugin_upload_{os.path.basename(filename) or 'plugin.zip'}",
f"plugin_upload_{upload_file.filename}",
)
await upload_file.save(file_path)
try:

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import ast
import asyncio
import re
import threading
@@ -26,7 +25,7 @@ from astrbot.core.utils.auth_password import (
is_default_dashboard_password,
is_md5_dashboard_password,
)
from astrbot.core.utils.io import get_dashboard_dist_version, get_dashboard_version
from astrbot.core.utils.io import get_dashboard_version
from astrbot.core.utils.storage_cleaner import StorageCleaner
from astrbot.core.utils.version_comparator import VersionComparator
from astrbot.dashboard.password_state import (
@@ -105,68 +104,6 @@ class StatService:
"password_upgrade_required": not storage_upgraded,
}
async def get_public_versions(
self,
dashboard_static_folder: str | None = None,
) -> dict:
"""Return version details that are safe to expose before login.
Args:
dashboard_static_folder: Static WebUI dist directory currently served by
the dashboard, when available.
Returns:
Public WebUI and AstrBot version information.
"""
def read_code_version() -> str | None:
"""Read the AstrBot code version from the package file.
Returns:
The version string from disk, or None when it is unavailable.
"""
version_file = Path(get_astrbot_path()) / "astrbot" / "__init__.py"
module = ast.parse(version_file.read_text(encoding="utf-8"))
for statement in module.body:
if not isinstance(statement, ast.Assign):
continue
if not any(
isinstance(target, ast.Name) and target.id == "__version__"
for target in statement.targets
):
continue
if isinstance(statement.value, ast.Constant) and isinstance(
statement.value.value,
str,
):
return statement.value.value.strip()
return None
return None
dashboard_version = None
try:
if dashboard_static_folder:
dashboard_version = get_dashboard_dist_version(
Path(dashboard_static_folder)
)
if dashboard_version is None:
dashboard_version = await get_dashboard_version()
except Exception as exc:
logger.warning("Failed to read public WebUI version: %s", exc)
code_version = None
try:
code_version = await asyncio.to_thread(read_code_version)
except Exception as exc:
logger.warning("Failed to read AstrBot code version from disk: %s", exc)
return {
"webui_version": dashboard_version,
"astrbot_version": VERSION,
"astrbot_code_version": code_version,
}
def get_start_time(self) -> dict:
return {"start_time": self.core_lifecycle.start_time}

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -86,9 +86,6 @@ export type ChatProjectRequest = {
};
export type ChatRequest = {
/**
* Caller-declared WebChat sender/session owner. This value is used as the message sender identity and may participate in sender-ID-based command permission checks. Treat chat-scoped API keys as trusted backend credentials and map or validate usernames before accepting end-user input.
*/
username?: string;
session_id?: string;
/**
@@ -194,7 +191,7 @@ export type ConversationRef = {
export type CreateApiKeyRequest = {
name: string;
scopes?: Array<('bot' | 'provider' | 'persona' | 'im' | 'config' | 'chat' | 'data' | 'file' | 'plugin' | 'mcp' | 'skill')>;
scopes?: Array<('bot' | 'provider' | 'persona' | 'im' | 'config' | 'chat' | 'file' | 'plugin' | 'mcp' | 'skill')>;
expires_at?: string;
expires_in_days?: number;
};
@@ -3094,10 +3091,6 @@ export type GetVersionResponse = (SuccessEnvelope);
export type GetVersionError = unknown;
export type GetPublicVersionsResponse = (SuccessEnvelope);
export type GetPublicVersionsError = unknown;
export type GetFirstNoticeData = {
query?: {
locale?: string;

View File

@@ -105,13 +105,6 @@ export interface VersionData {
[key: string]: unknown;
}
export interface PublicVersionData {
webui_version?: string | null;
astrbot_version?: string | null;
astrbot_code_version?: string | null;
[key: string]: unknown;
}
type StartTimeData = {
start_time?: number | string | null;
};
@@ -1730,15 +1723,6 @@ export const statsApi = {
},
};
export const publicApi = {
versions() {
return withLegacyFallback<PublicVersionData>(
openApiV1.getPublicVersions(),
() => httpClient.get<ApiEnvelope<PublicVersionData>>('/api/stat/versions'),
);
},
};
export const changelogApi = {
listVersions() {
return typed<{ versions?: string[] }>(openApiV1.listChangelogVersions());

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

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

View File

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

View File

@@ -56,19 +56,6 @@
"totpTitle": "Two-Factor Verification",
"subtitle": "Welcome"
},
"versions": {
"webui": "WebUI",
"astrbotRuntime": "AstrBot",
"astrbotCode": "AstrBot(Code)",
"mismatchTooltip": "Version mismatch",
"dialogTitle": "Version status needs attention",
"webuiMismatchTitle": "WebUI does not match the current AstrBot version",
"webuiMismatchMessage": "This WebUI may not have been built for the running AstrBot version, so some pages or APIs may behave unexpectedly. Restart AstrBot manually first. If the mismatch remains, delete data/dist in the runtime directory and restart again so AstrBot can download the matching WebUI.",
"runtimeMismatchTitle": "Running AstrBot does not match the code version",
"runtimeMismatchMessage": "The code has been updated, but the current process is still running an older version. Restart AstrBot manually to load the new code.",
"faq": "View FAQ",
"close": "Close"
},
"theme": {
"light": "Light Mode",
"dark": "Dark Mode",

View File

@@ -139,10 +139,6 @@
},
"web_search_link": {
"description": "Display Source Citations"
},
"websearch_exa_key": {
"description": "Exa API Key",
"hint": "Multiple keys can be added for rotation. Get a key at https://dashboard.exa.ai"
}
}
},
@@ -1225,22 +1221,22 @@
"hint": "Only effective for qwen3-rerank models. Recommended to write in English."
},
"nvidia_rerank_api_base": {
"description": "API Base URL"
"description": "API Base URL"
},
"nvidia_rerank_api_key": {
"description": "API Key"
"description": "API Key"
},
"nvidia_rerank_model": {
"description": "Rerank Model Name",
"hint": "Please refer to the NVIDIA Docs for the model name."
"description": "Rerank Model Name",
"hint": "Please refer to the NVIDIA Docs for the model name."
},
"nvidia_rerank_model_endpoint": {
"description": "Custom Model Endpoint",
"hint": "Custom URL suffix endpoint, defaults to /reranking."
"description": "Custom Model Endpoint",
"hint": "Custom URL suffix endpoint, defaults to /reranking."
},
"nvidia_rerank_truncate": {
"description": "Text Truncation Strategy",
"hint": "Whether to truncate the input to fit the model's maximum context length when the input text is too long."
"description": "Text Truncation Strategy",
"hint": "Whether to truncate the input to fit the model's maximum context length when the input text is too long."
},
"launch_model_if_not_running": {
"description": "Auto-start model if not running",

View File

@@ -56,19 +56,6 @@
"totpTitle": "Двухфакторная аутентификация",
"subtitle": "Добро пожаловать"
},
"versions": {
"webui": "WebUI",
"astrbotRuntime": "AstrBot",
"astrbotCode": "AstrBot(Code)",
"mismatchTooltip": "Несовпадение версий",
"dialogTitle": "Проверьте состояние версий",
"webuiMismatchTitle": "WebUI не соответствует текущей версии AstrBot",
"webuiMismatchMessage": "Этот WebUI мог быть собран не для запущенной версии AstrBot, поэтому некоторые страницы или API могут работать нестабильно. Сначала перезапустите AstrBot вручную. Если несовпадение останется, удалите data/dist в рабочем каталоге и перезапустите снова, чтобы AstrBot скачал подходящий WebUI.",
"runtimeMismatchTitle": "Запущенный AstrBot не соответствует версии кода",
"runtimeMismatchMessage": "Код уже обновлен, но текущий процесс все еще работает на старой версии. Перезапустите AstrBot вручную, чтобы загрузить новый код.",
"faq": "Открыть FAQ",
"close": "Закрыть"
},
"theme": {
"light": "Светлая тема",
"dark": "Темная тема",

View File

@@ -139,10 +139,6 @@
},
"web_search_link": {
"description": "Показывать ссылки на источники"
},
"websearch_exa_key": {
"description": "API-ключ Exa",
"hint": "Можно добавить несколько ключей для ротации. Получить ключ: https://dashboard.exa.ai"
}
}
},

View File

@@ -56,19 +56,6 @@
"totpTitle": "两步验证",
"subtitle": "欢迎使用"
},
"versions": {
"webui": "WebUI",
"astrbotRuntime": "AstrBot",
"astrbotCode": "AstrBot(Code)",
"mismatchTooltip": "版本不一致",
"dialogTitle": "版本状态需要确认",
"webuiMismatchTitle": "WebUI 与当前 AstrBot 版本不匹配",
"webuiMismatchMessage": "当前 WebUI 可能不是为此 AstrBot 版本构建,部分页面或接口可能出现异常。请先手动重启 AstrBot若仍不一致删除运行目录下的 data/dist 后再次重启AstrBot 会重新下载匹配的 WebUI。",
"runtimeMismatchTitle": "运行中的 AstrBot 与代码版本不一致",
"runtimeMismatchMessage": "代码已经更新,但当前进程仍在运行旧版本。手动重启 AstrBot 后,将加载新的代码版本。",
"faq": "查看 FAQ",
"close": "关闭"
},
"theme": {
"light": "浅色模式",
"dark": "深色模式",

View File

@@ -141,10 +141,6 @@
},
"web_search_link": {
"description": "显示来源引用"
},
"websearch_exa_key": {
"description": "Exa API Key",
"hint": "可添加多个 Key 进行轮询。获取 Key: https://dashboard.exa.ai"
}
}
},

View File

@@ -611,13 +611,7 @@ async function fetchAstrBotStartTime() {
function reloadAfterUpdate() {
stopRestartReloadTimer();
reloadWithCacheBuster();
}
function reloadWithCacheBuster() {
const url = new URL(window.location.href);
url.searchParams.set("_r", Date.now().toString());
window.location.replace(url.toString());
window.location.reload();
}
function showRestartCompleted() {
@@ -825,7 +819,7 @@ function updateDashboard() {
updateStatus.value = res.data.message || "";
if (res.data.status == "ok") {
setTimeout(() => {
reloadWithCacheBuster();
window.location.reload();
}, 1000);
}
})

View File

@@ -513,7 +513,6 @@ const availableScopes = [
{ value: 'im', label: 'im' },
{ value: 'config', label: 'config' },
{ value: 'chat', label: 'chat' },
{ value: 'data', label: 'data' },
{ value: 'file', label: 'file' },
{ value: 'plugin', label: 'plugin' },
{ value: 'mcp', label: 'mcp' },

View File

@@ -7,7 +7,7 @@ import { useRouter } from 'vue-router';
import { useCustomizerStore } from "@/stores/customizer";
import { useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import { authApi, publicApi, type PublicVersionData } from '@/api/v1';
import { authApi } from '@/api/v1';
const cardVisible = ref(false);
const router = useRouter();
@@ -16,10 +16,6 @@ const customizer = useCustomizerStore();
const { tm: t } = useModuleI18n('features/auth');
const theme = useTheme();
const authLoginRef = ref<InstanceType<typeof AuthLogin> | null>(null);
const publicVersions = ref<PublicVersionData | null>(null);
const versionDialogVisible = ref(false);
type VersionItem = { key: string; label: string; value: string };
type VersionWarning = { key: string; title: string; message: string };
const logoTitle = computed(() => {
if (authLoginRef.value?.stage === 'totp' || authLoginRef.value?.stage === 'recovery') {
@@ -45,90 +41,7 @@ const currentThemeIcon = computed(() => {
return 'mdi-white-balance-sunny';
});
const versionValues = computed(() => {
const versions = publicVersions.value;
if (!versions) {
return { webui: '', runtime: '', code: '' };
}
return {
webui: String(versions.webui_version || '').trim(),
runtime: String(versions.astrbot_version || '').trim(),
code: String(versions.astrbot_code_version || '').trim(),
};
});
const normalizedVersionValues = computed(() => {
return {
webui: versionValues.value.webui.replace(/^v/i, ''),
runtime: versionValues.value.runtime.replace(/^v/i, ''),
code: versionValues.value.code.replace(/^v/i, ''),
};
});
const versionWarnings = computed(() => {
const normalized = normalizedVersionValues.value;
const warnings: VersionWarning[] = [];
if (normalized.webui && normalized.runtime && normalized.webui !== normalized.runtime) {
warnings.push({
key: 'webui-runtime',
title: t('versions.webuiMismatchTitle'),
message: t('versions.webuiMismatchMessage'),
});
}
if (normalized.runtime && normalized.code && normalized.runtime !== normalized.code) {
warnings.push({
key: 'runtime-code',
title: t('versions.runtimeMismatchTitle'),
message: t('versions.runtimeMismatchMessage'),
});
}
return warnings;
});
const versionItems = computed(() => {
const { webui, runtime, code } = versionValues.value;
const normalized = normalizedVersionValues.value;
const items: VersionItem[] = [];
if (webui) {
items.push({
key: 'webui',
label: t('versions.webui'),
value: webui,
});
}
if (runtime) {
items.push({
key: 'astrbot',
label: t('versions.astrbotRuntime'),
value: runtime,
});
}
if (runtime && code && normalized.runtime !== normalized.code) {
items.push({
key: 'astrbot-code',
label: t('versions.astrbotCode'),
value: code,
});
}
return items;
});
onMounted(async () => {
publicApi.versions()
.then((res) => {
publicVersions.value = res.data?.data || null;
})
.catch((error) => {
if (import.meta.env.DEV) {
console.warn('Failed to load public versions:', error);
}
});
// 检查用户是否已登录,如果已登录则重定向
if (authStore.has_token()) {
const onboardingCompleted = await authStore.checkOnboardingCompleted();
@@ -227,60 +140,7 @@ onMounted(async () => {
<v-card-text>
<AuthLogin ref="authLoginRef" />
</v-card-text>
<div v-if="versionItems.length" class="login-version-info">
<span v-for="item in versionItems" :key="item.key" class="login-version-item">
<span class="login-version-label">{{ item.label }}</span>
<span class="login-version-value">{{ item.value }}</span>
</span>
<v-btn
v-if="versionWarnings.length"
class="version-help-btn"
icon
variant="text"
size="x-small"
:aria-label="t('versions.mismatchTooltip')"
@click="versionDialogVisible = true"
>
<v-icon size="16">mdi-help-circle-outline</v-icon>
<v-tooltip activator="parent" location="top">
{{ t('versions.mismatchTooltip') }}
</v-tooltip>
</v-btn>
</div>
</v-card>
<v-dialog v-model="versionDialogVisible" max-width="460">
<v-card class="version-dialog-card">
<v-card-title class="version-dialog-title">
<v-icon size="20" color="warning">mdi-alert-circle-outline</v-icon>
<span>{{ t('versions.dialogTitle') }}</span>
</v-card-title>
<v-card-text class="version-dialog-content">
<div
v-for="warning in versionWarnings"
:key="warning.key"
class="version-warning-block"
>
<div class="version-warning-title">{{ warning.title }}</div>
<div class="version-warning-message">{{ warning.message }}</div>
</div>
</v-card-text>
<v-card-actions class="version-dialog-actions">
<v-btn
href="https://docs.astrbot.app/faq.html"
target="_blank"
rel="noopener noreferrer"
variant="text"
prepend-icon="mdi-help-circle-outline"
>
{{ t('versions.faq') }}
</v-btn>
<v-spacer />
<v-btn color="primary" variant="text" @click="versionDialogVisible = false">
{{ t('versions.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
@@ -300,71 +160,4 @@ onMounted(async () => {
width: 400px;
padding: 8px;
}
.login-version-info {
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.56);
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
justify-content: center;
line-height: 1.45;
padding: 0 14px 10px;
text-align: center;
font-size: 12px;
}
.login-version-item {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.login-version-label {
margin-right: 4px;
}
.version-help-btn {
color: rgba(var(--v-theme-warning), 0.95);
margin-left: -2px;
}
.version-dialog-card {
border-radius: 8px !important;
}
.version-dialog-title {
align-items: center;
display: flex;
gap: 8px;
font-size: 17px;
line-height: 1.35;
padding-bottom: 8px;
}
.version-dialog-content {
padding-top: 4px !important;
}
.version-warning-block + .version-warning-block {
margin-top: 14px;
}
.version-warning-title {
color: rgba(var(--v-theme-on-surface), 0.88);
font-size: 14px;
font-weight: 600;
margin-bottom: 6px;
}
.version-warning-message {
color: rgba(var(--v-theme-on-surface), 0.68);
font-size: 13px;
line-height: 1.65;
}
.version-dialog-actions {
padding-top: 0;
}
</style>

View File

@@ -27,9 +27,7 @@ Set dashboard.host in data/cmd_config.json to enable remote access.
### Forgot Dashboard Password
If you forgot your AstrBot dashboard password, you can use the CLI tool `astrbot password` to change the password.
Another approach you can take is to find the `"dashboard"` field in `AstrBot/data/cmd_config.json`, for example:
If you forgot your AstrBot dashboard password, find the `"dashboard"` field in `AstrBot/data/cmd_config.json`, for example:
```json
"dashboard": {

View File

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

View File

@@ -28,9 +28,7 @@ Set dashboard.host in data/cmd_config.json to enable remote access.
### 管理面板的密码忘记了
如果你忘记了 AstrBot 管理面板的密码,你可以直接使用CLI工具`astrbot password`来更改密码
另外,你也可以在 `AstrBot/data/cmd_config.json` 配置文件中找到 `"dashboard"` 字段,如下:
如果你忘记了 AstrBot 管理面板的密码,你可以`AstrBot/data/cmd_config.json` 配置文件中找到 `"dashboard"` 字段,如下:
```json
"dashboard": {

View File

@@ -13,11 +13,11 @@ AstrBot 内置的网页搜索功能依赖大模型提供 `函数调用` 能力
等等带有搜索意味的提示让大模型触发调用搜索工具。
AstrBot 当前支持 6 种网页搜索源接入方式:`Tavily``BoCha``百度 AI 搜索``Brave``Firecrawl``Exa`
AstrBot 当前支持 5 种网页搜索源接入方式:`Tavily``BoCha``百度 AI 搜索``Brave``Firecrawl`
![image](https://files.astrbot.app/docs/source/images/websearch/image.png)
进入 `配置`,下拉找到网页搜索,您可选择 `Tavily``BoCha``百度 AI 搜索``Brave``Firecrawl``Exa`
进入 `配置`,下拉找到网页搜索,您可选择 `Tavily``BoCha``百度 AI 搜索``Brave``Firecrawl`
### Tavily
@@ -39,10 +39,6 @@ AstrBot 当前支持 6 种网页搜索源接入方式:`Tavily`、`BoCha`、`
前往 [Firecrawl](https://firecrawl.dev) 获取 API Key然后填写在相应的配置项。
### Exa
前往 [Exa](https://dashboard.exa.ai) 获取 API Key然后填写在相应的配置项。Exa 是一个 AI 原生搜索引擎,支持关键词和语义搜索,提供分类过滤、域名限制和日期范围等高级搜索功能。
如果您使用 Tavily 作为网页搜索源,在 AstrBot ChatUI 上将会获得更好的体验优化,包括引用来源展示等:
![](https://files.astrbot.app/docs/source/images/websearch/image1.png)

View File

@@ -8,7 +8,7 @@ info:
JSON objects because their schemas are provided at runtime by template
endpoints.
Developer API keys currently support these scopes only: bot, provider,
persona, im, config, chat, data, file, plugin, mcp, skill. The config scope also
persona, im, config, chat, file, plugin, mcp, skill. The config scope also
grants bot and provider access.
servers:
- url: http://localhost:6185
@@ -4135,16 +4135,6 @@ paths:
"200":
$ref: "#/components/responses/Ok"
/api/v1/stats/versions:
get:
tags: [Stats]
summary: Get public WebUI and AstrBot versions
operationId: getPublicVersions
security: []
responses:
"200":
$ref: "#/components/responses/Ok"
/api/v1/stats/first-notice:
get:
tags: [Stats]
@@ -5137,8 +5127,8 @@ components:
type: array
items:
type: string
enum: [bot, provider, persona, im, config, chat, data, file, plugin, mcp, skill]
example: [bot, provider, persona, im, config, chat, data, file, plugin, mcp, skill]
enum: [bot, provider, persona, im, config, chat, file, plugin, mcp, skill]
example: [bot, provider, persona, im, config, chat, file, plugin, mcp, skill]
expires_at:
type: string
format: date-time

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.26.0-beta.12"
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" }
@@ -121,7 +121,7 @@ exclude = ["dashboard", "node_modules", "dist", "data", "tests"]
allow-direct-references = true
# Include bundled dashboard dist even though it is not tracked by VCS.
[tool.hatch.build]
[tool.hatch.build.targets.wheel]
artifacts = ["astrbot/dashboard/dist/**"]
# Custom build hook: builds the Vue dashboard and copies dist into the package.

View File

@@ -12,7 +12,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from astrbot.core.agent.context.config import ContextConfig
from astrbot.core.agent.context.manager import ContextManager
from astrbot.core.agent.context.round_utils import count_conversation_rounds
from astrbot.core.agent.message import AudioURLPart, ImageURLPart, Message, TextPart
from astrbot.core.provider.entities import LLMResponse
@@ -43,26 +42,6 @@ class MockProvider:
return MagicMock(id="test_provider", type="openai")
class MessageCountTokenCounter:
"""Token counter that assigns a fixed cost to each message."""
def count_tokens(
self, messages: list[Message], trusted_token_usage: int = 0
) -> int:
"""Count tokens by message count for deterministic tests.
Args:
messages: The messages to count.
trusted_token_usage: A trusted token count to return when present.
Returns:
The deterministic token count.
"""
if trusted_token_usage > 0:
return trusted_token_usage
return len(messages) * 100
class TestContextManager:
"""Test suite for ContextManager."""
@@ -488,74 +467,6 @@ class TestContextManager:
assert len(system_msgs) >= 1
assert system_msgs[0].content == "System instruction"
@pytest.mark.asyncio
async def test_llm_enforce_max_turns_uses_summary_first(self):
"""LLM strategy should summarize before falling back to turn truncation."""
provider = MockProvider()
config = ContextConfig(
enforce_max_turns=1,
llm_compress_provider=provider, # type: ignore[arg-type]
llm_compress_keep_recent_ratio=0,
)
manager = ContextManager(config)
messages = [
self.create_message("user", "First"),
self.create_message("assistant", "First answer"),
self.create_message("user", "Second"),
self.create_message("assistant", "Second answer"),
self.create_message("user", "Continue"),
]
result = await manager.process(messages)
assert provider.last_text_chat_kwargs is not None
assert len(result) < len(messages)
assert result[-1] is messages[-1]
@pytest.mark.asyncio
async def test_llm_enforce_max_turns_caps_recent_rounds(self):
"""LLM summary should cap exact recent rounds for max-turn enforcement."""
provider = MockProvider()
config = ContextConfig(
enforce_max_turns=2,
llm_compress_provider=provider, # type: ignore[arg-type]
llm_compress_keep_recent_ratio=0.3,
custom_token_counter=MessageCountTokenCounter(),
)
manager = ContextManager(config)
messages = self.create_messages(20)
result = await manager.process(messages)
assert provider.last_text_chat_kwargs is not None
assert count_conversation_rounds(result) <= config.enforce_max_turns
@pytest.mark.asyncio
async def test_enforce_max_turns_counts_tool_chain_as_one_round(self):
"""Tool messages in one round should not inflate turn count."""
config = ContextConfig(enforce_max_turns=1, truncate_turns=1)
manager = ContextManager(config)
messages = [
self.create_message("user", "Run a tool"),
Message(
role="assistant",
content="Calling tool",
tool_calls=[
{
"id": "call_1",
"type": "function",
"function": {"name": "lookup", "arguments": "{}"},
}
],
),
Message(role="tool", content="Tool result", tool_call_id="call_1"),
self.create_message("assistant", "Done"),
]
result = await manager.process(messages)
assert result == messages
# ==================== Token-based Compression Tests ====================
@pytest.mark.asyncio
@@ -1055,27 +966,6 @@ class TestContextManager:
# Should have been compressed
assert len(result) <= len(messages)
@pytest.mark.asyncio
async def test_llm_failure_falls_back_until_token_threshold(self):
"""Failed LLM compression should hard truncate until tokens are acceptable."""
mock_provider = MockProvider()
mock_provider.text_chat = AsyncMock(
return_value=LLMResponse(role="err", completion_text="compress failed")
)
config = ContextConfig(
max_context_tokens=300,
truncate_turns=1,
llm_compress_provider=mock_provider, # type: ignore[arg-type]
custom_token_counter=MessageCountTokenCounter(),
)
manager = ContextManager(config)
messages = self.create_messages(10)
result = await manager.process(messages)
assert len(result) == 2
assert manager.token_counter.count_tokens(result) <= 246
# ==================== split_into_rounds Tests ====================
def test_split_rounds_ensures_user_start(self):

View File

@@ -134,32 +134,6 @@ class TestContextTruncator:
assert len(result) == 6
assert result == messages
def test_truncate_by_turns_counts_tool_chain_as_one_round(self):
"""Tool calls/results inside one round should not count as extra turns."""
truncator = ContextTruncator()
messages = [
self.create_message("user", "Run a tool"),
Message(
role="assistant",
content="Calling tool",
tool_calls=[
{
"id": "call_1",
"type": "function",
"function": {"name": "lookup", "arguments": "{}"},
}
],
),
Message(role="tool", content="Tool result", tool_call_id="call_1"),
self.create_message("assistant", "Done"),
]
result = truncator.truncate_by_turns(
messages, keep_most_recent_turns=1, drop_turns=1
)
assert result == messages
def test_truncate_by_turns_ensures_user_first(self):
"""Test that truncate_by_turns ensures user message comes first."""
truncator = ContextTruncator()

View File

@@ -1248,23 +1248,6 @@ async def test_version_endpoints_use_md5_password_hint(
assert _removed_md5_hint_alias_key() not in data["data"]
@pytest.mark.asyncio
async def test_public_versions_endpoint_does_not_require_auth(app: FastAPIAppAdapter):
test_client = app.test_client()
response = await test_client.get("/api/stat/versions")
data = await response.get_json()
assert response.status_code == 200
assert data["status"] == "ok"
assert data["data"]["astrbot_version"]
assert "webui_version" in data["data"]
assert "astrbot_code_version" in data["data"]
assert "change_pwd_hint" not in data["data"]
assert "md5_pwd_hint" not in data["data"]
assert "password_upgrade_required" not in data["data"]
def test_password_hash_lookup_falls_back_to_md5_when_pbkdf2_missing(
core_lifecycle_td: AstrBotCoreLifecycle,
):
@@ -1773,12 +1756,6 @@ async def test_plugin_page_content_issues_scoped_asset_token(
css_response = await anonymous_client.get(css_url.group(1))
assert css_response.status_code == 200
stale_cookie_response = await anonymous_client.get(
app_js_url.group(1),
headers={"Cookie": f"{DASHBOARD_JWT_COOKIE_NAME}=stale.dashboard.token"},
)
assert stale_cookie_response.status_code == 200
out_of_scope_response = await anonymous_client.get(
f"/api/plugin/get?asset_token={asset_token}"
)

View File

@@ -981,40 +981,6 @@ def _jwt_headers() -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
@pytest.mark.asyncio
async def test_public_versions_route_uses_static_folder(
fake_core_lifecycle,
fake_db: FakeDb,
tmp_path: Path,
):
static_folder = tmp_path / "dist"
assets_folder = static_folder / "assets"
assets_folder.mkdir(parents=True)
(static_folder / "index.html").write_text("<!doctype html>", encoding="utf-8")
(assets_folder / "version").write_text("v9.8.7", encoding="utf-8")
app = create_dashboard_asgi_app(
core_lifecycle=fake_core_lifecycle,
db=fake_db,
jwt_secret=JWT_SECRET,
static_folder=str(static_folder),
)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
response = await client.get("/api/v1/stats/versions")
data = response.json()
assert response.status_code == 200
assert data["status"] == "ok"
assert data["data"]["webui_version"] == "v9.8.7"
assert data["data"]["astrbot_version"]
assert "astrbot_code_version" in data["data"]
def test_fastapi_app_adapter_registers_on_app_state():
app = FastAPI()
adapter = FastAPIAppAdapter(app)

View File

@@ -2,15 +2,10 @@
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
from zoneinfo import ZoneInfo
import pytest
from astrbot.core.cron.manager import (
CronJobManager,
CronJobSchedulingError,
_normalize_crontab_day_of_week,
)
from astrbot.core.cron.manager import CronJobManager, CronJobSchedulingError
from astrbot.core.db.po import CronJob
@@ -374,15 +369,6 @@ class TestRemoveScheduled:
class TestScheduleJob:
"""Tests for _schedule_job method."""
def test_normalize_crontab_day_of_week(self):
"""Test standard crontab weekday numbers are normalized."""
assert _normalize_crontab_day_of_week("0") == "sun"
assert _normalize_crontab_day_of_week("7") == "sun"
assert _normalize_crontab_day_of_week("1-5") == "mon,tue,wed,thu,fri"
assert _normalize_crontab_day_of_week("*/2") == "sun,tue,thu,sat"
assert _normalize_crontab_day_of_week("0-6") == "*"
assert _normalize_crontab_day_of_week("mon-fri") == "mon-fri"
@pytest.mark.asyncio
async def test_schedule_job_basic(
self, cron_manager, sample_cron_job, mock_context
@@ -397,30 +383,6 @@ class TestScheduleJob:
# Verify job was added to scheduler
assert cron_manager.scheduler.get_job("test-job-id") is not None
@pytest.mark.asyncio
async def test_schedule_job_uses_standard_crontab_weekday_numbers(
self, cron_manager, sample_cron_job, mock_context
):
"""Test Sunday=0 crontab jobs are scheduled for Sunday."""
sample_cron_job.cron_expression = "0 9 * * 0"
sample_cron_job.timezone = "Asia/Shanghai"
mock_db = cron_manager.db
mock_db.list_cron_jobs = AsyncMock(return_value=[])
mock_db.update_cron_job = AsyncMock()
await cron_manager.start(mock_context)
cron_manager._schedule_job(sample_cron_job)
aps_job = cron_manager.scheduler.get_job("test-job-id")
assert aps_job is not None
next_fire_time = aps_job.trigger.get_next_fire_time(
None,
datetime(2026, 6, 22, tzinfo=ZoneInfo("Asia/Shanghai")),
)
assert next_fire_time == datetime(
2026, 6, 28, 9, 0, tzinfo=ZoneInfo("Asia/Shanghai")
)
@pytest.mark.asyncio
async def test_schedule_job_with_timezone(
self, cron_manager, sample_cron_job, mock_context

View File

@@ -378,138 +378,3 @@ def _context_with_provider_settings(provider_settings):
event=SimpleNamespace(unified_msg_origin="test:private:session"),
)
return SimpleNamespace(context=agent_context)
# --- Exa tests ---
def test_normalize_legacy_web_search_config_migrates_exa_key():
config = _FakeConfig({"provider_settings": {"websearch_exa_key": "exa-key"}})
tools.normalize_legacy_web_search_config(config)
assert config["provider_settings"]["websearch_exa_key"] == ["exa-key"]
assert config.saved is True
@pytest.mark.asyncio
async def test_exa_search_maps_results(monkeypatch):
async def fake_exa_search(provider_settings, payload):
assert provider_settings["websearch_exa_key"] == ["exa-key"]
assert payload["query"] == "AstrBot"
assert payload["numResults"] == 5
return [
tools.SearchResult(
title="AstrBot",
url="https://example.com",
snippet="AI Agent Assistant",
)
]
monkeypatch.setattr(tools, "_exa_search", fake_exa_search)
tool = tools.ExaWebSearchTool()
context = _context_with_provider_settings({"websearch_exa_key": ["exa-key"]})
result = await tool.call(context, query="AstrBot", num_results=5)
parsed = json.loads(result)
assert parsed["results"][0]["title"] == "AstrBot"
assert parsed["results"][0]["url"] == "https://example.com"
assert parsed["results"][0]["snippet"] == "AI Agent Assistant"
@pytest.mark.asyncio
async def test_exa_search_raw_api_call(monkeypatch):
session = _FakeFirecrawlSession(
_FakeFirecrawlResponse(
status=200,
json_data={
"results": [
{
"title": "AstrBot",
"url": "https://example.com",
"text": "AI Agent Assistant",
}
],
},
)
)
def fake_client_session(*, trust_env):
session.trust_env = trust_env
return session
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
results = await tools._exa_search(
{"websearch_exa_key": ["exa-key"]},
{"query": "AstrBot", "numResults": 10, "type": "auto"},
)
assert session.posted["url"] == "https://api.exa.ai/search"
assert session.posted["headers"]["x-api-key"] == "exa-key"
assert results == [
tools.SearchResult(
title="AstrBot", url="https://example.com", snippet="AI Agent Assistant"
)
]
@pytest.mark.asyncio
async def test_exa_search_raises_on_http_error(monkeypatch):
session = _FakeFirecrawlSession(
_FakeFirecrawlResponse(status=401, text_data="Unauthorized")
)
def fake_client_session(*, trust_env):
session.trust_env = trust_env
return session
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
with pytest.raises(
Exception,
match="Exa web search failed: Unauthorized, status: 401",
):
await tools._exa_search(
{"websearch_exa_key": ["exa-key"]},
{"query": "AstrBot"},
)
@pytest.mark.asyncio
async def test_exa_get_contents_returns_text(monkeypatch):
async def fake_exa_get_contents(provider_settings, payload):
assert provider_settings["websearch_exa_key"] == ["exa-key"]
assert payload["ids"] == ["https://example.com"]
return [{"url": "https://example.com", "text": "# Example Content"}]
monkeypatch.setattr(tools, "_exa_get_contents", fake_exa_get_contents)
tool = tools.ExaGetContentsTool()
context = _context_with_provider_settings({"websearch_exa_key": ["exa-key"]})
result = await tool.call(context, url="https://example.com")
assert result == "URL: https://example.com\nContent: # Example Content"
@pytest.mark.asyncio
async def test_exa_get_contents_raises_on_http_error(monkeypatch):
session = _FakeFirecrawlSession(
_FakeFirecrawlResponse(status=403, text_data="Forbidden")
)
def fake_client_session(*, trust_env):
session.trust_env = trust_env
return session
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
with pytest.raises(
Exception,
match="Exa get contents failed: Forbidden, status: 403",
):
await tools._exa_get_contents(
{"websearch_exa_key": ["exa-key"]},
{"ids": ["https://example.com"]},
)