Building Chat UI — AI Web

Membangun Chat UI Chat interface adalah pola UI paling umum untuk AI. Terlihat sederhana, tapi banyak detail UX yang perlu diperhatikan. Komponen Utama…

Chat UI untuk AI adalah daftar pesan yang disimpan di state: tiap kali pengguna mengirim, pesannya langsung muncul, lalu pesan asisten tumbuh potongan demi potongan dari stream. Bayangin WhatsApp. Bubble-mu muncul seketika dengan ikon jam (masih mengirim), lalu muncul "sedang mengetik...", lalu balasannya. Kamu bisa scroll ke atas membaca chat lama tanpa ditarik paksa ke bawah. Pekerjaan utamanya ada dua: mengelola state percakapan, dan menampilkan streaming response dengan rapi.

🧪 Tebakan cepat: di dalam callback stream kamu menulis setMessages([...messages.slice(0, -1), { ...terakhir, text: terakhir.text + delta }]), memakai messages dari render saat tombol Kirim ditekan. Setelah stream selesai, layar menampilkan jawaban utuh, atau cuma potongan terakhir? Tebak dulu, jawabannya ada di bagian "State: daftar pesan".

State: daftar pesan

Asisten Toko Buku menyimpan percakapan sebagai array object:

const [messages, setMessages] = useState([]); // { id, role, text, status }
const [streaming, setStreaming] = useState(false);

// ubah satu pesan: selalu dari state terbaru (prev), selalu object baru
const ubah = (id, fn) => setMessages((prev) => prev.map((m) => (m.id === id ? fn(m) : m)));

Kirim: pesan pengguna muncul duluan

Optimistic update artinya UI menampilkan hasil yang diharapkan sebelum server menjawab. Bubble pengguna dan bubble asisten yang masih kosong ditambahkan seketika, baru request-nya dikirim:

async function kirim(teks) {
  const user = { id: crypto.randomUUID(), role: "user", text: teks, status: "sending" };
  const asisten = { id: crypto.randomUUID(), role: "assistant", text: "", status: "streaming" };
  setMessages((prev) => [...prev, user, asisten]); // muncul di frame berikutnya
  await jalankanStream([...messages, user], asisten.id, user.id);
}

Jalannya di Lab untuk "Stok Laskar Pelangi masih ada?" (server stand-in, jeda awal 350 ms, satu potongan 24 byte tiap 60 ms):

Waktu Yang tampil
0 ms bubble pengguna dengan "mengirim...", bubble asisten "sedang menulis..."
sekitar 350 ms potongan pertama datang: status pengguna jadi terkirim
sekitar 410 ms delta pertama utuh, **Laskar, muncul
sekitar 890 ms 9 potongan, 8 delta: "Laskar Pelangi masih ada, stoknya 12. Harganya Rp89.000." status done

Streaming token demi token

Bagian membaca stream memakai fungsi bacaSSE dari pelajaran Streaming Responses (satu TextDecoder dengan { stream: true } dan buffer sampai baris kosong). Server Toko Buku mengirim data: {"teks":"..."} per potongan:

const kontrol = useRef(null);

async function jalankanStream(riwayat, asistenId, userId) {
  kontrol.current = new AbortController();
  setStreaming(true);
  try {
    const res = await fetch("/api/assistant/stream", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ messages: riwayat.map(({ role, text }) => ({ role, content: text })) }),
      signal: kontrol.current.signal,
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    ubah(userId, (m) => ({ ...m, status: "sent" }));
    await bacaSSE(res.body, (ev) => ubah(asistenId, (m) => ({ ...m, text: m.text + ev.teks })));
    ubah(asistenId, (m) => ({ ...m, status: "done" }));
  } catch (err) {
    ubah(asistenId, (m) => ({ ...m, status: err.name === "AbortError" ? "stopped" : "error" }));
  } finally {
    setStreaming(false);
  }
}

Stop dan Coba lagi

Tombol Stop memanggil kontrol.current.abort(). reader.read() menolak dengan AbortError, statusnya jadi "stopped", dan teks yang sudah datang tetap ada. Seperti di pelajaran Streaming, server ikut membatalkan request ke OpenAI saat koneksinya tertutup.

Kalau koneksi putus, bubble asisten menampilkan tombol Coba lagi. Coba lagi mengirim ulang riwayat yang sama dan mengganti bubble asisten yang gagal, bukan menambah bubble pengguna kedua:

function cobaLagi(asistenId) {
  const i = messages.findIndex((m) => m.id === asistenId);
  const user = messages[i - 1];
  ubah(asistenId, (m) => ({ ...m, text: "", status: "streaming" }));
  jalankanStream(messages.slice(0, i), asistenId, user.id);
}

Auto-scroll yang gak melawan pengguna

Pesan baru sebaiknya terlihat, tapi pengguna yang sedang membaca chat lama gak boleh ditarik ke bawah tiap ada token. Jadi scroll otomatis cuma kalau posisinya memang sudah dekat dasar:

const daftar = useRef(null);
const nempel = useRef(true); // apakah pengguna ada di dekat dasar

function onScroll(e) {
  const el = e.currentTarget;
  nempel.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}

useEffect(() => {
  if (nempel.current) daftar.current.scrollTop = daftar.current.scrollHeight;
}, [messages]);

Kalau pengguna sedang di atas, tampilkan tombol kecil "Pesan baru" yang membawanya ke bawah saat dia mau.

Menampilkan teks model dengan aman

Jawaban model adalah teks dari luar. Lewat prompt injection, misalnya ulasan pembeli yang dikutip asisten, isinya bisa memuat <img src=x onerror=alert(1)>. Tiga cara menampilkan:

Satu hal soal streaming: di tengah stream, markdown sering belum lengkap, misalnya **Laskar yang tanda tutupnya belum datang. Renderer yang baik menampilkannya sebagai teks dulu sampai pasangannya lengkap.

Aksesibilitas: diumumkan sekali, bukan per token

Screen reader membacakan perubahan di live region, elemen dengan aria-live. Pasang aria-live="polite" di daftar pesan, supaya pesan baru dibacakan tanpa memotong yang sedang dibaca. Tapi kalau tiap token diumumkan, pengguna mendengar potongan kata yang terputus-putus. MDN menyarankan aria-busy="true" selama elemen masih berubah, lalu false saat selesai, supaya teknologi bantu menunggu sampai isinya lengkap:

<div ref={daftar} onScroll={onScroll} aria-live="polite" aria-busy={streaming}>
  {messages.map((m) => <Bubble key={m.id} pesan={m} />)}
</div>

Tombol Stop dan Coba lagi harus <button> yang bisa dicapai keyboard, dan kolom ketik punya label.

⚠️ Jebakan yang sering ketemu

TL;DR: Chat UI = array pesan { id, role, text, status } di state, diubah dengan setMessages((prev) => ...) dan object baru. Kirim secara optimistic: bubble pengguna dan bubble asisten kosong muncul dulu, lalu stream dibaca dengan bacaSSE dan tiap delta ditambahkan ke pesan asisten. Stop memakai AbortController (status stopped, teks tetap ada); koneksi putus jadi status error dengan Coba lagi yang mengganti bubble asisten. Auto-scroll cuma kalau pengguna di dekat dasar. Teks model ditampilkan sebagai teks biasa, react-markdown, atau HTML yang dibersihkan DOMPurify. Daftar pesan pakai aria-live="polite" dan aria-busy selama streaming, supaya diumumkan sekali, bukan per token.

Yang akan kamu pelajari