import { Component, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges, ElementRef, ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { LucideAngularModule, Send, Sparkles, Trash2, BookmarkPlus, Square } from 'lucide-angular'; import { Subscription } from 'rxjs'; import { AiChatService, ChatMessage } from '../../services/ai-chat.service'; /** * Panneau de chat IA pour le mode jeu. * *

Diffère du {@link AiChatDrawerComponent} : * - conversation 100% éphémère (le journal joue le rôle de mémoire persistante) * - intégré dans le panneau latéral, pas en drawer * - chaque réponse peut être ajoutée au journal en un clic (event {@link saveToJournal})

* *

Le backend reçoit le contexte complet via {@code /api/ai/chat/stream-session} : * lore + campagne + GameSystem + journal — l'IA "sait" tout ce qui s'est passé.

*/ @Component({ selector: 'app-session-ai-chat-panel', imports: [FormsModule, LucideAngularModule], templateUrl: './session-ai-chat-panel.component.html', styleUrls: ['./session-ai-chat-panel.component.scss'] }) export class SessionAiChatPanelComponent implements OnChanges, OnDestroy { readonly Send = Send; readonly Sparkles = Sparkles; readonly Trash2 = Trash2; readonly BookmarkPlus = BookmarkPlus; readonly Square = Square; @Input() sessionId!: string; @Input() canSaveToJournal = true; /** Émis quand le MJ clique "Ajouter au journal" sur une réponse. */ @Output() saveToJournal = new EventEmitter(); @ViewChild('messagesContainer') messagesContainer?: ElementRef; messages: ChatMessage[] = []; currentAssistantText = ''; input = ''; isStreaming = false; error: string | null = null; private streamSub: Subscription | null = null; constructor(private aiChat: AiChatService) {} ngOnChanges(changes: SimpleChanges): void { // Reset complet si on change de session (changement d'instance jouée). if (changes['sessionId'] && !changes['sessionId'].firstChange) { this.cancelStream(); this.messages = []; this.currentAssistantText = ''; this.error = null; } } send(): void { const text = this.input.trim(); if (!text || this.isStreaming || !this.sessionId) return; this.messages = [...this.messages, { role: 'user', content: text }]; this.input = ''; this.error = null; this.startStream(); } private startStream(): void { this.isStreaming = true; this.currentAssistantText = ''; this.scrollToBottomSoon(); this.streamSub = this.aiChat.streamChatForSession(this.sessionId, this.messages).subscribe({ next: (event) => { if (event.type === 'token') { this.currentAssistantText += event.value; this.scrollToBottomSoon(); } else if (event.type === 'done') { this.finishAssistantMessage(); } }, error: (err: unknown) => { const message = err instanceof Error ? err.message : 'Erreur inconnue'; this.error = `Erreur IA : ${message}`; this.isStreaming = false; this.streamSub = null; }, complete: () => { this.finishAssistantMessage(); } }); } private finishAssistantMessage(): void { if (this.currentAssistantText.trim()) { this.messages = [...this.messages, { role: 'assistant', content: this.currentAssistantText }]; } this.currentAssistantText = ''; this.isStreaming = false; this.streamSub = null; this.scrollToBottomSoon(); } cancelStream(): void { if (this.streamSub) { this.streamSub.unsubscribe(); this.streamSub = null; } // On garde ce qui a déjà été streamé : utile si l'IA partait dans le mur. if (this.currentAssistantText.trim()) { this.messages = [...this.messages, { role: 'assistant', content: this.currentAssistantText + ' [interrompu]' }]; } this.currentAssistantText = ''; this.isStreaming = false; } clearConversation(): void { this.cancelStream(); this.messages = []; this.error = null; } onSaveToJournal(content: string): void { if (!this.canSaveToJournal) return; this.saveToJournal.emit(content); } /** Scroll vers le bas après cycle de change detection — preuve d'affichage du dernier token. */ private scrollToBottomSoon(): void { queueMicrotask(() => { const el = this.messagesContainer?.nativeElement; if (el) el.scrollTop = el.scrollHeight; }); } ngOnDestroy(): void { this.cancelStream(); } }