Mise en ligne de la version 0.2.0
All checks were successful
Build & Push Images / build (brain) (push) Successful in 46s
Build & Push Images / build (core) (push) Successful in 1m21s
Build & Push Images / build (web) (push) Successful in 1m25s

This commit is contained in:
2026-04-21 14:25:17 +02:00
parent ebee8e106b
commit ba8a503b3e
300 changed files with 35329 additions and 1 deletions

View File

@@ -0,0 +1,81 @@
<aside class="drawer" [class.drawer-open]="isOpen" aria-label="Assistant IA">
<header class="drawer-header">
<h2>Assistant IA</h2>
<button type="button" class="close-btn" (click)="onClose()" aria-label="Fermer">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
</header>
<div #messagesContainer class="messages">
<!-- Message d'accueil (non-stocké dans `messages`, toujours visible tant que la conversation est vide). -->
<div class="msg msg-assistant" *ngIf="messages.length === 0 && !currentAssistantText">
{{ welcomeMessage }}
</div>
<!-- Historique -->
<ng-container *ngFor="let m of messages">
<div class="msg" [class.msg-user]="m.role === 'user'" [class.msg-assistant]="m.role === 'assistant'">
{{ m.content }}
</div>
</ng-container>
<!-- Bulle en cours de streaming -->
<div class="msg msg-assistant msg-streaming" *ngIf="currentAssistantText">
{{ currentAssistantText }}<span class="caret"></span>
</div>
<!-- Indicateur pendant la phase "en train de réfléchir" (avant le premier token) -->
<div class="typing-indicator" *ngIf="isStreaming && !currentAssistantText" aria-live="polite">
<span></span><span></span><span></span>
</div>
<!-- Erreur locale au drawer -->
<div class="msg msg-error" *ngIf="errorMessage" role="alert">
{{ errorMessage }}
</div>
</div>
<!-- Action primaire (optionnelle) : ne passe PAS par le chat -->
<div class="primary-action" *ngIf="primaryAction">
<button
type="button"
class="primary-btn"
(click)="onPrimaryAction()"
[disabled]="isStreaming">
<lucide-icon [img]="Wand2" [size]="14"></lucide-icon>
{{ primaryAction.label }}
</button>
</div>
<!-- Suggestions rapides -->
<div class="quick-suggestions" *ngIf="quickSuggestions.length">
<p class="quick-label">Suggestions rapides :</p>
<div class="quick-list">
<button
type="button"
class="quick-btn"
*ngFor="let s of quickSuggestions"
(click)="useQuickSuggestion(s)"
[disabled]="isStreaming">
<lucide-icon [img]="Lightbulb" [size]="12"></lucide-icon>
{{ s }}
</button>
</div>
</div>
<!-- Zone de saisie -->
<form class="input-row" (ngSubmit)="send()">
<input
type="text"
[(ngModel)]="input"
name="chatInput"
placeholder="Posez une question..."
[disabled]="isStreaming"
autocomplete="off" />
<button type="submit" class="send-btn" [disabled]="!input.trim() || isStreaming" aria-label="Envoyer">
<lucide-icon [img]="Send" [size]="16"></lucide-icon>
</button>
</form>
</aside>

View File

@@ -0,0 +1,261 @@
:host {
// Le drawer lui-même gère son positionnement fixed. Rien à prévoir côté host.
display: contents;
}
.drawer {
position: fixed;
top: 0;
right: 0;
width: 380px;
height: 100vh;
background: #0f0f1a;
border-left: 1px solid #1e1e3a;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.25s ease;
z-index: 1000;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.4);
}
.drawer-open {
transform: translateX(0);
}
// --- Header --------------------------------------------------------------
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid #1e1e3a;
flex-shrink: 0;
h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: white;
}
.close-btn {
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 0.25rem;
border-radius: 4px;
display: flex;
&:hover {
color: white;
background: #1e1e3a;
}
}
}
// --- Zone messages -------------------------------------------------------
.messages {
flex: 1;
overflow-y: auto;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.msg {
max-width: 85%;
padding: 0.6rem 0.85rem;
border-radius: 10px;
font-size: 0.88rem;
line-height: 1.45;
white-space: pre-wrap;
word-wrap: break-word;
}
.msg-assistant {
align-self: flex-start;
background: #1a1a2e;
color: #e5e7eb;
border: 1px solid #2a2a3d;
}
.msg-user {
align-self: flex-end;
background: #6c63ff;
color: white;
}
.msg-streaming {
// Le caret clignotant à la fin donne la sensation de "l'IA est en train d'écrire"
.caret {
display: inline-block;
width: 6px;
height: 1em;
background: #a5b4fc;
margin-left: 2px;
vertical-align: text-bottom;
animation: blink 1s step-end infinite;
}
}
.msg-error {
align-self: stretch;
background: #3f1f1f;
color: #fca5a5;
border: 1px solid #7f1d1d;
font-size: 0.85rem;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
// --- Indicateur "typing" -------------------------------------------------
.typing-indicator {
display: flex;
gap: 0.3rem;
padding: 0.6rem 0.85rem;
align-self: flex-start;
span {
width: 6px;
height: 6px;
background: #6c63ff;
border-radius: 50%;
animation: bounce 1.2s infinite ease-in-out both;
}
span:nth-child(2) { animation-delay: 0.15s; }
span:nth-child(3) { animation-delay: 0.3s; }
}
@keyframes bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
// --- Action primaire -----------------------------------------------------
.primary-action {
padding: 0.75rem 1.25rem 0;
flex-shrink: 0;
.primary-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
width: 100%;
padding: 0.65rem 0.9rem;
background: #6c63ff;
color: white;
border: none;
border-radius: 6px;
font-size: 0.88rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
&:hover:not(:disabled) { background: #5a52e0; }
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
// --- Suggestions rapides -------------------------------------------------
.quick-suggestions {
padding: 0.75rem 1.25rem 0;
border-top: 1px solid #1e1e3a;
flex-shrink: 0;
.quick-label {
font-size: 0.75rem;
color: #9ca3af;
margin: 0 0 0.5rem;
}
.quick-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.quick-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.6rem;
background: #1a1a2e;
border: 1px solid #2a2a3d;
border-radius: 4px;
color: #d1d5db;
font-size: 0.78rem;
cursor: pointer;
transition: background 0.15s;
&:hover:not(:disabled) {
background: #2a2a3d;
color: white;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
// --- Zone de saisie ------------------------------------------------------
.input-row {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1.25rem 1rem;
flex-shrink: 0;
input {
flex: 1;
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
padding: 0.55rem 0.75rem;
border-radius: 6px;
font-size: 0.88rem;
font-family: inherit;
&:focus {
outline: none;
border-color: #6c63ff;
}
&:disabled {
opacity: 0.5;
}
}
.send-btn {
background: #6c63ff;
color: white;
border: none;
border-radius: 6px;
width: 38px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
&:hover:not(:disabled) { background: #5a52e0; }
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}

View File

@@ -0,0 +1,199 @@
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, X, Send, Sparkles, Lightbulb, Wand2 } from 'lucide-angular';
import { Subscription } from 'rxjs';
import { AiChatService, ChatMessage, NarrativeEntityType } from '../../services/ai-chat.service';
/**
* Action primaire optionnelle rendue en gros bouton au-dessus des suggestions.
* Utilisée pour les actions "spéciales" qui NE passent PAS par le chat
* (ex: "Remplir automatiquement tous les champs" → déclenche le one-shot b4).
*/
export interface ChatPrimaryAction {
label: string;
}
/**
* Drawer de chat IA réutilisable — panneau fixe à droite de l'écran.
*
* Usage minimal :
* <app-ai-chat-drawer
* [loreId]="loreId"
* [isOpen]="chatOpen"
* [quickSuggestions]="['Développe l'histoire', ...]"
* (close)="chatOpen = false">
* </app-ai-chat-drawer>
*
* Contrainte de design : conversation éphémère (on perd tout à la fermeture
* ou à la destruction du composant — choix MVP assumé).
*/
@Component({
selector: 'app-ai-chat-drawer',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './ai-chat-drawer.component.html',
styleUrls: ['./ai-chat-drawer.component.scss']
})
export class AiChatDrawerComponent implements OnDestroy {
readonly X = X;
readonly Send = Send;
readonly Sparkles = Sparkles;
readonly Lightbulb = Lightbulb;
readonly Wand2 = Wand2;
/**
* Mode Lore : fournir `loreId` (et optionnellement `pageId`).
* Mode Campagne : fournir `campaignId` (et optionnellement `entityType`+`entityId`).
* Les deux modes sont exclusifs — si `campaignId` est non-vide, on route
* vers l'endpoint Campagne, sinon vers l'endpoint Lore.
*/
@Input() loreId = '';
/**
* Optionnel : ID d'une page précise en cours d'édition. Si fourni, le
* backend focalise l'IA sur cette page (template, champs, valeurs) via
* un bloc "PAGE EN COURS" dans le system prompt. Sans cet ID, le chat
* reste générique au Lore.
*/
@Input() pageId: string | null = null;
/** ID de la Campagne — active le mode chat Campagne si non-vide. */
@Input() campaignId: string | null = null;
/** Optionnel : "arc"|"chapter"|"scene" — focalise l'IA sur une entité narrative. */
@Input() entityType: NarrativeEntityType | null = null;
/** Optionnel : ID de l'entité narrative en cours d'édition. */
@Input() entityId: string | null = null;
@Input() isOpen = false;
/** Texte accueil affiché au premier ouverture (avant tout échange). */
@Input() welcomeMessage = 'Bonjour ! Je peux vous aider à développer cette page. Que souhaitez-vous créer ?';
/** Suggestions rapides cliquables en bas (hardcodées par le parent, MVP). */
@Input() quickSuggestions: string[] = [];
/** Action primaire optionnelle (ex: "Remplir automatiquement") — ne passe PAS par le chat. */
@Input() primaryAction: ChatPrimaryAction | null = null;
/**
* Instructions système supplémentaires injectées en tête de la conversation
* envoyée au backend, INVISIBLES côté UI. Usage : mode wizard, où on veut
* contextualiser l'IA (template cible, format JSON attendu) sans polluer
* l'historique visuel.
*/
@Input() systemPromptAddon: string | null = null;
@Output() close = new EventEmitter<void>();
/** Émis au clic sur l'action primaire — le parent gère entièrement (one-shot, etc.). */
@Output() primaryActionClick = new EventEmitter<void>();
/** Émis à chaque fin de réponse assistant — utile pour parser côté parent (ex: bloc <values> du wizard). */
@Output() assistantReply = new EventEmitter<string>();
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
/** Conversation en cours (user + assistant). Le welcome n'est pas dedans — rendu séparément. */
messages: ChatMessage[] = [];
/** Texte en cours de streaming (écrit token par token, pas encore poussé dans `messages`). */
currentAssistantText = '';
/** Champ de saisie. */
input = '';
/** Stream en cours ? Désactive le bouton envoyer + les suggestions rapides. */
isStreaming = false;
/** Dernier message d'erreur (affiché dans une bannière locale au drawer). */
errorMessage: string | null = null;
private streamSub: Subscription | null = null;
constructor(private readonly chatService: AiChatService) {}
// --- Handlers UI --------------------------------------------------------
onClose(): void {
this.abortStream();
this.close.emit();
}
/** Envoi explicite depuis le formulaire (Entrée ou bouton envoyer). */
send(): void {
const text = this.input.trim();
if (!text || this.isStreaming) return;
this.sendUserMessage(text);
this.input = '';
}
/** Envoi depuis une suggestion rapide (bouton cliquable en bas). */
useQuickSuggestion(suggestion: string): void {
if (this.isStreaming) return;
this.sendUserMessage(suggestion);
}
/** Clic sur l'action primaire — on délègue entièrement au parent. */
onPrimaryAction(): void {
if (this.isStreaming) return;
this.primaryActionClick.emit();
}
// --- Logique envoi + streaming -----------------------------------------
private sendUserMessage(text: string): void {
this.errorMessage = null;
this.messages.push({ role: 'user', content: text });
this.currentAssistantText = '';
this.isStreaming = true;
this.scrollToBottom();
// Construit la liste effectivement envoyée au backend : systemPromptAddon
// (si fourni) préfixé, puis l'historique visible. Le system n'est PAS stocké
// dans this.messages → reste invisible côté UI.
const payload = this.systemPromptAddon
? [{ role: 'system' as const, content: this.systemPromptAddon }, ...this.messages]
: this.messages;
const stream$ = this.campaignId
? this.chatService.streamChatForCampaign(this.campaignId, payload, this.entityType, this.entityId)
: this.chatService.streamChat(this.loreId, payload, this.pageId);
this.streamSub = stream$.subscribe({
next: (event) => {
if (event.type === 'token') {
this.currentAssistantText += event.value;
this.scrollToBottom();
}
// 'done' : l'Observable va compléter → géré par complete()
},
error: (err) => {
this.isStreaming = false;
this.errorMessage = err?.message ?? 'Erreur inconnue.';
this.currentAssistantText = '';
},
complete: () => {
// On fige le texte streamé en message assistant réel, puis on reset le buffer.
const reply = this.currentAssistantText;
if (reply) {
this.messages.push({ role: 'assistant', content: reply });
this.assistantReply.emit(reply);
}
this.currentAssistantText = '';
this.isStreaming = false;
this.scrollToBottom();
}
});
}
private abortStream(): void {
this.streamSub?.unsubscribe();
this.streamSub = null;
this.isStreaming = false;
this.currentAssistantText = '';
}
/**
* Scroll différé au prochain tick : donne à Angular le temps de rendre
* le nouveau contenu avant qu'on mesure/ajuste la position du scroll.
*/
private scrollToBottom(): void {
queueMicrotask(() => {
const el = this.messagesContainer?.nativeElement;
if (el) el.scrollTop = el.scrollHeight;
});
}
ngOnDestroy(): void {
this.abortStream();
}
}