Files
aiagent/frontend/src/views/AgentChat.vue
renjianbo 3483c6b3be fix: update TTS voice mapping to only use valid Edge TTS voices
Previously the voice map referenced 10+ non-existent Edge TTS voice names
(xiaohan, xiaochen, xiaoshuang, etc.), causing NoAudioReceived errors and
fallback to browser default TTS. Now only maps to the 6 actually available
Chinese voices (xiaoxiao, xiaoyi, yunxi, yunyang, yunjian, yunxia).
Added voice speed adjustments and fixed --rate argument format for Windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-04 17:06:51 +08:00

1106 lines
49 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>
<!-- Session selector -->
<el-select
v-model="currentSessionId"
placeholder="对话记录"
style="width: 200px"
:disabled="!currentAgentId"
@change="switchSession"
clearable
>
<el-option label="+ 新对话" value="__new__">
<span style="color: var(--el-color-primary); font-weight: 500">+ 新对话</span>
</el-option>
<el-option
v-for="s in sessions"
:key="s.session_id"
:label="s.title || '未命名对话'"
:value="s.session_id"
>
<div style="display:flex;flex-direction:column">
<span class="session-option-title">{{ s.title || '未命名对话' }}</span>
<span class="session-option-meta">{{ s.message_count }} 条消息 · {{ relativeTimeText(s.updated_at) }}</span>
</div>
</el-option>
</el-select>
</template>
<!-- 编排模式模式选择 + Agent -->
<template v-if="chatMode === 'orchestrate'">
<el-select v-model="orchestrateMode" style="width: 140px">
<el-option label="辩论模式" value="debate" />
<el-option label="路由模式" value="route" />
<el-option label="顺序模式" value="sequential" />
<el-option label="流水线模式" value="pipeline" />
</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">
<!-- Rich empty state -->
<div v-if="displayMessages.length === 0 && !loading" class="chat-empty">
<template v-if="chatMode === 'single' && agent">
<el-avatar :size="56" icon="Promotion" style="background:var(--el-color-primary-light-5);margin-bottom:8px" />
<h3 class="welcome-title">{{ agent.name }}</h3>
<p v-if="agent.description" class="welcome-desc">{{ agent.description }}</p>
<div class="welcome-tags">
<el-tag size="small" type="info" v-if="agent.version">v{{ agent.version }}</el-tag>
<el-tag size="small" :type="agent.status === 'published' ? 'success' : 'warning'">{{ agent.status === 'published' ? '已发布' : agent.status }}</el-tag>
</div>
<p class="hint">可以问我任何问题或者试试下面的示例</p>
<div class="preset-questions">
<div class="preset-q" v-for="(q, qi) in getPresetQuestions()" :key="qi" @click="sendPresetQuestion(q)">
{{ q }}
</div>
</div>
</template>
<template v-else-if="chatMode === 'single'">
<el-icon :size="48"><ChatLineSquare /></el-icon>
<p>选择一个智能体开始对话</p>
<p class="hint">从上方下拉框中选择已创建的 Agent</p>
<div class="agent-quick-list" v-if="agents.length > 0">
<el-button v-for="a in agents.slice(0, 6)" :key="a.id" size="small" @click="currentAgentId = a.id; switchAgent()" style="margin:4px">
{{ a.name }}
</el-button>
</div>
</template>
<template v-else>
<el-icon :size="48"><ChatLineSquare /></el-icon>
<p>配置多个 Agent 后发送消息进行编排对话</p>
<p class="hint">支持辩论路由顺序流水线四种编排模式</p>
</template>
</div>
<!-- Messages using shared ChatMessageBubble -->
<ChatMessageBubble
v-for="(msg, i) in displayMessages"
:key="i"
:role="msg.role"
:content-html="renderMarkdown(msg.orchestrateResult?.final_answer || msg.content)"
:timestamp="msg.timestamp"
:status="msg.status"
:iterations="msg.iterations"
:tool-calls-made="msg.tool_calls_made"
>
<!-- Orchestrate result -->
<template v-if="msg.orchestrateResult" #extra>
<div 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>
</template>
<!-- Tool calls + thinking trace for non-orchestrate messages -->
<template v-if="!msg.orchestrateResult" #extra>
<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">{{ safeParseArgCount(tc.function?.arguments) }} 个参数</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>
<!-- Actions slot -->
<template #actions>
<el-button v-if="msg.role === 'assistant' && msg.content" link size="small" @click="isSpeaking ? stopSpeak() : speakMessage(msg.orchestrateResult?.final_answer || msg.content)" :title="isSpeaking ? '停止朗读' : '朗读'">
<el-icon><Headset v-if="!isSpeaking" /><VideoPause v-else /></el-icon>
</el-button>
<el-button v-if="msg.role === 'user'" link size="small" @click="editMessage(i)" title="编辑">
<el-icon><Edit /></el-icon>
</el-button>
<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>
<el-button link size="small" @click="deleteMessage(i)" title="删除">
<el-icon style="color:var(--el-color-danger)"><Delete /></el-icon>
</el-button>
</template>
</ChatMessageBubble>
<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>
<!-- Shared ChatInputArea -->
<ChatInputArea
ref="inputAreaRef"
v-model="inputMessage"
:loading="loading"
:disabled="loading || (chatMode === 'single' && !currentAgentId)"
:attachments="pendingAttachments"
:drag-over="inputDragOver"
:show-stop="loading"
:is-listening="isListening"
:voice-supported="voiceSupported"
placeholder="输入你的问题...&#10;Enter 发送Shift+Enter 换行&#10;支持拖拽或粘贴图片/文件"
@send="sendMessage"
@stop="stopGeneration"
@attach-file="handleAttachFile"
@start-voice="startListening"
@stop-voice="stopListening"
@files-selected="onFilesSelected"
@remove-attachment="removeAttachment"
>
<template #extraActions>
<el-popover v-if="chatMode === 'single'" placement="top" :width="260" trigger="click">
<template #reference>
<el-button text size="small" title="对话设置"><el-icon :size="18"><Setting /></el-icon></el-button>
</template>
<div class="chat-settings-popover">
<div style="margin-bottom:8px;font-size:13px;font-weight:500">模型</div>
<el-select v-model="chatModel" size="small" style="width:100%">
<el-option label="DeepSeek V4 Pro" value="deepseek-v4-pro" />
<el-option label="DeepSeek V4 Flash" value="deepseek-v4-flash" />
<el-option label="GPT-4o" value="gpt-4o" />
<el-option label="GPT-4o Mini" value="gpt-4o-mini" />
<el-option label="Claude Opus 4.6" value="claude-opus-4-6" />
<el-option label="Claude Sonnet 4.6" value="claude-sonnet-4-6" />
</el-select>
<div style="margin:10px 0 4px;font-size:13px;font-weight:500">Temperature: {{ chatTemperature.toFixed(1) }}</div>
<el-slider v-model="chatTemperature" :min="0" :max="2" :step="0.1" size="small" show-stops />
<div style="margin:10px 0 4px;font-size:13px;font-weight:500">最大迭代</div>
<el-input-number v-model="chatMaxIterations" :min="1" :max="50" size="small" style="width:100%" />
<div style="margin:10px 0 4px;font-size:13px;font-weight:500">朗读语音</div>
<el-select v-model="chatVoice" size="small" style="width:100%">
<el-option-group label="── 教师女声 ──">
<el-option label="晓晓老师 · 温柔教导" value="teacher1" />
<el-option label="晓怡老师 · 活泼教学" value="teacher2" />
</el-option-group>
<el-option-group label="── 女声 ──">
<el-option label="晓晓 · 温柔" value="xiaoxiao" />
<el-option label="晓怡 · 活泼" value="xiaoyi" />
<el-option label="晓辰 · 温和" value="xiaochen" />
<el-option label="晓涵 · 知性" value="xiaohan" />
</el-option-group>
<el-option-group label="── 男声 ──">
<el-option label="云希 · 阳光" value="yunxi" />
<el-option label="云扬 · 专业" value="yunyang" />
<el-option label="云健 · 激情" value="yunjian" />
<el-option label="云夏 · 可爱" value="yunxia" />
</el-option-group>
</el-select>
</div>
</el-popover>
</template>
</ChatInputArea>
<!-- 编排 Agent 编辑器 -->
<el-dialog v-model="showOrchestrateEditor" title="编排 Agent 配置" width="700px" @closed="saveState">
<div class="orch-editor">
<div v-if="orchestrateMode === 'pipeline'" class="orch-mode-hint">
<el-alert type="info" :closable="false" show-icon>
<template #title>
流水线模式仅使用第一个 Agent 作为执行器系统自动创建 Planner规划 Reviewer审查角色
</template>
</el-alert>
</div>
<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>
<!-- 工具审批对话框 -->
<el-dialog v-model="showApprovalDialog" title="工具执行审批" width="480px" :close-on-click-modal="false">
<div style="margin-bottom: 12px">
<el-tag type="warning" size="large">{{ approvalToolName }}</el-tag>
<span style="margin-left: 8px; color: #666">需要人工审批才能执行</span>
</div>
<div v-if="Object.keys(approvalArgs).length > 0" style="background: #f5f7fa; padding: 12px; border-radius: 6px; max-height: 200px; overflow-y: auto">
<pre style="margin: 0; font-size: 13px; white-space: pre-wrap; word-break: break-all">{{ JSON.stringify(approvalArgs, null, 2) }}</pre>
</div>
<template #footer>
<el-button @click="resolveApproval('denied')">拒绝</el-button>
<el-button @click="resolveApproval('skip')">跳过</el-button>
<el-button type="primary" @click="resolveApproval('approved')">批准执行</el-button>
</template>
</el-dialog>
</MainLayout>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ChatLineSquare, UserFilled, Promotion, Tools, CaretRight, ChatDotSquare, Select, DocumentCopy, Refresh, VideoPause, Edit, Delete, Headset, Setting } from '@element-plus/icons-vue'
import MainLayout from '@/components/MainLayout.vue'
import ChatMessageBubble from '@/components/ChatMessageBubble.vue'
import ChatInputArea from '@/components/ChatInputArea.vue'
import api from '@/api'
import type { Agent } from '@/stores/agent'
import { useMarkdown } from '@/composables/useMarkdown'
import { useFileUpload } from '@/composables/useFileUpload'
import { useTTS } from '@/composables/useTTS'
import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
// Shared composables
const { renderMarkdown, stripMarkdownForTTS } = useMarkdown()
const { pendingAttachments, fileInputRef, inputDragOver, uploadFiles, removeAttachment, clearAttachments, buildAttachmentContext, onDragLeave, onDrop, onFilesSelected, handleAttachFile } = useFileUpload()
const { isSpeaking, speak: ttsSpeak, stop: ttsStop } = useTTS()
const { isListening, transcript, supported: voiceSupported, start: voiceStart, stop: voiceStop } = useSpeechRecognition()
// TTS & Voice
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[]>
sessionId: Record<string, string>
currentAgentId: string
chatMode: 'single' | 'orchestrate'
orchestrateMode: string
orchestrateAgents: OrchestrateAgentForm[]
}
async function resolveApproval(decision: string) {
try {
await api.post(`/api/v1/approval/${approvalId.value}/resolve`, { decision })
} catch {
// 审批已超时或已处理,忽略
}
showApprovalDialog.value = false
approvalId.value = ''
approvalToolName.value = ''
approvalArgs.value = {}
}
function saveState() {
try {
const state: ChatState = {
messages: messages.value,
sessionId: sessionId.value,
currentAgentId: currentAgentId.value,
chatMode: chatMode.value,
orchestrateMode: orchestrateMode.value,
orchestrateAgents: orchestrateAgents.value,
_savedAt: Date.now(),
} as any
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)
if (parsed._savedAt && Date.now() - parsed._savedAt > 24 * 3600 * 1000) {
localStorage.removeItem(STORAGE_KEY)
return null
}
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 abortController = ref<AbortController | null>(null)
const inputAreaRef = ref<InstanceType<typeof ChatInputArea> | null>(null)
// 工具审批状态
const showApprovalDialog = ref(false)
const approvalId = ref('')
const approvalToolName = ref('')
const approvalArgs = ref<Record<string, any>>({})
const messagesRef = ref<HTMLElement | null>(null)
const isNearBottom = ref(true)
const sessionId = ref<Record<string, string>>({})
const agent = ref<Agent | null>(null)
// 会话历史
interface SessionInfo {
session_id: string; title?: string; last_message?: string
message_count: number; is_pinned: boolean
created_at?: string; updated_at?: string
}
const sessions = ref<SessionInfo[]>([])
const currentSessionId = ref('')
const sessionsLoading = ref(false)
// 对话设置
const chatModel = ref('deepseek-v4-pro')
const chatTemperature = ref(0.8)
const chatMaxIterations = ref(15)
const chatVoice = ref('teacher1')
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)
function handleScroll() {
if (!messagesRef.value) return
const el = messagesRef.value
const threshold = 60
isNearBottom.value = (el.scrollHeight - el.scrollTop - el.clientHeight) < threshold
}
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(() => {
messagesRef.value?.addEventListener('scroll', handleScroll, { passive: true })
scrollToBottom()
})
})
onUnmounted(() => {
messagesRef.value?.removeEventListener('scroll', handleScroll)
})
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; currentSessionId.value = ''; sessions.value = []; saveState(); nextTick(scrollToBottom); return }
try {
const resp = await api.get(`/api/v1/agents/${currentAgentId.value}`)
agent.value = resp.data
await loadSessions()
saveState()
nextTick(scrollToBottom)
} catch {
ElMessage.error('加载 Agent 失败')
agent.value = null
}
}
async function loadSessions() {
if (!currentAgentId.value) return
sessionsLoading.value = true
try {
const resp = await api.get(`/api/v1/agent-chat/${currentAgentId.value}/sessions?limit=50`)
sessions.value = resp.data.sessions || []
} catch { /* 静默失败 */ }
finally { sessionsLoading.value = false }
}
async function switchSession(sid: string) {
if (sid === '__new__') {
newSession()
return
}
currentSessionId.value = sid
const key = currentAgentKey.value
sessionId.value[key] = sid
sessionsLoading.value = true
try {
const resp = await api.get(`/api/v1/agent-chat/${currentAgentId.value}/sessions/${sid}/messages?limit=200`)
const serverMessages = resp.data.messages || []
messages.value[key] = serverMessages.map((m: any) => ({
role: m.role === 'user' ? 'user' : 'assistant',
content: m.content || '',
timestamp: m.created_at ? new Date(m.created_at).getTime() : Date.now(),
tool_calls: m.tool_name ? [{ function: { name: m.tool_name, arguments: m.tool_input } }] : [],
}))
} catch {
ElMessage.warning('加载对话历史失败')
}
finally { sessionsLoading.value = false; saveState(); nextTick(scrollToBottom) }
}
function newSession() {
const key = currentAgentKey.value
messages.value[key] = []
sessionId.value[key] = ''
currentSessionId.value = ''
saveState()
nextTick(scrollToBottom)
}
function getPresetQuestions(): string[] {
const name = agent.value?.name || ''
const desc = agent.value?.description || ''
const combined = `${name} ${desc}`.toLowerCase()
const questions: string[] = []
if (combined.includes('代码') || combined.includes('编程') || combined.includes('开发')) {
questions.push('帮我写一个 Python 脚本来处理 CSV 文件', '解释一下这段代码的作用和潜在问题', '如何设计一个 RESTful API 接口?')
} else if (combined.includes('写作') || combined.includes('文案') || combined.includes('文章')) {
questions.push('帮我写一篇关于 AI 发展的文章大纲', '润色这段文字,让它更加流畅易读', '给我几个吸引眼球的标题方案')
} else if (combined.includes('分析') || combined.includes('数据') || combined.includes('报告')) {
questions.push('分析这组数据的趋势和异常点', '帮我总结这份报告的核心要点', '对比 A 和 B 两个方案的优劣')
} else {
questions.push(`介绍一下你的能力和可以使用的工具`, '帮我分析一个复杂问题', '搜索并整理最新的相关信息')
}
return questions
}
function sendPresetQuestion(q: string) {
inputMessage.value = q
nextTick(() => sendMessage())
}
function speakMessage(text: string) {
if (!text) return
ttsSpeak(stripMarkdownForTTS(text), { voice: chatVoice.value })
}
function stopSpeak() {
ttsStop()
}
// Voice input
function startListening() {
voiceStart('zh-CN')
}
function stopListening() {
voiceStop()
if (transcript.value) {
inputMessage.value = (inputMessage.value + ' ' + transcript.value).trim()
transcript.value = ''
}
}
watch(transcript, (val) => {
if (val && isListening.value) { /* display during recording */ }
})
function relativeTimeText(isoStr?: string): string {
if (!isoStr) return ''
const diff = Date.now() - new Date(isoStr).getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
return `${Math.floor(diff / 86400000)} 天前`
}
// onDragLeave handled by ChatInputArea + parent wiring
// The drag events from useFileUpload can be applied directly since ChatInputArea emits events
function onInputDragEnter(e: DragEvent) {
if (loading.value || (chatMode.value === 'single' && !currentAgentId.value)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
inputDragOver.value = true
}
async function onInputDrop(e: DragEvent) {
inputDragOver.value = false
if (chatMode.value === 'single' && !currentAgentId.value) {
ElMessage.warning('请先选择智能体再上传文件')
return
}
const files = e.dataTransfer?.files
if (files?.length) await uploadFiles(Array.from(files))
}
async function sendMessage() {
const text = inputMessage.value.trim()
const hasAttachments = pendingAttachments.value.length > 0
if (!text && !hasAttachments) return
if (loading.value) return
const attachCtx = buildAttachmentContext()
const fullText = text + attachCtx
const key = currentAgentKey.value
if (!messages.value[key]) messages.value[key] = []
messages.value[key].push({ role: 'user', content: fullText, timestamp: Date.now() })
inputMessage.value = ''
pendingAttachments.value = []
inputDragOver.value = false
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'
let usedStreaming = false
let placeholderIdx = -1
streamingActive.value = false
abortController.value = new AbortController()
const streamTimeout = setTimeout(() => abortController.value?.abort(), 300000)
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,
model: chatModel.value || undefined,
temperature: chatTemperature.value,
max_iterations: chatMaxIterations.value,
}),
signal: abortController.value.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,
}
placeholderIdx = messages.value[key].push(msg) - 1
const currentMsg = messages.value[key][placeholderIdx]
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)
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 === 'approval_required') {
approvalId.value = data.approval_id || ''
approvalToolName.value = data.tool_name || ''
approvalArgs.value = data.args || {}
showApprovalDialog.value = true
} else if (eventType === 'final') {
clearTimeout(streamTimeout)
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
if (!currentSessionId.value) {
currentSessionId.value = data.session_id
loadSessions()
}
}
streamingActive.value = false
loading.value = false
} else if (eventType === 'error') {
clearTimeout(streamTimeout)
currentMsg.content = data.content || ''
currentMsg.status = 'error'
streamingActive.value = false
loading.value = false
} else {
console.warn('[AgentChat] 未知 SSE 事件类型:', eventType, data)
}
} catch { /* 跳过畸形事件 */ }
}
}
clearTimeout(streamTimeout)
}
} catch {
clearTimeout(streamTimeout)
usedStreaming = false
streamingActive.value = false
}
if (!usedStreaming) {
if (placeholderIdx >= 0 && placeholderIdx < messages.value[key].length) {
messages.value[key].splice(placeholderIdx, 1)
}
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,
model: chatModel.value || undefined,
temperature: chatTemperature.value,
max_iterations: chatMaxIterations.value,
})
const data = resp.data
sessionId.value[key] = data.session_id
if (!currentSessionId.value) {
currentSessionId.value = data.session_id
loadSessions()
}
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] = ''
currentSessionId.value = ''
}
saveState()
nextTick(scrollToBottom)
}
function scrollToBottom() { nextTick(() => { if (messagesRef.value && isNearBottom.value) messagesRef.value.scrollTop = messagesRef.value.scrollHeight }) }
function stopGeneration() {
abortController.value?.abort()
loading.value = false
streamingActive.value = false
saveState()
}
function safeParseArgCount(args?: string): number {
if (!args) return 0
try { return Object.keys(JSON.parse(args)).length }
catch { return 0 }
}
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 editMessage(idx: number) {
const key = currentAgentKey.value
const msgs = messages.value[key]
if (!msgs || !msgs[idx]) return
const msg = msgs[idx]
if (msg.role !== 'user') return
inputMessage.value = msg.content
msgs.splice(idx, 1)
saveState()
nextTick(scrollToBottom)
}
function deleteMessage(idx: number) {
const key = currentAgentKey.value
const msgs = messages.value[key]
if (!msgs || !msgs[idx]) return
msgs.splice(idx, 1)
saveState()
nextTick(scrollToBottom)
}
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 = ''
let userIdx = -1
for (let i = idx - 1; i >= 0; i--) {
if (msgs[i].role === 'user') {
userMsg = msgs[i].content
userIdx = i
break
}
}
if (!userMsg) {
ElMessage.warning('未找到可重试的消息')
return
}
const removeIndices: number[] = [userIdx, idx]
for (let i = userIdx + 1; i < idx; i++) {
const m = msgs[i]
if (m.role === 'assistant' && (!m.content || m.content === '') && (m.steps?.length || 0) > 0) {
removeIndices.push(i)
}
}
removeIndices.sort((a, b) => b - a)
for (const ri of removeIndices) {
msgs.splice(ri, 1)
}
saveState()
inputMessage.value = userMsg
nextTick(() => sendMessage())
}
</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; padding: 40px 20px; text-align: center; }
.chat-empty .hint { font-size: 13px; color: var(--el-text-color-placeholder); margin-top: 8px; }
.welcome-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.welcome-desc { margin: 0; font-size: 14px; color: var(--el-text-color-secondary); max-width: 480px; line-height: 1.6; }
.welcome-tags { display: flex; gap: 6px; }
.preset-questions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; max-width: 520px; margin-top: 4px; }
.preset-q { padding: 8px 16px; background: var(--el-fill-color); border: 1px solid var(--el-border-color-light); border-radius: 20px; font-size: 13px; cursor: pointer; transition: all 0.15s; color: var(--el-text-color-regular); }
.preset-q:hover { background: var(--el-color-primary-light-9); border-color: var(--el-color-primary-light-5); color: var(--el-color-primary); }
.agent-quick-list { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; max-width: 480px; }
/* Chat settings popover */
.chat-settings-popover { padding: 4px 0; }
.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: #1e1e2e; color: #cdd6f4; padding: 14px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.55; margin: 8px 0; }
.message-text :deep(pre code) { background: none; padding: 0; border-radius: 0; font-size: inherit; color: inherit; }
.message-text :deep(code) { background: var(--el-fill-color-darker); padding: 2px 6px; border-radius: 4px; font-size: 13px; font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace; }
.message-text :deep(h1) { font-size: 1.4em; font-weight: 700; margin: 12px 0 6px; }
.message-text :deep(h2) { font-size: 1.2em; font-weight: 700; margin: 10px 0 4px; }
.message-text :deep(h3) { font-size: 1.1em; font-weight: 600; margin: 8px 0 4px; }
.message-text :deep(ul), .message-text :deep(ol) { padding-left: 20px; margin: 4px 0; }
.message-text :deep(li) { margin: 2px 0; }
.message-text :deep(blockquote) { border-left: 3px solid var(--el-color-primary); margin: 8px 0; padding: 4px 12px; color: var(--el-text-color-secondary); background: var(--el-fill-color-lighter); border-radius: 0 6px 6px 0; }
.message-text :deep(table) { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 13px; }
.message-text :deep(th), .message-text :deep(td) { border: 1px solid var(--el-border-color); padding: 6px 10px; text-align: left; }
.message-text :deep(th) { background: var(--el-fill-color); font-weight: 600; }
.message-text :deep(a) { color: var(--el-color-primary); text-decoration: underline; }
.message-text :deep(hr) { border: none; border-top: 1px solid var(--el-border-color-light); margin: 12px 0; }
.message-text :deep(p) { margin: 4px 0; }
.message-text :deep(img) { max-width: 100%; border-radius: 8px; margin: 6px 0; }
/* 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); }
/* 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); } }
/* Session selector */
.session-option-title { font-size: 13px; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; }
.session-option-meta { font-size: 11px; color: var(--el-text-color-placeholder); }
/* 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>