趕工是日常...
昨天的系統架構分成三個部分:
今天把網站做起來,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
下面初始相關的設定程式放明天最後面的附錄:
特別要說的是 entry.client.tsx,我們在天氣查詢的內容有提過 SSR,也就是伺服器端渲染,會在伺服器產生頁面的 HTML,瀏覽器收到後再透過 hydrateRoot() 讓 React 接手既有畫面,接上輸入與點擊等互動,這個過程稱為 hydration。入口的 HydratedRouter 會接續 React Router 在瀏覽器端的路由運作。
因為我們用 React Router 範本建立專案,已提供預設的瀏覽器入口,裡面沒有 app/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。
新增 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 型別。
把 app/routes.ts 加入這條路由:
route("api/clarify", "routes/api.clarify.ts")
因為檔名本身不會自動註冊網址,要在 routes.ts 加入對應。
POST 請求進來時,流程是:
完成路由註冊後,開發伺服器會產生對應型別,實際檔案放在 .react-router/types/ 裡。
若編輯器仍提示找不到這個模組,可以在新專案根目錄執行:
npx react-router typegen
這個指令會依 app/routes.ts 重新產生路由型別。
這次使用 OpenRouter,透過 Chat Completions API 串接模型,OPENROUTER_MODEL 填入 OpenRouter 的模型 ID,範例會使用 google/gemini-2.5-flash,也可以改成其他支援 JSON 模式的模型。
在OpenRouter 的 API Keys 頁面建立金鑰後,回到專案根目錄新增 .env:
OPENROUTER_API_KEY=在這裡填入自己的金鑰
OPENROUTER_MODEL=google/gemini-2.5-flash
新增 app/services/clarify.server.ts,這份程式只會在伺服器使用。
串接內容包含四件事:
伺服器向 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() 檢查欄位、內容及目前階段。
在 app/components/requirements-assistant.tsx 接續新增 BackgroundFields、InterviewPanel、InterviewRecord 和 DraftResult 四個內層元件,由外層 RequirementsAssistant 組合,讓表單、回答與下一題的變化都接到當次訪談的資料。
訪談尚未完成時,再組合要送出的 input,第一次按「開始訪談」只會附帶填好的 background,問答紀錄 history 是空陣列,在接續回答題目時,則會把目前的問題與回答草稿接在已完成的問答紀錄後面。
如果 session.stage 已經是 "result",代表討論稿已產生,函式會直接 return,不再送出請求。

// 訪談尚未完成時的組合資料片段
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,資料不足時可以少於三項,並交代缺口。

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