Commit d7e7c0e3 authored by 水玉婷's avatar 水玉婷
Browse files

feat:添加重连机制

parent c57963b0
<template> <template>
<div class="chat-container" :class="props?.customClass"> <div class="chat-container" :class="props?.customClass">
<!-- 聊天头部 --> <!-- 聊天头部 -->
<div class="chat-header" v-if="props.dialogSessionId || hasStartedConversation"> <div class="chat-header" v-if="props?.dialogSessionId || hasStartedConversation">
<div class="header-avatar"> <div class="header-avatar">
<img :src="props.logoUrl || defaultAvatar" alt="avatar" class="avatar-image" /> <img :src="props?.logoUrl || defaultAvatar" alt="avatar" class="avatar-image" />
</div> </div>
<div class="header-info"> <div class="header-info">
<h2>{{ props.dialogSessionId ? props?.detailData?.title || '继续对话' : '新建对话' }}</h2> <h2>{{ props?.dialogSessionId ? props?.detailData?.title || '继续对话' : '新建对话' }}</h2>
</div> </div>
</div> </div>
<!-- 当没有dialogSessionId且未开始对话时显示介绍页面 --> <!-- 当没有dialogSessionId且未开始对话时显示介绍页面 -->
<div class="chat-intro-center" v-if="!props.dialogSessionId && !hasStartedConversation"> <div class="chat-intro-center" v-if="!props?.dialogSessionId && !hasStartedConversation">
<div class="intro-content"> <div class="intro-content">
<img :src="defaultAvatar" alt="avatar" class="avatar-image" /> <img :src="defaultAvatar" alt="avatar" class="avatar-image" />
<h3>嗨,我是国械小智</h3> <h3>嗨,我是国械小智</h3>
...@@ -20,12 +20,12 @@ ...@@ -20,12 +20,12 @@
</div> </div>
<!-- 消息区域 --> <!-- 消息区域 -->
<div class="chat-messages" ref="messagesContainer" v-if="props.dialogSessionId || hasStartedConversation"> <div class="chat-messages" ref="messagesContainer" v-if="props?.dialogSessionId || hasStartedConversation">
<div v-for="(msg, index) in messages" :key="index" :class="['message', msg.messageType]"> <div v-for="(msg, index) in messages" :key="index" :class="['message', msg.messageType]">
<div class="avatar-container"> <div class="avatar-container">
<div class="avatar"> <div class="avatar">
<template v-if="msg.messageType === 'received'"> <template v-if="msg.messageType === 'received'">
<img :src="props.logoUrl || defaultAvatar" alt="avatar" class="avatar-image" /> <img :src="props?.logoUrl || defaultAvatar" alt="avatar" class="avatar-image" />
</template> </template>
<template v-else> <template v-else>
<user-outlined /> <user-outlined />
...@@ -82,9 +82,9 @@ ...@@ -82,9 +82,9 @@
<div class="chat-input"> <div class="chat-input">
<!-- 语音识别按钮 --> <!-- 语音识别按钮 -->
<VoiceRecognition ref="voiceRecognitionRef" :disabled="loading" :debug="true" <VoiceRecognition ref="voiceRecognitionRef" :disabled="loading" :debug="true"
:token="props.token" :token="props?.token"
:appCode="props.appCode" :appCode="props?.appCode"
:apiBaseUrl="props.apiBaseUrl" :apiBaseUrl="props?.apiBaseUrl"
@audio="handleVoiceAudio" @audio="handleVoiceAudio"
@error="handleVoiceError" class="voice-recognition-wrapper" /> @error="handleVoiceError" class="voice-recognition-wrapper" />
...@@ -253,6 +253,48 @@ const handleSSEMessage = (data: SSEData) => { ...@@ -253,6 +253,48 @@ const handleSSEMessage = (data: SSEData) => {
} }
}; };
// 添加网络状态模拟消息函数(带去重机制和样式区分)
const lastNetworkStatusMessage = ref<{type: string, message: string, timestamp: number} | null>(null);
const addNetworkStatusMessage = (type: 'error' | 'success', message: string) => {
const now = Date.now();
// 去重逻辑:相同类型和内容的消息在5秒内不重复显示
if (lastNetworkStatusMessage.value &&
lastNetworkStatusMessage.value.type === type &&
lastNetworkStatusMessage.value.message === message &&
now - lastNetworkStatusMessage.value.timestamp < 5000) {
console.log(`📡 ${type === 'error' ? '' : ''} 跳过重复网络状态消息:`, message);
return;
}
lastNetworkStatusMessage.value = { type, message, timestamp: now };
const statusMessage: Message = {
messageType: 'received', // 改回原来的receive类型
avatar: 'AI',
recordId: '',
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
date: dayjs().format('HH:mm'),
contentBlocks: [{
content: type === 'error' ?
templateService.generateErrorTemplate(message) :
templateService.generateSuccessTemplate(message),
thinkContent: '',
hasThinkBox: false,
thinkBoxExpanded: false,
}]
};
messages.value.push(statusMessage);
nextTick(() => {
scrollToBottom();
});
console.log(`📡 ${type === 'error' ? '' : ''} 网络状态消息:`, message);
};
// 创建SSE服务实例 // 创建SSE服务实例
const sseService = createSSEService({ const sseService = createSSEService({
apiBaseUrl: props.apiBaseUrl, apiBaseUrl: props.apiBaseUrl,
...@@ -262,17 +304,55 @@ const sseService = createSSEService({ ...@@ -262,17 +304,55 @@ const sseService = createSSEService({
}, { }, {
onMessage: handleSSEMessage, onMessage: handleSSEMessage,
onError: (error) => { onError: (error) => {
console.error('SSE error:', error); console.error('SSE连接错误:', error);
isAIResponding.value = false; isAIResponding.value = false;
isInThinkingMode.value = false; isInThinkingMode.value = false;
closeSSE();
// 关键修改:SSE连接错误时也重置AI响应状态,确保重连后重新开始对话块
currentAIResponse.value = null;
currentBlockIndex.value = -1;
// 优化:只在没有网络断开消息的情况下显示SSE错误消息
if (!lastNetworkStatusMessage.value || lastNetworkStatusMessage.value.type !== 'error') {
addNetworkStatusMessage('error', '服务连接异常,正在尝试重新连接...');
}
console.log('🔄 等待自动重连...');
// 不再手动关闭SSE,让重连逻辑自动处理
// closeSSE();
}, },
onOpen: (event) => { onOpen: (event) => {
console.log('SSE连接已建立', event); console.log('✅ SSE连接已建立', event);
// 只在真正需要时显示连接成功消息
// 避免在正常连接时也显示恢复消息
if (lastNetworkStatusMessage.value?.type === 'error') {
addNetworkStatusMessage('success', '服务连接已恢复,可以正常对话了!');
}
}, },
onReconnect: (newDialogSessionId) => { onReconnect: (newDialogSessionId) => {
console.log('SSE重连成功,新的dialogSessionId:', newDialogSessionId); console.log('🔄 SSE重连成功,新的dialogSessionId:', newDialogSessionId);
dialogSessionId.value = newDialogSessionId; dialogSessionId.value = newDialogSessionId;
// 优化:只在有错误消息的情况下显示重连成功消息
if (lastNetworkStatusMessage.value?.type === 'error') {
addNetworkStatusMessage('success', '服务重连成功,对话已恢复!');
}
},
onNetworkOffline: () => {
console.log('📡 网络断开事件触发');
// 网络断开时添加错误消息
addNetworkStatusMessage('error', '网络连接已断开,正在尝试重新连接...');
// 关键修改:网络断开时重置AI响应状态,确保网络恢复后重新开始对话块
console.log('💡 网络断开,重置AI响应状态,准备网络恢复时重新开始对话块');
isAIResponding.value = false;
isInThinkingMode.value = false;
currentAIResponse.value = null;
currentBlockIndex.value = -1;
},
onNetworkOnline: () => {
console.log('🌐 网络恢复事件触发');
// 优化:网络恢复事件只记录日志,不显示消息,避免与重连成功消息重复
// 真正的恢复消息由onReconnect处理
} }
}); });
...@@ -447,6 +527,7 @@ const closeSSE = () => { ...@@ -447,6 +527,7 @@ const closeSSE = () => {
// 初始化SSE连接 // 初始化SSE连接
const initSSE = () => { const initSSE = () => {
console.log('🔗 初始化SSE连接,当前会话ID:', dialogSessionId.value || '');
sseService.initSSE(dialogSessionId.value); sseService.initSSE(dialogSessionId.value);
}; };
...@@ -552,6 +633,13 @@ defineExpose({ ...@@ -552,6 +633,13 @@ defineExpose({
// 生命周期 // 生命周期
onMounted(() => { onMounted(() => {
console.log('组件挂载,初始 dialogSessionId:', props.dialogSessionId); console.log('组件挂载,初始 dialogSessionId:', props.dialogSessionId);
// 添加防重复初始化检查
if (hasStartedConversation.value) {
console.log('⚠️ 检测到重复初始化,跳过SSE初始化');
return;
}
initSSE(); initSSE();
scrollToBottom(); scrollToBottom();
if (props.dialogSessionId) { if (props.dialogSessionId) {
......
...@@ -19,7 +19,6 @@ ...@@ -19,7 +19,6 @@
@gray-6: #666666; @gray-6: #666666;
@gray-7: #333333; @gray-7: #333333;
@success-color: #52c41a; @success-color: #52c41a;
@success-hover: #46a51a; // 添加缺失的变量定义
@error-color: #f5222d; @error-color: #f5222d;
@warning-color: #faad14; @warning-color: #faad14;
...@@ -300,6 +299,14 @@ li { ...@@ -300,6 +299,14 @@ li {
font-size: 14px; font-size: 14px;
} }
// 成功消息样式
:deep(.message-success) {
line-height: 1.5;
color: @success-color;
white-space: pre-wrap;
font-size: 14px;
}
// 思考消息样式 // 思考消息样式
:deep(.think-message) { :deep(.think-message) {
color: @gray-5; color: @gray-5;
......
...@@ -12,6 +12,7 @@ import { markdownTemplate, isLastBlockMarkdown, getLastMarkdownBlockIndex, merge ...@@ -12,6 +12,7 @@ import { markdownTemplate, isLastBlockMarkdown, getLastMarkdownBlockIndex, merge
option: (optionData: any) => string; option: (optionData: any) => string;
iframe: (iframeData: any) => string; iframe: (iframeData: any) => string;
tips: (content: string) => string; tips: (content: string) => string;
success: (content: string) => string;
} }
// 消息块类型定义 // 消息块类型定义
...@@ -133,6 +134,11 @@ export class ContentTemplateService { ...@@ -133,6 +134,11 @@ export class ContentTemplateService {
return `<div class="message-error">${content}</div>`; return `<div class="message-error">${content}</div>`;
}, },
// 成功信息
success: (content: string) => {
return `<div class="message-success">${content}</div>`;
},
// 表格模板 - 使用独立的表格模板工具 // 表格模板 - 使用独立的表格模板工具
table: (tableData: any) => { table: (tableData: any) => {
return tableTemplate(tableData); return tableTemplate(tableData);
...@@ -224,6 +230,10 @@ export class ContentTemplateService { ...@@ -224,6 +230,10 @@ export class ContentTemplateService {
return this.templates.error(content); return this.templates.error(content);
} }
public generateSuccessTemplate(content: string): string {
return this.templates.success(content);
}
// 处理SSE消息的核心方法 // 处理SSE消息的核心方法
......
...@@ -25,6 +25,8 @@ export interface SSEHandlers { ...@@ -25,6 +25,8 @@ export interface SSEHandlers {
onError?: (error: any) => void; onError?: (error: any) => void;
onOpen?: (event: any) => void; onOpen?: (event: any) => void;
onReconnect?: (newDialogSessionId: string) => void; onReconnect?: (newDialogSessionId: string) => void;
onNetworkOffline?: () => void; // 网络断开事件
onNetworkOnline?: () => void; // 网络恢复事件
} }
// SSE服务类 - 专注于SSE连接管理 // SSE服务类 - 专注于SSE连接管理
...@@ -34,15 +36,37 @@ export class SSEService { ...@@ -34,15 +36,37 @@ export class SSEService {
private handlers: SSEHandlers; private handlers: SSEHandlers;
private isReconnecting: Ref<boolean> = ref(false); private isReconnecting: Ref<boolean> = ref(false);
private timeArr: NodeJS.Timeout[] = []; private timeArr: NodeJS.Timeout[] = [];
private reconnectAttempts: number = 0;
private maxReconnectAttempts: number = 5;
private currentDialogSessionId: string = '';
private connectionMonitorInterval: NodeJS.Timeout | null = null;
// 心跳检测配置
private readonly HEARTBEAT_INTERVAL = 30000; // 30秒检测一次
private readonly RECONNECT_DELAY_BASE = 3000; // 3秒基础重连延迟
private readonly MAX_RECONNECT_DELAY = 60000; // 最大重连延迟60秒
private readonly MAX_RECONNECT_ATTEMPTS = 5; // 最大重连次数
constructor(config: SSEServiceConfig, handlers: SSEHandlers = {}) { constructor(config: SSEServiceConfig, handlers: SSEHandlers = {}) {
this.config = config; this.config = config;
this.handlers = handlers; this.handlers = handlers;
// 监听网络状态变化
if (typeof window !== 'undefined') {
window.addEventListener('online', this.handleNetworkOnline.bind(this));
window.addEventListener('offline', this.handleNetworkOffline.bind(this));
}
} }
// 初始化SSE连接 // 初始化SSE连接
public initSSE(dialogSessionId: string): void { public initSSE(dialogSessionId: string): void {
try { try {
this.currentDialogSessionId = dialogSessionId;
this.reconnectAttempts = 0; // 重置重连次数
console.log('🔗 开始建立SSE连接...', '会话ID:', dialogSessionId || '');
// 即使会话ID为空,也尝试建立连接
// 服务器可能会返回新的会话ID,或者允许空会话ID的连接
const url = `${this.config.apiBaseUrl}/aiService/sse/join/${this.config.params?.stage || ''}?app-id=${this.config.params?.appId || ''}&dialog-session-id=${dialogSessionId || ''}`; const url = `${this.config.apiBaseUrl}/aiService/sse/join/${this.config.params?.stage || ''}?app-id=${this.config.params?.appId || ''}&dialog-session-id=${dialogSessionId || ''}`;
this.eventSource = new EventSourcePolyfill(url, { this.eventSource = new EventSourcePolyfill(url, {
headers: { headers: {
...@@ -52,10 +76,18 @@ export class SSEService { ...@@ -52,10 +76,18 @@ export class SSEService {
}, },
withCredentials: true, withCredentials: true,
connectionTimeout: 60000, connectionTimeout: 60000,
heartbeatTimeout: 45000, // 心跳超时时间
}); });
this.eventSource.onopen = (event) => { this.eventSource.onopen = (event) => {
// 移除这里的日志,只在外部处理器中打印 this.reconnectAttempts = 0; // 连接成功时重置重连次数
this.isReconnecting.value = false;
console.log('✅ SSE连接已建立');
// 启动心跳检测
this.startHeartbeatCheck();
if (this.handlers.onOpen) { if (this.handlers.onOpen) {
this.handlers.onOpen(event); this.handlers.onOpen(event);
} }
...@@ -65,6 +97,9 @@ export class SSEService { ...@@ -65,6 +97,9 @@ export class SSEService {
try { try {
const data: SSEData = JSON.parse(event.data); const data: SSEData = JSON.parse(event.data);
// 重置重连次数,因为收到了有效消息
this.reconnectAttempts = 0;
// 只传递原始数据,模板处理在外部进行 // 只传递原始数据,模板处理在外部进行
if (this.handlers.onMessage) { if (this.handlers.onMessage) {
this.handlers.onMessage(data); this.handlers.onMessage(data);
...@@ -75,20 +110,24 @@ export class SSEService { ...@@ -75,20 +110,24 @@ export class SSEService {
}); });
this.eventSource.onerror = (error) => { this.eventSource.onerror = (error) => {
console.error('SSE error:', error); console.error('SSE连接错误:', error);
if (this.handlers.onError) { if (this.handlers.onError) {
this.handlers.onError(error); this.handlers.onError(error);
} }
this.closeSSE(); this.closeSSE();
// 添加错误重连逻辑 // 简单重连逻辑
if (!this.isReconnecting.value) { if (!this.isReconnecting.value && this.reconnectAttempts < this.MAX_RECONNECT_ATTEMPTS) {
this.reconnectAttempts++;
const delay = this.calculateReconnectDelay();
console.log(`🔄 SSE连接断开,将在 ${delay}ms 后尝试第 ${this.reconnectAttempts} 次重连`);
setTimeout(() => { setTimeout(() => {
if (dialogSessionId) { if (this.currentDialogSessionId) {
this.reconnectSSE(dialogSessionId); this.reconnectSSE(this.currentDialogSessionId);
} }
}, 3000); }, delay);
} }
}; };
...@@ -100,20 +139,32 @@ export class SSEService { ...@@ -100,20 +139,32 @@ export class SSEService {
// 重新连接SSE // 重新连接SSE
public reconnectSSE(newDialogSessionId: string): void { public reconnectSSE(newDialogSessionId: string): void {
if (this.isReconnecting.value) { if (this.isReconnecting.value) {
console.log('正在重连中,跳过重复重连'); console.log('正在重连中,跳过重复重连');
return; return;
} }
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('⛔ 重连次数已达上限,停止重连');
return;
}
this.isReconnecting.value = true; this.isReconnecting.value = true;
console.log('开始重连SSE,新的dialogSessionId:', newDialogSessionId); console.log(`🔄 开始重连SSE(第${this.reconnectAttempts}次)`, '会话ID:', newDialogSessionId || '');
this.closeSSE(); // 只关闭SSE连接,但不停止心跳检测
if (this.eventSource) {
try {
this.eventSource.close();
this.eventSource = null;
} catch (err) {
console.warn('关闭SSE连接时出错:', err);
}
}
const reconnectTimeout = setTimeout(() => { const reconnectTimeout = setTimeout(() => {
this.initSSE(newDialogSessionId); this.initSSE(newDialogSessionId);
setTimeout(() => { // 重连状态会在连接成功后自动重置
this.isReconnecting.value = false; }, 100);
}, 2000);
}, 500);
this.timeArr.push(reconnectTimeout); this.timeArr.push(reconnectTimeout);
...@@ -133,11 +184,17 @@ export class SSEService { ...@@ -133,11 +184,17 @@ export class SSEService {
} }
} }
// 停止心跳检测
this.stopHeartbeatCheck();
// 清理所有定时器 // 清理所有定时器
this.timeArr.forEach(timeout => { this.timeArr.forEach(timeout => {
clearTimeout(timeout); clearTimeout(timeout);
}); });
this.timeArr = []; this.timeArr = [];
// 重置重连状态
this.isReconnecting.value = false;
} }
// 获取重连状态 // 获取重连状态
...@@ -153,6 +210,136 @@ export class SSEService { ...@@ -153,6 +210,136 @@ export class SSEService {
return this.eventSource.readyState; return this.eventSource.readyState;
} }
// 计算重连延迟(指数退避策略)
private calculateReconnectDelay(): number {
// 指数退避:3s, 6s, 12s, 24s, 48s
return Math.min(this.RECONNECT_DELAY_BASE * Math.pow(2, this.reconnectAttempts - 1), this.MAX_RECONNECT_DELAY);
}
// 处理网络恢复
private handleNetworkOnline(): void {
console.log('🌐 网络已恢复,检查SSE连接状态');
console.log('📊 当前状态 - 重连中:', this.isReconnecting.value, '会话ID:', this.currentDialogSessionId, '重连次数:', this.reconnectAttempts);
// 检查当前连接状态
const isConnected = this.eventSource && this.eventSource.readyState === 1;
if (isConnected) {
console.log('✅ 网络恢复但SSE连接正常,无需重连');
// 即使连接正常,也触发网络恢复回调,但只在真正需要时显示消息
if (this.handlers.onNetworkOnline) {
this.handlers.onNetworkOnline();
}
return;
}
// 如果不在重连中且连接异常,尝试重新连接
if (!this.isReconnecting.value) {
// 无论之前是否有会话ID,都重新创建连接
console.log('🔄 网络恢复,创建新SSE会话');
this.reconnectSSE('');
} else {
console.log('⚠️ 网络恢复但未触发重连 - 重连中:', this.isReconnecting.value, '会话ID存在:', !!this.currentDialogSessionId);
console.log('💡 等待重连完成或心跳检测处理连接状态');
}
// 触发网络恢复回调
if (this.handlers.onNetworkOnline) {
this.handlers.onNetworkOnline();
}
}
// 处理网络断开
private handleNetworkOffline(): void {
console.log('🌐 网络已断开,关闭SSE连接');
// 保存当前的会话ID,保持会话连续性
console.log('💾 当前会话ID:', this.currentDialogSessionId || '');
// 关闭SSE连接
if (this.eventSource) {
try {
this.eventSource.close();
this.eventSource = null;
} catch (err) {
console.warn('关闭SSE连接时出错:', err);
}
}
// 停止心跳检测
this.stopHeartbeatCheck();
// 重置重连状态和重连次数
this.isReconnecting.value = false;
this.reconnectAttempts = 0;
// 关键修改:保持会话ID不变,但标记网络断开状态
// 这样网络恢复时可以继续使用同一个会话,但需要重新开始对话块
console.log('💡 网络断开,保持会话ID,准备网络恢复时重新开始对话块');
// 网络断开时启动心跳检测,以便网络恢复时能立即发现
this.startHeartbeatCheck();
// 触发网络断开回调
if (this.handlers.onNetworkOffline) {
this.handlers.onNetworkOffline();
}
}
// 启动心跳检测
private startHeartbeatCheck(): void {
// 停止之前的心跳检测
this.stopHeartbeatCheck();
console.log(`💓 心跳检测已启动(${this.HEARTBEAT_INTERVAL/1000}秒间隔)`);
// 使用固定间隔开始检测
this.connectionMonitorInterval = setInterval(() => {
this.performHeartbeatCheck();
}, this.HEARTBEAT_INTERVAL);
}
// 执行心跳检测
private performHeartbeatCheck(): void {
// 检查网络状态
const isOnline = typeof window !== 'undefined' && window.navigator.onLine;
if (!isOnline) {
console.log('💓 SSE心跳检测 - 网络已断开,等待网络恢复');
return;
}
if (this.eventSource && this.eventSource.readyState === 1) {
// 连接正常,记录心跳
console.log('💓 SSE心跳检测正常 - 连接状态:', this.eventSource.readyState);
} else {
console.log('💓 SSE心跳检测 - 连接状态:', this.eventSource ? this.eventSource.readyState : '未连接');
// 如果未连接或连接异常,但网络正常,尝试重连
if (!this.isReconnecting.value) {
if (this.currentDialogSessionId) {
console.log('💔 检测到SSE连接异常,尝试重连');
this.reconnectSSE(this.currentDialogSessionId);
} else {
console.log('💡 检测到网络已恢复但无会话ID,等待用户交互或网络恢复处理');
// 不再自动触发重连,避免重复消息
// 网络恢复事件会由网络状态监听器自动处理
}
}
}
}
// 停止心跳检测
private stopHeartbeatCheck(): void {
if (this.connectionMonitorInterval) {
clearInterval(this.connectionMonitorInterval);
this.connectionMonitorInterval = null;
console.log('💓 基础心跳检测已停止');
}
}
// 清理资源 // 清理资源
public destroy(): void { public destroy(): void {
this.closeSSE(); this.closeSSE();
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment