538 lines
13 KiB
Vue
538 lines
13 KiB
Vue
|
|
<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 } 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 messages = ref<Message[]>([])
|
|||
|
|
const inputMessage = ref('')
|
|||
|
|
const loading = ref(false)
|
|||
|
|
const messagesContainer = ref<HTMLElement>()
|
|||
|
|
|
|||
|
|
// 发送消息
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
// 轮询执行状态
|
|||
|
|
const checkStatus = async () => {
|
|||
|
|
try {
|
|||
|
|
const statusResponse = await api.get(`/api/v1/executions/${execution.id}`)
|
|||
|
|
const exec = statusResponse.data
|
|||
|
|
|
|||
|
|
if (exec.status === 'completed') {
|
|||
|
|
// 提取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()
|
|||
|
|
} else if (exec.status === 'failed') {
|
|||
|
|
messages.value.push({
|
|||
|
|
role: 'agent',
|
|||
|
|
content: `执行失败: ${exec.error_message || '未知错误'}`,
|
|||
|
|
timestamp: Date.now()
|
|||
|
|
})
|
|||
|
|
loading.value = false
|
|||
|
|
scrollToBottom()
|
|||
|
|
} else {
|
|||
|
|
// 继续轮询
|
|||
|
|
setTimeout(checkStatus, 1000)
|
|||
|
|
}
|
|||
|
|
} catch (error: any) {
|
|||
|
|
messages.value.push({
|
|||
|
|
role: 'agent',
|
|||
|
|
content: `获取执行结果失败: ${error.response?.data?.detail || error.message}`,
|
|||
|
|
timestamp: Date.now()
|
|||
|
|
})
|
|||
|
|
loading.value = false
|
|||
|
|
scrollToBottom()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 开始轮询
|
|||
|
|
setTimeout(checkStatus, 1000)
|
|||
|
|
|
|||
|
|
} 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 = []
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 滚动到底部
|
|||
|
|
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 })
|
|||
|
|
</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>
|