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.
| Phần | Trách nhiệm |
|---|---|
| MessageScroller | Vùng cuộn: bám đáy, nút tin mới, tải tin cũ, live region, phím End. |
| MessageBubble | Mộ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. |
| MessageDaySeparator | Mốc ngày giữa các tin — vẫn là chữ đọc được, không phải role=separator. |
| TypingIndicator | Ba 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ống | Hành vi |
|---|---|
| Đang ở đáy, có tin mới / chữ stream | Bá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ới | Khô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 tin | Luôn xuống đáy — họ cần thấy tin của mình đã đi. |
| Chạm đầu danh sách | Gọ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ím | Focus 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ểu | Mặc định | Mô tả |
|---|---|---|
| onLoadOlder() => void | Promise<void> | Mặc định: — | Trả Promise để không bị gọi lặp khi đang tải. |
| hasOlder / loadingOlderboolean | Mặc định: — | Hết lịch sử thì tắt; spinner ở đầu danh sách. |
| bottomThresholdnumber | Mặc định: 48 | Cò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.
- 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.
- 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 đọc | Cá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 stream | Chưa đọc | Đọc một lần khi streaming chuyển sang false. |
| Tin của chính mình | Không đọc | Người dùng vừa gõ nó. |
| Tin cũ tải thêm | Không đọc | Không phải tin mới. |
| Đang soạn | “Sakura Bot đang soạn tin…” | TypingIndicator role=status. |
| Gửi lỗi | Thông báo lỗi | role=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ím | Hành động |
|---|---|
| Enter | Gửi (khi có nội dung, không busy). |
| Shift+Enter | Xuống dòng. |
| Đang gõ Telex / VNI / IME | Enter chỉ xác nhận chữ đang ghép — không gửi (kiểm tra isComposing). |
| Ô soạn tự giãn | Tố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.
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)
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”.StreamingMarkdownnhậ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. TrongMessageBubble streamingđặtcaret={false}.[1]trong Markdown +citationsthành chipCitation;CitationListliệt kê nguồn bên dưới.PromptInputthayChatComposerkhi 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
- 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 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.