1465 lines
57 KiB
JavaScript
1465 lines
57 KiB
JavaScript
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 followLatest = true;
|
||
let creatingConv = null;
|
||
let sendPreparing = false;
|
||
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));
|
||
}
|
||
|
||
function clearAttachment(){
|
||
uploadVersion += 1;
|
||
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"));
|
||
fetch("/api/upload", {method:"POST", body:fd})
|
||
.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 instanceof SyntaxError || error instanceof TypeError ? t("uploadFailed") : (error.message || t("uploadFailed"));
|
||
if(version === uploadVersion) setComposerStatus(message, true);
|
||
})
|
||
.finally(function(){
|
||
uploadPending = false;
|
||
uploadBtn.disabled = false;
|
||
uploadBtn.removeAttribute("aria-busy");
|
||
uploadBtn.textContent = "+";
|
||
if(version !== uploadVersion) setComposerStatus("");
|
||
});
|
||
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: "您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入运单号可查轨迹,或点击下方快捷选项。",
|
||
humanHours: "人工客服工作时间:每天 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
|
||
offlinePrompt: "请输入您的留言:",
|
||
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
|
||
humanClosed: "人工客服已结束,您可以继续向 AI 提问。",
|
||
humanReply: "客服回复",
|
||
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: "运单号",
|
||
moreTracking: "展开全部轨迹",
|
||
lessTracking: "收起较早轨迹",
|
||
quickSubs: ["输入运单号,查看运输进度", "了解线路、费用与预计时效", "咨询延误、签收或理赔问题", "进一步沟通您的物流需求"],
|
||
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. How can we help? Enter a tracking number 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",
|
||
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: "Tracking",
|
||
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",
|
||
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;
|
||
|
||
// 欢迎页(若当前显示的是欢迎页则重绘)
|
||
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, "<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><small>${t("quickSubs")[index]}</small></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>
|
||
<p>${t("welcomeSub")}</p>
|
||
</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(!confirm(t("confirmClear"))) return;
|
||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||
fetch("/api/clear_all", {method:"DELETE"})
|
||
.then(()=>{
|
||
currentConvId = null;
|
||
chatDom.innerHTML = welcomeHTML();
|
||
loadHistory();
|
||
});
|
||
}
|
||
}
|
||
|
||
// 新建对话:只清界面,等用户开口时再建会话
|
||
if(newChatBtn){
|
||
newChatBtn.onclick = function(){
|
||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||
if(sseInstance){ sseInstance.close(); sseInstance = null; }
|
||
inputDom.value = "";
|
||
autoGrow();
|
||
clearAttachment();
|
||
setComposerStatus("");
|
||
currentConvId = null;
|
||
chatDom.innerHTML = welcomeHTML();
|
||
chatDom.scrollTop = 0;
|
||
followLatest = true;
|
||
loadHistory();
|
||
}
|
||
}
|
||
|
||
function loadHistory(){
|
||
fetch("/api/get_history")
|
||
.then(res=>res.json())
|
||
.then(data=>{
|
||
historyList = data;
|
||
renderSideHistory();
|
||
renderRecentChat();
|
||
})
|
||
}
|
||
|
||
function renderSideHistory(){
|
||
leftHistoryWrap.innerHTML = "";
|
||
const kw = (document.getElementById("history-search")?.value || "").trim().toLowerCase();
|
||
const pinned = JSON.parse(localStorage.getItem("pinned_conv") || "[]");
|
||
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 = JSON.parse(localStorage.getItem("pinned_conv") || "[]");
|
||
if(p.includes(item.id)){ p = p.filter(x=>x!==item.id); }
|
||
else { p.push(item.id); }
|
||
localStorage.setItem("pinned_conv", JSON.stringify(p));
|
||
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"))){
|
||
fetch(`/api/del_history?id=${item.id}`, {method:"DELETE"})
|
||
.then(()=>{
|
||
if(item.id === currentConvId){
|
||
currentConvId = null;
|
||
chatDom.innerHTML = welcomeHTML();
|
||
}
|
||
loadHistory();
|
||
})
|
||
}
|
||
}
|
||
div.appendChild(textWrap);
|
||
div.appendChild(pinBtn);
|
||
div.appendChild(delBtn);
|
||
div.onclick = ()=> openConversation(item.id);
|
||
leftHistoryWrap.appendChild(div);
|
||
})
|
||
}
|
||
|
||
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;
|
||
creatingConv = fetch("/api/new_chat", {method:"POST"})
|
||
.then(async function(res){
|
||
const data = await res.json();
|
||
if(!res.ok || !data.id) throw new Error('Session failed');
|
||
return data;
|
||
})
|
||
.then(data=>{
|
||
currentConvId = data.id;
|
||
if(data.welcome) addMsg(t('autoWelcome'), false);
|
||
return currentConvId;
|
||
}).finally(function(){ creatingConv = null; });
|
||
return creatingConv;
|
||
}
|
||
|
||
// 打开历史会话:还原整段对话,而不是把标题重发一遍
|
||
let openingConv = null;
|
||
function openConversation(id){
|
||
if(openingConv === id) return; // 防连点
|
||
openingConv = id;
|
||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||
if(sseInstance){ sseInstance.close(); sseInstance = null; }
|
||
currentConvId = id;
|
||
renderSideHistory();
|
||
fetch("/api/get_messages?conv_id=" + id)
|
||
.then(res=>res.json())
|
||
.then(msgs=>{
|
||
openingConv = null;
|
||
chatDom.innerHTML = "";
|
||
if(!msgs.length){
|
||
chatDom.innerHTML = welcomeHTML();
|
||
return;
|
||
}
|
||
msgs.forEach(m=>{
|
||
if(m.role === "assistant" && m.content.indexOf(CARD_PREFIX) === 0){
|
||
try{
|
||
addTrackCard(JSON.parse(m.content.slice(CARD_PREFIX.length)));
|
||
}catch(e){
|
||
addMsg(m.content, false);
|
||
}
|
||
}else{
|
||
const b = addMsg(m.content, m.role === "user");
|
||
if(m.role === "assistant" && b) attachActions(b, m.content, "", false);
|
||
}
|
||
});
|
||
// 等渲染完再滚到底部
|
||
requestAnimationFrame(function(){ scrollChatToEnd(true); });
|
||
})["catch"](function(){ openingConv = null; });
|
||
}
|
||
|
||
const CARD_PREFIX = "__CARD__";
|
||
|
||
// 把轨迹数据画成时间轴
|
||
function fillTrackCard(el, card){
|
||
el.innerHTML = "";
|
||
const head = document.createElement("div");
|
||
head.className = "track-head";
|
||
const no = document.createElement("div");
|
||
no.className = "track-no";
|
||
no.innerHTML = '<span data-i18n="trackingNo">' + t("trackingNo") + "</span>" + 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);
|
||
|
||
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 = '<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((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){
|
||
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" : (window.humanSid ? "humanAgent" : "assistant");
|
||
author.innerHTML = (isUser ? "" : '<span class="author-mark">' + welcomeIcon("box") + '</span>') + '<span data-i18n="' + role + '">' + t(role) + '</span>';
|
||
row.appendChild(author);
|
||
return row;
|
||
}
|
||
|
||
function addTrackCard(card){
|
||
const row = createMessageRow(false);
|
||
const bubble = document.createElement("div");
|
||
bubble.className = "robot-message track-card";
|
||
fillTrackCard(bubble, card);
|
||
row.appendChild(bubble);
|
||
chatDom.appendChild(row);
|
||
scrollChatToEnd();
|
||
return bubble;
|
||
}
|
||
|
||
loadHistory();
|
||
|
||
window.fillInput = function(words){
|
||
inputDom.value = words;
|
||
doSend();
|
||
}
|
||
|
||
// 点快捷问题:另起一个会话,不同话题就不会堆进同一条历史
|
||
window.askNewTopic = function(words){
|
||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||
currentConvId = null; // 下次发送时自动新建会话
|
||
// 新话题先重置界面,发送第一条消息时再收起欢迎内容。
|
||
if(!chatDom.querySelector('.ai-welcome-card')){
|
||
chatDom.innerHTML = welcomeHTML();
|
||
}
|
||
inputDom.value = words;
|
||
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); });
|
||
}
|
||
|
||
window.showHuman = async function(){
|
||
if(window.humanSid){
|
||
addMsg("您已在人工客服会话中,请直接提问。", false);
|
||
return;
|
||
}
|
||
var now = new Date();
|
||
var h = now.getHours();
|
||
var work = h >= 8 && h < 22;
|
||
if(!work){
|
||
leaveOfflineMessage();
|
||
return;
|
||
}
|
||
addMsg("正在为您转接人工客服...", false);
|
||
if(!currentConvId){
|
||
try{ await ensureConv(); }catch(e){ setComposerStatus(t('sessionFail'), true); return; }
|
||
}
|
||
fetch("/api/human/request",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({conv_id:currentConvId||0})})
|
||
.then(function(r){return r.json()})
|
||
.then(function(d){
|
||
if(d.ok){
|
||
window.humanSid = d.session_id;
|
||
addMsg("您已加入人工排队,请稍候...", false);
|
||
startHumanPoll();
|
||
}else if(d.closed){
|
||
leaveOfflineMessage();
|
||
}else{
|
||
addMsg("转接失败,请稍后再试。", false);
|
||
}
|
||
})
|
||
.catch(function(){
|
||
addMsg("网络错误,请稍后再试。", false);
|
||
});
|
||
}
|
||
window.humanSid = 0;
|
||
async function leaveOfflineMessage(){
|
||
addMsg(t('humanHours'), false);
|
||
const message = window.prompt(t('offlinePrompt'));
|
||
if(!message) return;
|
||
try{
|
||
const response = await fetch('/api/ticket', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({content:message, phone:''})});
|
||
const data = await response.json();
|
||
if(!response.ok || !data.ok) throw new Error('Ticket failed');
|
||
addMsg(t('offlineSaved'), false);
|
||
}catch(error){ setComposerStatus(t('networkFail'), true); }
|
||
}
|
||
|
||
function startHumanPoll(){
|
||
let last=0;
|
||
let polling=false;
|
||
const sid=window.humanSid;
|
||
const pollTimer=setInterval(()=>{
|
||
if(window.humanSid !== sid){ clearInterval(pollTimer); return; }
|
||
if(polling) return;
|
||
polling=true;
|
||
fetch("/api/human/poll?sid="+sid+"&after="+last)
|
||
.then(r=>r.json())
|
||
.then(d=>{
|
||
if(window.humanSid !== sid) return;
|
||
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);
|
||
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"){
|
||
clearInterval(pollTimer);
|
||
window.humanSid=0;
|
||
window.__busy=false;
|
||
if(typeof setBusy==="function") setBusy(false);
|
||
const typing = document.getElementById('human-typing');
|
||
if(typing) typing.remove();
|
||
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(()=>{}).finally(function(){ polling=false; });
|
||
}, 1000);
|
||
}
|
||
|
||
function addMsg(content, isUser){
|
||
if(chatDom.querySelector('.ai-welcome-card')) chatDom.innerHTML = "";
|
||
const rowDiv = createMessageRow(isUser);
|
||
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);
|
||
}
|
||
// 时间戳
|
||
const ts = document.createElement("div");
|
||
ts.className = "msg-time";
|
||
const d = new Date();
|
||
ts.textContent = String(d.getHours()).padStart(2,'0') + ":" + String(d.getMinutes()).padStart(2,'0');
|
||
rowDiv.appendChild(ts);
|
||
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");
|
||
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));
|
||
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 = "<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){
|
||
const isRetry = typeof retryText === "string";
|
||
const sendWord = (isRetry ? retryText : inputDom.value).trim();
|
||
var imgUrl = isRetry ? "" : (window.__pendingImg || "");
|
||
var tag = window.__pendingTag || "[图片]";
|
||
if(!sendWord && !imgUrl){
|
||
inputDom.focus();
|
||
return;
|
||
}
|
||
if(uploadPending){ setComposerStatus(t("uploading")); return; }
|
||
var fullContent = sendWord;
|
||
if(imgUrl) fullContent += (fullContent ? " " : "") + tag + imgUrl;
|
||
setComposerStatus("");
|
||
// 人工客服模式:直接发给客服,不走AI
|
||
if(window.humanSid){
|
||
var content = sendWord;
|
||
if(imgUrl) content += (content ? " " : "") + tag + imgUrl;
|
||
if(!isRetry) addMsg(content, true);
|
||
inputDom.value = "";
|
||
autoGrow();
|
||
window.__pendingImg = ""; window.__pendingTag = "[图片]";
|
||
var p = document.getElementById("imgPreview"); if(p) p.remove();
|
||
inputDom.focus();
|
||
fetch("/api/human/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:window.humanSid,side:"customer",content:content})});
|
||
return;
|
||
}
|
||
|
||
if(sendPreparing || (window.__busy && !retryText)) return; // 防连点
|
||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||
|
||
sendPreparing = true;
|
||
try{
|
||
await ensureConv();
|
||
}catch(error){
|
||
setComposerStatus(t('sessionFail'), true);
|
||
return;
|
||
}finally{
|
||
sendPreparing = false;
|
||
}
|
||
if(!isRetry){
|
||
addMsg(fullContent, true);
|
||
inputDom.value = "";
|
||
autoGrow();
|
||
window.__pendingImg = ""; window.__pendingTag = "[图片]";
|
||
var p = document.getElementById("imgPreview"); if(p) p.remove();
|
||
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("模型响应超时,请稍后重试"); }, 15000);
|
||
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) return;
|
||
let obj;
|
||
try{
|
||
obj = JSON.parse(dataText);
|
||
}catch(err){
|
||
obj = {t: dataText}; // 兼容旧的纯文本帧
|
||
}
|
||
if(obj.e){ fail(obj.e); return; }
|
||
if(obj.card){
|
||
aiBubble.classList.add("track-card");
|
||
fillTrackCard(aiBubble, obj.card);
|
||
aiBubble.dataset.raw = "运单号 " + 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){
|
||
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: currentConvId}),
|
||
signal: controller.signal
|
||
}).then(function(res){
|
||
if(!res.ok){ throw new Error("HTTP " + res.status); }
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder("utf-8");
|
||
let buffer = "";
|
||
function pump(){
|
||
return reader.read().then(function(r){
|
||
if(r.done){
|
||
// Single-frame attachment acknowledgements may end without a d frame.
|
||
if(!settled){
|
||
if(aiBubble.dataset.raw) finish();
|
||
else fail(t("networkFail"));
|
||
}
|
||
return;
|
||
}
|
||
buffer += decoder.decode(r.value, {stream: true});
|
||
let idx;
|
||
// SSE 以空行分隔事件
|
||
while((idx = buffer.indexOf("\n\n")) >= 0){
|
||
const chunk = buffer.slice(0, idx);
|
||
buffer = buffer.slice(idx + 2);
|
||
chunk.split("\n").forEach(function(line){
|
||
if(line.indexOf("data:") === 0){
|
||
handleFrame(line.slice(5).trimStart());
|
||
}
|
||
});
|
||
}
|
||
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();
|
||
});
|
||
|
||
});
|