1(function() { 2 'use strict'; 3 4 // ââ Bootstrap ââââââââââââââââââââââââââââââââââââââââââââââââââââ 5 const script = document.currentScript || document.querySelector('script[data-chatbot-id]'); 6 if (!script) { console.error('ChatAziendale.it widget: No chatbot ID found'); return; } 7 const chatbotId = script.getAttribute('data-chatbot-id'); 8 if (!chatbotId) { console.error('ChatAziendale.it widget: Missing data-chatbot-id'); return; } 9 const baseUrl = (script.src || '').replace(/\/widget\.js.*$/, '') || window.location.origin; 10 const sessionId = 'widget-' + Date.now() + '-' + Math.random().toString(36).slice(2, 11); 11 12 // ââ Color helpers âââââââââââââââââââââââââââââââââââââââââââââââ 13 function hexToRgb(hex) { 14 const h = (hex || '#3B82F6').replace('#', ''); 15 const n = h.length === 3 ? h.split('').map(c => c + c).join('') : h; 16 const num = parseInt(n, 16) || 0; 17 return { r: (num >> 16) & 0xff, g: (num >> 8) & 0xff, b: num & 0xff }; 18 } 19 function rgba(hex, a) { const { r, g, b } = hexToRgb(hex); return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')'; } 20 function shade(hex, pct) { 21 const { r, g, b } = hexToRgb(hex); 22 const f = c => Math.max(0, Math.min(255, Math.round(c + (pct / 100) * (pct > 0 ? 255 - c : c)))); 23 return '#' + [f(r), f(g), f(b)].map(x => x.toString(16).padStart(2, '0')).join(''); 24 } 25 26 // ââ Icons âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 27 const ICONS = { 28 spark: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="22" height="22"><path d="M12 4 C 9 8, 8 11, 9 15 C 10 17, 12 18, 12 21 C 12 18, 14 17, 15 15 C 16 11, 15 8, 12 4 Z" fill="currentColor" stroke="none" fill-opacity="0.95"/><path d="M19 5 L 19.6 6.4 L 21 7 L 19.6 7.6 L 19 9 L 18.4 7.6 L 17 7 L 18.4 6.4 Z" fill="currentColor" stroke="none"/><path d="M5 14 L 5.4 15 L 6.4 15.4 L 5.4 15.8 L 5 16.8 L 4.6 15.8 L 3.6 15.4 L 4.6 15 Z" fill="currentColor" stroke="none"/></svg>', 29 avatarMark: '<svg viewBox="0 0 20 20" fill="none"><path d="M10 3 C 7 6, 6 9, 7 12 C 8 14, 10 15, 10 17 C 10 15, 12 14, 13 12 C 14 9, 13 6, 10 3 Z" fill="white" fill-opacity="0.95"/><circle cx="10" cy="11.5" r="1.4" fill="rgba(0,0,0,0.18)"/></svg>', 30 expand: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M10 3h3v3M13 3l-4 4M6 13H3v-3M3 13l4-4"/></svg>', 31 compact: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M9 7l4-4M13 3v3M13 3h-3M7 9l-4 4M3 13v-3M3 13h3"/></svg>', 32 refresh: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8a5 5 0 019-3M13 8a5 5 0 01-9 3M11 5h2V3M5 11H3v2"/></svg>', 33 close: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>', 34 arrow: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h8M7 3l4 4-4 4"/></svg>', 35 send: '<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 12V2M3 6l4-4 4 4"/></svg>',
36 thumbUp: '<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6.5v5H2.5a.5.5 0 01-.5-.5v-4a.5.5 0 01.5-.5H4zM4 6.5L6.5 2a1.5 1.5 0 011.5 1v3h3a1 1 0 011 1l-1 4a1 1 0 01-1 .5H4"/></svg>', 37 thumbDown: '<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(180deg)"><path d="M4 6.5v5H2.5a.5.5 0 01-.5-.5v-4a.5.5 0 01.5-.5H4zM4 6.5L6.5 2a1.5 1.5 0 011.5 1v3h3a1 1 0 011 1l-1 4a1 1 0 01-1 .5H4"/></svg>', 38 copy: '<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="8" height="8" rx="1.5"/><path d="M2 10V3a1 1 0 011-1h7"/></svg>', 39 spinner: '<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" width="11" height="11"><path d="M6 1v2M6 9v2M1 6h2M9 6h2M2.5 2.5l1.4 1.4M8.1 8.1l1.4 1.4M2.5 9.5l1.4-1.4M8.1 3.9l1.4-1.4"/></svg>', 40 mic: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v4"/></svg>', 41 micLg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v4"/></svg>', 42 hangup: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85a.99.99 0 0 1-1.41-.01L.29 13.08a.99.99 0 0 1 0-1.41C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67a.99.99 0 0 1 0 1.41l-2.48 2.43a.99.99 0 0 1-1.41.01 11.6 11.6 0 0 0-2.66-1.85.998.998 0 0 1-.56-.9v-3.1A16.2 16.2 0 0 0 12 9z"/></svg>', 43 }; 44 45 const PRESET_LAUNCHER_ICONS = { 46 headset: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="26" height="26"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg>', 47 robot: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="26" height="26"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>', 48 help: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="26" height="26"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>', 49 star: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="26" height="26"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', 50 bolt: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="26" height="26"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>', 51 heart: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="26" height="26"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>', 52 'message-circle': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="26" height="26"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>', 53 }; 54 55 function getLauncherIconHTML(config) { 56 if (!config) return ICONS.spark; 57 if (config.chatIconType === 'preset' && config.chatIconPreset && PRESET_LAUNCHER_ICONS[config.chatIconPreset]) { 58 return PRESET_LAUNCHER_ICONS[config.chatIconPreset]; 59 } 60 if (config.chatIconType === 'custom' && config.chatIconImage) { 61 const fallback = ICONS.spark.replace(/'/g, "\\'"); 62 return '<img src="' + config.chatIconImage + '" alt="" width="32" height="32" style="display:block;pointer-events:none;border-radius:8px;" onerror="this.parentElement.innerHTML=\'' + fallback + '\'">'; 63 } 64 return ICONS.spark; 65 } 66 67 // Avatar icon â fills the gradient square. Preset SVGs render white via currentColor; 68 // custom images cover the full square. Falls back to the spark mark. 69 function getAvatarIconHTML(config) { 70 if (config && config.chatIconType === 'preset' && config.chatIconPreset && PRESET_LAUNCHER_ICONS[config.chatIconPreset]) { 71 return PRESET_LAUNCHER_ICONS[config.chatIconPreset]; 72 } 73 if (config && config.chatIconType === 'custom' && config.chatIconImage) { 74 const fallback = ICONS.avatarMark.replace(/'/g, "\\'"); 75 return '<img src="' + config.chatIconImage + '" alt="" style="width:100%;height:100%;object-fit:cover;display:block;" onerror="this.parentElement.innerHTML=\'' + fallback + '\'">'; 76 } 77 return ICONS.avatarMark; 78 } 79 80 // ââ State ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 81 let chatbot = null; 82 let mode = 'launcher'; 83 let isLoading = false;
84 let messageCount = 0; 85 86 // ââ Container + Shadow DOM ââââââââââââââââââââââââââââââââââââââ 87 const container = document.createElement('div'); 88 container.id = 'chataziendale-widget-container'; 89 document.body.appendChild(container); 90 const shadow = container.attachShadow({ mode: 'open' }); 91 92 // ââ Styles âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 93 const styles = document.createElement('style'); 94 styles.textContent = ` 95 :host { all: initial; } 96 * { 97 box-sizing: border-box; margin: 0; padding: 0; 98 font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 99 -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; 100 } 101 102 .root { 103 --bg: #f6f1ea; 104 --paper: #fefcf8; 105 --paper-hi: #fffefb; 106 --fg: #1a1410; 107 --fg-mute: rgba(26,20,16,0.6); 108 --fg-faint: rgba(26,20,16,0.36); 109 --line: rgba(26,20,16,0.07); 110 --line-hi: rgba(26,20,16,0.14); 111 --chip: #f3ede4; 112 --user-bubble: #1a1410; 113 --user-bubble-text: #fefcf8; 114 --accent: #3b82f6; 115 --accent-soft: rgba(59,130,246,0.10); 116 --accent-faint: rgba(59,130,246,0.08); 117 --accent-line: rgba(59,130,246,0.40); 118 --accent-shade-pos: #4f8df7; 119 --accent-shade-pos2: #6da3f9; 120 --accent-shade-neg: #2266dd; 121 --launcher-c1: #3b82f6; 122 --launcher-c2: #2266dd; 123 --shadow-glow: rgba(59,130,246,0.50); 124 --shadow-soft: rgba(59,130,246,0.30); 125 } 126 127 /* ââ Launcher ââââââââââââââââââââââââââââââââââââââââââââ */ 128 .launcher { 129 position: fixed; right: 24px; bottom: 24px; z-index: 2147483645; 130 width: 60px; height: 60px; border-radius: 22px; border: none; cursor: pointer; 131 background: linear-gradient(135deg, var(--launcher-c1), var(--launcher-c2)); 132 color: #fff; display: flex; align-items: center; justify-content: center; 133 box-shadow: 134 0 14px 30px -8px var(--shadow-glow), 135 0 6px 14px -4px var(--shadow-soft), 136 inset 0 1px 0 rgba(255,255,255,0.25); 137 transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease; 138 opacity: 0; 139 } 140 .launcher.ready { opacity: 1; } 141 .launcher:hover { transform: scale(1.06); } 142 .launcher:active { transform: scale(0.96); } 143 .launcher.hidden { opacity: 0; pointer-events: none; transform: scale(0.85); } 144 .launcher::before { 145 content: ''; position: absolute; inset: -6px; border-radius: 28px; 146 border: 1.5px solid var(--launcher-c1); opacity: 0.5; pointer-events: none; 147 animation: cw-halo 2.4s infinite ease-out; 148 } 149 .launcher svg, .launcher img { pointer-events: none; display: block; } 150 @keyframes cw-halo { 151 0% { transform: scale(0.9); opacity: 0.55; } 152 100% { transform: scale(1.35); opacity: 0; } 153 } 154 155 /* ââ Widget shell ââââââââââââââââââââââââââââââââââââââââ */ 156 .widget { 157 position: fixed; right: 24px; bottom: 24px; z-index: 2147483646; 158 width: 400px; height: 620px; 159 max-width: calc(100vw - 48px); max-height: calc(100vh - 48px); 160 background: var(--bg); border-radius: 26px; overflow: hidden; 161 color: var(--fg); 162 box-shadow: 163 0 24px 60px -18px var(--shadow-soft), 164 0 12px 36px -8px rgba(26,20,16,0.18), 165 0 0 0 1px rgba(26,20,16,0.05); 166 display: none; flex-direction: column; 167 transition: width 0.32s cubic-bezier(0.4, 0, 0.2, 1), height 0.32s cubic-bezier(0.4, 0, 0.2, 1); 168 } 169 .widget.open { display: flex; animation: cw-in 0.32s cubic-bezier(0.16, 1, 0.3, 1); } 170 .widget.focus { width: 740px; height: 760px; }
171 @keyframes cw-in { 172 from { opacity: 0; transform: translateY(12px) scale(0.98); } 173 to { opacity: 1; transform: none; } 174 } 175 176 .widget-bg { 177 position: absolute; inset: 0; pointer-events: none; z-index: 0; 178 background: 179 radial-gradient(ellipse 600px 320px at 70% -10%, var(--accent-faint), transparent 60%), 180 radial-gradient(ellipse 500px 280px at 0% 110%, var(--accent-faint), transparent 60%); 181 } 182 .header, .body, .composer-wrap { position: relative; z-index: 1; } 183 184 /* ââ Header âââââââââââââââââââââââââââââââââââââââââââââââ */ 185 .header { 186 display: flex; align-items: center; justify-content: space-between; 187 padding: 14px 14px 14px 18px; 188 border-bottom: 1px solid var(--line); 189 flex-shrink: 0; 190 } 191 .header-left { display: flex; align-items: center; gap: 11px; min-width: 0; } 192 .avatar { 193 flex-shrink: 0; border-radius: 32%; 194 background: linear-gradient(135deg, var(--accent-shade-pos), var(--accent-shade-neg)); 195 color: #fff; 196 display: flex; align-items: center; justify-content: center; 197 box-shadow: 0 2px 8px var(--accent-soft), inset 0 1px 0 rgba(255,255,255,0.25); 198 position: relative; overflow: hidden; 199 } 200 .avatar.lg { width: 32px; height: 32px; } 201 .avatar.sm { width: 26px; height: 26px; } 202 .avatar svg { width: 62%; height: 62%; } 203 .avatar img { display: block; } 204 .avatar.thinking svg { animation: cw-pulse 1.2s infinite; } 205 .header-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; } 206 .header-title { display: flex; align-items: center; gap: 7px; flex-wrap: nowrap; } 207 .header-title .name { 208 font-size: 14px; font-weight: 600; letter-spacing: -0.2px; color: var(--fg); 209 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; 210 } 211 .header-title .lang { 212 font-size: 9.5px; color: var(--accent); font-weight: 700; letter-spacing: 0.5px; 213 font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; 214 padding: 2px 6px; background: var(--accent-soft); border-radius: 4px; 215 white-space: nowrap; flex-shrink: 0; 216 } 217 .header-status { 218 display: flex; align-items: center; gap: 5px; font-size: 11px; 219 color: var(--fg-mute); white-space: nowrap; 220 } 221 .header-status .dot { 222 width: 6px; height: 6px; border-radius: 3px; background: #22c55e; 223 box-shadow: 0 0 6px rgba(34,197,94,0.5); flex-shrink: 0; 224 animation: cw-pulse 2.2s infinite; 225 } 226 .header-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; } 227 .icon-btn { 228 width: 30px; height: 30px; border-radius: 9px; border: none; cursor: pointer; 229 background: transparent; color: var(--fg-mute); 230 display: flex; align-items: center; justify-content: center; 231 transition: background-color 0.12s, color 0.12s, transform 0.1s; 232 } 233 .icon-btn:hover { background: var(--chip); color: var(--fg); } 234 .icon-btn:active { transform: scale(0.94); } 235 236 /* ââ Body âââââââââââââââââââââââââââââââââââââââââââââââââ */ 237 .body { 238 flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; 239 padding: 18px 18px 12px; 240 display: flex; flex-direction: column; gap: 14px; 241 scrollbar-width: thin; scrollbar-color: var(--line-hi) transparent; 242 } 243 .body.empty { padding: 4px 18px 12px; } 244 .body::-webkit-scrollbar { width: 5px; } 245 .body::-webkit-scrollbar-track { background: transparent; } 246 .body::-webkit-scrollbar-thumb { background: var(--line-hi); border-radius: 3px; } 247 248 /* ââ Empty state ââââââââââââââââââââââââââââââââââââââââââ */ 249 .empty-state { display: flex; flex-direction: column; gap: 16px; padding: 8px 0; } 250 .greeting { display: flex; flex-direction: column; gap: 6px; } 251 .eyebrow { 252 align-self: flex-start; display: inline-flex; align-items: center; gap: 6px; 253 font-size: 10.5px; font-weight: 600; letter-spacing: 0.8px; text-transform: uppercase; 254 color: var(--accent); font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; 255 } 256 .eyebrow .bar { width: 14px; height: 1px; background: var(--accent); opacity: 0.5; } 257 .greet-title { 258 font-size: 22px; font-weight: 600; letter-spacing: -0.6px; line-height: 1.18; 259 color: var(--fg); text-wrap: balance; 260 } 261 .greet-title .accent-text { 262 background: linear-gradient(120deg, var(--accent) 0%, var(--accent-shade-pos2) 100%); 263 -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; 264 } 265 .greet-body { 266 font-size: 13.5px; line-height: 1.5; color: var(--fg-mute); text-wrap: pretty; 267 word-wrap: break-word; 268 } 269 270 .section-label { 271 font-size: 10px; font-weight: 700; letter-spacing: 1.3px; text-transform: uppercase; 272 color: var(--fg-faint); font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; 273 } 274 .prompt-block { display: flex; flex-direction: column; gap: 8px; } 275 .prompt-list { display: flex; flex-direction: column; gap: 6px; } 276 .prompt-card { 277 display: flex; align-items: center; justify-content: space-between; gap: 12px; 278 padding: 12px 14px; border: 1px solid var(--line); border-radius: 14px; 279 background: var(--paper); color: var(--fg); 280 font-family: inherit; font-size: 13.5px; text-align: left; line-height: 1.4; 281 cursor: pointer; transition: border-color 0.15s, background-color 0.15s, transform 0.15s; 282 box-shadow: inset 0 1px 0 rgba(255,255,255,0.4); 283 } 284 .prompt-card:hover { border-color: var(--accent); background: var(--paper-hi); transform: translateY(-1px); } 285 .prompt-card .label { flex: 1; line-height: 1.4; word-wrap: break-word; } 286 .prompt-card .arrow { color: var(--accent); flex-shrink: 0; display: flex; } 287 288 /* ââ Messages âââââââââââââââââââââââââââââââââââââââââââââ */ 289 .msg { animation: cw-in 0.28s ease-out; } 290 .msg.user { display: flex; justify-content: flex-end; } 291 .msg.user .bubble { 292 background: var(--user-bubble); color: var(--user-bubble-text); 293 padding: 10px 14px; border-radius: 16px 16px 4px 16px; 294 font-size: 13.5px; line-height: 1.5; max-width: 78%; 295 box-shadow: 0 1px 2px rgba(0,0,0,0.08); 296 word-wrap: break-word;
296 overflow-wrap: anywhere; white-space: pre-wrap; 297 } 298 .msg.bot { display: flex; gap: 9px; align-items: flex-start; } 299 .msg.bot .bot-col { display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 0; } 300 .bot-bubble { 301 background: var(--paper); border: 1px solid var(--line); 302 border-radius: 4px 16px 16px 16px; padding: 12px 14px; 303 font-size: 13.5px; line-height: 1.55; color: var(--fg); 304 box-shadow: 0 1px 2px rgba(26,20,16,0.04); 305 word-wrap: break-word; overflow-wrap: anywhere; 306 } 307 .bot-bubble.streaming-cursor::after { 308 content: 'â'; animation: cw-blink 1s infinite; margin-left: 2px; opacity: 0.6; 309 } 310 @keyframes cw-blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } } 311 312 .sources { 313 margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--line); 314 display: flex; flex-direction: column; gap: 6px; 315 } 316 .sources-title { 317 font-size: 9.5px; font-weight: 700; letter-spacing: 1.1px; text-transform: uppercase; 318 color: var(--fg-faint); font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; 319 } 320 .sources-list { display: flex; flex-direction: column; gap: 4px; } 321 .source { 322 display: flex; align-items: center; gap: 7px; 323 padding: 6px 8px; border-radius: 8px; background: var(--chip); 324 font-size: 11.5px; color: var(--fg-mute); 325 transition: background-color 0.12s; text-decoration: none; 326 } 327 a.source { cursor: pointer; } 328 a.source:hover { background: var(--accent-soft); } 329 .source .num { 330 width: 18px; height: 18px; border-radius: 5px; background: var(--paper); 331 border: 1px solid var(--line); 332 display: inline-flex; align-items: center; justify-content: center; 333 font-size: 9.5px; font-weight: 700; color: var(--accent); 334 font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; 335 flex-shrink: 0; 336 } 337 .source .title { 338 flex: 1; color: var(--fg); font-weight: 500; min-width: 0; 339 overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 340 } 341 342 .actions { display: flex; align-items: center; gap: 2px; padding-left: 4px; } 343 .action-btn { 344 width: 24px; height: 24px; border-radius: 7px; border: none; cursor: pointer; 345 background: transparent; color: var(--fg-faint); 346 display: flex; align-items: center; justify-content: center; 347 transition: background-color 0.12s, color 0.12s; 348 } 349 .action-btn:hover { background: var(--chip); color: var(--fg); } 350 .action-btn.active { background: var(--accent-soft); color: var(--accent); } 351 .action-btn.active svg path { fill: var(--accent); } 352 .actions .spacer { flex: 1; } 353 .feedback { font-size: 10.5px; color: var(--fg-mute); margin-left: 4px; opacity: 0; transition: opacity 0.3s; white-space: nowrap; } 354 .feedback.visible { opacity: 1; } 355 356 .confidence { 357 display: inline-flex; align-items: center; gap: 4px; 358 font-size: 9.5px; font-weight: 700; padding: 3px 8px; border-radius: 6px; 359 letter-spacing: 0.7px; text-transform: uppercase; 360 font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; 361 width: fit-content; margin-left: 4px; 362 } 363 .confidence.high { background: rgba(34,197,94,0.12); color: #15803d; } 364 .confidence.medium { background: rgba(234,179,8,0.14); color: #a16207; } 365 .confidence.low { background: rgba(239,68,68,0.12); color: #b91c1c; } 366 367 /* ââ Thinking âââââââââââââââââââââââââââââââââââââââââââââ */ 368 .thinking { display: flex; gap: 9px; align-items: flex-start; animation: cw-in 0.28s ease-out; } 369 .thinking-bubble { 370 background: var(--paper); border: 1px solid var(--line); 371 border-radius: 4px 16px 16px 16px; padding: 10px 14px; 372 display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 0; 373 box-shadow: 0 1px 2px rgba(26,20,16,0.04); 374 } 375 .thinking-row { 376 display: flex; align-items: center; gap: 8px; 377 font-size: 11px; color: var(--fg-mute); 378 font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; 379 } 380 .thinking-row svg { color: var(--accent); animation: cw-spin 1.4s linear infinite; flex-shrink: 0; } 381 .thinking-dots { display: flex; gap: 4px; padding-left: 1px; } 382 .thinking-dots span { 383 width: 5px; height: 5px; border-radius: 3px; background: var(--accent); 384 animation: cw-dot 1.2s infinite ease-in-out; 385 } 386 .thinking-dots span:nth-child(2) { animation-delay: 0.15s; } 387 .thinking-dots span:nth-child(3) { animation-delay: 0.30s; }
388 @keyframes cw-spin { from { transform: rotate(0); } to { transform: rotate(360deg); } } 389 @keyframes cw-dot { 390 0%, 80%, 100% { opacity: 0.3; transform: translateY(0); } 391 40% { opacity: 1; transform: translateY(-3px); } 392 } 393 @keyframes cw-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } 394 395 /* ââ Composer âââââââââââââââââââââââââââââââââââââââââââââ */ 396 .composer-wrap { padding: 4px 14px 14px; flex-shrink: 0; position: relative; } 397 .composer-fade { 398 position: absolute; left: 0; right: 0; top: -24px; height: 24px; 399 background: linear-gradient(to bottom, transparent, var(--bg)); 400 pointer-events: none; 401 } 402 .composer { 403 display: flex; flex-direction: column; 404 background: var(--paper); border-radius: 18px; border: 1px solid var(--line); 405 box-shadow: 0 2px 6px rgba(26,20,16,0.04); 406 transition: border-color 0.18s, box-shadow 0.18s; 407 overflow: hidden; 408 } 409 .composer.focused { 410 border-color: var(--accent-line); 411 box-shadow: 0 0 0 3px var(--accent-faint), 0 6px 16px -6px var(--accent-soft); 412 } 413 .composer textarea { 414 width: 100%; resize: none; border: none; outline: none; 415 background: transparent; color: var(--fg); font-family: inherit; 416 font-size: 13.5px; line-height: 1.5; padding: 12px 14px 6px; 417 max-height: 120px; overflow-y: auto; 418 } 419 .composer textarea::placeholder { color: var(--fg-faint); } 420 .composer textarea:disabled { cursor: wait; } 421 .composer-row { 422 display: flex; align-items: center; justify-content: space-between; 423 padding: 4px 8px 8px 14px; 424 } 425 .composer-tools { display: flex; align-items: center; flex: 1; min-width: 0; overflow: hidden; } 426 .composer-hint { 427 font-size: 10.5px; color: var(--fg-faint); 428 font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; 429 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; 430 } 431 .send-btn { 432 width: 32px; height: 32px; border-radius: 10px; border: none; cursor: pointer; 433 background: var(--chip); color: var(--fg-faint); 434 display: flex; align-items: center; justify-content: center; 435 transition: background-color 0.15s, transform 0.15s, box-shadow 0.15s; 436 flex-shrink: 0; 437 } 438 .send-btn.active { 439 background: var(--accent); color: #fff; 440 box-shadow: 0 4px 10px var(--accent-soft), inset 0 1px 0 rgba(255,255,255,0.2); 441 } 442 .send-btn.active:hover { transform: translateY(-1px); } 443 .send-btn:disabled { cursor: not-allowed; } 444 445 .branding { 446 text-align: center; font-size: 10.5px; color: var(--fg-faint); 447 display: flex; align-items: center; justify-content: center; gap: 5px; 448 font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; 449 margin-top: 9px; flex-wrap: wrap; 450 } 451 .branding strong { font-weight: 600; color: var(--fg-mute); } 452 .branding a { color: inherit; text-decoration: none; } 453 .branding a:hover { text-decoration: underline; } 454 .branding .sep { opacity: 0.5; } 455 456 /* ââ Markdown âââââââââââââââââââââââââââââââââââââââââââââ */ 457 .md-p { margin: 0 0 10px 0; }
458 .md-p:last-child { margin-bottom: 0; } 459 .md-h2, .md-h3, .md-h4 { margin: 10px 0 4px 0; font-weight: 600; line-height: 1.3; } 460 .md-h2 { font-size: 15px; } 461 .md-h3 { font-size: 14px; } 462 .md-h4 { font-size: 13px; } 463 .md-code-block { 464 background: rgba(26,20,16,0.04); color: var(--fg); 465 padding: 10px 12px; border-radius: 8px; overflow-x: auto; 466 font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, 'Ubuntu Mono', monospace; 467 font-size: 12px; margin: 8px 0; 468 white-space: pre-wrap; word-break: break-word; 469 } 470 .md-inline-code { 471 background: var(--chip); color: var(--accent); 472 padding: 1px 6px; border-radius: 4px; 473 font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, 'Ubuntu Mono', monospace; 474 font-size: 12px; 475 } 476 .md-ul, .md-ol { margin: 6px 0; padding-left: 20px; } 477 .md-li, .md-oli { margin: 3px 0; line-height: 1.5; } 478 .md-link { color: var(--accent); text-decoration: none; font-weight: 500; } 479 .md-link:hover { text-decoration: underline; } 480 .bot-bubble strong { color: var(--accent); font-weight: 600; } 481 482 /* ââ Voice ââââââââââââââââââââââââââââââââââââââââââââââââ */ 483 .mic-btn { 484 width: 32px; height: 32px; border-radius: 10px; border: none; cursor: pointer; 485 background: var(--chip); color: var(--accent); 486 display: none; align-items: center; justify-content: center; 487 transition: background-color 0.15s, transform 0.15s; flex-shrink: 0; margin-right: 6px; 488 } 489 .mic-btn.show { display: flex; } 490 .mic-btn:hover { background: var(--accent-soft); } 491 .mic-btn:active { transform: scale(0.94); } 492 493 .voice-overlay { 494 position: absolute; inset: 0; z-index: 5; display: none; 495 flex-direction: column; align-items: center; justify-content: center; 496 gap: 22px; padding: 28px; 497 background: 498 radial-gradient(ellipse 520px 380px at 50% 38%, var(--accent-faint), transparent 70%), 499 var(--bg); 500 animation: cw-in 0.3s cubic-bezier(0.16, 1, 0.3, 1); 501 } 502 .voice-overlay.open { display: flex; } 503 .voice-orb { 504 width: 132px; height: 132px; border-radius: 50%; position: relative; flex-shrink: 0; 505 background: radial-gradient(circle at 38% 32%, var(--accent-shade-pos2), var(--accent-shade-neg)); 506 box-shadow: 0 18px 50px -10px var(--shadow-glow), inset 0 2px 6px rgba(255,255,255,0.3); 507 display: flex; align-items: center; justify-content: center; color: #fff; 508 transition: transform 0.12s ease; 509 } 510 .voice-orb::before, .voice-orb::after { 511 content: ''; position: absolute; inset: -10px; border-radius: 50%; 512 border: 2px solid var(--accent); opacity: 0; pointer-events: none; 513 } 514 .voice-overlay.listening .voice-orb::before { animation: cw-ring 1.8s infinite ease-out; } 515 .voice-overlay.speaking .voice-orb { animation: cw-orb-pulse 0.9s infinite ease-in-out; } 516 .voice-overlay.speaking .voice-orb::after { animation: cw-ring 1.2s infinite ease-out; } 517 .voice-overlay.connecting .voice-orb { animation: cw-orb-pulse 1.4s infinite ease-in-out; opacity: 0.85; } 518 @keyframes cw-ring { 0% { transform: scale(0.95); opacity: 0.5; } 100% { transform: scale(1.4); opacity: 0; } } 519 @keyframes cw-orb-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } } 520 .voice-orb svg { width: 46px; height: 46px; } 521 522 .voice-status { 523 font-size: 13px; font-weight: 600; letter-spacing: 0.3px; color: var(--fg); 524 text-align: center; min-height: 18px; 525 } 526 .voice-sub { 527 font-size: 11px; color: var(--fg-mute); text-align: center; 528 font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; 529 } 530 .voice-transcript { 531 width: 100%; max-width: 460px; max-height: 168px; overflow-y: auto; 532 display: flex; flex-direction: column; gap: 8px; padding: 4px; 533 scrollbar-width: thin; scrollbar-color: var(--line-hi) transparent; 534 } 535 .voice-transcript::-webkit-scrollbar { width: 5px; } 536 .voice-transcript::-webkit-scrollbar-thumb { background: var(--line-hi); border-radius: 3px; } 537 .vt-line { font-size: 13px; line-height: 1.5; padding: 8px 12px; border-radius: 12px; word-wrap: break-word; } 538 .vt-line.user { align-self: flex-end; background: var(--user-bubble); color: var(--user-bubble-text); border-radius: 14px 14px 4px 14px; max-width: 82%; } 539 .vt-line.bot { align-self: flex-start; background: var(--paper); border: 1px solid var(--line); color: var(--fg); border-radius: 14px 14px 14px 4px; max-width: 88%; }
540 .voice-controls { display: flex; align-items: center; gap: 14px; } 541 .voice-end { 542 display: inline-flex; align-items: center; gap: 8px; 543 padding: 11px 22px; border-radius: 14px; border: none; cursor: pointer; 544 background: #e5484d; color: #fff; font-family: inherit; font-size: 13px; font-weight: 600; 545 box-shadow: 0 6px 16px -4px rgba(229,72,77,0.5); transition: transform 0.12s, box-shadow 0.15s; 546 } 547 .voice-end:hover { transform: translateY(-1px); } 548 .voice-end:active { transform: scale(0.97); } 549 .voice-timer { 550 font-size: 11px; color: var(--fg-mute); font-weight: 600; 551 font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; 552 padding: 6px 10px; background: var(--chip); border-radius: 8px; 553 } 554 .voice-timer.warn { color: #b91c1c; background: rgba(239,68,68,0.10); } 555 556 /* ââ Mobile âââââââââââââââââââââââââââââââââââââââââââââââ */ 557 @media (max-width: 480px) { 558 .widget { 559 right: 0; bottom: 0; left: 0; top: 0; 560 width: 100%; height: 100%; 561 max-width: 100%; max-height: 100%; 562 border-radius: 0; 563 } 564 .widget.focus { width: 100%; height: 100%; } 565 .launcher { right: 16px; bottom: 16px; } 566 .composer-wrap { padding-bottom: max(14px, env(safe-area-inset-bottom)); } 567 .composer-hint { display: none; } 568 } 569 `; 570 shadow.appendChild(styles); 571 572 // ââ Markup ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 573 const root = document.createElement('div'); 574 root.className = 'root'; 575 root.innerHTML = ` 576 <button class="launcher" id="launcher" aria-label="Open chat">${ICONS.spark}</button> 577 578 <div class="widget" id="widget" role="dialog" aria-label="Chat"> 579 <div class="widget-bg"></div> 580 581 <div class="header"> 582 <div class="header-left"> 583 <div class="avatar lg" id="header-avatar">${ICONS.avatarMark}</div> 584 <div class="header-meta"> 585 <div class="header-title"> 586 <span class="name" id="header-name">Chat</span> 587 <span class="lang" id="header-lang">EN</span> 588 </div> 589 <div class="header-status"> 590 <span class="dot"></span> 591 <span id="header-status-text">Online · replies in seconds</span> 592 </div> 593 </div> 594 </div> 595 <div class="header-actions"> 596 <button class="icon-btn" id="btn-expand" type="button" title="Expand">${ICONS.expand}</button> 597 <button class="icon-btn" id="btn-new" type="button" title="New conversation">${ICONS.refresh}</button> 598 <button class="icon-btn" id="btn-close" type="button" title="Close">${ICONS.close}</button> 599 </div> 600 </div> 601 602 <div class="body" id="body"></div> 603 604 <div class="voice-overlay" id="voice-overlay"> 605 <div class="voice-orb" id="voice-orb">${ICONS.micLg}</div> 606 <div style="display:flex;flex-direction:column;gap:4px;align-items:center;"> 607 <div class="voice-status" id="voice-status">Connectingâ¦</div> 608 <div class="voice-sub" id="voice-sub">Setting up a secure connection</div> 609 </div> 610 <div class="voice-transcript" id="voice-transcript"></div> 611 <div class="voice-controls"> 612 <span class="voice-timer" id="voice-timer">5:00</span> 613 <button class="voice-end" id="voice-end" type="button">${ICONS.hangup} End call</button> 614 </div> 615 </div> 616 617 <div class="composer-wrap"> 618 <div class="composer-fade"></div> 619 <div class="composer" id="composer"> 620 <textarea id="input" rows="1" placeholder="Ask anythingâ¦" autocomplete="off"></textarea> 621 <div class="composer-row"> 622 <div class="composer-tools"> 623 <span class="composer-hint">â to send · â§â for newline</span> 624 </div> 625 <button class="mic-btn" id="mic" type="button" title="Talk to the assistant" aria-label="Start voice call">${ICONS.mic}</button> 626 <button class="send-btn" id="send" type="button" disabled aria-label="Send">${ICONS.send}</button> 627 </div> 628 </div> 629 <div class="branding" id="branding"> 630 Powered by <a href="https://chataziendale.it" target="_blank" rel="noopener"><strong>ChatAziendale</strong></a>
631 <span class="sep">·</span> AI may make mistakes 632 </div> 633 </div> 634 </div> 635 `; 636 shadow.appendChild(root); 637 638 // ââ DOM refs ââââââââââââââââââââââââââââââââââââââââââââââââââââ 639 const $ = id => shadow.getElementById(id); 640 const launcher = $('launcher'); 641 const widget = $('widget'); 642 const headerName = $('header-name'); 643 const headerLang = $('header-lang'); 644 const body = $('body'); 645 const composer = $('composer'); 646 const input = $('input'); 647 const sendBtn = $('send'); 648 const branding = $('branding'); 649 const btnExpand = $('btn-expand'); 650 const btnNew = $('btn-new'); 651 const btnClose = $('btn-close'); 652 const micBtn = $('mic'); 653 const voiceOverlay = $('voice-overlay'); 654 const voiceOrb = $('voice-orb'); 655 const voiceStatus = $('voice-status'); 656 const voiceSub = $('voice-sub'); 657 const voiceTranscript = $('voice-transcript'); 658 const voiceTimer = $('voice-timer'); 659 const voiceEnd = $('voice-end'); 660 661 // ââ Storage âââââââââââââââââââââââââââââââââââââââââââââââââââââ 662 const storageKey = 'chataziendale-chat-' + chatbotId; 663 function loadStored() { 664 try { 665 const raw = localStorage.getItem(storageKey); 666 if (!raw) return null; 667 const data = JSON.parse(raw); 668 if (data && data.timestamp && Date.now() - data.timestamp < 24 * 3600 * 1000) return data; 669 } catch {} 670 return null; 671 } 672 function saveStored() { 673 try { 674 const stored = []; 675 body.querySelectorAll('.msg').forEach(m => { 676 if (m.classList.contains('user')) { 677 const t = m.querySelector('.bubble'); 678 if (t && t.textContent.trim()) stored.push({ role: 'user', content: t.textContent }); 679 } else if (m.classList.contains('bot')) { 680 const text = m.dataset.text || ''; 681 if (text.trim()) stored.push({ role: 'assistant', content: text }); 682 } 683 }); 684 localStorage.setItem(storageKey, JSON.stringify({ messages: stored, timestamp: Date.now() })); 685 } catch {} 686 } 687 function clearStored() { try { localStorage.removeItem(storageKey); } catch {} } 688 689 // ââ Markdown parser âââââââââââââââââââââââââââââââââââââââââââââ 690 function escapeHtml(text) { 691 const d = document.createElement('div'); 692 d.textContent = String(text == null ? '' : text); 693 return d.innerHTML; 694 } 695 // Block-level markdown parser. Splits on blank lines, classifies each block 696 // (heading / list / code / paragraph), then runs inline transforms inside. 697 // This avoids the regex-soup pitfalls of the previous single-pass approach 698 // (extra <br> between <li> items, dangling </p> after lists, etc). 699 function parseMarkdown(text) { 700 const escaped = escapeHtml(text); 701 702 // Extract fenced code blocks first so their contents are not touched by 703 // inline transforms or block splitting. 704 const codeBlocks = []; 705 const stashed = escaped.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { 706 const i = codeBlocks.push('<pre class="md-code-block"><code>' + code.trim() + '</code></pre>') - 1; 707 return '�CB' + i + '�'; 708 }); 709 710 function inline(s) { 711 s = s.replace(/`([^`]+)`/g, '<code class="md-inline-code">$1</code>'); 712 s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); 713 s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>'); 714 s = s.replace(/(?<![*\w])\*([^*\n]+)\*(?![*\w])/g, '<em>$1</em>'); 715 s = s.replace(/(?<![_\w])_([^_\n]+)_(?![_\w])/g, '<em>$1</em>'); 716 s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" class="md-link">$1</a>'); 717 return s; 718 } 719 720 const blocks = stashed.split(/\n{2,}/); 721 const out = []; 722 for (const raw of blocks) { 723 const block = raw.replace(/^\n+|\n+$/g, ''); 724 if (!block) continue; 725 726 // Restored code block placeholder 727 const codeMatch = block.match(/^�CB(\d+)�$/); 728 if (codeMatch) { out.push(codeBlocks[+codeMatch[1]]); continue; } 729 730 // Heading: # â h2, ## â h3, ### â h4 (h1 reserved for the host page) 731 const h = block.match(/^(#{1,3}) (.+)$/); 732 if (h) { 733 const level = h[1].length + 1; 734 out.push('<h' + level + ' class="md-h' + level + '">' + inline(h[2]) + '</h' + level + '>'); 735 continue; 736 } 737 738 // Unordered list â every line starts with "- " or "* " 739 if (block.split('\n').every(l => /^[\-*] /.test(l))) { 740 const items = block.split('\n').map(l => '<li class="md-li">' + inline(l.replace(/^[\-*] /, '')) + '</li>').join(''); 741 out.push('<ul class="md-ul">' + items + '</ul>'); 742 continue; 743 } 744 745 // Ordered list â every line starts with "N. " 746 if (block.split('\n').every(l => /^\d+\. /.test(l))) { 747 const items = block.split('\n').map(l => '<li class="md-oli">' + inline(l.replace(/^\d+\. /, '')) + '</li>').join(''); 748 out.push('<ol class="md-ol">' + items + '</ol>'); 749 continue; 750 } 751 752 // Plain paragraph â preserve single newlines as <br> 753 out.push('<p class="md-p">' + inline(block).replace(/\n/g, '<br>') + '</p>'); 754 } 755 return out.join(''); 756 } 757 758 function dedupSources(sources) { 759 const seen = new Set(); 760 return sources.filter(s => { 761 if (!s || !s.documentTitle) return false;
762 if (seen.has(s.documentTitle)) return false; 763 seen.add(s.documentTitle); 764 return true; 765 }); 766 } 767 768 // ââ Mode switching ââââââââââââââââââââââââââââââââââââââââââââââ 769 function setMode(next) { 770 mode = next; 771 if (next === 'launcher') { 772 launcher.classList.remove('hidden'); 773 widget.classList.remove('open', 'focus'); 774 } else if (next === 'widget') { 775 launcher.classList.add('hidden'); 776 widget.classList.remove('focus'); 777 widget.classList.add('open'); 778 btnExpand.innerHTML = ICONS.expand; 779 btnExpand.title = 'Expand'; 780 setTimeout(() => { try { input.focus(); } catch {} }, 60); 781 } else if (next === 'focus') { 782 launcher.classList.add('hidden'); 783 widget.classList.add('open', 'focus'); 784 btnExpand.innerHTML = ICONS.compact; 785 btnExpand.title = 'Compact'; 786 setTimeout(() => { try { input.focus(); } catch {} }, 60); 787 } 788 } 789 790 // ââ Render helpers ââââââââââââââââââââââââââââââââââââââââââââââ 791 function ensureMessagesContainer() { 792 if (body.classList.contains('empty')) { 793 body.classList.remove('empty'); 794 body.innerHTML = ''; 795 } 796 } 797 function makeAvatar(small) { 798 const div = document.createElement('div'); 799 div.className = 'avatar ' + (small ? 'sm' : 'lg'); 800 div.innerHTML = getAvatarIconHTML(chatbot); 801 return div; 802 } 803 function buildSourcesEl(sources) { 804 const unique = dedupSources(sources); 805 if (!unique.length) return null; 806 const el = document.createElement('div'); 807 el.className = 'sources'; 808 const title = document.createElement('div'); 809 title.className = 'sources-title'; 810 title.textContent = 'Sources · ' + unique.length; 811 el.appendChild(title); 812 const list = document.createElement('div'); 813 list.className = 'sources-list'; 814 unique.forEach((s, i) => { 815 const node = document.createElement(s.sourceUrl ? 'a' : 'div'); 816 node.className = 'source'; 817 if (s.sourceUrl) { node.href = s.sourceUrl; node.target = '_blank'; node.rel = 'noopener'; } 818 const num = document.createElement('span'); num.className = 'num'; num.textContent = i + 1; 819 const t = document.createElement('span'); t.className = 'title'; t.textContent = s.documentTitle || 'Source'; 820 node.appendChild(num); node.appendChild(t); 821 list.appendChild(node); 822 }); 823 el.appendChild(list); 824 return el; 825 } 826 function buildActionsEl(messageId, text) { 827 const el = document.createElement('div'); 828 el.className = 'actions'; 829 el.innerHTML = ` 830 <button class="action-btn" type="button" data-r="up" title="Helpful">${ICONS.thumbUp}</button> 831 <button class="action-btn" type="button" data-r="down" title="Not helpful">${ICONS.thumbDown}</button> 832 <button class="action-btn" type="button" data-r="copy" title="Copy">${ICONS.copy}</button> 833 <span class="feedback"></span> 834 <span class="spacer"></span> 835 `; 836 el.querySelectorAll('.action-btn').forEach(btn => { 837 btn.addEventListener('click', () => handleAction(btn, messageId, text, el)); 838 }); 839 return el; 840 } 841 function buildConfidenceEl(confidence) { 842 if (!confidence) return null; 843 const el = document.createElement('div'); 844 el.className = 'confidence ' + confidence; 845 const labels = { high: 'High confidence', medium: 'Medium confidence', low: 'Low confidence' }; 846 el.textContent = labels[confidence] || ''; 847 return el; 848 } 849 850 // ââ Empty state âââââââââââââââââââââââââââââââââââââââââââââââââ 851 // Renders a single line of "accent markdown" â *word* wraps the word in the accent color. 852 function renderAccentMarkdown(text) { 853 const escaped = escapeHtml(text); 854 return escaped.replace(/\*([^*\n]+)\*/g, '<span class="accent-text">$1</span>'); 855 } 856 857 function renderEmptyState() { 858 body.classList.add('empty'); 859 body.innerHTML = ''; 860 const wrap = document.createElement('div'); 861 wrap.className = 'empty-state'; 862 863 const eyebrowText = chatbot && typeof chatbot.welcomeEyebrow === 'string' 864 ? chatbot.welcomeEyebrow.trim() 865 : 'Knowledge assistant'; 866 const headlineText = (chatbot && chatbot.welcomeHeadline && chatbot.welcomeHeadline.trim()) 867 || 'Hi â what would you like to *know*?'; 868 const welcome = (chatbot && chatbot.welcomeMessage) 869 || (chatbot && chatbot.name ? 'Ask me anything about ' + chatbot.name + '.' : 'Ask me anything.'); 870 871 const greet = document.createElement('div'); 872 greet.className = 'greeting'; 873 const eyebrowHTML = eyebrowText 874 ? '<div class="eyebrow"><span class="bar"></span>' + escapeHtml(eyebrowText) + '</div>' 875 : ''; 876 greet.innerHTML = 877 eyebrowHTML + 878 '<div class="greet-title">' + renderAccentMarkdown(headlineText) + '</div>' + 879 '<div class="greet-body"></div>'; 880 greet.querySelector('.greet-body').textContent = welcome; 881 wrap.appendChild(greet); 882 883 const prompts = (chatbot && Array.isArray(chatbot.suggestedPrompts) ? chatbot.suggestedPrompts : []).filter(
883Boolean); 884 if (prompts.length) { 885 const block = document.createElement('div'); 886 block.className = 'prompt-block'; 887 const label = document.createElement('div'); 888 label.className = 'section-label'; 889 label.textContent = 'Try asking'; 890 block.appendChild(label); 891 const list = document.createElement('div'); 892 list.className = 'prompt-list'; 893 prompts.forEach(p => { 894 const btn = document.createElement('button'); 895 btn.className = 'prompt-card'; 896 btn.type = 'button'; 897 const span = document.createElement('span'); 898 span.className = 'label'; 899 span.textContent = p; 900 const arrow = document.createElement('span'); 901 arrow.className = 'arrow'; 902 arrow.innerHTML = ICONS.arrow; 903 btn.appendChild(span); 904 btn.appendChild(arrow); 905 btn.addEventListener('click', () => sendMessage(p)); 906 list.appendChild(btn); 907 }); 908 block.appendChild(list); 909 wrap.appendChild(block); 910 } 911 912 body.appendChild(wrap); 913 } 914 915 // ââ Messages ââââââââââââââââââââââââââââââââââââââââââââââââââââ 916 function renderUserMessage(text) { 917 ensureMessagesContainer(); 918 const el = document.createElement('div'); 919 el.className = 'msg user'; 920 const bubble = document.createElement('div'); 921 bubble.className = 'bubble'; 922 bubble.textContent = text; 923 el.appendChild(bubble); 924 body.appendChild(el); 925 body.scrollTop = body.scrollHeight; 926 messageCount++; 927 } 928 929 function renderBotMessage(content, sources, confidence, messageId) { 930 ensureMessagesContainer(); 931 const el = document.createElement('div'); 932 el.className = 'msg bot'; 933 if (messageId) el.dataset.messageId = messageId; 934 el.dataset.text = content || ''; 935 936 el.appendChild(makeAvatar(true)); 937 const col = document.createElement('div'); 938 col.className = 'bot-col'; 939 940 const bubble = document.createElement('div'); 941 bubble.className = 'bot-bubble'; 942 bubble.innerHTML = parseMarkdown(content || ''); 943 if (sources && sources.length && chatbot && chatbot.showSources !== false) { 944 const sourcesEl = buildSourcesEl(sources); 945 if (sourcesEl) bubble.appendChild(sourcesEl); 946 } 947 col.appendChild(bubble); 948 949 const conf = buildConfidenceEl(confidence); 950 if (conf) col.appendChild(conf); 951 952 if (messageId) col.appendChild(buildActionsEl(messageId, content || '')); 953 954 el.appendChild(col); 955 body.appendChild(el); 956 body.scrollTop = body.scrollHeight; 957 messageCount++; 958 return { el, bubble, col }; 959 } 960 961 function showThinking() { 962 ensureMessagesContainer(); 963 const el = document.createElement('div'); 964 el.className = 'thinking'; 965 el.id = 'thinking'; 966 const av = makeAvatar(true); 967 av.classList.add('thinking'); 968 el.appendChild(av); 969 const bubble = document.createElement('div'); 970 bubble.className = 'thinking-bubble'; 971 bubble.innerHTML = ` 972 <div class="thinking-row">${ICONS.spinner}<span>Searching knowledge base</span></div> 973 <div class="thinking-dots"><span></span><span></span><span></span></div> 974 `; 975 el.appendChild(bubble); 976 body.appendChild(el); 977 body.scrollTop = body.scrollHeight; 978 } 979 function hideThinking() { 980 const t = $('thinking'); 981 if (t) t.remove(); 982 } 983 984 // ââ Streaming âââââââââââââââââââââââââââââââââââââââââââââââââââ 985 function addStreamingBubble() { 986 ensureMessagesContainer(); 987 const el = document.createElement('div'); 988 el.className = 'msg bot'; 989 el.id = 'streaming'; 990 el.appendChild(makeAvatar(true)); 991 const col = document.createElement('div'); 992 col.className = 'bot-col'; 993 const bubble = document.createElement('div'); 994 bubble.className = 'bot-bubble streaming-cursor'; 995 col.appendChild(bubble); 996 el.appendChild(col); 997 body.appendChild(el); 998 body.scrollTop = body.scrollHeight; 999 return { el, bubble, col }; 1000 } 1001 function finalizeStreaming(buffer, sources, confidence, messageId) { 1002 const el = $('streaming'); 1003 if (!el) return; 1004 el.removeAttribute('id'); 1005 if (messageId) el.dataset.messageId = messageId; 1006 el.dataset.text = buffer || ''; 1007 1008 const bubble = el.querySelector('.bot-bubble'); 1009 bubble.classList.remove('streaming-cursor'); 1010 bubble.innerHTML = parseMarkdown(buffer || ''); 1011 if (sources && sources.length && chatbot && chatbot.showSources !== false) { 1012 const sourcesEl = buildSourcesEl(sources); 1013 if (sourcesEl) bubble.appendChild(sourcesEl); 1014 } 1015 1016 const col = el.querySelector('.bot-col'); 1017 const conf = buildConfidenceEl(confidence); 1018 if (conf) col.appendChild(conf); 1019 if (messageId) col.appendChild(buildActionsEl(messageId, buffer || '')); 1020 1021 saveStored(); 1022 body.scrollTop = body.scrollHeight; 1023 messageCount++; 1024 } 1025 1026 // ââ Actions (reactions + copy) ââââââââââââââââââââââââââââââââââ 1027 async function handleAction(btn, messageId, text, actionsRow) { 1028 const r = btn.getAttribute('data-r'); 1029 const feedback = actionsRow.querySelector('.feedback'); 1030 1031 if (r === 'copy') {
1032 try { 1033 await navigator.clipboard.writeText(text); 1034 if (feedback) { 1035 feedback.textContent = 'Copied'; 1036 feedback.classList.add('visible'); 1037 setTimeout(() => feedback.classList.remove('visible'), 1800); 1038 } 1039 } catch {} 1040 return; 1041 } 1042 1043 actionsRow.querySelectorAll('.action-btn[data-r="up"], .action-btn[data-r="down"]').forEach(b => b.classList.remove('active')); 1044 btn.classList.add('active'); 1045 if (feedback) { 1046 feedback.textContent = r === 'up' ? 'Thanks!' : 'Thanks for the feedback'; 1047 feedback.classList.add('visible'); 1048 setTimeout(() => feedback.classList.remove('visible'), 2400); 1049 } 1050 1051 if (!messageId) return; 1052 try { 1053 await fetch(baseUrl + '/api/chat/reaction', { 1054 method: 'POST', 1055 headers: { 'Content-Type': 'application/json' }, 1056 body: JSON.stringify({ messageId, reaction: r, sessionId }), 1057 }); 1058 } catch (err) { 1059 console.error('Failed to save reaction:', err); 1060 } 1061 } 1062 1063 // ââ Send ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1064 async function sendMessage(text) { 1065 if (!text || !text.trim() || isLoading || !chatbot) return; 1066 isLoading = true; 1067 input.disabled = true; 1068 sendBtn.disabled = true; 1069 sendBtn.classList.remove('active'); 1070 1071 renderUserMessage(text); 1072 saveStored(); 1073 showThinking(); 1074 1075 let streamingState = null; 1076 let buffer = ''; 1077 let metadata = null; 1078 1079 try { 1080 const res = await fetch(baseUrl + '/api/chat/stream', { 1081 method: 'POST', 1082 headers: { 'Content-Type': 'application/json' }, 1083 body: JSON.stringify({ chatbotId, sessionId, message: text }), 1084 }); 1085 hideThinking(); 1086 1087 if (!res.ok || !res.body) { 1088 const fb = await fetch(baseUrl + '/api/chat', { 1089 method: 'POST', 1090 headers: { 'Content-Type': 'application/json' }, 1091 body: JSON.stringify({ chatbotId, sessionId, message: text }), 1092 }); 1093 if (!fb.ok) throw new Error('Failed to send message'); 1094 const data = await fb.json(); 1095 renderBotMessage(data.message.content, data.message.sources, data.message.confidence, data.message.id); 1096 saveStored(); 1097 return; 1098 } 1099 1100 // The server emits events in this order: metadata â content à N â [DONE] â messageId. 1101 // We defer finalization until the stream actually closes so messageId is captured. 1102 const reader = res.body.getReader(); 1103 const decoder = new TextDecoder(); 1104 let raw = ''; 1105 while (true) { 1106 const { done, value } = await reader.read(); 1107 if (done) break; 1108 raw += decoder.decode(value, { stream: true }); 1109 const lines = raw.split('\n'); 1110 raw = lines.pop() || ''; 1111 for (const line of lines) { 1112 if (!line.startsWith('data: ')) continue; 1113 const payload = line.slice(6); 1114 if (payload === '[DONE]') continue; 1115 try { 1116 const parsed = JSON.parse(payload); 1117 if (parsed.type === 'metadata') { 1118 metadata = parsed; 1119 } else if (parsed.messageId) { 1120 metadata = metadata || {}; 1121 metadata.messageId = parsed.messageId; 1122 } else if (parsed.content) { 1123 if (!streamingState) streamingState = addStreamingBubble(); 1124 buffer += parsed.content; 1125 streamingState.bubble.textContent = buffer; 1126 body.scrollTop = body.scrollHeight; 1127 } 1128 } catch {} 1129 } 1130 } 1131 if (streamingState) { 1132 finalizeStreaming(buffer, metadata && metadata.sources, metadata && metadata.confidence, metadata && metadata.messageId); 1133 } 1134 } catch (err) { 1135 hideThinking(); 1136 const orphan = $('streaming'); 1137 if (orphan) orphan.remove(); 1138 renderBotMessage('Sorry, something went wrong. Please try again.', null, null, null); 1139 } finally { 1140 isLoading = false; 1141 input.disabled = false; 1142 try { input.focus(); } catch {} 1143 updateSendState(); 1144 } 1145 } 1146 1147 // ââ Input handling ââââââââââââââââââââââââââââââââââââââââââââââ 1148 function autoGrow() { 1149 input.style.height = 'auto'; 1150 input.style.height = Math.min(input.scrollHeight, 120) + 'px'; 1151 } 1152 function updateSendState() { 1153 const v = input.value.trim(); 1154 if (v && !isLoading) { 1155 sendBtn.disabled = false;
1156 sendBtn.classList.add('active'); 1157 } else { 1158 sendBtn.disabled = true; 1159 sendBtn.classList.remove('active'); 1160 } 1161 } 1162 function submit() { 1163 const v = input.value.trim(); 1164 if (!v) return; 1165 sendMessage(v); 1166 input.value = ''; 1167 autoGrow(); 1168 updateSendState(); 1169 } 1170 1171 // ââ Event wiring ââââââââââââââââââââââââââââââââââââââââââââââââ 1172 launcher.addEventListener('click', () => setMode('widget')); 1173 btnClose.addEventListener('click', () => setMode('launcher')); 1174 btnExpand.addEventListener('click', () => setMode(mode === 'focus' ? 'widget' : 'focus')); 1175 btnNew.addEventListener('click', () => { 1176 if (messageCount === 0 || confirm('Start a new conversation?')) { 1177 clearStored(); 1178 messageCount = 0; 1179 renderEmptyState(); 1180 } 1181 }); 1182 input.addEventListener('input', () => { autoGrow(); updateSendState(); }); 1183 input.addEventListener('focus', () => composer.classList.add('focused')); 1184 input.addEventListener('blur', () => composer.classList.remove('focused')); 1185 input.addEventListener('keydown', e => { 1186 if (e.key === 'Enter' && !e.shiftKey) { 1187 e.preventDefault(); 1188 submit(); 1189 } 1190 }); 1191 sendBtn.addEventListener('click', submit); 1192 micBtn.addEventListener('click', () => Voice.start()); 1193 voiceEnd.addEventListener('click', () => Voice.stop('Call ended')); 1194 1195 // ââ Voice (Realtime WebRTC) âââââââââââââââââââââââââââââââââââââ 1196 const REALTIME_CALLS_URL = 'https://api.openai.com/v1/realtime/calls'; 1197 const VOICE_HEARTBEAT_MS = 15000; 1198 1199 const Voice = (function () { 1200 let active = false; 1201 let pc = null, dc = null, micStream = null, audioEl = null; 1202 let sessionId = null, maxSeconds = 300; 1203 let startTs = 0, tickTimer = null, heartbeatTimer = null; 1204 let botLine = null; // active streaming bot transcript element 1205 const handledCalls = new Set(); // dedup function_call ids 1206 1207 function elapsed() { return Math.floor((Date.now() - startTs) / 1000); } 1208 1209 function setState(s, status, sub) { 1210 voiceOverlay.classList.remove('connecting', 'listening', 'speaking'); 1211 if (s) voiceOverlay.classList.add(s); 1212 if (status != null) voiceStatus.textContent = status; 1213 if (sub != null) voiceSub.textContent = sub; 1214 } 1215 1216 function addTranscript(role, text) { 1217 if (!text) return null; 1218 const line = document.createElement('div'); 1219 line.className = 'vt-line ' + role; 1220 line.textContent = text; 1221 voiceTranscript.appendChild(line); 1222 voiceTranscript.scrollTop = voiceTranscript.scrollHeight; 1223 return line; 1224 } 1225 1226 function fmt(sec) { 1227 const m = Math.floor(sec / 60), s = sec % 60; 1228 return m + ':' + (s < 10 ? '0' : '') + s; 1229 } 1230 1231 function tick() { 1232 const left = Math.max(0, maxSeconds - elapsed()); 1233 voiceTimer.textContent = fmt(left); 1234 voiceTimer.classList.toggle('warn', left <= 30); 1235 if (left <= 0) stop('Time limit reached'); 1236 } 1237 1238 async function heartbeat(final) { 1239 if (!sessionId) return; 1240 try { 1241 const res = await fetch(baseUrl + '/api/voice/heartbeat', { 1242 method: 'POST', headers: { 'Content-Type': 'application/json' }, 1243 body: JSON.stringify({ sessionId, elapsedSeconds: startTs ? elapsed() : 0, final: !!final }), 1244 }); 1245 const data = await res.json().catch(() => ({})); 1246 if (data && data.stop && !final) stop('Voice limit reached'); 1247 } catch {} 1248 } 1249 1250 // Resolve a tool call from the model and feed the result back into the session. 1251 async function handleToolCall(item) { 1252 if (!item || item.type !== 'function_call' || handledCalls.has(item.call_id)) return; 1253 handledCalls.add(item.call_id); 1254 let args = {}; 1255 try { args = JSON.parse(item.arguments || '{}'); } catch {} 1256 let output = ''; 1257 try { 1258 if (item.name === 'search_knowledge') { 1259 const res = await fetch(baseUrl + '/api/voice/retrieve', { 1260 method: 'POST', headers: { 'Content-Type': 'application/json' }, 1261 body: JSON.stringify({ sessionId, query: args.query || '' }), 1262 }); 1263 const data = await res.json().catch(() => ({})); 1264 output = (data && data.context) || 'No relevant information was found in the knowledge base.'; 1265 } else if (item.name === 'capture_lead') { 1266 const res = await fetch(baseUrl + '/api/voice/lead', { 1267 method: 'POST', headers: { 'Content-Type': 'application/json' }, 1268 body: JSON.stringify({ sessionId, name: args.name, email: args.email, phone: args.p
1268hone, note: args.note }), 1269 }); 1270 const data = await res.json().catch(() => ({})); 1271 output = JSON.stringify({ saved: !!(data && data.ok) }); 1272 } else { 1273 output = 'Unknown tool.'; 1274 } 1275 } catch { 1276 output = item.name === 'capture_lead' ? JSON.stringify({ saved: false }) : ''; 1277 } 1278 send({ type: 'conversation.item.create', item: { type: 'function_call_output', call_id: item.call_id, output: String(output) } }); 1279 send({ type: 'response.create' }); 1280 } 1281 1282 function send(obj) { try { if (dc && dc.readyState === 'open') dc.send(JSON.stringify(obj)); } catch {} } 1283 1284 // Handle a server event off the data channel. Event names have drifted across 1285 // Realtime versions, so we match on type suffixes where practical. 1286 function onEvent(ev) { 1287 const t = ev.type || ''; 1288 if (t === 'input_audio_buffer.speech_started') { 1289 setState('listening', 'Listeningâ¦', 'Speak naturally â I can hear you'); 1290 } else if (t.indexOf('response.created') === 0) { 1291 setState('speaking', 'Speakingâ¦', ''); 1292 } else if (t === 'conversation.item.input_audio_transcription.completed') { 1293 addTranscript('user', (ev.transcript || '').trim()); 1294 } else if (/audio_transcript\.delta$/.test(t) || t === 'response.output_text.delta') { 1295 const delta = ev.delta || ''; 1296 if (!botLine) botLine = addTranscript('bot', delta); 1297 else { botLine.textContent += delta; voiceTranscript.scrollTop = voiceTranscript.scrollHeight; } 1298 } else if (/audio_transcript\.done$/.test(t) || t === 'response.output_text.done') { 1299 if (botLine && ev.transcript) botLine.textContent = ev.transcript; 1300 botLine = null; 1301 } else if (t === 'response.done') { 1302 const out = (ev.response && ev.response.output) || []; 1303 let hadCall = false; 1304 out.forEach(function (item) { if (item && item.type === 'function_call') { hadCall = true; handleToolCall(item); } }); 1305 if (!hadCall) setState('listening', 'Listeningâ¦', 'Ask me anything'); 1306 } else if (t === 'error') { 1307 // Non-fatal model error â keep the call alive. 1308 setState('listening', 'Listeningâ¦', ''); 1309 } 1310 } 1311 1312 async function start() { 1313 if (active || !chatbot) return; 1314 active = true; 1315 handledCalls.clear(); 1316 botLine = null; 1317 voiceTranscript.innerHTML = ''; 1318 voiceOverlay.classList.add('open'); 1319 setState('connecting', 'Connectingâ¦', 'Setting up a secure connection'); 1320 1321 // 1. Microphone
1322 try { 1323 micStream = await navigator.mediaDevices.getUserMedia({ audio: true }); 1324 } catch { 1325 endCall('Microphone blocked', 'Allow mic access to use voice, or keep typing.', 2600); return; 1326 } 1327 1328 // 2. Ephemeral token + session 1329 let cfg; 1330 try { 1331 const res = await fetch(baseUrl + '/api/voice/session', { 1332 method: 'POST', headers: { 'Content-Type': 'application/json' }, 1333 body: JSON.stringify({ chatbotId }), 1334 }); 1335 cfg = await res.json().catch(() => ({})); 1336 if (!res.ok || !cfg.token) { 1337 endCall('Voice unavailable', cfg.error || 'Please try again later.', 2600); return; 1338 } 1339 } catch { 1340 endCall('Voice unavailable', 'Please try again later.', 2600); return; 1341 } 1342 sessionId = cfg.sessionId; 1343 maxSeconds = cfg.maxSessionSeconds || 300; 1344 1345 // 3. WebRTC 1346 try { 1347 pc = new RTCPeerConnection(); 1348 audioEl = document.createElement('audio'); 1349 audioEl.autoplay = true; 1350 shadow.appendChild(audioEl); 1351 pc.ontrack = function (e) { audioEl.srcObject = e.streams[0]; }; 1352 micStream.getTracks().forEach(function (tr) { pc.addTrack(tr, micStream); }); 1353 1354 dc = pc.createDataChannel('oai-events'); 1355 dc.addEventListener('open', function () { 1356 setState('listening', 'Listeningâ¦', 'Speak naturally â I can hear you'); 1357 startTs = Date.now(); 1358 tick(); 1359 tickTimer = setInterval(tick, 1000); 1360 heartbeatTimer = setInterval(function () { heartbeat(false); }, VOICE_HEARTBEAT_MS); 1361 // Speak first only when the tenant opted in; the greeting text itself lives in the 1362 // server-side session instructions (never sent to the browser). Otherwise we wait 1363 // for the visitor to speak. 1364 if (cfg.speakGreeting) { send({ type: 'response.create' }); } 1365 }); 1366 dc.addEventListener('message', function (e) { try { onEvent(JSON.parse(e.data)); } catch {} }); 1367 dc.addEventListener('close', function () { endCall('Call ended', '', 800); }); 1368 1369 const offer = await pc.createOffer(); 1370 await pc.setLocalDescription(offer); 1371 const sdpRes = await fetch(REALTIME_CALLS_URL, { 1372 method: 'POST', body: offer.sdp, 1373 headers: { Authorization: 'Bearer ' + cfg.token, 'Content-Type': 'application/sdp' }, 1374 }); 1375 if (!sdpRes.ok) throw new Error('SDP exchange failed'); 1376 const answer = { type: 'answer', sdp: await sdpRes.text() }; 1377 await pc.setRemoteDescription(answer); 1378 } catch { 1379 endCall('Connection failed', 'Please try again.', 2600); 1380 } 1381 } 1382 1383 function cleanup() { 1384 if (tickTimer) { clearInterval(tickTimer); tickTimer = null; } 1385 if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } 1386 try { if (dc) dc.close(); } catch {} 1387 try { if (pc) pc.close(); } catch {} 1388 try { if (micStream) micStream.getTracks().forEach(function (t) { t.stop(); }); } catch {} 1389 try { if (audioEl) { audioEl.srcObject = null; audioEl.remove(); } } catch {} 1390 dc = null; pc = null; micStream = null; audioEl = null; 1391 } 1392 1393 function close() { voiceOverlay.classList.remove('open'); } 1394 1395 // Single termination path for every end/failure case. Resets `active` FIRST so 1396 // (a) the user can start a new call afterwards and (b) the dc.close listener â which 1397 // cleanup() triggers â sees active=false and doesn't re-enter and clobber the message. 1398 // A final heartbeat (when a session was minted) marks it ended server-side, freeing 1399 // the tenant's concurrent-session slot even on a failed connection. 1400 async function endCall(status, sub, delay) { 1401 if (!active) return; 1402 active = false; 1403 setState('', status || 'Call ended', sub || ''); 1404 if (sessionId) { try { await heartbeat(true); } catch {} } 1405 cleanup(); 1406 sessionId = null; 1407 setTimeout(close, delay || 800); 1408 } 1409 1410 function stop(reason) { endCall(reason || 'Call ended', '', 800); } 1411 1412 return { start: start, stop: stop, isActive: function () { return active; } }; 1413 })(); 1414 1415 // ââ Theme âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1416 function applyTheme(primary, secondary) { 1417 const accent = secondary || primary; 1418 const lc1 = primary; 1419 const lc2 = shade(primary, -12); 1420 1421 root.style.setProperty('--accent', accent); 1422 root.style.setProperty('--accent-soft', rgba(accent, 0.10)); 1423 root.style.setProperty('--accent-faint', rgba(accent, 0.08)); 1424 root.style.setProperty('--accent-line', rgba(accent, 0.40)); 1425 root.style.setProperty('--accent-shade-pos', shade(accent, 8)); 1426 root.style.setProperty('--accent-shade-pos2', shade(accent, 25)); 1427 root.style.setProperty('--accent-shade-neg', shade(accent, -10)); 1428 root.style.setProperty('--launcher-c1', lc1); 1429 root.style.setProperty('--launcher-c2', lc2); 1430 root.style.setProperty('--shadow-glow', rgba(lc1, 0.50)); 1431 root.style.setProperty('--shadow-soft', rgba(lc1, 0.30)); 1432 } 1433 1434 // ââ Init ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1435 async function loadChatbot() { 1436 try { 1437 const res = await fetch(baseUrl + '/api/widget/' + chatbotId); 1438 if (!res.ok) throw new Error('Failed to load chatbot'); 1439 const data = await res.json(); 1440 chatbot = data.chatbot; 1441 1442 const primary = chatbot.primaryColor || '#3B82F6'; 1443 const secondary = chatbot.secondaryColor || primary; 1444 applyTheme(primary, secondary); 1445 1446 launcher.innerHTML = getLauncherIconHTML(chatbot); 1447 launcher.classList.add('ready'); 1448 $('header-avatar').innerHTML = getAvatarIconHTML(chatbot); 1449 1450 headerName.textContent = chatbot.name || 'Chat'; 1451 const lang = (navigator.language || 'en').split('-')[0].toUpperCase(); 1452 headerLang.textContent = lang; 1453 const promptName = (chatbot.name || '').replace(/^ask\s+/i, '').trim() || 'anything'; 1454 input.placeholder = 'Ask ' + promptName + 'â¦'; 1455 1456 const desc = (chatbot.description || '').trim(); 1457 if (desc) $('header-status-text').textContent = desc; 1458 1459 if (chatbot.showBranding === false) branding.style.display = 'none'; 1460 1461 // Voice is only offered when the bot has it enabled (Business plan). 1462 if (chatbot.voiceEnabled) micBtn.classList.add('show'); 1463 1464 const stored = loadStored(); 1465 if (stored && Array.isArray(stored.messages) && stored.messages.length) { 1466 body.classList.remove('empty'); 1467 body.innerHTML = '';
1468 stored.messages.forEach(m => { 1469 if (m.role === 'user') renderUserMessage(m.content); 1470 else if (m.role === 'assistant') renderBotMessage(m.content, null, null, null); 1471 }); 1472 } else { 1473 renderEmptyState(); 1474 } 1475 } catch (err) { 1476 console.error('ChatAziendale.it widget: Failed to load', err); 1477 launcher.style.display = 'none'; 1478 } 1479 } 1480 1481 loadChatbot(); 1482})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.