企业向け開発におけるNext.jsとFastAPIによるフロントエンドとバックエンドの分離実装

構築する効率的なWebインターフェース

Posted by LuochuanAD on June 18, 2026 本文总阅读量

背景

以前に作成したAIAgentのWebページはすべてHtml+Css+Jsで構築しており、見た目が悪いだけでなく、Agentの会話管理やマルチユーザー状態管理が非常に複雑で非効率でした。

選択

Next.jsを使用して効率的なUIを構築し、Next.jsとFastAPIを組み合わせてAgentのフロントエンドとバックエンドのサービス分離を実現しました。

以下はNext.jsで構築したAgentのUIページです:

next.jsコード例(シンプルで基本的なため説明は省略します)

app/page.tsx

"use client";

import { useState, useRef, useEffect } from "react";

type Msg = {
  role: "user" | "assistant";
  content: string;
  image?: string;
};

export default function ChatPage() {
  const [selectedImage, setSelectedImage] = useState<File | null>(null);

  const fileInputRef = useRef<HTMLInputElement | null>(null);
  const [messages, setMessages] = useState<Msg[]>([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);

  const bottomRef = useRef<HTMLDivElement | null>(null);

  // 自動で下部までスクロール
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  function fileToBase64(file: File): Promise<string> {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();

      reader.readAsDataURL(file);

      reader.onload = () => {
        resolve(reader.result as string);
      };

      reader.onerror = reject;
    });
  }

  function startVoiceInput() {
    const SpeechRecognition =
      (window as any).SpeechRecognition ||
      (window as any).webkitSpeechRecognition;

    if (!SpeechRecognition) {
      alert("このブラウザは音声認識をサポートしていません");
      return;
    }

    const recognition = new SpeechRecognition();

    recognition.lang = "ja-JP";
    recognition.interimResults = false;

    recognition.onresult = (event: any) => {
      const transcript = event.results[0][0].transcript;

      setInput((prev) =>
        prev ? prev + " " + transcript : transcript
      );
    };

    recognition.start();
  }

  async function send() {
    if (!input.trim() && !selectedImage) return;

    const userMsg = input;

    let imageBase64 = "";

    if (selectedImage) {
      imageBase64 = await fileToBase64(selectedImage);
    }

    setInput("");

    setMessages((prev) => [
      ...prev,
      {
        role: "user",
        content: userMsg,
        image: imageBase64,
      },
      {
        role: "assistant",
        content: "",
      },
    ]);

    setSelectedImage(null);

    setLoading(true);

    const res = await fetch(
      "/api/chat",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          message: userMsg,
          image: imageBase64,
        }),
      }
    );

    const reader = res.body?.getReader();
    const decoder = new TextDecoder("utf-8");

    let buffer = "";

    while (true) {
      const { value, done } = await reader!.read();

      if (done) break;

      buffer += decoder.decode(value, {
        stream: true,
      });

      const parts = buffer.split("\n\n");

      buffer = parts.pop() || "";

      for (const part of parts) {
        if (!part.startsWith("data: ")) continue;

        const text = part.replace("data: ", "");

        if (text === "[DONE]") {
          setLoading(false);
          return;
        }

        setMessages((prev) => {
          const copy = [...prev];
          copy[copy.length - 1].content += text;
          return copy;
        });
      }
    }

    setLoading(false);
  }

  return (
    <div className="h-screen bg-zinc-950 text-white flex flex-col">
      {/* Header */}
      <div className="h-14 flex items-center justify-center border-b border-zinc-800">
        <div className="text-sm text-zinc-300">AI Agent Chat</div>
      </div>

      {/* Messages */}
      <div className="flex-1 overflow-y-auto px-4 py-6">
        <div className="max-w-3xl mx-auto space-y-6">
          {messages.length === 0 && (
            <div className="text-center text-zinc-500 mt-20">
              会話を始めましょう...
            </div>
          )}

          {messages.map((msg, i) => (
            <div
              key={i}
              className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"
                }`}
            >
              <div
                className={`px-4 py-3 rounded-2xl text-sm leading-relaxed whitespace-pre-wrap max-w-[80%] ${msg.role === "user"
                  ? "bg-blue-600 text-white"
                  : "bg-zinc-800 text-zinc-100"
                  }`}
              >
                <>
                  {msg.image && (
                    <img
                      src={msg.image}
                      alt="upload"
                      className="rounded-xl mb-2 max-w-xs"
                    />
                  )}

                  {msg.content}
                </>
              </div>
            </div>
          ))}

          <div ref={bottomRef} />
        </div>
      </div>

      {/* Input */}
      <div className="border-t border-zinc-800 p-4">
        <div className="max-w-3xl mx-auto flex gap-2 items-center">
          <button
            onClick={() => fileInputRef.current?.click()}
            className="px-3 py-3 rounded-xl bg-zinc-800 hover:bg-zinc-700"
          >
            📷
          </button>
          <input
            ref={fileInputRef}
            type="file"
            accept="image/*"
            hidden
            onChange={(e) => {
              const file = e.target.files?.[0];

              if (file) {
                setSelectedImage(file);
              }
            }}
          />
          <button
            onClick={startVoiceInput}
            className="px-3 py-3 rounded-xl bg-zinc-800 hover:bg-zinc-700"
          >
            🎤
          </button>
          {selectedImage && (
            <div className="max-w-3xl mx-auto mb-2">
              <img
                src={URL.createObjectURL(selectedImage)}
                alt="preview"
                className="h-24 rounded-lg"
              />
            </div>
          )}
          <input
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter" && !e.shiftKey) {
                e.preventDefault();
                send();
              }
            }}
            placeholder="メッセージ..."
            className="flex-1 bg-zinc-900 text-white px-4 py-3 rounded-xl outline-none border border-zinc-700 focus:border-zinc-500"
          />

          <button
            onClick={send}
            disabled={loading}
            className="px-4 py-3 rounded-xl bg-white text-black font-medium hover:bg-zinc-200 disabled:opacity-50"
          >
            {loading ? "..." : "送信"}
          </button>
        </div>
      </div>
    </div>
  );
}