応用編② - aw-furuta/ai-chat-app GitHub Wiki

チャットをLINE風のUIにしてみる

完成イメージ

※あくまでもイメージなのでこの通りでなくてもOK

 2025-03-22 13 51 06

やること

  • ユーザーが右側、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>
  );
}
⚠️ **GitHub.com Fallback** ⚠️