【實戰】Day 26:前後端串接 + SSE Streaming

【實戰】Day 26:前後端串接 + SSE Streaming

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


前兩天完成了 Mode A(單 Agent 問答)與 Mode B(多 Agent 辯論),今天把它們接上 Next.js Dashboard,讓使用者真正能在瀏覽器裡操作。

今日目標

任務說明
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?

  1. CORS:瀏覽器直接打 localhost:8000 會被 Same-Origin Policy 擋住(除非後端開放所有來源)。
  2. API Key 安全:若未來要在 Route Handler 加認證 token,可在 server side 處理,不暴露給瀏覽器。
  3. 統一入口:前端只知道相對路徑 /api/*,後端 URL 藏在 .env.local 裡。

請求時序圖

Loading Diagram...

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.bodyReadableStream,直接當作 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-reactIconSparkles,已在 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(串流) - 總經理視角

  1. 開啟 http://localhost:3000/dashboard/agent
  2. 身份選「總經理」,模式選「⚡ 快速問答」
  3. 輸入「Nike 訂單出貨率如何?」→ 文字逐 token 出現
總經理結果

測試 Mode A(串流) - 業務視角

  1. 開啟 http://localhost:3000/dashboard/agent
  2. 身份選「業務」,模式選「⚡ 快速問答」
  3. 輸入「Nike 訂單出貨率如何?」→ 文字逐 token 出現 業務主管結果

測試 Mode B(辯論)

  1. 模式切換成「⚖️ 多角色辯論」
  2. 點快速問題「是否接受 Puma 的新訂單?」
  3. 等待 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

小結

今天最重要的工程概念:

  1. Streaming Passthrough:Next.js Route Handler 把 upstream.body(ReadableStream)直接當作 Response body,不需要把整個回應讀進記憶體再轉發,延遲更低、記憶體更省。
  2. SSE Buffer 管理:HTTP 傳輸可能在任意 byte 切割,不能假設 \n\n 一定落在單次 read() 的末端,要用 buffer 變數拼湊完整行。
  3. UI 即時更新:每收到一個 token 就呼叫 setMessages,React Concurrent Mode 的批次更新確保 60fps 渲染不卡頓。

明天 Day 27:在 Dashboard Overview 加入 Shadcn Charts 訂單視覺化,把靜態的假數據換成真實的訂單統計圖表。