From 0299aa6e4cb85f83bda1d9877d02b3d3352ffebc Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:55:49 +0800 Subject: [PATCH 1/5] fix: validation error for ToolCall.extra_content in specific upstream model providers (#4102) * fix: validation error for ToolCall.extra_content in specific upstream model providers * fix: handle missing extra_content gracefully in ToolCall serialization --- astrbot/core/agent/message.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/astrbot/core/agent/message.py b/astrbot/core/agent/message.py index 6e3a4012d..d69bc6a81 100644 --- a/astrbot/core/agent/message.py +++ b/astrbot/core/agent/message.py @@ -3,7 +3,7 @@ from typing import Any, ClassVar, Literal, cast -from pydantic import BaseModel, GetCoreSchemaHandler, model_validator +from pydantic import BaseModel, GetCoreSchemaHandler, model_serializer, model_validator from pydantic_core import core_schema @@ -122,10 +122,12 @@ class ToolCall(BaseModel): extra_content: dict[str, Any] | None = None """Extra metadata for the tool call.""" - def model_dump(self, **kwargs: Any) -> dict[str, Any]: + @model_serializer(mode="wrap") + def serialize(self, handler): + data = handler(self) if self.extra_content is None: - kwargs.setdefault("exclude", set()).add("extra_content") - return super().model_dump(**kwargs) + data.pop("extra_content", None) + return data class ToolCallPart(BaseModel): From 4aced976a80c7791280ba18f4efe9a9abd353be1 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:19:15 +0800 Subject: [PATCH 2/5] refactor: update OneBot configuration and add platform logo (#4106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed "QQ 个人号(OneBot v11)" to "OneBot v11" in the configuration. - Added a new logo for OneBot in the dashboard assets. - Updated platform icon retrieval logic to include the new OneBot logo. --- astrbot/core/config/default.py | 2 +- .../src/assets/images/platform_logos/onebot.png | Bin 0 -> 18243 bytes dashboard/src/utils/platformUtils.js | 4 +++- 3 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 dashboard/src/assets/images/platform_logos/onebot.png diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 07c1a989c..0038d6b2c 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -209,7 +209,7 @@ CONFIG_METADATA_2 = { "callback_server_host": "0.0.0.0", "port": 6196, }, - "QQ 个人号(OneBot v11)": { + "OneBot v11": { "id": "default", "type": "aiocqhttp", "enable": False, diff --git a/dashboard/src/assets/images/platform_logos/onebot.png b/dashboard/src/assets/images/platform_logos/onebot.png new file mode 100644 index 0000000000000000000000000000000000000000..70cc8829f3fb7e476b66015ba411287bbee75dbf GIT binary patch literal 18243 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 Date: Thu, 18 Dec 2025 17:11:09 +0800 Subject: [PATCH 3/5] feat: enhance tool call handling and agent stats tracking and UI integration for tool calls render (#4101) * feat: enhance tool call handling and UI integration for tool calls render - Added support for tool call messages in the agent runner and webchat event handling. - Implemented JSON message component for structured tool call data. - Updated chat route to save tool call information in message history. - Enhanced frontend to display tool call details in a collapsible format, including status and results. - Introduced elapsed time tracking for ongoing tool calls in the chat interface. * fix: improve message handling in agent run utility and tool loop runner - Refactored message sending logic in `astr_agent_run_util.py` to use `msg_chain` directly for better clarity. - Added a check in `tool_loop_agent_runner.py` to ensure `tool_call_result_blocks` is not empty before yielding the last tool call result, preventing potential errors. * refactor: enhance message structure and UI for chat components - Updated message handling in `MessageList.vue` to support structured message parts, including plain text, images, audio, and files. - Improved the `Chat.vue` component styles for better visual consistency. - Refactored message parsing logic in `useMessages.ts` to accommodate new message formats and ensure proper rendering of embedded content. - Removed deprecated tool call handling from the message structure, streamlining the message display process. * chore: ruff format * feat: implement agent statistics tracking and display in chat - Added `AgentStats` and `TokenUsage` data classes to track agent performance metrics. - Enhanced `ToolLoopAgentRunner` to collect and update agent statistics during execution. - Integrated agent statistics sending to webchat for real-time updates. - Updated chat route to save and display agent statistics in message history. - Improved frontend components to visualize agent statistics, including token usage and duration metrics. * fix: improve message handling in Telegram event and agent run utility - Updated message sending logic in `astr_agent_run_util.py` to send the correct message chain for tool calls. - Enhanced `tg_event.py` to edit messages during streaming breaks, improving message management and user experience. - Added error handling for message editing failures to ensure robustness. * chore: ruff format --- astrbot/core/agent/response.py | 23 +- .../agent/runners/tool_loop_agent_runner.py | 68 +- astrbot/core/astr_agent_run_util.py | 25 +- astrbot/core/message/components.py | 9 +- .../platform/sources/telegram/tg_event.py | 9 + .../platform/sources/webchat/webchat_event.py | 40 +- astrbot/core/provider/entities.py | 41 ++ .../core/provider/sources/anthropic_source.py | 43 +- .../core/provider/sources/gemini_source.py | 21 +- .../core/provider/sources/openai_source.py | 19 +- astrbot/core/star/context.py | 4 + astrbot/dashboard/routes/chat.py | 65 +- dashboard/src/components/chat/Chat.vue | 4 + dashboard/src/components/chat/MessageList.vue | 681 +++++++++++++++--- dashboard/src/composables/useMessages.ts | 422 ++++++----- .../src/i18n/locales/en-US/features/chat.json | 8 + .../src/i18n/locales/zh-CN/features/chat.json | 8 + 17 files changed, 1155 insertions(+), 335 deletions(-) diff --git a/astrbot/core/agent/response.py b/astrbot/core/agent/response.py index 3f3430c87..9e61fa8c7 100644 --- a/astrbot/core/agent/response.py +++ b/astrbot/core/agent/response.py @@ -1,7 +1,8 @@ import typing as T -from dataclasses import dataclass +from dataclasses import dataclass, field from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.provider.entities import TokenUsage class AgentResponseData(T.TypedDict): @@ -12,3 +13,23 @@ class AgentResponseData(T.TypedDict): class AgentResponse: type: str data: AgentResponseData + + +@dataclass +class AgentStats: + token_usage: TokenUsage = field(default_factory=TokenUsage) + start_time: float = 0.0 + end_time: float = 0.0 + time_to_first_token: float = 0.0 + + @property + def duration(self) -> float: + return self.end_time - self.start_time + + def to_dict(self) -> dict: + return { + "token_usage": self.token_usage.__dict__, + "start_time": self.start_time, + "end_time": self.end_time, + "time_to_first_token": self.time_to_first_token, + } diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 450e4dbcb..069de144f 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -1,4 +1,5 @@ import sys +import time import traceback import typing as T @@ -12,6 +13,7 @@ from mcp.types import ( ) from astrbot import logger +from astrbot.core.message.components import Json from astrbot.core.message.message_event_result import ( MessageChain, ) @@ -24,7 +26,7 @@ from astrbot.core.provider.provider import Provider from ..hooks import BaseAgentRunHooks from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment -from ..response import AgentResponseData +from ..response import AgentResponseData, AgentStats from ..run_context import ContextWrapper, TContext from ..tool_executor import BaseFunctionToolExecutor from .base import AgentResponse, AgentState, BaseAgentRunner @@ -69,6 +71,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): ) self.run_context.messages = messages + self.stats = AgentStats() + self.stats.start_time = time.time() + async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]: """Yields chunks *and* a final LLMResponse.""" if self.streaming: @@ -98,6 +103,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): async for llm_response in self._iter_llm_responses(): if llm_response.is_chunk: + # update ttft + if self.stats.time_to_first_token == 0: + self.stats.time_to_first_token = time.time() - self.stats.start_time + if llm_response.result_chain: yield AgentResponse( type="streaming_delta", @@ -121,6 +130,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): ) continue llm_resp_result = llm_response + + if not llm_response.is_chunk and llm_response.usage: + # only count the token usage of the final response for computation purpose + self.stats.token_usage += llm_response.usage break # got final response if not llm_resp_result: @@ -132,6 +145,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): if llm_resp.role == "err": # 如果 LLM 响应错误,转换到错误状态 self.final_llm_resp = llm_resp + self.stats.end_time = time.time() self._transition_state(AgentState.ERROR) yield AgentResponse( type="err", @@ -146,6 +160,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): # 如果没有工具调用,转换到完成状态 self.final_llm_resp = llm_resp self._transition_state(AgentState.DONE) + self.stats.end_time = time.time() # record the final assistant message self.run_context.messages.append( Message( @@ -175,23 +190,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): # 如果有工具调用,还需处理工具调用 if llm_resp.tools_call_name: tool_call_result_blocks = [] - for tool_call_name in llm_resp.tools_call_name: - yield AgentResponse( - type="tool_call", - data=AgentResponseData( - chain=MessageChain(type="tool_call").message( - f"🔨 调用工具: {tool_call_name}" - ), - ), - ) async for result in self._handle_function_tools(self.req, llm_resp): if isinstance(result, list): tool_call_result_blocks = result elif isinstance(result, MessageChain): if result.type is None: - result.type = "tool_call_result" + # should not happen + continue + if result.type == "tool_direct_result": + ar_type = "tool_call_result" + else: + ar_type = result.type yield AgentResponse( - type="tool_call_result", + type=ar_type, data=AgentResponseData(chain=result), ) # 将结果添加到上下文中 @@ -234,6 +245,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): llm_response.tools_call_args, llm_response.tools_call_ids, ): + yield MessageChain( + type="tool_call", + chain=[ + Json( + data={ + "id": func_tool_id, + "name": func_tool_name, + "args": func_tool_args, + "ts": time.time(), + } + ) + ], + ) try: if not req.func_tool: return @@ -307,7 +331,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): content=res.content[0].text, ), ) - yield MessageChain().message(res.content[0].text) elif isinstance(res.content[0], ImageContent): tool_call_result_blocks.append( ToolCallMessageSegment( @@ -329,7 +352,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): content=resource.text, ), ) - yield MessageChain().message(resource.text) elif ( isinstance(resource, BlobResourceContents) and resource.mimeType @@ -353,7 +375,22 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): content="返回的数据类型不受支持", ), ) - yield MessageChain().message("返回的数据类型不受支持。") + + # yield the last tool call result + if tool_call_result_blocks: + last_tcr_content = str(tool_call_result_blocks[-1].content) + yield MessageChain( + type="tool_call_result", + chain=[ + Json( + data={ + "id": func_tool_id, + "ts": time.time(), + "result": last_tcr_content, + } + ) + ], + ) elif resp is None: # Tool 直接请求发送消息给用户 @@ -363,6 +400,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户,此工具调用不会被记录到历史中。" ) self._transition_state(AgentState.DONE) + self.stats.end_time = time.time() else: # 不应该出现其他类型 logger.warning( diff --git a/astrbot/core/astr_agent_run_util.py b/astrbot/core/astr_agent_run_util.py index d94d96a82..5421a14c0 100644 --- a/astrbot/core/astr_agent_run_util.py +++ b/astrbot/core/astr_agent_run_util.py @@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator from astrbot.core import logger from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner from astrbot.core.astr_agent_context import AstrAgentContext +from astrbot.core.message.components import Json from astrbot.core.message.message_event_result import ( MessageChain, MessageEventResult, @@ -33,16 +34,27 @@ async def run_agent( msg_chain = resp.data["chain"] if msg_chain.type == "tool_direct_result": # tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容 - await astr_event.send(resp.data["chain"]) + await astr_event.send(msg_chain) continue + if astr_event.get_platform_id() == "webchat": + await astr_event.send(msg_chain) # 对于其他情况,暂时先不处理 continue elif resp.type == "tool_call": if agent_runner.streaming: # 用来标记流式响应需要分节 yield MessageChain(chain=[], type="break") - if show_tool_use: + + if astr_event.get_platform_name() == "webchat": await astr_event.send(resp.data["chain"]) + elif show_tool_use: + json_comp = resp.data["chain"].chain[0] + if isinstance(json_comp, Json): + m = f"🔨 调用工具: {json_comp.data.get('name')}" + else: + m = "🔨 调用工具..." + chain = MessageChain(type="tool_call").message(m) + await astr_event.send(chain) continue if stream_to_general and resp.type == "streaming_delta": @@ -69,6 +81,15 @@ async def run_agent( continue yield resp.data["chain"] # MessageChain if agent_runner.done(): + # send agent stats to webchat + if astr_event.get_platform_name() == "webchat": + await astr_event.send( + MessageChain( + type="agent_stats", + chain=[Json(data=agent_runner.stats.to_dict())], + ) + ) + break except Exception as e: diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 0e7b3bab6..050e36521 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -629,12 +629,11 @@ class Nodes(BaseMessageComponent): class Json(BaseMessageComponent): type = ComponentType.Json - data: str | dict - resid: int | None = 0 + data: dict - def __init__(self, data, **_): - if isinstance(data, dict): - data = json.dumps(data) + def __init__(self, data: str | dict, **_): + if isinstance(data, str): + data = json.loads(data) super().__init__(data=data, **_) diff --git a/astrbot/core/platform/sources/telegram/tg_event.py b/astrbot/core/platform/sources/telegram/tg_event.py index 37f60e65a..5faba6803 100644 --- a/astrbot/core/platform/sources/telegram/tg_event.py +++ b/astrbot/core/platform/sources/telegram/tg_event.py @@ -200,6 +200,15 @@ class TelegramPlatformEvent(AstrMessageEvent): if isinstance(chain, MessageChain): if chain.type == "break": # 分割符 + if message_id: + try: + await self.client.edit_message_text( + text=delta, + chat_id=payload["chat_id"], + message_id=message_id, + ) + except Exception as e: + logger.warning(f"编辑消息失败(streaming-break): {e!s}") message_id = None # 重置消息 ID delta = "" # 重置 delta continue diff --git a/astrbot/core/platform/sources/webchat/webchat_event.py b/astrbot/core/platform/sources/webchat/webchat_event.py index 9f1a6d059..2e529bb1d 100644 --- a/astrbot/core/platform/sources/webchat/webchat_event.py +++ b/astrbot/core/platform/sources/webchat/webchat_event.py @@ -1,11 +1,12 @@ import base64 +import json import os import shutil import uuid from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import File, Image, Plain, Record +from astrbot.api.message_components import File, Image, Json, Plain, Record from astrbot.core.utils.astrbot_path import get_astrbot_data_path from .webchat_queue_mgr import webchat_queue_mgr @@ -41,12 +42,20 @@ class WebChatMessageEvent(AstrMessageEvent): await web_chat_back_queue.put( { "type": "plain", - "cid": cid, "data": data, "streaming": streaming, "chain_type": message.type, }, ) + elif isinstance(comp, Json): + await web_chat_back_queue.put( + { + "type": "plain", + "data": json.dumps(comp.data, ensure_ascii=False), + "streaming": streaming, + "chain_type": message.type, + }, + ) elif isinstance(comp, Image): # save image to local filename = f"{str(uuid.uuid4())}.jpg" @@ -58,7 +67,6 @@ class WebChatMessageEvent(AstrMessageEvent): await web_chat_back_queue.put( { "type": "image", - "cid": cid, "data": data, "streaming": streaming, }, @@ -74,7 +82,6 @@ class WebChatMessageEvent(AstrMessageEvent): await web_chat_back_queue.put( { "type": "record", - "cid": cid, "data": data, "streaming": streaming, }, @@ -91,7 +98,6 @@ class WebChatMessageEvent(AstrMessageEvent): await web_chat_back_queue.put( { "type": "file", - "cid": cid, "data": data, "streaming": streaming, }, @@ -111,18 +117,17 @@ class WebChatMessageEvent(AstrMessageEvent): cid = self.session_id.split("!")[-1] web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid) async for chain in generator: - if chain.type == "break" and final_data: - # 分割符 - await web_chat_back_queue.put( - { - "type": "break", # break means a segment end - "data": final_data, - "streaming": True, - "cid": cid, - }, - ) - final_data = "" - continue + # if chain.type == "break" and final_data: + # # 分割符 + # await web_chat_back_queue.put( + # { + # "type": "break", # break means a segment end + # "data": final_data, + # "streaming": True, + # }, + # ) + # final_data = "" + # continue r = await WebChatMessageEvent._send( chain, @@ -142,7 +147,6 @@ class WebChatMessageEvent(AstrMessageEvent): "data": final_data, "reasoning": reasoning_content, "streaming": True, - "cid": cid, }, ) await super().send_streaming(generator, use_fallback) diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index dc188f141..d13e9b56a 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import base64 import enum import json @@ -199,6 +201,38 @@ class ProviderRequest: return "" +@dataclass +class TokenUsage: + input_other: int = 0 + """The number of input tokens, excluding cached tokens.""" + input_cached: int = 0 + """The number of input cached tokens.""" + output: int = 0 + """The number of output tokens.""" + + @property + def total(self) -> int: + return self.input_other + self.input_cached + self.output + + @property + def input(self) -> int: + return self.input_other + self.input_cached + + def __add__(self, other: TokenUsage) -> TokenUsage: + return TokenUsage( + input_other=self.input_other + other.input_other, + input_cached=self.input_cached + other.input_cached, + output=self.output + other.output, + ) + + def __sub__(self, other: TokenUsage) -> TokenUsage: + return TokenUsage( + input_other=self.input_other - other.input_other, + input_cached=self.input_cached - other.input_cached, + output=self.output - other.output, + ) + + @dataclass class LLMResponse: role: str @@ -227,6 +261,11 @@ class LLMResponse: is_chunk: bool = False """Indicates if the response is a chunked response.""" + id: str | None = None + """The ID of the response. For chunked responses, it's the ID of the chunk; for non-chunked responses, it's the ID of the response.""" + usage: TokenUsage | None = None + """The usage of the response. For chunked responses, it's the usage of the chunk; for non-chunked responses, it's the usage of the response.""" + def __init__( self, role: str, @@ -241,6 +280,8 @@ class LLMResponse: | AnthropicMessage | None = None, is_chunk: bool = False, + id: str | None = None, + usage: TokenUsage | None = None, ): """初始化 LLMResponse diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index bd0f06fba..7e33f40d9 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -6,10 +6,12 @@ from mimetypes import guess_type import anthropic from anthropic import AsyncAnthropic from anthropic.types import Message +from anthropic.types.message_delta_usage import MessageDeltaUsage +from anthropic.types.usage import Usage from astrbot import logger from astrbot.api.provider import Provider -from astrbot.core.provider.entities import LLMResponse +from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.utils.io import download_image_by_url @@ -107,6 +109,22 @@ class ProviderAnthropic(Provider): return system_prompt, new_messages + def _extract_usage(self, usage: Usage) -> TokenUsage: + # https://docs.claude.com/en/docs/build-with-claude/prompt-caching#tracking-cache-performance + return TokenUsage( + input_other=usage.input_tokens or 0, + input_cached=usage.cache_read_input_tokens or 0, + output=usage.output_tokens, + ) + + def _update_usage(self, token_usage: TokenUsage, usage: MessageDeltaUsage) -> None: + if usage.input_tokens is not None: + token_usage.input_other = usage.input_tokens + if usage.cache_read_input_tokens is not None: + token_usage.input_cached = usage.cache_read_input_tokens + if usage.output_tokens is not None: + token_usage.output = usage.output_tokens + async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: if tools: if tool_list := tools.get_func_desc_anthropic_style(): @@ -131,6 +149,10 @@ class ProviderAnthropic(Provider): llm_response.tools_call_args.append(content_block.input) llm_response.tools_call_name.append(content_block.name) llm_response.tools_call_ids.append(content_block.id) + + llm_response.id = completion.id + llm_response.usage = self._extract_usage(completion.usage) + # TODO(Soulter): 处理 end_turn 情况 if not llm_response.completion_text and not llm_response.tools_call_args: raise Exception(f"Anthropic API 返回的 completion 无法解析:{completion}。") @@ -152,9 +174,16 @@ class ProviderAnthropic(Provider): final_text = "" final_tool_calls = [] + id = None + usage = TokenUsage() + async with self.client.messages.stream(**payloads) as stream: assert isinstance(stream, anthropic.AsyncMessageStream) async for event in stream: + if event.type == "message_start": + # the usage contains input token usage + id = event.message.id + usage = self._extract_usage(event.message.usage) if event.type == "content_block_start": if event.content_block.type == "text": # 文本块开始 @@ -162,6 +191,8 @@ class ProviderAnthropic(Provider): role="assistant", completion_text="", is_chunk=True, + usage=usage, + id=id, ) elif event.content_block.type == "tool_use": # 工具使用块开始,初始化缓冲区 @@ -179,6 +210,8 @@ class ProviderAnthropic(Provider): role="assistant", completion_text=event.delta.text, is_chunk=True, + usage=usage, + id=id, ) elif event.delta.type == "input_json_delta": # 工具调用参数增量 @@ -215,6 +248,8 @@ class ProviderAnthropic(Provider): tools_call_name=[tool_info["name"]], tools_call_ids=[tool_info["id"]], is_chunk=True, + usage=usage, + id=id, ) except json.JSONDecodeError: # JSON 解析失败,跳过这个工具调用 @@ -223,11 +258,17 @@ class ProviderAnthropic(Provider): # 清理缓冲区 del tool_use_buffer[event.index] + elif event.type == "message_delta": + if event.usage: + self._update_usage(usage, event.usage) + # 返回最终的完整结果 final_response = LLMResponse( role="assistant", completion_text=final_text, is_chunk=False, + usage=usage, + id=id, ) if final_tool_calls: diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index e2efc6aab..8e0b89081 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -14,7 +14,7 @@ import astrbot.core.message.components as Comp from astrbot import logger from astrbot.api.provider import Provider from astrbot.core.message.message_event_result import MessageChain -from astrbot.core.provider.entities import LLMResponse +from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.utils.io import download_image_by_url @@ -347,6 +347,16 @@ class ProviderGoogleGenAI(Provider): ] return "".join(thought_buf).strip() + def _extract_usage( + self, usage_metadata: types.GenerateContentResponseUsageMetadata + ) -> TokenUsage: + """Extract usage from candidate""" + return TokenUsage( + input_other=usage_metadata.prompt_token_count or 0, + input_cached=usage_metadata.cached_content_token_count or 0, + output=usage_metadata.candidates_token_count or 0, + ) + def _process_content_parts( self, candidate: types.Candidate, @@ -501,6 +511,9 @@ class ProviderGoogleGenAI(Provider): result.candidates[0], llm_response, ) + llm_response.id = result.response_id + if result.usage_metadata: + llm_response.usage = self._extract_usage(result.usage_metadata) return llm_response async def _query_stream( @@ -569,6 +582,9 @@ class ProviderGoogleGenAI(Provider): chunk.candidates[0], llm_response, ) + llm_response.id = chunk.response_id + if chunk.usage_metadata: + llm_response.usage = self._extract_usage(chunk.usage_metadata) yield llm_response return @@ -596,6 +612,9 @@ class ProviderGoogleGenAI(Provider): chunk.candidates[0], final_response, ) + final_response.id = chunk.response_id + if chunk.usage_metadata: + final_response.usage = self._extract_usage(chunk.usage_metadata) break # Yield final complete response with accumulated text diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 788b649a9..4aeacf672 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -12,6 +12,7 @@ from openai._exceptions import NotFoundError from openai.lib.streaming.chat._completions import ChatCompletionStreamState from openai.types.chat.chat_completion import ChatCompletion from openai.types.chat.chat_completion_chunk import ChatCompletionChunk +from openai.types.completion_usage import CompletionUsage import astrbot.core.message.components as Comp from astrbot import logger @@ -19,7 +20,7 @@ from astrbot.api.provider import Provider from astrbot.core.agent.message import Message from astrbot.core.agent.tool import ToolSet from astrbot.core.message.message_event_result import MessageChain -from astrbot.core.provider.entities import LLMResponse, ToolCallsResult +from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult from astrbot.core.utils.io import download_image_by_url from ..register import register_provider_adapter @@ -208,6 +209,7 @@ class ProviderOpenAIOfficial(Provider): # handle the content delta reasoning = self._extract_reasoning_content(chunk) _y = False + llm_response.id = chunk.id if reasoning: llm_response.reasoning_content = reasoning _y = True @@ -217,6 +219,8 @@ class ProviderOpenAIOfficial(Provider): chain=[Comp.Plain(completion_text)], ) _y = True + if chunk.usage: + llm_response.usage = self._extract_usage(chunk.usage) if _y: yield llm_response @@ -245,6 +249,15 @@ class ProviderOpenAIOfficial(Provider): reasoning_text = str(reasoning_attr) return reasoning_text + def _extract_usage(self, usage: CompletionUsage) -> TokenUsage: + ptd = usage.prompt_tokens_details + cached = ptd.cached_tokens if ptd and ptd.cached_tokens else 0 + return TokenUsage( + input_other=usage.prompt_tokens - cached, + input_cached=ptd.cached_tokens if ptd and ptd.cached_tokens else 0, + output=usage.completion_tokens, + ) + async def _parse_openai_completion( self, completion: ChatCompletion, tools: ToolSet | None ) -> LLMResponse: @@ -321,6 +334,10 @@ class ProviderOpenAIOfficial(Provider): raise Exception(f"API 返回的 completion 无法解析:{completion}。") llm_response.raw_completion = completion + llm_response.id = completion.id + + if completion.usage: + llm_response.usage = self._extract_usage(completion.usage) return llm_response diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 9a52ec8bc..2561762f1 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -296,6 +296,10 @@ class Context: provider_type=ProviderType.CHAT_COMPLETION, umo=umo, ) + if prov is None: + raise ProviderNotFoundError( + "provider not found, please choose provider first" + ) if not isinstance(prov, Provider): raise ValueError("返回的 Provider 不是 Provider 类型") return prov diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index f2439c058..c2b991ef7 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -227,16 +227,19 @@ class ChatRoute(Route): text: str, media_parts: list, reasoning: str, + agent_stats: dict, ): """保存 bot 消息到历史记录,返回保存的记录""" bot_message_parts = [] + bot_message_parts.extend(media_parts) if text: bot_message_parts.append({"type": "plain", "text": text}) - bot_message_parts.extend(media_parts) new_his = {"type": "bot", "message": bot_message_parts} if reasoning: new_his["reasoning"] = reasoning + if agent_stats: + new_his["agent_stats"] = agent_stats record = await self.platform_history_mgr.insert( platform_id="webchat", @@ -294,7 +297,8 @@ class ChatRoute(Route): accumulated_parts = [] accumulated_text = "" accumulated_reasoning = "" - + tool_calls = {} + agent_stats = {} try: async with track_conversation(self.running_convs, webchat_conv_id): while True: @@ -314,6 +318,16 @@ class ChatRoute(Route): result_text = result["data"] msg_type = result.get("type") streaming = result.get("streaming", False) + chain_type = result.get("chain_type") + + if chain_type == "agent_stats": + stats_info = { + "type": "agent_stats", + "data": json.loads(result_text), + } + yield f"data: {json.dumps(stats_info, ensure_ascii=False)}\n\n" + agent_stats = stats_info["data"] + continue # 发送 SSE 数据 try: @@ -335,8 +349,30 @@ class ChatRoute(Route): # 累积消息部分 if msg_type == "plain": - chain_type = result.get("chain_type", "normal") - if chain_type == "reasoning": + chain_type = result.get("chain_type") + if chain_type == "tool_call": + tool_call = json.loads(result_text) + tool_calls[tool_call.get("id")] = tool_call + if accumulated_text: + # 如果累积了文本,则先保存文本 + accumulated_parts.append( + {"type": "plain", "text": accumulated_text} + ) + accumulated_text = "" + elif chain_type == "tool_call_result": + tcr = json.loads(result_text) + tc_id = tcr.get("id") + if tc_id in tool_calls: + tool_calls[tc_id]["result"] = tcr.get("result") + tool_calls[tc_id]["finished_ts"] = tcr.get("ts") + accumulated_parts.append( + { + "type": "tool_call", + "tool_calls": [tool_calls[tc_id]], + } + ) + tool_calls.pop(tc_id, None) + elif chain_type == "reasoning": accumulated_reasoning += result_text elif streaming: accumulated_text += result_text @@ -369,15 +405,20 @@ class ChatRoute(Route): if msg_type == "end": break elif ( - (streaming and msg_type == "complete") - or not streaming - or msg_type == "break" + (streaming and msg_type == "complete") or not streaming + # or msg_type == "break" ): + if ( + chain_type == "tool_call" + or chain_type == "tool_call_result" + ): + continue saved_record = await self._save_bot_message( webchat_conv_id, accumulated_text, accumulated_parts, accumulated_reasoning, + agent_stats, ) # 发送保存的消息信息给前端 if saved_record and not client_disconnected: @@ -392,11 +433,11 @@ class ChatRoute(Route): yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n" except Exception: pass - # 重置累积变量 (对于 break 后的下一段消息) - if msg_type == "break": - accumulated_parts = [] - accumulated_text = "" - accumulated_reasoning = "" + accumulated_parts = [] + accumulated_text = "" + accumulated_reasoning = "" + tool_calls = {} + agent_stats = {} except BaseException as e: logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True) diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index 509971ca8..5524e787d 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -575,5 +575,9 @@ onBeforeUnmount(() => { .chat-page-container { padding: 0 !important; } + + .conversation-header { + padding: 2px; + } } diff --git a/dashboard/src/components/chat/MessageList.vue b/dashboard/src/components/chat/MessageList.vue index 8361b5176..cd14c6574 100644 --- a/dashboard/src/components/chat/MessageList.vue +++ b/dashboard/src/components/chat/MessageList.vue @@ -5,56 +5,66 @@
-
- -
- mdi-reply - {{ getReplyContent(msg.content.reply_to.message_id) }} -
-
{{ msg.content.message }}
- - -
-
- + +
- - - mdi-star-four-points-small + + mdi-star-four-points-small
@@ -62,10 +72,11 @@
{{ tm('message.loading') }}
- +
- {{ formatMessageTime(msg.created_at) }} + {{ formatMessageTime(msg.created_at) + }} + + + + + +
+ {{ tm('stats.inputTokens') }} + {{ getInputTokens(msg.content.agentStats.token_usage) }} +
+
+ {{ tm('stats.outputTokens') }} + {{ msg.content.agentStats.token_usage.output || 0 }} +
+
+ {{ tm('stats.cachedTokens') }} + {{ msg.content.agentStats.token_usage.input_cached }} +
+
+ {{ tm('stats.ttft') }} + {{ formatTTFT(msg.content.agentStats.time_to_first_token) }} +
+
+ {{ tm('stats.duration') }} + {{ formatAgentDuration(msg.content.agentStats) }} +
+
+
+
@@ -191,6 +287,9 @@ export default { scrollTimer: null, expandedReasoning: new Set(), // Track which reasoning blocks are expanded downloadingFiles: new Set(), // Track which files are being downloaded + expandedToolCalls: new Set(), // Track which tool call cards are expanded + elapsedTimeTimer: null, // Timer for updating elapsed time + currentTime: Date.now() / 1000, // Current time for elapsed time calculation }; }, mounted() { @@ -198,6 +297,7 @@ export default { this.initImageClickEvents(); this.addScrollListener(); this.scrollToBottom(); + this.startElapsedTimeTimer(); }, updated() { this.initCodeCopyButtons(); @@ -207,6 +307,12 @@ export default { } }, methods: { + // 检查 message 中是否有音频 + hasAudio(messageParts) { + if (!Array.isArray(messageParts)) return false; + return messageParts.some(part => part.type === 'record' && part.embedded_url); + }, + // 获取被引用消息的内容 getReplyContent(messageId) { const replyMsg = this.messages.find(m => m.id === messageId); @@ -214,9 +320,7 @@ export default { return this.tm('reply.notFound'); } let content = ''; - if (typeof replyMsg.content.message === 'string') { - content = replyMsg.content.message; - } else if (Array.isArray(replyMsg.content.message)) { + if (Array.isArray(replyMsg.content.message)) { const textParts = replyMsg.content.message .filter(part => part.type === 'plain' && part.text) .map(part => part.text); @@ -233,7 +337,7 @@ export default { scrollToMessage(messageId) { const msgIndex = this.messages.findIndex(m => m.id === messageId); if (msgIndex === -1) return; - + const container = this.$refs.messageContainer; const messageItems = container?.querySelectorAll('.message-item'); if (messageItems && messageItems[msgIndex]) { @@ -265,16 +369,16 @@ export default { // 下载文件 async downloadFile(file) { if (!file.attachment_id) return; - + // 标记为下载中 this.downloadingFiles.add(file.attachment_id); this.downloadingFiles = new Set(this.downloadingFiles); - + try { const response = await axios.get(`/api/chat/get_attachment?attachment_id=${file.attachment_id}`, { responseType: 'blob' }); - + const url = URL.createObjectURL(response.data); const a = document.createElement('a'); a.href = url; @@ -313,29 +417,29 @@ export default { }, // 复制bot消息到剪贴板 - copyBotMessage(message, messageIndex) { - // 获取对应的消息对象 - const msgObj = this.messages[messageIndex].content; + copyBotMessage(messageParts, messageIndex) { let textToCopy = ''; - // 如果有文本消息,添加到复制内容中 - if (message && message.trim()) { - // 移除HTML标签,获取纯文本 - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = message; - textToCopy = tempDiv.textContent || tempDiv.innerText || message; - } + if (Array.isArray(messageParts)) { + // 提取所有文本内容 + const textContents = messageParts + .filter(part => part.type === 'plain' && part.text) + .map(part => part.text); + textToCopy = textContents.join('\n'); - // 如果有内嵌图片,添加说明 - if (msgObj && msgObj.embedded_images && msgObj.embedded_images.length > 0) { - if (textToCopy) textToCopy += '\n\n'; - textToCopy += `[包含 ${msgObj.embedded_images.length} 张图片]`; - } + // 检查是否有图片 + const imageCount = messageParts.filter(part => part.type === 'image' && part.embedded_url).length; + if (imageCount > 0) { + if (textToCopy) textToCopy += '\n\n'; + textToCopy += `[包含 ${imageCount} 张图片]`; + } - // 如果有内嵌音频,添加说明 - if (msgObj && msgObj.embedded_audio) { - if (textToCopy) textToCopy += '\n\n'; - textToCopy += '[包含音频内容]'; + // 检查是否有音频 + const hasAudio = messageParts.some(part => part.type === 'record' && part.embedded_url); + if (hasAudio) { + if (textToCopy) textToCopy += '\n\n'; + textToCopy += '[包含音频内容]'; + } } // 如果没有任何内容,使用默认文本 @@ -487,26 +591,31 @@ export default { clearTimeout(this.scrollTimer); this.scrollTimer = null; } + // 清理 elapsed time 计时器 + if (this.elapsedTimeTimer) { + clearInterval(this.elapsedTimeTimer); + this.elapsedTimeTimer = null; + } }, // 格式化消息时间,支持别名显示 formatMessageTime(dateStr) { if (!dateStr) return ''; - + const date = new Date(dateStr); const now = new Date(); - + // 获取本地时间的日期部分 const dateDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()); const todayDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterdayDay = new Date(todayDay); yesterdayDay.setDate(yesterdayDay.getDate() - 1); - + // 格式化时间 HH:MM const hours = date.getHours().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0'); const timeStr = `${hours}:${minutes}`; - + // 判断是今天、昨天还是更早 if (dateDay.getTime() === todayDay.getTime()) { return `${this.tm('time.today')} ${timeStr}`; @@ -518,6 +627,114 @@ export default { const day = date.getDate().toString().padStart(2, '0'); return `${month}-${day} ${timeStr}`; } + }, + + // Tool call related methods + toggleToolCall(messageIndex, partIndex, toolCallIndex) { + const key = `${messageIndex}-${partIndex}-${toolCallIndex}`; + if (this.expandedToolCalls.has(key)) { + this.expandedToolCalls.delete(key); + } else { + this.expandedToolCalls.add(key); + } + // Force reactivity + this.expandedToolCalls = new Set(this.expandedToolCalls); + }, + + isToolCallExpanded(messageIndex, partIndex, toolCallIndex) { + return this.expandedToolCalls.has(`${messageIndex}-${partIndex}-${toolCallIndex}`); + }, + + // Start timer for updating elapsed time + startElapsedTimeTimer() { + // Update every 12ms for sub-second precision, then every second after 1s + let fastUpdateCount = 0; + const fastUpdateInterval = 12; + const slowUpdateInterval = 1000; + + const updateTime = () => { + this.currentTime = Date.now() / 1000; + + // Check if there are any running tool calls + const hasRunningToolCalls = this.messages.some(msg => + Array.isArray(msg.content.message) && msg.content.message.some(part => + part.type === 'tool_call' && part.tool_calls?.some(tc => !tc.finished_ts) + ) + ); + + if (hasRunningToolCalls) { + // Check if any running tool call is under 1 second + const hasSubSecondToolCall = this.messages.some(msg => + Array.isArray(msg.content.message) && msg.content.message.some(part => + part.type === 'tool_call' && part.tool_calls?.some(tc => + !tc.finished_ts && (this.currentTime - tc.ts) < 1 + ) + ) + ); + + if (hasSubSecondToolCall) { + fastUpdateCount++; + this.elapsedTimeTimer = setTimeout(updateTime, fastUpdateInterval); + } else { + this.elapsedTimeTimer = setTimeout(updateTime, slowUpdateInterval); + } + } else { + // No running tool calls, check again after 1 second + this.elapsedTimeTimer = setTimeout(updateTime, slowUpdateInterval); + } + }; + + updateTime(); + }, + + // Get elapsed time string for a tool call + getElapsedTime(startTs) { + const elapsed = this.currentTime - startTs; + return this.formatDuration(elapsed); + }, + + // Format duration in seconds to human readable string + formatDuration(seconds) { + if (seconds < 1) { + return `${Math.round(seconds * 1000)}ms`; + } else if (seconds < 60) { + return `${seconds.toFixed(1)}s`; + } else { + const minutes = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + return `${minutes}m ${secs}s`; + } + }, + + // Format tool result for display + formatToolResult(result) { + if (!result) return ''; + // Try to parse as JSON for pretty formatting + try { + const parsed = JSON.parse(result); + return JSON.stringify(parsed, null, 2); + } catch { + return result; + } + }, + + // Get input tokens (input_other + input_cached) + getInputTokens(tokenUsage) { + if (!tokenUsage) return 0; + return (tokenUsage.input_other || 0) + (tokenUsage.input_cached || 0); + }, + + // Format agent duration + formatAgentDuration(agentStats) { + if (!agentStats) return ''; + const duration = agentStats.end_time - agentStats.start_time; + return this.formatDuration(duration); + }, + + // Format time to first token + formatTTFT(ttft) { + if (!ttft || ttft <= 0) return ''; + return this.formatDuration(ttft); } } } @@ -548,6 +765,22 @@ export default { min-height: 0; } +.message-bubble { + padding: 2px 16px; + border-radius: 12px; +} + + +@media (max-width: 768px) { + .messages-container { + padding: 0; + } + + .message-bubble { + padding: 2px 8px; + } +} + /* 消息列表样式 */ .message-list { max-width: 900px; @@ -603,6 +836,19 @@ export default { white-space: nowrap; } +/* Agent Stats Info Icon */ +.stats-info-icon { + margin-left: 6px; + color: var(--v-theme-secondaryText); + opacity: 0.6; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.stats-info-icon:hover { + opacity: 1; +} + .bot-message:hover .message-actions { opacity: 1; } @@ -679,15 +925,12 @@ export default { 0% { background-color: rgba(103, 58, 183, 0.3); } + 100% { background-color: transparent; } } -.message-bubble { - padding: 2px 16px; - border-radius: 12px; -} .user-bubble { color: var(--v-theme-primaryText); @@ -911,6 +1154,175 @@ export default { .v-theme--dark .reasoning-text { opacity: 0.85; } + +/* Tool Call Card Styles */ +.tool-calls-container { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; + margin-top: 6px; +} + +.tool-call-card { + border-radius: 8px; + overflow: hidden; + background-color: #eff3f6; + margin: 8px 0px; +} + +.v-theme--dark .tool-call-card { + background-color: rgba(40, 60, 100, 0.4); + border-color: rgba(100, 140, 200, 0.4); +} + +.tool-call-header { + display: flex; + align-items: center; + padding: 10px 12px; + cursor: pointer; + user-select: none; + transition: background-color 0.2s ease; + gap: 8px; +} + +.tool-call-header:hover { + background-color: rgba(169, 194, 219, 0.15); +} + +.v-theme--dark .tool-call-header:hover { + background-color: rgba(100, 150, 200, 0.2); +} + +.tool-call-expand-icon { + color: var(--v-theme-secondary); + transition: transform 0.2s ease; + flex-shrink: 0; +} + +.tool-call-icon { + color: var(--v-theme-secondary); + flex-shrink: 0; +} + +.tool-call-info { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; +} + +.tool-call-name { + font-size: 13px; + font-weight: 600; + color: var(--v-theme-secondary); +} + +.tool-call-id { + font-size: 11px; + color: var(--v-theme-secondaryText); + opacity: 0.7; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tool-call-status { + margin-left: 8px; + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 500; + flex-shrink: 0; +} + +.tool-call-status.status-running { + color: #ff9800; +} + +.tool-call-status.status-finished { + color: #4caf50; +} + +.tool-call-status .status-icon { + font-size: 14px; +} + +.tool-call-status .status-icon.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.tool-call-details { + padding: 12px; + background-color: rgba(255, 255, 255, 0.5); + animation: fadeIn 0.2s ease-in-out; +} + +.v-theme--dark .tool-call-details { + border-top-color: rgba(100, 140, 200, 0.3); + background-color: rgba(30, 45, 70, 0.5); +} + +.tool-call-detail-row { + display: flex; + flex-direction: column; + margin-bottom: 8px; +} + +.tool-call-detail-row:last-child { + margin-bottom: 0; +} + +.detail-label { + font-size: 11px; + font-weight: 600; + color: var(--v-theme-secondaryText); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.detail-value { + font-size: 12px; + color: var(--v-theme-primaryText); + background-color: transparent; + padding: 4px 8px; + border-radius: 4px; + word-break: break-all; +} + +.detail-json { + font-family: 'Fira Code', 'Consolas', monospace; + white-space: pre-wrap; + max-height: 200px; + overflow-y: auto; + margin: 0; +} + +.detail-result { + max-height: 300px; + background-color: transparent; +} + +.v-theme--dark .detail-value { + background-color: transparent; +} + +.v-theme--dark .detail-result { + background-color: transparent; +} diff --git a/dashboard/src/composables/useMessages.ts b/dashboard/src/composables/useMessages.ts index d50f8558e..44b0e59a2 100644 --- a/dashboard/src/composables/useMessages.ts +++ b/dashboard/src/composables/useMessages.ts @@ -2,19 +2,29 @@ import { ref, reactive, type Ref } from 'vue'; import axios from 'axios'; import { useToast } from '@/utils/toast'; -// 新格式消息部分的类型定义 -export interface MessagePart { - type: 'plain' | 'image' | 'record' | 'file' | 'video' | 'reply'; - text?: string; // for plain - attachment_id?: string; // for image, record, file, video - filename?: string; // for file (filename from backend) - message_id?: number; // for reply (PlatformSessionHistoryMessage.id) +// 工具调用信息 +export interface ToolCall { + id: string; + name: string; + args: Record; + ts: number; // 开始时间戳 + result?: string; // 工具调用结果 + finished_ts?: number; // 完成时间戳 } -// 引用信息 -export interface ReplyInfo { - messageId: number; - messageContent: string; +// Token 使用统计 +export interface TokenUsage { + input_other: number; + input_cached: number; + output: number; +} + +// Agent 统计信息 +export interface AgentStats { + token_usage: TokenUsage; + start_time: number; + end_time: number; + time_to_first_token: number; } // 文件信息结构 @@ -24,24 +34,33 @@ export interface FileInfo { attachment_id?: string; // 用于按需下载 } -// 引用消息信息 -export interface ReplyTo { - message_id: number; - message_content?: string; // 被引用消息的内容(解析后填充) +// 消息部分的类型定义 +export interface MessagePart { + type: 'plain' | 'image' | 'record' | 'file' | 'video' | 'reply' | 'tool_call'; + text?: string; // for plain + attachment_id?: string; // for image, record, file, video + filename?: string; // for file (filename from backend) + message_id?: number; // for reply (PlatformSessionHistoryMessage.id) + tool_calls?: ToolCall[]; // for tool_call + // embedded fields - 加载后填充 + embedded_url?: string; // blob URL for image, record + embedded_file?: FileInfo; // for file (保留 attachment_id 用于按需下载) + reply_content?: string; // for reply - 被引用消息的内容 } +// 引用信息 (用于发送消息时) +export interface ReplyInfo { + messageId: number; + messageContent: string; +} + +// 简化的消息内容结构 export interface MessageContent { - type: string; - message: string | MessagePart[]; // 支持旧格式(string)和新格式(MessagePart[]) - reasoning?: string; - image_url?: string[]; - audio_url?: string; - file_url?: FileInfo[]; - embedded_images?: string[]; - embedded_audio?: string; - embedded_files?: FileInfo[]; - isLoading?: boolean; - reply_to?: ReplyTo; // 引用的消息 + type: string; // 'user' | 'bot' + message: MessagePart[]; // 消息部分列表 (保持顺序) + reasoning?: string; // reasoning content (for bot) + isLoading?: boolean; // loading state + agentStats?: AgentStats; // agent 统计信息 (for bot) } export interface Message { @@ -93,52 +112,64 @@ export function useMessages( } } - // 解析新格式消息为旧格式兼容的结构 (用于显示) + // 解析消息内容,填充 embedded 字段 (保持原始顺序) async function parseMessageContent(content: any): Promise { const message = content.message; - // 如果 message 是数组 (新格式) - if (Array.isArray(message)) { - let textParts: string[] = []; - let imageUrls: string[] = []; - let audioUrl: string | undefined; - let fileInfos: FileInfo[] = []; - let replyTo: ReplyTo | undefined; + // 如果 message 是字符串 (旧格式),转换为数组格式 + if (typeof message === 'string') { + const parts: MessagePart[] = []; + let text = message; + // 处理旧格式的特殊标记 + if (text.startsWith('[IMAGE]')) { + const img = text.replace('[IMAGE]', ''); + const imageUrl = await getMediaFile(img); + parts.push({ + type: 'image', + embedded_url: imageUrl + }); + } else if (text.startsWith('[RECORD]')) { + const audio = text.replace('[RECORD]', ''); + const audioUrl = await getMediaFile(audio); + parts.push({ + type: 'record', + embedded_url: audioUrl + }); + } else if (text) { + parts.push({ + type: 'plain', + text: text + }); + } + + content.message = parts; + return; + } + + // 如果 message 是数组 (新格式),遍历并填充 embedded 字段 + if (Array.isArray(message)) { for (const part of message as MessagePart[]) { - if (part.type === 'plain' && part.text) { - textParts.push(part.text); - } else if (part.type === 'image' && part.attachment_id) { - const url = await getAttachment(part.attachment_id); - if (url) imageUrls.push(url); + if (part.type === 'image' && part.attachment_id) { + part.embedded_url = await getAttachment(part.attachment_id); } else if (part.type === 'record' && part.attachment_id) { - audioUrl = await getAttachment(part.attachment_id); + part.embedded_url = await getAttachment(part.attachment_id); } else if (part.type === 'file' && part.attachment_id) { // file 类型不预加载,保留 attachment_id 以便点击时下载 - fileInfos.push({ + part.embedded_file = { attachment_id: part.attachment_id, filename: part.filename || 'file' - }); - } else if (part.type === 'reply' && part.message_id) { - replyTo = { message_id: part.message_id }; + }; } - // video 类型可以后续扩展 - } - - // 转换为旧格式兼容的结构 - content.message = textParts.join('\n'); - content.reply_to = replyTo; - if (content.type === 'user') { - content.image_url = imageUrls.length > 0 ? imageUrls : undefined; - content.audio_url = audioUrl; - content.file_url = fileInfos.length > 0 ? fileInfos : undefined; - } else { - content.embedded_images = imageUrls.length > 0 ? imageUrls : undefined; - content.embedded_audio = audioUrl; - content.embedded_files = fileInfos.length > 0 ? fileInfos : undefined; + // plain, reply, tool_call, video 保持原样 } } - // 如果 message 是字符串 (旧格式),保持原有处理逻辑 + + // 处理 agent_stats (snake_case -> camelCase) + if (content.agent_stats) { + content.agentStats = content.agent_stats; + delete content.agent_stats; + } } async function getSessionMessages(sessionId: string, router: any) { @@ -161,46 +192,10 @@ export function useMessages( }, 3000); } - // 处理历史消息中的媒体文件 + // 处理历史消息 for (let i = 0; i < history.length; i++) { let content = history[i].content; - - // 首先尝试解析新格式消息 await parseMessageContent(content); - - // 以下是旧格式的兼容处理 (message 是字符串的情况) - if (typeof content.message === 'string') { - if (content.message?.startsWith('[IMAGE]')) { - let img = content.message.replace('[IMAGE]', ''); - const imageUrl = await getMediaFile(img); - if (!content.embedded_images) { - content.embedded_images = []; - } - content.embedded_images.push(imageUrl); - content.message = ''; - } - - if (content.message?.startsWith('[RECORD]')) { - let audio = content.message.replace('[RECORD]', ''); - const audioUrl = await getMediaFile(audio); - content.embedded_audio = audioUrl; - content.message = ''; - } - } - - // 旧格式中的 image_url 和 audio_url 字段处理 - if (content.image_url && content.image_url.length > 0) { - for (let j = 0; j < content.image_url.length; j++) { - // 检查是否已经是 blob URL (新格式解析后的结果) - if (!content.image_url[j].startsWith('blob:')) { - content.image_url[j] = await getMediaFile(content.image_url[j]); - } - } - } - - if (content.audio_url && !content.audio_url.startsWith('blob:')) { - content.audio_url = await getMediaFile(content.audio_url); - } } messages.value = history; @@ -217,47 +212,66 @@ export function useMessages( selectedModelName: string, replyTo: ReplyInfo | null = null ) { - // Create user message + // 构建用户消息的 message 部分 + const userMessageParts: MessagePart[] = []; + + // 添加引用消息段 + if (replyTo) { + userMessageParts.push({ + type: 'reply', + message_id: replyTo.messageId, + reply_content: replyTo.messageContent + }); + } + + // 添加纯文本消息段 + if (prompt) { + userMessageParts.push({ + type: 'plain', + text: prompt + }); + } + + // 添加文件消息段 + for (const f of stagedFiles) { + const partType = f.type === 'image' ? 'image' : + f.type === 'record' ? 'record' : 'file'; + + // 获取嵌入 URL + const embeddedUrl = await getAttachment(f.attachment_id); + + userMessageParts.push({ + type: partType as 'image' | 'record' | 'file', + attachment_id: f.attachment_id, + filename: f.original_name, + embedded_url: partType !== 'file' ? embeddedUrl : undefined, + embedded_file: partType === 'file' ? { + attachment_id: f.attachment_id, + filename: f.original_name + } : undefined + }); + } + + // 添加录音(如果有) + if (audioName) { + userMessageParts.push({ + type: 'record', + embedded_url: audioName // 录音使用本地 URL + }); + } + + // 创建用户消息 const userMessage: MessageContent = { type: 'user', - message: prompt, - image_url: [], - audio_url: undefined, - file_url: [], - reply_to: replyTo ? { message_id: replyTo.messageId } : undefined + message: userMessageParts }; - // 分离图片和文件 - const imageFiles = stagedFiles.filter(f => f.type === 'image'); - const nonImageFiles = stagedFiles.filter(f => f.type !== 'image'); - - // 使用 attachment_id 获取图片内容(避免 blob URL 被 revoke 后 404) - if (imageFiles.length > 0) { - const imageUrls = await Promise.all( - imageFiles.map(f => getAttachment(f.attachment_id)) - ); - userMessage.image_url = imageUrls.filter(url => url !== ''); - } - - // 使用 blob URL 作为音频预览(录音不走 attachment) - if (audioName) { - userMessage.audio_url = audioName; - } - - // 文件不预加载,只显示文件名和 attachment_id - if (nonImageFiles.length > 0) { - userMessage.file_url = nonImageFiles.map(f => ({ - filename: f.original_name, - attachment_id: f.attachment_id - })); - } - messages.value.push({ content: userMessage }); // 添加一个加载中的机器人消息占位符 - const loadingMessage = reactive({ + const loadingMessage = reactive({ type: 'bot', - message: '', + message: [], reasoning: '', isLoading: true }); @@ -272,12 +286,11 @@ export function useMessages( // 收集所有 attachment_id const files = stagedFiles.map(f => f.attachment_id); - // 构建 message 参数 - // 当 files 或 reply 存在时,message 是 list,否则是 str + // 构建发送给后端的 message 参数 let messageToSend: string | MessagePart[]; if (files.length > 0 || replyTo) { const parts: MessagePart[] = []; - + // 添加引用消息段 if (replyTo) { parts.push({ @@ -285,7 +298,7 @@ export function useMessages( message_id: replyTo.messageId }); } - + // 添加纯文本消息段 if (prompt) { parts.push({ @@ -293,17 +306,17 @@ export function useMessages( text: prompt }); } - + // 添加文件消息段 for (const f of stagedFiles) { - const partType = f.type === 'image' ? 'image' : - f.type === 'record' ? 'record' : 'file'; + const partType = f.type === 'image' ? 'image' : + f.type === 'record' ? 'record' : 'file'; parts.push({ type: partType as 'image' | 'record' | 'file', attachment_id: f.attachment_id }); } - + messageToSend = parts; } else { messageToSend = prompt; @@ -331,7 +344,7 @@ export function useMessages( const reader = response.body!.getReader(); const decoder = new TextDecoder(); let in_streaming = false; - let message_obj: any = null; + let message_obj: MessageContent | null = null; isStreaming.value = true; @@ -378,8 +391,10 @@ export function useMessages( const imageUrl = await getMediaFile(img); let bot_resp: MessageContent = { type: 'bot', - message: '', - embedded_images: [imageUrl] + message: [{ + type: 'image', + embedded_url: imageUrl + }] }; messages.value.push({ content: bot_resp }); } else if (chunk_json.type === 'record') { @@ -387,43 +402,122 @@ export function useMessages( const audioUrl = await getMediaFile(audio); let bot_resp: MessageContent = { type: 'bot', - message: '', - embedded_audio: audioUrl + message: [{ + type: 'record', + embedded_url: audioUrl + }] }; messages.value.push({ content: bot_resp }); } else if (chunk_json.type === 'file') { // 格式: [FILE]filename|original_name let fileData = chunk_json.data.replace('[FILE]', ''); - let [filename, originalName] = fileData.includes('|') - ? fileData.split('|', 2) + let [filename, originalName] = fileData.includes('|') + ? fileData.split('|', 2) : [fileData, fileData]; const fileUrl = await getMediaFile(filename); let bot_resp: MessageContent = { type: 'bot', - message: '', - embedded_files: [{ - url: fileUrl, - filename: originalName + message: [{ + type: 'file', + embedded_file: { + url: fileUrl, + filename: originalName + } }] }; messages.value.push({ content: bot_resp }); } else if (chunk_json.type === 'plain') { const chain_type = chunk_json.chain_type || 'normal'; - if (!in_streaming) { - message_obj = reactive({ - type: 'bot', - message: chain_type === 'reasoning' ? '' : chunk_json.data, - reasoning: chain_type === 'reasoning' ? chunk_json.data : '', - }); - messages.value.push({ content: message_obj }); - in_streaming = true; - } else { - if (chain_type === 'reasoning') { - // 使用 reactive 对象,直接修改属性会触发响应式更新 - message_obj.reasoning = (message_obj.reasoning || '') + chunk_json.data; + if (chain_type === 'tool_call') { + // 解析工具调用数据 + const toolCallData = JSON.parse(chunk_json.data); + const toolCall: ToolCall = { + id: toolCallData.id, + name: toolCallData.name, + args: toolCallData.args, + ts: toolCallData.ts + }; + + if (!in_streaming) { + message_obj = reactive({ + type: 'bot', + message: [{ + type: 'tool_call', + tool_calls: [toolCall] + }] + }); + messages.value.push({ content: message_obj }); + in_streaming = true; } else { - message_obj.message = (message_obj.message || '') + chunk_json.data; + // 找到最后一个 tool_call part 或创建新的 + const lastPart = message_obj!.message[message_obj!.message.length - 1]; + if (lastPart?.type === 'tool_call') { + // 检查是否已存在相同id的tool_call + const existingIndex = lastPart.tool_calls!.findIndex((tc: ToolCall) => tc.id === toolCall.id); + if (existingIndex === -1) { + lastPart.tool_calls!.push(toolCall); + } + } else { + // 添加新的 tool_call part + message_obj!.message.push({ + type: 'tool_call', + tool_calls: [toolCall] + }); + } + } + } else if (chain_type === 'tool_call_result') { + // 解析工具调用结果数据 + const resultData = JSON.parse(chunk_json.data); + + if (message_obj) { + // 遍历所有 tool_call parts 找到对应的 tool_call + for (const part of message_obj.message) { + if (part.type === 'tool_call' && part.tool_calls) { + const toolCall = part.tool_calls.find((tc: ToolCall) => tc.id === resultData.id); + if (toolCall) { + toolCall.result = resultData.result; + toolCall.finished_ts = resultData.ts; + break; + } + } + } + } + } else if (chain_type === 'reasoning') { + if (!in_streaming) { + message_obj = reactive({ + type: 'bot', + message: [], + reasoning: chunk_json.data + }); + messages.value.push({ content: message_obj }); + in_streaming = true; + } else { + message_obj!.reasoning = (message_obj!.reasoning || '') + chunk_json.data; + } + } else { + // normal text + if (!in_streaming) { + message_obj = reactive({ + type: 'bot', + message: [{ + type: 'plain', + text: chunk_json.data + }] + }); + messages.value.push({ content: message_obj }); + in_streaming = true; + } else { + // 找到最后一个 plain part 或创建新的 + const lastPart = message_obj!.message[message_obj!.message.length - 1]; + if (lastPart?.type === 'plain') { + lastPart.text = (lastPart.text || '') + chunk_json.data; + } else { + message_obj!.message.push({ + type: 'plain', + text: chunk_json.data + }); + } } } } else if (chunk_json.type === 'update_title') { @@ -435,6 +529,11 @@ export function useMessages( lastBotMsg.id = chunk_json.data.id; lastBotMsg.created_at = chunk_json.data.created_at; } + } else if (chunk_json.type === 'agent_stats') { + // 更新当前 bot 消息的 agent 统计信息 + if (message_obj) { + message_obj.agentStats = chunk_json.data; + } } if ((chunk_json.type === 'break' && chunk_json.streaming) || !chunk_json.streaming) { @@ -480,4 +579,3 @@ export function useMessages( getAttachment }; } - diff --git a/dashboard/src/i18n/locales/en-US/features/chat.json b/dashboard/src/i18n/locales/en-US/features/chat.json index 8f2505dec..26271d820 100644 --- a/dashboard/src/i18n/locales/en-US/features/chat.json +++ b/dashboard/src/i18n/locales/en-US/features/chat.json @@ -80,6 +80,14 @@ "today": "Today", "yesterday": "Yesterday" }, + "stats": { + "tokens": "Tokens", + "inputTokens": "Input Tokens", + "outputTokens": "Output Tokens", + "cachedTokens": "Cached Tokens", + "duration": "Duration", + "ttft": "Time to First Token" + }, "connection": { "title": "Connection Status Notice", "message": "The system detected that the chat connection needs to be re-established.", diff --git a/dashboard/src/i18n/locales/zh-CN/features/chat.json b/dashboard/src/i18n/locales/zh-CN/features/chat.json index 2ec8f51f9..2a51ef8bf 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/chat.json +++ b/dashboard/src/i18n/locales/zh-CN/features/chat.json @@ -80,6 +80,14 @@ "today": "今天", "yesterday": "昨天" }, + "stats": { + "tokens": "Token", + "inputTokens": "输入 Token", + "outputTokens": "输出 Token", + "cachedTokens": "缓存 Token", + "duration": "耗时", + "ttft": "首字时间" + }, "connection": { "title": "连接状态提醒", "message": "系统检测到聊天连接需要重新建立。", From 94591d965b9975bfdd18416e8eeb3f00d28b855a Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Thu, 18 Dec 2025 17:15:01 +0800 Subject: [PATCH 4/5] feat: supports thinking level of google gemini (#4104) * feat: supports thinking level of google gemini - Updated google-genai version to >=1.56.0 in pyproject.toml and requirements.txt. - Changed model configuration from "gemini-1.5-flash" to "gemini-3-flash-preview" in default.py. - Enhanced thinking configuration handling in gemini_source.py to support new parameters for Gemini 3 models. * fix: standardize thinking level configuration in default.py and gemini_source.py - Updated the thinking level values in default.py to uppercase for consistency. - Enhanced gemini_source.py to validate the thinking level and default to "HIGH" if an invalid value is provided. --- astrbot/core/config/default.py | 25 ++++--- .../core/provider/sources/gemini_source.py | 66 ++++++++++++------- pyproject.toml | 2 +- requirements.txt | 2 +- 4 files changed, 60 insertions(+), 35 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 0038d6b2c..327191db6 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -946,7 +946,7 @@ CONFIG_METADATA_2 = { "api_base": "https://generativelanguage.googleapis.com/v1beta/openai/", "timeout": 120, "model_config": { - "model": "gemini-1.5-flash", + "model": "gemini-3-flash-preview", "temperature": 0.4, }, "custom_headers": {}, @@ -963,7 +963,7 @@ CONFIG_METADATA_2 = { "api_base": "https://generativelanguage.googleapis.com/", "timeout": 120, "model_config": { - "model": "gemini-2.0-flash-exp", + "model": "gemini-3-flash-preview", "temperature": 0.4, }, "gm_resp_image_modal": False, @@ -976,9 +976,7 @@ CONFIG_METADATA_2 = { "sexually_explicit": "BLOCK_MEDIUM_AND_ABOVE", "dangerous_content": "BLOCK_MEDIUM_AND_ABOVE", }, - "gm_thinking_config": { - "budget": 0, - }, + "gm_thinking_config": {"budget": 0, "level": "HIGH"}, "modalities": ["text", "image", "tool_use"], }, "DeepSeek": { @@ -1819,13 +1817,24 @@ CONFIG_METADATA_2 = { }, }, "gm_thinking_config": { - "description": "Gemini思考设置", + "description": "Thinking Config", "type": "object", "items": { "budget": { - "description": "思考预算", + "description": "Thinking Budget", "type": "int", - "hint": "模型应该生成的思考Token的数量,设为0关闭思考。除gemini-2.5-flash外的模型会静默忽略此参数。", + "hint": "Guides the model on the specific number of thinking tokens to use for reasoning. See: https://ai.google.dev/gemini-api/docs/thinking#set-budget", + }, + "level": { + "description": "Thinking Level", + "type": "string", + "hint": "Recommended for Gemini 3 models and onwards, lets you control reasoning behavior.See: https://ai.google.dev/gemini-api/docs/thinking#thinking-levels", + "options": [ + "MINIMAL", + "LOW", + "MEDIUM", + "HIGH", + ], }, }, }, diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index 8e0b89081..edd11b9ef 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -138,7 +138,7 @@ class ProviderGoogleGenAI(Provider): modalities = ["TEXT"] tool_list: list[types.Tool] | None = [] - model_name = self.get_model() + model_name = payloads.get("model", self.get_model()) native_coderunner = self.provider_config.get("gm_native_coderunner", False) native_search = self.provider_config.get("gm_native_search", False) url_context = self.provider_config.get("gm_url_context", False) @@ -197,6 +197,35 @@ class ProviderGoogleGenAI(Provider): types.Tool(function_declarations=func_desc["function_declarations"]), ] + # oper thinking config + thinking_config = None + if model_name.startswith("gemini-2.5"): + # The thinkingBudget parameter, introduced with the Gemini 2.5 series + thinking_budget = self.provider_config.get("gm_thinking_config", {}).get( + "budget", 0 + ) + if thinking_budget is not None: + thinking_config = types.ThinkingConfig( + thinking_budget=thinking_budget, + ) + elif model_name.startswith("gemini-3"): + # The thinkingLevel parameter, recommended for Gemini 3 models and onwards + # Gemini 2.5 series models don't support thinkingLevel; use thinkingBudget instead. + thinking_level = self.provider_config.get("gm_thinking_config", {}).get( + "level", "HIGH" + ) + if thinking_level and isinstance(thinking_level, str): + thinking_level = thinking_level.upper() + if thinking_level not in ["MINIMAL", "LOW", "MEDIUM", "HIGH"]: + logger.warning(f"Invalid thinking level: {thinking_level}, using HIGH") + thinking_level = "HIGH" + level = types.ThinkingLevel(thinking_level) + thinking_config = types.ThinkingConfig() + if not hasattr(types.ThinkingConfig, "thinking_level"): + setattr(types.ThinkingConfig, "thinking_level", level) + else: + thinking_config.thinking_level = level + return types.GenerateContentConfig( system_instruction=system_instruction, temperature=temperature, @@ -216,22 +245,7 @@ class ProviderGoogleGenAI(Provider): response_modalities=modalities, tools=cast(types.ToolListUnion | None, tool_list), safety_settings=self.safety_settings if self.safety_settings else None, - thinking_config=( - types.ThinkingConfig( - thinking_budget=min( - int( - self.provider_config.get("gm_thinking_config", {}).get( - "budget", - 0, - ), - ), - 24576, - ), - ) - if "gemini-2.5-flash" in self.get_model() - and hasattr(types.ThinkingConfig, "thinking_budget") - else None - ), + thinking_config=thinking_config, automatic_function_calling=types.AutomaticFunctionCallingConfig( disable=True, ), @@ -441,6 +455,8 @@ class ProviderGoogleGenAI(Provider): None, ) + model = payloads.get("model", self.get_model()) + modalities = ["TEXT"] if self.provider_config.get("gm_resp_image_modal", False): modalities.append("IMAGE") @@ -459,7 +475,7 @@ class ProviderGoogleGenAI(Provider): temperature, ) result = await self.client.models.generate_content( - model=self.get_model(), + model=model, contents=cast(types.ContentListUnion, conversation), config=config, ) @@ -485,11 +501,11 @@ class ProviderGoogleGenAI(Provider): e.message = "" if "Developer instruction is not enabled" in e.message: logger.warning( - f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)", + f"{model} 不支持 system prompt,已自动去除(影响人格设置)", ) system_instruction = None elif "Function calling is not enabled" in e.message: - logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除") + logger.warning(f"{model} 不支持函数调用,已自动去除") tools = None elif ( "Multi-modal output is not supported" in e.message @@ -498,7 +514,7 @@ class ProviderGoogleGenAI(Provider): or "only supports text output" in e.message ): logger.warning( - f"{self.get_model()} 不支持多模态输出,降级为文本模态", + f"{model} 不支持多模态输出,降级为文本模态", ) modalities = ["TEXT"] else: @@ -526,7 +542,7 @@ class ProviderGoogleGenAI(Provider): (msg["content"] for msg in payloads["messages"] if msg["role"] == "system"), None, ) - + model = payloads.get("model", self.get_model()) conversation = self._prepare_conversation(payloads) result = None @@ -538,7 +554,7 @@ class ProviderGoogleGenAI(Provider): system_instruction, ) result = await self.client.models.generate_content_stream( - model=self.get_model(), + model=model, contents=cast(types.ContentListUnion, conversation), config=config, ) @@ -548,11 +564,11 @@ class ProviderGoogleGenAI(Provider): e.message = "" if "Developer instruction is not enabled" in e.message: logger.warning( - f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)", + f"{model} 不支持 system prompt,已自动去除(影响人格设置)", ) system_instruction = None elif "Function calling is not enabled" in e.message: - logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除") + logger.warning(f"{model} 不支持函数调用,已自动去除") tools = None else: raise diff --git a/pyproject.toml b/pyproject.toml index 3c0a02e93..f56b101ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "docstring-parser>=0.16", "faiss-cpu==1.10.0", "filelock>=3.18.0", - "google-genai>=1.14.0, <1.51.0", + "google-genai>=1.56.0", "lark-oapi>=1.4.15", "lxml-html-clean>=0.4.2", "mcp>=1.8.0", diff --git a/requirements.txt b/requirements.txt index b56741192..5b70f33ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ dingtalk-stream>=0.22.1 docstring-parser>=0.16 faiss-cpu==1.10.0 filelock>=3.18.0 -google-genai>=1.14.0 +google-genai>=1.56.0 lark-oapi>=1.4.15 lxml-html-clean>=0.4.2 mcp>=1.8.0 From 5f531c9be570e28499ca094cdf09c28926644e66 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 18 Dec 2025 17:17:17 +0800 Subject: [PATCH 5/5] chore: ruff format --- astrbot/core/provider/sources/gemini_source.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index edd11b9ef..5a56170a5 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -217,7 +217,9 @@ class ProviderGoogleGenAI(Provider): if thinking_level and isinstance(thinking_level, str): thinking_level = thinking_level.upper() if thinking_level not in ["MINIMAL", "LOW", "MEDIUM", "HIGH"]: - logger.warning(f"Invalid thinking level: {thinking_level}, using HIGH") + logger.warning( + f"Invalid thinking level: {thinking_level}, using HIGH" + ) thinking_level = "HIGH" level = types.ThinkingLevel(thinking_level) thinking_config = types.ThinkingConfig()