diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index 81b3c327f..aa1192d09 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -5,90 +5,20 @@
- +
@@ -150,74 +80,43 @@
-
-
- -
-
- - - - - - -
-
- - - - - -
-
-
- - -
-
- - -
- -
- - - {{ tm('voice.recording') }} - - -
-
-
+
+ + + + {{ tm('actions.editTitle') }} + + + + + + {{ t('core.common.cancel') }} + {{ t('core.common.save') }} + + + @@ -231,1048 +130,273 @@ - - - - - - {{ tm('conversation.editDisplayName') }} - - - - - - - - - {{ t('core.common.cancel') }} - - - {{ t('core.common.save') }} - - - - - - \ 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..2ea9a4819 --- /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/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..3779576cd --- /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( + currSessionId: Ref, + getMediaFile: (filename: string) => Promise, + updateSessionTitle: (sessionId: string, title: string) => void, + onSessionsUpdate: () => 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 getSessionMessages(sessionId: string, router: any) { + if (!sessionId) return; + + try { + const response = await axios.get('/api/chat/get_session?session_id=' + sessionId); + 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(() => { + getSessionMessages(currSessionId.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, + session_id: currSessionId.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') { + updateSessionTitle(chunk_json.session_id, 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; + } + } + + // 获取最新的会话列表 + onSessionsUpdate(); + + } 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, + getSessionMessages, + 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 + }; +} diff --git a/dashboard/src/composables/useSessions.ts b/dashboard/src/composables/useSessions.ts new file mode 100644 index 000000000..860668a94 --- /dev/null +++ b/dashboard/src/composables/useSessions.ts @@ -0,0 +1,145 @@ +import { ref, computed } from 'vue'; +import axios from 'axios'; +import { useRouter } from 'vue-router'; + +export interface Session { + session_id: string; + display_name: string; + updated_at: number; +} + +export function useSessions(chatboxMode: boolean = false) { + const router = useRouter(); + const sessions = ref([]); + const selectedSessions = ref([]); + const currSessionId = ref(''); + const pendingSessionId = ref(null); + + // 编辑标题相关 + const editTitleDialog = ref(false); + const editingTitle = ref(''); + const editingSessionId = ref(''); + + const getCurrentSession = computed(() => { + if (!currSessionId.value) return null; + return sessions.value.find(s => s.session_id === currSessionId.value); + }); + + async function getSessions() { + try { + const response = await axios.get('/api/chat/sessions'); + sessions.value = response.data.data; + + // 处理待加载的会话 + if (pendingSessionId.value) { + const session = sessions.value.find(s => s.session_id === pendingSessionId.value); + if (session) { + selectedSessions.value = [pendingSessionId.value]; + pendingSessionId.value = null; + } + } else if (!currSessionId.value && sessions.value.length > 0) { + // 默认选择第一个会话 + const firstSession = sessions.value[0]; + selectedSessions.value = [firstSession.session_id]; + } + } catch (err: any) { + if (err.response?.status === 401) { + router.push('/auth/login?redirect=/chatbox'); + } + console.error(err); + } + } + + async function newSession() { + try { + const response = await axios.get('/api/chat/new_session'); + const sessionId = response.data.data.session_id; + currSessionId.value = sessionId; + + // 更新 URL + const basePath = chatboxMode ? '/chatbox' : '/chat'; + router.push(`${basePath}/${sessionId}`); + + await getSessions(); + return sessionId; + } catch (err) { + console.error(err); + throw err; + } + } + + async function deleteSession(sessionId: string) { + try { + await axios.get('/api/chat/delete_session?session_id=' + sessionId); + await getSessions(); + currSessionId.value = ''; + selectedSessions.value = []; + } catch (err) { + console.error(err); + } + } + + function showEditTitleDialog(sessionId: string, title: string) { + editingSessionId.value = sessionId; + editingTitle.value = title || ''; + editTitleDialog.value = true; + } + + async function saveTitle() { + if (!editingSessionId.value) return; + + const trimmedTitle = editingTitle.value.trim(); + try { + await axios.post('/api/chat/update_session_display_name', { + session_id: editingSessionId.value, + display_name: trimmedTitle + }); + + // 更新本地会话标题 + const session = sessions.value.find(s => s.session_id === editingSessionId.value); + if (session) { + session.display_name = trimmedTitle; + } + editTitleDialog.value = false; + } catch (err) { + console.error('重命名会话失败:', err); + } + } + + function updateSessionTitle(sessionId: string, title: string) { + const session = sessions.value.find(s => s.session_id === sessionId); + if (session) { + session.display_name = title; + } + } + + function newChat(closeMobileSidebar?: () => void) { + currSessionId.value = ''; + selectedSessions.value = []; + + const basePath = chatboxMode ? '/chatbox' : '/chat'; + router.push(basePath); + + if (closeMobileSidebar) { + closeMobileSidebar(); + } + } + + return { + sessions, + selectedSessions, + currSessionId, + pendingSessionId, + editTitleDialog, + editingTitle, + editingSessionId, + getCurrentSession, + getSessions, + newSession, + deleteSession, + showEditTitleDialog, + saveTitle, + updateSessionTitle, + newChat + }; +}