Files
schoolNews/schoolNewsWeb/src/views/public/ai/AIAgent.vue
2025-11-18 11:48:01 +08:00

1882 lines
51 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 v-if="hasAgent" class="ai-agent" :class="{ expanded: !isBall }">
<div v-if="isBall" class="ball-container" ref="ballRef">
<!-- 悬浮球 -->
<div class="chat-ball" @mousedown="startDrag" @touchstart="startDrag" :style="ballStyle">
<img src="@/assets/imgs/chat-ball.svg" alt="AI助手" class="ball-icon" />
<!-- 未读消息提示 -->
<div v-if="unreadCount > 0" class="unread-badge">{{ unreadCount }}</div>
</div>
</div>
<div v-else class="ai-agent-content">
<!-- 左侧对话历史列表 -->
<div class="ai-agent-history" :class="{ collapsed: historyCollapsed }">
<div class="history-header">
<h3>对话历史</h3>
<button @click="historyCollapsed = !historyCollapsed" class="collapse-btn">
{{ historyCollapsed ? '展开' : '收起' }}
</button>
</div>
<div v-if="!historyCollapsed" class="history-list">
<!-- 新建对话按钮 -->
<button class="new-chat-btn" @click="prepareNewConversation">
+ 新建对话
</button>
<!-- 历史对话列表 -->
<div v-for="conv in conversations" :key="conv.id" class="conversation-item"
:class="{ active: currentConversation?.id === conv.id }" @click="selectConversation(conv)">
<div class="conv-title">{{ conv.title || '新对话' }}</div>
<div class="conv-time">{{ formatTime(conv.updateTime) }}</div>
<button class="delete-conv-btn" @click.stop="deleteConversationConfirm(conv.id || '')">
×
</button>
</div>
<!-- 加载更多 -->
<div v-if="hasMoreConversations" class="load-more">
<button @click="loadMoreConversations">加载更多</button>
</div>
</div>
</div>
<!-- 右侧当前对话窗口 -->
<div class="ai-agent-current-chat">
<div class="current-chat-header">
<div class="current-chat-title">
<input v-if="editingTitle" v-model="editTitleValue" @blur="saveTitle" @keyup.enter="saveTitle"
class="title-input" autofocus />
<span v-else @dblclick="startEditTitle">
{{ currentConversation?.title || '新对话' }}
</span>
</div>
<div class="current-chat-action">
<button @click="minimizeChat" class="action-btn" title="最小化">
<span></span>
</button>
<button @click="clearCurrentConversation" class="action-btn" title="清空对话">
<img src="@/assets/imgs/trashbin-grey.svg" alt="清空" class="icon">
</button>
</div>
</div>
<!-- 对话内容区域 -->
<div class="current-chat-content" ref="chatContentRef">
<!-- 欢迎消息 -->
<div v-if="messages.length === 0" class="welcome-message">
<div class="welcome-icon">
<img v-if="agentAvatarUrl" :src="agentAvatarUrl" alt="AI助手" class="welcome-avatar" />
<img v-else src="@/assets/imgs/assistant.svg" alt="AI助手" class="welcome-avatar" />
</div>
<h2>你好我是{{ agentConfig?.name || 'AI助手' }}</h2>
<AIRecommend />
</div>
<!-- 消息列表 -->
<div v-for="message in messages" :key="message.id" class="message-item">
<!-- 用户消息 -->
<div v-if="message.role === 'user'" class="message user-message">
<div class="message-avatar">
<div class="avatar-circle">👤</div>
</div>
<div class="message-content">
<div class="message-text">{{ message.content }}</div>
<!-- 文件列表 -->
<div v-if="message.files && message.files.length > 0" class="message-files">
<div v-for="file in message.files" :key="file.id" class="message-file-item">
<a :href="`/api/file/download/${file.sysFileId || file.filePath}`"
:download="file.fileName"
target="_blank"
class="file-link">
<span class="file-icon">📎</span>
<span class="file-name">{{ file.fileName }}</span>
<span class="file-size">({{ formatFileSize(file.fileSize) }})</span>
</a>
</div>
</div>
<div class="message-time">{{ formatMessageTime(message.createTime) }}</div>
</div>
</div>
<!-- AI 消息 -->
<div v-else class="message ai-message">
<div class="message-avatar">
<div class="avatar-circle ai-avatar">
<img v-if="agentAvatarUrl" :src="agentAvatarUrl" alt="AI助手" class="ai-avatar-img" />
<span v-else>🤖</span>
</div>
</div>
<div class="message-content">
<!-- 显示消息内容 -->
<div v-if="message.content" class="message-text" v-html="formatMarkdown(message.content)"></div>
<!-- 正在生成中的加载动画 -->
<div v-if="isGenerating && messages[messages.length - 1]?.id === message.id" class="typing-indicator-inline">
<span></span>
<span></span>
<span></span>
</div>
<!-- 消息底部时间和操作按钮 -->
<div v-if="message.content" class="message-footer">
<div class="message-time">{{ formatMessageTime(message.createTime) }}</div>
<div class="message-actions">
<button @click="copyMessage(message.content || '')" class="msg-action-btn" title="复制">
📋
</button>
<button @click="regenerateMessage(message.id || '')" class="msg-action-btn" title="重新生成">
🔄
</button>
<button @click="rateMessage(message.id || '', 1)" class="msg-action-btn"
:class="{ active: message.rating === 1 }" title="好评">
👍
</button>
<button @click="rateMessage(message.id || '', -1)" class="msg-action-btn"
:class="{ active: message.rating === -1 }" title="差评">
👎
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="current-chat-input">
<!-- 已上传文件列表 -->
<div v-if="uploadedFiles.length > 0" class="input-files">
<div v-for="(file, index) in uploadedFiles" :key="index" class="uploaded-file-item">
<span class="file-name">{{ file.name }}</span>
<button @click="removeUploadedFile(index)" class="remove-file-btn">×</button>
</div>
</div>
<!-- 输入框 -->
<div class="input-area">
<textarea v-model="inputMessage" class="input-text" placeholder="请输入问题..."
@keydown.enter.exact.prevent="sendMessage" @keydown.shift.enter="handleShiftEnter" ref="inputRef"
rows="1" />
<div class="input-action">
<button @click="triggerFileUpload" class="action-icon-btn" title="上传文件">
<img src="@/assets/imgs/link.svg" alt="上传文件" class="link-icon" />
</button>
<!-- 停止生成按钮 -->
<button v-if="isGenerating" @click="stopGenerating" class="action-icon-btn stop-btn" title="停止生成">
<span class="stop-icon"></span>
</button>
<!-- 发送按钮 -->
<button v-else @click="sendMessage" class="action-icon-btn send-btn" :disabled="!canSend" title="发送 (Enter)">
<img src="@/assets/imgs/send.svg" alt="发送" class="send-icon" />
</button>
</div>
</div>
<!-- 隐藏的文件上传input -->
<input type="file" ref="fileInputRef" @change="handleFileUpload" style="display: none" multiple
accept=".txt,.pdf,.doc,.docx,.md" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { chatApi, chatHistoryApi, aiAgentConfigApi, fileUploadApi } from '@/apis/ai';
import type { AiConversation, AiMessage, AiAgentConfig, AiUploadFile } from '@/types/ai';
import { AIRecommend } from '@/views/public/ai';
interface AIAgentProps {
agentId?: string;
}
const props = withDefaults(defineProps<AIAgentProps>(), {
agentId: undefined
});
// ===== AI助手配置 =====
const hasAgent = ref(true);
const agentConfig = ref<AiAgentConfig | null>(null);
// 缓存头像URL为blob避免重复下载
const agentAvatarUrl = ref<string>('');
const cachedAvatarPath = ref<string>('');
// 加载并缓存头像
async function loadAndCacheAvatar(avatarPath: string) {
if (!avatarPath || cachedAvatarPath.value === avatarPath) {
return; // 已缓存,跳过
}
try {
const response = await fetch("/api/file/download/" + avatarPath);
if (response.ok) {
const blob = await response.blob();
// 释放旧的blob URL
if (agentAvatarUrl.value && agentAvatarUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(agentAvatarUrl.value);
}
// 创建新的blob URL
agentAvatarUrl.value = URL.createObjectURL(blob);
cachedAvatarPath.value = avatarPath;
}
} catch (error) {
console.warn('加载头像失败:', error);
agentAvatarUrl.value = '';
}
}
// 加载AI助手配置
async function loadAgentConfig() {
try {
// 优先根据agentId获取如果没有则获取启用的助手列表
if (props.agentId) {
const result = await aiAgentConfigApi.getAgentById(props.agentId);
if (result.success && result.data) {
agentConfig.value = result.data;
// 加载并缓存头像
if (result.data.avatar) {
await loadAndCacheAvatar(result.data.avatar);
}
} else {
hasAgent.value = false;
}
} else {
// 获取启用的助手列表,使用第一个
const result = await aiAgentConfigApi.listEnabledAgents();
if (result.success && result.dataList && result.dataList.length > 0) {
agentConfig.value = result.dataList[0];
// 加载并缓存头像
if (result.dataList[0].avatar) {
await loadAndCacheAvatar(result.dataList[0].avatar);
}
} else {
hasAgent.value = false;
}
}
} catch (error) {
console.error('加载AI助手配置失败:', error);
}
}
// ===== 悬浮球相关 =====
const isBall = ref(true);
const ballRef = ref<HTMLElement | null>(null);
const isDragging = ref(false);
const dragStartX = ref(0);
const dragStartY = ref(0);
const ballX = ref(0);
const ballY = ref(0);
const unreadCount = ref(0);
// 存储悬浮球的相对位置百分比用于窗口resize时保持相对位置
const ballXPercent = ref(1); // 1 表示右侧
const ballYPercent = ref(0.5); // 0.5 表示垂直居中
const isUserDragged = ref(false); // 标记用户是否手动拖动过
// 拖拽检测相关
const dragStartPosX = ref(0); // 记录拖拽开始时的实际位置
const dragStartPosY = ref(0);
const ballStyle = computed(() => ({
left: `${ballX.value}px`,
top: `${ballY.value}px`
}));
// 根据百分比计算实际位置
function updateBallPosition() {
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const ballWidth = 40;
const ballHeight = 40;
const margin = 20;
// 根据百分比计算位置
if (ballXPercent.value < 0.5) {
// 左侧
ballX.value = margin;
} else {
// 右侧
ballX.value = windowWidth - ballWidth - margin;
}
// 计算Y位置确保不超出边界
let targetY = windowHeight * ballYPercent.value - ballHeight / 2;
targetY = Math.max(margin, Math.min(targetY, windowHeight - ballHeight - margin));
ballY.value = targetY;
}
// 窗口resize监听器
function handleResize() {
updateBallPosition();
}
// 初始化悬浮球位置
onMounted(() => {
// 默认位置右侧垂直居中50vh
updateBallPosition();
// 监听窗口resize事件
window.addEventListener('resize', handleResize);
// 加载AI助手配置
loadAgentConfig();
// 加载最近对话
loadRecentConversations();
});
// 清理监听器和资源
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
// 释放blob URL
if (agentAvatarUrl.value && agentAvatarUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(agentAvatarUrl.value);
}
});
// 开始拖动
function startDrag(e: MouseEvent | TouchEvent) {
isDragging.value = true;
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
dragStartX.value = clientX - ballX.value;
dragStartY.value = clientY - ballY.value;
// 记录起始位置,用于判断是点击还是拖拽
dragStartPosX.value = ballX.value;
dragStartPosY.value = ballY.value;
document.addEventListener('mousemove', onDrag);
document.addEventListener('touchmove', onDrag);
document.addEventListener('mouseup', stopDrag);
document.addEventListener('touchend', stopDrag);
e.preventDefault();
}
// 拖动中
function onDrag(e: MouseEvent | TouchEvent) {
if (!isDragging.value) return;
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
ballX.value = clientX - dragStartX.value;
ballY.value = clientY - dragStartY.value;
e.preventDefault();
}
// 停止拖动
function stopDrag() {
if (!isDragging.value) return;
isDragging.value = false;
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('touchmove', onDrag);
document.removeEventListener('mouseup', stopDrag);
document.removeEventListener('touchend', stopDrag);
// 计算移动距离
const moveDistanceX = Math.abs(ballX.value - dragStartPosX.value);
const moveDistanceY = Math.abs(ballY.value - dragStartPosY.value);
const totalDistance = Math.sqrt(moveDistanceX * moveDistanceX + moveDistanceY * moveDistanceY);
// 判断是点击还是拖拽移动距离阈值为5px
const isClick = totalDistance <= 5;
if (isClick) {
// 如果是点击,展开对话框
expandChat();
} else {
// 如果是拖拽,执行吸附和位置调整
isUserDragged.value = true;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const ballWidth = 40;
const ballHeight = 40;
const margin = 20;
// 自动吸附到左右两侧
if (ballX.value < windowWidth / 2) {
// 吸附到左侧
ballX.value = margin;
ballXPercent.value = 0;
} else {
// 吸附到右侧
ballX.value = windowWidth - ballWidth - margin;
ballXPercent.value = 1;
}
// 限制垂直位置并保存百分比
if (ballY.value < margin) {
ballY.value = margin;
} else if (ballY.value > windowHeight - ballHeight - margin) {
ballY.value = windowHeight - ballHeight - margin;
}
// 保存Y位置的百分比以中心点计算
ballYPercent.value = (ballY.value + ballHeight / 2) / windowHeight;
}
}
// 展开对话
function expandChat() {
if (isDragging.value) return; // 拖动过程中不触发
isBall.value = false;
}
// 最小化对话
function minimizeChat() {
isBall.value = true;
}
// ===== 对话历史相关 =====
const historyCollapsed = ref(false);
const conversations = ref<AiConversation[]>([]);
const currentConversation = ref<AiConversation | null>(null);
const hasMoreConversations = ref(false);
const conversationPage = ref(1);
// 加载最近对话
async function loadRecentConversations() {
try {
const result = await chatHistoryApi.getRecentConversations(10);
if (result.success) {
// 后端返回List所以数据在dataList字段
const conversationList = result.dataList || result.data;
if (conversationList && Array.isArray(conversationList)) {
conversations.value = conversationList;
} else {
conversations.value = [];
}
// 不自动选中对话,等待用户手动点击
// 如果有对话,自动选中第一个
// if (conversations.value.length > 0 && !currentConversation.value) {
// await selectConversation(conversations.value[0]);
// }
}
} catch (error) {
console.error('加载对话历史失败:', error);
}
}
// 加载更多对话
async function loadMoreConversations() {
conversationPage.value++;
// TODO: 实现分页加载
}
// 准备新对话只清空状态不创建conversation
function prepareNewConversation() {
currentConversation.value = null;
messages.value = [];
ElMessage.success('已准备新对话,发送消息后将自动创建');
}
// 创建新对话(内部使用,在发送第一条消息时调用)
async function createNewConversation(title?: string) {
try {
const result = await chatApi.createConversation(agentConfig.value!.id!, title);
if (result.success && result.data) {
currentConversation.value = result.data;
// 将新创建的对话添加到列表开头
conversations.value.unshift(result.data);
return result.data;
}
} catch (error) {
console.error('创建对话失败:', error);
ElMessage.error('创建对话失败');
return null;
}
}
// 选择对话
async function selectConversation(conv: AiConversation) {
currentConversation.value = conv;
if (conv.id) {
await loadMessages(conv.id);
}
}
// 删除对话确认
async function deleteConversationConfirm(conversationId: string) {
try {
await ElMessageBox.confirm('确定要删除这个对话吗?', '提示', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
});
const result = await chatApi.deleteConversation(conversationId);
if (result.success) {
conversations.value = conversations.value.filter(c => c.id !== conversationId);
if (currentConversation.value?.id === conversationId) {
currentConversation.value = null;
messages.value = [];
// 选择第一个对话
if (conversations.value.length > 0) {
await selectConversation(conversations.value[0]);
}
}
ElMessage.success('对话已删除');
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除对话失败:', error);
ElMessage.error('删除对话失败');
}
}
}
// 清空当前对话
async function clearCurrentConversation() {
try {
await ElMessageBox.confirm('确定要清空当前对话吗?', '提示', {
confirmButtonText: '清空',
cancelButtonText: '取消',
type: 'warning'
});
messages.value = [];
ElMessage.success('对话已清空');
} catch (error) {
// 用户取消
}
}
// 编辑标题
const editingTitle = ref(false);
const editTitleValue = ref('');
function startEditTitle() {
if (!currentConversation.value) return;
editingTitle.value = true;
editTitleValue.value = currentConversation.value.title || '';
}
async function saveTitle() {
if (!currentConversation.value) return;
editingTitle.value = false;
if (editTitleValue.value && editTitleValue.value !== currentConversation.value.title) {
currentConversation.value.title = editTitleValue.value;
try {
await chatApi.updateConversation(currentConversation.value);
ElMessage.success('标题已更新');
} catch (error) {
console.error('更新标题失败:', error);
ElMessage.error('更新标题失败');
}
}
}
// ===== 消息相关 =====
const messages = ref<AiMessage[]>([]);
const inputMessage = ref('');
const isGenerating = ref(false);
const currentEventSource = ref<EventSource | null>(null); // 当前的EventSource连接
const currentMessageId = ref<string | null>(null); // 当前AI消息的数据库ID用于停止生成
const currentTaskId = ref<string | null>(null); // 当前任务的task_idDify的消息ID
const difyEventData = ref<Record<string, any>>({}); // 存储Dify事件数据
const chatContentRef = ref<HTMLElement | null>(null);
const inputRef = ref<HTMLTextAreaElement | null>(null);
// 消息文件列表缓存
const messageFilesCache = ref<Record<string, any[]>>({});
// 加载消息
async function loadMessages(conversationId: string) {
try {
const result = await chatApi.listMessages(conversationId);
if (result.success) {
// 后端返回List所以数据在dataList字段
const messageList = result.dataList || result.data || [];
messages.value = Array.isArray(messageList) ? messageList : [];
// 加载每条用户消息的关联文件只有当fileIDs不为空时
for (const message of messages.value) {
if (message.role === 'user' && message.id && message.fileIDs) {
// 检查fileIDs是否不为空可能是JSON字符串或数组
let hasFiles = false;
const fileIDs = message.fileIDs;
if (typeof fileIDs === 'string') {
hasFiles = fileIDs.trim() !== '' && fileIDs !== '[]';
} else if (Array.isArray(fileIDs)) {
hasFiles = (fileIDs as any[]).length > 0;
}
if (hasFiles) {
await loadMessageFiles(message.id);
}
}
}
await nextTick();
scrollToBottom();
}
} catch (error) {
console.error('加载消息失败:', error);
}
}
// 加载消息关联的文件列表
async function loadMessageFiles(messageId: string) {
try {
const result = await fileUploadApi.listFilesByMessage(messageId);
if (result.success && result.dataList) {
messageFilesCache.value[messageId] = result.dataList;
// 将文件列表附加到消息对象上
const message = messages.value.find(m => m.id === messageId);
if (message) {
(message as any).files = result.dataList;
}
}
} catch (error) {
console.error('加载消息文件失败:', error);
}
}
// 发送消息
async function sendMessage() {
if (!canSend.value) return;
const message = inputMessage.value.trim();
if (!message) return;
// 如果没有当前对话,创建新对话,使用第一个问题作为标题
const isFirstMessage = !currentConversation.value;
if (isFirstMessage) {
// 限制标题长度为20字符
const title = message.length > 20 ? message.substring(0, 20) + '...' : message;
const newConv = await createNewConversation(title);
if (!newConv) {
ElMessage.error('创建对话失败');
return;
}
}
// 添加用户消息到界面
const userMessage: AiMessage = {
id: `temp-user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
conversationID: currentConversation.value?.id || '',
role: 'user',
content: message,
createTime: new Date().toISOString(),
updateTime: new Date().toISOString()
};
messages.value.push(userMessage);
inputMessage.value = '';
await nextTick();
scrollToBottom();
// 调用API
isGenerating.value = true;
// 立即创建一个空的AI消息用于显示加载动画
messages.value.push({
id: `temp-ai-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
conversationID: currentConversation.value?.id || '',
role: 'assistant',
content: '',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString()
});
await nextTick();
scrollToBottom();
try {
let aiMessageContent = '';
chatApi.streamChat({
agentId: agentConfig.value!.id!,
conversationId: currentConversation.value?.id || '',
query: message,
files: uploadedFiles.value.map(f => ({
id: f.id, // Dify文件ID
sys_file_id: f.sys_file_id, // 系统文件ID用于保存关联记录
file_path: f.file_path, // 文件路径(用于保存记录)
name: f.name,
size: f.size,
type: f.type,
transfer_method: f.transfer_method,
upload_file_id: f.upload_file_id
}))
},
{
onStart: (eventSource: EventSource) => {
// 保存EventSource引用用于中止
currentEventSource.value = eventSource;
// 清空之前的数据
difyEventData.value = {};
currentTaskId.value = null;
currentMessageId.value = null;
},
onInit: (initData: { messageId: string; conversationId: string }) => {
// 保存AI消息的数据库IDtask_id用于停止生成
currentMessageId.value = initData.messageId;
console.log('[保存MessageID(TaskID)]', initData.messageId);
// 更新最后一条AI消息的临时ID为真实的数据库ID
const lastMessage = messages.value[messages.value.length - 1];
if (lastMessage && lastMessage.role === 'assistant') {
lastMessage.id = initData.messageId;
}
},
onMessage: (chunk: string) => {
// 确保AI消息已创建即使内容为空
const lastMessage = messages.value[messages.value.length - 1];
if (!lastMessage || lastMessage.role !== 'assistant') {
messages.value.push({
id: `temp-ai-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
conversationID: currentConversation.value!.id,
role: 'assistant',
content: '',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString()
});
}
// 累加内容包括空chunk因为后端可能分块发送
if (chunk) {
aiMessageContent += chunk;
}
// 更新AI消息内容
const aiMessage = messages.value[messages.value.length - 1];
if (aiMessage && aiMessage.role === 'assistant') {
aiMessage.content = aiMessageContent;
}
nextTick(() => scrollToBottom());
},
onDifyEvent: (eventType: string, eventData: any) => {
// 处理Dify原始事件包含完整信息
// 存储事件数据
difyEventData.value[eventType] = eventData;
// 特别处理workflow_started事件提取task_id
if (eventType === 'workflow_started' && eventData.task_id) {
currentTaskId.value = eventData.task_id;
console.log('[Task ID]', eventData.task_id);
}
// 可以根据需要处理其他事件类型
// 例如node_started, node_finished, agent_thought等
},
onMessageEnd: () => {
isGenerating.value = false;
currentEventSource.value = null;
currentTaskId.value = null;
currentMessageId.value = null;
// 清空已上传的文件列表(文件仅对一次消息发送生效)
uploadedFiles.value = [];
},
onError: (error: Error) => {
console.error('对话失败:', error);
ElMessage.error('对话失败,请重试');
isGenerating.value = false;
currentEventSource.value = null;
currentTaskId.value = null;
currentMessageId.value = null;
// 发送失败也清空文件列表
uploadedFiles.value = [];
}
}
);
// 把文件放到message里面转换为AiUploadFile格式
userMessage.files = uploadedFiles.value.map(f => ({
id: f.id,
sysFileId: f.sys_file_id,
fileName: f.name,
filePath: f.file_path,
fileSize: f.size,
fileType: f.type
} as AiUploadFile));
} catch (error) {
console.error('发送消息失败:', error);
ElMessage.error('发送消息失败');
isGenerating.value = false;
currentEventSource.value = null;
// 发送失败也清空文件列表
uploadedFiles.value = [];
}
}
// Shift+Enter 换行
function handleShiftEnter() {
// 允许默认行为(换行)
}
// 是否可以发送
const canSend = computed(() => {
return inputMessage.value.trim().length > 0 && !isGenerating.value;
});
// 滚动到底部
function scrollToBottom() {
if (chatContentRef.value) {
chatContentRef.value.scrollTop = chatContentRef.value.scrollHeight;
}
}
// 停止生成
async function stopGenerating() {
// 优先使用 taskId如果没有则使用 messageId
const taskIdToStop = currentTaskId.value || currentMessageId.value;
if (!taskIdToStop || !agentConfig.value?.id) {
ElMessage.warning('无法停止:缺少必要信息');
return;
}
try {
// 只调用后端API停止Dify生成不关闭EventSource
// EventSource会在收到Dify的停止/完成事件后自动关闭
const result = await chatApi.stopChatByTaskId(taskIdToStop, agentConfig.value.id);
if (result.success) {
ElMessage.success('正在停止生成...');
// 注意不在这里关闭EventSource和清理状态
// 等待Dify发送complete/error事件由onMessageEnd/onError处理
} else {
ElMessage.warning(result.message || '停止生成失败');
// 如果后端返回失败,手动清理状态
if (currentEventSource.value) {
currentEventSource.value.close();
currentEventSource.value = null;
}
isGenerating.value = false;
currentTaskId.value = null;
currentMessageId.value = null;
difyEventData.value = {};
}
} catch (error) {
console.error('停止生成失败:', error);
ElMessage.error('停止生成失败');
// API调用失败时手动清理状态
if (currentEventSource.value) {
currentEventSource.value.close();
currentEventSource.value = null;
}
isGenerating.value = false;
currentTaskId.value = null;
currentMessageId.value = null;
difyEventData.value = {};
}
}
// 复制消息
async function copyMessage(content: string) {
try {
await navigator.clipboard.writeText(content);
ElMessage.success('已复制到剪贴板');
} catch (error) {
ElMessage.error('复制失败');
}
}
// 重新生成消息
async function regenerateMessage(messageId: string) {
if (isGenerating.value) return;
// 清空原有AI消息内容
const messageIndex = messages.value.findIndex(m => m.id === messageId);
if (messageIndex === -1) {
ElMessage.error('消息不存在');
return;
}
// 清空内容,准备重新生成
messages.value[messageIndex].content = '';
isGenerating.value = true;
try {
let aiMessageContent = '';
await chatApi.regenerateAnswer(
messageId,
{
onStart: (eventSource: EventSource) => {
// 保存EventSource引用用于中止
currentEventSource.value = eventSource;
// 清空之前的数据
difyEventData.value = {};
currentTaskId.value = null;
currentMessageId.value = null;
},
onInit: (initData: { messageId: string; conversationId: string }) => {
// 保存AI消息的数据库IDtask_id用于停止生成
currentMessageId.value = initData.messageId;
console.log('[保存MessageID(TaskID)-重新生成]', initData.messageId);
// 如果后端返回了新的messageId更新消息对象的ID
if (initData.messageId !== messageId) {
messages.value[messageIndex].id = initData.messageId;
}
},
onMessage: (chunk: string) => {
// 累加内容包括空chunk因为后端可能分块发送
if (chunk) {
aiMessageContent += chunk;
}
// 直接使用messageIndex更新消息内容
if (messageIndex !== -1) {
messages.value[messageIndex].content = aiMessageContent;
}
nextTick(() => scrollToBottom());
},
onDifyEvent: (eventType: string, eventData: any) => {
// 存储事件数据
difyEventData.value[eventType] = eventData;
// 特别处理workflow_started事件提取task_id
if (eventType === 'workflow_started' && eventData.task_id) {
currentTaskId.value = eventData.task_id;
console.log('[Task ID-重新生成]', eventData.task_id);
}
},
onMessageEnd: () => {
isGenerating.value = false;
currentEventSource.value = null;
currentTaskId.value = null;
currentMessageId.value = null;
},
onError: (error: Error) => {
console.error('重新生成失败:', error);
ElMessage.error('重新生成失败');
isGenerating.value = false;
currentEventSource.value = null;
currentTaskId.value = null;
currentMessageId.value = null;
}
}
);
} catch (error) {
console.error('重新生成失败:', error);
ElMessage.error('重新生成失败');
isGenerating.value = false;
currentEventSource.value = null;
}
}
// 评价消息
async function rateMessage(messageId: string, rating: number) {
try {
const message = messages.value.find(m => m.id === messageId);
if (!message) return;
// 如果已经评价相同,则取消评价
const newRating = message.rating === rating ? 0 : rating;
const result = await chatApi.rateMessage(messageId, newRating);
if (result.success) {
message.rating = newRating;
ElMessage.success(newRating === 0 ? '已取消评价' : '评价成功');
}
} catch (error) {
console.error('评价失败:', error);
ElMessage.error('评价失败');
}
}
// ===== 文件上传相关 =====
interface DifyFile {
id: string;
name: string;
size: number;
type: string;
transfer_method: string;
upload_file_id: string;
localFile?: File; // 保留原始File对象用于显示
sys_file_id: string;
file_path: string;
}
const uploadedFiles = ref<DifyFile[]>([]);
const fileInputRef = ref<HTMLInputElement | null>(null);
const isUploading = ref(false);
function triggerFileUpload() {
fileInputRef.value?.click();
}
async function handleFileUpload(e: Event) {
const target = e.target as HTMLInputElement;
const files = target.files;
if (!files || files.length === 0) return;
if (!agentConfig.value?.id) {
ElMessage.error('智能体未加载');
return;
}
isUploading.value = true;
try {
// 逐个上传文件到Dify
for (const file of Array.from(files)) {
try {
const result = await fileUploadApi.uploadFileForChat(file, agentConfig.value.id);
if (result.success && result.data) {
// 保存Dify返回的文件信息
uploadedFiles.value.push({
...result.data as DifyFile,
localFile: file // 保留原始文件用于显示
});
ElMessage.success(`${file.name} 上传成功`);
} else {
ElMessage.error(`${file.name} 上传失败: ${result.message}`);
}
} catch (error) {
console.error(`上传文件失败: ${file.name}`, error);
ElMessage.error(`${file.name} 上传失败`);
}
}
} finally {
isUploading.value = false;
// 清空input允许重复选择同一文件
target.value = '';
}
}
function removeUploadedFile(index: number) {
uploadedFiles.value.splice(index, 1);
}
// ===== 工具函数 =====
function formatTime(dateStr: string | undefined) {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return date.toLocaleDateString();
}
function formatMessageTime(dateStr: string | undefined) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
// 格式化文件大小
function formatFileSize(bytes: number | undefined): string {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
}
function formatMarkdown(content: string) {
// 简单的 Markdown 转换(可以使用 marked.js 等库进行更复杂的转换)
let html = content;
// 代码块
html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
// 行内代码
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// 加粗
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// 斜体
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
// 换行
html = html.replace(/\n/g, '<br>');
return html;
}
// 清理
onUnmounted(() => {
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('touchmove', onDrag);
document.removeEventListener('mouseup', stopDrag);
document.removeEventListener('touchend', stopDrag);
});
</script>
<style scoped lang="scss">
.ai-agent {
position: fixed;
z-index: 50;
&.expanded {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90vw;
height: 80vh;
}
}
/* ===== 悬浮球样式 ===== */
.ball-container {
position: fixed;
z-index: 9999;
}
.chat-ball {
width: 40px;
height: 40px;
cursor: pointer;
position: relative;
transition: transform 0.3s ease;
user-select: none;
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
.ball-icon {
width: 100%;
height: 100%;
border-radius: 50%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.unread-badge {
position: absolute;
top: 0;
right: 0;
background: #E7000B;
color: white;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
}
}
/* ===== 展开的对话界面 ===== */
.ai-agent-content {
width: 100%;
height: 100%;
background-color: #fff;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
display: flex;
overflow: hidden;
}
/* ===== 左侧对话历史 ===== */
.ai-agent-history {
width: 240px;
background: #F9FAFB;
border-right: 1px solid #E5E7EB;
display: flex;
flex-direction: column;
transition: width 0.3s ease;
&.collapsed {
width: 60px;
}
.history-header {
padding: 20px;
border-bottom: 1px solid #E5E7EB;
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #101828;
}
.collapse-btn {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
color: #6B7240;
&:hover {
color: #E7000B;
}
}
}
.history-list {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.new-chat-btn {
width: 100%;
padding: 12px;
background: #E7000B;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
margin-bottom: 12px;
&:hover {
background: #C90009;
}
}
.conversation-item {
padding: 12px;
background: white;
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
position: relative;
transition: all 0.2s;
&:hover {
background: #EFF6FF;
.delete-conv-btn {
opacity: 1;
}
}
&.active {
background: #E7000B;
color: white;
.conv-title,
.conv-time {
color: white;
}
}
.conv-title {
font-size: 14px;
font-weight: 500;
color: #101828;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-time {
font-size: 12px;
color: #6B7240;
}
.delete-conv-btn {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.1);
border: none;
border-radius: 50%;
width: 20px;
height: 20px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
&:hover {
background: rgba(0, 0, 0, 0.2);
}
}
}
.load-more {
text-align: center;
padding: 12px;
button {
background: none;
border: none;
color: #E7000B;
cursor: pointer;
font-size: 14px;
&:hover {
text-decoration: underline;
}
}
}
}
.avatar-circle {
width: 36px;
height: 36px;
border-radius: 50%;
background: #E5E7EB;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
overflow: hidden;
&.ai-avatar {
background: #EFF6FF;
}
.ai-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
/* ===== 右侧当前对话 ===== */
.ai-agent-current-chat {
flex: 1;
display: flex;
flex-direction: column;
.current-chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #E5E7EB;
.current-chat-title {
font-size: 16px;
font-weight: 600;
color: #101828;
flex: 1;
.title-input {
width: 100%;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 8px 12px;
font-size: 16px;
font-weight: 600;
&:focus {
outline: none;
border-color: #E7000B;
}
}
span {
cursor: text;
&:hover {
color: #E7000B;
}
}
}
.current-chat-action {
display: flex;
gap: 8px;
.action-btn {
width: 32px;
height: 32px;
background: none;
border: 1px solid #E5E7EB;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #F3F4F6;
}
.icon {
width: 18px;
height: 18px;
}
span {
font-size: 20px;
color: #6B7240;
}
}
}
}
.current-chat-content {
flex: 1;
overflow-y: auto;
padding: 24px;
.welcome-message {
text-align: center;
padding: 60px 20px;
.welcome-icon {
font-size: 64px;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: center;
.welcome-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
}
h2 {
font-size: 24px;
font-weight: 600;
color: #101828;
margin: 0 0 8px 0;
}
p {
font-size: 16px;
color: #6B7240;
margin: 0;
}
}
.message-item {
margin-bottom: 24px;
.message {
display: flex;
gap: 12px;
align-items: flex-start;
.message-avatar {
flex-shrink: 0;
}
.message-content {
flex: 1;
max-width: 70%;
.message-text {
padding: 12px 16px;
border-radius: 12px;
font-size: 14px;
line-height: 1.6;
word-wrap: break-word;
// 图片样式约束
:deep(img) {
max-width: 100%;
max-height: 400px;
height: auto;
border-radius: 8px;
margin: 8px 0;
display: block;
object-fit: contain;
}
:deep(code) {
background: #F3F4F6;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13px;
}
:deep(pre) {
background: #1F2937;
color: #F9FAFB;
padding: 12px;
border-radius: 8px;
overflow-x: auto;
code {
background: none;
color: inherit;
padding: 0;
}
}
}
.message-time {
font-size: 12px;
color: #9CA3AF;
margin-top: 4px;
}
.message-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
.message-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
.msg-action-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 4px;
opacity: 0.6;
&:hover {
opacity: 1;
}
&.active {
opacity: 1;
}
}
}
}
&:hover .message-actions {
opacity: 1;
}
}
}
// 用户消息靠右(头像在右侧)
.user-message {
flex-direction: row-reverse;
.message-content {
display: flex;
flex-direction: column;
align-items: flex-end;
.message-text {
background: #E7000B;
color: white;
}
}
}
// AI消息靠左头像在左侧默认布局
.ai-message {
.message-content {
.message-text {
background: #F9FAFB;
color: #101828;
}
}
}
}
.generating {
.typing-indicator {
display: flex;
gap: 4px;
padding: 12px 16px;
background: #F9FAFB;
border-radius: 12px;
width: fit-content;
span {
width: 8px;
height: 8px;
border-radius: 50%;
background: #9CA3AF;
animation: typing 1.4s infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
}
// 内联加载动画(在消息内容下方显示)
.typing-indicator-inline {
display: flex;
gap: 4px;
padding: 8px 0;
width: fit-content;
span {
width: 6px;
height: 6px;
border-radius: 50%;
background: #9CA3AF;
animation: typing 1.4s infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
}
.current-chat-input {
border-top: 1px solid #E5E7EB;
padding: 16px 24px;
.input-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
.uploaded-file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #F3F4F6;
border-radius: 6px;
font-size: 13px;
.file-name {
color: #374151;
}
.remove-file-btn {
background: none;
border: none;
cursor: pointer;
color: #9CA3AF;
font-size: 18px;
&:hover {
color: #E7000B;
}
}
}
}
.message-files {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
.message-file-item {
.file-link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 13px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
&:hover {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.4);
transform: translateX(3px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
&:active {
transform: translateX(2px);
}
.file-icon {
font-size: 18px;
flex-shrink: 0;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
}
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
letter-spacing: 0.01em;
}
.file-size {
opacity: 0.8;
font-size: 11px;
font-weight: 400;
flex-shrink: 0;
padding-left: 8px;
border-left: 1px solid rgba(255, 255, 255, 0.2);
}
}
}
}
.input-area {
display: flex;
gap: 12px;
align-items: flex-end;
.input-text {
flex: 1;
padding: 12px 16px;
border: 1px solid #E5E7EB;
border-radius: 12px;
font-size: 14px;
resize: none;
max-height: 120px;
font-family: inherit;
&:focus {
outline: none;
border-color: #E7000B;
}
&::placeholder {
color: #9CA3AF;
}
}
.input-action {
display: flex;
gap: 8px;
.action-icon-btn {
width: 40px;
height: 40px;
background: #F9FAFB;
border: 1px solid #E5E7EB;
border-radius: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover:not(:disabled) {
background: #F3F4F6;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.send-btn {
background: #E7000B;
border-color: #E7000B;
&:hover:not(:disabled) {
background: #C90009;
}
}
&.stop-btn {
background: #FEF2F2;
border-color: #FCA5A5;
.stop-icon {
font-size: 18px;
color: #DC2626;
}
&:hover {
background: #FEE2E2;
border-color: #F87171;
}
}
.link-icon,
.send-icon {
width: 20px;
height: 20px;
}
}
}
}
}
}
@keyframes typing {
0%,
60%,
100% {
transform: translateY(0);
}
30% {
transform: translateY(-8px);
}
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #D1D5DB;
border-radius: 3px;
&:hover {
background: #9CA3AF;
}
}
</style>