Compare commits

...

1 Commits

Author SHA1 Message Date
Soulter
b2a54d7a26 fix: recording issue on chatui
#8364
2026-05-30 18:03:39 +08:00
3 changed files with 136 additions and 46 deletions

View File

@@ -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);
}

View File

@@ -11,7 +11,6 @@ export interface StagedFileInfo {
}
export function useMediaHandling() {
const stagedAudioUrl = ref<string>('');
const stagedFiles = ref<StagedFileInfo[]>([]);
const mediaCache = ref<Record<string, string>>({});
const pendingFileSignatures = new Set<string>();
@@ -56,9 +55,9 @@ export function useMediaHandling() {
}
}
async function uploadStagedFile(file: File) {
async function uploadStagedFile(file: File): Promise<StagedFileInfo | undefined> {
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 {

View File

@@ -1,11 +1,27 @@
import { ref } from 'vue';
import axios from 'axios';
export function useRecording() {
const isRecording = ref(false);
const audioChunks = ref<Blob[]>([]);
const mediaRecorder = ref<MediaRecorder | null>(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<string> {
async function stopRecording(onStop?: (label: string) => void): Promise<File> {
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);
}
});
}