從零開始建立一個完整的 AI 聊天機器人,使用 Next.js、TypeScript、Tailwind CSS 和 Google Gemini API
這個教學將帶你建立一個完整的 AI 聊天機器人,具備以下功能:
在開始之前,請確保你的電腦已安裝以下軟體:
| 軟體 | 版本要求 | 下載連結 |
|---|---|---|
| Node.js | 18.0 或以上 | https://nodejs.org/ |
| npm | 隨 Node.js 自動安裝 | - |
| Git | 最新版本 | https://git-scm.com/ |
| 程式碼編輯器 | - | 建議使用 VS Code |
開啟終端機(Terminal 或 Command Prompt),執行以下指令確認安裝成功:
node --version
npm --version
git --version
選擇一個你喜歡的位置建立專案資料夾。例如在桌面或文件夾中:
# Windows (PowerShell)
mkdir gemini-chatbot
cd gemini-chatbot
# Mac/Linux
mkdir gemini-chatbot
cd gemini-chatbot
gemini-chatbot 改成任何你喜歡的專案名稱。
複製你的 API Key,格式類似:
AIzaSyDcthAMZTriGhOpjYt0zxmgpjGrEqlC2Zo
我們稍後會在專案中使用這個 Key。
在終端機中執行以下指令,建立一個新的 Next.js 專案:
npx create-next-app@latest gemini-chatbot --typescript --eslint --src-dir --app --tailwind --import-alias "@/*" --yes
--typescript:啟用 TypeScript 支援--eslint:啟用 ESLint 程式碼檢查--src-dir:使用 src 資料夾結構--app:使用 App Router(Next.js 13+ 的新路由系統)--tailwind:啟用 Tailwind CSS--import-alias "@/*":設定路徑別名--yes:自動回答所有問題專案建立完成後,進入專案資料夾:
cd gemini-chatbot
你的專案資料夾應該包含以下結構:
gemini-chatbot/
├── src/
│ └── app/
│ ├── layout.tsx
│ ├── page.tsx
│ └── globals.css
├── public/
├── package.json
├── tsconfig.json
└── next.config.ts
在 src 資料夾中建立 components 資料夾:
# Windows
mkdir src\components
# Mac/Linux
mkdir -p src/components
建立 src/components/ChatWindow.tsx 檔案,這是顯示聊天訊息的元件:
import React, { useEffect, useRef } from "react";
export type ChatMessage = {
id: string;
role: "user" | "assistant" | "system";
content: string;
timestamp: string;
};
type ChatWindowProps = {
messages: ChatMessage[];
isLoading: boolean;
};
export function ChatWindow({ messages, isLoading }: ChatWindowProps) {
const containerRef = useRef(null);
useEffect(() => {
if (!containerRef.current) return;
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}, [messages, isLoading]);
return (
<div
ref={containerRef}
className="flex-1 overflow-y-auto rounded-2xl border border-zinc-200 bg-white/70 p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/70"
>
{messages.length === 0 && !isLoading && (
<div className="flex h-full items-center justify-center text-sm text-zinc-500 dark:text-zinc-400">
開始輸入訊息,與你的 Gemini 助理聊天。
</div>
)}
<div className="flex flex-col gap-3">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[80%] whitespace-pre-wrap rounded-2xl px-4 py-2 text-sm leading-relaxed ${
msg.role === "user"
? "bg-blue-600 text-white"
: "bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50"
}`}
>
{msg.content}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="flex items-center gap-1 rounded-2xl bg-zinc-100 px-3 py-2 text-xs text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-zinc-400 [animation-delay:-0.2s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-zinc-400 [animation-delay:-0.05s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-zinc-400" />
</div>
</div>
)}
</div>
</div>
);
}
useRef 和 useEffect 自動捲動到最新訊息isLoading 為 true 時,顯示載入動畫建立 src/components/ChatInput.tsx 檔案,這是輸入訊息的元件:
import React, { FormEvent, KeyboardEvent, useState } from "react";
type ChatInputProps = {
onSend: (message: string) => void;
disabled?: boolean;
};
export function ChatInput({ onSend, disabled }: ChatInputProps) {
const [value, setValue] = useState("");
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue("");
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue("");
}
};
return (
<form
onSubmit={handleSubmit}
className="flex items-end gap-3 rounded-2xl border border-zinc-200 bg-white/80 p-3 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/80"
>
<textarea
className="max-h-40 min-h-[3rem] flex-1 resize-none border-none bg-transparent text-sm text-zinc-900 outline-none placeholder:text-zinc-400 dark:text-zinc-50 dark:placeholder:text-zinc-500"
placeholder="輸入訊息,按 Enter 送出(Shift+Enter 換行)"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
/>
<button
type="submit"
disabled={disabled || !value.trim()}
className="inline-flex h-9 items-center rounded-full bg-blue-600 px-4 text-xs font-medium text-white shadow-sm transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-zinc-300 dark:disabled:bg-zinc-700"
>
發送
</button>
</form>
);
}
useState 管理輸入框的值disabled 為 true 時,無法發送訊息(防止重複請求)更新 src/app/page.tsx 檔案,整合聊天介面:
"use client";
import { useState } from "react";
import { ChatWindow, type ChatMessage } from "@/components/ChatWindow";
import { ChatInput } from "@/components/ChatInput";
export default function Home() {
const [messages, setMessages] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const handleSend = async (text: string) => {
const timestamp = new Date().toISOString();
const userMessage: ChatMessage = {
id: `${timestamp}-user`,
role: "user",
content: text,
timestamp,
};
setMessages((prev) => [...prev, userMessage]);
setIsLoading(true);
setError(null);
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
userMessage: text,
messages,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
const message =
data?.error || data?.message || "伺服器發生未知錯誤,請稍後再試。";
throw new Error(message);
}
const data: { reply: string } = await res.json();
const aiTimestamp = new Date().toISOString();
const aiMessage: ChatMessage = {
id: `${aiTimestamp}-assistant`,
role: "assistant",
content: data.reply,
timestamp: aiTimestamp,
};
setMessages((prev) => [...prev, aiMessage]);
} catch (err) {
console.error(err);
const message =
err instanceof Error ? err.message : "發送訊息失敗,請稍後再試。";
setError(message);
} finally {
setIsLoading(false);
}
};
const handleClear = () => {
setMessages([]);
setError(null);
};
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-b from-zinc-50 to-zinc-100 px-4 py-8 font-sans dark:from-zinc-900 dark:to-black">
<main className="flex w-full max-w-4xl flex-1 flex-col gap-4 rounded-3xl bg-white/80 p-6 shadow-xl backdrop-blur dark:bg-zinc-950/80">
<header className="flex flex-col gap-2 border-b border-zinc-200 pb-4 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">
Gemini Self Chatbot
</h1>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
基於 Google Gemini 的個人助理。你的訊息會在本服務端儲存以供日後查詢,請避免輸入敏感個資。
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200">
部署準備完成 · GenAI deploy page
</span>
<button
type="button"
onClick={handleClear}
className="rounded-full border border-zinc-200 px-3 py-1 text-xs text-zinc-500 transition hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
清除對話
</button>
</div>
</header>
{error && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-2 text-xs text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
{error}
</div>
)}
<section className="flex min-h-0 flex-1 flex-col gap-3">
<ChatWindow messages={messages} isLoading={isLoading} />
<ChatInput onSend={handleSend} disabled={isLoading} />
</section>
<footer className="mt-1 flex flex-col gap-1 text-[10px] text-zinc-500 dark:text-zinc-500 sm:flex-row sm:items-center sm:justify-between">
<span>使用 Google Gemini API · 僅於伺服器端存取 API Key。</span>
<span>部署建議:搭配 Docker、GitHub Actions,自動化測試通過後再部署。</span>
</footer>
</main>
</div>
);
}
"use client" 表示這是客戶端元件(可以使用 React Hooks)useState 管理訊息列表、載入狀態和錯誤訊息handleSend 函數負責發送訊息到後端 APIfetch API 與後端通訊在 src/app 資料夾中建立 api/chat 資料夾:
# Windows
mkdir src\app\api\chat
# Mac/Linux
mkdir -p src/app/api/chat
建立 src/lib 資料夾和 src/lib/geminiClient.ts 檔案:
# Windows
mkdir src\lib
# Mac/Linux
mkdir -p src/lib
然後建立 src/lib/geminiClient.ts:
import "server-only";
type ChatRole = "user" | "assistant" | "system";
export type ChatMessageInput = {
role: ChatRole;
content: string;
};
export type GeminiReply = {
replyText: string;
};
const DEFAULT_MODEL = process.env.GEMINI_MODEL_NAME || "gemini-pro";
export async function generateReplyFromGemini(
messages: ChatMessageInput[],
): Promise {
const apiKey = process.env.GOOGLE_GENAI_API_KEY;
if (!apiKey) {
throw new Error("伺服器尚未設定 GOOGLE_GENAI_API_KEY 環境變數。");
}
const userMessage = messages.findLast((m) => m.role === "user");
if (!userMessage) {
throw new Error("沒有可用的使用者訊息。");
}
const systemPrompt =
"You are a helpful assistant for a personal Gemini-based chatbot. Respond in Traditional Chinese when possible.";
const contents = [
{
role: "user",
parts: [
{
text: `${systemPrompt}\n\nUser: ${userMessage.content}`,
},
],
},
];
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30_000);
try {
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
DEFAULT_MODEL,
)}:generateContent`,
{
method: "POST",
signal: controller.signal,
headers: {
"Content-Type": "application/json",
"x-goog-api-key": apiKey,
},
body: JSON.stringify({
contents,
}),
},
);
if (!response.ok) {
const errorBody = await response.json().catch(() => null);
const message =
(errorBody as { error?: { message?: string } } | null)?.error
?.message ||
`Gemini API 回應錯誤 (status ${response.status}).`;
throw new Error(message);
}
const data = (await response.json()) as {
candidates?: Array<{
content?: {
parts?: Array<{ text?: string }>;
};
}>;
};
const text =
data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? "";
if (!text) {
throw new Error("Gemini 沒有回傳可用文字內容。");
}
return { replyText: text };
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error("呼叫 Gemini API 逾時,請稍後再試。");
}
if (error instanceof Error) {
throw error;
}
throw new Error("呼叫 Gemini API 時發生未知錯誤。");
} finally {
clearTimeout(timeoutId);
}
}
"server-only" 確保這個檔案只在伺服器端執行AbortController 設定 30 秒超時建立 src/lib/chatStore.ts 檔案,用於儲存聊天紀錄:
import "server-only";
import { promises as fs } from "fs";
import path from "path";
export type StoredMessage = {
role: "user" | "assistant" | "system";
content: string;
timestamp: string;
};
export type ChatSession = {
sessionId: string;
createdAt: string;
updatedAt: string;
messages: StoredMessage[];
};
function getChatsDir() {
return path.join(process.cwd(), "data", "chats");
}
export async function ensureChatsDirExists() {
const dir = getChatsDir();
try {
await fs.mkdir(dir, { recursive: true });
} catch (error) {
if (error instanceof Error) {
throw new Error(`無法建立聊天資料夾: ${error.message}`);
}
throw new Error("無法建立聊天資料夾(未知錯誤)。");
}
}
export async function loadChat(sessionId: string): Promise {
const filePath = path.join(getChatsDir(), `${sessionId}.json`);
try {
const content = await fs.readFile(filePath, "utf8");
const data = JSON.parse(content) as ChatSession;
return data;
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
(error as { code?: string }).code === "ENOENT"
) {
return null;
}
if (error instanceof Error) {
throw new Error(`讀取聊天紀錄失敗: ${error.message}`);
}
throw new Error("讀取聊天紀錄時發生未知錯誤。");
}
}
export async function saveChat(session: ChatSession): Promise {
await ensureChatsDirExists();
const filePath = path.join(getChatsDir(), `${session.sessionId}.json`);
const payload: ChatSession = {
...session,
updatedAt: new Date().toISOString(),
};
try {
await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
} catch (error) {
if (error instanceof Error) {
throw new Error(`儲存聊天紀錄失敗: ${error.message}`);
}
throw new Error("儲存聊天紀錄時發生未知錯誤。");
}
}
export function createEmptyChat(sessionId: string): ChatSession {
const now = new Date().toISOString();
return {
sessionId,
createdAt: now,
updatedAt: now,
messages: [],
};
}
建立 src/app/api/chat/route.ts 檔案,這是處理聊天請求的 API 端點:
import { NextRequest, NextResponse } from "next/server";
import { generateReplyFromGemini } from "@/lib/geminiClient";
import {
ChatSession,
StoredMessage,
createEmptyChat,
loadChat,
saveChat,
} from "@/lib/chatStore";
import { randomUUID } from "crypto";
type RequestBody = {
userMessage?: string;
messages?: Array<{ role: "user" | "assistant" | "system"; content: string }>;
sessionId?: string;
};
type ErrorResponse = {
error: string;
};
export async function POST(req: NextRequest) {
try {
let body: RequestBody | null = null;
try {
body = (await req.json()) as RequestBody;
} catch {
const errorBody: ErrorResponse = { error: "無法解析請求內容。" };
return NextResponse.json(errorBody, { status: 400 });
}
const userMessage = body?.userMessage?.trim();
if (!userMessage) {
const errorBody: ErrorResponse = { error: "userMessage 不可為空。" };
return NextResponse.json(errorBody, { status: 400 });
}
const sessionId = body?.sessionId || randomUUID();
let session: ChatSession | null;
try {
session = await loadChat(sessionId);
} catch (error) {
const message =
error instanceof Error ? error.message : "讀取聊天紀錄時發生錯誤。";
const errorBody: ErrorResponse = { error: message };
return NextResponse.json(errorBody, { status: 500 });
}
if (!session) {
session = createEmptyChat(sessionId);
}
const now = new Date().toISOString();
const newUserMessage: StoredMessage = {
role: "user",
content: userMessage,
timestamp: now,
};
const historyMessages: StoredMessage[] = session.messages.concat(
newUserMessage,
);
let replyText: string;
try {
const { replyText: text } = await generateReplyFromGemini(
historyMessages.map((m) => ({
role: m.role,
content: m.content,
})),
);
replyText = text;
} catch (error) {
const message =
error instanceof Error ? error.message : "呼叫 Gemini API 時發生錯誤。";
const errorBody: ErrorResponse = { error: message };
return NextResponse.json(errorBody, { status: 500 });
}
const assistantMessage: StoredMessage = {
role: "assistant",
content: replyText,
timestamp: new Date().toISOString(),
};
const updatedSession: ChatSession = {
...session,
messages: historyMessages.concat(assistantMessage),
};
try {
await saveChat(updatedSession);
} catch (error) {
const message =
error instanceof Error ? error.message : "儲存聊天紀錄時發生錯誤。";
const errorBody: ErrorResponse = { error: message };
return NextResponse.json(errorBody, { status: 500 });
}
return NextResponse.json(
{
reply: replyText,
sessionId,
},
{ status: 200 },
);
} catch (error) {
const message =
error instanceof Error ? error.message : "伺服器發生未知錯誤。";
const errorBody: ErrorResponse = { error: message };
return NextResponse.json(errorBody, { status: 500 });
}
}
更新 src/app/layout.tsx 檔案,設定正確的標題和語言:
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Gemini Self Chatbot",
description: "基於 Google Gemini 的個人助理 Chatbot",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-TW">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}
更新 .gitignore 檔案,確保聊天紀錄不會被上傳到 Git:
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# chat data
/data/chats/*.json
在專案根目錄建立 .env.local 檔案:
GOOGLE_GENAI_API_KEY=你的_API_Key_在這裡
GEMINI_MODEL_NAME=gemini-pro
你的_API_Key_在這裡 替換成你在步驟 4 中取得的 API Key.env.local 檔案已經在 .gitignore 中,不會被上傳到 Git在終端機中執行:
npm run dev
▲ Next.js 16.1.3
- Local: http://localhost:3000
- ready started server on 0.0.0.0:3000
http://localhost:3000data/chats/ 資料夾中原因:環境變數檔案沒有正確設定
解決方法:
.env.local 檔案在專案根目錄npm run dev)原因:模型名稱不正確或 API Key 沒有權限使用該模型
解決方法:
gemini-1.5-progemini-1.5-flashgemini-2.0-flash-exp.env.local 中更新 GEMINI_MODEL_NAME原因:檔案系統權限問題
解決方法:
data/chats 資料夾原因:可能是程式碼錯誤或編譯問題
解決方法:
rm -rf .next(Mac/Linux)或 rmdir /s .next(Windows)Vercel 是 Next.js 的官方部署平台,提供免費方案:
GOOGLE_GENAI_API_KEY = 你的 API KeyGEMINI_MODEL_NAME = gemini-pro(可選)https://your-project.vercel.app
如果你想要在自己的伺服器上部署,可以使用 Docker:
首先,建立 Dockerfile 檔案(已在專案中):
FROM node:20-alpine AS builder
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.* ./ || true
COPY --from=builder /app/tsconfig.json ./tsconfig.json
RUN mkdir -p /app/data/chats && chown -R nextjs:nextjs /app
USER nextjs
EXPOSE 3000
CMD ["npm", "start"]
建立 Docker 映像檔:
docker build -t gemini-chatbot .
執行 Docker 容器:
docker run -d -p 3000:3000 --env-file .env.local --name gemini-chatbot gemini-chatbot
.env.local 檔案存在且包含正確的環境變數在部署前,先測試生產環境建置:
npm run build
如果建置成功,可以啟動生產伺服器:
npm start
npm run build 會建立最佳化的生產版本npm start 會啟動生產伺服器(預設在 http://localhost:3000)npm run dev你已經成功建立了一個完整的 AI 聊天機器人!
現在你可以:
教學文件建立日期:2026年1月
如有問題,請參考 Next.js 官方文件 或 Google AI 文件