
【實戰】Day 26:前後端串接 + SSE Streaming
讓前端真正「看見」AI 在思考:FastAPI StreamingResponse + Next.js Route Handler Proxy + React SSE 消費,全端串接一次打通。同時加入 Mode B 辯論結果展示 UI。
WRITTEN BY

- Name
- Harry Chang
前兩天完成了 Mode A(單 Agent 問答)與 Mode B(多 Agent 辯論),今天把它們接上 Next.js Dashboard,讓使用者真正能在瀏覽器裡操作。
- 今日目標
- 架構全貌
- 請求時序圖
- Step 1:FastAPI 串流端點
- Step 2:Next.js API Route Handler(Proxy)
- Step 3:Agent Chat UI
- Step 4:Dashboard 頁面 + 導覽
- Step 5:環境設定
- 測試
- 今日 Checklist
- 小結
今日目標
| 任務 | 說明 |
|---|---|
| FastAPI SSE 端點 | POST /api/v1/agent/query/stream — 逐 token 推送 |
| Next.js Proxy | /api/agent、/api/debate — 兩個 Route Handler |
| Agent Chat UI | 支援身份切換、模式切換、快速問題 |
| Dashboard 頁面 | /dashboard/agent + Sidebar 導覽項 |
架構全貌
前後端資料流如下:
瀏覽器 (React)
│ fetch('/api/agent', ...) ← 同源,無 CORS 問題
▼
Next.js Route Handler (/app/api/agent/route.ts)
│ fetch('http://localhost:8000/api/v1/agent/query/stream', ...)
▼
FastAPI StreamingResponse
│ order_agent.run_stream(...)
▼
Anthropic Claude API (SSE)
為什麼要 Next.js 當 Proxy?
- CORS:瀏覽器直接打
localhost:8000會被 Same-Origin Policy 擋住(除非後端開放所有來源)。 - API Key 安全:若未來要在 Route Handler 加認證 token,可在 server side 處理,不暴露給瀏覽器。
- 統一入口:前端只知道相對路徑
/api/*,後端 URL 藏在.env.local裡。
請求時序圖
Step 1:FastAPI 串流端點
在 backend/app/routers/agent.py 加入第二個路由:
from fastapi.responses import StreamingResponse
@router.post("/agent/query/stream")
async def agent_query_stream(request: AgentRequest):
deps = get_agent_deps(request.user_id)
async def generate():
async with order_agent.run_stream(request.message, deps=deps) as result:
async for chunk in result.stream_text(delta=True):
yield f"data: {chunk}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
重點說明:
run_stream()是 Pydantic AI 0.0.46 的非同步上下文管理器,回傳一個StreamedRunResult。stream_text(delta=True)每次 yield 只有新增的文字片段(差分),不是累積全文。- SSE 格式要求每個訊息以
data: ...\n\n結尾(雙換行)。 [DONE]是結束標記,前端收到後停止讀取。
Step 2:Next.js API Route Handler(Proxy)
/src/app/api/agent/route.ts — 串流 Proxy
import { NextRequest } from 'next/server';
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8000';
export async function POST(request: NextRequest) {
const body = await request.json();
const upstream = await fetch(`${BACKEND_URL}/api/v1/agent/query/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!upstream.ok) {
return new Response(await upstream.text(), { status: upstream.status });
}
// 直接把 upstream.body(ReadableStream)轉傳給瀏覽器
return new Response(upstream.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
關鍵:upstream.body 是 ReadableStream,直接當作 Response 的 body,Node.js 18+ 支援這種 Streaming Passthrough,不需要手動讀取後再重發。
/src/app/api/debate/route.ts — JSON Proxy
Mode B 辯論不需要串流(等全部 Agent 完成再回傳),用一般的 JSON proxy 即可:
export async function POST(request: NextRequest) {
const body = await request.json();
const upstream = await fetch(`${BACKEND_URL}/api/v1/agent/debate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await upstream.json();
return NextResponse.json(data);
}
Step 3:Agent Chat UI
src/features/agent/components/agent-chat.tsx 是今天最重的部分,核心邏輯如下:
串流讀取(Mode A)
const res = await fetch('/api/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, user_id: userId }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // 保留不完整的最後一行
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;
// 把新 token 追加進對話泡泡
setMessages(prev =>
prev.map(m => m.id === assistantId
? { ...m, content: m.content + data }
: m
)
);
}
}
注意 buffer 的處理:SSE 每個事件以 \n\n 結束,但 HTTP 傳輸可能在任意位置切割,所以要把不完整的行保留到下一次 read() 才能正確解析。
Mode B 辯論展示
辯論結果以卡片方式展示四個視角:
<div className='grid grid-cols-1 gap-2 md:grid-cols-3'>
{/* 業務 / 生管 / 財務 — 三欄 */}
<Card className='border-blue-200'>…業務觀點…</Card>
<Card className='border-green-200'>…生管觀點…</Card>
<Card className='border-orange-200'>…財務觀點…</Card>
</div>
{/* 總經理裁決 — 全寬 */}
<Card className='border-purple-200'>…最終決策…</Card>
若 finance_veto === true,頂部顯示紅色警告橫幅。
Step 4:Dashboard 頁面 + 導覽
頁面 /src/app/dashboard/agent/page.tsx
import AgentChat from '@/features/agent/components/agent-chat';
export const metadata = { title: 'Dashboard: AI 決策幕僚' };
export default function Page() {
return (
<div className='flex h-[calc(100vh-4rem)] flex-col gap-4 p-4 md:p-6'>
<h1 className='text-xl font-semibold'>AI 決策幕僚</h1>
<AgentChat />
</div>
);
}
導覽項 nav-config.ts
{
title: 'AI 決策幕僚',
url: '/dashboard/agent',
icon: 'sparkles',
shortcut: ['a', 'i'],
isActive: false,
items: []
}
sparkles 是 @tabler/icons-react 的 IconSparkles,已在 icons.tsx 中定義,直接可用。
Step 5:環境設定
在前端根目錄建立 .env.local:
# 伺服器端讀取,不會暴露給瀏覽器
BACKEND_URL=http://localhost:8000
注意:沒有
NEXT_PUBLIC_前綴 → 只在 Route Handler(Node.js 端)可讀,瀏覽器 bundle 不包含這個值。
測試
啟動兩個服務
Terminal 1 — FastAPI
cd backend
uvicorn app.main:app --reload
Terminal 2 — Next.js
cd frontend/next-shadcn-dashboard-starter-main
npx next dev
測試 Mode A(串流) - 總經理視角
- 開啟
http://localhost:3000/dashboard/agent - 身份選「總經理」,模式選「⚡ 快速問答」
- 輸入「Nike 訂單出貨率如何?」→ 文字逐 token 出現

測試 Mode A(串流) - 業務視角
- 開啟
http://localhost:3000/dashboard/agent - 身份選「業務」,模式選「⚡ 快速問答」
- 輸入「Nike 訂單出貨率如何?」→ 文字逐 token 出現

測試 Mode B(辯論)
- 模式切換成「⚖️ 多角色辯論」
- 點快速問題「是否接受 Puma 的新訂單?」
- 等待 10-20 秒 → 出現三欄專家意見 + 紅色否決警告 + 總經理裁決

今日 Checklist
-
POST /api/v1/agent/query/stream:FastAPI SSE 端點 -
src/app/api/agent/route.ts:串流 Proxy -
src/app/api/debate/route.ts:JSON Proxy -
src/features/agent/components/agent-chat.tsx:雙模式 Chat UI -
src/app/dashboard/agent/page.tsx:Dashboard 頁面 -
nav-config.ts新增「AI 決策幕僚」 -
.env.local設定BACKEND_URL
小結
今天最重要的工程概念:
- Streaming Passthrough:Next.js Route Handler 把
upstream.body(ReadableStream)直接當作 Response body,不需要把整個回應讀進記憶體再轉發,延遲更低、記憶體更省。 - SSE Buffer 管理:HTTP 傳輸可能在任意 byte 切割,不能假設
\n\n一定落在單次read()的末端,要用buffer變數拼湊完整行。 - UI 即時更新:每收到一個 token 就呼叫
setMessages,React Concurrent Mode 的批次更新確保 60fps 渲染不卡頓。
明天 Day 27:在 Dashboard Overview 加入 Shadcn Charts 訂單視覺化,把靜態的假數據換成真實的訂單統計圖表。