Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion .github/workflows/firebase-ai-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ permissions:
on:
pull_request:
branches:
- main
- master
paths:
- 'ai/ai-react-app/**'

Expand Down
2 changes: 1 addition & 1 deletion ai/ai-react-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"firebase": "12.0.0",
"firebase": "12.2.1",
"immer": "^10.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
Expand Down
2 changes: 1 addition & 1 deletion ai/ai-react-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import MainLayout from "./components/Layout/MainLayout";

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

function App() {
// State to manage which main view ('chat' or 'imagenGen') is currently active.
Expand Down
3 changes: 2 additions & 1 deletion ai/ai-react-app/src/components/Layout/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { BackendType, Content, ModelParams } from "firebase/ai";
import { PREDEFINED_PERSONAS } from "../../config/personas";

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

const handleBackendChange = (event: React.ChangeEvent<HTMLInputElement>) => {
Expand Down
7 changes: 6 additions & 1 deletion ai/ai-react-app/src/components/Layout/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import LeftSidebar from "./LeftSidebar";
import RightSidebar from "./RightSidebar";
import ChatView from "../../views/ChatView";
import ImagenView from "../../views/ImagenView";
import LiveView from "../../views/LiveView";
import { AppMode } from "../../App";
import {
UsageMetadata,
Expand Down Expand Up @@ -81,7 +82,7 @@ const MainLayout: React.FC<MainLayoutProps> = ({
}, [activeMode]);

useEffect(() => {
const validModes: AppMode[] = ["chat", "imagenGen"];
const validModes: AppMode[] = ["chat", "imagenGen", "live"];
if (!validModes.includes(activeMode)) {
console.warn(`Invalid activeMode "${activeMode}". Resetting to "chat".`);
setActiveMode("chat");
Expand Down Expand Up @@ -112,6 +113,10 @@ const MainLayout: React.FC<MainLayoutProps> = ({
return (
<ImagenView aiInstance={activeAI} currentParams={imagenParams} />
);
case "live":
return (
<LiveView aiInstance={activeAI} />
);
default:
console.error(`Unexpected activeMode: ${activeMode}`);
return (
Expand Down
7 changes: 6 additions & 1 deletion ai/ai-react-app/src/services/firebaseAIService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ImagenModelParams,
FunctionCall,
GoogleSearchTool,
BackendType,
} from "firebase/ai";

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

let app: FirebaseApp;
try {
Expand Down Expand Up @@ -163,4 +168,4 @@ export const countTokensInPrompt = async (
}
};

export { app };
export { app };
101 changes: 101 additions & 0 deletions ai/ai-react-app/src/views/LiveView.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
.liveViewContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 24px;
text-align: center;
gap: 20px;
}

.title {
font-size: 1.5rem;
font-weight: 400;
color: var(--color-text-primary);
margin: 0;
}

.instructions {
max-width: 500px;
color: var(--color-text-secondary);
font-size: 0.875rem;
line-height: 1.5;
}

.statusContainer {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 24px;
}

.statusIndicator {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: var(--color-text-placeholder);
transition: background-color 0.3s ease;
}

.statusIndicator.active {
background-color: var(--brand-google-cloud-green);
animation: pulse 2s infinite;
}

.statusText {
font-size: 1rem;
font-weight: 500;
color: var(--color-text-secondary);
}

.controlButton {
background-color: var(--color-surface-interactive);
color: var(--color-text-on-interactive);
border: none;
padding: 12px 24px;
border-radius: 24px;
cursor: pointer;
font-weight: 500;
font-size: 1rem;
transition: background-color 0.15s ease;
min-width: 200px;
}
.controlButton:hover:not(:disabled) {
background-color: var(--color-surface-interactive-hover);
}
.controlButton.stop {
background-color: var(--brand-firebase-red);
}
.controlButton.stop:hover:not(:disabled) {
background-color: #b71c1c;
}
.controlButton:disabled {
background-color: var(--color-surface-tertiary);
color: var(--color-text-disabled);
cursor: not-allowed;
}

.errorMessage {
background-color: var(--color-error-bg);
color: var(--color-error-text);
border: 1px solid var(--color-error-border);
padding: 10px 16px;
border-radius: 4px;
margin-top: 20px;
max-width: 500px;
white-space: pre-wrap;
}

@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(52, 168, 83, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(52, 168, 83, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(52, 168, 83, 0);
}
}
138 changes: 138 additions & 0 deletions ai/ai-react-app/src/views/LiveView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import React, { useState, useEffect, useCallback } from "react";
import styles from "./LiveView.module.css";
import {
AI,
getLiveGenerativeModel,
startAudioConversation,
AudioConversationController,
AIError,
ResponseModality,
} from "firebase/ai";
import { LIVE_MODELS } from "../services/firebaseAIService";

interface LiveViewProps {
aiInstance: AI;
}

type ConversationState = "idle" | "active" | "error";

const LiveView: React.FC<LiveViewProps> = ({ aiInstance }) => {
const [conversationState, setConversationState] =
useState<ConversationState>("idle");
const [error, setError] = useState<string | null>(null);
const [controller, setController] =
useState<AudioConversationController | null>(null);

const handleStartConversation = useCallback(async () => {
setError(null);
setConversationState("active");

try {
const modelName = LIVE_MODELS.get(aiInstance.backend.backendType)!;
console.log(`[LiveView] Getting live model: ${modelName}`);
const model = getLiveGenerativeModel(aiInstance, {
model: modelName,
generationConfig: {
responseModalities: [ResponseModality.AUDIO]
}
});

console.log("[LiveView] Connecting to live session...");
const liveSession = await model.connect();

console.log(
"[LiveView] Starting audio conversation. This will request microphone permissions.",
);

const newController = await startAudioConversation(liveSession);

setController(newController);
console.log("[LiveView] Audio conversation started successfully.");
} catch (err: unknown) {
console.error("[LiveView] Failed to start conversation:", err);
let errorMessage = "An unknown error occurred.";
if (err instanceof AIError) {
errorMessage = `Error (${err.code}): ${err.message}`;
} else if (err instanceof Error) {
errorMessage = err.message;
}
setError(errorMessage);
setConversationState("error");
setController(null); // Ensure controller is cleared on error
}
}, [aiInstance]);

const handleStopConversation = useCallback(async () => {
if (!controller) return;

console.log("[LiveView] Stopping audio conversation...");
await controller.stop();
setController(null);
setConversationState("idle");
console.log("[LiveView] Audio conversation stopped.");
}, [controller]);

// Cleanup effect to stop the conversation if the component unmounts
useEffect(() => {
return () => {
if (controller) {
console.log(
"[LiveView] Component unmounting, stopping active conversation.",
);
controller.stop();
}
};
}, [controller]);

const getStatusText = () => {
switch (conversationState) {
case "idle":
return "Ready";
case "active":
return "In Conversation";
case "error":
return "Error";
default:
return "Unknown";
}
};

return (
<div className={styles.liveViewContainer}>
<h2 className={styles.title}>Live Conversation</h2>
<p className={styles.instructions}>
Click the button below to start a real-time voice conversation with the
model. Your browser will ask for microphone permissions.
</p>

<div className={styles.statusContainer}>
<div
className={`${styles.statusIndicator} ${
conversationState === "active" ? styles.active : ""
}`}
/>
<span className={styles.statusText}>Status: {getStatusText()}</span>
</div>

<button
className={`${styles.controlButton} ${
conversationState === "active" ? styles.stop : ""
}`}
onClick={
conversationState === "active"
? handleStopConversation
: handleStartConversation
}
disabled={false} // The button is never truly disabled, it just toggles state
>
{conversationState === "active"
? "Stop Conversation"
: "Start Conversation"}
</button>

{error && <div className={styles.errorMessage}>{error}</div>}
</div>
);
};

export default LiveView;
Loading
Loading