
VercelのAI SDK + AI Elementsを使って簡易的なAIチャットアプリを作成してみた
はじめに
最近学習の一環でAIアプリの開発を進めており、その過程でAI Elementsの存在を知りました。
デザイン周りが苦手な方でも、AI Elementsを活用することでリッチなUIが作成可能です。
今回はAI SDKとAI Elementsを使用して簡易的なAIチャットアプリを作成してみたいと思います。
ついでと言っては何ですが、裏側で呼び出すLLMは本日登場したClaude Sonnet 4.5を使用します。
やってみた
今回使用するソースコードは以下のリポジトリに格納しているので適宜確認してください。
プロジェクトセットアップ
まずはじめに以下のコマンドを実行して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
を作成します。
リージョンはオレゴンで固定し、APIキーを使用する形で定義します。
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用のページとコンポーネントを作成します。
"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;
import Chat from "./chat";
export default function Page() {
return (
<div>
<Chat />
</div>
);
}
モデル定義は別ファイルに切り出し、BedrockのモデルIDと表示名を定義します。
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",
},
];
せっかくなので以下を参考にダークモード対応もしておきます。
"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>;
}
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>
);
}
"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>
);
}
...
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キーを発行して環境変数に設定します。
BEDROCK_API_KEY=your-bedrock-api-key
動作確認
開発サーバーを起動して動作確認をします。
pnpm dev
適当にチャットしてみると問題なく回答が返ってきました。
DevToolsを見てみるとClaude Sonnet 4.5
が呼び出せていることがわかります。
最後に右上のアイコンからダークモードに切り替えてみます。
問題なく切り替わっていそうですね。
まとめ
今回はAI SDKとAI Elementsを使用してAIチャットアプリを作成しました。
ついでにはなりましたが、早速リリースされたClaude Sonnet 4.5
も使用してみました。
今回は使いませんでしたが、VercelのAI Gatewayも良さそうなので今度使ってみたいと思います。
どなたかの参考になれば幸いです。