昨天我們成功在 SmartExpense AI 專案中串接了 OpenAI SDK,實現了個人財務診斷報告功能。但你有沒有發現一個嚴重的 UX 痛點?當使用者按下「生成財務分析」時,網頁會卡住轉圈圈將近 8 到 10 秒,最後才一口氣跳出大段文字,體驗極差!
今天我們要邁入階段四:進階功能與 AI 功能整合的核心瓶頸突破 — 實作串流回應處理 (Streaming UI)。
透過 Server-Sent Events (SSE) 技術,後端可以在 AI 生成文字的同時,將 Chunk(數據區塊)即時推送到前端,打造出像 ChatGPT 一樣流暢的打字機互動體驗。
在傳統開發中,處理 Web Streams、ReadableStream 以及跨瀏覽器相容性的 SSE 往往需要編寫複雜的 EventListener 與 Buffer 處理邏輯。現在我們嘗試讓 AI 幫我們一口氣建立 Next.js App Router 的 Route Handler 與前端 Streaming 元件。
我們需要 AI 幫我們在 Next.js App Router 中實作一個支援 SSE 的 API 端點,並在前端用 React 讀取串流資料。
你現在是一位資深全棧工程師。請幫我在 SmartExpense AI 專案中實作 AI 分析報告的 Streaming 功能。
需求如下:
1. 後端 Route Handler (`src/app/api/ai/analyze/route.ts`):
- 使用 OpenAI SDK 的 `openai.chat.completions.create`,並開啟 `stream: true`。
- 接收前端傳來的財務交易資料 (`transactions`),將 OpenAI 的 AsyncIterable 轉為 Web ReadableStream 返回給前端。
- 設定正確的 SSE (Server-Sent Events) HTTP Response Headers。
2. 前端 React 組件 (`src/components/FinancialAnalysisStream.tsx`):
- 使用 Fetch API 呼叫該端點,並使用 `response.body.getReader()` 讀取 ReadableStream。
- 將讀取到的文字即時更新到 State 中,實現打字機效果。
- 套用 shadcn/ui 的 Button 與 Tailwind CSS 樣式。
AI 很快地給出了一套 API 與前端組件程式碼:
// src/app/api/ai/analyze/route.ts
import { OpenAI } from 'openai'
const openai = new OpenAI()
export async function POST(req: Request) {
const { transactions } = await req.json()
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: '你是一位專業財務顧問,請分析以下交易數據...' },
{ role: 'user', content: JSON.stringify(transactions) }
],
stream: true,
})
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of response) {
const text = chunk.choices[0]?.delta?.content || ''
controller.enqueue(new TextEncoder().encode(text))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
}
這段程式碼看起來完全合乎邏輯,但在實際執行時,立刻引爆了兩個非常棘手的 Vibe Coding 踩坑點!
翻車點一:Next.js Response Buffering 導致串流依然卡頓
當我們在瀏覽器測試時,發現畫面完全沒有「打字機效果」,而是依然乾等了 8 秒,最後「砰」一聲一次性印出所有字!
原因分析:Next.js App Router 預設會對 API Response 進行快取與 Buffer 處理,且部署至 Vercel/Node Server 時,若沒有明確指示停用 Response Buffering,中間層會把 Chunk 積攢起來一次性送出,導致 SSE 串流徹底失效。
翻車點二:UTF-8 中文解碼斷字與亂碼鬼打牆
前端在讀取 reader.read() 時,AI 寫了 new TextDecoder().decode(value)。在測試英文時一切正常,但只要 AI 生成中文報告,畫面上每隔幾個字就會噴出 (Unicoded Replacement Character) 亂碼!
原因分析:一個 UTF-8 中文字由 3 個 Bytes 組成。ReadableStream 傳輸時是按固定 Buffer 大小切分 Chunk,若一個中文字正好被切在 Byte 2 和 Byte 3 之間,單次 decode() 就會將不完整的 Byte 判斷為非法字元並吐出亂碼!
我們發現了 AI 忽視的底層細節,引導 AI 補足配置並進行調校:
後端強制停用快取與 Buffering:加入 export const dynamic = 'force-dynamic',並在 Response Header 設定 X-Content-Type-Options: nosniff。
前端使用 TextDecoder 的 Stream Mode:在 decoder.decode(value, { stream: true }) 傳入 { stream: true } 參數,告知解碼器保持內部 Buffer,遇到未完結的 Byte 時留到下個 Chunk 組合後再解碼。
優化 UI 渲染與 Markdown 支援:整合 react-markdown 讓漸進輸出的 Markdown 格式能實時渲染成漂亮排版。
// src/components/FinancialAnalysisStream.tsx
'use client'
import { useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Sparkles, Loader2 } from 'lucide-react'
export function FinancialAnalysisStream({ transactions }: { transactions: any[] }) {
const [analysis, setAnalysis] = useState<string>('')
const [isLoading, setIsLoading] = useState<boolean>(false)
const startAnalysis = async () => {
setIsLoading(true)
setAnalysis('')
try {
const response = await fetch('/api/ai/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transactions }),
})
if (!response.body) throw new Error('ReadableStream not supported.')
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let done = false
while (!done) {
const { value, done: doneReading } = await reader.read()
done = doneReading
// 關鍵修復:{ stream: !done } 阻止 UTF-8 中文切字亂碼
const chunkValue = decoder.decode(value, { stream: !done })
setAnalysis((prev) => prev + chunkValue)
}
} catch (error) {
console.error('Streaming error:', error)
} finally {
setIsLoading(false)
}
}
return (
<Card className="border-emerald-500/20 bg-slate-900/90 text-slate-100 shadow-xl backdrop-blur">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg font-bold text-emerald-400">
<Sparkles className="h-5 w-5" /> AI 財務智能診斷
</CardTitle>
<Button
onClick={startAnalysis}
disabled={isLoading}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> 分析中...
</>
) : (
'開始生成報告'
)}
</Button>
</CardHeader>
<CardContent>
<div className="prose prose-invert max-w-none min-h-[160px] rounded-lg bg-slate-950/60 p-4 border border-slate-800">
{analysis ? (
<ReactMarkdown>{analysis}</ReactMarkdown>
) : (
<p className="text-sm text-slate-500 italic">點擊上方按鈕,體驗即時 AI 財務分析回應...</p>
)}
</div>
</CardContent>
</Card>
)
}
ReadableStream 與 TextDecoder 的運作機制:
AI 非常會套用範例碼,但常常忽略瀏覽器編碼層級的細節。在處理多位元組字元(如中文、日文、Emoji)的串流傳輸時,必須清楚理解 TextDecoderStream 或 TextDecoder({ stream: true }) 的保留區塊原理,否則在生產環境一定會面臨亂碼危機。
Server-Sent Events (SSE) 與 HTTP 傳輸協定:
了解 HTTP header (text/event-stream 與 no-cache) 如何影響邊緣運算 (Edge/Serverless) 的行為,是進階全棧工程師必備的功底。當 AI 生成的程式碼無效時,能立刻從網絡傳輸層(Network Tab)診斷出是 Buffering 問題,正是人類工程師不可替代的價值。