ForcePilot/web/src/components/ToolCallingResult/BaseToolCall.vue

348 lines
8.6 KiB
Vue
Raw Normal View History

<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">
<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>
</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;
}
}
.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>