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 + `
+
+
+
+
+
+ {{ isSuccess ? '✓' : '✗' }} 退出码: {{ exitCode }}
+
+
+
{{ output || '(无输出)' }}
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ {{ getIcon(entry) }}
+ {{ getName(entry) }}
+
+ {{ formatSize(entry.size) }}
+
+
+
+
+
+
+
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(() => {
系统错误
+
+
+ 正在执行
+
+
{
v-html="renderMarkdown(msg.text)"
/>
+
+
{
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;