feat(voice): mic button in chat widget with press-and-hold recording
All checks were successful
build-and-release / build (push) Successful in 1m52s
All checks were successful
build-and-release / build (push) Successful in 1m52s
- Imports onCleanup, Mic, MicOff from solid-js / lucide-solid - [isRecording, isTranscribing] signals track state across the flow - startRecording(): requests getUserMedia, creates MediaRecorder (prefers audio/webm;codecs=opus, falls back to webm then ogg), starts recording - stopRecording(): stops MediaRecorder; onstop handler assembles Blob, POSTs to /api/ai/voice/transcribe as multipart 'audio' field - transcribeAudio(): sends the Blob, populates input() with the transcript, then auto-sends after 600ms so the user sees it before it flies - Mic button: grey at rest → red while recording → spinner while transcribing; uses onPointerDown/Up/Leave for reliable hold UX - Red recording hint bar below input with pulsing dot while active - onCleanup stops any in-progress MediaRecorder on widget unmount Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ff2dde1900
commit
d8947a8357
1 changed files with 136 additions and 2 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { createSignal, Show, For, onMount } from "solid-js";
|
||||
import { MessageCircle, X, Send, Bot, User, Loader } from "lucide-solid";
|
||||
import { createSignal, Show, For, onMount, onCleanup } from "solid-js";
|
||||
import { MessageCircle, X, Send, Bot, User, Loader, Mic, MicOff } from "lucide-solid";
|
||||
|
||||
const API = "/api";
|
||||
|
||||
|
|
@ -110,6 +110,82 @@ export function AiChatWidget() {
|
|||
const [conversationId, setConversationId] = createSignal("");
|
||||
const [usage, setUsage] = createSignal<UsageStatus | null>(null);
|
||||
|
||||
// ── Voice recording state ─────────────────────────────────────────────────
|
||||
const [isRecording, setIsRecording] = createSignal(false);
|
||||
const [isTranscribing, setIsTranscribing] = createSignal(false);
|
||||
let mediaRecorder: MediaRecorder | null = null;
|
||||
let recordedChunks: BlobPart[] = [];
|
||||
|
||||
onCleanup(() => {
|
||||
if (mediaRecorder && mediaRecorder.state !== "inactive") {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
});
|
||||
|
||||
const startRecording = async () => {
|
||||
if (isRecording() || isTranscribing()) return;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
recordedChunks = [];
|
||||
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
|
||||
? "audio/webm;codecs=opus"
|
||||
: MediaRecorder.isTypeSupported("audio/webm")
|
||||
? "audio/webm"
|
||||
: "audio/ogg";
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType });
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) recordedChunks.push(e.data);
|
||||
};
|
||||
mediaRecorder.onstop = async () => {
|
||||
// Stop all tracks so the mic indicator goes away
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
const blob = new Blob(recordedChunks, { type: mimeType });
|
||||
if (blob.size < 100) return; // too small — likely empty
|
||||
await transcribeAudio(blob, mimeType);
|
||||
};
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
} catch {
|
||||
// Mic permission denied or not available — fail silently
|
||||
setIsRecording(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (!isRecording() || !mediaRecorder) return;
|
||||
setIsRecording(false);
|
||||
mediaRecorder.stop();
|
||||
};
|
||||
|
||||
const transcribeAudio = async (blob: Blob, mimeType: string) => {
|
||||
setIsTranscribing(true);
|
||||
try {
|
||||
const ext = mimeType.includes("ogg") ? "audio.ogg" : "audio.webm";
|
||||
const formData = new FormData();
|
||||
formData.append("audio", blob, ext);
|
||||
const res = await fetch(`${API}/ai/voice/transcribe`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${sessionStorage.getItem("nxtgauge_access_token") || ""}`,
|
||||
},
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const text: string = data.transcript || "";
|
||||
if (text.trim()) {
|
||||
setInput(text.trim());
|
||||
// Auto-send after a short pause so the user can see the text first
|
||||
setTimeout(() => sendMessage(), 600);
|
||||
}
|
||||
} catch {
|
||||
// Transcription failed — leave input as-is
|
||||
} finally {
|
||||
setIsTranscribing(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const hasToken = typeof window !== "undefined" && !!sessionStorage.getItem("nxtgauge_access_token");
|
||||
if (hasToken) fetchUsage();
|
||||
|
|
@ -696,6 +772,41 @@ export function AiChatWidget() {
|
|||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
{/* Mic button — hold to record, release to transcribe */}
|
||||
<button
|
||||
onPointerDown={startRecording}
|
||||
onPointerUp={stopRecording}
|
||||
onPointerLeave={stopRecording}
|
||||
disabled={isLoading() || isTranscribing()}
|
||||
aria-label={isRecording() ? "Stop recording" : "Hold to record voice message"}
|
||||
title={isRecording() ? "Release to send" : "Hold to record"}
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
"border-radius": "50%",
|
||||
background: isRecording()
|
||||
? "#EF4444"
|
||||
: isTranscribing()
|
||||
? "#E5E7EB"
|
||||
: "#F3F4F6",
|
||||
border: isRecording() ? "2px solid #FCA5A5" : "1px solid #E5E7EB",
|
||||
cursor: isLoading() || isTranscribing() ? "default" : "pointer",
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
"justify-content": "center",
|
||||
transition: "background 0.15s, border 0.15s",
|
||||
"flex-shrink": "0",
|
||||
}}
|
||||
>
|
||||
<Show when={isTranscribing()} fallback={
|
||||
<Show when={isRecording()} fallback={<Mic size={16} color="#6B7280" />}>
|
||||
<MicOff size={16} color="#fff" />
|
||||
</Show>
|
||||
}>
|
||||
<Loader size={14} color="#9CA3AF" style={{ animation: "spin 1s linear infinite" }} />
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={isLoading() || !input().trim()}
|
||||
|
|
@ -710,11 +821,30 @@ export function AiChatWidget() {
|
|||
display: "flex",
|
||||
"align-items": "center",
|
||||
"justify-content": "center",
|
||||
"flex-shrink": "0",
|
||||
}}
|
||||
>
|
||||
<Send size={16} color="#fff" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recording hint bar */}
|
||||
<Show when={isRecording()}>
|
||||
<div style={{
|
||||
padding: "6px 16px",
|
||||
background: "#FEF2F2",
|
||||
"border-top": "1px solid #FECACA",
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "8px",
|
||||
"font-size": "11px",
|
||||
color: "#EF4444",
|
||||
"font-weight": "600",
|
||||
}}>
|
||||
<span style={{ width: "8px", height: "8px", "border-radius": "50%", background: "#EF4444", display: "inline-block", animation: "pulse 1s ease-in-out infinite" }} />
|
||||
Recording… release to transcribe
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
|
@ -723,6 +853,10 @@ export function AiChatWidget() {
|
|||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue