feat: 前端工具调用折叠展示,优化对话中工具调用的显示逻辑
This commit is contained in:
parent
9c8998699c
commit
4d346103b4
@ -25,6 +25,10 @@ PROMPT = f"""
|
||||
当 query_kb 中没有找到相关的内容,或者需要进一步基于检索到的内容获取更加详细的上下文的时候,还可以直接访问知识库文件系统
|
||||
(路径为 {VIRTUAL_KBS_PATH})来获取信息。
|
||||
源文件可能无法直接读取,可以在 {VIRTUAL_KBS_PATH}/<db_name>/parsed/ 中找到解析后的 markdown 文件。
|
||||
"""
|
||||
|
||||
# 效果不好,暂时不启用
|
||||
SOURCE_CITE_PROMPT = """
|
||||
|
||||
<| 引用来源 |>
|
||||
当你提供的信息来自于用户上传的文件或者知识库中的内容时,请务必在回答中注明信息来源,以增加答案的可信度和透明度。
|
||||
|
||||
@ -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 个工具”的轻量摘要,展开后改为弱化时间线样式,减少工具结果卡片对正文阅读节奏的干扰。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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--) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
246
web/src/components/ToolCallsGroupComponent.vue
Normal file
246
web/src/components/ToolCallsGroupComponent.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user