【經驗分享】為 Next.js App 加上 AI 摘要
近來因為想要用 AI 翻譯日語遊戲,接觸到中國公司推出的 AI 平台,其中 Deepseek 這個平台可說是其中的佼佼者,智能程度不輸 Open AI ChatGPT 4o 之餘,API 價格還遠低於 4o(大概只需十分之一的價格,現時 2 美元有高達 700 萬 tokens 的額度)。
那麼用剩下的 API 額度,我就想把它用來為我的博客文章提供 AI 摘要功能了。雖然我的文章長度普遍不長,可能未必有 TL;DR 的情況,但我個人文筆和表達一般,AI 摘要除了精簡文字外,還能提供更為清晰的說明,可說是博客站長的利器。
1. 後端的 Summary API
為博客文章加入 AI 摘要的第一步,是實現一個後端的 API,專門用來返回文章的摘要。這裡稱為 Summary API。
為什麼選擇後端處理?
由於 Deepseek 是付費平台,API 調用需要密鑰 (API Key)。為了避免密鑰被暴露在前端導致額度被盜用,我們必須從後端向 AI 平台請求摘要。另外,這樣做也能保證發送給 AI 平台的文章是博客後端存儲的最新版本(因為我的博客是通過 SSG 生成的,靜態頁面可能未必即時更新)。
API Routes 作為中轉站
我們使用 Next.js API Routes 實現 Summary API。如果部署在 Vercel 上,這些 API Routes 會以無狀態的服務器函數(serverless functions)形式運行,適合作為中轉站。數據處理的主工作仍然在博客後端的數據庫(另一個 cms endpoint,不在 API Routes 調用數據庫 ) 完成。
以我的博客為例,文章的數據模型包括文章內容(content)和 摘要(Summary)。
API 流程
-
從博客後端獲取指定文章的內容和摘要。如果摘要已經存在,直接返回,節省資源。
-
如果摘要為空,則向 Deepseek 平台請求生成摘要。
-
將生成的摘要存儲到博客後端,為未來請求提供快取。
-
將摘要返回給前端。
請求示例
Deepseek 的 API 使用對話形式設計,會話由系統消息 (system
)、用戶消息 (user
) 和助手消息 (assistant
) 組成。(事實上,Deepseek API 是 Openai Compatible API,可直接用 OpenAI 的 SDK 來調用其 API )
以下是一個請求的示例代碼:
const completion = await openai.chat.completions.create({ messages: [ { role: "system", content: "用戶將提供給你一段文章內容,請你分析文章內容,並提取其中的關鍵信息,並以純文本和繁體中文輸出,需要不多於200字。", }, { role: "user", content: postData.content, }, ], model: "deepseek-chat", });
當 Deepseek 返回摘要後,將其存儲到數據庫的 Summary
字段,並在後續相同文章的請求中直接返回該摘要,而無需重複請求 AI。
2. 前端展示 AI 摘要
後端 API 完成後,我們需要在前端展示摘要。這裡,我們使用一個帶按鈕的摘要框來控制顯示:
-
靜態生成的頁面 Props:文章數據可能包含摘要 (
Summary
),也可能不包含。 -
動態摘要加載:如果
Summary
為空,用戶可以點擊按鈕向 Summary API 發送請求,並將返回的摘要更新到頁面。
前端摘要框的邏輯
我們使用一個 state summary
來控制摘要框的內容。如果 Props 中有摘要,直接顯示;否則,點擊按鈕時向 API 發送請求並動態更新。
3. 添加打字機效果的摘要文字框
為了增加交互性和吸引力,我在摘要框中加入了 打字機效果。這模仿了 ChatGPT 的輸出方式,即逐字呈現摘要內容。然而,與 ChatGPT 使用 SSE(服務器推送事件)實現的即時流式響應不同,我這裡的打字機效果只是純粹的視覺模仿,並無實際技術意義。
實現邏輯
打字機效果使用了 Framer Motion
,通過動畫漸顯的方式逐個顯示文字:
核心代碼
export const sentenceVariants = { hidden: {}, visible: { opacity: 1, transition: { staggerChildren: 0.1 } }, }; export const letterVariants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { opacity: { duration: 0 } } }, }; export const Typewriter = ({ text, ...rest }: any) => ( <motion.p key={text} variants={sentenceVariants} initial="hidden" animate="visible" {...rest} > {text.split("").map((char: string, i: number) => ( <motion.span key={`${char}-${i}`} variants={letterVariants}> {char} </motion.span> ))} </motion.p> );
代碼將文字分割為單個字符,為每個字符應用動畫,實現了逐字出現的效果。