对话流实现 文件上传

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

@@ -47,7 +47,9 @@ export const aiAgentConfigApi = {
* @returns Promise<ResultDomain<AiAgentConfig>>
*/
async getAgentById(agentId: string): Promise<ResultDomain<AiAgentConfig>> {
const response = await api.get<AiAgentConfig>(`/ai/agent/${agentId}`);
const response = await api.get<AiAgentConfig>(`/ai/agent/${agentId}`, {
showLoading: false
});
return response.data;
},
@@ -56,7 +58,9 @@ export const aiAgentConfigApi = {
* @returns Promise<ResultDomain<AiAgentConfig[]>>
*/
async listEnabledAgents(): Promise<ResultDomain<AiAgentConfig>> {
const response = await api.get<AiAgentConfig>('/ai/agent/enabled');
const response = await api.get<AiAgentConfig>('/ai/agent/enabled', {
showLoading: false
});
return response.data;
},

View File

@@ -128,7 +128,7 @@ export const chatHistoryApi = {
*/
async getRecentConversations(limit = 10): Promise<ResultDomain<AiConversation>> {
const response = await api.get<AiConversation>('/ai/chat/history/recent', {
params: { limit }
limit
});
return response.data;
}

View File

@@ -18,114 +18,132 @@ import type {
*/
export const chatApi = {
/**
* 流式对话SSE- 使用fetch支持Authorization
* 流式对话SSE- 两步法POST准备 + GET建立SSE
* @param request 对话请求
* @param callback 流式回调
* @returns Promise<ResultDomain<AiMessage>>
*/
async streamChat(request: ChatRequest, callback?: StreamCallback): Promise<ResultDomain<AiMessage>> {
const token = localStorage.getItem('token');
const tokenData = token ? JSON.parse(token) : null;
return new Promise((resolve, reject) => {
// 使用相对路径走Vite代理避免跨域
const eventSource = new EventSource(
`/api/ai/chat/stream?` +
new URLSearchParams({
// 使用IIFE包装async逻辑避免Promise executor是async的警告
(async () => {
try {
const token = localStorage.getItem('token');
const tokenData = token ? JSON.parse(token).value : '';
// 第1步POST准备会话获取sessionId
const prepareResponse = await api.post<string>('/ai/chat/stream/prepare', {
agentId: request.agentId,
conversationId: request.conversationId || '',
query: request.query,
knowledgeIds: request.knowledgeIds?.join(',') || '',
token: tokenData?.value || ''
})
);
files: request.files || []
}, {
showLoading: false
});
// 通知外部EventSource已创建
callback?.onStart?.(eventSource);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let fullMessage = ''; // 累积完整消息内容
// 监听初始化事件包含messageId和conversationId
eventSource.addEventListener('init', (event) => {
try {
const initData = JSON.parse(event.data);
console.log('[初始化数据]', initData);
// 通知外部保存messageId用于停止生成
if (callback?.onInit) {
callback.onInit(initData);
if (!prepareResponse.data.success || !prepareResponse.data.data) {
throw new Error(prepareResponse.data.message || '准备会话失败');
}
} catch (e) {
console.warn('解析init事件失败:', event.data);
}
});
// 监听标准消息事件
eventSource.addEventListener('message', (event) => {
const data = event.data;
fullMessage += data;
callback?.onMessage?.(data);
});
const sessionId = prepareResponse.data.data;
console.log('[会话创建成功] sessionId:', sessionId);
// 监听结束事件
eventSource.addEventListener('end', (event) => {
const metadata = JSON.parse(event.data);
callback?.onMessageEnd?.(metadata);
eventSource.close();
// 第2步GET建立SSE连接
const eventSource = new EventSource(
`/api/ai/chat/stream?sessionId=${sessionId}&token=${tokenData}`
);
resolve({
code: 200,
success: true,
login: true,
auth: true,
data: metadata as AiMessage,
message: '对话成功'
});
});
// 通知外部EventSource已创建
callback?.onStart?.(eventSource);
// 监听所有Dify原始事件workflow_started, node_started等
const difyEventTypes = [
'dify_workflow_started',
'dify_node_started',
'dify_node_finished',
'dify_workflow_finished',
'dify_message',
'dify_agent_message',
'dify_message_end',
'dify_message_file',
'dify_agent_thought',
'dify_ping'
];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let fullMessage = ''; // 累积完整消息内容
difyEventTypes.forEach(eventType => {
eventSource.addEventListener(eventType, (event: any) => {
try {
const eventData = JSON.parse(event.data);
console.log(`[Dify事件] ${eventType}:`, eventData);
// 调用自定义的Dify事件回调
if (callback?.onDifyEvent) {
const cleanEventType = eventType.replace('dify_', '');
callback.onDifyEvent(cleanEventType, eventData);
// 监听初始化事件包含messageId和conversationId
eventSource.addEventListener('init', (event) => {
try {
const initData = JSON.parse(event.data);
console.log('[初始化数据]', initData);
// 通知外部保存messageId用于停止生成
if (callback?.onInit) {
callback.onInit(initData);
}
} catch (e) {
console.warn('解析init事件失败:', event.data);
}
} catch (e) {
console.warn(`解析Dify事件失败 ${eventType}:`, event.data);
}
});
});
});
// 监听错误事件
eventSource.addEventListener('error', (event: any) => {
const error = new Error(event.data || '对话失败');
callback?.onError?.(error);
eventSource.close();
reject(error);
});
// 监听标准消息事件
eventSource.addEventListener('message', (event) => {
const data = event.data;
fullMessage += data;
callback?.onMessage?.(data);
});
eventSource.onerror = (error) => {
callback?.onError?.(error as unknown as Error);
eventSource.close();
reject(error);
};
// 监听结束事件
eventSource.addEventListener('end', (event) => {
const metadata = JSON.parse(event.data);
callback?.onMessageEnd?.(metadata);
eventSource.close();
resolve({
code: 200,
success: true,
login: true,
auth: true,
data: metadata as AiMessage,
message: '对话成功'
});
});
// 监听所有Dify原始事件workflow_started, node_started等
const difyEventTypes = [
'dify_workflow_started',
'dify_node_started',
'dify_node_finished',
'dify_workflow_finished',
'dify_message',
'dify_agent_message',
'dify_message_end',
'dify_message_file',
'dify_agent_thought',
'dify_ping'
];
difyEventTypes.forEach(eventType => {
eventSource.addEventListener(eventType, (event: any) => {
try {
const eventData = JSON.parse(event.data);
// 调用自定义的Dify事件回调
if (callback?.onDifyEvent) {
const cleanEventType = eventType.replace('dify_', '');
callback.onDifyEvent(cleanEventType, eventData);
}
} catch (e) {
console.warn(`解析Dify事件失败 ${eventType}:`, event.data);
}
});
});
// 监听错误事件
eventSource.addEventListener('error', (event: any) => {
const error = new Error(event.data || '对话失败');
callback?.onError?.(error);
eventSource.close();
reject(error);
});
eventSource.onerror = (error) => {
callback?.onError?.(error as unknown as Error);
eventSource.close();
reject(error);
};
} catch (error) {
console.error('流式对话失败:', error);
callback?.onError?.(error as Error);
reject(error);
}
})(); // 立即执行IIFE
});
},
@@ -173,6 +191,8 @@ export const chatApi = {
const response = await api.post<AiConversation>('/ai/chat/conversation', {
agentId,
title
}, {
showLoading: false
});
return response.data;
},
@@ -193,7 +213,9 @@ export const chatApi = {
* @returns Promise<ResultDomain<AiConversation>>
*/
async updateConversation(conversation: AiConversation): Promise<ResultDomain<AiConversation>> {
const response = await api.put<AiConversation>('/ai/chat/conversation', conversation);
const response = await api.put<AiConversation>('/ai/chat/conversation', conversation, {
showLoading: false
});
return response.data;
},
@@ -203,7 +225,9 @@ export const chatApi = {
* @returns Promise<ResultDomain<boolean>>
*/
async deleteConversation(conversationId: string): Promise<ResultDomain<boolean>> {
const response = await api.delete<boolean>(`/ai/chat/conversation/${conversationId}`);
const response = await api.delete<boolean>(`/ai/chat/conversation/${conversationId}`, {
showLoading: false
});
return response.data;
},
@@ -225,7 +249,9 @@ export const chatApi = {
* @returns Promise<ResultDomain<AiMessage[]>>
*/
async listMessages(conversationId: string): Promise<ResultDomain<AiMessage>> {
const response = await api.get<AiMessage>(`/ai/chat/conversation/${conversationId}/messages`);
const response = await api.get<AiMessage>(`/ai/chat/conversation/${conversationId}/messages`, {
showLoading: false
});
return response.data;
},
@@ -318,9 +344,7 @@ export const chatApi = {
difyEventTypes.forEach(eventType => {
eventSource.addEventListener(eventType, (event: any) => {
try {
const eventData = JSON.parse(event.data);
console.log(`[Dify事件] ${eventType}:`, eventData);
const eventData = JSON.parse(event.data);
// 调用自定义的Dify事件回调
if (callback?.onDifyEvent) {
const cleanEventType = eventType.replace('dify_', '');
@@ -369,6 +393,8 @@ export const chatApi = {
const response = await api.post<boolean>(`/ai/chat/message/${messageId}/rate`, {
rating,
feedback
}, {
showLoading: false
});
return response.data;
}

View File

@@ -10,6 +10,24 @@ import type { AiUploadFile, ResultDomain, FileUploadResponse, PageParam } from '
* 文件上传API服务
*/
export const fileUploadApi = {
/**
* 上传文件用于对话(图文多模态)
* @param file 文件对象
* @param agentId 智能体ID
* @returns Promise<ResultDomain<Record<string, any>>> 返回Dify文件信息
*/
async uploadFileForChat(file: File, agentId: string): Promise<ResultDomain<Record<string, any>>> {
const formData = new FormData();
formData.append('file', file);
formData.append('agentId', agentId);
// 关闭加载提示,避免影响用户体验
const response = await api.post<Record<string, any>>('/ai/file/upload-for-chat', formData, {
showLoading: false
});
return response.data;
},
/**
* 上传单个文件到知识库
* @param knowledgeId 知识库ID
@@ -21,7 +39,9 @@ export const fileUploadApi = {
formData.append('file', file);
formData.append('knowledgeId', knowledgeId);
const response = await api.post<FileUploadResponse>('/ai/file/upload', formData);
const response = await api.post<FileUploadResponse>('/ai/file/upload', formData, {
showLoading: false
});
return response.data;
},
@@ -38,7 +58,9 @@ export const fileUploadApi = {
});
formData.append('knowledgeId', knowledgeId);
const response = await api.post<FileUploadResponse[]>('/ai/file/batch-upload', formData);
const response = await api.post<FileUploadResponse[]>('/ai/file/batch-upload', formData, {
showLoading: false
});
return response.data;
},
@@ -104,5 +126,17 @@ export const fileUploadApi = {
async batchSyncFileStatus(fileIds: string[]): Promise<ResultDomain<number>> {
const response = await api.post<number>('/ai/file/batch-sync', { fileIds });
return response.data;
},
/**
* 查询消息关联的文件列表
* @param messageId 消息ID
* @returns Promise<ResultDomain<AiUploadFile[]>>
*/
async listFilesByMessage(messageId: string): Promise<ResultDomain<AiUploadFile[]>> {
const response = await api.get<AiUploadFile[]>(`/ai/file/message/${messageId}`, {
showLoading: false
});
return response.data;
}
};

View File

@@ -19,6 +19,8 @@ export interface AiAgentConfig extends BaseDTO {
description?: string;
/** 系统提示词 */
systemPrompt?: string;
/** 是否连接互联网0否 1是 */
connectInternet?: number;
/** 模型名称 */
modelName?: string;
/** 模型提供商 */
@@ -73,6 +75,8 @@ export interface AiKnowledge extends BaseDTO {
export interface AiUploadFile extends BaseDTO {
/** 知识库ID */
knowledgeId?: string;
/** 系统文件ID关联tb_sys_file */
sysFileId?: string;
/** 文件名 */
fileName?: string;
/** 文件路径 */
@@ -139,6 +143,8 @@ export interface AiMessage extends BaseDTO {
content?: string;
/** 关联文件IDJSON数组 */
fileIDs?: string;
/** 关联文件列表(前端附加,用于显示文件详情) */
files?: AiUploadFile[];
/** 引用知识IDJSON数组 */
knowledgeIDs?: string;
/** 知识库引用详情JSON数组 */
@@ -185,10 +191,15 @@ export interface ChatRequest {
conversationId?: string;
/** 用户问题 */
query: string;
/** 指定的知识库ID列表可选 */
knowledgeIds?: string[];
/** 是否流式返回 */
stream?: boolean;
fileIDs?: string;
/** 上传的文件列表Dify文件信息 */
files?: Array<{
type: string;
transfer_method: string;
upload_file_id: string;
}>;
}
/**

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;