- ng update @angular/core@21 @angular/cli@21 (corrige GHSA-jrmj-c5cx-3cw6, XSS SVG) - Migration automatique des templates vers le control flow natif (@if/@for, 138 fichiers) - Correction des 5 `track` invalides generes par la migration (trackBy a 1 argument -> track $index) + suppression des fonctions trackBy mortes - TypeScript 5.9, zone.js 0.15 ; npm audit : 0 vulnerabilite Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
147 lines
4.5 KiB
TypeScript
147 lines
4.5 KiB
TypeScript
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.
|
|
*
|
|
* <p>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})</p>
|
|
*
|
|
* <p>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é.</p>
|
|
*/
|
|
@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<string>();
|
|
|
|
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
|
|
|
|
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();
|
|
}
|
|
}
|