昨天我們輕鬆搞定了 Firebase 專案創建與金鑰封印。今天我們要來解決 Day 03 發現的致命痛點,只要按下 F5 重新整理,對話紀錄就直接蒸發 的悲慘命運。
今天的任務很純粹:
db.py 測試連線與寫入
app.py
db.py)為了不讓 app.py 變得又長又亂,我們另外寫一個 db.py 來專門處理資料庫的讀寫。
在專案目錄下新增 db.py:
import firebase_admin
from firebase_admin import credentials, firestore
# 初始化 Firebase (避免重複初始化)
if not firebase_admin._apps:
cred = credentials.Certificate("firebase-key.json")
firebase_admin.initialize_app(cred)
db = firestore.client()
# 一次載入的最大訊息總數(一輪對話 = user + assistant 共 2 筆,所以 50 筆約等於 25 輪)
# 這個上限同時擋住兩件事:Firestore 的讀取次數、以及送進 Gemini 的 token 量
MAX_HISTORY = 50
# 儲存對話紀錄到 Firestore
def save_message(user_id: str, role: str, content: str):
doc_ref = db.collection("users").document(user_id).collection("chat_history").document()
doc_ref.set({
"role": role,
"content": content,
"timestamp": firestore.SERVER_TIMESTAMP
})
# 讀取歷史對話紀錄 (只取最近的 MAX_HISTORY 筆)
def load_messages(user_id: str, limit: int = MAX_HISTORY):
messages_ref = db.collection("users").document(user_id).collection("chat_history")
# limit_to_last 取的是「排序後的尾端」,也就是時間最新的那幾筆,
# 但回傳時仍維持 ASCENDING (舊 -> 新),剛好就是對話該有的順序
# 注意:limit_to_last 只能搭配 .get(),用 .stream() 會直接拋錯
docs = (
messages_ref
.order_by("timestamp", direction=firestore.Query.ASCENDING)
.limit_to_last(limit)
.get()
)
history = []
for doc in docs:
data = doc.to_dict()
# 用 get 而不是 []:只要有一筆文件缺欄位(後台手動改壞、寫入中斷),
# 用 [] 會直接 KeyError 讓整個 app 打不開,get 則是跳過那筆繼續讀
role = data.get("role")
content = data.get("content")
if role is None or content is None:
continue
history.append({"role": role, "content": content})
return history
app.py)接著打開 Day 03 的 app.py,把剛才寫好的 db.py 匯入進來。
修改後的 app.py (大更正版本)如下:
import streamlit as st
from dotenv import load_dotenv
from google import genai
from google.genai import types
from db import load_messages, save_message
# 載入 API Key
load_dotenv()
# 設定網頁標題與圖示
st.set_page_config(page_title="AI 角色陪伴系統", page_icon="images/AI_icon.png", layout="wide")
st.title("你的專屬 AI 陪伴 Agent")
# 初始化 user_id (可根據實際需求改為登入系統的 userID 或固定的測試 ID)
if "user_id" not in st.session_state:
st.session_state.user_id = "test_user_001" # 範例用的固定 ID,也可改為 str(uuid.uuid4())
# 初始化 Gemini Client
@st.cache_resource
def get_gemini_client():
return genai.Client()
client = get_gemini_client()
USER_AVATAR = "images/USER_icon.png" # 使用者的圖片(沒有圖片也可以用 Emoji)
AI_AVATAR = "images/AI_icon.png"
# 初始化 Session State (記憶對話歷史)
if "messages" not in st.session_state:
db_history = load_messages(st.session_state.user_id)
if db_history:
# 如果資料庫中有紀錄,載入進 Session State
st.session_state.messages = db_history
else:
# 如果是新使用者,給予預設開場白並寫入 Firestore
default_msg = "哼!別以為我是特地在這裡等你的,只是剛好程式啟動了而已!有什麼事快說!"
st.session_state.messages = [{"role": "assistant", "content": default_msg}]
save_message(st.session_state.user_id, "assistant", default_msg)
# 初始化 Gemini 記憶對話物件
if "chat" not in st.session_state:
# 將舊有的對話格式轉換為 Gemini Chat 所需的 Content 格式
# 開場白 (model 角色) 會一起帶進去,實測 history 以 model 開頭是合法的
gemini_history = []
for msg in st.session_state.messages:
# 轉換 role 格式:assistant -> model, user -> user
gemini_role = "model" if msg["role"] == "assistant" else "user"
gemini_history.append(
types.Content(
role=gemini_role,
parts=[types.Part.from_text(text=msg["content"])]
)
)
st.session_state.chat = client.chats.create(
model="gemini-3.6-flash",
history=gemini_history,
config=types.GenerateContentConfig(
system_instruction="你現在是一個傲嬌角色。請直接以第一人稱回覆使用者,回答請簡短,不要列出選項或多餘說明。"
)
)
# 歷史對話紀錄
for msg in st.session_state.messages:
# 判斷如果是 assistant 就用圖片,否則用預設/其他頭像
avatar_icon = AI_AVATAR if msg["role"] == "assistant" else USER_AVATAR
with st.chat_message(msg["role"], avatar=avatar_icon):
st.write(msg["content"])
# 接收使用者輸入
if user_input := st.chat_input("跟你的角色說點什麼吧..."):
# 顯示使用者發送的訊息
with st.chat_message("user", avatar=USER_AVATAR):
st.write(user_input)
# 先呼叫 API,成功之後才寫入紀錄
# 順序很重要:如果先存 user 訊息、API 才失敗,
# Firestore 會留下筆沒有回覆的孤兒訊息,下次載入時歷史就以 user 結尾
# 用 send_message!Gemini 會自動帶入之前的對話上下文
try:
response = st.session_state.chat.send_message(user_input)
except Exception as e:
# 這一輪完全不落地:session 和 Firestore 都維持送出前的狀態
st.error(f"角色沒有回應,API 出錯了:{e}")
st.info("這句話沒有被記錄,重新整理後會消失,可以直接再說一次。")
st.stop()
# 顯示 AI 角色的回應
with st.chat_message("assistant", avatar=AI_AVATAR):
st.write(response.text)
# 確定有回應了,才把這一輪的兩筆訊息一起存入 Session 與 Firestore
st.session_state.messages.append({"role": "user", "content": user_input})
save_message(st.session_state.user_id, "user", user_input)
st.session_state.messages.append({"role": "assistant", "content": response.text})
save_message(st.session_state.user_id, "assistant", response.text)
這裡提一下,正式環境不可使用固定的 test_user_001。
本文僅使用固定 ID 方便 Demo 與測試;正式環境應使用經過驗證的使用者 ID,並由後端驗證使用者身分,不能直接信任前端傳入的。




1. 訊息變孤兒
2. 壞資料讓系統當機
data["role"] 硬抓資料,只要資料庫裡有一筆紀錄漏掉欄位,整個網頁就直接噴錯(歐歐歐歐的)死掉。.get() 溫和讀取,遇到欄位缺失的壞資料就自動跳過,保障程式正常運作。3. 歷史紀錄塞爆(防讀取慢與 Token 爆炸)
limit_to_last 時,必須把 .stream() 改成 .get() 才不會報錯。