ForcePilot/web/src/components/ToolCallingResult/BaseToolCall.vue
Wenjie Zhang 0d87c1755e feat(tool-calling): 统一工具调用结果的头部样式并添加写文件工具
- 为WebSearchTool、KnowledgeGraphTool、TodoListTool和TaskTool添加统一的sep-header样式
- 新增WriteFileTool组件用于显示写文件工具调用结果
- 在ToolCallRenderer中集成WriteFileTool组件
- 在BaseToolCall中定义统一的sep-header样式
2025-12-28 01:00:12 +08:00

402 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="tool-call-display" :class="{ 'is-collapsed': !isExpanded }">
<!-- 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) -->
<!-- Actually, the requirement says "tool call 的 header 也要有 slot", but "ICON 保留".
So we should probably not use a single "header" slot that replaces everything.
Instead, we structure it: Icon + Content + ExpandIcon.
-->
<!-- Fixed Status Icon -->
<span v-if="toolCall.status === 'success' || toolCall.tool_call_result">
<CircleCheckBig size="16" class="tool-loader tool-success" />
</span>
<span v-else-if="toolCall.status === 'error'">
<CircleCheckBig size="16" class="tool-loader tool-error" />
</span>
<span v-else>
<Loader size="16" class="tool-loader rotate tool-loading" />
</span>
<!-- Content Area with Slots -->
<div class="tool-header-content">
<!-- Generic Header Slot (Overrides specific slots if provided) -->
<template v-if="$slots.header">
<slot name="header" :tool-call="toolCall" :tool-name="toolName"></slot>
</template>
<!-- Specific State Slots (Fallback) -->
<template v-else>
<slot
name="header-success"
v-if="toolCall.status === 'success' || toolCall.tool_call_result"
:tool-name="toolName"
:result-content="resultContent"
>
工具&nbsp; <span class="tool-name">{{ toolName }}</span> &nbsp; 执行完成
</slot>
<slot
name="header-error"
v-else-if="toolCall.status === 'error'"
:tool-name="toolName"
:error-message="toolCall.error_message"
>
工具&nbsp; <span class="tool-name">{{ toolName }}</span> &nbsp; 执行失败
<span v-if="toolCall.error_message">{{ toolCall.error_message }}</span>
</slot>
<slot
name="header-running"
v-else
:tool-name="toolName"
>
正在调用工具: &nbsp; <span class="tool-name">{{ toolName }}</span>
</slot>
</template>
</div>
<!-- Fixed Expand Icon -->
<span class="tool-expand-icon">
<ChevronsDownUp v-if="isExpanded" size="14" />
<ChevronsUpDown v-else size="14" />
</span>
</div>
<!-- Content Area -->
<div class="tool-content" v-show="isExpanded">
<!-- Params Slot -->
<div class="tool-params" v-if="hasParams && !hideParams">
<slot name="params" :tool-call="toolCall" :args="formattedArgs">
<div class="tool-params-content">
<strong>参数: </strong>
<span>{{ formattedArgs }}</span>
</div>
</slot>
</div>
<!-- Result Slot -->
<div class="tool-result" 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 -->
<div class="tool-result-renderer">
<div class="default-result">
<div class="default-content">
<pre>{{ formatResultData(parsedResultData) }}</pre>
</div>
</div>
</div>
</div>
</slot>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { Loader, CircleCheckBig, ChevronsUpDown, ChevronsDownUp } from 'lucide-vue-next';
import { useAgentStore } from '@/stores/agent';
import { storeToRefs } from 'pinia';
const props = defineProps({
toolCall: {
type: Object,
required: true
},
defaultExpanded: {
type: Boolean,
default: false
},
hideParams: {
type: Boolean,
default: false
}
});
const agentStore = useAgentStore();
const { availableTools } = storeToRefs(agentStore);
const isExpanded = ref(props.defaultExpanded);
const toggleExpand = () => {
isExpanded.value = !isExpanded.value;
};
// Tool Name Logic
const toolName = computed(() => {
const toolId = props.toolCall.name || props.toolCall.function?.name;
const toolsList = availableTools.value ? Object.values(availableTools.value) : [];
const tool = toolsList.find(t => t.id === toolId);
return tool ? tool.name : toolId;
});
// Args Logic
const formattedArgs = computed(() => {
const args = props.toolCall.args ? props.toolCall.args : props.toolCall.function?.arguments;
if (!args) return '';
try {
if (typeof args === 'string' && args.trim().startsWith('{')) {
const parsed = JSON.parse(args);
return JSON.stringify(parsed, null, 2);
} else if (typeof args === 'object' && args !== null) {
return JSON.stringify(args, null, 2);
}
} catch (e) {
// ignore
}
return args;
});
const hasParams = computed(() => {
const argsStr = String(props.toolCall.args || props.toolCall.function?.arguments || '');
return argsStr.length > 2;
});
// Result Logic
const resultContent = computed(() => {
return props.toolCall.tool_call_result?.content;
});
const hasResult = computed(() => {
return !!resultContent.value;
});
// Default Result Rendering Logic
const parsedResultData = computed(() => {
const content = resultContent.value;
if (typeof content === 'string') {
try {
return JSON.parse(content);
} catch (error) {
return content;
}
}
return content;
});
const formatResultData = (data) => {
if (typeof data === 'object') {
return JSON.stringify(data, null, 2);
}
return String(data);
};
// Auto expand if loading
// Note: In the original code, expansion was managed by parent.
// Here we might want to default to expanded if it's loading?
// Original: :class="{ 'is-collapsed': !expandedToolCalls.has(toolCall.id) }"
// And expandedToolCalls defaults to empty set.
// User didn't specify default behavior, but usually we want to see what's happening.
// Let's keep it simple for now, defaulting to closed unless specified.
</script>
<style lang="less" scoped>
.tool-call-display {
background-color: var(--gray-25);
outline: 1px solid var(--gray-150);
border-radius: 8px;
overflow: hidden;
transition: all 0.2s ease;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
.tool-header {
padding: 8px 12px;
font-size: 14px;
font-weight: 500;
color: var(--gray-800);
border-bottom: 1px solid var(--gray-100);
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
position: relative;
transition: color 0.2s ease;
.anticon {
color: var(--main-color);
font-size: 16px;
}
.tool-name {
font-weight: 600;
color: var(--main-700);
}
span {
display: flex;
align-items: center;
gap: 4px;
}
.tool-loader {
margin-top: 2px;
color: var(--main-700);
}
.tool-loader.rotate {
animation: rotate 2s linear infinite;
}
.tool-loader.tool-success {
color: var(--color-success-500);
}
.tool-loader.tool-error {
color: var(--color-error-500);
}
.tool-loader.tool-loading {
color: var(--color-info-500);
}
.tool-expand-icon {
margin-left: auto;
color: var(--gray-400);
display: flex;
align-items: center;
}
.tool-header-content {
display: flex;
align-items: center;
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
:deep(.sep-header) {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
width: 100%;
overflow: hidden;
}
: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(.separator) {
color: var(--gray-300);
flex-shrink: 0;
}
:deep(.description) {
color: var(--gray-600);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
:deep(.tag) {
font-size: 12px;
color: var(--gray-600);
background-color: var(--gray-100);
padding: 0px 4px;
border-radius: 4px;
margin-left: 8px;
}
}
}
.tool-content {
transition: all 0.3s ease;
.tool-params {
padding: 8px 12px;
background-color: var(--gray-25);
border-bottom: 1px solid var(--gray-150);
.tool-params-content {
margin: 0;
font-size: 12px;
overflow-x: auto;
color: var(--gray-700);
line-height: 1.5;
pre {
margin: 0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
}
}
.tool-result {
padding: 0;
background-color: transparent;
.tool-result-content {
padding: 0;
background-color: transparent;
}
}
}
&.is-collapsed {
.tool-header {
border-bottom: none;
}
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Default Renderer Styles */
.tool-result-renderer {
width: 100%;
height: 100%;
.default-result {
background: var(--gray-0);
border-radius: 8px;
.default-content {
background: var(--gray-0);
padding: 12px;
pre {
margin: 0;
font-size: 12px;
line-height: 1.4;
color: var(--gray-700);
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
background: var(--gray-50);
padding: 10px;
border-radius: 4px;
}
}
}
}
</style>