Compare commits
2
Commits
df688844d3
...
167c888718
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
167c888718 | ||
|
|
97fc9bec14 |
@@ -24,7 +24,9 @@ done < "$release/release.env"
|
||||
[[ "$CHAT_IMAGE" =~ ^baiyue-logistics-chat-gray:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
|
||||
[[ "$HOST_PORT" =~ ^[0-9]+$ ]] && ((HOST_PORT >= 1024 && HOST_PORT <= 65535)) || exit 2
|
||||
[[ -d "$CHAT_PROXY_DIR" && -r "$CHAT_TLS_CERT" && -r "$CHAT_TLS_KEY" ]] || exit 2
|
||||
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_IMAGE")" == "$CHAT_IMAGE_ID" ]] || exit 2
|
||||
loaded_id=$(awk -F= '$1 == "CHAT_IMAGE_ID" {print $2}' "$release/loaded-image-ids.env")
|
||||
[[ "$loaded_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
|
||||
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_IMAGE")" == "$loaded_id" ]] || { echo 'Loaded application image changed.' >&2; exit 2; }
|
||||
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CHAT_IMAGE")" == "$SOURCE_COMMIT" ]] || exit 2
|
||||
openssl x509 -in "$CHAT_TLS_CERT" -checkhost "$CHAT_DOMAIN" -noout >/dev/null
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ done < "$release/release.env"
|
||||
[[ "$CHAT_TLS_CERT" == "$root/tls/config/live/$CHAT_DOMAIN/fullchain.pem" ]] || exit 2
|
||||
[[ "$CHAT_TLS_KEY" == "$root/tls/config/live/$CHAT_DOMAIN/privkey.pem" ]] || exit 2
|
||||
[[ "$CHAT_CERTBOT_IMAGE" =~ ^baiyue-logistics-chat-certbot:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
|
||||
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_CERTBOT_IMAGE")" == "$CHAT_CERTBOT_IMAGE_ID" ]] || exit 2
|
||||
loaded_id=$(awk -F= '$1 == "CERTBOT_IMAGE_ID" {print $2}' "$release/loaded-image-ids.env")
|
||||
[[ "$loaded_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
|
||||
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_CERTBOT_IMAGE")" == "$loaded_id" ]] || { echo 'Loaded certificate image changed.' >&2; exit 2; }
|
||||
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CHAT_CERTBOT_IMAGE")" == "$SOURCE_COMMIT" ]] || exit 2
|
||||
|
||||
site="$CHAT_PROXY_DIR/baiyue-logistics-chat-gray.conf"
|
||||
|
||||
+33
-3
@@ -40,7 +40,7 @@ release=$2
|
||||
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
|
||||
[[ "$release" == "$root"/releases/* ]] || exit 2
|
||||
[[ "$(id -u)" == 0 ]] || { echo 'The managed gray deployment account must have the required host permissions.' >&2; exit 2; }
|
||||
for program in docker nginx openssl curl ss systemctl; do command -v "$program" >/dev/null; done
|
||||
for program in docker nginx openssl curl ss systemctl sha256sum; do command -v "$program" >/dev/null; done
|
||||
if [[ -e "$root" && ! -f "$root/.project" ]] && [[ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
|
||||
echo 'Refusing to reuse an unowned deployment directory.' >&2
|
||||
exit 2
|
||||
@@ -55,8 +55,38 @@ chown 10001:10001 "$root/data" "$root/logs"
|
||||
install -d -m 700 "$release"
|
||||
PREPARE
|
||||
|
||||
# Transport only tested images built without runtime credentials.
|
||||
docker save "$CHAT_IMAGE" "$CERTBOT_IMAGE" | gzip | run_ssh "bash -o pipefail -c 'gzip -dc | docker load'"
|
||||
# Verify the exact exported archive before loading. Classic and containerd image
|
||||
# stores can report different IDs for the same archive, so retain target IDs too.
|
||||
docker save "$CHAT_IMAGE" "$CERTBOT_IMAGE" | gzip > "$temporary/images.tar.gz"
|
||||
archive_sha=$(sha256sum "$temporary/images.tar.gz" | awk '{print $1}')
|
||||
scp "${ssh_options[@]}" "$temporary/images.tar.gz" "$target:$release/images.tar.gz"
|
||||
run_ssh bash -s -- "$root" "$release" "$archive_sha" "$CHAT_IMAGE" "$CERTBOT_IMAGE" "$SOURCE_COMMIT" <<'LOAD'
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
root=$1
|
||||
release=$2
|
||||
checksum=$3
|
||||
app_image=$4
|
||||
certbot_image=$5
|
||||
source_commit=$6
|
||||
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
|
||||
[[ "$release" == "$root"/releases/* && "$release" != *..* ]] || exit 2
|
||||
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
|
||||
[[ "$checksum" =~ ^[a-f0-9]{64}$ && "$source_commit" =~ ^[a-f0-9]{40}$ ]] || exit 2
|
||||
[[ "$app_image" =~ ^baiyue-logistics-chat-gray:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
|
||||
[[ "$certbot_image" =~ ^baiyue-logistics-chat-certbot:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
|
||||
printf '%s %s\n' "$checksum" "$release/images.tar.gz" | sha256sum --check --status
|
||||
gzip -dc "$release/images.tar.gz" | docker load
|
||||
for image in "$app_image" "$certbot_image"; do
|
||||
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" == "$source_commit" ]] || exit 2
|
||||
done
|
||||
app_id=$(docker image inspect --format '{{.Id}}' "$app_image")
|
||||
certbot_id=$(docker image inspect --format '{{.Id}}' "$certbot_image")
|
||||
[[ "$app_id" =~ ^sha256:[a-f0-9]{64}$ && "$certbot_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
|
||||
printf 'ARCHIVE_SHA256=%s\nCHAT_IMAGE_ID=%s\nCERTBOT_IMAGE_ID=%s\n' "$checksum" "$app_id" "$certbot_id" > "$release/loaded-image-ids.env"
|
||||
rm -f -- "$release/images.tar.gz"
|
||||
echo 'Image archive checksum and source labels verified; target image IDs recorded.'
|
||||
LOAD
|
||||
scp "${ssh_options[@]}" "$temporary/release/"* "$target:$release/"
|
||||
run_ssh bash "$release/prepare-tls.sh" "$root" "$release"
|
||||
run_ssh bash "$release/activate.sh" "$root" "$release"
|
||||
|
||||
@@ -86,6 +86,7 @@ tests/ # 按业务模块组织的回归
|
||||
- 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。
|
||||
- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。
|
||||
- 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。
|
||||
- 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。
|
||||
- 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。
|
||||
|
||||
## 后续结构调整顺序
|
||||
|
||||
+2
-2
@@ -37,11 +37,11 @@
|
||||
1. 完成检查并提交代码,创建 `gray-chat-YYYYMMDD-<commit12>` 一类不可变发布标签。不要重写已有标签或强推覆盖远端分支;分支有其他更新时先明确发布基线。
|
||||
2. 推送标签,在任务 `SOURCE_REF` 选择该标签。Jenkins 按此引用加载流水线并检出代码,镜像记录完整提交 SHA。
|
||||
3. 流水线检查 Bash 语法,运行 Node 回归,再构建 `test` 目标执行 Python 回归;通过后构建 `production` 应用镜像及 `certbot` 运维镜像。
|
||||
4. 构建完成后才绑定运行凭据。发布脚本校验路径、域名、源提交和镜像 ID,生成私有配置并发送镜像及发布文件。
|
||||
4. 构建完成后才绑定运行凭据。发布脚本校验路径、域名和源提交,生成私有配置并发送镜像及发布文件。服务器验证镜像归档的 SHA-256 后加载,记录目标主机的镜像 ID。
|
||||
5. 首次签发域名证书,等待应用健康,再安装本服务的 Nginx 配置。已有业务的容器和站点配置不参与本次发布。
|
||||
6. Jenkins 使用验收账号验证公网 HTTPS `/healthz`;之后在浏览器验证 AI 流式回复、上传、转人工和工作台。
|
||||
|
||||
应用镜像必须选择 `production` 构建目标;`certbot` 仅用于证书运维。每个构建生成独立镜像标签,接收主机再次核验镜像 ID,不依赖 `latest`。
|
||||
应用镜像必须选择 `production` 构建目标;`certbot` 仅用于证书运维。每个构建生成独立镜像标签,不依赖 `latest`。Docker 的 classic 与 containerd 镜像存储可能返回不同 ID;跨主机以归档校验和与源提交标签核验传输,激活前再与 `loaded-image-ids.env` 中的目标 ID 比较,拒绝加载后被替换的镜像。归档加载后删除,构建机 ID 保留在 `release.env` 供追溯。
|
||||
|
||||
## 数据与失败回退
|
||||
|
||||
|
||||
@@ -42,11 +42,20 @@ def human_poll(sid, after, typing_map):
|
||||
def human_send(data):
|
||||
sid = data.get('sid')
|
||||
side = data.get('side', 'customer')
|
||||
content = (data.get('content') or '').strip()
|
||||
if not sid or not content:
|
||||
content = data.get('content') or ''
|
||||
if not sid or not isinstance(content, str) or not content.strip() or side not in ('customer', 'agent'):
|
||||
return ({'ok': False}, 400)
|
||||
content = content.strip()
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
conn = connect()
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
row = conn.execute('SELECT conv_id, status FROM human_sessions WHERE id=?', (sid,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return ({'ok': False, 'error': 'session_not_found'}, 404)
|
||||
if row[1] == 'closed' or (data.get('conv_id') is not None and data['conv_id'] != row[0]):
|
||||
conn.close()
|
||||
return ({'ok': False, 'error': 'conversation_changed'}, 409)
|
||||
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)', (sid, side, content, now))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
</section>
|
||||
</div>
|
||||
<footer class="composer">
|
||||
<div id="human-session" class="human-session" role="status" hidden><span id="human-session-label"></span><button id="return-ai" type="button">返回 AI 问答</button></div>
|
||||
<div class="hot-bar">
|
||||
<span class="hot-bar-label" data-i18n="quickLabel">快捷咨询</span>
|
||||
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查运单</button>
|
||||
|
||||
+220
-14
@@ -5,12 +5,14 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const flush = () => new Promise(setImmediate);
|
||||
|
||||
async function customerPage(){
|
||||
const elements = new Map(), listeners = {}, intervals = [], calls = [];
|
||||
async function customerPage(pageOptions = {}){
|
||||
const elements = new Map(), listeners = {}, intervals = new Map(), calls = [], hooks = {};
|
||||
let intervalId = 0, nextConvId = 1;
|
||||
class Element {
|
||||
constructor(){
|
||||
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
|
||||
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300;
|
||||
this.listeners = {};
|
||||
this.classList = {
|
||||
contains: name => this.className.split(' ').includes(name),
|
||||
add: name => { this.className += ' '+name; },
|
||||
@@ -24,7 +26,8 @@ async function customerPage(){
|
||||
get textContent(){ return (this.text || this.innerText || this.html || '')+this.children.map(child => child.textContent).join(''); }
|
||||
setAttribute(name, value){ this.attributes[name] = value; }
|
||||
getAttribute(name){ return this.attributes[name]; }
|
||||
addEventListener(){}
|
||||
addEventListener(name, callback){ this.listeners[name] = callback; }
|
||||
click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); }
|
||||
appendChild(child){ child.parent = this; this.children.push(child); }
|
||||
append(...children){ children.forEach(child => this.appendChild(child)); }
|
||||
focus(){}
|
||||
@@ -39,31 +42,49 @@ async function customerPage(){
|
||||
};
|
||||
return this.children.flatMap(child => [...(matches(child) ? [child] : []), ...child.querySelectorAll(selector)]);
|
||||
}
|
||||
closest(selector){ return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector); }
|
||||
closest(selector){
|
||||
if(selector === 'button[data-question]' && this.dataset.question) return this;
|
||||
if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this;
|
||||
return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector);
|
||||
}
|
||||
remove(){ if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
|
||||
}
|
||||
const element = key => { if(!elements.has(key)) elements.set(key, new Element()); return elements.get(key); };
|
||||
const quickButtons = [0, 1, 2].map(index => { const button = new Element(); button.dataset.questionIndex = String(index); return button; });
|
||||
const document = {
|
||||
body: new Element(), documentElement: {},
|
||||
getElementById: id => ['human-typing', 'imgPreview', 'recentChatWrap'].includes(id) ? null : element(id),
|
||||
querySelector: selector => element(selector), querySelectorAll: () => [],
|
||||
querySelector: selector => element(selector), querySelectorAll: selector => selector === '[data-question-index]' ? quickButtons : [],
|
||||
createElement: () => new Element(),
|
||||
addEventListener: (name, callback) => { listeners[name] = callback; },
|
||||
};
|
||||
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, 12])); } }
|
||||
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
|
||||
let state = {status:'active', messages:[]};
|
||||
const context = vm.createContext({
|
||||
document, console, Date:TestDate, navigator:{language:'zh-CN'},
|
||||
document, console, Date:TestDate, navigator:{language:'zh-CN'}, AbortController, TextDecoder,
|
||||
requestAnimationFrame:callback => callback(),
|
||||
localStorage:{getItem:() => null, setItem(){}},
|
||||
window:{innerWidth:1200, addEventListener(){}, matchMedia:() => ({matches:false})},
|
||||
setInterval: callback => { intervals.push(callback); return intervals.length; },
|
||||
clearInterval(){}, setTimeout(){}, clearTimeout(){},
|
||||
window:{innerWidth:1200, addEventListener(){}, matchMedia:() => ({matches:false}), prompt:() => pageOptions.offlineMessage || null},
|
||||
setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; },
|
||||
clearInterval:id => intervals.delete(id), setTimeout(){}, clearTimeout(){},
|
||||
fetch: async (url, options) => {
|
||||
calls.push({url, body:options && options.body && JSON.parse(options.body)});
|
||||
const data = url === '/api/new_chat' ? {id:1, welcome:'welcome'}
|
||||
if(hooks[url]) return hooks[url](options);
|
||||
if(url.startsWith('/api/human/poll') && hooks.poll) return hooks.poll(url);
|
||||
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
|
||||
if(url === '/stream_chat'){
|
||||
let read = false;
|
||||
return {ok:true, body:{getReader:() => ({read:async () => {
|
||||
if(read) return {done:true};
|
||||
read = true;
|
||||
return {value:new TextEncoder().encode('data: {"t":"测试 AI 回复"}\n\ndata: {"d":1}\n\n'), done:false};
|
||||
}})}};
|
||||
}
|
||||
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
|
||||
: url === '/api/human/request' ? {ok:true, session_id:7}
|
||||
: url.startsWith('/api/human/poll') ? state
|
||||
: url === '/api/rate' ? {ok:true} : [];
|
||||
: ['/api/rate', '/api/human/send', '/api/human/close', '/api/ticket'].includes(url) ? {ok:true}
|
||||
: url === '/api/get_history' ? (pageOptions.history || []) : [];
|
||||
return {ok:true, json:async () => data};
|
||||
},
|
||||
});
|
||||
@@ -72,9 +93,13 @@ async function customerPage(){
|
||||
}
|
||||
listeners.DOMContentLoaded();
|
||||
await flush();
|
||||
await context.window.showHuman();
|
||||
if(pageOptions.human !== false) await context.window.showHuman();
|
||||
await flush();
|
||||
return {context, element, calls, setState:value => { state=value; }, tick:async () => { intervals[0](); await flush(); }};
|
||||
return {context, element, calls, hooks, quickButtons,
|
||||
setState:value => { state=value; },
|
||||
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
|
||||
click:async button => { await button.click(); await flush(); },
|
||||
};
|
||||
}
|
||||
|
||||
test('the final customer-visible reply is processed before closure and a rating can be submitted', async () => {
|
||||
@@ -103,3 +128,184 @@ test('recalling an earlier reply preserves newer replies in the customer log', a
|
||||
assert.match(log.textContent, /最新回复/);
|
||||
assert.doesNotMatch(log.textContent, /早先的回复/);
|
||||
});
|
||||
|
||||
function response(data, status = 200){ return {ok:status >= 200 && status < 300, status, json:async () => data}; }
|
||||
|
||||
test('all three footer questions leave the human queue and send to separate AI conversations', async () => {
|
||||
const app = await customerPage();
|
||||
for(const button of app.quickButtons) await app.click(button);
|
||||
const requests = app.calls.filter(call => call.url === '/stream_chat');
|
||||
assert.equal(requests.length, 3);
|
||||
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 3);
|
||||
assert.deepEqual(requests.map(call => call.body.msg), ['帮我查询物流轨迹', '欧洲海运专线报价', '包裹异常处理']);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
|
||||
assert.equal(app.context.window.humanSid, 0);
|
||||
assert.equal(app.element('human-session').hidden, true);
|
||||
});
|
||||
|
||||
test('welcome and hot question buttons use the same AI routing as the footer', async () => {
|
||||
const app = await customerPage();
|
||||
for(const question of ['欧洲海运专线报价', '如何申请理赔?']){
|
||||
const button = app.element('question-'+question);
|
||||
button.dataset.question = question;
|
||||
app.element('chatDom').listeners.click({target:button});
|
||||
await flush();
|
||||
}
|
||||
const requests = app.calls.filter(call => call.url === '/stream_chat');
|
||||
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '如何申请理赔?']);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
|
||||
});
|
||||
|
||||
test('the welcome contact button opens the human queue and does not send an AI question', async () => {
|
||||
const app = await customerPage({human:false});
|
||||
const button = app.element('contact-card');
|
||||
button.setAttribute('data-human-support', '');
|
||||
app.element('chatDom').listeners.click({target:button});
|
||||
await flush();
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 1);
|
||||
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 0);
|
||||
assert.equal(app.element('human-session').hidden, false);
|
||||
});
|
||||
|
||||
test('new chat clears human routing and the next quote is sent to AI', async () => {
|
||||
const app = await customerPage();
|
||||
await app.click(app.element('.new-chat-btn'));
|
||||
await app.context.window.fillInput('请报价,30KG发往美国FBA仓');
|
||||
await flush();
|
||||
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '请报价,30KG发往美国FBA仓');
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
|
||||
assert.equal(app.context.window.__busy, false);
|
||||
});
|
||||
|
||||
test('opening AI history clears the human session and retains that conversation id', async () => {
|
||||
const app = await customerPage({history:[{id:9, title:'之前的对话', ctime:'2026-10-29 12:00'}]});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
await app.context.window.fillInput('请继续回答这个问题');
|
||||
await flush();
|
||||
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id, 9);
|
||||
assert.equal(app.context.window.humanSid, 0);
|
||||
const authors = app.element('chatDom').querySelectorAll('.message-author');
|
||||
assert.ok(authors.some(author => author.innerHTML.includes('data-i18n="assistant"')));
|
||||
});
|
||||
|
||||
test('a repeated quick click while a new conversation is being created sends only once', async () => {
|
||||
const app = await customerPage({human:false});
|
||||
let resolve;
|
||||
app.hooks['/api/new_chat'] = () => new Promise(done => { resolve=done; });
|
||||
app.quickButtons[0].click();
|
||||
app.quickButtons[0].click();
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length, 1);
|
||||
resolve(response({id:12, welcome:'welcome'}));
|
||||
await flush();
|
||||
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 1);
|
||||
});
|
||||
|
||||
test('a late conversation creation cannot replace the newest quick question', async () => {
|
||||
const app = await customerPage({human:false});
|
||||
let resolve, count=0;
|
||||
app.hooks['/api/new_chat'] = () => ++count === 1 ? new Promise(done => { resolve=done; }) : response({id:22, welcome:'welcome'});
|
||||
app.quickButtons[0].click();
|
||||
await app.click(app.quickButtons[2]);
|
||||
resolve(response({id:21, welcome:'welcome'}));
|
||||
await flush();
|
||||
const requests = app.calls.filter(call => call.url === '/stream_chat');
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].body.conv_id, 22);
|
||||
assert.equal(requests[0].body.msg, '包裹异常处理');
|
||||
assert.equal(app.context.window.__busy, false);
|
||||
});
|
||||
|
||||
test('a late history response cannot overwrite the latest quick conversation', async () => {
|
||||
const app = await customerPage({history:[{id:9, title:'旧会话', ctime:'2026-10-29 12:00'}]});
|
||||
let resolve;
|
||||
app.hooks.messages = () => new Promise(done => { resolve=done; });
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
await app.click(app.quickButtons[1]);
|
||||
resolve(response([{role:'user', content:'旧会话的内容不应显示'}]));
|
||||
await flush();
|
||||
assert.doesNotMatch(app.element('chatDom').textContent, /旧会话的内容不应显示/);
|
||||
assert.match(app.element('chatDom').textContent, /测试 AI 回复/);
|
||||
});
|
||||
|
||||
test('a delayed human connection cannot capture a newly selected AI topic', async () => {
|
||||
const app = await customerPage({human:false});
|
||||
let resolve;
|
||||
app.hooks['/api/human/request'] = () => new Promise(done => { resolve=done; });
|
||||
const connecting = app.context.window.showHuman();
|
||||
await flush();
|
||||
await app.click(app.quickButtons[1]);
|
||||
resolve(response({ok:true, session_id:8}));
|
||||
await connecting;
|
||||
assert.equal(app.context.window.humanSid, 0);
|
||||
assert.equal(app.element('human-session').hidden, true);
|
||||
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 1);
|
||||
});
|
||||
|
||||
test('repeated contact clicks create one human request and show a visible queue state', async () => {
|
||||
const app = await customerPage({human:false});
|
||||
await Promise.all([app.context.window.showHuman(), app.context.window.showHuman()]);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 1);
|
||||
assert.equal(app.element('human-session').hidden, false);
|
||||
assert.match(app.element('human-session-label').textContent, /正在排队/);
|
||||
});
|
||||
|
||||
test('the contact shortcut outside service hours submits a ticket without entering human mode', async () => {
|
||||
const app = await customerPage({human:false, hour:23, offlineMessage:'请明天跟进报价'});
|
||||
await app.context.window.showHuman();
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 0);
|
||||
assert.equal(app.calls.find(call => call.url === '/api/ticket').body.content, '请明天跟进报价');
|
||||
assert.match(app.element('chatDom').textContent, /留言已提交/);
|
||||
assert.equal(app.context.window.humanSid, 0);
|
||||
});
|
||||
|
||||
test('a failed human send preserves the text and makes retry possible', async () => {
|
||||
const app = await customerPage();
|
||||
app.hooks['/api/human/send'] = () => response({ok:false}, 503);
|
||||
await app.context.window.fillInput('请确认这批货的价格');
|
||||
assert.equal(app.element('input-text').value, '请确认这批货的价格');
|
||||
assert.match(app.element('composer-status').textContent, /内容已保留/);
|
||||
assert.equal(app.element('send-button').disabled, false);
|
||||
});
|
||||
|
||||
test('a duplicate human send cannot submit an in-flight message twice', async () => {
|
||||
const app = await customerPage();
|
||||
let resolve;
|
||||
app.hooks['/api/human/send'] = () => new Promise(done => { resolve=done; });
|
||||
const first = app.context.window.fillInput('请确认时效');
|
||||
await app.context.window.fillInput('请确认时效');
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1);
|
||||
resolve(response({ok:true}));
|
||||
await first;
|
||||
assert.equal(app.element('input-text').value, '');
|
||||
});
|
||||
|
||||
test('returning to AI explicitly closes the human session and preserves the conversation', async () => {
|
||||
const app = await customerPage();
|
||||
await app.click(app.element('return-ai'));
|
||||
await app.context.window.fillInput('请继续解答');
|
||||
await flush();
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/human/close').length, 1);
|
||||
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id, 1);
|
||||
assert.equal(app.context.window.humanSid, 0);
|
||||
});
|
||||
|
||||
test('a stale human poll cannot append messages to a newly selected AI topic', async () => {
|
||||
const app = await customerPage();
|
||||
let resolve;
|
||||
app.hooks.poll = () => new Promise(done => { resolve=done; });
|
||||
await app.tick();
|
||||
await app.click(app.quickButtons[1]);
|
||||
resolve(response({status:'active', messages:[{id:1, side:'agent', content:'旧人工会话的回复'}]}));
|
||||
await flush();
|
||||
assert.doesNotMatch(app.element('chatDom').textContent, /旧人工会话的回复/);
|
||||
});
|
||||
|
||||
test('human poll failures are visible and clear after the connection recovers', async () => {
|
||||
const app = await customerPage();
|
||||
app.hooks.poll = () => Promise.reject(new Error('Disconnected'));
|
||||
await app.tick();
|
||||
assert.match(app.element('composer-status').textContent, /连接中断/);
|
||||
delete app.hooks.poll;
|
||||
await app.tick();
|
||||
assert.equal(app.element('composer-status').hidden, true);
|
||||
});
|
||||
|
||||
@@ -67,6 +67,17 @@ class DevIntegrationTests(unittest.TestCase):
|
||||
self.client.post('/api/human/accept', json={'sid': sid})
|
||||
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'], 'active')
|
||||
|
||||
def test_human_send_rejects_closed_missing_or_mismatched_conversations(self):
|
||||
sid = self.session()
|
||||
payload = {'sid': sid, 'side': 'customer', 'content': '不能串到其他会话'}
|
||||
other_cid = self.client.post('/api/new_chat').json['id']
|
||||
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'conv_id': other_cid}).status_code, 409)
|
||||
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'sid': 99999}).status_code, 404)
|
||||
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'side': 'system'}).status_code, 400)
|
||||
self.client.post('/api/human/close', json={'sid': sid})
|
||||
self.assertEqual(self.client.post('/api/human/send', json=payload).status_code, 409)
|
||||
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['messages'], [])
|
||||
|
||||
def test_recall_targets_older_agent_message_and_is_idempotent(self):
|
||||
sid = self.session()
|
||||
for side, text in (('agent', '早先的回复'), ('customer', '客户消息'), ('agent', '最新回复')):
|
||||
|
||||
@@ -66,9 +66,12 @@ class GrayActivationTests(unittest.TestCase):
|
||||
unrelated.write_text('# unrelated site\n')
|
||||
config = GrayConfig('chat.example.com', 29013, str(root), str(root / 'releases/new'),
|
||||
str(proxy), str(certificate), str(private_key), 'a'*40,
|
||||
'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'b'*64, 'sha256:' + 'c'*64)
|
||||
'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'd'*64, 'sha256:' + 'c'*64)
|
||||
release = Path(config.release)
|
||||
write_release(release, config, 'fixture-model-key')
|
||||
# Docker 26 and 29 may assign different IDs after an archive is loaded.
|
||||
loaded_id = ('e' if scenario == 'image-mismatch' else 'b') * 64
|
||||
(release / 'loaded-image-ids.env').write_text('CHAT_IMAGE_ID=sha256:' + loaded_id + '\n')
|
||||
(release / 'site.htpasswd').write_text('new-auth-hash\n')
|
||||
commands = directory / 'commands'
|
||||
commands.mkdir()
|
||||
@@ -105,6 +108,13 @@ class GrayActivationTests(unittest.TestCase):
|
||||
def test_failed_application_restores_previous_release(self):
|
||||
self.assert_rolled_back('app-fail')
|
||||
|
||||
def test_image_retagged_after_verified_load_is_rejected_before_activation(self):
|
||||
result, root, proxy, previous, release, old_site, calls = self.run_activation('image-mismatch')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
|
||||
self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site)
|
||||
self.assertFalse(any(name == 'docker' and 'compose' in args for name, args in calls))
|
||||
|
||||
def test_invalid_nginx_configuration_restores_previous_site_and_application(self):
|
||||
self.assert_rolled_back('proxy-fail')
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Verify the real remote image-load guard without contacting Docker or a host."""
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == 'posix' and shutil.which('bash'), 'Image transfer requires Linux Bash')
|
||||
class GrayImageTransferTests(unittest.TestCase):
|
||||
def run_load(self, scenario):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
directory = Path(temporary.name)
|
||||
root = directory / 'gray-logistics-chat'
|
||||
release = root / 'releases/build'
|
||||
release.mkdir(parents=True)
|
||||
(root / '.project').write_text('baiyue-logistics-chat-gray\n')
|
||||
archive = release / 'images.tar.gz'
|
||||
archive.write_bytes(gzip.compress(b'fixture-image-archive'))
|
||||
checksum = hashlib.sha256(archive.read_bytes()).hexdigest()
|
||||
if scenario == 'corrupt':
|
||||
archive.write_bytes(archive.read_bytes() + b'changed-in-transit')
|
||||
commands = directory / 'commands'
|
||||
commands.mkdir()
|
||||
docker = commands / 'docker'
|
||||
docker.write_text('#!' + sys.executable + '\n' + '''import os, pathlib, sys
|
||||
args = sys.argv[1:]
|
||||
with pathlib.Path(os.environ['FAKE_LOG']).open('a') as output:
|
||||
output.write(' '.join(args) + '\\n')
|
||||
if args == ['load']:
|
||||
assert sys.stdin.buffer.read() == b'fixture-image-archive'
|
||||
elif args[:2] == ['image', 'inspect']:
|
||||
if args[3] == '{{.Id}}':
|
||||
print('sha256:' + ('b' if '-gray:' in args[-1] else 'c') * 64)
|
||||
else:
|
||||
print(('e' if os.environ['FAKE_SCENARIO'] == 'wrong-source' else 'a') * 40)
|
||||
else:
|
||||
sys.exit(3)
|
||||
''')
|
||||
docker.chmod(0o755)
|
||||
publish = (Path(__file__).resolve().parents[1] / 'deploy/publish-gray.sh').read_text()
|
||||
remote = publish.split("<<'LOAD'\n", 1)[1].split('\nLOAD\n', 1)[0]
|
||||
log = directory / 'commands.log'
|
||||
environment = dict(os.environ, PATH=str(commands) + os.pathsep + os.environ['PATH'],
|
||||
FAKE_LOG=str(log), FAKE_SCENARIO=scenario)
|
||||
result = subprocess.run(['bash', '-s', '--', str(root), str(release), checksum,
|
||||
'baiyue-logistics-chat-gray:' + 'a' * 12 + '-b7',
|
||||
'baiyue-logistics-chat-certbot:' + 'a' * 12 + '-b7', 'a' * 40],
|
||||
input=remote, env=environment, capture_output=True, text=True, timeout=15)
|
||||
return result, release, log
|
||||
|
||||
def test_verified_archive_records_target_ids_and_removes_archive(self):
|
||||
result, release, log = self.run_load('success')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
receipt = (release / 'loaded-image-ids.env').read_text()
|
||||
self.assertIn('CHAT_IMAGE_ID=sha256:' + 'b' * 64, receipt)
|
||||
self.assertIn('CERTBOT_IMAGE_ID=sha256:' + 'c' * 64, receipt)
|
||||
self.assertFalse((release / 'images.tar.gz').exists())
|
||||
|
||||
def test_modified_archive_is_rejected_before_docker_load(self):
|
||||
result, release, log = self.run_load('corrupt')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(log.exists())
|
||||
self.assertFalse((release / 'loaded-image-ids.env').exists())
|
||||
|
||||
def test_wrong_source_cannot_create_a_verified_receipt(self):
|
||||
result, release, log = self.run_load('wrong-source')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((release / 'loaded-image-ids.env').exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user