Files

2019 lines
85 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {"&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;"}[c];
});
}
// 轻量 Markdown:加粗、行内代码、无序/有序列表、换行
function mdToHtml(text){
const inline = function(s){
return s.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
.replace(/`([^`]+)`/g, "<code>$1</code>");
};
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 += "<ul>"; list = "ul"; }
html += "<li>" + inline(m[1]) + "</li>";
} else if ((m = t.match(/^\d+[.、)]\s*(.*)$/))){ // 有序列表
if (list !== "ol"){ if (list) html += "</" + list + ">"; html += "<ol>"; list = "ol"; }
html += "<li>" + inline(m[1]) + "</li>";
} else if (!t){ // 空行
if (list){ html += "</" + list + ">"; list = null; }
html += "<br>";
} else { // 普通段落
if (list){ html += "</" + list + ">"; list = null; }
html += inline(t) + "<br>";
}
}
if (list) html += "</" + list + ">";
return html;
}
function parseBold(text){
return mdToHtml(text);
}
// 空会话时的欢迎内容(新建对话 / 打开空会话都要用)
function welcomeIcon(kind){
const paths = {
box: '<path d="m4 7 8-4 8 4v10l-8 4-8-4Z"/><path d="m4 7 8 4 8-4M12 11v10M8 5l8 4"/>',
track: '<circle cx="10" cy="10" r="6"/><path d="m15 15 5 5M7 10h6M10 7v6"/>',
quote: '<rect x="5" y="3" width="14" height="18" rx="2"/><path d="M8 7h8M8 11h3M14 11h2M8 15h3M14 15h2M8 18h3M14 18h2"/>',
issue: '<path d="m12 3 10 18H2L12 3Z M12 9v5M12 17v.1"/>',
human: '<path d="M4 14v-3a8 8 0 0 1 16 0v3M19 17a4 4 0 0 1-4 4h-3"/><rect x="2" y="11" width="4" height="7" rx="2"/><rect x="18" y="11" width="4" height="7" rx="2"/>'
};
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + paths[kind] + '</svg>';
}
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 `<button type="button" class="suggest-item" ${action}><span class="suggest-icon">${welcomeIcon(kinds[index])}</span><span class="suggest-copy"><strong>${question}</strong></span><span class="suggest-arrow" aria-hidden="true">↗</span></button>`;
}).join("");
return `
<div class="ai-welcome-card">
<div class="ai-welcome-top">
<div class="ai-avatar">${welcomeIcon("box")}</div>
<div class="welcome-eyebrow">${t("welcomeEyebrow")}</div>
<h3>${t("welcomeTitle")}</h3>
</div>
<div class="suggest-grid">${cards}</div>
</div>
<div class="hot-questions">
<h4>${t("hot")}</h4>
<div class="hot-capsule-wrap">
${h.map(question => `<button type="button" class="hot-capsule" data-question="${escHtml(question)}">${question}</button>`).join("")}
</div>
</div>
<div class="recent-chat" hidden>
<h4>${t("recent")}</h4>
<div id="recentChatWrap"></div>
</div>
`;
}
// 清空全部历史
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 = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>" + 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 = '<span class="track-time">' + escHtml(n.time) + '</span><strong class="track-event">' + escHtml(n.text) + "</strong>" +
'<span class="track-place">' + escHtml(n.place || "") + "</span>";
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 ? "" : '<span class="author-mark">' + welcomeIcon("box") + '</span>') + '<span data-i18n="' + role + '">' + t(role) + '</span>';
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 = "<span></span><span class='arrow'>›</span>";
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();
});
});