PageSourceSearch

https://chatniuexa.onrender.com/widget.js

js chatniuexa.onrender.com collected 2026-10-03 11:39:49 UTC 72,508 bytes, 1,482 lines download raw bytes

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.