From 8cff4275b6d74c2bde4ba8e44b515c73b3da0409 Mon Sep 17 00:00:00 2001 From: Moein Moradi Date: Sun, 28 Sep 2025 13:46:57 +0330 Subject: [PATCH] feat:add voice input --- resources/js/pages/apps/chat.vue | 703 ++++++++++++++++++++++++------- resources/styles/chat-app.scss | 446 ++++++++++++++++---- 2 files changed, 904 insertions(+), 245 deletions(-) diff --git a/resources/js/pages/apps/chat.vue b/resources/js/pages/apps/chat.vue index 0429acd..9147408 100644 --- a/resources/js/pages/apps/chat.vue +++ b/resources/js/pages/apps/chat.vue @@ -103,6 +103,285 @@ const undoTimer = ref(null); const undoProgress = ref(100); const undoInterval = ref(null); +const formatRecordingTime = (seconds) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; +}; + + +// Voice Recording +const isRecording = ref(false); +const mediaRecorder = ref(null); +const audioChunks = ref([]); +const recordingStartTime = ref(null); +const recordingDuration = ref(0); +const recordingInterval = ref(null); +const voiceMessage = ref(null); +const audioStream = ref(null); +const audioContext = ref(null); +const analyser = ref(null); +const dataArray = ref(null); +const animationFrame = ref(null); +const waveformData = ref(new Array(50).fill(0)); +const waveformCanvas = ref(null); +const canvasCtx = ref(null); + + +const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + audioStream.value = stream; + mediaRecorder.value = new MediaRecorder(stream); + audioChunks.value = []; + + audioContext.value = new (window.AudioContext || window.webkitAudioContext)(); + analyser.value = audioContext.value.createAnalyser(); + analyser.value.smoothingTimeConstant = 0.9; + const source = audioContext.value.createMediaStreamSource(stream); + + analyser.value.fftSize = 2048; + analyser.value.smoothingTimeConstant = 0.7; + source.connect(analyser.value); + + const bufferLength = analyser.value.fftSize; + dataArray.value = new Uint8Array(bufferLength); + + mediaRecorder.value.ondataavailable = (event) => { + audioChunks.value.push(event.data); + }; + + mediaRecorder.value.onstop = () => { + const audioBlob = new Blob(audioChunks.value, { type: "audio/wav" }); + voiceMessage.value = audioBlob; + stopAudioAnalysis(); + }; + + mediaRecorder.value.start(); + isRecording.value = true; + recordingStartTime.value = Date.now(); + + recordingInterval.value = setInterval(() => { + recordingDuration.value = Math.floor((Date.now() - recordingStartTime.value) / 1000); + }, 1000); + + nextTick(() => { + if (waveformCanvas.value) { + canvasCtx.value = waveformCanvas.value.getContext("2d"); + drawWaveform(); + } + }); + } catch (error) { + console.error("Error accessing microphone:", error); + } +}; + + +const drawWaveform = () => { + if (!isRecording.value || !analyser.value || !canvasCtx.value) return; + + analyser.value.getByteTimeDomainData(dataArray.value); + + const canvas = waveformCanvas.value; + const ctx = canvasCtx.value; + const width = canvas.width; + const height = canvas.height; + + ctx.clearRect(0, 0, width, height); + + ctx.lineWidth = 2; + ctx.strokeStyle = `rgb(${getComputedStyle(document.documentElement) + .getPropertyValue(`--v-theme-${activeModelColor.value}`) + .trim()})`; + + ctx.beginPath(); + + const sliceWidth = (width * 1.0) / dataArray.value.length; + let x = 0; + + for (let i = 0; i < dataArray.value.length; i++) { + const v = dataArray.value[i] / 128.0; + const y = (v * height) / 2; + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + + x += sliceWidth; + } + + ctx.lineTo(width, height / 2); + ctx.stroke(); + + animationFrame.value = requestAnimationFrame(drawWaveform); +}; + + +const animateWaveform = () => { + if (!isRecording.value || !analyser.value) return; + + analyser.value.getByteFrequencyData(dataArray.value); + + const chunkSize = Math.floor(dataArray.value.length / 50); + + for (let i = 0; i < 50; i++) { + let sum = 0; + const start = i * chunkSize; + const end = Math.min(start + chunkSize, dataArray.value.length); + + for (let j = start; j < end; j++) { + sum += dataArray.value[j]; + } + + const average = sum / (end - start); + let normalizedValue = (average / 255) * 100; + + waveformData.value[i] = waveformData.value[i] * 0.7 + normalizedValue * 0.3; + } + + updateWaveformBars(); + animationFrame.value = requestAnimationFrame(() => animateWaveform()); +}; + +const updateWaveformBars = () => { + const bars = document.querySelectorAll('.waveform-bar'); + bars.forEach((bar, index) => { + if (index < waveformData.value.length) { + const height = Math.max(6, Math.min(20, 6 + (waveformData.value[index] * 0.14))); + bar.style.height = `${height}px`; + } + }); +}; + + +const stopRecording = () => { + if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') { + mediaRecorder.value.stop(); + isRecording.value = false; + + if (recordingInterval.value) { + clearInterval(recordingInterval.value); + recordingInterval.value = null; + } + } +}; + +const cancelRecording = () => { + if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') { + mediaRecorder.value.onstop = null; + mediaRecorder.value.stop(); + } + + stopAudioAnalysis(); + + if (audioStream.value) { + audioStream.value.getTracks().forEach(track => track.stop()); + audioStream.value = null; + } + + if (recordingInterval.value) { + clearInterval(recordingInterval.value); + recordingInterval.value = null; + } + + mediaRecorder.value = null; + isRecording.value = false; + voiceMessage.value = null; + recordingDuration.value = 0; + recordingStartTime.value = null; + waveformData.value = new Array(50).fill(0); + audioChunks.value = []; +}; +const stopAudioAnalysis = () => { + if (animationFrame.value) { + cancelAnimationFrame(animationFrame.value); + animationFrame.value = null; + } + if (audioContext.value && audioContext.value.state !== 'closed') { + audioContext.value.close(); + audioContext.value = null; + } + if (audioStream.value) { + audioStream.value.getTracks().forEach(track => track.stop()); + audioStream.value = null; + } +}; + +const sendVoiceMessage = async () => { + if (!voiceMessage.value) return; + + isQuickRepliesVisible.value = false; + + isRequestCancelled.value = false; + if (isInputCentered.value) isInputCentered.value = false; + + messageQueue.value.push({ + text: "Voice message sent", + sender: "user", + isVoiceMessage: true, + voiceBlob: voiceMessage.value, + duration: recordingDuration.value + }); + + const voiceBlob = voiceMessage.value; + const duration = recordingDuration.value; + voiceMessage.value = null; + recordingDuration.value = 0; + + await nextTick(); + scrollToBottom(); + + if (!isProcessingQueue.value && messageQueue.value.length > 0) { + processMessageQueue(); + } + + isBotTyping.value = true; + abortController.value = new AbortController(); + + try { + const formData = new FormData(); + formData.append('voice', voiceBlob, 'voice_message.wav'); + + const response = await fetch("/dev/voice", { + method: "POST", + body: formData, + signal: abortController.value.signal, + }); + + if (response.ok) { + const result = await response.json(); + const responseText = result.response || result.message || result; + const cleanText = cleanHtmlResponse(responseText); + messageQueue.value.push({ text: cleanText, sender: "bot" }); + } else { + messageQueue.value.push({ + text: "خطا در پردازش پیام صوتی. لطفاً دوباره امتحان کنید.", + sender: "bot", + }); + } + } catch (error) { + console.error("Voice API Error:", error); + if (error.name !== "AbortError") { + messageQueue.value.push({ + text: "خطا در ارسال پیام صوتی. لطفاً اتصال را چک کنید.", + sender: "bot", + }); + } + } finally { + isBotTyping.value = false; + } + + await nextTick(); + scrollToBottom(); +}; + +const isVoiceRecordingAvailable = computed(() => { + return !isInputDisabled.value && !isConfirmationActive.value && !activeForm.value && !activeMultiForm.value; +}); + + // Suggestion database const suggestionDatabase = { پروژه: ["New Project ایجاد کنم", "پروژه وب سایت", "پروژه اپلیکیشن موبایل", "پروژه سیستم مدیریت", "پروژه فروشگاه آنلاین", "پروژه های انجام شده", "پروژه در حال اجرا"], @@ -254,6 +533,11 @@ const findNextValidStep = (form, currentStepIndex, formData) => { return null; }; +const rtlRegex = /[\u0600-\u06FF\u0750-\u077F]/; +function getDir(str = "") { + return rtlRegex.test(str) ? "rtl" : "ltr"; +} + const getSuggestions = (input) => { if (!input || input.length < 2) return []; @@ -473,6 +757,11 @@ const startMultiForm = (formId) => { const sendMessage = async () => { if (isConfirmationActive.value) return; + if (voiceMessage.value) { + await sendVoiceMessage(); + return; + } + const userMsg = msg.value.trim(); if (!userMsg && selectedAttachments.value.length === 0) return; @@ -485,7 +774,10 @@ const sendMessage = async () => { if (userMsg === "/cancel") { activeForm.value = null; activeMultiForm.value = null; - messageQueue.value.push({ text: "The form has been cancelled", sender: "bot" }); + messageQueue.value.push({ + text: "The form has been cancelled", + sender: "bot", + }); msg.value = ""; return; } @@ -493,16 +785,27 @@ const sendMessage = async () => { isRequestCancelled.value = false; if (isInputCentered.value) isInputCentered.value = false; - // Handle file uploads if (selectedAttachments.value.length > 0) { - const fileNames = selectedAttachments.value.map(f => f.name).join(", "); + const fileNames = selectedAttachments.value.map((f) => f.name).join(", "); messageQueue.value.push({ text: userMsg || `Files sent: ${fileNames}`, sender: "user", isAttachment: true, - files: [...selectedAttachments.value] + files: [...selectedAttachments.value], }); + selectedAttachments.value = []; + msg.value = ""; + + messageQueue.value.push({ + text: "فایل‌ها با موفقیت دریافت شد ✅", + sender: "bot", + isSystemMessage: true + }); + + await nextTick(); + scrollToBottom(); + return; } else { messageQueue.value.push({ text: userMsg, sender: "user" }); } @@ -515,7 +818,6 @@ const sendMessage = async () => { processMessageQueue(); } - // Handle different message types if (activeMultiForm.value) { const formResponse = handleMultiFormResponse(userMsg); if (formResponse.completed) { @@ -526,35 +828,42 @@ const sendMessage = async () => { messageQueue.value.push({ sender: "bot", multiForm: formResponse.nextStep, - type: "multi-form" + type: "multi-form", }); } + return; } else if (activeForm.value) { const formResponse = handleFormResponse(userMsg); messageQueue.value.push({ text: formResponse, sender: "bot" }); activeForm.value = null; + return; } else { const botReply = getBotReply(userMsg); if (botReply && typeof botReply === "object") { if (botReply.type === "multi-form") { isBotTyping.value = true; - await new Promise(r => setTimeout(r, 500)); - activeMultiForm.value = botReply.formId; - currentFormStep.value = 0; - multiFormData.value = {}; - const form = forms[botReply.formId]; - messageQueue.value.push({ - sender: "bot", - multiForm: { - ...form.steps[0], - stepNumber: 1, - totalSteps: form.steps.length, - formTitle: form.title - }, - type: "multi-form" - }); - isBotTyping.value = false; + try { + await new Promise((r) => setTimeout(r, 500)); + activeMultiForm.value = botReply.formId; + currentFormStep.value = 0; + multiFormData.value = {}; + const form = forms[botReply.formId]; + messageQueue.value.push({ + sender: "bot", + multiForm: { + ...form.steps[0], + stepNumber: 1, + totalSteps: form.steps.length, + formTitle: form.title, + }, + type: "multi-form", + }); + } catch (error) { + console.error("Multi-form setup error:", error); + } finally { + isBotTyping.value = false; + } } else if (botReply.type === "form") { activeForm.value = botReply.formId; const form = forms[botReply.formId]; @@ -565,43 +874,75 @@ const sendMessage = async () => { id: form.id, title: form.title, question: form.question, - options: form.options + options: form.options, }, - type: "form" + type: "form", }); } return; } else if (typeof botReply === "string") { messageQueue.value.push({ text: botReply, sender: "bot" }); + return; } else { - // API call isBotTyping.value = true; abortController.value = new AbortController(); + const SHOW_GENERIC_ERROR_IN_CHAT = true; + try { - const response = await fetch("/CallChatService.php", { + console.log("Sending message to server:", userMsg); + + const response = await fetch("/dev/chatbot", { method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ message: userMsg }), - signal: abortController.value.signal + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ message: userMsg }), + signal: abortController.value.signal, }); + console.log("Response status:", response.status); + console.log("Response ok:", response.ok); + if (response.ok) { - const resultText = await response.text(); - const cleanText = cleanHtmlResponse(resultText); + const result = await response.json(); + const responseText = result.response || result.message || result; + const cleanText = cleanHtmlResponse(responseText); messageQueue.value.push({ text: cleanText, sender: "bot" }); } else { - messageQueue.value.push({ text: "Error receiving response from server", sender: "bot" }); + let serverText = ""; + try { + serverText = await response.text(); + } catch (e) { } + console.error(`Server error (${response.status})`, serverText); + if (SHOW_GENERIC_ERROR_IN_CHAT) { + messageQueue.value.push({ + text: "چت‌بات با مشکل روبه‌رو شد. لطفاً دوباره امتحان کن.", + sender: "bot", + }); + } + return; } - } catch { - if (!isRequestCancelled.value) { - messageQueue.value.push({ text: "Could not connect to the server", sender: "bot" }); + } catch (error) { + console.error("Chatbot API Error:", error); + if (error.name === "AbortError") { + console.log("Request was aborted"); + return; } + if (SHOW_GENERIC_ERROR_IN_CHAT) { + messageQueue.value.push({ + text: "چت‌بات با مشکل روبه‌رو شد. لطفاً اتصال را چک کن و دوباره تلاش کن.", + sender: "bot", + }); + } + return; + } finally { + isBotTyping.value = false; } } } - isBotTyping.value = false; await nextTick(); scrollToBottom(); }; @@ -1134,6 +1475,9 @@ watch([activeForm, activeMultiForm, isConfirmationActive], ([form, multiForm, co if (form !== null || multiForm !== null || confirmation) { console.log('Quick replies hidden due to active form'); } + if (voiceMessage.value) { + voiceMessage.value = null; + } }); // Lifecycle @@ -1147,6 +1491,7 @@ const models = MODELS; const commands = COMMANDS; + -
-
+
+ + Voice message ({{ message.duration }}s) +
+ +
{{ message.text }}
@@ -1239,6 +1590,16 @@ const commands = COMMANDS;
+ +
+ + + Submit + +
@@ -1251,7 +1612,7 @@ const commands = COMMANDS; {{ key }}: {{ Array.isArray(value) ? value.join(", ") : value - }} + }}
@@ -1326,9 +1687,11 @@ const commands = COMMANDS; 'centered-input': isInputCentered, 'bottom-input': !isInputCentered, }"> -
+
@@ -1338,142 +1701,162 @@ const commands = COMMANDS;
-
- -
- -
-
- Suggestions +
+
+
-
- - {{ suggestion }} +
+
{{ formatRecordingTime(recordingDuration) }}
+
+ + + + + + +
-
-
- {{ cmd }} + + +
+
+ + Voice message ready + ({{ recordingDuration }}s) +
+
+ + + + + +
-
-
- - - - - - - - - - - - Scenario - - - - - - - - - - - {{ option }} - - - - - - +
+
+
- - - +
+
+ Suggestions +
+
+ + {{ suggestion }} +
+
- - - +
+
+ {{ cmd }} +
+
+ +
+
+ + + + + + + + + + + + + + + + Scenario + + + + + + + + + + + {{ option }} + + + + +
+ + + + + + + + +
-
+
+ --hover-bg-color: rgba(var(--v-theme-${activeModelColor}), 0.1); + --hover-border-color: rgba(var(--v-theme-${activeModelColor}), 0.8)`" @click="selectQuickReply(reply)"> {{ reply }}
@@ -1524,7 +1907,7 @@ const commands = COMMANDS; {{ file.name }} {{ getFileSize(file.size) - }} + }}