@ -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 >
< / e l - t o o l t i p >
< / 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" / >
< / e l - i c o n >
@ -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" / >
< / e l - t o o l t i p >
< / 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 / >
< / e l - i c o n >
< / 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 / >
< / e l - i c o n >
< el -icon v-if ="isPlaying && playAudioKey == item.key" @click="handlePause(item)" >
< el -icon v -if = " isPlaying & & playAudioKey = = item.key "
@ click = "handlePause(item)" >
< VideoPause / >
< / e l - i c o n >
< / e l - b u t t o n >
@ -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 @@
< / e l - i c o n >
< / e l - b u t t o n >
< 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 / >
< / e l - i c o n >
@ -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' ) } } < / e l - b u t t o n >
< el -button @click ="cancelRename" class = "ai-btn-cancel" > { { $t ( 'message.chat.cancel' )
} } < / e l - b u t t o n >
< el -button type = "primary" @click ="confirmRename" class = "ai-btn-confirm" >
{ { $t ( 'message.chat.confirm' ) } }
< / e l - b u t t o n >
@ -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 } ;
/ / # r e g i o n s s e 客 户 端
let eventSource : EventSource | null = null ;
let monitorSSEConnectionHandler : NodeJS . Timeout | null = null ;
let lastSseConnectionTime = Date . now ( ) ;
let isSSEConnectionClosed = false ;
const SSE _CONNECTION _TIMEOUT = 5000 ;
/ / 初 始 化 s s e 连 接
const initSSEConnection = ( ) => {
if ( eventSource ) {
eventSource . close ( ) ;
initSSEConnectionCore ( ) ;
/ / 监 控 s s e 连 接
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 ) ;
} ;
/ / 检 查 s s e 连 接 状 态
const checkSSEConnectionStatus = ( ) => {
if ( isSSEConnectionClosed ) {
return false ;
}
return true ;
}
/ / 初 始 化 s s e 连 接 核 心 代 码
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 ; / / 先 接 收 流 式 数 据 存 放 在 临 时 变 量 中
} ) ;
/ / 收 到 p i n g 消 息 , 用 于 心 跳 检 测
eventSource . addEventListener ( 'ping' , ( event ) => {
console . log ( 'heat beat:' , event . data ) ;
/ / 收 到 p i n g 后 重 置 重 试 计 数
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) => {
/ / # e n d r e g i o n s s e 客 户 端
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 ;