跳至內容

網頁使用者介面

本導覽說明位於 Python 零售分析 API 前端的靜態瀏覽器用戶端。您將了解 FastAPI 如何提供檔案、原生 JavaScript 如何將聊天回應串流至訊息清單、如何保存本機設定,以及模型選擇器如何與目前可用的模型清單保持同步。

應用程式結構與連接埠

Python 使用者介面由下列位置的靜態 HTML 與原生 JavaScript 組成: app/static/index.htmlapp/static/app.js。它由 FastAPI 提供,而不是 Blazor WebAssembly。

此架構不需要建置步驟,也不必下載 WebAssembly 執行階段;代價是沒有元件模型、編譯期使用者介面型別安全性或自動產生的用戶端程式碼。

單一伺服器會透過連接埠 5070 同時提供 API 與使用者介面。這與 .NET 路線不同;在 .NET 路線中,API 執行於 5050,Blazor 使用者介面則執行於 5051。由於瀏覽器要求來自相同來源,一般使用者介面路徑不需要經過 CORS。

⚠️ 連接埠不能任意選擇。Chrome、Edge 與 Firefox 會完全封鎖連接埠 5060 (SIP),因此若從該處提供使用者介面,會出現 ERR_UNSAFE_PORT 錯誤,即使 curl 可以成功。為瀏覽器必須載入的服務選擇連接埠時,請避開 5060、5061 與 6000。

尚無訊息時,頁面會顯示歡迎標題與五個建議的零售問題:

零售分析助理使用者介面的初始狀態:頁首包含模型下拉式選單、清除按鈕與佈景主題切換按鈕;中央顯示歡迎標題、五個建議選項與訊息輸入欄位。

FastAPI 靜態掛載

app/main.py 會先加入 API 路由器:

app.include_router(chat.router)
app.include_router(transactions.router)
app.include_router(segments.router)

接著將靜態應用程式掛載於 /

# Mounted last so it does not shadow the /api routes above.
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")

⚠️ 順序很重要。 StaticFiles 掛載於 /,因此若在路由器之前掛載,就會遮蔽 /api/... 要求。

index.html

index.html 包含完整的頁面框架:頁首、模型選擇器、清除按鈕、佈景主題切換按鈕、訊息容器、歡迎面板、建議項目、文字區域與傳送按鈕。

<h1>📊 Retail Analytics Assistant</h1>
<span class="badge">Copilot SDK Demo · Python</span>

頁面會從 CDN 載入 markedhighlight.js,用於呈現 Markdown 與醒目提示語法。樣式表為 app/static/app.css;此檔案複製自 Blazor 專案,並移除 Blazor 專用規則,讓兩條路線的外觀一致。

頁面狀態與本機儲存空間

app.js 會維持與 Blazor Home.razor 頁面相同的狀態:

let messages = [];
let selectedModel = 'claude-haiku-4.5';
let isDark = true;
let isStreaming = false;

它使用與 Blazor 用戶端相同的索引鍵,保存訊息、所選模型與佈景主題:

const STORAGE_KEYS = {
    messages: 'chat_messages',
    model: 'selected_model',
    theme: 'theme',
};

loadState() 會在頁面載入時還原這些值; setTheme() 會更新根類別、highlight.js 佈景主題,以及 localStorage 值。

模型選擇器

模型選擇器會擷取 GET /api/chat/models

const res = await fetch('/api/chat/models');

API 會傳回 JSON 清單,其中包含 {id, name, description}。使用者介面會將此清單縮減為 Blazor ChatService 所使用的 ID → 標籤字典結構:

const list = await res.json();
if (Array.isArray(list) && list.length > 0) {
    models = Object.fromEntries(
        list.filter((m) => m.id).map((m) => [m.id, m.name || m.id])
    );
}

如果無法連上 API,頁面會改用包含六個模型的靜態目錄(claude-haiku-4.5gpt-4.1gpt-5claude-sonnet-4.5claude-opus-4.5gemini-2.5-pro)。如果 localStorage 中儲存的模型已過時,則會以 claude-haiku-4.5 取代(若可用);否則使用第一個目前可用的模型。

串流呈現

sendMessage() 會附加使用者訊息、加入空白的助理預留位置,並向 SSE 端點送出 POST 要求:

const res = await fetch('/api/chat/stream', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ prompt, model: selectedModel }),
});

⚠️ 該欄位名稱不可變更。 較早的修訂版本傳送的是 { message: prompt, ... }ChatRequest 宣告的是 prompt,而 Pydantic 會忽略未知索引鍵,不會拒絕它們。因此要求傳回 200 並串流實際回應,但輸入的提示卻在沒有任何通知的情況下遭捨棄。整個流程沒有報錯;模型只是回答了一個空白問題。

這正是在用戶端與伺服器邊界使用寬鬆剖析器的完整風險,因此 tests/test_chat_contract.py 會判斷 app.js 所送出的欄位正是 ChatRequest 所讀取的欄位。

回應本文會使用下列項目讀取: fetch()res.body.getReader(),以及 TextDecoder

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

每次讀取的內容都會解碼並分割成多行,結尾尚未完整的行會保留至下一次讀取:

buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';

剖析器會尋找 data: 訊框、將 [DONE] 辨識為完成哨兵值,並且只附加 content 值。伺服器會在 [DONE] 之後關閉回應,因此讀取器迴圈會結束。

if (!line.startsWith('data: ')) continue;
const payload = line.slice(6);
if (payload === '[DONE]') continue;

呈現節流

使用者介面每隔 50 毫秒重新繪製一次,約為每秒 20 個畫面:

const timer = setInterval(() => {
    if (!needsRender) return;
    needsRender = false;
    messages[messages.length - 1].content = content;
    renderMessages();
}, 50);

這直接對應到 Blazor 用戶端的呈現計時器。快速模型可能產生數百個差異片段;若每個權杖都重新繪製,會浪費瀏覽器資源,並降低捲動的穩定性。

輸入、建議與佈景主題

按一下按鈕或在未按住 Shift 時按 Enter,即會送出輸入內容;串流期間會停用輸入,回應完成後則恢復焦點。建議選項與輸入文字使用相同的傳送路徑,其中包含如下字串: Who are our highest spending customers?Predict which segment customer C002 belongs to。佈景主題按鈕可切換深色與淺色模式,並變更作用中的 highlight.js 樣式表。

以下是透過上述路徑呈現的完整對話:

聊天使用者介面顯示一段完整對話:使用者要求「列出三個零售 KPI,每項各一行」,助理則以編號 Markdown 清單回答轉換率、平均訂單價值(AOV)與客戶留存率。