@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.
- package/dist/chat-widget-vanilla.js.bak +1264 -0
- package/dist/chat-widget.cjs.js +606 -0
- package/dist/chat-widget.css +56 -3
- package/dist/chat-widget.esm.js +606 -0
- package/dist/chat-widget.js +188 -172
- package/dist/chat-widget.js.map +7 -0
- package/dist/react.cjs.js +606 -0
- package/dist/react.esm.js +606 -0
- package/package.json +34 -9
- package/src/components/ChatWidget.js +13 -3
- package/src/components/ModelSelector.js +34 -10
- package/src/hooks/useChat.js +17 -2
- package/src/hooks/useModels.js +28 -4
- package/src/react.js +78 -0
- package/src/utils/config.js +1 -0
- package/src/utils/helpers.js +35 -0
|
@@ -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);
|