mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-02 18:50:15 +08:00
Compare commits
1 Commits
codex/cont
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c53b82534 |
2
.github/workflows/build-docs.yml
vendored
2
.github/workflows/build-docs.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/code-format.yml
vendored
2
.github/workflows/code-format.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/coverage_test.yml
vendored
2
.github/workflows/coverage_test.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
2
.github/workflows/dashboard_ci.yml
vendored
2
.github/workflows/dashboard_ci.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/docker-image.yml
vendored
4
.github/workflows/docker-image.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/smoke_test.yml
vendored
2
.github/workflows/smoke_test.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
2
.github/workflows/sync-wiki.yml
vendored
2
.github/workflows/sync-wiki.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/unit_tests.yml
vendored
2
.github/workflows/unit_tests.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3094,10 +3094,6 @@ export type GetVersionResponse = (SuccessEnvelope);
|
||||
|
||||
export type GetVersionError = unknown;
|
||||
|
||||
export type GetPublicVersionsResponse = (SuccessEnvelope);
|
||||
|
||||
export type GetPublicVersionsError = unknown;
|
||||
|
||||
export type GetFirstNoticeData = {
|
||||
query?: {
|
||||
locale?: string;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Темная тема",
|
||||
|
||||
@@ -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": "深色模式",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user