Chat persistant pour la partie lore et la partie campagne pour chaque page / scène.....
Correction du carroussel Passage en v0.4.0 Correction du docker compose pour tout le temps utiliser le bon port que ce soit prod ou dev
This commit is contained in:
@@ -1,105 +1,246 @@
|
||||
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges, ViewChild } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, X, Send, Sparkles, Lightbulb, Wand2 } from 'lucide-angular';
|
||||
import { LucideAngularModule, Lightbulb, MessageSquarePlus, PanelLeftClose, PanelLeftOpen, Pencil, Send, Sparkles, Trash2, Wand2, X } from 'lucide-angular';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { AiChatService, ChatMessage, NarrativeEntityType } from '../../services/ai-chat.service';
|
||||
import { AiChatService, ChatMessage, ChatUsage, NarrativeEntityType } from '../../services/ai-chat.service';
|
||||
import { Conversation, ConversationContext } from '../../services/conversation.model';
|
||||
import { ConversationService } from '../../services/conversation.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).
|
||||
* Utilisee pour les actions "speciales" qui NE passent PAS par le chat
|
||||
* (ex: "Remplir automatiquement tous les champs" → declenche le one-shot b4).
|
||||
*/
|
||||
export interface ChatPrimaryAction {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drawer de chat IA réutilisable — panneau fixe à droite de l'écran.
|
||||
* Drawer de chat IA reutilisable — panneau fixe a droite de l'ecran.
|
||||
*
|
||||
* 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é).
|
||||
* Deux modes :
|
||||
* - `persistent = true` (defaut) : sidebar + conversations persistees en base,
|
||||
* filtrees par contexte (loreId/campaignId + optionnellement entityType+Id).
|
||||
* Les messages sont persistes en base au fil du chat et un titre automatique
|
||||
* est genere apres le 1er echange.
|
||||
* - `persistent = false` : mode ephemere (pour le wizard de generation de page,
|
||||
* ou la conversation n'a aucune valeur au-dela de l'usage immediat).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-ai-chat-drawer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './ai-chat-drawer.component.html',
|
||||
styleUrls: ['./ai-chat-drawer.component.scss']
|
||||
styleUrls: ['./ai-chat-drawer.component.scss'],
|
||||
})
|
||||
export class AiChatDrawerComponent implements OnDestroy {
|
||||
export class AiChatDrawerComponent implements OnChanges, OnDestroy {
|
||||
readonly X = X;
|
||||
readonly Send = Send;
|
||||
readonly Sparkles = Sparkles;
|
||||
readonly Lightbulb = Lightbulb;
|
||||
readonly Wand2 = Wand2;
|
||||
readonly MessageSquarePlus = MessageSquarePlus;
|
||||
readonly PanelLeftClose = PanelLeftClose;
|
||||
readonly PanelLeftOpen = PanelLeftOpen;
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
|
||||
/**
|
||||
* 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() welcomeMessage = 'Bonjour ! Je peux vous aider a developper cette page. Que souhaitez-vous creer ?';
|
||||
@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;
|
||||
/** Persistance activee ? false = mode wizard ephemere. */
|
||||
@Input() persistent = true;
|
||||
|
||||
@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;
|
||||
usage: ChatUsage | null = null;
|
||||
|
||||
// --- Persistance --------------------------------------------------------
|
||||
|
||||
/** Liste visible dans la sidebar pour le contexte courant. */
|
||||
conversations: Conversation[] = [];
|
||||
/** Conversation actuellement chargee (null = nouvelle conversation vierge). */
|
||||
currentConversationId: string | null = null;
|
||||
/** Titre de la conversation courante (affiche dans le header). */
|
||||
currentTitle = '';
|
||||
/** Mode edition inline du titre. */
|
||||
editingTitle = false;
|
||||
titleDraft = '';
|
||||
/** Etat repliable de la sidebar. */
|
||||
sidebarOpen = true;
|
||||
|
||||
private streamSub: Subscription | null = null;
|
||||
|
||||
constructor(private readonly chatService: AiChatService) {}
|
||||
constructor(
|
||||
private readonly chatService: AiChatService,
|
||||
private readonly conversationService: ConversationService,
|
||||
) {}
|
||||
|
||||
// --- Jauge de contexte --------------------------------------------------
|
||||
|
||||
get usageTotal(): number {
|
||||
if (!this.usage) return 0;
|
||||
return this.usage.system + this.usage.history + this.usage.current;
|
||||
}
|
||||
get usageRatio(): number {
|
||||
if (!this.usage || this.usage.max <= 0) return 0;
|
||||
return Math.min(1, this.usageTotal / this.usage.max);
|
||||
}
|
||||
get usagePercent(): number {
|
||||
return Math.round(this.usageRatio * 100);
|
||||
}
|
||||
get usageLevel(): 'low' | 'mid' | 'high' {
|
||||
const r = this.usageRatio;
|
||||
if (r > 0.8) return 'high';
|
||||
if (r >= 0.5) return 'mid';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
// --- Cycle de vie -------------------------------------------------------
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (!this.persistent) return;
|
||||
const contextChanged =
|
||||
changes['loreId'] || changes['pageId'] || changes['campaignId'] || changes['entityType'] || changes['entityId'];
|
||||
const openedNow = changes['isOpen'] && this.isOpen;
|
||||
if (contextChanged || openedNow) {
|
||||
this.resetConversationState();
|
||||
this.reloadConversations();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.abortStream();
|
||||
}
|
||||
|
||||
// --- Sidebar : listing / nouveau / select / rename / delete ------------
|
||||
|
||||
private buildContext(): ConversationContext {
|
||||
// Cote Lore : pageId joue le role de focus entite (entityType="page").
|
||||
// Cote Campagne : entityType + entityId sont deja fournis directement.
|
||||
if (this.loreId) {
|
||||
return {
|
||||
loreId: this.loreId,
|
||||
campaignId: null,
|
||||
entityType: this.pageId ? 'page' : null,
|
||||
entityId: this.pageId ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
loreId: null,
|
||||
campaignId: this.campaignId || null,
|
||||
entityType: this.entityType,
|
||||
entityId: this.entityId,
|
||||
};
|
||||
}
|
||||
|
||||
reloadConversations(): void {
|
||||
if (!this.persistent) return;
|
||||
const ctx = this.buildContext();
|
||||
if (!ctx.loreId && !ctx.campaignId) {
|
||||
this.conversations = [];
|
||||
return;
|
||||
}
|
||||
this.conversationService.list(ctx).subscribe({
|
||||
next: (rows) => (this.conversations = rows),
|
||||
error: () => (this.conversations = []),
|
||||
});
|
||||
}
|
||||
|
||||
startNewConversation(): void {
|
||||
if (this.isStreaming) return;
|
||||
this.resetConversationState();
|
||||
}
|
||||
|
||||
private resetConversationState(): void {
|
||||
this.currentConversationId = null;
|
||||
this.currentTitle = '';
|
||||
this.messages = [];
|
||||
this.currentAssistantText = '';
|
||||
this.errorMessage = null;
|
||||
this.usage = null;
|
||||
this.editingTitle = false;
|
||||
}
|
||||
|
||||
selectConversation(conv: Conversation): void {
|
||||
if (this.isStreaming) return;
|
||||
this.conversationService.getById(conv.id).subscribe({
|
||||
next: (full) => {
|
||||
this.currentConversationId = full.id;
|
||||
this.currentTitle = full.title;
|
||||
this.messages = (full.messages ?? [])
|
||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }));
|
||||
this.currentAssistantText = '';
|
||||
this.errorMessage = null;
|
||||
this.usage = null;
|
||||
this.scrollToBottom();
|
||||
},
|
||||
error: () => (this.errorMessage = 'Impossible de charger la conversation.'),
|
||||
});
|
||||
}
|
||||
|
||||
deleteConversation(conv: Conversation, event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (this.isStreaming) return;
|
||||
if (!confirm(`Supprimer la conversation "${conv.title}" ?`)) return;
|
||||
this.conversationService.delete(conv.id).subscribe({
|
||||
next: () => {
|
||||
this.conversations = this.conversations.filter((c) => c.id !== conv.id);
|
||||
if (this.currentConversationId === conv.id) this.resetConversationState();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
startRenameTitle(): void {
|
||||
if (!this.currentConversationId) return;
|
||||
this.titleDraft = this.currentTitle;
|
||||
this.editingTitle = true;
|
||||
}
|
||||
|
||||
cancelRenameTitle(): void {
|
||||
this.editingTitle = false;
|
||||
this.titleDraft = '';
|
||||
}
|
||||
|
||||
submitRenameTitle(): void {
|
||||
const t = this.titleDraft.trim();
|
||||
if (!t || !this.currentConversationId) {
|
||||
this.cancelRenameTitle();
|
||||
return;
|
||||
}
|
||||
const id = this.currentConversationId;
|
||||
this.conversationService.rename(id, t).subscribe({
|
||||
next: () => {
|
||||
this.currentTitle = t;
|
||||
this.conversations = this.conversations.map((c) =>
|
||||
c.id === id ? { ...c, title: t } : c,
|
||||
);
|
||||
this.editingTitle = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleSidebar(): void {
|
||||
this.sidebarOpen = !this.sidebarOpen;
|
||||
}
|
||||
|
||||
// --- Handlers UI --------------------------------------------------------
|
||||
|
||||
@@ -108,7 +249,6 @@ export class AiChatDrawerComponent implements OnDestroy {
|
||||
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;
|
||||
@@ -116,45 +256,114 @@ export class AiChatDrawerComponent implements OnDestroy {
|
||||
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 -----------------------------------------
|
||||
// --- Envoi + streaming --------------------------------------------------
|
||||
|
||||
private sendUserMessage(text: string): void {
|
||||
if (this.persistent) {
|
||||
this.ensureConversation().then((convId) => {
|
||||
if (convId) this.streamAndPersist(text, convId);
|
||||
});
|
||||
} else {
|
||||
this.streamEphemeral(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree la conversation cote serveur si elle n'existe pas encore. Resolu
|
||||
* avec l'id, ou null sur erreur (auquel cas on n'envoie pas).
|
||||
*/
|
||||
private ensureConversation(): Promise<string | null> {
|
||||
if (this.currentConversationId) return Promise.resolve(this.currentConversationId);
|
||||
const ctx = this.buildContext();
|
||||
if (!ctx.loreId && !ctx.campaignId) {
|
||||
this.errorMessage = 'Contexte manquant pour creer une conversation.';
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.conversationService.create(ctx).subscribe({
|
||||
next: (conv) => {
|
||||
this.currentConversationId = conv.id;
|
||||
this.currentTitle = conv.title;
|
||||
this.conversations = [conv, ...this.conversations];
|
||||
resolve(conv.id);
|
||||
},
|
||||
error: () => {
|
||||
this.errorMessage = 'Impossible de creer la conversation.';
|
||||
resolve(null);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private streamAndPersist(text: string, convId: string): void {
|
||||
const wasEmpty = this.messages.length === 0;
|
||||
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;
|
||||
// Persiste le message user immediatement — evite toute perte si stream interrompu.
|
||||
this.conversationService.appendMessage(convId, 'user', text).subscribe({ error: () => {} });
|
||||
|
||||
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({
|
||||
this.streamSub = this.buildStream().subscribe({
|
||||
next: (event) => {
|
||||
if (event.type === 'token') {
|
||||
this.currentAssistantText += event.value;
|
||||
this.scrollToBottom();
|
||||
} else if (event.type === 'usage') {
|
||||
this.usage = event.usage;
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.isStreaming = false;
|
||||
this.errorMessage = err?.message ?? 'Erreur inconnue.';
|
||||
this.currentAssistantText = '';
|
||||
},
|
||||
complete: () => {
|
||||
const reply = this.currentAssistantText;
|
||||
if (reply) {
|
||||
this.messages.push({ role: 'assistant', content: reply });
|
||||
this.assistantReply.emit(reply);
|
||||
this.conversationService.appendMessage(convId, 'assistant', reply).subscribe({
|
||||
next: () => {
|
||||
if (wasEmpty) this.triggerAutoTitle(convId);
|
||||
},
|
||||
error: () => {},
|
||||
});
|
||||
}
|
||||
this.currentAssistantText = '';
|
||||
this.isStreaming = false;
|
||||
this.scrollToBottom();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private streamEphemeral(text: string): void {
|
||||
this.errorMessage = null;
|
||||
this.messages.push({ role: 'user', content: text });
|
||||
this.currentAssistantText = '';
|
||||
this.isStreaming = true;
|
||||
this.scrollToBottom();
|
||||
|
||||
this.streamSub = this.buildStream().subscribe({
|
||||
next: (event) => {
|
||||
if (event.type === 'token') {
|
||||
this.currentAssistantText += event.value;
|
||||
this.scrollToBottom();
|
||||
} else if (event.type === 'usage') {
|
||||
this.usage = event.usage;
|
||||
}
|
||||
// 'done' : l'Observable va compléter → géré par complete()
|
||||
},
|
||||
error: (err) => {
|
||||
this.isStreaming = false;
|
||||
@@ -162,7 +371,6 @@ export class AiChatDrawerComponent implements OnDestroy {
|
||||
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 });
|
||||
@@ -171,7 +379,28 @@ export class AiChatDrawerComponent implements OnDestroy {
|
||||
this.currentAssistantText = '';
|
||||
this.isStreaming = false;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private buildStream() {
|
||||
const payload = this.systemPromptAddon
|
||||
? [{ role: 'system' as const, content: this.systemPromptAddon }, ...this.messages]
|
||||
: this.messages;
|
||||
return this.campaignId
|
||||
? this.chatService.streamChatForCampaign(this.campaignId, payload, this.entityType, this.entityId)
|
||||
: this.chatService.streamChat(this.loreId, payload, this.pageId);
|
||||
}
|
||||
|
||||
private triggerAutoTitle(convId: string): void {
|
||||
this.conversationService.autoTitle(convId).subscribe({
|
||||
next: ({ title }) => {
|
||||
this.currentTitle = title;
|
||||
this.conversations = this.conversations.map((c) =>
|
||||
c.id === convId ? { ...c, title } : c,
|
||||
);
|
||||
},
|
||||
error: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -182,18 +411,10 @@ export class AiChatDrawerComponent implements OnDestroy {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user