Files
ai-agent/internal/web/supportchat/assets/chat.js
T

1374 lines
50 KiB
JavaScript

(() => {
"use strict";
const params = new URLSearchParams(window.location.search);
const byId = (id) => document.getElementById(id);
const dom = {
app: byId("support-app"),
title: byId("channel-title"),
subtitle: byId("channel-subtitle"),
statusDot: byId("header-status-dot"),
connectionBadge: byId("connection-badge"),
connectionLabel: byId("connection-label"),
retry: byId("retry-button"),
close: byId("close-button"),
scroller: byId("message-scroller"),
loadMore: byId("load-more"),
loading: byId("loading-state"),
empty: byId("empty-state"),
list: byId("message-list"),
status: byId("status-bar"),
queueStatus: byId("queue-status"),
queueTitle: byId("queue-title"),
queuePosition: byId("queue-position"),
queueDetail: byId("queue-detail"),
queueWait: byId("queue-wait"),
queueEta: byId("queue-eta"),
quickSection: byId("quick-section"),
quickButton: byId("quick-button"),
input: byId("message-input"),
imageInput: byId("image-input"),
fileInput: byId("file-input"),
imageButton: byId("image-button"),
fileButton: byId("file-button"),
pendingUploads: byId("pending-uploads"),
send: byId("send-button"),
quickOverlay: byId("quick-overlay"),
quickClose: byId("quick-close"),
quickList: byId("quick-list"),
quickEmpty: byId("quick-empty"),
closeOverlay: byId("close-overlay"),
closeDialogX: byId("close-dialog-x"),
continueButton: byId("continue-button"),
confirmClose: byId("confirm-close-button"),
toasts: byId("toast-region"),
};
const channelId = (params.get("channel_id") || "").trim();
const apiBase = getApiBase();
const accessTarget = getAccessTarget();
const identity = getIdentity();
const state = {
conversation: null,
messages: [],
cursor: "",
hasMore: false,
loadingOlder: false,
sending: false,
closing: false,
initializing: false,
initialized: false,
quickActions: [],
socket: null,
reconnectTimer: null,
pingTimer: null,
reconnectAttempt: 0,
allowReconnect: true,
pendingUploads: [],
queueRefreshTimer: null,
queueTickTimer: null,
queueSyncedAt: 0,
initialScrollSettling: false,
chatBinding: accessTarget ? loadChatBinding(accessTarget) : "",
};
class CustomerSessionRefreshError extends Error {}
function getApiBase() {
const explicit = (params.get("api_base_url") || "").trim();
if (explicit) return explicit.replace(/\/$/, "");
const marker = "/support/chat";
const index = window.location.pathname.indexOf(marker);
const prefix = index >= 0 ? window.location.pathname.slice(0, index) : "";
return `${prefix}/api`;
}
function getIdentity() {
let external_id = (params.get("external_id") || "").trim();
let external_name = (params.get("external_name") || "").trim();
const userId = (params.get("user_id") || "").trim();
if (!external_id && userId) {
external_id = `mall_user:${userId}`;
if (!external_name) external_name = `商城用户 ${userId}`;
}
if (!external_id) {
const storageKey = "agent_desk_guest_id";
external_id = localStorage.getItem(storageKey) || `guest_${randomId()}`;
localStorage.setItem(storageKey, external_id);
if (!external_name) external_name = `访客${external_id.slice(-8)}`;
}
return {
external_id,
external_name: external_name || "访客",
userId,
};
}
function getAccessTarget() {
const fragment = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const fragmentType = (fragment.get("identity_type") || "").trim().toLowerCase();
const fragmentNumber = (fragment.get("identity") || "").trim();
const legacyCard = (params.get("card_no") || "").trim();
const legacyDevice = (params.get("device_no") || "").trim();
let target = null;
if ((fragmentType === "card" || fragmentType === "device") && validAccessNumber(fragmentNumber)) {
target = { type: fragmentType, number: fragmentNumber };
} else if (validAccessNumber(legacyCard)) {
target = { type: "card", number: legacyCard };
} else if (validAccessNumber(legacyDevice)) {
target = { type: "device", number: legacyDevice };
}
if (target) {
writeSessionStorage(accessTargetStorageKey(), JSON.stringify(target));
return target;
}
try {
const stored = JSON.parse(readSessionStorage(accessTargetStorageKey()) || "null");
if (stored && (stored.type === "card" || stored.type === "device") && validAccessNumber(stored.number)) {
return { type: stored.type, number: String(stored.number).trim() };
}
} catch (_) {
// Invalid per-tab state is ignored and cannot become an identity proof.
}
return null;
}
function validAccessNumber(value) {
const text = String(value || "").trim();
return text.length > 0 && text.length <= 128 && !/[\u0000-\u001f\u007f]/.test(text);
}
function accessTargetStorageKey() {
return `agent_desk_support_target:${window.location.pathname}:${channelId}`;
}
function chatBindingStorageKey(target) {
return `agent_desk_support_binding:${window.location.pathname}:${channelId}:${target.type}:${target.number}`;
}
function loadChatBinding(target) {
return String(readSessionStorage(chatBindingStorageKey(target)) || "").trim();
}
function readSessionStorage(key) {
try {
return window.sessionStorage.getItem(key);
} catch (_) {
return null;
}
}
function writeSessionStorage(key, value) {
try {
window.sessionStorage.setItem(key, value);
} catch (_) {
// Private browsing may disable session storage; the chat entry can be
// established again on the next navigation.
}
}
function removeSessionStorage(key) {
try {
window.sessionStorage.removeItem(key);
} catch (_) {
// Ignore inaccessible browser storage.
}
}
function randomId() {
if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID().replace(/-/g, "");
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
}
function lastItem(items) {
return Array.isArray(items) && items.length ? items[items.length - 1] : undefined;
}
function replaceChildrenCompat(element, children) {
while (element.firstChild) element.removeChild(element.firstChild);
(children || []).forEach((child) => element.appendChild(child));
}
function clientMessageId(prefix = "support_chat") {
return `${prefix}_${Date.now()}_${randomId().slice(0, 8)}`;
}
async function request(path, options = {}) {
const headers = new Headers(options.headers || {});
headers.set("X-Channel-Id", channelId);
headers.set("X-External-Id", identity.external_id);
headers.set("X-External-Name", encodeURIComponent(identity.external_name));
headers.set("Accept", "application/json");
if (accessTarget) {
headers.set("X-H5-Chat-Session", "required");
if (state.chatBinding) headers.set("X-H5-Chat-Binding", state.chatBinding);
}
if (options.body && !(options.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
let response;
try {
response = await fetch(`${apiBase}${path}`, Object.assign({}, options, {
headers,
cache: "no-store",
credentials: "include",
}));
} catch (_) {
throw new Error("网络连接失败,请检查网络后重试");
}
let payload;
try {
payload = await response.json();
} catch (_) {
throw new Error("客服服务暂时不可用,请稍后重试");
}
if (!response.ok || (payload && payload.ok === false) || (payload && payload.success === false)) {
const value = payload && (payload.msg || payload.message || payload.code);
if (accessTarget && isCustomerSessionError(value, response.status)) {
clearChatBinding();
window.location.reload();
throw new CustomerSessionRefreshError("正在重新连接客服");
}
throw new Error(chineseError(value));
}
return Object.prototype.hasOwnProperty.call(payload || {}, "data") ? payload.data : payload;
}
function isCustomerSessionError(value, status) {
const text = String(value || "");
return status === 401 || /unauthorized|身份验证|二次验证|重新验证|验证已失效/i.test(text);
}
function targetQuery(target = accessTarget) {
const query = new URLSearchParams();
if (target && target.type === "card") query.set("card_no", target.number);
if (target && target.type === "device") query.set("device_no", target.number);
return query;
}
function h5AccessURL(path) {
const url = new URL(`/h5api${path}`, window.location.origin);
const query = targetQuery();
query.forEach((value, key) => url.searchParams.set(key, value));
return url;
}
async function h5AccessRequest(path, options = {}) {
let response;
try {
response = await fetch(h5AccessURL(path), Object.assign({}, options, {
cache: "no-store",
credentials: "include",
headers: Object.assign({ Accept: "application/json" }, options.headers || {}),
}));
} catch (_) {
throw new Error("网络连接失败,请检查网络后重试");
}
let payload;
try {
payload = await response.json();
} catch (_) {
throw new Error("客服入口暂时不可用,请稍后重试");
}
if (!response.ok || (payload && payload.ok === false) || (payload && payload.success === false)) {
throw new Error(chineseError(payload && (payload.msg || payload.message || payload.code)));
}
return Object.prototype.hasOwnProperty.call(payload || {}, "data") ? payload.data : payload;
}
async function establishCustomerChatSession() {
if (!accessTarget) return false;
try {
const entry = await h5AccessRequest("/access/chat-entry", {
method: "POST",
body: "{}",
headers: { "Content-Type": "application/json" },
});
const ticket = String((entry && entry.ticket) || "").trim();
const binding = String((entry && entry.session_binding) || "").trim();
if (!ticket || !binding) throw new Error("客服入口无效,请重试");
state.chatBinding = binding;
writeSessionStorage(chatBindingStorageKey(accessTarget), binding);
const destination = new URL(window.location.href);
destination.searchParams.delete("card_no");
destination.searchParams.delete("device_no");
destination.searchParams.delete("external_id");
destination.searchParams.delete("external_name");
destination.searchParams.set("access_ticket", ticket);
destination.hash = "";
window.location.replace(destination.toString());
return true;
} catch (error) {
clearChatBinding();
fatal(error instanceof Error ? error.message : "客服入口建立失败,请重试");
return false;
}
}
function clearChatBinding() {
state.chatBinding = "";
if (accessTarget) removeSessionStorage(chatBindingStorageKey(accessTarget));
}
function clearCustomerAccessState() {
clearChatBinding();
removeSessionStorage(accessTargetStorageKey());
}
function chineseError(value) {
const text = String(value || "");
if (/forbidden|unauthorized|auth/i.test(text)) return "身份验证失败,请重新进入客服页面";
if (/channel/i.test(text)) return "客服渠道无效或已停用";
if (/conversation/i.test(text)) return "当前会话不可用,请刷新后重试";
if (/file|upload|asset/i.test(text)) return "文件上传失败,请稍后重试";
if (/timeout/i.test(text)) return "请求超时,请稍后重试";
if (/^[\u4e00-\u9fa5\s,。!?、:;()【】“”‘’_-]+$/.test(text) && text.length < 80) return text;
return "操作未完成,请稍后重试或联系人工客服";
}
async function init() {
if (state.initializing) return;
if (!channelId) {
fatal("缺少客服渠道参数 channel_id,请从正确的客服入口进入");
return;
}
if (accessTarget && !state.chatBinding) {
state.initializing = true;
updateConnectionStatus("connecting");
await establishCustomerChatSession();
state.initializing = false;
return;
}
state.initializing = true;
updateConnectionStatus("connecting");
try {
const config = await request(`/channel/config?channel_id=${encodeURIComponent(channelId)}`);
applyConfig(config || {});
state.conversation = await request("/conversation/create_or_match", { method: "POST", body: "{}" });
state.queueSyncedAt = Date.now();
await Promise.all([loadMessages(true), loadQuickActions()]);
dom.loading.hidden = true;
dom.app.setAttribute("aria-busy", "false");
state.initialized = true;
state.allowReconnect = true;
updateAvailability();
connectSocket();
} catch (error) {
if (error instanceof CustomerSessionRefreshError) {
state.initialized = false;
return;
}
fatal(error instanceof Error ? error.message : "客服服务暂时不可用,请稍后重试");
} finally {
state.initializing = false;
}
}
function applyConfig(config) {
const title = config.title || "在线客服";
const subtitle = config.subtitle || "欢迎咨询";
dom.title.textContent = title;
dom.subtitle.textContent = subtitle;
document.title = title;
const color = /^#[0-9a-f]{6}$/i.test(config.theme_color || "") ? config.theme_color : "#2475fc";
document.documentElement.style.setProperty("--theme", color);
const rgb = color.match(/[0-9a-f]{2}/gi).map((item) => parseInt(item, 16)).join(", ");
document.documentElement.style.setProperty("--theme-rgb", rgb);
document.querySelector('meta[name="theme-color"]').setAttribute("content", color);
}
async function loadMessages(initial = false) {
if (!state.conversation || !state.conversation.id) return;
const result = await request(`/message/list?conversation_id=${state.conversation.id}&limit=50`);
const incoming = result && Array.isArray(result.results) ? result.results : [];
state.cursor = (result && result.cursor) || "";
state.hasMore = Boolean(result && result.has_more) || incoming.length >= 50;
state.messages = mergeMessages(state.messages, incoming);
if (initial) state.initialScrollSettling = true;
renderMessages();
dom.loading.hidden = true;
dom.empty.hidden = state.messages.length > 0;
dom.loadMore.hidden = !state.hasMore;
if (initial) settleInitialScroll();
await markLatestRead();
}
async function loadOlder() {
if (!state.hasMore || state.loadingOlder || !state.cursor || !state.conversation || !state.conversation.id) return;
state.loadingOlder = true;
dom.loadMore.disabled = true;
dom.loadMore.textContent = "正在加载…";
const oldHeight = dom.scroller.scrollHeight;
try {
const result = await request(`/message/list?conversation_id=${state.conversation.id}&limit=50&cursor=${encodeURIComponent(state.cursor)}`);
state.cursor = (result && result.cursor) || "";
state.hasMore = Boolean(result && result.has_more);
state.messages = mergeMessages((result && result.results) || [], state.messages);
renderMessages();
requestAnimationFrame(() => {
dom.scroller.scrollTop = dom.scroller.scrollHeight - oldHeight;
});
} catch (error) {
toast(error instanceof Error ? error.message : "加载更早的消息失败", true);
} finally {
state.loadingOlder = false;
dom.loadMore.disabled = false;
dom.loadMore.textContent = "加载更早的消息";
dom.loadMore.hidden = !state.hasMore;
}
}
function mergeMessages(first, second) {
const map = new Map();
[first, second].forEach((group) => (group || []).forEach((item) => {
if (item && item.id != null) map.set(String(item.id), item);
}));
return Array.from(map.values()).sort((a, b) => Number(a.id) - Number(b.id));
}
function renderMessages() {
const fragment = document.createDocumentFragment();
state.messages.forEach((message, index) => {
const previous = index > 0 ? state.messages[index - 1] : null;
if (!previous || dayKey(previous.sent_at) !== dayKey(message.sent_at)) {
const timeline = document.createElement("div");
timeline.className = "timeline";
const label = document.createElement("span");
label.textContent = timelineLabel(message.sent_at);
timeline.append(label);
fragment.append(timeline);
}
fragment.append(createMessageElement(message));
});
replaceChildrenCompat(dom.list, [fragment]);
dom.empty.hidden = state.messages.length > 0;
}
function createMessageElement(message) {
const sender = String(message.sender_type || "customer");
const mine = sender === "customer";
const system = sender === "system";
const row = document.createElement("article");
row.className = `message-row ${sender}${mine ? " mine" : ""}${system ? " system" : ""}`;
const copy = document.createElement("div");
copy.className = "message-copy";
const meta = document.createElement("div");
meta.className = "message-meta";
const name = document.createElement("span");
name.className = "message-sender";
name.textContent = senderName(message);
const time = document.createElement("time");
time.textContent = formatDateTime(message.sent_at);
meta.append(name, time);
if (mine && !system) {
const read = document.createElement("span");
read.textContent = message.agent_read ? "客服已读" : "客服未读";
meta.append(read);
}
const bubble = document.createElement("div");
bubble.className = "message-bubble";
renderMessageBody(bubble, message);
copy.append(meta, bubble);
if (system) row.append(copy);
else if (mine) row.append(copy);
else row.append(avatar(message), copy);
return row;
}
function avatar(message) {
const node = document.createElement("span");
node.className = "message-avatar";
const name = senderName(message);
const fallback = (name || "客服").slice(0, 1).toUpperCase();
if (message.sender_avatar) {
const image = document.createElement("img");
image.src = message.sender_avatar;
image.alt = "";
image.addEventListener("error", () => {
image.remove();
node.textContent = fallback;
}, { once: true });
node.append(image);
return node;
}
node.textContent = fallback;
return node;
}
function senderName(message) {
if (message.sender_type === "customer") return "我";
if (message.sender_name) return message.sender_name;
if (message.sender_type === "ai") return "AI 客服";
if (message.sender_type === "agent") return "客服";
if (message.sender_type === "system") return "系统消息";
return "客服";
}
function renderMessageBody(container, message) {
if (message.recalled_at || Number(message.send_status) === 6) {
container.textContent = "该消息已撤回";
return;
}
const type = String(message.message_type || "text").toLowerCase();
const payload = parsePayload(message.payload);
if (type === "image") {
const assets = Array.isArray(payload.assets) && payload.assets.length ? payload.assets : [payload];
const gallery = document.createElement("div");
gallery.className = `message-image-grid count-${Math.min(assets.length, 9)}`;
assets.slice(0, 9).forEach((asset) => {
const image = document.createElement("img");
image.src = (asset && asset.url) || "";
image.alt = (asset && asset.filename) || "聊天图片";
image.loading = "eager";
image.addEventListener("load", () => {
if (state.initialScrollSettling) scrollToBottom(false);
});
image.addEventListener("error", () => {
if (state.initialScrollSettling) scrollToBottom(false);
});
image.addEventListener("click", () => window.open(image.src, "_blank", "noopener"));
gallery.append(image);
});
container.append(gallery);
if (String(message.content || "").trim()) {
const caption = document.createElement("div");
caption.className = "message-image-caption";
caption.append(sanitizeHtml(message.content || ""));
container.append(caption);
}
return;
}
if (type === "attachment") {
const link = document.createElement("a");
link.className = "attachment-card";
link.href = payload.url || message.content || "#";
link.target = "_blank";
link.rel = "noopener";
link.innerHTML = '<span class="attachment-icon"><svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg></span>';
const text = document.createElement("span");
text.className = "attachment-copy";
const strong = document.createElement("strong");
strong.textContent = payload.filename || message.content || "查看附件";
const small = document.createElement("small");
small.textContent = formatBytes(payload.file_size);
text.append(strong, small);
link.append(text);
container.append(link);
return;
}
if (type === "html") {
container.append(sanitizeHtml(message.content || ""));
return;
}
container.append(sanitizeHtml(toSafeHtml(message.content || "")));
}
function sanitizeHtml(html) {
const parsed = new DOMParser().parseFromString(`<div>${html}</div>`, "text/html");
const root = parsed.body.firstElementChild;
const allowedTags = new Set([
"A", "B", "BLOCKQUOTE", "BR", "CODE", "EM", "I", "LI",
"OL", "P", "PRE", "S", "SPAN", "STRONG", "U", "UL",
]);
const blockedTags = new Set([
"EMBED", "FORM", "IFRAME", "INPUT", "LINK", "META", "OBJECT",
"SCRIPT", "STYLE", "TEMPLATE",
]);
Array.from(root.querySelectorAll("*")).forEach((node) => {
if (blockedTags.has(node.tagName)) {
if (node.parentNode) node.parentNode.removeChild(node);
return;
}
if (!allowedTags.has(node.tagName)) {
const parent = node.parentNode;
if (parent) {
while (node.firstChild) parent.insertBefore(node.firstChild, node);
parent.removeChild(node);
}
return;
}
Array.from(node.attributes).forEach((attr) => {
if (node.tagName !== "A" || !["href", "title"].includes(attr.name.toLowerCase())) {
node.removeAttribute(attr.name);
}
});
if (node.tagName === "A") {
const href = String(node.getAttribute("href") || "").trim();
const schemeMatch = href.match(/^([a-z][a-z0-9+.-]*):/i);
const scheme = schemeMatch && schemeMatch[1] ? schemeMatch[1].toLowerCase() : "";
if (scheme && !["http", "https", "mailto", "tel"].includes(scheme)) {
node.removeAttribute("href");
}
if (node.hasAttribute("href")) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer nofollow");
}
}
});
const fragment = document.createDocumentFragment();
Array.from(root.childNodes).forEach((node) => fragment.appendChild(document.importNode(node, true)));
return fragment;
}
function parsePayload(value) {
if (value && typeof value === "object") return value;
try {
return JSON.parse(value || "{}");
} catch (_) {
return {};
}
}
function parseDate(value) {
if (!value) return null;
const date = new Date(String(value).replace(" ", "T"));
return Number.isNaN(date.getTime()) ? null : date;
}
function dayKey(value) {
const date = parseDate(value);
if (!date) return String(value || "unknown").slice(0, 10);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function timelineLabel(value) {
const date = parseDate(value);
if (!date) return "刚刚";
const time = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
if (dayKey(value) === dayKey(new Date().toISOString())) return `今天 ${time}`;
return `${dayKey(value)} ${time}`;
}
function formatDateTime(value) {
const date = parseDate(value);
if (!date) return String(value || "");
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
}
function formatBytes(value) {
const size = Number(value || 0);
if (!size) return "附件";
if (size < 1024) return `${size} B`;
if (size < 1048576) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / 1048576).toFixed(1)} MB`;
}
function toSafeHtml(text) {
const div = document.createElement("div");
div.textContent = String(text || "").replace(/\r\n?/g, "\n");
const paragraphs = div.innerHTML
.split(/\n{2,}/)
.map((paragraph) => `<p>${paragraph.replace(/\n/g, "<br>")}</p>`);
return paragraphs.join("");
}
function stageFiles(files, kind) {
if (!files.length || !canSend() || state.sending) return;
const accepted = files.filter((file) => {
if (file.size > 20 * 1024 * 1024) {
toast(`${file.name || "文件"}不能超过 20MB`, true);
return false;
}
if (kind === "image" && !String(file.type || "").startsWith("image/")) {
toast(`${file.name || "所选文件"}不是图片`, true);
return false;
}
return true;
});
if (!accepted.length) return;
if (kind !== "image" || state.pendingUploads.some((pending) => pending.kind !== "image")) {
clearPendingUploads();
}
const remaining = kind === "image" ? Math.max(0, 9 - state.pendingUploads.length) : 1;
if (accepted.length > remaining) toast("每次最多发送 9 张图片", true);
accepted.slice(0, remaining).forEach((file) => {
state.pendingUploads.push({
file,
kind,
previewUrl: kind === "image" ? URL.createObjectURL(file) : "",
});
});
renderPendingUploads();
updateAvailability();
}
function renderPendingUploads() {
replaceChildrenCompat(dom.pendingUploads, []);
dom.pendingUploads.hidden = state.pendingUploads.length === 0;
state.pendingUploads.forEach((pending) => {
const image = pending.kind === "image";
const item = document.createElement("div");
item.className = `pending-upload${image ? " pending-upload-image" : ""}`;
item.dataset.kind = pending.kind;
const preview = document.createElement("div");
preview.className = "pending-upload-preview";
preview.setAttribute("aria-hidden", "true");
if (image) {
const img = document.createElement("img");
img.alt = "";
img.src = pending.previewUrl;
preview.append(img);
} else {
const icon = document.createElement("span");
icon.className = "pending-upload-file-icon";
icon.innerHTML = '<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>';
preview.append(icon);
}
let copy = null;
if (!image) {
copy = document.createElement("div");
copy.className = "pending-upload-copy";
const name = document.createElement("strong");
name.textContent = pending.file.name || "附件";
const meta = document.createElement("span");
meta.textContent = `附件 · ${formatBytes(pending.file.size)}`;
copy.append(name, meta);
}
const remove = document.createElement("button");
remove.className = "pending-upload-remove";
remove.type = "button";
remove.disabled = state.sending;
remove.setAttribute("aria-label", "移除待发送文件");
remove.title = "移除";
remove.innerHTML = '<svg viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"/></svg>';
remove.addEventListener("click", () => removePendingUpload(pending));
item.append(preview);
if (copy) item.append(copy);
item.append(remove);
dom.pendingUploads.append(item);
});
}
function removePendingUpload(pending) {
const index = state.pendingUploads.indexOf(pending);
if (index < 0) return;
if (pending.previewUrl) URL.revokeObjectURL(pending.previewUrl);
state.pendingUploads.splice(index, 1);
renderPendingUploads();
updateAvailability();
}
function clearPendingUploads() {
state.pendingUploads.forEach((pending) => {
if (pending.previewUrl) URL.revokeObjectURL(pending.previewUrl);
});
state.pendingUploads = [];
replaceChildrenCompat(dom.pendingUploads, []);
dom.pendingUploads.hidden = true;
dom.imageInput.value = "";
dom.fileInput.value = "";
updateAvailability();
}
async function createMessage(messageType, content, payload) {
const message = await request("/message/send", {
method: "POST",
body: JSON.stringify({
conversation_id: state.conversation.id,
message_type: messageType,
content,
payload,
client_msg_id: clientMessageId(messageType === "html" ? "support_chat_html" : "support_chat_asset"),
}),
});
state.messages = mergeMessages(state.messages, [message]);
renderMessages();
scrollToBottom();
}
async function uploadPendingFile(pending) {
const form = new FormData();
form.append("conversation_id", String(state.conversation.id));
form.append("file", pending.file);
const asset = await request(`/message/${pending.kind === "image" ? "upload_image" : "upload_attachment"}`, { method: "POST", body: form });
return {
asset_id: asset.asset_id,
filename: asset.filename,
file_size: asset.file_size,
mime_type: asset.mime_type,
url: asset.url,
};
}
async function sendComposer() {
const content = dom.input.value.trim();
const pending = state.pendingUploads.slice();
if ((!content && !pending.length) || state.sending || !canSend()) return;
state.sending = true;
updateAvailability();
try {
if (pending.length && pending.every((item) => item.kind === "image")) {
const assets = [];
for (const item of pending) assets.push(await uploadPendingFile(item));
await createMessage("image", content ? toSafeHtml(content) : "", JSON.stringify({ assets }));
dom.input.value = "";
clearPendingUploads();
autoResizeInput();
} else {
for (const item of pending) {
const asset = await uploadPendingFile(item);
await createMessage(item.kind, asset.filename || item.file.name, JSON.stringify(asset));
removePendingUpload(item);
}
}
if (content && (!pending.length || pending.some((item) => item.kind !== "image"))) {
await createMessage("html", toSafeHtml(content), "");
dom.input.value = "";
autoResizeInput();
}
} catch (error) {
toast(error instanceof Error ? error.message : "发送消息失败", true);
} finally {
state.sending = false;
updateAvailability();
}
}
async function loadQuickActions() {
try {
const actions = await request(`/conversation/quick_actions?conversation_id=${state.conversation.id}`);
state.quickActions = Array.isArray(actions) ? actions : [];
} catch (_) {
state.quickActions = [];
}
renderQuickActions();
}
function renderQuickActions() {
replaceChildrenCompat(dom.quickList, state.quickActions.map((action) => {
const button = document.createElement("button");
button.type = "button";
button.className = "quick-item";
button.textContent = action.title || action.code;
button.title = action.description || action.title || action.code;
button.addEventListener("click", () => executeQuickAction(action));
return button;
}));
dom.quickEmpty.hidden = state.quickActions.length > 0;
dom.quickSection.hidden = state.quickActions.length === 0;
}
async function executeQuickAction(action) {
if (state.sending || !canSend()) return;
closeQuickDialog();
state.sending = true;
updateAvailability();
try {
const result = await request("/conversation/quick_action", {
method: "POST",
body: JSON.stringify({
conversation_id: state.conversation.id,
code: action.code,
client_msg_id: clientMessageId("support_chat_quick"),
}),
});
state.messages = mergeMessages(state.messages, [result && result.customer_message, result && result.reply_message].filter(Boolean));
renderMessages();
scrollToBottom();
} catch (error) {
toast(error instanceof Error ? error.message : "快捷服务执行失败", true);
} finally {
state.sending = false;
updateAvailability();
}
}
async function markLatestRead() {
const latest = lastItem(state.messages);
if (!latest || !state.conversation || !state.conversation.id) return;
try {
await request("/message/read", {
method: "POST",
body: JSON.stringify({ conversation_id: state.conversation.id, message_id: Number(latest.id) }),
});
} catch (_) {
// Read receipt failure must not interrupt chatting.
}
}
function canSend() {
return Boolean(state.conversation && state.conversation.id) && Number(state.conversation.status) !== 4;
}
function updateAvailability() {
const closed = state.conversation && Number(state.conversation.status) === 4;
const disabled = !state.conversation || closed || state.sending;
dom.input.disabled = disabled;
dom.send.disabled = disabled || (!dom.input.value.trim() && !state.pendingUploads.length);
dom.imageButton.disabled = disabled;
dom.fileButton.disabled = disabled;
dom.pendingUploads.querySelectorAll(".pending-upload-remove").forEach((button) => {
button.disabled = disabled;
});
dom.quickButton.disabled = disabled;
dom.status.hidden = !closed;
if (closed) dom.status.textContent = "当前客服会话已关闭,感谢您的使用。";
updateQueueStatus();
syncQueueTimers();
}
function updateQueueStatus() {
const conversation = state.conversation;
const queued = Number(conversation && conversation.status) === 2 && Number((conversation && conversation.current_assignee_id) || 0) === 0;
dom.queueStatus.hidden = !queued;
if (!queued) return;
const position = Math.max(1, Number(conversation.queue_position || 1));
const ahead = Math.max(0, Number(conversation.queue_ahead_count || position - 1));
const waiting = Math.max(position, Number(conversation.queue_waiting_count || position));
const online = Boolean(conversation.queue_service_online);
const waitSeconds = currentQueueWaitSeconds();
const etaSeconds = Math.max(0, Number(conversation.queue_estimated_wait_seconds || 0));
dom.queueTitle.textContent = online ? "人工客服排队中" : "已进入人工客服队列";
dom.queuePosition.textContent = `第 ${position} 位`;
if (!online) {
dom.queueDetail.textContent = "当前为非服务时段,排队顺序已保留。你可以继续留言,客服上线后会查看并优先接入。";
} else if (ahead > 0) {
dom.queueDetail.textContent = `前方 ${ahead} 人,当前队列共 ${waiting} 人。等待期间可以继续留言,客服接入后会看到。`;
} else {
dom.queueDetail.textContent = "已排在队首,正在为你匹配客服。你可以继续留言,客服接入后会看到。";
}
dom.queueWait.textContent = `已等待 ${formatQueueDuration(waitSeconds)}`;
dom.queueEta.textContent = online
? (etaSeconds > 0 ? `预计约 ${formatQueueDuration(etaSeconds)}` : "预计很快接入")
: "等待客服上线";
}
function currentQueueWaitSeconds() {
const baseline = Math.max(0, Number((state.conversation && state.conversation.queue_wait_seconds) || 0));
if (!state.queueSyncedAt) return baseline;
return baseline + Math.max(0, Math.floor((Date.now() - state.queueSyncedAt) / 1000));
}
function formatQueueDuration(seconds) {
const value = Math.max(0, Number(seconds || 0));
if (value < 60) return "不到 1 分钟";
if (value < 3600) return `${Math.max(1, Math.ceil(value / 60))} 分钟`;
const hours = Math.floor(value / 3600);
const minutes = Math.ceil((value % 3600) / 60);
return minutes > 0 ? `${hours} 小时 ${minutes} 分钟` : `${hours} 小时`;
}
function syncQueueTimers() {
const queued = Number(state.conversation && state.conversation.status) === 2 && Number((state.conversation && state.conversation.current_assignee_id) || 0) === 0;
if (!queued) {
clearQueueTimers();
return;
}
if (!state.queueTickTimer) {
state.queueTickTimer = window.setInterval(updateQueueStatus, 1000);
}
if (!state.queueRefreshTimer) {
state.queueRefreshTimer = window.setInterval(refreshConversationQueue, 15000);
}
}
function clearQueueTimers() {
if (state.queueTickTimer) window.clearInterval(state.queueTickTimer);
if (state.queueRefreshTimer) window.clearInterval(state.queueRefreshTimer);
state.queueTickTimer = null;
state.queueRefreshTimer = null;
}
async function refreshConversationQueue() {
if (!state.conversation || !state.conversation.id || Number(state.conversation.status) !== 2) return;
try {
const latest = await request(`/conversation/${state.conversation.id}`);
state.conversation = Object.assign({}, state.conversation, latest);
state.queueSyncedAt = Date.now();
updateAvailability();
} catch (_) {
// WebSocket remains the primary update path; polling is only a recovery path.
}
}
function updateConnectionStatus(status) {
const labels = { connecting: "连接中", connected: "在线服务", disconnected: "连接已断开" };
dom.statusDot.className = `header-status-dot ${status}`;
dom.connectionBadge.className = `connection-badge ${status}`;
dom.connectionLabel.textContent = labels[status] || labels.disconnected;
}
function websocketUrl() {
const base = apiBase.startsWith("http://") || apiBase.startsWith("https://")
? apiBase.replace(/^http/, "ws")
: `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}${apiBase.startsWith("/") ? "" : "/"}${apiBase}`;
const query = new URLSearchParams();
query.set("channel_id", channelId);
if (accessTarget) {
query.set("h5_chat_session", "required");
query.set("h5_chat_binding", state.chatBinding);
} else {
query.set("external_id", identity.external_id);
}
return `${base.replace(/\/$/, "")}/ws/open?${query.toString()}`;
}
function connectSocket() {
if (!state.conversation || !state.conversation.id || Number(state.conversation.status) === 4) return;
clearRealtimeTimers();
if (state.socket) {
const oldSocket = state.socket;
state.socket = null;
oldSocket.close();
}
state.allowReconnect = true;
updateConnectionStatus("connecting");
let socket;
try {
socket = new WebSocket(websocketUrl());
} catch (_) {
updateConnectionStatus("disconnected");
scheduleReconnect();
return;
}
state.socket = socket;
socket.addEventListener("open", () => {
if (state.socket !== socket) return;
state.reconnectAttempt = 0;
updateConnectionStatus("connected");
state.pingTimer = window.setInterval(() => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "ping" }));
}, 20000);
});
socket.addEventListener("message", (event) => {
if (state.socket !== socket) return;
handleRealtimeMessage(event.data);
});
socket.addEventListener("error", () => {
if (state.socket !== socket) return;
updateConnectionStatus("disconnected");
});
socket.addEventListener("close", () => {
if (state.socket !== socket) return;
state.socket = null;
clearPingTimer();
updateConnectionStatus("disconnected");
scheduleReconnect();
});
}
function handleRealtimeMessage(raw) {
let event;
try {
event = JSON.parse(raw);
} catch (_) {
return;
}
const payload = event && event.data;
if (event && event.type === "resyncRequired") {
loadMessages(false).catch(() => {});
return;
}
if (!payload || Number(payload.conversation_id) !== Number(state.conversation && state.conversation.id)) return;
if (event.type === "message.created") {
const message = normalizeRealtimeMessage(payload);
if (!message) {
loadMessages(false).catch(() => {});
return;
}
const previousLast = lastItem(state.messages);
const previousLastId = previousLast && previousLast.id;
state.messages = mergeMessages(state.messages, [message]);
state.conversation.last_message_id = message.id;
state.conversation.last_message_at = message.sent_at || state.conversation.last_message_at;
renderMessages();
const currentLast = lastItem(state.messages);
if ((currentLast && currentLast.id) !== previousLastId) {
scrollToBottom();
markLatestRead();
}
return;
}
if (String(event.type || "").startsWith("conversation.")) {
const patch = Object.assign({}, payload);
delete patch.conversation_id;
state.conversation = Object.assign({}, state.conversation, patch);
state.queueSyncedAt = Date.now();
applyReadState(payload);
renderMessages();
updateAvailability();
if (Number(state.conversation.status) === 4) {
clearPendingUploads();
disconnectSocket(false);
}
}
}
function normalizeRealtimeMessage(payload) {
if (payload.message && payload.message.id) return payload.message;
const id = Number(payload.message_id || 0);
const conversationId = Number(payload.conversation_id || 0);
if (!id || !conversationId) return null;
return {
id,
conversation_id: conversationId,
sender_type: payload.sender_type || "",
sender_id: Number(payload.sender_id || 0),
sender_name: payload.sender_name,
sender_avatar: payload.sender_avatar,
message_type: payload.message_type || "text",
content: payload.content || "",
payload: payload.payload,
send_status: Number(payload.send_status || 0),
sent_at: payload.sent_at,
customer_read: false,
agent_read: false,
};
}
function applyReadState(payload) {
const agentReadId = Number(payload.agent_last_read_message_id || 0);
const customerReadId = Number(payload.customer_last_read_message_id || 0);
state.messages = state.messages.map((message) => Object.assign({}, message, {
agent_read: message.agent_read || (agentReadId > 0 && Number(message.id) <= agentReadId),
customer_read: message.customer_read || (customerReadId > 0 && Number(message.id) <= customerReadId),
}));
}
function scheduleReconnect() {
if (!state.allowReconnect || state.reconnectTimer || !state.conversation || !state.conversation.id || Number(state.conversation.status) === 4) return;
const delay = Math.min(2000 * 2 ** state.reconnectAttempt, 30000);
state.reconnectTimer = window.setTimeout(() => {
state.reconnectTimer = null;
state.reconnectAttempt += 1;
connectSocket();
}, delay);
}
function clearPingTimer() {
if (state.pingTimer) window.clearInterval(state.pingTimer);
state.pingTimer = null;
}
function clearRealtimeTimers() {
clearPingTimer();
if (state.reconnectTimer) window.clearTimeout(state.reconnectTimer);
state.reconnectTimer = null;
}
function disconnectSocket(allowReconnect = false) {
state.allowReconnect = allowReconnect;
clearRealtimeTimers();
const socket = state.socket;
state.socket = null;
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) socket.close();
updateConnectionStatus("disconnected");
}
async function retry() {
if (!state.initialized || !state.conversation || !state.conversation.id) {
dom.loading.hidden = false;
dom.empty.hidden = true;
await init();
return;
}
try {
await loadMessages(false);
} catch (_) {
// The socket can still reconnect when a one-time resync fails.
}
state.reconnectAttempt = 0;
connectSocket();
}
async function closeConversation() {
if (state.closing) return;
if (!state.conversation || !state.conversation.id) {
closeCloseDialog();
closePage();
return;
}
state.closing = true;
dom.confirmClose.disabled = true;
dom.continueButton.disabled = true;
dom.confirmClose.textContent = "正在结束…";
try {
await request("/conversation/close", {
method: "POST",
body: JSON.stringify({ conversation_id: state.conversation.id }),
});
state.conversation = Object.assign({}, state.conversation, { status: 4 });
disconnectSocket(false);
clearCustomerAccessState();
clearPendingUploads();
clearQueueTimers();
updateAvailability();
closeCloseDialog();
if (isEmbedded()) {
window.parent.postMessage({ type: "agent-desk:request-close" }, "*");
}
} catch (error) {
toast(error instanceof Error ? error.message : "结束会话失败", true);
} finally {
state.closing = false;
dom.confirmClose.disabled = false;
dom.continueButton.disabled = false;
dom.confirmClose.textContent = "结束对话";
}
}
function isEmbedded() {
try {
return window.parent !== window;
} catch (_) {
return false;
}
}
function closePage() {
if (isEmbedded()) {
window.parent.postMessage({ type: "agent-desk:request-close" }, "*");
} else if (history.length > 1) {
history.back();
} else {
window.close();
}
}
function scrollToBottom(smooth = true) {
requestAnimationFrame(() => dom.scroller.scrollTo({
top: dom.scroller.scrollHeight,
behavior: smooth ? "smooth" : "auto",
}));
}
function settleInitialScroll() {
const startedAt = Date.now();
let lastHeight = -1;
let stableTicks = 0;
const apply = () => {
dom.scroller.scrollTop = dom.scroller.scrollHeight;
const height = dom.scroller.scrollHeight;
stableTicks = height === lastHeight ? stableTicks + 1 : 0;
lastHeight = height;
const imagesReady = Array.from(dom.list.querySelectorAll("img")).every((image) => image.complete);
if ((imagesReady && stableTicks >= 4) || Date.now() - startedAt >= 6000) {
dom.scroller.scrollTop = dom.scroller.scrollHeight;
state.initialScrollSettling = false;
return;
}
window.setTimeout(apply, 80);
};
requestAnimationFrame(() => requestAnimationFrame(apply));
}
function autoResizeInput() {
dom.input.style.height = "auto";
dom.input.style.height = `${Math.min(dom.input.scrollHeight, 160)}px`;
updateAvailability();
}
async function openQuickDialog() {
dom.quickOverlay.hidden = false;
state.quickActions = [];
renderQuickActions();
await loadQuickActions();
}
function closeQuickDialog() {
dom.quickOverlay.hidden = true;
}
function openCloseDialog() {
dom.closeOverlay.hidden = false;
}
function closeCloseDialog() {
if (!state.closing) dom.closeOverlay.hidden = true;
}
function toast(message, error = false) {
const node = document.createElement("div");
node.className = `toast${error ? " error" : ""}`;
node.textContent = message;
dom.toasts.append(node);
window.setTimeout(() => node.remove(), 3200);
}
function fatal(message) {
disconnectSocket(false);
dom.loading.hidden = true;
dom.empty.hidden = false;
dom.empty.querySelector("p").textContent = message;
dom.input.disabled = true;
dom.send.disabled = true;
dom.imageButton.disabled = true;
dom.fileButton.disabled = true;
dom.quickSection.hidden = true;
dom.app.setAttribute("aria-busy", "false");
}
function bindEvents() {
dom.retry.addEventListener("click", retry);
dom.close.addEventListener("click", openCloseDialog);
dom.loadMore.addEventListener("click", loadOlder);
dom.send.addEventListener("click", sendComposer);
dom.input.addEventListener("input", autoResizeInput);
dom.imageButton.addEventListener("click", () => dom.imageInput.click());
dom.fileButton.addEventListener("click", () => dom.fileInput.click());
dom.imageInput.addEventListener("change", () => {
stageFiles(Array.from(dom.imageInput.files || []), "image");
dom.imageInput.value = "";
});
dom.fileInput.addEventListener("change", () => {
stageFiles(Array.from(dom.fileInput.files || []).slice(0, 1), "attachment");
dom.fileInput.value = "";
});
dom.quickButton.addEventListener("click", openQuickDialog);
dom.quickClose.addEventListener("click", closeQuickDialog);
dom.quickOverlay.addEventListener("click", (event) => {
if (event.target === dom.quickOverlay) closeQuickDialog();
});
dom.closeDialogX.addEventListener("click", closeCloseDialog);
dom.continueButton.addEventListener("click", closeCloseDialog);
dom.confirmClose.addEventListener("click", closeConversation);
dom.closeOverlay.addEventListener("click", (event) => {
if (event.target === dom.closeOverlay) closeCloseDialog();
});
dom.scroller.addEventListener("scroll", () => {
if (!state.initialScrollSettling && dom.scroller.scrollTop < 24) loadOlder();
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden && state.initialized) {
loadMessages(false).catch(() => {});
if (!state.socket) connectSocket();
}
});
window.addEventListener("message", (event) => {
const type = event.data && event.data.type;
if (type === "agent-desk:open" && state.initialized && !state.socket) connectSocket();
if (type === "agent-desk:minimize") disconnectSocket(false);
});
window.addEventListener("beforeunload", () => {
state.pendingUploads.forEach((pending) => {
if (pending.previewUrl) URL.revokeObjectURL(pending.previewUrl);
});
disconnectSocket(false);
clearQueueTimers();
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
if (!dom.quickOverlay.hidden) closeQuickDialog();
else if (!dom.closeOverlay.hidden) closeCloseDialog();
});
}
bindEvents();
if (isEmbedded()) window.parent.postMessage({ type: "agent-desk:ready" }, "*");
init();
})();