VercelのAI SDK + AI Elementsを使って簡易的なAIチャットアプリを作成してみた

VercelのAI SDK + AI Elementsを使って簡易的なAIチャットアプリを作成してみた

2025.09.30

はじめに

最近学習の一環でAIアプリの開発を進めており、その過程でAI Elementsの存在を知りました。
デザイン周りが苦手な方でも、AI Elementsを活用することでリッチなUIが作成可能です。
今回はAI SDKとAI Elementsを使用して簡易的なAIチャットアプリを作成してみたいと思います。
ついでと言っては何ですが、裏側で呼び出すLLMは本日登場したClaude Sonnet 4.5を使用します。

やってみた

今回使用するソースコードは以下のリポジトリに格納しているので適宜確認してください。

https://github.com/sakai-classmethod/ai-elements-sample

プロジェクトセットアップ

まずはじめに以下のコマンドを実行してNext.jsプロジェクトをセットアップします。

			
			pnpm create next-app@latest .

✔ Would you like to use TypeScript? … No / Yes
✔ Which linter would you like to use? › Biome
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like your code inside a `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to use Turbopack? (recommended) … No / Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No / Yes
Creating a new Next.js app in /sandbox/ai-elements-sample.

		

shadcn/uiとAI Elementsの初期設定および追加を行います。

			
			pnpm dlx shadcn@latest init
pnpm dlx shadcn@latest add -a

pnpm dlx ai-elements@latest

		

最後に各種パッケージを追加して準備完了です。

			
			pnpm add ai @ai-sdk/react @ai-sdk/amazon-bedrock next-themes

		

Route Handlerの作成

以下のチュートリアルを参考にRoute Handlerを作成します。

https://ai-sdk.dev/elements/examples/chatbot#server

リージョンはオレゴンで固定し、APIキーを使用する形で定義します。

app/api/chat/route.ts
			
			import { streamText, UIMessage, convertToModelMessages } from "ai";
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock";

export const maxDuration = 30;

export async function POST(req: Request) {
  const {
    messages,
    model,
  }: {
    messages: UIMessage[];
    model: string;
    webSearch: boolean;
  } = await req.json();

  const bedrock = createAmazonBedrock({
    region: "us-west-2",
    apiKey: process.env.BEDROCK_API_KEY,
  });

  const result = streamText({
    model: bedrock(model),
    messages: convertToModelMessages(messages),
    system:
      "You are a helpful and capable assistant. Follow the user instructions carefully and thoroughly. Always respond in the same language that the user is using.",
  });

  return result.toUIMessageStreamResponse({
    sendSources: true,
    sendReasoning: true,
  });
}

		

Pages&Componentsの作成

続けてChat用のページとコンポーネントを作成します。

app/(ai)/chat/chat.tsx
			
			"use client";

import {
  Conversation,
  ConversationContent,
  ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import { Message, MessageContent } from "@/components/ai-elements/message";
import {
  PromptInput,
  PromptInputAttachment,
  PromptInputAttachments,
  PromptInputBody,
  type PromptInputMessage,
  PromptInputModelSelect,
  PromptInputModelSelectContent,
  PromptInputModelSelectItem,
  PromptInputModelSelectTrigger,
  PromptInputModelSelectValue,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputToolbar,
  PromptInputTools,
} from "@/components/ai-elements/prompt-input";
import { Action, Actions } from "@/components/ai-elements/actions";
import { Fragment, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { Response } from "@/components/ai-elements/response";
import {
  Source,
  Sources,
  SourcesContent,
  SourcesTrigger,
} from "@/components/ai-elements/sources";
import {
  Reasoning,
  ReasoningContent,
  ReasoningTrigger,
} from "@/components/ai-elements/reasoning";
import { Loader } from "@/components/ai-elements/loader";
import { CheckIcon, CopyIcon, RefreshCcwIcon } from "lucide-react";
import { bedrockModels } from "../models";

const Chat = () => {
  const [input, setInput] = useState("");
  const [model, setModel] = useState<string>(bedrockModels[0].value);
  const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
  const { messages, sendMessage, status, regenerate } = useChat();

  const handleSubmit = (message: PromptInputMessage) => {
    const hasText = Boolean(message.text);
    const hasAttachments = Boolean(message.files?.length);

    if (!(hasText || hasAttachments)) {
      return;
    }

    sendMessage(
      {
        text: message.text || "Sent with attachments",
        files: message.files,
      },
      {
        body: {
          model: model,
        },
      },
    );
    setInput("");
  };

  return (
    <div className="max-w-4xl mx-auto p-6 relative size-full h-screen">
      <div className="flex flex-col h-full">
        <div className="flex justify-between items-center mb-4">
          <h1 className="text-2xl font-bold">AI Chat</h1>
        </div>
        <Conversation className="h-full">
          <ConversationContent>
            {messages.map((message, messageIndex) => (
              <div key={message.id}>
                {message.role === "assistant" &&
                  message.parts.filter((part) => part.type === "source-url")
                    .length > 0 && (
                    <Sources>
                      <SourcesTrigger
                        count={
                          message.parts.filter(
                            (part) => part.type === "source-url",
                          ).length
                        }
                      />
                      {message.parts
                        .filter((part) => part.type === "source-url")
                        .map((part, i) => (
                          <SourcesContent key={`${message.id}-${i}`}>
                            <Source
                              key={`${message.id}-${i}`}
                              href={part.url}
                              title={part.url}
                            />
                          </SourcesContent>
                        ))}
                    </Sources>
                  )}
                {message.parts.map((part, i) => {
                  switch (part.type) {
                    case "text":
                      return (
                        <Fragment key={`${message.id}-${i}`}>
                          <Message from={message.role}>
                            <MessageContent>
                              <Response>{part.text}</Response>
                            </MessageContent>
                          </Message>
                          {message.role === "assistant" &&
                            messageIndex === messages.length - 1 &&
                            i === message.parts.length - 1 && (
                              <Actions className="mt-2">
                                <Action
                                  onClick={() =>
                                    regenerate({
                                      body: {
                                        model: model,
                                      },
                                    })
                                  }
                                  label="Retry"
                                >
                                  <RefreshCcwIcon className="size-3" />
                                </Action>
                                <Action
                                  onClick={() => {
                                    navigator.clipboard.writeText(part.text);
                                    setCopiedMessageId(message.id);
                                    setTimeout(
                                      () => setCopiedMessageId(null),
                                      2000,
                                    );
                                  }}
                                  label={
                                    copiedMessageId === message.id
                                      ? "Copied!"
                                      : "Copy"
                                  }
                                >
                                  {copiedMessageId === message.id ? (
                                    <CheckIcon className="size-3" />
                                  ) : (
                                    <CopyIcon className="size-3" />
                                  )}
                                </Action>
                              </Actions>
                            )}
                        </Fragment>
                      );
                    case "reasoning":
                      return (
                        <Reasoning
                          key={`${message.id}-${i}`}
                          className="w-full"
                          isStreaming={
                            status === "streaming" &&
                            i === message.parts.length - 1 &&
                            message.id === messages.at(-1)?.id
                          }
                        >
                          <ReasoningTrigger />
                          <ReasoningContent>{part.text}</ReasoningContent>
                        </Reasoning>
                      );
                    default:
                      return null;
                  }
                })}
              </div>
            ))}
            {status === "submitted" && <Loader />}
          </ConversationContent>
          <ConversationScrollButton />
        </Conversation>

        <PromptInput
          onSubmit={handleSubmit}
          className="mt-4"
          globalDrop
          multiple
        >
          <PromptInputBody>
            <PromptInputAttachments>
              {(attachment) => <PromptInputAttachment data={attachment} />}
            </PromptInputAttachments>
            <PromptInputTextarea
              onChange={(e) => setInput(e.target.value)}
              value={input}
              placeholder="メッセージを入力"
            />
          </PromptInputBody>
          <PromptInputToolbar>
            <PromptInputTools>
              <PromptInputModelSelect
                onValueChange={(value) => {
                  setModel(value);
                }}
                value={model}
              >
                <PromptInputModelSelectTrigger>
                  <PromptInputModelSelectValue />
                </PromptInputModelSelectTrigger>
                <PromptInputModelSelectContent>
                  {bedrockModels.map((model) => (
                    <PromptInputModelSelectItem
                      key={model.value}
                      value={model.value}
                    >
                      {model.name}
                    </PromptInputModelSelectItem>
                  ))}
                </PromptInputModelSelectContent>
              </PromptInputModelSelect>
            </PromptInputTools>
            <PromptInputSubmit disabled={!input && !status} status={status} />
          </PromptInputToolbar>
        </PromptInput>
      </div>
    </div>
  );
};

export default Chat;

		
app/(ai)/chat/page.tsx
			
			import Chat from "./chat";

export default function Page() {
  return (
    <div>
      <Chat />
    </div>
  );
}

		

モデル定義は別ファイルに切り出し、BedrockのモデルIDと表示名を定義します。

app/(ai)/models.ts
			
			export const bedrockModels = [
  {
    name: "Anthropic Claude Sonnet 4.5",
    value: "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
  },
  {
    name: "Anthropic Claude Opus 4.1",
    value: "us.anthropic.claude-opus-4-1-20250805-v1:0",
  },
];

		

せっかくなので以下を参考にダークモード対応もしておきます。

https://ui.shadcn.com/docs/dark-mode/next

components/theme-provider.tsx
			
			"use client";

import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";

export function ThemeProvider({
  children,
  ...props
}: React.ComponentProps<typeof NextThemesProvider>) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

		
app/layout.tsx
			
			import type { Metadata } from "next";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { cn } from "@/lib/utils";

export const metadata: Metadata = {
  title: "AI Elements Sample",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja" suppressHydrationWarning>
      <body className={cn("min-h-dvh")}>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

		
components/mode-toggle.tsx
			
			"use client";

import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";

import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

export function ModeToggle() {
  const { setTheme } = useTheme();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
          <Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme("light")}>
          Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>
          Dark
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>
          System
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

		
app/(ai)/chat/chat.tsx
			
			...
import { Loader } from "@/components/ai-elements/loader";
import { CheckIcon, CopyIcon, RefreshCcwIcon } from "lucide-react";
import { bedrockModels } from "../models";
+ import { ModeToggle } from "@/components/mode-toggle";

...
        <div className="flex justify-between items-center mb-4">
          <h1 className="text-2xl font-bold">AI Chat</h1>
+         <ModeToggle />
        </div>

...


		

環境変数の設定

最後に以下を参考にBedrockのAPIキーを発行して環境変数に設定します。

https://docs.aws.amazon.com/ja_jp/bedrock/latest/userguide/api-keys-generate.html#api-keys-generate-console

.env
			
			BEDROCK_API_KEY=your-bedrock-api-key 

		

動作確認

開発サーバーを起動して動作確認をします。

			
			pnpm dev

		

適当にチャットしてみると問題なく回答が返ってきました。

2025-09-30-160

DevToolsを見てみるとClaude Sonnet 4.5が呼び出せていることがわかります。

CleanShot 2025-09-30 at 03.02.37@2x

最後に右上のアイコンからダークモードに切り替えてみます。

2025-09-30-161

問題なく切り替わっていそうですね。

まとめ

今回はAI SDKとAI Elementsを使用してAIチャットアプリを作成しました。
ついでにはなりましたが、早速リリースされたClaude Sonnet 4.5も使用してみました。
今回は使いませんでしたが、VercelのAI Gatewayも良さそうなので今度使ってみたいと思います。
どなたかの参考になれば幸いです。

この記事をシェアする

FacebookHatena blogX

関連記事

VercelのAI SDK + AI Elementsを使って簡易的なAIチャットアプリを作成してみた | DevelopersIO