diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index 7e88480f1..3179e59f0 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -526,6 +526,7 @@ import { type TransportMode, } from "@/composables/useMessages"; import { useMediaHandling } from "@/composables/useMediaHandling"; +import { useRecording } from "@/composables/useRecording"; import { useProjects } from "@/composables/useProjects"; import { useCustomizerStore } from "@/stores/customizer"; import ProviderChatCompletionPanel from "@/components/provider/ProviderChatCompletionPanel.vue"; @@ -633,8 +634,12 @@ const threadSelection = reactive<{ selectedText: "", }); const enableStreaming = ref(true); -const isRecording = ref(false); const sendShortcut = ref<"enter" | "shift_enter">("enter"); +const { + isRecording, + startRecording: startRecorder, + stopRecording: stopRecorder, +} = useRecording(); const chatSidebarDrawer = computed({ get: () => lgAndUp.value || customizer.chatSidebarOpen, set: (value: boolean) => { @@ -1303,12 +1308,26 @@ function toggleStreaming() { enableStreaming.value = !enableStreaming.value; } -function startRecording() { - isRecording.value = true; +async function startRecording() { + try { + await startRecorder(); + } catch (error) { + console.error("Failed to start recording:", error); + toast.error(tm("voice.error")); + } } -function stopRecording() { - isRecording.value = false; +async function stopRecording() { + try { + const audioFile = await stopRecorder(); + const uploaded = await processAndUploadFile(audioFile); + if (!uploaded) { + toast.error(tm("voice.error")); + } + } catch (error) { + console.error("Failed to stop recording:", error); + toast.error(tm("voice.error")); + } } function handleMessagesScroll() { @@ -1520,7 +1539,12 @@ function toggleTheme() { } .session-progress { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); flex-shrink: 0; + transition: right 0.16s ease; } .session-actions { @@ -1544,6 +1568,11 @@ function toggleTheme() { visibility: visible; } +.session-item:hover .session-progress, +.session-item:focus-within .session-progress { + right: 62px; +} + .session-action-btn { color: var(--chat-muted); } diff --git a/dashboard/src/composables/useMediaHandling.ts b/dashboard/src/composables/useMediaHandling.ts index 6c81a7ec7..2b102f096 100644 --- a/dashboard/src/composables/useMediaHandling.ts +++ b/dashboard/src/composables/useMediaHandling.ts @@ -11,7 +11,6 @@ export interface StagedFileInfo { } export function useMediaHandling() { - const stagedAudioUrl = ref(''); const stagedFiles = ref([]); const mediaCache = ref>({}); const pendingFileSignatures = new Set(); @@ -56,9 +55,9 @@ export function useMediaHandling() { } } - async function uploadStagedFile(file: File) { + async function uploadStagedFile(file: File): Promise { const signature = await getFileSignature(file); - if (isDuplicateFile(signature)) return; + if (isDuplicateFile(signature)) return undefined; pendingFileSignatures.add(signature); const formData = new FormData(); @@ -72,27 +71,30 @@ export function useMediaHandling() { }); const { attachment_id, filename, type } = response.data.data; - stagedFiles.value.push({ + const stagedFile = { attachment_id, filename, original_name: file.name, url: URL.createObjectURL(file), type, signature - }); + }; + stagedFiles.value.push(stagedFile); + return stagedFile; } catch (err) { console.error('Error uploading file:', err); + return undefined; } finally { pendingFileSignatures.delete(signature); } } async function processAndUploadImage(file: File) { - await uploadStagedFile(file); + return uploadStagedFile(file); } async function processAndUploadFile(file: File) { - await uploadStagedFile(file); + return uploadStagedFile(file); } async function handlePaste(event: ClipboardEvent) { @@ -128,14 +130,25 @@ export function useMediaHandling() { } function removeAudio() { - stagedAudioUrl.value = ''; + for (let i = stagedFiles.value.length - 1; i >= 0; i--) { + if (stagedFiles.value[i].type !== 'record') continue; + + const fileToRemove = stagedFiles.value[i]; + if (fileToRemove.url.startsWith('blob:')) { + URL.revokeObjectURL(fileToRemove.url); + } + stagedFiles.value.splice(i, 1); + } } function removeFile(index: number) { - // 找到第 index 个非图片类型的文件 + // Find the requested non-image, non-audio attachment. let fileCount = 0; for (let i = 0; i < stagedFiles.value.length; i++) { - if (stagedFiles.value[i].type !== 'image') { + if ( + stagedFiles.value[i].type !== 'image' && + stagedFiles.value[i].type !== 'record' + ) { if (fileCount === index) { const fileToRemove = stagedFiles.value[i]; if (fileToRemove.url.startsWith('blob:')) { @@ -151,7 +164,6 @@ export function useMediaHandling() { function clearStaged(options: { revokeUrls?: boolean } = {}) { const { revokeUrls = true } = options; - stagedAudioUrl.value = ''; if (revokeUrls) { // 清理文件的 blob URLs stagedFiles.value.forEach(file => { @@ -177,9 +189,13 @@ export function useMediaHandling() { stagedFiles.value.filter(f => f.type === 'image').map(f => f.url) ); + const stagedAudioUrl = computed(() => + stagedFiles.value.find(f => f.type === 'record')?.url || '' + ); + // 计算属性:获取非图片文件列表 const stagedNonImageFiles = computed(() => - stagedFiles.value.filter(f => f.type !== 'image') + stagedFiles.value.filter(f => f.type !== 'image' && f.type !== 'record') ); return { diff --git a/dashboard/src/composables/useRecording.ts b/dashboard/src/composables/useRecording.ts index 083ed1c5b..0ad66aa1f 100644 --- a/dashboard/src/composables/useRecording.ts +++ b/dashboard/src/composables/useRecording.ts @@ -1,11 +1,27 @@ import { ref } from 'vue'; -import axios from 'axios'; export function useRecording() { const isRecording = ref(false); const audioChunks = ref([]); const mediaRecorder = ref(null); + function getSupportedMimeType(): string { + const candidates = [ + 'audio/webm;codecs=opus', + 'audio/webm', + 'audio/ogg;codecs=opus', + 'audio/ogg', + 'audio/mp4', + 'audio/wav' + ]; + + if (typeof MediaRecorder === 'undefined' || !MediaRecorder.isTypeSupported) { + return ''; + } + + return candidates.find(type => MediaRecorder.isTypeSupported(type)) || ''; + } + function getRecordingMimeType(): string { const chunkType = audioChunks.value.find(chunk => chunk.type)?.type; return chunkType || mediaRecorder.value?.mimeType || 'audio/webm'; @@ -23,16 +39,30 @@ export function useRecording() { }; const normalizedMimeType = mimeType.toLowerCase(); const extension = extensionMap[normalizedMimeType] || normalizedMimeType.split('/')[1]?.split(';')[0] || 'webm'; - return `${crypto.randomUUID()}.${extension}`; + const id = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `${id}.${extension}`; } async function startRecording(onStart?: (label: string) => void) { try { + if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') { + throw new Error('Audio recording is not supported in this browser'); + } + + mediaRecorder.value?.stream.getTracks().forEach(track => track.stop()); + audioChunks.value = []; + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - mediaRecorder.value = new MediaRecorder(stream); + const mimeType = getSupportedMimeType(); + mediaRecorder.value = new MediaRecorder( + stream, + mimeType ? { mimeType } : undefined + ); mediaRecorder.value.ondataavailable = (event) => { - audioChunks.value.push(event.data); + if (event.data.size > 0) { + audioChunks.value.push(event.data); + } }; mediaRecorder.value.start(); @@ -43,13 +73,16 @@ export function useRecording() { } } catch (error) { console.error('Failed to start recording:', error); + isRecording.value = false; + throw error; } } - async function stopRecording(onStop?: (label: string) => void): Promise { + async function stopRecording(onStop?: (label: string) => void): Promise { return new Promise((resolve, reject) => { - if (!mediaRecorder.value) { - reject('No media recorder'); + const recorder = mediaRecorder.value; + if (!recorder) { + reject(new Error('No media recorder')); return; } @@ -58,33 +91,45 @@ export function useRecording() { onStop('聊天输入框'); } - mediaRecorder.value.stop(); - mediaRecorder.value.onstop = async () => { + recorder.onstop = () => { const mimeType = getRecordingMimeType(); const audioBlob = new Blob(audioChunks.value, { type: mimeType }); - const filename = getRecordingFilename(mimeType); audioChunks.value = []; - - mediaRecorder.value?.stream.getTracks().forEach(track => track.stop()); - - const formData = new FormData(); - formData.append('file', audioBlob, filename); - - try { - const response = await axios.post('/api/chat/post_file', formData, { - headers: { - 'Content-Type': 'multipart/form-data' - } - }); - - const attachmentId = response.data.data.attachment_id; - console.log('Audio uploaded:', attachmentId); - resolve(attachmentId); - } catch (err) { - console.error('Error uploading audio:', err); - reject(err); + recorder.stream.getTracks().forEach(track => track.stop()); + if (mediaRecorder.value === recorder) { + mediaRecorder.value = null; } + + if (!audioBlob.size) { + reject(new Error('Recording is empty')); + return; + } + + const filename = getRecordingFilename(mimeType); + const audioFile = new File([audioBlob], filename, { + type: mimeType, + lastModified: Date.now() + }); + resolve(audioFile); }; + + recorder.onerror = (event) => { + recorder.stream.getTracks().forEach(track => track.stop()); + if (mediaRecorder.value === recorder) { + mediaRecorder.value = null; + } + reject(event); + }; + + try { + recorder.stop(); + } catch (error) { + recorder.stream.getTracks().forEach(track => track.stop()); + if (mediaRecorder.value === recorder) { + mediaRecorder.value = null; + } + reject(error); + } }); }