Yugen UI

Giao diện hội thoại & AI

Quy tắc cho màn chat và trợ lý AI: cuộn sao cho không cướp mắt người đọc, stream chữ mà không làm phiền trình đọc màn hình, lỗi có đường thử lại, và luôn cho thấy AI đang làm gì.

Cấu trúc màn hội thoại

Một màn chat trong Yugen UI ghép từ năm phần, mỗi phần giữ đúng một trách nhiệm.

Hôm nay
Sakura Bot
Chào bạn! Mình có thể giúp gì?
Bạn
Đổi brand sang màu xanh thế nào?
Đã xem
Sakura Bot đang soạn tin…
PhầnTrách nhiệm
MessageScrollerVùng cuộn: bám đáy, nút tin mới, tải tin cũ, live region, phím End.
MessageBubbleMột tin: phía (đến / đi), người gửi, giờ, trạng thái gửi, cảm xúc, stream. Tự gộp nhóm theo người gửi.
MessageDaySeparatorMốc ngày giữa các tin — vẫn là chữ đọc được, không phải role=separator.
TypingIndicatorBa chấm “đang soạn” trước khi có nội dung; role=status.
ChatComposerÔ soạn tự giãn: Enter gửi, Shift+Enter xuống dòng, busy + nút Dừng cho AI.

Quy tắc cuộn

Người dùng đang đọc ở đâu thì giữ họ ở đó. Chỉ tự cuộn khi chắc chắn họ muốn thấy tin mới nhất.

Tình huốngHành vi
Đang ở đáy, có tin mới / chữ streamBám đáy — nội dung mới luôn trong tầm nhìn (ResizeObserver theo dõi cả ảnh tải xong, composer giãn).
Đã cuộn lên, có tin mớiKhông nhảy. Hiện nút nổi “N tin nhắn mới ↓”; bấm để xuống, số đếm về 0 khi chạm đáy.
Chính người dùng gửi tinLuôn xuống đáy — họ cần thấy tin của mình đã đi.
Chạm đầu danh sáchGọi onLoadOlder; tin cũ chèn lên trên mà điểm đang đọc không xê dịch (tự bù scrollTop, tắt overflow-anchor).
Bàn phímFocus vùng tin rồi nhấn End để nhảy tới tin mới nhất; mũi tên / PageUp / PageDown cuộn như thường.
<MessageScroller
  className="h-[60vh]"
  onLoadOlder={loadOlder}      // chạm đầu danh sách → tải thêm, giữ nguyên vị trí đọc
  hasOlder={cursor !== null}
  loadingOlder={isLoading}
>
  {messages.map((m) => (
    <MessageBubble key={m.id} side={m.mine ? "outgoing" : "incoming"} author={m.author} time={m.time}>
      {m.text}
    </MessageBubble>
  ))}
</MessageScroller>

Mỗi phần tử con cần key ổn định (id tin nhắn): MessageScroller so sánh key đầu / cuối giữa các lần render để phân biệt tin cũ được chèn lên đầu với tin mới thêm vào cuối.

Prop · KiểuMặc địnhMô tả
onLoadOlder() => void | Promise<void>Mặc định: —Trả Promise để không bị gọi lặp khi đang tải.
hasOlder / loadingOlderbooleanMặc định: —Hết lịch sử thì tắt; spinner ở đầu danh sách.
bottomThresholdnumberMặc định: 48Còn cách đáy bao nhiêu px vẫn coi là “đang ở cuối”.

Stream & chỉ báo đang soạn

Phản hồi AI đến theo từng mẩu. Giao diện phải cho thấy tiến triển mà không giật bố cục, không đọc lắp bắp cho trình đọc màn hình.

Bạn
Làm sao đổi brand?
  • Hiện TypingIndicator ngay sau khi gửi — trước token đầu tiên — để người dùng biết yêu cầu đã được nhận.
  • Token đầu tiên về: thay TypingIndicator bằng MessageBubble streaming ở đúng vị trí đó.
  • Con trỏ nhấp nháy báo “còn tiếp”; ở chế độ giảm chuyển động nó đứng yên.
  • Cho phép dừng: ChatComposer busy onStop. Câu trả lời dở dang vẫn giữ lại, kèm ghi chú “Đã dừng”.
// 1. Đã gửi, chưa có token nào → TypingIndicator
{waiting && <TypingIndicator key="typing" author="Sakura Bot" />}

// 2. Token đầu tiên về → thay bằng bong bóng streaming (cùng vị trí, không nhảy bố cục)
<MessageBubble key={answer.id} author="Sakura Bot" streaming={!answer.done}>
  {answer.text}
</MessageBubble>

// 3. Xong → streaming={false}: con trỏ biến mất, live region đọc toàn bộ câu trả lời một lần
// Trong lúc chờ: <ChatComposer busy onStop={abortController.abort} … />

Lỗi & thử lại

Lỗi gửi thuộc về chính tin nhắn đó — không phải một toast trôi qua.

Bạn
Gửi mình bảng giá mới nhé.
Đã xem
Cần trước thứ Sáu.
  • Giữ nguyên nội dung tin lỗi; viền và thông báo đỏ kèm icon — không chỉ đổi màu.
  • Nút “Thử lại” ngay dưới tin; thông báo lỗi dùng role=alert để được đọc ngay.
  • Lỗi của AI (hết hạn mức, bị chặn nội dung) là một tin đến bình thường, giải thích lý do và việc có thể làm.
  • Mất kết nối toàn cục → MessageBar phía trên danh sách, không lặp lỗi trên từng tin.

Live region & trình đọc màn hình

role=log mặc định đọc mọi thay đổi — với chữ stream, điều đó nghĩa là đọc từng mẩu vài từ. Yugen tách việc hiển thị và việc thông báo.

<div role="log" aria-label="Cuộc trò chuyện" aria-live="off" tabIndex={0}>
  …tin nhắn…
</div>
{/* Vùng đọc riêng: chỉ nhận tin đến đã hoàn chỉnh */}
<div aria-live="polite" aria-atomic="true" className="sr-only">Sakura Bot: Đặt data-brand…</div>
Sự kiệnĐược đọcCách
Tin đến hoàn chỉnh“Tên người gửi: nội dung”Vùng polite riêng của MessageScroller.
Tin đang streamChưa đọcĐọc một lần khi streaming chuyển sang false.
Tin của chính mìnhKhông đọcNgười dùng vừa gõ nó.
Tin cũ tải thêmKhông đọcKhông phải tin mới.
Đang soạn“Sakura Bot đang soạn tin…”TypingIndicator role=status.
Gửi lỗiThông báo lỗirole=alert dưới tin.

Vùng tin có tabIndex=0 để người dùng bàn phím cuộn được, và có tên (label). Trạng thái gửi là icon kèm chữ ẩn (“Đã xem”), không chỉ là màu.

Bàn phím trong ô soạn

Theo thói quen của mọi ứng dụng chat phổ biến.

PhímHành động
EnterGửi (khi có nội dung, không busy).
Shift+EnterXuống dòng.
Đang gõ Telex / VNI / IMEEnter chỉ xác nhận chữ đang ghép — không gửi (kiểm tra isComposing).
Ô soạn tự giãnTối đa maxRows dòng (mặc định 6) rồi cuộn bên trong.
Sau khi gửiÔ soạn được xoá và giữ focus để gõ tiếp.

Minh bạch với Trace

Trợ lý AI làm nhiều bước ngầm: tìm kiếm, gọi công cụ, đọc tài liệu. Đặt một Trace thu gọn trong câu trả lời để người dùng kiểm chứng được.

Bạn
Tóm tắt giúp mình 3 email mới nhất của khách hàng.
Sakura Bot

Đã hoàn tất 3 bước

Đang chờ

Thời lượng: 4.2s
  • Tìm email từ khách hàng, Hoàn tất
    820ms
    mail.search
  • Đọc 3 email, Hoàn tất
    1.1s
    mail.readsong song
  • Viết tóm tắt, Hoàn tất
    2.3s
    1.4k token
1. Công ty Hoa Đào cần báo giá gói Team trước 25/10. 2. Anh Minh báo lỗi đăng nhập trên Safari. 3. Chị Lan muốn dời buổi demo sang thứ Năm.

Trace trong tin nhắn nên ở size="small", dùng appearance="plain" cho bong bóng chứa nó, và đặt trước câu trả lời. Xem thêm trang Minh bạch tiến trình với Trace.

Câu trả lời AI: Markdown, nguồn, suy luận

Bộ AI Kit ghép với MessageScroller để dựng câu trả lời đầy đủ: suy luận thu gọn, Markdown đang stream, chip nguồn và ô soạn có đính kèm.

Kiểm tra quy tắc brand và ngưỡng tương phản WCAG.

Dùng brand cho hành động chính và trạng thái chọn; chữ cần tương phản ≥ 4.5:1.

  • Không phủ brand lên nền lớn
  • Luôn kèm icon + chữ cho trạng thái

Nguồn (2)

  1. 1fluent2.microsoft.designFluent 2 — Color (mở tab mới)
  2. 2w3.orgWCAG 2.2 — Contrast (Minimum) (mở tab mới)
  • Reasoning đứng trước câu trả lời: mở khi model đang suy nghĩ, tự thu lại kèm “Đã suy luận trong 12s”.
  • StreamingMarkdown nhận chuỗi đang dài dần: không lộ ** hay fence dở dang, khối đã xong không render lại, link chỉ http / https / mailto. Trong MessageBubble streaming đặt caret={false}.
  • [1] trong Markdown + citations thành chip Citation; CitationList liệt kê nguồn bên dưới.
  • PromptInput thay ChatComposer khi cần đính kèm (nút, kéo thả, dán ảnh), chọn model và nút Dừng.

Nên & không nên

Nên
  • Bám đáy chỉ khi người dùng đang ở đáy; còn lại dùng nút “tin nhắn mới”.
  • Hiện chỉ báo đang soạn ngay khi gửi, và nút Dừng cho câu trả lời dài.
  • Gắn lỗi và nút thử lại vào đúng tin nhắn thất bại.
  • Cho thấy các bước agent đã làm bằng Trace, mặc định thu gọn.
Không nên
  • Không giật danh sách xuống đáy khi người dùng đang đọc tin cũ.
  • Không để trình đọc màn hình đọc từng mẩu chữ đang stream.
  • Không xoá tin lỗi hay nội dung ô soạn khi gửi thất bại.
  • Không giấu việc AI đã gọi công cụ hay đọc dữ liệu của người dùng.