SCALE — Build Lab
UI部品 · REACT COMPONENT

AIチャットインターフェイス

CATEGORYUI部品 TYPEReact Component EFFORT240〜600分 DIFFICULTY
PRIMARY CODE
tsx
'use client';
import { useState, useRef, useEffect } from 'react';

type Msg = { role: 'user' | 'assistant'; content: string };

// AI チャット UI(ストリーミング対応)
export function AIChat({
  onSend,
}: {
  onSend: (msgs: Msg[]) => AsyncIterable<string>;  // SSE / fetch streaming
}) {
  const [msgs, setMsgs] = useState<Msg[]>([]);
  const [input, setInput] = useState('');
  const [streaming, setStreaming] = useState(false);
  const scrollRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
  }, [msgs]);

  const send = async () => {
    if (!input.trim() || streaming) return;
    const userMsg: Msg = { role: 'user', content: input.trim() };
    const next = [...msgs, userMsg, { role: 'assistant' as const, content: '' }];
    setMsgs(next);
    setInput('');
    setStreaming(true);
    try {
      let acc = '';
      for await (const chunk of onSend([...msgs, userMsg])) {
        acc += chunk;
        setMsgs((m) => [...m.slice(0, -1), { role: 'assistant', content: acc }]);
      }
    } finally {
      setStreaming(false);
    }
  };

  return (
    <div className="flex flex-col h-[600px] bg-zinc-900 rounded-2xl border border-zinc-800">
      <div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-3">
        {msgs.map((m, i) => (
          <div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
            <div className={`max-w-[80%] rounded-2xl px-4 py-2 ${m.role === 'user' ? 'bg-indigo-500 text-white' : 'bg-zinc-800 text-zinc-100'}`}>
              <pre className="whitespace-pre-wrap font-sans text-sm">{m.content || (streaming && i === msgs.length - 1 ? '...' : '')}</pre>
            </div>
          </div>
        ))}
      </div>
      <div className="border-t border-zinc-800 p-3 flex gap-2">
        <input value={input} onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
          disabled={streaming}
          placeholder="メッセージを入力..."
          className="flex-1 bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500" />
        <button onClick={send} disabled={streaming || !input.trim()}
          className="px-4 py-2 rounded-xl bg-indigo-500 text-white disabled:opacity-50">送信</button>
      </div>
    </div>
  );
}
前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • AI秘書
  • カスタマーサポートチャット

AIチャットインターフェイス

:LiTarget: 用途

AIアシスタント用のチャットUI。ストリーミング表示対応。

:LiSparkle: 特徴

  • ストリーミング表示
  • 履歴管理
  • Markdown対応
  • コードハイライト

:LiCode: コード(コピペ用)

'use client';
import { useState, useRef, useEffect } from 'react';

type Msg = { role: 'user' | 'assistant'; content: string };

// AI チャット UI(ストリーミング対応)
export function AIChat({
  onSend,
}: {
  onSend: (msgs: Msg[]) => AsyncIterable<string>;  // SSE / fetch streaming
}) {
  const [msgs, setMsgs] = useState<Msg[]>([]);
  const [input, setInput] = useState('');
  const [streaming, setStreaming] = useState(false);
  const scrollRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
  }, [msgs]);

  const send = async () => {
    if (!input.trim() || streaming) return;
    const userMsg: Msg = { role: 'user', content: input.trim() };
    const next = [...msgs, userMsg, { role: 'assistant' as const, content: '' }];
    setMsgs(next);
    setInput('');
    setStreaming(true);
    try {
      let acc = '';
      for await (const chunk of onSend([...msgs, userMsg])) {
        acc += chunk;
        setMsgs((m) => [...m.slice(0, -1), { role: 'assistant', content: acc }]);
      }
    } finally {
      setStreaming(false);
    }
  };

  return (
    <div className="flex flex-col h-[600px] bg-zinc-900 rounded-2xl border border-zinc-800">
      <div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-3">
        {msgs.map((m, i) => (
          <div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
            <div className={`max-w-[80%] rounded-2xl px-4 py-2 ${m.role === 'user' ? 'bg-indigo-500 text-white' : 'bg-zinc-800 text-zinc-100'}`}>
              <pre className="whitespace-pre-wrap font-sans text-sm">{m.content || (streaming && i === msgs.length - 1 ? '...' : '')}</pre>
            </div>
          </div>
        ))}
      </div>
      <div className="border-t border-zinc-800 p-3 flex gap-2">
        <input value={input} onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
          disabled={streaming}
          placeholder="メッセージを入力..."
          className="flex-1 bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500" />
        <button onClick={send} disabled={streaming || !input.trim()}
          className="px-4 py-2 rounded-xl bg-indigo-500 text-white disabled:opacity-50">送信</button>
      </div>
    </div>
  );
}

:LiHandPointer: 使い方

対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。

:LiAlertCircle: 注意事項

  • 依存パッケージを忘れず追加