Ajout d'un mode "jeu" (possibilité de lancer des sessions dans une campagne). Cela permet de faire de prendre des notes en live au cours d'une partie et d'avoir plusieurs outils sous la main pour aider le mj :
- Possibilité de parler à une IA pour règle de jeu ou élément de lore / campagne au cours d'une partie comme aide mémoire - Onglet dédié aux personnages de la campagne - Onglet dédié aux scènes - Onglet avec dès pour ceux qui souhaitent ; Possibilité de rajouté une note en tant qu'évènement, jet de dès ou encore action du joueur par exemple. D'autres ajouts seront fait dans le futur (notamment des tables aléatoires pour PNJ en live).
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
Component, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges,
|
||||
ElementRef, ViewChild
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
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',
|
||||
standalone: true,
|
||||
imports: [CommonModule, 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user