チャットインターフェース(前半)
💬 チャットインタフェース(前半)
このレッスンでは、ユーザーとAI Agentが対話するためのチャットインタフェースの前半部分を実装します。
前半では、状態管理、メッセージ表示、API連携を実装します。
後半のレッスンでは、設定画面と友達リスト管理UIを実装します。
📝 実装するファイル(前半部分)
src/components/ChatInterface.tsxファイルを作成し、以下のコードを記述します。
まず、ファイルを作成します:
cd jpyc-ai-agent
mkdir -p src/components
touch src/components/ChatInterface.tsx
以下のコードを記述します:
"use client";
import {
addFriend,
deleteFriend,
deleteProfile,
type Friend,
getFriends,
getProfile,
setProfile,
type UserProfile,
} from "@/lib/storage/localStorage";
import { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
type Message = {
role: "user" | "assistant";
content: string;
timestamp: Date;
};
/**
* ChatInterfaceコンポーネント
* @returns
*/
export default function ChatInterface() {
// 状態管理
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [conversationId, setConversationId] = useState<string | null>(null);
const [currentChainName, setCurrentChainName] = useState<string>("Loading...");
const [profile, setProfileState] = useState<UserProfile | null>(null);
const [friends, setFriendsState] = useState<Friend[]>([]);
const [showSettings, setShowSettings] = useState(false);
const [profileName, setProfileName] = useState("");
const [friendName, setFriendName] = useState("");
const [friendAddress, setFriendAddress] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
// メッセージが更新されたら自動スクロール
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, loading]);
// プロフィールと友達リストを読み込む
useEffect(() => {
setProfileState(getProfile());
setFriendsState(getFriends());
}, []);
// 現在のチェーンを取得
useEffect(() => {
const fetchCurrentChain = async () => {
try {
const response = await fetch("/api/chain");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setCurrentChainName(data.chainName);
} else {
throw new Error(data.error || "Unknown error");
}
} catch (error) {
console.error("Failed to fetch current chain:", error);
setCurrentChainName("Ethereum Sepolia");
}
};
fetchCurrentChain();
const interval = setInterval(fetchCurrentChain, 3000);
return () => clearInterval(interval);
}, [messages]);
// メッセージ送信処理
const sendMessage = async () => {
if (!input.trim()) return;
const userMessage: Message = {
role: "user",
content: input,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInput("");
setLoading(true);
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: input,
conversationId,
profile,
friends,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// ストリーミングレスポンスを処理
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let assistantMessage = "";
// アシスタントメッセージの枠を追加
setMessages((prev) => [
...prev,
{
role: "assistant",
content: "",
timestamp: new Date(),
},
]);
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
assistantMessage += chunk;
// メッセージを更新
setMessages((prev) => {
const newMessages = [...prev];
newMessages[newMessages.length - 1] = {
role: "assistant",
content: assistantMessage,
timestamp: new Date(),
};
return newMessages;
});
}
}
} catch (error) {
setMessages((prev) => [
...prev,
{
role: "assistant",
content: `❌ エラー: ${error instanceof Error ? error.message : "不明なエラー"}`,
timestamp: new Date(),
},
]);
} finally {
setLoading(false);
}
};
// プロフィール保存処理
const handleSaveProfile = async () => {
if (!profileName.trim()) {
alert("名前を入力してください");
return;
}
try {
// サーバーサイドからアドレスを取得
const response = await fetch("/api/address");
const data = await response.json();
if (!data.success) {
throw new Error(data.error);
}
const newProfile = setProfile(profileName, data.address);
setProfileState(newProfile);
setProfileName("");
alert("プロフィールを保存しました");
} catch (error: any) {
alert(`エラー: ${error.message}`);
}
};
// プロフィール削除処理
const handleDeleteProfile = () => {
if (confirm("プロフィールを削除しますか?")) {
deleteProfile();
setProfileState(null);
alert("プロフィールを削除しました");
}
};
// 友達追加処理
const handleAddFriend = () => {
if (!friendName.trim() || !friendAddress.trim()) {
alert("名前とアドレスを入力してください");
return;
}
try {
const newFriend = addFriend(friendName, friendAddress as `0x${string}`);
setFriendsState(getFriends());
setFriendName("");
setFriendAddress("");
alert(`${newFriend.name}を友達リストに追加しました`);
} catch (error: any) {
alert(`エラー: ${error.message}`);
}
};
// 友達削除処理
const handleDeleteFriend = (id: string, name: string) => {
if (confirm(`${name}を友達リストから削除しますか?`)) {
deleteFriend(id);
setFriendsState(getFriends());
alert(`${name}を削除しました`);
}
};
// UIレンダリング(次のレッスンで実装)
return (
<div className="flex flex-col h-screen bg-gray-50">
{/* ヘッダー */}
<div className="bg-white border-b p-4 flex justify-between items-center">
<div>
<h1 className="text-xl font-bold">JPYC AI Agent</h1>
<p className="text-sm text-gray-600">Current Chain: {currentChainName}</p>
</div>
<button
onClick={() => setShowSettings(!showSettings)}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
設定
</button>
</div>
{/* メッセージリスト */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message, index) => (
<div
key={index}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-3xl px-4 py-2 rounded-lg ${
message.role === "user"
? "bg-blue-500 text-white"
: "bg-white border"
}`}
>
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
</div>
))}
{loading && (
<div className="flex justify-start">
<div className="bg-white border px-4 py-2 rounded-lg">
<p className="text-gray-500">...</p>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 入力フォーム */}
<div className="bg-white border-t p-4">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && sendMessage()}
placeholder="メッセージを入力..."
className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={loading}
/>
<button
onClick={sendMessage}
disabled={loading}
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-300"
>
送信
</button>
</div>
</div>
</div>
);
}