main: 增加工具调用支持及相关功能集成

- 添加多个工具卡片组件,包括 LsResultCard、BashResultCard、FileReadResultCard 和 ToolCallCard
- 更新 ChatView 消息处理逻辑,支持工具消息的解析与展示
- 实现工具调用与结果处理机制,完善工具消息的归一化及合并逻辑
- 优化消息界面,新增工具调用列表与对应的样式调整
- 更新工具调用相关功能的状态管理与交互逻辑
This commit is contained in:
2025-12-24 01:43:01 +08:00
parent 9280fbe762
commit 86e0f4936d
7 changed files with 811 additions and 2 deletions

99
CLAUDE.md Normal file
View File

@@ -0,0 +1,99 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
ARSAgent 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

View 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>

View 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>

View 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>

View 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>

View 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'

View File

@@ -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;