1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

freeAiChat【第3回】:React × Next.js × shadcn/ui で構築されたチャットフロントエンドを詳細解説

1
Posted at

🎯 本記事の対象読者

  • 第1回・第2回を読み、freeAiChat の全体像とバックエンドの仕組みを把握済みの方
  • 「Next.jsでAIチャットのUIを作りたい」という方
  • React(Hooks)の基礎的な知識がある方(useStateuseEffect の理解程度)
  • 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 } messagequestion
/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⭐ をいただけると励みになります!


著者をフォローしていただくと、次回掲載の通知を受け取れます!


1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?