document.addEventListener('DOMContentLoaded', function(){
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");
let uploadPending = false;
let uploadVersion = 0;
let uploadController = null;
let historyRequestVersion = 0;
let followLatest = true;
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;
SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
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;
const hint = document.createElement("small");
hint.textContent = t("attachmentReady");
copy.append(name, hint);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "attachment-remove";
remove.textContent = "×";
remove.setAttribute("aria-label", t("removeAttachment"));
remove.onclick = function(){ clearAttachment(); inputDom.focus(); };
prev.append(copy, remove);
var area = document.querySelector(".input-area");
area.before(prev);
setComposerStatus("");
inputDom.focus();
}
})
.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: "柏悦物流智能助手",
newChat: "新建对话",
clearAll: "清空全部历史",
history: "历史对话",
searchPh: "搜索历史对话...",
confirmClear: "确定清空全部历史对话吗?无法恢复!",
confirmDel: "确定要删除这条历史对话吗?",
emptySearch: "没有匹配的历史对话",
emptyHistory: "暂无历史对话",
thinking: "正在思考中...",
busyRetry: "服务繁忙,请稍后重试",
sessionFail: "会话创建失败,请确认服务已启动",
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入订单号、入仓码或 Shipment ID 可查订单,或点击下方快捷选项。",
humanHours: "人工客服工作时间:每天 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
offlinePrompt: "请输入您的留言:",
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
humanClosed: "人工客服已结束,您可以继续向 AI 提问。",
humanReply: "客服回复",
humanConnecting: "正在为您连接人工客服…",
humanWaiting: "正在排队,消息将由人工客服接入后回复。您也可以返回 AI 继续咨询。",
humanActive: "人工客服接待中,消息将发送给人工客服。",
returnAI: "返回 AI 问答",
humanSent: "已发送给人工客服",
humanSending: "正在发送给人工客服…",
humanSendFailed: "消息未发送成功,内容已保留,请重试。",
humanConnectFailed: "人工客服连接失败,请稍后重试。",
humanPollFailed: "人工客服连接中断,正在重试。您可以返回 AI 继续咨询。",
humanReturnFailed: "暂时无法结束人工会话,请重试。",
historyLoading: "正在加载这段会话,请稍候…",
recalled: "该消息已撤回",
ratingQuestion: "本次服务满意吗?",
ratingThanks: "感谢您的评价!",
ratingFailed: "评价未提交,请重试。",
networkFail: "网络连接中断,请检查网络后重试",
stopped: "已停止生成",
stoppedMark: "(已停止生成)",
inputPh: "请输入问题、订单号或入仓码…(Enter 发送,Shift+Enter 换行)",
send: "发送",
stop: "停止",
copy: "复制",
copied: "已复制",
retry: "重答",
clickRetry: "点击重试",
welcomeTitle: "物流问题,随时为您解答",
welcomeSub: "查询订单状态、了解专线报价,或联系人工客服。",
welcomeEyebrow: "中欧 · 英国物流服务",
serviceScope: "中欧与英国专线咨询",
serviceTag: "物流咨询服务",
quickLabel: "快捷咨询",
footerTrack: "查订单",
footerQuote: "专线报价",
footerException: "异常处理",
footerHuman: "联系人工",
disclaimer: "报价与时效仅供参考,请以客服最终确认为准",
keyboardHint: "Enter 发送 · Shift + Enter 换行",
inputShort: "输入问题、订单号或入仓码…",
uploading: "附件正在上传,请稍候…",
uploadFailed: "附件上传失败,请重新选择文件",
serviceUnavailable: "服务暂时不可用,请稍后重试,或联系人工客服。",
attachmentReady: "已准备好,发送后将附在消息中",
removeAttachment: "移除待发送附件",
you: "您",
assistant: "柏悦物流助手",
humanAgent: "人工客服",
trackingNo: "运单号",
orderNo: "订单号",
orderHistory: "订单业务状态历史",
noOrderHistory: "未提供订单状态历史",
legacyTrackingNotice: "历史模拟轨迹,仅供演示,不代表真实订单。",
order_inbound: "入仓码", order_shipment: "Shipment ID", order_country: "目的国",
order_warehouse: "目的仓", order_transport: "运输方式", order_packages: "件数",
order_weight: "申报毛重", order_volume: "申报体积", order_role: "订单层级",
order_parent: "父订单号", order_updated: "来源更新时间",
moreTracking: "展开全部轨迹",
lessTracking: "收起较早轨迹",
quickSubs: ["输入订单号、入仓码或 Shipment ID", "了解线路、费用与预计时效", "咨询延误、签收或理赔问题", "进一步沟通您的物流需求"],
welcomeTip: "您可以直接输入问题,也可以点击下面选项快速提问。",
hot: "热点问题",
recent: "最近对话记录",
sideCollapse: "收起侧栏",
sideExpand: "展开侧栏",
themeTip: "切换深色/浅色",
langTip: "中英文切换(Ctrl+L)",
toBottom: "回到底部",
quick: [
"查询订单状态", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
],
hotList: [
"如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?"
]
},
en: {
appTitle: "Baiyue Logistics AI Assistant",
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. Enter an order number, inbound code or Shipment ID, or choose a quick option below.",
humanHours: "Our team is available daily, 08:00–22:00. 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",
humanSent: "Sent to our support team",
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…",
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: "Ask a question or enter a tracking number... (Enter to send, Shift+Enter for newline)",
send: "Send",
stop: "Stop",
copy: "Copy",
copied: "Copied",
retry: "Regenerate",
clickRetry: "Retry",
welcomeTitle: "Your next shipment, made clearer.",
welcomeSub: "Track a parcel, explore freight options, or speak to our team.",
welcomeEyebrow: "EUROPE & UK LOGISTICS",
serviceScope: "Europe & UK freight support",
serviceTag: "Logistics support",
quickLabel: "Quick help",
footerTrack: "Order lookup",
footerQuote: "Get a quote",
footerException: "Parcel issue",
footerHuman: "Human support",
disclaimer: "Quotes and transit times are subject to final confirmation",
keyboardHint: "Enter to send · Shift + Enter for a new line",
inputShort: "Question or tracking number…",
uploading: "Uploading your attachment…",
uploadFailed: "Upload failed. Please select the file again.",
serviceUnavailable: "Service is temporarily unavailable. Please try again later or contact support.",
attachmentReady: "Ready to attach to your next message",
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",
legacyTrackingNotice: "Historical simulated tracking for demonstration only; this is not a real order record.",
order_inbound: "Inbound code", 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",
quickSubs: ["Check the latest shipment progress", "Explore routes, rates and transit times", "Get help with delays, delivery or claims", "Discuss your shipping requirements"],
welcomeTip: "Type your question, or pick a quick option below.",
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 my shipment", "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];
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(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;
});
document.querySelectorAll(".retry-btn").forEach(function(b){
if(!b.dataset.keep) b.textContent = dict.clickRetry;
});
}
(function initLang(){
let saved = null;
try{ saved = localStorage.getItem("wuliu-lang"); }catch(e){}
if(!saved){
saved = (navigator.language || "zh").toLowerCase().indexOf("zh") === 0 ? "zh" : "en";
}
applyLanguage(saved);
const btn = document.getElementById("lang-toggle");
if(btn){
btn.onclick = function(){ applyLanguage(LANG === "en" ? "zh" : "en"); };
}
// 快捷键:Ctrl+L 切换语言(不占用输入框)
document.addEventListener("keydown", function(e){
if((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l"){
e.preventDefault();
applyLanguage(LANG === "en" ? "zh" : "en");
}
});
})();
// ===== 历史搜索 =====
const historySearch = document.getElementById("history-search");
if(historySearch){
historySearch.addEventListener("input", renderSideHistory);
}
let historyList = [];
let sseInstance = null;
let activeBubble = null; // 正在输出的气泡
let cancelActive = null; // 中止当前请求(切换对话/重复发送时用)
let currentConvId = null; // 当前会话 id,null 表示还没建
function formatTime(timeStr) {
const target = new Date(timeStr);
const y = target.getFullYear();
const m = String(target.getMonth() + 1).padStart(2, '0');
const d = String(target.getDate()).padStart(2, '0');
const h = String(target.getHours()).padStart(2, '0');
const min = String(target.getMinutes()).padStart(2, '0');
return `${y}-${m}-${d} ${h}:${min}`;
}
// 先转义再套格式,防止模型输出里的标签被浏览器执行
function escHtml(s){
return String(s).replace(/[&<>"]/g, function(c){
return {"&":"&", "<":"<", ">":">", '"':"""}[c];
});
}
// 轻量 Markdown:加粗、行内代码、无序/有序列表、换行
function mdToHtml(text){
const inline = function(s){
return s.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/`([^`]+)`/g, "$1");
};
const lines = escHtml(text).split("\n");
let html = "";
let list = null;
for (let i = 0; i < lines.length; i++){
const t = lines[i].trim();
let m;
if ((m = t.match(/^[-*•]\s+(.*)$/))){ // 无序列表
if (list !== "ul"){ if (list) html += "" + list + ">"; html += "
"; list = "ul"; }
html += "- " + inline(m[1]) + "
";
} else if ((m = t.match(/^\d+[.、)]\s*(.*)$/))){ // 有序列表
if (list !== "ol"){ if (list) html += "" + list + ">"; html += ""; list = "ol"; }
html += "- " + inline(m[1]) + "
";
} else if (!t){ // 空行
if (list){ html += "" + list + ">"; list = null; }
html += "
";
} else { // 普通段落
if (list){ html += "" + list + ">"; list = null; }
html += inline(t) + "
";
}
}
if (list) html += "" + list + ">";
return html;
}
function parseBold(text){
return mdToHtml(text);
}
// 空会话时的欢迎内容(新建对话 / 打开空会话都要用)
function welcomeIcon(kind){
const paths = {
box: '',
track: '',
quote: '',
issue: '',
human: ''
};
return '';
}
function welcomeHTML(){
const q = t("quick");
const h = t("hotList");
const kinds = ["track", "quote", "issue", "human"];
const cards = q.map(function(question, index){
const action = index === 3 ? 'data-human-support' : `data-question="${escHtml(question)}"`;
return ``;
}).join("");
return `
${welcomeIcon("box")}
${t("welcomeEyebrow")}
${t("welcomeTitle")}
${t("welcomeSub")}
${cards}
${t("hot")}
${h.map(question => ``).join("")}
`;
}
// 清空全部历史
if(clearAllBtn){
clearAllBtn.onclick = function(){
if(openingConv === 'clearing') return;
if(!confirm(t("confirmClear"))) return;
const version = resetConversation(null);
openingConv = 'clearing';
chatDom.innerHTML = welcomeHTML();
setComposerStatus(t('historyLoading'));
requestJson("/api/clear_all", {method:"DELETE"})
.then(data=>{
if(!data.ok) throw new Error('Clear failed');
if(version !== conversationVersion) return;
openingConv = null;
setComposerStatus('');
chatDom.innerHTML = welcomeHTML();
loadHistory();
}).catch(function(){
if(version !== conversationVersion) return;
openingConv = null;
setComposerStatus(t('networkFail'), true);
});
}
}
// 新建对话:只清界面,等用户开口时再建会话
if(newChatBtn){
newChatBtn.onclick = function(){
if(openingConv === 'clearing') return;
resetConversation(null);
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();
}).catch(function(){ /* 保留上次成功加载的历史,避免网络错误清空侧栏。 */ });
}
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();
}
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;
if(data.welcome) addMsg(t('autoWelcome'), false);
return currentConvId;
}).finally(function(){ if(creatingConv === pending) creatingConv = null; });
creatingConv = pending;
return pending;
}
function resetConversation(id){
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
conversationVersion += 1;
clearAttachment();
stopHumanMode();
currentConvId = id;
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);
openingConv = id;
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;
}
msgs.forEach(m=>{
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);
}
});
// 等渲染完再滚到底部
requestAnimationFrame(function(){ scrollChatToEnd(true); });
}).catch(function(){
if(version !== conversationVersion) return;
openingConv = null;
setComposerStatus(t('networkFail'), true);
});
}
const CARD_PREFIX = "__CARD__";
// 把轨迹数据画成时间轴
function fillTrackCard(el, card){
el.innerHTML = "";
const isOrder = card.kind === 'order';
const head = document.createElement("div");
head.className = "track-head";
const no = document.createElement("div");
no.className = "track-no";
const numberLabel = isOrder ? 'orderNo' : 'trackingNo';
no.innerHTML = '' + t(numberLabel) + "" + escHtml(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 legacyNotice = document.createElement('p');
legacyNotice.className = 'order-data-notice';
legacyNotice.dataset.i18n = 'legacyTrackingNotice';
legacyNotice.textContent = t('legacyTrackingNotice');
el.appendChild(legacyNotice);
}
if(isOrder){
const fields = document.createElement('dl');
fields.className = 'order-data-fields';
(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);
});
el.appendChild(fields);
const title = document.createElement('p');
title.className = 'order-history-title';
title.dataset.i18n = 'orderHistory';
title.textContent = t('orderHistory');
el.appendChild(title);
if(!(card.nodes || []).length){
const empty = document.createElement('p');
empty.className = 'order-history-empty';
empty.dataset.i18n = 'noOrderHistory';
empty.textContent = t('noOrderHistory');
el.appendChild(empty);
}
}
const timeline = document.createElement("div");
timeline.className = "track-timeline";
el.appendChild(timeline);
const visibleCount = window.matchMedia("(max-width: 520px)").matches ? 2 : 3;
(card.nodes || []).forEach(function(n, i){
const node = document.createElement("div");
node.className = "track-node" + (i === 0 ? " active" : "");
const dot = document.createElement("span");
dot.className = "track-dot";
const text = document.createElement("div");
text.className = "track-text";
text.innerHTML = '' + escHtml(n.time) + '' + escHtml(n.text) + "" +
'' + escHtml(n.place || "") + "";
node.appendChild(dot);
node.appendChild(text);
if(i >= visibleCount){ node.classList.add("track-earlier"); node.hidden = true; }
timeline.appendChild(node);
});
if((card.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);
}
}
function createMessageRow(isUser, authorRole){
const row = document.createElement("div");
row.className = "msg-row " + (isUser ? "user-line" : "robot-line");
const author = document.createElement("div");
author.className = "message-author";
const role = isUser ? "you" : (authorRole || "assistant");
author.innerHTML = (isUser ? "" : '' + welcomeIcon("box") + '') + '' + t(role) + '';
row.appendChild(author);
return row;
}
function appendMessageTime(row, timeStr){
const ts = document.createElement('div');
ts.className = 'msg-time';
const stored = typeof timeStr === 'string' && timeStr.match(/(?:^|[T\s])(\d{2}:\d{2})/);
const now = new Date();
ts.textContent = stored ? stored[1] : String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0');
if(timeStr) ts.title = timeStr;
row.appendChild(ts);
}
function addTrackCard(card, timeStr){
const row = createMessageRow(false);
const bubble = document.createElement("div");
bubble.className = "robot-message track-card";
fillTrackCard(bubble, card);
row.appendChild(bubble);
appendMessageTime(row, timeStr);
chatDom.appendChild(row);
scrollChatToEnd();
return bubble;
}
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 myOrders(){
var phone = window.prompt("请输入您的手机号:");
if(!phone) return;
addMsg("查询手机号 "+phone+" 的包裹...", false);
fetch("/api/my_orders?phone="+encodeURIComponent(phone))
.then(r=>r.json())
.then(d=>{
if(!d.orders.length){
addMsg("手机号 "+phone+" 下暂无运单记录。", false);
}else{
var txt = "您名下有 "+d.orders.length+" 个包裹:\n";
d.orders.forEach(function(o){ txt += "• "+o.track_no+"("+o.status+")\n"; });
addMsg(txt, false);
}
})
.catch(function(){ addMsg("查询失败,请重试", false); });
}
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(openingConv === 'clearing') return;
if(humanRequestToken || humanClosing) return;
if(window.humanSid && humanConvId === currentConvId){ updateHumanMode(); return; }
if(window.humanSid) stopHumanMode();
const hour = new Date().getHours();
if(hour < 8 || hour >= 22) return leaveOfflineMessage();
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/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/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);
inputDom.focus();
}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/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(t(humanStatus === 'active' ? 'humanSent' : 'humanWaiting'));
inputDom.focus();
}catch(error){
if(version !== conversationVersion || humanSendingToken !== token) return;
if(error.status === 404 || error.status === 409) stopHumanMode();
setComposerStatus(t('humanSendFailed'), true);
}finally{
if(humanSendingToken === token){ humanSendingToken = null; updateHumanMode(); }
}
}
async function leaveOfflineMessage(){
const version = conversationVersion;
addMsg(t('humanHours'), false);
const message = window.prompt(t('offlinePrompt'));
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;
setComposerStatus(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/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')});
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 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(){
const plain = mdToPlain(rawText);
const done = ()=> {
copyBtn.textContent = t("copied");
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, 1200);
};
if(navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(plain).then(done).catch(fallback);
}else{
fallback();
}
function fallback(){
const ta = document.createElement("textarea");
ta.value = plain;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try{ document.execCommand("copy"); }catch(e){}
document.body.removeChild(ta);
done();
}
};
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(){
const row = bubble.closest(".msg-row");
if(row) row.remove();
doSend(question); // 传字符串 = 只重发,不重复插入提问气泡
};
bar.appendChild(retryBtn);
}
// 👍/👎 反馈
function addVote(vote, label){
const b = document.createElement("button");
b.className = "msg-action-btn";
b.textContent = label;
b.onclick = function(){
if(b.classList.contains("voted")) return;
bar.querySelectorAll(".msg-action-btn").forEach(function(x){ x.classList.remove("voted"); });
b.classList.add("voted");
fetch("/api/feedback", {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({question: question, answer: rawText, vote: vote})
}).catch(function(){});
};
bar.appendChild(b);
}
addVote("up", "👍");
addVote("down", "👎");
bubble.appendChild(bar);
}
// 回答下方的"相关追问":点一下就接着问
function renderFollowups(bubble){
let list = [];
try{ list = JSON.parse(bubble.dataset.suggest || "[]"); }catch(e){ list = []; }
if(!list.length) return;
const wrap = document.createElement("div");
wrap.className = "followup-list";
list.forEach(function(q){
const item = document.createElement("div");
item.className = "followup-item";
item.innerHTML = "›";
item.firstChild.textContent = q;
item.onclick = function(){
wrap.remove();
inputDom.value = q;
doSend();
};
wrap.appendChild(item);
});
bubble.appendChild(wrap);
}
async function doSend(retryText){
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){
inputDom.focus();
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('historyLoading')); 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();
inputDom.focus();
}
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;
inputDom.focus();
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, true);
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(){
const row = aiBubble.closest(".msg-row");
if(row) row.remove();
doSend(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.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"));
});
}
// ===== 发送 / 停止 按钮状态 =====
// 生成中按钮变成“停止”,点击走 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(){
inputDom.style.height = "auto";
inputDom.style.height = Math.min(inputDom.scrollHeight, 140) + "px";
}
inputDom.addEventListener("input", autoGrow);
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');
}).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();
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();
});
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();
});
});