d1aed8e722
上游 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>
312 lines
12 KiB
JavaScript
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);
|
|
});
|
|
})();
|