fix: resolve merge follow-ups (typing, imports, frontend tests)

This commit is contained in:
LIghtJUNction
2026-05-19 21:16:25 +08:00
parent 987099d4cb
commit 92c58f8a2a
8 changed files with 147 additions and 31 deletions

View File

@@ -1 +1 @@
__version__ = "4.25.1"
__version__ = "4.30.0-dev"

View File

@@ -81,7 +81,7 @@ class ContentPart(BaseModel):
class TextPart(ContentPart):
""">>> TextPart(text="Hello, world!").model_dump()
"""TextPart(text="Hello, world!").model_dump()
{'type': 'text', 'text': 'Hello, world!'}
"""
@@ -90,7 +90,7 @@ class TextPart(ContentPart):
class ThinkPart(ContentPart):
""">>> ThinkPart(think="I think I need to think about this.").model_dump()
"""ThinkPart(think="I think I need to think about this.").model_dump()
{'type': 'think', 'think': 'I think I need to think about this.', 'encrypted': None}
"""
@@ -111,7 +111,7 @@ class ThinkPart(ContentPart):
class ImageURLPart(ContentPart):
""">>> ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
"""ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
{'type': 'image_url', 'image_url': 'http://example.com/image.jpg'}
"""
@@ -126,7 +126,7 @@ class ImageURLPart(ContentPart):
class AudioURLPart(ContentPart):
""">>> AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")).model_dump()
"""AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")).model_dump()
{'type': 'audio_url', 'audio_url': {'url': 'https://example.com/audio.mp3', 'id': None}}
"""
@@ -143,7 +143,7 @@ class AudioURLPart(ContentPart):
class ToolCall(BaseModel):
"""A tool call requested by the assistant.
>>> ToolCall(
ToolCall(
... id="123",
... function=ToolCall.FunctionBody(
... name="function",

View File

@@ -48,10 +48,9 @@ from astrbot.core.provider.modalities import (
)
from astrbot.core.provider.provider import Provider
from ..context.compressor import ContextCompressor
from ..context.config import ContextConfig
from ..context.manager import ContextManager
from ..context.token_counter import EstimateTokenCounter, TokenCounter
from ..context.token_counter import EstimateTokenCounter
from ..hooks import BaseAgentRunHooks
from ..message import (
AssistantMessageSegment,
@@ -187,7 +186,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
think=llm_resp.reasoning_content,
think=llm_resp.reasoning_content or "",
encrypted=llm_resp.reasoning_signature,
),
)
@@ -222,10 +221,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# truncate by turns compressor
truncate_turns: int = 1,
# customize
custom_token_counter: TokenCounter | None = None,
custom_compressor: ContextCompressor | None = None,
custom_token_counter: T.Any = None,
custom_compressor: T.Any = None,
tool_schema_mode: str | None = "full",
fallback_providers: list[Provider] | None = None,
provider_config: dict | None = None,
tool_result_overflow_dir: str | None = None,
read_tool: FunctionTool | None = None,
**kwargs: T.Any,
@@ -462,22 +462,29 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
include_model: bool = True,
) -> T.AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse."""
payload = {
"contexts": self._sanitize_contexts_for_provider(self.run_context.messages),
"func_tool": self._func_tool_for_provider(),
"session_id": self.req.session_id,
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
"abort_signal": self._abort_signal,
}
if include_model:
# For primary provider we keep explicit model selection if provided.
payload["model"] = self.req.model
contexts = self._sanitize_contexts_for_provider(self.run_context.messages)
func_tool = self._func_tool_for_provider()
model = self.req.model if include_model else None
if self.streaming:
stream = self.provider.text_chat_stream(**payload)
async for resp in stream: # type: ignore
stream = self.provider.text_chat_stream(
contexts=contexts,
func_tool=func_tool,
session_id=self.req.session_id,
extra_user_content_parts=self.req.extra_user_content_parts,
abort_signal=self._abort_signal,
model=model,
)
async for resp in stream:
yield resp
else:
yield await self.provider.text_chat(**payload)
yield await self.provider.text_chat(
contexts=contexts,
func_tool=func_tool,
session_id=self.req.session_id,
extra_user_content_parts=self.req.extra_user_content_parts,
abort_signal=self._abort_signal,
model=model,
)
async def _iter_llm_responses_with_fallback(
self,
@@ -865,7 +872,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
think=llm_resp.reasoning_content,
think=llm_resp.reasoning_content or "",
encrypted=llm_resp.reasoning_signature,
),
)
@@ -1360,7 +1367,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
think=llm_resp.reasoning_content,
think=llm_resp.reasoning_content or "",
encrypted=llm_resp.reasoning_signature,
),
)
@@ -1387,6 +1394,12 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
with suppress(asyncio.CancelledError, RuntimeError, StopAsyncIteration):
await close_executor()
async def _anext_coro(
self,
ait: AsyncIterator[ToolExecutorResultT],
) -> ToolExecutorResultT:
return await anext(ait)
async def _iter_tool_executor_results(
self,
executor: AsyncIterator[ToolExecutorResultT],
@@ -1398,7 +1411,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
"Tool execution interrupted before reading the next tool result.",
)
next_result_task = asyncio.create_task(anext(executor))
next_result_task = asyncio.create_task(self._anext_coro(executor))
abort_task = asyncio.create_task(self._abort_signal.wait())
try:
done, _ = await asyncio.wait(

View File

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

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from importlib import import_module
from typing import Any, TypeVar
from typing import Any, TypeVar, overload
from astrbot.core.agent.tool import FunctionTool
@@ -213,6 +213,16 @@ def _resolve_builtin_tool_name(tool_cls: type[FunctionTool]) -> str:
)
@overload
def builtin_tool(tool_cls: TFunctionTool) -> TFunctionTool: ...
@overload
def builtin_tool(
*, config: dict[str, Any] | None = None,
) -> Callable[[TFunctionTool], TFunctionTool]: ...
def builtin_tool(
tool_cls: TFunctionTool | None = None,
*,

View File

@@ -178,7 +178,7 @@ class RepoZipUpdator:
def unzip(self) -> NoReturn:
raise NotImplementedError
async def update(self) -> NoReturn:
async def update(self, **kwargs: Any) -> None:
raise NotImplementedError
def compare_version(self, v1: str, v2: str) -> int:

View File

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

View File

@@ -0,0 +1,93 @@
"""Regression tests for frontend HTTP client conventions.
Ensures dashboard source files always use the project's custom request
module instead of importing axios directly, and that API fetch URLs are
resolved through ``resolveApiUrl``.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
DASHBOARD_SRC = Path(__file__).parent.parent.parent / "dashboard" / "src"
# Files that are allowed to import axios directly (the wrapper itself).
AXIOS_ALLOWLIST: set[str] = {
"dashboard/src/utils/request.ts",
}
def _iter_source_files() -> list[Path]:
"""Return all .ts and .vue files under dashboard/src."""
return sorted(DASHBOARD_SRC.rglob("*.ts")) + sorted(DASHBOARD_SRC.rglob("*.vue"))
def _relative(path: Path) -> str:
"""Return a repo-relative path string for readable error messages."""
parts = path.parts
try:
idx = parts.index("dashboard")
except ValueError:
return str(path)
return "/".join(parts[idx:])
class TestDashboardHttpClientConventions:
"""Prevent direct axios imports and bare API fetch URLs."""
def test_no_direct_axios_imports(self) -> None:
"""Source files (except the wrapper) must not import plain axios."""
direct_axios_pattern = re.compile(
r"import\s+axios\s+from\s+['\"]axios['\"]",
re.IGNORECASE,
)
violations: list[str] = []
for src in _iter_source_files():
rel = _relative(src)
if rel in AXIOS_ALLOWLIST:
continue
text = src.read_text(encoding="utf-8")
if direct_axios_pattern.search(text):
violations.append(rel)
if violations:
pytest.fail(
"Direct axios imports found. Use `@/utils/request` instead:\n - "
+ "\n - ".join(violations)
)
def test_api_fetch_urls_use_resolve_api_url(self) -> None:
"""fetch() calls hitting /api/* must wrap the URL in resolveApiUrl()."""
# Match fetch calls whose first arg starts with "/api/" and is NOT
# already wrapped in resolveApiUrl.
bare_api_fetch_pattern = re.compile(
r"fetch\s*\(\s*['\"](/api/[^'\"]+)['\"]",
)
# Allowlist for legitimate non-API fetches (i18n, config, external).
fetch_allowlist: set[str] = {
# i18n loader loads JSON translation modules, not backend API.
"dashboard/src/i18n/loader.ts",
# main.ts may fetch runtime config before axios is configured.
"dashboard/src/main.ts",
}
violations: list[str] = []
for src in _iter_source_files():
rel = _relative(src)
if rel in fetch_allowlist:
continue
text = src.read_text(encoding="utf-8")
for match in bare_api_fetch_pattern.finditer(text):
url = match.group(1)
# SSE/live-log endpoints are handled via resolveApiUrl in stores/common.ts.
violations.append(f"{rel}: fetch('{url}')")
if violations:
pytest.fail(
"Bare fetch() calls to API paths must use resolveApiUrl():\n - "
+ "\n - ".join(violations)
)