Skip to main content

かんたん生成AIアプリ1

難しいことは抜きでコピペで出来ます。

以下のツールを使用して、ストリーミングでAIが回答するチャットアプリを構築します。

note

Next.js × Vercel AI SDK × AWS Bedrock

技術スタック

Next.js

  • Reactベースのフルスタックフレームワーク
  • サーバーサイドレンダリング(SSR)とスタティックサイト生成(SSG)をサポート
  • API Routeを通じてサーバーレス関数を実装可能

Vercel AI SDK

  • AIモデルとの対話を簡単に実装できるツール
  • ストリーミングレスポンスのための最適化された機能を提供
  • 様々なAIプロバイダーとの互換性

AWS Bedrock

  • AWSが提供する生成AIサービス
  • Claude、Stable Diffusion等の様々なモデルを利用可能
  • セキュアな環境でAIモデルを実行

環境構築

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

# プロジェクトの作成
npx create-next-app@latest

create-next-appNext.jsを作成する際の設定

$ npx create-next-app@latest
Need to install the following packages:
create-next-app@15.0.4
Ok to proceed? (y) y

✔ What is your project named? … ai-app ← なんでもいい
✔ Would you like to use TypeScript? … No / Yes ← Yes
✔ Would you like to use ESLint? … No / Yes ← Yes
✔ Would you like to use Tailwind CSS? … No / Yes ← Yes
✔ Would you like to use `src/` directory? … No / Yes ← Yes
✔ Would you like to use App Router? (recommended) … No / Yes ← Yes
✔ Would you like to use Turbopack for next dev? … No / Yes ← Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes ← Yes
✔ What import alias would you like configured? … @/* ← そのままEnter

インストール

# 必要なパッケージのインストール
npm install ai @ai-sdk/amazon-bedrock @aws-sdk/client-bedrock-runtime zod

AWS認証情報の設定

// .env.local
NEXT_AWS_BEDROCK_ACCESS_KEY_ID=your_access_key
NEXT_AWS_BEDROCK_SECRET_ACCESS_KEY=your_secret_key
NEXT_AWS_BEDROCK_REGION=your_region

実装

ホーム画面の実装

ヘッダーの追加

src/app/layout.tsx
import type { Metadata } from "next";
import Link from 'next/link';
import localFont from "next/font/local";
import "./globals.css";

const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});

export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{/* ヘッダー */}
<header className="bg-blue-400 shadow-sm">
<nav className="max-w-4xl mx-auto px-4 py-4">
<div className="flex justify-between items-center">
<div className="font-bold text-xl">My App</div>
<div className="space-x-6">
<Link href="/" className="text-white hover:text-gray-500">
ホーム
</Link>
<Link href="/chat" className="text-white hover:text-gray-500">
チャット
</Link>
<Link href="/object" className="text-white hover:text-gray-500">
オブジェクト
</Link>
</div>
</div>
</nav>
</header>
{children}
</body>
</html>
);
}

簡単なページの作成

src/app/page.tsx
const features = [
{
title: '簡単',
description: 'シンプルで使いやすい',
icon: '🎯',
},
{
title: '高速',
description: '快適な操作性',
icon: '⚡',
},
{
title: '安全',
description: '安心してご利用いただけます',
icon: '🔒',
},
];

export default function Home() {
return (
<div>

{/* メインコンテンツ */}
<main className="max-w-4xl mx-auto px-4 py-12">
{/* 説明セクション */}
<section className="text-center mb-16">
<h1 className="text-4xl font-bold mb-4">
Welcome to My App
</h1>
<p className="text-gray-600 mb-8">
シンプルな生成AIアプリ
</p>
<button className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
何も起きないボタン
</button>
</section>

{/* 特徴セクション */}
<section>
<h2 className="text-2xl font-bold text-center mb-8">特徴</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{features.map((feature, index) => (
<div
key={index}
className="bg-white p-6 rounded-lg border shadow-sm"
>
<div className="text-3xl mb-2">{feature.icon}</div>
<h3 className="font-bold mb-2">{feature.title}</h3>
<p className="text-gray-600">{feature.description}</p>
</div>
))}
</div>
</section>
</main>

</div>
);
}

APIの実装

このAPIの役割は以下です。

  • クライアントからのメッセージを受信
  • AWS Bedrockモデルとの通信
  • ストリーミングレスポンスの生成
src/app/api/chat/route.ts
import { streamText } from 'ai';
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';

const bedrock = createAmazonBedrock({
region: process.env.NEXT_AWS_BEDROCK_REGION ?? '',
accessKeyId: process.env.NEXT_AWS_BEDROCK_ACCESS_KEY_ID ?? '',
secretAccessKey: process.env.NEXT_AWS_BEDROCK_SECRET_ACCESS_KEY ?? '',
});

export async function POST(req: Request) {
const { messages } = await req.json();
const result = await streamText({
model: bedrock('anthropic.claude-3-5-sonnet-20241022-v2:0'), // 生成AIのモデル
system: 'あなたは役に立つアシスタントです。', // AIに行わせたい役割を記載
messages,
});
return result.toDataStreamResponse();
}

フロントエンドの実装

Vercel AI SDKのuseChatフックを使用したシンプルな構成です。 フォームでチャットの入力を行い、メッセージの表示を行います。

src/app/chat/page.tsx
'use client';

import { useChat } from 'ai/react';

export default function Page() {
const { messages, input, handleSubmit, handleInputChange, isLoading } = useChat();

return (
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">生成AIとチャット</h1>
{messages.map(m => (
<div key={m.id} className="whitespace-pre-wrap">
{m.role === 'user' ? 'User: ' : 'AI: '}
{m.content}
</div>
))}
{isLoading && <div className="whitespace-pre-wrap">回答中</div> }
<form onSubmit={handleSubmit} className="fixed bottom-0 w-full max-w-2xl p-2 mb-8 border border-gray-300 rounded-md">
<input
className="w-full p-2 border border-gray-300 rounded"
value={input}
placeholder="メッセージを入力してください..."
onChange={handleInputChange}
/>
</form>
</div>
);
}

改良

東京の天気予報を取得する。

APIの実装

src/app/api/chat/route.ts
import { streamText, tool } from 'ai';
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
import { z } from 'zod';

const bedrock = createAmazonBedrock({
region: process.env.NEXT_AWS_BEDROCK_REGION ?? '',
accessKeyId: process.env.NEXT_AWS_BEDROCK_ACCESS_KEY_ID ?? '',
secretAccessKey: process.env.NEXT_AWS_BEDROCK_SECRET_ACCESS_KEY ?? '',
});

export const maxDuration = 30;

export async function POST(req: Request) {
const { messages } = await req.json();

const result = await streamText({
model: bedrock('anthropic.claude-3-5-sonnet-20241022-v2:0'),
system: `あなたは天気予報を提供するアシスタントです。ユーザーが天気に関する質問をしたら、askForConfirmationツールを使用してユーザーに確認を求めてください。ユーザーが「はい」などの回答をしたら、getWeatherInformationツールを使用して最新の天気予報を取得してください。`,
messages,
tools: {
getWeatherInformation: tool({
description: '東京の天気予報を取得する',
parameters: z.object({
dummy: z.string().optional(),
}),
execute: async () => {
const response = await fetch('https://www.jma.go.jp/bosai/forecast/data/forecast/130000.json');
const data = await response.json();

// 東京地方のデータを抽出
const tokyoArea = data[0].timeSeries[0].areas[0];
const temperatures = data[0].timeSeries[2].areas[0].temps;

return {
forecast: tokyoArea.weatherCodes.map((code: string, index: number) => ({
date: index === 0 ? "今日" : index === 1 ? "明日" : "明後日",
weather: tokyoArea.weathers[index],
wind: tokyoArea.winds[index],
maxTemp: temperatures[index * 2 + 1],
minTemp: temperatures[index * 2],
}))
};
},
}),
askForConfirmation: tool({
description: 'ユーザーに確認を求める',
parameters: z.object({
message: z.string().describe('確認を求めるメッセージ'),
}),
}),
},
});

return result.toDataStreamResponse();
}

フロントエンドの実装

src/app/chat/page.tsx
'use client';

import { ToolInvocation } from 'ai';
import { Message, useChat } from 'ai/react';
import { useState } from 'react';

type WeatherInfo = {
date: string;
weather: string;
maxTemp?: string;
minTemp?: string;
wind: string;
};

const WeatherCard = ({ weather }: { weather: WeatherInfo }) => {
const getWeatherIcon = (weather: string) => {
if (weather.includes('晴れ')) return '☀️';
if (weather.includes('くもり')) return '☁️';
if (weather.includes('雨')) return '🌧️';
return '❓';
};

return (
<div className="bg-white rounded-xl shadow-lg p-6 w-full hover:shadow-xl transition-shadow duration-300">
<div className="text-xl font-bold text-center mb-4 text-gray-800">{weather.date}</div>
<div className="flex flex-col items-center gap-3">
<div className="text-6xl mb-3">{getWeatherIcon(weather.weather)}</div>
<div className="text-md text-gray-600 font-medium">{weather.weather}</div>
{weather.maxTemp && weather.minTemp && (
<div className="flex items-center gap-6 mt-3 bg-gray-50 p-3 rounded-lg w-full justify-center">
<div className="text-red-500 flex flex-col items-center">
<span className="text-sm font-medium">最高気温</span>
<span className="text-2xl font-bold">{weather.maxTemp}°</span>
</div>
<div className="text-blue-500 flex flex-col items-center">
<span className="text-sm font-medium">最低気温</span>
<span className="text-2xl font-bold">{weather.minTemp}°</span>
</div>
</div>
)}
<div className="text-sm text-gray-600 mt-3 flex items-center gap-2 bg-gray-50 p-3 rounded-lg w-full justify-center">
<span className="text-lg">💨</span>
<span className="font-medium">{weather.wind}</span>
</div>
</div>
</div>
);
};

const ChatMessage = ({ message }: { message: Message }) => (
<div className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] p-4 rounded-2xl ${
message.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-800'
}`}>
<div className="font-bold mb-1">
{message.role === 'user' ? 'あなた' : 'AI'}
</div>
<div className="whitespace-pre-wrap">{message.content}</div>
</div>
</div>
);

export default function Page() {
const { messages, input, handleInputChange, handleSubmit, addToolResult, isLoading } = useChat({
api: '/api/chat',
maxSteps: 5,
});
const [isFocused, setIsFocused] = useState(false);

return (
<div className="min-h-screen bg-gray-200">
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="bg-white rounded-2xl shadow-lg p-6 mb-8">
<h1 className="text-3xl font-bold text-center mb-4 text-gray-800">東京の天気予報</h1>
<p className="text-center text-gray-600">
天気を知りたい時は「天気を教えて」と入力してください
</p>
</div>

<div className="space-y-6 mb-24">
{messages.map((m: Message) => (
<div key={m.id} className="space-y-4">
<ChatMessage message={m} />

{'toolInvocations' in m && m.toolInvocations?.map((toolInvocation: ToolInvocation) => {
const toolCallId = toolInvocation.toolCallId;
const addResult = (result: string) =>
addToolResult({ toolCallId, result });

if (toolInvocation.toolName === 'askForConfirmation') {
return (
<div key={toolCallId} className="bg-yellow-50 rounded-xl p-6 shadow-md">
<p className="text-gray-800 mb-4">{toolInvocation.args.message}</p>
{'result' in toolInvocation ? (
<div className="font-bold text-gray-800">{toolInvocation.result}</div>
) : (
<div className="flex gap-3">
<button
onClick={() => addResult('はい')}
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors duration-200 font-medium"
>
はい
</button>
<button
onClick={() => addResult('いいえ')}
className="px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors duration-200 font-medium"
>
いいえ
</button>
</div>
)}
</div>
);
}

if (toolInvocation.toolName === 'getWeatherInformation' && 'result' in toolInvocation) {
return (
<div key={toolCallId} className="mt-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{toolInvocation.result.forecast.map((weather: WeatherInfo, index: number) => (
<WeatherCard key={index} weather={weather} />
))}
</div>
</div>
);
}

return null;
})}
</div>
))}

{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-100 rounded-2xl p-4 max-w-[80%]">
<div className="animate-pulse flex space-x-2">
<div className="h-3 w-3 bg-gray-400 rounded-full"></div>
<div className="h-3 w-3 bg-gray-400 rounded-full"></div>
<div className="h-3 w-3 bg-gray-400 rounded-full"></div>
</div>
</div>
</div>
)}
</div>

<form
onSubmit={handleSubmit}
className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 p-4"
>
<div className="max-w-4xl mx-auto">
<div className={`relative rounded-xl transition-all duration-200 ${
isFocused ? 'ring-2 ring-blue-500' : 'border border-gray-300'
}`}>
<input
className="w-full p-4 rounded-xl focus:outline-none"
value={input}
placeholder="メッセージを入力してください..."
onChange={handleInputChange}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<button
type="submit"
className="absolute right-2 top-1/2 transform -translate-y-1/2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors duration-200"
disabled={isLoading}
>
送信
</button>
</div>
</div>
</form>
</div>
</div>
);
}