// ---- basePath(spec 2026-10-19 §4):chat.html 引导脚本按 location.pathname ---- // ---- 推导并挂载 window.__BASE_PATH__(路径模式 /t/,Host 模式空串)。 ---- var B = window.__BASE_PATH__ || ""; // ---- Task 10: brand 拉取(同源 /api/public/brand,下方两个 IIFE 共用) ---- // 失败/超时(3s) → resolve(null) → 中性兜底,页面照常可用不白屏。 var chatBrandReady = (function () { try { var p = fetch(B + "/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 + B + "/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_)——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); }); })();