Chat Widget AI Chúng Tôi Gắn Vào Một Site Tĩnh (Và Thứ Hỏng Đầu Tiên)
Thêm chat widget AI vào landing site tĩnh: vì sao cần proxy, vì sao session_key không phải danh tính, và vì sao vai system xoá mất sản phẩm.
Trong bài này
Ngày 15/07/2026, chỉ vài tiếng sau khi xong hai ngày dồn sức làm email analytics, tôi ship một thứ nhìn nhỏ hơn hẳn trên giấy: một khung chat nổi trên chính trang landing của mình. Khách vào site giờ gõ câu hỏi và nhận trả lời từ một agent, thay vì phải đọc hết bài blog hay điền form để lại thông tin. Nghe như buổi chiều nối một cái nút với một API. Thực tế mất gần trọn một ngày, và gần hết thời gian đó không nằm ở cái nút.
Trang landing là site tĩnh — build bằng Vite, deploy lên Cloudflare Pages. Backend agent là GoClaw, cùng hệ thống đang chạy con bot vận hành nội bộ trên Discord (agent Discord hỗ trợ store của chúng tôi cùng họ với nó). Riêng hai điểm đó đã loại bỏ phương án ngây thơ nhất.
Vì sao site tĩnh không thể gọi thẳng agent
Cách làm hiển nhiên nhất: widget gọi thẳng webhook URL của agent từ trình duyệt. Một lệnh fetch, không cần backend. Nhưng đó cũng là đường thẳng tới việc secret của webhook nằm ngay trong bundle JS — ai mở devtools cũng đọc được, rồi gọi agent nhân danh chúng tôi, tốn tiền chúng tôi, không rate limit nào chúng tôi kiểm soát được.
Nên buộc phải có một chặng server ở giữa. Site tĩnh thì "server" ở đây là một Cloudflare Pages Function — functions/api/chat.js, một hàm onRequestPost duy nhất đọc biến môi trường của Pages (nơi secret thực sự nằm, set qua wrangler secret hoặc dashboard) rồi forward request đi. Chạy local thì Pages Functions không chạy dưới vite dev, nên tôi viết thêm một middleware Vite bắt /api/chat riêng cho dev. Cả hai cùng gọi một file, scripts/chat-proxy-core.mjs. Tôi tách file này ra ngay từ đầu, không phải vì lo xa — mà vì sau khi viết logic validate một lần cho production, tôi không muốn viết lại lần hai hơi khác cho dev rồi để hai bên âm thầm lệch nhau. Đó là kiểu lỗi chỉ lộ ra khi có khách thật gặp đúng cái chỉ hỏng ở production.
Cái endpoint nhìn đúng mà không đúng
GoClaw có route tương thích OpenAI, /v1/chat/completions — thứ tôi thử trước tiên vì mọi tool LLM đều biết gọi kiểu này. Nó không dùng được cho việc này: endpoint đó mở session mới mỗi request và chỉ đọc tin cuối trong mảng, nên tin nhắn thứ hai của khách tới nơi mà không còn nhớ gì tin đầu. Ổn cho một lần hoàn thành, sai cho một cuộc hội thoại.
Cách sửa là hạ xuống dùng webhook gốc của GoClaw, /v1/webhooks/llm, và truyền một session_key mà cả hai chặng proxy (dev lẫn prod) chuyển nguyên vẹn:
body: JSON.stringify({
input: buildInput(message, payload?.lang),
session_key: sessionId,
user_id: sessionId,
mode: 'sync'
})
Widget sinh key đó một lần mỗi tab trình duyệt bằng crypto.randomUUID(), gắn tiền tố web-, và giữ trong sessionStorage chứ không phải localStorage — một phiên nên sống qua khi khách đổi trang, không nên theo khách về nhà. Có một ca ngoại lệ Safari chế độ riêng tư tôi chỉ bắt được lúc test: private browsing có thể ném lỗi ngay tại sessionStorage.setItem, và nếu vậy widget vẫn chat được, chỉ mất mạch hội thoại khi reload thay vì crash.
Một ID do client sinh ra không phải là danh tính
session_key được sinh trong trình duyệt, nghĩa là proxy nhận một chuỗi mà nó không tự chọn. Nếu proxy cứ forward nguyên những gì tới, một khách có thể tự tạo session_key trùng với session của một agent nội bộ và đọc hoặc can thiệp vào một hội thoại không phải của họ. Cách vá là kiểm tra khuôn dạng thật chặt trước khi bất cứ thứ gì chạm tới upstream:
const SESSION_ID_RE = /^web-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
Cái gì không khớp khuôn web-<uuid-v4> bị trả 400 ngay, chưa đi đâu gần GoClaw cả. Chỉ năm dòng code, nhưng đó là ranh giới giữa "khách có hội thoại riêng, cô lập" và "khách gõ được vào hội thoại của người khác."
Cái vai "system" đã xoá sạch sản phẩm
Widget cần trả lời đúng ngôn ngữ khách đang đọc — tiếng Anh hay tiếng Việt, tuỳ site đang render bản nào. Cách sách vở để báo điều đó cho một chat model là gửi kèm một tin nhắn vai system bên cạnh lượt của user. Tôi đã thử. Agent quay lại tin chắc StoreFleet là một shop bán lẻ, không phải công cụ chúng tôi xây để vận hành cùng lúc nhiều store Shopify. Gửi một entry vai system trong mảng input của GoClaw không cộng thêm vào system prompt gốc của agent — nó ghi đè hẳn, và mất theo đó toàn bộ kiến thức sản phẩm agent vẫn có.
Cách chạy được đơn giản hơn: chèn một chỉ dẫn dạng text thường vào ngay đầu tin nhắn của user trước khi gửi đi — (Trả lời hoàn toàn bằng tiếng Việt.) hoặc bản tiếng Anh tương ứng — để nó đi cùng như một thứ agent đọc được, chứ không phải một lệnh thay đổi việc agent nghĩ nó là ai.
Tôi dừng ở đâu
Cả việc này ship ra 8 file và 636 dòng, và một phần ba trong số đó là validate input chẳng liên quan gì tới giao diện chat: giới hạn 2000 ký tự mỗi tin, timeout 90 giây cho upstream trước khi proxy bỏ cuộc và trả lỗi "thử lại", và hoàn toàn không có header CORS, vì widget chỉ bao giờ gọi cùng origin — không có lý do gì để site khác gọi được endpoint này, nên tôi không để cửa mở sẵn.
Chẳng phần nào trong đó nhìn ấn tượng. Nhưng đó lại là phần lớn của việc thực sự cần để đặt một agent biết trò chuyện trước lưu lượng ẩn danh từ internet một cách có trách nhiệm. Phần demo được — cái bong bóng mở ra, chữ chạy, nhìn đẹp — chắc mất hai tiếng. Phần trình duyệt của một người lạ nói chuyện với một agent thật, có thể đốt tiền API thật, và tôi phải tự chứng minh nó không thể bị gán hội thoại của người khác hay bị lừa quên mất nó là gì — đó là phần chiếm hết thời gian còn lại của ngày. Nếu bạn đang xếp "thêm chat widget AI" vào roadmap như một việc nhỏ, hãy tính luôn phần thứ hai; nó không phải tuỳ chọn và không hiện lên trong ảnh chụp màn hình. Đó cũng đúng bài học mà AI customer support đã dạy chúng tôi ở phía Discord — lỗi thú vị nhất chưa bao giờ nằm ở nội dung câu trả lời.