修复快捷咨询串入人工会话及上下文切换

This commit is contained in:
2026-10-29 17:16:34 +08:00
parent 97fc9bec14
commit 167c888718
7 changed files with 493 additions and 103 deletions
+2
View File
@@ -87,6 +87,8 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; }
.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
.hot-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 10px; }
.human-session { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; margin-bottom: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--brand-soft); color: var(--secondary); font-size: 12px; }
.human-session button { flex-shrink: 0; padding: 6px 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--brand-text); font-size: 12px; }
.hot-bar-label { font-size: 10px; color: var(--muted); margin-right: 3px; }
.hot-bar .hot-capsule { border: 0; padding: 4px 8px; background: var(--surface-soft); font-size: 10px; }
.input-area { display: flex; align-items: flex-end; gap: 10px; border: 1px solid var(--border); border-radius: 12px; padding: 9px; background: var(--surface); box-shadow: 0 3px 15px rgba(28, 47, 80, .035); }
+247 -87
View File
@@ -10,6 +10,15 @@ let uploadVersion = 0;
let followLatest = true;
let creatingConv = null;
let sendPreparing = false;
let conversationVersion = 0;
let activeQuickTopic = null;
let humanConvId = null;
let humanStatus = '';
let humanRequestToken = null;
let humanPollTimer = null;
let humanSendingToken = null;
let humanClosing = false;
window.humanSid = 0;
SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
@@ -175,6 +184,17 @@ const I18N = {
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
humanClosed: "人工客服已结束,您可以继续向 AI 提问。",
humanReply: "客服回复",
humanConnecting: "正在为您连接人工客服…",
humanWaiting: "正在排队,消息将由人工客服接入后回复。您也可以返回 AI 继续咨询。",
humanActive: "人工客服接待中,消息将发送给人工客服。",
returnAI: "返回 AI 问答",
humanSent: "已发送给人工客服",
humanSending: "正在发送给人工客服…",
humanSendFailed: "消息未发送成功,内容已保留,请重试。",
humanConnectFailed: "人工客服连接失败,请稍后重试。",
humanPollFailed: "人工客服连接中断,正在重试。您可以返回 AI 继续咨询。",
humanReturnFailed: "暂时无法结束人工会话,请重试。",
historyLoading: "正在加载这段会话,请稍候…",
recalled: "该消息已撤回",
ratingQuestion: "本次服务满意吗?",
ratingThanks: "感谢您的评价!",
@@ -248,6 +268,17 @@ const I18N = {
offlineSaved: "Message submitted. Our team will reply during service hours (08:00–22:00).",
humanClosed: "This support session has ended. You can continue chatting with the AI assistant.",
humanReply: "Support reply",
humanConnecting: "Connecting you to our support team…",
humanWaiting: "You are in the queue. A team member will reply after joining, or you can return to AI chat.",
humanActive: "You are chatting with our support team. Messages will be sent to a team member.",
returnAI: "Return to AI chat",
humanSent: "Sent to our support team",
humanSending: "Sending to our support team…",
humanSendFailed: "Message not sent. Your text has been kept. Please retry.",
humanConnectFailed: "Could not connect to our support team. Please try again.",
humanPollFailed: "Support connection lost. Retrying, or you can return to AI chat.",
humanReturnFailed: "Could not end the support session. Please try again.",
historyLoading: "Loading this conversation. Please wait…",
recalled: "This message was recalled",
ratingQuestion: "How was your support experience?",
ratingThanks: "Thank you for your feedback!",
@@ -339,6 +370,7 @@ function applyLanguage(lang){
if(tb) tb.title = dict.toBottom;
const tg = document.querySelector(".toggle-divider");
if(tg) tg.title = tg.getAttribute('aria-expanded') === 'false' ? dict.sideExpand : dict.sideCollapse;
updateHumanMode();
// 欢迎页(若当前显示的是欢迎页则重绘)
if(chatDom.querySelector(".ai-welcome-card")){
@@ -495,12 +527,19 @@ function welcomeHTML(){
if(clearAllBtn){
clearAllBtn.onclick = function(){
if(!confirm(t("confirmClear"))) return;
if(cancelActive){ cancelActive(); cancelActive = null; }
const version = resetConversation(null);
openingConv = 'clearing';
fetch("/api/clear_all", {method:"DELETE"})
.then(()=>{
currentConvId = null;
.then(response=>{
if(!response.ok) throw new Error('Clear failed');
if(version !== conversationVersion) return;
openingConv = null;
chatDom.innerHTML = welcomeHTML();
loadHistory();
}).catch(function(){
if(version !== conversationVersion) return;
openingConv = null;
setComposerStatus(t('networkFail'), true);
});
}
}
@@ -508,13 +547,11 @@ clearAllBtn.onclick = function(){
// 新建对话:只清界面,等用户开口时再建会话
if(newChatBtn){
newChatBtn.onclick = function(){
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
resetConversation(null);
inputDom.value = "";
autoGrow();
clearAttachment();
setComposerStatus("");
currentConvId = null;
chatDom.innerHTML = welcomeHTML();
chatDom.scrollTop = 0;
followLatest = true;
@@ -647,33 +684,56 @@ function renderRecentChat(){
function ensureConv(){
if(currentConvId) return Promise.resolve(currentConvId);
if(creatingConv) return creatingConv;
creatingConv = fetch("/api/new_chat", {method:"POST"})
const version = conversationVersion;
const pending = 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=>{
if(version !== conversationVersion){
const error = new Error('Conversation changed');
error.name = 'AbortError';
throw error;
}
currentConvId = data.id;
if(data.welcome) addMsg(t('autoWelcome'), false);
return currentConvId;
}).finally(function(){ creatingConv = null; });
return creatingConv;
}).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;
stopHumanMode();
currentConvId = id;
openingConv = null;
creatingConv = null;
sendPreparing = false;
activeQuickTopic = null;
setBusy(false);
setComposerStatus('');
return conversationVersion;
}
// 打开历史会话:还原整段对话,而不是把标题重发一遍
let openingConv = null;
function openConversation(id){
if(openingConv === id) return; // 防连点
const version = resetConversation(id);
openingConv = id;
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
currentConvId = id;
setComposerStatus(t('historyLoading'));
renderSideHistory();
fetch("/api/get_messages?conv_id=" + id)
.then(res=>res.json())
.then(res=>{ if(!res.ok) throw new Error('History failed'); return res.json(); })
.then(msgs=>{
if(version !== conversationVersion) return;
openingConv = null;
setComposerStatus('');
chatDom.innerHTML = "";
if(!msgs.length){
chatDom.innerHTML = welcomeHTML();
@@ -693,7 +753,11 @@ function openConversation(id){
});
// 等渲染完再滚到底部
requestAnimationFrame(function(){ scrollChatToEnd(true); });
})["catch"](function(){ openingConv = null; });
}).catch(function(){
if(version !== conversationVersion) return;
openingConv = null;
setComposerStatus(t('networkFail'), true);
});
}
const CARD_PREFIX = "__CARD__";
@@ -749,12 +813,12 @@ function fillTrackCard(el, card){
}
}
function createMessageRow(isUser){
function createMessageRow(isUser, authorRole){
const row = document.createElement("div");
row.className = "msg-row " + (isUser ? "user-line" : "robot-line");
const author = document.createElement("div");
author.className = "message-author";
const role = isUser ? "you" : (window.humanSid ? "humanAgent" : "assistant");
const role = isUser ? "you" : (authorRole || "assistant");
author.innerHTML = (isUser ? "" : '<span class="author-mark">' + welcomeIcon("box") + '</span>') + '<span data-i18n="' + role + '">' + t(role) + '</span>';
row.appendChild(author);
return row;
@@ -775,19 +839,20 @@ loadHistory();
window.fillInput = function(words){
inputDom.value = words;
doSend();
return doSend();
}
// 点快捷问题:另起一个会话,不同话题就不会堆进同一条历史
window.askNewTopic = function(words){
if(cancelActive){ cancelActive(); cancelActive = null; }
currentConvId = null; // 下次发送时自动新建会话
if(activeQuickTopic === words && (sendPreparing || window.__busy)) return;
resetConversation(null);
activeQuickTopic = words;
// 新话题先重置界面,发送第一条消息时再收起欢迎内容。
if(!chatDom.querySelector('.ai-welcome-card')){
chatDom.innerHTML = welcomeHTML();
}
inputDom.value = words;
doSend();
return doSend();
}
window.sendOrder = function(){
@@ -816,64 +881,161 @@ function myOrders(){
.catch(function(){ addMsg("查询失败,请重试", false); });
}
function updateHumanMode(){
const banner = document.getElementById('human-session');
const label = document.getElementById('human-session-label');
const button = document.getElementById('return-ai');
if(banner) banner.hidden = !window.humanSid && !humanRequestToken;
const text = t(humanRequestToken ? 'humanConnecting' : humanStatus === 'active' ? 'humanActive' : 'humanWaiting');
if(label && label.textContent !== text) label.textContent = text;
if(button){
button.textContent = t('returnAI');
button.disabled = Boolean(humanRequestToken || humanClosing || humanSendingToken);
}
sendBtn.disabled = Boolean(humanRequestToken || humanClosing || humanSendingToken);
}
function stopHumanMode(){
if(humanPollTimer !== null) clearInterval(humanPollTimer);
humanPollTimer = null;
humanConvId = null;
humanStatus = '';
humanRequestToken = null;
humanSendingToken = null;
humanClosing = false;
window.humanSid = 0;
const typing = document.getElementById('human-typing');
if(typing) typing.remove();
updateHumanMode();
}
async function humanRequest(path, data){
const controller = new AbortController();
const timer = setTimeout(function(){ controller.abort(); }, 10000);
try{
const response = await fetch(path, {method:data ? 'POST' : 'GET',
headers:data ? {'Content-Type':'application/json'} : {},
body:data ? JSON.stringify(data) : undefined, signal:controller.signal});
const result = await response.json();
if(!response.ok){
const error = new Error('Human request failed');
error.status = response.status;
throw error;
}
return result;
}finally{ clearTimeout(timer); }
}
window.showHuman = async function(){
if(window.humanSid){
addMsg("您已在人工客服会话中,请直接提问。", false);
return;
if(humanRequestToken || humanClosing) return;
if(window.humanSid && humanConvId === currentConvId){ updateHumanMode(); return; }
if(window.humanSid) stopHumanMode();
const hour = new Date().getHours();
if(hour < 8 || hour >= 22) return leaveOfflineMessage();
const token = {version:conversationVersion};
humanRequestToken = token;
if(cancelActive){ cancelActive(); cancelActive = null; }
setBusy(false);
updateHumanMode();
try{
const cid = await ensureConv();
if(humanRequestToken !== token || token.version !== conversationVersion) return;
const data = await humanRequest('/api/human/request', {conv_id:cid});
if(humanRequestToken !== token || token.version !== conversationVersion) return;
if(data.closed){ await leaveOfflineMessage(); return; }
if(!data.ok || !data.session_id) throw new Error('Human session failed');
window.humanSid = data.session_id;
humanConvId = cid;
humanStatus = 'waiting';
addMsg(t('humanWaiting'), false);
startHumanPoll();
}catch(error){
if(humanRequestToken === token && token.version === conversationVersion) setComposerStatus(t('humanConnectFailed'), true);
}finally{
if(humanRequestToken === token){ humanRequestToken = null; updateHumanMode(); }
}
};
async function returnToAI(){
if(!window.humanSid || humanClosing || humanSendingToken) return;
const sid = window.humanSid, version = conversationVersion;
humanClosing = true;
updateHumanMode();
try{
const result = await humanRequest('/api/human/close', {sid:sid});
if(!result.ok) throw new Error('Close failed');
if(version !== conversationVersion || window.humanSid !== sid) return;
stopHumanMode();
setComposerStatus('');
addMsg(t('humanClosed'), false);
inputDom.focus();
}catch(error){
if(version === conversationVersion) setComposerStatus(t('humanReturnFailed'), true);
}finally{
if(version === conversationVersion){ humanClosing = false; updateHumanMode(); }
}
}
const returnAIButton = document.getElementById('return-ai');
if(returnAIButton) returnAIButton.onclick = returnToAI;
async function sendHumanMessage(content){
if(humanSendingToken || humanClosing) return;
const sid = window.humanSid, cid = currentConvId, version = conversationVersion;
const draft = inputDom.value, attachment = window.__pendingImg;
const token = {};
humanSendingToken = token;
updateHumanMode();
setComposerStatus(t('humanSending'));
try{
const result = await humanRequest('/api/human/send', {sid:sid, conv_id:cid, side:'customer', content:content});
if(!result.ok) throw new Error('Send failed');
if(version !== conversationVersion || humanSendingToken !== token) return;
addMsg(content, true);
if(inputDom.value === draft){ inputDom.value = ''; autoGrow(); }
if(window.__pendingImg === attachment) clearAttachment();
setComposerStatus(t(humanStatus === 'active' ? 'humanSent' : 'humanWaiting'));
inputDom.focus();
}catch(error){
if(version !== conversationVersion || humanSendingToken !== token) return;
if(error.status === 404 || error.status === 409) stopHumanMode();
setComposerStatus(t('humanSendFailed'), true);
}finally{
if(humanSendingToken === token){ humanSendingToken = null; updateHumanMode(); }
}
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(){
const version = conversationVersion;
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); }
const data = await humanRequest('/api/ticket', {content:message, phone:''});
if(!data.ok) throw new Error('Ticket failed');
if(version === conversationVersion) addMsg(t('offlineSaved'), false);
}catch(error){
if(version !== conversationVersion) return;
if(!inputDom.value) inputDom.value = message;
setComposerStatus(t('networkFail'), true);
}
}
function startHumanPoll(){
let last=0;
let polling=false;
const sid=window.humanSid;
const cid=humanConvId, version=conversationVersion;
let hadPollError=false;
if(humanPollTimer !== null) clearInterval(humanPollTimer);
const pollTimer=setInterval(()=>{
if(window.humanSid !== sid){ clearInterval(pollTimer); return; }
if(window.humanSid !== sid || version !== conversationVersion || currentConvId !== cid){ clearInterval(pollTimer); return; }
if(polling) return;
polling=true;
fetch("/api/human/poll?sid="+sid+"&after="+last)
.then(r=>r.json())
humanRequest("/api/human/poll?sid="+sid+"&after="+last)
.then(d=>{
if(window.humanSid !== sid) return;
if(window.humanSid !== sid || version !== conversationVersion || currentConvId !== cid) return;
if(hadPollError){ setComposerStatus(''); hadPollError=false; }
humanStatus=d.status;
updateHumanMode();
d.messages.forEach(m=>{
if(m.id <= last) return;
last = m.id;
@@ -888,7 +1050,7 @@ function startHumanPoll(){
system.textContent = m.content;
chatDom.appendChild(system);
}else if(m.side === 'agent'){
const bubble = addMsg(m.content, false);
const bubble = addMsg(m.content, false, 'humanAgent');
bubble.closest('.msg-row').setAttribute('data-human-message-id', String(m.id));
SupportUI.notify(t('humanReply'), m.content.substring(0, 50));
}
@@ -896,12 +1058,9 @@ function startHumanPoll(){
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();
if(humanClosing) return;
stopHumanMode();
setBusy(false);
addMsg(t('humanClosed'), false);
SupportUI.addRating(chatDom, sid, {question:t('ratingQuestion'), thanks:t('ratingThanks'), failed:t('ratingFailed')});
scrollChatToEnd();
@@ -920,13 +1079,18 @@ function startHumanPoll(){
oldT.remove();
}
})
.catch(()=>{}).finally(function(){ polling=false; });
.catch(function(){
if(version !== conversationVersion || window.humanSid !== sid) return;
hadPollError=true;
setComposerStatus(t('humanPollFailed'), true);
}).finally(function(){ polling=false; });
}, 1000);
humanPollTimer = pollTimer;
}
function addMsg(content, isUser){
function addMsg(content, isUser, authorRole){
if(chatDom.querySelector('.ai-welcome-card')) chatDom.innerHTML = "";
const rowDiv = createMessageRow(isUser);
const rowDiv = createMessageRow(isUser, authorRole);
let robotMsg;
// 检测图片附件
let imgUrl = null;
@@ -1134,35 +1298,31 @@ async function doSend(retryText){
return;
}
if(uploadPending){ setComposerStatus(t("uploading")); return; }
if(humanSendingToken || humanClosing) return;
var fullContent = sendWord;
if(imgUrl) fullContent += (fullContent ? " " : "") + tag + imgUrl;
setComposerStatus("");
if(openingConv !== null){ setComposerStatus(t('historyLoading')); return; }
if(humanRequestToken || humanClosing){ updateHumanMode(); return; }
// 人工客服模式:直接发给客服,不走AI
if(window.humanSid){
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(window.humanSid && humanConvId === currentConvId) return sendHumanMessage(fullContent);
if(window.humanSid) stopHumanMode();
if(sendPreparing || (window.__busy && !retryText)) return; // 防连点
if(cancelActive){ cancelActive(); cancelActive = null; }
sendPreparing = true;
const version = conversationVersion;
let convId;
try{
await ensureConv();
convId = await ensureConv();
}catch(error){
setComposerStatus(t('sessionFail'), true);
if(version === conversationVersion) setComposerStatus(t('sessionFail'), true);
return;
}finally{
sendPreparing = false;
if(version === conversationVersion) sendPreparing = false;
}
if(version !== conversationVersion || humanRequestToken) return;
if(!isRetry){
addMsg(fullContent, true);
inputDom.value = "";
@@ -1257,7 +1417,7 @@ async function doSend(retryText){
cancelActive = stop;
function handleFrame(dataText){
if(settled) return;
if(settled || version !== conversationVersion) return;
let obj;
try{
obj = JSON.parse(dataText);
@@ -1309,7 +1469,7 @@ async function doSend(retryText){
fetch("/stream_chat", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({msg: fullContent, conv_id: currentConvId}),
body: JSON.stringify({msg: fullContent, conv_id: convId}),
signal: controller.signal
}).then(function(res){
if(!res.ok){ throw new Error("HTTP " + res.status); }