-
Notifications
You must be signed in to change notification settings - Fork 20
feat: add model-aware voice input with audio support detection #621
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,7 +1,7 @@ | ||||||||||||||||||||||||||||||
| "use client"; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| import { Bot, MessageSquare, Paperclip, User as UserIcon } from "lucide-react"; | ||||||||||||||||||||||||||||||
| import { useEffect, useRef, useState } from "react"; | ||||||||||||||||||||||||||||||
| import { useEffect, useMemo, useRef, useState } from "react"; | ||||||||||||||||||||||||||||||
| import { toast } from "sonner"; | ||||||||||||||||||||||||||||||
| import type { Message as ChatMessage } from "@/app/(dashboard)/_schema"; | ||||||||||||||||||||||||||||||
| import { ConversationAutoScroll } from "@/components/ai-elements/conversation-auto-scroll"; | ||||||||||||||||||||||||||||||
|
|
@@ -21,8 +21,14 @@ import { | |||||||||||||||||||||||||||||
| } from "@/components/ai-elements/prompt-input"; | ||||||||||||||||||||||||||||||
| import { Response } from "@/components/ai-elements/response"; | ||||||||||||||||||||||||||||||
| import { Button } from "@/components/ui/button"; | ||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||
| Tooltip, | ||||||||||||||||||||||||||||||
| TooltipContent, | ||||||||||||||||||||||||||||||
| TooltipTrigger, | ||||||||||||||||||||||||||||||
| } from "@/components/ui/tooltip"; | ||||||||||||||||||||||||||||||
| import { useChatAttachments } from "@/hooks/use-chat-attachments"; | ||||||||||||||||||||||||||||||
| import useVoiceRecording from "@/hooks/use-voice-recording"; | ||||||||||||||||||||||||||||||
| import { getAudioUnsupportedMessage } from "@/lib/model-capabilities"; | ||||||||||||||||||||||||||||||
| import { cn } from "@/lib/utils"; | ||||||||||||||||||||||||||||||
| import type { AgentListItemDto as Agent } from "../Api"; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
@@ -57,6 +63,51 @@ export function ChatPanel({ | |||||||||||||||||||||||||||||
| isDragOver, | ||||||||||||||||||||||||||||||
| } = useChatAttachments(); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Infer model name from agent name or path | ||||||||||||||||||||||||||||||
| // This is a best-effort approach; we can enhance this later with actual model info from backend | ||||||||||||||||||||||||||||||
| // Handles: | ||||||||||||||||||||||||||||||
| // - Direct model names in agent name: "gpt-4o-agent", "gemini-agent" | ||||||||||||||||||||||||||||||
| // - OpenRouter format in path: "agents/openai-gpt-4o-agent" | ||||||||||||||||||||||||||||||
| // - Common patterns: "gpt4o", "gemini-2.5", etc. | ||||||||||||||||||||||||||||||
| const inferredModelName = useMemo(() => { | ||||||||||||||||||||||||||||||
| if (!selectedAgent) return null; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const name = selectedAgent.name.toLowerCase(); | ||||||||||||||||||||||||||||||
| const path = selectedAgent.relativePath?.toLowerCase() || ""; | ||||||||||||||||||||||||||||||
| const combined = `${name} ${path}`; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Check for OpenRouter format patterns (provider/model) | ||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||
| combined.includes("openai/gpt-4o") || | ||||||||||||||||||||||||||||||
| combined.includes("openai/gpt4o") | ||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||
| return "openai/gpt-4o"; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| if (combined.includes("google/gemini")) { | ||||||||||||||||||||||||||||||
| return "google/gemini-2.5-flash"; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Check for direct model patterns in agent name | ||||||||||||||||||||||||||||||
| if (name.includes("gpt-4o") || name.includes("gpt4o")) return "gpt-4o"; | ||||||||||||||||||||||||||||||
| if (name.includes("gemini")) { | ||||||||||||||||||||||||||||||
| // Try to extract specific version if present | ||||||||||||||||||||||||||||||
| const geminiMatch = name.match(/gemini[-\s]?([\d.]+)?/); | ||||||||||||||||||||||||||||||
| if (geminiMatch?.[1]) { | ||||||||||||||||||||||||||||||
| return `gemini-${geminiMatch[1]}`; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| return "gemini-2.5-flash"; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| if (name.includes("gpt-4") || name.includes("gpt4")) return "gpt-4"; | ||||||||||||||||||||||||||||||
| if (name.includes("gpt-3.5")) return "gpt-3.5-turbo"; | ||||||||||||||||||||||||||||||
| if (name.includes("claude")) return "claude-3-5-sonnet"; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Check path for model indicators | ||||||||||||||||||||||||||||||
| if (path.includes("gpt-4o") || path.includes("gpt4o")) return "gpt-4o"; | ||||||||||||||||||||||||||||||
| if (path.includes("gemini")) return "gemini-2.5-flash"; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||
| }, [selectedAgent]); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const { | ||||||||||||||||||||||||||||||
| recording, | ||||||||||||||||||||||||||||||
| error, | ||||||||||||||||||||||||||||||
|
|
@@ -65,7 +116,8 @@ export function ChatPanel({ | |||||||||||||||||||||||||||||
| startRecording, | ||||||||||||||||||||||||||||||
| stopRecording, | ||||||||||||||||||||||||||||||
| clearAudio, | ||||||||||||||||||||||||||||||
| } = useVoiceRecording(); | ||||||||||||||||||||||||||||||
| audioSupported, | ||||||||||||||||||||||||||||||
| } = useVoiceRecording({ modelName: inferredModelName }); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const handleSubmit = (e: React.FormEvent) => { | ||||||||||||||||||||||||||||||
| e.preventDefault(); | ||||||||||||||||||||||||||||||
|
|
@@ -84,13 +136,26 @@ export function ChatPanel({ | |||||||||||||||||||||||||||||
| const handleVoiceRecording = async () => { | ||||||||||||||||||||||||||||||
| if (recording) { | ||||||||||||||||||||||||||||||
| // Stop recording and get both the audio file and transcript | ||||||||||||||||||||||||||||||
| const { file, transcript } = await stopRecording(); | ||||||||||||||||||||||||||||||
| const { file, transcript, hasValidTranscript } = await stopRecording(); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if (file) { | ||||||||||||||||||||||||||||||
| // Check if we have valid transcription | ||||||||||||||||||||||||||||||
| if (!hasValidTranscript) { | ||||||||||||||||||||||||||||||
| toast.error( | ||||||||||||||||||||||||||||||
| "Transcription failed or is too short. Please try speaking more clearly or use text input.", | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| clearAudio(); | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Use the transcribed text as the message | ||||||||||||||||||||||||||||||
| // If transcription failed or is empty, use a fallback message | ||||||||||||||||||||||||||||||
| const messageText = | ||||||||||||||||||||||||||||||
| transcript?.trim() || "Voice message (transcription unavailable)"; | ||||||||||||||||||||||||||||||
| const messageText = transcript?.trim() || ""; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if (!messageText) { | ||||||||||||||||||||||||||||||
| toast.error("No transcription available. Please try again."); | ||||||||||||||||||||||||||||||
| clearAudio(); | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Send the transcribed text along with the audio file | ||||||||||||||||||||||||||||||
| // The agent receives the text message, and optionally the audio file as attachment | ||||||||||||||||||||||||||||||
|
|
@@ -320,12 +385,36 @@ export function ChatPanel({ | |||||||||||||||||||||||||||||
| </PromptInputButton> | ||||||||||||||||||||||||||||||
| </PromptInputTools> | ||||||||||||||||||||||||||||||
| <div> | ||||||||||||||||||||||||||||||
| <PromptInputMicButton | ||||||||||||||||||||||||||||||
| variant={"secondary"} | ||||||||||||||||||||||||||||||
| status={{ recording }} | ||||||||||||||||||||||||||||||
| onClick={handleVoiceRecording} | ||||||||||||||||||||||||||||||
| disabled={isLoading || isSendingMessage} | ||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||
| {audioSupported ? ( | ||||||||||||||||||||||||||||||
| <PromptInputMicButton | ||||||||||||||||||||||||||||||
| variant={"secondary"} | ||||||||||||||||||||||||||||||
| status={{ recording }} | ||||||||||||||||||||||||||||||
| onClick={handleVoiceRecording} | ||||||||||||||||||||||||||||||
| disabled={isLoading || isSendingMessage} | ||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||
| ) : ( | ||||||||||||||||||||||||||||||
| <Tooltip> | ||||||||||||||||||||||||||||||
| <TooltipTrigger asChild> | ||||||||||||||||||||||||||||||
| <div> | ||||||||||||||||||||||||||||||
| <PromptInputMicButton | ||||||||||||||||||||||||||||||
| variant={"secondary"} | ||||||||||||||||||||||||||||||
| onClick={() => { | ||||||||||||||||||||||||||||||
| // Show tooltip message | ||||||||||||||||||||||||||||||
| toast.error( | ||||||||||||||||||||||||||||||
| getAudioUnsupportedMessage(inferredModelName), | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||
| disabled={true} | ||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||
|
Comment on lines
+362
to
+365
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| </TooltipTrigger> | ||||||||||||||||||||||||||||||
| <TooltipContent> | ||||||||||||||||||||||||||||||
| <p className="max-w-xs"> | ||||||||||||||||||||||||||||||
| {getAudioUnsupportedMessage(inferredModelName)} | ||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||
| </TooltipContent> | ||||||||||||||||||||||||||||||
| </Tooltip> | ||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||
| <PromptInputSubmit | ||||||||||||||||||||||||||||||
| status={isSendingMessage ? "streaming" : "ready"} | ||||||||||||||||||||||||||||||
| disabled={ | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,8 +3,33 @@ import { | |||||||||||||||||||||
| isSpeechRecognitionSupported, | ||||||||||||||||||||||
| startTranscription, | ||||||||||||||||||||||
| } from "@/lib/transcribe-audio"; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const useVoiceRecording = () => { | ||||||||||||||||||||||
| import { supportsAudioInput } from "@/lib/model-capabilities"; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| interface UseVoiceRecordingOptions { | ||||||||||||||||||||||
| modelName?: string | null; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Validates if transcribed text has meaningful content | ||||||||||||||||||||||
| * Checks for minimum length and non-placeholder text | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| function isValidTranscript(text: string): boolean { | ||||||||||||||||||||||
| const trimmed = text.trim(); | ||||||||||||||||||||||
| // Minimum 3 characters to be considered valid | ||||||||||||||||||||||
| if (trimmed.length < 3) return false; | ||||||||||||||||||||||
| // Check if it's not just placeholder text | ||||||||||||||||||||||
| const placeholders = [ | ||||||||||||||||||||||
| "voice message", | ||||||||||||||||||||||
| "transcription unavailable", | ||||||||||||||||||||||
| "listening", | ||||||||||||||||||||||
| "recording", | ||||||||||||||||||||||
| ]; | ||||||||||||||||||||||
| const lower = trimmed.toLowerCase(); | ||||||||||||||||||||||
| return !placeholders.some((placeholder) => lower.includes(placeholder)); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const useVoiceRecording = (options?: UseVoiceRecordingOptions) => { | ||||||||||||||||||||||
| const { modelName } = options || {}; | ||||||||||||||||||||||
| const [recording, setRecording] = useState(false); | ||||||||||||||||||||||
| const [audioFile, setAudioFile] = useState<File | null>(null); | ||||||||||||||||||||||
| const [error, setError] = useState<string | null>(null); | ||||||||||||||||||||||
|
|
@@ -16,7 +41,18 @@ const useVoiceRecording = () => { | |||||||||||||||||||||
| const stopTranscriptionRef = useRef<(() => void) | null>(null); | ||||||||||||||||||||||
| const accumulatedTranscriptRef = useRef<string>(""); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Check if model supports audio | ||||||||||||||||||||||
| const audioSupported = supportsAudioInput(modelName); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const startRecording = useCallback(async () => { | ||||||||||||||||||||||
| // Check if model supports audio | ||||||||||||||||||||||
| if (!audioSupported) { | ||||||||||||||||||||||
| setError( | ||||||||||||||||||||||
| "Voice input is not supported for this model. Please use GPT-4o or Gemini models.", | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| return; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| setError(null); | ||||||||||||||||||||||
| setAudioFile(null); | ||||||||||||||||||||||
|
|
@@ -96,11 +132,12 @@ const useVoiceRecording = () => { | |||||||||||||||||||||
| setError(errorMessage); | ||||||||||||||||||||||
| console.error("Error starting recording:", err); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||
| }, [audioSupported]); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const stopRecording = useCallback(async (): Promise<{ | ||||||||||||||||||||||
| file: File | null; | ||||||||||||||||||||||
| transcript: string; | ||||||||||||||||||||||
| hasValidTranscript: boolean; | ||||||||||||||||||||||
| }> => { | ||||||||||||||||||||||
| return new Promise((resolve) => { | ||||||||||||||||||||||
| // Step 1: Stop transcription first | ||||||||||||||||||||||
|
|
@@ -113,10 +150,15 @@ const useVoiceRecording = () => { | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Step 2: Get the final transcribed text | ||||||||||||||||||||||
| const finalTranscript = accumulatedTranscriptRef.current.trim(); | ||||||||||||||||||||||
| const hasValidTranscript = isValidTranscript(finalTranscript); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (!mediaRecorderRef.current) { | ||||||||||||||||||||||
| setRecording(false); | ||||||||||||||||||||||
| resolve({ file: null, transcript: finalTranscript }); | ||||||||||||||||||||||
| resolve({ | ||||||||||||||||||||||
| file: null, | ||||||||||||||||||||||
| transcript: finalTranscript, | ||||||||||||||||||||||
| hasValidTranscript, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| return; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -143,20 +185,30 @@ const useVoiceRecording = () => { | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Clean up microphone stream | ||||||||||||||||||||||
| if (streamRef.current) { | ||||||||||||||||||||||
| streamRef.current.getTracks().forEach((track) => track.stop()); | ||||||||||||||||||||||
| streamRef.current.getTracks().forEach((track) => { | ||||||||||||||||||||||
| track.stop(); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| streamRef.current = null; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| mediaRecorderRef.current = null; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Return both the file and the transcript | ||||||||||||||||||||||
| resolve({ file, transcript: finalTranscript }); | ||||||||||||||||||||||
| resolve({ | ||||||||||||||||||||||
| file, | ||||||||||||||||||||||
| transcript: finalTranscript, | ||||||||||||||||||||||
| hasValidTranscript: isValidTranscript(finalTranscript), | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
Comment on lines
+196
to
+200
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (mediaRecorderRef.current.state !== "inactive") { | ||||||||||||||||||||||
| mediaRecorderRef.current.stop(); | ||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| setRecording(false); | ||||||||||||||||||||||
| resolve({ file: null, transcript: finalTranscript }); | ||||||||||||||||||||||
| resolve({ | ||||||||||||||||||||||
| file: null, | ||||||||||||||||||||||
| transcript: finalTranscript, | ||||||||||||||||||||||
| hasValidTranscript, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||
|
|
@@ -177,6 +229,7 @@ const useVoiceRecording = () => { | |||||||||||||||||||||
| startRecording, | ||||||||||||||||||||||
| stopRecording, | ||||||||||||||||||||||
| clearAudio, | ||||||||||||||||||||||
| audioSupported, | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This
useMemohook for inferring the model name contains a significant amount of complex logic. To improve separation of concerns and make this component cleaner, this logic should be extracted into a dedicated function withinapps/adk-web/lib/model-capabilities.ts. This will centralize all model-related inference and capability-checking logic in one place, making it easier to maintain and test.For example, you could create a function
inferModelNameFromAgent(agent: Agent): string | nullinmodel-capabilities.tsand call it from here.