Files
ai-kefu-admin/static/widget.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

800 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
var script = document.currentScript || (function () {
var scripts = document.getElementsByTagName("script");
return scripts[scripts.length - 1];
})();
// Save config immediately (before DOM ready)
// Task 10 值管道: data-* 属性(嵌入方显式覆盖,最高优先) > brand 端点
// (租户/站点门面) > 中性兜底。此处只同步读原始属性;有效值在 brand
// 落定后由 boot() 推导(见文件尾),品牌文案不再硬编码于本文件。
var position = script.getAttribute("data-position") || "bottom-right";
var theme = script.getAttribute("data-theme") || "light";
var titleAttr = script.getAttribute("data-title");
// 同步自上游 ai-kefu: data-placeholder 显式覆盖输入框占位文案。裸读不带
// 默认值——Task 10 管道里默认值在 boot() 推导链尾(读取行给默认会让
// brand.placeholder 永远无法生效),tests/test_placeholder_static.py 守卫。
var placeholderAttr = script.getAttribute("data-placeholder");
var autoOpen = script.getAttribute("data-auto-open") === "true";
var whatsappAttr = script.getAttribute("data-whatsapp");
var emailAttr = script.getAttribute("data-email");
var colorAttr = (script.getAttribute("data-color") || "").trim();
var avatarAttr = (script.getAttribute("data-avatar") || "").trim();
var siteAttr = (script.getAttribute("data-site") || "").trim();
var autoInvite = script.getAttribute("data-auto-invite") !== "false";
var isMobile = script.getAttribute("data-mobile") === "true";
var wsBase = new URL(script.src).origin;
/* ===== 主题派生单元:与 static/js/theme.js 逐字同步,tests/test_theme_static.py 强制;只可整块替换,不可单边修改 ===== */
var DEFAULT_COLOR = "#066fc0";
var DEFAULT_PALETTE = {
primary: "#066fc0",
primaryRgb: "6,111,192",
gradientEnd: "#0666bb",
deepText: "#14517a",
captionText: "#2174ab",
softBg: "#f3f9fe",
borderSoft: "#bbdcf2"
};
function clampNum(v, lo, hi) {
return Math.min(hi, Math.max(lo, v));
}
function hexToRgb(hex) {
hex = String(hex || "").replace(/^#/, "");
if (hex.length === 3) {
hex = hex.split("").map(function (ch) { return ch + ch; }).join("");
}
if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null;
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16)
};
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
var max = Math.max(r, g, b), min = Math.min(r, g, b);
var l = (max + min) / 2;
var d = max - min;
var h = 0, s = 0;
if (d !== 0) {
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
return { h: h, s: s * 100, l: l * 100 };
}
function toHex2(v) {
var s = Math.round(clampNum(v, 0, 255)).toString(16);
return s.length < 2 ? "0" + s : s;
}
function hslToHex(h, s, l) {
h = ((h % 360) + 360) % 360;
s = clampNum(s, 0, 100) / 100;
l = clampNum(l, 0, 100) / 100;
var c = (1 - Math.abs(2 * l - 1)) * s;
var x = c * (1 - Math.abs(((h / 60) % 2) - 1));
var m = l - c / 2;
var rp = 0, gp = 0, bp = 0;
if (h < 60) { rp = c; gp = x; }
else if (h < 120) { rp = x; gp = c; }
else if (h < 180) { gp = c; bp = x; }
else if (h < 240) { gp = x; bp = c; }
else if (h < 300) { rp = x; bp = c; }
else { rp = c; bp = x; }
return "#" + toHex2((rp + m) * 255) + toHex2((gp + m) * 255) + toHex2((bp + m) * 255);
}
function derivePalette(color) {
try {
var rgb = hexToRgb(color);
if (!rgb) {
if (typeof console !== "undefined" && console.warn) console.warn("[chat-theme] invalid color, fallback:", color);
return DEFAULT_PALETTE;
}
var hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
var h = hsl.h, s = hsl.s, l = hsl.l;
return {
primary: "#" + toHex2(rgb.r) + toHex2(rgb.g) + toHex2(rgb.b),
primaryRgb: rgb.r + "," + rgb.g + "," + rgb.b,
gradientEnd: hslToHex(h + 2, s, l - 1),
deepText: hslToHex(h - 2, s * 0.76, clampNum(l - 24, 28, 40)),
captionText: hslToHex(h - 2, s * 0.72, clampNum(l - 10, 40, 52)),
softBg: hslToHex(h, s * 0.9, 97.5),
borderSoft: hslToHex(h - 2, s * 0.72, 84)
};
} catch (e) {
return DEFAULT_PALETTE;
}
}
/* ===== 主题派生单元结束 ===== */
var DEFAULT_AVATAR_PATH = "/static/kefu-small.png";
// ---- Task 10: brand 端点取门面 + 有效值推导 ------------------------------
// __brand = /api/public/brand 响应(null = 拉取失败/超时 → 中性兜底,不白屏)
var __brand = null;
var title = "", whatsapp = "", email = "", avatarUrl = "", privacyUrl = "";
var welcome = "", placeholder = "Type a message...", topics = [];
var palette = null;
var brandSlug = "default";
function fetchBrand() {
var url = wsBase + "/api/public/brand" + (siteAttr ? "?site=" + encodeURIComponent(siteAttr) : "");
var p = fetch(url, { cache: "no-store" }).then(function (r) {
return r.ok ? r.json() : null;
}).catch(function () {
return null; // fetch 失败 → data-*/中性兜底,挂件照常渲染
});
// 挂起保护: brand 端点 3s 无响应按失败处理,网络问题不挡挂件挂载
return Promise.race([p, new Promise(function (res) { setTimeout(function () { res(null); }, 3000); })]);
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (ch) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch];
});
}
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;
}
// boot: brand 落定(成功或失败)后推导有效值再挂载。只改"默认值从哪来",
// 不触碰上方主题派生单元(调色板算法零改动,只换输入)。
function boot() {
var b = (__brand && typeof __brand === "object") ? __brand : {};
title = titleAttr || b.title || "Chat with us";
whatsapp = contactHref(whatsappAttr || b.whatsapp || "", "wa");
email = contactHref(emailAttr || b.email || "", "mail");
palette = derivePalette(colorAttr || (b.color || "").trim() || DEFAULT_COLOR);
avatarUrl = avatarAttr
? (/^https?:\/\//i.test(avatarAttr) ? avatarAttr : wsBase + "/" + avatarAttr.replace(/^\//, ""))
: (b.avatar ? wsBase + (b.avatar.charAt(0) === "/" ? b.avatar : "/" + b.avatar)
: wsBase + DEFAULT_AVATAR_PATH);
welcome = b.welcome || "";
topics = Array.isArray(b.topics)
? b.topics.filter(function (t) { return typeof t === "string" && t; })
: [];
placeholder = placeholderAttr || b.placeholder || "Type a message...";
privacyUrl = b.privacy_url || "";
brandSlug = String(b.slug || "default").replace(/[^a-zA-Z0-9_-]/g, "") || "default";
if (script.getAttribute("data-debug") === "true") {
window.__chatWidgetDebug = { derivePalette: derivePalette, palette: palette };
}
init();
}
var positionStyles = {
"bottom-right": { bottom: "80px", right: "10px" },
"bottom-left": { bottom: "20px", left: "20px" },
"top-right": { top: "20px", right: "20px" },
"top-left": { top: "20px", left: "20px" },
};
var positionStylesMobile = {
"bottom-right": { bottom: "20px", right: "16px" },
"bottom-left": { bottom: "20px", left: "16px" },
"top-right": { top: "20px", right: "16px" },
"top-left": { top: "20px", left: "16px" },
};
var colors = {
light: { bg: "#ffffff", text: "#333", bubble: "#f0f0f0" },
dark: { bg: "#1e1e1e", text: "#e0e0e0", bubble: "#2d2d2d" },
};
var c = colors[theme] || colors.light;
// 无效 position 归一化(防 "pos-垃圾" class 与查表错位)
if (!positionStyles[position]) position = "bottom-right";
var pos = (isMobile ? positionStylesMobile : positionStyles)[position]
|| (isMobile ? positionStylesMobile : positionStyles)["bottom-right"];
function init() {
var host = document.createElement("div");
host.id = "cc-widget-host-" + brandSlug;
document.body.appendChild(host);
var shadow = host.attachShadow({ mode: "open" });
// Inject styles
var style = document.createElement("style");
style.textContent = [
":host{--w-primary:" + palette.primary + ";--w-primary-rgb:" + palette.primaryRgb + ";--w-gradient-end:" + palette.gradientEnd + ";--w-deep-text:" + palette.deepText + ";--w-caption-text:" + palette.captionText + ";--w-soft-bg:" + palette.softBg + ";--w-border-soft:" + palette.borderSoft + "}",
"* { margin:0; padding:0; box-sizing:border-box; }",
".widget-container { position:fixed; z-index:99999; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }",
".widget-container { " + Object.keys(pos).map(function (k) { return k + ":" + pos[k]; }).join(";") + "; }",
// Toggle button
".toggle-btn { width:auto; height:auto; background:var(--w-primary); border-radius:30px; padding:8px 20px; display:flex; align-items:center; gap:12px; box-shadow:0 4px 16px rgba(var(--w-primary-rgb),0.4); transition:transform 0.2s; cursor:pointer; border:none; }",
".toggle-btn:hover { transform:scale(1.1); }",
".toggle-btn.mobile { flex-direction:column; gap:5px; padding:0; background:transparent; box-shadow:none; border-radius:0; }",
".toggle-btn.mobile:hover { transform:scale(1.05); }",
".toggle-btn.mobile .mobile-circle { position:relative; width:56px; height:56px; border-radius:50%; background:var(--w-primary); display:flex; align-items:center; justify-content:center; font-size:28px; box-shadow:0 4px 16px rgba(var(--w-primary-rgb),0.45); }",
".toggle-btn.mobile .mobile-dot { position:absolute; bottom:2px; right:2px; width:13px; height:13px; background:#22c55e; border-radius:50%; border:2px solid var(--w-primary); }",
".toggle-btn.mobile .mobile-label { color:var(--w-deep-text); font-size:10px; font-weight:600; line-height:1.1; }",
".toggle-avatar-wrap { position:relative; }",
".toggle-avatar-circle { position:relative; width:36px; height:36px; background:rgba(255,255,255,0.2); border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:18px; }",
".toggle-avatar-dot { position:absolute; top:0; right:0; width:10px; height:10px; background:#22c55e; border-radius:50%; border:2px solid var(--w-primary); }",
".avatar-emoji { line-height:1; }",
".avatar-img { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; border-radius:50%; }",
// 移动端 D2 按钮比桌面药丸高(72px vs 52px),抬高面板底边避免压住按钮(覆盖面板默认 80px 与 @media 60px)
".widget-container.mobile-host .chat-panel { bottom:84px; }",
".widget-container.mobile-host { bottom:70px; }",
// Chat panel
".chat-panel { position:absolute; bottom:80px; right:0; width:400px; height:540px; background:" + c.bg + "; border-radius:16px; box-shadow:0 10px 40px rgba(0,0,0,0.15); display:none; flex-direction:column; overflow:hidden; }",
".chat-panel.open { display:flex; }",
// 位置联动(2026-09-30 修复): 面板/邀请卡对齐随 data-position 翻转,
// 默认 right:0 只适配 bottom-right——左侧模式溢出屏幕、顶部模式向上溢出
// (邀请卡 invite-container 与 chat-panel 同款定位,必须一起联动)
".widget-container.pos-bottom-left .chat-panel,"
+ " .widget-container.pos-bottom-left .invite-container { left:0; right:auto; }",
".widget-container.pos-top-right .chat-panel,"
+ " .widget-container.pos-top-right .invite-container { top:0; bottom:auto; }",
".widget-container.pos-top-left .chat-panel,"
+ " .widget-container.pos-top-left .invite-container { left:0; right:auto; top:0; bottom:auto; }",
// Panel header
".panel-header { display:flex; align-items:center; gap:12px; padding:14px 18px; background:var(--w-primary); color:white; }",
".panel-header .avatar { width:48px; height:48px; background:rgba(255,255,255,0.2); border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:22px; position:relative; }",
".panel-header .avatar::after { content:''; position:absolute; bottom:1px; right:1px; width:14px; height:14px; background:#22c55e; border-radius:50%; border:3px solid var(--w-primary); }",
".panel-header .info { flex:1; }",
".panel-header .info h3 { font-size:17px; font-weight:600; margin:0; }",
".panel-header .info .status { font-size:13px; opacity:0.95; margin:0; }",
".panel-header .actions { display:flex; gap:8px; }",
".panel-header .action-btn { width:32px; height:32px; border-radius:8px; background:rgba(255,255,255,0.15); cursor:pointer; display:flex; align-items:center; justify-content:center; transition:background 0.2s; border:none; color:white; }",
".panel-header .action-btn:hover { background:rgba(255,255,255,0.25); }",
// Messages
".panel-top { flex-shrink:0; padding:10px 16px; background:var(--w-soft-bg); }",
".panel-messages { flex:1; overflow-y:auto; padding:16px; background:#ffffff; display:flex; flex-direction:column; gap:8px; padding-bottom:8px; }",
".popular-topics-fixed { flex-shrink:0; padding:12px 16px 8px; background:#ffffff; }",
".chat-separator-topics { height:1px; background:var(--w-primary); opacity:0.15; flex-shrink:0; }",
".help-block { background:var(--w-soft-bg); border-radius:12px; padding:10px 14px; border:1px solid var(--w-border-soft); }",
".help-block .help-content { flex:1; min-width:0; }",
".help-block .help-title { font-size:16px; font-weight:700; color:#111827; margin-bottom:3px; }",
".help-block .help-description { font-size:12px; color:#6b7280; line-height:1.4; }",
".help-block .help-actions { display:grid; gap:8px; flex-shrink:0; }",
".help-block .call-btn { justify-content: center;background: #fff; color: var(--w-deep-text);border: 1px solid var(--w-border-soft);padding: 6px 10px; border-radius:20px; font-size:13px; font-weight:600; cursor:pointer; display:flex; align-items:center; gap:5px; text-decoration:none; flex-shrink:0; transition:all 0.2s; }",
// ".help-block .whatsapp-btn:hover { box-shadow:0 4px 12px rgba(37,211,102,0.4); transform:translateY(-1px); }",
// ".help-block .email-btn { background:linear-gradient(135deg, #EA4335 0%, #C5221F 100%); }",
// ".help-block .email-btn:hover { box-shadow:0 4px 12px rgba(234,67,53,0.4); transform:translateY(-1px); }",
".popular-topics-label { font-size:11px; font-weight:600; color:var(--w-caption-text); letter-spacing:0.14em; margin-bottom:8px; text-transform:uppercase; }",
".popular-topics-grid { display:flex; gap:8px; flex-wrap:wrap; }",
".popular-topic-btn { border-radius:20px; border:1px solid var(--w-border-soft); background:var(--w-soft-bg); padding:8px 16px; font-size:12px; font-weight:500; color:var(--w-deep-text); cursor:pointer; transition:background 0.2s; white-space:nowrap; }",
".popular-topic-btn:hover { background:white; }",
".msg-wrapper { display:flex; gap:10px; }",
".msg-wrapper .avatar { width:32px; height:32px; background:var(--w-primary); border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:14px; color:white; flex-shrink:0; box-shadow:0 2px 8px rgba(var(--w-primary-rgb),0.3); }",
".msg { max-width:280px; padding:12px 16px; border-radius:16px; font-size:14px; line-height:1.5; word-break:break-word; }",
".msg.user { align-self:flex-end; background:var(--w-primary); color:#fff; border-bottom-right-radius:4px; }",
".msg.ai { background:#f3f4f6; color:#1f2937; border-bottom-left-radius:4px; }",
// Separator
".chat-separator { height:1px; background:var(--w-primary); opacity:0.3; flex-shrink:0; }",
// Footer
".chat-footer { padding:10px 10px; background:white; display:flex; align-items:center; justify-content:space-between; font-size:11px; color:var(--w-caption-text); gap:20px; }",
".chat-footer .footer-left { flex:1; }",
".chat-footer .footer-right { flex-shrink:0; }",
".chat-footer a { color:var(--w-caption-text); text-decoration:underline; }",
// Typing
".typing { display:none; align-self:flex-start; padding:8px 12px; background:" + c.bubble + "; border-radius:12px; }",
".typing.active { display:flex; gap:4px; align-items:center; }",
".typing span { width:6px; height:6px; background:#999; border-radius:50%; animation:blink 1.4s infinite; }",
".typing span:nth-child(2){animation-delay:0.2s} .typing span:nth-child(3){animation-delay:0.4s}",
"@keyframes blink { 0%,60%,100%{transform:translateY(0);opacity:0.4} 30%{transform:translateY(-4px);opacity:1} }",
// Input
".input-container { padding:10px 10px; background:white; }",
".input-wrapper { display:flex; align-items:center; gap:0; padding:8px 16px; background:var(--w-soft-bg); border-radius:28px; box-shadow:0 1px 3px rgba(0,0,0,0.05); }",
".input-wrapper input { flex:1; padding:0; border:none; font-size:14px; background:transparent; outline:none; min-height:20px; color:#1f2937; }",
".input-wrapper input::placeholder { color:#9ca3af; }",
".input-separator { width:1px; height:24px; background:#e5e7eb; margin:0 8px; flex-shrink:0; }",
".input-wrapper button { padding:8px 16px; border:none; border-radius:12px; background:linear-gradient(135deg, var(--w-primary), var(--w-gradient-end)); color:#fff; font-size:14px; font-weight:500; cursor:pointer; display:flex; align-items:center; gap:6px; white-space:nowrap; box-shadow:0 2px 8px rgba(var(--w-primary-rgb),0.25); flex-shrink:0; }",
".input-wrapper button:disabled { background:#ccc; box-shadow:none; }",
// Ended
".ended { display:none; text-align:center; padding:8px; background:#e8f5e9; color:#34a853; font-size:12px; }",
".ended.active { display:block; }",
// Invite card container
// pointer-events:none:容器永不拦截点击,展示态由 .invite-panel.show 的 auto 恢复交互
".invite-container { z-index:99998;position: absolute;bottom: 80px;right: 0;pointer-events:none; }",
".invite-panel { width:360px; max-width:calc(100vw - 24px); overflow:hidden; border-radius:26px; border:1px solid var(--w-border-soft); background:white; box-shadow:0 28px 80px rgba(var(--w-primary-rgb),0.22); opacity:0; transform:translateY(20px); transition:opacity 0.3s, transform 0.3s; pointer-events:none; }",
".invite-panel.show { opacity:1; transform:translateY(0); pointer-events:auto; }",
".invite-panel.hide { display:none; }",
// Invite header
".invite-header { background:linear-gradient(135deg, var(--w-primary), var(--w-gradient-end)); padding:16px 20px; color:white; display:flex; gap:12px; }",
".invite-icon-wrapper { flex-shrink:0; }",
".invite-icon { position:relative;width:40px; height:40px; background:rgba(255,255,255,0.18); border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:18px; backdrop-filter:blur(10px); }",
".invite-status-dot { position:absolute; bottom:-2px; right:-2px; width:12px; height:12px; background:#22c55e; border-radius:50%; border:3px solid var(--w-primary); }",
".invite-header-content { flex:1; min-width:0; }",
".invite-title { font-size:15px; font-weight:600; line-height:1.3; }",
".invite-subtitle { font-size:12px; margin-top:2px; opacity:0.9; line-height:1.4; }",
".invite-header-actions { display:flex; flex-direction:column; gap:6px; flex-shrink:0; margin-left:auto; }",
".invite-close-btn { align-self: flex-end; width:28px; height:28px; border-radius:6px; background:rgba(255,255,255,0.1); border:none; color:white; cursor:pointer; display:flex; align-items:center; justify-content:center; transition:background 0.2s; flex-shrink:0; }",
".invite-close-btn:hover { background:rgba(255,255,255,0.2); }",
".invite-action-btn { justify-content: center; background:#fff; color:var(--w-deep-text); border:1px solid var(--w-border-soft); padding:5px 10px; border-radius:20px; font-size:12px; font-weight:600; cursor:pointer; display:flex; align-items:center; gap:5px; text-decoration:none; flex-shrink:0; transition:all 0.2s; white-space:nowrap; }",
".invite-action-btn:hover { background:var(--w-soft-bg); }",
// Invite body
".invite-body { padding:16px 8px; background:var(--w-soft-bg); }",
// Topic buttons
".invite-topics { display:grid; grid-template-columns:1fr 1fr; gap:5px; margin-bottom:16px; }",
".invite-topic-btn { border-radius:22px; border:1px solid var(--w-border-soft); background:white; padding:14px 10px; text-align:center; font-size:13px; font-weight:500; color:var(--w-deep-text); cursor:pointer; transition:background 0.2s; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }",
".invite-topic-btn:hover { background:var(--w-soft-bg); }",
// Action buttons
".invite-actions { display:flex; gap:10px; }",
".invite-start-btn { flex:1; border-radius:20px; border:none; background:linear-gradient(135deg, var(--w-primary), var(--w-gradient-end)); padding:14px 20px; color:white; font-size:14px; font-weight:600; cursor:pointer; display:flex; align-items:center; justify-content:center; gap:8px; box-shadow:0 2px 8px rgba(var(--w-primary-rgb),0.25); transition:opacity 0.2s; }",
".invite-start-btn:hover { opacity:0.95; }",
".invite-dismiss-btn { border-radius:20px; border:1px solid var(--w-border-soft); background:white; padding:14px 20px; color:var(--w-deep-text); font-size:14px; font-weight:500; cursor:pointer; transition:background 0.2s; }",
".invite-dismiss-btn:hover { background:#FEFEFE; }",
".invite-privacy { padding:2px 16px 12px; font-size:11px; color:var(--w-caption-text); text-align:center; }",
".invite-privacy a { color:var(--w-caption-text); text-decoration:underline; }",
// Responsive
"@media(max-width:450px){ .chat-panel{width:calc(100vw - 20px);height:calc(100vh - 200px);bottom:60px;right:0px;} .invite-panel{width:calc(100vw - 24px);} .invite-topics{grid-template-columns:1fr;} }",
].join("\n");
shadow.appendChild(style);
// Build DOM
var container = document.createElement("div");
container.className = "widget-container pos-" + position + (isMobile ? " mobile-host" : "");
// Task 10: 品牌/联系/隐私/话题分块——值缺位(data-* 与 brand 都没给)则整块
// 不渲染(隐私行隐藏、联系按钮隐藏、话题区隐藏),不再硬编码任何租户文案
var waBtnHtml = whatsapp
? '<a href="' + esc(whatsapp) + '" target="_blank" rel="noopener" class="call-btn whatsapp-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg><span>WhatsApp</span></a>'
: "";
var emBtnHtml = email
? '<a href="' + esc(email) + '" class="call-btn email-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg><span>Email</span></a>'
: "";
var privacyLineHtml = privacyUrl
? 'By chatting, you agree to our <a href="' + esc(privacyUrl) + '" target="_blank" rel="noopener">privacy policy</a>.'
: "";
var chatFooterHtml = privacyLineHtml
? '<div class="chat-footer"><span class="footer-left">' + privacyLineHtml + '</span></div>'
: "";
var invitePrivacyHtml = privacyLineHtml
? '<div class="invite-privacy">' + privacyLineHtml + '</div>'
: "";
var topicsBtnsHtml = topics.map(function (t) {
return '<button class="popular-topic-btn" data-topic="' + esc(t) + '">' + esc(t) + '</button>';
}).join("");
var inviteTopicsHtml = topics.map(function (t) {
return '<button class="invite-topic-btn" data-topic="' + esc(t) + '">' + esc(t) + '</button>';
}).join("");
var popularTopicsSectionHtml = topics.length
? '<div class="popular-topics-fixed" id="w-popular-topics"><div class="popular-topics-label">POPULAR TOPICS</div><div class="popular-topics-grid">' + topicsBtnsHtml + '</div></div><div class="chat-separator-topics"></div>'
: "";
var welcomeMsgHtml = welcome
? '<div class="msg-wrapper"><div class="msg ai">' + esc(welcome) + '</div></div>'
: "";
var panel = document.createElement("div");
panel.className = "chat-panel";
panel.innerHTML = [
'<div class="panel-header">',
'<div class="avatar"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="'+esc(avatarUrl)+'"></div>',
'<div class="info"><h3>' + esc(title) + '</h3><div class="status">Online now · Usually replies in minutes</div></div>',
'<div class="actions">',
'<button class="action-btn" aria-label="Minimize">−</button>',
// '<button class="close-btn action-btn" aria-label="Close">×</button>',
'</div></div>',
'<div class="ended" id="w-ended">Chat ended. We\'ll be in touch!</div>',
'<div class="panel-top">',
'<div class="help-block"><div style="display:flex;align-items:center;gap:14px;"><div class="help-content"><div class="help-title">How can we help?</div><div class="help-description">Ask about products, quotations, MOQ, shipping and delivery.</div></div><div class="help-actions">' + waBtnHtml + emBtnHtml + '</div></div></div>',
'</div>',
'<div class="chat-separator"></div>',
'<div class="panel-messages" id="w-msgs">',
welcomeMsgHtml,
'</div>',
'<div class="typing" id="w-typing"><span></span><span></span><span></span></div>',
popularTopicsSectionHtml,
'<div class="input-container"><div class="input-wrapper">',
'<input type="text" id="w-input" placeholder="' + esc(placeholder) + '">',
'<button id="w-send"><span style="font-size:16px;">➤</span><span>Send</span></button>',
'</div></div>',
chatFooterHtml,
].join("");
// Invite card HTML template
var inviteTemplate = [
'<div class="invite-panel hide">',
'<div class="invite-header">',
'<div class="invite-icon-wrapper">',
'<div class="invite-icon"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="'+esc(avatarUrl)+'"><span class="invite-status-dot"></span></div>',
'</div>',
'<div class="invite-header-content">',
'<div class="invite-title">Chat with us for products, quotations and delivery.</div>',
'</div>',
'<div class="invite-header-actions">',
'<button class="invite-close-btn" aria-label="Close">',
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>',
'</button>',
(whatsapp
? '<a href="' + esc(whatsapp) + '" target="_blank" rel="noopener" class="invite-action-btn whatsapp-btn">'
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>'
+ '<span>WhatsApp</span></a>'
: ""),
(email
? '<a href="' + esc(email) + '" class="invite-action-btn email-btn">'
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>'
+ '<span>Email</span></a>'
: ""),
'</div>',
'</div>',
(topics.length
? '<div class="invite-body">'
+ '<div class="invite-topics">' + inviteTopicsHtml + '</div>'
+ '</div>'
: ""),
'<div class="chat-separator"></div>',
'<div class="input-container">',
'<div class="invite-actions">',
'<button class="invite-start-btn">Start Chat',
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"></polyline></svg>',
'</button>',
'<button class="invite-dismiss-btn">No Thanks</button>',
'</div>',
invitePrivacyHtml,
'</div>',
'</div>'
].join("");
var inviteEl = document.createElement("div");
inviteEl.className = "invite-container";
inviteEl.innerHTML = inviteTemplate;
// 不变量:面板出厂即 hide(display:none)—— 任何不经过 showInvite 的路径都零占位。
// 2026-09-01 修复:此前 permanent 关闭后刷新时 showInvite 不运行、invitePanel 恒为
// null、hideInvite 提前返回,隐形 invite-panel(display:block + opacity:0)在
// z-index:99998 容器里拦截右下角点击。出厂 hide 取代原 !autoInvite 特判。
var toggleBtn = document.createElement("button");
toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : "");
if (isMobile) {
toggleBtn.innerHTML = `
<div class="mobile-circle"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="${esc(avatarUrl)}"><span class="mobile-dot"></span></div>
<span class="mobile-label">Chat with us</span>
`;
} else {
toggleBtn.innerHTML = `
<div class="toggle-avatar-wrap">
<div class="toggle-avatar-circle"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="${esc(avatarUrl)}"></div>
<div class="toggle-avatar-dot"></div>
</div>
<div style="color:white;">
<div style="font-size:14px;font-weight:600;">Chat with us</div>
<div style="font-size:11px;opacity:0.9;">Usually replies quickly</div>
</div>
`;
}
container.appendChild(panel);
container.appendChild(inviteEl);
container.appendChild(toggleBtn);
shadow.appendChild(container);
// 头像兜底:load 成功隐藏底层 emoji;error 移除 img 露出 emoji(spec §12.4)
Array.prototype.forEach.call(shadow.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(); });
});
// Widget state
var isOpen = autoOpen;
var wSessionId = null;
var wWs = null;
var wEnded = false;
var streamBubble = null;
var pageToken = crypto.randomUUID();
var pendingMessage = null; // Queue message to send after WebSocket opens
// Invite card state management
var inviteTimer = null;
var invitePanel = null;
var inviteShown = false;
// Task 10: key 租户化——同浏览器多租户挂件互不踩(不同租户 slug 隔离)
var inviteStorageKey = "widget_invite_dismissed_" + brandSlug + "_" + window.location.pathname;
var isPermanentlyClosed = false;
function checkInviteStorage() {
try {
var dismissed = sessionStorage.getItem(inviteStorageKey);
if (dismissed === "permanent") {
isPermanentlyClosed = true;
return true;
}
return false;
} catch (e) {
return false; // Assume not dismissed on error
}
}
function setInviteDismissed(type) {
try {
if (type !== "permanent" && type !== "temporary") {
type = "temporary"; // Default fallback
}
if (type === "permanent") {
sessionStorage.setItem(inviteStorageKey, "permanent");
isPermanentlyClosed = true;
} else {
sessionStorage.setItem(inviteStorageKey, "temporary");
}
} catch (e) {
// Silently fail - storage unavailable
}
}
function clearInviteDismissed() {
try {
sessionStorage.removeItem(inviteStorageKey);
isPermanentlyClosed = false;
inviteShown = false; // Reset shown flag
} catch (e) {
// Silently fail - storage unavailable
}
}
function startInviteTimer() {
if (!autoInvite) return; // 配置关闭则不启动计时器,邀请卡永不自动弹
if (checkInviteStorage()) {
return; // Already dismissed, don't show
}
inviteTimer = window.setTimeout(function() {
if (!isOpen) {
showInvite();
}
}, 5000); // 5 seconds
}
function showInvite() {
if (inviteShown || isPermanentlyClosed) return;
inviteShown = true;
invitePanel = shadow.querySelector(".invite-panel");
if (invitePanel) {
invitePanel.classList.remove("hide");
void invitePanel.offsetWidth; // 强制回流:remove 与 add 同帧会跳过 0.3s 淡入过渡
invitePanel.classList.add("show");
}
}
function hideInvite(type) {
if (!invitePanel) return;
invitePanel.classList.remove("show");
invitePanel.classList.add("hide");
setInviteDismissed(type || "temporary");
}
var msgsEl = shadow.getElementById("w-msgs");
var inputEl = shadow.getElementById("w-input");
var sendEl = shadow.getElementById("w-send");
var typingEl = shadow.getElementById("w-typing");
var endedEl = shadow.getElementById("w-ended");
var inviteCloseBtn = shadow.querySelector(".invite-close-btn");
var inviteStartBtn = shadow.querySelector(".invite-start-btn");
var inviteDismissBtn = shadow.querySelector(".invite-dismiss-btn");
var inviteTopicBtns = shadow.querySelectorAll(".invite-topic-btn");
if (isOpen) panel.classList.add("open");
function toggle() {
isOpen = !isOpen;
panel.classList.toggle("open", isOpen);
if (isOpen) {
if (inviteTimer) {
window.clearTimeout(inviteTimer);
inviteTimer = null;
}
hideInvite("temporary");
if (!wWs) wConnect();
} else {
// Only restart timer if not permanently closed
if (!isPermanentlyClosed && !checkInviteStorage()) {
inviteShown = false;
startInviteTimer();
}
}
}
toggleBtn.addEventListener("click", toggle);
// panel.querySelector(".close-btn").addEventListener("click", toggle);
panel.querySelector(".action-btn:not(.close-btn)")?.addEventListener("click", toggle);
// Invite close button
// 2026-08-20 用户决策: 点 X 与点 No Thanks 同效(permanent)——本标签页会话+本页面
// 路径内不再自动弹(含关面板后/刷新后);temporary 写了从未被 checkInviteStorage
// 消费,等于无抑制,原行为即"关面板5s后又弹"。
inviteCloseBtn.addEventListener("click", function() {
hideInvite("permanent");
});
// Invite Start Chat button
inviteStartBtn.addEventListener("click", function() {
hideInvite("temporary");
if (!isOpen) {
toggle();
}
});
// Invite No Thanks button
inviteDismissBtn.addEventListener("click", function() {
hideInvite("permanent");
});
// Invite topic buttons - auto send message
Array.prototype.forEach.call(inviteTopicBtns, function(btn) {
btn.addEventListener("click", function() {
var topic = this.getAttribute("data-topic");
hideInvite("temporary");
if (!isOpen) {
toggle();
}
// Queue message to send after WebSocket opens
pendingMessage = topic;
});
});
// Popular topics buttons
var popularTopicBtns = shadow.querySelectorAll(".popular-topic-btn");
Array.prototype.forEach.call(popularTopicBtns, function(btn) {
btn.addEventListener("click", function() {
var topic = this.getAttribute("data-topic");
// Hide popular topics after first interaction
var popularTopicsEl = shadow.getElementById("w-popular-topics");
if (popularTopicsEl) {
popularTopicsEl.style.display = "none";
}
// Send message
autoSendMessage(topic);
});
});
function wConnect() {
var proto = wsBase.indexOf("https") === 0 ? "wss" : "ws";
var wsHost = wsBase.replace(/^https?/, proto);
wWs = new WebSocket(wsHost + "/ws/chat");
wWs.onopen = function () {
// Task 10: init 带 site(空域名站点如 es 靠 data-site 接入,服务端
// 记入 client_info["site"] 作会话站点标;空值服务端忽略)
if (wSessionId) {
wWs.send(JSON.stringify({ session_id: wSessionId, page_token: pageToken, site: siteAttr }));
} else {
wWs.send(JSON.stringify({ source: "widget", page_token: pageToken, site: siteAttr }));
}
// Send any pending message after WebSocket opens
if (pendingMessage) {
wWs.send(JSON.stringify({ content: pendingMessage }));
addMsg("user", pendingMessage);
typingEl.classList.add("active");
pendingMessage = null;
}
};
wWs.onmessage = function (e) {
var data = JSON.parse(e.data);
if (data.type === "init") {
wSessionId = data.session_id;
}
if (data.type === "stream_start") {
typingEl.classList.remove("active");
streamBubble = addStreamBubble();
}
if (data.type === "stream_chunk") {
if (streamBubble) {
streamBubble.textContent += data.content;
msgsEl.scrollTop = msgsEl.scrollHeight;
}
}
if (data.type === "stream_end") {
streamBubble = null;
}
if (data.type === "evaluated") {
if (data.ended) {
wEnded = true;
endedEl.classList.add("active");
inputEl.disabled = true;
sendEl.disabled = true;
}
}
if (data.type === "error") {
typingEl.classList.remove("active");
streamBubble = null;
addMsg("ai", "Sorry, something went wrong.");
}
};
wWs.onclose = function () {
if (!wEnded && host.isConnected) setTimeout(wConnect, 3000);
};
}
function addMsg(role, text) {
var div = document.createElement("div");
div.className = "msg " + role;
div.textContent = text;
msgsEl.appendChild(div);
msgsEl.scrollTop = msgsEl.scrollHeight;
}
function addStreamBubble() {
var div = document.createElement("div");
div.className = "msg ai";
div.textContent = "";
msgsEl.appendChild(div);
msgsEl.scrollTop = msgsEl.scrollHeight;
return div;
}
function autoSendMessage(text) {
if (!text || !wWs || wWs.readyState !== WebSocket.OPEN) return;
addMsg("user", text);
typingEl.classList.add("active");
wWs.send(JSON.stringify({ content: text }));
}
function wSend() {
var text = inputEl.value.trim();
if (!text || wEnded || !wWs || wWs.readyState !== WebSocket.OPEN) return;
addMsg("user", text);
inputEl.value = "";
typingEl.classList.add("active");
wWs.send(JSON.stringify({ content: text }));
}
sendEl.addEventListener("click", wSend);
inputEl.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); wSend(); }
});
// Start invite timer
startInviteTimer();
}
// Wait for body if script is in <head>; Task 10: 同时等 brand 落定(成功或
// 失败都继续)再挂载——两条件齐备才 init,任一失败路径都不白屏
function domReady(fn) {
if (document.body) fn();
else document.addEventListener("DOMContentLoaded", fn);
}
fetchBrand().then(function (b) {
__brand = b;
domReady(boot);
});
})();