@ourlu/assistant-sdk 0.2.0 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,721 @@
1
+ (function() {
2
+ "use strict";
3
+ var runtime = window.__OurluWidgetRuntimeV1 || (window.__OurluWidgetRuntimeV1 = {});
4
+ if (!runtime.utils || !runtime.WidgetUIManager || !runtime.WidgetAudioManager || !runtime.EventBus) {
5
+ throw new Error("Widget runtime UI module must be loaded first.");
6
+ }
7
+
8
+ var WIDGET_VERSION = "v1", WIDGET_NS = "OurluMairieWidget";
9
+ var trimTrailingSlash = runtime.utils.trimTrailingSlash;
10
+ var WidgetUIManager = runtime.WidgetUIManager, WidgetAudioManager = runtime.WidgetAudioManager;
11
+ var EventBus = runtime.EventBus, CONTAINER_ID = runtime.constants.CONTAINER_ID;
12
+ var MASCOT_SPECS = JSON.parse('[["mascotSecondaryColor","mascot_secondary_color","data-mascot-secondary-color","#58878d"],["mascotSecondaryDarkColor","mascot_secondary_dark_color","data-mascot-secondary-dark-color","#355c62"],["mascotGoldColor","mascot_gold_color","data-mascot-gold-color","#f4c934"],["mascotEyeColor","mascot_eye_color","data-mascot-eye-color","#040402"],["mascotBeakColor","mascot_beak_color","data-mascot-beak-color","#ab6d46"],["mascotNeutralColor","mascot_neutral_color","data-mascot-neutral-color","#e6e6e6"]]');
13
+ var CONFIG_STRING_ATTRS = JSON.parse('[["tenantId","data-tenant-id",""],["widgetKey","data-widget-key",""],["apiBaseUrl","data-api-base-url",""],["widgetOrigin","data-widget-origin",""],["bootstrapEndpoint","data-bootstrap-endpoint",""],["configEndpoint","data-config-endpoint",""],["sessionBootstrapToken","data-session-bootstrap-token",""],["sessionBootstrapTokenExpiresAt","data-session-bootstrap-token-expires-at",""],["chatSessionEndpoint","data-chat-session-endpoint","/v1/occe/chat/sessions"],["chatMessageTemplate","data-chat-message-template","/v1/occe/chat/sessions/{session_id}/messages"],["chatStreamTemplate","data-chat-stream-template","/v1/occe/chat/sessions/{session_id}/stream"],["chatAudioSessionTemplate","data-chat-audio-session-template","/v1/occe/chat/sessions/{session_id}/audio/session"],["chatAudioChunkTemplate","data-chat-audio-chunk-template","/v1/occe/chat/sessions/{session_id}/audio/chunk"],["chatAudioCloseTemplate","data-chat-audio-close-template","/v1/occe/chat/sessions/{session_id}/audio/close"],["chatAudioStreamTemplate","data-chat-audio-stream-template","/v1/occe/chat/sessions/{session_id}/audio/stream"],["panelWidth","data-panel-width","420"],["panelHeight","data-panel-height","640"],["panelMaxHeightVh","data-panel-max-height-vh","85"],["mascotUrl","data-mascot-url",""],["welcomeMessage","data-welcome-message","Bonjour ! Comment puis-je vous aider ?"],["disclaimerText","data-disclaimer-text","Assistant IA - Les reponses ne constituent pas une decision administrative officielle."],["transparencyText","data-transparency-text","Reponses generees par intelligence artificielle."]]');
14
+ var RUNTIME_ENDPOINT_SPECS = JSON.parse('[["chatSessionEndpoint","chat_session_endpoint","/v1/occe/chat/sessions"],["chatMessageTemplate","chat_message_template","/v1/occe/chat/sessions/{session_id}/messages"],["chatStreamTemplate","chat_stream_template","/v1/occe/chat/sessions/{session_id}/stream"],["chatAudioSessionTemplate","chat_audio_session_template","/v1/occe/chat/sessions/{session_id}/audio/session"],["chatAudioChunkTemplate","chat_audio_chunk_template","/v1/occe/chat/sessions/{session_id}/audio/chunk"],["chatAudioCloseTemplate","chat_audio_close_template","/v1/occe/chat/sessions/{session_id}/audio/close"],["chatAudioStreamTemplate","chat_audio_stream_template","/v1/occe/chat/sessions/{session_id}/audio/stream"]]');
15
+ function applyMascotLabelColors(config, labels) {
16
+ MASCOT_SPECS.forEach(function(spec) {
17
+ config[spec[0]] = normalizeHexColor(labels[spec[1]], config[spec[0]] || spec[3]);
18
+ });
19
+ }
20
+ function applyScriptMascotColors(config, scriptTag) {
21
+ MASCOT_SPECS.forEach(function(spec) {
22
+ config[spec[0]] = readScriptHexAttr(scriptTag, spec[2], spec[3]);
23
+ });
24
+ }
25
+ function readScriptAttr(scriptTag, attributeName, fallbackValue) {
26
+ var value = scriptTag.getAttribute(attributeName);
27
+ return value == null || value === "" ? fallbackValue : value;
28
+ }
29
+ function readScriptHexAttr(scriptTag, attributeName, fallbackColor) {
30
+ return normalizeHexColor(readScriptAttr(scriptTag, attributeName, ""), fallbackColor);
31
+ }
32
+ function assignRuntimeEndpoints(config, runtimeConfig) {
33
+ RUNTIME_ENDPOINT_SPECS.forEach(function(spec) {
34
+ config[spec[0]] = String(runtimeConfig[spec[1]] || config[spec[0]] || spec[2]).trim();
35
+ });
36
+ }
37
+ function resolveAbsoluteMascotUrl(mascotUrl, apiBaseUrl) {
38
+ if (runtime.utils && typeof runtime.utils.resolveAbsoluteMascotUrl === "function") {
39
+ return runtime.utils.resolveAbsoluteMascotUrl(mascotUrl, apiBaseUrl);
40
+ }
41
+ var normalized = String(mascotUrl || "").trim();
42
+ if (!normalized) return "";
43
+ if (/^https?:\/\//i.test(normalized)) return normalized;
44
+ var base = String(apiBaseUrl || "").trim().replace(/\/$/, "");
45
+ if (!base) {
46
+ throw new Error("[OurluMairie] mascot URL relative sans apiBaseUrl: " + normalized);
47
+ }
48
+ return normalized.startsWith("/") ? base + normalized : base + "/" + normalized;
49
+ }
50
+ function normalizeHexColor(rawValue, fallbackColor) {
51
+ var normalized = String(rawValue || "").trim().toLowerCase();
52
+ return /^#[0-9a-f]{6}$/.test(normalized) ? normalized : fallbackColor;
53
+ }
54
+ function normalizePosition(rawValue, fallbackValue) {
55
+ var normalized = String(rawValue || "").trim().toLowerCase();
56
+ return normalized === "bottom-left" || normalized === "bottom-right" ? normalized : fallbackValue;
57
+ }
58
+ function normalizeThemeVersion(rawValue) {
59
+ var parsed = Number(rawValue);
60
+ return !Number.isFinite(parsed) || parsed <= 0 ? 0 : Math.floor(parsed);
61
+ }
62
+ function normalizeThemeModeStrategy(rawValue) {
63
+ var normalized = String(rawValue || "").trim().toLowerCase();
64
+ return normalized === "manual_dark" || normalized === "auto_follow_host" || normalized === "manual_light" ? normalized : "manual_light";
65
+ }
66
+ function resolveHostPrefersDark(fallbackValue) {
67
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return Boolean(fallbackValue);
68
+ return Boolean(window.matchMedia("(prefers-color-scheme: dark)").matches);
69
+ }
70
+ function resolvePreferredThemeScheme(config) {
71
+ var mode = normalizeThemeModeStrategy((config || {}).themeModeStrategy);
72
+ if (mode === "manual_dark") return "dark";
73
+ if (mode === "auto_follow_host") return resolveHostPrefersDark((config || {}).hostPrefersDark) ? "dark" : "light";
74
+ return "light";
75
+ }
76
+ function clampNumericString(rawValue, fallbackValue, minValue, maxValue, shouldRound) {
77
+ var parsed = Number(rawValue);
78
+ if (!Number.isFinite(parsed)) return fallbackValue;
79
+ if (parsed < minValue) return String(minValue);
80
+ if (parsed > maxValue) return String(maxValue);
81
+ return shouldRound ? String(Math.round(parsed)) : String(parsed);
82
+ }
83
+ function decodeBase64UrlPayload(rawValue) {
84
+ var encoded = String(rawValue || "").trim();
85
+ if (!encoded) return "";
86
+ var base64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
87
+ while (base64.length % 4 !== 0) base64 += "=";
88
+ var binary = atob(base64);
89
+ var percentEncoded = "";
90
+ for (var index = 0; index < binary.length; index += 1) {
91
+ percentEncoded += "%" + ("00" + binary.charCodeAt(index).toString(16)).slice(-2);
92
+ }
93
+ return decodeURIComponent(percentEncoded);
94
+ }
95
+ function parseThemeSnapshot(rawValue) {
96
+ try {
97
+ var decodedPayload = decodeBase64UrlPayload(rawValue);
98
+ if (!decodedPayload) return null;
99
+ var parsedPayload = JSON.parse(decodedPayload);
100
+ return parsedPayload && typeof parsedPayload === "object" ? parsedPayload : null;
101
+ } catch (_) {
102
+ return null;
103
+ }
104
+ }
105
+ function applyThemeConfigToRuntime(config, rawWidgetConfig) {
106
+ if (!rawWidgetConfig || typeof rawWidgetConfig !== "object") return false;
107
+ var labels = rawWidgetConfig.labels && typeof rawWidgetConfig.labels === "object" ? rawWidgetConfig.labels : {};
108
+ config.themeSplitEnabled = Boolean(rawWidgetConfig.theme_split_enabled);
109
+ config.themeModeStrategy = normalizeThemeModeStrategy(rawWidgetConfig.theme_mode_strategy || labels.theme_mode_strategy || config.themeModeStrategy);
110
+ config.hostPrefersDark = resolveHostPrefersDark(config.hostPrefersDark);
111
+ config.effectiveThemeScheme = resolvePreferredThemeScheme(config);
112
+ var resolvedThemeConfig = rawWidgetConfig;
113
+ if (config.themeSplitEnabled) {
114
+ var splitConfig = config.effectiveThemeScheme === "dark" ? rawWidgetConfig.dark_theme_defaults : rawWidgetConfig.light_theme_defaults;
115
+ if (splitConfig && typeof splitConfig === "object") {
116
+ var splitLabels = splitConfig.labels && typeof splitConfig.labels === "object" ? splitConfig.labels : {};
117
+ labels = Object.assign({}, labels, splitLabels);
118
+ resolvedThemeConfig = { primary_color: splitConfig.primary_color || rawWidgetConfig.primary_color, position: splitConfig.position || rawWidgetConfig.position, labels: labels };
119
+ }
120
+ }
121
+ config.primaryColor = normalizeHexColor(resolvedThemeConfig.primary_color, config.primaryColor || "#0066ff");
122
+ config.position = normalizePosition(resolvedThemeConfig.position, config.position || "bottom-right");
123
+ var themedAssistantName = String(labels.title || labels.assistant_name || "").trim();
124
+ if (themedAssistantName) {
125
+ config.assistantName = themedAssistantName;
126
+ } else if (!String(config.assistantName || "").trim()) {
127
+ config.assistantName = "Assistant mairie";
128
+ }
129
+ config.panelBackgroundColor = normalizeHexColor(labels.panel_background_color, config.panelBackgroundColor || "#ffffff");
130
+ config.panelBackgroundAlpha = clampNumericString(labels.panel_background_alpha, config.panelBackgroundAlpha || "1", 0, 1, false);
131
+ config.panelBorderRadius = clampNumericString(labels.panel_border_radius, config.panelBorderRadius || "16", 0, 32, true);
132
+ applyMascotLabelColors(config, labels);
133
+ var candidateVersion = normalizeThemeVersion(rawWidgetConfig.theme_updated_at);
134
+ if (candidateVersion > 0) config.themeVersion = candidateVersion;
135
+ return true;
136
+ }
137
+ function applyWidgetTextConfig(config, widgetConfig) {
138
+ if (!widgetConfig || typeof widgetConfig !== "object") return;
139
+ var welcomeMessage = String(widgetConfig.welcome_message || "").trim();
140
+ if (welcomeMessage) config.welcomeMessage = welcomeMessage;
141
+ var disclaimerText = String(widgetConfig.disclaimer_text || "").trim();
142
+ if (disclaimerText) config.disclaimerText = disclaimerText;
143
+ var mascotUrl = String(widgetConfig.mascot_asset_svg_url || "").trim();
144
+ if (mascotUrl) config.mascotUrl = resolveAbsoluteMascotUrl(mascotUrl, config.apiBaseUrl);
145
+ var chatbotName = String(widgetConfig.chatbot_name || "").trim();
146
+ if (chatbotName) config.assistantName = chatbotName;
147
+ }
148
+ var TURNSTILE_SCRIPT_URL = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
149
+ var TURNSTILE_CONTAINER_ID = "__ourlu_turnstile_container";
150
+ var TURNSTILE_LOAD_TIMEOUT_MS = 10000;
151
+ var TURNSTILE_CHALLENGE_TIMEOUT_MS = 30000;
152
+ var TURNSTILE_TOKEN_TTL_MS = 240000;
153
+ function TurnstileChallengeManager(siteKey) {
154
+ this.siteKey = String(siteKey || "").trim();
155
+ this.scriptLoaded = false;
156
+ this.scriptLoadPromise = null;
157
+ this.widgetId = null;
158
+ this.cachedToken = "";
159
+ this.cachedTokenTimestamp = 0;
160
+ }
161
+ Object.assign(TurnstileChallengeManager.prototype, {
162
+ isEnabled: function() { return Boolean(this.siteKey); },
163
+ loadScript: function() {
164
+ if (this.scriptLoaded) return Promise.resolve();
165
+ if (this.scriptLoadPromise) return this.scriptLoadPromise;
166
+ var self = this;
167
+ this.scriptLoadPromise = new Promise(function(resolve, reject) {
168
+ if (typeof window.turnstile !== "undefined") { self.scriptLoaded = true; return resolve(); }
169
+ var script = document.createElement("script");
170
+ script.src = TURNSTILE_SCRIPT_URL;
171
+ script.async = true;
172
+ var timeoutId = setTimeout(function() {
173
+ reject(new Error("Turnstile: délai de chargement dépassé"));
174
+ }, TURNSTILE_LOAD_TIMEOUT_MS);
175
+ script.onload = function() { clearTimeout(timeoutId); self.scriptLoaded = true; resolve(); };
176
+ script.onerror = function() { clearTimeout(timeoutId); self.scriptLoadPromise = null; reject(new Error("Turnstile: échec du chargement du script")); };
177
+ document.head.appendChild(script);
178
+ });
179
+ return this.scriptLoadPromise;
180
+ },
181
+ ensureContainer: function() {
182
+ var existing = document.getElementById(TURNSTILE_CONTAINER_ID);
183
+ if (existing) return existing;
184
+ var container = document.createElement("div");
185
+ container.id = TURNSTILE_CONTAINER_ID;
186
+ container.style.cssText = "display:none;position:fixed;bottom:90px;right:20px;z-index:2147483646;background:#fff;border-radius:12px;padding:16px;box-shadow:0 4px 24px rgba(0,0,0,0.18);";
187
+ document.body.appendChild(container);
188
+ return container;
189
+ },
190
+ showContainer: function(container) { container.style.display = "block"; },
191
+ hideContainer: function(container) { container.style.display = "none"; },
192
+ isCachedTokenValid: function() {
193
+ return Boolean(this.cachedToken) && (Date.now() - this.cachedTokenTimestamp) < TURNSTILE_TOKEN_TTL_MS;
194
+ },
195
+ execute: async function() {
196
+ if (!this.isEnabled()) return "";
197
+ if (this.isCachedTokenValid()) return this.cachedToken;
198
+ await this.loadScript();
199
+ if (typeof window.turnstile === "undefined") {
200
+ throw new Error("Turnstile: API non disponible après chargement");
201
+ }
202
+ var self = this;
203
+ var token = await new Promise(function(resolve, reject) {
204
+ var container = self.ensureContainer();
205
+ var timeoutId = setTimeout(function() {
206
+ self.hideContainer(container);
207
+ self.cleanup();
208
+ reject(new Error("Turnstile: délai de vérification dépassé"));
209
+ }, TURNSTILE_CHALLENGE_TIMEOUT_MS);
210
+ if (self.widgetId !== null) { window.turnstile.remove(self.widgetId); self.widgetId = null; }
211
+ self.widgetId = window.turnstile.render(container, {
212
+ sitekey: self.siteKey,
213
+ language: "fr",
214
+ appearance: "interaction-only",
215
+ "before-interactive-callback": function() {
216
+ self.showContainer(container);
217
+ },
218
+ callback: function(freshToken) {
219
+ clearTimeout(timeoutId);
220
+ self.hideContainer(container);
221
+ resolve(freshToken);
222
+ },
223
+ "error-callback": function(errorCode) {
224
+ clearTimeout(timeoutId);
225
+ self.hideContainer(container);
226
+ self.cleanup();
227
+ reject(new Error("Turnstile: erreur de vérification (" + errorCode + ")"));
228
+ },
229
+ "timeout-callback": function() {
230
+ clearTimeout(timeoutId);
231
+ self.hideContainer(container);
232
+ self.cleanup();
233
+ reject(new Error("Turnstile: expiration du challenge"));
234
+ }
235
+ });
236
+ });
237
+ this.cachedToken = token;
238
+ this.cachedTokenTimestamp = Date.now();
239
+ return token;
240
+ },
241
+ invalidateCache: function() {
242
+ this.cachedToken = "";
243
+ this.cachedTokenTimestamp = 0;
244
+ },
245
+ cleanup: function() {
246
+ if (this.widgetId !== null && typeof window.turnstile !== "undefined") {
247
+ try { window.turnstile.remove(this.widgetId); } catch (_) {}
248
+ this.widgetId = null;
249
+ }
250
+ },
251
+ reset: function() {
252
+ if (this.widgetId !== null && typeof window.turnstile !== "undefined") {
253
+ try { window.turnstile.reset(this.widgetId); } catch (_) {}
254
+ }
255
+ }
256
+ });
257
+ function WidgetApiManager(config, state, events, uiManager, turnstile) {
258
+ this.config = config;
259
+ this.state = state;
260
+ this.events = events;
261
+ this.uiManager = uiManager;
262
+ this.turnstile = turnstile || null;
263
+ this.prefetchedBootstrapAcked = false;
264
+ }
265
+ Object.assign(WidgetApiManager.prototype, {
266
+ resolveRuntimeUrl: function(pathTemplate, sessionId) {
267
+ var template = String(pathTemplate || "").trim() || "/v1/occe/chat/sessions";
268
+ if (sessionId) template = template.replace("{session_id}", encodeURIComponent(String(sessionId)));
269
+ if (/^https?:\/\//i.test(template)) return template;
270
+ if (template.charAt(0) !== "/") template = "/" + template;
271
+ return this.config.apiBaseUrl + template;
272
+ },
273
+ resolveWidgetOrigin: function() {
274
+ if (typeof window !== "undefined" && window.location && window.location.origin) {
275
+ return String(window.location.origin).trim();
276
+ }
277
+ return String(this.config.widgetOrigin || "").trim();
278
+ },
279
+ applyRuntimeConfig: function(payload, sourceLabel) {
280
+ var runtimeConfig = payload && payload.runtime_config ? payload.runtime_config : {};
281
+ assignRuntimeEndpoints(this.config, runtimeConfig);
282
+ var incomingTurnstileKey = String((payload && payload.turnstile_site_key) || (runtimeConfig && runtimeConfig.turnstile_site_key) || "").trim();
283
+ if (incomingTurnstileKey && !this.config.turnstileSiteKey) {
284
+ this.config.turnstileSiteKey = incomingTurnstileKey;
285
+ if (!this.turnstile) this.turnstile = new TurnstileChallengeManager(incomingTurnstileKey);
286
+ }
287
+ var widgetConfig = payload && payload.widget_config ? payload.widget_config : null;
288
+ if (!widgetConfig) return;
289
+ var currentThemeVersion = normalizeThemeVersion(this.config.themeVersion);
290
+ var incomingThemeVersion = normalizeThemeVersion(widgetConfig.theme_updated_at);
291
+ if (this.config.hasSnapshotTheme && incomingThemeVersion > 0 && incomingThemeVersion < currentThemeVersion) return;
292
+ applyWidgetTextConfig(this.config, widgetConfig);
293
+ applyThemeConfigToRuntime(this.config, widgetConfig);
294
+ this.config.themeVersion = incomingThemeVersion > 0 ? incomingThemeVersion : currentThemeVersion;
295
+ this.config.hasSnapshotTheme = this.config.hasSnapshotTheme && this.config.themeVersion > 0;
296
+ if (this.uiManager && typeof this.uiManager.updateTheme === "function") this.uiManager.updateTheme(this.config);
297
+ this.events.emit("theme_updated", { source: String(sourceLabel || "runtime_config"), theme_version: this.config.themeVersion || 0 });
298
+ },
299
+ buildHeaders: function(extra) {
300
+ var widgetOrigin = this.resolveWidgetOrigin();
301
+ var headers = Object.assign({
302
+ "X-Tenant-ID": this.config.tenantId,
303
+ "X-Widget-Key": String(this.config.widgetKey || "").trim(),
304
+ "X-Target-Language": this.config.targetLanguage || "fr"
305
+ }, extra || {});
306
+ if (widgetOrigin) headers["X-Widget-Origin"] = widgetOrigin;
307
+ if (this.state.token) headers.Authorization = "Bearer " + this.state.token;
308
+ return headers;
309
+ },
310
+ parseErrorMessage: async function(response) {
311
+ var bodyText = "";
312
+ try { bodyText = await response.text(); } catch (_) {}
313
+ if (!bodyText) return "HTTP " + response.status;
314
+ try { var parsed = JSON.parse(bodyText); return parsed.error || parsed.message || ("HTTP " + response.status); } catch (_) { return bodyText.slice(0, 300); }
315
+ },
316
+ resolvePublicConfigEndpoint: function() {
317
+ var endpoint = String(this.config.configEndpoint || "").trim();
318
+ return endpoint || this.config.apiBaseUrl + "/v1/widget/config";
319
+ },
320
+ fetchPublicConfig: async function() {
321
+ var endpoint = this.resolvePublicConfigEndpoint();
322
+ var requestUrl = endpoint + (endpoint.indexOf("?") === -1 ? "?" : "&") + [
323
+ "tenant_id=" + encodeURIComponent(this.config.tenantId || ""),
324
+ "public_widget_key=" + encodeURIComponent(this.config.widgetKey || ""),
325
+ "origin=" + encodeURIComponent(window.location.origin || "")
326
+ ].join("&");
327
+ var response = await fetch(requestUrl, { method: "GET" });
328
+ if (!response.ok) {
329
+ var errorMessage = await this.parseErrorMessage(response);
330
+ var error = new Error(errorMessage);
331
+ error.httpStatus = response.status;
332
+ if (response.status === 403 && this.isWidgetDisabledError(error)) {
333
+ this.events.emit("widget_disabled", { source: "public_config", code: "WIDGET_DISABLED" });
334
+ throw error;
335
+ }
336
+ throw error;
337
+ }
338
+ var payload = await response.json();
339
+ this.applyRuntimeConfig(payload || {}, "public_config");
340
+ this.events.emit("public_config_succeeded", payload || {});
341
+ return payload;
342
+ },
343
+ hydrateTheme: async function() {
344
+ if (this.state.themeHydrationPromise) return this.state.themeHydrationPromise;
345
+ var self = this;
346
+ this.state.themeHydrationPromise = this.fetchPublicConfig().catch(async function(publicConfigError) {
347
+ self.events.emit("public_config_failed", { error: publicConfigError && publicConfigError.message ? publicConfigError.message : "public config failed" });
348
+ try {
349
+ return await self.bootstrap();
350
+ } catch (bootstrapError) {
351
+ self.events.emit("theme_hydration_failed", {
352
+ public_config_error: publicConfigError && publicConfigError.message ? publicConfigError.message : "public config failed",
353
+ bootstrap_error: bootstrapError && bootstrapError.message ? bootstrapError.message : "bootstrap failed"
354
+ });
355
+ throw bootstrapError;
356
+ }
357
+ }).finally(function() { self.state.themeHydrationPromise = null; });
358
+ return this.state.themeHydrationPromise;
359
+ },
360
+ bootstrap: async function() {
361
+ if (this.state.token && this.isBootstrapTokenExpired()) {
362
+ this.state.token = "";
363
+ }
364
+ if (this.state.token) {
365
+ if (!this.prefetchedBootstrapAcked) {
366
+ this.prefetchedBootstrapAcked = true;
367
+ this.events.emit("bootstrap_succeeded", {
368
+ session_bootstrap_token: this.state.token,
369
+ token_expires_at: this.config.sessionBootstrapTokenExpiresAt || "",
370
+ preauthorized: true
371
+ });
372
+ }
373
+ return;
374
+ }
375
+ if (this.state.bootstrapPromise) return this.state.bootstrapPromise;
376
+ var self = this;
377
+ this.state.bootstrapPromise = (async function() {
378
+ var turnstileToken = "";
379
+ if (self.turnstile && self.turnstile.isEnabled()) {
380
+ turnstileToken = await self.turnstile.execute();
381
+ self.events.emit("turnstile_validated", { token_length: turnstileToken.length });
382
+ }
383
+ var bootstrapBody = { tenant_id: self.config.tenantId, public_widget_key: self.config.widgetKey, origin: self.resolveWidgetOrigin() };
384
+ if (turnstileToken) bootstrapBody.cf_turnstile_token = turnstileToken;
385
+ return fetch(self.config.bootstrapEndpoint, {
386
+ method: "POST",
387
+ headers: { "Content-Type": "application/json" },
388
+ body: JSON.stringify(bootstrapBody)
389
+ });
390
+ })().then(async function(response) {
391
+ if (!response.ok) {
392
+ var errorMessage = await self.parseErrorMessage(response);
393
+ var error = new Error(errorMessage);
394
+ error.httpStatus = response.status;
395
+ if (response.status === 403 && self.isWidgetDisabledError(error)) {
396
+ self.events.emit("widget_disabled", { source: "bootstrap", code: "WIDGET_DISABLED" });
397
+ self.state.bootstrapPromise = null;
398
+ throw error;
399
+ }
400
+ throw error;
401
+ }
402
+ var payload = await response.json();
403
+ self.state.token = payload.session_bootstrap_token || payload.session_token || "";
404
+ self.config.sessionBootstrapTokenExpiresAt = String(payload.session_bootstrap_token_expires_at || payload.expires_at || "").trim();
405
+ self.applyRuntimeConfig(payload, "bootstrap");
406
+ self.events.emit("bootstrap_succeeded", payload || {});
407
+ return payload;
408
+ }).catch(function(error) {
409
+ if (self.isWidgetDisabledError(error)) {
410
+ self.events.emit("widget_disabled", { source: "bootstrap", code: "WIDGET_DISABLED" });
411
+ self.state.bootstrapPromise = null;
412
+ throw error;
413
+ }
414
+ self.events.emit("bootstrap_failed", { error: error.message || "bootstrap failed" });
415
+ self.state.bootstrapPromise = null;
416
+ throw error;
417
+ });
418
+ return this.state.bootstrapPromise;
419
+ },
420
+ ensureSession: async function() {
421
+ if (this.state.sessionId) return this.state.sessionId;
422
+ if (this.state.sessionPromise) return this.state.sessionPromise;
423
+ var self = this;
424
+ this.state.sessionPromise = (async function() {
425
+ await self.bootstrap();
426
+ var response = await self.fetchWithBootstrapRetry("create_session", self.config.chatSessionEndpoint, function() {
427
+ return { method: "POST", headers: self.buildHeaders({ "Content-Type": "application/json" }), body: "{}" };
428
+ });
429
+ var payload = await response.json();
430
+ var sessionId = payload.session_id || payload.id || "";
431
+ if (!sessionId) throw new Error("chat session id absent");
432
+ self.state.sessionId = sessionId;
433
+ self.state.sessionPromise = null;
434
+ self.events.emit("ready", payload || {});
435
+ return sessionId;
436
+ })().catch(function(error) { self.state.sessionPromise = null; throw error; });
437
+ return this.state.sessionPromise;
438
+ },
439
+ isBootstrapTokenExpired: function() {
440
+ var rawExpiry = String(this.config.sessionBootstrapTokenExpiresAt || "").trim();
441
+ if (!rawExpiry) return false;
442
+ var expiryMs = Date.parse(rawExpiry);
443
+ if (!Number.isFinite(expiryMs)) return false;
444
+ return Date.now() >= expiryMs;
445
+ },
446
+ isWidgetDisabledError: function(error) {
447
+ var message = String(error && error.message ? error.message : "").toLowerCase();
448
+ return message.indexOf("widget_disabled") !== -1;
449
+ },
450
+ shouldRetryAfterUnauthorized: function(error) {
451
+ if (this.isWidgetDisabledError(error)) return false;
452
+ var status = Number(error && error.httpStatus);
453
+ if (status === 401) return true;
454
+ var message = String(error && error.message ? error.message : "").toLowerCase();
455
+ return message.indexOf("401") !== -1 || message.indexOf("unauthorized") !== -1 ||
456
+ message.indexOf("token expired") !== -1 || message.indexOf("invalid widget token") !== -1 ||
457
+ message.indexOf("token bootstrap invalide") !== -1 || message.indexOf("invalide ou expir") !== -1 ||
458
+ message.indexOf("auth_invalid") !== -1;
459
+ },
460
+ refreshBootstrapToken: async function() {
461
+ this.state.token = "";
462
+ this.state.bootstrapPromise = null;
463
+ this.prefetchedBootstrapAcked = false;
464
+ await this.bootstrap();
465
+ },
466
+ withBootstrapRetry: async function(operationName, operation) {
467
+ try {
468
+ return await operation(0);
469
+ } catch (error) {
470
+ if (!this.shouldRetryAfterUnauthorized(error)) throw error;
471
+ this.events.emit("bootstrap_refresh", { operation: operationName, reason: error && error.message ? error.message : "unauthorized", refresh_attempt: 1 });
472
+ await this.refreshBootstrapToken();
473
+ return operation(1);
474
+ }
475
+ },
476
+ fetchWithBootstrapRetry: async function(operationName, endpointPath, buildOptions, sessionId) {
477
+ var self = this;
478
+ return this.withBootstrapRetry(operationName, async function(refreshAttempt) {
479
+ var options = typeof buildOptions === "function" ? buildOptions() : (buildOptions || {});
480
+ var response = await fetch(self.resolveRuntimeUrl(endpointPath, sessionId), options);
481
+ if (!response.ok) {
482
+ var message = await self.parseErrorMessage(response);
483
+ var error = new Error(refreshAttempt > 0 ? message + " (refresh_attempt=" + refreshAttempt + ")" : message);
484
+ error.httpStatus = response.status;
485
+ throw error;
486
+ }
487
+ return response;
488
+ });
489
+ },
490
+ postMessage: async function(content) {
491
+ var sessionId = await this.ensureSession();
492
+ var self = this;
493
+ await this.fetchWithBootstrapRetry("post_message", this.config.chatMessageTemplate, function() {
494
+ return { method: "POST", headers: self.buildHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ content: content }) };
495
+ }, sessionId);
496
+ return sessionId;
497
+ },
498
+ consumeSSE: function(reader, callbacks, allowedEvents) {
499
+ var decoder = new TextDecoder();
500
+ var pending = "";
501
+ var currentEvent = "";
502
+ var allowed = {};
503
+ allowedEvents.forEach(function(name) { allowed[name] = true; });
504
+ function dispatch(eventName, payload) {
505
+ if (!allowed[eventName]) return;
506
+ if (eventName === "token" && callbacks.onToken) callbacks.onToken(payload.value || "");
507
+ if (eventName === "final" && callbacks.onFinal) callbacks.onFinal(payload);
508
+ if (eventName === "done" && callbacks.onDone) callbacks.onDone(payload);
509
+ if (eventName === "error" && callbacks.onError) callbacks.onError(payload.message || "Erreur de streaming");
510
+ if (eventName === "transcription_draft_delta" && callbacks.onDelta) callbacks.onDelta(payload);
511
+ if (eventName === "transcription_draft_complete" && callbacks.onComplete) callbacks.onComplete(payload);
512
+ if (eventName === "transcription_draft_error" && callbacks.onError) callbacks.onError(payload);
513
+ }
514
+ return new Promise(function(resolve, reject) {
515
+ function step() {
516
+ reader.read().then(function(result) {
517
+ if (result.done) return resolve();
518
+ pending += decoder.decode(result.value, { stream: true });
519
+ var lines = pending.split("\n");
520
+ pending = lines.pop() || "";
521
+ lines.forEach(function(line) {
522
+ if (line.startsWith("event: ")) currentEvent = line.slice(7).trim();
523
+ if (line.startsWith("data: ")) {
524
+ try { dispatch(currentEvent, JSON.parse(line.slice(6))); } catch (_) {}
525
+ currentEvent = "";
526
+ }
527
+ });
528
+ step();
529
+ }).catch(reject);
530
+ }
531
+ step();
532
+ });
533
+ },
534
+ openEventStream: async function(operationName, endpointPath, sessionId, callbacks, allowedEvents, requestOptions) {
535
+ var self = this;
536
+ var response = await this.fetchWithBootstrapRetry(operationName, endpointPath, function() {
537
+ var options = { method: "GET", headers: self.buildHeaders() };
538
+ if (requestOptions) Object.assign(options, requestOptions);
539
+ return options;
540
+ }, sessionId);
541
+ if (!response.body) throw new Error("stream response body is missing");
542
+ return this.consumeSSE(response.body.getReader(), callbacks, allowedEvents);
543
+ },
544
+ streamSession: async function(sessionId, callbacks) {
545
+ return this.openEventStream("stream_session", this.config.chatStreamTemplate, sessionId, callbacks, ["token", "final", "error", "done"]);
546
+ },
547
+ streamAudioDraft: async function(sessionId, callbacks) {
548
+ var self = this;
549
+ var controller = new AbortController();
550
+ var streamPromise = this.openEventStream(
551
+ "audio_stream",
552
+ this.config.chatAudioStreamTemplate,
553
+ sessionId,
554
+ callbacks,
555
+ ["transcription_draft_delta", "transcription_draft_complete", "transcription_draft_error"],
556
+ { signal: controller.signal }
557
+ );
558
+ streamPromise.catch(function(error) {
559
+ if (self.isExpectedStreamAbortError(error)) return;
560
+ if (callbacks.onTransportError) callbacks.onTransportError(error.message || "Erreur stream audio");
561
+ });
562
+ return function() { controller.abort(); };
563
+ },
564
+ isExpectedStreamAbortError: function(error) {
565
+ var message = String(error && error.message ? error.message : "").toLowerCase();
566
+ return message.indexOf("aborted") !== -1 ||
567
+ message.indexOf("aborterror") !== -1 ||
568
+ message.indexOf("body stream buffer was aborted") !== -1 ||
569
+ message.indexOf("signal is aborted") !== -1;
570
+ },
571
+ postAudioJson: async function(operationName, endpointPath, sessionId, body) {
572
+ var self = this;
573
+ return this.fetchWithBootstrapRetry(operationName, endpointPath, function() {
574
+ return { method: "POST", headers: self.buildHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(body) };
575
+ }, sessionId);
576
+ },
577
+ startAudioSession: async function(sessionId) {
578
+ return (await this.postAudioJson("audio_session_start", this.config.chatAudioSessionTemplate, sessionId, { language: "fr", realtime: false, model: "voxtral-mini-latest" })).json();
579
+ },
580
+ sendAudioChunk: async function(sessionId, audioSessionId, chunk) {
581
+ await this.postAudioJson("audio_chunk_send", this.config.chatAudioChunkTemplate, sessionId, { audio_session_id: audioSessionId, chunk: chunk, index: 0 });
582
+ },
583
+ closeAudioSession: async function(sessionId, audioSessionId) {
584
+ await this.postAudioJson("audio_session_close", this.config.chatAudioCloseTemplate, sessionId, { audio_session_id: audioSessionId });
585
+ }
586
+ });
587
+ function buildConfig(scriptTag) {
588
+ var themeSnapshot = parseThemeSnapshot(readScriptAttr(scriptTag, "data-theme-snapshot", ""));
589
+ var initialThemeVersion = normalizeThemeVersion(readScriptAttr(scriptTag, "data-theme-version", ""));
590
+ var config = {
591
+ themeSplitEnabled: false,
592
+ hostPrefersDark: resolveHostPrefersDark(false),
593
+ effectiveThemeScheme: "light",
594
+ hasSnapshotTheme: false,
595
+ themeVersion: initialThemeVersion,
596
+ disclaimer: readScriptAttr(scriptTag, "data-disclaimer", "true") !== "false"
597
+ };
598
+ CONFIG_STRING_ATTRS.forEach(function(row) {
599
+ var value = readScriptAttr(scriptTag, row[1], row[2]);
600
+ config[row[0]] = row[0] === "apiBaseUrl" || row[0] === "widgetOrigin" ? trimTrailingSlash(value) : value;
601
+ });
602
+ config.primaryColor = readScriptHexAttr(scriptTag, "data-primary-color", "#0066ff");
603
+ config.position = normalizePosition(readScriptAttr(scriptTag, "data-position", ""), "bottom-right");
604
+ config.panelBackgroundColor = readScriptHexAttr(scriptTag, "data-panel-background-color", "#ffffff");
605
+ config.panelBackgroundAlpha = clampNumericString(readScriptAttr(scriptTag, "data-panel-background-alpha", ""), "1", 0, 1, false);
606
+ config.panelBorderRadius = clampNumericString(readScriptAttr(scriptTag, "data-panel-border-radius", ""), "16", 0, 32, true);
607
+ config.assistantName = String(readScriptAttr(scriptTag, "data-assistant-name", "Assistant mairie")).trim() || "Assistant mairie";
608
+ config.targetLanguage = String(readScriptAttr(scriptTag, "data-target-language", "fr")).trim() || "fr";
609
+ config.themeModeStrategy = normalizeThemeModeStrategy(readScriptAttr(scriptTag, "data-theme-mode-strategy", ""));
610
+ applyScriptMascotColors(config, scriptTag);
611
+ if (themeSnapshot && applyThemeConfigToRuntime(config, themeSnapshot)) {
612
+ config.hasSnapshotTheme = true;
613
+ if (normalizeThemeVersion(config.themeVersion) === 0) {
614
+ config.themeVersion = normalizeThemeVersion(themeSnapshot.theme_updated_at);
615
+ }
616
+ }
617
+ config.effectiveThemeScheme = resolvePreferredThemeScheme(config);
618
+ if (!config.apiBaseUrl) config.apiBaseUrl = config.widgetOrigin || trimTrailingSlash(window.location.origin);
619
+ if (!config.bootstrapEndpoint) config.bootstrapEndpoint = config.apiBaseUrl + "/v1/occe/chat/bootstrap";
620
+ if (!config.configEndpoint) config.configEndpoint = config.apiBaseUrl + "/v1/widget/config";
621
+ if (!config.mascotUrl) config.mascotUrl = "/v1/widget/assets/chouette_ourlu.svg";
622
+ config.mascotUrl = resolveAbsoluteMascotUrl(config.mascotUrl, config.apiBaseUrl);
623
+ return config;
624
+ }
625
+ function mountFromScript(scriptTag) {
626
+ if (!scriptTag || !scriptTag.getAttribute("data-tenant-id")) {
627
+ console.error("[OurluMairie] Loader: data-tenant-id is required.");
628
+ return;
629
+ }
630
+ if (document.getElementById(CONTAINER_ID)) return;
631
+ var config = buildConfig(scriptTag);
632
+ var events = new EventBus();
633
+ var state = { open: false, sessionId: "", token: config.sessionBootstrapToken || "", sending: false, listening: false, bootstrapPromise: null, sessionPromise: null };
634
+ var ui = new WidgetUIManager(config);
635
+ if (!ui.mount()) return;
636
+ var turnstile = config.turnstileSiteKey ? new TurnstileChallengeManager(config.turnstileSiteKey) : null;
637
+ var api = new WidgetApiManager(config, state, events, ui, turnstile);
638
+ var audio = new WidgetAudioManager(state, api, ui);
639
+ if (!audio.isSupported()) ui.micButton.style.display = "none";
640
+ api.hydrateTheme().catch(function() {});
641
+ var mediaQueryList = null;
642
+ var handleHostThemeChange = null;
643
+ if (typeof window.matchMedia === "function") {
644
+ mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
645
+ handleHostThemeChange = function(event) {
646
+ if (normalizeThemeModeStrategy(config.themeModeStrategy) !== "auto_follow_host") return;
647
+ config.hostPrefersDark = Boolean(event && event.matches);
648
+ config.effectiveThemeScheme = resolvePreferredThemeScheme(config);
649
+ ui.updateTheme(config);
650
+ };
651
+ if (typeof mediaQueryList.addEventListener === "function") mediaQueryList.addEventListener("change", handleHostThemeChange);
652
+ else if (typeof mediaQueryList.addListener === "function") mediaQueryList.addListener(handleHostThemeChange);
653
+ }
654
+ async function handleSend(event) {
655
+ event.preventDefault();
656
+ if (state.sending || state.listening) return;
657
+ var message = ui.pullInput();
658
+ if (!message) return;
659
+ state.sending = true;
660
+ ui.showError("");
661
+ ui.addMessage("user", message);
662
+ ui.showTyping(true);
663
+ ui.setComposerDisabled(true);
664
+ try {
665
+ var sessionId = await api.postMessage(message);
666
+ ui.startAssistantStream();
667
+ await api.streamSession(sessionId, {
668
+ onToken: function(token) { ui.appendAssistantToken(token); },
669
+ onFinal: function(payload) { ui.finalizeAssistantMessage(payload.content || ""); },
670
+ onError: function(errorText) { ui.showError(errorText || "Erreur de streaming."); },
671
+ onDone: function() {}
672
+ });
673
+ } catch (error) {
674
+ ui.showError((error && error.message) || "Impossible d'envoyer le message.");
675
+ } finally {
676
+ state.sending = false;
677
+ ui.showTyping(false);
678
+ ui.setComposerDisabled(false);
679
+ }
680
+ }
681
+ function togglePanel() {
682
+ state.open = !state.open;
683
+ ui.setOpen(state.open);
684
+ events.emit(state.open ? "open" : "close", {});
685
+ if (state.open) {
686
+ api.ensureSession().catch(function(error) {
687
+ ui.showError((error && error.message) || "Impossible d'initialiser le chat.");
688
+ });
689
+ }
690
+ }
691
+ ui.bind({
692
+ onToggle: togglePanel,
693
+ onSend: handleSend,
694
+ onMicToggle: function() {
695
+ audio.toggle().catch(function(error) {
696
+ state.listening = false;
697
+ ui.setMicListening(false);
698
+ ui.showError((error && error.message) || "Erreur micro.");
699
+ });
700
+ },
701
+ onEscape: function(event) { if (event.key === "Escape" && state.open) togglePanel(); }
702
+ });
703
+ var instance = { version: WIDGET_VERSION, open: function() { if (!state.open) togglePanel(); }, close: function() { if (state.open) togglePanel(); }, destroy: function() {
704
+ if (mediaQueryList && handleHostThemeChange) {
705
+ if (typeof mediaQueryList.removeEventListener === "function") mediaQueryList.removeEventListener("change", handleHostThemeChange);
706
+ else if (typeof mediaQueryList.removeListener === "function") mediaQueryList.removeListener(handleHostThemeChange);
707
+ }
708
+ if (audio.stopDraftStream) audio.stopDraftStream();
709
+ audio.cleanupMedia();
710
+ if (ui.host && ui.host.parentNode) ui.host.parentNode.removeChild(ui.host);
711
+ }, on: function(eventName, handler) { events.on(eventName, handler); } };
712
+ events.on("widget_disabled", function() {
713
+ instance.destroy();
714
+ window[WIDGET_NS] = null;
715
+ window.OurluWidget = null;
716
+ });
717
+ window[WIDGET_NS] = instance;
718
+ window.OurluWidget = { instance: instance };
719
+ }
720
+ runtime.mountFromScript = mountFromScript;
721
+ })();