fix: #33 内置多模态工具现在在工具市场 /api/v1/tools 中可见

list_tools 端点合并内置工具(image_ocr/image_vision/speech_to_text/text_to_speech 等),
按 scope=public/all 时自动包含,无需额外种子到 DB。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-05-06 22:13:41 +08:00
parent 9054f42cda
commit 5b5eb84dfb
9 changed files with 1095 additions and 7 deletions

View File

@@ -35,6 +35,10 @@
<el-icon><ChatLineSquare /></el-icon>
<span>Agent对话</span>
</el-menu-item>
<el-menu-item index="agent-orchestration" @click="router.push('/agent-orchestration')">
<el-icon><Share /></el-icon>
<span>Agent协作</span>
</el-menu-item>
<el-menu-item index="agent-schedules" @click="router.push('/agent-schedules')">
<el-icon><Clock /></el-icon>
<span>定时任务</span>
@@ -63,9 +67,13 @@
<el-icon><Star /></el-icon>
<span>模板市场</span>
</el-menu-item>
<el-menu-item
<el-menu-item index="agent-market" @click="router.push('/agent-market')">
<el-icon><Shop /></el-icon>
<span>Agent技能商店</span>
</el-menu-item>
<el-menu-item
v-if="userStore.user?.role === 'admin'"
index="permissions"
index="permissions"
@click="router.push('/permissions')"
>
<el-icon><Lock /></el-icon>
@@ -98,7 +106,7 @@
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { Document, User, List, Connection, Setting, Star, Lock, Monitor, Bell, Grid, DataAnalysis, Tools, Clock } from '@element-plus/icons-vue'
import { Document, User, List, Connection, Setting, Star, Lock, Monitor, Bell, Grid, DataAnalysis, Tools, Clock, Share, ChatLineSquare, Shop } from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
@@ -117,6 +125,7 @@ const activeMenu = computed(() => {
if (route.path === '/node-templates') return 'node-templates'
if (route.path === '/permissions') return 'permissions'
if (route.path === '/template-market') return 'template-market'
if (route.path === '/agent-market') return 'agent-market'
if (route.path === '/monitoring') return 'monitoring'
if (route.path === '/agent-monitoring') return 'agent-monitoring'
if (route.path === '/alert-rules') return 'alert-rules'
@@ -145,6 +154,8 @@ const handleMenuSelect = (key: string) => {
router.push('/node-templates')
} else if (key === 'template-market') {
router.push('/template-market')
} else if (key === 'agent-market') {
router.push('/agent-market')
} else if (key === 'permissions') {
router.push('/permissions')
} else if (key === 'monitoring') {

View File

@@ -141,6 +141,18 @@ const router = createRouter({
name: 'plugin-market',
component: () => import('@/views/PluginMarket.vue'),
meta: { requiresAuth: true }
},
{
path: '/agent-orchestration',
name: 'agent-orchestration',
component: () => import('@/views/AgentOrchestration.vue'),
meta: { requiresAuth: true }
},
{
path: '/agent-market',
name: 'agent-market',
component: () => import('@/views/AgentMarket.vue'),
meta: { requiresAuth: true }
}
]
})

View File

@@ -0,0 +1,595 @@
<template>
<MainLayout>
<div class="orch-page">
<!-- Toolbar -->
<div class="orch-toolbar">
<div class="toolbar-left">
<h3>Agent 协作工作台</h3>
<el-tag size="small" type="info">{{ nodes.length }} 节点 · {{ edges.length }} 连线</el-tag>
</div>
<div class="toolbar-right">
<el-button @click="handleSaveTemplate" :disabled="nodes.length === 0">
<el-icon><FolderChecked /></el-icon> 保存模板
</el-button>
<el-button @click="showLoadDialog = true">
<el-icon><FolderOpened /></el-icon> 加载模板
</el-button>
<el-button @click="handleAddConditionNode" type="warning" plain>
<el-icon><Switch /></el-icon> 添加条件
</el-button>
<el-button @click="handleClear" :disabled="nodes.length === 0">清空</el-button>
</div>
</div>
<!-- Main content -->
<div class="orch-body">
<!-- Left: Agent list -->
<div class="orch-left-panel">
<div class="panel-title">Agent 列表</div>
<el-input v-model="agentSearch" placeholder="搜索 Agent..." size="small" clearable style="margin-bottom: 8px" />
<div class="agent-list">
<div
v-for="agent in filteredAgents"
:key="agent.id"
class="agent-item"
draggable="true"
@dragstart="onDragStart($event, agent)"
>
<div class="agent-item-icon"><el-icon><User /></el-icon></div>
<div class="agent-item-info">
<div class="agent-item-name">{{ agent.name }}</div>
<div class="agent-item-desc">{{ agent.description || '无描述' }}</div>
</div>
<el-tag size="small" :type="agent.status === 'published' ? 'success' : 'info'">
{{ agent.status === 'published' ? '已发布' : '草稿' }}
</el-tag>
</div>
<div v-if="filteredAgents.length === 0" class="agent-empty">
{{ agentSearch ? '无匹配 Agent' : '暂无 Agent请先创建' }}
</div>
</div>
<div class="panel-hint">拖拽 Agent 到画布上</div>
</div>
<!-- Center: Vue Flow canvas -->
<div
class="orch-canvas"
@drop="onDrop"
@dragover.prevent
@dragenter.prevent
>
<VueFlow
ref="vueFlowRef"
v-model:nodes="nodes"
v-model:edges="edges"
:node-types="nodeTypes"
:default-edge-options="defaultEdgeOptions"
:connection-line-style="{ stroke: '#409EFF', strokeWidth: 2 }"
fit-view-on-init
@node-click="onNodeClick"
@pane-click="selectedNodeId = null"
@connect="onConnect"
>
<Background :gap="16" />
<Controls />
<MiniMap />
</VueFlow>
<div class="canvas-hint" v-if="nodes.length === 0">
拖拽左侧 Agent 到此区域或添加条件节点开始编排
</div>
</div>
<!-- Right: Node config panel -->
<div class="orch-right-panel" v-if="selectedNode">
<div class="panel-title">
{{ selectedNode.type === 'condition' ? '条件配置' : 'Agent 配置' }}
<el-button link size="small" @click="handleDeleteNode" type="danger">删除</el-button>
</div>
<template v-if="selectedNode.type === 'condition'">
<el-form label-position="top" size="small">
<el-form-item label="节点名称">
<el-input v-model="selectedNode.data.name" placeholder="条件节点" />
</el-form-item>
<el-form-item label="操作符">
<el-select v-model="selectedNode.data.operator" style="width: 100%">
<el-option label="包含 (contains)" value="contains" />
<el-option label="不包含 (not_contains)" value="not_contains" />
<el-option label="等于 (equals)" value="equals" />
<el-option label="不等于 (not_equals)" value="not_equals" />
<el-option label="以...开头 (starts_with)" value="starts_with" />
<el-option label="以...结尾 (ends_with)" value="ends_with" />
</el-select>
</el-form-item>
<el-form-item label="匹配值">
<el-input v-model="selectedNode.data.value" placeholder="要匹配的文本" />
</el-form-item>
<el-form-item label="条件说明">
<el-input v-model="selectedNode.data.condition" placeholder="条件说明(可选)" />
</el-form-item>
</el-form>
</template>
<template v-else>
<el-form label-position="top" size="small">
<el-form-item label="System Prompt">
<el-input v-model="selectedNode.data.system_prompt" type="textarea" :rows="4" placeholder="你是一个有用的AI助手。" />
</el-form-item>
<el-form-item label="模型">
<el-select v-model="selectedNode.data.model" style="width: 100%">
<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-form-item>
<el-form-item label="Temperature">
<el-slider v-model="selectedNode.data.temperature" :min="0" :max="2" :step="0.1" show-input />
</el-form-item>
<el-form-item label="最大迭代次数">
<el-input-number v-model="selectedNode.data.max_iterations" :min="1" :max="50" />
</el-form-item>
<el-form-item label="工具白名单">
<el-select v-model="selectedNode.data.tools" multiple filterable placeholder="全部工具" style="width: 100%">
<el-option v-for="t in availableTools" :key="t" :label="t" :value="t" />
</el-select>
<span class="form-hint">留空=全部可用工具</span>
</el-form-item>
</el-form>
</template>
</div>
<div class="orch-right-panel" v-else>
<div class="panel-title">节点配置</div>
<div class="panel-empty">点击画布中的节点查看配置</div>
</div>
</div>
<!-- Bottom: Input + Results -->
<div class="orch-bottom">
<div class="bottom-input">
<el-input
v-model="userInput"
type="textarea"
:rows="2"
placeholder="输入消息,测试编排流程..."
@keydown.enter.exact.prevent="handleExecute"
:disabled="executing"
/>
<el-button type="primary" @click="handleExecute" :loading="executing" :disabled="!userInput.trim() || nodes.length === 0">
{{ executing ? '执行中...' : '执行编排' }}
</el-button>
</div>
<!-- Result display -->
<div class="bottom-result" v-if="executionResult || executionError">
<div v-if="executionError" class="result-error">
<el-alert type="error" :closable="false" show-icon :title="executionError" />
</div>
<div v-if="executionResult" class="result-content">
<div class="result-header">
<el-tag type="success">执行完成</el-tag>
<span class="result-mode">{{ executionResult.steps.length }} </span>
</div>
<div class="result-answer">
<div class="result-section-title">最终回答</div>
<div class="message-text" v-html="renderMarkdown(executionResult.final_answer)"></div>
</div>
<div class="result-steps">
<div class="result-section-title">执行步骤</div>
<div v-for="(step, i) in executionResult.steps" :key="i" class="result-step" :class="{ expanded: step._open }">
<div class="result-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="step-meta">{{ step.iterations_used }} · {{ step.tool_calls_made }} 次工具</span>
</div>
<div v-show="step._open" class="result-step-body">
<div class="message-text" v-html="renderMarkdown(step.output)"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Load template dialog -->
<el-dialog v-model="showLoadDialog" title="加载编排模板" width="550px">
<el-table :data="templates" @row-click="handleLoadTemplate" highlight-current-row stripe max-height="350px">
<el-table-column prop="name" label="名称" />
<el-table-column prop="description" label="描述" />
<el-table-column label="更新日期" width="160">
<template #default="{ row }">
{{ row.updated_at ? new Date(row.updated_at).toLocaleDateString() : '' }}
</template>
</el-table-column>
</el-table>
<div v-if="templates.length === 0" style="text-align: center; padding: 24px; color: #999;">暂无模板</div>
</el-dialog>
<!-- Save template dialog -->
<el-dialog v-model="showSaveDialog" title="保存编排模板" width="450px" @closed="templateName = ''; templateDesc = ''">
<el-form label-width="80px" size="small">
<el-form-item label="模板名称">
<el-input v-model="templateName" placeholder="输入模板名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="templateDesc" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showSaveDialog = false">取消</el-button>
<el-button type="primary" @click="doSaveTemplate" :disabled="!templateName.trim()">保存</el-button>
</template>
</el-dialog>
</div>
</MainLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, markRaw } from 'vue'
import { ElMessage } from 'element-plus'
import { VueFlow, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap'
import { User, Switch, FolderChecked, FolderOpened, CaretRight } from '@element-plus/icons-vue'
import { h } from 'vue'
import MainLayout from '@/components/MainLayout.vue'
import api from '@/api'
import type { Agent } from '@/stores/agent'
// ---- Custom Agent Node Component ----
const AgentNodeComponent = {
props: ['data', 'selected'],
setup(props: any) {
return () => h('div', {
class: 'orch-agent-node' + (props.selected ? ' selected' : ''),
style: { padding: '10px 14px', borderRadius: '10px', background: props.selected ? '#ecf5ff' : '#f0f9eb', border: props.selected ? '2px solid #409EFF' : '2px solid #c8e6c9', minWidth: '160px', fontSize: '13px' }
}, [
h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' } }, [
h('span', { style: { fontSize: '16px' } }, '🤖'),
h('strong', {}, props.data?.name || props.data?.agent_name || 'Agent'),
]),
h('div', { style: { fontSize: '11px', color: '#888' } }, props.data?.model || 'deepseek-v4-flash'),
h('div', { style: { fontSize: '11px', color: '#aaa', marginTop: '2px' } },
(props.data?.tools || []).length > 0 ? `${props.data.tools.length} 个工具` : '全部工具'
),
])
}
}
// ---- Custom Condition Node Component ----
const ConditionNodeComponent = {
props: ['data', 'selected'],
setup(props: any) {
return () => h('div', {
class: 'orch-condition-node' + (props.selected ? ' selected' : ''),
style: { padding: '8px 14px', borderRadius: '8px', background: props.selected ? '#fdf6ec' : '#fef0f0', border: props.selected ? '2px solid #E6A23C' : '2px solid #f5dab1', minWidth: '120px', textAlign: 'center', fontSize: '13px', transform: 'rotate(2deg)' }
}, [
h('div', { style: { fontSize: '14px', marginBottom: '2px' } }, '◇'),
h('strong', { style: { fontSize: '12px' } }, props.data?.name || '条件'),
h('div', { style: { fontSize: '10px', color: '#888', marginTop: '2px' } },
`${props.data?.operator || 'contains'} "${props.data?.value || ''}"`),
])
}
}
const nodeTypes = {
'agent-node': markRaw(AgentNodeComponent),
'condition-node': markRaw(ConditionNodeComponent),
}
const defaultEdgeOptions = {
animated: true,
style: { strokeWidth: 2 },
}
// ---- State ----
const vueFlowRef = ref()
const { nodes, edges, onConnect: vfConnect, addNodes, addEdges } = useVueFlow()
// Override onConnect to add animated + deletable
function onConnect(connection: any) {
if (!connection.source || !connection.target) return
edges.value.push({
id: `e-${connection.source}-${connection.target}-${Date.now()}`,
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle || '',
animated: true,
style: { strokeWidth: 2 },
})
}
let nextNodeId = 1
function makeNodeId() { return `node-${Date.now()}-${nextNodeId++}` }
const selectedNodeId = ref<string | null>(null)
const userInput = ref('')
const executing = ref(false)
const executionResult = ref<any>(null)
const executionError = ref('')
// Templates
const templates = ref<any[]>([])
const showLoadDialog = ref(false)
const showSaveDialog = ref(false)
const templateName = ref('')
const templateDesc = ref('')
// Agent list
const agents = ref<Agent[]>([])
const agentSearch = ref('')
const filteredAgents = computed(() => {
if (!agentSearch.value) return agents.value
const s = agentSearch.value.toLowerCase()
return agents.value.filter(a => a.name.toLowerCase().includes(s) || (a.description || '').toLowerCase().includes(s))
})
// Common tools for the dropdown
const availableTools = [
'datetime', 'system_info', 'file_read', 'file_write',
'http_request', 'database_query', 'web_search', 'calculator',
'grep_search', 'list_files', 'execute_code',
]
// Selected node
const selectedNode = computed(() => {
if (!selectedNodeId.value) return null
return nodes.value.find(n => n.id === selectedNodeId.value) || null
})
// ---- Drag & Drop ----
function onDragStart(event: DragEvent, agent: Agent) {
if (!event.dataTransfer) return
event.dataTransfer.setData('application/agent', JSON.stringify(agent))
event.dataTransfer.effectAllowed = 'copy'
}
function onDrop(event: DragEvent) {
const raw = event.dataTransfer?.getData('application/agent')
if (!raw) return
const agent: Agent = JSON.parse(raw)
const pos = vueFlowRef.value?.screenToFlowCoordinate?.({ x: event.clientX, y: event.clientY }) || { x: event.clientX - 350, y: event.clientY - 100 }
const wc = agent.workflow_config || {}
const agentNodes = wc.nodes || []
const agentNodeCfg = agentNodes.find((n: any) => ['agent', 'llm', 'template'].includes(n.type || ''))
const data = agentNodeCfg?.data || {}
const node = {
id: makeNodeId(),
type: 'agent-node',
position: { x: pos.x - 80, y: pos.y - 30 },
data: {
agent_id: agent.id,
name: agent.name,
agent_name: agent.name,
system_prompt: data.system_prompt || agent.description || '你是一个有用的AI助手。',
model: data.model || 'deepseek-v4-flash',
provider: data.provider || 'deepseek',
temperature: data.temperature || 0.7,
max_iterations: data.max_iterations || 10,
tools: data.tools || [],
},
}
addNodes([node])
}
// ---- Node interaction ----
function onNodeClick({ node }: any) {
selectedNodeId.value = node.id
}
function handleAddConditionNode() {
const pos = { x: 200 + nodes.value.length * 50, y: 200 + nodes.value.length * 30 }
const node = {
id: makeNodeId(),
type: 'condition-node',
position: pos,
data: {
name: '条件',
condition: '',
operator: 'contains',
value: '',
},
}
addNodes([node])
}
function handleDeleteNode() {
if (!selectedNodeId.value) return
nodes.value = nodes.value.filter(n => n.id !== selectedNodeId.value)
edges.value = edges.value.filter(e => e.source !== selectedNodeId.value && e.target !== selectedNodeId.value)
selectedNodeId.value = null
}
function handleClear() {
nodes.value = []
edges.value = []
selectedNodeId.value = null
executionResult.value = null
executionError.value = ''
}
// ---- Templates ----
async function loadTemplates() {
try {
const resp = await api.get('/api/v1/orchestration-templates')
templates.value = resp.data || []
} catch { templates.value = [] }
}
async function handleSaveTemplate() {
await loadTemplates()
showSaveDialog.value = true
}
async function doSaveTemplate() {
try {
await api.post('/api/v1/orchestration-templates', {
name: templateName.value.trim(),
description: templateDesc.value.trim(),
nodes: JSON.parse(JSON.stringify(nodes.value)),
edges: JSON.parse(JSON.stringify(edges.value)),
})
ElMessage.success('模板已保存')
showSaveDialog.value = false
templateName.value = ''
templateDesc.value = ''
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存失败')
}
}
async function handleLoadTemplate(row: any) {
try {
const resp = await api.get(`/api/v1/orchestration-templates/${row.id}`)
const tpl = resp.data
nodes.value = tpl.nodes || []
edges.value = tpl.edges || []
nextNodeId = nodes.value.length + 1
showLoadDialog.value = false
ElMessage.success(`已加载: ${tpl.name}`)
} catch {
ElMessage.error('加载失败')
}
}
// ---- Execution ----
async function handleExecute() {
if (!userInput.value.trim() || nodes.value.length === 0) return
executing.value = true
executionResult.value = null
executionError.value = ''
try {
const resp = await api.post('/api/v1/agent-chat/orchestrate/graph', {
message: userInput.value.trim(),
nodes: JSON.parse(JSON.stringify(nodes.value)),
edges: JSON.parse(JSON.stringify(edges.value)),
})
const data = resp.data
data.steps.forEach((s: any) => { s._open = false })
executionResult.value = data
} catch (e: any) {
executionError.value = e.response?.data?.detail || e.message || '执行失败'
} finally {
executing.value = false
}
}
// ---- Helpers ----
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>')
}
// ---- Init ----
onMounted(async () => {
try {
const resp = await api.get('/api/v1/agents')
agents.value = resp.data || []
} catch { /* ignore */ }
})
</script>
<style scoped>
.orch-page {
display: flex; flex-direction: column; height: calc(100vh - 60px);
max-width: 100%; margin: 0 auto;
}
/* Toolbar */
.orch-toolbar {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 16px; border-bottom: 1px solid var(--el-border-color-light);
flex-shrink: 0; background: var(--el-bg-color);
}
.toolbar-left { display: flex; align-items: center; gap: 12px; }
.toolbar-left h3 { margin: 0; font-size: 16px; }
.toolbar-right { display: flex; gap: 8px; }
/* Body */
.orch-body {
display: flex; flex: 1; min-height: 0; overflow: hidden;
}
/* Left panel */
.orch-left-panel {
width: 220px; flex-shrink: 0; border-right: 1px solid var(--el-border-color-light);
padding: 12px; display: flex; flex-direction: column; overflow-y: auto;
}
.panel-title { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--el-text-color-primary); }
.agent-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; }
.agent-item {
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
border: 1px solid var(--el-border-color-lighter); border-radius: 8px;
cursor: grab; background: var(--el-bg-color); transition: all 0.15s;
}
.agent-item:hover { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.agent-item:active { cursor: grabbing; }
.agent-item-icon { font-size: 18px; color: var(--el-color-primary); flex-shrink: 0; }
.agent-item-info { flex: 1; min-width: 0; }
.agent-item-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-item-desc { font-size: 11px; color: var(--el-text-color-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-empty { text-align: center; padding: 24px; color: var(--el-text-color-placeholder); font-size: 13px; }
.panel-hint { font-size: 11px; color: var(--el-text-color-placeholder); margin-top: 8px; text-align: center; }
/* Canvas */
.orch-canvas {
flex: 1; position: relative; min-width: 0; background: var(--el-fill-color-lighter);
}
.canvas-hint {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
color: var(--el-text-color-placeholder); font-size: 14px; pointer-events: none; z-index: 1;
}
/* Right panel */
.orch-right-panel {
width: 260px; flex-shrink: 0; border-left: 1px solid var(--el-border-color-light);
padding: 12px; overflow-y: auto; background: var(--el-bg-color);
}
.panel-empty { text-align: center; padding: 24px; color: var(--el-text-color-placeholder); font-size: 13px; }
.form-hint { font-size: 11px; color: var(--el-text-color-placeholder); }
/* Bottom */
.orch-bottom {
flex-shrink: 0; border-top: 1px solid var(--el-border-color-light);
padding: 10px 16px; background: var(--el-bg-color);
}
.bottom-input { display: flex; gap: 10px; align-items: flex-end; }
.bottom-input .el-textarea { flex: 1; }
/* Result */
.bottom-result { margin-top: 10px; max-height: 300px; overflow-y: auto; }
.result-error { margin-bottom: 8px; }
.result-content { font-size: 14px; }
.result-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.result-mode { font-size: 12px; color: var(--el-text-color-secondary); }
.result-section-title { font-size: 13px; font-weight: 600; margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid var(--el-border-color-light); }
.result-answer { margin-bottom: 10px; }
/* Steps */
.result-steps { display: flex; flex-direction: column; gap: 4px; }
.result-step { border: 1px solid var(--el-border-color-lighter); border-radius: 8px; overflow: hidden; }
.result-step-header { display: flex; align-items: center; gap: 6px; padding: 6px 10px; cursor: pointer; background: var(--el-fill-color-lighter); font-size: 13px; }
.result-step-header:hover { background: var(--el-fill-color-light); }
.step-meta { font-size: 11px; color: var(--el-text-color-placeholder); margin-left: auto; }
.result-step-body { padding: 8px 12px; font-size: 13px; }
/* Message text */
.message-text :deep(pre) { background: var(--el-fill-color); padding: 10px; border-radius: 6px; overflow-x: auto; font-size: 13px; }
.message-text :deep(code) { background: var(--el-fill-color); padding: 2px 4px; border-radius: 3px; font-size: 13px; }
</style>
<style>
/* Global overrides for Vue Flow (non-scoped) */
@import '@vue-flow/core/dist/style.css';
@import '@vue-flow/core/dist/theme-default.css';
@import '@vue-flow/controls/dist/style.css';
@import '@vue-flow/minimap/dist/style.css';
</style>