Files
aiagent/frontend/src/components/AgentChatPreview.vue
2026-01-22 09:59:02 +08:00

631 lines
16 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>
<div class="agent-chat-preview">
<div class="chat-header">
<div class="agent-info">
<el-avatar :size="32" :src="agentAvatar" :icon="UserFilled" />
<span class="agent-name">{{ agentName || 'Agent' }}</span>
</div>
<div class="header-actions">
<el-button text size="small" @click="handleClearChat">
<el-icon><Delete /></el-icon>
清空对话
</el-button>
</div>
</div>
<div class="chat-messages" ref="messagesContainer">
<!-- 开场白 -->
<div v-if="openingMessage" class="message agent-message">
<el-avatar :size="32" :src="agentAvatar" :icon="UserFilled" />
<div class="message-content">
<div class="message-bubble" v-html="formatMessage(openingMessage)"></div>
</div>
</div>
<!-- 预设问题 -->
<div v-if="presetQuestions.length > 0 && messages.length === 0" class="preset-questions">
<div
v-for="(question, index) in presetQuestions"
:key="index"
class="preset-question"
@click="handlePresetQuestion(question)"
>
{{ question }}
</div>
</div>
<!-- 对话消息 -->
<div
v-for="(message, index) in messages"
:key="index"
:class="['message', message.role === 'user' ? 'user-message' : 'agent-message']"
>
<el-avatar
v-if="message.role === 'agent'"
:size="32"
:src="agentAvatar"
:icon="UserFilled"
/>
<div class="message-content">
<div class="message-bubble" v-html="formatMessage(message.content)"></div>
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
</div>
<el-avatar
v-if="message.role === 'user'"
:size="32"
:icon="UserFilled"
style="background-color: #409eff;"
/>
</div>
<!-- 加载中 -->
<div v-if="loading" class="message agent-message">
<el-avatar :size="32" :src="agentAvatar" :icon="UserFilled" />
<div class="message-content">
<div class="message-bubble loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>正在思考...</span>
</div>
</div>
</div>
<!-- 节点测试结果 -->
<div v-if="props.nodeTestResult" class="node-test-result">
<el-alert
:type="props.nodeTestResult.result.status === 'success' ? 'success' : 'error'"
:closable="true"
show-icon
@close="handleCloseNodeTest"
>
<template #title>
<div class="node-test-header">
<strong>节点测试: {{ props.nodeTestResult.node.data?.label || props.nodeTestResult.node.type }}</strong>
<span class="node-test-time">{{ props.nodeTestResult.result.execution_time }}ms</span>
</div>
</template>
<div class="node-test-content">
<div v-if="props.nodeTestResult.result.status === 'success'">
<div class="test-section">
<strong>输入:</strong>
<pre>{{ JSON.stringify(props.nodeTestResult.input, null, 2) }}</pre>
</div>
<div class="test-section">
<strong>输出:</strong>
<pre>{{ JSON.stringify(props.nodeTestResult.result.output, null, 2) }}</pre>
</div>
</div>
<div v-else>
<div class="test-section">
<strong>错误:</strong>
<pre>{{ props.nodeTestResult.result.error_message || '未知错误' }}</pre>
</div>
</div>
</div>
</el-alert>
</div>
</div>
<div class="chat-input-area">
<div class="input-toolbar">
<el-button text size="small" @click="handleAttachFile">
<el-icon><Paperclip /></el-icon>
</el-button>
</div>
<el-input
v-model="inputMessage"
type="textarea"
:rows="2"
placeholder="发送消息..."
@keydown.enter.exact.prevent="handleSendMessage"
@keydown.enter.shift.exact="handleNewLine"
:disabled="loading || !agentId"
/>
<div class="input-footer">
<div class="disclaimer">
内容由AI生成无法确保真实准确仅供参考
</div>
<div class="input-actions">
<el-button text size="small" @click="handleVoiceInput">
<el-icon><Microphone /></el-icon>
</el-button>
<el-button
type="primary"
@click="handleSendMessage"
:disabled="!inputMessage.trim() || loading || !agentId"
:loading="loading"
>
<el-icon><Promotion /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
UserFilled,
Delete,
Loading,
Paperclip,
Microphone,
Promotion
} from '@element-plus/icons-vue'
import api from '@/api'
interface Message {
role: 'user' | 'agent'
content: string
timestamp: number
}
const props = defineProps<{
agentId?: string
agentName?: string
agentAvatar?: string
openingMessage?: string
presetQuestions?: string[]
nodeTestResult?: any
}>()
const emit = defineEmits<{
'execution-status': [status: any]
}>()
const messages = ref<Message[]>([])
const inputMessage = ref('')
const loading = ref(false)
const messagesContainer = ref<HTMLElement>()
let pollingInterval: any = null
let replyAdded = false // 标志位:防止重复添加回复
// 发送消息
const handleSendMessage = async () => {
if (!inputMessage.value.trim() || loading.value || !props.agentId) return
const userMessage = inputMessage.value.trim()
inputMessage.value = ''
// 添加用户消息
messages.value.push({
role: 'user',
content: userMessage,
timestamp: Date.now()
})
// 滚动到底部
scrollToBottom()
// 发送到Agent
loading.value = true
try {
const response = await api.post('/api/v1/executions', {
agent_id: props.agentId,
input_data: {
USER_INPUT: userMessage,
query: userMessage
}
})
const execution = response.data
// 重置标志位
replyAdded = false
// 轮询执行状态
const checkStatus = async () => {
try {
// 如果已经添加过回复,直接返回,避免重复添加
if (replyAdded) {
return
}
// 获取详细执行状态(包含节点执行信息)
const statusResponse = await api.get(`/api/v1/executions/${execution.id}/status`)
const status = statusResponse.data
// 将执行状态传递给父组件,用于显示工作流动画
emit('execution-status', status)
// 获取执行详情(用于提取输出结果)
const execResponse = await api.get(`/api/v1/executions/${execution.id}`)
const exec = execResponse.data
if (exec.status === 'completed') {
// 防止重复添加:如果已经添加过回复,直接返回
if (replyAdded) {
return
}
// 标记已添加回复
replyAdded = true
// 提取Agent回复
let agentReply = ''
if (exec.output_data) {
// 优先从 result 字段获取(工作流执行结果)
if (exec.output_data.result) {
// result 字段应该是纯文本字符串End节点的输出
if (typeof exec.output_data.result === 'string') {
agentReply = exec.output_data.result
} else {
agentReply = String(exec.output_data.result)
}
} else if (typeof exec.output_data === 'string') {
agentReply = exec.output_data
} else if (exec.output_data.output) {
agentReply = exec.output_data.output
} else if (exec.output_data.response) {
agentReply = exec.output_data.response
} else if (exec.output_data.text) {
agentReply = exec.output_data.text
} else {
agentReply = JSON.stringify(exec.output_data, null, 2)
}
}
messages.value.push({
role: 'agent',
content: agentReply || '执行完成',
timestamp: Date.now()
})
loading.value = false
scrollToBottom()
// 延迟清除执行状态,让用户能看到最终的执行结果
setTimeout(() => {
emit('execution-status', null)
}, 3000) // 3秒后清除
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
} else if (exec.status === 'failed') {
// 防止重复添加:如果已经添加过回复,直接返回
if (replyAdded) {
return
}
// 标记已添加回复
replyAdded = true
messages.value.push({
role: 'agent',
content: `执行失败: ${exec.error_message || '未知错误'}`,
timestamp: Date.now()
})
loading.value = false
scrollToBottom()
// 延迟清除执行状态,让用户能看到失败节点的状态
setTimeout(() => {
emit('execution-status', null)
}, 5000) // 5秒后清除
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
} else {
// 继续轮询pending 或 running 状态)
// 不需要做任何操作,等待下次轮询
}
} catch (error: any) {
// 防止重复添加:如果已经添加过回复,直接返回
if (replyAdded) {
return
}
// 标记已添加回复
replyAdded = true
messages.value.push({
role: 'agent',
content: `获取执行结果失败: ${error.response?.data?.detail || error.message}`,
timestamp: Date.now()
})
loading.value = false
scrollToBottom()
// 清除执行状态
emit('execution-status', null)
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
}
}
// 使用 setInterval 进行轮询每500毫秒检查一次更频繁能捕获快速执行的节点
pollingInterval = setInterval(checkStatus, 500)
// 立即执行一次
checkStatus()
} catch (error: any) {
console.error('发送消息失败:', error)
messages.value.push({
role: 'agent',
content: `发送失败: ${error.response?.data?.detail || error.message}`,
timestamp: Date.now()
})
loading.value = false
scrollToBottom()
ElMessage.error(error.response?.data?.detail || '发送失败')
}
}
// 预设问题点击
const handlePresetQuestion = (question: string) => {
inputMessage.value = question
handleSendMessage()
}
// 清空对话
const handleClearChat = () => {
messages.value = []
// 清除执行状态
emit('execution-status', null)
// 清除轮询
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
}
// 滚动到底部
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
// 格式化消息支持简单的Markdown
const formatMessage = (content: string) => {
if (!content) return ''
// 简单的换行处理
return content.replace(/\n/g, '<br>')
}
// 格式化时间
const formatTime = (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
// 附件
const handleAttachFile = () => {
ElMessage.info('文件上传功能开发中')
}
// 语音输入
const handleVoiceInput = () => {
ElMessage.info('语音输入功能开发中')
}
// 换行
const handleNewLine = () => {
// Shift+Enter 换行,不需要特殊处理
}
// 关闭节点测试结果
const handleCloseNodeTest = () => {
// 通过事件通知父组件清除测试结果
// 这里暂时不做处理,由父组件自动清除
}
// 监听消息变化,自动滚动
watch(messages, () => {
scrollToBottom()
}, { deep: true })
// 组件卸载时清理轮询
onUnmounted(() => {
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
// 清除执行状态
emit('execution-status', null)
})
</script>
<style scoped>
.agent-chat-preview {
display: flex;
flex-direction: column;
height: 100%;
background: #f5f5f5;
border-left: 1px solid #e4e7ed;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: white;
border-bottom: 1px solid #e4e7ed;
}
.agent-info {
display: flex;
align-items: center;
gap: 8px;
}
.agent-name {
font-weight: 500;
font-size: 14px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: flex;
gap: 12px;
align-items: flex-start;
}
.user-message {
flex-direction: row-reverse;
}
.message-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.user-message .message-content {
align-items: flex-end;
}
.message-bubble {
padding: 10px 14px;
border-radius: 12px;
max-width: 70%;
word-wrap: break-word;
line-height: 1.5;
}
.agent-message .message-bubble {
background: white;
color: #303133;
border: 1px solid #e4e7ed;
}
.user-message .message-bubble {
background: #409eff;
color: white;
}
.message-bubble.loading {
display: flex;
align-items: center;
gap: 8px;
color: #909399;
}
.message-time {
font-size: 12px;
color: #909399;
padding: 0 4px;
}
.preset-questions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.preset-question {
padding: 10px 14px;
background: white;
border: 1px solid #e4e7ed;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
color: #409eff;
}
.preset-question:hover {
background: #ecf5ff;
border-color: #409eff;
}
.node-test-result {
margin-top: 16px;
margin-bottom: 16px;
}
.node-test-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.node-test-time {
font-size: 12px;
color: #909399;
font-weight: normal;
}
.node-test-content {
margin-top: 8px;
}
.test-section {
margin-bottom: 12px;
}
.test-section strong {
display: block;
margin-bottom: 4px;
font-size: 13px;
color: #606266;
}
.test-section pre {
background: #f5f7fa;
padding: 8px;
border-radius: 4px;
font-size: 12px;
max-height: 200px;
overflow: auto;
margin: 0;
border: 1px solid #e4e7ed;
}
.chat-input-area {
background: white;
border-top: 1px solid #e4e7ed;
padding: 12px;
}
.input-toolbar {
margin-bottom: 8px;
}
.input-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.disclaimer {
font-size: 12px;
color: #909399;
}
.input-actions {
display: flex;
gap: 8px;
align-items: center;
}
:deep(.el-textarea__inner) {
border: 1px solid #dcdfe6;
border-radius: 8px;
resize: none;
}
</style>