Files
aiagent/frontend/src/views/AgentChat.vue
renjianbo 7aba0f9bc5 fix: 修复 Agent 流式对话无响应和工具 schema 兼容性问题
- 在 `run_stream()` LLM 调用前 yield `think` 事件,前端即时显示"思考中..."
- 修复 tool schema 规范化逻辑:`{"function":{...}}` 格式缺少 `type` 字段导致 LLM API 拒绝
- 启动时从数据库加载自定义工具(`load_tools_from_db`),解决重启后工具丢失
- 前端 SSE 添加 60s 超时保护,任何事件类型均触发 `receivedFirstEvent`
- 流式失败自动降级到非流式 POST
- 添加 `scripts/seed_coding_agent.py` 和 `scripts/test_coding_agent.py`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 00:38:41 +08:00

697 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<MainLayout>
<div class="page-header">
<div class="page-header-left">
<h3>{{ chatMode === 'single' ? (agent ? agent.name : 'AI Agent 对话') : '多 Agent 编排' }}</h3>
</div>
<div class="page-header-actions">
<el-switch
v-model="chatMode"
active-value="orchestrate"
inactive-value="single"
active-text="编排"
inactive-text=" Agent"
style="margin-right: 12px"
/>
<!-- Agent 模式选择 Agent -->
<template v-if="chatMode === 'single'">
<el-select v-model="currentAgentId" placeholder="选择 Agent" @change="switchAgent" style="width: 180px" clearable>
<el-option v-for="a in agents" :key="a.id" :label="a.name" :value="a.id">
<span>{{ a.name }}</span>
</el-option>
</el-select>
</template>
<!-- 编排模式模式选择 + Agent -->
<template v-if="chatMode === 'orchestrate'">
<el-select v-model="orchestrateMode" style="width: 130px">
<el-option label="辩论模式" value="debate" />
<el-option label="路由模式" value="route" />
<el-option label="顺序模式" value="sequential" />
</el-select>
<el-button @click="showOrchestrateEditor = true" style="margin-left: 8px">
配置 Agent ({{ orchestrateAgents.length }})
</el-button>
</template>
<el-button @click="clearChat" :disabled="displayMessages.length === 0">清空</el-button>
</div>
</div>
<div class="chat-messages" ref="messagesRef">
<div v-if="displayMessages.length === 0" class="chat-empty">
<el-icon :size="48"><ChatLineSquare /></el-icon>
<p v-if="chatMode === 'single'">选择一个 Agent 开始对话</p>
<p v-else>配置多个 Agent 后发送消息进行编排对话</p>
<p class="hint">Agent 可以使用内置工具帮你完成任务</p>
</div>
<div v-for="(msg, i) in displayMessages" :key="i" class="message" :class="[msg.role, msg.status === 'error' ? 'error' : '']">
<div class="message-avatar">
<el-avatar :size="36" :icon="msg.role === 'user' ? UserFilled : Promotion" />
</div>
<div class="message-bubble">
<!-- 编排模式显示每个 Agent 的独立输出 -->
<div v-if="msg.orchestrateResult" class="orchestrate-result">
<div class="orch-header">
<el-tag size="small" type="info">{{ msg.orchestrateResult.mode }}</el-tag>
<span class="orch-agent-count">{{ msg.orchestrateResult.steps.length }} Agent</span>
</div>
<div class="orch-final">
<div class="orch-section-title">最终回答</div>
<div class="message-text" v-html="renderMarkdown(msg.orchestrateResult.final_answer)"></div>
</div>
<div class="orch-steps">
<div
v-for="(step, si) in msg.orchestrateResult.steps"
:key="si"
class="orch-step"
:class="{ expanded: step._open }"
>
<div class="orch-step-header" @click="step._open = !step._open">
<el-icon><CaretRight :style="{ transform: step._open ? 'rotate(90deg)' : '' }" /></el-icon>
<el-tag size="small" :type="step.error ? 'danger' : 'success'" round>
{{ step.agent_name }}
</el-tag>
<span class="orch-step-meta">
{{ step.iterations_used }} · {{ step.tool_calls_made }} 次工具
</span>
</div>
<div v-show="step._open" class="orch-step-body">
<div class="message-text" v-html="renderMarkdown(step.output)"></div>
</div>
</div>
</div>
</div>
<!-- Agent 模式原有内容 -->
<template v-if="!msg.orchestrateResult">
<div class="message-text" v-html="renderMarkdown(msg.content)"></div>
<div v-if="msg.tool_calls && msg.tool_calls.length > 0" class="tool-calls">
<div class="tool-calls-header">
<el-icon><Tools /></el-icon> 工具调用 ({{ msg.tool_calls.length }})
</div>
<div v-for="(tc, j) in msg.tool_calls" :key="j" class="tool-call-item">
<span class="tool-name">{{ tc.function?.name || '?' }}</span>
<el-tag size="small" type="info">{{ Object.keys(JSON.parse(tc.function?.arguments || '{}')).length }} 个参数</el-tag>
</div>
</div>
<!-- 思考链 -->
<div v-if="msg.steps && msg.steps.length > 0" class="thinking-trace">
<div class="trace-header" @click="toggleTrace(msg)">
<el-icon><CaretRight :style="{ transform: msg._traceOpen ? 'rotate(90deg)' : '' }" /></el-icon>
<span>思考链 ({{ msg.steps.length }} )</span>
</div>
<div v-show="msg._traceOpen" class="trace-steps">
<div v-for="(step, si) in msg.steps" :key="si" class="trace-step" :class="'step-' + step.type">
<div class="step-icon">
<el-icon v-if="step.type === 'think'"><ChatDotSquare /></el-icon>
<el-icon v-else-if="step.type === 'tool_result'"><Tools /></el-icon>
<el-icon v-else><Select /></el-icon>
</div>
<div class="step-body">
<div class="step-header">
<span class="step-tag" :class="'tag-' + step.type">{{ {think:'思考',tool_result:'工具结果',final:'最终回答'}[step.type] || step.type }}</span>
<span class="step-iter">#{{ step.iteration }}</span>
<span v-if="step.tool_name" class="step-tool-name">{{ step.tool_name }}</span>
</div>
<div v-if="step.content" class="step-content" v-html="renderMarkdown(step.content)"></div>
<div v-if="step.reasoning" class="step-reasoning">
<div class="reasoning-header">推理过程</div>
<div class="reasoning-text">{{ step.reasoning }}</div>
</div>
<div v-if="step.tool_input && Object.keys(step.tool_input).length" class="step-tool-input">
<div class="reasoning-header">参数</div>
<pre>{{ JSON.stringify(step.tool_input, null, 2) }}</pre>
</div>
<div v-if="step.tool_result" class="step-tool-result">
<div class="reasoning-header">结果</div>
<pre>{{ step.tool_result }}</pre>
</div>
</div>
</div>
</div>
</div>
</template>
<div class="message-meta">
{{ msg.role === 'user' ? '用户' : 'Agent' }} · {{ relativeTime(msg.timestamp) }}
<span v-if="msg.iterations" class="meta-iterations">· {{ msg.iterations }} · {{ msg.tool_calls_made }} 次工具调用</span>
<span class="meta-actions">
<el-button v-if="msg.role === 'assistant'" link size="small" @click="copyMessage(msg)" title="复制">
<el-icon><DocumentCopy /></el-icon>
</el-button>
<el-button v-if="msg.status === 'error'" link type="danger" size="small" @click="retryMessage(i)" title="重试">
<el-icon><Refresh /></el-icon>
</el-button>
</span>
</div>
</div>
</div>
<div v-if="loading && !streamingActive" class="message assistant">
<div class="message-avatar"><el-avatar :size="36" icon="Promotion" /></div>
<div class="message-bubble">
<div class="thinking"><span class="dot"></span><span class="dot"></span><span class="dot"></span></div>
</div>
</div>
</div>
<div class="chat-input">
<el-input v-model="inputMessage" type="textarea" :rows="3" placeholder="输入你的问题..." @keydown.enter.exact.prevent="sendMessage" :disabled="loading" />
<el-button type="primary" @click="sendMessage" :loading="loading" :disabled="!inputMessage.trim()" class="send-btn">
{{ loading ? '处理中...' : '发送' }}
</el-button>
</div>
<!-- 编排 Agent 编辑器 -->
<el-dialog v-model="showOrchestrateEditor" title="编排 Agent 配置" width="700px" @closed="saveState">
<div class="orch-editor">
<div v-for="(agt, i) in orchestrateAgents" :key="i" class="orch-agent-card">
<div class="orch-agent-header">
<span class="orch-agent-num">#{{ i + 1 }}</span>
<el-input v-model="agt.name" placeholder="名称" style="width: 140px" size="small" />
<el-input v-model="agt.id" placeholder="ID" style="width: 120px" size="small" />
<el-button size="small" type="danger" link @click="orchestrateAgents.splice(i, 1)">删除</el-button>
</div>
<el-input v-model="agt.system_prompt" type="textarea" :rows="3" placeholder="System Prompt" size="small" />
<div class="orch-agent-params">
<el-select v-model="agt.model" size="small" style="width: 160px">
<el-option label="DeepSeek V4 Flash" value="deepseek-v4-flash" />
<el-option label="DeepSeek V4 Pro" value="deepseek-v4-pro" />
<el-option label="GPT-4o Mini" value="gpt-4o-mini" />
<el-option label="GPT-4o" value="gpt-4o" />
</el-select>
<el-input-number v-model="agt.temperature" :min="0" :max="2" :step="0.1" size="small" style="width: 110px" />
<el-input-number v-model="agt.max_iterations" :min="1" :max="50" size="small" style="width: 110px" />
</div>
</div>
<el-button @click="addOrchestrateAgent" style="width: 100%; margin-top: 8px">
+ 添加 Agent
</el-button>
</div>
<template #footer>
<el-button @click="showOrchestrateEditor = false">关闭</el-button>
</template>
</el-dialog>
</MainLayout>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ChatLineSquare, UserFilled, Promotion, Tools, CaretRight, ChatDotSquare, Select, DocumentCopy, Refresh } from '@element-plus/icons-vue'
import MainLayout from '@/components/MainLayout.vue'
import api from '@/api'
import type { Agent } from '@/stores/agent'
interface AgentStep {
iteration: number; type: string; content: string
tool_name?: string; tool_input?: Record<string, any>; tool_result?: string; reasoning?: string
}
interface OrchestrateStep {
agent_id: string; agent_name: string; input: string; output: string
iterations_used: number; tool_calls_made: number; error?: string; _open?: boolean
}
interface OrchestrateResult {
mode: string; final_answer: string; steps: OrchestrateStep[]; agent_results: any[]
}
interface ChatMessage {
role: 'user' | 'assistant'; content: string; tool_calls?: any[]; timestamp: number
iterations?: number; tool_calls_made?: number; status?: string; steps?: AgentStep[]
_traceOpen?: boolean; orchestrateResult?: OrchestrateResult
}
interface OrchestrateAgentForm {
id: string; name: string; system_prompt: string; model: string
temperature: number; max_iterations: number; description: string
}
const STORAGE_KEY = 'agent_chat_state'
interface ChatState {
messages: Record<string, ChatMessage[]> // keyed by agentId / '__bare__' / '__orchestrate__'
sessionId: Record<string, string>
currentAgentId: string
chatMode: 'single' | 'orchestrate'
orchestrateMode: string
orchestrateAgents: OrchestrateAgentForm[]
}
function saveState() {
try {
const state: ChatState = {
messages: messages.value,
sessionId: sessionId.value,
currentAgentId: currentAgentId.value,
chatMode: chatMode.value,
orchestrateMode: orchestrateMode.value,
orchestrateAgents: orchestrateAgents.value,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
} catch { /* quota exceeded, ignore */ }
}
function loadState(): ChatState | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
// 兼容旧格式:旧版本 messages 是数组,迁移为 Record
if (Array.isArray(parsed.messages)) {
const oldSessionId = parsed.sessionId || ''
parsed.messages = { '__bare__': parsed.messages }
parsed.sessionId = { '__bare__': oldSessionId }
}
return parsed
} catch { return null }
}
const route = useRoute()
const agents = ref<Agent[]>([])
const currentAgentId = ref('')
const messages = ref<Record<string, ChatMessage[]>>({})
const inputMessage = ref('')
const loading = ref(false)
const streamingActive = ref(false)
const messagesRef = ref<HTMLElement | null>(null)
const sessionId = ref<Record<string, string>>({})
const agent = ref<Agent | null>(null)
const currentAgentKey = computed(() => {
if (chatMode.value === 'orchestrate') return '__orchestrate__'
return currentAgentId.value || '__bare__'
})
const displayMessages = computed(() => {
return messages.value[currentAgentKey.value] || []
})
// 编排模式
const chatMode = ref<'single' | 'orchestrate'>('single')
const orchestrateMode = ref('debate')
const showOrchestrateEditor = ref(false)
const orchestrateAgents = ref<OrchestrateAgentForm[]>([
{ id: 'agent-a', name: 'Agent A', system_prompt: '你是一个有用的AI助手。', model: 'deepseek-v4-flash', temperature: 0.7, max_iterations: 10, description: '' },
{ id: 'agent-b', name: 'Agent B', system_prompt: '你是一个专业的分析助手。', model: 'deepseek-v4-flash', temperature: 0.7, max_iterations: 10, description: '' },
])
function addOrchestrateAgent() {
const n = orchestrateAgents.value.length + 1
orchestrateAgents.value.push({
id: `agent-${String.fromCharCode(96 + n)}`,
name: `Agent ${String.fromCharCode(64 + n)}`,
system_prompt: '你是一个有用的AI助手。',
model: 'deepseek-v4-flash',
temperature: 0.7,
max_iterations: 10,
description: '',
})
saveState()
}
// 模式/配置变化时自动保存
watch(chatMode, saveState)
watch(orchestrateMode, saveState)
onMounted(async () => {
await loadAgents()
const saved = loadState()
if (saved) {
messages.value = saved.messages
sessionId.value = saved.sessionId
chatMode.value = saved.chatMode
orchestrateMode.value = saved.orchestrateMode
orchestrateAgents.value = saved.orchestrateAgents
// 恢复展开状态
for (const arr of Object.values(messages.value)) {
arr.forEach(m => { if (m.steps?.length) m._traceOpen = false })
}
}
if (route.params.id) {
currentAgentId.value = route.params.id as string
await switchAgent()
} else if (saved?.currentAgentId) {
currentAgentId.value = saved.currentAgentId
await switchAgent()
}
nextTick(scrollToBottom)
})
async function loadAgents() {
try { const resp = await api.get('/api/v1/agents'); agents.value = resp.data || [] }
catch (e) { console.error('加载 Agent 列表失败:', e) }
}
async function switchAgent() {
if (!currentAgentId.value) { agent.value = null; saveState(); nextTick(scrollToBottom); return }
try {
const resp = await api.get(`/api/v1/agents/${currentAgentId.value}`)
agent.value = resp.data
saveState()
nextTick(scrollToBottom)
} catch {
ElMessage.error('加载 Agent 失败')
agent.value = null
}
}
async function sendMessage() {
const text = inputMessage.value.trim()
if (!text || loading.value) return
const key = currentAgentKey.value
if (!messages.value[key]) messages.value[key] = []
messages.value[key].push({ role: 'user', content: text, timestamp: Date.now() })
inputMessage.value = ''
loading.value = true
scrollToBottom()
try {
if (chatMode.value === 'orchestrate') {
const resp = await api.post('/api/v1/agent-chat/orchestrate', {
message: text,
mode: orchestrateMode.value,
agents: orchestrateAgents.value.map(a => ({
id: a.id, name: a.name, system_prompt: a.system_prompt,
model: a.model, temperature: a.temperature, max_iterations: a.max_iterations,
tools: [], description: a.description,
})),
})
const data = resp.data as OrchestrateResult
data.steps.forEach(s => { s._open = false })
messages.value[key].push({
role: 'assistant', content: data.final_answer, timestamp: Date.now(),
orchestrateResult: data, _traceOpen: true,
})
} else {
const sessId = sessionId.value[key] || ''
const streamEndpoint = currentAgentId.value
? `/api/v1/agent-chat/${currentAgentId.value}/stream`
: '/api/v1/agent-chat/bare/stream'
// 尝试 SSE 流式(带超时控制)
let usedStreaming = false
streamingActive.value = false
const abortController = new AbortController()
const streamTimeout = setTimeout(() => abortController.abort(), 60000)
try {
const token = localStorage.getItem('token') || ''
const resp = await fetch(streamEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
},
body: JSON.stringify({ message: text, session_id: sessId || undefined }),
signal: abortController.signal,
})
if (resp.ok && resp.body) {
usedStreaming = true
// 创建占位消息,流式更新
const msg: ChatMessage = {
role: 'assistant', content: '', timestamp: Date.now(),
steps: [], _traceOpen: true, iterations: 0, tool_calls_made: 0,
}
const idx = messages.value[key].push(msg) - 1
const currentMsg = messages.value[key][idx]
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let receivedFirstEvent = false
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split('\n\n')
buffer = parts.pop() || ''
for (const part of parts) {
const lines = part.split('\n')
let eventType = ''
let dataStr = ''
for (const line of lines) {
if (line.startsWith('event: ')) eventType = line.slice(7)
else if (line.startsWith('data: ')) dataStr = line.slice(6)
}
if (!dataStr) continue
try {
const data = JSON.parse(dataStr)
// 首个事件到达 → 隐藏 loading dots无论什么事件类型
if (!receivedFirstEvent) {
receivedFirstEvent = true
streamingActive.value = true
}
if (eventType === 'think') {
const thinkContent = data.content || '思考中...'
currentMsg.steps!.push({
iteration: data.iteration, type: 'think',
content: thinkContent,
reasoning: data.reasoning,
tool_name: data.tool_names?.[0],
})
} else if (eventType === 'tool_call') {
currentMsg.steps!.push({
iteration: data.iteration, type: 'tool_call',
content: `调用工具: ${data.name}`,
tool_name: data.name,
tool_input: data.input,
})
} else if (eventType === 'tool_result') {
currentMsg.steps!.push({
iteration: data.iteration, type: 'tool_result',
content: `工具 ${data.name} 返回结果`,
tool_name: data.name,
tool_result: data.result,
})
} else if (eventType === 'final') {
currentMsg.content = data.content || ''
currentMsg.iterations = data.iterations_used || 0
currentMsg.tool_calls_made = data.tool_calls_made || 0
if (data.session_id) {
sessionId.value[key] = data.session_id
}
streamingActive.value = false
} else if (eventType === 'error') {
currentMsg.content = data.content || ''
currentMsg.status = 'error'
streamingActive.value = false
}
} catch { /* 跳过畸形事件 */ }
}
}
}
} catch {
clearTimeout(streamTimeout)
// 流式失败时标记为非流式,让 fallback POST 兜底
usedStreaming = false
streamingActive.value = false
}
if (!usedStreaming) {
// 降级:标准 POST 请求
const fallbackEndpoint = currentAgentId.value
? `/api/v1/agent-chat/${currentAgentId.value}`
: '/api/v1/agent-chat/bare'
const resp = await api.post(fallbackEndpoint, { message: text, session_id: sessId || undefined })
const data = resp.data
sessionId.value[key] = data.session_id
messages.value[key].push({
role: 'assistant', content: data.content, timestamp: Date.now(),
iterations: data.iterations_used, tool_calls_made: data.tool_calls_made,
status: data.truncated ? 'error' : 'success', steps: data.steps || [],
_traceOpen: data.steps && data.steps.length > 0,
})
}
}
saveState()
} catch (e: any) {
messages.value[key].push({
role: 'assistant', content: `错误:${e.response?.data?.detail || e.message || '请求失败'}`,
timestamp: Date.now(), status: 'error',
})
saveState()
} finally {
loading.value = false; scrollToBottom()
}
}
function toggleTrace(msg: ChatMessage) { msg._traceOpen = !msg._traceOpen }
function clearChat() {
const key = currentAgentKey.value
messages.value[key] = []
if (chatMode.value === 'single') {
sessionId.value[key] = ''
}
saveState()
}
function scrollToBottom() { nextTick(() => { if (messagesRef.value) messagesRef.value.scrollTop = messagesRef.value.scrollHeight }) }
function relativeTime(ts: number): string {
const diff = Date.now() - ts
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
const d = new Date(ts)
return `${d.getMonth() + 1}${d.getDate()}${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
}
function copyMessage(msg: ChatMessage) {
const text = msg.orchestrateResult?.final_answer || msg.content
if (!text) return
navigator.clipboard.writeText(text).then(() => {
ElMessage.success('已复制')
}).catch(() => {
ElMessage.warning('复制失败')
})
}
function retryMessage(idx: number) {
const key = currentAgentKey.value
const msgs = messages.value[key]
if (!msgs) return
// 查找错误消息之前的最后一条用户消息
let userMsg = ''
for (let i = idx - 1; i >= 0; i--) {
if (msgs[i].role === 'user') {
userMsg = msgs[i].content
break
}
}
if (!userMsg) {
ElMessage.warning('未找到可重试的消息')
return
}
// 移除该错误消息及关联的用户消息
const removeIndices: number[] = []
for (let i = idx - 1; i >= 0; i--) {
if (msgs[i].role === 'user' && msgs[i].content === userMsg) {
removeIndices.push(i)
break
}
}
removeIndices.push(idx)
// 从后往前删除,避免 index 错乱
removeIndices.sort((a, b) => b - a)
for (const ri of removeIndices) {
msgs.splice(ri, 1)
}
saveState()
// 填入输入框并发送
inputMessage.value = userMsg
nextTick(() => sendMessage())
}
function renderMarkdown(text: string): string {
if (!text) return ''
return text.replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\n/g, '<br>')
}
</script>
<style scoped>
.agent-chat-page { display: flex; flex-direction: column; height: 100%; max-width: 960px; margin: 0 auto; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--el-border-color-light); }
.page-header-left { display: flex; align-items: center; gap: 8px; }
.page-header-left h3 { margin: 0; font-size: 16px; font-weight: 600; }
.page-header-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.chat-messages { flex: 1; overflow-y: auto; padding: 8px 0; display: flex; flex-direction: column; gap: 12px; min-height: 0; }
.chat-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--el-text-color-secondary); gap: 12px; }
.chat-empty .hint { font-size: 13px; color: var(--el-text-color-placeholder); }
.message { display: flex; gap: 12px; max-width: 88%; }
.message.user { align-self: flex-end; flex-direction: row-reverse; }
.message.assistant { align-self: flex-start; }
.message-bubble { padding: 10px 14px; border-radius: 12px; background: var(--el-fill-color-light); line-height: 1.6; font-size: 14px; }
.message.user .message-bubble { background: var(--el-color-primary-light-8); }
.message.error .message-bubble { border: 1px solid var(--el-color-danger-light-5); }
.message-text :deep(pre) { background: var(--el-fill-color); padding: 12px; border-radius: 8px; overflow-x: auto; font-size: 13px; }
.message-text :deep(code) { background: var(--el-fill-color); padding: 2px 6px; border-radius: 4px; font-size: 13px; }
/* Tool calls */
.tool-calls { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--el-border-color-light); }
.tool-calls-header { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 4px; }
.tool-call-item { display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 12px; }
.tool-name { font-weight: 500; color: var(--el-color-primary); }
.message-meta { font-size: 11px; color: var(--el-text-color-placeholder); margin-top: 4px; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.meta-iterations { color: var(--el-color-info); }
.meta-actions { margin-left: auto; display: flex; gap: 2px; opacity: 0; transition: opacity 0.15s; }
.message-bubble:hover .meta-actions { opacity: 1; }
/* Thinking trace */
.thinking-trace { margin-top: 10px; border-top: 1px solid var(--el-border-color-light); padding-top: 8px; }
.trace-header { display: flex; align-items: center; gap: 4px; cursor: pointer; font-size: 12px; color: var(--el-color-primary); user-select: none; padding: 4px 0; }
.trace-steps { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.trace-step { display: flex; gap: 8px; padding: 8px 10px; border-radius: 8px; background: var(--el-fill-color-lighter); border-left: 3px solid var(--el-border-color); }
.trace-step.step-think { border-left-color: var(--el-color-primary); }
.trace-step.step-tool_result { border-left-color: var(--el-color-warning); }
.trace-step.step-final { border-left-color: var(--el-color-success); }
.step-icon { flex-shrink: 0; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 14px; color: var(--el-text-color-secondary); }
.step-body { flex: 1; min-width: 0; font-size: 13px; }
.step-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.step-tag { font-size: 11px; padding: 1px 6px; border-radius: 4px; font-weight: 500; }
.tag-think { background: var(--el-color-primary-light-9); color: var(--el-color-primary); }
.tag-tool_result { background: var(--el-color-warning-light-9); color: var(--el-color-warning); }
.tag-final { background: var(--el-color-success-light-9); color: var(--el-color-success); }
.step-iter { font-size: 11px; color: var(--el-text-color-placeholder); }
.step-tool-name { font-size: 11px; background: var(--el-color-info-light-9); color: var(--el-color-info); padding: 0 6px; border-radius: 4px; font-family: monospace; }
.step-content { line-height: 1.5; }
.step-content :deep(pre) { background: var(--el-fill-color); padding: 8px; border-radius: 6px; overflow-x: auto; font-size: 12px; margin: 4px 0; }
.step-reasoning, .step-tool-input, .step-tool-result { margin-top: 6px; }
.reasoning-header { font-size: 11px; color: var(--el-text-color-secondary); margin-bottom: 2px; font-weight: 500; }
.reasoning-text { font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.5; font-style: italic; }
.step-tool-input pre, .step-tool-result pre { background: var(--el-fill-color-darker); padding: 6px 8px; border-radius: 4px; font-size: 11px; overflow-x: auto; max-height: 200px; margin: 0; }
/* Thinking dots */
.thinking { display: flex; gap: 4px; padding: 8px 0; }
.dot { width: 8px; height: 8px; background: var(--el-text-color-placeholder); border-radius: 50%; animation: bounce 1.4s infinite ease-in-out; }
.dot:nth-child(2) { animation-delay: 0.16s; }
.dot:nth-child(3) { animation-delay: 0.32s; }
@keyframes bounce { 0%,80%,100% { transform: scale(0); } 40% { transform: scale(1); } }
/* Chat input */
.chat-input { display: flex; gap: 12px; padding-top: 12px; border-top: 1px solid var(--el-border-color-light); }
.chat-input .el-textarea { flex: 1; }
.send-btn { align-self: flex-end; min-width: 100px; }
/* Orchestrate result */
.orchestrate-result { font-size: 14px; }
.orch-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.orch-agent-count { font-size: 12px; color: var(--el-text-color-secondary); }
.orch-final { margin-bottom: 12px; }
.orch-section-title { font-size: 13px; font-weight: 600; color: var(--el-text-color-primary); margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid var(--el-border-color-light); }
.orch-steps { display: flex; flex-direction: column; gap: 4px; }
.orch-step { border: 1px solid var(--el-border-color-lighter); border-radius: 8px; overflow: hidden; }
.orch-step-header { display: flex; align-items: center; gap: 6px; padding: 6px 10px; cursor: pointer; background: var(--el-fill-color-lighter); font-size: 13px; }
.orch-step-header:hover { background: var(--el-fill-color-light); }
.orch-step-meta { font-size: 11px; color: var(--el-text-color-placeholder); margin-left: auto; }
.orch-step-body { padding: 8px 12px; font-size: 13px; background: var(--el-bg-color); }
/* Orchestrate editor */
.orch-editor { display: flex; flex-direction: column; gap: 12px; max-height: 500px; overflow-y: auto; }
.orch-agent-card { border: 1px solid var(--el-border-color-light); border-radius: 8px; padding: 12px; }
.orch-agent-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.orch-agent-num { font-weight: 600; color: var(--el-color-primary); font-size: 14px; }
.orch-agent-params { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
</style>