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 :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled

- 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:
2026-05-20 14:59:26 +02:00
parent bc05b1536d
commit e3fc96a1bc
53 changed files with 3614 additions and 17 deletions

View File

@@ -0,0 +1,72 @@
<div class="ai-chat-panel">
<div #messagesContainer class="messages-area">
<div class="welcome-hint" *ngIf="messages.length === 0 && !currentAssistantText && !error">
<lucide-icon [img]="Sparkles" [size]="18"></lucide-icon>
<p>Pose une question à l'IA pendant la partie.</p>
<p class="welcome-sub">
Elle connaît ton univers, ta campagne, les règles du système et tout ce qui a été noté dans le journal.
</p>
</div>
<div *ngFor="let m of messages" class="msg" [class.msg--user]="m.role === 'user'" [class.msg--assistant]="m.role === 'assistant'">
<div class="msg-content">{{ m.content }}</div>
<button *ngIf="m.role === 'assistant'"
type="button"
class="msg-action"
[disabled]="!canSaveToJournal"
[title]="canSaveToJournal ? 'Ajouter cette réponse au journal' : 'Session terminée'"
(click)="onSaveToJournal(m.content)">
<lucide-icon [img]="BookmarkPlus" [size]="12"></lucide-icon>
Au journal
</button>
</div>
<!-- Stream en cours : on affiche les tokens au fil de l'eau. -->
<div *ngIf="currentAssistantText" class="msg msg--assistant msg--streaming">
<div class="msg-content">{{ currentAssistantText }}<span class="cursor"></span></div>
</div>
<p class="error-hint" *ngIf="error">{{ error }}</p>
</div>
<div class="composer">
<textarea
class="composer-input"
[(ngModel)]="input"
name="aiChatInput"
rows="2"
[placeholder]="isStreaming ? 'LIA répond' : 'Demande une idée, un rebondissement, une description'"
[disabled]="isStreaming"
(keydown.control.enter)="send()"></textarea>
<div class="composer-actions">
<button type="button"
class="btn-link"
[disabled]="messages.length === 0 && !currentAssistantText"
(click)="clearConversation()"
title="Effacer la conversation">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
<button *ngIf="!isStreaming"
type="button"
class="btn-primary btn-send"
[disabled]="!input.trim()"
(click)="send()">
<lucide-icon [img]="Send" [size]="14"></lucide-icon>
Envoyer
</button>
<button *ngIf="isStreaming"
type="button"
class="btn-secondary btn-send"
(click)="cancelStream()">
<lucide-icon [img]="Square" [size]="14"></lucide-icon>
Stop
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,168 @@
.ai-chat-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
gap: 0.5rem;
}
.messages-area {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-right: 0.25rem;
}
.welcome-hint {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
color: #9ca3af;
font-size: 0.85rem;
text-align: center;
padding: 1rem 0.5rem;
p { margin: 0; }
.welcome-sub { font-size: 0.75rem; color: #6b7280; font-style: italic; }
}
.msg {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.55rem 0.7rem;
border-radius: 8px;
font-size: 0.85rem;
line-height: 1.45;
word-break: break-word;
white-space: pre-wrap;
}
.msg--user {
align-self: flex-end;
max-width: 90%;
background: #1e3a5f;
color: #dbeafe;
}
.msg--assistant {
align-self: flex-start;
max-width: 95%;
background: #111827;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.msg--streaming {
opacity: 0.95;
}
.msg-content {
white-space: pre-wrap;
}
.msg-action {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: transparent;
border: 1px dashed #374151;
color: #9ca3af;
font-size: 0.7rem;
padding: 0.25rem 0.55rem;
border-radius: 999px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
&:hover:not(:disabled) {
border-color: #6c63ff;
color: #c4bdff;
background: rgba(108, 99, 255, 0.08);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.cursor {
color: #6c63ff;
animation: blink 1s steps(2) infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
.error-hint {
color: #f87171;
font-size: 0.8rem;
font-style: italic;
margin: 0.25rem 0;
}
// ─────────────── Composer ───────────────
.composer {
display: flex;
flex-direction: column;
gap: 0.4rem;
border-top: 1px solid #1f2937;
padding-top: 0.5rem;
}
.composer-input {
width: 100%;
background: #111827;
border: 1px solid #1f2937;
color: #e5e7eb;
font-family: inherit;
font-size: 0.85rem;
padding: 0.55rem 0.7rem;
border-radius: 6px;
resize: vertical;
min-height: 50px;
&:focus { outline: none; border-color: #6c63ff; }
&:disabled { opacity: 0.7; }
}
.composer-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.45rem;
}
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: none;
padding: 0.4rem 0.75rem;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
}
.btn-primary { background: #6c63ff; color: white; &:hover:not(:disabled) { background: #5b52e0; } &:disabled { opacity: 0.5; cursor: not-allowed; } }
.btn-secondary{ background: #374151; color: #e5e7eb; &:hover { background: #4b5563; } }
.btn-link {
background: transparent;
border: none;
color: #6b7280;
padding: 0;
cursor: pointer;
margin-right: auto;
&:hover:not(:disabled) { color: #e5e7eb; }
&:disabled { opacity: 0.4; cursor: not-allowed; }
}

View File

@@ -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();
}
}