diff --git a/astrbot/cli/__init__.py b/astrbot/cli/__init__.py index bf4d023cf..51655eb9e 100644 --- a/astrbot/cli/__init__.py +++ b/astrbot/cli/__init__.py @@ -1 +1 @@ -__version__ = "4.25.1" +__version__ = "4.30.0-dev" diff --git a/astrbot/core/agent/message.py b/astrbot/core/agent/message.py index 985b4f235..157326795 100644 --- a/astrbot/core/agent/message.py +++ b/astrbot/core/agent/message.py @@ -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", diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 544443377..5bfbe79bf 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -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( diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 2e1c65f4b..f5f8293b6 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -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": { diff --git a/astrbot/core/tools/registry.py b/astrbot/core/tools/registry.py index 1b120121f..fa5787582 100644 --- a/astrbot/core/tools/registry.py +++ b/astrbot/core/tools/registry.py @@ -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, *, diff --git a/astrbot/core/zip_updator.py b/astrbot/core/zip_updator.py index dc813542c..e05f2c0ec 100644 --- a/astrbot/core/zip_updator.py +++ b/astrbot/core/zip_updator.py @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 056880540..1a471e747 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/tests/dashboard/test_frontend_imports.py b/tests/dashboard/test_frontend_imports.py new file mode 100644 index 000000000..4abaa0564 --- /dev/null +++ b/tests/dashboard/test_frontend_imports.py @@ -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) + )