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, Lightbulb, MessageSquarePlus, PanelLeftClose, PanelLeftOpen, Pencil, Send, Sparkles, Trash2, Wand2, X } from 'lucide-angular'; import { Subscription } from 'rxjs'; 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. * 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 reutilisable — panneau fixe a droite de l'ecran. * * 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'], }) 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; @Input() loreId = ''; @Input() pageId: string | null = null; @Input() campaignId: string | null = null; @Input() entityType: NarrativeEntityType | null = null; @Input() entityId: string | null = null; @Input() isOpen = false; @Input() welcomeMessage = 'Bonjour ! Je peux vous aider a developper cette page. Que souhaitez-vous creer ?'; @Input() quickSuggestions: string[] = []; @Input() primaryAction: ChatPrimaryAction | null = null; @Input() systemPromptAddon: string | null = null; /** Persistance activee ? false = mode wizard ephemere. */ @Input() persistent = true; @Output() close = new EventEmitter(); @Output() primaryActionClick = new EventEmitter(); @Output() assistantReply = new EventEmitter(); @ViewChild('messagesContainer') messagesContainer?: ElementRef; messages: ChatMessage[] = []; currentAssistantText = ''; input = ''; isStreaming = false; 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, 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 -------------------------------------------------------- onClose(): void { this.abortStream(); this.close.emit(); } send(): void { const text = this.input.trim(); if (!text || this.isStreaming) return; this.sendUserMessage(text); this.input = ''; } useQuickSuggestion(suggestion: string): void { if (this.isStreaming) return; this.sendUserMessage(suggestion); } onPrimaryAction(): void { if (this.isStreaming) return; this.primaryActionClick.emit(); } // --- Envoi + streaming -------------------------------------------------- private sendUserMessage(text: string): void { if (this.persistent) { this.ensureConversation().then((convId) => { if (convId) this.stream(text, convId); }); } else { this.stream(text, null); } } /** * 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 { 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); }, }); }); } /** * Stream unifie : persistant si convId est fourni, ephemere sinon. * - Le message user est pousse dans le flux visuel immediatement, puis persiste * (si convId) avant meme l'arrivee du premier token — evite la perte en cas * d'interruption reseau. * - Le message assistant est persiste a la completion, et un titre auto est * declenche lorsqu'il s'agit du tout premier echange de la conversation. */ private stream(text: string, convId: string | null): void { const wasEmpty = this.messages.length === 0; this.errorMessage = null; this.messages.push({ role: 'user', content: text }); this.currentAssistantText = ''; this.isStreaming = true; this.scrollToBottom(); if (convId) { this.conversationService.appendMessage(convId, 'user', text).subscribe({ error: () => {} }); } 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); if (convId) { this.conversationService.appendMessage(convId, 'assistant', reply).subscribe({ next: () => { if (wasEmpty) this.triggerAutoTitle(convId); }, error: () => {}, }); } } 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: () => {}, }); } private abortStream(): void { this.streamSub?.unsubscribe(); this.streamSub = null; this.isStreaming = false; this.currentAssistantText = ''; } private scrollToBottom(): void { queueMicrotask(() => { const el = this.messagesContainer?.nativeElement; if (el) el.scrollTop = el.scrollHeight; }); } }