@makemore/agent-frontend 2.7.2 → 2.8.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,1264 @@
1
+ /**
2
+ * Embeddable Chat Widget
3
+ * A standalone chat widget that can be embedded in any website.
4
+ *
5
+ * Usage (Single Instance - Original API):
6
+ * <script src="chat-widget.js"></script>
7
+ * <link rel="stylesheet" href="chat-widget.css">
8
+ * <script>
9
+ * ChatWidget.init({
10
+ * backendUrl: 'https://your-api.com',
11
+ * agentKey: 'your-agent',
12
+ * title: 'Support Chat',
13
+ * primaryColor: '#0066cc',
14
+ * });
15
+ * </script>
16
+ *
17
+ * Usage (Multiple Instances):
18
+ * <script>
19
+ * const widget1 = ChatWidget.createInstance({
20
+ * containerId: 'chat-container-1',
21
+ * backendUrl: 'https://your-api.com',
22
+ * agentKey: 'agent-1',
23
+ * embedded: true,
24
+ * });
25
+ *
26
+ * const widget2 = ChatWidget.createInstance({
27
+ * containerId: 'chat-container-2',
28
+ * backendUrl: 'https://your-api.com',
29
+ * agentKey: 'agent-2',
30
+ * embedded: true,
31
+ * });
32
+ * </script>
33
+ */
34
+ (function(global) {
35
+ 'use strict';
36
+
37
+ // Default configuration
38
+ const DEFAULT_CONFIG = {
39
+ backendUrl: 'http://localhost:8000',
40
+ agentKey: 'insurance-agent',
41
+ title: 'Chat Assistant',
42
+ subtitle: 'How can we help you today?',
43
+ primaryColor: '#0066cc',
44
+ position: 'bottom-right',
45
+ defaultJourneyType: 'general',
46
+ enableDebugMode: true,
47
+ enableAutoRun: true,
48
+ journeyTypes: {},
49
+ customerPrompts: {},
50
+ placeholder: 'Type your message...',
51
+ emptyStateTitle: 'Start a Conversation',
52
+ emptyStateMessage: 'Send a message to get started.',
53
+ authStrategy: null,
54
+ authToken: null,
55
+ authHeader: null,
56
+ authTokenPrefix: null,
57
+ anonymousSessionEndpoint: null,
58
+ anonymousTokenKey: 'chat_widget_anonymous_token',
59
+ onAuthError: null,
60
+ anonymousTokenHeader: 'X-Anonymous-Token',
61
+ conversationIdKey: 'chat_widget_conversation_id',
62
+ sessionTokenKey: 'chat_widget_session_token',
63
+ modelKey: 'chat_widget_selected_model',
64
+ apiPaths: {
65
+ anonymousSession: '/api/accounts/anonymous-session/',
66
+ conversations: '/api/agent-runtime/conversations/',
67
+ runs: '/api/agent-runtime/runs/',
68
+ runEvents: '/api/agent-runtime/runs/{runId}/events/',
69
+ simulateCustomer: '/api/agent-runtime/simulate-customer/',
70
+ ttsVoices: '/api/tts/voices/',
71
+ ttsSetVoice: '/api/tts/set-voice/',
72
+ models: '/api/agent-runtime/models/',
73
+ },
74
+ // Conversation sidebar
75
+ showConversationSidebar: true,
76
+ autoRunDelay: 1000,
77
+ autoRunMode: 'automatic',
78
+ enableTTS: false,
79
+ ttsProxyUrl: null,
80
+ elevenLabsApiKey: null,
81
+ ttsVoices: { assistant: null, user: null },
82
+ ttsModel: 'eleven_turbo_v2_5',
83
+ ttsSettings: { stability: 0.5, similarity_boost: 0.75, style: 0.0, use_speaker_boost: true },
84
+ availableVoices: [],
85
+ showClearButton: true,
86
+ showDebugButton: true,
87
+ showTTSButton: true,
88
+ showVoiceSettings: true,
89
+ showExpandButton: true,
90
+ onEvent: null,
91
+ // Multi-instance options
92
+ containerId: null,
93
+ embedded: false,
94
+ metadata: {},
95
+ };
96
+
97
+ // Track instances
98
+ const instances = new Map();
99
+ let instanceCounter = 0;
100
+ let defaultInstance = null;
101
+
102
+ // ============================================================================
103
+ // ChatWidgetInstance Class
104
+ // ============================================================================
105
+
106
+ class ChatWidgetInstance {
107
+ constructor(userConfig = {}) {
108
+ this.instanceId = `cw-${++instanceCounter}`;
109
+
110
+ const mergedApiPaths = { ...DEFAULT_CONFIG.apiPaths, ...(userConfig.apiPaths || {}) };
111
+ this.config = { ...DEFAULT_CONFIG, ...userConfig, apiPaths: mergedApiPaths };
112
+
113
+ this.state = {
114
+ isOpen: this.config.embedded,
115
+ isExpanded: false,
116
+ isLoading: false,
117
+ isSimulating: false,
118
+ autoRunActive: false,
119
+ autoRunPaused: false,
120
+ debugMode: false,
121
+ journeyType: this.config.defaultJourneyType,
122
+ messages: [],
123
+ conversationId: null,
124
+ authToken: this.config.authToken || null,
125
+ error: null,
126
+ eventSource: null,
127
+ currentAudio: null,
128
+ isSpeaking: false,
129
+ speechQueue: [],
130
+ voiceSettingsOpen: false,
131
+ // Conversation sidebar state
132
+ sidebarOpen: false,
133
+ conversations: [],
134
+ conversationsLoading: false,
135
+ // Message pagination state
136
+ totalMessages: 0,
137
+ hasMoreMessages: false,
138
+ loadingMoreMessages: false,
139
+ messagesOffset: 0,
140
+ // Model selection state
141
+ selectedModel: null,
142
+ availableModels: [],
143
+ defaultModel: null,
144
+ modelSelectorOpen: false,
145
+ };
146
+
147
+ this.container = null;
148
+ instances.set(this.instanceId, this);
149
+ }
150
+
151
+ // Storage helpers with instance-specific keys
152
+ _storageKey(key) {
153
+ return this.config.containerId ? `${key}_${this.config.containerId}` : key;
154
+ }
155
+
156
+ _getStored(key) {
157
+ try { return localStorage.getItem(this._storageKey(key)); } catch (e) { return null; }
158
+ }
159
+
160
+ _setStored(key, value) {
161
+ try {
162
+ const k = this._storageKey(key);
163
+ value === null ? localStorage.removeItem(k) : localStorage.setItem(k, value);
164
+ } catch (e) {}
165
+ }
166
+
167
+ // Model selection
168
+ async loadAvailableModels() {
169
+ try {
170
+ const response = await fetch(
171
+ `${this.config.backendUrl}${this.config.apiPaths.models}`,
172
+ this._getFetchOptions({ method: 'GET' })
173
+ );
174
+ if (response.ok) {
175
+ const data = await response.json();
176
+ this.state.availableModels = data.models || [];
177
+ this.state.defaultModel = data.default;
178
+ // Restore saved model or use default
179
+ const savedModel = this._getStored(this.config.modelKey);
180
+ if (savedModel && this.state.availableModels.some(m => m.id === savedModel)) {
181
+ this.state.selectedModel = savedModel;
182
+ } else {
183
+ this.state.selectedModel = data.default;
184
+ }
185
+ }
186
+ } catch (err) {
187
+ console.warn('[ChatWidget] Failed to load models:', err);
188
+ }
189
+ }
190
+
191
+ setModel(modelId) {
192
+ this.state.selectedModel = modelId;
193
+ this._setStored(this.config.modelKey, modelId);
194
+ this.state.modelSelectorOpen = false;
195
+ this.render();
196
+ }
197
+
198
+ toggleModelSelector() {
199
+ this.state.modelSelectorOpen = !this.state.modelSelectorOpen;
200
+ this.render();
201
+ }
202
+
203
+ // Utility
204
+ _generateId() {
205
+ return 'msg-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
206
+ }
207
+
208
+ _escapeHtml(text) {
209
+ const div = document.createElement('div');
210
+ div.textContent = text;
211
+ return div.innerHTML;
212
+ }
213
+
214
+ _parseMarkdown(text) {
215
+ if (global.ChatWidget && global.ChatWidget._enhancedMarkdownParser) {
216
+ return global.ChatWidget._enhancedMarkdownParser(text);
217
+ }
218
+ let html = this._escapeHtml(text);
219
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
220
+ html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
221
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
222
+ html = html.replace(/_(.+?)_/g, '<em>$1</em>');
223
+ html = html.replace(/`(.+?)`/g, '<code>$1</code>');
224
+ html = html.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
225
+ html = html.replace(/\n/g, '<br>');
226
+ return html;
227
+ }
228
+
229
+ _formatDate(dateStr) {
230
+ if (!dateStr) return '';
231
+ try {
232
+ const date = new Date(dateStr);
233
+ const now = new Date();
234
+ const diffMs = now - date;
235
+ const diffMins = Math.floor(diffMs / 60000);
236
+ const diffHours = Math.floor(diffMs / 3600000);
237
+ const diffDays = Math.floor(diffMs / 86400000);
238
+
239
+ if (diffMins < 1) return 'Just now';
240
+ if (diffMins < 60) return `${diffMins}m ago`;
241
+ if (diffHours < 24) return `${diffHours}h ago`;
242
+ if (diffDays < 7) return `${diffDays}d ago`;
243
+
244
+ return date.toLocaleDateString();
245
+ } catch (e) {
246
+ return '';
247
+ }
248
+ }
249
+
250
+ // Authentication
251
+ _getAuthStrategy() {
252
+ if (this.config.authStrategy) return this.config.authStrategy;
253
+ if (this.config.authToken) return 'token';
254
+ if (this.config.apiPaths.anonymousSession || this.config.anonymousSessionEndpoint) return 'anonymous';
255
+ return 'none';
256
+ }
257
+
258
+ _getAuthHeaders() {
259
+ const strategy = this._getAuthStrategy();
260
+ const headers = {};
261
+ const token = this.config.authToken || this.state.authToken;
262
+
263
+ if (strategy === 'token' && token) {
264
+ const headerName = this.config.authHeader || 'Authorization';
265
+ const prefix = this.config.authTokenPrefix !== undefined ? this.config.authTokenPrefix : 'Token';
266
+ headers[headerName] = prefix ? `${prefix} ${token}` : token;
267
+ } else if (strategy === 'jwt' && token) {
268
+ const headerName = this.config.authHeader || 'Authorization';
269
+ const prefix = this.config.authTokenPrefix !== undefined ? this.config.authTokenPrefix : 'Bearer';
270
+ headers[headerName] = prefix ? `${prefix} ${token}` : token;
271
+ } else if (strategy === 'anonymous' && token) {
272
+ const headerName = this.config.authHeader || this.config.anonymousTokenHeader || 'X-Anonymous-Token';
273
+ headers[headerName] = token;
274
+ }
275
+
276
+ // Add CSRF token for session-based auth (Django)
277
+ if (strategy === 'session') {
278
+ const csrfToken = this._getCSRFToken();
279
+ if (csrfToken) {
280
+ headers['X-CSRFToken'] = csrfToken;
281
+ }
282
+ }
283
+
284
+ return headers;
285
+ }
286
+
287
+ _getCSRFToken() {
288
+ // Try to get from cookie (Django default)
289
+ const cookieName = this.config.csrfCookieName || 'csrftoken';
290
+ const cookies = document.cookie.split(';');
291
+ for (let cookie of cookies) {
292
+ const [name, value] = cookie.trim().split('=');
293
+ if (name === cookieName) {
294
+ return decodeURIComponent(value);
295
+ }
296
+ }
297
+ // Try to get from meta tag (alternative Django pattern)
298
+ const metaTag = document.querySelector('meta[name="csrf-token"]');
299
+ if (metaTag) {
300
+ return metaTag.getAttribute('content');
301
+ }
302
+ return null;
303
+ }
304
+
305
+ _getFetchOptions(options = {}) {
306
+ const strategy = this._getAuthStrategy();
307
+ const fetchOptions = { ...options };
308
+ fetchOptions.headers = { ...fetchOptions.headers, ...this._getAuthHeaders() };
309
+ if (strategy === 'session') fetchOptions.credentials = 'include';
310
+ return fetchOptions;
311
+ }
312
+
313
+ async _getOrCreateSession() {
314
+ const strategy = this._getAuthStrategy();
315
+ if (strategy !== 'anonymous') return this.config.authToken || this.state.authToken;
316
+ if (this.state.authToken) return this.state.authToken;
317
+
318
+ const storageKey = this.config.anonymousTokenKey || this.config.sessionTokenKey;
319
+ const stored = this._getStored(storageKey);
320
+ if (stored) { this.state.authToken = stored; return stored; }
321
+
322
+ try {
323
+ const endpoint = this.config.anonymousSessionEndpoint || this.config.apiPaths.anonymousSession;
324
+ const response = await fetch(`${this.config.backendUrl}${endpoint}`, {
325
+ method: 'POST',
326
+ headers: { 'Content-Type': 'application/json' },
327
+ });
328
+ if (response.ok) {
329
+ const data = await response.json();
330
+ this.state.authToken = data.token;
331
+ this._setStored(storageKey, data.token);
332
+ return data.token;
333
+ }
334
+ } catch (e) {
335
+ console.warn('[ChatWidget] Failed to create session:', e);
336
+ }
337
+ return null;
338
+ }
339
+
340
+ // TTS
341
+ async speakText(text, role) {
342
+ if (!this.config.enableTTS) return;
343
+ if (!this.config.ttsProxyUrl && !this.config.elevenLabsApiKey) return;
344
+ this.state.speechQueue.push({ text, role });
345
+ if (!this.state.isSpeaking) this._processSpeechQueue();
346
+ }
347
+
348
+ async _processSpeechQueue() {
349
+ if (this.state.speechQueue.length === 0) {
350
+ this.state.isSpeaking = false;
351
+ this.render();
352
+ return;
353
+ }
354
+ this.state.isSpeaking = true;
355
+ this.render();
356
+
357
+ const { text, role } = this.state.speechQueue.shift();
358
+ try {
359
+ let response;
360
+ if (this.config.ttsProxyUrl) {
361
+ response = await fetch(this.config.ttsProxyUrl, this._getFetchOptions({
362
+ method: 'POST',
363
+ headers: { 'Content-Type': 'application/json' },
364
+ body: JSON.stringify({ text, role }),
365
+ }));
366
+ } else {
367
+ const voiceId = role === 'assistant' ? this.config.ttsVoices.assistant : this.config.ttsVoices.user;
368
+ response = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, {
369
+ method: 'POST',
370
+ headers: { 'Accept': 'audio/mpeg', 'Content-Type': 'application/json', 'xi-api-key': this.config.elevenLabsApiKey },
371
+ body: JSON.stringify({ text, model_id: this.config.ttsModel, voice_settings: this.config.ttsSettings }),
372
+ });
373
+ }
374
+ if (!response.ok) throw new Error(`TTS API error: ${response.status}`);
375
+ const audioBlob = await response.blob();
376
+ const audioUrl = URL.createObjectURL(audioBlob);
377
+ const audio = new Audio(audioUrl);
378
+ this.state.currentAudio = audio;
379
+ audio.onended = () => { URL.revokeObjectURL(audioUrl); this.state.currentAudio = null; this._processSpeechQueue(); };
380
+ audio.onerror = () => { URL.revokeObjectURL(audioUrl); this.state.currentAudio = null; this._processSpeechQueue(); };
381
+ await audio.play();
382
+ } catch (err) {
383
+ console.error('[ChatWidget] TTS error:', err);
384
+ this.state.currentAudio = null;
385
+ this._processSpeechQueue();
386
+ }
387
+ }
388
+
389
+ stopSpeech() {
390
+ if (this.state.currentAudio) { this.state.currentAudio.pause(); this.state.currentAudio = null; }
391
+ this.state.speechQueue = [];
392
+ this.state.isSpeaking = false;
393
+ this.render();
394
+ }
395
+
396
+ toggleTTS() {
397
+ this.config.enableTTS = !this.config.enableTTS;
398
+ if (!this.config.enableTTS) this.stopSpeech();
399
+ this.render();
400
+ }
401
+
402
+ // API Functions
403
+ async sendMessage(content) {
404
+ if (!content.trim() || this.state.isLoading) return;
405
+
406
+ this.state.isLoading = true;
407
+ this.state.error = null;
408
+
409
+ const userMessage = {
410
+ id: this._generateId(),
411
+ role: 'user',
412
+ content: content.trim(),
413
+ timestamp: new Date(),
414
+ type: 'message',
415
+ };
416
+ this.state.messages.push(userMessage);
417
+ this.render();
418
+
419
+ try {
420
+ const token = await this._getOrCreateSession();
421
+ if (!this.state.conversationId) {
422
+ this.state.conversationId = this._getStored(this.config.conversationIdKey);
423
+ }
424
+
425
+ // Build params with model selection
426
+ const params = {};
427
+ if (this.state.selectedModel) {
428
+ params.model = this.state.selectedModel;
429
+ }
430
+
431
+ const response = await fetch(`${this.config.backendUrl}${this.config.apiPaths.runs}`, this._getFetchOptions({
432
+ method: 'POST',
433
+ headers: { 'Content-Type': 'application/json' },
434
+ body: JSON.stringify({
435
+ agentKey: this.config.agentKey,
436
+ conversationId: this.state.conversationId,
437
+ messages: [{ role: 'user', content: content.trim() }],
438
+ metadata: { ...this.config.metadata, journeyType: this.state.journeyType },
439
+ params: params,
440
+ }),
441
+ }));
442
+
443
+ if (!response.ok) {
444
+ const errorData = await response.json().catch(() => ({}));
445
+ throw new Error(errorData.error || `HTTP ${response.status}`);
446
+ }
447
+
448
+ const run = await response.json();
449
+ // Handle both camelCase and snake_case from backend
450
+ const runConversationId = run.conversationId || run.conversation_id;
451
+ if (!this.state.conversationId && runConversationId) {
452
+ this.state.conversationId = runConversationId;
453
+ this._setStored(this.config.conversationIdKey, runConversationId);
454
+ }
455
+
456
+ await this._subscribeToEvents(run.id, token);
457
+ } catch (err) {
458
+ this.state.error = err.message || 'Failed to send message';
459
+ this.state.isLoading = false;
460
+ this.render();
461
+ }
462
+ }
463
+
464
+ async _subscribeToEvents(runId, token) {
465
+ if (this.state.eventSource) this.state.eventSource.close();
466
+
467
+ const eventPath = this.config.apiPaths.runEvents.replace('{runId}', runId);
468
+ let url = `${this.config.backendUrl}${eventPath}`;
469
+ if (token) url += `?anonymous_token=${encodeURIComponent(token)}`;
470
+
471
+ const eventSource = new EventSource(url);
472
+ this.state.eventSource = eventSource;
473
+ let assistantContent = '';
474
+ const self = this;
475
+
476
+ eventSource.addEventListener('assistant.message', (event) => {
477
+ try {
478
+ const data = JSON.parse(event.data);
479
+ if (self.config.onEvent) self.config.onEvent('assistant.message', data.payload);
480
+ const content = data.payload.content;
481
+ if (content) {
482
+ assistantContent += content;
483
+ const lastMsg = self.state.messages[self.state.messages.length - 1];
484
+ if (lastMsg?.role === 'assistant' && lastMsg.id.startsWith('assistant-stream-')) {
485
+ lastMsg.content = assistantContent;
486
+ } else {
487
+ self.state.messages.push({
488
+ id: 'assistant-stream-' + Date.now(),
489
+ role: 'assistant',
490
+ content: assistantContent,
491
+ timestamp: new Date(),
492
+ type: 'message',
493
+ });
494
+ }
495
+ self.render();
496
+ }
497
+ } catch (err) { console.error('[ChatWidget] Parse error:', err); }
498
+ });
499
+
500
+ eventSource.addEventListener('tool.call', (event) => {
501
+ try {
502
+ const data = JSON.parse(event.data);
503
+ if (self.config.onEvent) self.config.onEvent('tool.call', data.payload);
504
+ // Always show tool calls (like Claude) - they're part of the conversation
505
+ const toolCallId = data.payload.id || data.payload.tool_call_id || ('tool-' + Date.now());
506
+ self.state.messages.push({
507
+ id: toolCallId,
508
+ role: 'assistant',
509
+ content: '',
510
+ timestamp: new Date(),
511
+ type: 'tool_call',
512
+ toolName: data.payload.name,
513
+ toolArguments: data.payload.arguments,
514
+ toolStatus: 'running',
515
+ });
516
+ self.render();
517
+ } catch (err) { console.error('[ChatWidget] Parse error:', err); }
518
+ });
519
+
520
+ eventSource.addEventListener('tool.result', (event) => {
521
+ try {
522
+ const data = JSON.parse(event.data);
523
+ if (self.config.onEvent) self.config.onEvent('tool.result', data.payload);
524
+ // Find the matching tool call and update it with the result
525
+ const toolCallId = data.payload.tool_call_id;
526
+ const toolMsg = self.state.messages.find(m => m.id === toolCallId && m.type === 'tool_call');
527
+ if (toolMsg) {
528
+ toolMsg.toolResult = data.payload.result;
529
+ toolMsg.toolStatus = 'complete';
530
+ } else {
531
+ // If we can't find the tool call, add a standalone result
532
+ self.state.messages.push({
533
+ id: 'tool-result-' + Date.now(),
534
+ role: 'assistant',
535
+ content: '',
536
+ timestamp: new Date(),
537
+ type: 'tool_result',
538
+ toolName: data.payload.name,
539
+ toolResult: data.payload.result,
540
+ toolStatus: 'complete',
541
+ });
542
+ }
543
+ self.render();
544
+ } catch (err) { console.error('[ChatWidget] Parse error:', err); }
545
+ });
546
+
547
+ const handleTerminal = (event) => {
548
+ try {
549
+ const data = JSON.parse(event.data);
550
+ if (self.config.onEvent) self.config.onEvent(data.type, data.payload);
551
+ if (data.type === 'run.failed') {
552
+ self.state.error = data.payload.error || 'Agent run failed';
553
+ self.state.messages.push({
554
+ id: 'error-' + Date.now(),
555
+ role: 'system',
556
+ content: `❌ Error: ${self.state.error}`,
557
+ timestamp: new Date(),
558
+ type: 'error',
559
+ });
560
+ }
561
+ } catch (err) { console.error('[ChatWidget] Parse error:', err); }
562
+ self.state.isLoading = false;
563
+ eventSource.close();
564
+ self.state.eventSource = null;
565
+ self.render();
566
+ if (assistantContent && !self.state.error) self.speakText(assistantContent, 'assistant');
567
+ };
568
+
569
+ eventSource.addEventListener('run.succeeded', handleTerminal);
570
+ eventSource.addEventListener('run.failed', handleTerminal);
571
+ eventSource.addEventListener('run.cancelled', handleTerminal);
572
+ eventSource.addEventListener('run.timed_out', handleTerminal);
573
+
574
+ eventSource.onerror = () => {
575
+ self.state.isLoading = false;
576
+ eventSource.close();
577
+ self.state.eventSource = null;
578
+ self.render();
579
+ };
580
+ }
581
+
582
+ // UI Actions
583
+ open() {
584
+ this.state.isOpen = true;
585
+ this._getOrCreateSession();
586
+ this.render();
587
+ }
588
+
589
+ close() {
590
+ this.state.isOpen = false;
591
+ this.state.autoRunActive = false;
592
+ this.state.autoRunPaused = false;
593
+ this.render();
594
+ }
595
+
596
+ toggleExpand() {
597
+ this.state.isExpanded = !this.state.isExpanded;
598
+ this.render();
599
+ }
600
+
601
+ toggleDebugMode() {
602
+ this.state.debugMode = !this.state.debugMode;
603
+ this.render();
604
+ }
605
+
606
+ clearMessages() {
607
+ this.state.messages = [];
608
+ this.state.conversationId = null;
609
+ this.state.error = null;
610
+ this.state.autoRunActive = false;
611
+ this.state.autoRunPaused = false;
612
+ this.state.totalMessages = 0;
613
+ this.state.hasMoreMessages = false;
614
+ this.state.messagesOffset = 0;
615
+ this._setStored(this.config.conversationIdKey, null);
616
+ this.render();
617
+ }
618
+
619
+ // Conversation sidebar methods
620
+ toggleSidebar() {
621
+ this.state.sidebarOpen = !this.state.sidebarOpen;
622
+ if (this.state.sidebarOpen) {
623
+ this.loadConversations();
624
+ }
625
+ this.render();
626
+ }
627
+
628
+ async loadConversations() {
629
+ if (!this.config.showConversationSidebar) return;
630
+
631
+ this.state.conversationsLoading = true;
632
+ this.render();
633
+
634
+ try {
635
+ const token = await this._getOrCreateSession();
636
+ const url = `${this.config.backendUrl}${this.config.apiPaths.conversations}?agent_key=${encodeURIComponent(this.config.agentKey)}`;
637
+
638
+ const response = await fetch(url, this._getFetchOptions({
639
+ method: 'GET',
640
+ }));
641
+
642
+ if (!response.ok) {
643
+ throw new Error(`HTTP ${response.status}`);
644
+ }
645
+
646
+ const data = await response.json();
647
+ // Handle paginated response or array
648
+ this.state.conversations = data.results || data;
649
+ } catch (err) {
650
+ console.error('[ChatWidget] Failed to load conversations:', err);
651
+ this.state.conversations = [];
652
+ } finally {
653
+ this.state.conversationsLoading = false;
654
+ this.render();
655
+ }
656
+ }
657
+
658
+ async switchConversation(conversationId) {
659
+ if (conversationId === this.state.conversationId) {
660
+ this.state.sidebarOpen = false;
661
+ this.render();
662
+ return;
663
+ }
664
+
665
+ // Save current conversation ID
666
+ this.state.conversationId = conversationId;
667
+ this._setStored(this.config.conversationIdKey, conversationId);
668
+
669
+ // Clear current messages and reset pagination state
670
+ this.state.messages = [];
671
+ this.state.sidebarOpen = false;
672
+ this.state.isLoading = true;
673
+ this.state.totalMessages = 0;
674
+ this.state.hasMoreMessages = false;
675
+ this.state.messagesOffset = 0;
676
+ this.render();
677
+
678
+ try {
679
+ // Fetch conversation details with last 10 messages
680
+ const token = await this._getOrCreateSession();
681
+ const limit = 10;
682
+ const url = `${this.config.backendUrl}${this.config.apiPaths.conversations}${conversationId}/?limit=${limit}&offset=0`;
683
+
684
+ const response = await fetch(url, this._getFetchOptions({
685
+ method: 'GET',
686
+ }));
687
+
688
+ if (response.ok) {
689
+ const conversation = await response.json();
690
+ // Load messages from conversation runs if available
691
+ if (conversation.messages) {
692
+ this.state.messages = conversation.messages.map(m => ({
693
+ id: this._generateId(),
694
+ role: m.role,
695
+ content: m.content,
696
+ }));
697
+ }
698
+ // Update pagination state
699
+ this.state.totalMessages = conversation.total_messages || conversation.totalMessages || 0;
700
+ this.state.hasMoreMessages = conversation.has_more || conversation.hasMore || false;
701
+ this.state.messagesOffset = this.state.messages.length;
702
+ }
703
+ } catch (err) {
704
+ console.error('[ChatWidget] Failed to load conversation:', err);
705
+ } finally {
706
+ this.state.isLoading = false;
707
+ this.render();
708
+ }
709
+ }
710
+
711
+ async loadMoreMessages() {
712
+ if (!this.state.conversationId || this.state.loadingMoreMessages || !this.state.hasMoreMessages) {
713
+ return;
714
+ }
715
+
716
+ this.state.loadingMoreMessages = true;
717
+ this.render();
718
+
719
+ try {
720
+ const token = await this._getOrCreateSession();
721
+ const limit = 10;
722
+ const offset = this.state.messagesOffset;
723
+ const url = `${this.config.backendUrl}${this.config.apiPaths.conversations}${this.state.conversationId}/?limit=${limit}&offset=${offset}`;
724
+
725
+ const response = await fetch(url, this._getFetchOptions({
726
+ method: 'GET',
727
+ }));
728
+
729
+ if (response.ok) {
730
+ const conversation = await response.json();
731
+ if (conversation.messages && conversation.messages.length > 0) {
732
+ // Prepend older messages to the beginning
733
+ const olderMessages = conversation.messages.map(m => ({
734
+ id: this._generateId(),
735
+ role: m.role,
736
+ content: m.content,
737
+ }));
738
+ this.state.messages = [...olderMessages, ...this.state.messages];
739
+ this.state.messagesOffset += olderMessages.length;
740
+ this.state.hasMoreMessages = conversation.has_more || conversation.hasMore || false;
741
+ } else {
742
+ this.state.hasMoreMessages = false;
743
+ }
744
+ }
745
+ } catch (err) {
746
+ console.error('[ChatWidget] Failed to load more messages:', err);
747
+ } finally {
748
+ this.state.loadingMoreMessages = false;
749
+ this.render();
750
+ }
751
+ }
752
+
753
+ newConversation() {
754
+ this.state.conversationId = null;
755
+ this.state.messages = [];
756
+ this.state.totalMessages = 0;
757
+ this.state.hasMoreMessages = false;
758
+ this.state.messagesOffset = 0;
759
+ this._setStored(this.config.conversationIdKey, null);
760
+ this.state.sidebarOpen = false;
761
+ this.render();
762
+ }
763
+
764
+ send(message) {
765
+ if (!this.state.isOpen) this.open();
766
+ this.sendMessage(message);
767
+ }
768
+
769
+ setAuth(authConfig = {}) {
770
+ if (authConfig.strategy) this.config.authStrategy = authConfig.strategy;
771
+ if (authConfig.token !== undefined) {
772
+ this.config.authToken = authConfig.token;
773
+ this.state.authToken = authConfig.token;
774
+ }
775
+ }
776
+
777
+ clearAuth() {
778
+ this.config.authToken = null;
779
+ this.state.authToken = null;
780
+ this._setStored(this.config.anonymousTokenKey, null);
781
+ }
782
+
783
+ getState() { return { ...this.state }; }
784
+ getConfig() { return { ...this.config }; }
785
+
786
+ // Render
787
+ _renderMessage(msg) {
788
+ const isUser = msg.role === 'user';
789
+ const isToolCall = msg.type === 'tool_call';
790
+ const isToolResult = msg.type === 'tool_result';
791
+ const isError = msg.type === 'error';
792
+
793
+ // Render tool calls as collapsible cards (like Claude)
794
+ if (isToolCall) {
795
+ return this._renderToolCall(msg);
796
+ }
797
+
798
+ // Skip standalone tool results (they're merged into tool calls)
799
+ if (isToolResult) return '';
800
+
801
+ let classes = 'cw-message';
802
+ if (isUser) classes += ' cw-message-user';
803
+ if (isError) classes += ' cw-message-error';
804
+
805
+ let content = msg.role === 'assistant' ? this._parseMarkdown(msg.content) : this._escapeHtml(msg.content);
806
+
807
+ return `<div class="cw-message-row ${isUser ? 'cw-message-row-user' : ''}"><div class="${classes}">${content}</div></div>`;
808
+ }
809
+
810
+ _renderToolCall(msg) {
811
+ const isRunning = msg.toolStatus === 'running';
812
+ const hasResult = msg.toolResult !== undefined;
813
+ const toolId = msg.id.replace(/[^a-zA-Z0-9-]/g, '');
814
+
815
+ // Format arguments for display
816
+ let argsDisplay = '';
817
+ if (msg.toolArguments) {
818
+ try {
819
+ const args = typeof msg.toolArguments === 'string'
820
+ ? JSON.parse(msg.toolArguments)
821
+ : msg.toolArguments;
822
+ argsDisplay = JSON.stringify(args, null, 2);
823
+ } catch (e) {
824
+ argsDisplay = String(msg.toolArguments);
825
+ }
826
+ }
827
+
828
+ // Format result for display
829
+ let resultDisplay = '';
830
+ if (hasResult) {
831
+ try {
832
+ const result = typeof msg.toolResult === 'string'
833
+ ? (msg.toolResult.startsWith('{') || msg.toolResult.startsWith('[')
834
+ ? JSON.parse(msg.toolResult)
835
+ : msg.toolResult)
836
+ : msg.toolResult;
837
+ resultDisplay = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
838
+ } catch (e) {
839
+ resultDisplay = String(msg.toolResult);
840
+ }
841
+ }
842
+
843
+ return `
844
+ <div class="cw-message-row">
845
+ <div class="cw-tool-card" data-tool-id="${toolId}">
846
+ <div class="cw-tool-header" data-action="toggle-tool" data-tool-target="${toolId}">
847
+ <span class="cw-tool-icon">${isRunning ? '<span class="cw-spinner-small"></span>' : '🔧'}</span>
848
+ <span class="cw-tool-name">${this._escapeHtml(msg.toolName || 'Tool')}</span>
849
+ <span class="cw-tool-status ${isRunning ? 'cw-tool-running' : 'cw-tool-complete'}">
850
+ ${isRunning ? 'Running...' : '✓'}
851
+ </span>
852
+ <span class="cw-tool-chevron">▼</span>
853
+ </div>
854
+ <div class="cw-tool-body" id="tool-body-${toolId}" style="display: none;">
855
+ <div class="cw-tool-section">
856
+ <div class="cw-tool-section-title">Arguments</div>
857
+ <pre class="cw-tool-code">${this._escapeHtml(argsDisplay || '{}')}</pre>
858
+ </div>
859
+ ${hasResult ? `
860
+ <div class="cw-tool-section">
861
+ <div class="cw-tool-section-title">Result</div>
862
+ <pre class="cw-tool-code">${this._escapeHtml(resultDisplay)}</pre>
863
+ </div>
864
+ ` : ''}
865
+ </div>
866
+ </div>
867
+ </div>
868
+ `;
869
+ }
870
+
871
+ render() {
872
+ if (!this.container) return;
873
+ const cfg = this.config;
874
+ const st = this.state;
875
+
876
+ // Embedded mode: always show widget inline
877
+ if (cfg.embedded) {
878
+ this._renderWidget();
879
+ return;
880
+ }
881
+
882
+ // Floating mode: show FAB or widget
883
+ if (!st.isOpen) {
884
+ this.container.innerHTML = `
885
+ <button class="cw-fab" style="background-color: ${cfg.primaryColor}">
886
+ <svg class="cw-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
887
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
888
+ </svg>
889
+ </button>
890
+ `;
891
+ this.container.querySelector('.cw-fab').addEventListener('click', () => this.open());
892
+ return;
893
+ }
894
+
895
+ this._renderWidget();
896
+ }
897
+
898
+ _renderWidget() {
899
+ const cfg = this.config;
900
+ const st = this.state;
901
+ const self = this;
902
+
903
+ // Load more indicator at the top
904
+ const loadMoreHtml = st.hasMoreMessages ? `
905
+ <div class="cw-load-more" data-action="load-more">
906
+ ${st.loadingMoreMessages
907
+ ? '<span class="cw-spinner"></span><span>Loading...</span>'
908
+ : '<span>↑ Scroll up or click to load older messages</span>'}
909
+ </div>
910
+ ` : '';
911
+
912
+ const messagesHtml = st.messages.length === 0
913
+ ? `<div class="cw-empty-state">
914
+ <svg class="cw-empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
915
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
916
+ </svg>
917
+ <h3>${this._escapeHtml(cfg.emptyStateTitle)}</h3>
918
+ <p>${this._escapeHtml(cfg.emptyStateMessage)}</p>
919
+ </div>`
920
+ : loadMoreHtml + st.messages.map(m => this._renderMessage(m)).join('');
921
+
922
+ const typingIndicator = st.isLoading ? `
923
+ <div class="cw-message-row">
924
+ <div class="cw-typing"><span class="cw-spinner"></span><span>Thinking...</span></div>
925
+ </div>
926
+ ` : '';
927
+
928
+ const statusBar = st.debugMode ? '<div class="cw-status-bar"><span>🐛 Debug</span></div>' : '';
929
+ const errorBar = st.error ? `<div class="cw-error-bar">${this._escapeHtml(st.error)}</div>` : '';
930
+
931
+ // Render conversation sidebar
932
+ const sidebarHtml = cfg.showConversationSidebar ? `
933
+ <div class="cw-sidebar ${st.sidebarOpen ? 'cw-sidebar-open' : ''}">
934
+ <div class="cw-sidebar-header">
935
+ <span>Conversations</span>
936
+ <button class="cw-sidebar-close" data-action="toggle-sidebar">✕</button>
937
+ </div>
938
+ <button class="cw-new-conversation" data-action="new-conversation">
939
+ <span>+ New Conversation</span>
940
+ </button>
941
+ <div class="cw-conversation-list">
942
+ ${st.conversationsLoading ? '<div class="cw-sidebar-loading"><span class="cw-spinner"></span></div>' : ''}
943
+ ${!st.conversationsLoading && st.conversations.length === 0 ? '<div class="cw-sidebar-empty">No conversations yet</div>' : ''}
944
+ ${st.conversations.map(conv => `
945
+ <div class="cw-conversation-item ${conv.id === st.conversationId ? 'cw-conversation-active' : ''}"
946
+ data-action="switch-conversation" data-conversation-id="${conv.id}">
947
+ <div class="cw-conversation-title">${this._escapeHtml(conv.title || 'Untitled')}</div>
948
+ <div class="cw-conversation-date">${this._formatDate(conv.updatedAt || conv.createdAt)}</div>
949
+ </div>
950
+ `).join('')}
951
+ </div>
952
+ </div>
953
+ <div class="cw-sidebar-overlay ${st.sidebarOpen ? 'cw-sidebar-overlay-visible' : ''}" data-action="toggle-sidebar"></div>
954
+ ` : '';
955
+
956
+ // Hamburger menu button for sidebar
957
+ const hamburgerBtn = cfg.showConversationSidebar ? `
958
+ <button class="cw-header-btn cw-hamburger" data-action="toggle-sidebar" title="Conversations">
959
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
960
+ <line x1="3" y1="6" x2="21" y2="6"></line>
961
+ <line x1="3" y1="12" x2="21" y2="12"></line>
962
+ <line x1="3" y1="18" x2="21" y2="18"></line>
963
+ </svg>
964
+ </button>
965
+ ` : '';
966
+
967
+ this.container.innerHTML = `
968
+ <div class="cw-widget ${st.isExpanded ? 'cw-widget-expanded' : ''} ${cfg.embedded ? 'cw-widget-embedded' : ''}" style="--cw-primary: ${cfg.primaryColor}">
969
+ ${sidebarHtml}
970
+ <div class="cw-header" style="background-color: ${cfg.primaryColor}">
971
+ ${hamburgerBtn}
972
+ <span class="cw-title">${this._escapeHtml(cfg.title)}</span>
973
+ <div class="cw-header-actions">
974
+ ${cfg.showClearButton ? `<button class="cw-header-btn" data-action="clear" title="Clear" ${st.isLoading || st.messages.length === 0 ? 'disabled' : ''}>🗑️</button>` : ''}
975
+ ${cfg.showDebugButton && cfg.enableDebugMode ? `<button class="cw-header-btn ${st.debugMode ? 'cw-btn-active' : ''}" data-action="toggle-debug" title="Debug">🐛</button>` : ''}
976
+ ${cfg.showTTSButton && (cfg.elevenLabsApiKey || cfg.ttsProxyUrl) ? `<button class="cw-header-btn ${cfg.enableTTS ? 'cw-btn-active' : ''}" data-action="toggle-tts" title="TTS">${cfg.enableTTS ? '🔊' : '🔇'}</button>` : ''}
977
+ ${cfg.showExpandButton && !cfg.embedded ? `<button class="cw-header-btn" data-action="toggle-expand" title="${st.isExpanded ? 'Minimize' : 'Expand'}">${st.isExpanded ? '⊖' : '⊕'}</button>` : ''}
978
+ ${!cfg.embedded ? '<button class="cw-header-btn" data-action="close" title="Close">✕</button>' : ''}
979
+ </div>
980
+ </div>
981
+ ${statusBar}
982
+ <div class="cw-messages" id="${this.instanceId}-messages">
983
+ ${messagesHtml}
984
+ ${typingIndicator}
985
+ </div>
986
+ ${errorBar}
987
+ ${st.availableModels.length > 0 ? `
988
+ <div class="cw-model-selector">
989
+ <button class="cw-model-btn" data-action="toggle-model-selector" title="Select Model">
990
+ <span class="cw-model-icon">🤖</span>
991
+ <span class="cw-model-name">${st.availableModels.find(m => m.id === st.selectedModel)?.name || 'Select Model'}</span>
992
+ <span class="cw-model-chevron">${st.modelSelectorOpen ? '▲' : '▼'}</span>
993
+ </button>
994
+ ${st.modelSelectorOpen ? `
995
+ <div class="cw-model-dropdown">
996
+ ${st.availableModels.map(m => `
997
+ <button class="cw-model-option ${m.id === st.selectedModel ? 'cw-model-option-selected' : ''}"
998
+ data-action="select-model" data-model-id="${m.id}">
999
+ <span class="cw-model-option-name">${this._escapeHtml(m.name)}</span>
1000
+ <span class="cw-model-option-provider">${this._escapeHtml(m.provider)}</span>
1001
+ ${m.description ? `<span class="cw-model-option-desc">${this._escapeHtml(m.description)}</span>` : ''}
1002
+ </button>
1003
+ `).join('')}
1004
+ </div>
1005
+ ` : ''}
1006
+ </div>
1007
+ ` : ''}
1008
+ <form class="cw-input-form" id="${this.instanceId}-form">
1009
+ <input type="text" class="cw-input" placeholder="${this._escapeHtml(cfg.placeholder)}" ${st.isLoading ? 'disabled' : ''}>
1010
+ <button type="submit" class="cw-send-btn" style="background-color: ${cfg.primaryColor}" ${st.isLoading ? 'disabled' : ''}>
1011
+ ${st.isLoading ? '<span class="cw-spinner"></span>' : '➤'}
1012
+ </button>
1013
+ </form>
1014
+ </div>
1015
+ `;
1016
+
1017
+ // Attach event listeners
1018
+ this.container.querySelectorAll('[data-action]').forEach(btn => {
1019
+ btn.addEventListener('click', (e) => {
1020
+ e.preventDefault();
1021
+ e.stopPropagation();
1022
+ const action = btn.dataset.action;
1023
+ if (action === 'close') self.close();
1024
+ else if (action === 'toggle-expand') self.toggleExpand();
1025
+ else if (action === 'toggle-debug') self.toggleDebugMode();
1026
+ else if (action === 'toggle-tts') self.toggleTTS();
1027
+ else if (action === 'clear') self.clearMessages();
1028
+ else if (action === 'toggle-sidebar') self.toggleSidebar();
1029
+ else if (action === 'new-conversation') self.newConversation();
1030
+ else if (action === 'switch-conversation') {
1031
+ const convId = btn.dataset.conversationId;
1032
+ if (convId) self.switchConversation(convId);
1033
+ }
1034
+ else if (action === 'load-more') self.loadMoreMessages();
1035
+ else if (action === 'toggle-model-selector') self.toggleModelSelector();
1036
+ else if (action === 'select-model') {
1037
+ const modelId = btn.dataset.modelId;
1038
+ if (modelId) self.setModel(modelId);
1039
+ }
1040
+ else if (action === 'toggle-tool') {
1041
+ const toolTarget = btn.dataset.toolTarget;
1042
+ const toolBody = document.getElementById(`tool-body-${toolTarget}`);
1043
+ const chevron = btn.querySelector('.cw-tool-chevron');
1044
+ if (toolBody) {
1045
+ const isHidden = toolBody.style.display === 'none';
1046
+ toolBody.style.display = isHidden ? 'block' : 'none';
1047
+ if (chevron) chevron.textContent = isHidden ? '▲' : '▼';
1048
+ }
1049
+ }
1050
+ });
1051
+ });
1052
+
1053
+ const form = document.getElementById(`${this.instanceId}-form`);
1054
+ if (form) {
1055
+ form.addEventListener('submit', (e) => {
1056
+ e.preventDefault();
1057
+ const input = form.querySelector('.cw-input');
1058
+ if (input && input.value.trim()) {
1059
+ self.sendMessage(input.value);
1060
+ input.value = '';
1061
+ input.focus();
1062
+ }
1063
+ });
1064
+ }
1065
+
1066
+ // Handle scroll to load more messages
1067
+ const messagesEl = document.getElementById(`${this.instanceId}-messages`);
1068
+ if (messagesEl) {
1069
+ // Store previous scroll height to maintain position after loading more
1070
+ const prevScrollHeight = messagesEl.scrollHeight;
1071
+ const prevScrollTop = messagesEl.scrollTop;
1072
+
1073
+ // Add scroll listener for loading more messages
1074
+ messagesEl.addEventListener('scroll', () => {
1075
+ // If scrolled near the top (within 50px) and has more messages, load more
1076
+ if (messagesEl.scrollTop < 50 && st.hasMoreMessages && !st.loadingMoreMessages) {
1077
+ const currentScrollHeight = messagesEl.scrollHeight;
1078
+ self.loadMoreMessages().then(() => {
1079
+ // After loading, adjust scroll position to maintain view
1080
+ const newScrollHeight = messagesEl.scrollHeight;
1081
+ messagesEl.scrollTop = newScrollHeight - currentScrollHeight + messagesEl.scrollTop;
1082
+ });
1083
+ }
1084
+ });
1085
+
1086
+ // Scroll to bottom only on initial load or new messages (not when loading older)
1087
+ if (!st.loadingMoreMessages && st.messagesOffset <= 10) {
1088
+ messagesEl.scrollTop = messagesEl.scrollHeight;
1089
+ }
1090
+ }
1091
+
1092
+ // Focus input
1093
+ const inputEl = this.container.querySelector('.cw-input');
1094
+ if (inputEl && !st.isLoading) setTimeout(() => inputEl.focus(), 0);
1095
+ }
1096
+
1097
+ // Initialize
1098
+ init() {
1099
+ this.state.conversationId = this._getStored(this.config.conversationIdKey);
1100
+
1101
+ if (this.config.containerId) {
1102
+ // Embedded mode: use existing container
1103
+ this.container = document.getElementById(this.config.containerId);
1104
+ if (!this.container) {
1105
+ console.error(`[ChatWidget] Container not found: ${this.config.containerId}`);
1106
+ return this;
1107
+ }
1108
+ this.container.classList.add('cw-container-embedded');
1109
+ } else {
1110
+ // Floating mode: create container
1111
+ this.container = document.createElement('div');
1112
+ this.container.id = this.instanceId;
1113
+ this.container.className = `cw-container cw-position-${this.config.position}`;
1114
+ document.body.appendChild(this.container);
1115
+ }
1116
+
1117
+ this.render();
1118
+
1119
+ // Load available models for the model selector
1120
+ this.loadAvailableModels().then(() => this.render());
1121
+
1122
+ // If we have a stored conversation ID, load its messages
1123
+ if (this.state.conversationId) {
1124
+ this._loadConversationMessages(this.state.conversationId);
1125
+ }
1126
+
1127
+ console.log(`[ChatWidget] Instance ${this.instanceId} initialized`);
1128
+ return this;
1129
+ }
1130
+
1131
+ async _loadConversationMessages(conversationId) {
1132
+ // Load messages for an existing conversation (e.g., on page reload)
1133
+ this.state.isLoading = true;
1134
+ this.render();
1135
+
1136
+ try {
1137
+ const token = await this._getOrCreateSession();
1138
+ const limit = 10;
1139
+ const url = `${this.config.backendUrl}${this.config.apiPaths.conversations}${conversationId}/?limit=${limit}&offset=0`;
1140
+
1141
+ const response = await fetch(url, this._getFetchOptions({
1142
+ method: 'GET',
1143
+ }));
1144
+
1145
+ if (response.ok) {
1146
+ const conversation = await response.json();
1147
+ if (conversation.messages && conversation.messages.length > 0) {
1148
+ this.state.messages = conversation.messages.map(m => ({
1149
+ id: this._generateId(),
1150
+ role: m.role,
1151
+ content: m.content,
1152
+ }));
1153
+ this.state.totalMessages = conversation.total_messages || conversation.totalMessages || 0;
1154
+ this.state.hasMoreMessages = conversation.has_more || conversation.hasMore || false;
1155
+ this.state.messagesOffset = this.state.messages.length;
1156
+ }
1157
+ } else if (response.status === 404) {
1158
+ // Conversation not found - clear the stored ID
1159
+ console.log('[ChatWidget] Stored conversation not found, clearing');
1160
+ this.state.conversationId = null;
1161
+ this._setStored(this.config.conversationIdKey, null);
1162
+ }
1163
+ } catch (err) {
1164
+ console.error('[ChatWidget] Failed to load conversation messages:', err);
1165
+ } finally {
1166
+ this.state.isLoading = false;
1167
+ this.render();
1168
+ }
1169
+ }
1170
+
1171
+ destroy() {
1172
+ if (this.state.eventSource) this.state.eventSource.close();
1173
+ if (this.state.currentAudio) { this.state.currentAudio.pause(); this.state.currentAudio = null; }
1174
+
1175
+ if (this.container) {
1176
+ if (this.config.containerId) {
1177
+ // Embedded: just clear the container
1178
+ this.container.innerHTML = '';
1179
+ this.container.classList.remove('cw-container-embedded');
1180
+ } else {
1181
+ // Floating: remove the container
1182
+ this.container.remove();
1183
+ }
1184
+ this.container = null;
1185
+ }
1186
+
1187
+ instances.delete(this.instanceId);
1188
+ console.log(`[ChatWidget] Instance ${this.instanceId} destroyed`);
1189
+ }
1190
+ }
1191
+
1192
+ // ============================================================================
1193
+ // Public API
1194
+ // ============================================================================
1195
+
1196
+ /**
1197
+ * Create a new chat widget instance.
1198
+ * Use this for multiple widgets on the same page.
1199
+ */
1200
+ function createInstance(config = {}) {
1201
+ const instance = new ChatWidgetInstance(config);
1202
+ return instance.init();
1203
+ }
1204
+
1205
+ /**
1206
+ * Initialize the default (singleton) chat widget.
1207
+ * For backwards compatibility with the original API.
1208
+ */
1209
+ function init(config = {}) {
1210
+ if (defaultInstance) {
1211
+ defaultInstance.destroy();
1212
+ }
1213
+ defaultInstance = createInstance(config);
1214
+ return defaultInstance;
1215
+ }
1216
+
1217
+ /**
1218
+ * Destroy the default instance.
1219
+ */
1220
+ function destroy() {
1221
+ if (defaultInstance) {
1222
+ defaultInstance.destroy();
1223
+ defaultInstance = null;
1224
+ }
1225
+ }
1226
+
1227
+ // Proxy methods to default instance for backwards compatibility
1228
+ function open() { if (defaultInstance) defaultInstance.open(); }
1229
+ function close() { if (defaultInstance) defaultInstance.close(); }
1230
+ function send(message) { if (defaultInstance) defaultInstance.send(message); }
1231
+ function clearMessages() { if (defaultInstance) defaultInstance.clearMessages(); }
1232
+ function toggleTTS() { if (defaultInstance) defaultInstance.toggleTTS(); }
1233
+ function stopSpeech() { if (defaultInstance) defaultInstance.stopSpeech(); }
1234
+ function setAuth(config) { if (defaultInstance) defaultInstance.setAuth(config); }
1235
+ function clearAuth() { if (defaultInstance) defaultInstance.clearAuth(); }
1236
+ function getState() { return defaultInstance ? defaultInstance.getState() : null; }
1237
+ function getConfig() { return defaultInstance ? defaultInstance.getConfig() : null; }
1238
+
1239
+ // Export public API
1240
+ global.ChatWidget = {
1241
+ // Multi-instance API
1242
+ createInstance,
1243
+ getInstance: (id) => instances.get(id),
1244
+ getAllInstances: () => Array.from(instances.values()),
1245
+
1246
+ // Singleton API (backwards compatible)
1247
+ init,
1248
+ destroy,
1249
+ open,
1250
+ close,
1251
+ send,
1252
+ clearMessages,
1253
+ toggleTTS,
1254
+ stopSpeech,
1255
+ setAuth,
1256
+ clearAuth,
1257
+ getState,
1258
+ getConfig,
1259
+
1260
+ // For markdown plugin
1261
+ _enhancedMarkdownParser: null,
1262
+ };
1263
+
1264
+ })(typeof window !== 'undefined' ? window : this);