応用編② - aw-furuta/ai-chat-app GitHub Wiki
※あくまでもイメージなのでこの通りでなくてもOK
- ユーザーが右側、AIを左側としそれぞれの投稿だと分かるようにする
- 投稿にはアイコンも付ける
- 投稿時間も合わせて表示させる
上記完成イメージのソースコード
'use client';
import { useChat } from 'ai/react';
import { useRef, useState } from 'react';
import { Paperclip, Send, X } from 'lucide-react';
const UserIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-10 h-10 text-gray-500"
>
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
);
const AIBotIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 64 64"
fill="currentColor"
className="w-10 h-10 text-gray-500"
>
<rect x="14" y="14" width="36" height="36" rx="8" ry="8" fill="#ccc" />
<circle cx="22" cy="26" r="4" fill="#000" />
<circle cx="42" cy="26" r="4" fill="#000" />
<rect x="24" y="36" width="16" height="4" fill="#000" />
<rect x="28" y="44" width="8" height="4" fill="#000" />
<rect x="30" y="10" width="4" height="4" fill="#000" />
<rect x="10" y="30" width="4" height="4" fill="#000" />
<rect x="50" y="30" width="4" height="4" fill="#000" />
</svg>
);
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat();
const [files, setFiles] = useState<FileList | undefined>(undefined);
const fileInputRef = useRef<HTMLInputElement>(null);
// FileListをArrayに変換するヘルパー関数
const fileListToArray = (fileList: FileList | undefined) => {
if (!fileList) return [];
return Array.from(fileList);
};
// ファイルを削除する関数
const removeFile = (indexToRemove: number) => {
if (!files) return;
const newFiles = fileListToArray(files).filter((_, index) => index !== indexToRemove);
const dataTransfer = new DataTransfer();
newFiles.forEach(file => dataTransfer.items.add(file));
setFiles(dataTransfer.files);
if (fileInputRef.current) {
fileInputRef.current.files = dataTransfer.files;
}
};
// メッセージを解析してコードブロックとテキストに分割する関数
const parseMessageContent = (content: string) => {
const regex = /(```\w+\n[\s\S]+?```)/g;
const parts = content.split(regex);
return parts.map(part => {
const match = part.match(/```(\w+)\n([\s\S]+?)```/);
if (match) {
return { type: 'code', language: match[1], code: match[2] };
}
return { type: 'text', text: part };
});
};
// 日付フォーマット関数
const formatDate = (date: Date) => {
return `${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}`;
};
return (
<div className="flex flex-col w-full h-full max-w-3xl mx-auto">
<div className="flex-1 overflow-y-auto py-4 px-4">
{messages.map(m => (
<div key={m.id} className={`mb-4 flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
{m.role !== 'user' && (
<div className="mr-2">
<AIBotIcon />
</div>
)}
<div className="flex items-end">
{m.role === 'user' && (
<div className="text-xs text-gray-400 mr-2">
{m.createdAt ? formatDate(new Date(m.createdAt)) : 'Invalid date'}
</div>
)}
<div className={`whitespace-pre-wrap rounded-lg p-3 ${m.role === 'user' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-black'}`}>
{parseMessageContent(m.content).map((part, index) => {
return <span key={index}>{part.text}</span>;
})}
</div>
{m.role !== 'user' && (
<div className="text-xs text-gray-400 ml-2">
{m.createdAt ? formatDate(new Date(m.createdAt)) : 'Invalid date'}
</div>
)}
</div>
{m.role === 'user' && (
<div className="ml-2">
<UserIcon />
</div>
)}
<div className="mt-2">
{m?.experimental_attachments
?.filter(attachment =>
attachment?.contentType?.startsWith('image/'),
)
.map((attachment, index) => (
<img
key={`${m.id}-${index}`}
src={attachment.url}
width={300}
height={300}
alt={attachment.name ?? `attachment-${index}`}
className="rounded-lg"
/>
))}
</div>
</div>
))}
</div>
<div className="border-t bg-white p-4">
{/* 添付ファイルのプレビュー */}
{files && files.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{fileListToArray(files).map((file, index) => (
<div
key={index}
className="relative group rounded-lg border border-gray-200 p-2 pr-8"
>
<div className="text-sm text-gray-600 max-w-[200px] truncate">
{file.name}
</div>
<button
type="button"
onClick={() => removeFile(index)}
className="absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-gray-100"
>
<X className="h-4 w-4 text-gray-500" />
</button>
</div>
))}
</div>
)}
<form
className="flex items-end gap-2"
onSubmit={event => {
handleSubmit(event, {
experimental_attachments: files,
});
setFiles(undefined);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}}
>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip className="h-5 w-5 text-gray-500" />
</button>
<input
type="file"
className="hidden"
onChange={event => {
if (event.target.files) {
setFiles(event.target.files);
}
}}
multiple
ref={fileInputRef}
accept="image/*"
/>
<div className="relative flex-1">
<input
className="w-full rounded-full border border-gray-300 py-2 pl-4 pr-10 focus:border-gray-400 focus:outline-none"
value={input}
placeholder="質問を入力してください"
onChange={handleInputChange}
/>
<button
type="submit"
className="absolute right-1 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full bg-black text-white hover:bg-gray-800"
>
<Send className="h-4 w-4" />
</button>
</div>
</form>
</div>
</div>
);
}