2019 lines
85 KiB
JavaScript
2019 lines
85 KiB
JavaScript
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, "<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();
|
||
});
|
||
|
||
});
|