Compare commits

..

7 Commits

15 changed files with 206 additions and 128 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.10'
python-version: '3.12'
- name: Install UV
run: pip install uv
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.17.2"
__version__ = "4.17.3"
+8 -4
View File
@@ -5,8 +5,9 @@ import mcp
from astrbot.api import FunctionTool
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent
from astrbot.core.computer.computer_client import get_booter, get_local_booter
from astrbot.core.message.message_event_result import MessageChain
param_schema = {
"type": "object",
@@ -25,7 +26,7 @@ param_schema = {
}
def handle_result(result: dict) -> ToolExecResult:
async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult:
data = result.get("data", {})
output = data.get("output", {})
error = data.get("error", "")
@@ -44,6 +45,9 @@ def handle_result(result: dict) -> ToolExecResult:
type="image", data=img["image/png"], mimeType="image/png"
)
)
if event.get_platform_name() == "webchat":
await event.send(message=MessageChain().base64_image(img["image/png"]))
if text:
resp.content.append(mcp.types.TextContent(type="text", text=text))
@@ -68,7 +72,7 @@ class PythonTool(FunctionTool):
)
try:
result = await sb.python.exec(code, silent=silent)
return handle_result(result)
return await handle_result(result, context.context.event)
except Exception as e:
return f"Error executing code: {str(e)}"
@@ -89,6 +93,6 @@ class LocalPythonTool(FunctionTool):
sb = get_local_booter()
try:
result = await sb.python.exec(code, silent=silent)
return handle_result(result)
return await handle_result(result, context.context.event)
except Exception as e:
return f"Error executing code: {str(e)}"
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.17.2"
VERSION = "4.17.3"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
+26 -22
View File
@@ -25,10 +25,14 @@ import asyncio
import base64
import json
import os
import sys
import uuid
from enum import Enum
from pydantic.v1 import BaseModel
if sys.version_info >= (3, 14):
from pydantic import BaseModel
else:
from pydantic.v1 import BaseModel
from astrbot.core import astrbot_config, file_token_service, logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
@@ -85,7 +89,7 @@ class BaseMessageComponent(BaseModel):
class Plain(BaseMessageComponent):
type = ComponentType.Plain
type: ComponentType = ComponentType.Plain
text: str
convert: bool | None = True
@@ -100,7 +104,7 @@ class Plain(BaseMessageComponent):
class Face(BaseMessageComponent):
type = ComponentType.Face
type: ComponentType = ComponentType.Face
id: int
def __init__(self, **_) -> None:
@@ -108,7 +112,7 @@ class Face(BaseMessageComponent):
class Record(BaseMessageComponent):
type = ComponentType.Record
type: ComponentType = ComponentType.Record
file: str | None = ""
magic: bool | None = False
url: str | None = ""
@@ -215,7 +219,7 @@ class Record(BaseMessageComponent):
class Video(BaseMessageComponent):
type = ComponentType.Video
type: ComponentType = ComponentType.Video
file: str
cover: str | None = ""
c: int | None = 2
@@ -301,7 +305,7 @@ class Video(BaseMessageComponent):
class At(BaseMessageComponent):
type = ComponentType.At
type: ComponentType = ComponentType.At
qq: int | str # 此处str为all时代表所有人
name: str | None = ""
@@ -323,28 +327,28 @@ class AtAll(At):
class RPS(BaseMessageComponent): # TODO
type = ComponentType.RPS
type: ComponentType = ComponentType.RPS
def __init__(self, **_) -> None:
super().__init__(**_)
class Dice(BaseMessageComponent): # TODO
type = ComponentType.Dice
type: ComponentType = ComponentType.Dice
def __init__(self, **_) -> None:
super().__init__(**_)
class Shake(BaseMessageComponent): # TODO
type = ComponentType.Shake
type: ComponentType = ComponentType.Shake
def __init__(self, **_) -> None:
super().__init__(**_)
class Share(BaseMessageComponent):
type = ComponentType.Share
type: ComponentType = ComponentType.Share
url: str
title: str
content: str | None = ""
@@ -355,7 +359,7 @@ class Share(BaseMessageComponent):
class Contact(BaseMessageComponent): # TODO
type = ComponentType.Contact
type: ComponentType = ComponentType.Contact
_type: str # type 字段冲突
id: int | None = 0
@@ -364,7 +368,7 @@ class Contact(BaseMessageComponent): # TODO
class Location(BaseMessageComponent): # TODO
type = ComponentType.Location
type: ComponentType = ComponentType.Location
lat: float
lon: float
title: str | None = ""
@@ -375,7 +379,7 @@ class Location(BaseMessageComponent): # TODO
class Music(BaseMessageComponent):
type = ComponentType.Music
type: ComponentType = ComponentType.Music
_type: str
id: int | None = 0
url: str | None = ""
@@ -392,7 +396,7 @@ class Music(BaseMessageComponent):
class Image(BaseMessageComponent):
type = ComponentType.Image
type: ComponentType = ComponentType.Image
file: str | None = ""
_type: str | None = ""
subType: int | None = 0
@@ -507,7 +511,7 @@ class Image(BaseMessageComponent):
class Reply(BaseMessageComponent):
type = ComponentType.Reply
type: ComponentType = ComponentType.Reply
id: str | int
"""所引用的消息 ID"""
chain: list["BaseMessageComponent"] | None = []
@@ -543,7 +547,7 @@ class Poke(BaseMessageComponent):
class Forward(BaseMessageComponent):
type = ComponentType.Forward
type: ComponentType = ComponentType.Forward
id: str
def __init__(self, **_) -> None:
@@ -553,7 +557,7 @@ class Forward(BaseMessageComponent):
class Node(BaseMessageComponent):
"""群合并转发消息"""
type = ComponentType.Node
type: ComponentType = ComponentType.Node
id: int | None = 0 # 忽略
name: str | None = "" # qq昵称
uin: str | None = "0" # qq号
@@ -605,7 +609,7 @@ class Node(BaseMessageComponent):
class Nodes(BaseMessageComponent):
type = ComponentType.Nodes
type: ComponentType = ComponentType.Nodes
nodes: list[Node]
def __init__(self, nodes: list[Node], **_) -> None:
@@ -631,7 +635,7 @@ class Nodes(BaseMessageComponent):
class Json(BaseMessageComponent):
type = ComponentType.Json
type: ComponentType = ComponentType.Json
data: dict
def __init__(self, data: str | dict, **_) -> None:
@@ -641,14 +645,14 @@ class Json(BaseMessageComponent):
class Unknown(BaseMessageComponent):
type = ComponentType.Unknown
type: ComponentType = ComponentType.Unknown
text: str
class File(BaseMessageComponent):
"""文件消息段"""
type = ComponentType.File
type: ComponentType = ComponentType.File
name: str | None = "" # 名字
file_: str | None = "" # 本地路径
url: str | None = "" # url
@@ -783,7 +787,7 @@ class File(BaseMessageComponent):
class WechatEmoji(BaseMessageComponent):
type = ComponentType.WechatEmoji
type: ComponentType = ComponentType.WechatEmoji
md5: str | None = ""
md5_len: int | None = 0
cdnurl: str | None = ""
@@ -22,7 +22,6 @@ from astrbot.core.utils.network_utils import (
)
from ..register import register_provider_adapter
from .default import with_model_request_retry
@register_provider_adapter(
@@ -205,7 +204,6 @@ class ProviderAnthropic(Provider):
if usage.output_tokens is not None:
token_usage.output = usage.output_tokens
@with_model_request_retry()
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
if tools:
if tool_list := tools.get_func_desc_anthropic_style():
@@ -267,10 +265,6 @@ class ProviderAnthropic(Provider):
return llm_response
@with_model_request_retry()
async def _create_message_stream(self, payloads: dict, extra_body: dict):
return self.client.messages.stream(**payloads, extra_body=extra_body)
async def _query_stream(
self,
payloads: dict,
@@ -299,8 +293,9 @@ class ProviderAnthropic(Provider):
"type": "enabled",
}
stream_ctx = await self._create_message_stream(payloads, extra_body)
async with stream_ctx as stream:
async with self.client.messages.stream(
**payloads, extra_body=extra_body
) as stream:
assert isinstance(stream, anthropic.AsyncMessageStream)
async for event in stream:
if event.type == "message_start":
-38
View File
@@ -1,38 +0,0 @@
from tenacity import (
AsyncRetrying,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
MODEL_REQUEST_RETRY_ATTEMPTS = 5
MODEL_REQUEST_RETRY_WAIT_MAX_SECONDS = 15
MODEL_REQUEST_RETRY_WAIT_MIN_SECONDS = 1
MODEL_REQUEST_RETRY_WAIT_MULTIPLIER = 1
def with_model_request_retry():
return retry(
retry=retry_if_exception_type(Exception),
stop=stop_after_attempt(MODEL_REQUEST_RETRY_ATTEMPTS),
wait=wait_exponential(
multiplier=MODEL_REQUEST_RETRY_WAIT_MULTIPLIER,
min=MODEL_REQUEST_RETRY_WAIT_MIN_SECONDS,
max=MODEL_REQUEST_RETRY_WAIT_MAX_SECONDS,
),
reraise=True,
)
def get_model_request_async_retrying() -> AsyncRetrying:
return AsyncRetrying(
retry=retry_if_exception_type(Exception),
stop=stop_after_attempt(MODEL_REQUEST_RETRY_ATTEMPTS),
wait=wait_exponential(
multiplier=MODEL_REQUEST_RETRY_WAIT_MULTIPLIER,
min=MODEL_REQUEST_RETRY_WAIT_MIN_SECONDS,
max=MODEL_REQUEST_RETRY_WAIT_MAX_SECONDS,
),
reraise=True,
)
+24 -16
View File
@@ -21,7 +21,6 @@ from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.network_utils import is_connection_error, log_connection_failure
from ..register import register_provider_adapter
from .default import get_model_request_async_retrying, with_model_request_retry
class SuppressNonTextPartsWarning(logging.Filter):
@@ -514,7 +513,6 @@ class ProviderGoogleGenAI(Provider):
llm_response.reasoning_signature = base64.b64encode(ts).decode("utf-8")
return MessageChain(chain=chain)
@with_model_request_retry()
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
"""非流式请求 Gemini API"""
system_instruction = next(
@@ -603,17 +601,6 @@ class ProviderGoogleGenAI(Provider):
self,
payloads: dict,
tools: ToolSet | None,
) -> AsyncGenerator[LLMResponse, None]:
async for attempt in get_model_request_async_retrying():
with attempt:
async for response in self._query_stream_once(payloads, tools):
yield response
return
async def _query_stream_once(
self,
payloads: dict,
tools: ToolSet | None,
) -> AsyncGenerator[LLMResponse, None]:
"""流式请求 Gemini API"""
system_instruction = next(
@@ -772,7 +759,18 @@ class ProviderGoogleGenAI(Provider):
payloads = {"messages": context_query, "model": model}
return await self._query(payloads, func_tool)
retry = 10
keys = self.api_keys.copy()
for _ in range(retry):
try:
return await self._query(payloads, func_tool)
except APIError as e:
if await self._handle_api_error(e, keys):
continue
break
raise Exception("请求失败。")
async def text_chat_stream(
self,
@@ -816,8 +814,18 @@ class ProviderGoogleGenAI(Provider):
payloads = {"messages": context_query, "model": model}
async for response in self._query_stream(payloads, func_tool):
yield response
retry = 10
keys = self.api_keys.copy()
for _ in range(retry):
try:
async for response in self._query_stream(payloads, func_tool):
yield response
break
except APIError as e:
if await self._handle_api_error(e, keys):
continue
break
async def get_models(self):
try:
+87 -22
View File
@@ -31,7 +31,6 @@ from astrbot.core.utils.network_utils import (
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
from ..register import register_provider_adapter
from .default import get_model_request_async_retrying, with_model_request_retry
@register_provider_adapter(
@@ -222,7 +221,6 @@ class ProviderOpenAIOfficial(Provider):
except NotFoundError as e:
raise Exception(f"获取模型列表失败:{e}")
@with_model_request_retry()
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
if tools:
model = payloads.get("model", "").lower()
@@ -248,6 +246,8 @@ class ProviderOpenAIOfficial(Provider):
if isinstance(custom_extra_body, dict):
extra_body.update(custom_extra_body)
model = payloads.get("model", "").lower()
completion = await self.client.chat.completions.create(
**payloads,
stream=False,
@@ -269,17 +269,6 @@ class ProviderOpenAIOfficial(Provider):
self,
payloads: dict,
tools: ToolSet | None,
) -> AsyncGenerator[LLMResponse, None]:
async for attempt in get_model_request_async_retrying():
with attempt:
async for response in self._query_stream_once(payloads, tools):
yield response
return
async def _query_stream_once(
self,
payloads: dict,
tools: ToolSet | None,
) -> AsyncGenerator[LLMResponse, None]:
"""流式查询API,逐步返回结果"""
if tools:
@@ -727,7 +716,7 @@ class ProviderOpenAIOfficial(Provider):
extra_user_content_parts=None,
**kwargs,
) -> LLMResponse:
payloads, _ = await self._prepare_chat_payload(
payloads, context_query = await self._prepare_chat_payload(
prompt,
image_urls,
contexts,
@@ -739,9 +728,47 @@ class ProviderOpenAIOfficial(Provider):
)
llm_response = None
if self.api_keys:
self.client.api_key = random.choice(self.api_keys)
llm_response = await self._query(payloads, func_tool)
max_retries = 10
available_api_keys = self.api_keys.copy()
chosen_key = random.choice(available_api_keys)
image_fallback_used = False
last_exception = None
retry_cnt = 0
for retry_cnt in range(max_retries):
try:
self.client.api_key = chosen_key
llm_response = await self._query(payloads, func_tool)
break
except Exception as e:
last_exception = e
(
success,
chosen_key,
available_api_keys,
payloads,
context_query,
func_tool,
image_fallback_used,
) = await self._handle_api_error(
e,
payloads,
context_query,
func_tool,
chosen_key,
available_api_keys,
retry_cnt,
max_retries,
image_fallback_used=image_fallback_used,
)
if success:
break
if retry_cnt == max_retries - 1 or llm_response is None:
logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。")
if last_exception is None:
raise Exception("未知错误")
raise last_exception
return llm_response
async def text_chat_stream(
@@ -757,7 +784,7 @@ class ProviderOpenAIOfficial(Provider):
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
"""流式对话,与服务商交互并逐步返回结果"""
payloads, _ = await self._prepare_chat_payload(
payloads, context_query = await self._prepare_chat_payload(
prompt,
image_urls,
contexts,
@@ -767,10 +794,48 @@ class ProviderOpenAIOfficial(Provider):
**kwargs,
)
if self.api_keys:
self.client.api_key = random.choice(self.api_keys)
async for response in self._query_stream(payloads, func_tool):
yield response
max_retries = 10
available_api_keys = self.api_keys.copy()
chosen_key = random.choice(available_api_keys)
image_fallback_used = False
last_exception = None
retry_cnt = 0
for retry_cnt in range(max_retries):
try:
self.client.api_key = chosen_key
async for response in self._query_stream(payloads, func_tool):
yield response
break
except Exception as e:
last_exception = e
(
success,
chosen_key,
available_api_keys,
payloads,
context_query,
func_tool,
image_fallback_used,
) = await self._handle_api_error(
e,
payloads,
context_query,
func_tool,
chosen_key,
available_api_keys,
retry_cnt,
max_retries,
image_fallback_used=image_fallback_used,
)
if success:
break
if retry_cnt == max_retries - 1:
logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。")
if last_exception is None:
raise Exception("未知错误")
raise last_exception
async def _remove_image_from_context(self, contexts: list):
"""从上下文中删除所有带有 image 的记录"""
+14 -11
View File
@@ -513,6 +513,16 @@ class PluginManager:
)
logger.info(metadata)
metadata.config = plugin_config
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (metadata.author or "unknown").lower().replace("/", "_")
plugin_id = f"{p_author}/{p_name}"
# 在实例化前注入类属性,保证插件 __init__ 可读取这些值
if metadata.star_cls_type:
setattr(metadata.star_cls_type, "name", p_name)
setattr(metadata.star_cls_type, "author", p_author)
setattr(metadata.star_cls_type, "plugin_id", plugin_id)
if path not in inactivated_plugins:
# 只有没有禁用插件时才实例化插件类
if plugin_config and metadata.star_cls_type:
@@ -530,17 +540,10 @@ class PluginManager:
context=self.context,
)
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (
(metadata.author or "unknown").lower().replace("/", "_")
)
setattr(metadata.star_cls, "name", p_name)
setattr(metadata.star_cls, "author", p_author)
setattr(
metadata.star_cls,
"plugin_id",
f"{p_author}/{p_name}",
)
if metadata.star_cls:
setattr(metadata.star_cls, "name", p_name)
setattr(metadata.star_cls, "author", p_author)
setattr(metadata.star_cls, "plugin_id", plugin_id)
else:
logger.info(f"插件 {metadata.name} 已被禁用。")
+2 -2
View File
@@ -148,8 +148,8 @@ class AstrBotUpdator(RepoZipUpdator):
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
file_url = None
if os.environ.get("ASTRBOT_CLI"):
raise Exception("不支持更新CLI启动的AstrBot") # 避免版本管理混乱
if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"):
raise Exception("不支持更新此方式启动的AstrBot") # 避免版本管理混乱
if latest:
latest_version = update_data[0]["tag_name"]
+27
View File
@@ -0,0 +1,27 @@
## What's Changed
### 修复
- ‼️ 修复 Python 3.14 环境下 `'Plain' object has no attribute 'text'` 报错问题 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154))。
- ‼️ 修复插件元数据处理流程:在实例化前注入必要属性,避免初始化阶段元数据缺失 ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155))。
- 修复桌面端后端构建中 AstrBot 内置插件运行时依赖未打包的问题 ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146))。
- 修复通过 AstrBot Launcher 启动时仍被检测并触发更新的问题。
### 优化
- Webchat 下,使用 `astrbot_execute_ipython` 工具如果返回了图片,会自动将图片发送到聊天中。
### 其他
- 执行 `ruff format` 代码格式整理。
## What's Changed (EN)
### Fixes
- ‼️ Fixed plugin metadata handling by injecting required attributes before instantiation to avoid missing metadata during initialization ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155)).
- ‼️ Fixed `'Plain' object has no attribute 'text'` error when using Python 3.14 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154)).
- Fixed missing runtime dependencies for built-in plugins in desktop backend builds ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146)).
- Fixed update checks being triggered when AstrBot is launched via AstrBot Launcher.
### Improvements
- In Webchat, when using the `astrbot_execute_ipython` tool, if an image is returned, it will automatically be sent to the chat.
### Others
- Applied `ruff format` code formatting.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "astrbot-desktop",
"version": "4.17.2",
"version": "4.17.3",
"description": "AstrBot desktop wrapper",
"private": true,
"main": "main.js",
+10
View File
@@ -35,6 +35,16 @@ const args = [
'aiosqlite',
'--collect-all',
'pip',
'--collect-all',
'bs4',
'--collect-all',
'readability',
'--collect-all',
'lxml',
'--collect-all',
'lxml_html_clean',
'--collect-all',
'rfc3987_syntax',
'--collect-submodules',
'astrbot.api',
'--collect-submodules',
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.17.2"
version = "4.17.3"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.12"