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 += ""; html += "