iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0

前言

歡迎來到第十六天!昨天我們成功讓 AI 面試官「動」了起來,透過 Streaming 技術,使用者終於不用再盯著空白畫面乾等到完整回覆產生然後被一口氣塞臉上,而是能看到 AI 即時生成一步步的回覆,在體驗上稍微好了一點!(當然,我知道 api 請求在gemini端現在還是極慢,這實在是挺奇怪的,我在規劃初期也測試過幾次免費版本,同樣的模型當時頂多幾秒鐘就有回覆,現在有時候甚至會直接 overload,這點如果你真的忍受不了的話可以設定付款方式改為付費版的 api,不過現在作為一個 POC 我可以忍耐一點不便)。
撇開回應速度本身的問題不談,目前的體驗還有兩個明顯的痛點:

  • 生硬的回覆:文字是一塊一塊地「跳」出來,而非流暢地「打」出來,根本比不上目前常見的 AI聊天服務。+
  • 失控的列車:目前的串流像一輛沒有煞車的賽車,一旦發動就只能衝到終點。如果網路不穩、後端出錯,或使用者只是單純不想等了,整個體驗就會變得很糟糕。

今天我們要一次解決這兩個問題,內容意外的不會很多,放心跟著來吧!

今日目標

  • 實作 AI 回覆的打字機效果,提升使用者體驗。
  • 整合 AbortController:讓前端可以主動取消一個正在進行中的 fetch 請求。

Step 1: 讓 AI 一個字一個字說話 (高效能打字機效果)

這是今天最有感的優化。我們要直接修改 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
  • 動畫的觸發器 (useEffect):第一個 useEffect 是動畫的主引擎。它的依賴陣列中只有 [message.content],這代表只有當目標文字改變時(例如後端傳來新的資料塊),我們才需要啟動或更新動畫,避免了不必要的重複運算。
  • 逐字顯示的核心 (setInterval):useEffect 內部建立了一個 setInterval 計時器。每隔 TYPING_SPEED 毫秒,它就會執行一次 setDisplayedContent,將目標字串 targetContent 根據當前已顯示的長度 prev.length 再多截取一個字元,從而實現逐字增加的效果。
  • 自動清理 (return () => clearInterval(intervalId)):useEffect 的回傳函式是一個清理函式。它會在元件卸載,或下一次 useEffect 即將執行前被呼叫,確保舊的計時器被清除,防止記憶體洩漏和不必要的背景執行。
  • 重置機制:第二個 useEffect 是一個簡單的輔助,確保當 message.content 變成空的時候(例如,準備顯示下一則訊息),displayedContent 也會同步清空。

Step 2: 煞車系統 - 在前端實作 AbortController

打字機效果雖然酷炫,但如果 AI 的回覆需要花很長時間生成(例如遇到複雜問題或網路延遲),使用者可能會失去耐心或想提問其他問題。目前我們的應用就像一列啟動後就無法停下的火車,使用者只能被動地等待它抵達終點。這不是好的使用者體驗。

為了將控制權交還給使用者,我們需要一個「煞車」機制,讓使用者可以隨時中止正在進行中的 AI 回應生成。在現代 Web 開發中,fetch API 的標準配套工具 AbortController 正是為此而生。

補充說明: 什麼是 AbortController

你可以把 AbortController 想像成一個專為非同步操作(如 fetch)設計的「遙控器與接收器」組合:

  • AbortController (遙控器):當你 new AbortController() 時,你就得到了一個遙控器。這個遙控器上只有一個最重要的按鈕:.abort()。
  • controller.signal (接收器):每個遙控器都配有一個獨一無二的 signal。你可以把這個 signal 安裝到一個或多個 fetch 請求上。
  • 運作方式:當你按下遙控器上的 .abort() 按鈕時,所有安裝了對應 signalfetch 請求都會立刻中止,並拋出一個名為 AbortError 的特殊錯誤,讓你的 catch 區塊可以捕捉到並進行相應處理。
    這套機制讓我們能夠乾淨利落地從外部取消一個已經發出的網路請求,常見的使用情境包含在 React 組件中避免race condition造成頁面渲染的混亂或不必要的請求發出等等(例如使用者已經離開頁面,那已經發出的請求也就沒必要繼續了)。

interview/[sessionId]/page.tsx 中,我們需要使用 useRef 來管理 AbortController,並在 fetch 時傳入其 signal

1. 建立 AbortController 的參照

TypeScript

// interview/[sessionId]/page.tsx
import { useRef, useState, useEffect /* ... */ } from 'react';
// ...
const abortControllerRef = useRef<AbortController | null>(null);

2. 修改 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;
  }
};

3. 加上取消按鈕的 UI

光有邏輯還不夠,我們必須在畫面上提供一個按鈕讓使用者可以觸發取消操作。這個按鈕應該只在 isLoading 為 true 時出現。

你需要將 abortControllerRef 作為 prop 傳遞給負責渲染 UI 的組件(也就是 CodingInterviewConceptualInterview),或者直接將取消函式傳遞下去。這裡我們以直接傳遞函式為例:

// 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
**圖1 : 請求成功被取消畫面 **

非常好! 一切順利! 歲歲平安!

今日回顧

今天我們完成了一次還不錯升級,且意外的並沒有寫很多的程式碼對吧!很多時候小小的改動就能帶來很明顯的體驗優化。

✅ 實現了高效且穩健的打字機效果:我們用符合 React 最佳實踐的方式,在 AIMessage 元件中實現了擬真且高效的打字動畫。
✅ 加入了可靠的煞車 (AbortController):使用者現在可以透過 UI 按鈕隨時取消 AI 的生成,將控制權掌握在自己手中。

明日預告

穩固、流暢且可控的通訊管道已經鋪設完成。接下來,我們要讓對話變得更「有記憶」。目前,每一次問答都是獨立的、無狀態的。

明天 (Day 17),我們將開始實作對話管理。我們將探討如何儲存基本的對話歷史,並將其作為上下文傳遞給 AI,讓我們的面試官能夠進行一場連貫、有深度的多輪對話。

我們明天見!

今日程式碼: https://github.com/windate3411/Itiron-2025-code/tree/day-16


上一篇
AI 面試官動起來!實現最小可行 Streaming
下一篇
為 AI 植入短期記憶 :實作對話上下文
系列文
前端工程師的AI應用開發實戰:30天從Prompt到Production - 以打造AI前端面試官為例18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言