Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 101 additions & 12 deletions apps/adk-web/components/chat-panel.tsx
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";
Expand All @@ -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";

Expand Down Expand Up @@ -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]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This useMemo hook 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 within apps/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 | null in model-capabilities.ts and call it from here.


const {
recording,
error,
Expand All @@ -65,7 +116,8 @@ export function ChatPanel({
startRecording,
stopRecording,
clearAudio,
} = useVoiceRecording();
audioSupported,
} = useVoiceRecording({ modelName: inferredModelName });

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
Expand All @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The onClick handler on this disabled PromptInputMicButton will not be triggered by user clicks because the button's disabled prop is set to true. The toast.error will never be shown. The tooltip already provides sufficient information to the user about why the button is disabled. This onClick handler is effectively dead code and should be removed for clarity.

Suggested change
<PromptInputMicButton
variant={"secondary"}
onClick={() => {
// Show tooltip message
toast.error(
getAudioUnsupportedMessage(inferredModelName),
);
}}
disabled={true}
/>
<PromptInputMicButton
variant={"secondary"}
disabled={true}
/>

</div>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs">
{getAudioUnsupportedMessage(inferredModelName)}
</p>
</TooltipContent>
</Tooltip>
)}
<PromptInputSubmit
status={isSendingMessage ? "streaming" : "ready"}
disabled={
Expand Down
67 changes: 60 additions & 7 deletions apps/adk-web/hooks/use-voice-recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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;
}

Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The isValidTranscript function is being called again here, but its result has already been calculated and stored in the hasValidTranscript variable on line 153. To avoid this redundant computation, you should reuse the hasValidTranscript variable that is already in scope.

Suggested change
resolve({
file,
transcript: finalTranscript,
hasValidTranscript: isValidTranscript(finalTranscript),
});
resolve({
file,
transcript: finalTranscript,
hasValidTranscript,
});

};

if (mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop();
} else {
setRecording(false);
resolve({ file: null, transcript: finalTranscript });
resolve({
file: null,
transcript: finalTranscript,
hasValidTranscript,
});
}
});
}, []);
Expand All @@ -177,6 +229,7 @@ const useVoiceRecording = () => {
startRecording,
stopRecording,
clearAudio,
audioSupported,
};
};

Expand Down
Loading
Loading