mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-01 18:20:16 +08:00
Compare commits
1 Commits
codex/rest
...
fix/8364
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2a54d7a26 |
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user