- 在 `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>
697 lines
31 KiB
Vue
697 lines
31 KiB
Vue
<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, '<').replace(/>/g, '>')
|
||
.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>
|