feat(tool-calling): 添加任务工具支持并改进工具显示样式

- 新增 TaskTool 组件用于显示任务分配工具结果
- 为 BaseToolCall 添加通用 header slot 支持自定义标题
- 改进 TodoListTool 和 WebSearchTool 的显示样式
- 调整浮动侧边栏的 z-index 防止遮挡
- 优化 AgentChatComponent 的 header-right slot 支持响应式隐藏文本
This commit is contained in:
Wenjie Zhang 2025-12-27 17:29:21 +08:00
parent 84128b831a
commit 2b315f19ab
7 changed files with 207 additions and 46 deletions

View File

@ -68,7 +68,7 @@
<span v-if="hasAgentStateContent" class="text">状态</span>
</div>
</AgentPopover>
<slot name="header-right"></slot>
<slot name="header-right" :is-medium-container="isMediumContainer"></slot>
</div>
</div>
@ -1043,7 +1043,7 @@ watch(conversations, () => {
.floating-sidebar {
position: absolute !important;
z-index: 101;
z-index: 1001;
height: 100%;
left: 0;
top: 0;

View File

@ -21,32 +21,40 @@
<!-- 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>
<!-- Generic Header Slot (Overrides specific slots if provided) -->
<template v-if="$slots.header">
<slot name="header" :tool-call="toolCall" :tool-name="toolName"></slot>
</template>
<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>
<!-- 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-running"
v-else
:tool-name="toolName"
>
正在调用工具: &nbsp; <span class="tool-name">{{ toolName }}</span>
</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 -->
@ -264,6 +272,21 @@ const formatResultData = (data) => {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
:deep(.keywords) {
color: var(--main-700);
font-weight: 600;
font-size: 14px;
}
:deep(.tag) {
font-size: 12px;
color: var(--gray-600);
background-color: var(--gray-100);
padding: 0px 4px;
border-radius: 4px;
margin-left: 8px;
}
}
}

View File

@ -21,6 +21,9 @@
<!-- 图片 -->
<ImageTool v-else-if="isImageResult" :tool-call="toolCall" />
<!-- 任务分配 -->
<TaskTool v-else-if="isTaskResult" :tool-call="toolCall" />
<!-- 默认展示 -->
<BaseToolCall v-else :tool-call="toolCall" />
</template>
@ -36,6 +39,7 @@ import KnowledgeGraphTool from './tools/KnowledgeGraphTool.vue';
import CalculatorTool from './tools/CalculatorTool.vue';
import TodoListTool from './tools/TodoListTool.vue';
import ImageTool from './tools/ImageTool.vue';
import TaskTool from './tools/TaskTool.vue';
const props = defineProps({
toolCall: {
@ -63,10 +67,19 @@ const parseData = (content) => {
//
const isWebSearchResult = computed(() => {
const name = toolName.value.toLowerCase();
const isWebSearchTool = name.includes('search') || name.includes('tavily') || name.includes('web');
if (!isWebSearchTool) return false;
const data = parseData(props.toolCall.tool_call_result?.content);
return data && typeof data === 'object' && 'results' in data && Array.isArray(data.results);
return name.includes('search') || name.includes('tavily') || name.includes('web');
});
const isTaskResult = computed(() => {
let args = props.toolCall.args || props.toolCall.function?.arguments;
if (typeof args === 'string') {
try {
args = JSON.parse(args);
} catch {
return false;
}
}
return args && typeof args === 'object' && 'subagent_type' in args;
});
const isKnowledgeBaseResult = computed(() => {

View File

@ -0,0 +1,107 @@
<template>
<BaseToolCall :tool-call="toolCall" :hide-params="true">
<template #header>
<div class="task-header">
<span class="subagent">{{ subagentType }}</span>
<span class="separator" v-if="shortDescription">|</span>
<span class="description" v-if="shortDescription">{{ shortDescription }}</span>
</div>
</template>
<template #result="{ resultContent }"> <div class="task-result">
<MdPreview
:modelValue="String(resultContent)"
:theme="theme"
previewTheme="github"
class="md-preview-wrapper"
/>
</div>
</template>
</BaseToolCall>
</template>
<script setup>
import { computed } from 'vue';
import BaseToolCall from '../BaseToolCall.vue';
import { MdPreview } from 'md-editor-v3';
import 'md-editor-v3/lib/preview.css';
import { useThemeStore } from '@/stores/theme';
const props = defineProps({
toolCall: {
type: Object,
required: true
}
});
const themeStore = useThemeStore();
const theme = computed(() => themeStore.isDark ? 'dark' : 'light');
const parsedArgs = computed(() => {
const args = props.toolCall.args || props.toolCall.function?.arguments;
if (!args) return {};
if (typeof args === 'object') return args;
try {
return JSON.parse(args);
} catch (e) {
return {};
}
});
const subagentType = computed(() => parsedArgs.value.subagent_type || 'Unknown Agent');
const description = computed(() => parsedArgs.value.description || '');
const shortDescription = computed(() => {
const desc = description.value;
if (!desc) return '';
return desc.length > 50 ? desc.slice(0, 50) + '...' : desc;
});
</script>
<style lang="less" scoped>
.task-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
width: 100%;
overflow: hidden;
.subagent {
font-weight: 600;
color: var(--main-700);
white-space: nowrap;
flex-shrink: 0;
}
.separator {
color: var(--gray-300);
flex-shrink: 0;
}
.description {
color: var(--gray-600);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
}
.task-result {
padding: 12px;
background: var(--gray-0);
border-radius: 8px;
:deep(.md-editor-preview-wrapper) {
padding: 0;
}
:deep(.md-editor-preview) {
font-size: 14px;
color: var(--gray-800);
background: transparent;
}
}
</style>

View File

@ -1,9 +1,7 @@
<template>
<BaseToolCall :tool-call="toolCall" hide-params>
<template #header-success="{ resultContent }">
<span v-if="todoListData(resultContent).length > 0">
{{ getHeaderDisplayContent(resultContent) }}
</span>
<span v-if="todoListData(resultContent).length > 0" v-html="getHeaderDisplayContent(resultContent)"></span>
<span v-else>
待办事项工具执行完成
</span>
@ -106,15 +104,15 @@ const getHeaderDisplayContent = (content) => {
// 1. in_progress
const inProgress = list.find(item => item.status === 'in_progress');
if (inProgress) return `进行中: ${inProgress.content}`;
if (inProgress) return `进行中<span class="keywords">${inProgress.content}</span>`;
// 2. pending
const pending = list.find(item => item.status === 'pending');
if (pending) return `待处理: ${pending.content}`;
if (pending) return `待处理<span class="keywords">${pending.content}</span>`;
// 3. (fallback)
const last = list[list.length - 1];
return `更新: ${last.content}`;
return `更新<span class="keywords">${last.content}</span>`;
};
</script>

View File

@ -1,12 +1,11 @@
<template>
<BaseToolCall :tool-call="toolCall">
<BaseToolCall :tool-call="toolCall" :hide-params="true">
<template #header>
查询
<span class="keywords">"{{ query }}"</span>
</template>
<template #result="{ resultContent }">
<div class="web-search-result">
<div class="search-meta">
<span class="query-text">查询: {{ parsedData(resultContent).query }}</span>
<span class="response-time">响应时间: {{ parsedData(resultContent).response_time }}s</span>
</div>
<div class="search-results">
<div
v-for="(result, index) in parsedData(resultContent).results"
@ -45,8 +44,8 @@
<script setup>
import BaseToolCall from '../BaseToolCall.vue';
import { GlobalOutlined } from '@ant-design/icons-vue'
import { parseToShanghai } from '@/utils/time'
import { computed } from 'vue';
const props = defineProps({
toolCall: {
@ -68,6 +67,23 @@ const parseData = (content) => {
const parsedData = (content) => parseData(content);
const query = computed(() => {
// First try to get it from result
const result = parsedData(props.toolCall.tool_call_result?.content);
if (result?.query) return result.query;
// Fallback to args
const args = props.toolCall.args || props.toolCall.function?.arguments;
if (!args) return '';
if (typeof args === 'object') return args.query || args.q || '';
try {
const parsed = JSON.parse(args);
return parsed.query || parsed.q || '';
} catch (e) {
return '';
}
});
const formatDate = (dateString) => {
if (!dateString) return ''
const parsed = parseToShanghai(dateString)
@ -181,4 +197,4 @@ const formatDate = (dateString) => {
font-size: 13px;
}
}
</style>
</style>

View File

@ -44,10 +44,10 @@
@open-agent-modal="openAgentModal"
@close-config-sidebar="() => chatUIStore.isConfigSidebarOpen = false"
>
<template #header-right>
<template #header-right="{ isMediumContainer }">
<div type="button" class="agent-nav-btn" @click="toggleConf">
<Settings2 size="18" class="nav-btn-icon"/>
<span class="text">配置</span>
<span class="text" :class="{ 'hide-text': isMediumContainer }">配置</span>
</div>
<div v-if="selectedAgentId" ref="moreButtonRef" type="button" class="agent-nav-btn" @click="toggleMoreMenu">
<Ellipsis size="18" class="nav-btn-icon"/>
@ -1009,4 +1009,8 @@ const handlePreview = () => {
font-weight: 500;
}
}
.hide-text {
display: none;
}
</style>