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 { createSignal, Show, For, onMount, onCleanup } from "solid-js";
|
||||||
import { MessageCircle, X, Send, Bot, User, Loader } from "lucide-solid";
|
import { MessageCircle, X, Send, Bot, User, Loader, Mic, MicOff } from "lucide-solid";
|
||||||
|
|
||||||
const API = "/api";
|
const API = "/api";
|
||||||
|
|
||||||
|
|
@ -110,6 +110,82 @@ export function AiChatWidget() {
|
||||||
const [conversationId, setConversationId] = createSignal("");
|
const [conversationId, setConversationId] = createSignal("");
|
||||||
const [usage, setUsage] = createSignal<UsageStatus | null>(null);
|
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(() => {
|
onMount(() => {
|
||||||
const hasToken = typeof window !== "undefined" && !!sessionStorage.getItem("nxtgauge_access_token");
|
const hasToken = typeof window !== "undefined" && !!sessionStorage.getItem("nxtgauge_access_token");
|
||||||
if (hasToken) fetchUsage();
|
if (hasToken) fetchUsage();
|
||||||
|
|
@ -696,6 +772,41 @@ export function AiChatWidget() {
|
||||||
outline: "none",
|
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
|
<button
|
||||||
onClick={sendMessage}
|
onClick={sendMessage}
|
||||||
disabled={isLoading() || !input().trim()}
|
disabled={isLoading() || !input().trim()}
|
||||||
|
|
@ -710,11 +821,30 @@ export function AiChatWidget() {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
"align-items": "center",
|
"align-items": "center",
|
||||||
"justify-content": "center",
|
"justify-content": "center",
|
||||||
|
"flex-shrink": "0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Send size={16} color="#fff" />
|
<Send size={16} color="#fff" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
@ -723,6 +853,10 @@ export function AiChatWidget() {
|
||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue