Compare commits

...

11 Commits

Author SHA1 Message Date
Soulter a44fdaaec0 chore: bump version to 4.10.0 2025-12-22 18:10:30 +08:00
Soulter 60105c76f5 feat: implement router loading progress indicator 2025-12-22 13:20:39 +08:00
Soulter bcf87d3ce4 fix: update provider subtitle for clarity in English and Chinese locales
- Revised the subtitle in the provider feature localization files to provide a more detailed description of functionalities, including chat model configuration and third-party service integrations.
2025-12-22 13:13:42 +08:00
Soulter 4d7c8c8453 style: add active background color for provider source list item in dark theme 2025-12-22 12:59:55 +08:00
Soulter a064a9115f fix: omit thinking params for gemini image generation models (#4151)
- Expanded model name checks to include specific Gemini 2.5 and 3 variants, ensuring correct configuration for thinking parameters based on the model used.
2025-12-22 00:09:30 +08:00
Soulter 6ef99e1553 feat: enhance ChatInput and ConversationSidebar dark theme 2025-12-21 21:19:54 +08:00
Soulter c0dbe5cf65 chore: bump version to 4.10.0-alpha.2 2025-12-21 13:11:32 +08:00
Soulter 3598c51eff fix: enhance provider model menu and sidebar session selection handling (#4144)
- Updated `ProviderModelMenu.vue` to manage menu state and load provider configurations dynamically upon opening.
- Filtered provider configurations to exclude those with `enable` set to false.
- Improved session selection logic in `useSessions.ts` to ensure the currently selected session is highlighted and properly managed during navigation.
2025-12-21 13:05:15 +08:00
Soulter b5cdb8f650 fix: improve error handling in tool execution to prevent infinite tool call loops (#4143)
* fix: improve error handling in tool execution to prevent infinite tool call loops

- Enhanced error handling in `call_local_llm_tool` to provide more informative exceptions for ValueError and TypeError, including detailed parameter information.
- Updated `ToolLoopAgentRunner` to yield appropriate messages for cases with no response or unsupported types, ensuring clearer communication to users.
- Improved logging and messaging consistency across tool execution processes.

* refactor: clean up unused router parameter in message retrieval functions

- Removed the unused `router` parameter from `getSessionMessages` and related function calls in `Chat.vue` and `useMessages.ts`.
- Commented out the `tool_calls` dictionary in `chat.py` for clarity, indicating it is not currently in use.

* fix: enhance exception handling in tool execution for clearer error reporting

- Improved exception handling in `call_local_llm_tool` by chaining exceptions for ValueError and TypeError, providing more context in error messages.
- Ensured that traceback information is preserved in raised exceptions for better debugging.
2025-12-21 12:57:54 +08:00
Yokami fc5b520f9b perf(agent): add max step limit to prevent infinite tool call loops (#4110)
* perf(agent): add max step limit to prevent infinite tool call loops

* feat: implement max step limit handling in main agent runner

- Enhanced the agent runner to enforce a maximum step limit, logging a warning and forcing a final response when the limit is reached.
- Updated message handling to append a user prompt when the tool call limit is exceeded.
- Refactored tool response handling to yield appropriate messages based on the response type, including handling cases with no response or unsupported types.
- Improved conversation message formatting to ensure consistent output in the assistant's responses.

* chore: ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-12-21 12:30:43 +08:00
Soulter 904f56b32f fix: webui conversation traj data display error (#4142)
fixes: #4141
2025-12-20 23:29:40 +08:00
25 changed files with 754 additions and 52 deletions
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.10.0-alpha.1"
__version__ = "4.10.0"
@@ -76,12 +76,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse."""
payload = {
"contexts": self.run_context.messages,
"func_tool": self.req.func_tool,
"model": self.req.model, # NOTE: in fact, this arg is None in most cases
"session_id": self.req.session_id,
}
if self.streaming:
stream = self.provider.text_chat_stream(**self.req.__dict__)
stream = self.provider.text_chat_stream(**payload)
async for resp in stream: # type: ignore
yield resp
else:
yield await self.provider.text_chat(**self.req.__dict__)
yield await self.provider.text_chat(**payload)
@override
async def step(self):
@@ -165,7 +172,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.run_context.messages.append(
Message(
role="assistant",
content=llm_resp.completion_text or "",
content=llm_resp.completion_text or "*No response*",
),
)
try:
@@ -230,6 +237,25 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
async for resp in self.step():
yield resp
# 如果循环结束了但是 agent 还没有完成,说明是达到了 max_step
if not self.done():
logger.warning(
f"Agent reached max steps ({max_step}), forcing a final response."
)
# 拔掉所有工具
if self.req:
self.req.func_tool = None
# 注入提示词
self.run_context.messages.append(
Message(
role="user",
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
)
)
# 再执行最后一步
async for resp in self.step():
yield resp
async def _handle_function_tools(
self,
req: ProviderRequest,
@@ -376,35 +402,33 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
),
)
# 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 直接请求发送消息给用户
# 这里我们将直接结束 Agent Loop。
# 发送消息逻辑在 ToolExecutor 中处理了。
logger.warning(
f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户,此工具调用不会被记录到历史中"
f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户。"
)
self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="*工具没有返回值或者将结果直接发送给了用户*",
),
)
else:
# 不应该出现其他类型
logger.warning(
f"Tool 返回了不支持的类型: {type(resp)},将忽略",
f"Tool 返回了不支持的类型: {type(resp)}",
)
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="*工具返回了不支持的类型,请告诉用户检查这个工具的定义和实现。*",
),
)
try:
@@ -426,6 +450,22 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
),
)
# 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,
}
)
],
)
# 处理函数调用响应
if tool_call_result_blocks:
yield tool_call_result_blocks
+19 -1
View File
@@ -2,6 +2,7 @@ import traceback
from collections.abc import AsyncGenerator
from astrbot.core import logger
from astrbot.core.agent.message import Message
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
@@ -24,8 +25,25 @@ async def run_agent(
) -> AsyncGenerator[MessageChain | None, None]:
step_idx = 0
astr_event = agent_runner.run_context.context.event
while step_idx < max_step:
while step_idx < max_step + 1:
step_idx += 1
if step_idx == max_step + 1:
logger.warning(
f"Agent reached max steps ({max_step}), forcing a final response."
)
if not agent_runner.done():
# 拔掉所有工具
if agent_runner.req:
agent_runner.req.func_tool = None
# 注入提示词
agent_runner.run_context.messages.append(
Message(
role="user",
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
)
)
try:
async for resp in agent_runner.step():
if astr_event.is_stopped():
+34 -4
View File
@@ -209,12 +209,42 @@ async def call_local_llm_tool(
else:
raise ValueError(f"未知的方法名: {method_name}")
except ValueError as e:
logger.error(f"调用本地 LLM 工具时出错: {e}", exc_info=True)
except TypeError:
logger.error("处理函数参数不匹配,请检查 handler 的定义。", exc_info=True)
raise Exception(f"Tool execution ValueError: {e}") from e
except TypeError as e:
# 获取函数的签名(包括类型),除了第一个 event/context 参数。
try:
sig = inspect.signature(handler)
params = list(sig.parameters.values())
# 跳过第一个参数(event 或 context
if params:
params = params[1:]
param_strs = []
for param in params:
param_str = param.name
if param.annotation != inspect.Parameter.empty:
# 获取类型注解的字符串表示
if isinstance(param.annotation, type):
type_str = param.annotation.__name__
else:
type_str = str(param.annotation)
param_str += f": {type_str}"
if param.default != inspect.Parameter.empty:
param_str += f" = {param.default!r}"
param_strs.append(param_str)
handler_param_str = (
", ".join(param_strs) if param_strs else "(no additional parameters)"
)
except Exception:
handler_param_str = "(unable to inspect signature)"
raise Exception(
f"Tool handler parameter mismatch, please check the handler definition. Handler parameters: {handler_param_str}"
) from e
except Exception as e:
trace_ = traceback.format_exc()
logger.error(f"调用本地 LLM 工具时出错: {e}\n{trace_}")
raise Exception(f"Tool execution error: {e}. Traceback: {trace_}") from e
if not ready_to_call:
return
+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.10.0-alpha.1"
VERSION = "4.10.0"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -321,7 +321,12 @@ class InternalAgentSubStage(Stage):
elif isinstance(req.tool_calls_result, list):
for tcr in req.tool_calls_result:
messages.extend(tcr.to_openai_messages())
messages.append({"role": "assistant", "content": llm_response.completion_text})
messages.append(
{
"role": "assistant",
"content": llm_response.completion_text or "*No response*",
}
)
messages = list(filter(lambda item: "_no_save" not in item, messages))
await self.conv_manager.update_conversation(
event.unified_msg_origin,
+19 -3
View File
@@ -138,7 +138,7 @@ class ProviderGoogleGenAI(Provider):
modalities = ["TEXT"]
tool_list: list[types.Tool] | None = []
model_name = payloads.get("model", self.get_model())
model_name = cast(str, 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)
@@ -199,7 +199,16 @@ class ProviderGoogleGenAI(Provider):
# oper thinking config
thinking_config = None
if model_name.startswith("gemini-2.5"):
if model_name in [
"gemini-2.5-pro",
"gemini-2.5-pro-preview",
"gemini-2.5-flash",
"gemini-2.5-flash-preview",
"gemini-2.5-flash-lite",
"gemini-2.5-flash-lite-preview",
"gemini-robotics-er-1.5-preview",
"gemini-live-2.5-flash-preview-native-audio-09-2025",
]:
# The thinkingBudget parameter, introduced with the Gemini 2.5 series
thinking_budget = self.provider_config.get("gm_thinking_config", {}).get(
"budget", 0
@@ -208,7 +217,14 @@ class ProviderGoogleGenAI(Provider):
thinking_config = types.ThinkingConfig(
thinking_budget=thinking_budget,
)
elif model_name.startswith("gemini-3"):
elif model_name in [
"gemini-3-pro",
"gemini-3-pro-preview",
"gemini-3-flash",
"gemini-3-flash-preview",
"gemini-3-flash-lite",
"gemini-3-flash-lite-preview",
]:
# 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(
+1 -1
View File
@@ -436,7 +436,7 @@ class ChatRoute(Route):
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
tool_calls = {}
# tool_calls = {}
agent_stats = {}
except BaseException as e:
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
+44
View File
@@ -0,0 +1,44 @@
## What's Changed
> 📢 在升级前,请**完整阅读**本次更新日志。
>
> **特别提醒:**
> 1. 该版本为 alpha.2 预览版本。
> 2. 本次升级**如果再降级**,会由于提供商配置的变更,导致提供商配置错乱,需要手动删除后重新添加。
> 3. 此版本 WebUI 包体相较上一个版本增加约 **193%**,共约 **9.8 MB**,升级可能会需要一些时间。
## alpha.1 -> alpha.2
- 修复:“对话数据”页对话轨迹详情显示异常的问题
- 优化:当 Agent 达到最大步数时的处理。在达到最大步数后,会移除所有请求中的 tools 并告知模型根据上下文进行最终总结。
- 优化:LLM tools 执行的错误处理,减少工具调用无限循环的问题。
- 优化:ChatUI 打开模型选择菜单时,会重新获取提供商配置。
- 优化:ChatUI 新建对话并发送消息后,对话列表页自动选中该对话。
## 4.10.0 变化
### 重构与优化
- 重构 Provider 页面和提供商的配置结构,将 Chat Provider 配置拆分为 Provider Source(提供商源)和 Provider(代表提供商源的各个模型),引入了提供商模型自动发现、模型元数据自动发现的功能,**提供更加便捷的模型添加体验**。
- ⚠️ 将 “MCP” 页面移动到了 “插件” 页面中
- ⚠️ 将 “MCP” 页面中的工具管理移动到了 “插件” -> “管理行为” 中。
- ⚠️ 将 “QQ 个人号(OneBot v11)” 机器人适配器类型更名为 “OneBot v11”,并将其 Logo 更改为 OneBot 的 Logo。
- ⚠️ AstrBot WebChat 升级为 **AstrBot ChatUI**,入口从边栏修改为顶部(右上角)切换按钮。
- 优化引用消息的逻辑,减少对模型输入缓存的破坏。
### 修复
- ‼️ 修复部分情况下,分段回复无法正常分段的问题。
- 修复处理工具返回结果的过程中,导致一些直接发送图片的工具(如生图工具)无法正确发送到用户的问题。
- 修复 WebChat 部分情况下,上一条消息文字内容增量到下一条消息的问题。
### 新增
- 支持**指令管理**,设置指令别名、解决指令冲突、查看指令详情等。入口:“插件” -> “管理行为”。
- 支持 Google Gemini 3 系列引入的 [Thinking Level](https://ai.google.dev/gemini-api/docs/thinking#thinking-levels) 配置。
- 支持记录每条 LLM 消息的耗时、Token 使用量、TTFT 数据,以及每次 Agent Loop 的各种统计数据。
- AstrBot ChatUI 支持查看每条消息的 TTFT、Token 使用量数据。
- AstrBot ChatUI 支持显示每次工具调用的耗时、参数和响应。
- AstrBot ChatUI 支持渲染 Mermaid、LateX 内容,优化了 Code Block 的显示效果(使用 Monaco Editor),并减少 DOM 更新于内存占用。(Powered by [Simon-He95/markstream-vue](https://github.com/Simon-He95/markstream-vue)
- 支持查看 Changelog 历史版本更新日志。
- 🎄
+40
View File
@@ -0,0 +1,40 @@
## What's Changed
> 📢 在升级前,请**完整阅读**本次更新日志。
>
> **特别提醒:**
> 1. 本次升级**如果再降级**,会由于提供商配置的变更,导致提供商配置错乱,需要手动删除后重新添加。
> 2. 此版本 WebUI 包体相较上一个版本增加约 **193%**,共约 **9.8 MB**,升级可能会需要一些时间。
> 3. **升级后请务必确保 WebUI 和 AstrBot Core 版本一致**,否则会产生预期之外的情况。(判断方法:日志中出现 `WebUI 版本已是最新。` 即为一致的版本,`检测到 WebUI 版本 (xxx) 与当前 AstrBot 版本 (xxx) 不符。` 即为不一致的版本。此版本的判断方法也可通查看 WebUI 右上角是否出现 Bot / Chat 的切换按钮控件来判断是否是新版本的 WebUI)。
> 4. 如果有任何问题请提交 [Issue](https://github.com/AstrBotDevs/AstrBot/issues) 并附带 `v4.10.0` tag。
### 重构与优化
- 重构 Provider 页面和提供商的配置结构,将 Chat Provider 配置拆分为 Provider Source(提供商源)和 Provider(代表提供商源的各个模型),引入了提供商模型自动发现、模型元数据自动发现的功能,**提供更加便捷的模型添加体验**。
- ⚠️ 将 “MCP” 页面移动到了 “插件” 页面中
- ⚠️ 将 “MCP” 页面中的工具管理移动到了 “插件” -> “管理行为” 中。
- ⚠️ 将 “QQ 个人号(OneBot v11)” 机器人适配器类型更名为 “OneBot v11”,并将其 Logo 更改为 OneBot 的 Logo。
- ⚠️ AstrBot WebChat 升级为 **AstrBot ChatUI**,入口从边栏修改为顶部(右上角)切换按钮。
- 优化引用消息的逻辑,减少对模型输入缓存的破坏。
- 优化当 Agent 达到最大步数时的处理。在达到最大步数后,会移除所有请求中的 tools 并告知模型根据上下文进行最终总结。
- 优化 LLM tools 执行的错误处理,减少工具调用无限循环的问题。
### 修复
- ‼️ 修复部分情况下,分段回复无法正常分段的问题。
- 修复处理工具返回结果的过程中,导致一些直接发送图片的工具(如生图工具)无法正确发送到用户的问题。
- 修复 WebChat 部分情况下,上一条消息文字内容增量到下一条消息的问题。
### 新增
- 支持**指令管理**,设置指令别名、解决指令冲突、查看指令详情等。入口:“插件” -> “管理行为”。
- 支持 Google Gemini 3 系列引入的 [Thinking Level](https://ai.google.dev/gemini-api/docs/thinking#thinking-levels) 配置。
- 支持记录每条 LLM 消息的耗时、Token 使用量、TTFT 数据,以及每次 Agent Loop 的各种统计数据。
- AstrBot ChatUI 支持查看每条消息的 TTFT、Token 使用量数据。
- AstrBot ChatUI 支持显示每次工具调用的耗时、参数和响应。
- AstrBot ChatUI 支持渲染 Mermaid、LateX 内容,优化了 Code Block 的显示效果(使用 Monaco Editor),并减少 DOM 更新于内存占用。(Powered by [Simon-He95/markstream-vue](https://github.com/Simon-He95/markstream-vue)
- 支持查看 Changelog 历史版本更新日志。
- 🎄
Merry Christmas!
+1 -1
View File
@@ -310,7 +310,7 @@ async function handleSelectConversation(sessionIds: string[]) {
isLoadingMessages.value = true;
try {
await getSessionMsg(sessionIds[0], router);
await getSessionMsg(sessionIds[0]);
} finally {
isLoadingMessages.value = false;
}
+12 -2
View File
@@ -1,7 +1,15 @@
<template>
<div class="input-area fade-in">
<div class="input-container"
style="width: 85%; max-width: 900px; margin: 0 auto; border: 1px solid #e0e0e0; border-radius: 24px; box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.1);">
:style="{
width: '85%',
maxWidth: '900px',
margin: '0 auto',
border: isDark ? 'none' : '1px solid #e0e0e0',
borderRadius: '24px',
boxShadow: isDark ? 'none' : '0px 2px 2px rgba(0, 0, 0, 0.1)',
backgroundColor: isDark ? '#2d2d2d' : 'transparent'
}">
<!-- 引用预览区 -->
<div class="reply-preview" v-if="props.replyTo">
<div class="reply-content">
@@ -86,6 +94,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { useCustomizerStore } from '@/stores/customizer';
import ConfigSelector from './ConfigSelector.vue';
import ProviderModelMenu from './ProviderModelMenu.vue';
import type { Session } from '@/composables/useSessions';
@@ -140,6 +149,7 @@ const emit = defineEmits<{
}>();
const { tm } = useModuleI18n('features/chat');
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
const inputField = ref<HTMLTextAreaElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
@@ -261,7 +271,7 @@ defineExpose({
<style scoped>
.input-area {
padding: 16px;
background-color: var(--v-theme-surface);
background-color: transparent;
position: relative;
border-top: 1px solid var(--v-theme-border);
flex-shrink: 0;
@@ -35,7 +35,8 @@
@update:selected="$emit('selectConversation', $event)">
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
rounded="lg" class="conversation-item" active-color="secondary">
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title">
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
:style="{ color: isDark ? '#ffffff' : '#000000' }">
{{ item.display_name || tm('conversation.newConversation') }}
</v-list-item-title>
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
@@ -1,5 +1,5 @@
<template>
<v-menu :close-on-content-click="false" location="top">
<v-menu v-model="menuOpen" :close-on-content-click="false" location="top" @update:model-value="handleMenuToggle">
<template v-slot:activator="{ props: menuProps }">
<v-chip v-bind="menuProps" class="text-none provider-chip" variant="tonal" size="x-small">
<v-icon start size="14">mdi-creation</v-icon>
@@ -72,11 +72,13 @@ interface ProviderConfig {
model: string;
api_base?: string;
model_metadata?: ModelMetadata;
enable?: boolean;
}
const providerConfigs = ref<ProviderConfig[]>([]);
const selectedProviderId = ref('');
const searchQuery = ref('');
const menuOpen = ref(false);
const filteredProviders = computed(() => {
if (!searchQuery.value) {
@@ -107,7 +109,10 @@ function loadProviderConfigs() {
params: { provider_type: 'chat_completion' }
}).then(response => {
if (response.data.status === 'ok') {
providerConfigs.value = response.data.data || [];
// 过滤掉 enable 为 false 的配置
providerConfigs.value = (response.data.data || []).filter(
(p: ProviderConfig) => p.enable !== false
);
}
}).catch(error => {
console.error('获取提供商列表失败:', error);
@@ -140,6 +145,13 @@ function getCurrentSelection() {
};
}
function handleMenuToggle(isOpen: boolean) {
if (isOpen) {
// 每次打开菜单时重新获取数据
loadProviderConfigs();
}
}
onMounted(() => {
loadFromStorage();
loadProviderConfigs();
@@ -148,3 +148,10 @@ const emitDeleteSource = (source) => emit('delete-provider-source', source)
}
}
</style>
<style>
.v-theme--PurpleThemeDark .provider-source-list-item--active {
background-color: #2d2d2d;
border: none;
}
</style>
+6 -2
View File
@@ -172,7 +172,7 @@ export function useMessages(
}
}
async function getSessionMessages(sessionId: string, router: any) {
async function getSessionMessages(sessionId: string) {
if (!sessionId) return;
try {
@@ -188,7 +188,7 @@ export function useMessages(
// 如果会话还在运行,3秒后重新获取消息
setTimeout(() => {
getSessionMessages(currSessionId.value, router);
getSessionMessages(currSessionId.value);
}, 3000);
}
@@ -353,6 +353,10 @@ export function useMessages(
const { done, value } = await reader.read();
if (done) {
console.log('SSE stream completed');
// 流式传输结束后,获取最终消息并重新渲染
if (currSessionId.value) {
await getSessionMessages(currSessionId.value);
}
break;
}
+11 -1
View File
@@ -41,7 +41,13 @@ export function useSessions(chatboxMode: boolean = false) {
selectedSessions.value = [pendingSessionId.value];
pendingSessionId.value = null;
}
} else if (!currSessionId.value && sessions.value.length > 0) {
} else if (currSessionId.value) {
// 如果当前有选中的会话,确保它在列表中并被选中
const session = sessions.value.find(s => s.session_id === currSessionId.value);
if (session) {
selectedSessions.value = [currSessionId.value];
}
} else if (sessions.value.length > 0) {
// 默认选择第一个会话
const firstSession = sessions.value[0];
selectedSessions.value = [firstSession.session_id];
@@ -65,6 +71,10 @@ export function useSessions(chatboxMode: boolean = false) {
router.push(`${basePath}/${sessionId}`);
await getSessions();
// 确保新创建的会话被选中高亮
selectedSessions.value = [sessionId];
return sessionId;
} catch (err) {
console.error(err);
@@ -1,6 +1,6 @@
{
"title": "Providers",
"subtitle": "Manage model providers",
"subtitle": "Can configure chat models in \"Chat Completion\". Additionally, \"Agent Runner\" includes integrations with third-party services like Dify, Coze, and Alibaba Bailian(DashScope).",
"providers": {
"title": "Service Providers",
"settings": "Settings",
@@ -1,6 +1,6 @@
{
"title": "模型提供商",
"subtitle": "管理模型提供商",
"subtitle": "可以在“对话”中配置对话模型。此外,“Agent 执行器”包含了 Dify、Coze、阿里云百炼应用等第三方服务的集成。",
"providers": {
"title": "模型提供商",
"settings": "设置",
+12
View File
@@ -7,9 +7,11 @@ import VerticalHeaderVue from './vertical-header/VerticalHeader.vue';
import MigrationDialog from '@/components/shared/MigrationDialog.vue';
import Chat from '@/components/chat/Chat.vue';
import { useCustomizerStore } from '@/stores/customizer';
import { useRouterLoadingStore } from '@/stores/routerLoading';
const customizer = useCustomizerStore();
const route = useRoute();
const routerLoadingStore = useRouterLoadingStore();
// 计算是否在聊天页面(非全屏模式)
const isChatPage = computed(() => {
@@ -60,6 +62,16 @@ onMounted(() => {
<v-app :theme="useCustomizerStore().uiTheme"
:class="[customizer.fontTheme, customizer.mini_sidebar ? 'mini-sidebar' : '', customizer.inputBg ? 'inputWithbg' : '']"
>
<!-- 路由切换进度条 -->
<v-progress-linear
v-if="routerLoadingStore.isLoading"
:model-value="routerLoadingStore.progress"
color="primary"
height="2"
fixed
top
style="z-index: 9999; position: absolute; opacity: 0.3; "
/>
<VerticalHeaderVue />
<VerticalSidebarVue v-if="showSidebar" />
<v-main :style="{
+11
View File
@@ -3,6 +3,7 @@ import MainRoutes from './MainRoutes';
import AuthRoutes from './AuthRoutes';
import ChatBoxRoutes from './ChatBoxRoutes';
import { useAuthStore } from '@/stores/auth';
import { useRouterLoadingStore } from '@/stores/routerLoading';
export const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
@@ -22,6 +23,11 @@ interface AuthStore {
}
router.beforeEach(async (to, from, next) => {
if (from.name && from.path !== to.path) {
const loadingStore = useRouterLoadingStore();
loadingStore.start();
}
const publicPages = ['/auth/login'];
const authRequired = !publicPages.includes(to.path);
const auth: AuthStore = useAuthStore();
@@ -40,3 +46,8 @@ router.beforeEach(async (to, from, next) => {
next();
}
});
router.afterEach(() => {
const loadingStore = useRouterLoadingStore();
loadingStore.finish();
});
+60
View File
@@ -0,0 +1,60 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useRouterLoadingStore = defineStore('routerLoading', () => {
const isLoading = ref(false);
const progress = ref(0);
let progressInterval: ReturnType<typeof setInterval> | null = null;
function start() {
isLoading.value = true;
progress.value = 0;
if (progressInterval) {
clearInterval(progressInterval);
}
let currentProgress = 0;
progressInterval = setInterval(() => {
if (currentProgress < 80) {
// 快速阶段:0-80%
currentProgress += Math.random() * 20 + 10;
if (currentProgress > 80) {
currentProgress = 80;
}
} else if (currentProgress < 90) {
// 缓慢阶段:80-90%
currentProgress += Math.random() * 3 + 1;
if (currentProgress > 90) {
currentProgress = 90;
}
}
progress.value = Math.min(currentProgress, 90);
}, 50);
}
function finish() {
// 清理interval
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
// 快速完成到100%
progress.value = 100;
// 延迟隐藏,让用户看到100%
setTimeout(() => {
isLoading.value = false;
progress.value = 0;
}, 300);
}
return {
isLoading,
progress,
start,
finish
};
});
+63 -7
View File
@@ -499,21 +499,23 @@ export default {
// 将对话历史转换为 MessageList 组件期望的格式
formattedMessages() {
return this.conversationHistory.map(msg => {
console.log('处理消息:', msg.role, msg.image_url, msg.audio_url);
console.log('处理消息:', msg.role, msg.content);
// 将消息内容转换为 MessagePart[] 格式
const messageParts = this.convertContentToMessageParts(msg.content);
if (msg.role === 'user') {
return {
content: {
type: 'user',
message: this.extractTextFromContent(msg.content),
image_url: this.extractImagesFromContent(msg.content),
message: messageParts
}
};
} else {
return {
content: {
type: 'bot',
message: this.extractTextFromContent(msg.content),
embedded_images: this.extractImagesFromContent(msg.content),
message: messageParts
}
};
}
@@ -990,7 +992,61 @@ export default {
this.showMessage = true;
},
// 从内容中提取文本
// 将消息内容转换为 MessagePart[] 格式
convertContentToMessageParts(content) {
const parts = [];
if (typeof content === 'string') {
// 纯文本内容
if (content.trim()) {
parts.push({
type: 'plain',
text: content
});
}
} else if (Array.isArray(content)) {
// 数组格式(OpenAI 格式)
content.forEach(item => {
if (item.type === 'text' && item.text) {
parts.push({
type: 'plain',
text: item.text
});
} else if (item.type === 'image_url' && item.image_url?.url) {
parts.push({
type: 'image',
embedded_url: item.image_url.url
});
}
});
} else if (typeof content === 'object' && content !== null) {
// 对象格式,尝试提取文本和图片
const textParts = [];
for (const [key, value] of Object.entries(content)) {
if (typeof value === 'string' && value.trim()) {
textParts.push(value);
}
}
if (textParts.length > 0) {
parts.push({
type: 'plain',
text: textParts.join('\n')
});
}
}
// 如果没有提取到任何内容,添加一个空文本
if (parts.length === 0) {
parts.push({
type: 'plain',
text: ''
});
}
return parts;
},
// 从内容中提取文本(保留用于其他用途)
extractTextFromContent(content) {
if (typeof content === 'string') {
return content;
@@ -1004,7 +1060,7 @@ export default {
return '';
},
// 从内容中提取图片URL
// 从内容中提取图片URL(保留用于其他用途)
extractImagesFromContent(content) {
if (Array.isArray(content)) {
return content.filter(item => item.type === 'image_url')
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.10.0-alpha.1"
version = "4.10.0"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.10"
+326
View File
@@ -0,0 +1,326 @@
import os
import sys
from unittest.mock import AsyncMock
import pytest
# 将项目根目录添加到 sys.path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.provider.entities import LLMResponse, ProviderRequest, TokenUsage
from astrbot.core.provider.provider import Provider
class MockProvider(Provider):
"""模拟Provider用于测试"""
def __init__(self):
super().__init__({}, {})
self.call_count = 0
self.should_call_tools = True
self.max_calls_before_normal_response = 10
def get_current_key(self) -> str:
return "test_key"
def set_key(self, key: str):
pass
async def get_models(self) -> list[str]:
return ["test_model"]
async def text_chat(self, **kwargs) -> LLMResponse:
self.call_count += 1
# 检查工具是否被禁用
func_tool = kwargs.get("func_tool")
# 如果工具被禁用或超过最大调用次数,返回正常响应
if func_tool is None or self.call_count > self.max_calls_before_normal_response:
return LLMResponse(
role="assistant",
completion_text="这是我的最终回答",
usage=TokenUsage(input_other=10, output=5),
)
# 模拟工具调用响应
if self.should_call_tools:
return LLMResponse(
role="assistant",
completion_text="我需要使用工具来帮助您",
tools_call_name=["test_tool"],
tools_call_args=[{"query": "test"}],
tools_call_ids=["call_123"],
usage=TokenUsage(input_other=10, output=5),
)
# 默认返回正常响应
return LLMResponse(
role="assistant",
completion_text="这是我的最终回答",
usage=TokenUsage(input_other=10, output=5),
)
async def text_chat_stream(self, **kwargs):
response = await self.text_chat(**kwargs)
response.is_chunk = True
yield response
response.is_chunk = False
yield response
class MockToolExecutor:
"""模拟工具执行器"""
@classmethod
def execute(cls, tool, run_context, **tool_args):
async def generator():
# 模拟工具返回结果,使用正确的类型
from mcp.types import CallToolResult, TextContent
result = CallToolResult(
content=[TextContent(type="text", text="工具执行结果")]
)
yield result
return generator()
class MockHooks(BaseAgentRunHooks):
"""模拟钩子函数"""
def __init__(self):
self.agent_begin_called = False
self.agent_done_called = False
self.tool_start_called = False
self.tool_end_called = False
async def on_agent_begin(self, run_context):
self.agent_begin_called = True
async def on_tool_start(self, run_context, tool, tool_args):
self.tool_start_called = True
async def on_tool_end(self, run_context, tool, tool_args, tool_result):
self.tool_end_called = True
async def on_agent_done(self, run_context, llm_response):
self.agent_done_called = True
@pytest.fixture
def mock_provider():
return MockProvider()
@pytest.fixture
def mock_tool_executor():
return MockToolExecutor()
@pytest.fixture
def mock_hooks():
return MockHooks()
@pytest.fixture
def tool_set():
"""创建测试用的工具集"""
tool = FunctionTool(
name="test_tool",
description="测试工具",
parameters={"type": "object", "properties": {"query": {"type": "string"}}},
handler=AsyncMock(),
)
return ToolSet(tools=[tool])
@pytest.fixture
def provider_request(tool_set):
"""创建测试用的ProviderRequest"""
return ProviderRequest(prompt="请帮我查询信息", func_tool=tool_set, contexts=[])
@pytest.fixture
def runner():
"""创建ToolLoopAgentRunner实例"""
return ToolLoopAgentRunner()
@pytest.mark.asyncio
async def test_max_step_limit_functionality(
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
):
"""测试最大步数限制功能"""
# 设置模拟provider,让它总是返回工具调用
mock_provider.should_call_tools = True
mock_provider.max_calls_before_normal_response = (
100 # 设置一个很大的值,确保不会自然结束
)
# 初始化runner
await runner.reset(
provider=mock_provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=False,
)
# 设置较小的最大步数来测试限制功能
max_steps = 3
# 收集所有响应
responses = []
async for response in runner.step_until_done(max_steps):
responses.append(response)
# 验证结果
assert runner.done(), "代理应该在达到最大步数后完成"
# 验证工具被禁用(这是最重要的验证点)
assert runner.req.func_tool is None, "达到最大步数后工具应该被禁用"
# 验证有最终响应
final_responses = [r for r in responses if r.type == "llm_result"]
assert len(final_responses) > 0, "应该有最终的LLM响应"
# 验证最后一条消息是assistant的最终回答
last_message = runner.run_context.messages[-1]
assert last_message.role == "assistant", "最后一条消息应该是assistant的最终回答"
@pytest.mark.asyncio
async def test_normal_completion_without_max_step(
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
):
"""测试正常完成(不触发最大步数限制)"""
# 设置模拟provider,让它在第2次调用时返回正常响应
mock_provider.should_call_tools = True
mock_provider.max_calls_before_normal_response = 2
# 初始化runner
await runner.reset(
provider=mock_provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=False,
)
# 设置足够大的最大步数
max_steps = 10
# 收集所有响应
responses = []
async for response in runner.step_until_done(max_steps):
responses.append(response)
# 验证结果
assert runner.done(), "代理应该正常完成"
# 验证没有触发最大步数限制 - 通过检查provider调用次数
# mock_provider在第2次调用后返回正常响应,所以不应该达到max_steps(10)
assert mock_provider.call_count < max_steps, (
f"正常完成时调用次数({mock_provider.call_count})应该小于最大步数({max_steps})"
)
# 验证没有最大步数警告消息(注意:实际注入的是user角色的消息)
user_messages = [m for m in runner.run_context.messages if m.role == "user"]
max_step_messages = [
m for m in user_messages if "工具调用次数已达到上限" in m.content
]
assert len(max_step_messages) == 0, "正常完成时不应该有步数限制消息"
# 验证工具仍然可用(没有被禁用)
assert runner.req.func_tool is not None, "正常完成时工具不应该被禁用"
@pytest.mark.asyncio
async def test_max_step_with_streaming(
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
):
"""测试流式响应下的最大步数限制"""
# 设置模拟provider
mock_provider.should_call_tools = True
mock_provider.max_calls_before_normal_response = 100
# 初始化runner,启用流式响应
await runner.reset(
provider=mock_provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=True,
)
# 设置较小的最大步数
max_steps = 2
# 收集所有响应
responses = []
async for response in runner.step_until_done(max_steps):
responses.append(response)
# 验证结果
assert runner.done(), "代理应该在达到最大步数后完成"
# 验证有流式响应
streaming_responses = [r for r in responses if r.type == "streaming_delta"]
assert len(streaming_responses) > 0, "应该有流式响应"
# 验证工具被禁用
assert runner.req.func_tool is None, "达到最大步数后工具应该被禁用"
# 验证最后一条消息是assistant的最终回答
last_message = runner.run_context.messages[-1]
assert last_message.role == "assistant", "最后一条消息应该是assistant的最终回答"
@pytest.mark.asyncio
async def test_hooks_called_with_max_step(
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
):
"""测试达到最大步数时钩子函数是否被正确调用"""
# 设置模拟provider
mock_provider.should_call_tools = True
mock_provider.max_calls_before_normal_response = 100
# 初始化runner
await runner.reset(
provider=mock_provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=False,
)
# 设置较小的最大步数
max_steps = 2
# 执行步骤
async for response in runner.step_until_done(max_steps):
pass
# 验证钩子函数被调用
assert mock_hooks.agent_begin_called, "on_agent_begin应该被调用"
assert mock_hooks.agent_done_called, "on_agent_done应该被调用"
assert mock_hooks.tool_start_called, "on_tool_start应该被调用"
assert mock_hooks.tool_end_called, "on_tool_end应该被调用"
if __name__ == "__main__":
# 运行测试
pytest.main([__file__, "-v"])