django-lucy-assist 1.2.6__py3-none-any.whl → 1.2.7__py3-none-any.whl

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.
@@ -1,27 +1,22 @@
1
1
  /**
2
- * Lucy Assist - Composant Alpine.js pour le chatbot
3
- *
4
- * Ce fichier contient toute la logique client du chatbot Lucy Assist.
5
- * Il utilise Alpine.js pour la réactivité et communique avec le backend Django via des API REST.
2
+ * Lucy Assist - Chatbot en vanilla JavaScript
3
+ * Zero dependance externe - pas d'Alpine.js, pas de Font Awesome
6
4
  */
5
+ (function () {
6
+ 'use strict';
7
7
 
8
- function lucyAssist() {
9
- return {
10
- // État de l'interface
8
+ // ========================================================================
9
+ // ETAT
10
+ // ========================================================================
11
+ const state = {
11
12
  isOpen: false,
12
13
  isExpanded: false,
13
14
  isLoading: false,
14
15
  showHistory: false,
15
16
  showDoc: false,
16
- showBuyCredits: false,
17
- showFeedback: false,
18
- showGuide: false,
19
- guideStep: 1,
20
17
  hasNewMessage: false,
21
18
  hasError: false,
22
19
  lucyDidNotUnderstand: false,
23
-
24
- // Données
25
20
  currentMessage: '',
26
21
  messages: [],
27
22
  conversations: [],
@@ -31,805 +26,742 @@ function lucyAssist() {
31
26
  buyAmount: 10,
32
27
  feedbackDescription: '',
33
28
  feedbackSending: false,
29
+ guideStep: 1,
30
+ };
34
31
 
35
- // Configuration
36
- apiBaseUrl: '/lucy-assist/api',
37
- csrfToken: '',
38
-
39
- /**
40
- * Initialisation du composant
41
- */
42
- init() {
43
- // Récupérer le token CSRF
44
- this.csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
45
- this.getCookie('csrftoken');
46
-
47
- // Restaurer l'état depuis sessionStorage
48
- this.restoreState();
49
-
50
- // Charger les données initiales
51
- this.loadTokenStatus();
52
- this.loadConversations().then(() => {
53
- // Recharger la conversation en cours si elle existe
54
- if (this.currentConversationId) {
55
- this.loadConversation(this.currentConversationId);
56
- }
57
- });
58
- this.loadSuggestions();
59
-
60
- // Vérifier si c'est le premier lancement
61
- this.checkFirstLaunch();
62
-
63
- // Sauvegarder l'état quand isOpen change
64
- this.$watch('isOpen', (value) => {
65
- this.saveState();
66
- if (value) {
67
- this.$nextTick(() => {
68
- this.$refs.messageInput?.focus();
69
- });
70
- }
71
- });
32
+ // ========================================================================
33
+ // CONFIG
34
+ // ========================================================================
35
+ const API_BASE = '/lucy-assist/api';
36
+ let csrfToken = '';
72
37
 
73
- // Sauvegarder l'état quand la conversation change
74
- this.$watch('currentConversationId', () => {
75
- this.saveState();
76
- });
38
+ // ========================================================================
39
+ // ELEMENTS DOM (caches apres init)
40
+ // ========================================================================
41
+ const $ = (id) => document.getElementById(id);
77
42
 
78
- // Sauvegarder avant de quitter la page
79
- window.addEventListener('beforeunload', () => {
80
- this.saveState();
81
- });
82
- },
83
-
84
- /**
85
- * Sauvegarde l'état dans sessionStorage
86
- */
87
- saveState() {
88
- const state = {
89
- isOpen: this.isOpen,
90
- isExpanded: this.isExpanded,
91
- currentConversationId: this.currentConversationId,
92
- showHistory: this.showHistory,
93
- showDoc: this.showDoc
94
- };
95
- sessionStorage.setItem('lucy_assist_state', JSON.stringify(state));
96
- },
97
-
98
- /**
99
- * Restaure l'état depuis sessionStorage
100
- */
101
- restoreState() {
102
- try {
103
- const savedState = sessionStorage.getItem('lucy_assist_state');
104
- if (savedState) {
105
- const state = JSON.parse(savedState);
106
- this.isOpen = state.isOpen || false;
107
- this.isExpanded = state.isExpanded || false;
108
- this.currentConversationId = state.currentConversationId || null;
109
- this.showHistory = state.showHistory || false;
110
- this.showDoc = state.showDoc || false;
111
- }
112
- } catch (e) {
113
- console.error('Erreur restauration état Lucy Assist:', e);
114
- }
115
- },
116
-
117
- /**
118
- * Gestion des raccourcis clavier
119
- */
120
- handleKeydown(event) {
121
- // Ctrl+K pour ouvrir
122
- if (event.ctrlKey && event.key === 'k') {
123
- event.preventDefault();
124
- this.toggleSidebar();
125
- }
43
+ let els = {};
126
44
 
127
- // Echap pour fermer
128
- if (event.key === 'Escape' && this.isOpen) {
129
- this.closeSidebar();
130
- }
131
- },
132
-
133
- /**
134
- * Toggle du sidebar
135
- */
136
- toggleSidebar() {
137
- this.isOpen = !this.isOpen;
138
- if (this.isOpen) {
139
- this.hasNewMessage = false;
140
- }
141
- this.saveState();
142
- },
143
-
144
- /**
145
- * Fermeture du sidebar
146
- */
147
- closeSidebar() {
148
- this.isOpen = false;
149
- this.saveState();
150
- this.showHistory = false;
151
- this.showDoc = false;
152
- },
153
-
154
- /**
155
- * Toggle du mode agrandi (80% de la fenetre)
156
- */
157
- toggleExpanded() {
158
- this.isExpanded = !this.isExpanded;
159
- this.saveState();
160
- },
161
-
162
- /**
163
- * Vérifie si c'est le premier lancement
164
- */
165
- checkFirstLaunch() {
166
- const hasSeenGuide = localStorage.getItem('lucy_assist_guide_seen');
167
- if (!hasSeenGuide && this.conversations.length === 0) {
168
- // Afficher le guide au premier chargement si pas de conversations
169
- setTimeout(() => {
170
- if (this.conversations.length === 0) {
171
- this.showGuide = true;
172
- this.isOpen = true;
173
- }
174
- }, 2000);
175
- }
176
- },
177
-
178
- /**
179
- * Navigation dans le guide
180
- */
181
- nextGuideStep() {
182
- this.guideStep++;
183
- },
184
-
185
- skipGuide() {
186
- this.finishGuide();
187
- },
188
-
189
- finishGuide() {
190
- this.showGuide = false;
191
- localStorage.setItem('lucy_assist_guide_seen', 'true');
192
- },
193
-
194
- /**
195
- * Charge le statut des tokens
196
- */
197
- async loadTokenStatus() {
198
- try {
199
- const response = await this.apiGet('/tokens/status');
200
- this.tokensDisponibles = response.tokens_disponibles || 0;
201
- } catch (error) {
202
- console.error('Erreur chargement tokens:', error);
203
- }
204
- },
205
-
206
- /**
207
- * Charge la liste des conversations
208
- */
209
- async loadConversations() {
210
- try {
211
- const response = await this.apiGet('/conversations');
212
- this.conversations = response.conversations || [];
213
- } catch (error) {
214
- console.error('Erreur chargement conversations:', error);
215
- }
216
- },
217
-
218
- /**
219
- * Charge les suggestions
220
- */
221
- async loadSuggestions() {
222
- try {
223
- const response = await this.apiGet('/suggestions');
224
- this.suggestions = response.suggestions || [];
225
- } catch (error) {
226
- console.error('Erreur chargement suggestions:', error);
227
- // Suggestions par défaut
228
- this.suggestions = [
229
- "Comment créer un nouveau membre ?",
230
- "Comment effectuer un paiement ?",
231
- "Où trouver la liste des adhésions ?"
232
- ];
233
- }
234
- },
235
-
236
- /**
237
- * Crée une nouvelle conversation
238
- */
239
- async newConversation() {
240
- try {
241
- const response = await this.apiPost('/conversations', {
242
- page_contexte: window.location.pathname
243
- });
244
-
245
- this.currentConversationId = response.id;
246
- this.messages = [];
247
- this.showHistory = false;
248
- this.showDoc = false;
249
- this.saveState();
250
-
251
- // Recharger les conversations
252
- await this.loadConversations();
253
-
254
- } catch (error) {
255
- console.error('Erreur création conversation:', error);
256
- this.showToast('Erreur lors de la création de la conversation', 'error');
257
- }
258
- },
259
-
260
- /**
261
- * Charge une conversation existante
262
- */
263
- async loadConversation(conversationId) {
264
- try {
265
- const response = await this.apiGet(`/conversations/${conversationId}`);
266
-
267
- this.currentConversationId = conversationId;
268
- this.messages = response.messages || [];
269
- this.showHistory = false;
270
- this.saveState();
271
-
272
- // Scroll vers le bas avec un petit délai pour laisser le DOM se mettre à jour
273
- this.$nextTick(() => {
274
- setTimeout(() => {
275
- this.scrollToBottom();
276
- }, 100);
277
- });
278
-
279
- } catch (error) {
280
- console.error('Erreur chargement conversation:', error);
281
- // Si la conversation n'existe plus, réinitialiser
282
- this.currentConversationId = null;
283
- this.messages = [];
284
- this.saveState();
285
- this.showToast('Erreur lors du chargement de la conversation', 'error');
286
- }
287
- },
288
-
289
- /**
290
- * Supprime une conversation
291
- */
292
- async deleteConversation(conversationId) {
293
- if (!confirm('Êtes-vous sûr de vouloir supprimer cette conversation ?')) {
294
- return;
295
- }
45
+ // ========================================================================
46
+ // HELPERS
47
+ // ========================================================================
296
48
 
297
- try {
298
- await this.apiDelete(`/conversations/${conversationId}`);
49
+ function getCookie(name) {
50
+ const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
51
+ return match ? decodeURIComponent(match[2]) : null;
52
+ }
299
53
 
300
- // Recharger les conversations
301
- await this.loadConversations();
54
+ function formatTokens(tokens) {
55
+ if (tokens >= 1000000) return (tokens / 1000000).toFixed(1) + 'M';
56
+ if (tokens >= 1000) return (tokens / 1000).toFixed(0) + 'K';
57
+ return tokens.toString();
58
+ }
302
59
 
303
- // Si c'était la conversation courante, reset
304
- if (this.currentConversationId === conversationId) {
305
- this.currentConversationId = null;
306
- this.messages = [];
307
- }
60
+ function formatTokenCost(tokens) {
61
+ return ((tokens / 1000000) * 10).toFixed(4) + '€';
62
+ }
308
63
 
309
- this.showToast('Conversation supprimée', 'success');
64
+ function formatDate(dateString) {
65
+ const date = new Date(dateString);
66
+ const now = new Date();
67
+ const diff = now - date;
68
+ if (diff < 60000) return "À l'instant";
69
+ if (diff < 3600000) return `Il y a ${Math.floor(diff / 60000)} min`;
70
+ if (date.toDateString() === now.toDateString())
71
+ return `Aujourd'hui à ${date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`;
72
+ if (diff < 7 * 86400000)
73
+ return date.toLocaleDateString('fr-FR', { weekday: 'long', hour: '2-digit', minute: '2-digit' });
74
+ return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' });
75
+ }
310
76
 
311
- } catch (error) {
312
- console.error('Erreur suppression conversation:', error);
313
- this.showToast('Erreur lors de la suppression', 'error');
314
- }
315
- },
316
-
317
- /**
318
- * Envoie le message courant
319
- */
320
- async sendCurrentMessage() {
321
- const message = this.currentMessage.trim();
322
- if (!message) return;
323
-
324
- await this.sendMessage(message);
325
- },
326
-
327
- /**
328
- * Envoie un message (depuis l'input ou une suggestion)
329
- */
330
- async sendMessage(message) {
331
- if (this.isLoading) return;
332
-
333
- // Réinitialiser le flag "Lucy n'a pas compris" pour le nouveau message
334
- this.lucyDidNotUnderstand = false;
335
-
336
- // Créer une conversation si nécessaire
337
- if (!this.currentConversationId) {
338
- await this.newConversation();
339
- }
77
+ function formatTime(dateString) {
78
+ return new Date(dateString).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
79
+ }
340
80
 
341
- // Ajouter le message utilisateur à l'affichage
342
- const userMessage = {
343
- id: Date.now(),
344
- repondant: 'UTILISATEUR',
345
- contenu: message,
346
- created_date: new Date().toISOString(),
347
- tokens_utilises: 0
348
- };
349
- this.messages.push(userMessage);
350
- this.currentMessage = '';
351
-
352
- // Scroll vers le bas
353
- this.$nextTick(() => {
354
- this.scrollToBottom();
355
- });
81
+ function escapeHtml(s) {
82
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
83
+ }
356
84
 
357
- // Envoyer au serveur
358
- try {
359
- this.isLoading = true;
85
+ function formatMessage(content) {
86
+ if (!content) return '';
87
+ let f = escapeHtml(content);
88
+ f = f.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
89
+ f = f.replace(/\*(.*?)\*/g, '<em>$1</em>');
90
+ f = f.replace(/`(.*?)`/g, '<code style="background:rgba(0,0,0,.1);padding:0 4px;border-radius:3px;">$1</code>');
91
+ f = f.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--lucy-primary,#6366f1);text-decoration:underline;">$1</a>');
92
+ f = f.replace(/\n/g, '<br>');
93
+ return f;
94
+ }
360
95
 
361
- // Envoyer le message utilisateur
362
- await this.apiPost(`/conversations/${this.currentConversationId}/messages`, {
363
- contenu: message
364
- });
96
+ function checkIfLucyDidNotUnderstand(response) {
97
+ if (!response) return false;
98
+ const lower = response.toLowerCase();
99
+ const patterns = [
100
+ 'je ne comprends pas', "je n'ai pas compris", 'pourriez-vous reformuler',
101
+ 'pouvez-vous reformuler', 'pourriez-vous préciser', 'pouvez-vous préciser',
102
+ "je ne suis pas sûr de comprendre", "je n'ai pas accès", "je n'ai pas les outils",
103
+ 'je ne peux pas', 'je ne suis pas en mesure', 'fonctionnalité non disponible',
104
+ 'je ne dispose pas', 'mes outils actuels ne permettent pas', "je n'arrive pas à",
105
+ 'impossible de réaliser', 'en dehors de mon périmètre', 'hors de mon champ',
106
+ 'dépasse mes capacités', 'comment puis-je vous aider avec votre crm'
107
+ ];
108
+ return patterns.some(p => lower.includes(p));
109
+ }
365
110
 
366
- // Demander une réponse à Claude (avec streaming)
367
- await this.streamChatCompletion();
111
+ // ========================================================================
112
+ // API
113
+ // ========================================================================
368
114
 
369
- } catch (error) {
370
- console.error('Erreur envoi message:', error);
115
+ async function apiGet(endpoint) {
116
+ const r = await fetch(API_BASE + endpoint, {
117
+ headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }
118
+ });
119
+ if (!r.ok) throw { status: r.status, ...(await r.json()) };
120
+ return r.json();
121
+ }
371
122
 
372
- if (error.status === 402) {
373
- // Pas assez de crédits
374
- this.showBuyCredits = true;
375
- } else {
376
- // Marquer qu'il y a eu une erreur pour proposer le feedback
377
- this.hasError = true;
378
- this.showToast('Erreur lors de l\'envoi du message', 'error');
379
- }
123
+ async function apiPost(endpoint, data) {
124
+ const r = await fetch(API_BASE + endpoint, {
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
127
+ body: JSON.stringify(data)
128
+ });
129
+ if (!r.ok) throw { status: r.status, ...(await r.json()) };
130
+ return r.json();
131
+ }
380
132
 
381
- } finally {
382
- this.isLoading = false;
383
- }
384
- },
385
-
386
- /**
387
- * Stream la réponse de Claude
388
- */
389
- async streamChatCompletion() {
390
- const url = `${this.apiBaseUrl}/conversations/${this.currentConversationId}/chat`;
391
-
392
- // Index du message bot dans le tableau
393
- const botMessageIndex = this.messages.length;
394
-
395
- // Créer un placeholder pour la réponse
396
- this.messages.push({
397
- id: Date.now() + 1,
398
- repondant: 'CHATBOT',
399
- contenu: '',
400
- created_date: new Date().toISOString(),
401
- tokens_utilises: 0
402
- });
133
+ async function apiDelete(endpoint) {
134
+ const r = await fetch(API_BASE + endpoint, {
135
+ method: 'DELETE',
136
+ headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }
137
+ });
138
+ if (!r.ok) throw { status: r.status, ...(await r.json()) };
139
+ return r.json();
140
+ }
403
141
 
404
- try {
405
- const response = await fetch(url, {
406
- method: 'POST',
407
- headers: {
408
- 'Content-Type': 'application/json',
409
- 'X-CSRFToken': this.csrfToken
410
- },
411
- body: JSON.stringify({
412
- page_contexte: window.location.pathname
413
- })
414
- });
415
-
416
- if (!response.ok) {
417
- const errorData = await response.json();
418
- throw { status: response.status, ...errorData };
419
- }
142
+ // ========================================================================
143
+ // RENDU
144
+ // ========================================================================
420
145
 
421
- const reader = response.body.getReader();
422
- const decoder = new TextDecoder();
423
- let fullContent = '';
424
-
425
- while (true) {
426
- const { done, value } = await reader.read();
427
- if (done) break;
428
-
429
- const text = decoder.decode(value);
430
- const lines = text.split('\n');
431
-
432
- for (const line of lines) {
433
- if (line.startsWith('data: ')) {
434
- try {
435
- const data = JSON.parse(line.slice(6));
436
-
437
- if (data.type === 'content') {
438
- fullContent += data.content;
439
- // Forcer la réactivité Alpine en réassignant l'objet
440
- this.messages[botMessageIndex] = {
441
- ...this.messages[botMessageIndex],
442
- contenu: fullContent
443
- };
444
- this.$nextTick(() => this.scrollToBottom());
445
- } else if (data.type === 'done') {
446
- // Mise à jour finale avec l'ID et les tokens
447
- this.messages[botMessageIndex] = {
448
- ...this.messages[botMessageIndex],
449
- id: data.message_id,
450
- tokens_utilises: data.tokens_utilises
451
- };
452
- // Mettre à jour les tokens
453
- await this.loadTokenStatus();
454
- } else if (data.type === 'error') {
455
- throw new Error(data.error);
456
- }
457
- } catch (e) {
458
- if (e.message) throw e;
459
- }
460
- }
461
- }
462
- }
146
+ function scrollToBottom() {
147
+ const c = els.messages;
148
+ if (c) c.scrollTop = c.scrollHeight;
149
+ }
463
150
 
464
- // Supprimer le message placeholder s'il est resté vide
465
- if (!this.messages[botMessageIndex]?.contenu) {
466
- this.messages.splice(botMessageIndex, 1);
467
- }
151
+ function updateTokensDisplay() {
152
+ els.tokens.textContent = formatTokens(state.tokensDisponibles);
153
+ els.btnBuyCredits.style.display = state.tokensDisponibles < 100000 ? '' : 'none';
154
+ }
468
155
 
469
- // Vérifier si Lucy n'a pas compris le message
470
- const botResponse = this.messages[botMessageIndex]?.contenu || '';
471
- this.lucyDidNotUnderstand = this.checkIfLucyDidNotUnderstand(botResponse);
156
+ function updateExpandIcons() {
157
+ const expand = els.sidebar.querySelector('.lucy-icon-expand');
158
+ }
472
159
 
473
- // Réinitialiser le flag d'erreur après une réponse réussie (sauf si Lucy n'a pas compris)
474
- if (!this.lucyDidNotUnderstand) {
475
- this.hasError = false;
476
- }
160
+ /** Affiche la bonne vue : chat / history / doc */
161
+ function showView(view) {
162
+ state.showHistory = view === 'history';
163
+ state.showDoc = view === 'doc';
477
164
 
478
- // Recharger les conversations pour mettre à jour les titres
479
- await this.loadConversations();
165
+ els.viewHistory.style.display = state.showHistory ? '' : 'none';
166
+ els.viewDoc.style.display = state.showDoc ? '' : 'none';
167
+ els.viewChat.style.display = (!state.showHistory && !state.showDoc) ? '' : 'none';
480
168
 
481
- } catch (error) {
482
- // Marquer qu'il y a eu une erreur
483
- this.hasError = true;
169
+ // boutons actifs
170
+ els.btnHistory.classList.toggle('active', state.showHistory);
171
+ els.btnDoc.classList.toggle('active', state.showDoc);
172
+ }
484
173
 
485
- // Supprimer le message placeholder vide en cas d'erreur
486
- if (botMessageIndex < this.messages.length && !this.messages[botMessageIndex]?.contenu) {
487
- this.messages.splice(botMessageIndex, 1);
488
- }
489
- throw error;
490
- }
491
- },
492
-
493
- /**
494
- * Vérifie si Lucy n'a pas compris ou ne peut pas aider l'utilisateur
495
- */
496
- checkIfLucyDidNotUnderstand(response) {
497
- if (!response) return false;
498
-
499
- const lowerResponse = response.toLowerCase();
500
-
501
- // Patterns indiquant que Lucy n'a pas compris ou ne peut pas aider
502
- const notUnderstoodPatterns = [
503
- // Ne comprend pas
504
- 'je ne comprends pas',
505
- 'je n\'ai pas compris',
506
- 'je n\'arrive pas à comprendre',
507
- 'pourriez-vous reformuler',
508
- 'pouvez-vous reformuler',
509
- 'pourriez-vous préciser',
510
- 'pouvez-vous préciser',
511
- 'je ne suis pas sûr de comprendre',
512
- 'je ne suis pas certain de comprendre',
513
- 'votre message n\'est pas clair',
514
- 'erreur de frappe',
515
- 'erreur de saisie',
516
- 'message incompréhensible',
517
- 'je n\'ai pas pu interpréter',
518
- 'que souhaitez-vous faire',
519
- 'que voulez-vous faire',
520
- // Ne peut pas aider / pas accès
521
- 'je n\'ai pas accès',
522
- 'je n\'ai pas les outils',
523
- 'je n\'ai pas la possibilité',
524
- 'je ne peux pas',
525
- 'je ne suis pas en mesure',
526
- 'pas accès aux outils',
527
- 'outils nécessaires',
528
- 'fonctionnalité non disponible',
529
- 'cette fonctionnalité n\'est pas',
530
- 'je ne dispose pas',
531
- 'mes outils actuels ne permettent pas',
532
- 'mes outils ne permettent pas',
533
- 'je n\'arrive pas à',
534
- 'impossible de réaliser',
535
- 'je ne trouve pas',
536
- 'modèle non disponible',
537
- 'n\'existe pas dans',
538
- // Questions de clarification multiples
539
- 'pouvez-vous me dire',
540
- 'pourriez-vous me préciser',
541
- 'solutions possibles',
542
- // Hors du périmètre / redirection vers autres outils
543
- 'je suis spécialisée',
544
- 'je vous recommande de consulter',
545
- 'je vous conseille de',
546
- 'en dehors de mon périmètre',
547
- 'hors de mon champ',
548
- 'ne fait pas partie de mes compétences',
549
- 'dépasse mes capacités',
550
- 'assistants culinaires',
551
- 'chatgpt',
552
- 'google assistant',
553
- 'sites spécialisés',
554
- 'comment puis-je vous aider avec votre crm',
555
- 'y a-t-il quelque chose que vous souhaitez faire'
556
- ];
174
+ /** Rend la liste des conversations dans l'historique */
175
+ function renderHistoryList() {
176
+ const container = els.historyList;
177
+ if (!container) return;
557
178
 
558
- return notUnderstoodPatterns.some(pattern => lowerResponse.includes(pattern));
559
- },
560
-
561
- /**
562
- * Ouvre le modal de feedback
563
- */
564
- openFeedback() {
565
- this.showFeedback = true;
566
- this.feedbackDescription = '';
567
- },
568
-
569
- /**
570
- * Ferme le modal de feedback
571
- */
572
- closeFeedback() {
573
- this.showFeedback = false;
574
- this.feedbackDescription = '';
575
- },
576
-
577
- /**
578
- * Envoie le feedback à Revolucy
579
- */
580
- async sendFeedback() {
581
- if (!this.currentConversationId) {
582
- this.showToast('Aucune conversation à signaler', 'warning');
583
- return;
584
- }
179
+ if (state.conversations.length === 0) {
180
+ container.innerHTML = '<p class="lucy-history-empty">Aucune conversation</p>';
181
+ return;
182
+ }
585
183
 
586
- this.feedbackSending = true;
587
-
588
- try {
589
- const response = await this.apiPost('/feedback', {
590
- conversation_id: this.currentConversationId,
591
- description: this.feedbackDescription,
592
- page_url: window.location.href
593
- });
594
-
595
- this.showToast(response.message || 'Feedback envoyé avec succès !', 'success');
596
- this.closeFeedback();
597
- this.hasError = false;
598
- this.lucyDidNotUnderstand = false;
599
-
600
- } catch (error) {
601
- console.error('Erreur envoi feedback:', error);
602
- this.showToast('Erreur lors de l\'envoi du feedback', 'error');
603
- } finally {
604
- this.feedbackSending = false;
605
- }
606
- },
607
-
608
- /**
609
- * Achat de crédits
610
- */
611
- async buyCredits() {
612
- if (this.buyAmount < 10) {
613
- this.showToast('Le montant minimum est de 10 EUR', 'warning');
614
- return;
615
- }
184
+ container.innerHTML = state.conversations.map(conv => `
185
+ <div class="lucy-history-item ${state.currentConversationId === conv.id ? 'active' : ''}"
186
+ data-conv-id="${conv.id}">
187
+ <div class="lucy-history-item-title">${escapeHtml(conv.titre || 'Sans titre')}</div>
188
+ <div class="lucy-history-item-meta">
189
+ <span>${formatDate(conv.created_date)}</span>
190
+ <span style="margin-left:.5rem">${conv.total_tokens} tokens | ${formatTokenCost(conv.total_tokens)}</span>
191
+ </div>
192
+ <button class="lucy-btn-delete" data-delete-id="${conv.id}">🗑️ Supprimer</button>
193
+ </div>
194
+ `).join('');
195
+
196
+ // Event delegation
197
+ container.querySelectorAll('[data-conv-id]').forEach(el => {
198
+ el.addEventListener('click', (e) => {
199
+ if (e.target.closest('[data-delete-id]')) return;
200
+ loadConversation(parseInt(el.dataset.convId));
201
+ });
202
+ });
203
+ container.querySelectorAll('[data-delete-id]').forEach(btn => {
204
+ btn.addEventListener('click', (e) => {
205
+ e.stopPropagation();
206
+ deleteConversation(parseInt(btn.dataset.deleteId));
207
+ });
208
+ });
209
+ }
616
210
 
617
- try {
618
- const response = await this.apiPost('/tokens/buy', {
619
- montant_ht: this.buyAmount
620
- });
211
+ /** Rend la zone de messages */
212
+ function renderMessages() {
213
+ const container = els.messages;
214
+ if (!container) return;
215
+
216
+ if (state.messages.length === 0) {
217
+ els.welcome.style.display = '';
218
+ // Feedback bars cachees quand pas de messages
219
+ els.feedbackContact.style.display = 'none';
220
+ els.feedbackError.style.display = 'none';
221
+ return;
222
+ }
621
223
 
622
- // Ouvrir l'URL de souscription dans un nouvel onglet
623
- window.open(response.url_souscription, '_blank');
224
+ els.welcome.style.display = 'none';
624
225
 
625
- this.showBuyCredits = false;
626
- this.showToast('Redirection vers la page de paiement...', 'info');
226
+ // Garder le welcome puis ajouter les messages
227
+ // On reconstruit uniquement les bulles
228
+ // Supprimer les anciens message-divs
229
+ container.querySelectorAll('.lucy-message').forEach(el => el.remove());
627
230
 
628
- } catch (error) {
629
- console.error('Erreur achat crédits:', error);
630
- this.showToast('Erreur lors de la génération du lien de paiement', 'error');
631
- }
632
- },
633
-
634
- /**
635
- * Calcule le nombre de tokens pour un montant
636
- */
637
- calculateTokens(amount) {
638
- return Math.floor((amount / 10) * 1000000);
639
- },
640
-
641
- /**
642
- * Formate un nombre de tokens
643
- */
644
- formatTokens(tokens) {
645
- if (tokens >= 1000000) {
646
- return (tokens / 1000000).toFixed(1) + 'M';
647
- } else if (tokens >= 1000) {
648
- return (tokens / 1000).toFixed(0) + 'K';
649
- }
650
- return tokens.toString();
651
- },
652
-
653
- /**
654
- * Calcule et formate le coût en euros pour un nombre de tokens
655
- * Prix par défaut: 10€ par million de tokens
656
- */
657
- formatTokenCost(tokens) {
658
- const prixParMillion = 10; // 10€ par million de tokens
659
- const cout = (tokens / 1000000) * prixParMillion;
660
- return cout.toFixed(4) + '€';
661
- },
662
-
663
- /**
664
- * Formate une date
665
- */
666
- formatDate(dateString) {
667
- const date = new Date(dateString);
668
- const now = new Date();
669
- const diff = now - date;
670
-
671
- // Moins d'une minute
672
- if (diff < 60000) {
673
- return 'À l\'instant';
674
- }
231
+ const avatarUrl = container.dataset.avatarUrl || '';
232
+ const lucyIcon = container.dataset.lucyIcon || '';
675
233
 
676
- // Moins d'une heure
677
- if (diff < 3600000) {
678
- const minutes = Math.floor(diff / 60000);
679
- return `Il y a ${minutes} min`;
680
- }
234
+ state.messages.forEach(msg => {
235
+ const isUser = msg.repondant === 'UTILISATEUR';
236
+ const div = document.createElement('div');
237
+ div.className = 'lucy-message ' + (isUser ? 'user' : 'bot');
681
238
 
682
- // Aujourd'hui
683
- if (date.toDateString() === now.toDateString()) {
684
- return `Aujourd'hui à ${date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`;
239
+ let avatarHtml;
240
+ if (isUser) {
241
+ avatarHtml = avatarUrl
242
+ ? `<img src="${avatarUrl}" alt="Utilisateur">`
243
+ : `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>`;
244
+ } else {
245
+ avatarHtml = lucyIcon
246
+ ? `<img src="${lucyIcon}" alt="Lucy">`
247
+ : '🤖';
685
248
  }
686
249
 
687
- // Cette semaine
688
- if (diff < 7 * 24 * 3600000) {
689
- return date.toLocaleDateString('fr-FR', { weekday: 'long', hour: '2-digit', minute: '2-digit' });
690
- }
250
+ const bubbleContent = msg.contenu
251
+ ? `<span>${formatMessage(msg.contenu)}</span>`
252
+ : `<span class="lucy-loading-dots"><span></span><span></span><span></span></span>`;
253
+
254
+ div.innerHTML = `
255
+ <div class="lucy-message-avatar">${avatarHtml}</div>
256
+ <div class="lucy-message-content">
257
+ <div class="lucy-message-bubble">${bubbleContent}</div>
258
+ <div class="lucy-message-time">${formatTime(msg.created_date)}</div>
259
+ </div>
260
+ `;
261
+ container.appendChild(div);
262
+ });
263
+
264
+ // Feedback bars
265
+ els.feedbackContact.style.display = (state.lucyDidNotUnderstand && state.messages.length > 0) ? '' : 'none';
266
+ els.feedbackError.style.display = (state.hasError && !state.lucyDidNotUnderstand && state.messages.length > 0) ? '' : 'none';
267
+
268
+ requestAnimationFrame(scrollToBottom);
269
+ }
691
270
 
692
- return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' });
693
- },
694
-
695
- /**
696
- * Formate une heure
697
- */
698
- formatTime(dateString) {
699
- const date = new Date(dateString);
700
- return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
701
- },
702
-
703
- /**
704
- * Formate le contenu d'un message (markdown basique)
705
- */
706
- formatMessage(content) {
707
- if (!content) return '';
708
-
709
- // Échapper le HTML
710
- let formatted = content
711
- .replace(/&/g, '&amp;')
712
- .replace(/</g, '&lt;')
713
- .replace(/>/g, '&gt;');
714
-
715
- // Markdown basique
716
- formatted = formatted
717
- // Gras
718
- .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
719
- // Italique
720
- .replace(/\*(.*?)\*/g, '<em>$1</em>')
721
- // Code inline
722
- .replace(/`(.*?)`/g, '<code class="bg-base-300 px-1 rounded">$1</code>')
723
- // Liens
724
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" class="link link-primary">$1</a>')
725
- // Sauts de ligne
726
- .replace(/\n/g, '<br>');
727
-
728
- return formatted;
729
- },
730
-
731
- /**
732
- * Scroll vers le bas de la zone de messages
733
- */
734
- scrollToBottom() {
735
- const container = this.$refs.messagesContainer;
736
- if (container) {
737
- container.scrollTop = container.scrollHeight;
271
+ /** Rend les suggestions */
272
+ function renderSuggestions() {
273
+ const container = els.suggestions;
274
+ if (!container) return;
275
+ container.innerHTML = state.suggestions.map(s => `
276
+ <button class="lucy-suggestion-btn" data-suggestion="${escapeHtml(s)}">
277
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:#f59e0b"><path stroke-linecap="round" stroke-linejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
278
+ <span>${escapeHtml(s)}</span>
279
+ </button>
280
+ `).join('');
281
+
282
+ container.querySelectorAll('[data-suggestion]').forEach(btn => {
283
+ btn.addEventListener('click', () => sendMessage(btn.dataset.suggestion));
284
+ });
285
+ }
286
+
287
+ function updateBuyModal() {
288
+ const tokens = Math.floor((state.buyAmount / 10) * 1000000);
289
+ els.buyTokens.textContent = formatTokens(tokens);
290
+ els.buyConvs.textContent = Math.floor(tokens / 2000).toString();
291
+ }
292
+
293
+ // ========================================================================
294
+ // ACTIONS
295
+ // ========================================================================
296
+
297
+ function openSidebar() {
298
+ state.isOpen = true;
299
+ state.hasNewMessage = false;
300
+ els.floatBtn.style.display = 'none';
301
+ els.sidebar.style.display = '';
302
+ els.sidebar.classList.remove('hidden');
303
+ saveState();
304
+ setTimeout(() => els.input.focus(), 100);
305
+ }
306
+
307
+ function closeSidebar() {
308
+ state.isOpen = false;
309
+ state.showHistory = false;
310
+ state.showDoc = false;
311
+ els.sidebar.style.display = 'none';
312
+ els.sidebar.classList.add('hidden');
313
+ els.floatBtn.style.display = '';
314
+ showView('chat');
315
+ saveState();
316
+ }
317
+
318
+ function toggleSidebar() {
319
+ state.isOpen ? closeSidebar() : openSidebar();
320
+ }
321
+
322
+ function toggleExpanded() {
323
+ state.isExpanded = !state.isExpanded;
324
+ els.sidebar.classList.toggle('expanded', state.isExpanded);
325
+ updateExpandIcons();
326
+ saveState();
327
+ }
328
+
329
+ async function loadTokenStatus() {
330
+ try {
331
+ const r = await apiGet('/tokens/status');
332
+ state.tokensDisponibles = r.tokens_disponibles || 0;
333
+ updateTokensDisplay();
334
+ } catch (e) { console.error('Erreur chargement tokens:', e); }
335
+ }
336
+
337
+ async function loadConversations() {
338
+ try {
339
+ const r = await apiGet('/conversations');
340
+ state.conversations = r.conversations || [];
341
+ renderHistoryList();
342
+ } catch (e) { console.error('Erreur chargement conversations:', e); }
343
+ }
344
+
345
+ async function loadSuggestions() {
346
+ try {
347
+ const r = await apiGet('/suggestions');
348
+ state.suggestions = r.suggestions || [];
349
+ } catch (e) {
350
+ state.suggestions = [
351
+ "Comment créer un nouveau membre ?",
352
+ "Comment effectuer un paiement ?",
353
+ "Où trouver la liste des adhésions ?"
354
+ ];
355
+ }
356
+ renderSuggestions();
357
+ }
358
+
359
+ async function newConversation() {
360
+ try {
361
+ const r = await apiPost('/conversations', { page_contexte: window.location.pathname });
362
+ state.currentConversationId = r.id;
363
+ state.messages = [];
364
+ showView('chat');
365
+ renderMessages();
366
+ saveState();
367
+ await loadConversations();
368
+ } catch (e) { console.error('Erreur création conversation:', e); }
369
+ }
370
+
371
+ async function loadConversation(id) {
372
+ try {
373
+ const r = await apiGet(`/conversations/${id}`);
374
+ state.currentConversationId = id;
375
+ state.messages = r.messages || [];
376
+ showView('chat');
377
+ renderMessages();
378
+ saveState();
379
+ setTimeout(scrollToBottom, 100);
380
+ } catch (e) {
381
+ console.error('Erreur chargement conversation:', e);
382
+ state.currentConversationId = null;
383
+ state.messages = [];
384
+ renderMessages();
385
+ saveState();
386
+ }
387
+ }
388
+
389
+ async function deleteConversation(id) {
390
+ if (!confirm('Êtes-vous sûr de vouloir supprimer cette conversation ?')) return;
391
+ try {
392
+ await apiDelete(`/conversations/${id}`);
393
+ await loadConversations();
394
+ if (state.currentConversationId === id) {
395
+ state.currentConversationId = null;
396
+ state.messages = [];
397
+ renderMessages();
738
398
  }
739
- },
740
-
741
- /**
742
- * Affiche un toast de notification
743
- */
744
- showToast(message, type = 'info') {
745
- // Utiliser le système de toast Alpine.js existant si disponible
746
- if (window.Alpine && window.toastData) {
747
- window.toastData.show(message, type);
399
+ } catch (e) { console.error('Erreur suppression:', e); }
400
+ }
401
+
402
+ async function sendMessage(message) {
403
+ if (state.isLoading || !message.trim()) return;
404
+
405
+ state.lucyDidNotUnderstand = false;
406
+
407
+ if (!state.currentConversationId) await newConversation();
408
+
409
+ // Message utilisateur
410
+ state.messages.push({
411
+ id: Date.now(),
412
+ repondant: 'UTILISATEUR',
413
+ contenu: message,
414
+ created_date: new Date().toISOString(),
415
+ tokens_utilises: 0
416
+ });
417
+ state.currentMessage = '';
418
+ els.input.value = '';
419
+ els.input.style.height = 'auto';
420
+ updateSendBtn();
421
+ renderMessages();
422
+
423
+ try {
424
+ state.isLoading = true;
425
+ els.input.disabled = true;
426
+ els.btnSend.disabled = true;
427
+
428
+ await apiPost(`/conversations/${state.currentConversationId}/messages`, { contenu: message });
429
+ await streamChatCompletion();
430
+ } catch (e) {
431
+ console.error('Erreur envoi message:', e);
432
+ if (e.status === 402) {
433
+ showModal('buy');
748
434
  } else {
749
- // Fallback: alert simple
750
- console.log(`[${type}] ${message}`);
435
+ state.hasError = true;
436
+ renderMessages();
751
437
  }
752
- },
753
-
754
- /**
755
- * Récupère un cookie
756
- */
757
- getCookie(name) {
758
- let cookieValue = null;
759
- if (document.cookie && document.cookie !== '') {
760
- const cookies = document.cookie.split(';');
761
- for (let i = 0; i < cookies.length; i++) {
762
- const cookie = cookies[i].trim();
763
- if (cookie.substring(0, name.length + 1) === (name + '=')) {
764
- cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
765
- break;
766
- }
438
+ } finally {
439
+ state.isLoading = false;
440
+ els.input.disabled = false;
441
+ updateSendBtn();
442
+ els.input.focus();
443
+ }
444
+ }
445
+
446
+ async function streamChatCompletion() {
447
+ const url = `${API_BASE}/conversations/${state.currentConversationId}/chat`;
448
+ const botIdx = state.messages.length;
449
+
450
+ state.messages.push({
451
+ id: Date.now() + 1,
452
+ repondant: 'CHATBOT',
453
+ contenu: '',
454
+ created_date: new Date().toISOString(),
455
+ tokens_utilises: 0
456
+ });
457
+ renderMessages();
458
+
459
+ try {
460
+ const response = await fetch(url, {
461
+ method: 'POST',
462
+ headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
463
+ body: JSON.stringify({ page_contexte: window.location.pathname })
464
+ });
465
+
466
+ if (!response.ok) throw { status: response.status, ...(await response.json()) };
467
+
468
+ const reader = response.body.getReader();
469
+ const decoder = new TextDecoder();
470
+ let fullContent = '';
471
+
472
+ while (true) {
473
+ const { done, value } = await reader.read();
474
+ if (done) break;
475
+
476
+ const text = decoder.decode(value);
477
+ for (const line of text.split('\n')) {
478
+ if (!line.startsWith('data: ')) continue;
479
+ try {
480
+ const data = JSON.parse(line.slice(6));
481
+ if (data.type === 'content') {
482
+ fullContent += data.content;
483
+ state.messages[botIdx].contenu = fullContent;
484
+ renderMessages();
485
+ } else if (data.type === 'done') {
486
+ state.messages[botIdx].id = data.message_id;
487
+ state.messages[botIdx].tokens_utilises = data.tokens_utilises;
488
+ await loadTokenStatus();
489
+ } else if (data.type === 'error') {
490
+ throw new Error(data.error);
491
+ }
492
+ } catch (e) { if (e.message) throw e; }
767
493
  }
768
494
  }
769
- return cookieValue;
770
- },
771
-
772
- // Méthodes utilitaires pour les appels API
773
- async apiGet(endpoint) {
774
- const response = await fetch(`${this.apiBaseUrl}${endpoint}`, {
775
- method: 'GET',
776
- headers: {
777
- 'Content-Type': 'application/json',
778
- 'X-CSRFToken': this.csrfToken
779
- }
780
- });
781
495
 
782
- if (!response.ok) {
783
- const error = await response.json();
784
- throw { status: response.status, ...error };
496
+ if (!state.messages[botIdx]?.contenu) {
497
+ state.messages.splice(botIdx, 1);
785
498
  }
786
499
 
787
- return response.json();
788
- },
500
+ state.lucyDidNotUnderstand = checkIfLucyDidNotUnderstand(state.messages[botIdx]?.contenu || '');
501
+ if (!state.lucyDidNotUnderstand) state.hasError = false;
789
502
 
790
- async apiPost(endpoint, data) {
791
- const response = await fetch(`${this.apiBaseUrl}${endpoint}`, {
792
- method: 'POST',
793
- headers: {
794
- 'Content-Type': 'application/json',
795
- 'X-CSRFToken': this.csrfToken
796
- },
797
- body: JSON.stringify(data)
503
+ renderMessages();
504
+ await loadConversations();
505
+ } catch (e) {
506
+ state.hasError = true;
507
+ if (botIdx < state.messages.length && !state.messages[botIdx]?.contenu) {
508
+ state.messages.splice(botIdx, 1);
509
+ }
510
+ renderMessages();
511
+ throw e;
512
+ }
513
+ }
514
+
515
+ async function sendFeedback() {
516
+ if (!state.currentConversationId) return;
517
+ state.feedbackSending = true;
518
+ try {
519
+ await apiPost('/feedback', {
520
+ conversation_id: state.currentConversationId,
521
+ description: state.feedbackDescription,
522
+ page_url: window.location.href
798
523
  });
524
+ state.hasError = false;
525
+ state.lucyDidNotUnderstand = false;
526
+ hideModal('feedback');
527
+ renderMessages();
528
+ } catch (e) { console.error('Erreur feedback:', e); }
529
+ finally { state.feedbackSending = false; }
530
+ }
799
531
 
800
- if (!response.ok) {
801
- const error = await response.json();
802
- throw { status: response.status, ...error };
803
- }
532
+ async function buyCredits() {
533
+ if (state.buyAmount < 10) return;
534
+ try {
535
+ const r = await apiPost('/tokens/buy', { montant_ht: state.buyAmount });
536
+ window.open(r.url_souscription, '_blank');
537
+ hideModal('buy');
538
+ } catch (e) { console.error('Erreur achat:', e); }
539
+ }
804
540
 
805
- return response.json();
806
- },
541
+ // ========================================================================
542
+ // MODALS
543
+ // ========================================================================
544
+
545
+ function showModal(name) {
546
+ if (name === 'buy') {
547
+ els.modalBuy.style.display = '';
548
+ updateBuyModal();
549
+ } else if (name === 'feedback') {
550
+ els.modalFeedback.style.display = '';
551
+ state.feedbackDescription = '';
552
+ els.feedbackDesc.value = '';
553
+ }
554
+ }
807
555
 
808
- async apiDelete(endpoint) {
809
- const response = await fetch(`${this.apiBaseUrl}${endpoint}`, {
810
- method: 'DELETE',
811
- headers: {
812
- 'Content-Type': 'application/json',
813
- 'X-CSRFToken': this.csrfToken
814
- }
815
- });
556
+ function hideModal(name) {
557
+ if (name === 'buy') els.modalBuy.style.display = 'none';
558
+ else if (name === 'feedback') els.modalFeedback.style.display = 'none';
559
+ }
816
560
 
817
- if (!response.ok) {
818
- const error = await response.json();
819
- throw { status: response.status, ...error };
820
- }
561
+ // ========================================================================
562
+ // GUIDE
563
+ // ========================================================================
821
564
 
822
- return response.json();
565
+ function checkFirstLaunch() {
566
+ if (!localStorage.getItem('lucy_assist_guide_seen') && state.conversations.length === 0) {
567
+ setTimeout(() => {
568
+ if (state.conversations.length === 0) {
569
+ els.guideOverlay.style.display = '';
570
+ openSidebar();
571
+ }
572
+ }, 2000);
823
573
  }
824
- };
825
- }
574
+ }
575
+
576
+ function guideNext() {
577
+ state.guideStep = 2;
578
+ els.guideStep1.style.display = 'none';
579
+ els.guideStep2.style.display = '';
580
+ }
581
+
582
+ function guideFinish() {
583
+ els.guideOverlay.style.display = 'none';
584
+ localStorage.setItem('lucy_assist_guide_seen', 'true');
585
+ }
586
+
587
+ // ========================================================================
588
+ // STATE PERSISTENCE
589
+ // ========================================================================
590
+
591
+ function saveState() {
592
+ sessionStorage.setItem('lucy_assist_state', JSON.stringify({
593
+ isOpen: state.isOpen,
594
+ isExpanded: state.isExpanded,
595
+ currentConversationId: state.currentConversationId,
596
+ showHistory: state.showHistory,
597
+ showDoc: state.showDoc
598
+ }));
599
+ }
600
+
601
+ function restoreState() {
602
+ try {
603
+ const s = JSON.parse(sessionStorage.getItem('lucy_assist_state'));
604
+ if (!s) return;
605
+ state.isOpen = s.isOpen || false;
606
+ state.isExpanded = s.isExpanded || false;
607
+ state.currentConversationId = s.currentConversationId || null;
608
+ state.showHistory = s.showHistory || false;
609
+ state.showDoc = s.showDoc || false;
610
+ } catch (e) {}
611
+ }
612
+
613
+ // ========================================================================
614
+ // SEND BUTTON STATE
615
+ // ========================================================================
616
+
617
+ function updateSendBtn() {
618
+ els.btnSend.disabled = state.isLoading || !els.input.value.trim();
619
+ }
826
620
 
827
- // Exposer la fonction globalement pour que x-data puisse y accéder
828
- window.lucyAssist = lucyAssist;
621
+ // ========================================================================
622
+ // INIT
623
+ // ========================================================================
624
+
625
+ function init() {
626
+ // Cache des elements
627
+ els = {
628
+ floatBtn: $('lucy-float-btn'),
629
+ sidebar: $('lucy-sidebar'),
630
+ tokens: $('lucy-tokens'),
631
+ btnBuyCredits: $('lucy-btn-buy-credits'),
632
+ btnExpand: $('lucy-btn-expand'),
633
+ btnHistory: $('lucy-btn-history'),
634
+ btnNew: $('lucy-btn-new'),
635
+ btnDoc: $('lucy-btn-doc'),
636
+ btnClose: $('lucy-btn-close'),
637
+ btnSend: $('lucy-btn-send'),
638
+ input: $('lucy-input'),
639
+ messages: $('lucy-messages'),
640
+ welcome: $('lucy-welcome'),
641
+ suggestions: $('lucy-suggestions'),
642
+ viewHistory: $('lucy-view-history'),
643
+ viewDoc: $('lucy-view-doc'),
644
+ viewChat: $('lucy-view-chat'),
645
+ historyList: $('lucy-history-list'),
646
+ feedbackContact: $('lucy-feedback-contact'),
647
+ feedbackError: $('lucy-feedback-error'),
648
+ btnFeedbackContact: $('lucy-btn-feedback-contact'),
649
+ btnFeedbackError: $('lucy-btn-feedback-error'),
650
+ modalBuy: $('lucy-modal-buy'),
651
+ modalFeedback: $('lucy-modal-feedback'),
652
+ buyAmount: $('lucy-buy-amount'),
653
+ buyTokens: $('lucy-buy-tokens'),
654
+ buyConvs: $('lucy-buy-convs'),
655
+ buyCancel: $('lucy-buy-cancel'),
656
+ buyConfirm: $('lucy-buy-confirm'),
657
+ feedbackDesc: $('lucy-feedback-desc'),
658
+ feedbackCancel: $('lucy-feedback-cancel'),
659
+ feedbackConfirm: $('lucy-feedback-confirm'),
660
+ guideOverlay: $('lucy-guide-overlay'),
661
+ guideStep1: $('lucy-guide-step1'),
662
+ guideStep2: $('lucy-guide-step2'),
663
+ guideSkip: $('lucy-guide-skip'),
664
+ guideNext: $('lucy-guide-next'),
665
+ guideFinish: $('lucy-guide-finish'),
666
+ };
667
+
668
+ // CSRF
669
+ csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || getCookie('csrftoken') || '';
670
+
671
+ // Restaurer l'etat
672
+ restoreState();
673
+
674
+ // Appliquer l'etat initial
675
+ if (state.isOpen) {
676
+ els.floatBtn.style.display = 'none';
677
+ els.sidebar.style.display = '';
678
+ els.sidebar.classList.remove('hidden');
679
+ }
680
+ if (state.isExpanded) {
681
+ els.sidebar.classList.add('expanded');
682
+ }
683
+ updateExpandIcons();
684
+
685
+ if (state.showHistory) showView('history');
686
+ else if (state.showDoc) showView('doc');
687
+ else showView('chat');
688
+
689
+ // ---- EVENT LISTENERS ----
690
+
691
+ // Bouton flottant
692
+ els.floatBtn.addEventListener('click', toggleSidebar);
693
+
694
+ // Header buttons
695
+ els.btnExpand.addEventListener('click', toggleExpanded);
696
+ els.btnHistory.addEventListener('click', () => showView(state.showHistory ? 'chat' : 'history'));
697
+ els.btnNew.addEventListener('click', newConversation);
698
+ els.btnDoc.addEventListener('click', () => showView(state.showDoc ? 'chat' : 'doc'));
699
+ els.btnClose.addEventListener('click', closeSidebar);
700
+
701
+ // Input
702
+ els.input.addEventListener('input', () => {
703
+ state.currentMessage = els.input.value;
704
+ els.input.style.height = 'auto';
705
+ els.input.style.height = Math.min(els.input.scrollHeight, 128) + 'px';
706
+ updateSendBtn();
707
+ });
708
+ els.input.addEventListener('keydown', (e) => {
709
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
710
+ e.preventDefault();
711
+ sendMessage(els.input.value);
712
+ }
713
+ });
714
+
715
+ // Send
716
+ els.btnSend.addEventListener('click', () => sendMessage(els.input.value));
717
+
718
+ // Feedback
719
+ els.btnFeedbackContact.addEventListener('click', () => showModal('feedback'));
720
+ els.btnFeedbackError.addEventListener('click', () => showModal('feedback'));
721
+
722
+ // Buy credits
723
+ els.btnBuyCredits.addEventListener('click', () => showModal('buy'));
724
+ els.buyCancel.addEventListener('click', () => hideModal('buy'));
725
+ els.buyConfirm.addEventListener('click', buyCredits);
726
+ els.buyAmount.addEventListener('input', () => {
727
+ state.buyAmount = parseInt(els.buyAmount.value) || 10;
728
+ updateBuyModal();
729
+ });
730
+
731
+ // Feedback modal
732
+ els.feedbackCancel.addEventListener('click', () => hideModal('feedback'));
733
+ els.feedbackConfirm.addEventListener('click', sendFeedback);
734
+ els.feedbackDesc.addEventListener('input', () => {
735
+ state.feedbackDescription = els.feedbackDesc.value;
736
+ });
737
+
738
+ // Guide
739
+ els.guideSkip.addEventListener('click', guideFinish);
740
+ els.guideNext.addEventListener('click', guideNext);
741
+ els.guideFinish.addEventListener('click', guideFinish);
742
+
743
+ // Raccourcis clavier globaux
744
+ document.addEventListener('keydown', (e) => {
745
+ if (e.ctrlKey && e.key === 'k') { e.preventDefault(); toggleSidebar(); }
746
+ if (e.key === 'Escape' && state.isOpen) closeSidebar();
747
+ });
748
+
749
+ // Sauvegarder avant de quitter
750
+ window.addEventListener('beforeunload', saveState);
751
+
752
+ // Charger les donnees
753
+ loadTokenStatus();
754
+ loadConversations().then(() => {
755
+ if (state.currentConversationId) loadConversation(state.currentConversationId);
756
+ });
757
+ loadSuggestions();
758
+ checkFirstLaunch();
759
+ }
829
760
 
830
- // Enregistrer le composant avec Alpine si disponible
831
- document.addEventListener('DOMContentLoaded', () => {
832
- if (window.Alpine && window.Alpine.data) {
833
- window.Alpine.data('lucyAssist', lucyAssist);
761
+ // Lancer l'init quand le DOM est pret
762
+ if (document.readyState === 'loading') {
763
+ document.addEventListener('DOMContentLoaded', init);
764
+ } else {
765
+ init();
834
766
  }
835
- });
767
+ })();