@@ -11,7 +11,7 @@ export const JTBDAgentHeader: FC = () => {
Product Survey
- Share your experience with BrowserOS to help us improve
+ We'd love your honest feedback. All responses are anonymous.
diff --git a/apps/agent/entrypoints/options/jtbd-agent/jtbd-agent-page.tsx b/apps/agent/entrypoints/options/jtbd-agent/index.tsx
similarity index 83%
rename from apps/agent/entrypoints/options/jtbd-agent/jtbd-agent-page.tsx
rename to apps/agent/entrypoints/options/jtbd-agent/index.tsx
index 9717bef6..d1a50907 100644
--- a/apps/agent/entrypoints/options/jtbd-agent/jtbd-agent-page.tsx
+++ b/apps/agent/entrypoints/options/jtbd-agent/index.tsx
@@ -1,10 +1,10 @@
import { AlertCircle, CheckCircle2, RotateCcw } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
-import { JTBDAgentChat } from './jtbd-agent-chat'
-import { JTBDAgentHeader } from './jtbd-agent-header'
-import { JTBDAgentWelcome } from './jtbd-agent-welcome'
-import { useJTBDAgentChat } from './use-jtbd-agent-chat'
+import { Chat } from './chat'
+import { Header } from './header'
+import { useChat } from './use-chat'
+import { Welcome } from './welcome'
const ThankYouCard: FC<{ onReset: () => void }> = ({ onReset }) => (
@@ -42,19 +42,19 @@ const ErrorCard: FC<{ error: Error; onRetry: () => void }> = ({
)
-export const JTBDAgentPage: FC = () => {
- const chat = useJTBDAgentChat()
+export const SurveyPage: FC = () => {
+ const chat = useChat()
return (
-
+
{chat.phase === 'idle' && (
-
+
)}
{chat.phase === 'active' && (
-
('idle')
const [messages, setMessages] = useState([])
const [isStreaming, setIsStreaming] = useState(false)
diff --git a/apps/agent/entrypoints/options/jtbd-agent/use-voice-input.ts b/apps/agent/entrypoints/options/jtbd-agent/use-voice-input.ts
new file mode 100644
index 00000000..3cc8a25e
--- /dev/null
+++ b/apps/agent/entrypoints/options/jtbd-agent/use-voice-input.ts
@@ -0,0 +1,208 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+const GATEWAY_URL = 'https://llm.browseros.com'
+
+interface UseVoiceInputReturn {
+ isRecording: boolean
+ isTranscribing: boolean
+ transcript: string
+ audioLevel: number
+ error: string | null
+ startRecording: () => Promise
+ stopRecording: () => Promise
+ clearTranscript: () => void
+}
+
+async function transcribeAudio(audioBlob: Blob): Promise {
+ const formData = new FormData()
+ formData.append('file', audioBlob, 'recording.webm')
+ formData.append('response_format', 'json')
+
+ const response = await fetch(`${GATEWAY_URL}/api/transcribe`, {
+ method: 'POST',
+ body: formData,
+ })
+
+ if (!response.ok) {
+ const error = await response
+ .json()
+ .catch(() => ({ error: 'Transcription failed' }))
+ throw new Error(error.error || `Transcription failed: ${response.status}`)
+ }
+
+ const result = await response.json()
+ return result.text || ''
+}
+
+export function useVoiceInput(): UseVoiceInputReturn {
+ const [isRecording, setIsRecording] = useState(false)
+ const [isTranscribing, setIsTranscribing] = useState(false)
+ const [transcript, setTranscript] = useState('')
+ const [audioLevel, setAudioLevel] = useState(0)
+ const [error, setError] = useState(null)
+
+ const mediaRecorderRef = useRef(null)
+ const streamRef = useRef(null)
+ const chunksRef = useRef([])
+ const audioContextRef = useRef(null)
+ const analyserRef = useRef(null)
+ const animationFrameRef = useRef(null)
+
+ const stopAudioLevelMonitoring = useCallback(() => {
+ if (animationFrameRef.current) {
+ cancelAnimationFrame(animationFrameRef.current)
+ animationFrameRef.current = null
+ }
+ if (audioContextRef.current?.state !== 'closed') {
+ audioContextRef.current?.close()
+ }
+ audioContextRef.current = null
+ analyserRef.current = null
+ setAudioLevel(0)
+ }, [])
+
+ useEffect(() => {
+ return () => {
+ streamRef.current?.getTracks().forEach((track) => {
+ track.stop()
+ })
+ if (mediaRecorderRef.current?.state === 'recording') {
+ mediaRecorderRef.current.stop()
+ }
+ stopAudioLevelMonitoring()
+ }
+ }, [stopAudioLevelMonitoring])
+
+ const startAudioLevelMonitoring = useCallback((stream: MediaStream) => {
+ const audioContext = new AudioContext()
+ const analyser = audioContext.createAnalyser()
+ analyser.fftSize = 256
+
+ const source = audioContext.createMediaStreamSource(stream)
+ source.connect(analyser)
+
+ audioContextRef.current = audioContext
+ analyserRef.current = analyser
+
+ const updateLevel = () => {
+ if (!analyserRef.current) return
+
+ const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount)
+ analyserRef.current.getByteFrequencyData(dataArray)
+
+ const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length
+ const normalized = Math.min(100, (average / 128) * 100)
+ setAudioLevel(Math.round(normalized))
+
+ animationFrameRef.current = requestAnimationFrame(updateLevel)
+ }
+
+ updateLevel()
+ }, [])
+
+ const startRecording = useCallback(async () => {
+ try {
+ setError(null)
+ setTranscript('')
+ chunksRef.current = []
+
+ const stream = await navigator.mediaDevices.getUserMedia({
+ audio: {
+ channelCount: 1,
+ sampleRate: 16000,
+ echoCancellation: true,
+ noiseSuppression: true,
+ },
+ })
+
+ streamRef.current = stream
+ startAudioLevelMonitoring(stream)
+
+ const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
+ ? 'audio/webm;codecs=opus'
+ : 'audio/webm'
+
+ const mediaRecorder = new MediaRecorder(stream, { mimeType })
+ mediaRecorderRef.current = mediaRecorder
+
+ mediaRecorder.ondataavailable = (e) => {
+ if (e.data.size > 0) {
+ chunksRef.current.push(e.data)
+ }
+ }
+
+ mediaRecorder.start(250)
+ setIsRecording(true)
+ } catch (err) {
+ if (err instanceof Error) {
+ if (err.name === 'NotAllowedError') {
+ setError('Microphone permission denied')
+ } else if (err.name === 'NotFoundError') {
+ setError('No microphone found')
+ } else {
+ setError(err.message)
+ }
+ } else {
+ setError('Failed to start recording')
+ }
+ }
+ }, [startAudioLevelMonitoring])
+
+ const stopRecording = useCallback(async () => {
+ const mediaRecorder = mediaRecorderRef.current
+
+ if (!mediaRecorder || mediaRecorder.state === 'inactive') {
+ return
+ }
+
+ await new Promise((resolve) => {
+ mediaRecorder.onstop = () => resolve()
+ mediaRecorder.stop()
+ })
+
+ streamRef.current?.getTracks().forEach((track) => {
+ track.stop()
+ })
+ streamRef.current = null
+ stopAudioLevelMonitoring()
+ setIsRecording(false)
+
+ const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' })
+ chunksRef.current = []
+
+ if (audioBlob.size === 0) {
+ setError('No audio recorded')
+ return
+ }
+
+ setIsTranscribing(true)
+ try {
+ const text = await transcribeAudio(audioBlob)
+ if (text.trim()) {
+ setTranscript(text.trim())
+ } else {
+ setError('No speech detected')
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Transcription failed')
+ } finally {
+ setIsTranscribing(false)
+ }
+ }, [stopAudioLevelMonitoring])
+
+ const clearTranscript = useCallback(() => {
+ setTranscript('')
+ setError(null)
+ }, [])
+
+ return {
+ isRecording,
+ isTranscribing,
+ transcript,
+ audioLevel,
+ error,
+ startRecording,
+ stopRecording,
+ clearTranscript,
+ }
+}
diff --git a/apps/agent/entrypoints/options/jtbd-agent/voice-input-button.tsx b/apps/agent/entrypoints/options/jtbd-agent/voice-input-button.tsx
new file mode 100644
index 00000000..6da7d725
--- /dev/null
+++ b/apps/agent/entrypoints/options/jtbd-agent/voice-input-button.tsx
@@ -0,0 +1,53 @@
+import { Loader2, Mic, Square } from 'lucide-react'
+import type { FC } from 'react'
+import { Button } from '@/components/ui/button'
+
+interface Props {
+ isRecording: boolean
+ isTranscribing: boolean
+ audioLevel: number
+ disabled?: boolean
+ onStart: () => void
+ onStop: () => void
+}
+
+export const VoiceInputButton: FC = ({
+ isRecording,
+ isTranscribing,
+ audioLevel,
+ disabled,
+ onStart,
+ onStop,
+}) => {
+ if (isTranscribing) {
+ return (
+
+ )
+ }
+
+ if (isRecording) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/agent/entrypoints/options/jtbd-agent/jtbd-agent-welcome.tsx b/apps/agent/entrypoints/options/jtbd-agent/welcome.tsx
similarity index 92%
rename from apps/agent/entrypoints/options/jtbd-agent/jtbd-agent-welcome.tsx
rename to apps/agent/entrypoints/options/jtbd-agent/welcome.tsx
index cc47e94f..51e2db51 100644
--- a/apps/agent/entrypoints/options/jtbd-agent/jtbd-agent-welcome.tsx
+++ b/apps/agent/entrypoints/options/jtbd-agent/welcome.tsx
@@ -7,7 +7,7 @@ interface Props {
isLoading?: boolean
}
-export const JTBDAgentWelcome: FC = ({ onStart, isLoading }) => {
+export const Welcome: FC = ({ onStart, isLoading }) => {
return (