398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
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<void>();
|
|
@Output() primaryActionClick = new EventEmitter<void>();
|
|
@Output() assistantReply = new EventEmitter<string>();
|
|
|
|
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
|
|
|
|
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<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);
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
});
|
|
}
|
|
}
|