main: 增加工具调用支持及相关功能集成

- 添加多个工具卡片组件,包括 LsResultCard、BashResultCard、FileReadResultCard 和 ToolCallCard
- 更新 ChatView 消息处理逻辑,支持工具消息的解析与展示
- 实现工具调用与结果处理机制,完善工具消息的归一化及合并逻辑
- 优化消息界面,新增工具调用列表与对应的样式调整
- 更新工具调用相关功能的状态管理与交互逻辑
This commit is contained in:
2025-12-24 01:43:01 +08:00
parent 9280fbe762
commit 86e0f4936d
7 changed files with 811 additions and 2 deletions

View File

@@ -13,6 +13,12 @@ import { ElMessage } from 'element-plus'
import axios from 'axios'
import MarkdownIt from 'markdown-it'
import mermaid from 'mermaid'
import {
ToolCallCard,
LsResultCard,
FileReadResultCard,
BashResultCard,
} from '../components/tools'
const route = useRoute()
const router = useRouter()
@@ -95,6 +101,113 @@ const finalizedRunIds = new Set()
// Typewriter effect state for streaming deltas
const typingStates = new Map()
const typingIntervalMs = 24
// Tool calls grouped by run_id: run_id -> { toolCallId -> toolCallData }
const runToolCalls = new Map()
// 解析工具调用/结果的 content (JSON 字符串)
const parseToolContent = (content) => {
if (!content) return {}
try {
return JSON.parse(content)
} catch {
return {}
}
}
// 获取 tool_call_id
const getToolCallId = (msg = {}) => msg.payload?.tool_call_id || null
// 获取父级 run_id工具调用所属的主运行
const getParentRunId = (msg = {}) => msg.payload?.parent_run_id || msg.payload?.run_id || null
// 判断是否为工具相关消息
const isToolMessage = (msg = {}) =>
msg.type === 'tool.call' || msg.type === 'tool.result'
// 判断是否为工具级别的 run.status需要隐藏
const isToolRunStatus = (msg = {}) =>
msg.type === 'run.status' && msg.payload?.tool_call_id
// 创建工具调用对象
const createToolCall = (msg) => {
const toolCallId = getToolCallId(msg)
const toolName = msg.payload?.name || 'unknown'
const isCall = msg.type === 'tool.call'
const isResult = msg.type === 'tool.result'
// 解析 content JSON
let parsedContent = {}
if (msg.content) {
try {
parsedContent = JSON.parse(msg.content)
} catch (e) {
console.warn('Failed to parse tool content:', msg.content, e)
}
}
const result = {
toolCallId,
toolName,
toolInput: isCall ? (msg.payload?.arguments || parsedContent) : null,
toolResult: isResult ? parsedContent : null,
toolStatus: isResult ? (msg.payload?.status || 'SUCCESS') : 'RUNNING',
toolError: msg.payload?.error || null,
truncated: msg.payload?.truncated || false,
seq: msg.seq ?? 0,
time: normalizeTime(msg.created_at),
}
// 调试日志
console.log('[createToolCall]', msg.type, toolName, result)
return result
}
// 合并工具调用call + result
const mergeToolCall = (existing, newData) => {
// 如果没有现有数据,直接返回新数据
if (!existing) return newData
// 如果没有新数据,直接返回现有数据
if (!newData) return existing
return {
...existing,
...newData, // newData 的字段优先,但保留 existing 中 newData 没有的字段
// 确保关键字段不丢失
toolCallId: newData.toolCallId || existing.toolCallId,
toolName: newData.toolName || existing.toolName,
toolInput: newData.toolInput !== null ? newData.toolInput : existing.toolInput,
toolResult: newData.toolResult !== null ? newData.toolResult : existing.toolResult,
toolStatus: newData.toolStatus || existing.toolStatus,
toolError: newData.toolError || existing.toolError,
truncated: newData.truncated || existing.truncated,
seq: Math.max(existing.seq || 0, newData.seq || 0),
time: newData.time || existing.time,
}
}
// 收集工具调用到 run
const collectToolCall = (msg) => {
const toolCallId = getToolCallId(msg)
const parentRunId = getParentRunId(msg)
console.log('[collectToolCall] type:', msg.type, 'toolCallId:', toolCallId, 'parentRunId:', parentRunId, 'msg:', msg)
if (!toolCallId || !parentRunId) return
if (!runToolCalls.has(parentRunId)) {
runToolCalls.set(parentRunId, new Map())
}
const toolsMap = runToolCalls.get(parentRunId)
const existing = toolsMap.get(toolCallId)
const newToolCall = createToolCall(msg)
toolsMap.set(toolCallId, mergeToolCall(existing, newToolCall))
}
// 获取某个 run 的所有工具调用(按 seq 排序)
const getToolCallsForRun = (runId) => {
const toolsMap = runToolCalls.get(runId)
if (!toolsMap) return []
const result = Array.from(toolsMap.values()).sort((a, b) => a.seq - b.seq)
console.log('[getToolCallsForRun] runId:', runId, 'tools:', result)
return result
}
const getRunId = (msg = {}) => msg.payload?.run_id || null
const shouldGroupByRun = (msg = {}) => {
const runId = getRunId(msg)
@@ -127,6 +240,7 @@ const stopAllTyping = () => {
const resetMessageCache = () => {
seenDedupeKeys.clear()
finalizedRunIds.clear()
runToolCalls.clear()
stopAllTyping()
}
const normalizeTime = (val) =>
@@ -161,6 +275,9 @@ const normalizeMessage = (msg = {}, options = {}) => {
roleUpper === 'USER' ? 'user' : roleUpper === 'SYSTEM' ? 'system' : 'agent'
const author =
roleUpper === 'USER' ? '你' : roleUpper === 'SYSTEM' ? '系统' : 'ARS'
// 获取该 run 的工具调用
const toolCalls = runId ? getToolCallsForRun(runId) : []
return {
id: options.id || buildMessageId(msg, seq, runId),
seq,
@@ -179,6 +296,7 @@ const normalizeMessage = (msg = {}, options = {}) => {
runId,
dedupeKey: msg.dedupe_key || null,
payload: msg.payload || null,
toolCalls, // 附加工具调用列表
}
}
@@ -249,28 +367,41 @@ const normalizeMessageList = (list = []) => {
const runIndexMap = new Map()
resetMessageCache()
// 第一遍:收集 finalizedRunIds 和所有工具调用
list.forEach((msg) => {
if (msg?.type === 'agent.message') {
if (!msg) return
if (msg.type === 'agent.message') {
const runId = getRunId(msg)
if (runId) finalizedRunIds.add(runId)
}
if (msg?.type === 'run.status') {
if (msg.type === 'run.status') {
const status = extractRunStatus(msg)
if (status === 'FAILED') {
const runId = getRunId(msg)
if (runId) finalizedRunIds.add(runId)
}
}
// 收集工具调用
if (isToolMessage(msg)) {
collectToolCall(msg)
}
})
// 第二遍:构建消息列表
list.forEach((msg) => {
if (!msg) return
// 跳过工具消息(已收集到 runToolCalls
if (isToolMessage(msg)) return
// 跳过工具级别的 run.status
if (isToolRunStatus(msg)) return
// 处理主运行的 run.status
if (msg.type === 'run.status') {
const status = extractRunStatus(msg)
if (status) latestStatus = status
return
}
if (markDedupeKey(msg)) return
const runId = getRunId(msg)
if (msg.type === 'message.delta') {
if (runId && finalizedRunIds.has(runId)) return
@@ -390,10 +521,62 @@ const appendDeltaMessage = (msg) => {
}
}
// 处理工具消息的 SSE 更新
const handleToolMessage = (msg) => {
// 收集工具调用
collectToolCall(msg)
// 找到对应 run 的消息并更新其 toolCalls
const parentRunId = getParentRunId(msg)
if (!parentRunId) return
const list = messagesState.value
const existingIndex = list.findIndex(
(item) => item.runId === parentRunId
)
if (existingIndex >= 0) {
// 更新已存在消息的 toolCalls
const existing = list[existingIndex]
const next = [...list]
next[existingIndex] = {
...existing,
toolCalls: getToolCallsForRun(parentRunId),
}
messagesState.value = next
} else {
// 该 run 的 agent.message 还没到,创建临时占位消息
const placeholderMsg = {
id: `run-${parentRunId}`,
seq: msg.seq ?? 0,
role: 'agent',
author: 'ARS',
time: normalizeTime(msg.created_at),
text: '',
attachments: [],
type: 'agent.pending',
runId: parentRunId,
dedupeKey: null,
payload: null,
toolCalls: getToolCallsForRun(parentRunId),
}
messagesState.value = [...list, placeholderMsg]
}
}
const handleIncomingMessage = (msg) => {
if (!msg) return
// 忽略工具级别的 run.status
if (isToolRunStatus(msg)) return
if (applyRunStatus(msg)) return
if (markDedupeKey(msg)) return
// 处理工具消息
if (isToolMessage(msg)) {
handleToolMessage(msg)
return
}
if (msg.type === 'message.delta') {
appendDeltaMessage(msg)
return
@@ -911,6 +1094,11 @@ onBeforeUnmount(() => {
系统错误
</el-tag>
</template>
<template v-else-if="msg.type === 'agent.pending'">
<el-tag size="small" type="warning" effect="plain">
正在执行
</el-tag>
</template>
<template v-else>
<el-tag
v-if="msg.role === 'agent'"
@@ -957,6 +1145,40 @@ onBeforeUnmount(() => {
v-html="renderMarkdown(msg.text)"
/>
</template>
<!-- 工具调用列表 (放在 agent 气泡底部) -->
<div v-if="msg.toolCalls?.length" class="tool-calls-list">
<template v-for="(tool, idx) in msg.toolCalls" :key="tool.toolCallId">
<ToolCallCard
v-if="tool.toolName"
:tool-name="tool.toolName"
:tool-input="tool.toolInput"
:is-latest="tool.toolStatus === 'RUNNING' || (idx === msg.toolCalls.length - 1 && !msg.toolCalls.some(t => t.toolStatus === 'RUNNING'))"
>
<div v-if="tool.toolStatus === 'RUNNING'" class="tool-loading">
<el-icon class="is-loading"><i class="el-icon-loading" /></el-icon>
<span>正在执行...</span>
</div>
<div v-else-if="tool.toolError" class="tool-error">
<el-alert :title="tool.toolError" type="error" :closable="false" />
</div>
<template v-else-if="tool.toolResult">
<LsResultCard
v-if="tool.toolName === 'ls'"
:result="tool.toolResult"
/>
<FileReadResultCard
v-else-if="tool.toolName === 'file_read'"
:result="tool.toolResult"
/>
<BashResultCard
v-else-if="tool.toolName === 'bash'"
:result="tool.toolResult"
/>
<pre v-else class="tool-json">{{ JSON.stringify(tool.toolResult, null, 2) }}</pre>
</template>
</ToolCallCard>
</template>
</div>
<div v-if="msg.attachments?.length" class="attachment-list">
<div
v-for="file in msg.attachments"
@@ -1236,6 +1458,48 @@ onBeforeUnmount(() => {
background: var(--bubble-system-bg);
}
/* 工具调用列表容器 */
.tool-calls-list {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.tool-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
color: var(--text-muted);
font-size: 14px;
}
.tool-loading .is-loading {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.tool-error {
padding: 8px;
}
.tool-json {
margin: 0;
padding: 12px;
background: var(--code-bg);
color: var(--code-text);
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
overflow: auto;
max-height: 300px;
border-radius: 0 0 12px 12px;
}
.bubble-head {
display: flex;
align-items: center;