对话流实现 文件上传

This commit is contained in:
2025-11-06 16:43:28 +08:00
parent d9d62e22de
commit 0bb4853d54
35 changed files with 1748 additions and 575 deletions

View File

@@ -55,6 +55,21 @@
show-word-limit
/>
</el-form-item>
<el-form-item label="联网功能">
<div class="internet-switch-container">
<el-switch
v-model="internetEnabled"
active-text="启用联网"
inactive-text="关闭联网"
:active-value="1"
:inactive-value="0"
/>
<div class="internet-description">
启用后AI助手可以访问互联网获取实时信息
</div>
</div>
</el-form-item>
</div>
<!-- 操作按钮 -->
@@ -90,11 +105,20 @@ const configForm = ref<AiAgentConfig>({
name: '',
avatar: '',
systemPrompt: '',
connectInternet: 0,
modelName: '',
modelProvider: 'dify',
status: 1
});
// 联网开关(用于双向绑定)
const internetEnabled = computed({
get: () => configForm.value.connectInternet || 0,
set: (val) => {
configForm.value.connectInternet = val;
}
});
// 状态
const saving = ref(false);
const loading = ref(false);
@@ -119,9 +143,9 @@ async function loadConfig() {
loading.value = true;
// 获取启用的智能体列表
const result = await aiAgentConfigApi.listEnabledAgents();
if (result.success && result.data && result.data.length > 0) {
if (result.success && result.dataList && result.dataList.length > 0) {
// 使用第一个启用的智能体
Object.assign(configForm.value, result.data[0]);
Object.assign(configForm.value, result.dataList[0]);
}
} catch (error) {
console.error('加载配置失败:', error);
@@ -391,12 +415,33 @@ async function handleReset() {
}
}
.internet-switch-container {
display: flex;
flex-direction: column;
gap: 8px;
.internet-description {
font-size: 13px;
color: #6B7240;
line-height: 1.5;
}
}
:deep(.el-switch) {
--el-switch-on-color: #E7000B;
.el-switch__label {
font-size: 14px;
color: #0A0A0A;
font-weight: 500;
}
&.is-checked .el-switch__label--left {
color: #6B7240;
}
&:not(.is-checked) .el-switch__label--right {
color: #6B7240;
}
}
</style>

View File

@@ -21,7 +21,7 @@
<div v-if="!historyCollapsed" class="history-list">
<!-- 新建对话按钮 -->
<button class="new-chat-btn" @click="() => createNewConversation()">
<button class="new-chat-btn" @click="prepareNewConversation">
+ 新建对话
</button>
@@ -83,6 +83,19 @@
</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>
@@ -96,53 +109,36 @@
</div>
</div>
<div class="message-content">
<!-- 如果内容为空且正在生成显示加载动画 -->
<div v-if="!message.content && isGenerating" class="typing-indicator">
<!-- 显示消息内容 -->
<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>
<!-- 否则显示实际内容 -->
<template v-else>
<div class="message-text" v-html="formatMarkdown(message.content || '')"></div>
<div 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 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>
</template>
</div>
</div>
</div>
<!-- 加载中提示只在还没有AI消息时显示 -->
<div v-if="isGenerating && (!messages.length || messages[messages.length - 1]?.role !== 'assistant')"
class="message ai-message generating">
<div class="message-avatar">
<div class="avatar-circle ai-avatar">
<img v-if="agentAvatarUrl" :src="agentAvatarUrl" alt="AI助手" class="ai-avatar-img" />
<img v-else src="@/assets/imgs/assistant.svg" alt="AI助手" class="ai-avatar-img" />
</div>
</div>
<div class="message-content">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
@@ -191,8 +187,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { chatApi, chatHistoryApi, aiAgentConfigApi } from '@/apis/ai';
import type { AiConversation, AiMessage, AiAgentConfig } from '@/types/ai';
import { chatApi, chatHistoryApi, aiAgentConfigApi, fileUploadApi } from '@/apis/ai';
import type { AiConversation, AiMessage, AiAgentConfig, AiUploadFile } from '@/types/ai';
interface AIAgentProps {
agentId?: string;
@@ -480,21 +476,27 @@ async function loadMoreConversations() {
// 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);
messages.value = [];
if (!title) {
ElMessage.success('已创建新对话');
}
return result.data;
}
} catch (error) {
console.error('创建对话失败:', error);
ElMessage.error('创建对话失败');
return null;
}
}
@@ -594,6 +596,9 @@ 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 {
@@ -602,6 +607,14 @@ async function loadMessages(conversationId: string) {
// 后端返回List所以数据在dataList字段
const messageList = result.dataList || result.data || [];
messages.value = Array.isArray(messageList) ? messageList : [];
// 加载每条用户消息的关联文件
for (const message of messages.value) {
if (message.role === 'user' && message.id) {
await loadMessageFiles(message.id);
}
}
await nextTick();
scrollToBottom();
}
@@ -610,6 +623,24 @@ async function loadMessages(conversationId: string) {
}
}
// 加载消息关联的文件列表
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;
@@ -620,10 +651,13 @@ async function sendMessage() {
// 如果没有当前对话,创建新对话,使用第一个问题作为标题
const isFirstMessage = !currentConversation.value;
if (isFirstMessage) {
// 限制标题长度为50字符
const title = message.length > 50 ? message.substring(0, 50) + '...' : message;
await createNewConversation(title);
if (!currentConversation.value) return;
// 限制标题长度为20字符
const title = message.length > 20 ? message.substring(0, 20) + '...' : message;
const newConv = await createNewConversation(title);
if (!newConv) {
ElMessage.error('创建对话失败');
return;
}
}
// 添加用户消息到界面
@@ -648,11 +682,20 @@ async function sendMessage() {
try {
let aiMessageContent = '';
await chatApi.streamChat({
chatApi.streamChat({
agentId: agentConfig.value!.id!,
conversationId: currentConversation.value?.id || '',
query: message,
knowledgeIds: []
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) => {
@@ -681,7 +724,6 @@ async function sendMessage() {
updateTime: new Date().toISOString()
});
}
// 累加内容包括空chunk因为后端可能分块发送
if (chunk) {
aiMessageContent += chunk;
@@ -697,7 +739,7 @@ async function sendMessage() {
},
onDifyEvent: (eventType: string, eventData: any) => {
// 处理Dify原始事件包含完整信息
console.log(`[Dify事件] ${eventType}:`, eventData);
// 存储事件数据
difyEventData.value[eventType] = eventData;
@@ -712,17 +754,12 @@ async function sendMessage() {
// 例如node_started, node_finished, agent_thought等
},
onMessageEnd: () => {
// 消息结束,如果是第一条消息,更新会话列表中的标题
if (isFirstMessage && currentConversation.value) {
const convIndex = conversations.value.findIndex(c => c.id === currentConversation.value!.id);
if (convIndex !== -1) {
conversations.value[convIndex].title = currentConversation.value.title;
}
}
isGenerating.value = false;
currentEventSource.value = null;
currentTaskId.value = null;
currentMessageId.value = null;
// 清空已上传的文件列表(文件仅对一次消息发送生效)
uploadedFiles.value = [];
},
onError: (error: Error) => {
console.error('对话失败:', error);
@@ -731,14 +768,27 @@ async function sendMessage() {
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 = [];
}
}
@@ -866,8 +916,6 @@ async function regenerateMessage(messageId: string) {
nextTick(() => scrollToBottom());
},
onDifyEvent: (eventType: string, eventData: any) => {
// 处理Dify原始事件包含完整信息
console.log(`[Dify事件-重新生成] ${eventType}:`, eventData);
// 存储事件数据
difyEventData.value[eventType] = eventData;
@@ -923,24 +971,64 @@ async function rateMessage(messageId: string, rating: number) {
}
// ===== 文件上传相关 =====
const uploadedFiles = ref<File[]>([]);
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();
}
function handleFileUpload(e: Event) {
async function handleFileUpload(e: Event) {
const target = e.target as HTMLInputElement;
const files = target.files;
if (files && files.length > 0) {
uploadedFiles.value.push(...Array.from(files));
ElMessage.success(`已添加 ${files.length} 个文件`);
if (!files || files.length === 0) return;
if (!agentConfig.value?.id) {
ElMessage.error('智能体未加载');
return;
}
// 清空input允许重复选择同一文件
target.value = '';
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) {
@@ -974,6 +1062,15 @@ function formatMessageTime(dateStr: string | undefined) {
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;
@@ -1507,6 +1604,30 @@ onUnmounted(() => {
}
}
}
// 内联加载动画(在消息内容下方显示)
.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 {
@@ -1546,6 +1667,49 @@ onUnmounted(() => {
}
}
.message-files {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
.message-file-item {
.file-link {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
text-decoration: none;
color: inherit;
transition: all 0.2s;
font-size: 13px;
&:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateX(2px);
}
.file-icon {
font-size: 16px;
}
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
opacity: 0.7;
font-size: 12px;
}
}
}
}
.input-area {
display: flex;
gap: 12px;