歡迎來到第十六天!昨天我們成功讓 AI 面試官「動」了起來,透過 Streaming 技術,使用者終於不用再盯著空白畫面乾等到完整回覆產生然後被一口氣塞臉上,而是能看到 AI 即時生成一步步的回覆,在體驗上稍微好了一點!(當然,我知道 api 請求在gemini端現在還是極慢,這實在是挺奇怪的,我在規劃初期也測試過幾次免費版本,同樣的模型當時頂多幾秒鐘就有回覆,現在有時候甚至會直接 overload,這點如果你真的忍受不了的話可以設定付款方式改為付費版的 api,不過現在作為一個 POC 我可以忍耐一點不便)。
撇開回應速度本身的問題不談,目前的體驗還有兩個明顯的痛點:
今天我們要一次解決這兩個問題,內容意外的不會很多,放心跟著來吧!
這是今天最有感的優化。我們要直接修改 AIMessage.tsx
組件,用一個簡單但有效的方式實現那種常見的效果,賦予它「打字機」動畫的能力。
打開 app/components/AIMessage.tsx
,我們將引入 useState 和 useEffect,並加入我們最終版的動畫邏輯。
// app/components/AIMessage.tsx
'use client';
import { Bot, Star, ThumbsUp, ThumbsDown } from 'lucide-react';
import { useState, useEffect } from 'react';
const TYPING_SPEED = 20; // 每個字元的延遲 (ms)
export default function AIMessage({ message }: AIMessageProps) {
const [displayedContent, setDisplayedContent] = useState('');
useEffect(() => {
const targetContent = message.content || '';
const intervalId = setInterval(() => {
setDisplayedContent(prev => {
if (prev.length === targetContent.length) {
clearInterval(intervalId);
return prev;
}
return targetContent.substring(0, prev.length + 1);
});
}, TYPING_SPEED);
return () => clearInterval(intervalId);
}, [message.content]);
useEffect(() => {
if (!message.content) {
setDisplayedContent('');
}
}, [message.content]);
const evaluation = message.evaluation ? {
...message.evaluation,
pros: message.evaluation.strengths || [],
cons: message.evaluation.improvements || [],
} : null;
return (
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center border border-blue-400">
<Bot size={20} />
</div>
<div className="flex-1">
{message.content && (
<div
className="bg-gray-700/80 rounded-lg p-3 text-gray-200"
dangerouslySetInnerHTML={{
__html: displayedContent.replace(
/\*\*(.*?)\*\*/g,
'<strong>$1</strong>'
),
}}
/>
)}
{evaluation && (
<div className="bg-gray-700/50 rounded-lg p-4 mt-2 border border-gray-600">
{/* ... 原有的 evaluation JSX ... */}
</div>
)}
</div>
</div>
);
}
這段程式碼的核心是利用 useState 和 useEffect 兩個 React Hooks 來巧妙地實現打字機動畫。整個過程可以拆解成幾個關鍵部分:
message.content
這個 prop 作為「目標內容」,也就是最終要完整顯示的文字。另外,我們用 displayedContent
這個 state 來儲存「當前已顯示的內容」。整個動畫的目標,就是讓 displayedContent
逐字地「追上」message.content
。message.content
],這代表只有當目標文字改變時(例如後端傳來新的資料塊),我們才需要啟動或更新動畫,避免了不必要的重複運算。setInterval
計時器。每隔 TYPING_SPEED
毫秒,它就會執行一次 setDisplayedContent
,將目標字串 targetContent
根據當前已顯示的長度 prev.length
再多截取一個字元,從而實現逐字增加的效果。message.content
變成空的時候(例如,準備顯示下一則訊息),displayedContent
也會同步清空。打字機效果雖然酷炫,但如果 AI 的回覆需要花很長時間生成(例如遇到複雜問題或網路延遲),使用者可能會失去耐心或想提問其他問題。目前我們的應用就像一列啟動後就無法停下的火車,使用者只能被動地等待它抵達終點。這不是好的使用者體驗。
為了將控制權交還給使用者,我們需要一個「煞車」機制,讓使用者可以隨時中止正在進行中的 AI 回應生成。在現代 Web 開發中,fetch
API 的標準配套工具 AbortController
正是為此而生。
補充說明: 什麼是
AbortController
你可以把 AbortController
想像成一個專為非同步操作(如 fetch
)設計的「遙控器與接收器」組合:
AbortController
(遙控器):當你 new AbortController()
時,你就得到了一個遙控器。這個遙控器上只有一個最重要的按鈕:.abort()。controller.signal
(接收器):每個遙控器都配有一個獨一無二的 signal
。你可以把這個 signal
安裝到一個或多個 fetch
請求上。signal
的 fetch
請求都會立刻中止,並拋出一個名為 AbortError
的特殊錯誤,讓你的 catch 區塊可以捕捉到並進行相應處理。在 interview/[sessionId]/page.tsx
中,我們需要使用 useRef
來管理 AbortController
,並在 fetch
時傳入其 signal
。
AbortController
的參照TypeScript
// interview/[sessionId]/page.tsx
import { useRef, useState, useEffect /* ... */ } from 'react';
// ...
const abortControllerRef = useRef<AbortController | null>(null);
handleSubmit
// interview/[sessionId]/page.tsx
const handleSubmit = async () => {
// ...
try {
// 【關鍵 1】為這次請求創建一個新的 AbortController
abortControllerRef.current = new AbortController();
const response = await fetch('/api/interview/evaluate', { // API 路徑用回 Day 15 的即可
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ /* ... */ }),
// 【關鍵 2】將 signal 傳遞給 fetch
signal: abortControllerRef.current.signal,
});
// ...
} catch (error) {
// 【關鍵 3】捕捉中止錯誤
if (error instanceof Error && error.name === 'AbortError') {
console.log('Fetch request was aborted by the user.');
// 更新 UI 顯示已取消
setChatHistory((prevHistory) => {
const newHistory = [...prevHistory];
newHistory[newHistory.length - 1].content = '[已手動取消]';
return newHistory;
});
}
// ... 其他錯誤處理
} finally {
setIsLoading(false);
// 【關鍵 4】請求結束後,清理參照
abortControllerRef.current = null;
}
};
光有邏輯還不夠,我們必須在畫面上提供一個按鈕讓使用者可以觸發取消操作。這個按鈕應該只在 isLoading
為 true 時出現。
你需要將 abortControllerRef 作為 prop 傳遞給負責渲染 UI 的組件(也就是 CodingInterview
或 ConceptualInterview
),或者直接將取消函式傳遞下去。這裡我們以直接傳遞函式為例:
// interview/[sessionId]/page.tsx
// ...
const handleCancel = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
return (
// ...
<ConceptualInterview
// ... other props
isLoading={isLoading}
onCancel={handleCancel}
/>
// ...
);
然後在 ConceptualInterview
(或 CodingInterview
) 組件中,根據 isLoading
狀態顯示原本的「提交」或新增的「X」按鈕。
// components/interview/ConceptualInterview.tsx
import { ChevronRight, X } from 'lucide-react'; // 在開頭引入X icon
interface ConceptualInterviewProps {
// ... other props
isLoading: boolean;
onCancel: () => void; // 新增這個prop
}
export default function ConceptualInterview({
chatHistory,
sessionInfo,
inputValue,
onInputChange,
onSubmit,
isLoading,
onCancel,
}: ConceptualInterviewProps) {
return (
<div className="flex flex-col h-full p-4">
// ...
<footer className="p-4 border-t border-gray-700 flex-shrink-0">
<div className="relative">
// ...
// 加入新的邏輯判斷渲染不同的按鈕
{isLoading ? (
<button
onClick={onCancel}
className="absolute right-3 top-1/2 -translate-y-1/2 text-blue-400 hover:text-blue-300 disabled:text-gray-500"
>
<X size={24} />
</button>
) : (
<button
onClick={onSubmit}
disabled={isLoading}
className="absolute right-3 top-1/2 -translate-y-1/2 text-blue-400 hover:text-blue-300 disabled:text-gray-500"
>
<ChevronRight size={24} />
</button>
)}
</div>
</footer>
</div>
</div>
);
}
這些都完成後試著隨便送出個訊息後按下取消,打開devtool觀察你應該會看到整個請求被取消,且畫面上也如我們所想呈現了被取消的訊息,如下圖所示:
![]() |
---|
**圖1 : 請求成功被取消畫面 ** |
非常好! 一切順利! 歲歲平安!
今天我們完成了一次還不錯升級,且意外的並沒有寫很多的程式碼對吧!很多時候小小的改動就能帶來很明顯的體驗優化。
✅ 實現了高效且穩健的打字機效果:我們用符合 React 最佳實踐的方式,在 AIMessage
元件中實現了擬真且高效的打字動畫。
✅ 加入了可靠的煞車 (AbortController):使用者現在可以透過 UI 按鈕隨時取消 AI 的生成,將控制權掌握在自己手中。
穩固、流暢且可控的通訊管道已經鋪設完成。接下來,我們要讓對話變得更「有記憶」。目前,每一次問答都是獨立的、無狀態的。
明天 (Day 17),我們將開始實作對話管理。我們將探討如何儲存基本的對話歷史,並將其作為上下文傳遞給 AI,讓我們的面試官能夠進行一場連貫、有深度的多輪對話。
我們明天見!
今日程式碼: https://github.com/windate3411/Itiron-2025-code/tree/day-16