2026-10-12 16:25:32 +08:00
( function () {
var script = document . currentScript || ( function () {
var scripts = document . getElementsByTagName ( "script" );
return scripts [ scripts . length - 1 ];
})();
// Save config immediately (before DOM ready)
2026-10-12 22:26:15 +08:00
// Task 10 值管道: data-* 属性(嵌入方显式覆盖,最高优先) > brand 端点
// (租户/站点门面) > 中性兜底。此处只同步读原始属性;有效值在 brand
// 落定后由 boot() 推导(见文件尾),品牌文案不再硬编码于本文件。
2026-10-12 16:25:32 +08:00
var position = script . getAttribute ( "data-position" ) || "bottom-right" ;
var theme = script . getAttribute ( "data-theme" ) || "light" ;
2026-10-12 22:26:15 +08:00
var titleAttr = script . getAttribute ( "data-title" );
2026-10-12 23:19:02 +08:00
// 同步自上游 ai-kefu: data-placeholder 显式覆盖输入框占位文案。裸读不带
// 默认值——Task 10 管道里默认值在 boot() 推导链尾(读取行给默认会让
// brand.placeholder 永远无法生效),tests/test_placeholder_static.py 守卫。
var placeholderAttr = script . getAttribute ( "data-placeholder" );
2026-10-12 16:25:32 +08:00
var autoOpen = script . getAttribute ( "data-auto-open" ) === "true" ;
2026-10-12 22:26:15 +08:00
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 ();
2026-10-12 16:25:32 +08:00
var autoInvite = script . getAttribute ( "data-auto-invite" ) !== "false" ;
var isMobile = script . getAttribute ( "data-mobile" ) === "true" ;
var wsBase = new URL ( script . src ). origin ;
2026-10-19 18:13:49 +08:00
// spec 2026-10-19 §4: 脚本地址自携带租户路径(/t/<slug>/static/widget.js)
// → 剥掉 /static/widget.js 起的前缀得 basePath("" = Host 模式零变化)。
// script.src 永远指向本文件,indexOf 命中即安全;query(?v=)不影响 pathname。
// 拼接安全: basePath 仅进 URL 上下文(fetch/WebSocket/img.src),
// 绝不进 innerHTML 字符串拼 HTML(防线 §2/§3)。
var basePath = ( function () {
var p = new URL ( script . src ). pathname ;
var i = p . indexOf ( "/static/widget.js" );
return i > 0 ? p . slice ( 0 , i ) : "" ;
})();
2026-10-12 16:25:32 +08:00
/* ===== 主题派生单元:与 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" ;
2026-10-12 22:26:15 +08:00
// ---- 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 () {
2026-10-19 18:13:49 +08:00
var url = wsBase + basePath + "/api/public/brand" + ( siteAttr ? "?site=" + encodeURIComponent ( siteAttr ) : "" );
2026-10-12 22:26:15 +08:00
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 { "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ , "'" : "'" }[ 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
2026-10-19 18:13:49 +08:00
? ( /^https?:\/\//i . test ( avatarAttr ) ? avatarAttr : wsBase + basePath + "/" + avatarAttr . replace ( /^\// , "" ))
: ( b . avatar ? wsBase + basePath + ( b . avatar . charAt ( 0 ) === "/" ? b . avatar : "/" + b . avatar )
: wsBase + basePath + DEFAULT_AVATAR_PATH );
2026-10-12 22:26:15 +08:00
welcome = b . welcome || "" ;
topics = Array . isArray ( b . topics )
? b . topics . filter ( function ( t ) { return typeof t === "string" && t ; })
: [];
2026-10-12 23:19:02 +08:00
placeholder = placeholderAttr || b . placeholder || "Type a message..." ;
2026-10-12 22:26:15 +08:00
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 ();
2026-10-12 16:25:32 +08:00
}
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" );
2026-10-12 22:26:15 +08:00
host . id = "cc-widget-host-" + brandSlug ;
2026-10-12 16:25:32 +08:00
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" : "" );
2026-10-12 22:26:15 +08:00
// 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>'
: "" ;
2026-10-12 16:25:32 +08:00
var panel = document . createElement ( "div" );
panel . className = "chat-panel" ;
panel . innerHTML = [
'<div class="panel-header">' ,
2026-10-12 22:52:08 +08:00
'<div class="avatar"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="' + esc ( avatarUrl ) + '"></div>' ,
2026-10-12 22:26:15 +08:00
'<div class="info"><h3>' + esc ( title ) + '</h3><div class="status">Online now · Usually replies in minutes</div></div>' ,
2026-10-12 16:25:32 +08:00
'<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">' ,
2026-10-12 22:52:08 +08:00
'<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>' ,
2026-10-12 16:25:32 +08:00
'</div>' ,
'<div class="chat-separator"></div>' ,
'<div class="panel-messages" id="w-msgs">' ,
2026-10-12 22:26:15 +08:00
welcomeMsgHtml ,
2026-10-12 16:25:32 +08:00
'</div>' ,
'<div class="typing" id="w-typing"><span></span><span></span><span></span></div>' ,
2026-10-12 22:26:15 +08:00
popularTopicsSectionHtml ,
2026-10-12 16:25:32 +08:00
'<div class="input-container"><div class="input-wrapper">' ,
2026-10-12 22:26:15 +08:00
'<input type="text" id="w-input" placeholder="' + esc ( placeholder ) + '">' ,
2026-10-12 16:25:32 +08:00
'<button id="w-send"><span style="font-size:16px;">➤</span><span>Send</span></button>' ,
'</div></div>' ,
2026-10-12 22:26:15 +08:00
chatFooterHtml ,
2026-10-12 16:25:32 +08:00
]. join ( "" );
// Invite card HTML template
var inviteTemplate = [
'<div class="invite-panel hide">' ,
'<div class="invite-header">' ,
'<div class="invite-icon-wrapper">' ,
2026-10-12 22:52:08 +08:00
'<div class="invite-icon"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="' + esc ( avatarUrl ) + '"><span class="invite-status-dot"></span></div>' ,
2026-10-12 16:25:32 +08:00
'</div>' ,
'<div class="invite-header-content">' ,
2026-10-12 22:52:08 +08:00
'<div class="invite-title">Chat with us for products, quotations and delivery.</div>' ,
2026-10-12 16:25:32 +08:00
'</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>' ,
2026-10-12 22:26:15 +08:00
( 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>'
: "" ),
2026-10-12 16:25:32 +08:00
'</div>' ,
'</div>' ,
2026-10-12 22:26:15 +08:00
( topics . length
? '<div class="invite-body">'
+ '<div class="invite-topics">' + inviteTopicsHtml + '</div>'
+ '</div>'
: "" ),
2026-10-12 16:25:32 +08:00
'<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>' ,
2026-10-12 22:26:15 +08:00
invitePrivacyHtml ,
2026-10-12 16:25:32 +08:00
'</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 = `
2026-10-12 22:52:08 +08:00
<div class="mobile-circle"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src=" ${ esc ( avatarUrl ) } "><span class="mobile-dot"></span></div>
2026-10-12 16:25:32 +08:00
<span class="mobile-label">Chat with us</span>
` ;
} else {
toggleBtn . innerHTML = `
<div class="toggle-avatar-wrap">
2026-10-12 22:52:08 +08:00
<div class="toggle-avatar-circle"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src=" ${ esc ( avatarUrl ) } "></div>
2026-10-12 16:25:32 +08:00
<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 ;
2026-10-12 22:26:15 +08:00
// Task 10: key 租户化——同浏览器多租户挂件互不踩(不同租户 slug 隔离)
var inviteStorageKey = "widget_invite_dismissed_" + brandSlug + "_" + window . location . pathname ;
2026-10-12 16:25:32 +08:00
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 );
2026-10-19 18:13:49 +08:00
wWs = new WebSocket ( wsHost + basePath + "/ws/chat" );
2026-10-12 16:25:32 +08:00
wWs . onopen = function () {
2026-10-12 22:26:15 +08:00
// Task 10: init 带 site(空域名站点如 es 靠 data-site 接入,服务端
// 记入 client_info["site"] 作会话站点标;空值服务端忽略)
2026-10-12 16:25:32 +08:00
if ( wSessionId ) {
2026-10-12 22:26:15 +08:00
wWs . send ( JSON . stringify ({ session_id : wSessionId , page_token : pageToken , site : siteAttr }));
2026-10-12 16:25:32 +08:00
} else {
2026-10-12 22:26:15 +08:00
wWs . send ( JSON . stringify ({ source : "widget" , page_token : pageToken , site : siteAttr }));
2026-10-12 16:25:32 +08:00
}
// 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 ();
}
2026-10-12 22:26:15 +08:00
// Wait for body if script is in <head>; Task 10: 同时等 brand 落定(成功或
// 失败都继续)再挂载——两条件齐备才 init,任一失败路径都不白屏
function domReady ( fn ) {
if ( document . body ) fn ();
else document . addEventListener ( "DOMContentLoaded" , fn );
2026-10-12 16:25:32 +08:00
}
2026-10-12 22:26:15 +08:00
fetchBrand (). then ( function ( b ) {
__brand = b ;
domReady ( boot );
});
2026-10-12 16:25:32 +08:00
})();