Build a Multimodal AI Assistant
Build Atlas AI: a research assistant with chat, file analysis, and web search.
Introduction
30 Second Summary
Think about how many tabs you open when researching a topic. You're switching between a search engine, a PDF viewer, an image analyzer, and a chat assistant all at once.
In this project, you will build Atlas AI, a multimodal research assistant that combines conversation, file analysis, and web search into a single polished interface powered by Google Gemini and Tavily.
What You'll Build
Atlas AI is a Streamlit-powered web app that uses Google's Gemini 2.5 Flash model for chat and file understanding, and Tavily's search API for structured web research.
By the end of this project, you'll have:
- A dark-themed chat interface with three operational modes: Chat, Analyze, and Research.
- A file analysis pipeline that accepts image and PDF uploads and returns AI-powered insights.
- A web research tool that searches the internet and synthesizes findings with clickable source links.
- Secret Mission: Add conversation memory so Atlas AI remembers context across mode switches and can reference earlier uploads in later questions.
Are there any prerequisites?
You'll need basic Python knowledge, a Google account (for the free Gemini API key), and a Tavily account (for the free search API key, no credit card required).
Set Up Your Environment and API Keys
Before you can build Atlas AI, you need the right tools in place. Three Python libraries power the app: Streamlit for the web interface, the google-genai SDK for connecting to Gemini, and tavily-python for web search.
You also need API keys to authenticate with Google's Gemini model and Tavily's search service. Both offer generous free tiers, so there is no cost to complete this project.
In this step, get ready to:
- Install the three Python dependencies your project needs.
- Get free API keys from Google AI Studio and Tavily.
- Create the project folder and file structure.
Install your Python dependencies
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Install the three packages your project needs by running this command:
pip install streamlit google-genai tavily-python
What do these packages do?
- streamlit is a Python framework that turns scripts into interactive web apps with built-in chat components.
- google-genai is the official SDK for Google's Gemini models. It handles sending prompts and receiving streaming responses.
- tavily-python is the SDK for Tavily's search API. It returns structured web results with titles, URLs, and content snippets.
- Confirm the packages installed correctly by running:
pip show streamlit google-genai tavily-python
You should see output for each package showing its name, version, and location. Look for streamlit, google-genai, and tavily-python listed with version numbers.
Seeing a permission error or command not found?
- If you see a permissions error, try running pip install --user streamlit google-genai tavily-python instead.
- If pip is not found, try pip3 instead. Some systems use pip3 for Python 3.
- Still stuck? Help me troubleshoot my pip install failing
Get your API keys
Atlas AI talks to two external services. Each one needs an API key so it knows who is making the request.
Get your Gemini API key
- Open your browser and navigate to aistudio.google.com.
- Sign in with your Google account.
- If you are a new user, Google AI Studio automatically creates a default project after you accept the Terms of Service.
- Click Get API key in the left sidebar.
- Click Create API key.
- Copy the key and save it somewhere safe, like a text file or password manager. You will need it in the next step.
Why is this free?
Gemini 2.5 Flash offers free input and output tokens on its free tier. For a personal project like this, you will comfortably stay within the free limits.
Get your Tavily API key
- Open a new browser tab and navigate to app.tavily.com.
- Click Sign Up and create a free account. No credit card is required.
- Once signed in, your API key is displayed on the dashboard.
- Copy the key and save it alongside your Gemini key.
What does the Tavily free tier include?
Tavily gives you 1,000 free API credits every month. Each basic search uses 1 credit, so you can run 1,000 research queries per month at no cost.
Keep your keys private
Never share your API keys publicly or commit them to version control. In the next step, you will store them securely using Streamlit's built-in secrets management.
Create your project folder and files
With your dependencies installed and keys saved, it is time to create the project structure.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir atlas-ai
cd atlas-ai
- Create the folder structure and empty files by running these commands:
mkdir .streamlit
touch .streamlit/config.toml app.py requirements.txt
- Confirm the files exist by running:
ls -a
You should see app.py, requirements.txt, and the .streamlit folder in the output.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the atlas-ai folder, and click Open.
- In the Explorer sidebar, click on requirements.txt to open it.
- Add the following content to requirements.txt:
streamlit==1.45.0
google-genai==2.10.0
tavily-python==0.5.0
What is requirements.txt for?
This file documents the exact package versions your project uses. Anyone who clones your project can run pip install -r requirements.txt to install the same versions you used.
- Save requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
✔️ Awesome, I've got everything!
Great. Double check you have saved your file, then move on to verify Streamlit below.
ⓧ I'd like to double check the full code
Here is the complete requirements.txt file:
streamlit==1.45.0
google-genai==2.10.0
tavily-python==0.5.0
Verify Streamlit is working
- Open the terminal in VS Code by pressing Ctrl+` (backtick key, top-left of keyboard).
- Run the Streamlit demo app to confirm everything is installed correctly:
streamlit hello
Streamlit's Hello app should open in a new tab in your web browser. You will see a demo page with interactive widgets, charts, and layout examples.
Streamlit command not found?
- Try the long-form command instead: python -m streamlit hello.
- If that also fails, your terminal may not see the installed packages. Close and reopen your terminal, then try again.
- Still stuck? Help me get streamlit hello working
- Once you have confirmed the demo page loads, press Ctrl+C in the terminal to stop the server.
Your environment is fully set up. Next, you will build the dark-themed UI shell that gives Atlas AI its professional look.
Build the Dark-Themed UI Shell
Atlas AI needs to look like a professional tool from the first time a user opens it. A polished dark theme with clear navigation sets the tone and makes the three modes (Chat, Analyze, Research) easy to discover.
In this step, you will configure Streamlit's theming system, build a sidebar with mode switching, and set up session state so the app remembers the conversation as users interact with it.
In this step, get ready to:
- Configure a dark professional theme with custom accent colors.
- Build a sidebar with mode selector and app branding.
- Set up session state and a chat history rendering loop.
Configure the dark theme
Streamlit reads theme settings from a TOML configuration file inside the .streamlit folder. You already created this file in Step 1. Now you will populate it with color values that give Atlas AI its dark, professional appearance.
- Open .streamlit/config.toml in VS Code and paste the following theme configuration:
[theme]
primaryColor = "#6C63FF"
backgroundColor = "#0E1117"
secondaryBackgroundColor = "#1A1F2E"
textColor = "#FAFAFA"
font = "sans serif"
What do these colors do?
- primaryColor sets the accent used for buttons, links, and interactive elements. The purple #6C63FF gives the app a modern feel.
- backgroundColor and secondaryBackgroundColor create the dark base. The sidebar uses the secondary color automatically.
- textColor ensures readable white text on dark backgrounds.
- Save .streamlit/config.toml.
✔️ Awesome, I've got everything!
Great. The theme file is done and won't need changes for the rest of the project.
ⓧ I'd like to double check the full code
[theme]
primaryColor = "#6C63FF"
backgroundColor = "#0E1117"
secondaryBackgroundColor = "#1A1F2E"
textColor = "#FAFAFA"
font = "sans serif"
Add the page config and custom CSS
Every Streamlit app starts with a page configuration call that sets the browser tab title, favicon, and layout. You will also inject custom CSS to style elements that Streamlit's built-in theme cannot reach.
- Open app.py in VS Code and paste the following imports and page configuration:
import streamlit as st
import base64
from google import genai
from google.genai import types
from tavily import TavilyClient
# Page configuration
st.set_page_config(
page_title="Atlas AI",
page_icon="\ud83d\udd2c",
layout="wide"
)
What does this code do?
- The imports bring in all the libraries you installed in Step 1. You will use them in later steps when connecting to Gemini and Tavily.
- st.set_page_config must be the first Streamlit command in your script. It sets the browser tab title to "Atlas AI", adds a microscope emoji as the favicon, and enables wide layout so the app uses the full browser width.
- Below the page config, paste this custom CSS block:
# Custom CSS for professional styling
st.markdown("""
<style>
.stApp > header {display: none;}
.main-header {
font-size: 2rem;
font-weight: 700;
color: #6C63FF;
margin-bottom: 0.5rem;
}
.mode-description {
font-size: 0.9rem;
color: #888;
margin-bottom: 1rem;
}
.source-card {
background-color: #1A1F2E;
border: 1px solid #2D3748;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
}
.source-card a {
color: #6C63FF;
text-decoration: none;
}
</style>
""", unsafe_allow_html=True)
What does this CSS do?
- .stApp > header hides Streamlit's default top header bar for a cleaner look.
- .main-header styles the sidebar title with the purple accent color and bold weight.
- .mode-description makes the mode instructions smaller and gray so they don't compete with the main content.
- .source-card creates styled cards for research source links you will add in a later step.
- Save app.py.
- Run the app by entering this command in your terminal:
streamlit run app.py
- Your browser opens a new tab showing a dark-themed page with the title "Atlas AI" in the browser tab. The page is mostly empty for now.
Seeing a ModuleNotFoundError?
- Make sure you installed all dependencies in Step 1. Run pip show streamlit google-genai tavily-python to confirm they are installed.
- Check that you are running the command from inside your project folder where app.py lives.
Why is my Streamlit app showing ModuleNotFoundError?
Build the sidebar with mode selection
The sidebar is where users switch between Atlas AI's three modes. It also holds the app branding and a button to clear the conversation.
Before you can build the sidebar, you need to initialize session state. Streamlit reruns your entire script on every interaction, so session state is how you persist data (like messages) between reruns.
- In app.py, paste the following session state initialization below the CSS block:
# Session state initialization
if "messages" not in st.session_state:
st.session_state.messages = []
if "current_mode" not in st.session_state:
st.session_state.current_mode = "Chat"
What is session state?
Streamlit reruns your Python script from top to bottom every time the user clicks a button or types something. Without session state, all your variables would reset.
st.session_state.messages stores the conversation history as a list. st.session_state.current_mode remembers which mode (Chat, Analyze, or Research) is active.
- Below the session state initialization, paste the sidebar code:
# Sidebar
with st.sidebar:
st.markdown('<p class="main-header">Atlas AI</p>', unsafe_allow_html=True)
st.markdown("Your multimodal research assistant")
st.divider()
mode = st.radio(
"Mode",
["Chat", "Analyze", "Research"],
index=["Chat", "Analyze", "Research"].index(st.session_state.current_mode),
help="Switch between conversation, file analysis, and web research"
)
st.session_state.current_mode = mode
st.divider()
mode_descriptions = {
"Chat": "Have a conversation with Atlas AI. Ask questions, brainstorm ideas, or get explanations.",
"Analyze": "Upload images or PDFs and ask questions about them. Atlas AI will analyze the content.",
"Research": "Enter a topic and get structured research findings with cited web sources."
}
st.markdown(f'<p class="mode-description">{mode_descriptions[mode]}</p>', unsafe_allow_html=True)
if st.button("Clear conversation", use_container_width=True):
st.session_state.messages = []
st.rerun()
What does this sidebar do?
- st.sidebar creates a collapsible panel on the left side of the app.
- st.radio renders the three mode options as radio buttons. The index parameter keeps the selection synced with session state.
- The mode_descriptions dictionary shows context-sensitive help text that changes when the user picks a different mode.
- The Clear conversation button resets the message list and calls st.rerun() to refresh the app immediately.
- Save app.py and check your browser tab.
- You should see a sidebar on the left with "Atlas AI" in purple text, a radio selector showing Chat, Analyze, and Research, and a Clear conversation button at the bottom.
Sidebar not showing up?
- Make sure the session state block comes before the sidebar block in your file. The sidebar references st.session_state.current_mode which must exist first.
- Check for indentation errors inside the with st.sidebar: block. All sidebar content must be indented one level.
Why isn't my Streamlit sidebar rendering?
Render the chat history
The final piece of the UI shell is the main content area where messages will appear. You need a heading that shows the current mode and a loop that renders any stored messages from session state.
- Below the sidebar block in app.py, paste the chat history rendering code:
# Main chat area
st.markdown(f"### {mode} Mode")
# Display chat history
for message in st.session_state.messages:
with st.chat_message(message["role"]):
if message.get("images"):
for img_data in message["images"]:
st.image(img_data, width=300)
if message.get("files_info"):
for f_info in message["files_info"]:
st.caption(f"Attached: {f_info}")
st.markdown(message["content"])
if message.get("sources"):
st.markdown("---")
st.markdown("**Sources:**")
for source in message["sources"]:
st.markdown(f'<div class="source-card"><a href="{source["url"]}" target="_blank">{source["title"]}</a><br><small>{source["url"]}</small></div>', unsafe_allow_html=True)
What does this loop do?
The loop iterates over every message stored in session state and renders it using Streamlit's st.chat_message component. Each message bubble shows the role (user or assistant) with the correct avatar.
The conditional checks handle different message types. Images get rendered inline. File names show as captions. Source links display as styled cards. Right now the messages list is empty, so nothing will appear yet.
- Save app.py and check your browser.
- You should see "Chat Mode" as a heading in the main content area. The rest of the page is empty because there are no messages yet.
Seeing a NameError for mode?
- The variable mode is defined inside the sidebar block. Make sure the chat history code is placed after (below) the with st.sidebar: block, not inside it.
- Check that the for message in st.session_state.messages: line has no extra indentation.
Help me fix a NameError in my Streamlit app.
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py and that your browser shows the sidebar and the "Chat Mode" heading.
ⓧ I'd like to double check the full code
import streamlit as st
import base64
from google import genai
from google.genai import types
from tavily import TavilyClient
# Page configuration
st.set_page_config(
page_title="Atlas AI",
page_icon="\ud83d\udd2c",
layout="wide"
)
# Custom CSS for professional styling
st.markdown("""
<style>
.stApp > header {display: none;}
.main-header {
font-size: 2rem;
font-weight: 700;
color: #6C63FF;
margin-bottom: 0.5rem;
}
.mode-description {
font-size: 0.9rem;
color: #888;
margin-bottom: 1rem;
}
.source-card {
background-color: #1A1F2E;
border: 1px solid #2D3748;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
}
.source-card a {
color: #6C63FF;
text-decoration: none;
}
</style>
""", unsafe_allow_html=True)
# Session state initialization
if "messages" not in st.session_state:
st.session_state.messages = []
if "current_mode" not in st.session_state:
st.session_state.current_mode = "Chat"
# Sidebar
with st.sidebar:
st.markdown('<p class="main-header">Atlas AI</p>', unsafe_allow_html=True)
st.markdown("Your multimodal research assistant")
st.divider()
mode = st.radio(
"Mode",
["Chat", "Analyze", "Research"],
index=["Chat", "Analyze", "Research"].index(st.session_state.current_mode),
help="Switch between conversation, file analysis, and web research"
)
st.session_state.current_mode = mode
st.divider()
mode_descriptions = {
"Chat": "Have a conversation with Atlas AI. Ask questions, brainstorm ideas, or get explanations.",
"Analyze": "Upload images or PDFs and ask questions about them. Atlas AI will analyze the content.",
"Research": "Enter a topic and get structured research findings with cited web sources."
}
st.markdown(f'<p class="mode-description">{mode_descriptions[mode]}</p>', unsafe_allow_html=True)
if st.button("Clear conversation", use_container_width=True):
st.session_state.messages = []
st.rerun()
# Main chat area
st.markdown(f"### {mode} Mode")
# Display chat history
for message in st.session_state.messages:
with st.chat_message(message["role"]):
if message.get("images"):
for img_data in message["images"]:
st.image(img_data, width=300)
if message.get("files_info"):
for f_info in message["files_info"]:
st.caption(f"Attached: {f_info}")
st.markdown(message["content"])
if message.get("sources"):
st.markdown("---")
st.markdown("**Sources:**")
for source in message["sources"]:
st.markdown(f'<div class="source-card"><a href="{source["url"]}" target="_blank">{source["title"]}</a><br><small>{source["url"]}</small></div>', unsafe_allow_html=True)
Your dark-themed UI shell is complete. The app has professional styling, a working sidebar for switching modes, and a rendering loop ready to display messages. Next up, you will connect Gemini and make Chat mode actually respond to your questions.
Implement Chat Mode with Streaming
Your UI shell looks great. The sidebar switches modes, the chat history renders stored messages, and the dark theme gives everything a professional feel.
Now it is time to make Atlas AI actually think. In this step, you will connect to Google's Gemini API and build a streaming chat that displays responses word-by-word, just like ChatGPT.
In this step, get ready to:
- Connect to the Gemini and Tavily APIs using a secure secrets file.
- Create helper functions that stream AI responses progressively.
- Wire up Chat mode so users can have a real-time conversation with Gemini.
Initialize the API clients and create streaming helpers
Before the app can talk to Gemini, it needs a secure way to store your API keys. Streamlit provides a built-in secrets manager that reads from a .streamlit/secrets.toml file.
- Create a new file called secrets.toml inside your .streamlit folder (the same folder that holds config.toml) by pasting the following:
GEMINI_API_KEY = "your-gemini-api-key-here"
TAVILY_API_KEY = "your-tavily-api-key-here"
- Replace your-gemini-api-key-here with the Gemini API key you created in Google AI Studio.
- Replace your-tavily-api-key-here with the Tavily API key from your Tavily dashboard.
- Save secrets.toml.
Why secrets.toml instead of hardcoding keys?
Streamlit's st.secrets reads API keys from .streamlit/secrets.toml at runtime. This keeps credentials out of your source code so you never accidentally share them.
Now you will add the client initialization code to app.py. This connects both APIs and sets the model constant.
- In app.py, find the closing </style> tag's line that ends with:
""", unsafe_allow_html=True)
- Directly below that line, add the following client initialization code:
# Initialize API clients
gemini_client = genai.Client(api_key=st.secrets["GEMINI_API_KEY"])
tavily_client = TavilyClient(api_key=st.secrets["TAVILY_API_KEY"])
MODEL = "gemini-2.5-flash"
What does this code do?
genai.Client(api_key=st.secrets["GEMINI_API_KEY"]) creates a Gemini client that authenticates using the key stored in your secrets file.
TavilyClient(api_key=st.secrets["TAVILY_API_KEY"]) does the same for Tavily. You will use this client later for Research mode.
MODEL = "gemini-2.5-flash" stores the model ID in one place so it is easy to change later.
- Save app.py.
Seeing a FileNotFoundError?
If Streamlit cannot find your secrets, make sure secrets.toml is inside the .streamlit folder (not the project root). The file path should be .streamlit/secrets.toml.
help me fix this FileNotFoundError for secrets.toml
Next, you will create two helper functions that handle communication with Gemini. These go after the chat history rendering loop at the bottom of your file.
- In app.py, scroll to the very bottom of the file (after the chat history for loop) and add the following function:
def get_gemini_response_stream(messages, system_instruction=None):
"""Send messages to Gemini and return a streaming response."""
contents = []
for msg in messages:
role = "user" if msg["role"] == "user" else "model"
parts = []
if msg.get("multimodal_parts"):
parts = msg["multimodal_parts"]
else:
parts = [types.Part.from_text(text=msg["content"])]
contents.append(types.Content(role=role, parts=parts))
config = types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=0.7,
)
response_stream = gemini_client.models.generate_content_stream(
model=MODEL,
contents=contents,
config=config,
)
return response_stream
What does this function do?
This function converts your chat history into the format Gemini expects. Each message gets a role (either user or model) and parts (the actual content).
The generate_content_stream() method returns chunks of text as they are generated, rather than waiting for the complete response. This is what creates the live-typing effect.
- Directly below get_gemini_response_stream, add this smaller helper that yields text from the stream:
def stream_response(response_stream):
"""Yield text chunks from the streaming response."""
for chunk in response_stream:
if chunk.text:
yield chunk.text
What does this function do?
stream_response is a Python generator. It loops through each chunk Gemini sends back and yields only the text. Streamlit's st.write_stream can consume this generator to display words as they arrive.
- Save app.py.
Seeing an indentation error?
Make sure both functions start at column 0 (no indentation). They should not be nested inside the for loop or any other block.
help me fix indentation errors in my Python functions
Wire up the chat input and stream responses
The helper functions are ready. Now you need the actual Chat mode logic that captures what the user types, sends it to Gemini, and displays the streaming response.
- Below the stream_response function you just added, paste the Chat mode block:
# Mode: Chat
if mode == "Chat":
prompt = st.chat_input("Ask Atlas AI anything...")
if prompt:
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
with st.chat_message("assistant"):
system = "You are Atlas AI, a helpful and knowledgeable research assistant. Provide clear, well-structured responses. Use markdown formatting when appropriate."
response_stream = get_gemini_response_stream(
st.session_state.messages, system_instruction=system
)
response_text = st.write_stream(stream_response(response_stream))
st.session_state.messages.append({"role": "assistant", "content": response_text})
What does this code do?
st.chat_input("Ask Atlas AI anything...") renders a text box at the bottom of the page. When the user presses Enter, the message is captured in prompt.
The user's message is appended to st.session_state.messages and displayed immediately. Then the full conversation history is sent to Gemini so the AI has context from earlier messages.
st.write_stream consumes the generator and renders each text chunk as it arrives. Once complete, it returns the full response text, which gets saved to session state for future messages.
- Save app.py.
Chat input not appearing?
Make sure the if mode == "Chat": block is at the same indentation level as the helper functions (column 0). It should not be inside the chat history for loop.
help me figure out why my chat input box is not showing up
Test your streaming chat
The system instruction tells Gemini to behave as a helpful research assistant. The response persistence ensures that when Streamlit reruns the page (which happens on every interaction), earlier messages are still visible.
- Run your app by entering this command in the terminal:
streamlit run app.py
- Make sure the sidebar shows Chat mode selected.
- Type a message like What is machine learning? in the chat input and press Enter.
- You should see the response stream in word-by-word, just like a real AI assistant.
- Send a follow-up question like Can you give me an example? to confirm the AI remembers context from earlier messages.
Getting an API error?
Double-check that your Gemini API key in .streamlit/secrets.toml is correct and has no extra spaces. If you just created the secrets file while the app was already running, stop the app with Ctrl+C and rerun streamlit run app.py.
help me debug my Gemini API connection error in Streamlit
✔️ Awesome, I've got everything!
Great. Double check you have saved both app.py and .streamlit/secrets.toml.
ⓧ I'd like to double check the full code
Here is the complete app.py at the end of this step:
import streamlit as st
import base64
from google import genai
from google.genai import types
from tavily import TavilyClient
# Page configuration
st.set_page_config(
page_title="Atlas AI",
page_icon="\ud83d\udd2c",
layout="wide"
)
# Custom CSS for professional styling
st.markdown("""
<style>
.stApp > header {display: none;}
.main-header {
font-size: 2rem;
font-weight: 700;
color: #6C63FF;
margin-bottom: 0.5rem;
}
.mode-description {
font-size: 0.9rem;
color: #888;
margin-bottom: 1rem;
}
.source-card {
background-color: #1A1F2E;
border: 1px solid #2D3748;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
}
.source-card a {
color: #6C63FF;
text-decoration: none;
}
</style>
""", unsafe_allow_html=True)
# Initialize API clients
gemini_client = genai.Client(api_key=st.secrets["GEMINI_API_KEY"])
tavily_client = TavilyClient(api_key=st.secrets["TAVILY_API_KEY"])
MODEL = "gemini-2.5-flash"
# Session state initialization
if "messages" not in st.session_state:
st.session_state.messages = []
if "current_mode" not in st.session_state:
st.session_state.current_mode = "Chat"
# Sidebar
with st.sidebar:
st.markdown('<p class="main-header">Atlas AI</p>', unsafe_allow_html=True)
st.markdown("Your multimodal research assistant")
st.divider()
mode = st.radio(
"Mode",
["Chat", "Analyze", "Research"],
index=["Chat", "Analyze", "Research"].index(st.session_state.current_mode),
help="Switch between conversation, file analysis, and web research"
)
st.session_state.current_mode = mode
st.divider()
mode_descriptions = {
"Chat": "Have a conversation with Atlas AI. Ask questions, brainstorm ideas, or get explanations.",
"Analyze": "Upload images or PDFs and ask questions about them. Atlas AI will analyze the content.",
"Research": "Enter a topic and get structured research findings with cited web sources."
}
st.markdown(f'<p class="mode-description">{mode_descriptions[mode]}</p>', unsafe_allow_html=True)
if st.button("Clear conversation", use_container_width=True):
st.session_state.messages = []
st.rerun()
# Main chat area
st.markdown(f"### {mode} Mode")
# Display chat history
for message in st.session_state.messages:
with st.chat_message(message["role"]):
if message.get("images"):
for img_data in message["images"]:
st.image(img_data, width=300)
if message.get("files_info"):
for f_info in message["files_info"]:
st.caption(f"Attached: {f_info}")
st.markdown(message["content"])
if message.get("sources"):
st.markdown("---")
st.markdown("**Sources:**")
for source in message["sources"]:
st.markdown(f'<div class="source-card"><a href="{source["url"]}" target="_blank">{source["title"]}</a><br><small>{source["url"]}</small></div>', unsafe_allow_html=True)
def get_gemini_response_stream(messages, system_instruction=None):
"""Send messages to Gemini and return a streaming response."""
contents = []
for msg in messages:
role = "user" if msg["role"] == "user" else "model"
parts = []
if msg.get("multimodal_parts"):
parts = msg["multimodal_parts"]
else:
parts = [types.Part.from_text(text=msg["content"])]
contents.append(types.Content(role=role, parts=parts))
config = types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=0.7,
)
response_stream = gemini_client.models.generate_content_stream(
model=MODEL,
contents=contents,
config=config,
)
return response_stream
def stream_response(response_stream):
"""Yield text chunks from the streaming response."""
for chunk in response_stream:
if chunk.text:
yield chunk.text
# Mode: Chat
if mode == "Chat":
prompt = st.chat_input("Ask Atlas AI anything...")
if prompt:
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
with st.chat_message("assistant"):
system = "You are Atlas AI, a helpful and knowledgeable research assistant. Provide clear, well-structured responses. Use markdown formatting when appropriate."
response_stream = get_gemini_response_stream(
st.session_state.messages, system_instruction=system
)
response_text = st.write_stream(stream_response(response_stream))
st.session_state.messages.append({"role": "assistant", "content": response_text})
Your Chat mode is alive. Atlas AI can hold a streaming conversation and remember what was said earlier. Next up, you will give it the power to see by adding image and PDF analysis in Analyze mode.
Implement Analyze Mode with File Uploads
Your Chat mode is streaming responses beautifully. Now it is time to give Atlas AI the ability to see. Multimodal AI means the model can process more than just text. It can analyze images and read PDF documents.
In this step, you will build the Analyze mode. Users will upload images or PDFs directly in the chat input, ask a question about them, and Gemini will return a detailed analysis streamed back in real time.
In this step, get ready to:
- Enable file uploads in the chat input for images and PDFs.
- Process uploaded files into multimodal parts that Gemini can understand.
- Display uploads in the chat and stream the AI's analysis response.
Configure chat input for file uploads
Streamlit's st.chat_input has a built-in accept_file parameter that lets users attach files alongside their text message. When files are attached, the return value becomes a dict-like object with .text and ["files"] attributes.
- In app.py, scroll to the bottom of the file and find the end of the Chat mode block (the last line is st.session_state.messages.append({"role": "assistant", "content": response_text})).
- Add the following code directly below it to start the Analyze mode block:
# Mode: Analyze
elif mode == "Analyze":
prompt = st.chat_input(
"Upload a file and ask a question about it...",
accept_file="multiple",
file_type=["png", "jpg", "jpeg", "pdf"],
)
if prompt:
user_text = prompt.text if prompt.text else "Analyze this file in detail."
uploaded_files = prompt["files"] if prompt["files"] else []
What does this code do?
- accept_file="multiple" lets the user attach more than one file at a time in the chat input widget.
- file_type=["png", "jpg", "jpeg", "pdf"] restricts uploads to image and PDF formats only.
- When the user submits, prompt.text holds the typed message and prompt["files"] holds the list of attached files.
- If no text is provided, a default prompt ("Analyze this file in detail.") is used so Gemini always has an instruction.
You will not see a change yet. The next chunk processes the uploaded files.
Process uploaded files into multimodal parts
Gemini accepts multimodal input as a list of "parts" containing text, image bytes, or document bytes. Each uploaded file needs to be read as raw bytes and packaged into the format Gemini expects.
- In app.py, add the following code directly below uploaded_files = prompt["files"] if prompt["files"] else [] (still inside the if prompt: block):
multimodal_parts = [types.Part.from_text(text=user_text)]
display_images = []
files_info = []
for uploaded_file in uploaded_files:
file_bytes = uploaded_file.read()
mime = uploaded_file.type
if mime.startswith("image/"):
display_images.append(file_bytes)
multimodal_parts.append(
types.Part.from_bytes(data=file_bytes, mime_type=mime)
)
elif mime == "application/pdf":
multimodal_parts.append(
types.Part.from_bytes(data=file_bytes, mime_type=mime)
)
files_info.append(uploaded_file.name)
st.session_state.messages.append({
"role": "user",
"content": user_text,
"images": display_images,
"files_info": files_info,
"multimodal_parts": multimodal_parts,
})
What does this code do?
- multimodal_parts starts with the user's text as the first part. Gemini needs at least one text part to know what to do.
- For each uploaded file, .read() extracts the raw bytes and .type gives the MIME type (like image/png or application/pdf).
- types.Part.from_bytes() packages the bytes into a format Gemini can process natively for both images and PDFs.
- Images are also stored in display_images so the app can show them back to the user in the chat.
- The full message dict (with content, images, file info, and multimodal parts) is appended to session state so the chat history persists on rerun.
You will not see a change yet. The next chunk handles displaying the files and streaming the AI's response.
Display uploads and stream the AI's analysis
Now you need to render the uploaded files in the chat UI and send the multimodal message to Gemini for analysis.
- In app.py, add the following code directly below the st.session_state.messages.append block you just wrote (still inside if prompt:):
with st.chat_message("user"):
for img in display_images:
st.image(img, width=300)
for f_info in files_info:
st.caption(f"Attached: {f_info}")
st.markdown(user_text)
with st.chat_message("assistant"):
system = "You are Atlas AI, an expert at analyzing images and documents. Provide detailed, insightful analysis. Describe what you see, extract key information, and answer the user's question thoroughly."
response_stream = get_gemini_response_stream(
st.session_state.messages, system_instruction=system
)
response_text = st.write_stream(stream_response(response_stream))
st.session_state.messages.append({"role": "assistant", "content": response_text})
What does this code do?
- st.image(img, width=300) renders each uploaded image inside the user's chat bubble so you can see what was uploaded.
- st.caption() shows the filename for every attached file (both images and PDFs).
- The system instruction tells Gemini to act as a document/image analysis expert, prompting detailed responses.
- get_gemini_response_stream() (written in the previous step) already handles multimodal parts. It checks each message for a multimodal_parts key and uses those parts directly.
- st.write_stream() displays the response token by token as Gemini generates it.
- Save app.py.
- In your terminal, run your Streamlit app:
streamlit run app.py
- In the sidebar, select Analyze mode.
- Click the paperclip icon in the chat input, upload any image (a screenshot or photo), and type a question like What do you see in this image?.
- Press Enter to submit.
You should see your uploaded image displayed in the chat, followed by a streaming analysis response from Gemini describing the contents of your file.
Not seeing the file upload icon?
- Make sure your Streamlit version is 1.45.0 or later. The accept_file parameter requires a recent version. Run pip show streamlit to check.
- Check that your elif mode == "Analyze": block is at the same indentation level as the if mode == "Chat": block above it.
- If you see an error about GEMINI_API_KEY, confirm your .streamlit/secrets.toml file has the key set correctly.
Why is my file upload not working in Analyze mode?
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py and that your Analyze mode is returning image analysis responses.
ⓧ I'd like to double check the full code
Here is the full app.py as it should look at the end of this step:
import streamlit as st
import base64
from google import genai
from google.genai import types
from tavily import TavilyClient
# Page configuration
st.set_page_config(
page_title="Atlas AI",
page_icon="🔬",
layout="wide"
)
# Custom CSS for professional styling
st.markdown("""
<style>
.stApp > header {display: none;}
.main-header {
font-size: 2rem;
font-weight: 700;
color: #6C63FF;
margin-bottom: 0.5rem;
}
.mode-description {
font-size: 0.9rem;
color: #888;
margin-bottom: 1rem;
}
.source-card {
background-color: #1A1F2E;
border: 1px solid #2D3748;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
}
.source-card a {
color: #6C63FF;
text-decoration: none;
}
</style>
""", unsafe_allow_html=True)
# Initialize API clients
gemini_client = genai.Client(api_key=st.secrets["GEMINI_API_KEY"])
tavily_client = TavilyClient(api_key=st.secrets["TAVILY_API_KEY"])
MODEL = "gemini-2.5-flash"
# Session state initialization
if "messages" not in st.session_state:
st.session_state.messages = []
if "current_mode" not in st.session_state:
st.session_state.current_mode = "Chat"
# Sidebar
with st.sidebar:
st.markdown('<p class="main-header">Atlas AI</p>', unsafe_allow_html=True)
st.markdown("Your multimodal research assistant")
st.divider()
mode = st.radio(
"Mode",
["Chat", "Analyze", "Research"],
index=["Chat", "Analyze", "Research"].index(st.session_state.current_mode),
help="Switch between conversation, file analysis, and web research"
)
st.session_state.current_mode = mode
st.divider()
mode_descriptions = {
"Chat": "Have a conversation with Atlas AI. Ask questions, brainstorm ideas, or get explanations.",
"Analyze": "Upload images or PDFs and ask questions about them. Atlas AI will analyze the content.",
"Research": "Enter a topic and get structured research findings with cited web sources."
}
st.markdown(f'<p class="mode-description">{mode_descriptions[mode]}</p>', unsafe_allow_html=True)
if st.button("Clear conversation", use_container_width=True):
st.session_state.messages = []
st.rerun()
# Main chat area
st.markdown(f"### {mode} Mode")
# Display chat history
for message in st.session_state.messages:
with st.chat_message(message["role"]):
if message.get("images"):
for img_data in message["images"]:
st.image(img_data, width=300)
if message.get("files_info"):
for f_info in message["files_info"]:
st.caption(f"Attached: {f_info}")
st.markdown(message["content"])
if message.get("sources"):
st.markdown("---")
st.markdown("**Sources:**")
for source in message["sources"]:
st.markdown(f'<div class="source-card"><a href="{source["url"]}" target="_blank">{source["title"]}</a><br><small>{source["url"]}</small></div>', unsafe_allow_html=True)
def get_gemini_response_stream(messages, system_instruction=None):
"""Send messages to Gemini and return a streaming response."""
contents = []
for msg in messages:
role = "user" if msg["role"] == "user" else "model"
parts = []
if msg.get("multimodal_parts"):
parts = msg["multimodal_parts"]
else:
parts = [types.Part.from_text(text=msg["content"])]
contents.append(types.Content(role=role, parts=parts))
config = types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=0.7,
)
response_stream = gemini_client.models.generate_content_stream(
model=MODEL,
contents=contents,
config=config,
)
return response_stream
def stream_response(response_stream):
"""Yield text chunks from the streaming response."""
for chunk in response_stream:
if chunk.text:
yield chunk.text
# Mode: Chat
if mode == "Chat":
prompt = st.chat_input("Ask Atlas AI anything...")
if prompt:
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
with st.chat_message("assistant"):
system = "You are Atlas AI, a helpful and knowledgeable research assistant. Provide clear, well-structured responses. Use markdown formatting when appropriate."
response_stream = get_gemini_response_stream(
st.session_state.messages, system_instruction=system
)
response_text = st.write_stream(stream_response(response_stream))
st.session_state.messages.append({"role": "assistant", "content": response_text})
# Mode: Analyze
elif mode == "Analyze":
prompt = st.chat_input(
"Upload a file and ask a question about it...",
accept_file="multiple",
file_type=["png", "jpg", "jpeg", "pdf"],
)
if prompt:
user_text = prompt.text if prompt.text else "Analyze this file in detail."
uploaded_files = prompt["files"] if prompt["files"] else []
multimodal_parts = [types.Part.from_text(text=user_text)]
display_images = []
files_info = []
for uploaded_file in uploaded_files:
file_bytes = uploaded_file.read()
mime = uploaded_file.type
if mime.startswith("image/"):
display_images.append(file_bytes)
multimodal_parts.append(
types.Part.from_bytes(data=file_bytes, mime_type=mime)
)
elif mime == "application/pdf":
multimodal_parts.append(
types.Part.from_bytes(data=file_bytes, mime_type=mime)
)
files_info.append(uploaded_file.name)
st.session_state.messages.append({
"role": "user",
"content": user_text,
"images": display_images,
"files_info": files_info,
"multimodal_parts": multimodal_parts,
})
with st.chat_message("user"):
for img in display_images:
st.image(img, width=300)
for f_info in files_info:
st.caption(f"Attached: {f_info}")
st.markdown(user_text)
with st.chat_message("assistant"):
system = "You are Atlas AI, an expert at analyzing images and documents. Provide detailed, insightful analysis. Describe what you see, extract key information, and answer the user's question thoroughly."
response_stream = get_gemini_response_stream(
st.session_state.messages, system_instruction=system
)
response_text = st.write_stream(stream_response(response_stream))
st.session_state.messages.append({"role": "assistant", "content": response_text})
Atlas AI can now see and understand visual content. Next up, you will give it the power to search the web and synthesize research findings with cited sources.
Implement Research Mode with Tavily
Your Atlas AI app can now hold a conversation and analyze uploaded files. The final mode brings in real-time information from the web.
In Research mode, the user enters a topic and Tavily searches the web for relevant pages. Those results are then passed to Gemini, which synthesizes them into a structured summary with clickable source links.
In this step, get ready to:
- Call Tavily's search API when the user submits a research query.
- Build a prompt that feeds search results to Gemini for synthesis.
- Render a Sources section with clickable links below the AI's response.
Search the web with Tavily
The tavily_client is already initialized at the top of your file. Now you need to wire it up so that when a user types a research query, the app searches the web and collects the top results.
- In app.py, scroll to the very bottom of the file (after the Analyze mode block).
- Add the first part of the Research mode block by pasting this code:
# Mode: Research
elif mode == "Research":
prompt = st.chat_input("Enter a research topic or question...")
if prompt:
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
with st.chat_message("assistant"):
# Show a spinner while Tavily searches the web
with st.spinner("Searching the web..."):
search_response = tavily_client.search(
query=prompt,
max_results=5,
search_depth="basic",
include_answer=True,
)
# Extract the list of results and build a sources reference
results = search_response.get("results", [])
sources = [{"title": r["title"], "url": r["url"]} for r in results]
What does this code do?
This sets up the Research mode entry point. When the user submits a query, it appends their message to session state and displays it in the chat.
The tavily_client.search() call hits Tavily's web search API with the user's query. It returns up to 5 results, each containing a title, URL, and content snippet. The st.spinner shows a loading indicator while the search runs.
You won't see a change yet. The next chunk uses these search results to build a prompt for Gemini.
Build the research prompt and call Gemini
Raw search results alone are hard to read. The next piece formats them into a context string, then asks Gemini to synthesize everything into a clear research summary.
- Directly below the code you just pasted (still inside the with st.chat_message("assistant"): block), add this code:
# Format each result into a text block Gemini can read
context = "Web search results:\n\n"
for r in results:
context += f"Title: {r['title']}\nURL: {r['url']}\nContent: {r['content']}\n\n"
# Build a detailed prompt with the search context and synthesis instructions
research_prompt = f"""Based on the following web search results, provide a comprehensive research summary for the user's query: "{prompt}"
{context}
Instructions:
- Synthesize the information into a clear, well-organized summary
- Use markdown headers and bullet points for readability
- Reference specific sources by name when citing information
- Highlight key findings and insights
- Note any conflicting information between sources"""
# Send the enriched prompt to Gemini (swap user's raw query for the research prompt)
research_messages = st.session_state.messages[:-1] + [
{"role": "user", "content": research_prompt}
]
system = "You are Atlas AI in Research Mode. Synthesize web search results into clear, well-cited research summaries. Always reference your sources by name."
response_stream = get_gemini_response_stream(
research_messages, system_instruction=system
)
response_text = st.write_stream(stream_response(response_stream))
What does this code do?
The context string combines each result's title, URL, and content into one block of text that Gemini can reason over. The research_prompt wraps that context with specific instructions telling Gemini how to structure its summary.
The research_messages list replaces the user's short query with the enriched prompt. This way Gemini sees the full search context instead of just the original question. The streaming response is displayed progressively using st.write_stream.
Why swap the user message instead of appending?
Session state stores the short query ("What is quantum computing?") for display in the chat UI. But Gemini needs the full prompt with search results to do its job. research_messages is a temporary list used only for the API call. It never overwrites what the learner sees in the chat.
You won't see a change yet. The next chunk renders the source links below the AI's response.
Display the sources and save the response
A research summary is only useful if the reader can verify claims. This last piece renders each source as a clickable card below the AI's answer, then saves everything to session state.
- Directly below the response_text = st.write_stream(stream_response(response_stream)) line you just added, paste this code:
# Render clickable source cards below the response
if sources:
st.markdown("---")
st.markdown("**Sources:**")
for source in sources:
st.markdown(f'<div class="source-card"><a href="{source["url"]}" target="_blank">{source["title"]}</a><br><small>{source["url"]}</small></div>', unsafe_allow_html=True)
# Save the assistant's response along with source metadata
st.session_state.messages.append({
"role": "assistant",
"content": response_text,
"sources": sources,
})
What does this code do?
The if sources: block renders a horizontal rule followed by each source as a styled card with a clickable title and the URL displayed below it. The source-card CSS class you defined earlier in the custom styles handles the dark background and purple link color.
Finally, the message is appended to session state with a sources key. The chat history loop at the top of the file already knows how to re-render source cards when it encounters this key on page reruns.
- Save app.py.
- Switch to your browser and select Research in the sidebar.
- Type a research query (for example, "latest advances in renewable energy") and press Enter.
- You should see a "Searching the web..." spinner, then a streaming AI summary followed by a Sources section with purple clickable links.
Not seeing any sources or getting an error?
- Check that your Tavily API key is set correctly in .streamlit/secrets.toml under the key TAVILY_API_KEY.
- Make sure the indentation of the new code matches the Analyze mode block above it. The elif mode == "Research": line should be at the same indent level as elif mode == "Analyze":.
- If you see a spinner that never stops, your Tavily free tier may have hit its monthly limit (1,000 credits). Check your usage at app.tavily.com.
Help me debug my Research mode in Atlas AI
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete app.py file with all three modes implemented:
import streamlit as st
import base64
from google import genai
from google.genai import types
from tavily import TavilyClient
# Page configuration
st.set_page_config(
page_title="Atlas AI",
page_icon="🔬",
layout="wide"
)
# Custom CSS for professional styling
st.markdown("""
<style>
.stApp > header {display: none;}
.main-header {
font-size: 2rem;
font-weight: 700;
color: #6C63FF;
margin-bottom: 0.5rem;
}
.mode-description {
font-size: 0.9rem;
color: #888;
margin-bottom: 1rem;
}
.source-card {
background-color: #1A1F2E;
border: 1px solid #2D3748;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
}
.source-card a {
color: #6C63FF;
text-decoration: none;
}
</style>
""", unsafe_allow_html=True)
# Initialize API clients
gemini_client = genai.Client(api_key=st.secrets["GEMINI_API_KEY"])
tavily_client = TavilyClient(api_key=st.secrets["TAVILY_API_KEY"])
MODEL = "gemini-2.5-flash"
# Session state initialization
if "messages" not in st.session_state:
st.session_state.messages = []
if "current_mode" not in st.session_state:
st.session_state.current_mode = "Chat"
# Sidebar
with st.sidebar:
st.markdown('<p class="main-header">Atlas AI</p>', unsafe_allow_html=True)
st.markdown("Your multimodal research assistant")
st.divider()
mode = st.radio(
"Mode",
["Chat", "Analyze", "Research"],
index=["Chat", "Analyze", "Research"].index(st.session_state.current_mode),
help="Switch between conversation, file analysis, and web research"
)
st.session_state.current_mode = mode
st.divider()
mode_descriptions = {
"Chat": "Have a conversation with Atlas AI. Ask questions, brainstorm ideas, or get explanations.",
"Analyze": "Upload images or PDFs and ask questions about them. Atlas AI will analyze the content.",
"Research": "Enter a topic and get structured research findings with cited web sources."
}
st.markdown(f'<p class="mode-description">{mode_descriptions[mode]}</p>', unsafe_allow_html=True)
if st.button("Clear conversation", use_container_width=True):
st.session_state.messages = []
st.rerun()
# Main chat area
st.markdown(f"### {mode} Mode")
# Display chat history
for message in st.session_state.messages:
with st.chat_message(message["role"]):
if message.get("images"):
for img_data in message["images"]:
st.image(img_data, width=300)
if message.get("files_info"):
for f_info in message["files_info"]:
st.caption(f"Attached: {f_info}")
st.markdown(message["content"])
if message.get("sources"):
st.markdown("---")
st.markdown("**Sources:**")
for source in message["sources"]:
st.markdown(f'<div class="source-card"><a href="{source["url"]}" target="_blank">{source["title"]}</a><br><small>{source["url"]}</small></div>', unsafe_allow_html=True)
def get_gemini_response_stream(messages, system_instruction=None):
"""Send messages to Gemini and return a streaming response."""
contents = []
for msg in messages:
role = "user" if msg["role"] == "user" else "model"
parts = []
if msg.get("multimodal_parts"):
parts = msg["multimodal_parts"]
else:
parts = [types.Part.from_text(text=msg["content"])]
contents.append(types.Content(role=role, parts=parts))
config = types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=0.7,
)
response_stream = gemini_client.models.generate_content_stream(
model=MODEL,
contents=contents,
config=config,
)
return response_stream
def stream_response(response_stream):
"""Yield text chunks from the streaming response."""
for chunk in response_stream:
if chunk.text:
yield chunk.text
# Mode: Chat
if mode == "Chat":
prompt = st.chat_input("Ask Atlas AI anything...")
if prompt:
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
with st.chat_message("assistant"):
system = "You are Atlas AI, a helpful and knowledgeable research assistant. Provide clear, well-structured responses. Use markdown formatting when appropriate."
response_stream = get_gemini_response_stream(
st.session_state.messages, system_instruction=system
)
response_text = st.write_stream(stream_response(response_stream))
st.session_state.messages.append({"role": "assistant", "content": response_text})
# Mode: Analyze
elif mode == "Analyze":
prompt = st.chat_input(
"Upload a file and ask a question about it...",
accept_file="multiple",
file_type=["png", "jpg", "jpeg", "pdf"],
)
if prompt:
user_text = prompt.text if prompt.text else "Analyze this file in detail."
uploaded_files = prompt["files"] if prompt["files"] else []
multimodal_parts = [types.Part.from_text(text=user_text)]
display_images = []
files_info = []
for uploaded_file in uploaded_files:
file_bytes = uploaded_file.read()
mime = uploaded_file.type
if mime.startswith("image/"):
display_images.append(file_bytes)
multimodal_parts.append(
types.Part.from_bytes(data=file_bytes, mime_type=mime)
)
elif mime == "application/pdf":
multimodal_parts.append(
types.Part.from_bytes(data=file_bytes, mime_type=mime)
)
files_info.append(uploaded_file.name)
st.session_state.messages.append({
"role": "user",
"content": user_text,
"images": display_images,
"files_info": files_info,
"multimodal_parts": multimodal_parts,
})
with st.chat_message("user"):
for img in display_images:
st.image(img, width=300)
for f_info in files_info:
st.caption(f"Attached: {f_info}")
st.markdown(user_text)
with st.chat_message("assistant"):
system = "You are Atlas AI, an expert at analyzing images and documents. Provide detailed, insightful analysis. Describe what you see, extract key information, and answer the user's question thoroughly."
response_stream = get_gemini_response_stream(
st.session_state.messages, system_instruction=system
)
response_text = st.write_stream(stream_response(response_stream))
st.session_state.messages.append({"role": "assistant", "content": response_text})
# Mode: Research
elif mode == "Research":
prompt = st.chat_input("Enter a research topic or question...")
if prompt:
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
with st.chat_message("assistant"):
with st.spinner("Searching the web..."):
search_response = tavily_client.search(
query=prompt,
max_results=5,
search_depth="basic",
include_answer=True,
)
results = search_response.get("results", [])
sources = [{"title": r["title"], "url": r["url"]} for r in results]
context = "Web search results:\n\n"
for r in results:
context += f"Title: {r['title']}\nURL: {r['url']}\nContent: {r['content']}\n\n"
research_prompt = f"""Based on the following web search results, provide a comprehensive research summary for the user's query: "{prompt}"
{context}
Instructions:
- Synthesize the information into a clear, well-organized summary
- Use markdown headers and bullet points for readability
- Reference specific sources by name when citing information
- Highlight key findings and insights
- Note any conflicting information between sources"""
research_messages = st.session_state.messages[:-1] + [
{"role": "user", "content": research_prompt}
]
system = "You are Atlas AI in Research Mode. Synthesize web search results into clear, well-cited research summaries. Always reference your sources by name."
response_stream = get_gemini_response_stream(
research_messages, system_instruction=system
)
response_text = st.write_stream(stream_response(response_stream))
if sources:
st.markdown("---")
st.markdown("**Sources:**")
for source in sources:
st.markdown(f'<div class="source-card"><a href="{source["url"]}" target="_blank">{source["title"]}</a><br><small>{source["url"]}</small></div>', unsafe_allow_html=True)
st.session_state.messages.append({
"role": "assistant",
"content": response_text,
"sources": sources,
})
All three modes are now fully operational. Atlas AI can chat, analyze files, and research the web. You have a complete multimodal research assistant ready to show off.
Secret mission
Add Conversation Memory Across Modes
Right now, Atlas AI forgets everything when you switch modes. Upload an image in Analyze mode, switch to Chat, and ask about it. The AI has no idea what you're talking about. Let's fix that by giving Atlas AI cross-mode memory.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project uses free-tier APIs with no ongoing costs, so there is no financial risk in leaving everything active.
Resources you used:
- Project folder on your Desktop containing app.py, .streamlit/config.toml, and requirements.txt.
- Gemini API key (free tier, no charges within free tier limits).
- Tavily API key (free, 1,000 credits per month).
- Python packages: streamlit, google-genai, tavily-python.
Keep everything running
No action needed. Choose this if you plan to keep building on Atlas AI or experimenting with the modes.
- Your API keys remain active on the free tier with no charges.
- Your project files stay on your Desktop ready for future development.
- The installed Python packages remain available for other projects that use them.
Pause - I'll come back to this later
No action needed. Both the Gemini and Tavily free tiers have no billing, so there is nothing to pause.
- Your API keys will remain valid when you return.
- If you have the Streamlit dev server running, stop it by pressing Ctrl+C in the terminal.
Delete - I don't want to use this again
Remove all project resources and revoke your API keys so nothing lingers on your machine or in your accounts.
- Stop the Streamlit dev server if it is still running by pressing Ctrl+C in the terminal.
- Delete your project folder by running this command (macOS/Linux):
cd ~/Desktop
rm -rf atlas-ai
On Windows, run:
cd ~\Desktop
rmdir /s /q atlas-ai
- Uninstall the Python packages by running:
pip uninstall streamlit google-genai tavily-python -y
- Revoke your Gemini API key. Navigate to aistudio.google.com, open the API keys section, find the key you created for this project, and delete it.
- Revoke your Tavily API key. Navigate to app.tavily.com, open your account settings or API keys section, and delete the key you created for this project.
That's a Wrap!
That's a Wrap!
Nice work! You just built Atlas AI, a professional multimodal research assistant that can hold conversations, analyze uploaded files, and search the web for structured research findings.
You've learned how to:
- Integrated Gemini 2.5 Flash for multimodal AI capabilities including chat, image analysis, and PDF understanding with streaming responses.
- Connected Tavily's web search API to power a research tool that returns structured findings with clickable source URLs.
- Built a polished dark-themed UI with Streamlit's chat components, custom CSS styling, sidebar mode switching, and session state management for persistent conversation history.
- Secret Mission: Added cross-mode memory so Atlas AI maintains context across Chat, Analyze, and Research interactions.
Ready to quiz yourself?