From 86e0f4936d6b09b15c2419f5516832a26e43c566 Mon Sep 17 00:00:00 2001 From: ROOG Date: Wed, 24 Dec 2025 01:43:01 +0800 Subject: [PATCH] =?UTF-8?q?main:=20=E5=A2=9E=E5=8A=A0=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E6=94=AF=E6=8C=81=E5=8F=8A=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E9=9B=86=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加多个工具卡片组件,包括 LsResultCard、BashResultCard、FileReadResultCard 和 ToolCallCard - 更新 ChatView 消息处理逻辑,支持工具消息的解析与展示 - 实现工具调用与结果处理机制,完善工具消息的归一化及合并逻辑 - 优化消息界面,新增工具调用列表与对应的样式调整 - 更新工具调用相关功能的状态管理与交互逻辑 --- CLAUDE.md | 99 ++++++++ src/components/tools/BashResultCard.vue | 64 +++++ src/components/tools/FileReadResultCard.vue | 106 ++++++++ src/components/tools/LsResultCard.vue | 147 +++++++++++ src/components/tools/ToolCallCard.vue | 125 +++++++++ src/components/tools/index.js | 4 + src/views/ChatView.vue | 268 +++++++++++++++++++- 7 files changed, 811 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/components/tools/BashResultCard.vue create mode 100644 src/components/tools/FileReadResultCard.vue create mode 100644 src/components/tools/LsResultCard.vue create mode 100644 src/components/tools/ToolCallCard.vue create mode 100644 src/components/tools/index.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f734d42 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概述 + +ARS(Agent Runtime Server)前端 Demo,一个基于 Vue 3 + Vite 的 SPA,为智能体运行时后端提供会话管理和消息展示界面。与 `ars-backend` 配合使用。 + +## 常用命令 + +```bash +# 安装依赖 +npm install + +# 启动开发服务器 +npm run dev + +# 生产构建 +npm run build + +# 运行所有测试 +npm test + +# 运行单个测试文件 +npx vitest tests/chat-view.spec.js + +# 运行匹配模式的测试 +npx vitest -t "message.delta" +``` + +## 环境配置 + +- `VITE_API_BASE`: 后端 API 基址,默认 `http://localhost:8000/api` + +## 架构概览 + +### 技术栈 +- **框架**: Vue 3 (Composition API + ` + + + + diff --git a/src/components/tools/FileReadResultCard.vue b/src/components/tools/FileReadResultCard.vue new file mode 100644 index 0000000..e10e56e --- /dev/null +++ b/src/components/tools/FileReadResultCard.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/src/components/tools/LsResultCard.vue b/src/components/tools/LsResultCard.vue new file mode 100644 index 0000000..2dbf8d1 --- /dev/null +++ b/src/components/tools/LsResultCard.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/src/components/tools/ToolCallCard.vue b/src/components/tools/ToolCallCard.vue new file mode 100644 index 0000000..98b79c1 --- /dev/null +++ b/src/components/tools/ToolCallCard.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/src/components/tools/index.js b/src/components/tools/index.js new file mode 100644 index 0000000..5c82035 --- /dev/null +++ b/src/components/tools/index.js @@ -0,0 +1,4 @@ +export { default as ToolCallCard } from './ToolCallCard.vue' +export { default as LsResultCard } from './LsResultCard.vue' +export { default as FileReadResultCard } from './FileReadResultCard.vue' +export { default as BashResultCard } from './BashResultCard.vue' diff --git a/src/views/ChatView.vue b/src/views/ChatView.vue index ada8769..6445c04 100644 --- a/src/views/ChatView.vue +++ b/src/views/ChatView.vue @@ -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(() => { 系统错误 + + +
+ +
{ 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;