document.addEventListener('DOMContentLoaded', function(){
const fetch = VisitorSession.fetch;
const chatDom = document.getElementById("chatDom");
const inputDom = document.getElementById("input-text");
const sendBtn = document.getElementById("send-button");
const uploadBtn = document.getElementById("upload-btn");
const uploadInput = document.getElementById("upload-input");
const composerStatus = document.getElementById("composer-status");
const drafts = ChatDrafts.create({scope:VisitorSession.localKey(),storage:()=>localStorage,tabStorage:()=>sessionStorage});
let draftTimer = null;
let draftSnapshot = null;
function flushDraft(){
if(draftTimer !== null){ clearTimeout(draftTimer); draftTimer = null; }
const saved = drafts.write(currentConvId, inputDom.value);
drafts.active(currentConvId);
if(!saved && inputDom.value && !composerStatus.classList.contains('is-error')) setComposerStatus(t('draftSaveFailed'), true);
else if(saved && composerStatus.textContent === t('draftSaveFailed')) setComposerStatus('');
}
function restoreDraft(){
inputDom.value = drafts.read(currentConvId);
draftSnapshot = String(currentConvId) + '\n' + inputDom.value;
drafts.active(currentConvId);
autoGrow();
}
function queueDraft(){
const snapshot = String(currentConvId) + '\n' + inputDom.value;
if(snapshot === draftSnapshot) return;
draftSnapshot = snapshot;
if(draftTimer !== null) clearTimeout(draftTimer);
draftTimer = setTimeout(flushDraft, 250);
}
function focusComposer(force){
if(force || !isNarrow() || document.activeElement === inputDom){
inputDom.focus({preventScroll:true});
}
}
let uploadPending = false;
let uploadVersion = 0;
let uploadController = null;
let historyRequestVersion = 0;
let followLatest = true;
let voiceInput = null;
let customerLogin = null;
let creatingConv = null;
let sendPreparing = false;
let conversationVersion = 0;
let activeQuickTopic = null;
let humanConvId = null;
let humanStatus = '';
let humanRequestToken = null;
let humanPollTimer = null;
let humanSendingToken = null;
let humanClosing = false;
window.humanSid = 0;
const emojiPicker = SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
SupportUI.initAttachments(chatDom, ()=>t('attachmentUnavailable'));
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
function setComposerStatus(message, isError){
composerStatus.textContent = message || "";
composerStatus.hidden = !message;
composerStatus.classList.toggle("is-error", Boolean(isError));
}
async function requestJson(path, options){
const controller = new AbortController();
const timer = setTimeout(function(){ controller.abort(); }, 10000);
try{
const response = await fetch(path, Object.assign({}, options, {signal:controller.signal}));
const data = await response.json();
if(!response.ok){
const error = new Error(data && (data.e || data.error) || t('networkFail'));
error.status = response.status;
throw error;
}
return data;
}finally{ clearTimeout(timer); }
}
function clearAttachment(){
uploadVersion += 1;
if(uploadController){
uploadController.abort();
uploadController = null;
uploadPending = false;
uploadBtn.disabled = false;
uploadBtn.removeAttribute('aria-busy');
uploadBtn.textContent = '+';
}
window.__pendingImg = "";
window.__pendingTag = "[图片]";
const preview = document.getElementById("imgPreview");
if(preview) preview.remove();
}
function scrollChatToEnd(force){
if(force) followLatest = true;
if(followLatest) chatDom.scrollTop = chatDom.scrollHeight;
}
uploadBtn.onclick = ()=> uploadInput.click();
uploadInput.onchange = function(){
const f = uploadInput.files[0];
if(!f || uploadPending) return;
const version = ++uploadVersion;
const fd = new FormData();
fd.append("file", f);
uploadPending = true;
uploadBtn.disabled = true;
uploadBtn.setAttribute("aria-busy", "true");
uploadBtn.textContent = "…";
setComposerStatus(t("uploading"));
const controller = new AbortController();
uploadController = controller;
const timer = setTimeout(function(){ controller.abort(); }, 15000);
fetch("/api/upload", {method:"POST", body:fd, signal:controller.signal})
.then(async function(response){
const data = await response.json();
if(!response.ok || !data.ok) throw new Error(data.error || t("uploadFailed"));
return data;
})
.then(data=>{
if(version === uploadVersion){
window.__pendingImg = data.url;
window.__pendingTag = data.is_image ? "[图片]" : "[文件]";
var old = document.getElementById("imgPreview");
if(old) old.remove();
var prev = document.createElement("div");
prev.id = "imgPreview";
prev.className = "attachment-preview";
if(data.is_image){
var image = document.createElement("img");
image.src = data.url;
image.alt = data.name || "待发送图片";
prev.appendChild(image);
}else{
var filename = document.createElement("span");
filename.className = "attachment-file-icon";
filename.textContent = data.name.split(".").pop().slice(0,5).toUpperCase();
prev.appendChild(filename);
}
const copy = document.createElement("div");
copy.className = "attachment-copy";
const name = document.createElement("strong");
name.textContent = data.name;
copy.append(name);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "attachment-remove";
remove.textContent = "×";
remove.setAttribute("aria-label", t("removeAttachment"));
remove.onclick = function(){ clearAttachment(); focusComposer(true); };
prev.append(copy, remove);
var area = document.querySelector(".input-area");
area.before(prev);
setComposerStatus("");
focusComposer();
}
})
.catch(function(error){
const message = error.name === 'AbortError' || error instanceof SyntaxError || error instanceof TypeError ? t("uploadFailed") : (error.message || t("uploadFailed"));
if(version === uploadVersion) setComposerStatus(message, true);
})
.finally(function(){
clearTimeout(timer);
if(uploadController === controller){
uploadController = null;
uploadPending = false;
uploadBtn.disabled = false;
uploadBtn.removeAttribute("aria-busy");
uploadBtn.textContent = "+";
}
});
uploadInput.value = "";
};
const leftHistoryWrap = document.getElementById("historyWrap");
const recentChatWrap = document.getElementById("recentChatWrap");
const clearAllBtn = document.querySelector('.clear-all-btn');
const newChatBtn = document.querySelector('.new-chat-btn');
window.__busy = false; // 生成中状态,用 window 属性避免作用域问题
// 生成中状态(语言切换等会引用,需提前声明)
// ===== 深色模式 =====
(function initTheme(){
const KEY = "wuliu-theme";
const btn = document.getElementById("theme-toggle");
function apply(mode){
document.body.classList.toggle("dark", mode === "dark");
if(btn) btn.textContent = mode === "dark" ? "☀" : "☾";
}
let saved = null;
try{ saved = localStorage.getItem(KEY); }catch(e){}
if(!saved){
saved = "light";
}
apply(saved);
if(btn){
btn.onclick = function(){
const next = document.body.classList.contains("dark") ? "light" : "dark";
apply(next);
try{ localStorage.setItem(KEY, next); }catch(e){}
}
}
})();
// ===== 字体大小切换 =====
(function(){
var KEY = "wuliu-fontsize";
var SIZES = [14, 16, 18, 20];
var idx = 1;
try{ var s = Number(localStorage.getItem(KEY)); if(Number.isInteger(s) && s >= 0 && s < SIZES.length && localStorage.getItem(KEY) !== null) idx = s; }catch(e){}
var btn = document.getElementById("font-toggle");
function apply(){
document.body.style.fontSize = SIZES[idx] + "px";
if(btn) btn.textContent = SIZES[idx] + "px";
}
apply();
if(btn){
btn.onclick = function(){
idx = (idx + 1) % SIZES.length;
apply();
try{ localStorage.setItem(KEY, idx); }catch(e){}
};
}
})();
// ===== 中英文切换 =====
const I18N = {
zh: {
appTitle: "柏悦物流智能助手",
customerLogin: "登录", customerLogout: "退出登录", customerCancel: "取消",
customerUsername: "账号", customerPassword: "密码", customerQuery: "登录查询",
customerUsernamePlaceholder: "请输入账号", customerPasswordPlaceholder: "请输入密码",
customerSigningIn: "登录中…", customerLoginFailed: "登录失败,请稍后重试。",
customerLogoutFailed: "退出失败,请重试。",
newChat: "新建对话",
clearAll: "清空全部历史",
history: "历史对话",
searchPh: "搜索历史对话...",
confirmClear: "确定清空全部历史对话吗?无法恢复!",
confirmDel: "确定要删除这条历史对话吗?",
emptySearch: "没有匹配的历史对话",
emptyHistory: "暂无历史对话",
thinking: "正在思考中...",
busyRetry: "服务繁忙,请稍后重试",
sessionFail: "会话创建失败,请确认服务已启动",
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。",
humanHours: "人工客服工作时间:每天北京时间 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
offlinePrompt: "请输入您的留言:",
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
humanClosed: "人工客服已结束,您可以继续向 AI 提问。",
humanReply: "客服回复",
humanConnecting: "正在为您连接人工客服…",
humanWaiting: "正在排队,消息将由人工客服接入后回复。您也可以返回 AI 继续咨询。",
humanActive: "人工客服接待中,消息将发送给人工客服。",
returnAI: "返回 AI 问答",
humanSending: "正在发送给人工客服…",
humanSendFailed: "消息未发送成功,内容已保留,请重试。",
humanConnectFailed: "人工客服连接失败,请稍后重试。",
humanPollFailed: "人工客服连接中断,正在重试。您可以返回 AI 继续咨询。",
humanReturnFailed: "暂时无法结束人工会话,请重试。",
historyLoading: "正在加载这段会话,请稍候…",
historyLoadFailed: "会话未加载成功,请重新选择该会话重试,或新建对话。",
draftSaveFailed: "草稿暂时无法保存到当前浏览器,刷新前请保留输入内容。",
attachmentUnavailable: "图片暂时无法访问,请重新发送附件。",
recalled: "该消息已撤回",
ratingQuestion: "本次服务满意吗?",
ratingThanks: "感谢您的评价!",
ratingFailed: "评价未提交,请重试。",
networkFail: "网络连接中断,请检查网络后重试",
stopped: "已停止生成",
stoppedMark: "(已停止生成)",
inputPh: "输入入仓码或问题…",
send: "发送",
stop: "停止",
copy: "复制",
copied: "已复制",
copyFailed: "复制失败",
voteUp: "这条回复有帮助",
voteDown: "这条回复没有帮助",
retry: "重答",
retryAIOnly: "请先返回 AI 问答,再重答这条消息。",
clickRetry: "点击重试",
welcomeTitle: "物流问题,随时为您解答",
welcomeEyebrow: "中欧 · 英国物流服务",
serviceTag: "物流咨询服务",
footerTrack: "查入仓码",
footerQuote: "专线报价",
footerException: "异常处理",
footerHuman: "联系人工",
inputShort: "输入入仓码…",
uploading: "附件正在上传,请稍候…",
uploadFailed: "附件上传失败,请重新选择文件",
serviceUnavailable: "服务暂时不可用,请稍后重试,或联系人工客服。",
voiceInput: "语音输入", voiceKeyboard: "键盘输入", voiceHold: "按住说话",
voiceRelease: "松开发送,上滑取消", voicePreparing: "正在准备麦克风…",
voiceCancelTip: "正在录音,松开发送,上滑取消", voiceCancelRelease: "松开取消发送",
voiceTranscribing: "正在转文字…", voiceCancelled: "已取消语音",
voiceUnavailable: "语音输入暂不可用,请使用文字输入。",
voiceUnsupported: "当前浏览器不支持录音,请使用文字输入或在其他浏览器打开。",
voiceHttps: "语音输入需要 HTTPS 安全连接,请使用安全访问地址。",
voicePermission: "未获得麦克风权限,请允许录音后重试。", voiceNoMic: "没有找到麦克风,请使用文字输入。",
voiceTooShort: "按住时间太短,请按住按钮说话后再松开。", voiceTooLarge: "录音过大,请缩短后重试。",
voiceEmpty: "没有识别到文字,请靠近麦克风重试。", voiceFailed: "语音输入失败,请重试或使用文字输入。",
voiceTimeout: "语音处理超时,请重试。", voiceWait: "请等待当前操作完成后,再使用语音输入。",
voiceDraft: "输入框还有未发送内容,请先发送或清空后再使用语音。",
removeAttachment: "移除待发送附件",
you: "您",
assistant: "柏悦物流助手",
humanAgent: "人工客服",
trackingNo: "运单号",
orderNo: "订单号",
orderHistory: "订单业务状态历史",
noOrderHistory: "未提供订单状态历史",
moreOrderDetails: "展开订单信息",
lessOrderDetails: "收起订单信息",
logisticsHistory: "物流轨迹",
noLogisticsHistory: "暂无已发生的物流节点",
estimatedTracking: "预计节点",
order_inbound: "入仓码", order_number: "订单号", order_shipment: "Shipment ID", order_country: "目的国",
order_warehouse: "目的仓", order_transport: "运输方式", order_packages: "件数",
order_weight: "申报毛重", order_volume: "申报体积", order_role: "订单层级",
order_parent: "父订单号", order_updated: "来源更新时间",
moreTracking: "展开全部轨迹",
lessTracking: "收起较早轨迹",
hot: "热点问题",
recent: "最近对话记录",
sideCollapse: "收起侧栏",
sideExpand: "展开侧栏",
themeTip: "切换深色/浅色",
langTip: "中英文切换(Ctrl+L)",
toBottom: "回到底部",
quick: [
"按入仓码查轨迹", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
],
hotList: [
"如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?"
]
},
en: {
appTitle: "Baiyue Logistics AI Assistant",
customerLogin: "Sign in", customerLogout: "Sign out", customerCancel: "Cancel",
customerUsername: "Username", customerPassword: "Password", customerQuery: "Sign in to view orders",
customerUsernamePlaceholder: "Enter username", customerPasswordPlaceholder: "Enter password",
customerSigningIn: "Signing in…", customerLoginFailed: "Could not sign in. Please try again.",
customerLogoutFailed: "Could not sign out. Please try again.",
newChat: "New Chat",
clearAll: "Clear All History",
history: "History",
searchPh: "Search history...",
confirmClear: "Clear all conversation history? This cannot be undone.",
confirmDel: "Delete this conversation?",
emptySearch: "No matching conversations",
emptyHistory: "No conversations yet",
thinking: "Thinking...",
busyRetry: "Service busy, please try again later",
sessionFail: "Failed to create session, please check the service is running",
autoWelcome: "Welcome to Baiyue Logistics. Ask about rates, transit times or human support.",
humanHours: "Our team is available daily, 08:00–22:00 China Standard Time (UTC+8). Please leave a message and we will reply during service hours.",
offlinePrompt: "Please leave your message:",
offlineSaved: "Message submitted. Our team will reply during service hours (08:00–22:00).",
humanClosed: "This support session has ended. You can continue chatting with the AI assistant.",
humanReply: "Support reply",
humanConnecting: "Connecting you to our support team…",
humanWaiting: "You are in the queue. A team member will reply after joining, or you can return to AI chat.",
humanActive: "You are chatting with our support team. Messages will be sent to a team member.",
returnAI: "Return to AI chat",
humanSending: "Sending to our support team…",
humanSendFailed: "Message not sent. Your text has been kept. Please retry.",
humanConnectFailed: "Could not connect to our support team. Please try again.",
humanPollFailed: "Support connection lost. Retrying, or you can return to AI chat.",
humanReturnFailed: "Could not end the support session. Please try again.",
historyLoading: "Loading this conversation. Please wait…",
historyLoadFailed: "Conversation could not load. Select it again to retry, or start a new chat.",
draftSaveFailed: "Your browser could not save this draft. Keep a copy before refreshing.",
attachmentUnavailable: "Image unavailable. Please upload the attachment again.",
recalled: "This message was recalled",
ratingQuestion: "How was your support experience?",
ratingThanks: "Thank you for your feedback!",
ratingFailed: "Your rating was not submitted. Please try again.",
networkFail: "Connection lost, please check your network and retry",
stopped: "Stopped",
stoppedMark: "(Generation stopped)",
inputPh: "Enter an inbound code or question…",
send: "Send",
stop: "Stop",
copy: "Copy",
copied: "Copied",
copyFailed: "Copy failed",
voteUp: "This reply was helpful",
voteDown: "This reply was not helpful",
retry: "Regenerate",
retryAIOnly: "Return to AI chat before regenerating this answer.",
clickRetry: "Retry",
welcomeTitle: "Your next shipment, made clearer.",
welcomeEyebrow: "EUROPE & UK LOGISTICS",
serviceTag: "Logistics support",
footerTrack: "Inbound lookup",
footerQuote: "Get a quote",
footerException: "Parcel issue",
footerHuman: "Human support",
inputShort: "Inbound code…",
uploading: "Uploading your attachment…",
uploadFailed: "Upload failed. Please select the file again.",
serviceUnavailable: "Service is temporarily unavailable. Please try again later or contact support.",
voiceInput: "Voice input", voiceKeyboard: "Keyboard input", voiceHold: "Hold to speak",
voiceRelease: "Release to send, slide up to cancel", voicePreparing: "Preparing the microphone…",
voiceCancelTip: "Recording · Release to send · Slide up to cancel", voiceCancelRelease: "Release to cancel",
voiceTranscribing: "Converting speech to text…", voiceCancelled: "Voice input cancelled",
voiceUnavailable: "Voice input is unavailable. Please type your message.",
voiceUnsupported: "This browser cannot record audio. Please type or open another browser.",
voiceHttps: "Voice input requires a secure HTTPS connection.",
voicePermission: "Microphone permission is required. Please allow recording and try again.",
voiceNoMic: "No microphone found. Please type your message.",
voiceTooShort: "Hold the button while speaking, then release.", voiceTooLarge: "The recording is too large. Please record a shorter message.",
voiceEmpty: "No speech was recognized. Please try again closer to the microphone.",
voiceFailed: "Voice input failed. Please try again or type your message.", voiceTimeout: "Voice input timed out. Please try again.",
voiceWait: "Please wait for the current operation to finish before recording.",
voiceDraft: "Please send or clear your existing draft before recording.",
removeAttachment: "Remove pending attachment",
you: "You",
assistant: "Baiyue Assistant",
humanAgent: "Support agent",
trackingNo: "Tracking number",
orderNo: "Order number",
orderHistory: "Order workflow history",
noOrderHistory: "No order status history provided",
moreOrderDetails: "Show order details",
lessOrderDetails: "Hide order details",
logisticsHistory: "Shipment tracking",
noLogisticsHistory: "No actual shipment events available",
estimatedTracking: "Estimated milestones",
order_inbound: "Inbound code", order_number: "Order number", order_shipment: "Shipment ID", order_country: "Destination country",
order_warehouse: "Warehouse", order_transport: "Transport", order_packages: "Packages",
order_weight: "Declared gross weight", order_volume: "Declared volume", order_role: "Order level",
order_parent: "Parent order", order_updated: "Source updated",
moreTracking: "View full tracking history",
lessTracking: "Show recent updates",
hot: "Popular Questions",
recent: "Recent Conversations",
sideCollapse: "Collapse sidebar",
sideExpand: "Expand sidebar",
themeTip: "Toggle dark/light",
langTip: "Switch language (Ctrl+L)",
toBottom: "Back to bottom",
quick: [
"Track by inbound code", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent"
],
hotList: [
"How do I track my shipment?", "How long does Europe sea freight take?",
"My parcel shows delivered but I didn't get it", "How do I file a claim?"
]
}
};
let LANG = "zh";
function t(key){
return (I18N[LANG] || I18N.zh)[key];
}
function applyLanguage(lang){
LANG = lang === "en" ? "en" : "zh";
const dict = I18N[LANG];
if(customerLogin) customerLogin.render();
document.documentElement.lang = LANG === "en" ? "en" : "zh-CN";
document.querySelectorAll("[data-i18n]").forEach(function(element){
const value = dict[element.dataset.i18n];
if(typeof value === "string") element.textContent = value;
});
try{ localStorage.setItem("wuliu-lang", LANG); }catch(e){}
const titleEl = document.getElementById("app-title");
if(titleEl) titleEl.textContent = dict.appTitle;
const langBtn = document.getElementById("lang-toggle");
if(langBtn){
langBtn.textContent = LANG === "en" ? "EN" : "中";
langBtn.title = dict.langTip;
}
const themeBtn = document.getElementById("theme-toggle");
if(themeBtn) themeBtn.title = dict.themeTip;
document.querySelectorAll(".new-chat-btn").forEach(function(b){ b.textContent = dict.newChat; });
document.querySelectorAll(".clear-all-btn").forEach(function(b){ b.textContent = dict.clearAll; });
document.querySelectorAll(".panel-title").forEach(function(e){ e.textContent = dict.history; });
const hs = document.getElementById("history-search");
if(hs) hs.placeholder = dict.searchPh;
if(inputDom) inputDom.placeholder = window.innerWidth > 900 ? dict.inputPh : dict.inputShort;
sendBtn.textContent = window.__busy ? dict.stop : dict.send;
const tb = document.getElementById("to-bottom");
if(tb) tb.title = dict.toBottom;
const tg = document.querySelector(".toggle-divider");
if(tg) tg.title = tg.getAttribute('aria-expanded') === 'false' ? dict.sideExpand : dict.sideCollapse;
updateHumanMode();
if(voiceInput) voiceInput.refresh();
// 欢迎页(若当前显示的是欢迎页则重绘)
if(chatDom.querySelector(".ai-welcome-card")){
const scroll = chatDom.scrollTop;
chatDom.innerHTML = welcomeHTML();
chatDom.scrollTop = scroll;
}
// 历史列表 / 最近对话(空状态与条目都要刷新)
if(typeof renderSideHistory === 'function'){
try{
renderSideHistory();
renderRecentChat();
}catch(e){ /* 列表尚未初始化时忽略 */ }
}
// 已有消息上的复制/重答按钮
document.querySelectorAll(".msg-action-btn").forEach(function(b){
const n = b.dataset.action;
if(n === "copy") b.textContent = dict.copy;
if(n === "retry") b.textContent = dict.retry;
if(n === 'voteUp' || n === 'voteDown') b.setAttribute('aria-label', dict[n]);
});
document.querySelectorAll(".retry-btn").forEach(function(b){
if(!b.dataset.keep) b.textContent = dict.clickRetry;
});
}
(function initLang(){
let saved = null;
try{ saved = localStorage.getItem("wuliu-lang"); }catch(e){}
if(!saved){
saved = (navigator.language || "zh").toLowerCase().indexOf("zh") === 0 ? "zh" : "en";
}
applyLanguage(saved);
const btn = document.getElementById("lang-toggle");
if(btn){
btn.onclick = function(){ applyLanguage(LANG === "en" ? "zh" : "en"); };
}
// 快捷键:Ctrl+L 切换语言(不占用输入框)
document.addEventListener("keydown", function(e){
if((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l"){
e.preventDefault();
applyLanguage(LANG === "en" ? "zh" : "en");
}
});
})();
// ===== 历史搜索 =====
const historySearch = document.getElementById("history-search");
if(historySearch){
historySearch.addEventListener("input", renderSideHistory);
}
let historyList = [];
let sseInstance = null;
let activeBubble = null; // 正在输出的气泡
let cancelActive = null; // 中止当前请求(切换对话/重复发送时用)
let currentConvId = null; // 当前会话 id,null 表示还没建
function formatTime(timeStr) {
const target = new Date(timeStr);
const y = target.getFullYear();
const m = String(target.getMonth() + 1).padStart(2, '0');
const d = String(target.getDate()).padStart(2, '0');
const h = String(target.getHours()).padStart(2, '0');
const min = String(target.getMinutes()).padStart(2, '0');
return `${y}-${m}-${d} ${h}:${min}`;
}
// 先转义再套格式,防止模型输出里的标签被浏览器执行
function escHtml(s){
return String(s).replace(/[&<>"]/g, function(c){
return {"&":"&", "<":"<", ">":">", '"':"""}[c];
});
}
// 轻量 Markdown:加粗、行内代码、无序/有序列表、换行
function mdToHtml(text){
const inline = function(s){
return s.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/`([^`]+)`/g, "$1");
};
const lines = escHtml(text).split("\n");
let html = "";
let list = null;
for (let i = 0; i < lines.length; i++){
const t = lines[i].trim();
let m;
if ((m = t.match(/^[-*•]\s+(.*)$/))){ // 无序列表
if (list !== "ul"){ if (list) html += "" + list + ">"; html += "
"; list = "ul"; }
html += "- " + inline(m[1]) + "
";
} else if ((m = t.match(/^\d+[.、)]\s*(.*)$/))){ // 有序列表
if (list !== "ol"){ if (list) html += "" + list + ">"; html += ""; list = "ol"; }
html += "- " + inline(m[1]) + "
";
} else if (!t){ // 空行
if (list){ html += "" + list + ">"; list = null; }
html += "
";
} else { // 普通段落
if (list){ html += "" + list + ">"; list = null; }
html += inline(t) + "
";
}
}
if (list) html += "" + list + ">";
return html;
}
function parseBold(text){
return mdToHtml(text);
}
// 空会话时的欢迎内容(新建对话 / 打开空会话都要用)
function welcomeIcon(kind){
const paths = {
box: '',
track: '',
quote: '',
issue: '',
human: ''
};
return '';
}
function welcomeHTML(){
const q = t("quick");
const h = t("hotList");
const kinds = ["track", "quote", "issue", "human"];
const cards = q.map(function(question, index){
const action = index === 3 ? 'data-human-support' : `data-question="${escHtml(question)}"`;
return ``;
}).join("");
return `
${welcomeIcon("box")}
${t("welcomeEyebrow")}
${t("welcomeTitle")}
${cards}
${t("hot")}
${h.map(question => ``).join("")}
`;
}
// 清空全部历史
if(clearAllBtn){
clearAllBtn.onclick = function(){
if(openingConv === 'clearing') return;
if(!confirm(t("confirmClear"))) return;
const version = resetConversation(null);
openingConv = 'clearing';
chatDom.innerHTML = welcomeHTML();
setComposerStatus(t('historyLoading'));
requestJson("/api/clear_all", {method:"DELETE"})
.then(data=>{
if(!data.ok) throw new Error('Clear failed');
if(version !== conversationVersion) return;
openingConv = null;
setComposerStatus('');
chatDom.innerHTML = welcomeHTML();
removeCachedHistory();
loadHistory();
}).catch(function(){
if(version !== conversationVersion) return;
openingConv = null;
setComposerStatus(t('networkFail'), true);
});
}
}
// 新建对话:只清界面,等用户开口时再建会话
if(newChatBtn){
newChatBtn.onclick = function(){
if(openingConv === 'clearing') return;
const alreadyNew = currentConvId === null;
resetConversation(null);
if(alreadyNew) inputDom.value = "";
autoGrow();
clearAttachment();
setComposerStatus('');
chatDom.innerHTML = welcomeHTML();
chatDom.scrollTop = 0;
followLatest = true;
loadHistory();
}
}
function loadHistory(){
const version = ++historyRequestVersion;
return requestJson("/api/get_history")
.then(data=>{
if(version !== historyRequestVersion || !Array.isArray(data)) return;
historyList = data;
renderSideHistory();
renderRecentChat();
return data;
}).catch(function(){ return null; /* 保留上次成功加载的历史。 */ });
}
function removeCachedHistory(id){
if(id === undefined){ drafts.clear(); flushDraft(); }
else drafts.remove(id);
historyRequestVersion += 1;
historyList = id === undefined ? [] : historyList.filter(item => item.id !== id);
renderSideHistory();
renderRecentChat();
}
function renderSideHistory(){
leftHistoryWrap.innerHTML = "";
const kw = (document.getElementById("history-search")?.value || "").trim().toLowerCase();
const pinned = pinnedConversations();
let list = kw
? historyList.filter(item => (item.title || "").toLowerCase().indexOf(kw) >= 0)
: historyList.slice();
// 置顶的排前面
list.sort((a,b)=>{
const ap = pinned.includes(a.id) ? 0 : 1;
const bp = pinned.includes(b.id) ? 0 : 1;
return ap - bp;
});
if(!list.length){
const empty = document.createElement("div");
empty.className = "history-empty";
empty.innerText = kw ? t("emptySearch") : t("emptyHistory");
leftHistoryWrap.appendChild(empty);
return;
}
list.forEach((item)=>{
let div = document.createElement('div');
div.className = "history-item" + (item.id === currentConvId ? " active" : "");
div.tabIndex = 0;
div.setAttribute("role", "button");
div.addEventListener("keydown", function(event){
if(event.target === div && (event.key === "Enter" || event.key === " ")){
event.preventDefault();
div.click();
}
});
div.dataset.fullText = item.title;
const isPinned = pinned.includes(item.id);
const textWrap = document.createElement("div");
textWrap.className = "history-text-wrap";
const titleSpan = document.createElement("div");
titleSpan.className = "history-title";
titleSpan.innerText = (isPinned ? "📌 " : "") + item.title;
let timeDiv = document.createElement('div');
timeDiv.className = "history-time";
timeDiv.innerText = formatTime(item.utime || item.ctime);
textWrap.appendChild(titleSpan);
textWrap.appendChild(timeDiv);
const pinBtn = document.createElement("button");
pinBtn.className = "del-history-btn";
pinBtn.innerText = isPinned ? "📍" : "📌";
pinBtn.title = isPinned ? "取消置顶" : "置顶";
pinBtn.style.marginRight = "4px";
pinBtn.onclick = (e)=>{
e.stopPropagation();
let p = pinnedConversations();
if(p.includes(item.id)){ p = p.filter(x=>x!==item.id); }
else { p.push(item.id); }
try{ localStorage.setItem("pinned_conv", JSON.stringify(p)); }catch(error){}
renderSideHistory();
};
const delBtn = document.createElement("button");
delBtn.className = "del-history-btn";
delBtn.innerText = "×";
delBtn.setAttribute("aria-label", LANG === "en" ? "Delete conversation" : "删除对话");
delBtn.onclick = (e)=>{
e.stopPropagation();
if(confirm(t("confirmDel"))){
const version = conversationVersion;
return requestJson(`/api/del_history?id=${item.id}`, {method:"DELETE"})
.then(data=>{
if(!data.ok) throw new Error('Delete failed');
if(item.id === currentConvId){
resetConversation(null);
chatDom.innerHTML = welcomeHTML();
}
removeCachedHistory(item.id);
return loadHistory();
}).catch(function(){
if(version === conversationVersion) setComposerStatus(t('networkFail'), true);
});
}
}
div.appendChild(textWrap);
div.appendChild(pinBtn);
div.appendChild(delBtn);
div.onclick = ()=> openConversation(item.id);
leftHistoryWrap.appendChild(div);
})
}
function pinnedConversations(){
try{
const stored = JSON.parse(localStorage.getItem('pinned_conv') || '[]');
return Array.isArray(stored) ? stored.filter(Number.isInteger) : [];
}catch(error){ return []; }
}
function renderRecentChat(){
const wrap = document.getElementById("recentChatWrap");
if(!wrap) return; // 切过对话后旧节点会失效,每次重新取
wrap.innerHTML = "";
wrap.closest('.recent-chat').hidden = historyList.length === 0;
historyList.slice(0,2).forEach(item=>{
const div = document.createElement('div');
div.className = "recent-item";
div.tabIndex = 0;
div.setAttribute("role", "button");
div.addEventListener("keydown", function(event){
if(event.key === "Enter" || event.key === " "){
event.preventDefault();
div.click();
}
});
div.onclick = ()=> openConversation(item.id);
const titleSpan = document.createElement('span');
titleSpan.innerText = item.title;
const timeSpan = document.createElement('span');
timeSpan.className = "recent-date";
timeSpan.innerText = formatTime(item.utime || item.ctime);
div.appendChild(titleSpan);
div.appendChild(timeSpan);
wrap.appendChild(div);
})
}
// 没有会话就先建一个,拿到 conv_id
function ensureConv(){
if(currentConvId) return Promise.resolve(currentConvId);
if(creatingConv) return creatingConv;
const version = conversationVersion;
const pending = requestJson("/api/new_chat", {method:"POST"})
.then(function(data){
if(!data || !data.id) throw new Error('Session failed');
return data;
})
.then(data=>{
if(version !== conversationVersion){
const error = new Error('Conversation changed');
error.name = 'AbortError';
throw error;
}
currentConvId = data.id;
drafts.remove('new');
flushDraft();
if(data.welcome) addMsg(t('autoWelcome'), false);
return currentConvId;
}).finally(function(){ if(creatingConv === pending) creatingConv = null; });
creatingConv = pending;
return pending;
}
function resetConversation(id){
flushDraft();
emojiPicker.close();
if(voiceInput) voiceInput.cancel();
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
conversationVersion += 1;
clearAttachment();
stopHumanMode();
currentConvId = id;
restoreDraft();
openingConv = null;
creatingConv = null;
sendPreparing = false;
activeQuickTopic = null;
setBusy(false);
setComposerStatus('');
return conversationVersion;
}
// 打开历史会话:还原整段对话,而不是把标题重发一遍
let openingConv = null;
function openConversation(id){
if(openingConv === 'clearing') return;
if(openingConv === id) return; // 防连点
const version = resetConversation(id);
if(isNarrow()){ sideOpen = false; renderSidebar(); }
openingConv = id;
chatDom.innerHTML = '';
setComposerStatus(t('historyLoading'));
renderSideHistory();
requestJson("/api/get_messages?conv_id=" + id)
.then(msgs=>{
if(version !== conversationVersion) return;
if(!Array.isArray(msgs)) throw new Error('History failed');
openingConv = null;
setComposerStatus('');
chatDom.innerHTML = "";
if(!msgs.length){
chatDom.innerHTML = welcomeHTML();
return;
}
let previousQuestion = '';
msgs.forEach(m=>{
if(m.role === 'user') previousQuestion = m.content;
if(m.role === "assistant" && m.content.indexOf(CARD_PREFIX) === 0){
try{
const bubble = addTrackCard(JSON.parse(m.content.slice(CARD_PREFIX.length)), m.ctime);
attachActions(bubble, m.content, '', false);
}catch(e){
addMsg(m.content, false, undefined, m.ctime);
}
}else{
const b = addMsg(m.content, m.role === "user", undefined, m.ctime);
if(m.role === "assistant" && b) attachActions(b, m.content, "", false);
if(m.role === 'assistant' && b && (/^请先使用客户账号登录并关联订单/.test(m.content) || /^订单查询将在客户账号登录并关联后开放/.test(m.content))){
attachCustomerLogin(b, previousQuestion);
}
}
});
// 等渲染完再滚到底部
requestAnimationFrame(function(){ scrollChatToEnd(true); });
}).catch(function(){
if(version !== conversationVersion) return;
openingConv = 'failed';
setComposerStatus(t('historyLoadFailed'), true);
});
}
const CARD_PREFIX = "__CARD__";
// 把轨迹数据画成时间轴
function fillTrackCard(el, card){
el.innerHTML = "";
const isOrder = card.kind === 'order';
const isLogistics = isOrder && card.node_kind === 'logistics';
const head = document.createElement("div");
head.className = "track-head";
const no = document.createElement("div");
no.className = "track-no";
const inbound = isOrder && (card.fields || []).find(field=>field && field.key === 'inbound' && field.value);
const numberLabel = inbound ? 'order_inbound' : isOrder ? 'orderNo' : 'trackingNo';
no.innerHTML = '' + t(numberLabel) + "" + escHtml(inbound ? inbound.value : card.no);
const st = document.createElement("div");
st.className = "track-status";
st.textContent = card.status;
head.appendChild(no);
head.appendChild(st);
el.appendChild(head);
if(isOrder){
const fields = document.createElement('dl');
fields.className = 'order-data-fields is-collapsed';
(card.fields || []).forEach(function(field){
const label = document.createElement('dt');
label.dataset.i18n = 'order_'+field.key;
label.textContent = t('order_'+field.key) || field.key;
const value = document.createElement('dd');
value.textContent = field.value;
fields.append(label, value);
});
if((card.fields || []).length){
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'order-details-toggle';
toggle.setAttribute('aria-expanded', 'false');
toggle.dataset.i18n = 'moreOrderDetails';
toggle.textContent = t('moreOrderDetails');
toggle.onclick = function(){
const expanded = toggle.getAttribute('aria-expanded') !== 'true';
fields.classList.toggle('is-collapsed', !expanded);
toggle.setAttribute('aria-expanded', String(expanded));
toggle.dataset.i18n = expanded ? 'lessOrderDetails' : 'moreOrderDetails';
toggle.textContent = t(toggle.dataset.i18n);
};
el.appendChild(toggle);
el.appendChild(fields);
}
const title = document.createElement('p');
title.className = 'order-history-title';
title.dataset.i18n = isLogistics ? 'logisticsHistory' : 'orderHistory';
title.textContent = t(title.dataset.i18n);
el.appendChild(title);
if(isLogistics && card.tracking_status){
const current = document.createElement('p');
current.className = 'order-tracking-status';
current.textContent = card.tracking_status;
el.appendChild(current);
}
if(!(card.nodes || []).length){
const empty = document.createElement('p');
empty.className = 'order-history-empty';
empty.dataset.i18n = isLogistics ? 'noLogisticsHistory' : 'noOrderHistory';
empty.textContent = t(empty.dataset.i18n);
el.appendChild(empty);
}
}
function renderTimeline(nodes, forecast){
const timeline = document.createElement("div");
timeline.className = "track-timeline";
el.appendChild(timeline);
const visibleCount = window.matchMedia("(max-width: 520px)").matches ? 2 : 3;
nodes.forEach(function(n, i){
const node = document.createElement("div");
node.className = "track-node" + (i === 0 && !forecast ? " active" : "");
const dot = document.createElement("span");
dot.className = "track-dot";
const text = document.createElement("div");
text.className = "track-text";
text.innerHTML = '' + escHtml(n.time) + '' + escHtml(n.text) + "" +
'' + escHtml(n.place || "") + "";
node.appendChild(dot);
node.appendChild(text);
if(i >= visibleCount){ node.classList.add("track-earlier"); node.hidden = true; }
timeline.appendChild(node);
});
if(nodes.length > visibleCount){
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "track-expand";
toggle.setAttribute("aria-expanded", "false");
toggle.dataset.i18n = "moreTracking";
toggle.textContent = t("moreTracking");
toggle.onclick = function(){
const expanded = toggle.getAttribute("aria-expanded") !== "true";
timeline.querySelectorAll(".track-earlier").forEach(node => { node.hidden = !expanded; });
toggle.setAttribute("aria-expanded", String(expanded));
toggle.dataset.i18n = expanded ? "lessTracking" : "moreTracking";
toggle.textContent = t(toggle.dataset.i18n);
};
el.appendChild(toggle);
}
}
renderTimeline(card.nodes || [], false);
if(isLogistics && (card.estimated_nodes || []).length){
const title = document.createElement('p');
title.className = 'order-history-title';
title.dataset.i18n = 'estimatedTracking';
title.textContent = t('estimatedTracking');
el.appendChild(title);
renderTimeline(card.estimated_nodes, true);
}
}
function createMessageRow(isUser, authorRole){
const row = document.createElement("div");
row.className = "msg-row " + (isUser ? "user-line" : "robot-line");
const author = document.createElement("div");
author.className = "message-author";
const role = isUser ? "you" : (authorRole || "assistant");
author.innerHTML = (isUser ? "" : '' + welcomeIcon("box") + '') + '' + t(role) + '';
row.appendChild(author);
return row;
}
function appendMessageTime(row, timeStr){
const ts = document.createElement('div');
ts.className = 'msg-time';
const stored = typeof timeStr === 'string' && timeStr.match(/(?:^|[T\s])(\d{2}:\d{2})/);
const now = new Date();
ts.textContent = stored ? stored[1] : String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0');
if(timeStr) ts.title = timeStr;
row.appendChild(ts);
}
function addTrackCard(card, timeStr){
const row = createMessageRow(false);
const bubble = document.createElement("div");
bubble.className = "robot-message track-card";
fillTrackCard(bubble, card);
row.appendChild(bubble);
appendMessageTime(row, timeStr);
chatDom.appendChild(row);
scrollChatToEnd();
return bubble;
}
const restoreId = drafts.active();
currentConvId = restoreId === 'new' ? null : Number(restoreId);
restoreDraft();
if(currentConvId){
const id = currentConvId, version = conversationVersion, initialDraft = inputDom.value;
openingConv = 'restoring';
loadHistory().then(function(history){
if(version !== conversationVersion || currentConvId !== id || openingConv !== 'restoring') return;
if(Array.isArray(history) && !history.some(item=>item.id === id)){
if(inputDom.value !== initialDraft){
openingConv = 'failed';
flushDraft();
setComposerStatus(t('historyLoadFailed'), true);
return;
}
drafts.remove(id);
currentConvId = null;
restoreDraft();
openingConv = null;
return;
}
openConversation(id);
});
}else loadHistory();
window.fillInput = function(words){
inputDom.value = words;
return doSend();
}
// 点快捷问题:另起一个会话,不同话题就不会堆进同一条历史
window.askNewTopic = function(words){
if(openingConv === 'clearing') return;
if(activeQuickTopic === words && (sendPreparing || window.__busy)) return;
resetConversation(null);
activeQuickTopic = words;
// 新话题先重置界面,发送第一条消息时再收起欢迎内容。
if(!chatDom.querySelector('.ai-welcome-card')){
chatDom.innerHTML = welcomeHTML();
}
inputDom.value = words;
return doSend();
}
window.sendOrder = function(){
const no = prompt("请输入运单号:");
if(no && no.trim()){
inputDom.value = "我的运单号是 " + no.trim() + ",帮我查一下轨迹";
doSend();
}
}
function updateHumanMode(){
const banner = document.getElementById('human-session');
const label = document.getElementById('human-session-label');
const button = document.getElementById('return-ai');
if(banner) banner.hidden = !window.humanSid && !humanRequestToken;
const text = t(humanRequestToken ? 'humanConnecting' : humanStatus === 'active' ? 'humanActive' : 'humanWaiting');
if(label && label.textContent !== text) label.textContent = text;
if(button){
button.textContent = t('returnAI');
button.disabled = Boolean(humanRequestToken || humanClosing || humanSendingToken);
}
sendBtn.disabled = Boolean(humanRequestToken || humanClosing || humanSendingToken);
}
function stopHumanMode(){
if(humanPollTimer !== null) clearInterval(humanPollTimer);
humanPollTimer = null;
humanConvId = null;
humanStatus = '';
humanRequestToken = null;
humanSendingToken = null;
humanClosing = false;
window.humanSid = 0;
const typing = document.getElementById('human-typing');
if(typing) typing.remove();
updateHumanMode();
}
async function humanRequest(path, data){
return requestJson(path, {method:data ? 'POST' : 'GET',
headers:data ? {'Content-Type':'application/json'} : {},
body:data ? JSON.stringify(data) : undefined});
}
window.showHuman = async function(){
if(voiceInput) voiceInput.cancel();
if(openingConv === 'clearing') return;
if(openingConv !== null){ setComposerStatus(t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'), true); return; }
if(humanRequestToken || humanClosing) return;
if(window.humanSid && humanConvId === currentConvId){ updateHumanMode(); return; }
if(window.humanSid) stopHumanMode();
const token = {version:conversationVersion};
humanRequestToken = token;
if(cancelActive){ cancelActive(); cancelActive = null; }
setBusy(false);
updateHumanMode();
try{
const cid = await ensureConv();
if(humanRequestToken !== token || token.version !== conversationVersion) return;
const data = await humanRequest('/api/visitor/human/request', {conv_id:cid});
if(humanRequestToken !== token || token.version !== conversationVersion) return;
if(data.closed){ await leaveOfflineMessage(); return; }
if(!data.ok || !data.session_id) throw new Error('Human session failed');
window.humanSid = data.session_id;
humanConvId = cid;
humanStatus = 'waiting';
addMsg(t('humanWaiting'), false);
startHumanPoll();
}catch(error){
if(humanRequestToken === token && token.version === conversationVersion) setComposerStatus(t('humanConnectFailed'), true);
}finally{
if(humanRequestToken === token){ humanRequestToken = null; updateHumanMode(); }
}
};
async function returnToAI(){
if(!window.humanSid || humanClosing || humanSendingToken) return;
const sid = window.humanSid, version = conversationVersion;
humanClosing = true;
updateHumanMode();
try{
const result = await humanRequest('/api/visitor/human/close', {sid:sid});
if(!result.ok) throw new Error('Close failed');
if(version !== conversationVersion || window.humanSid !== sid) return;
stopHumanMode();
setComposerStatus('');
addMsg(t('humanClosed'), false);
focusComposer();
}catch(error){
if(version === conversationVersion) setComposerStatus(t('humanReturnFailed'), true);
}finally{
if(version === conversationVersion){ humanClosing = false; updateHumanMode(); }
}
}
const returnAIButton = document.getElementById('return-ai');
if(returnAIButton) returnAIButton.onclick = returnToAI;
async function sendHumanMessage(content){
if(humanSendingToken || humanClosing) return;
const sid = window.humanSid, cid = currentConvId, version = conversationVersion;
const draft = inputDom.value, attachment = window.__pendingImg;
const token = {};
humanSendingToken = token;
updateHumanMode();
setComposerStatus(t('humanSending'));
try{
const result = await humanRequest('/api/visitor/human/send', {sid:sid, conv_id:cid, side:'customer', content:content});
if(!result.ok) throw new Error('Send failed');
if(version !== conversationVersion || humanSendingToken !== token) return;
addMsg(content, true);
if(inputDom.value === draft){ inputDom.value = ''; autoGrow(); }
if(window.__pendingImg === attachment) clearAttachment();
setComposerStatus('');
focusComposer();
return true;
}catch(error){
if(version !== conversationVersion || humanSendingToken !== token) return;
if(error.status === 404 || error.status === 409) stopHumanMode();
setComposerStatus(t('humanSendFailed'), true);
return false;
}finally{
if(humanSendingToken === token){ humanSendingToken = null; updateHumanMode(); }
}
}
async function leaveOfflineMessage(){
const version = conversationVersion;
addMsg(t('humanHours'), false);
const message = (window.prompt(t('offlinePrompt')) || '').trim();
if(!message) return;
try{
const data = await humanRequest('/api/ticket', {content:message, phone:''});
if(!data.ok) throw new Error('Ticket failed');
if(version === conversationVersion) addMsg(t('offlineSaved'), false);
}catch(error){
if(version !== conversationVersion) return;
if(!inputDom.value){ inputDom.value = message; autoGrow(); }
setComposerStatus(error.status === 400 ? error.message : t('networkFail'), true);
}
}
function startHumanPoll(){
let last=0;
let polling=false;
const sid=window.humanSid;
const cid=humanConvId, version=conversationVersion;
let hadPollError=false;
if(humanPollTimer !== null) clearInterval(humanPollTimer);
const pollTimer=setInterval(()=>{
if(window.humanSid !== sid || version !== conversationVersion || currentConvId !== cid){ clearInterval(pollTimer); return; }
if(polling) return;
polling=true;
humanRequest("/api/visitor/human/poll?sid="+sid+"&after="+last)
.then(d=>{
if(window.humanSid !== sid || version !== conversationVersion || currentConvId !== cid) return;
if(hadPollError){ setComposerStatus(''); hadPollError=false; }
humanStatus=d.status;
updateHumanMode();
d.messages.forEach(m=>{
if(m.id <= last) return;
last = m.id;
if(m.recalled_id){
const recalled = chatDom.querySelector('[data-human-message-id="'+Number(m.recalled_id)+'"]');
if(recalled){ recalled.className='human-system-message'; recalled.textContent=t('recalled'); return; }
}
if(m.side === 'system'){
const system = document.createElement('div');
system.className = 'human-system-message';
system.setAttribute('data-human-message-id', String(m.id));
system.textContent = m.content;
chatDom.appendChild(system);
}else if(m.side === 'agent'){
const bubble = addMsg(m.content, false, 'humanAgent');
bubble.closest('.msg-row').setAttribute('data-human-message-id', String(m.id));
SupportUI.notify(t('humanReply'), m.content.substring(0, 50));
}
const typing = document.getElementById('human-typing');
if(typing) typing.remove();
});
if(d.status==="closed"){
if(humanClosing) return;
stopHumanMode();
setBusy(false);
addMsg(t('humanClosed'), false);
SupportUI.addRating(chatDom, sid, {question:t('ratingQuestion'), thanks:t('ratingThanks'), failed:t('ratingFailed'), fetch:fetch});
scrollChatToEnd();
return;
}
// 客服正在输入
var oldT = document.getElementById("human-typing");
if(d.typing && !oldT){
var td = document.createElement("div");
td.id = "human-typing";
td.style.cssText = "font-size:12px;color:#999;padding:4px 0;";
td.textContent = "客服正在输入...";
chatDom.appendChild(td);
scrollChatToEnd();
}else if(!d.typing && oldT){
oldT.remove();
}
})
.catch(function(){
if(version !== conversationVersion || window.humanSid !== sid) return;
hadPollError=true;
setComposerStatus(t('humanPollFailed'), true);
}).finally(function(){ polling=false; });
}, 1000);
humanPollTimer = pollTimer;
}
function addMsg(content, isUser, authorRole, timeStr){
if(chatDom.querySelector('.ai-welcome-card')) chatDom.innerHTML = "";
const rowDiv = createMessageRow(isUser, authorRole);
let robotMsg;
// 检测图片附件
let imgUrl = null;
if(content.indexOf("[图片]") >= 0){
const m = content.match(/\/uploads\/\S+\.(?:jpg|jpeg|png|gif|webp)/i);
if(m) imgUrl = m[0];
}
// 检测文件附件
let fileUrl = null;
if(content.indexOf("[文件]") >= 0){
const m = content.match(/\/uploads\/\S+\.(?:pdf|txt|csv|doc|docx|xls|xlsx|zip)/i);
if(m) fileUrl = m[0];
}
if(isUser){
rowDiv.classList.add("user-line");
const userMsg = document.createElement('div');
userMsg.className = "user-message";
if(imgUrl){
var txt = content.replace(/\[图片\]\/uploads\/\S+\.(?:jpg|jpeg|png|gif|webp)/i, "").trim();
const link = document.createElement("a");
link.href = imgUrl;
link.target = "_blank";
link.rel = "noopener";
const image = document.createElement("img");
image.src = imgUrl;
image.alt = "附件图片";
image.className = "message-image";
link.appendChild(image);
userMsg.appendChild(link);
if(txt){ const caption = document.createElement("div"); caption.textContent = txt; userMsg.appendChild(caption); }
}else if(fileUrl){
var fname = fileUrl.split("/").pop();
const link = document.createElement("a");
link.href = fileUrl;
link.target = "_blank";
link.rel = "noopener";
link.className = "message-file";
link.textContent = "↗ " + fname;
userMsg.appendChild(link);
const caption = content.replace("[文件]" + fileUrl, "").trim();
if(caption){ const text = document.createElement("div"); text.textContent = caption; userMsg.appendChild(text); }
}else{
userMsg.innerText = content;
}
rowDiv.appendChild(userMsg);
}else{
rowDiv.classList.add("robot-line");
robotMsg = document.createElement('div');
robotMsg.className = "robot-message";
robotMsg.innerHTML = parseBold(content);
rowDiv.appendChild(robotMsg);
}
// 时间戳
appendMessageTime(rowDiv, timeStr);
chatDom.appendChild(rowDiv);
scrollChatToEnd(Boolean(isUser));
if(!isUser){
return robotMsg;
}else{
return rowDiv;
}
}
function renderError(bubble, text, retry){
bubble.classList.remove("typing");
bubble.classList.add("error-bubble");
if(!bubble.dataset.raw) bubble.innerHTML = "";
const line = document.createElement("div");
line.className = "err-line";
line.textContent = "⚠️ " + (/API Key|DEEPSEEK/i.test(text) ? t("serviceUnavailable") : text);
const btn = document.createElement("button");
btn.className = "retry-btn";
btn.textContent = t("clickRetry");
btn.onclick = retry;
bubble.appendChild(line);
bubble.appendChild(btn);
scrollChatToEnd();
}
// 复制用:把 Markdown 标记去掉,得到干净纯文本
function mdToPlain(md){
if(md.indexOf(CARD_PREFIX) === 0){
try{
const card = JSON.parse(md.slice(CARD_PREFIX.length));
if(card.kind === 'order') return card.summary || card.no;
const lines = ["运单号 " + card.no + "(" + (card.status || "") + ")"];
(card.nodes || []).forEach(function(n){
lines.push(n.time + " " + n.text + (n.place ? " " + n.place : ""));
});
return lines.join("\n");
}catch(e){ /* 解析失败就走普通处理 */ }
}
return String(md)
.replace(/`([^`]+)`/g, "$1") // 行内代码
.replace(/\*\*(.+?)\*\*/g, "$1") // 加粗
.replace(/^[-*•]\s+/gm, "· ") // 列表符号换成 ·
.replace(/^\d+[.、)]\s*/gm, "") // 去掉编号前缀
.replace(/“|”/g, '"');
}
function attachCustomerLogin(bubble, question){
const button = document.createElement('button');
button.type = 'button'; button.className = 'customer-login-cta';
button.textContent = t('customerQuery');
const version = conversationVersion;
button.onclick = function(){
customerLogin.require(async function(){
if(version === conversationVersion && question) await retryAnswer(bubble, question);
});
};
bubble.appendChild(button);
}
async function retryAnswer(bubble, question){
if(window.humanSid || humanRequestToken){ setComposerStatus(t('retryAIOnly')); return; }
if(await doSend(question)){
const row = bubble.closest('.msg-row');
if(row) row.remove();
}
}
// 给一条回答挂上「复制 / 重答」按钮
function attachActions(bubble, rawText, question, showRetry){
const old = bubble.querySelector(".msg-actions");
if(old) old.remove();
if(!rawText) return;
const bar = document.createElement("div");
bar.className = "msg-actions";
const copyBtn = document.createElement("button");
copyBtn.className = "msg-action-btn";
copyBtn.dataset.action = "copy";
copyBtn.textContent = t("copy");
copyBtn.onclick = function(){
if(copyBtn.disabled) return;
copyBtn.disabled = true;
const plain = mdToPlain(rawText);
const done = function(copied){
copyBtn.disabled = false;
copyBtn.textContent = t(copied ? 'copied' : 'copyFailed');
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, copied ? 1200 : 3000);
};
if(navigator.clipboard && navigator.clipboard.writeText){
return navigator.clipboard.writeText(plain).then(()=>done(true)).catch(fallback);
}else{
return fallback();
}
function fallback(){
const ta = document.createElement("textarea");
ta.value = plain;
ta.readOnly = true;
ta.style.position = "fixed";
ta.style.opacity = "0";
const previousFocus = document.activeElement;
document.body.appendChild(ta);
let copied = false;
try{ ta.focus({preventScroll:true}); ta.select(); copied = Boolean(document.execCommand("copy")); }catch(e){}
finally{
ta.remove();
if(previousFocus && previousFocus.isConnected) previousFocus.focus({preventScroll:true});
}
done(copied);
}
};
bar.appendChild(copyBtn);
if(showRetry){
const retryBtn = document.createElement("button");
retryBtn.className = "msg-action-btn";
retryBtn.dataset.action = "retry";
retryBtn.textContent = t("retry");
retryBtn.onclick = function(){ return retryAnswer(bubble, question); };
bar.appendChild(retryBtn);
}
// 👍/👎 反馈
let votePending = false;
const voteButtons = [];
const voteStatus = document.createElement('span');
voteStatus.className = 'vote-status';
voteStatus.setAttribute('role', 'status');
function addVote(vote, label){
const b = document.createElement("button");
b.className = "msg-action-btn";
b.textContent = label;
b.dataset.action = vote === 'up' ? 'voteUp' : 'voteDown';
b.setAttribute('aria-label', t(vote === 'up' ? 'voteUp' : 'voteDown'));
b.setAttribute('aria-pressed', 'false');
b.onclick = async function(){
if(votePending || b.classList.contains('voted')) return;
votePending = true;
voteButtons.forEach(button => { button.disabled = true; });
voteStatus.textContent = '';
voteStatus.removeAttribute('data-i18n');
voteStatus.classList.remove('is-error');
try{
const data = await requestJson('/api/feedback', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({question:question, answer:rawText, vote:vote})
});
if(!data.ok) throw new Error('Feedback failed');
voteButtons.forEach(function(button){
button.classList.toggle('voted', button === b);
button.setAttribute('aria-pressed', String(button === b));
});
voteStatus.textContent = t('ratingThanks');
voteStatus.setAttribute('data-i18n', 'ratingThanks');
}catch(error){
voteStatus.textContent = t('ratingFailed');
voteStatus.setAttribute('data-i18n', 'ratingFailed');
voteStatus.classList.add('is-error');
}finally{
votePending = false;
voteButtons.forEach(button => { button.disabled = false; });
}
};
voteButtons.push(b);
bar.appendChild(b);
}
addVote("up", "👍");
addVote("down", "👎");
bar.appendChild(voteStatus);
bubble.appendChild(bar);
}
// 回答下方的"相关追问":点一下就接着问
function renderFollowups(bubble){
let list = [];
try{ list = JSON.parse(bubble.dataset.suggest || "[]"); }catch(e){ list = []; }
if(!list.length) return;
const wrap = document.createElement("div");
wrap.className = "followup-list";
list.forEach(function(q){
const item = document.createElement("div");
item.className = "followup-item";
item.innerHTML = "›";
item.firstChild.textContent = q;
item.onclick = function(){
wrap.remove();
inputDom.value = q;
doSend();
};
wrap.appendChild(item);
});
bubble.appendChild(wrap);
}
async function doSend(retryText){
if(voiceInput && voiceInput.busy()){ setComposerStatus(t('voiceWait')); return false; }
const isRetry = typeof retryText === "string";
const sendWord = (isRetry ? retryText : inputDom.value).trim();
var imgUrl = isRetry ? "" : (window.__pendingImg || "");
var tag = window.__pendingTag || "[图片]";
const draft = inputDom.value, attachmentVersion = uploadVersion;
if(!sendWord && !imgUrl){
focusComposer(true);
return;
}
if(uploadPending){ setComposerStatus(t("uploading")); return; }
if(humanSendingToken || humanClosing) return;
var fullContent = sendWord;
if(imgUrl) fullContent += (fullContent ? " " : "") + tag + imgUrl;
setComposerStatus("");
if(openingConv !== null){ setComposerStatus(t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'), true); return; }
if(humanRequestToken || humanClosing){ updateHumanMode(); return; }
// 人工客服模式:直接发给客服,不走AI
if(window.humanSid && humanConvId === currentConvId) return sendHumanMessage(fullContent);
if(window.humanSid) stopHumanMode();
if(sendPreparing || (window.__busy && !retryText)) return; // 防连点
if(cancelActive){ cancelActive(); cancelActive = null; }
sendPreparing = true;
const version = conversationVersion;
let convId;
try{
convId = await ensureConv();
}catch(error){
if(version === conversationVersion) setComposerStatus(t('sessionFail'), true);
return;
}finally{
if(version === conversationVersion) sendPreparing = false;
}
if(version !== conversationVersion || humanRequestToken) return;
if(!isRetry){
addMsg(fullContent, true);
if(inputDom.value === draft){ inputDom.value = ""; autoGrow(); }
if(attachmentVersion === uploadVersion) clearAttachment();
focusComposer();
}
const aiBubble = addMsg(t("thinking"), false);
aiBubble.classList.add("typing");
activeBubble = aiBubble;
setBusy(true);
let es = null;
let firstChunk = true;
let settled = false; // 已经结束(成功或失败),用它区分"正常关闭"和"真断开"
const firstTimer = setTimeout(function(){ fail("模型响应超时,请稍后重试"); }, 90000);
const totalTimer = setTimeout(function(){ fail("生成时间过长,已中断,请重试"); }, 120000);
function cleanup(){
clearTimeout(firstTimer);
clearTimeout(totalTimer);
const cur = es;
es = null;
if(cur){
cur.close();
if(sseInstance === cur) sseInstance = null;
}
}
function finish(){
if(settled) return;
settled = true;
cleanup();
setBusy(false);
aiBubble.classList.remove("typing");
if(activeBubble === aiBubble) activeBubble = null;
let raw = aiBubble.dataset.raw || "";
// 模型有时会把卡片标记原样输出,这里兜底转成时间轴
if(raw.indexOf(CARD_PREFIX) === 0){
try{
const card = JSON.parse(raw.slice(CARD_PREFIX.length));
aiBubble.classList.add("track-card");
fillTrackCard(aiBubble, card);
raw = "运单号 " + card.no + "(" + (card.status || "") + ")";
}catch(e){ /* 解析失败就按普通文本显示 */ }
}
attachActions(aiBubble, raw, fullContent, !aiBubble.dataset.loginRequired);
if(aiBubble.dataset.loginRequired) attachCustomerLogin(aiBubble, fullContent);
renderFollowups(aiBubble);
updateToBottom();
loadHistory(); // 首条消息会让标题变成问题内容,这里刷新一下
}
function fail(text){
if(settled) return;
settled = true;
cleanup();
setBusy(false);
if(activeBubble === aiBubble) activeBubble = null;
renderError(aiBubble, text || t("busyRetry"), function(){
return retryAnswer(aiBubble, fullContent);
});
}
// 静默中止(用户点了停止 / 开了新对话 / 问了新问题)
function stop(){
if(settled) return;
settled = true;
cleanup();
setBusy(false);
aiBubble.classList.remove("typing");
if(activeBubble === aiBubble) activeBubble = null;
const raw = aiBubble.dataset.raw || "";
if(raw){
// 已生成的部分保留,并标注已停止
const hint = document.createElement("div");
hint.className = "stopped-hint";
hint.textContent = t("stoppedMark");
aiBubble.appendChild(hint);
attachActions(aiBubble, raw, fullContent, false);
}else{
aiBubble.innerHTML = t("stopped");
aiBubble.classList.add("hint-bubble");
}
}
cancelActive = stop;
function handleFrame(dataText){
if(settled || version !== conversationVersion) return;
let obj;
try{
obj = JSON.parse(dataText);
}catch(err){
obj = {t: dataText}; // 兼容旧的纯文本帧
}
if(!obj || typeof obj !== 'object') return;
if(obj.e){ fail(obj.e); return; }
if(obj.login_required) aiBubble.dataset.loginRequired = '1';
if(obj.card){
clearTimeout(firstTimer);
aiBubble.classList.add("track-card");
fillTrackCard(aiBubble, obj.card);
aiBubble.dataset.raw = obj.card.kind === 'order' ? obj.card.summary : "运单号 " + obj.card.no + "(" + (obj.card.status || "") + ")\n" +
(obj.card.nodes || []).map(function(n){ return n.time + " " + n.text + " " + (n.place || ""); }).join("\n");
firstChunk = false;
scrollChatToEnd();
return;
}
if(obj.replace){
clearTimeout(firstTimer);
aiBubble.dataset.raw = obj.replace;
aiBubble.innerHTML = parseBold(obj.replace);
scrollChatToEnd();
return;
}
if(obj.suggest){
aiBubble.dataset.suggest = JSON.stringify(obj.suggest);
return;
}
if(obj.d){ finish(); return; }
if(typeof obj.t !== "string") return;
clearTimeout(firstTimer);
if(firstChunk){
aiBubble.innerHTML = "";
aiBubble.dataset.raw = "";
firstChunk = false;
}
const raw = (aiBubble.dataset.raw || "") + obj.t;
aiBubble.dataset.raw = raw;
aiBubble.innerHTML = parseBold(raw);
scrollChatToEnd();
}
// 用 POST + fetch 流式读取:问题放在请求体里,不受 URL 长度限制
const controller = new AbortController();
es = { close: function(){ try{ controller.abort(); }catch(e){} } };
sseInstance = es;
// 保留 stop 里的 close 调用
const prevStop = cancelActive;
cancelActive = function(){ prevStop && prevStop(); try{ controller.abort(); }catch(e){} };
fetch("/stream_chat", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({msg: fullContent, conv_id: convId}),
signal: controller.signal
}).then(async function(res){
if(!res.ok){
let data;
try{ data = await res.json(); }catch(e){}
fail(data && (data.e || data.error) || t('networkFail'));
return;
}
if(!res.body || typeof res.body.getReader !== 'function'){ fail(t('networkFail')); return; }
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
function drain(final){
function dispatch(frame){
const data = frame.split(/\r?\n/).filter(line => line.indexOf('data:') === 0)
.map(line => line.slice(5).trimStart()).join('\n');
if(data) handleFrame(data);
}
let boundary;
while(!settled && (boundary = /\r?\n\r?\n/.exec(buffer))){
const frame = buffer.slice(0, boundary.index);
buffer = buffer.slice(boundary.index + boundary[0].length);
dispatch(frame);
}
if(final && !settled && buffer.trim()){ dispatch(buffer); buffer = ''; }
}
function pump(){
return reader.read().then(function(r){
if(settled) return;
if(r.done){
buffer += decoder.decode();
drain(true);
if(!settled) fail(t("networkFail"));
return;
}
buffer += decoder.decode(r.value, {stream: true});
drain(false);
if(!settled) return pump();
});
}
return pump();
}).catch(function(err){
if(err && err.name === "AbortError") return; // 用户主动停止
if(settled) return;
fail(t("networkFail"));
});
return true;
}
// ===== 发送 / 停止 按钮状态 =====
// 生成中按钮变成“停止”,点击走 cancelActive
function setBusy(on){
window.__busy = on;
sendBtn.textContent = on ? t("stop") : t("send");
sendBtn.classList.toggle("stop", on);
}
sendBtn.addEventListener("click", function(){
if(window.__busy && cancelActive){
cancelActive();
cancelActive = null;
setBusy(false);
return;
}
doSend();
});
// ===== 多行输入框:自动增高 =====
function autoGrow(){
queueDraft();
if(!inputDom.value){
inputDom.style.height = '';
return;
}
inputDom.style.height = "auto";
const viewport = window.visualViewport;
const height = viewport && Math.abs(viewport.scale - 1) < 0.01 ? viewport.height : window.innerHeight;
const limit = isNarrow() && height > 0 ? Math.min(140, Math.max(44, Math.floor(height / 4))) : 140;
inputDom.style.height = Math.min(inputDom.scrollHeight, limit) + "px";
}
inputDom.addEventListener("input", autoGrow);
window.addEventListener('pagehide', flushDraft);
document.addEventListener('visibilitychange', function(){ if(document.hidden) flushDraft(); });
inputDom.addEventListener("keydown",(e)=>{
// Enter 发送,Shift+Enter 换行
if(e.key === 'Enter' && !e.shiftKey && !e.isComposing){
e.preventDefault();
if(window.humanSid || !window.__busy) doSend();
}
});
// ===== 回到底部 =====
const toBottomBtn = document.getElementById("to-bottom");
function updateToBottom(){
const gap = chatDom.scrollHeight - chatDom.scrollTop - chatDom.clientHeight;
toBottomBtn.classList.toggle("show", gap > 160);
}
chatDom.addEventListener("scroll", function(){
followLatest = chatDom.scrollHeight - chatDom.scrollTop - chatDom.clientHeight < 96;
updateToBottom();
});
toBottomBtn.onclick = ()=>{
followLatest = true;
chatDom.scrollTo({top: chatDom.scrollHeight, behavior:"smooth"});
};
if(window.ResizeObserver){
new ResizeObserver(function(){
document.querySelector('.main-chat').style.setProperty('--composer-height', document.querySelector('.composer').offsetHeight + 'px');
if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd();
updateToBottom();
}).observe(document.querySelector('.composer'));
}
const sidePanel = document.querySelector('.left-panel');
const toggleBtn = document.querySelector('.toggle-divider');
const chatContainer = document.querySelector('.chat-container');
let wasNarrow = isNarrow();
let sideOpen = !wasNarrow;
function isNarrow(){
return window.matchMedia("(max-width: 900px)").matches;
}
function renderSidebar(){
const narrow = isNarrow();
chatContainer.classList.toggle('side-closed', narrow && !sideOpen);
sidePanel.classList.toggle('left-panel-hide', !narrow && !sideOpen);
sidePanel.setAttribute('aria-hidden', String(!sideOpen));
toggleBtn.setAttribute('aria-expanded', String(sideOpen));
toggleBtn.title = t(sideOpen ? 'sideCollapse' : 'sideExpand');
toggleBtn.textContent = "";
inputDom.placeholder = t(narrow ? 'inputShort' : 'inputPh');
}
renderSidebar();
function syncChatViewport(){
const viewport = window.visualViewport;
const narrow = isNarrow();
const unzoomed = !viewport || Math.abs(viewport.scale - 1) < 0.01;
if(narrow && unzoomed){
const height = viewport ? viewport.height : window.innerHeight;
if(Number.isFinite(height) && height > 0){
chatContainer.style.height = Math.round(height) + 'px';
// Keyboard focus may pan the visual viewport without changing its height.
chatContainer.style.top = Math.max(0, Math.round(viewport && viewport.offsetTop || 0)) + 'px';
}
}else{
chatContainer.style.height = '';
chatContainer.style.top = '';
}
if(customerLogin) customerLogin.viewport(chatContainer.style.height, chatContainer.style.top);
chatContainer.classList.toggle('is-composing', narrow && document.activeElement === inputDom);
autoGrow();
if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd();
updateToBottom();
}
let viewportSyncQueued = false;
function scheduleChatViewport(){
if(viewportSyncQueued) return;
viewportSyncQueued = true;
requestAnimationFrame(function(){ viewportSyncQueued = false; syncChatViewport(); });
}
toggleBtn.onclick = function(){
sideOpen = !sideOpen;
renderSidebar();
}
document.querySelector('.sidebar-backdrop').onclick = function(){
sideOpen = false;
renderSidebar();
};
document.addEventListener('keydown', function(event){
if(event.key === 'Escape' && isNarrow() && sideOpen){
sideOpen = false;
renderSidebar();
toggleBtn.focus();
}
});
// 从窄屏切到宽屏时,还原侧栏状态,避免卡在隐藏
window.addEventListener("resize", function(){
const narrow = isNarrow();
if(narrow !== wasNarrow){
sideOpen = !narrow;
wasNarrow = narrow;
}
renderSidebar();
syncChatViewport();
});
if(window.visualViewport){
window.visualViewport.addEventListener('resize', scheduleChatViewport);
window.visualViewport.addEventListener('scroll', scheduleChatViewport);
}
window.addEventListener('pageshow', scheduleChatViewport);
inputDom.addEventListener('focus', scheduleChatViewport);
inputDom.addEventListener('blur', scheduleChatViewport);
// Let toolbar clicks complete before blur changes the keyboard and composer layout.
['pointerdown', 'mousedown'].forEach(function(name){
document.querySelector('.composer').addEventListener(name, function(event){
if(event.button === 0 && document.activeElement === inputDom && event.target.closest('button')){
event.preventDefault();
}
});
});
syncChatViewport();
voiceInput = ChatVoice.create({
fetch:fetch,
toggle:document.getElementById('voice-toggle'), hold:document.getElementById('voice-hold'),
panel:document.getElementById('voice-panel'), label:document.getElementById('voice-label'),
clock:document.getElementById('voice-clock'), inputArea:document.querySelector('.input-area'), input:inputDom,
text:t, status:setComposerStatus, language:() => LANG,
context:() => conversationVersion + ':' + currentConvId + ':' + window.humanSid,
canStart:function(){
if(uploadPending || sendPreparing || window.__busy || openingConv !== null || humanRequestToken || humanSendingToken || humanClosing) return t('voiceWait');
return inputDom.value.trim() ? t('voiceDraft') : '';
},
onText:async function(text){
if(inputDom.value.trim()){
inputDom.value += '\n' + text;
autoGrow(); voiceInput.setMode(false); return;
}
inputDom.value = text;
autoGrow();
if(!await doSend()) voiceInput.setMode(false);
}
});
customerLogin = CustomerLogin.create({fetch:fetch, text:t,
onOpen:function(){ if(voiceInput) voiceInput.cancel(); sideOpen=false; renderSidebar(); syncChatViewport(); },
onChange:function(user,previous){
if(previous === undefined) return;
if(previous && (!user || user.id !== previous.id)){
resetConversation(null); chatDom.innerHTML=welcomeHTML();
}
loadHistory();
},
onError:function(message){ setComposerStatus(message,true); }
});
customerLogin.viewport(chatContainer.style.height,chatContainer.style.top);
customerLogin.refresh();
window.addEventListener('resize', function(){
if(!isNarrow()) voiceInput.setMode(false);
});
document.querySelectorAll('[data-question-index]').forEach(function(button){
button.addEventListener('click', function(){
window.askNewTopic(t('quick')[Number(button.dataset.questionIndex)]);
});
});
chatDom.addEventListener('click', function(event){
const question = event.target.closest('button[data-question]');
if(question) window.askNewTopic(question.dataset.question);
const human = event.target.closest('button[data-human-support]');
if(human) window.showHuman();
});
});