From 164a4226ea0daa971a08f939dba19f81cce053e4 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Thu, 20 Nov 2025 16:07:09 +0800 Subject: [PATCH] feat(chat): refactor chat component structure and add new features (#3701) - Introduced `ConversationSidebar.vue` for improved conversation management and sidebar functionality. - Enhanced `MessageList.vue` to handle loading states and improved message rendering. - Created new composables: `useConversations`, `useMessages`, `useMediaHandling`, `useRecording` for better code organization and reusability. - Added loading indicators and improved user experience during message processing. - Ensured backward compatibility and maintained existing functionalities. --- dashboard/src/components/chat/Chat.vue | 1700 ++++------------- dashboard/src/components/chat/ChatInput.vue | 283 +++ .../components/chat/ConversationSidebar.vue | 310 +++ dashboard/src/components/chat/MessageList.vue | 92 +- dashboard/src/composables/useConversations.ts | 145 ++ dashboard/src/composables/useMediaHandling.ts | 104 + dashboard/src/composables/useMessages.ts | 303 +++ dashboard/src/composables/useRecording.ts | 74 + 8 files changed, 1615 insertions(+), 1396 deletions(-) create mode 100644 dashboard/src/components/chat/ChatInput.vue create mode 100644 dashboard/src/components/chat/ConversationSidebar.vue create mode 100644 dashboard/src/composables/useConversations.ts create mode 100644 dashboard/src/composables/useMediaHandling.ts create mode 100644 dashboard/src/composables/useMessages.ts create mode 100644 dashboard/src/composables/useRecording.ts diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index d671b15b7..bb3418d67 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -5,89 +5,20 @@
- +
@@ -149,69 +80,23 @@
-
-
- -
-
- - - - - - -
-
- - - - - -
-
-
- - -
-
- - -
- -
- - - {{ tm('voice.recording') }} - - -
-
-
+
@@ -227,8 +112,8 @@ - {{ t('core.common.cancel') }} - {{ t('core.common.save') }} + {{ t('core.common.cancel') }} + {{ t('core.common.save') }} @@ -247,989 +132,271 @@ - - \ No newline at end of file diff --git a/dashboard/src/components/chat/ChatInput.vue b/dashboard/src/components/chat/ChatInput.vue new file mode 100644 index 000000000..7ca0ec94a --- /dev/null +++ b/dashboard/src/components/chat/ChatInput.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/dashboard/src/components/chat/ConversationSidebar.vue b/dashboard/src/components/chat/ConversationSidebar.vue new file mode 100644 index 000000000..80b574c7f --- /dev/null +++ b/dashboard/src/components/chat/ConversationSidebar.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/dashboard/src/components/chat/MessageList.vue b/dashboard/src/components/chat/MessageList.vue index 9cd69241f..15f3c1d31 100644 --- a/dashboard/src/components/chat/MessageList.vue +++ b/dashboard/src/components/chat/MessageList.vue @@ -37,42 +37,49 @@
- -
-
- - {{ isReasoningExpanded(index) ? 'mdi-chevron-down' : 'mdi-chevron-right' }} - - {{ tm('reasoning.thinking') }} -
-
-
-
+ +
+ {{ tm('message.loading') }}
- -
- - -
-
- +
-
+
@@ -841,6 +848,29 @@ export default { margin: 10px 0; } +.loading-container { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; + margin-top: 2px; +} + +.loading-text { + font-size: 14px; + color: var(--v-theme-secondaryText); + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 0.6; + } + 50% { + opacity: 1; + } +} + .markdown-content blockquote { border-left: 4px solid var(--v-theme-secondary); padding-left: 16px; diff --git a/dashboard/src/composables/useConversations.ts b/dashboard/src/composables/useConversations.ts new file mode 100644 index 000000000..cf86246c8 --- /dev/null +++ b/dashboard/src/composables/useConversations.ts @@ -0,0 +1,145 @@ +import { ref, computed } from 'vue'; +import axios from 'axios'; +import { useRouter } from 'vue-router'; + +export interface Conversation { + cid: string; + title: string; + updated_at: number; +} + +export function useConversations(chatboxMode: boolean = false) { + const router = useRouter(); + const conversations = ref([]); + const selectedConversations = ref([]); + const currCid = ref(''); + const pendingCid = ref(null); + + // 编辑标题相关 + const editTitleDialog = ref(false); + const editingTitle = ref(''); + const editingCid = ref(''); + + const getCurrentConversation = computed(() => { + if (!currCid.value) return null; + return conversations.value.find(c => c.cid === currCid.value); + }); + + async function getConversations() { + try { + const response = await axios.get('/api/chat/conversations'); + conversations.value = response.data.data; + + // 处理待加载的会话 + if (pendingCid.value) { + const conversation = conversations.value.find(c => c.cid === pendingCid.value); + if (conversation) { + selectedConversations.value = [pendingCid.value]; + pendingCid.value = null; + } + } else if (!currCid.value && conversations.value.length > 0) { + // 默认选择第一个会话 + const firstConversation = conversations.value[0]; + selectedConversations.value = [firstConversation.cid]; + } + } catch (err: any) { + if (err.response?.status === 401) { + router.push('/auth/login?redirect=/chatbox'); + } + console.error(err); + } + } + + async function newConversation() { + try { + const response = await axios.get('/api/chat/new_conversation'); + const cid = response.data.data.conversation_id; + currCid.value = cid; + + // 更新 URL + const basePath = chatboxMode ? '/chatbox' : '/chat'; + router.push(`${basePath}/${cid}`); + + await getConversations(); + return cid; + } catch (err) { + console.error(err); + throw err; + } + } + + async function deleteConversation(cid: string) { + try { + await axios.get('/api/chat/delete_conversation?conversation_id=' + cid); + await getConversations(); + currCid.value = ''; + selectedConversations.value = []; + } catch (err) { + console.error(err); + } + } + + function showEditTitleDialog(cid: string, title: string) { + editingCid.value = cid; + editingTitle.value = title || ''; + editTitleDialog.value = true; + } + + async function saveTitle() { + if (!editingCid.value) return; + + const trimmedTitle = editingTitle.value.trim(); + try { + await axios.post('/api/chat/rename_conversation', { + conversation_id: editingCid.value, + title: trimmedTitle + }); + + // 更新本地会话标题 + const conversation = conversations.value.find(c => c.cid === editingCid.value); + if (conversation) { + conversation.title = trimmedTitle; + } + editTitleDialog.value = false; + } catch (err) { + console.error('重命名对话失败:', err); + } + } + + function updateConversationTitle(cid: string, title: string) { + const conversation = conversations.value.find(c => c.cid === cid); + if (conversation) { + conversation.title = title; + } + } + + function newChat(closeMobileSidebar?: () => void) { + currCid.value = ''; + selectedConversations.value = []; + + const basePath = chatboxMode ? '/chatbox' : '/chat'; + router.push(basePath); + + if (closeMobileSidebar) { + closeMobileSidebar(); + } + } + + return { + conversations, + selectedConversations, + currCid, + pendingCid, + editTitleDialog, + editingTitle, + editingCid, + getCurrentConversation, + getConversations, + newConversation, + deleteConversation, + showEditTitleDialog, + saveTitle, + updateConversationTitle, + newChat + }; +} diff --git a/dashboard/src/composables/useMediaHandling.ts b/dashboard/src/composables/useMediaHandling.ts new file mode 100644 index 000000000..e24c25fb8 --- /dev/null +++ b/dashboard/src/composables/useMediaHandling.ts @@ -0,0 +1,104 @@ +import { ref } from 'vue'; +import axios from 'axios'; + +export function useMediaHandling() { + const stagedImagesName = ref([]); + const stagedImagesUrl = ref([]); + const stagedAudioUrl = ref(''); + const mediaCache = ref>({}); + + async function getMediaFile(filename: string): Promise { + if (mediaCache.value[filename]) { + return mediaCache.value[filename]; + } + + try { + const response = await axios.get('/api/chat/get_file', { + params: { filename }, + responseType: 'blob' + }); + + const blobUrl = URL.createObjectURL(response.data); + mediaCache.value[filename] = blobUrl; + return blobUrl; + } catch (error) { + console.error('Error fetching media file:', error); + return ''; + } + } + + async function processAndUploadImage(file: File) { + const formData = new FormData(); + formData.append('file', file); + + try { + const response = await axios.post('/api/chat/post_image', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + + const img = response.data.data.filename; + stagedImagesName.value.push(img); + stagedImagesUrl.value.push(URL.createObjectURL(file)); + } catch (err) { + console.error('Error uploading image:', err); + } + } + + async function handlePaste(event: ClipboardEvent) { + const items = event.clipboardData?.items; + if (!items) return; + + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image') !== -1) { + const file = items[i].getAsFile(); + if (file) { + await processAndUploadImage(file); + } + } + } + } + + function removeImage(index: number) { + const urlToRevoke = stagedImagesUrl.value[index]; + if (urlToRevoke && urlToRevoke.startsWith('blob:')) { + URL.revokeObjectURL(urlToRevoke); + } + + stagedImagesName.value.splice(index, 1); + stagedImagesUrl.value.splice(index, 1); + } + + function removeAudio() { + stagedAudioUrl.value = ''; + } + + function clearStaged() { + stagedImagesName.value = []; + stagedImagesUrl.value = []; + stagedAudioUrl.value = ''; + } + + function cleanupMediaCache() { + Object.values(mediaCache.value).forEach(url => { + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + }); + mediaCache.value = {}; + } + + return { + stagedImagesName, + stagedImagesUrl, + stagedAudioUrl, + getMediaFile, + processAndUploadImage, + handlePaste, + removeImage, + removeAudio, + clearStaged, + cleanupMediaCache + }; +} diff --git a/dashboard/src/composables/useMessages.ts b/dashboard/src/composables/useMessages.ts new file mode 100644 index 000000000..dc243f1b4 --- /dev/null +++ b/dashboard/src/composables/useMessages.ts @@ -0,0 +1,303 @@ +import { ref, reactive, type Ref } from 'vue'; +import axios from 'axios'; +import { useToast } from '@/utils/toast'; + +export interface MessageContent { + type: string; + message: string; + reasoning?: string; + image_url?: string[]; + audio_url?: string; + embedded_images?: string[]; + embedded_audio?: string; + isLoading?: boolean; +} + +export interface Message { + content: MessageContent; +} + +export function useMessages( + currCid: Ref, + getMediaFile: (filename: string) => Promise, + updateConversationTitle: (cid: string, title: string) => void, + onConversationsUpdate: () => void +) { + const messages = ref([]); + const isStreaming = ref(false); + const isConvRunning = ref(false); + const isToastedRunningInfo = ref(false); + const activeSSECount = ref(0); + const enableStreaming = ref(true); + + // 从 localStorage 读取流式响应开关状态 + const savedStreamingState = localStorage.getItem('enableStreaming'); + if (savedStreamingState !== null) { + enableStreaming.value = JSON.parse(savedStreamingState); + } + + function toggleStreaming() { + enableStreaming.value = !enableStreaming.value; + localStorage.setItem('enableStreaming', JSON.stringify(enableStreaming.value)); + } + + async function getConversationMessages(cid: string, router: any) { + if (!cid) return; + + try { + const response = await axios.get('/api/chat/get_conversation?conversation_id=' + cid); + isConvRunning.value = response.data.data.is_running || false; + let history = response.data.data.history; + + if (isConvRunning.value) { + if (!isToastedRunningInfo.value) { + useToast().info("该对话正在运行中。", { timeout: 5000 }); + isToastedRunningInfo.value = true; + } + + // 如果对话还在运行,3秒后重新获取消息 + setTimeout(() => { + getConversationMessages(currCid.value, router); + }, 3000); + } + + // 处理历史消息中的媒体文件 + for (let i = 0; i < history.length; i++) { + let content = history[i].content; + + if (content.message?.startsWith('[IMAGE]')) { + let img = content.message.replace('[IMAGE]', ''); + const imageUrl = await getMediaFile(img); + if (!content.embedded_images) { + content.embedded_images = []; + } + content.embedded_images.push(imageUrl); + content.message = ''; + } + + if (content.message?.startsWith('[RECORD]')) { + let audio = content.message.replace('[RECORD]', ''); + const audioUrl = await getMediaFile(audio); + content.embedded_audio = audioUrl; + content.message = ''; + } + + if (content.image_url && content.image_url.length > 0) { + for (let j = 0; j < content.image_url.length; j++) { + content.image_url[j] = await getMediaFile(content.image_url[j]); + } + } + + if (content.audio_url) { + content.audio_url = await getMediaFile(content.audio_url); + } + } + + messages.value = history; + } catch (err) { + console.error(err); + } + } + + async function sendMessage( + prompt: string, + imageNames: string[], + audioName: string, + selectedProviderId: string, + selectedModelName: string + ) { + // Create user message + const userMessage: MessageContent = { + type: 'user', + message: prompt, + image_url: [], + audio_url: undefined + }; + + // Convert image filenames to blob URLs + if (imageNames.length > 0) { + const imagePromises = imageNames.map(name => { + if (!name.startsWith('blob:')) { + return getMediaFile(name); + } + return Promise.resolve(name); + }); + userMessage.image_url = await Promise.all(imagePromises); + } + + // Convert audio filename to blob URL + if (audioName) { + if (!audioName.startsWith('blob:')) { + userMessage.audio_url = await getMediaFile(audioName); + } else { + userMessage.audio_url = audioName; + } + } + + messages.value.push({ content: userMessage }); + + // 添加一个加载中的机器人消息占位符 + const loadingMessage = reactive({ + type: 'bot', + message: '', + reasoning: '', + isLoading: true + }); + messages.value.push({ content: loadingMessage }); + + try { + activeSSECount.value++; + if (activeSSECount.value === 1) { + isConvRunning.value = true; + } + + const response = await fetch('/api/chat/send', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + localStorage.getItem('token') + }, + body: JSON.stringify({ + message: prompt, + conversation_id: currCid.value, + image_url: imageNames, + audio_url: audioName ? [audioName] : [], + selected_provider: selectedProviderId, + selected_model: selectedModelName, + enable_streaming: enableStreaming.value + }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let in_streaming = false; + let message_obj: any = null; + + isStreaming.value = true; + + while (true) { + try { + const { done, value } = await reader.read(); + if (done) { + console.log('SSE stream completed'); + break; + } + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n\n'); + + for (let i = 0; i < lines.length; i++) { + let line = lines[i].trim(); + if (!line) continue; + + let chunk_json; + try { + chunk_json = JSON.parse(line.replace('data: ', '')); + } catch (parseError) { + console.warn('JSON解析失败:', line, parseError); + continue; + } + + if (!chunk_json || typeof chunk_json !== 'object' || !chunk_json.hasOwnProperty('type')) { + console.warn('无效的数据对象:', chunk_json); + continue; + } + + if (chunk_json.type === 'error') { + console.error('Error received:', chunk_json.data); + continue; + } + + if (chunk_json.type === 'image') { + let img = chunk_json.data.replace('[IMAGE]', ''); + const imageUrl = await getMediaFile(img); + let bot_resp: MessageContent = { + type: 'bot', + message: '', + embedded_images: [imageUrl] + }; + messages.value.push({ content: bot_resp }); + } else if (chunk_json.type === 'record') { + let audio = chunk_json.data.replace('[RECORD]', ''); + const audioUrl = await getMediaFile(audio); + let bot_resp: MessageContent = { + type: 'bot', + message: '', + embedded_audio: audioUrl + }; + messages.value.push({ content: bot_resp }); + } else if (chunk_json.type === 'plain') { + const chain_type = chunk_json.chain_type || 'normal'; + + if (!in_streaming) { + // 移除加载占位符 + const lastMsg = messages.value[messages.value.length - 1]; + if (lastMsg?.content?.isLoading) { + messages.value.pop(); + } + + message_obj = reactive({ + type: 'bot', + message: chain_type === 'reasoning' ? '' : chunk_json.data, + reasoning: chain_type === 'reasoning' ? chunk_json.data : '', + }); + messages.value.push({ content: message_obj }); + in_streaming = true; + } else { + if (chain_type === 'reasoning') { + // 使用 reactive 对象,直接修改属性会触发响应式更新 + message_obj.reasoning = (message_obj.reasoning || '') + chunk_json.data; + } else { + message_obj.message = (message_obj.message || '') + chunk_json.data; + } + } + } else if (chunk_json.type === 'update_title') { + updateConversationTitle(chunk_json.cid, chunk_json.data); + } + + if ((chunk_json.type === 'break' && chunk_json.streaming) || !chunk_json.streaming) { + in_streaming = false; + if (!chunk_json.streaming) { + isStreaming.value = false; + } + } + } + } catch (readError) { + console.error('SSE读取错误:', readError); + break; + } + } + + // 获取最新的对话列表 + onConversationsUpdate(); + + } catch (err) { + console.error('发送消息失败:', err); + // 移除加载占位符 + const lastMsg = messages.value[messages.value.length - 1]; + if (lastMsg?.content?.isLoading) { + messages.value.pop(); + } + } finally { + isStreaming.value = false; + activeSSECount.value--; + if (activeSSECount.value === 0) { + isConvRunning.value = false; + } + } + } + + return { + messages, + isStreaming, + isConvRunning, + enableStreaming, + getConversationMessages, + sendMessage, + toggleStreaming + }; +} diff --git a/dashboard/src/composables/useRecording.ts b/dashboard/src/composables/useRecording.ts new file mode 100644 index 000000000..4b03e8508 --- /dev/null +++ b/dashboard/src/composables/useRecording.ts @@ -0,0 +1,74 @@ +import { ref } from 'vue'; +import axios from 'axios'; + +export function useRecording() { + const isRecording = ref(false); + const audioChunks = ref([]); + const mediaRecorder = ref(null); + + async function startRecording(onStart?: (label: string) => void) { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + mediaRecorder.value = new MediaRecorder(stream); + + mediaRecorder.value.ondataavailable = (event) => { + audioChunks.value.push(event.data); + }; + + mediaRecorder.value.start(); + isRecording.value = true; + + if (onStart) { + onStart('录音中...'); + } + } catch (error) { + console.error('Failed to start recording:', error); + } + } + + async function stopRecording(onStop?: (label: string) => void): Promise { + return new Promise((resolve, reject) => { + if (!mediaRecorder.value) { + reject('No media recorder'); + return; + } + + isRecording.value = false; + if (onStop) { + onStop('聊天输入框'); + } + + mediaRecorder.value.stop(); + mediaRecorder.value.onstop = async () => { + const audioBlob = new Blob(audioChunks.value, { type: 'audio/wav' }); + audioChunks.value = []; + + mediaRecorder.value?.stream.getTracks().forEach(track => track.stop()); + + const formData = new FormData(); + formData.append('file', audioBlob); + + try { + const response = await axios.post('/api/chat/post_file', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + + const audio = response.data.data.filename; + console.log('Audio uploaded:', audio); + resolve(audio); + } catch (err) { + console.error('Error uploading audio:', err); + reject(err); + } + }; + }); + } + + return { + isRecording, + startRecording, + stopRecording + }; +}