iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
自我挑戰組

React 入門到實作與除錯|30 天哩ㄟ刻系列 第 28 篇

Day 28 : 蹭熱度,做個需求顧問 AI 網站之二

  • 分享至 

  • xImage
  •  

趕工是日常...

昨天的系統架構分成三個部分:

  • React 網站
  • 輕量 API 負責 AI 串接與流程控制
  • AI 模型服務負責產生內容

今天把網站做起來,API 和 AI 串上去,首先依照昨天的系統架構分類需求:

需求 功能 分類
需求顧問首頁 依進度顯示背景表單、訪談與討論稿 頁面
畫面區塊 需求背景表單、訪談區、問答紀錄與結果顯示 元件
資料結構 需求背景、問答、候選需求與 API 請求/回應的資料格式 型別
訪談進度與輸入 保存需求背景、回答草稿、輪次、問答紀錄、結果及等待/錯誤狀態 狀態
使用者操作 表單輸入、訪談問答、送出回答與失敗重試 事件
網站與 API 溝通 POST 送出背景與問答紀錄,接收下一題或討論稿 API 串接
訪談流程控制 驗證輸入、判斷輪數,第三輪回答後切換成整理任務 API 流程
模型產生內容 追問與整理,呼叫模型並檢查回應 AI 串接
執行環境設定 伺服器載入 API 金鑰與模型名稱,設定環境變數 環境設定
複製成果與重新討論 複製討論稿與重新開始 事件

接著就開始吧。

建立專案

依照之前教學的,建立一個 React Router 專案並使用 MUI 元件庫,命名為 ai-requirements-assistant。

npx create-react-router@latest project/ai-requirements-assistant
cd project/ai-requirements-assistant
npm install
npm install @mui/material @emotion/react @emotion/styled

下面初始相關的設定程式放明天最後面的附錄:

  • vite.config.ts
  • app/root.tsx
  • app/entry.client.tsx
  • app/routes.ts

特別要說的是 entry.client.tsx,我們在天氣查詢的內容有提過 SSR,也就是伺服器端渲染,會在伺服器產生頁面的 HTML,瀏覽器收到後再透過 hydrateRoot() 讓 React 接手既有畫面,接上輸入與點擊等互動,這個過程稱為 hydration。入口的 HydratedRouter 會接續 React Router 在瀏覽器端的路由運作。

React 的 hydrateRoot 說明

因為我們用 React Router 範本建立專案,已提供預設的瀏覽器入口,裡面沒有 app/entry.client.tsx,我們加入這個程式來自訂瀏覽器啟動流程。

React Router 的 entry.client.tsx 說明

建立畫面

新增 app/components/requirements-assistant.tsx,暫時放一個可以顯示的內容:

export function RequirementsAssistant() {
  return <main><h1>需求顧問 AI 網站</h1></main>;
}

修改 home.tsx,主要是把原本的 Welcome 替換掉:

import { RequirementsAssistant } from "../components/requirements-assistant";

export default function Home() {
  return <RequirementsAssistant />;
}

npm run dev 看有沒有出現前面設定的內容,接著我們要來做主畫面的三欄表單。

表單建立

來到表單,依照昨天討論,我們有「想解決的問題、誰會用、目前如何處理」三欄必填,新增 app/shared/clarify.ts,因為不能無限制的字數,所以還要建立檢查程式,檢查輸入的內容是否有在限制字數內,這裡我們示意背景資訊型別和字數上限常數:

// 定義的三欄背景資訊
export type Background = {
  problem: string;
  audience: string;
  currentProcess: string;
};

// 字數上限
export const MAX_BACKGROUND_LENGTH = 1000;

接著將 app/components/requirements-assistant.tsx 改成三欄表單,在 RequirementsAssistant 裡保存輸入與錯誤狀態,並且判斷三欄是否都有填寫:

const [background, setBackground] = useState<Background>({
  problem: "", audience: "", currentProcess: "",
});
const [error, setError] = useState<string | null>(null);
const complete = Object.values(background).every((value) => value.trim());

當使用者觸發送出事件,會先檢查表單背景並記錄到 Console。初始 history 是空陣列,後續有問答紀錄時,再一起送給 API:

function handleStart(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();
  try {
    const input = parseInput({ background, history: [] });
    console.info("表單背景", input.background);
    setError(null);
  } catch (error) {
    setError(error instanceof Error ? error.message : "請檢查輸入。");
  }
}

最後把欄位綁定到 background,並讓表單送出接到 handleStart()。FIELDS 主要列出「想解決的問題、誰會用、目前如何處理」三欄,以下節錄輸入與送出的片段:

<Stack component="form" onSubmit={handleStart}>
  {FIELDS.map((field) => (
    <TextField
      key={field.key} label={field.label} required
      value={background[field.key]}
      onChange={(event) => setBackground((previous) => ({
        ...previous, [field.key]: event.target.value,
      }))}
      slotProps={{ htmlInput: { maxLength: MAX_BACKGROUND_LENGTH } }}
    />
  ))}
  {error ? <Alert severity="error">{error}</Alert> : null}
  <Button type="submit" variant="contained" disabled={!complete}>開始訪談</Button>
</Stack>

首先畫面會檢查,任一欄留白或只填空白,按鈕不能送出。三欄都填好後,按下開始訪談,會在 Console 看到三欄資料,頁面不會重新載入。不知道背景時,可以明確填「不知道」。

表單這段做完,就有基本可以操作的網站入口了,接著來建立 API。

建立 API

新增 app/routes/api.clarify.ts,主要是讓 API 能接收與檢查請求,模型沒接好時會回傳 503:

import type { Route } from "./+types/api.clarify";
import { parseInput } from "../shared/clarify";

export function loader() {
  return new Response(null, { status: 405, headers: { Allow: "POST" } });
}

export async function action({ request }: Route.ActionArgs) {
  if (request.method !== "POST") {
    return new Response(null, { status: 405, headers: { Allow: "POST" } });
  }
  // regex 檢查 Content-Type 是否為 JSON
  if (!/^application\/json(?:;|$)/i.test(request.headers.get("Content-Type") || "")) {
    return Response.json({ error: "請使用 JSON 格式提交資料。" }, { status: 415 });
  }
  try {
    parseInput(await request.json());
  } catch {
    return Response.json({ error: "請檢查背景與問答資料。" }, { status: 400 });
  }
  return Response.json(
    { error: "API 已收到資料,模型串接尚未完成。" },
    { status: 503, headers: { "Cache-Control": "no-store" } },
  );
}

程式第一行的 ./+types/api.clarify 是 React Router 為這條路由自動產生的 TypeScript 型別。

註冊 API

把 app/routes.ts 加入這條路由:

route("api/clarify", "routes/api.clarify.ts")

因為檔名本身不會自動註冊網址,要在 routes.ts 加入對應。

POST 請求進來時,流程是:

  1. /api/clarify 對應到 api.clarify.ts。
  2. 執行裡面的 action()。
  3. action() 呼叫共用檔案的 parseInput(),檢查背景與問答資料。

React Router:Resource Routes

完成路由註冊後,開發伺服器會產生對應型別,實際檔案放在 .react-router/types/ 裡。

若編輯器仍提示找不到這個模組,可以在新專案根目錄執行:

npx react-router typegen

這個指令會依 app/routes.ts 重新產生路由型別。

React Router:Type Safety

接上 AI

這次使用 OpenRouter,透過 Chat Completions API 串接模型,OPENROUTER_MODEL 填入 OpenRouter 的模型 ID,範例會使用 google/gemini-2.5-flash,也可以改成其他支援 JSON 模式的模型。

OpenRouter:Quickstart
範例模型

在OpenRouter 的 API Keys 頁面建立金鑰後,回到專案根目錄新增 .env:

OPENROUTER_API_KEY=在這裡填入自己的金鑰
OPENROUTER_MODEL=google/gemini-2.5-flash

發送請求給 AI 供應商

新增 app/services/clarify.server.ts,這份程式只會在伺服器使用。

React Router:.server modules

串接內容包含四件事:

  • 固定需求顧問指令。
  • 依問答筆數選擇本次任務。
  • 呼叫模型並檢查回傳 JSON。
  • 固定 prompt 要求參考表單與前文,使用 5W1H 檢查缺口、依照使用者回答探索原因,整理時會留下未知與待確認事項。

伺服器向 OpenRouter 發送請求的部分。key、model 從環境變數(.env)取得,messages 放入固定 prompt 與本次討論資料:

const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
  method: "POST",
  signal,
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
  body: JSON.stringify({
    model,
    messages: [
      { role: "system", content: SYSTEM_INSTRUCTION },
      { role: "user", content: `${taskFor(input)}\n討論資料:\n${JSON.stringify(input)}` },
    ],
    response_format: { type: "json_object" },
    provider: { require_parameters: true },
    max_tokens: 8192,
    stream: false,
  }),
});

response_format 常用於要求模型輸出固定格式,例如我們要求 json_object,如果模型有支援,就會固定輸出 JSON,require_parameters: true 讓 OpenRouter 選擇支援這些參數的供應商。

JSON 模式處理輸出格式,欄位與內容檢查由我們程式處理。
更換模型時,也要確認供應商支援 response_format。
OpenRouter:Provider Routing

模型回傳文字位於 choices[0].message.content,我們收到後再用 JSON.parse() 與 parseResponse() 檢查欄位、內容及目前階段。

OpenRouter:API Reference

三輪訪談

在 app/components/requirements-assistant.tsx 接續新增 BackgroundFields、InterviewPanel、InterviewRecord 和 DraftResult 四個內層元件,由外層 RequirementsAssistant 組合,讓表單、回答與下一題的變化都接到當次訪談的資料。

  • BackgroundFields:需求背景表單
  • InterviewPanel:當前訪談題目
  • InterviewRecord:已完成的問答
  • DraftResult:需求討論稿

訪談尚未完成時,再組合要送出的 input,第一次按「開始訪談」只會附帶填好的 background,問答紀錄 history 是空陣列,在接續回答題目時,則會把目前的問題與回答草稿接在已完成的問答紀錄後面。

如果 session.stage 已經是 "result",代表討論稿已產生,函式會直接 return,不再送出請求。

https://ithelp.ithome.com.tw/upload/images/20261012/20184332vzZ6ds431v.png

// 訪談尚未完成時的組合資料片段
const input = parseInput({
  background: session.stage === "background" ? background : session.background,
  history: session.stage === "background" ? [] : [
    ...session.history,
    { question: session.question, answer: answerDraft },
  ],
});

接著向前面建好的 API 送出資料:

const response = await fetch("/api/clarify", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(input),
  signal: AbortSignal.timeout(60_000),
});

同一個函式內還需要讀取回應、檢查 HTTP 狀態,成功時再檢查內容並更新畫面資料:

const result = parseResponse(raw, input.history.length === MAX_ROUNDS);
if (result.kind === "question") {
  setSession({
    stage: "interview", background: input.background,
    history: input.history, question: result.question,
  });
} else {
  setSession({
    stage: "result", background: input.background,
    history: input.history, result: result.result,
  });
}
setAnswerDraft("");

上面的 raw 是 await response.json() 取得的資料,取得題目後進入訪談,背景鎖定供回看,取得討論稿則進入結果階段,成功接續後才清空回答。

用表格來看 API 是如何用問答筆數控制流程:

請求帶上的問答 工作 畫面接續
0 筆 產生第一題 第 1 / 3 題
1 筆 產生第二題 第 2 / 3 題
2 筆 產生第三題 第 3 / 3 題
3 筆 整理需求 顯示討論稿

顯示討論稿

假設使用者回答到第三題,第三題的回答送出後,API 要選擇整理任務,包含完整成功流程一共四次模型請求。

模型回傳 kind: "result" 時,advance() 會把結果放進 session.result,下方這段 JSX 才顯示討論稿:

{session.stage === "result" ? <DraftResult result={session.result} /> : null}

DraftResult 會負責顯示問題摘要、候選需求與待確認事項,每項需求列出內容、理由、User Story 與建議 AC,資料不足時可以少於三項,並交代缺口。

待續

https://ithelp.ithome.com.tw/upload/images/20261012/20184332oSDBuGp7Wt.png

今天先把重點的片段帶過,明天補完複製成果與重新討論,再來看完整執行的過程吧。


上一篇
Day 27 : 蹭熱度,做個需求顧問 AI 網站
下一篇
Day 29 : 蹭熱度,做個需求顧問 AI 網站之三
系列文
React 入門到實作與除錯|30 天哩ㄟ刻 共 29 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言