对话流实现 文件上传
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user