Skip to content

Commit 1659278

Browse files
committed
Add Live to Firebase AI sample app
Add a new entry in the right sidebar that allows users to use the Live Audio Conversation (`startAudioConversation()`) API.
1 parent 614cfef commit 1659278

File tree

8 files changed

+308
-58
lines changed

8 files changed

+308
-58
lines changed

ai/ai-react-app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"preview": "vite preview"
1111
},
1212
"dependencies": {
13-
"firebase": "12.0.0",
13+
"firebase": "12.2.1",
1414
"immer": "^10.1.1",
1515
"react": "^19.0.0",
1616
"react-dom": "^19.0.0"

ai/ai-react-app/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
22
import MainLayout from "./components/Layout/MainLayout";
33

44
// Defines the primary modes or views available in the application.
5-
export type AppMode = "chat" | "imagenGen";
5+
export type AppMode = "chat" | "imagenGen" | "live";
66

77
function App() {
88
// State to manage which main view ('chat' or 'imagenGen') is currently active.

ai/ai-react-app/src/components/Layout/LeftSidebar.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { BackendType, Content, ModelParams } from "firebase/ai";
55
import { PREDEFINED_PERSONAS } from "../../config/personas";
66

77
interface LeftSidebarProps {
8-
/** The currently active application mode (e.g., 'chat', 'imagenGen'). */
8+
/** The currently active application mode. */
99
activeMode: AppMode;
1010
/** Function to call when a mode button is clicked, updating the active mode in the parent. */
1111
setActiveMode: (mode: AppMode) => void;
@@ -41,6 +41,7 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({
4141
const modes: { id: AppMode; label: string }[] = [
4242
{ id: "chat", label: "Chat" },
4343
{ id: "imagenGen", label: "Imagen Generation" },
44+
{ id: "live", label: "Live Conversation" },
4445
];
4546

4647
const handleBackendChange = (event: React.ChangeEvent<HTMLInputElement>) => {

ai/ai-react-app/src/components/Layout/MainLayout.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import LeftSidebar from "./LeftSidebar";
44
import RightSidebar from "./RightSidebar";
55
import ChatView from "../../views/ChatView";
66
import ImagenView from "../../views/ImagenView";
7+
import LiveView from "../../views/LiveView";
78
import { AppMode } from "../../App";
89
import {
910
UsageMetadata,
@@ -81,7 +82,7 @@ const MainLayout: React.FC<MainLayoutProps> = ({
8182
}, [activeMode]);
8283

8384
useEffect(() => {
84-
const validModes: AppMode[] = ["chat", "imagenGen"];
85+
const validModes: AppMode[] = ["chat", "imagenGen", "live"];
8586
if (!validModes.includes(activeMode)) {
8687
console.warn(`Invalid activeMode "${activeMode}". Resetting to "chat".`);
8788
setActiveMode("chat");
@@ -112,6 +113,10 @@ const MainLayout: React.FC<MainLayoutProps> = ({
112113
return (
113114
<ImagenView aiInstance={activeAI} currentParams={imagenParams} />
114115
);
116+
case "live":
117+
return (
118+
<LiveView aiInstance={activeAI} />
119+
);
115120
default:
116121
console.error(`Unexpected activeMode: ${activeMode}`);
117122
return (

ai/ai-react-app/src/services/firebaseAIService.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ImagenModelParams,
1313
FunctionCall,
1414
GoogleSearchTool,
15+
BackendType,
1516
} from "firebase/ai";
1617

1718
import { firebaseConfig } from "../config/firebase-config";
@@ -23,6 +24,10 @@ export const AVAILABLE_GENERATIVE_MODELS = [
2324
"gemini-2.5-flash"
2425
];
2526
export const AVAILABLE_IMAGEN_MODELS = ["imagen-3.0-generate-002"];
27+
export const LIVE_MODELS = new Map<BackendType, string>([
28+
[BackendType.GOOGLE_AI, 'gemini-live-2.5-flash-preview'],
29+
[BackendType.VERTEX_AI, 'gemini-2.0-flash-exp']
30+
])
2631

2732
let app: FirebaseApp;
2833
try {
@@ -163,4 +168,4 @@ export const countTokensInPrompt = async (
163168
}
164169
};
165170

166-
export { app };
171+
export { app };
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
.liveViewContainer {
2+
display: flex;
3+
flex-direction: column;
4+
align-items: center;
5+
justify-content: center;
6+
height: 100%;
7+
padding: 24px;
8+
text-align: center;
9+
gap: 20px;
10+
}
11+
12+
.title {
13+
font-size: 1.5rem;
14+
font-weight: 400;
15+
color: var(--color-text-primary);
16+
margin: 0;
17+
}
18+
19+
.instructions {
20+
max-width: 500px;
21+
color: var(--color-text-secondary);
22+
font-size: 0.875rem;
23+
line-height: 1.5;
24+
}
25+
26+
.statusContainer {
27+
display: flex;
28+
align-items: center;
29+
justify-content: center;
30+
gap: 12px;
31+
min-height: 24px;
32+
}
33+
34+
.statusIndicator {
35+
width: 12px;
36+
height: 12px;
37+
border-radius: 50%;
38+
background-color: var(--color-text-placeholder);
39+
transition: background-color 0.3s ease;
40+
}
41+
42+
.statusIndicator.active {
43+
background-color: var(--brand-google-cloud-green);
44+
animation: pulse 2s infinite;
45+
}
46+
47+
.statusText {
48+
font-size: 1rem;
49+
font-weight: 500;
50+
color: var(--color-text-secondary);
51+
}
52+
53+
.controlButton {
54+
background-color: var(--color-surface-interactive);
55+
color: var(--color-text-on-interactive);
56+
border: none;
57+
padding: 12px 24px;
58+
border-radius: 24px;
59+
cursor: pointer;
60+
font-weight: 500;
61+
font-size: 1rem;
62+
transition: background-color 0.15s ease;
63+
min-width: 200px;
64+
}
65+
.controlButton:hover:not(:disabled) {
66+
background-color: var(--color-surface-interactive-hover);
67+
}
68+
.controlButton.stop {
69+
background-color: var(--brand-firebase-red);
70+
}
71+
.controlButton.stop:hover:not(:disabled) {
72+
background-color: #b71c1c;
73+
}
74+
.controlButton:disabled {
75+
background-color: var(--color-surface-tertiary);
76+
color: var(--color-text-disabled);
77+
cursor: not-allowed;
78+
}
79+
80+
.errorMessage {
81+
background-color: var(--color-error-bg);
82+
color: var(--color-error-text);
83+
border: 1px solid var(--color-error-border);
84+
padding: 10px 16px;
85+
border-radius: 4px;
86+
margin-top: 20px;
87+
max-width: 500px;
88+
white-space: pre-wrap;
89+
}
90+
91+
@keyframes pulse {
92+
0% {
93+
box-shadow: 0 0 0 0 rgba(52, 168, 83, 0.7);
94+
}
95+
70% {
96+
box-shadow: 0 0 0 10px rgba(52, 168, 83, 0);
97+
}
98+
100% {
99+
box-shadow: 0 0 0 0 rgba(52, 168, 83, 0);
100+
}
101+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import React, { useState, useEffect, useCallback } from "react";
2+
import styles from "./LiveView.module.css";
3+
import {
4+
AI,
5+
getLiveGenerativeModel,
6+
startAudioConversation,
7+
AudioConversationController,
8+
AIError,
9+
ResponseModality,
10+
} from "firebase/ai";
11+
import { LIVE_MODELS } from "../services/firebaseAIService";
12+
13+
interface LiveViewProps {
14+
aiInstance: AI;
15+
}
16+
17+
type ConversationState = "idle" | "active" | "error";
18+
19+
const LiveView: React.FC<LiveViewProps> = ({ aiInstance }) => {
20+
const [conversationState, setConversationState] =
21+
useState<ConversationState>("idle");
22+
const [error, setError] = useState<string | null>(null);
23+
const [controller, setController] =
24+
useState<AudioConversationController | null>(null);
25+
26+
const handleStartConversation = useCallback(async () => {
27+
setError(null);
28+
setConversationState("active");
29+
30+
try {
31+
const modelName = LIVE_MODELS.get(aiInstance.backend.backendType)!;
32+
console.log(`[LiveView] Getting live model: ${modelName}`);
33+
const model = getLiveGenerativeModel(aiInstance, {
34+
model: modelName,
35+
generationConfig: {
36+
responseModalities: [ResponseModality.AUDIO]
37+
}
38+
});
39+
40+
console.log("[LiveView] Connecting to live session...");
41+
const liveSession = await model.connect();
42+
43+
console.log(
44+
"[LiveView] Starting audio conversation. This will request microphone permissions.",
45+
);
46+
47+
const newController = await startAudioConversation(liveSession);
48+
49+
setController(newController);
50+
console.log("[LiveView] Audio conversation started successfully.");
51+
} catch (err: unknown) {
52+
console.error("[LiveView] Failed to start conversation:", err);
53+
let errorMessage = "An unknown error occurred.";
54+
if (err instanceof AIError) {
55+
errorMessage = `Error (${err.code}): ${err.message}`;
56+
} else if (err instanceof Error) {
57+
errorMessage = err.message;
58+
}
59+
setError(errorMessage);
60+
setConversationState("error");
61+
setController(null); // Ensure controller is cleared on error
62+
}
63+
}, [aiInstance]);
64+
65+
const handleStopConversation = useCallback(async () => {
66+
if (!controller) return;
67+
68+
console.log("[LiveView] Stopping audio conversation...");
69+
await controller.stop();
70+
setController(null);
71+
setConversationState("idle");
72+
console.log("[LiveView] Audio conversation stopped.");
73+
}, [controller]);
74+
75+
// Cleanup effect to stop the conversation if the component unmounts
76+
useEffect(() => {
77+
return () => {
78+
if (controller) {
79+
console.log(
80+
"[LiveView] Component unmounting, stopping active conversation.",
81+
);
82+
controller.stop();
83+
}
84+
};
85+
}, [controller]);
86+
87+
const getStatusText = () => {
88+
switch (conversationState) {
89+
case "idle":
90+
return "Ready";
91+
case "active":
92+
return "In Conversation";
93+
case "error":
94+
return "Error";
95+
default:
96+
return "Unknown";
97+
}
98+
};
99+
100+
return (
101+
<div className={styles.liveViewContainer}>
102+
<h2 className={styles.title}>Live Conversation</h2>
103+
<p className={styles.instructions}>
104+
Click the button below to start a real-time voice conversation with the
105+
model. Your browser will ask for microphone permissions.
106+
</p>
107+
108+
<div className={styles.statusContainer}>
109+
<div
110+
className={`${styles.statusIndicator} ${
111+
conversationState === "active" ? styles.active : ""
112+
}`}
113+
/>
114+
<span className={styles.statusText}>Status: {getStatusText()}</span>
115+
</div>
116+
117+
<button
118+
className={`${styles.controlButton} ${
119+
conversationState === "active" ? styles.stop : ""
120+
}`}
121+
onClick={
122+
conversationState === "active"
123+
? handleStopConversation
124+
: handleStartConversation
125+
}
126+
disabled={false} // The button is never truly disabled, it just toggles state
127+
>
128+
{conversationState === "active"
129+
? "Stop Conversation"
130+
: "Start Conversation"}
131+
</button>
132+
133+
{error && <div className={styles.errorMessage}>{error}</div>}
134+
</div>
135+
);
136+
};
137+
138+
export default LiveView;

0 commit comments

Comments
 (0)