- 📂 目次:【AIチャット(freeAiChat)無料構築】連載の全記事まとめ
- 【第1回】無料で構築できるRAG × マルチLLM対応AIチャットシステムの全体像を紹介
- 【第2回】:FastAPI × LangChain × Weaviate で構築された RAG対応バックエンドを詳細解説
- 【第3回】:React × Next.js × shadcn/ui で構築されたチャットフロントエンドを詳細解説(閲覧中)
- 💡 今後も開発効率化・ツール連携に関する記事を随時追加していきます!
🎯 本記事の対象読者
- 第1回・第2回を読み、freeAiChat の全体像とバックエンドの仕組みを把握済みの方
- 「Next.jsでAIチャットのUIを作りたい」という方
- React(Hooks)の基礎的な知識がある方(
useState、useEffectの理解程度) - Next.js App Router(
appディレクトリ)の構成に興味がある方 - shadcn/ui を使ったモダンUIの実装例を知りたい方
💡 第1回・第2回をまだ読んでいない方は、先にそちらからご覧ください。
バックエンドのAPI仕様を理解していないと、フロントエンドの「なぜこのAPIを呼ぶのか」が分かりにくくなります。
⚠️ 本リポジトリの位置づけ(第1回・第2回同様)
freeAiChat は、AI と RAG の仕組みを「動かしながら学ぶ」ことを目的としたサンプル・教材です。
以下は意図的に簡略化・未対応です。ご理解の上ご利用ください。
- セキュリティ対策(認証・認可、APIキーの露出防止、入力検証の網羅性など)
- 本番運用を想定した設計(ログ管理、エラーモニタリング、パフォーマンス最適化など)
- エラーハンドリングの完全な網羅
「まず動かして仕組みを理解する」→「その後、必要に応じて本番対応を追加する」 という流れを想定しています。
🖥️ ai-chat-frontend の概要
ai-chat-frontend は、Next.js 15(App Router)+ React 19 + shadcn/ui + Tailwind CSS v4 で構築された、シンプルなチャットUIです。
提供する画面
| 画面 | パス | 役割 |
|---|---|---|
| チャット画面 | / |
ユーザーがAIと対話するメイン画面 |
| 知識ベース管理画面 | /admin |
テキスト・URL・ファイルからナレッジを登録 |
技術スタック
| 技術 | 役割 | バージョン |
|---|---|---|
| Next.js | Reactフレームワーク(App Router) | 15.x |
| React | UIライブラリ | 19.x |
| TypeScript | 型安全な開発 | 5.x |
| Tailwind CSS | ユーティリティCSS | v4 |
| shadcn/ui | ヘッドレスUIコンポーネント(Radix UIベース) | - |
| lucide-react | アイコンライブラリ | - |
🧩 フロントエンド ↔ バックエンドの連携構成
freeAiChat のフロントエンドは、バックエンド(FastAPI)と直接通信せず、Next.jsのAPI Routesを介して通信します。
なぜAPI Routesを介するのか?
-
CORS問題の回避:ブラウザから直接
localhost:8000を呼ぶとCORS制約に引っかかる可能性がある -
環境変数の隠蔽:バックエンドURLは
.env.localで管理し、ブラウザから見えないサーバーサイドで処理 - レスポンス形式の変換:バックエンドのレスポンスをフロントエンド向けに整形できる
⚠️ ただし、本サンプルではAPIキー認証は未実装です。
FASTAPI_API_KEYのコメントアウト部分を参考に、必要に応じて追加してください。
🗂️ ファイル構成
ai-chat-frontend/
├── src/
│ ├── app/
│ │ ├── api/
│ │ │ ├── chat/
│ │ │ │ └── route.ts ← チャットAPIの中継
│ │ │ └── admin/
│ │ │ └── ingest/
│ │ │ ├── text/
│ │ │ │ └── route.ts ← テキスト登録の中継
│ │ │ ├── file/
│ │ │ │ └── route.ts ← ファイル登録の中継
│ │ │ └── url/
│ │ │ └── route.ts ← URL登録の中継
│ │ ├── admin/
│ │ │ └── page.tsx ← 管理画面(クライアントコンポーネント)
│ │ ├── page.tsx ← チャット画面(クライアントコンポーネント)
│ │ ├── layout.tsx ← 共通レイアウト
│ │ └── globals.css ← グローバルCSS
│ ├── components/
│ │ └── ui/ ← shadcn/uiコンポーネント
│ │ ├── avatar.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── input.tsx
│ │ ├── scroll-area.tsx
│ │ ├── textarea.tsx
│ │ ├── tabs.tsx
│ │ ├── switch.tsx
│ │ ├── progress.tsx
│ │ ├── label.tsx
│ │ ├── alert.tsx
│ │ └── badge.tsx
│ └── lib/
│ └── utils.ts ← cn() ユーティリティ
├── .env.local ← 環境変数
├── package.json
└── next.config.js
💬 チャット画面(page.tsx)
チャット画面は React Client Component("use client")として実装されています。
核心部分:状態管理とメッセージ送信
"use client"
import { useState, useRef, useEffect } from "react"
interface Message {
id: string
role: "user" | "assistant"
content: string
timestamp: Date
}
export default function ChatPage() {
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const scrollAreaRef = useRef<HTMLDivElement>(null)
// 新しいメッセージが追加されたら自動スクロール
useEffect(() => {
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight
}
}, [messages])
const sendMessage = async (e: React.FormEvent) => {
e.preventDefault()
if (!input.trim() || isLoading) return
// 1. ユーザーメッセージを画面に追加
const userMessage: Message = {
id: Date.now().toString(),
role: "user",
content: input.trim(),
timestamp: new Date(),
}
setMessages((prev) => [...prev, userMessage])
setInput("")
setIsLoading(true)
setError(null)
try {
// 2. Next.jsのAPI Routeに送信(バックエンドはサーバーサイドで呼ばれる)
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: userMessage.content,
history: messages.map((msg) => ({
role: msg.role,
content: msg.content,
})),
}),
})
if (!response.ok) {
throw new Error(`APIエラー: ${response.status}`)
}
const data = await response.json()
// 3. AIの回答を画面に追加
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: data.response || "申し訳ございませんが、回答を生成できませんでした。",
timestamp: new Date(),
}
setMessages((prev) => [...prev, assistantMessage])
} catch (err) {
setError(err instanceof Error ? err.message : "不明なエラーが発生しました")
} finally {
setIsLoading(false)
}
}
const clearChat = () => {
setMessages([])
setError(null)
}
// ... JSX(後述)
}
ポイント解説
| 実装 | 役割 |
|---|---|
useState<Message[]> |
メッセージ履歴の状態管理 |
useRef<HTMLDivElement> |
スクロール領域への参照(自動スクロール用) |
useEffect(..., [messages]) |
メッセージ追加時に最下部へ自動スクロール |
isLoading |
送信中のUI制御(入力欄無効化・スピナー表示) |
history |
会話の文脈を維持するための過去メッセージ配列 |
💡 本サンプルでは
historyをバックエンドに送信していますが、バックエンド側(app.py)では現状この履歴を使用していません。 将来的な会話コンテキスト対応のための準備としてフロントエンド側に実装しています。
UI部分(JSX)
チャットUIは shadcn/ui のコンポーネントを組み合わせて構築しています。
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<div className="max-w-4xl mx-auto">
<Card className="h-[80vh] flex flex-col shadow-xl">
{/* ヘッダー */}
<CardHeader className="border-b bg-white/50 backdrop-blur-sm">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-xl">
<Bot className="w-6 h-6 text-blue-600" />
カスタムAIチャット
</CardTitle>
<Button variant="outline" size="sm" onClick={clearChat}>
チャットをクリア
</Button>
</div>
</CardHeader>
{/* メッセージ表示エリア */}
<CardContent className="flex-1 p-0 overflow-hidden">
<ScrollArea className="h-full p-4" ref={scrollAreaRef}>
<div className="space-y-4">
{messages.length === 0 && (
<div className="text-center text-gray-500 mt-8">
<Bot className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="text-lg">こんにちは!何かお手伝いできることはありますか?</p>
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={`flex gap-3 ${
message.role === "user" ? "justify-end" : "justify-start"
}`}
>
{message.role === "assistant" && (
<Avatar className="w-8 h-8 border-2 border-blue-200">
<AvatarFallback className="bg-blue-100">
<Bot className="w-4 h-4 text-blue-600" />
</AvatarFallback>
</Avatar>
)}
<div
className={`max-w-[70%] rounded-2xl px-4 py-2 ${
message.role === "user"
? "bg-blue-600 text-white"
: "bg-white border border-gray-200 text-gray-800"
}`}
>
<p className="whitespace-pre-wrap">{message.content}</p>
<p className={`text-xs mt-1 ${
message.role === "user" ? "text-blue-100" : "text-gray-400"
}`}>
{message.timestamp.toLocaleTimeString("ja-JP")}
</p>
</div>
{message.role === "user" && (
<Avatar className="w-8 h-8 border-2 border-blue-200">
<AvatarFallback className="bg-blue-100">
<User className="w-4 h-4 text-blue-600" />
</AvatarFallback>
</Avatar>
)}
</div>
))}
{/* ローディング表示 */}
{isLoading && (
<div className="flex gap-3 justify-start">
<Avatar>...</Avatar>
<div className="bg-white border rounded-2xl px-4 py-2">
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
<span>回答を生成中...</span>
</div>
</div>
)}
</div>
</ScrollArea>
</CardContent>
{/* 入力エリア */}
<div className="border-t bg-white/50 backdrop-blur-sm p-4">
{error && (
<div className="mb-3 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
<strong>エラー:</strong> {error}
</div>
)}
<form onSubmit={sendMessage} className="flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="メッセージを入力してください..."
disabled={isLoading}
maxLength={1000}
className="flex-1 rounded-full"
/>
<Button type="submit" disabled={isLoading || !input.trim()} size="icon" className="rounded-full">
<Send className="w-4 h-4" />
</Button>
</form>
<div className="flex justify-between text-xs text-gray-500 mt-2">
<span>{input.length}/1000</span>
<span>メッセージ数: {messages.length}</span>
</div>
</div>
</Card>
</div>
</div>
)
使用している shadcn/ui コンポーネント
| コンポーネント | 用途 |
|---|---|
Card / CardHeader / CardContent
|
チャット全体の枠組み |
ScrollArea |
メッセージ一覧のスクロール領域 |
Avatar / AvatarFallback
|
ユーザー/AIのアイコン表示 |
Input |
メッセージ入力欄 |
Button |
送信ボタン・クリアボタン |
Tailwind CSS のユーティリティクラスで、グラデーション背景(bg-gradient-to-br)、ガラス風効果(backdrop-blur-sm)、吹き出しの色分けなどを実現しています。
🗄️ 知識ベース管理画面(admin/page.tsx)
管理画面は 3つのタブで構成され、それぞれ異なる登録方法に対応しています。
"use client"
import { useState } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Switch } from "@/components/ui/switch"
import { Progress } from "@/components/ui/progress"
export default function AdminPage() {
// 各タブ用の状態
const [textContent, setTextContent] = useState("")
const [url, setUrl] = useState("")
const [urlChunkSize, setUrlChunkSize] = useState(1000)
const [urlPreprocess, setUrlPreprocess] = useState(true)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
// ... 他の状態
// テキスト登録
const handleTextIngest = async () => {
const response = await fetch("/api/admin/ingest/text", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: textContent }),
})
const result = await response.json()
// 結果表示...
}
// URL登録
const handleUrlIngest = async () => {
const response = await fetch("/api/admin/ingest/url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url,
chunk_size: urlChunkSize,
preprocess: urlPreprocess,
}),
})
// ...
}
// ファイル登録
const handleFileUpload = async () => {
const formData = new FormData()
formData.append("file", selectedFile!)
formData.append("chunk_size", fileChunkSize.toString())
formData.append("preprocess", filePreprocess.toString())
const response = await fetch("/api/admin/ingest/file", {
method: "POST",
body: formData, // Content-Typeは自動設定
})
// ...
}
return (
<Tabs defaultValue="text" className="space-y-6">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="text">テキスト</TabsTrigger>
<TabsTrigger value="url">URL</TabsTrigger>
<TabsTrigger value="file">ファイル</TabsTrigger>
</TabsList>
<TabsContent value="text">...</TabsContent>
<TabsContent value="url">...</TabsContent>
<TabsContent value="file">...</TabsContent>
</Tabs>
)
}
ポイント解説
| 実装 | 役割 |
|---|---|
Tabs / TabsList / TabsTrigger / TabsContent
|
3つの登録方法を切り替えるUI |
Switch |
前処理のON/OFF切り替え |
FormData |
ファイルアップロード時に使用(multipart/form-data自動生成) |
Progress |
ファイルアップロードの進捗表示(UI用、実際の進捗計算は未実装) |
🔌 API Routes(バックエンドへの中継)
Next.js App Router の Route Handler を使い、ブラウザからのリクエストを FastAPI に中継します。
チャット中継(/api/chat/route.ts)
import { type NextRequest, NextResponse } from "next/server"
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// FastAPIエンドポイント
const fastApiUrl = process.env.FASTAPI_URL || "http://localhost:8000"
// フロントエンドのリクエスト形式 → バックエンドの形式に変換
const response = await fetch(`${fastApiUrl}/ask`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
question: body.message, // "message" → "question" に変換
}),
})
if (!response.ok) {
throw new Error(`FastAPI エラー: ${response.status}`)
}
const data = await response.json()
// バックエンドのレスポンス形式 → フロントエンドの形式に変換
return NextResponse.json({
response: data.answer || "回答を取得できませんでした",
})
} catch (error) {
return NextResponse.json(
{ error: "内部サーバーエラー", details: error instanceof Error ? error.message : "不明なエラー" },
{ status: 500 }
)
}
}
ファイルアップロード中継(/api/admin/ingest/file/route.ts)
import { type NextRequest, NextResponse } from "next/server"
export async function POST(request: NextRequest) {
try {
// 1. フロントエンドから FormData を受け取る
const formData = await request.formData()
const file = formData.get("file") as File
if (!file) {
return NextResponse.json({ success: false, message: "ファイルが選択されていません" }, { status: 400 })
}
// 2. ファイルサイズチェック(10MB制限)
if (file.size > 10 * 1024 * 1024) {
return NextResponse.json({ success: false, message: "ファイルサイズが10MBを超えています" }, { status: 400 })
}
// 3. FastAPI 用の FormData を新規作成(そのまま転送)
const fastApiFormData = new FormData()
fastApiFormData.append("file", file)
fastApiFormData.append("chunk_size", formData.get("chunk_size") as string)
fastApiFormData.append("preprocess", formData.get("preprocess") as string)
const fastApiUrl = process.env.FASTAPI_URL || "http://localhost:8000"
const response = await fetch(`${fastApiUrl}/upload/`, {
method: "POST",
body: fastApiFormData, // Content-Type は自動で multipart/form-data に設定される
})
const result = await response.json()
return NextResponse.json({
success: true,
message: `ファイル「${file.name}」が正常に追加されました`,
})
} catch (error) {
return NextResponse.json(
{ success: false, message: "ファイルの処理中にエラーが発生しました" },
{ status: 500 }
)
}
}
中継処理のまとめ
| API Route | フロントエンド入力 | FastAPI出力 | 変換内容 |
|---|---|---|---|
/api/chat |
{ message, history } |
/ask に { question }
|
message → question
|
/api/admin/ingest/text |
{ text } |
/ingest に { text }
|
ほぼそのまま |
/api/admin/ingest/url |
{ url, chunk_size, preprocess } |
/ingest-url に同形式 |
ほぼそのまま |
/api/admin/ingest/file |
FormData |
/upload/ に FormData
|
ほぼそのまま |
⚙️ 環境変数(.env.local)
# FastAPIバックエンドのURL
FASTAPI_URL=http://localhost:8000
# 必要に応じてAPIキー(現状未使用)
# FASTAPI_API_KEY=your-api-key
💡
.env.localは サーバーサイドのみ で読み込まれます。ブラウザの開発者ツールからは見えないため、APIキーなどの機密情報を安全に管理できます。
ただし、本サンプルでは認証自体が未実装なので、APIキーの有無に関わらず誰でもアクセス可能な状態です。
🚀 起動手順(全体の流れ)
# 1. バックエンドを起動(別ターミナル)
# cd ai-chat-backend && uvicorn app:app --reload
# 2. フロントエンドの依存関係をインストール(初回のみ)
cd ai-chat-frontend
npm install
# 3. 環境変数を設定
echo "FASTAPI_URL=http://localhost:8000" > .env.local
# 4. 開発サーバーを起動
npm run dev
# 5. ブラウザで確認
open http://localhost:3000 # チャット画面
open http://localhost:3000/admin # 管理画面
🎨 画面イメージ
① チャット画面
② 知識ベース管理画面
🛡️ エラーハンドリング(サンプル範囲内)
| パターン | 実装箇所 | 対応内容 |
|---|---|---|
| API通信エラー | page.tsx |
try-catch でエラーメッセージを画面に表示 |
| 空入力送信 |
page.tsx / admin/page.tsx
|
!input.trim() で送信ボタンを無効化 |
| ファイル未選択 | file/route.ts |
400 エラーを返す |
| ファイルサイズ超過 | file/route.ts |
10MB 制限、超過時は 400 エラー |
| FastAPI接続失敗 | 各 route.ts
|
500 エラーを返し、コンソールにログ出力 |
未対応のエラーパターン(本番化時に検討)
| パターン | 現状 | 本番化時の対応例 |
|---|---|---|
| ネットワーク切断(オフライン) | 考慮外 | オフライン検出と再試行UI |
| ファイル形式の厳密な検証 |
accept 属性のみ |
MIMEタイプのサーバーサイド検証 |
| 同時アップロードの競合 | 考慮外 | アップロードキューの実装 |
| XSS対策 |
dangerouslySetInnerHTML 未使用 |
入力サニタイズの徹底 |
📚 ソースコード
今回ご紹介したコードの全文は、GitHubで公開しています。
👉 GitHub: https://github.com/8alfalfa8/freeAiChat
本記事がお役に立ちましたら、いいね❤️ や GitHub Star⭐ をいただけると励みになります!
- 📂 目次:【AIチャット(freeAiChat)無料構築】連載の全記事まとめ
- 【第1回】無料で構築できるRAG × マルチLLM対応AIチャットシステムの全体像を紹介
- 【第2回】:FastAPI × LangChain × Weaviate で構築された RAG対応バックエンドを詳細解説
- 【第3回】:React × Next.js × shadcn/ui で構築されたチャットフロントエンドを詳細解説(閲覧中)
- 💡 今後も開発効率化・ツール連携に関する記事を随時追加していきます!
著者をフォローしていただくと、次回掲載の通知を受け取れます!

