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