feat: 前端工具调用折叠展示,优化对话中工具调用的显示逻辑

This commit is contained in:
Wenjie Zhang 2026-04-13 18:06:25 +08:00
parent 9c8998699c
commit 4d346103b4
12 changed files with 577 additions and 133 deletions

View File

@ -25,6 +25,10 @@ PROMPT = f"""
query_kb 中没有找到相关的内容或者需要进一步基于检索到的内容获取更加详细的上下文的时候还可以直接访问知识库文件系统
路径为 {VIRTUAL_KBS_PATH}来获取信息
源文件可能无法直接读取可以在 {VIRTUAL_KBS_PATH}/<db_name>/parsed/ 中找到解析后的 markdown 文件
"""
# 效果不好,暂时不启用
SOURCE_CITE_PROMPT = """
<| 引用来源 |>
当你提供的信息来自于用户上传的文件或者知识库中的内容时请务必在回答中注明信息来源以增加答案的可信度和透明度

View File

@ -51,6 +51,7 @@
- 修复前端依赖安全告警:通过 `pnpm.overrides` 将传递依赖 `flatted` 锁定到 `3.4.2`、`lodash-es` 锁定到 `4.18.1`,并同步更新 `pnpm-lock.yaml` 以消除 DriftGuard 报告的高危 CVE
- 重写界面设计规范:参考 `DESIGN.md` 写法补充视觉气质、颜色 token、组件状态、布局层级、响应式与 Agent Prompt Guide并基于该规范收敛首页视觉表现移除装饰性渐变、重阴影、hover 位移和入场动画。
- 修复对话摘要中间件的工具结果卸载链路:摘要触发时改为将大体积 `ToolMessage` 写入当前 agent 可见的 sandbox outputs 路径,修正 `summary_offload` 路径拼接错误、`messages` 触发条件下不会真正裁剪历史的问题,并避免将 system message 重复纳入摘要与最终消息列表;补充对应单元测试覆盖。
- 调整智能体对话中的工具调用展示:连续工具调用默认折叠为“调用了 N 个工具”的轻量摘要,展开后改为弱化时间线样式,减少工具结果卡片对正文阅读节奏的干扰。
---

View File

@ -86,19 +86,22 @@
<div class="chat-main" ref="chatMainRef">
<div class="chat-box">
<div class="conv-box" v-for="(conv, index) in conversations" :key="index">
<AgentMessageComponent
v-for="(message, msgIndex) in conv.messages"
:message="message"
:key="msgIndex"
:is-processing="
isProcessing &&
conv.status === 'streaming' &&
msgIndex === conv.messages.length - 1
"
:show-refs="showMsgRefs(message)"
@retry="retryMessage(message)"
>
</AgentMessageComponent>
<template v-for="(displayItem, itemIndex) in getConversationDisplayItems(conv)" :key="displayItem.key">
<AgentMessageComponent
v-if="displayItem.type === 'message'"
:message="displayItem.message"
:is-processing="isDisplayMessageProcessing(conv, displayItem)"
:show-refs="showMsgRefs(displayItem.message)"
:hide-tool-calls="true"
@retry="retryMessage(displayItem.message)"
>
</AgentMessageComponent>
<ToolCallsGroupComponent
v-else
:tool-calls="displayItem.toolCalls"
:is-active="isToolGroupActive(conv, itemIndex, getConversationDisplayItems(conv))"
/>
</template>
<!-- 显示对话最后一个消息使用的模型 -->
<RefsComponent
v-if="shouldShowRefs(conv)"
@ -243,6 +246,7 @@ import AgentInputArea from '@/components/AgentInputArea.vue'
import AgentMessageComponent from '@/components/AgentMessageComponent.vue'
import ChatSidebarComponent from '@/components/ChatSidebarComponent.vue'
import RefsComponent from '@/components/RefsComponent.vue'
import ToolCallsGroupComponent from '@/components/ToolCallsGroupComponent.vue'
import { PanelLeftOpen, MessageCirclePlus, LoaderCircle, Bot, Telescope } from 'lucide-vue-next'
import { handleChatError, handleValidationError } from '@/utils/errorHandler'
import { ScrollController } from '@/utils/scrollController'
@ -1496,6 +1500,120 @@ const handleResizingChange = (isResizingState, clientX = 0) => {
}
// ==================== HELPER FUNCTIONS ====================
const extractAssistantMessageBody = (message) => {
let content = typeof message?.content === 'string' ? message.content.trim() : ''
let reasoningContent = message?.additional_kwargs?.reasoning_content || ''
if (!reasoningContent && content) {
const thinkRegex = /<think>(.*?)<\/think>|<think>(.*?)$/s
const thinkMatch = content.match(thinkRegex)
if (thinkMatch) {
reasoningContent = (thinkMatch[1] || thinkMatch[2] || '').trim()
content = content.replace(thinkMatch[0], '').trim()
}
}
return { content, reasoningContent }
}
const hasVisibleAssistantBody = (message) => {
if (!message || message.type !== 'ai') return true
const { content, reasoningContent } = extractAssistantMessageBody(message)
return Boolean(
content ||
reasoningContent ||
message.error_type ||
message.extra_metadata?.error_type ||
message.isStoppedByUser
)
}
const getMessageToolCalls = (message) => {
if (!Array.isArray(message?.tool_calls)) return []
return message.tool_calls.filter((toolCall) => {
return (
toolCall &&
(toolCall.id || toolCall.name || toolCall.function?.name) &&
(toolCall.args !== undefined ||
toolCall.function?.arguments !== undefined ||
toolCall.tool_call_result !== undefined)
)
})
}
// AI
const getConversationDisplayItems = (conv) => {
if (!Array.isArray(conv?.messages) || conv.messages.length === 0) return []
const items = []
let pendingToolGroup = null
const flushToolGroup = () => {
if (pendingToolGroup && pendingToolGroup.toolCalls.length > 0) {
items.push(pendingToolGroup)
}
pendingToolGroup = null
}
conv.messages.forEach((message, index) => {
if (message.type !== 'ai') {
flushToolGroup()
items.push({
type: 'message',
key: message.id || `message-${index}`,
message,
sourceIndex: index
})
return
}
if (hasVisibleAssistantBody(message)) {
flushToolGroup()
items.push({
type: 'message',
key: message.id || `message-${index}`,
message,
sourceIndex: index
})
}
const toolCalls = getMessageToolCalls(message)
if (toolCalls.length === 0) return
if (!pendingToolGroup) {
pendingToolGroup = {
type: 'tool-group',
key: `tool-group-${message.id || index}`,
toolCalls: []
}
}
pendingToolGroup.toolCalls.push(...toolCalls)
})
flushToolGroup()
return items
}
const isDisplayMessageProcessing = (conv, displayItem) => {
return (
displayItem?.type === 'message' &&
isProcessing.value &&
conv?.status === 'streaming' &&
displayItem.sourceIndex === conv.messages.length - 1
)
}
const isToolGroupActive = (conv, itemIndex, displayItems) => {
return (
isProcessing.value &&
conv?.status === 'streaming' &&
itemIndex === displayItems.length - 1
)
}
const getLastMessage = (conv) => {
if (!conv?.messages?.length) return null
for (let i = conv.messages.length - 1; i >= 0; i--) {

View File

@ -63,15 +63,10 @@
<span v-else>{{ message.error_type || '未知错误' }}</span>
</div>
<div v-if="validToolCalls && validToolCalls.length > 0" class="tool-calls-container">
<div
v-for="(toolCall, index) in validToolCalls"
:key="toolCall.id || index"
class="tool-call-container"
>
<ToolCallRenderer :tool-call="toolCall" />
</div>
</div>
<ToolCallsGroupComponent
v-if="!hideToolCalls && validToolCalls.length > 0"
:tool-calls="validToolCalls"
/>
<div v-if="message.isStoppedByUser" class="retry-hint">
你停止生成了本次回答
@ -111,7 +106,7 @@ import { computed, ref } from 'vue'
import { CaretRightOutlined } from '@ant-design/icons-vue'
import RefsComponent from '@/components/RefsComponent.vue'
import { Copy, Check } from 'lucide-vue-next'
import { ToolCallRenderer } from '@/components/ToolCallingResult'
import ToolCallsGroupComponent from '@/components/ToolCallsGroupComponent.vue'
import { useAgentStore } from '@/stores/agent'
import { useInfoStore } from '@/stores/info'
import { useThemeStore } from '@/stores/theme'
@ -147,6 +142,10 @@ const props = defineProps({
type: Boolean,
default: false
},
hideToolCalls: {
type: Boolean,
default: false
},
// (使 infoStore.debugMode)
debugMode: {
type: Boolean,
@ -250,7 +249,7 @@ const validToolCalls = computed(() => {
//
return (
toolCall &&
(toolCall.id || toolCall.name) &&
(toolCall.id || toolCall.name || toolCall.function?.name) &&
(toolCall.args !== undefined ||
toolCall.function?.arguments !== undefined ||
toolCall.tool_call_result !== undefined)
@ -474,19 +473,6 @@ const parsedData = computed(() => {
max-height: 200px;
overflow-y: auto;
}
:deep(.tool-calls-container) {
width: 100%;
margin-top: 10px;
.tool-call-container {
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
}
}
.retry-hint {

View File

@ -1,5 +1,8 @@
<template>
<div class="tool-call-display" :class="{ 'is-collapsed': !isExpanded }">
<div
class="tool-call-display"
:class="{ 'is-collapsed': !isExpanded, 'is-timeline': isTimeline }"
>
<!-- Header Slot -->
<div class="tool-header" @click="toggleExpand">
<!-- Slot for completely overriding header (not recommended based on new requirement but kept for backward compat if needed, or remove if strict) -->
@ -74,7 +77,7 @@
</div>
<!-- Result Slot -->
<div class="tool-result" v-if="hasResult">
<div class="tool-result" style="opacity: 0.8;" v-if="hasResult">
<slot name="result" :tool-call="toolCall" :result-content="resultContent">
<div class="tool-result-content" :data-tool-call-id="toolCall.id">
<!-- Default rendering -->
@ -111,6 +114,10 @@ const props = defineProps({
hideParams: {
type: Boolean,
default: false
},
appearance: {
type: String,
default: 'card'
}
})
@ -118,6 +125,7 @@ const agentStore = useAgentStore()
const { availableTools } = storeToRefs(agentStore)
const isExpanded = ref(props.defaultExpanded)
const isTimeline = computed(() => props.appearance === 'timeline')
const toggleExpand = () => {
isExpanded.value = !isExpanded.value
@ -198,33 +206,37 @@ const formatResultData = (data) => {
<style lang="less" scoped>
.tool-call-display {
outline: 1px solid var(--gray-150);
border: 1px solid var(--gray-100);
border-radius: 8px;
overflow: hidden;
transition: all 0.2s ease;
margin-bottom: 10px;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
.tool-header {
padding: 8px 12px;
font-size: 14px;
padding: 6px 10px;
font-size: 13px;
font-weight: 500;
color: var(--gray-800);
border-bottom: 1px solid var(--gray-100);
border-bottom: 1px solid var(--gray-50);
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
position: relative;
transition: color 0.2s ease;
transition: background-color 0.2s ease;
.anticon {
color: var(--main-color);
font-size: 16px;
&:hover {
background-color: var(--gray-25);
}
&>span {
display: flex;
align-items: center;
}
.tool-name {
@ -232,15 +244,9 @@ const formatResultData = (data) => {
color: var(--main-700);
}
span {
display: flex;
align-items: center;
gap: 4px;
}
.tool-loader {
margin-top: 2px;
color: var(--main-700);
margin-top: 0;
color: var(--main-600);
}
.tool-loader.rotate {
@ -248,7 +254,7 @@ const formatResultData = (data) => {
}
.tool-loader.tool-success {
color: var(--main-color);
color: var(--color-success-500);
}
.tool-loader.tool-error {
@ -261,7 +267,7 @@ const formatResultData = (data) => {
.tool-expand-icon {
margin-left: auto;
color: var(--gray-400);
color: var(--gray-300);
display: flex;
align-items: center;
}
@ -273,57 +279,49 @@ const formatResultData = (data) => {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--gray-600);
:deep(.sep-header) {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-size: 13px;
width: 100%;
overflow: hidden;
.note {
font-weight: 600;
color: var(--gray-700);
flex-shrink: 0;
}
.separator {
color: var(--gray-300);
flex-shrink: 0;
}
.description {
color: var(--gray-500);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
}
:deep(.keywords) {
color: var(--main-700);
font-weight: 600;
font-size: 14px;
}
:deep(.note) {
font-weight: 600;
color: var(--main-700);
white-space: nowrap;
flex-shrink: 0;
}
:deep(span.code) {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
:deep(.separator) {
color: var(--gray-300);
flex-shrink: 0;
}
:deep(.description) {
color: var(--gray-700);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
:deep(.tag) {
font-size: 12px;
color: var(--gray-800);
font-size: 11px;
color: var(--gray-500);
background-color: var(--gray-50);
padding: 0px 4px;
border-radius: 4px;
margin-left: 8px;
&.tag-yes {
color: var(--main-500);
}
white-space: nowrap;
&.success {
color: var(--color-success-500);
@ -343,13 +341,13 @@ const formatResultData = (data) => {
.tool-params {
padding: 8px 12px;
background-color: var(--gray-25);
border-bottom: 1px solid var(--gray-150);
border-bottom: 1px solid var(--gray-50);
.tool-params-content {
margin: 0;
font-size: 12px;
overflow-x: auto;
color: var(--gray-700);
color: var(--gray-600);
line-height: 1.5;
pre {
@ -358,16 +356,6 @@ const formatResultData = (data) => {
}
}
}
.tool-result {
padding: 0;
background-color: transparent;
.tool-result-content {
padding: 0;
background-color: transparent;
}
}
}
&.is-collapsed {
@ -375,6 +363,57 @@ const formatResultData = (data) => {
border-bottom: none;
}
}
&.is-timeline {
border: none;
border-radius: 0;
overflow: visible;
margin-bottom: 0;
padding-left: 0;
position: relative;
.tool-header {
padding: 4px 0;
background-color: transparent;
border-bottom: none;
color: var(--gray-500);
gap: 10px;
&:hover {
background-color: transparent;
color: var(--gray-700);
}
.tool-name {
color: var(--gray-600);
}
.tool-loader {
color: var(--gray-400);
}
.tool-expand-icon {
opacity: 0.5;
}
.tool-header-content {
font-size: 13px;
color: var(--gray-500);
}
}
.tool-content {
margin: 4px 0 8px 12px;
padding-left: 12px;
border-left: 1px solid var(--gray-100);
.tool-params {
padding: 4px 0 8px;
background-color: transparent;
border-bottom: none;
}
}
}
}
@keyframes rotate {
@ -397,7 +436,7 @@ const formatResultData = (data) => {
.default-content {
background: var(--gray-0);
padding: 12px;
padding: 8px 0px;
pre {
margin: 0;
@ -408,7 +447,7 @@ const formatResultData = (data) => {
word-break: break-word;
max-height: 300px;
overflow-y: auto;
background: var(--gray-50);
background: var(--gray-25);
padding: 10px;
border-radius: 4px;
}

View File

@ -3,9 +3,16 @@
:is="currentRenderer"
v-if="currentRenderer"
:tool-call="toolCall"
:appearance="appearance"
:default-expanded="defaultExpanded"
ref="toolRendererRef"
/>
<BaseToolCall v-else-if="!isHidden" :tool-call="toolCall" />
<BaseToolCall
v-else-if="!isHidden"
:tool-call="toolCall"
:appearance="appearance"
:default-expanded="defaultExpanded"
/>
</template>
<script setup>
@ -19,6 +26,7 @@ import QueryKbTool from './tools/QueryKbTool.vue'
import KnowledgeGraphTool from './tools/KnowledgeGraphTool.vue'
import CalculatorTool from './tools/CalculatorTool.vue'
import TodoListTool from './tools/TodoListTool.vue'
import TaskTool from './tools/TaskTool.vue'
import ImageTool from './tools/ImageTool.vue'
import WriteFileTool from './tools/WriteFileTool.vue'
import ReadFileTool from './tools/ReadFileTool.vue'
@ -32,12 +40,20 @@ import MysqlDescribeTableTool from './tools/MysqlDescribeTableTool.vue'
import MysqlListTablesTool from './tools/MysqlListTablesTool.vue'
import AskUserQuestionTool from './tools/AskUserQuestionTool.vue'
import ExecuteTool from './tools/ExecuteTool.vue'
import { getToolCallId } from './toolRegistry'
import { getToolCallId, HIDDEN_TOOL_CALL_IDS } from './toolRegistry'
const props = defineProps({
toolCall: {
type: Object,
required: true
},
appearance: {
type: String,
default: 'card'
},
defaultExpanded: {
type: Boolean,
default: false
}
})
@ -65,16 +81,15 @@ const TOOL_RENDERERS = {
replace: EditFileTool,
run_shell_command: ExecuteTool,
search_file_content: SearchFileContentTool,
task: TaskTool,
tavily_search: WebSearchTool,
text_to_img_qwen_image: ImageTool,
write_file: WriteFileTool,
write_todos: TodoListTool
}
const TOOL_RENDERER_HIDE = ['present_artifacts']
const currentRenderer = computed(() => TOOL_RENDERERS[toolId.value] || null)
const isHidden = computed(() => TOOL_RENDERER_HIDE.includes(toolId.value))
const isHidden = computed(() => HIDDEN_TOOL_CALL_IDS.includes(toolId.value))
const toolRendererRef = ref(null)
const refreshGraph = () => {

View File

@ -1,5 +1,6 @@
import {
BookOpen,
Bot,
Calculator,
CheckSquare,
Database,
@ -39,12 +40,16 @@ export const TOOL_ICON_MAP = {
replace: FilePen,
run_shell_command: Terminal,
search_file_content: FolderSearch,
task: Bot,
tavily_search: Globe,
text_to_img_qwen_image: Image,
write_file: FileEdit,
write_todos: CheckSquare
}
// Keep intentionally hidden tool calls centralized so group summaries and renderers stay consistent.
export const HIDDEN_TOOL_CALL_IDS = ['present_artifacts']
export const getToolCallId = (toolCall) => toolCall?.name || toolCall?.function?.name || ''
export const getToolIcon = (toolId) => TOOL_ICON_MAP[toolId] || null

View File

@ -2,10 +2,10 @@
<BaseToolCall :tool-call="toolCall">
<template #header>
<div class="sep-header">
<span class="note">{{ toolCallName }}</span>
<span class="note">Edit</span>
<span class="separator" v-if="filePath">|</span>
<span class="description">
<span class="code">{{ filePath }}</span>
<span class="description" :title="filePath">
<span class="code">{{ fileName }}</span>
<span class="tag success" v-if="addedLines > 0">+{{ addedLines }}</span>
<span class="tag error" v-if="removedLines > 0">-{{ removedLines }}</span>
</span>
@ -25,10 +25,6 @@ const props = defineProps({
}
})
const toolCallName = computed(
() => props.toolCall.name || props.toolCall.function?.name || 'edit_file'
)
const parsedArgs = computed(() => {
const args = props.toolCall.args || props.toolCall.function?.arguments
if (!args) return {}
@ -42,6 +38,13 @@ const parsedArgs = computed(() => {
const filePath = computed(() => parsedArgs.value.file_path || '')
// title
const fileName = computed(() => {
const path = filePath.value
if (!path) return ''
return path.replace(/\\/g, '/').split('/').pop()
})
const addedLines = computed(() => {
const newStr = parsedArgs.value.new_string || ''
return newStr ? String(newStr).split('\n').length : 0

View File

@ -2,12 +2,20 @@
<BaseToolCall :tool-call="toolCall">
<template #header>
<div class="sep-header">
<span class="note">read_file</span>
<span class="separator" v-if="filePath">|</span>
<span class="description">
<span class="code">{{ filePath }}</span>
<span class="tag" v-if="lineRange">{{ lineRange }}</span>
</span>
<!-- 特殊处理SKILL.md 文件显示为 Skill | <父目录名> -->
<template v-if="skillName">
<span class="note skill-note">Skill</span>
<span class="separator">|</span>
<span class="description skill-name">{{ skillName }}</span>
</template>
<template v-else>
<span class="note">Read</span>
<span class="separator" v-if="filePath">|</span>
<span class="description" :title="filePath">
<span class="code">{{ fileName }}</span>
<span class="tag" v-if="lineRange">{{ lineRange }}</span>
</span>
</template>
</div>
</template>
</BaseToolCall>
@ -37,6 +45,13 @@ const parsedArgs = computed(() => {
const filePath = computed(() => parsedArgs.value.file_path || '')
// title
const fileName = computed(() => {
const path = filePath.value
if (!path) return ''
return path.replace(/\\/g, '/').split('/').pop()
})
const lineRange = computed(() => {
const offset = parsedArgs.value.offset
const limit = parsedArgs.value.limit
@ -47,6 +62,16 @@ const lineRange = computed(() => {
}
return ''
})
// SKILL.md
// mmm/xxx/SKILL.md skillName = 'xxx'
const skillName = computed(() => {
const path = filePath.value
if (!path.endsWith('SKILL.md')) return null
const parts = path.replace(/\\/g, '/').split('/')
// SKILL.md
return parts.length >= 2 ? parts[parts.length - 2] : null
})
</script>
<style lang="less" scoped>
@ -55,5 +80,15 @@ const lineRange = computed(() => {
color: var(--color-primary-600);
background-color: var(--color-primary-50);
}
// SKILL.md
.skill-note {
color: var(--main-700);
}
.skill-name {
font-weight: 600;
color: var(--main-600);
}
}
</style>

View File

@ -2,7 +2,7 @@
<BaseToolCall :tool-call="toolCall">
<template #header>
<div class="sep-header">
<span class="subagent">{{ subagentType }}</span>
<span class="note">{{ subagentType }}</span>
<span class="separator" v-if="shortDescription">|</span>
<span class="description" v-if="shortDescription">{{ shortDescription }}</span>
</div>
@ -71,13 +71,6 @@ const shortDescription = computed(() => {
font-size: 14px;
width: 100%;
overflow: hidden;
.subagent {
font-weight: 600;
color: var(--main-700);
white-space: nowrap;
flex-shrink: 0;
}
}
.task-description {

View File

@ -141,8 +141,7 @@ const todoListData = (content) => {
<style lang="less" scoped>
.todo-list-result {
background: var(--gray-0);
border-radius: 8px;
padding: 12px;
padding: 0px;
.todo-list {
display: flex;
@ -154,7 +153,7 @@ const todoListData = (content) => {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 4px 8px;
padding: 4px 0px;
// background: var(--gray-10);
border-radius: 6px;
// border: 1px solid var(--gray-150);

View File

@ -0,0 +1,246 @@
<template>
<div v-if="normalizedToolCalls.length > 0" class="tool-calls-container">
<button
v-if="shouldCollapseToolCalls"
type="button"
class="tool-calls-summary"
:class="{ 'is-expanded': areToolCallsExpanded }"
:aria-expanded="areToolCallsExpanded"
@click="toggleToolCallsExpanded"
>
<span class="summary-leading">
<Wrench size="14" />
</span>
<span class="summary-content">
<span class="summary-title">{{ toolCallsSummaryTitle }}</span>
<span class="summary-separator" v-if="normalizedToolCalls.length > 1 && toolCallsNamesMeta">·</span>
<span class="summary-meta" v-if="normalizedToolCalls.length > 1 && toolCallsNamesMeta">{{ toolCallsNamesMeta }}</span>
<span class="summary-status-tag" v-if="statusSummary">{{ statusSummary }}</span>
</span>
<span class="summary-trailing">
<component :is="areToolCallsExpanded ? ChevronDown : ChevronRight" size="14" />
</span>
</button>
<div v-if="!shouldCollapseToolCalls || areToolCallsExpanded" class="tool-calls-panel">
<div
v-for="(toolCall, index) in normalizedToolCalls"
:key="toolCall.id || `${getToolCallId(toolCall)}-${index}`"
class="tool-call-container"
>
<ToolCallRenderer :tool-call="toolCall" appearance="timeline" :default-expanded="false" />
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ChevronDown, ChevronRight, Wrench } from 'lucide-vue-next'
import { ToolCallRenderer } from '@/components/ToolCallingResult'
import { getToolCallId, HIDDEN_TOOL_CALL_IDS } from '@/components/ToolCallingResult/toolRegistry'
const props = defineProps({
toolCalls: {
type: Array,
default: () => []
},
isActive: {
type: Boolean,
default: false
}
})
const normalizedToolCalls = computed(() => {
return (props.toolCalls || []).filter((toolCall) => {
const toolId = getToolCallId(toolCall)
return (
toolCall &&
!HIDDEN_TOOL_CALL_IDS.includes(toolId) &&
(toolCall.id || toolCall.name || toolCall.function?.name) &&
(toolCall.args !== undefined ||
toolCall.function?.arguments !== undefined ||
toolCall.tool_call_result !== undefined)
)
})
})
const shouldCollapseToolCalls = computed(() => normalizedToolCalls.value.length > 0)
const areToolCallsExpanded = ref(false)
watch(
[() => normalizedToolCalls.value.length, () => props.isActive],
([, isActive], [, previousActive]) => {
//
if (isActive) {
areToolCallsExpanded.value = true
return
}
//
if (previousActive === true && isActive === false) {
areToolCallsExpanded.value = false
return
}
//
if (!previousActive && !isActive) {
areToolCallsExpanded.value = false
}
},
{ immediate: true }
)
const getToolCallLabel = (toolCall) => {
const rawName = getToolCallId(toolCall)
const name = typeof rawName === 'string' ? rawName.replaceAll('_', ' ') : 'tool'
return name.charAt(0).toUpperCase() + name.slice(1)
}
const toolCallsSummaryTitle = computed(() => {
if (normalizedToolCalls.value.length === 1) {
return `使用了工具: ${getToolCallLabel(normalizedToolCalls.value[0])}`
}
return `已调用 ${normalizedToolCalls.value.length} 个工具`
})
const toolCallsNamesMeta = computed(() => {
const names = normalizedToolCalls.value.map(getToolCallLabel).filter(Boolean)
const uniqueNames = [...new Set(names)]
const visibleNames = uniqueNames.slice(0, 3)
if (visibleNames.length === 0) return ''
return `${visibleNames.join(' · ')}${uniqueNames.length > visibleNames.length ? ` +${uniqueNames.length - visibleNames.length}` : ''}`
})
const statusSummary = computed(() => {
const successCount = normalizedToolCalls.value.filter(
(toolCall) => toolCall.status === 'success' || toolCall.tool_call_result
).length
const runningCount = normalizedToolCalls.value.filter(
(toolCall) =>
toolCall.status !== 'success' && toolCall.status !== 'error' && !toolCall.tool_call_result
).length
const errorCount = normalizedToolCalls.value.filter((toolCall) => toolCall.status === 'error')
.length
const parts = []
if (successCount > 0 && successCount === normalizedToolCalls.value.length) {
return '已完成'
}
if (errorCount > 0) parts.push(`${errorCount} 失败`)
if (runningCount > 0) parts.push(`${runningCount} 进行中`)
return parts.join(' · ')
})
const toggleToolCallsExpanded = () => {
if (!shouldCollapseToolCalls.value) return
areToolCallsExpanded.value = !areToolCallsExpanded.value
}
</script>
<style lang="less" scoped>
.tool-calls-container {
width: 100%;
margin: 0;
padding: 0;
.tool-calls-summary {
appearance: none;
width: auto;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--gray-500);
text-align: left;
cursor: pointer;
outline: none;
transition: all 0.2s ease;
user-select: none;
&:hover {
background: var(--gray-100);
color: var(--gray-700);
}
&.is-expanded {
color: var(--gray-700);
background: var(--gray-50);
margin-bottom: 4px;
}
.summary-leading {
display: inline-flex;
align-items: center;
color: var(--gray-400);
flex-shrink: 0;
}
.summary-content {
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
flex: 1;
font-size: 13px;
}
.summary-title {
font-weight: 500;
white-space: nowrap;
}
.summary-separator {
color: var(--gray-300);
flex-shrink: 0;
}
.summary-meta {
color: var(--gray-400);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.summary-status-tag {
margin-left: 4px;
font-size: 11px;
padding: 0px 4px;
background: var(--gray-25);
color: var(--gray-500);
border-radius: 4px;
white-space: nowrap;
font-weight: normal;
}
.summary-trailing {
display: inline-flex;
align-items: center;
color: var(--gray-300);
flex-shrink: 0;
}
}
.tool-calls-panel {
padding: 4px 0 4px 12px;
border-left: 1px solid var(--gray-100);
margin-left: 16px;
margin-top: 4px;
margin-bottom: 8px;
}
.tool-call-container {
margin-bottom: 4px;
&:last-child {
margin-bottom: 0;
}
}
}
</style>