main: 增加工具调用支持及相关功能集成
- 添加多个工具卡片组件,包括 LsResultCard、BashResultCard、FileReadResultCard 和 ToolCallCard - 更新 ChatView 消息处理逻辑,支持工具消息的解析与展示 - 实现工具调用与结果处理机制,完善工具消息的归一化及合并逻辑 - 优化消息界面,新增工具调用列表与对应的样式调整 - 更新工具调用相关功能的状态管理与交互逻辑
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user