main: 增加工具调用支持及相关功能集成
- 添加多个工具卡片组件,包括 LsResultCard、BashResultCard、FileReadResultCard 和 ToolCallCard - 更新 ChatView 消息处理逻辑,支持工具消息的解析与展示 - 实现工具调用与结果处理机制,完善工具消息的归一化及合并逻辑 - 优化消息界面,新增工具调用列表与对应的样式调整 - 更新工具调用相关功能的状态管理与交互逻辑
This commit is contained in:
99
CLAUDE.md
Normal file
99
CLAUDE.md
Normal file
@@ -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 + `<script setup>`)
|
||||
- **UI 库**: Element Plus
|
||||
- **路由**: Vue Router 4
|
||||
- **HTTP**: Axios
|
||||
- **Markdown**: markdown-it + mermaid
|
||||
- **测试**: Vitest + Vue Test Utils + jsdom
|
||||
|
||||
### 目录结构
|
||||
```
|
||||
src/
|
||||
├── main.js # 应用入口,初始化主题/路由/Element Plus
|
||||
├── App.vue # 根布局:登录页布局 vs 管理后台布局(含侧边栏)
|
||||
├── router/index.js # 路由配置
|
||||
├── views/ # 页面组件
|
||||
│ ├── LoginView.vue # 登录页
|
||||
│ ├── WelcomeView.vue # 欢迎/仪表盘
|
||||
│ ├── UsersView.vue # 用户管理
|
||||
│ └── ChatView.vue # 会话详情(核心页面)
|
||||
├── components/
|
||||
│ ├── SessionSidebar.vue # 会话列表侧边栏
|
||||
│ ├── NewChatButton.vue # 新建会话按钮
|
||||
│ └── tools/ # 工具调用结果卡片组件
|
||||
└── composables/
|
||||
└── useTheme.js # 主题切换逻辑
|
||||
tests/ # Vitest 测试文件
|
||||
```
|
||||
|
||||
### 核心数据流
|
||||
|
||||
1. **认证**: JWT token 存储在 `localStorage` (`ars-token`)
|
||||
2. **会话管理**: 当前会话 ID 存储在 `localStorage` (`ars-current-session`)
|
||||
3. **实时消息**: 通过 SSE (Server-Sent Events) 接收增量消息
|
||||
4. **主题**: 支持 light/dark/system,存储在 `localStorage` (`ars-theme`)
|
||||
|
||||
### ChatView 消息处理逻辑
|
||||
|
||||
ChatView 是最复杂的组件,处理以下消息类型:
|
||||
- `user.prompt`: 用户输入
|
||||
- `agent.message`: Agent 最终回复
|
||||
- `message.delta`: 流式增量内容(打字机效果)
|
||||
- `run.status`: 运行状态(RUNNING/DONE/FAILED)
|
||||
- `error`: 系统错误
|
||||
|
||||
关键机制:
|
||||
- `dedupe_key` 去重防止重复消息
|
||||
- `run_id` 将同一运行的 delta 和 final message 关联
|
||||
- `finalizedRunIds` 标记已完成的运行,忽略后续 delta
|
||||
- 打字机效果通过 `typingStates` Map 管理
|
||||
|
||||
### 测试模式
|
||||
|
||||
测试使用 jsdom 环境,需要 mock:
|
||||
- `axios` 的 HTTP 请求
|
||||
- `EventSource` 的 SSE 连接
|
||||
- `mermaid` 渲染
|
||||
- `window.scrollTo`
|
||||
|
||||
## 样式约定
|
||||
|
||||
- 使用 CSS 变量定义主题色(见 `style.css`)
|
||||
- 组件使用 `<style scoped>`
|
||||
- 响应式断点:900px/960px
|
||||
64
src/components/tools/BashResultCard.vue
Normal file
64
src/components/tools/BashResultCard.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
result: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const output = computed(() => props.result?.output || '')
|
||||
const exitCode = computed(() => props.result?.exit_code ?? null)
|
||||
const isSuccess = computed(() => exitCode.value === 0 || exitCode.value === null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bash-result" :class="{ error: !isSuccess }">
|
||||
<div v-if="exitCode !== null" class="bash-status">
|
||||
<span :class="isSuccess ? 'success' : 'error'">
|
||||
{{ isSuccess ? '✓' : '✗' }} 退出码: {{ exitCode }}
|
||||
</span>
|
||||
</div>
|
||||
<pre class="bash-output"><code>{{ output || '(无输出)' }}</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bash-result {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bash-result.error {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
.bash-status {
|
||||
padding: 8px 16px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bash-status .success {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.bash-status .error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.bash-output {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
background: var(--code-bg);
|
||||
color: var(--code-text);
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
overflow: auto;
|
||||
max-height: 400px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
106
src/components/tools/FileReadResultCard.vue
Normal file
106
src/components/tools/FileReadResultCard.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
result: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const path = computed(() => props.result?.path || '')
|
||||
const content = computed(() => props.result?.content || '')
|
||||
const linesRead = computed(() => props.result?.lines_read || 0)
|
||||
const startLine = computed(() => props.result?.start_line || 1)
|
||||
const endLine = computed(() => props.result?.end_line || 0)
|
||||
const truncated = computed(() => props.result?.truncated || false)
|
||||
|
||||
// 获取文件扩展名用于语法高亮提示
|
||||
const fileExt = computed(() => {
|
||||
const p = path.value
|
||||
const idx = p.lastIndexOf('.')
|
||||
return idx > 0 ? p.slice(idx + 1).toLowerCase() : ''
|
||||
})
|
||||
|
||||
// 语言映射
|
||||
const langClass = computed(() => {
|
||||
const map = {
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
vue: 'vue',
|
||||
php: 'php',
|
||||
py: 'python',
|
||||
json: 'json',
|
||||
md: 'markdown',
|
||||
css: 'css',
|
||||
html: 'html',
|
||||
sh: 'bash',
|
||||
}
|
||||
return map[fileExt.value] || 'plaintext'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-read-result">
|
||||
<div class="file-header">
|
||||
<span class="file-path">{{ path }}</span>
|
||||
<span class="file-lines">
|
||||
行 {{ startLine }}-{{ endLine }} (共 {{ linesRead }} 行)
|
||||
</span>
|
||||
<span v-if="truncated" class="file-truncated">已截断</span>
|
||||
</div>
|
||||
<pre class="file-content" :class="langClass"><code>{{ content }}</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-read-result {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.file-path {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-lines {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-truncated {
|
||||
font-size: 11px;
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.file-content {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
background: var(--code-bg);
|
||||
color: var(--code-text);
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
overflow: auto;
|
||||
max-height: 400px;
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
147
src/components/tools/LsResultCard.vue
Normal file
147
src/components/tools/LsResultCard.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
result: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 解析结果
|
||||
const directory = computed(() => props.result?.directory || '.')
|
||||
const entries = computed(() => props.result?.entries || [])
|
||||
|
||||
// 判断是否为详情模式
|
||||
const isDetailMode = computed(() => {
|
||||
return entries.value.length > 0 && typeof entries.value[0] === 'object'
|
||||
})
|
||||
|
||||
// 获取文件图标
|
||||
const getIcon = (entry) => {
|
||||
if (typeof entry === 'string') {
|
||||
// 简单模式:根据名称猜测类型
|
||||
if (entry.startsWith('.')) return '📁' // 隐藏目录/文件
|
||||
if (entry.includes('.')) return '📄'
|
||||
return '📁'
|
||||
}
|
||||
// 详情模式
|
||||
return entry.type === 'directory' ? '📁' : '📄'
|
||||
}
|
||||
|
||||
// 获取条目名称
|
||||
const getName = (entry) => {
|
||||
return typeof entry === 'string' ? entry : entry.name
|
||||
}
|
||||
|
||||
// 是否为隐藏文件
|
||||
const isHidden = (entry) => {
|
||||
const name = getName(entry)
|
||||
return name.startsWith('.')
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
const formatSize = (size) => {
|
||||
if (!size) return ''
|
||||
if (size < 1024) return `${size} B`
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ls-result">
|
||||
<div class="ls-header">
|
||||
<span class="ls-path">{{ directory }}</span>
|
||||
<span class="ls-count">{{ entries.length }} 项</span>
|
||||
</div>
|
||||
<div class="ls-entries">
|
||||
<div
|
||||
v-for="(entry, idx) in entries"
|
||||
:key="idx"
|
||||
class="ls-entry"
|
||||
:class="{ hidden: isHidden(entry) }"
|
||||
>
|
||||
<span class="entry-icon">{{ getIcon(entry) }}</span>
|
||||
<span class="entry-name">{{ getName(entry) }}</span>
|
||||
<template v-if="isDetailMode && typeof entry === 'object'">
|
||||
<span class="entry-size">{{ formatSize(entry.size) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ls-result {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.ls-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.ls-path {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ls-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: var(--chip-bg);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ls-entries {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ls-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-soft);
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
|
||||
.ls-entry:hover {
|
||||
background: var(--chip-bg);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ls-entry.hidden {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.entry-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.entry-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-size {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
125
src/components/tools/ToolCallCard.vue
Normal file
125
src/components/tools/ToolCallCard.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
toolName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
toolInput: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isLatest: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const expanded = ref(props.isLatest)
|
||||
|
||||
// 当 isLatest 变化时自动更新展开状态
|
||||
watch(
|
||||
() => props.isLatest,
|
||||
(val) => {
|
||||
expanded.value = val
|
||||
}
|
||||
)
|
||||
|
||||
const toggle = () => {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
// 工具名称映射
|
||||
const toolDisplayName = computed(() => {
|
||||
const nameMap = {
|
||||
ls: '📁 列出目录',
|
||||
file_read: '📄 读取文件',
|
||||
bash: '💻 执行命令',
|
||||
get_time: '🕐 获取时间',
|
||||
}
|
||||
return nameMap[props.toolName] || `🔧 ${props.toolName}`
|
||||
})
|
||||
|
||||
// 简短的输入摘要
|
||||
const inputSummary = computed(() => {
|
||||
if (!props.toolInput) return ''
|
||||
if (props.toolName === 'ls') {
|
||||
return props.toolInput.directory || '.'
|
||||
}
|
||||
if (props.toolName === 'file_read') {
|
||||
return props.toolInput.path || ''
|
||||
}
|
||||
if (props.toolName === 'bash') {
|
||||
const cmd = props.toolInput.command || ''
|
||||
return cmd.length > 40 ? cmd.slice(0, 40) + '...' : cmd
|
||||
}
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tool-call-card" :class="{ expanded }">
|
||||
<div class="tool-header" @click="toggle">
|
||||
<span class="tool-icon">{{ expanded ? '▼' : '▶' }}</span>
|
||||
<span class="tool-name">{{ toolDisplayName }}</span>
|
||||
<span v-if="inputSummary && !expanded" class="tool-summary">
|
||||
{{ inputSummary }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-show="expanded" class="tool-body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tool-call-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.1), rgba(59, 130, 246, 0.08));
|
||||
border-bottom: 1px solid rgba(139, 92, 246, 0.15);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.tool-header:hover {
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(59, 130, 246, 0.12));
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.expanded .tool-icon {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tool-summary {
|
||||
flex: 1;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-body {
|
||||
border-top: none;
|
||||
}
|
||||
</style>
|
||||
4
src/components/tools/index.js
Normal file
4
src/components/tools/index.js
Normal file
@@ -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'
|
||||
@@ -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