Files
baiyue-logistics-chat/static/js/chat.js
T

1465 lines
57 KiB
JavaScript
Raw Blame History

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