/** * @license * SPDX-License-Identifier: Apache-2.0 */ import React, { useEffect, useRef, useState, useCallback } from 'react'; // import WelcomeScreen from '../welcome-screen/WelcomeScreen'; // FIX: Import LiveServerContent to correctly type the content handler. import { LiveConnectConfig, Modality, LiveServerContent } from '@google/genai'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { useLiveAPIContext } from '../../contexts/LiveAPIContext'; import { useSettings, useLogStore, useTools, ConversationTurn, useUI, } from '@/lib/state'; import { SourcesPopover } from '../sources-popover/sources-popover'; import { GroundingWidget } from '../GroundingWidget'; const formatTimestamp = (date: Date) => { const pad = (num: number, size = 2) => num.toString().padStart(size, '0'); const hours = pad(date.getHours()); const minutes = pad(date.getMinutes()); const seconds = pad(date.getSeconds()); const milliseconds = pad(date.getMilliseconds(), 3); return `${hours}:${minutes}:${seconds}.${milliseconds}`; }; // Hook to detect screen size for responsive component rendering const useMediaQuery = (query: string) => { const [matches, setMatches] = useState(false); useEffect(() => { const media = window.matchMedia(query); if (media.matches !== matches) { setMatches(media.matches); } const listener = () => { setMatches(media.matches); }; media.addEventListener('change', listener); return () => media.removeEventListener('change', listener); }, [matches, query]); return matches; }; export default function StreamingConsole() { const { client, setConfig, heldGroundingChunks, clearHeldGroundingChunks, heldGroundedResponse, clearHeldGroundedResponse, } = useLiveAPIContext(); const { systemPrompt, voice } = useSettings(); const { tools } = useTools(); const turns = useLogStore(state => state.turns); const { showSystemMessages } = useUI(); const isAwaitingFunctionResponse = useLogStore( state => state.isAwaitingFunctionResponse, ); const scrollRef = useRef(null); const isMobile = useMediaQuery('(max-width: 768px)'); const displayedTurns = showSystemMessages ? turns : turns.filter(turn => turn.role !== 'system'); // Set the configuration for the Live API useEffect(() => { const enabledTools = tools .filter(tool => tool.isEnabled) .map(tool => ({ functionDeclarations: [ { name: tool.name, description: tool.description, parameters: tool.parameters, }, ], })); // Text-only configuration for agricultural form-based application const config: any = { responseModalities: [Modality.TEXT], systemInstruction: { parts: [ { text: systemPrompt, }, ], }, tools: enabledTools, thinkingConfig: { thinkingBudget: 0 }, }; setConfig(config); }, [setConfig, systemPrompt, tools]); useEffect(() => { const { addTurn, updateLastTurn, mergeIntoLastAgentTurn } = useLogStore.getState(); const handleContent = (serverContent: LiveServerContent) => { const { turns, updateLastTurn, addTurn, mergeIntoLastAgentTurn } = useLogStore.getState(); const text = serverContent.modelTurn?.parts ?.map((p: any) => p.text) .filter(Boolean) .join('') ?? ''; const groundingChunks = serverContent.groundingMetadata?.groundingChunks; if (!text && !groundingChunks) return; const last = turns[turns.length - 1]; if (last?.role === 'agent' && !last.isFinal) { const updatedTurn: Partial = { text: last.text + text, }; if (groundingChunks) { updatedTurn.groundingChunks = [ ...(last.groundingChunks || []), ...groundingChunks, ]; } updateLastTurn(updatedTurn); } else { const lastAgentTurnIndex = turns.map(t => t.role).lastIndexOf('agent'); let shouldMerge = false; if (lastAgentTurnIndex !== -1) { const subsequentTurns = turns.slice(lastAgentTurnIndex + 1); if ( subsequentTurns.length > 0 && subsequentTurns.every(t => t.role === 'system') ) { shouldMerge = true; } } const newTurnData: Omit = { text, isFinal: false, groundingChunks, }; if (heldGroundingChunks) { const combinedChunks = [ ...(heldGroundingChunks || []), ...(newTurnData.groundingChunks || []), ]; newTurnData.groundingChunks = combinedChunks; clearHeldGroundingChunks(); } if (heldGroundedResponse) { newTurnData.toolResponse = heldGroundedResponse; clearHeldGroundedResponse(); } if (shouldMerge) { mergeIntoLastAgentTurn(newTurnData); } else { addTurn({ ...newTurnData, role: 'agent' }); } } }; const handleTurnComplete = () => { const turns = useLogStore.getState().turns; // FIX: Replace .at(-1) with array indexing for broader compatibility. const last = turns[turns.length - 1]; if (last && !last.isFinal) { updateLastTurn({ isFinal: true }); } }; client.on('content', handleContent); client.on('turncomplete', handleTurnComplete); client.on('generationcomplete', handleTurnComplete); return () => { client.off('content', handleContent); client.off('turncomplete', handleTurnComplete); client.off('generationcomplete', handleTurnComplete); }; }, [ client, heldGroundingChunks, clearHeldGroundingChunks, heldGroundedResponse, clearHeldGroundedResponse, ]); useEffect(() => { if (scrollRef.current) { // The widget has a 300ms transition for max-height. We need to wait // for that transition to finish before we can accurately scroll to the bottom. const scrollTimeout = setTimeout(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, 350); // A little longer than the transition duration return () => clearTimeout(scrollTimeout); } }, [turns, isAwaitingFunctionResponse]); return (
{displayedTurns.length === 0 && !isAwaitingFunctionResponse ? (
) : (
{displayedTurns.map((t) => { if (t.role === 'system') { return (
System
{formatTimestamp(t.timestamp)}
{t.text}
) } const widgetToken = t.toolResponse?.candidates?.[0]?.groundingMetadata ?.googleMapsWidgetContextToken; let sources: { uri: string; title: string }[] = []; if (t.groundingChunks) { sources = t.groundingChunks .map(chunk => { const source = chunk.web || chunk.maps; if (source && source.uri) { return { uri: source.uri, title: source.title || source.uri, }; } return null; }) .filter((s): s is { uri: string; title: string } => s !== null); if (t.groundingChunks.length === 1) { const chunk = t.groundingChunks[0]; // The type for `placeAnswerSources` might be missing or incomplete. Use `any` for safety. const placeAnswerSources = (chunk.maps as any)?.placeAnswerSources; if ( placeAnswerSources && Array.isArray(placeAnswerSources.reviewSnippets) ) { const reviewSources = placeAnswerSources.reviewSnippets .map((review: any) => { if (review.googleMapsUri && review.title) { return { uri: review.googleMapsUri, title: review.title, }; } return null; }) .filter((s): s is { uri: string; title: string } => s !== null); sources.push(...reviewSources); } } } const hasSources = sources.length > 0; return (
{t.role === 'user' ? 'person' : 'auto_awesome'}
{t.text}
{hasSources && ( )} {widgetToken && !isMobile && (
)}
); })} {isAwaitingFunctionResponse && (

Calling tool...

)}
)}
); }