2026-10-29 10:59:29 +08:00
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 ;
2026-10-29 18:41:02 +08:00
let uploadController = null ;
let historyRequestVersion = 0 ;
2026-10-29 10:59:29 +08:00
let followLatest = true ;
2026-10-29 16:28:50 +08:00
let creatingConv = null ;
let sendPreparing = false ;
2026-10-29 17:16:34 +08:00
let conversationVersion = 0 ;
let activeQuickTopic = null ;
let humanConvId = null ;
let humanStatus = '' ;
let humanRequestToken = null ;
let humanPollTimer = null ;
let humanSendingToken = null ;
let humanClosing = false ;
window . humanSid = 0 ;
2026-10-29 16:28:50 +08:00
SupportUI . initEmoji ( document . getElementById ( 'emoji-toggle' ), document . getElementById ( 'emoji-panel' ), inputDom , function (){ autoGrow (); });
document . getElementById ( 'notifications-toggle' ). onclick = function (){ SupportUI . enableNotifications ( this ); };
2026-10-29 10:59:29 +08:00
function setComposerStatus ( message , isError ){
composerStatus . textContent = message || "" ;
composerStatus . hidden = ! message ;
composerStatus . classList . toggle ( "is-error" , Boolean ( isError ));
}
2026-10-29 18:41:02 +08:00
async function requestJson ( path , options ){
const controller = new AbortController ();
const timer = setTimeout ( function (){ controller . abort (); }, 10000 );
try {
const response = await fetch ( path , Object . assign ({}, options , { signal : controller . signal }));
const data = await response . json ();
if ( ! response . ok ){
const error = new Error ( data && ( data . e || data . error ) || t ( 'networkFail' ));
error . status = response . status ;
throw error ;
}
return data ;
} finally { clearTimeout ( timer ); }
}
2026-10-29 10:59:29 +08:00
function clearAttachment (){
uploadVersion += 1 ;
2026-10-29 18:41:02 +08:00
if ( uploadController ){
uploadController . abort ();
uploadController = null ;
uploadPending = false ;
uploadBtn . disabled = false ;
uploadBtn . removeAttribute ( 'aria-busy' );
uploadBtn . textContent = '+' ;
}
2026-10-29 10:59:29 +08:00
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" ));
2026-10-29 18:41:02 +08:00
const controller = new AbortController ();
uploadController = controller ;
const timer = setTimeout ( function (){ controller . abort (); }, 15000 );
fetch ( "/api/upload" , { method : "POST" , body : fd , signal : controller . signal })
2026-10-29 10:59:29 +08:00
. 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 ){
2026-10-29 18:41:02 +08:00
const message = error . name === 'AbortError' || error instanceof SyntaxError || error instanceof TypeError ? t ( "uploadFailed" ) : ( error . message || t ( "uploadFailed" ));
2026-10-29 10:59:29 +08:00
if ( version === uploadVersion ) setComposerStatus ( message , true );
})
. finally ( function (){
2026-10-29 18:41:02 +08:00
clearTimeout ( timer );
if ( uploadController === controller ){
uploadController = null ;
uploadPending = false ;
uploadBtn . disabled = false ;
uploadBtn . removeAttribute ( "aria-busy" );
uploadBtn . textContent = "+" ;
}
2026-10-29 10:59:29 +08:00
});
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 : "会话创建失败,请确认服务已启动" ,
2026-10-29 18:01:28 +08:00
autoWelcome : "您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入订单号、入仓码或 Shipment ID 可查订单,或点击下方快捷选项。" ,
2026-10-29 16:28:50 +08:00
humanHours : "人工客服工作时间:每天 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。" ,
offlinePrompt : "请输入您的留言:" ,
offlineSaved : "留言已提交,工作时间(8:00-22:00)会尽快回复您。" ,
humanClosed : "人工客服已结束,您可以继续向 AI 提问。" ,
humanReply : "客服回复" ,
2026-10-29 17:16:34 +08:00
humanConnecting : "正在为您连接人工客服…" ,
humanWaiting : "正在排队,消息将由人工客服接入后回复。您也可以返回 AI 继续咨询。" ,
humanActive : "人工客服接待中,消息将发送给人工客服。" ,
returnAI : "返回 AI 问答" ,
humanSent : "已发送给人工客服" ,
humanSending : "正在发送给人工客服…" ,
humanSendFailed : "消息未发送成功,内容已保留,请重试。" ,
humanConnectFailed : "人工客服连接失败,请稍后重试。" ,
humanPollFailed : "人工客服连接中断,正在重试。您可以返回 AI 继续咨询。" ,
humanReturnFailed : "暂时无法结束人工会话,请重试。" ,
historyLoading : "正在加载这段会话,请稍候…" ,
2026-10-29 16:28:50 +08:00
recalled : "该消息已撤回" ,
ratingQuestion : "本次服务满意吗?" ,
ratingThanks : "感谢您的评价!" ,
ratingFailed : "评价未提交,请重试。" ,
2026-10-29 10:59:29 +08:00
networkFail : "网络连接中断,请检查网络后重试" ,
stopped : "已停止生成" ,
stoppedMark : "(已停止生成)" ,
2026-10-29 18:01:28 +08:00
inputPh : "请输入问题、订单号或入仓码…(Enter 发送,Shift+Enter 换行)" ,
2026-10-29 10:59:29 +08:00
send : "发送" ,
stop : "停止" ,
copy : "复制" ,
copied : "已复制" ,
retry : "重答" ,
clickRetry : "点击重试" ,
welcomeTitle : "物流问题,随时为您解答" ,
2026-10-29 18:01:28 +08:00
welcomeSub : "查询订单状态、了解专线报价,或联系人工客服。" ,
2026-10-29 10:59:29 +08:00
welcomeEyebrow : "中欧 · 英国物流服务" ,
serviceScope : "中欧与英国专线咨询" ,
serviceTag : "物流咨询服务" ,
quickLabel : "快捷咨询" ,
2026-10-29 18:01:28 +08:00
footerTrack : "查订单" ,
2026-10-29 10:59:29 +08:00
footerQuote : "专线报价" ,
footerException : "异常处理" ,
footerHuman : "联系人工" ,
disclaimer : "报价与时效仅供参考,请以客服最终确认为准" ,
keyboardHint : "Enter 发送 · Shift + Enter 换行" ,
2026-10-29 18:01:28 +08:00
inputShort : "输入问题、订单号或入仓码…" ,
2026-10-29 10:59:29 +08:00
uploading : "附件正在上传,请稍候…" ,
uploadFailed : "附件上传失败,请重新选择文件" ,
serviceUnavailable : "服务暂时不可用,请稍后重试,或联系人工客服。" ,
attachmentReady : "已准备好,发送后将附在消息中" ,
removeAttachment : "移除待发送附件" ,
you : "您" ,
assistant : "柏悦物流助手" ,
humanAgent : "人工客服" ,
trackingNo : "运单号" ,
2026-10-29 18:01:28 +08:00
orderNo : "订单号" ,
orderHistory : "订单业务状态历史" ,
noOrderHistory : "未提供订单状态历史" ,
legacyTrackingNotice : "历史模拟轨迹,仅供演示,不代表真实订单。" ,
order_inbound : "入仓码" , order_shipment : "Shipment ID" , order_country : "目的国" ,
order_warehouse : "目的仓" , order_transport : "运输方式" , order_packages : "件数" ,
order_weight : "申报毛重" , order_volume : "申报体积" , order_role : "订单层级" ,
order_parent : "父订单号" , order_updated : "来源更新时间" ,
2026-10-29 10:59:29 +08:00
moreTracking : "展开全部轨迹" ,
lessTracking : "收起较早轨迹" ,
2026-10-29 18:01:28 +08:00
quickSubs : [ "输入订单号、入仓码或 Shipment ID" , "了解线路、费用与预计时效" , "咨询延误、签收或理赔问题" , "进一步沟通您的物流需求" ],
2026-10-29 10:59:29 +08:00
welcomeTip : "您可以直接输入问题,也可以点击下面选项快速提问。" ,
hot : "热点问题" ,
recent : "最近对话记录" ,
sideCollapse : "收起侧栏" ,
sideExpand : "展开侧栏" ,
themeTip : "切换深色/浅色" ,
langTip : "中英文切换(Ctrl+L) " ,
toBottom : "回到底部" ,
quick : [
2026-10-29 18:01:28 +08:00
"查询订单状态" , "欧洲海运专线报价" , "包裹异常处理" , "转接人工客服"
2026-10-29 10:59:29 +08:00
],
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" ,
2026-10-29 18:01:28 +08:00
autoWelcome : "Welcome to Baiyue Logistics. Enter an order number, inbound code or Shipment ID, or choose a quick option below." ,
2026-10-29 16:28:50 +08:00
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" ,
2026-10-29 17:16:34 +08:00
humanConnecting : "Connecting you to our support team…" ,
humanWaiting : "You are in the queue. A team member will reply after joining, or you can return to AI chat." ,
humanActive : "You are chatting with our support team. Messages will be sent to a team member." ,
returnAI : "Return to AI chat" ,
humanSent : "Sent to our support team" ,
humanSending : "Sending to our support team…" ,
humanSendFailed : "Message not sent. Your text has been kept. Please retry." ,
humanConnectFailed : "Could not connect to our support team. Please try again." ,
humanPollFailed : "Support connection lost. Retrying, or you can return to AI chat." ,
humanReturnFailed : "Could not end the support session. Please try again." ,
historyLoading : "Loading this conversation. Please wait…" ,
2026-10-29 16:28:50 +08:00
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." ,
2026-10-29 10:59:29 +08:00
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" ,
2026-10-29 18:01:28 +08:00
footerTrack : "Order lookup" ,
2026-10-29 10:59:29 +08:00
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" ,
2026-10-29 18:01:28 +08:00
orderNo : "Order number" ,
orderHistory : "Order workflow history" ,
noOrderHistory : "No order status history provided" ,
legacyTrackingNotice : "Historical simulated tracking for demonstration only; this is not a real order record." ,
order_inbound : "Inbound code" , order_shipment : "Shipment ID" , order_country : "Destination country" ,
order_warehouse : "Warehouse" , order_transport : "Transport" , order_packages : "Packages" ,
order_weight : "Declared gross weight" , order_volume : "Declared volume" , order_role : "Order level" ,
order_parent : "Parent order" , order_updated : "Source updated" ,
2026-10-29 10:59:29 +08:00
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 ;
2026-10-29 17:16:34 +08:00
updateHumanMode ();
2026-10-29 10:59:29 +08:00
// 欢迎页(若当前显示的是欢迎页则重绘)
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 (){
2026-10-29 18:41:02 +08:00
if ( openingConv === 'clearing' ) return ;
2026-10-29 10:59:29 +08:00
if ( ! confirm ( t ( "confirmClear" ))) return ;
2026-10-29 17:16:34 +08:00
const version = resetConversation ( null );
openingConv = 'clearing' ;
2026-10-29 18:41:02 +08:00
chatDom . innerHTML = welcomeHTML ();
setComposerStatus ( t ( 'historyLoading' ));
requestJson ( "/api/clear_all" , { method : "DELETE" })
. then ( data =>{
if ( ! data . ok ) throw new Error ( 'Clear failed' );
2026-10-29 17:16:34 +08:00
if ( version !== conversationVersion ) return ;
openingConv = null ;
2026-10-29 18:41:02 +08:00
setComposerStatus ( '' );
2026-10-29 10:59:29 +08:00
chatDom . innerHTML = welcomeHTML ();
loadHistory ();
2026-10-29 17:16:34 +08:00
}). catch ( function (){
if ( version !== conversationVersion ) return ;
openingConv = null ;
setComposerStatus ( t ( 'networkFail' ), true );
2026-10-29 10:59:29 +08:00
});
}
}
// 新建对话:只清界面,等用户开口时再建会话
if ( newChatBtn ){
newChatBtn . onclick = function (){
2026-10-29 18:41:02 +08:00
if ( openingConv === 'clearing' ) return ;
2026-10-29 17:16:34 +08:00
resetConversation ( null );
2026-10-29 10:59:29 +08:00
inputDom . value = "" ;
autoGrow ();
clearAttachment ();
setComposerStatus ( "" );
chatDom . innerHTML = welcomeHTML ();
chatDom . scrollTop = 0 ;
followLatest = true ;
loadHistory ();
}
}
function loadHistory (){
2026-10-29 18:41:02 +08:00
const version = ++ historyRequestVersion ;
return requestJson ( "/api/get_history" )
2026-10-29 10:59:29 +08:00
. then ( data =>{
2026-10-29 18:41:02 +08:00
if ( version !== historyRequestVersion || ! Array . isArray ( data )) return ;
2026-10-29 10:59:29 +08:00
historyList = data ;
renderSideHistory ();
renderRecentChat ();
2026-10-29 18:41:02 +08:00
}). catch ( function (){ /* 保留上次成功加载的历史,避免网络错误清空侧栏。 */ });
2026-10-29 10:59:29 +08:00
}
function renderSideHistory (){
leftHistoryWrap . innerHTML = "" ;
const kw = ( document . getElementById ( "history-search" ) ? . value || "" ). trim (). toLowerCase ();
2026-10-29 18:41:02 +08:00
const pinned = pinnedConversations ();
2026-10-29 10:59:29 +08:00
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 ();
2026-10-29 18:41:02 +08:00
let p = pinnedConversations ();
2026-10-29 10:59:29 +08:00
if ( p . includes ( item . id )){ p = p . filter ( x => x !== item . id ); }
else { p . push ( item . id ); }
2026-10-29 18:41:02 +08:00
try { localStorage . setItem ( "pinned_conv" , JSON . stringify ( p )); } catch ( error ){}
2026-10-29 10:59:29 +08:00
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" ))){
2026-10-29 18:41:02 +08:00
const version = conversationVersion ;
return requestJson ( `/api/del_history?id= ${ item . id } ` , { method : "DELETE" })
. then ( data =>{
if ( ! data . ok ) throw new Error ( 'Delete failed' );
2026-10-29 10:59:29 +08:00
if ( item . id === currentConvId ){
2026-10-29 18:41:02 +08:00
resetConversation ( null );
2026-10-29 10:59:29 +08:00
chatDom . innerHTML = welcomeHTML ();
}
2026-10-29 18:41:02 +08:00
return loadHistory ();
}). catch ( function (){
if ( version === conversationVersion ) setComposerStatus ( t ( 'networkFail' ), true );
});
2026-10-29 10:59:29 +08:00
}
}
div . appendChild ( textWrap );
div . appendChild ( pinBtn );
div . appendChild ( delBtn );
div . onclick = ()=> openConversation ( item . id );
leftHistoryWrap . appendChild ( div );
})
}
2026-10-29 18:41:02 +08:00
function pinnedConversations (){
try {
const stored = JSON . parse ( localStorage . getItem ( 'pinned_conv' ) || '[]' );
return Array . isArray ( stored ) ? stored . filter ( Number . isInteger ) : [];
} catch ( error ){ return []; }
}
2026-10-29 10:59:29 +08:00
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 );
2026-10-29 16:28:50 +08:00
if ( creatingConv ) return creatingConv ;
2026-10-29 17:16:34 +08:00
const version = conversationVersion ;
2026-10-29 18:41:02 +08:00
const pending = requestJson ( "/api/new_chat" , { method : "POST" })
. then ( function ( data ){
if ( ! data || ! data . id ) throw new Error ( 'Session failed' );
2026-10-29 16:28:50 +08:00
return data ;
})
2026-10-29 10:59:29 +08:00
. then ( data =>{
2026-10-29 17:16:34 +08:00
if ( version !== conversationVersion ){
const error = new Error ( 'Conversation changed' );
error . name = 'AbortError' ;
throw error ;
}
2026-10-29 10:59:29 +08:00
currentConvId = data . id ;
2026-10-29 16:28:50 +08:00
if ( data . welcome ) addMsg ( t ( 'autoWelcome' ), false );
2026-10-29 10:59:29 +08:00
return currentConvId ;
2026-10-29 17:16:34 +08:00
}). finally ( function (){ if ( creatingConv === pending ) creatingConv = null ; });
creatingConv = pending ;
return pending ;
}
function resetConversation ( id ){
if ( cancelActive ){ cancelActive (); cancelActive = null ; }
if ( sseInstance ){ sseInstance . close (); sseInstance = null ; }
conversationVersion += 1 ;
2026-10-29 18:41:02 +08:00
clearAttachment ();
2026-10-29 17:16:34 +08:00
stopHumanMode ();
currentConvId = id ;
openingConv = null ;
creatingConv = null ;
sendPreparing = false ;
activeQuickTopic = null ;
setBusy ( false );
setComposerStatus ( '' );
return conversationVersion ;
2026-10-29 10:59:29 +08:00
}
// 打开历史会话:还原整段对话,而不是把标题重发一遍
let openingConv = null ;
function openConversation ( id ){
2026-10-29 18:41:02 +08:00
if ( openingConv === 'clearing' ) return ;
2026-10-29 10:59:29 +08:00
if ( openingConv === id ) return ; // 防连点
2026-10-29 17:16:34 +08:00
const version = resetConversation ( id );
2026-10-29 10:59:29 +08:00
openingConv = id ;
2026-10-29 17:16:34 +08:00
setComposerStatus ( t ( 'historyLoading' ));
2026-10-29 10:59:29 +08:00
renderSideHistory ();
2026-10-29 18:41:02 +08:00
requestJson ( "/api/get_messages?conv_id=" + id )
2026-10-29 10:59:29 +08:00
. then ( msgs =>{
2026-10-29 17:16:34 +08:00
if ( version !== conversationVersion ) return ;
2026-10-29 18:41:02 +08:00
if ( ! Array . isArray ( msgs )) throw new Error ( 'History failed' );
2026-10-29 10:59:29 +08:00
openingConv = null ;
2026-10-29 17:16:34 +08:00
setComposerStatus ( '' );
2026-10-29 10:59:29 +08:00
chatDom . innerHTML = "" ;
if ( ! msgs . length ){
chatDom . innerHTML = welcomeHTML ();
return ;
}
msgs . forEach ( m =>{
if ( m . role === "assistant" && m . content . indexOf ( CARD_PREFIX ) === 0 ){
try {
2026-10-29 18:43:25 +08:00
const bubble = addTrackCard ( JSON . parse ( m . content . slice ( CARD_PREFIX . length )), m . ctime );
attachActions ( bubble , m . content , '' , false );
2026-10-29 10:59:29 +08:00
} catch ( e ){
2026-10-29 18:43:25 +08:00
addMsg ( m . content , false , undefined , m . ctime );
2026-10-29 10:59:29 +08:00
}
} else {
2026-10-29 18:43:25 +08:00
const b = addMsg ( m . content , m . role === "user" , undefined , m . ctime );
2026-10-29 10:59:29 +08:00
if ( m . role === "assistant" && b ) attachActions ( b , m . content , "" , false );
}
});
// 等渲染完再滚到底部
requestAnimationFrame ( function (){ scrollChatToEnd ( true ); });
2026-10-29 17:16:34 +08:00
}). catch ( function (){
if ( version !== conversationVersion ) return ;
openingConv = null ;
setComposerStatus ( t ( 'networkFail' ), true );
});
2026-10-29 10:59:29 +08:00
}
const CARD_PREFIX = "__CARD__" ;
// 把轨迹数据画成时间轴
function fillTrackCard ( el , card ){
el . innerHTML = "" ;
2026-10-29 18:01:28 +08:00
const isOrder = card . kind === 'order' ;
2026-10-29 10:59:29 +08:00
const head = document . createElement ( "div" );
head . className = "track-head" ;
const no = document . createElement ( "div" );
no . className = "track-no" ;
2026-10-29 18:01:28 +08:00
const numberLabel = isOrder ? 'orderNo' : 'trackingNo' ;
no . innerHTML = '<span data-i18n="' + numberLabel + '">' + t ( numberLabel ) + "</span>" + escHtml ( card . no );
2026-10-29 10:59:29 +08:00
const st = document . createElement ( "div" );
st . className = "track-status" ;
st . textContent = card . status ;
head . appendChild ( no );
head . appendChild ( st );
el . appendChild ( head );
2026-10-29 18:01:28 +08:00
if ( ! isOrder ){
const legacyNotice = document . createElement ( 'p' );
legacyNotice . className = 'order-data-notice' ;
legacyNotice . dataset . i18n = 'legacyTrackingNotice' ;
legacyNotice . textContent = t ( 'legacyTrackingNotice' );
el . appendChild ( legacyNotice );
}
if ( isOrder ){
const fields = document . createElement ( 'dl' );
fields . className = 'order-data-fields' ;
( card . fields || []). forEach ( function ( field ){
const label = document . createElement ( 'dt' );
label . dataset . i18n = 'order_' + field . key ;
label . textContent = t ( 'order_' + field . key ) || field . key ;
const value = document . createElement ( 'dd' );
value . textContent = field . value ;
fields . append ( label , value );
});
el . appendChild ( fields );
const title = document . createElement ( 'p' );
title . className = 'order-history-title' ;
title . dataset . i18n = 'orderHistory' ;
title . textContent = t ( 'orderHistory' );
el . appendChild ( title );
if ( ! ( card . nodes || []). length ){
const empty = document . createElement ( 'p' );
2026-10-29 18:17:24 +08:00
empty . className = 'order-history-empty' ;
2026-10-29 18:01:28 +08:00
empty . dataset . i18n = 'noOrderHistory' ;
empty . textContent = t ( 'noOrderHistory' );
el . appendChild ( empty );
}
}
2026-10-29 10:59:29 +08:00
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 );
}
}
2026-10-29 17:16:34 +08:00
function createMessageRow ( isUser , authorRole ){
2026-10-29 10:59:29 +08:00
const row = document . createElement ( "div" );
row . className = "msg-row " + ( isUser ? "user-line" : "robot-line" );
const author = document . createElement ( "div" );
author . className = "message-author" ;
2026-10-29 17:16:34 +08:00
const role = isUser ? "you" : ( authorRole || "assistant" );
2026-10-29 10:59:29 +08:00
author . innerHTML = ( isUser ? "" : '<span class="author-mark">' + welcomeIcon ( "box" ) + '</span>' ) + '<span data-i18n="' + role + '">' + t ( role ) + '</span>' ;
row . appendChild ( author );
return row ;
}
2026-10-29 18:43:25 +08:00
function appendMessageTime ( row , timeStr ){
const ts = document . createElement ( 'div' );
ts . className = 'msg-time' ;
const stored = typeof timeStr === 'string' && timeStr . match ( /(?:^|[T\s])(\d{2}:\d{2})/ );
const now = new Date ();
ts . textContent = stored ? stored [ 1 ] : String ( now . getHours ()). padStart ( 2 , '0' ) + ':' + String ( now . getMinutes ()). padStart ( 2 , '0' );
if ( timeStr ) ts . title = timeStr ;
row . appendChild ( ts );
}
function addTrackCard ( card , timeStr ){
2026-10-29 10:59:29 +08:00
const row = createMessageRow ( false );
const bubble = document . createElement ( "div" );
bubble . className = "robot-message track-card" ;
fillTrackCard ( bubble , card );
row . appendChild ( bubble );
2026-10-29 18:43:25 +08:00
appendMessageTime ( row , timeStr );
2026-10-29 10:59:29 +08:00
chatDom . appendChild ( row );
scrollChatToEnd ();
return bubble ;
}
loadHistory ();
window . fillInput = function ( words ){
inputDom . value = words ;
2026-10-29 17:16:34 +08:00
return doSend ();
2026-10-29 10:59:29 +08:00
}
// 点快捷问题:另起一个会话,不同话题就不会堆进同一条历史
window . askNewTopic = function ( words ){
2026-10-29 18:41:02 +08:00
if ( openingConv === 'clearing' ) return ;
2026-10-29 17:16:34 +08:00
if ( activeQuickTopic === words && ( sendPreparing || window . __busy )) return ;
resetConversation ( null );
activeQuickTopic = words ;
2026-10-29 10:59:29 +08:00
// 新话题先重置界面,发送第一条消息时再收起欢迎内容。
if ( ! chatDom . querySelector ( '.ai-welcome-card' )){
chatDom . innerHTML = welcomeHTML ();
}
inputDom . value = words ;
2026-10-29 17:16:34 +08:00
return doSend ();
2026-10-29 10:59:29 +08:00
}
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 ); });
}
2026-10-29 17:16:34 +08:00
function updateHumanMode (){
const banner = document . getElementById ( 'human-session' );
const label = document . getElementById ( 'human-session-label' );
const button = document . getElementById ( 'return-ai' );
if ( banner ) banner . hidden = ! window . humanSid && ! humanRequestToken ;
const text = t ( humanRequestToken ? 'humanConnecting' : humanStatus === 'active' ? 'humanActive' : 'humanWaiting' );
if ( label && label . textContent !== text ) label . textContent = text ;
if ( button ){
button . textContent = t ( 'returnAI' );
button . disabled = Boolean ( humanRequestToken || humanClosing || humanSendingToken );
}
sendBtn . disabled = Boolean ( humanRequestToken || humanClosing || humanSendingToken );
}
function stopHumanMode (){
if ( humanPollTimer !== null ) clearInterval ( humanPollTimer );
humanPollTimer = null ;
humanConvId = null ;
humanStatus = '' ;
humanRequestToken = null ;
humanSendingToken = null ;
humanClosing = false ;
window . humanSid = 0 ;
const typing = document . getElementById ( 'human-typing' );
if ( typing ) typing . remove ();
updateHumanMode ();
}
async function humanRequest ( path , data ){
2026-10-29 18:41:02 +08:00
return requestJson ( path , { method : data ? 'POST' : 'GET' ,
headers : data ? { 'Content-Type' : 'application/json' } : {},
body : data ? JSON . stringify ( data ) : undefined });
2026-10-29 17:16:34 +08:00
}
2026-10-29 10:59:29 +08:00
window . showHuman = async function (){
2026-10-29 18:41:02 +08:00
if ( openingConv === 'clearing' ) return ;
2026-10-29 17:16:34 +08:00
if ( humanRequestToken || humanClosing ) return ;
if ( window . humanSid && humanConvId === currentConvId ){ updateHumanMode (); return ; }
if ( window . humanSid ) stopHumanMode ();
const hour = new Date (). getHours ();
if ( hour < 8 || hour >= 22 ) return leaveOfflineMessage ();
const token = { version : conversationVersion };
humanRequestToken = token ;
if ( cancelActive ){ cancelActive (); cancelActive = null ; }
setBusy ( false );
updateHumanMode ();
try {
const cid = await ensureConv ();
if ( humanRequestToken !== token || token . version !== conversationVersion ) return ;
const data = await humanRequest ( '/api/human/request' , { conv_id : cid });
if ( humanRequestToken !== token || token . version !== conversationVersion ) return ;
if ( data . closed ){ await leaveOfflineMessage (); return ; }
if ( ! data . ok || ! data . session_id ) throw new Error ( 'Human session failed' );
window . humanSid = data . session_id ;
humanConvId = cid ;
humanStatus = 'waiting' ;
addMsg ( t ( 'humanWaiting' ), false );
startHumanPoll ();
} catch ( error ){
if ( humanRequestToken === token && token . version === conversationVersion ) setComposerStatus ( t ( 'humanConnectFailed' ), true );
} finally {
if ( humanRequestToken === token ){ humanRequestToken = null ; updateHumanMode (); }
}
};
async function returnToAI (){
if ( ! window . humanSid || humanClosing || humanSendingToken ) return ;
const sid = window . humanSid , version = conversationVersion ;
humanClosing = true ;
updateHumanMode ();
try {
const result = await humanRequest ( '/api/human/close' , { sid : sid });
if ( ! result . ok ) throw new Error ( 'Close failed' );
if ( version !== conversationVersion || window . humanSid !== sid ) return ;
stopHumanMode ();
setComposerStatus ( '' );
addMsg ( t ( 'humanClosed' ), false );
inputDom . focus ();
} catch ( error ){
if ( version === conversationVersion ) setComposerStatus ( t ( 'humanReturnFailed' ), true );
} finally {
if ( version === conversationVersion ){ humanClosing = false ; updateHumanMode (); }
}
}
const returnAIButton = document . getElementById ( 'return-ai' );
if ( returnAIButton ) returnAIButton . onclick = returnToAI ;
async function sendHumanMessage ( content ){
if ( humanSendingToken || humanClosing ) return ;
const sid = window . humanSid , cid = currentConvId , version = conversationVersion ;
const draft = inputDom . value , attachment = window . __pendingImg ;
const token = {};
humanSendingToken = token ;
updateHumanMode ();
setComposerStatus ( t ( 'humanSending' ));
try {
const result = await humanRequest ( '/api/human/send' , { sid : sid , conv_id : cid , side : 'customer' , content : content });
if ( ! result . ok ) throw new Error ( 'Send failed' );
if ( version !== conversationVersion || humanSendingToken !== token ) return ;
addMsg ( content , true );
if ( inputDom . value === draft ){ inputDom . value = '' ; autoGrow (); }
if ( window . __pendingImg === attachment ) clearAttachment ();
setComposerStatus ( t ( humanStatus === 'active' ? 'humanSent' : 'humanWaiting' ));
inputDom . focus ();
} catch ( error ){
if ( version !== conversationVersion || humanSendingToken !== token ) return ;
if ( error . status === 404 || error . status === 409 ) stopHumanMode ();
setComposerStatus ( t ( 'humanSendFailed' ), true );
} finally {
if ( humanSendingToken === token ){ humanSendingToken = null ; updateHumanMode (); }
2026-10-29 10:59:29 +08:00
}
}
2026-10-29 16:28:50 +08:00
async function leaveOfflineMessage (){
2026-10-29 17:16:34 +08:00
const version = conversationVersion ;
2026-10-29 16:28:50 +08:00
addMsg ( t ( 'humanHours' ), false );
const message = window . prompt ( t ( 'offlinePrompt' ));
if ( ! message ) return ;
try {
2026-10-29 17:16:34 +08:00
const data = await humanRequest ( '/api/ticket' , { content : message , phone : '' });
if ( ! data . ok ) throw new Error ( 'Ticket failed' );
if ( version === conversationVersion ) addMsg ( t ( 'offlineSaved' ), false );
} catch ( error ){
if ( version !== conversationVersion ) return ;
if ( ! inputDom . value ) inputDom . value = message ;
setComposerStatus ( t ( 'networkFail' ), true );
}
2026-10-29 16:28:50 +08:00
}
2026-10-29 10:59:29 +08:00
function startHumanPoll (){
let last = 0 ;
2026-10-29 16:28:50 +08:00
let polling = false ;
const sid = window . humanSid ;
2026-10-29 17:16:34 +08:00
const cid = humanConvId , version = conversationVersion ;
let hadPollError = false ;
if ( humanPollTimer !== null ) clearInterval ( humanPollTimer );
2026-10-29 16:28:50 +08:00
const pollTimer = setInterval (()=>{
2026-10-29 17:16:34 +08:00
if ( window . humanSid !== sid || version !== conversationVersion || currentConvId !== cid ){ clearInterval ( pollTimer ); return ; }
2026-10-29 16:28:50 +08:00
if ( polling ) return ;
polling = true ;
2026-10-29 17:16:34 +08:00
humanRequest ( "/api/human/poll?sid=" + sid + "&after=" + last )
2026-10-29 10:59:29 +08:00
. then ( d =>{
2026-10-29 17:16:34 +08:00
if ( window . humanSid !== sid || version !== conversationVersion || currentConvId !== cid ) return ;
if ( hadPollError ){ setComposerStatus ( '' ); hadPollError = false ; }
humanStatus = d . status ;
updateHumanMode ();
2026-10-29 16:28:50 +08:00
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' ){
2026-10-29 17:16:34 +08:00
const bubble = addMsg ( m . content , false , 'humanAgent' );
2026-10-29 16:28:50 +08:00
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 ();
});
2026-10-29 10:59:29 +08:00
if ( d . status === "closed" ){
2026-10-29 17:16:34 +08:00
if ( humanClosing ) return ;
stopHumanMode ();
setBusy ( false );
2026-10-29 16:28:50 +08:00
addMsg ( t ( 'humanClosed' ), false );
SupportUI . addRating ( chatDom , sid , { question : t ( 'ratingQuestion' ), thanks : t ( 'ratingThanks' ), failed : t ( 'ratingFailed' )});
scrollChatToEnd ();
2026-10-29 10:59:29 +08:00
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 ();
}
})
2026-10-29 17:16:34 +08:00
. catch ( function (){
if ( version !== conversationVersion || window . humanSid !== sid ) return ;
hadPollError = true ;
setComposerStatus ( t ( 'humanPollFailed' ), true );
}). finally ( function (){ polling = false ; });
2026-10-29 10:59:29 +08:00
}, 1000 );
2026-10-29 17:16:34 +08:00
humanPollTimer = pollTimer ;
2026-10-29 10:59:29 +08:00
}
2026-10-29 18:43:25 +08:00
function addMsg ( content , isUser , authorRole , timeStr ){
2026-10-29 10:59:29 +08:00
if ( chatDom . querySelector ( '.ai-welcome-card' )) chatDom . innerHTML = "" ;
2026-10-29 17:16:34 +08:00
const rowDiv = createMessageRow ( isUser , authorRole );
2026-10-29 10:59:29 +08:00
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 );
}
// 时间戳
2026-10-29 18:43:25 +08:00
appendMessageTime ( rowDiv , timeStr );
2026-10-29 10:59:29 +08:00
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" );
2026-10-29 18:41:02 +08:00
if ( ! bubble . dataset . raw ) bubble . innerHTML = "" ;
2026-10-29 10:59:29 +08:00
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 ));
2026-10-29 18:01:28 +08:00
if ( card . kind === 'order' ) return card . summary || card . no ;
2026-10-29 10:59:29 +08:00
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 || "[图片]" ;
2026-10-29 18:41:02 +08:00
const draft = inputDom . value , attachmentVersion = uploadVersion ;
2026-10-29 10:59:29 +08:00
if ( ! sendWord && ! imgUrl ){
inputDom . focus ();
return ;
}
if ( uploadPending ){ setComposerStatus ( t ( "uploading" )); return ; }
2026-10-29 17:16:34 +08:00
if ( humanSendingToken || humanClosing ) return ;
2026-10-29 10:59:29 +08:00
var fullContent = sendWord ;
if ( imgUrl ) fullContent += ( fullContent ? " " : "" ) + tag + imgUrl ;
setComposerStatus ( "" );
2026-10-29 17:16:34 +08:00
if ( openingConv !== null ){ setComposerStatus ( t ( 'historyLoading' )); return ; }
if ( humanRequestToken || humanClosing ){ updateHumanMode (); return ; }
2026-10-29 10:59:29 +08:00
// 人工客服模式:直接发给客服,不走AI
2026-10-29 17:16:34 +08:00
if ( window . humanSid && humanConvId === currentConvId ) return sendHumanMessage ( fullContent );
if ( window . humanSid ) stopHumanMode ();
2026-10-29 10:59:29 +08:00
2026-10-29 16:28:50 +08:00
if ( sendPreparing || ( window . __busy && ! retryText )) return ; // 防连点
2026-10-29 10:59:29 +08:00
if ( cancelActive ){ cancelActive (); cancelActive = null ; }
2026-10-29 16:28:50 +08:00
sendPreparing = true ;
2026-10-29 17:16:34 +08:00
const version = conversationVersion ;
let convId ;
2026-10-29 16:28:50 +08:00
try {
2026-10-29 17:16:34 +08:00
convId = await ensureConv ();
2026-10-29 16:28:50 +08:00
} catch ( error ){
2026-10-29 17:16:34 +08:00
if ( version === conversationVersion ) setComposerStatus ( t ( 'sessionFail' ), true );
2026-10-29 16:28:50 +08:00
return ;
} finally {
2026-10-29 17:16:34 +08:00
if ( version === conversationVersion ) sendPreparing = false ;
2026-10-29 16:28:50 +08:00
}
2026-10-29 17:16:34 +08:00
if ( version !== conversationVersion || humanRequestToken ) return ;
2026-10-29 10:59:29 +08:00
if ( ! isRetry ){
addMsg ( fullContent , true );
2026-10-29 18:41:02 +08:00
if ( inputDom . value === draft ){ inputDom . value = "" ; autoGrow (); }
if ( attachmentVersion === uploadVersion ) clearAttachment ();
2026-10-29 10:59:29 +08:00
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 ; // 已经结束(成功或失败),用它区分"正常关闭"和"真断开"
2026-10-29 18:41:02 +08:00
const firstTimer = setTimeout ( function (){ fail ( "模型响应超时,请稍后重试" ); }, 90000 );
2026-10-29 10:59:29 +08:00
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 ){
2026-10-29 17:16:34 +08:00
if ( settled || version !== conversationVersion ) return ;
2026-10-29 10:59:29 +08:00
let obj ;
try {
obj = JSON . parse ( dataText );
} catch ( err ){
obj = { t : dataText }; // 兼容旧的纯文本帧
}
2026-10-29 18:41:02 +08:00
if ( ! obj || typeof obj !== 'object' ) return ;
2026-10-29 10:59:29 +08:00
if ( obj . e ){ fail ( obj . e ); return ; }
if ( obj . card ){
2026-10-29 18:41:02 +08:00
clearTimeout ( firstTimer );
2026-10-29 10:59:29 +08:00
aiBubble . classList . add ( "track-card" );
fillTrackCard ( aiBubble , obj . card );
2026-10-29 18:01:28 +08:00
aiBubble . dataset . raw = obj . card . kind === 'order' ? obj . card . summary : "运单号 " + obj . card . no + "( " + ( obj . card . status || "" ) + ") \n" +
2026-10-29 10:59:29 +08:00
( obj . card . nodes || []). map ( function ( n ){ return n . time + " " + n . text + " " + ( n . place || "" ); }). join ( "\n" );
firstChunk = false ;
scrollChatToEnd ();
return ;
}
if ( obj . replace ){
2026-10-29 18:41:02 +08:00
clearTimeout ( firstTimer );
2026-10-29 10:59:29 +08:00
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" },
2026-10-29 17:16:34 +08:00
body : JSON . stringify ({ msg : fullContent , conv_id : convId }),
2026-10-29 10:59:29 +08:00
signal : controller . signal
2026-10-29 18:41:02 +08:00
}). then ( async function ( res ){
if ( ! res . ok ){
let data ;
try { data = await res . json (); } catch ( e ){}
fail ( data && ( data . e || data . error ) || t ( 'networkFail' ));
return ;
}
if ( ! res . body || typeof res . body . getReader !== 'function' ){ fail ( t ( 'networkFail' )); return ; }
2026-10-29 10:59:29 +08:00
const reader = res . body . getReader ();
const decoder = new TextDecoder ( "utf-8" );
let buffer = "" ;
2026-10-29 18:41:02 +08:00
function drain ( final ){
function dispatch ( frame ){
const data = frame . split ( /\r?\n/ ). filter ( line => line . indexOf ( 'data:' ) === 0 )
. map ( line => line . slice ( 5 ). trimStart ()). join ( '\n' );
if ( data ) handleFrame ( data );
}
let boundary ;
while ( ! settled && ( boundary = /\r?\n\r?\n/ . exec ( buffer ))){
const frame = buffer . slice ( 0 , boundary . index );
buffer = buffer . slice ( boundary . index + boundary [ 0 ]. length );
dispatch ( frame );
}
if ( final && ! settled && buffer . trim ()){ dispatch ( buffer ); buffer = '' ; }
}
2026-10-29 10:59:29 +08:00
function pump (){
return reader . read (). then ( function ( r ){
2026-10-29 18:41:02 +08:00
if ( settled ) return ;
2026-10-29 10:59:29 +08:00
if ( r . done ){
2026-10-29 18:41:02 +08:00
buffer += decoder . decode ();
drain ( true );
if ( ! settled ) fail ( t ( "networkFail" ));
2026-10-29 10:59:29 +08:00
return ;
}
buffer += decoder . decode ( r . value , { stream : true });
2026-10-29 18:41:02 +08:00
drain ( false );
if ( ! settled ) return pump ();
2026-10-29 10:59:29 +08:00
});
}
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 ();
});
});