fix(llm): 前端续连

This commit is contained in:
PZ688 2025-06-24 15:25:28 +08:00
parent 7ae8e5e00f
commit 81bfdb1860
22 changed files with 127 additions and 114 deletions

View File

@ -25,7 +25,6 @@ public class LLMChatCoreService : ITransient
private readonly SqlSugarRepository<LLMChatSummaryHistory> _chatSummaryHistoryService;
private readonly IOptions<LLMOptions> _llmOption;
private readonly UserManager _userManager;
private Kernel _kernel;
private readonly SysCacheService _sysCacheService;
public LLMChatCoreService(ILogger<LLMChatCoreService> logger,

View File

@ -46,7 +46,7 @@ public class SseService : ControllerBase
await Response.WriteAsync($"event: ping\n");
await Response.WriteAsync($"data: pong\n\n");
await Response.Body.FlushAsync(cancellationToken);
await Task.Delay(5000, cancellationToken);
await Task.Delay(3000, cancellationToken);
}
});
_ = Task.Run(async () =>

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: 'Denken abgeschlossen',
thinkingFailed: 'Denken fehlgeschlagen',
thinkingPrepare: 'Denken vorbereiten...',
backEndError: 'Fehler beim Verbinden mit dem Backend-Service, bitte überprüfen Sie, ob das Backend normal ist',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: 'Thinking Done',
thinkingFailed: 'Thinking Failed',
thinkingPrepare: 'Thinking Prepare...',
backEndError: 'Failed to connect to the backend service, please check if the backend is normal',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: 'Pensamiento completado',
thinkingFailed: 'Pensamiento fallido',
thinkingPrepare: 'Pensando para preparar...',
backEndError: 'Error al conectar con el servicio backend, por favor verifique si el backend está funcionando',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: 'Keskustelu valmis',
thinkingFailed: 'Keskustelu epäonnistui',
thinkingPrepare: 'Keskustelu valmistelemassa...',
backEndError: 'Virhe yhdistämässä backend-palvelua, tarkista, onko backend ollut kunnossa',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: 'Pensée terminée',
thinkingFailed: 'Pensée échouée',
thinkingPrepare: 'Pensée en cours de préparation...',
backEndError: 'Erreur lors de la connexion au service backend, veuillez vérifier si le backend est normal',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: 'Berpikir selesai',
thinkingFailed: 'Berpikir gagal',
thinkingPrepare: 'Mempersiapkan berpikir...',
backEndError: 'Gagal menghubungkan ke layanan backend, silakan periksa apakah backend berfungsi',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: 'Pensamento completato',
thinkingFailed: 'Pensamento fallito',
thinkingPrepare: 'Pensando per prepararsi...',
backEndError: 'Errore nel collegarsi al servizio backend, controlla se il backend è normale',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: '思考が完了しました',
thinkingFailed: '思考に失敗しました',
thinkingPrepare: '思考を準備中...',
backEndError: 'バックエンドサービスへの接続に失敗しました。バックエンドが正常に動作していることを確認してください',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: '생각 완료',
thinkingFailed: '생각 실패',
thinkingPrepare: '생각 준비 중...',
backEndError: '백엔드 서비스에 연결하는 데 실패했습니다. 백엔드가 정상적으로 작동하는지 확인해주세요.',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: 'Berpikir selesai',
thinkingFailed: 'Berpikir gagal',
thinkingPrepare: 'Mempersiapkan berpikir...',
backEndError: 'Gagal menghubungkan ke layanan backend, silakan periksa apakah backend berfungsi',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: 'Tenking ferdig',
thinkingFailed: 'Tenking feilet',
thinkingPrepare: 'Tenking forbereder...',
backEndError: 'Feil ved tilkobling til backend-tjenesten, sjekk om backend-tjenesten er normal',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: 'Myślenie zakończone',
thinkingFailed: 'Myślenie nie powiodło się',
thinkingPrepare: 'Rozpoczynam myślenie...',
backEndError: 'Błąd podczas łączenia z backendem, sprawdź, czy backend działa poprawnie',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: 'Pensamento concluído',
thinkingFailed: 'Pensamento falhou',
thinkingPrepare: 'Pensando para preparar...',
backEndError: 'Erro ao conectar com o backend, por favor verifique se o backend está funcionando',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: 'Мышление завершено',
thinkingFailed: 'Мышление не удалось',
thinkingPrepare: 'Мышление для подготовки...',
backEndError: 'Ошибка при подключении к сервису, пожалуйста, проверьте, настроен ли сервис',
},
};

View File

@ -25,6 +25,7 @@ export default {
thinkingDone: 'คิดลึก',
thinkingFailed: 'คิดลึก',
thinkingPrepare: 'คิดลึก',
backEndError: 'ลบัพของระบบไม่สามารถดึงข้อมูลระบบได้ กรุณาตรวจสอบอีกครั้ง',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: 'Suy nghĩ đã hoàn tất',
thinkingFailed: 'Suy nghĩ thất bại',
thinkingPrepare: 'Đang suy nghĩ làm bài tập...',
backEndError: 'Lỗi khi kết nối với máy chủ, vui lòng kiểm tra xem máy chủ có hoạt động không',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: '思考完成',
thinkingFailed: '思考失败',
thinkingPrepare: '正在思考做准备...',
backEndError: '连接后端服务有错误,请检查后端是否正常',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: '思考完成',
thinkingFailed: '思考失敗',
thinkingPrepare: '正在思考做準備...',
backEndError: '連接後端服務有錯誤,請檢查後端是否正常',
},
};

View File

@ -25,5 +25,6 @@ export default {
thinkingDone: '思考完成',
thinkingFailed: '思考失敗',
thinkingPrepare: '正在思考做準備...',
backEndError: '連接後端服務有錯誤,請檢查後端是否正常',
},
};

View File

@ -2,7 +2,8 @@
<div style="flex: 1; background: #f3f4f6; overflow: auto">
<el-container>
<!-- 侧边栏 -->
<el-aside v-show="!isFold" :class="isFold ? 'sidebar-fold' : 'expand-sidebar'" style="background: #f3f4f6; border-right: 1px solid #f0f0f0; display: flex; flex-direction: column">
<el-aside v-show="!isFold" :class="isFold ? 'sidebar-fold' : 'expand-sidebar'"
style="background: #f3f4f6; border-right: 1px solid #f0f0f0; display: flex; flex-direction: column">
<div class="chat-action">
<el-tooltip :content="$t('message.chat.newChat')" placement="top">
<div class="chat-action-item" @click.stop="handleNewChat">
@ -15,28 +16,23 @@
</div>
</el-tooltip>
</div>
<div style="display: flex; flex-direction: column; align-items: center; padding-bottom: 8px; padding-top: 0px; margin-top: 0px">
<el-avatar :size="60" style="background-color: #f3f4f6" src="/chat.png" fit="fill" class="avatar-with-shadow" />
<div style="margin-top: 10px; font-weight: bold; font-size: 18px; color: #333">{{ $t('message.chat.title') }}</div>
<div
style="display: flex; flex-direction: column; align-items: center; padding-bottom: 8px; padding-top: 0px; margin-top: 0px">
<el-avatar :size="60" style="background-color: #f3f4f6" src="/chat.png" fit="fill"
class="avatar-with-shadow" />
<div style="margin-top: 10px; font-weight: bold; font-size: 18px; color: #333">{{
$t('message.chat.title') }}</div>
</div>
<div style="flex: 1; overflow-y: auto; width: 100%">
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%">
<Conversations
style="border-radius: 0%; width: 255px"
v-model:active="activeHistoryKey"
:items="sideBarHistoryList"
row-key="key"
:show-tooltip="true"
showToTopBtn
:label-max-width="210"
:load-more="loadMoreHistoryItems"
:load-more-loading="isHistoryListLoading"
showBuiltInMenu
@change="handleChange"
>
<Conversations style="border-radius: 0%; width: 255px" v-model:active="activeHistoryKey"
:items="sideBarHistoryList" row-key="key" :show-tooltip="true" showToTopBtn
:label-max-width="210" :load-more="loadMoreHistoryItems"
:load-more-loading="isHistoryListLoading" showBuiltInMenu @change="handleChange">
<template #menu="{ item }">
<div class="menu-buttons">
<el-button v-for="menuItem in actionMenuItems" :key="menuItem.key" link size="default" @click="handleMenuClick(menuItem.key, item)">
<el-button v-for="menuItem in actionMenuItems" :key="menuItem.key" link
size="default" @click="handleMenuClick(menuItem.key, item)">
<el-icon v-if="menuItem.icon">
<component :is="menuItem.icon" />
</el-icon>
@ -51,8 +47,7 @@
<!-- 主体内容 -->
<el-container>
<el-main
style="
<el-main style="
flex: 1;
width: 100%;
height: 100%;
@ -65,12 +60,12 @@
align-items: center;
justify-content: flex-start;
background: #fff;
"
>
">
<div class="main_action_toolbar" style="position: absolute; top: 0px; width: 500px; left: 20px">
<div v-if="isFold" class="chat-action-item main_action_item">
<el-tooltip :content="$t('message.chat.expandChat')" placement="top">
<Expand style="width: 1.5em; height: 1.5em; color: #333" @click.stop="handleExpandChat" />
<Expand style="width: 1.5em; height: 1.5em; color: #333"
@click.stop="handleExpandChat" />
</el-tooltip>
</div>
<div v-if="isFold" class="chat-action-item main_action_item" @click="handleNewChat">
@ -88,10 +83,12 @@
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="item in modellList.models" :key="item.modelName" @click="handleChangeModel(item)">
<el-dropdown-item v-for="item in modellList.models" :key="item.modelName"
@click="handleChangeModel(item)">
<div class="model-item">
<span>{{ item.providerName }}/{{ item.modelName }}</span>
<el-icon v-if="item.modelName == modellList.currentModel" style="color: #409eff">
<el-icon v-if="item.modelName == modellList.currentModel"
style="color: #409eff">
<Select />
</el-icon>
</div>
@ -102,21 +99,33 @@
</div>
</div>
<div v-if="isNew" class="new_chat_title">
<div style="margin-top: 60px; font-size: 36px; font-weight: bold; letter-spacing: 2px">Hello, {{ userName }}</div>
<div style="margin-top: 20px; width: 100%; text-align: center; color: #999; font-size: 16px; font-weight: bold; letter-spacing: 2px">
<div style="margin-top: 60px; font-size: 36px; font-weight: bold; letter-spacing: 2px">Hello, {{
userName }}
</div>
<div
style="margin-top: 20px; width: 100%; text-align: center; color: #999; font-size: 16px; font-weight: bold; letter-spacing: 2px">
{{ $t('message.chat.subTitle') }}
</div>
</div>
<div v-else class="chat_content">
<BubbleList class="chat_content_list" ref="chatRef" :list="chatList" maxHeight="100%" style="padding: 0 60px 100px 60px" @complete="handleBubbleComplete" :triggerIndices="triggerIndices">
<BubbleList class="chat_content_list" ref="chatRef" :list="chatList" maxHeight="100%"
style="padding: 0 60px 100px 60px" @complete="handleBubbleComplete"
:triggerIndices="triggerIndices">
<template #header="{ item }">
<div v-if="item.role == 'assistant' && deepThinkingVisible && item.key == chatList[chatList.length - 1].key" class="header-wrapper">
<Thinking max-width="100%" buttonWidth="250px" autoCollapse :content="deepThinkingMessage" :status="deepThinkingStatus" backgroundColor="#fff9e6" color="#000">
<div v-if="item.role == 'assistant' && deepThinkingVisible && item.key == chatList[chatList.length - 1].key"
class="header-wrapper">
<Thinking max-width="100%" buttonWidth="250px" autoCollapse
:content="deepThinkingMessage" :status="deepThinkingStatus"
backgroundColor="#fff9e6" color="#000">
<template #label="{ status }">
<span v-if="status === 'start'">{{ $t('message.chat.startThinking') }}</span>
<span v-else-if="status === 'thinking'">{{ $t('message.chat.thinking') }}</span>
<span v-else-if="status === 'end'">{{ $t('message.chat.thinkingDone') }}</span>
<span v-else-if="status === 'error'">{{ $t('message.chat.thinkingFailed') }}</span>
<span v-if="status === 'start'">{{ $t('message.chat.startThinking')
}}</span>
<span v-else-if="status === 'thinking'">{{ $t('message.chat.thinking')
}}</span>
<span v-else-if="status === 'end'">{{ $t('message.chat.thinkingDone')
}}</span>
<span v-else-if="status === 'error'">{{ $t('message.chat.thinkingFailed')
}}</span>
</template>
<template #content="{ content }">
<span>
@ -128,12 +137,15 @@
</template>
<template #footer="{ item }">
<div v-if="item.role == 'assistant'" class="footer-container">
<el-button type="info" text :icon="CopyDocument" size="small" @click="handleCopy(item)" />
<el-button type="info" text :icon="CopyDocument" size="small"
@click="handleCopy(item)" />
<el-button type="info" text size="small">
<el-icon v-if="!isPlaying || playAudioKey != item.key" @click="handlePlay(item)">
<el-icon v-if="!isPlaying || playAudioKey != item.key"
@click="handlePlay(item)">
<VideoPlay />
</el-icon>
<el-icon v-if="isPlaying && playAudioKey == item.key" @click="handlePause(item)">
<el-icon v-if="isPlaying && playAudioKey == item.key"
@click="handlePause(item)">
<VideoPause />
</el-icon>
</el-button>
@ -142,18 +154,9 @@
</BubbleList>
</div>
<div :class="isNew ? 'chat_new_input_style' : 'chat_edit_input_style'">
<sender
ref="senderRef"
variant="updown"
clearable
allow-speech
:loading="isSenderLoading"
:read-only="isSenderLoading"
:auto-size="{ minRows: 1, maxRows: 5 }"
v-model="senderInput"
@submit="handleSend"
:placeholder="$t('message.chat.inputPlaceholder')"
>
<sender ref="senderRef" variant="updown" clearable allow-speech :loading="isSenderLoading"
:read-only="isSenderLoading" :auto-size="{ minRows: 1, maxRows: 5 }" v-model="senderInput"
@submit="handleSend" :placeholder="$t('message.chat.inputPlaceholder')">
<template #prefix>
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<el-button round plain>
@ -162,11 +165,9 @@
</el-icon>
</el-button>
<div
:class="{ isDeepThinking }"
<div :class="{ isDeepThinking }"
style="display: flex; align-items: center; gap: 4px; padding: 4px 12px; border: 1px solid silver; border-radius: 15px; cursor: pointer; font-size: 12px"
@click="handleDeepThinking"
>
@click="handleDeepThinking">
<el-icon>
<ElementPlus />
</el-icon>
@ -187,10 +188,12 @@
<div class="ai-rename-icon ai-rename-drag" @mousedown="startDrag">
<el-avatar :size="48" src="/chat.png" />
</div>
<el-input v-model="renameInput" @keyup.enter="confirmRename" ref="renameInputRef" class="ai-rename-input" />
<el-input v-model="renameInput" @keyup.enter="confirmRename" ref="renameInputRef"
class="ai-rename-input" />
</div>
<div class="ai-dialog-footer">
<el-button @click="cancelRename" class="ai-btn-cancel">{{ $t('message.chat.cancel') }}</el-button>
<el-button @click="cancelRename" class="ai-btn-cancel">{{ $t('message.chat.cancel')
}}</el-button>
<el-button type="primary" @click="confirmRename" class="ai-btn-confirm">
{{ $t('message.chat.confirm') }}
</el-button>
@ -256,11 +259,7 @@ const deepThinkingStatus = ref('start');
const deepThinkingVisible = ref(false);
let historyListSource: ChatListOutput[] = []; //
//
const reconnectInterval = 5000; //
const maxRetries = 1000; //
let retryCount = 0;
let reconnectTimer: number | null = null;
let utterance: SpeechSynthesisUtterance | null = null;
const triggerIndices = ref<BubbleListProps['triggerIndices']>('only-last');
@ -283,14 +282,46 @@ const actionMenuItems = [
icon: Delete,
},
];
//
const renameDialogVisible = ref(false);
const currentEditItem = ref<any>(null);
const renameInput = ref('');
const renameModalRef = ref<HTMLElement | null>(null);
let dragData = { dragging: false, offsetX: 0, offsetY: 0 };
//#region sse
let eventSource: EventSource | null = null;
let monitorSSEConnectionHandler: NodeJS.Timeout | null = null;
let lastSseConnectionTime = Date.now();
let isSSEConnectionClosed = false;
const SSE_CONNECTION_TIMEOUT = 5000;
// sse
const initSSEConnection = () => {
if (eventSource) {
eventSource.close();
initSSEConnectionCore();
// sse
monitorSSEConnectionHandler = setInterval(() => {
isSSEConnectionClosed = Date.now() - lastSseConnectionTime > SSE_CONNECTION_TIMEOUT;
if (isSSEConnectionClosed) {
console.log("SSE connection timed out, reconnecting");
try {
initSSEConnectionCore();
} catch (err) {
console.log("SSE connection timed out, reconnecting failed", err);
}
}
}, SSE_CONNECTION_TIMEOUT);
};
// sse
const checkSSEConnectionStatus = () => {
if (isSSEConnectionClosed) {
return false;
}
return true;
}
// sse
const initSSEConnectionCore = () => {
closeSSEConnection();
eventSource = new EventSource('/sse/chat/' + userId());
@ -328,66 +359,27 @@ const initSSEConnection = () => {
chatList.value[chatList.value.length - 1].content = currentChatItemMessage.value;
return;
}
currentChatItemMessage.value = currentChatItemMessage.value + data; //
//
retryCount = 0;
reconnectTimer = null;
currentChatItemMessage.value = currentChatItemMessage.value + data; //
});
// ping,
eventSource.addEventListener('ping', (event) => {
console.log('heat beat:', event.data);
// ping
retryCount = 0;
reconnectTimer = null;
lastSseConnectionTime = Date.now();
});
//
const checkConnection = () => {
if (eventSource && eventSource.readyState === EventSource.CLOSED) {
console.log('Connection closed, attempting to reconnect...');
if (retryCount < maxRetries) {
retryCount++;
console.log(`Attempting to reconnect (${retryCount}/${maxRetries})...`);
reconnectTimer = window.setTimeout(() => {
initSSEConnection();
}, reconnectInterval);
}
}
};
//
const connectionCheckInterval = setInterval(checkConnection, 3000);
eventSource.onerror = () => {
console.log('SSE connection error');
if (eventSource?.readyState === EventSource.CLOSED) {
if (connectionCheckInterval) {
clearInterval(connectionCheckInterval);
}
if (retryCount < maxRetries) {
retryCount++;
console.log(`Attempting to reconnect (${retryCount}/${maxRetries})...`);
reconnectTimer = window.setTimeout(() => {
initSSEConnection();
}, reconnectInterval);
}
}
};
eventSource.onopen = (event) => {
console.log('SSE connection opened:', event);
retryCount = 0; //
reconnectTimer = null;
};
};
const closeSSEConnection = () => {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (eventSource) {
eventSource.close();
eventSource = null;
@ -401,6 +393,10 @@ const handleBubbleComplete = (instance: TypewriterInstance, index: number) => {
//#endregion sse
const handleSend = async () => {
if (!senderInput.value.trim()) return;
if (!checkSSEConnectionStatus()) {
ElMessage.error(t('message.chat.backEndError'));
return;
}
isSenderLoading.value = true;
currentChatItemMessage.value = '';
if (isNew.value) {
@ -675,12 +671,7 @@ const loadMoreHistoryItems = async () => {
isHistoryListLoading.value = false;
};
//
const renameDialogVisible = ref(false);
const currentEditItem = ref<any>(null);
const renameInput = ref('');
const renameModalRef = ref<HTMLElement | null>(null);
let dragData = { dragging: false, offsetX: 0, offsetY: 0 };
const handleMenuClick = (menuKey: string, item: any) => {
switch (menuKey) {
@ -839,6 +830,10 @@ onMounted(async () => {
onBeforeUnmount(() => {
closeSSEConnection();
if (monitorSSEConnectionHandler) {
clearInterval(monitorSSEConnectionHandler);
monitorSSEConnectionHandler = null;
}
if (utterance) {
window.speechSynthesis.cancel();
utterance = null;