Skip to content
Open
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
10 changes: 9 additions & 1 deletion ai/ai-samples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,22 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:text": "VITE_ISOLATED_FEATURE=text-generation vite",
"dev:chat": "VITE_ISOLATED_FEATURE=chat vite",
"dev:multimodal": "VITE_ISOLATED_FEATURE=multimodal vite",
"dev:structured": "VITE_ISOLATED_FEATURE=structured-output vite",
"dev:function": "VITE_ISOLATED_FEATURE=function-calling vite",
"dev:image": "VITE_ISOLATED_FEATURE=image-generation vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"firebase": "^12.2.1",
"react": "^19.2.1",
"react-dom": "^19.2.1"
"react-dom": "^19.2.1",
"react-router-dom": "^7.17.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/react": "^19.0.10",
Expand Down
58 changes: 52 additions & 6 deletions ai/ai-samples/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,56 @@
import React from 'react'
import { Link, Outlet, useLocation } from 'react-router-dom';
import TextGenerationView from './features/text-generation';
import ChatView from './features/chat';
import MultimodalView from './features/multimodal';
import StructuredOutputView from './features/structured-output';
import FunctionCallingView from './features/function-calling';
import ImageGenerationView from './features/image-generation';

const NAV_ITEMS = [
{ path: '/text-generation', label: 'Text Generation' },
{ path: '/chat', label: 'Chat' },
{ path: '/multimodal', label: 'Multimodal' },
{ path: '/structured-output', label: 'Structured Output' },
{ path: '/function-calling', label: 'Function Calling' },
{ path: '/image-generation', label: 'Image Generation' },
];

export default function App() {
const { pathname } = useLocation();
const isolatedFeature = import.meta.env.VITE_ISOLATED_FEATURE;
// If running an isolated script, bypass the shell entirely
if (isolatedFeature) {
switch (isolatedFeature) {
case 'text-generation': return <TextGenerationView />;
case 'chat': return <ChatView />;
case 'multimodal': return <MultimodalView />;
case 'structured-output': return <StructuredOutputView />;
case 'function-calling': return <FunctionCallingView />;
case 'image-generation': return <ImageGenerationView />;
}
}

// Otherwise, return the multi-feature app shell layout
return (
<div>
<h1>Firebase AI Samples</h1>
<p>Modular Firebase AI capabilities.</p>
<div className="app-shell">
<nav className="sidebar">
<h1 className="sidebar-title">Firebase AI Samples</h1>
<ul className="nav-list">
{NAV_ITEMS.map(({ path, label }) => (
<li key={path}>
<Link
to={path}
className={`nav-link ${pathname === path ? 'nav-link-active' : ''}`}
>
{label}
</Link>
</li>
))}
</ul>
</nav>
<main className="content">
<Outlet />
</main>
</div>
)
}
);
}
156 changes: 151 additions & 5 deletions ai/ai-samples/src/features/chat/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,155 @@
import React from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { ChatSession } from 'firebase/ai';
import { startNewChat, sendChatMessage } from './service';

type Message = {
role: 'user' | 'model';
text: string;
};

export default function ChatView() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

// Use a ref to hold the active chat session from the Firebase AI SDK.
// This prevents the session from being recreated on every React render.
const chatSessionRef = useRef<ChatSession | null>(null);

// Initialize the chat when the component mounts
useEffect(() => {
handleResetChat();
}, []);

const handleResetChat = () => {
try {
chatSessionRef.current = startNewChat();
setError(null);
} catch (err: any) {
setError(err.message || 'Failed to initialize chat session. Please check your Firebase configuration.');
}
setMessages([]);
setInput('');
};

const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || !chatSessionRef.current) return;

const userMessage = input.trim();
setInput(''); // Clear input immediately
setError(null);
setLoading(true);

// Optimistically add user message to UI
setMessages((prev) => [...prev, { role: 'user', text: userMessage }]);

try {
// Call framework-agnostic service layer
const responseText = await sendChatMessage(chatSessionRef.current, userMessage);

// Add model response to UI
setMessages((prev) => [...prev, { role: 'model', text: responseText }]);
} catch (err: any) {
setError(err.message || 'Failed to send message. Check console for details.');
} finally {
setLoading(false);
}
};

export default function Feature() {
return (
<div>
<h2>chat</h2>
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '24px', fontFamily: 'system-ui, sans-serif' }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h2 style={{ fontSize: '1.8rem', margin: '0 0 8px 0', color: '#1a73e8' }}>Multi-Turn Chat</h2>
<p style={{ color: '#5f6368', margin: 0 }}>Maintains persistent history in a single session.</p>
</div>
<button
onClick={handleResetChat}
style={{ padding: '8px 16px', backgroundColor: '#f1f3f4', color: '#3c4043', border: '1px solid #dadce0', borderRadius: '4px', cursor: 'pointer' }}
>
Reset Chat
</button>
</header>

{/* Chat History Window */}
<div style={{
height: '400px',
overflowY: 'auto',
border: '1px solid #dadce0',
borderRadius: '8px',
padding: '16px',
marginBottom: '16px',
backgroundColor: '#f8f9fa',
display: 'flex',
flexDirection: 'column',
gap: '12px'
}}>
{messages.length === 0 && (
<div style={{ color: '#80868b', textAlign: 'center', marginTop: 'auto', marginBottom: 'auto' }}>
No messages yet. Say hello!
</div>
)}

{messages.map((msg, index) => (
<div key={index} style={{
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
backgroundColor: msg.role === 'user' ? '#d2e3fc' : '#ffffff',
border: '1px solid',
borderColor: msg.role === 'user' ? '#aecbfa' : '#dadce0',
padding: '12px 16px',
borderRadius: '16px',
maxWidth: '80%'
}}>
<strong style={{ display: 'block', fontSize: '0.8rem', color: '#5f6368', marginBottom: '4px' }}>
{msg.role === 'user' ? 'You' : 'Gemini'}
</strong>
<div style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{msg.text}</div>
</div>
))}
{loading && <div style={{ alignSelf: 'flex-start', color: '#80868b', fontStyle: 'italic' }}>Gemini is typing...</div>}
</div>

{error && (
<div style={{ marginBottom: '16px', padding: '12px', backgroundColor: '#fce8e6', color: '#c5221f', borderRadius: '8px' }}>
<strong>Error:</strong> {error}
</div>
)}

{/* Input Area */}
<form onSubmit={handleSendMessage} style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type your message..."
disabled={loading}
style={{
flex: 1,
padding: '12px',
borderRadius: '8px',
border: '1px solid #dadce0',
fontSize: '1rem',
}}
/>
<button
type="submit"
disabled={loading || !input.trim()}
style={{
padding: '0 24px',
backgroundColor: loading || !input.trim() ? '#ccc' : '#1a73e8',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '1rem',
fontWeight: 500,
cursor: loading || !input.trim() ? 'not-allowed' : 'pointer',
}}
>
Send
</button>
</form>
</div>
);
}
}
30 changes: 30 additions & 0 deletions ai/ai-samples/src/features/chat/service.ts
Original file line number Diff line number Diff line change
@@ -1 +1,31 @@
// Service for chat
import { ChatSession } from 'firebase/ai';
import { getAiModel } from '../../services/firebaseAIService';

/**
* Initializes a new multi-turn chat session.
* The SDK's ChatSession automatically maintains the conversation history.
* * @returns A new ChatSession instance.
*/
export function startNewChat(): ChatSession {
const model = getAiModel('gemini-3.5-flash');
return model.startChat({
history: [],
});
}
Comment thread
sedanah-m marked this conversation as resolved.

/**
* Sends a message to an existing chat session and returns the response text.
* * @param chat The active ChatSession instance.
* @param message The user's message string.
* @returns The text string response generated by the model.
*/
export async function sendChatMessage(chat: ChatSession, message: string): Promise<string> {
try {
const result = await chat.sendMessage(message);
return result.response.text();
} catch (error) {
console.error('Error sending chat message:', error);
throw error;
}
}
4 changes: 2 additions & 2 deletions ai/ai-samples/src/features/function-calling/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React from 'react';

export default function Feature() {
export default function FunctionCallingFeature() {
return (
<div>
<h2>function-calling</h2>
</div>
);
}
}
2 changes: 1 addition & 1 deletion ai/ai-samples/src/features/function-calling/service.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
// Service for function-calling
// Service for function-calling
4 changes: 2 additions & 2 deletions ai/ai-samples/src/features/image-generation/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React from 'react';

export default function Feature() {
export default function ImageGenerationFeature() {
return (
<div>
<h2>image-generation</h2>
</div>
);
}
}
2 changes: 1 addition & 1 deletion ai/ai-samples/src/features/image-generation/service.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
// Service for image-generation
// Service for image-generation
4 changes: 2 additions & 2 deletions ai/ai-samples/src/features/multimodal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React from 'react';

export default function Feature() {
export default function MultimodalFeature() {
return (
<div>
<h2>multimodal</h2>
</div>
);
}
}
2 changes: 1 addition & 1 deletion ai/ai-samples/src/features/multimodal/service.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
// Service for multimodal
// Service for multimodal
50 changes: 45 additions & 5 deletions ai/ai-samples/src/features/text-generation/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,49 @@
import React from 'react';
import { useState } from 'react';
import { generateText } from './service';

export default function TextGeneration() {
const [prompt, setPrompt] = useState<string>('');
const [response, setResponse] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);

const handleGenerate = async () => {
if (!prompt.trim()) return;

setLoading(true);
setError(null);
setResponse('');

try {
// Direct call to the decoupled logic service
const text = await generateText(prompt);
setResponse(text);
} catch (err: any) {
setError(err.message || 'An unexpected error occurred');
} finally {
setLoading(false);
}
};


export default function Feature() {
return (
<div>
<h2>text-generation</h2>
<div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
<h2>Text Generation</h2>

<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Ask the AI a question..."
rows={5}
style={{ width: '100%', marginBottom: '10px', padding: '10px' }}
/>

<button onClick={handleGenerate} disabled={loading || !prompt.trim()} style={{ padding: '10px 20px' }}>
{loading ? 'Generating...' : 'Generate'}
</button>

{error && <p style={{ color: 'red', marginTop: '15px' }}>{error}</p>}
{response && <div style={{ marginTop: '20px', padding: '15px', backgroundColor: '#f0f0f0' }}><p style={{ whiteSpace: 'pre-wrap' }}>{response}</p></div>}
</div>
);
}
}
Loading
Loading