Files
ai-kefu-admin/static/js/chat.js
T
浅念 d1aed8e722 feat: 吸收上游 7 个测试文件断言与 placeholder 配置化对齐
上游 ai-kefu → phase1 吸收(Task 11 unit 12):
- 拷入 7 个测试文件,import/夹具按目标库重锚(app.engine.* / conftest
  async_db+build_jiancai_runtime / seed_profiles 提示词锚点)
- 补实现(同步自上游): chat_rest 服务端可信 IP(_server_ip/
  _merge_client_info,直连场景自报假 IP 不再原样落库);widget.js 与
  chat.js 的 data-placeholder 读取(叠加 Task 10 管道:data-* > brand >
  中性兜底,tests/test_placeholder_static.py 守卫)
- 断言重锚以 jc 主线为准: ev.contact_info 存裸号+等待尾注(e164 走
  _cc 派生)、source 值 fallback/customer_stated 口径、推送标注键
  号码补全说明、prompt 静态锚按租户(seed_profiles)双测
- 全量回归 459 passed(基线 421 + 吸收 38)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-12 23:19:02 +08:00

312 lines
12 KiB
JavaScript

// ---- Task 10: brand 拉取(同源 /api/public/brand,下方两个 IIFE 共用) ----
// 失败/超时(3s) → resolve(null) → 中性兜底,页面照常可用不白屏。
var chatBrandReady = (function () {
try {
var p = fetch("/api/public/brand", { cache: "no-store" }).then(function (r) {
return r.ok ? r.json() : null;
}).catch(function () {
return null;
});
return Promise.race([p, new Promise(function (res) { setTimeout(function () { res(null); }, 3000); })]);
} catch (e) {
return Promise.resolve(null); // 无 fetch 的远古环境:中性兜底
}
})();
// ---- 动态主题色 bootstrap(spec §3):派生 → :root 变量 ----
// Task 10 颜色优先级: ?color=(URL 显式覆盖,语义同挂件 data-*) >
// brand.color(租户/站点门面) > DEFAULT_COLOR。
(function () {
if (typeof ChatTheme === "undefined") return; // theme.js 加载失败→style.css :root 品牌蓝兜底
// 头像兜底(与挂件同机制)——与 brand 无关,立即挂
Array.prototype.forEach.call(document.querySelectorAll(".avatar-img"), function (img) {
function hideEmoji() {
var s = img.parentElement && img.parentElement.querySelector(".avatar-emoji");
if (s) s.style.display = "none";
}
if (img.complete && img.naturalWidth > 0) { hideEmoji(); return; }
img.addEventListener("load", hideEmoji);
img.addEventListener("error", function () { img.remove(); });
});
chatBrandReady.then(function (b) {
var m = /(?:^|[?&])color=([^&]*)/.exec(location.search);
function safeDecode(s) { try { return decodeURIComponent(s); } catch (e) { return ""; } }
var color = m ? safeDecode(m[1]).trim() : (((b && b.color) || "").trim());
var palette = ChatTheme.derivePalette(color || ChatTheme.DEFAULT_COLOR);
var root = document.documentElement.style;
root.setProperty("--w-primary", palette.primary);
root.setProperty("--w-primary-rgb", palette.primaryRgb);
root.setProperty("--w-gradient-end", palette.gradientEnd);
root.setProperty("--w-deep-text", palette.deepText);
root.setProperty("--w-caption-text", palette.captionText);
root.setProperty("--w-soft-bg", palette.softBg);
root.setProperty("--w-border-soft", palette.borderSoft);
});
})();
(function () {
// 联系方式配置(与 widget.js 的 data-* 机制一致:属性显式覆盖 > brand)
var chatScript = document.currentScript || document.querySelector('script[src*="chat.js"]');
var whatsappAttr = chatScript && chatScript.getAttribute("data-whatsapp");
var emailAttr = chatScript && chatScript.getAttribute("data-email");
// 同步自上游 ai-kefu: data-placeholder 显式覆盖输入框占位文案(裸读,
// 有效值在 applyBrand 推导——data-* > brand > 不动 chat.html 静态值)
var placeholderAttr = chatScript && chatScript.getAttribute("data-placeholder");
var waBtn = document.querySelector('.whatsapp-btn');
var emBtn = document.querySelector('.email-btn');
var sessionId = null;
var messagesEl = document.getElementById("messages");
var inputEl = document.getElementById("input");
var sendBtn = document.getElementById("sendBtn");
var typingEl = document.getElementById("typing");
var endedEl = document.getElementById("chatEnded");
var popularTopicsEl = document.getElementById("popular-topics");
var ws = null;
var ended = false;
// Current streaming message element
var streamBubble = null;
var statusTimer = null;
function contactHref(value, kind) {
// brand 存裸值(电话/邮箱),data-* 历史上传完整 URL——两者都接受
if (!value) return "";
if (/^(https?:|mailto:)/i.test(value)) return value;
return kind === "wa" ? "https://wa.me/" + value : "mailto:" + value;
}
function applyBrand(b) {
b = (b && typeof b === "object") ? b : {};
if (b.title) {
document.title = b.title;
var h1 = document.querySelector(".chat-header .info h1");
if (h1) h1.textContent = b.title;
}
var wa = contactHref(whatsappAttr || b.whatsapp || "", "wa");
if (waBtn) { if (wa) waBtn.href = wa; else waBtn.style.display = "none"; }
var em = contactHref(emailAttr || b.email || "", "mail");
if (emBtn) { if (em) emBtn.href = em; else emBtn.style.display = "none"; }
// 占位文案优先级(同步自上游 ai-kefu,叠加 Task 10 管道):
// data-placeholder > brand.placeholder > 不触碰(chat.html 静态兜底)
var placeholderText = placeholderAttr || b.placeholder || "";
if (placeholderText) inputEl.placeholder = placeholderText;
var firstMsg = document.querySelector("#messages .message");
var bubble = firstMsg && firstMsg.querySelector(".bubble");
if (b.welcome) {
if (bubble) bubble.textContent = b.welcome;
} else if (firstMsg) {
firstMsg.style.display = "none"; // 空 welcome 不渲染空气泡(与挂件行为对齐)
}
if (Array.isArray(b.topics) && b.topics.length) {
var grid = document.querySelector(".popular-topics-grid");
if (grid) {
grid.innerHTML = "";
b.topics.forEach(function (t) {
if (typeof t !== "string" || !t) return;
var btn = document.createElement("button");
btn.className = "popular-topic-btn";
btn.setAttribute("data-topic", t);
btn.textContent = t;
btn.addEventListener("click", function () { sendTopicMessage(t); });
grid.appendChild(btn);
});
}
} else if (popularTopicsEl) {
popularTopicsEl.style.display = "none"; // 无话题配置:整区隐藏
var sep = document.querySelector(".chat-separator-topics");
if (sep) sep.style.display = "none"; // 分隔线随区隐藏,不留孤线
}
var footer = document.querySelector(".chat-footer");
if (footer) {
var link = footer.querySelector("a");
if (b.privacy_url && link) link.href = b.privacy_url;
else footer.style.display = "none"; // GDPR 行:为空即隐藏
}
}
function getWsUrl() {
var loc = window.location;
var proto = loc.protocol === "https:" ? "wss:" : "ws:";
return proto + "//" + loc.host + "/ws/chat";
}
function connect(sessionKey) {
ws = new WebSocket(getWsUrl());
ws.onopen = function () {
if (sessionId) {
ws.send(JSON.stringify({ session_id: sessionId }));
} else {
ws.send(JSON.stringify({ source: "standalone" }));
}
};
ws.onmessage = function (e) {
var data = JSON.parse(e.data);
if (data.type === "init" || data.type === "resumed") {
sessionId = data.session_id;
localStorage.setItem(sessionKey, sessionId);
if (data.type === "resumed") {
loadHistory();
}
}
if (data.type === "stream_start") {
clearStatusTimer();
hideTyping();
streamBubble = addStreamBubble();
}
if (data.type === "stream_chunk") {
if (streamBubble) {
streamBubble.textContent += data.content;
messagesEl.scrollTop = messagesEl.scrollHeight;
}
}
if (data.type === "stream_end") {
streamBubble = null;
}
if (data.type === "evaluated") {
if (data.ended) {
ended = true;
endedEl.classList.add("active");
inputEl.disabled = true;
sendBtn.disabled = true;
}
}
if (data.type === "error") {
clearStatusTimer();
hideTyping();
streamBubble = null;
addMessage("assistant", "抱歉,出了点问题,请稍后再试。");
}
};
ws.onclose = function () {
if (!ended) {
setTimeout(function () { connect(sessionKey); }, 3000);
}
};
ws.onerror = function () {
ws.close();
};
}
function send() {
var text = inputEl.value.trim();
if (!text || ended || !ws || ws.readyState !== WebSocket.OPEN) return;
addMessage("user", text);
inputEl.value = "";
hidePopularTopics();
// Immediate feedback: show status then typing
showStatusThenTyping();
ws.send(JSON.stringify({ content: text }));
}
function sendTopicMessage(topic) {
if (ended || !ws || ws.readyState !== WebSocket.OPEN) return;
addMessage("user", topic);
hidePopularTopics();
// Immediate feedback: show status then typing
showStatusThenTyping();
ws.send(JSON.stringify({ content: topic }));
}
function hidePopularTopics() {
if (popularTopicsEl) {
popularTopicsEl.style.display = "none";
}
}
function showStatusThenTyping() {
clearStatusTimer();
showTyping();
// Auto-hide typing is handled by stream_start replacing it
}
function clearStatusTimer() {
if (statusTimer) {
clearTimeout(statusTimer);
statusTimer = null;
}
}
function addMessage(role, content) {
var div = document.createElement("div");
div.className = "message " + role;
var wrapper = document.createElement("div");
var bubble = document.createElement("div");
bubble.className = "bubble";
bubble.textContent = content;
wrapper.appendChild(bubble);
div.appendChild(wrapper);
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function addStreamBubble() {
var div = document.createElement("div");
div.className = "message assistant";
var wrapper = document.createElement("div");
var bubble = document.createElement("div");
bubble.className = "bubble";
bubble.textContent = "";
wrapper.appendChild(bubble);
div.appendChild(wrapper);
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
return bubble;
}
function showTyping() {
typingEl.classList.add("active");
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function hideTyping() {
typingEl.classList.remove("active");
}
function loadHistory() {
}
sendBtn.addEventListener("click", send);
inputEl.addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
});
// (出厂模板话题区为空——话题按钮只由 applyBrand 重建并自带绑定,
// 无静态兜底绑定段)
// Task 10: 会话 key 租户化(cc_session_id_<slug>)——brand 落定(成功或
// 失败,slug 回退 "default")后才读 key/连 WS,保证同浏览器多租户互不串
chatBrandReady.then(function (b) {
applyBrand(b);
var slug = String((b && b.slug) || "default").replace(/[^a-zA-Z0-9_-]/g, "") || "default";
var sessionKey = "cc_session_id_" + slug;
sessionId = null;
try { sessionId = localStorage.getItem(sessionKey); } catch (e) { /* storage 不可用 */ }
connect(sessionKey);
});
})();