Chapter 3: 使用者介面 (Streamlit UI)

歡迎來到 open-notebook 教學系列的第三章!在前一章 模型管理器 (ModelManager) 中,我們學會了如何集中管理應用程式所需的各種 AI 模型,就像一個隨時待命的工具箱管理員。我們現在有了管理資料的 物件模型 (ObjectModel) 和管理 AI 工具的 ModelManager

但是,使用者如何與這些強大的工具和儲存的資料互動呢?他們如何看到自己的筆記本列表?如何新增筆記?如何觸發 AI 功能來產生摘要或與知識庫聊天?這就需要一個友善且直觀的「門面」——也就是我們的使用者介面。

為什麼需要使用者介面?

想像一下,就算您擁有一輛引擎強大、功能齊全的汽車,但如果沒有方向盤、儀表板、油門和煞車踏板,您也無法駕駛它去任何地方。您需要一個控制面板來告訴汽車您想做什麼,並看到汽車目前的狀態(例如速度、油量)。

open-notebook 中,使用者介面 (UI) 就扮演著這個「駕駛艙」的角色。它提供了視覺元素和互動方式,讓使用者能夠:

如果沒有 UI,使用者就無法方便地使用 open-notebook 的各種功能。UI 是連接使用者和應用程式底層邏輯(如物件模型、模型管理器)的橋樑。

什麼是 Streamlit UI?

open-notebook 使用一個名為 Streamlit 的 Python 函式庫來建立其網頁使用者介面。Streamlit 的最大優點是它讓開發者可以用純 Python 程式碼快速建立互動式的網頁應用程式,而不需要編寫複雜的前端程式碼(如 HTML, CSS, JavaScript)。

您可以將 Streamlit 想像成一個數位樂高積木

open-notebook 的 Streamlit UI 主要包含以下幾個部分:

  1. 頁面 (Pages): 應用程式的不同主要區塊,例如:
    • app_home.py: 應用的主入口(通常重新導向)。
    • pages/2_📒_Notebooks.py: 顯示筆記本列表和單一筆記本內容的頁面。
    • pages/3_🔍_Ask_and_Search.py: 執行搜尋和問答功能的頁面。
    • pages/7_🤖_Models.py: 管理和設定 AI 模型的頁面。
    • Streamlit 會自動將 pages/ 目錄下的 Python 檔案轉換成側邊欄的導覽項目。
  2. 可重複使用的元件 (Components): 將常用的 UI 區塊封裝成函式,方便在不同地方重複使用,例如:
    • pages/components/note_panel.py: 用於顯示和編輯單一筆記的面板。
    • pages/components/source_panel.py: 用於顯示和處理單一來源的面板。
    • pages/stream_app/note.py 中的 note_card(): 用於在列表中顯示筆記摘要卡片。
    • pages/stream_app/source.py 中的 source_card(): 用於在列表中顯示來源摘要卡片。

透過組合這些頁面和元件,open-notebook 為使用者提供了一個互動式的介面來存取其所有功能。

Streamlit UI 如何運作:一個範例

讓我們以「查看筆記本列表並建立一個新筆記本」這個常見的使用情境為例,看看 Streamlit UI 是如何實現的。這個功能主要由 pages/2_📒_Notebooks.py 這個檔案處理。

1. 顯示頁面標題和基本佈局

當使用者點擊側邊欄的「📒 Notebooks」連結時,Streamlit 會執行 pages/2_📒_Notebooks.py 裡的程式碼。

# pages/2_📒_Notebooks.py (簡化片段)
import streamlit as st
from open_notebook.domain.notebook import Notebook # 匯入 Notebook 物件模型
from pages.stream_app.utils import setup_page # 匯入頁面設定輔助函式

# 設定頁面標題和基本配置
setup_page("📒 Open Notebook") 

# ... 其他程式碼 ...

# 顯示主標題
st.title("📒 我的筆記本") 
st.caption("筆記本是組織您的想法、點子和來源的好方法...") 

程式碼解釋:

Streamlit 會將這些指令轉換成對應的 HTML 元素顯示在瀏覽器中。

2. 讀取並顯示筆記本列表

為了顯示現有的筆記本,UI 程式碼需要從後端(也就是我們的 物件模型 (ObjectModel))取得資料。

# pages/2_📒_Notebooks.py (簡化片段)
from pages.stream_app.note import note_card # 匯入顯示筆記卡片的元件
from pages.stream_app.source import source_card # 匯入顯示來源卡片的元件
from pages.stream_app.utils import setup_stream_state # 匯入狀態管理輔助函式
from humanize import naturaltime # 用於顯示相對時間 (例如 "5分鐘前")

# ... (前面顯示標題的程式碼) ...

def notebook_list_item(notebook):
    """一個顯示單一筆記本摘要的函式 (元件)"""
    with st.container(border=True): # 建立一個帶邊框的容器
        st.subheader(notebook.name) # 顯示筆記本名稱 (子標題)
        st.caption(
            f"建立時間: {naturaltime(notebook.created)}, 更新時間: {naturaltime(notebook.updated)}"
        )
        st.write(notebook.description) # 顯示筆記本描述
        # 顯示一個 "Open" 按鈕,key 是為了讓每個按鈕獨一無二
        if st.button("開啟", key=f"open_notebook_{notebook.id}"):
            # 如果按鈕被點擊,設定 session_state 並重新執行頁面
            st.session_state["current_notebook_id"] = notebook.id
            st.rerun()

# --- 主邏輯 ---
# 從資料庫獲取所有未封存的筆記本 (使用 ObjectModel 的功能)
notebooks = Notebook.get_all(order_by="updated desc") 
active_notebooks = [nb for nb in notebooks if not nb.archived]

# 遍歷每個筆記本,並使用 notebook_list_item 函式顯示它
for notebook in active_notebooks:
    notebook_list_item(notebook)

程式碼解釋:

3. 處理使用者輸入:建立新筆記本

UI 還需要提供讓使用者輸入資訊的方式,例如建立新筆記本時輸入名稱和描述。

# pages/2_📒_Notebooks.py (簡化片段)

# ... (前面顯示列表的程式碼) ...

# 使用 st.expander 建立一個可展開/收合的區塊
with st.expander("➕ **新增筆記本**"):
    # 使用 st.text_input 建立一個文字輸入框,用於輸入名稱
    new_notebook_title = st.text_input("新筆記本名稱")
    # 使用 st.text_area 建立一個多行文字輸入區,用於輸入描述
    new_notebook_description = st.text_area(
        "描述",
        placeholder="解釋這個筆記本的用途..."
    )
    # 建立一個 "Create" 按鈕
    if st.button("建立新筆記本", icon="➕"):
        # 如果按鈕被點擊
        if new_notebook_title: # 簡單檢查名稱是否為空
            # 1. 建立一個 Notebook 物件 (使用 ObjectModel)
            notebook = Notebook(
                name=new_notebook_title, description=new_notebook_description
            )
            # 2. 呼叫 save() 方法將其儲存到資料庫 (使用 ObjectModel)
            notebook.save()
            st.toast("筆記本建立成功", icon="📒") # 顯示一個短暫的提示訊息
            st.rerun() # 重新執行頁面以更新列表
        else:
            st.warning("請輸入筆記本名稱") # 如果名稱為空,顯示警告

# ... (顯示封存筆記本的程式碼) ...

程式碼解釋:

4. 顯示單一筆記本(導覽與狀態)

當使用者點擊某個筆記本的「開啟」按鈕時,我們需要顯示該筆記本的詳細內容,而不是列表。這通常透過 st.session_state 來控制。

# pages/2_📒_Notebooks.py (簡化片段)

# ... (匯入 和 setup_page) ...

def notebook_page(current_notebook: Notebook):
    """顯示單一筆記本內容的函式 (頁面片段)"""
    st.header(current_notebook.name) # 顯示筆記本名稱
    st.write(current_notebook.description) # 顯示描述
    
    # 顯示返回按鈕
    if st.button("返回列表", icon="🔙"):
        st.session_state["current_notebook_id"] = None # 清除狀態
        st.rerun() # 返回列表頁面

    # 顯示該筆記本的來源和筆記 (使用 source_card 和 note_card 元件)
    st.subheader("來源")
    for source in current_notebook.sources:
        source_card(source=source, notebook_id=current_notebook.id)
        
    st.subheader("筆記")
    for note in current_notebook.notes:
        note_card(note=note, notebook_id=current_notebook.id)
    
    # ... 可能還有聊天側邊欄等其他元件 ...

# --- 主邏輯 ---
# 檢查 session_state 中是否儲存了當前要顯示的筆記本 ID
if "current_notebook_id" not in st.session_state:
    st.session_state["current_notebook_id"] = None

if st.session_state["current_notebook_id"]:
    # 如果有 ID,則從資料庫讀取該 Notebook 物件
    current_notebook: Notebook = Notebook.get(st.session_state["current_notebook_id"])
    if not current_notebook:
        st.error("找不到筆記本")
        st.session_state["current_notebook_id"] = None # 清除無效 ID
        st.rerun()
    else:
        # 呼叫 notebook_page 函式來顯示單一筆記本的內容
        notebook_page(current_notebook)
        st.stop() # 停止執行,避免顯示後面的列表程式碼
else:
    # 如果沒有 current_notebook_id,則顯示筆記本列表
    st.title("📒 我的筆記本")
    # ... (顯示 "新增筆記本" expander 的程式碼) ...
    # ... (顯示筆記本列表的程式碼) ...

程式碼解釋:

這個例子展示了 Streamlit 如何透過簡單的 Python 指令建立 UI、如何與後端(物件模型)互動來讀寫資料、以及如何使用 st.session_state 來管理簡單的頁面狀態和導覽。

深入探索:Streamlit 的內部機制

理解 Streamlit 的基本運作方式有助於更好地使用它。

Streamlit 的執行模型

Streamlit 最核心的概念是它的重新執行 (rerun) 模型。當使用者與任何 widget(按鈕、滑塊、文字輸入等)互動時,Streamlit 會從頭到尾重新執行整個 Python 腳本

這聽起來可能效率不高,但 Streamlit 在內部做了很多優化:

狀態管理 (st.session_state)

由於每次互動都會重新執行腳本,普通的 Python 變數無法在多次執行之間保持其值。st.session_state 就是為了解決這個問題而設計的。它是一個類似字典的物件,您可以在其中儲存需要在重新執行之間保留的資訊。

我們在上面的例子中看到了如何使用 st.session_state["current_notebook_id"] 來追蹤使用者想要查看的筆記本。open-notebook 在許多地方使用 st.session_state 來儲存:

UI 與後端互動流程

當使用者在 Streamlit UI 中執行一個操作(例如點擊「儲存」按鈕)時,通常會發生以下情況:

sequenceDiagram participant User as 使用者 participant StreamlitUI as Streamlit UI (瀏覽器) participant StreamlitScript as Streamlit 腳本 (Python) participant DomainLogic as 領域邏輯 (例如 ObjectModel, ModelManager) participant DatabaseRepo as 資料庫儲存庫 participant Database as 資料庫 User->>StreamlitUI: 點擊 "儲存" 按鈕 StreamlitUI->>StreamlitScript: 觸發腳本重新執行 StreamlitScript->>StreamlitScript: 檢查哪個按鈕被點擊 (if st.button(...)) StreamlitScript->>DomainLogic: 呼叫相關函式 (例如 notebook.save()) DomainLogic->>DatabaseRepo: 呼叫儲存庫函式 (例如 repo_update) DatabaseRepo->>Database: 執行資料庫操作 (UPDATE ...) Database-->>DatabaseRepo: 操作結果 DatabaseRepo-->>DomainLogic: 回傳結果 DomainLogic-->>StreamlitScript: 儲存完成 (可能更新物件狀態) StreamlitScript->>StreamlitScript: 準備更新後的 UI 元素 StreamlitScript->>StreamlitUI: 將新的 UI 渲染到瀏覽器 StreamlitUI->>User: 顯示更新後的介面 (例如提示 "儲存成功")

流程解釋:

  1. 使用者與 UI 互動(點擊按鈕)。
  2. 瀏覽器通知 Streamlit 後端,觸發對應的 Python 腳本重新執行。
  3. 腳本執行到被點擊的按鈕處 (if st.button(...)),條件為真。
  4. 腳本內的程式碼呼叫後端的業務邏輯(例如 Notebook 物件的 save() 方法)。
  5. 業務邏輯(屬於 物件模型 (ObjectModel))與 資料庫儲存庫 (Database Repository) 互動,執行資料庫操作。
  6. 操作完成後,結果返回到 Streamlit 腳本。
  7. 腳本繼續執行,可能會根據結果更新 st.session_state 或準備新的 UI 元素(例如 st.toast 訊息)。
  8. Streamlit 將腳本生成的最終 UI 狀態發送回瀏覽器進行渲染。

檔案結構與元件

open-notebook 的 UI 相關程式碼主要分佈在:

這種結構有助於保持 UI 程式碼的組織性和可維護性。

總結

在本章中,我們探索了 open-notebook 的使用者介面,以及它是如何使用 Streamlit 函式庫建立的。

現在我們有了一個可以讓使用者瀏覽資料、輸入內容並與 AI 模型互動的介面。但是,當使用者透過 UI 新增了一個來源(例如一個網址或一個文件)後,應用程式需要對這個來源進行處理,例如提取文字內容、產生摘要、計算嵌入向量等。這個處理過程可能包含多個步驟。

在下一章,我們將深入探討 內容處理流程 (Content Processing Graph),了解 open-notebook 如何定義和執行這些多步驟的內容處理任務。