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 axios from 'axios'
|
||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
import mermaid from 'mermaid'
|
import mermaid from 'mermaid'
|
||||||
|
import {
|
||||||
|
ToolCallCard,
|
||||||
|
LsResultCard,
|
||||||
|
FileReadResultCard,
|
||||||
|
BashResultCard,
|
||||||
|
} from '../components/tools'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -95,6 +101,113 @@ const finalizedRunIds = new Set()
|
|||||||
// Typewriter effect state for streaming deltas
|
// Typewriter effect state for streaming deltas
|
||||||
const typingStates = new Map()
|
const typingStates = new Map()
|
||||||
const typingIntervalMs = 24
|
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 getRunId = (msg = {}) => msg.payload?.run_id || null
|
||||||
const shouldGroupByRun = (msg = {}) => {
|
const shouldGroupByRun = (msg = {}) => {
|
||||||
const runId = getRunId(msg)
|
const runId = getRunId(msg)
|
||||||
@@ -127,6 +240,7 @@ const stopAllTyping = () => {
|
|||||||
const resetMessageCache = () => {
|
const resetMessageCache = () => {
|
||||||
seenDedupeKeys.clear()
|
seenDedupeKeys.clear()
|
||||||
finalizedRunIds.clear()
|
finalizedRunIds.clear()
|
||||||
|
runToolCalls.clear()
|
||||||
stopAllTyping()
|
stopAllTyping()
|
||||||
}
|
}
|
||||||
const normalizeTime = (val) =>
|
const normalizeTime = (val) =>
|
||||||
@@ -161,6 +275,9 @@ const normalizeMessage = (msg = {}, options = {}) => {
|
|||||||
roleUpper === 'USER' ? 'user' : roleUpper === 'SYSTEM' ? 'system' : 'agent'
|
roleUpper === 'USER' ? 'user' : roleUpper === 'SYSTEM' ? 'system' : 'agent'
|
||||||
const author =
|
const author =
|
||||||
roleUpper === 'USER' ? '你' : roleUpper === 'SYSTEM' ? '系统' : 'ARS'
|
roleUpper === 'USER' ? '你' : roleUpper === 'SYSTEM' ? '系统' : 'ARS'
|
||||||
|
// 获取该 run 的工具调用
|
||||||
|
const toolCalls = runId ? getToolCallsForRun(runId) : []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: options.id || buildMessageId(msg, seq, runId),
|
id: options.id || buildMessageId(msg, seq, runId),
|
||||||
seq,
|
seq,
|
||||||
@@ -179,6 +296,7 @@ const normalizeMessage = (msg = {}, options = {}) => {
|
|||||||
runId,
|
runId,
|
||||||
dedupeKey: msg.dedupe_key || null,
|
dedupeKey: msg.dedupe_key || null,
|
||||||
payload: msg.payload || null,
|
payload: msg.payload || null,
|
||||||
|
toolCalls, // 附加工具调用列表
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,28 +367,41 @@ const normalizeMessageList = (list = []) => {
|
|||||||
const runIndexMap = new Map()
|
const runIndexMap = new Map()
|
||||||
resetMessageCache()
|
resetMessageCache()
|
||||||
|
|
||||||
|
// 第一遍:收集 finalizedRunIds 和所有工具调用
|
||||||
list.forEach((msg) => {
|
list.forEach((msg) => {
|
||||||
if (msg?.type === 'agent.message') {
|
if (!msg) return
|
||||||
|
if (msg.type === 'agent.message') {
|
||||||
const runId = getRunId(msg)
|
const runId = getRunId(msg)
|
||||||
if (runId) finalizedRunIds.add(runId)
|
if (runId) finalizedRunIds.add(runId)
|
||||||
}
|
}
|
||||||
if (msg?.type === 'run.status') {
|
if (msg.type === 'run.status') {
|
||||||
const status = extractRunStatus(msg)
|
const status = extractRunStatus(msg)
|
||||||
if (status === 'FAILED') {
|
if (status === 'FAILED') {
|
||||||
const runId = getRunId(msg)
|
const runId = getRunId(msg)
|
||||||
if (runId) finalizedRunIds.add(runId)
|
if (runId) finalizedRunIds.add(runId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 收集工具调用
|
||||||
|
if (isToolMessage(msg)) {
|
||||||
|
collectToolCall(msg)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 第二遍:构建消息列表
|
||||||
list.forEach((msg) => {
|
list.forEach((msg) => {
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
|
// 跳过工具消息(已收集到 runToolCalls)
|
||||||
|
if (isToolMessage(msg)) return
|
||||||
|
// 跳过工具级别的 run.status
|
||||||
|
if (isToolRunStatus(msg)) return
|
||||||
|
// 处理主运行的 run.status
|
||||||
if (msg.type === 'run.status') {
|
if (msg.type === 'run.status') {
|
||||||
const status = extractRunStatus(msg)
|
const status = extractRunStatus(msg)
|
||||||
if (status) latestStatus = status
|
if (status) latestStatus = status
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (markDedupeKey(msg)) return
|
if (markDedupeKey(msg)) return
|
||||||
|
|
||||||
const runId = getRunId(msg)
|
const runId = getRunId(msg)
|
||||||
if (msg.type === 'message.delta') {
|
if (msg.type === 'message.delta') {
|
||||||
if (runId && finalizedRunIds.has(runId)) return
|
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) => {
|
const handleIncomingMessage = (msg) => {
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
|
// 忽略工具级别的 run.status
|
||||||
|
if (isToolRunStatus(msg)) return
|
||||||
if (applyRunStatus(msg)) return
|
if (applyRunStatus(msg)) return
|
||||||
if (markDedupeKey(msg)) return
|
if (markDedupeKey(msg)) return
|
||||||
|
|
||||||
|
// 处理工具消息
|
||||||
|
if (isToolMessage(msg)) {
|
||||||
|
handleToolMessage(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'message.delta') {
|
if (msg.type === 'message.delta') {
|
||||||
appendDeltaMessage(msg)
|
appendDeltaMessage(msg)
|
||||||
return
|
return
|
||||||
@@ -911,6 +1094,11 @@ onBeforeUnmount(() => {
|
|||||||
系统错误
|
系统错误
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="msg.type === 'agent.pending'">
|
||||||
|
<el-tag size="small" type="warning" effect="plain">
|
||||||
|
正在执行
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<el-tag
|
<el-tag
|
||||||
v-if="msg.role === 'agent'"
|
v-if="msg.role === 'agent'"
|
||||||
@@ -957,6 +1145,40 @@ onBeforeUnmount(() => {
|
|||||||
v-html="renderMarkdown(msg.text)"
|
v-html="renderMarkdown(msg.text)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</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-if="msg.attachments?.length" class="attachment-list">
|
||||||
<div
|
<div
|
||||||
v-for="file in msg.attachments"
|
v-for="file in msg.attachments"
|
||||||
@@ -1236,6 +1458,48 @@ onBeforeUnmount(() => {
|
|||||||
background: var(--bubble-system-bg);
|
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 {
|
.bubble-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user