feat: 增强工具调用结果组件,新增 grep 工具支持,优化工具图标映射和渲染逻辑

This commit is contained in:
Wenjie Zhang 2026-03-26 12:13:51 +08:00
parent abee1fcc9b
commit 61d4bbf9ea
10 changed files with 351 additions and 212 deletions

View File

@ -48,6 +48,7 @@
### 修复
- 为沙盒与 viewer 文件系统补齐知识库只读映射:新增 `/home/gem/kbs` 命名空间,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown并补充对应后端与 viewer 路由测试
- 修复前端工具图标与渲染匹配不准确的问题:工具管理列表与工具调用结果统一改为基于工具 `id` 的精确映射,避免模糊匹配导致的误渲染,未命中的工具不再显示默认扳手图标
<!-- 添加到这里 -->

View File

@ -31,11 +31,50 @@
flex-shrink: 0;
.search-box {
padding: 12px 12px 0;
padding: 8px 12px 0;
:deep(.ant-input-affix-wrapper) {
height: 28px;
padding: 0 12px;
// border: none;
border-radius: 8px;
background-color: var(--gray-0);
box-shadow: none;
&:hover,
&:focus,
&.ant-input-affix-wrapper-focused {
// border: none;
box-shadow: none;
}
}
:deep(.ant-input-prefix) {
margin-right: 8px;
color: var(--gray-400);
}
:deep(.ant-input) {
height: 100%;
background-color: transparent;
}
:deep(.ant-input::placeholder) {
color: var(--gray-400);
}
:deep(.ant-input-clear-icon) {
color: var(--gray-400);
}
:deep(.ant-input-clear-icon:hover) {
color: var(--gray-500);
}
// :deep(.ant-input-outlined) {
// border: none;
// }
.search-input :deep(.ant-input) {
height: 28px;
}
}
.list-container {

View File

@ -155,7 +155,6 @@
:is-loading="isProcessing"
:disabled="!currentAgent"
:send-button-disabled="isSendButtonDisabled"
placeholder="输入问题..."
:mention="mentionConfig"
:supports-file-upload="supportsFileUpload"
:is-panel-open="isAgentPanelOpen"

View File

@ -63,7 +63,6 @@ const props = defineProps({
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
sendButtonDisabled: { type: Boolean, default: false },
placeholder: { type: String, default: '输入问题...' },
mention: { type: Object, default: () => null },
supportsFileUpload: { type: Boolean, default: false },
isPanelOpen: { type: Boolean, default: false },
@ -80,6 +79,7 @@ const emit = defineEmits([
const inputRef = ref(null)
const currentImage = ref(null)
const placeholder = '问点什么?使用 @ 可以提及哦~'
const updateValue = (val) => {
emit('update:modelValue', val)

View File

@ -141,7 +141,7 @@
</button>
</div>
</div>
<div class="file-content flat-md-preview inline-file-content">
<div class="file-content inline-file-content">
<template v-if="currentFile?.previewType === 'image' && currentFile?.previewUrl">
<div class="image-preview-wrapper">
<img
@ -168,6 +168,7 @@
</template>
<template v-else-if="isMarkdown">
<MdPreview
class="flat-md-preview "
:modelValue="formatContent(currentFile?.content)"
:theme="theme"
previewTheme="github"
@ -258,7 +259,7 @@
</div>
</div>
</template>
<div class="file-content flat-md-preview">
<div class="file-content">
<template v-if="currentFile?.previewType === 'image' && currentFile?.previewUrl">
<div class="image-preview-wrapper">
<img :src="currentFile.previewUrl" :alt="currentFilePath" class="image-preview" />
@ -277,6 +278,7 @@
</template>
<template v-else-if="isMarkdown">
<MdPreview
class="flat-md-preview"
:modelValue="formatContent(currentFile?.content)"
:theme="theme"
previewTheme="github"
@ -1297,6 +1299,10 @@ watch(useInlinePreview, (isInline) => {
color: var(--gray-1000);
background: transparent;
}
.flat-md-preview {
padding: 12px;
}
}
.file-content-pre.code-highlight {

View File

@ -9,7 +9,7 @@
-->
<!-- Fixed Status Icon -->
<span v-if="toolCall.status === 'success' || toolCall.tool_call_result">
<span v-if="(toolCall.status === 'success' || toolCall.tool_call_result) && toolIcon">
<component :is="toolIcon" size="16" class="tool-loader tool-success" />
</span>
<span v-else-if="toolCall.status === 'error'">
@ -93,30 +93,10 @@
<script setup>
import { ref, computed } from 'vue'
import {
Loader,
CircleCheckBig,
ChevronsUpDown,
ChevronsDownUp,
FileText,
FileEdit,
FilePen,
FolderSearch,
Folder,
Database,
BookOpen,
Globe,
BarChart3,
Image,
Calculator,
CheckSquare,
Wrench,
XCircle,
HelpCircle,
Terminal
} from 'lucide-vue-next'
import { Loader, ChevronsUpDown, ChevronsDownUp, XCircle } from 'lucide-vue-next'
import { useAgentStore } from '@/stores/agent'
import { storeToRefs } from 'pinia'
import { getToolCallId, getToolIcon } from './toolRegistry'
const props = defineProps({
toolCall: {
@ -143,47 +123,16 @@ const toggleExpand = () => {
}
// Tool Name Logic
const toolId = computed(() => getToolCallId(props.toolCall))
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
const tool = toolsList.find((t) => t.id === toolId.value)
return tool ? tool.name : toolId.value
})
// Tool Icon Mapping
const toolIcon = computed(() => {
const name = toolName.value.toLowerCase()
//
if (name.includes('read_file') || name.includes('file')) return FileText
if (name.includes('write_file')) return FileEdit
if (name.includes('edit_file') || name.includes('replace')) return FilePen
if (name.includes('glob') || name.includes('search_file')) return FolderSearch
if (name.includes('list_directory') || name.includes('ls')) return Folder
//
if (name.includes('mysql')) return Database
// /
if (name.includes('kb') || name.includes('knowledge')) return BookOpen
if (name.includes('web_search') || name.includes('tavily')) return Globe
// /
if (name.includes('chart')) return BarChart3
if (name.includes('image') || name.includes('img')) return Image
//
if (name.includes('calc') || name.includes('math')) return Calculator
//
if (name.includes('task') || name.includes('todo')) return CheckSquare
//
if (
name.includes('execute') ||
name.includes('cmd') ||
name.includes('shell') ||
name.includes('bash')
)
return Terminal
//
if (name.includes('ask_user_question') || name.includes('question')) return HelpCircle
//
return Wrench
})
const toolIcon = computed(() => getToolIcon(toolId.value))
// Args Logic
const formattedArgs = computed(() => {
@ -197,7 +146,7 @@ const formattedArgs = computed(() => {
} else if (typeof args === 'object' && args !== null) {
return JSON.stringify(args, null, 2)
}
} catch (e) {
} catch {
// ignore
}
return args
@ -223,7 +172,7 @@ const parsedResultData = computed(() => {
if (typeof content === 'string') {
try {
return JSON.parse(content)
} catch (error) {
} catch {
return content
}
}

View File

@ -1,82 +1,10 @@
<template>
<!-- 知识图谱查询结果 -->
<KnowledgeGraphTool v-if="isKnowledgeGraphResult" :tool-call="toolCall" ref="graphToolCallRef" />
<!-- 网页搜索 -->
<WebSearchTool v-else-if="isWebSearchResult" :tool-call="toolCall" />
<!-- Chart -->
<ChartTool v-else-if="isChartResult" :tool-call="toolCall" />
<!-- 知识库列表 -->
<ListKbsTool v-else-if="toolName === 'list_kbs'" :tool-call="toolCall" />
<!-- 思维导图 -->
<GetMindmapTool v-else-if="toolName === 'get_mindmap'" :tool-call="toolCall" />
<!-- 知识库检索 -->
<QueryKbTool v-else-if="toolName === 'query_kb'" :tool-call="toolCall" />
<!-- 待办事项 -->
<TodoListTool v-else-if="toolName === 'write_todos'" :tool-call="toolCall" />
<!-- 计算器 -->
<CalculatorTool v-else-if="isCalculatorResult" :tool-call="toolCall" />
<!-- 图片 -->
<ImageTool v-else-if="isImageResult" :tool-call="toolCall" />
<!-- 任务分配 -->
<TaskTool v-else-if="isTaskResult" :tool-call="toolCall" />
<!-- 写文件 -->
<WriteFileTool v-else-if="toolName === 'write_file'" :tool-call="toolCall" />
<!-- 读文件 -->
<ReadFileTool v-else-if="toolName === 'read_file'" :tool-call="toolCall" />
<!-- 列目录 -->
<ListDirectoryTool
v-else-if="toolName === 'list_directory' || toolName === 'ls'"
<component
:is="currentRenderer"
v-if="currentRenderer"
:tool-call="toolCall"
ref="toolRendererRef"
/>
<!-- 搜索文件内容 -->
<SearchFileContentTool v-else-if="toolName === 'search_file_content'" :tool-call="toolCall" />
<!-- Glob 搜索 -->
<GlobTool v-else-if="toolName === 'glob'" :tool-call="toolCall" />
<!-- 编辑文件 -->
<EditFileTool
v-else-if="toolName === 'edit_file' || toolName === 'replace'"
:tool-call="toolCall"
/>
<!-- MySQL 查询 -->
<MysqlQueryTool v-else-if="toolName === 'mysql_query'" :tool-call="toolCall" />
<!-- MySQL 描述表 -->
<MysqlDescribeTableTool v-else-if="toolName === 'mysql_describe_table'" :tool-call="toolCall" />
<!-- MySQL 列出表 -->
<MysqlListTablesTool v-else-if="toolName === 'mysql_list_tables'" :tool-call="toolCall" />
<!-- 向用户提问 -->
<AskUserQuestionTool v-else-if="toolName === 'ask_user_question'" :tool-call="toolCall" />
<!-- 执行命令 -->
<ExecuteTool
v-else-if="
toolName === 'execute' ||
toolName === 'run_shell_command' ||
toolName === 'bash' ||
toolName === 'cmd'
"
:tool-call="toolCall"
/>
<!-- 默认展示 -->
<BaseToolCall v-else :tool-call="toolCall" />
</template>
@ -89,15 +17,14 @@ import ListKbsTool from './tools/ListKbsTool.vue'
import GetMindmapTool from './tools/GetMindmapTool.vue'
import QueryKbTool from './tools/QueryKbTool.vue'
import KnowledgeGraphTool from './tools/KnowledgeGraphTool.vue'
import ChartTool from './tools/ChartTool.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'
import WriteFileTool from './tools/WriteFileTool.vue'
import ReadFileTool from './tools/ReadFileTool.vue'
import ListDirectoryTool from './tools/ListDirectoryTool.vue'
import SearchFileContentTool from './tools/SearchFileContentTool.vue'
import GrepTool from './tools/GrepTool.vue'
import GlobTool from './tools/GlobTool.vue'
import EditFileTool from './tools/EditFileTool.vue'
import MysqlQueryTool from './tools/MysqlQueryTool.vue'
@ -105,6 +32,7 @@ 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'
const props = defineProps({
toolCall: {
@ -113,74 +41,42 @@ const props = defineProps({
}
})
const toolName = computed(() => props.toolCall.name || props.toolCall.function?.name || '')
const toolId = computed(() => getToolCallId(props.toolCall))
const parseData = (content) => {
if (typeof content === 'string') {
try {
return JSON.parse(content)
} catch (error) {
return content
}
}
return content
const TOOL_RENDERERS = {
ask_user_question: AskUserQuestionTool,
bash: ExecuteTool,
calculator: CalculatorTool,
cmd: ExecuteTool,
edit_file: EditFileTool,
execute: ExecuteTool,
get_mindmap: GetMindmapTool,
glob: GlobTool,
grep: GrepTool,
list_directory: ListDirectoryTool,
list_kbs: ListKbsTool,
ls: ListDirectoryTool,
mysql_describe_table: MysqlDescribeTableTool,
mysql_list_tables: MysqlListTablesTool,
mysql_query: MysqlQueryTool,
query_kb: QueryKbTool,
query_knowledge_graph: KnowledgeGraphTool,
read_file: ReadFileTool,
replace: EditFileTool,
run_shell_command: ExecuteTool,
search_file_content: SearchFileContentTool,
tavily_search: WebSearchTool,
text_to_img_qwen_image: ImageTool,
write_file: WriteFileTool,
write_todos: TodoListTool
}
//
const isWebSearchResult = computed(() => {
const name = toolName.value.toLowerCase()
return name.includes('tavily_search')
})
const currentRenderer = computed(() => TOOL_RENDERERS[toolId.value] || null)
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 isKnowledgeGraphResult = computed(() => {
const name = toolName.value.toLowerCase()
const hasGraphKeyword = name.includes('graph') || name.includes('图谱') || name.includes('kg')
const data = parseData(props.toolCall.tool_call_result?.content)
const hasBasicStructure = data && typeof data === 'object'
const hasTriples = hasBasicStructure && 'triples' in data
return (
(hasTriples && Array.isArray(data.triples) && data.triples.length > 0) ||
(hasTriples && hasGraphKeyword)
)
})
const isCalculatorResult = computed(() => {
const name = toolName.value.toLowerCase()
return name.includes('calculator') || name.includes('calc') || name.includes('math')
})
const isChartResult = computed(() => {
const name = toolName.value.toLowerCase()
if (!name.includes('chart')) return false
const data = parseData(props.toolCall.tool_call_result?.content)
// chart [{ type: "text", text: "url", id: "..." }]
return Array.isArray(data) && data.length > 0 && data[0].type === 'text'
})
const isImageResult = computed(() => {
const name = toolName.value.toLowerCase()
if (!name.includes('text_to_img')) return false
const data = parseData(props.toolCall.tool_call_result?.content)
return data && typeof data === 'string' && data.startsWith('http')
})
//
const graphToolCallRef = ref(null)
const toolRendererRef = ref(null)
const refreshGraph = () => {
if (graphToolCallRef.value && typeof graphToolCallRef.value.refreshGraph === 'function') {
graphToolCallRef.value.refreshGraph()
if (toolRendererRef.value && typeof toolRendererRef.value.refreshGraph === 'function') {
toolRendererRef.value.refreshGraph()
}
}

View File

@ -0,0 +1,48 @@
import {
BookOpen,
Calculator,
CheckSquare,
Database,
FileEdit,
FilePen,
FileText,
Folder,
FolderSearch,
Globe,
HelpCircle,
Image,
Network,
Terminal
} from 'lucide-vue-next'
export const TOOL_ICON_MAP = {
ask_user_question: HelpCircle,
bash: Terminal,
calculator: Calculator,
cmd: Terminal,
edit_file: FilePen,
execute: Terminal,
get_mindmap: Network,
glob: FolderSearch,
grep: FolderSearch,
list_directory: Folder,
list_kbs: BookOpen,
ls: Folder,
mysql_describe_table: Database,
mysql_list_tables: Database,
mysql_query: Database,
query_kb: BookOpen,
query_knowledge_graph: Network,
read_file: FileText,
replace: FilePen,
run_shell_command: Terminal,
search_file_content: FolderSearch,
tavily_search: Globe,
text_to_img_qwen_image: Image,
write_file: FileEdit,
write_todos: CheckSquare
}
export const getToolCallId = (toolCall) => toolCall?.name || toolCall?.function?.name || ''
export const getToolIcon = (toolId) => TOOL_ICON_MAP[toolId] || null

View File

@ -0,0 +1,195 @@
<template>
<BaseToolCall :tool-call="toolCall" :hide-params="true">
<template #header>
<div class="sep-header">
<span class="note">grep</span>
<span class="separator" v-if="pattern">|</span>
<span class="keywords">{{ pattern }}</span>
<span class="separator" v-if="targetPath">|</span>
<span class="description code">{{ targetPath }}</span>
<span class="tag" v-if="outputMode">{{ outputMode }}</span>
</div>
</template>
<template #result>
<div class="tool-result-renderer grep-result">
<div class="result-summary" v-if="matchCountLabel">
<span>{{ matchCountLabel }}</span>
</div>
<div class="result-list" v-if="isFileListResult">
<div v-for="file in fileMatches" :key="file" class="result-item">
<span class="path code">{{ file }}</span>
</div>
</div>
<div class="result-list" v-else-if="lineMatches.length">
<div
v-for="match in lineMatches"
:key="`${match.path}-${match.line}-${match.text}`"
class="result-item"
>
<div class="result-meta">
<span class="path code">{{ match.path }}</span>
<span class="line-number" v-if="match.line">Line {{ match.line }}</span>
</div>
<pre class="matched-text">{{ match.text }}</pre>
</div>
</div>
<div class="empty-state" v-else-if="isEmptyResult">未找到匹配结果</div>
<div class="raw-result" v-else>
<pre>{{ rawResultText }}</pre>
</div>
</div>
</template>
</BaseToolCall>
</template>
<script setup>
import { computed } from 'vue'
import BaseToolCall from '../BaseToolCall.vue'
const props = defineProps({
toolCall: {
type: Object,
required: true
}
})
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 {
return {}
}
})
const parsedResult = computed(() => {
const content = props.toolCall.tool_call_result?.content
if (!content) return null
if (typeof content === 'object') return content
try {
return JSON.parse(content)
} catch {
return content
}
})
const pattern = computed(() => parsedArgs.value.pattern || '')
const targetPath = computed(() => parsedArgs.value.path || parsedArgs.value.dir_path || '/')
const outputMode = computed(() => parsedArgs.value.output_mode || '')
const isFileListResult = computed(() => {
return Array.isArray(parsedResult.value) && parsedResult.value.every((item) => typeof item === 'string')
})
const fileMatches = computed(() => {
return isFileListResult.value ? parsedResult.value : []
})
const lineMatches = computed(() => {
if (!Array.isArray(parsedResult.value)) return []
return parsedResult.value.filter(
(item) =>
item &&
typeof item === 'object' &&
typeof item.path === 'string' &&
('text' in item || 'line' in item)
)
})
const isEmptyResult = computed(() => {
return Array.isArray(parsedResult.value) && parsedResult.value.length === 0
})
const matchCountLabel = computed(() => {
if (isFileListResult.value) {
const count = fileMatches.value.length
return count ? `共匹配 ${count} 个文件` : ''
}
if (lineMatches.value.length) {
return `共匹配 ${lineMatches.value.length}`
}
return ''
})
const rawResultText = computed(() => {
if (typeof parsedResult.value === 'string') return parsedResult.value
return JSON.stringify(parsedResult.value, null, 2)
})
</script>
<style lang="less" scoped>
.grep-result {
padding: 12px;
background: var(--gray-0);
.result-summary {
font-size: 12px;
color: var(--gray-600);
margin-bottom: 10px;
}
.result-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.result-item {
padding: 10px 12px;
border: 1px solid var(--gray-150);
border-radius: 8px;
background: var(--gray-25);
}
.result-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
min-width: 0;
}
.path {
color: var(--gray-800);
word-break: break-all;
}
.line-number {
flex-shrink: 0;
font-size: 12px;
color: var(--main-700);
background: var(--main-50);
border-radius: 999px;
padding: 2px 8px;
}
.matched-text,
.raw-result pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.5;
color: var(--gray-700);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.matched-text {
padding: 8px 10px;
background: var(--gray-0);
border-radius: 6px;
}
.empty-state {
font-size: 12px;
color: var(--gray-500);
}
}
</style>

View File

@ -42,7 +42,12 @@
@click="selectTool(tool)"
>
<div class="item-header">
<Wrench :size="16" class="item-icon" />
<component
:is="getToolIcon(tool.id)"
v-if="getToolIcon(tool.id)"
:size="16"
class="item-icon"
/>
<span class="item-name">{{ tool.name }}</span>
</div>
<div class="item-details item-details-inline">
@ -140,6 +145,7 @@ import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { Search, Wrench, Tag, Tags, FileText, List } from 'lucide-vue-next'
import { toolApi } from '@/apis/tool_api'
import { getToolIcon } from '@/components/ToolCallingResult/toolRegistry'
const loading = ref(false)
const searchQuery = ref('')
@ -192,7 +198,7 @@ const fetchTools = async () => {
if (!currentTool.value && tools.value.length > 0) {
currentTool.value = tools.value[0]
}
} catch (error) {
} catch {
message.error('加载工具失败')
} finally {
loading.value = false