iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Vibe Coding

Vibe Mode 開啟:30 天用 AI 打造網頁,邊做邊學 JavaScript系列 第 21 篇

# Day 21:串流回應處理:Web Application 的 SSE (Server-Sent Events) 與 Streaming UI 實作

  • 分享至 

  • xImage
  •  

今日目標

昨天我們成功在 SmartExpense AI 專案中串接了 OpenAI SDK,實現了個人財務診斷報告功能。但你有沒有發現一個嚴重的 UX 痛點?當使用者按下「生成財務分析」時,網頁會卡住轉圈圈將近 8 到 10 秒,最後才一口氣跳出大段文字,體驗極差!

今天我們要邁入階段四:進階功能與 AI 功能整合的核心瓶頸突破 — 實作串流回應處理 (Streaming UI)。

透過 Server-Sent Events (SSE) 技術,後端可以在 AI 生成文字的同時,將 Chunk(數據區塊)即時推送到前端,打造出像 ChatGPT 一樣流暢的打字機互動體驗。

Human vs. AI 實戰記錄

在傳統開發中,處理 Web Streams、ReadableStream 以及跨瀏覽器相容性的 SSE 往往需要編寫複雜的 EventListener 與 Buffer 處理邏輯。現在我們嘗試讓 AI 幫我們一口氣建立 Next.js App Router 的 Route Handler 與前端 Streaming 元件。

1. 給 AI 的指令 (Prompt)

我們需要 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 樣式。

2. AI 產出的結果 (Code)

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',
    },
  })
}

3. 修正與翻車紀錄 (Debug Experience)

這段程式碼看起來完全合乎邏輯,但在實際執行時,立刻引爆了兩個非常棘手的 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>
  )
}

JavaScript / AI 學習小結

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 問題,正是人類工程師不可替代的價值。


上一篇
# Day 20 :在 Web 中整合 LLM API:使用 OpenAI / Anthropic SDK 實現產品核心 AI 功能
下一篇
Day 22:效能優化 (Performance):AI 分析 Web Vitals、Code Splitting 與 Memo 化優化
系列文
Vibe Mode 開啟:30 天用 AI 打造網頁,邊做邊學 JavaScript 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言