Day 23 的對話頁已能使用,但送出後會等模型完成整句回覆才顯示內容,畫面會暫時空白。今天改為讓教練回覆逐字顯示。
做法分兩邊:後端新增一支 POST /chat/stream,模型每產生一小段字就立刻送出去;前端改成邊收邊顯示,順便加上聊天泡泡和「停止」按鈕。
SSE 是伺服器單向、持續把資料送到瀏覽器的方式。原本的 /chat 會等完整回覆生成後才回傳;SSE 則會在每一小段內容產生時立即送出。
前端送出問題 → 後端開始請模型回答
後端:「Amazon」 → 前端立刻顯示
後端:「 Web」 → 前端接在後面顯示
後端:「 Services」 → 前端再接上去
後端:「done」 → 前端知道結束了
SSE 的格式很簡單:每一則訊息以 data: 開頭,後面接內容,最後空一行代表這則結束。
data: {"type": "token", "content": "Amazon"}
data: {"type": "token", "content": " Web"}
data: {"type": "done", "intent": "qa"}
模型生成總時間大致不變,但第一個字會更早顯示,使用者能立即知道系統正在回應。
EventSource瀏覽器內建一個專門收 SSE 的 EventSource,但它只能發 GET 請求,沒辦法帶 JSON 的請求內容。我們的 /chat 需要送 user_id 和 message,所以前端改用 fetch,自己一段一段讀回應。多寫一點程式碼,換來可以用 POST,也可以用 AbortController 隨時切斷連線。
POST /chat/stream檔案位置: backend/main.py
狀態: 修改檔案(接在 Day 19 的 /chat 後面,新增,原本的 /chat 保留不動)
用途: 把對話圖的輸出改成一個字一個字串流回去
依賴: fastapi, graph (已安裝)
import json
from fastapi.responses import StreamingResponse
def _sse(event: dict) -> str:
"""把一則事件包成SSE格式:以「data: 」開頭,空一行代表這則結束"""
return f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
@app.post("/chat/stream")
def chat_stream(payload: ChatRequest) -> StreamingResponse:
"""跟 /chat 做同一件事,但回應改成一個字一個字串流出來"""
config = {"configurable": {"thread_id": f"user-{payload.user_id}"}}
inputs = {"messages": [{"role": "user", "content": payload.message}], "intent": ""}
def event_generator():
intent = ""
try:
# stream_mode="messages" 會把模型每產生的一小段字都送出來
for chunk, metadata in graph.stream(inputs, config, stream_mode="messages"):
node = metadata["langgraph_node"]
# 只轉送回答節點的字,並用節點名稱當作這輪的意圖
if node in ("plan", "report", "qa") and chunk.content:
intent = node
yield _sse({"type": "token", "content": chunk.content})
yield _sse({"type": "done", "intent": intent})
except Exception:
yield _sse({"type": "error", "detail": "教練暫時無法回應,請稍後再試"})
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
import json 和 StreamingResponse 請放在 main.py 最上面的 import 區,ChatRequest 和 graph 在 Day 19 已經 import 過了。
這段有三個重點:
graph.stream(..., stream_mode="messages") 取代了 graph.invoke,每次拿到的是模型吐出的一小段字(chunk)和這段字來自哪個節點(metadata)。router 節點不呼叫模型,所以只有 plan、report、qa 三個節點會產生字。節點名稱剛好就是意圖,不用另外查。async,FastAPI 會自動把它放到執行緒裡跑,不會卡住其他請求。Day 11 用的 SqliteSaver 是同步版本,所以這裡維持同步最單純。跟 /chat 一樣用 user-{user_id} 當 Thread ID,所以對話記憶是共用的:先用 /chat 聊的內容,/chat/stream 也記得。
Swagger 的 /docs 會等整個回應結束才顯示,看不出一個字一個字的效果。我們改用 curl,加上 -N 讓它不要緩衝。
啟動後端:
uvicorn main:app --reload
先在 backend/ 資料夾建一個 req.json,把要送的內容存進去:
{"user_id": 1, "message": "Introduce AWS in one sentence"}
另開一個終端機,在 backend/ 資料夾裡執行:
curl.exe -N -X POST http://127.0.0.1:8000/chat/stream -H "Content-Type: application/json" --data-binary "@req.json"
Windows PowerShell 裡的 curl 其實是另一個指令的別名,要打 curl.exe。macOS 或 Linux 直接用 curl。把內容存成檔案再送,可以避開各種終端機對引號的處理差異。
你應該會看到字一行一行跳出來,最後出現 done:
data: {"type": "token", "content": "Amazon"}
data: {"type": "token", "content": " Web"}
...
data: {"type": "done", "intent": "qa"}
看到一行一行出現,而不是等很久才一次全部出來,後端就完成了。
檔案位置: frontend/src/lib/api.ts
狀態: 修改檔案(只改第一行,加上 export)
用途: 讓新的串流函式也能用同一個後端網址
依賴: 無
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000";
檔案其他部分(apiFetch)不用動。
streamChat檔案位置: frontend/src/lib/chatStream.ts
狀態: 新增檔案
用途: 送出訊息,一邊讀後端串流回來的資料,一邊把每個字交給呼叫者
依賴: lib/api
import { API_BASE_URL } from "@/lib/api";
// 後端 /chat/stream 會送出的三種事件
type ChatStreamEvent =
| { type: "token"; content: string }
| { type: "done"; intent: string }
| { type: "error"; detail: string };
interface StreamChatOptions {
userId: number;
message: string;
onToken: (token: string) => void;
signal?: AbortSignal;
}
// 送出訊息並邊收邊處理,每收到一個字就呼叫onToken,結束時回傳教練判斷的意圖
export async function streamChat({
userId,
message,
onToken,
signal,
}: StreamChatOptions): Promise<string> {
const response = await fetch(`${API_BASE_URL}/chat/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId, message }),
signal,
});
if (!response.ok || !response.body) {
throw new Error(`API錯誤:${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let intent = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
// stream: true 讓被切在中間的中文字先留著,等下一包資料來了再接起來
buffer += decoder.decode(value, { stream: true });
// 每則事件以空白行結尾,最後一段可能還沒收完,先留在buffer
const parts = buffer.split("\n\n");
buffer = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) {
continue;
}
const event = JSON.parse(part.slice("data: ".length)) as ChatStreamEvent;
if (event.type === "token") {
onToken(event.content);
} else if (event.type === "done") {
intent = event.intent;
} else {
throw new Error(event.detail);
}
}
}
return intent;
}
網路資料不會剛好依事件邊界送達,一包資料可能只有半則事件,也可能包含多則。因此先把內容放進 buffer,以 \n\n 切出完整事件,未完成的部分留到下一輪接續處理。
檔案位置: frontend/src/app/chat/chat.module.css
狀態: 新增檔案
用途: 讓使用者的話靠右、教練的話靠左,各有一個泡泡
依賴: 無
.page {
max-width: 640px;
margin: 0 auto;
padding: 16px;
}
.messages {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 320px;
max-height: 60vh;
overflow-y: auto;
padding: 12px;
border: 1px solid #ddd;
border-radius: 12px;
}
.bubble {
max-width: 80%;
padding: 10px 14px;
border-radius: 16px;
white-space: pre-wrap;
line-height: 1.6;
}
.user {
align-self: flex-end;
background: #2563eb;
color: #fff;
}
.coach {
align-self: flex-start;
background: #f1f1f1;
color: #111;
}
.inputRow {
display: flex;
gap: 8px;
margin-top: 12px;
}
.input {
flex: 1;
padding: 10px 12px;
border: 1px solid #ccc;
border-radius: 8px;
font-size: 16px;
}
.css 結尾加上 module 是 Next.js 內建支援的寫法,每個樣式名稱只對這一頁有效,不會影響別的頁面,所以不用再裝任何套件。
檔案位置: frontend/src/app/chat/page.tsx
狀態: 修改檔案(整個覆蓋 Day 23 的版本)
用途: 呼叫 streamChat,讓教練的回覆逐字長出來,並支援停止
依賴: context, lib/chatStream, chat.module.css
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { streamChat } from "@/lib/chatStream";
import styles from "./chat.module.css";
interface ChatMessage {
role: "user" | "coach";
content: string;
}
export default function ChatPage() {
const { userId } = useAuth();
const router = useRouter();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (userId === null) {
router.push("/login");
}
}, [userId, router]);
// 每次訊息變長,就捲到最底下
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// 離開這一頁時,順便把還在進行中的串流切斷
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
// 把新收到的字接到最後一則訊息(也就是教練正在回的那一則)後面
const appendToLastMessage = (text: string) => {
setMessages((prev) =>
prev.map((msg, index) =>
index === prev.length - 1 ? { ...msg, content: msg.content + text } : msg
)
);
};
const handleSend = async () => {
const text = input.trim();
if (!text || userId === null || sending) {
return;
}
const controller = new AbortController();
abortRef.current = controller;
// 先放使用者的訊息,再放一個空的教練訊息,等字一個一個長出來
setMessages((prev) => [
...prev,
{ role: "user", content: text },
{ role: "coach", content: "" },
]);
setInput("");
setSending(true);
try {
await streamChat({
userId,
message: text,
onToken: appendToLastMessage,
signal: controller.signal,
});
} catch (error) {
// 使用者自己按停止不算錯誤,其他情況才顯示提示
const stoppedByUser =
error instanceof DOMException && error.name === "AbortError";
if (!stoppedByUser) {
appendToLastMessage("(教練暫時無法回應,請稍後再試)");
}
} finally {
setSending(false);
abortRef.current = null;
}
};
return (
<main className={styles.page}>
<h1>跟教練聊聊</h1>
<div className={styles.messages}>
{messages.map((msg, index) => (
<div
key={index}
className={`${styles.bubble} ${
msg.role === "user" ? styles.user : styles.coach
}`}
>
{msg.content || "..."}
</div>
))}
<div ref={bottomRef} />
</div>
<div className={styles.inputRow}>
<input
className={styles.input}
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => event.key === "Enter" && handleSend()}
placeholder="輸入想跟教練說的話"
/>
{sending ? (
<button onClick={() => abortRef.current?.abort()}>停止</button>
) : (
<button onClick={handleSend}>送出</button>
)}
</div>
</main>
);
}
幾個設計的小地方:
...,使用者知道教練正在想。AbortController:按「停止」或離開這一頁,前端就切斷連線,不會再收到新的字,也不會對已經不存在的畫面更新狀態。確認後端和 Ollama 都開著,然後:
cd frontend
npm run dev
進入 http://localhost:3000,登入後點導覽列的「對話」,輸入一句話按送出。你應該會看到:
... 的灰色泡泡再試一次停止:問一個會回答很久的問題(例如「請介紹 AWS 的十個服務」),字開始出現後按「停止」,文字應該立刻停住,按鈕變回「送出」,可以馬上問下一題。
我自己實測,一句 30 幾個字的回答,第一個字大約 0.7 秒出現,整句在 4 秒左右完成。你的數字會因為電腦和模型而不同,重點是第一個字很快就出現。
...,很久才有字第一次呼叫時 Ollama 要先把模型載入記憶體,可能要多等好幾秒,之後就會快很多。如果等很久還是沒反應,先確認 Ollama 有在跑,並用步驟 2 的 curl.exe -N 試後端有沒有輸出。
先用步驟 2 的 curl.exe -N 判斷問題在哪邊。如果 curl 也是一次全部出現,是後端的問題,檢查 /chat/stream 有沒有用 graph.stream 而不是 graph.invoke。如果 curl 是逐字出現,但網頁不是,檢查前端是不是用了 Day 23 的 apiFetch(它會等整個回應結束),而不是 streamChat。
curl 傳中文,後端回 There was an error parsing the bodyWindows 命令列直接傳給 curl 的中文,編碼不是 UTF-8,後端讀不懂。測試時請把請求內容存成 UTF-8 編碼的 req.json 再用 --data-binary "@req.json" 送出,或乾脆改用英文。從網頁送出的中文不受影響。
/chat/stream 跟其他 API 共用同一個 CORS 設定,檢查 Day 6 的 allow_origins 是不是包含 http://localhost:3000,網址要完全一致,包含 port。
跟 Day 23 一樣,畫面上的 messages 只存在這個分頁的記憶體裡,重新整理就會清空。後端的對話記憶還在,教練仍然記得你們聊過什麼,只是畫面不會自動補回舊訊息。
明天會用 Recharts 製作核心指標儀表板,呈現完成率、學習時數、連續天數、能力評分與進度條。