在昨天的 [Day 15] 中,我們成功打造了穩健的 Google AI File API 檔案管線,讓 OmniVibe AI 能輕鬆吞下數百 MB 的巨型影片與 PDF 檔案。
然而,當使用者在 Dashboard 上按下「開始 AI 內容提煉」後,如果後端採用傳統的「阻塞式同步請求(Blocking API)」,系統必須等待 Gemini 1.5 將數千字的摘要、Threads 貼文與短影音腳本全部生成完畢,才能一次性回傳 JSON。這意味著使用者將面對長達 8 至 15 秒的白屏與等待空窗期。
在現代 AI SaaS 的產品設計中,「首字回應時間(Time-To-First-Token, TTFT)」 是決定用戶留存率的關鍵指標。
今天,我們將結合 Gemini SDK 的 generateContentStream 與 Web API 的 ReadableStream / Server-Sent Events (SSE),在 Next.js 15 中打造出一條極速噴發的流式傳輸管線,將使用者的感知延遲從「10 秒乾等」直接降至「0.5 秒秒級吐字」!
sequenceDiagram
autonumber
actor User as 使用者 UI
participant Server as Next.js API Route
participant Gemini as Gemini 1.5 Engine
box RGB(255,235,235) 傳統阻塞式 (無 Stream)
User->>Server: 1. 發送提煉請求
Server->>Gemini: 2. generateContent()
Note over Gemini: 模型運算中... (乾等 10 秒)
Gemini-->>Server: 3. 一次性回傳完整 2000 字
Server-->>User: 4. 回傳全量 JSON (使用者等待感極強)
end
box RGB(235,255,235) 現代流式傳輸 (Gemini Stream + SSE)
User->>Server: 5. 發送串流請求
Server->>Gemini: 6. generateContentStream()
Gemini-->>Server: 7. 吐出 Chunk 1 ("# OmniVibe...") [TTFT < 500ms]
Server-->>User: 8. SSE 串流推送 Chunk 1
Gemini-->>Server: 9. 吐出 Chunk 2 ("核心洞見如下...")
Server-->>User: 10. SSE 串流推送 Chunk 2 (畫面呈現打字機效果)
end
在 AI 文本生成的場景中,數據流向是單向的(伺服器不斷推送文字給客戶端)。與雙向的 WebSocket 相比,SSE 具備以下優勢:
EventSource / fetch 串流能自動處理網路波動。我們將在 Next.js 15 App Router 中實作支援串流輸出的 Route Handler (src/app/api/ai/stream-distill/route.ts)。
src/app/api/ai/stream-distill/route.ts)我們使用 @google/generative-ai 提供的 generateContentStream 方法,並利用 Web Standard ReadableStream 將資料塊(Chunk)即時編碼推送:
// src/app/api/ai/stream-distill/route.ts
import { NextRequest } from 'next/server';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { OMNIVIBE_SYSTEM_INSTRUCTION } from '@/lib/gemini/prompts';
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || '');
export const runtime = 'edge'; // 使用 Edge Runtime 取得更低回應延遲
export async function POST(req: NextRequest) {
try {
const { fileUri, mimeType, prompt } = await req.json();
if (!fileUri || !mimeType) {
return new Response(JSON.stringify({ error: '缺少必要的 fileUri 或 mimeType' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const model = genAI.getGenerativeModel({
model: 'gemini-1.5-flash',
systemInstruction: OMNIVIBE_SYSTEM_INSTRUCTION,
});
// 1. 發起 Gemini 串流請求
const streamingResult = await model.generateContentStream([
{
fileData: {
fileUri: fileUri,
mimeType: mimeType,
},
},
prompt || '請將此資產提煉為核心洞見、爆款 Threads 貼文與 60 秒短影音腳本。',
]);
// 2. 建立 Web ReadableStream 封裝 SSE 數據流
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
for await (const chunk of streamingResult.stream) {
const chunkText = chunk.text();
if (chunkText) {
// 以 SSE 格式發送 data: {"text": "..."}\n\n
const eventData = `data: ${JSON.stringify({ text: chunkText })}\n\n`;
controller.enqueue(encoder.encode(eventData));
}
}
// 推送完成標記
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
} catch (error) {
console.error('[Stream Controller Error]:', error);
controller.error(error);
}
},
});
// 3. 回傳 SSE 標準標頭 Responses
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // 禁用 Nginx 等反向代理快取
},
});
} catch (error: any) {
console.error('[Stream Route Error]:', error);
return new Response(JSON.stringify({ error: '串流生成失敗', message: error.message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
src/hooks/useGeminiStream.ts)在前端,我們使用 fetch 結合 response.body.getReader() 來逐字解碼並更新 React State:
// src/hooks/useGeminiStream.ts
'use client';
import { useState, useCallback } from 'react';
export function useGeminiStream() {
const [streamedText, setStreamedText] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const startStream = useCallback(async (params: { fileUri: string; mimeType: string; prompt?: string }) => {
setIsStreaming(true);
setStreamedText('');
setError(null);
try {
const response = await fetch('/api/ai/stream-distill', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
if (!response.ok || !response.body) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
// 留存未完整的片段
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.replace('data: ', '').trim();
if (dataStr === '[DONE]') {
setIsStreaming(false);
return;
}
try {
const parsed = JSON.parse(dataStr);
if (parsed.text) {
// 逐字累加文字狀態,驅動打字機效果
setStreamedText((prev) => prev + parsed.text);
}
} catch (e) {
console.error('SSE JSON 解析失敗:', e);
}
}
}
}
} catch (err: any) {
console.error('[Stream Hook Error]:', err);
setError(err.message || '串流傳送發生錯誤');
} finally {
setIsStreaming(false);
}
}, []);
return {
streamedText,
isStreaming,
error,
startStream,
};
}
src/components/dashboard/ResultViewer.tsx)現在,將 useGeminiStream 產生的文字即時綁定至 Day 14 的 ResultViewer 中,文字將順暢地噴發在畫面上:
// src/components/dashboard/ResultViewer.tsx (部分切片)
import { Sparkles } from 'lucide-react';
interface StreamingViewerProps {
streamedContent: string;
isStreaming: boolean;
}
export function StreamingResultViewer({ streamedContent, isStreaming }: StreamingViewerProps) {
return (
<div className="border border-slate-800 rounded-xl bg-slate-950/60 p-5 min-h-[450px] relative font-mono text-sm leading-relaxed text-slate-200">
{/* 頂部串流狀態指示燈 */}
<div className="flex items-center justify-between border-b border-slate-800 pb-3 mb-4">
<div className="flex items-center space-x-2">
<span className={`w-2.5 h-2.5 rounded-full ${isStreaming ? 'bg-emerald-500 animate-pulse' : 'bg-slate-600'}`} />
<span className="text-xs text-slate-400 font-sans">
{isStreaming ? 'Gemini 1.5 串流生成中...' : '生成完畢'}
</span>
</div>
{isStreaming && <Sparkles className="w-4 h-4 text-indigo-400 animate-spin" />}
</div>
{/* 打字機內容即時展示 */}
<div className="whitespace-pre-wrap">
{streamedContent}
{/* 光標打字機閃爍效果 */}
{isStreaming && (
<span className="inline-block w-2 h-4 bg-indigo-500 ml-1 animate-ping" />
)}
</div>
</div>
);
}
我們在上傳一段 10 分鐘的 Podcasting 音訊後發起流式提煉:
[HTTP Monitor] POST /api/ai/stream-distill
- 傳統 Blocking 方式: 等待 9.2 秒才一次顯示全篇文字。
- 本串流 SSE 方式:
* 首個 Chunk 送達 (TTFT): 380 ms!
* 文字噴發速度: 每秒約 45 門 Token 逐字順暢展開。
今天我們打通了 SaaS 互動體驗中最關鍵的一環:
generateContentStream** 的串流管線。useGeminiStream React Hook,成功在前端打造出零延遲、逐字噴發的打字機互動體驗。在完成前端介面、檔案管線與串流互動後,我們的 OmniVibe AI 已經具備了驚艷的產品雛形!但目前的資產與提煉成果只儲存在前端記憶體中,重新整理網頁資料就會消失。
👉 明天(Day 17),我們將進入【資料持久化篇】:實戰 Supabase / Firestore + Firebase Auth 多租戶資料庫架構!看我們如何用代碼保存使用者的專案歷史紀錄、提煉結果與 Token 額度管理!
我們明天見!🔥