import { Component, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { LucideAngularModule, ArrowLeft, Upload, Trash2, Send, FileText, Loader, CheckCircle2, AlertCircle, Sparkles, Layers, Eraser, History, X } from 'lucide-angular'; import { NotebookService } from '../../../services/notebook.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { CampaignService } from '../../../services/campaign.service'; import { NpcService } from '../../../services/npc.service'; import { EnemyService } from '../../../services/enemy.service'; import { NotebookArchive, NotebookDetail, NotebookSource, NotebookMessage } from '../../../services/notebook.model'; import { parseNotebookActions, NotebookAction } from '../../../services/notebook-action.model'; import { Arc, Chapter } from '../../../services/campaign.model'; import { loadCampaignTreeData } from '../../campaign-tree.helper'; import { NotebookActionCardComponent } from '../notebook-action-card/notebook-action-card.component'; import { MarkdownPipe } from '../../../shared/markdown.pipe'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; /** * Atelier (workspace) : sources indexées (gauche) + chat ancré RAG (droite). * Route : /campaigns/:campaignId/notebooks/:notebookId */ @Component({ selector: 'app-notebook-detail', imports: [FormsModule, LucideAngularModule, NotebookActionCardComponent, MarkdownPipe, TranslatePipe], templateUrl: './notebook-detail.component.html', styleUrls: ['./notebook-detail.component.scss'] }) export class NotebookDetailComponent implements OnInit { readonly ArrowLeft = ArrowLeft; readonly Upload = Upload; readonly Trash2 = Trash2; readonly Send = Send; readonly FileText = FileText; readonly Loader = Loader; readonly CheckCircle2 = CheckCircle2; readonly AlertCircle = AlertCircle; readonly Sparkles = Sparkles; readonly Layers = Layers; readonly Eraser = Eraser; readonly History = History; readonly X = X; campaignId = ''; notebookId = ''; detail: NotebookDetail | null = null; sources: NotebookSource[] = []; messages: NotebookMessage[] = []; uploading = false; uploadError = ''; sending = false; draft = ''; /** Avancement de l'analyse approfondie (lecture du doc par lots). */ deepProgress: { current: number; total: number } | null = null; // Arbre de la campagne — sert aux cartes d'action (cibles arc/chapitre). arcs: Arc[] = []; chaptersByArc: Record = {}; constructor( private route: ActivatedRoute, private router: Router, private service: NotebookService, private campaignSidebar: CampaignSidebarService, private campaignService: CampaignService, private npcService: NpcService, private enemyService: EnemyService, private confirmDialog: ConfirmDialogService, private translate: TranslateService ) {} ngOnInit(): void { this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? ''; this.notebookId = this.route.snapshot.paramMap.get('notebookId') ?? ''; if (this.campaignId) { this.campaignSidebar.show(this.campaignId); this.loadTree(); } this.load(); } private loadTree(): void { loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, undefined, this.enemyService) .subscribe({ next: (data) => { // Pas d'envoi de contenu généré vers l'arc SYSTEM (plomberie des quêtes libres). this.arcs = data.arcs.filter(a => a.type !== 'SYSTEM'); this.chaptersByArc = data.chaptersByArc; }, error: () => { /* cibles indisponibles : les cartes le signaleront */ } }); } /** Après création depuis une carte : rafraîchit la sidebar (l'élément apparaît) * et l'arbre des cibles (pour les cartes suivantes). */ onActionCreated(): void { if (this.campaignId) { this.campaignSidebar.show(this.campaignId); this.loadTree(); } } /** Sépare le texte affiché des blocs d'action — MÉMORISÉ par message : renvoie * une référence STABLE tant que le contenu n'a pas changé. Indispensable : * appeler le parseur directement dans le template recréait le DOM à chaque * détection de changement (au mousedown) → les clics sur les cartes étaient * perdus. */ parsedOf(m: NotebookMessage): { text: string; actions: NotebookAction[] } { const cache = m as unknown as { _parsedFor?: string; _parsed?: { text: string; actions: NotebookAction[] }; }; if (cache._parsedFor !== m.content || !cache._parsed) { cache._parsed = parseNotebookActions(m.content); cache._parsedFor = m.content; } return cache._parsed; } /** * Libellé compact des pages utilisées par le RAG pour une réponse * (« p. 12, 47, 103 » — préfixé du nom du fichier si plusieurs sources). */ sourcesLabel(m: NotebookMessage): string { const src = m.sources ?? []; if (!src.length) return ''; const bySource = new Map(); for (const s of src) { if (s.page == null) continue; const pages = bySource.get(s.sourceId) ?? []; if (!pages.includes(s.page)) pages.push(s.page); bySource.set(s.sourceId, pages); } if (bySource.size === 0) return ''; const nameOf = (id: string) => this.sources.find(x => x.id === id)?.filename ?? ''; return [...bySource.entries()] .map(([id, pages]) => { const label = `p. ${pages.sort((a, b) => a - b).join(', ')}`; return bySource.size > 1 && nameOf(id) ? `${nameOf(id)} — ${label}` : label; }) .join(' · '); } load(): void { this.service.get(this.notebookId).subscribe({ next: (d) => { this.detail = d; this.sources = d.sources ?? []; this.messages = d.messages ?? []; this.syncSelection(); }, error: () => this.back() }); } reloadSources(): void { this.service.get(this.notebookId).subscribe({ next: (d) => { this.sources = d.sources ?? []; this.syncSelection(); } }); } // --- Sélection des sources utilisées par le chat ------------------------- // Par défaut tout est coché ; décocher permet de limiter une question (et // surtout une analyse approfondie, coûteuse en requêtes) à certains PDF. /** IDs des sources cochées (sous-ensemble des sources READY). */ selectedSourceIds = new Set(); /** IDs déjà vus — pour ne cocher par défaut que les NOUVELLES sources. */ private knownSourceIds = new Set(); /** Aligne la sélection sur la liste courante : nouvelles sources cochées par * défaut, sources supprimées retirées, choix de l'utilisateur préservés. */ private syncSelection(): void { const readyIds = new Set(this.sources.filter(s => s.status === 'READY').map(s => s.id)); for (const id of readyIds) { if (!this.knownSourceIds.has(id)) { this.selectedSourceIds.add(id); this.knownSourceIds.add(id); } } for (const id of [...this.selectedSourceIds]) { if (!readyIds.has(id)) this.selectedSourceIds.delete(id); } } isSelected(s: NotebookSource): boolean { return this.selectedSourceIds.has(s.id); } toggleSource(s: NotebookSource): void { if (this.selectedSourceIds.has(s.id)) this.selectedSourceIds.delete(s.id); else this.selectedSourceIds.add(s.id); } get readySourceCount(): number { return this.sources.filter(s => s.status === 'READY').length; } // --- Sources --- onFile(event: Event): void { const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; if (!file) return; this.uploading = true; this.uploadError = ''; this.service.addSource(this.notebookId, file).subscribe({ next: () => { this.uploading = false; this.reloadSources(); }, error: (err) => { this.uploading = false; this.uploadError = err?.error?.message || this.translate.instant('notebookDetail.uploadError'); this.reloadSources(); } }); } removeSource(s: NotebookSource): void { this.service.deleteSource(s.id).subscribe(() => this.reloadSources()); } // --- Vider la conversation / archives ------------------------------------- // « Vider » ARCHIVE le fil (rien n'est supprimé) ; les archives restent // consultables en lecture seule via le sélecteur. /** Conversations archivées (chargées à l'ouverture du panneau). */ archives: NotebookArchive[] = []; /** Archive affichée en lecture seule (null = chat actif). */ viewingArchive: NotebookArchive | null = null; /** Panneau « archives » ouvert ? */ archivesOpen = false; clearChat(): void { if (this.sending || this.messages.length === 0) return; this.confirmDialog.confirm({ title: this.translate.instant('notebookDetail.clearTitle'), message: this.translate.instant('notebookDetail.clearMessage'), details: [this.translate.instant('notebookDetail.clearDetail')], confirmLabel: this.translate.instant('notebookDetail.clear'), variant: 'danger' }).then(ok => { if (!ok) return; this.service.clearChat(this.notebookId).subscribe({ next: () => { this.messages = []; this.archives = []; // re-chargées à la prochaine ouverture du panneau this.archivesOpen = false; }, error: () => { /* le fil reste affiché : rien n'a été modifié côté serveur */ } }); }); } toggleArchives(): void { this.archivesOpen = !this.archivesOpen; if (this.archivesOpen && this.archives.length === 0) { this.service.getArchives(this.notebookId).subscribe({ next: (a) => this.archives = a, error: () => { this.archives = []; } }); } } /** * Archives cochées « en référence » : leur contenu est injecté dans le * contexte de chaque tour de chat (normal et approfondi). Clés = archivedAt. */ referencedArchiveIds = new Set(); isReferenced(a: NotebookArchive): boolean { return this.referencedArchiveIds.has(a.archivedAt); } toggleReference(a: NotebookArchive): void { if (this.referencedArchiveIds.has(a.archivedAt)) this.referencedArchiveIds.delete(a.archivedAt); else this.referencedArchiveIds.add(a.archivedAt); } openArchive(a: NotebookArchive): void { this.viewingArchive = a; this.archivesOpen = false; } closeArchive(): void { this.viewingArchive = null; } /** Libellé d'une archive : date du clear + nb d'échanges. */ archiveLabel(a: NotebookArchive): string { const date = new Date(a.archivedAt); const locale = this.translate.getCurrentLang() === 'en' ? 'en-US' : 'fr-FR'; const when = isNaN(date.getTime()) ? a.archivedAt : date.toLocaleString(locale, { dateStyle: 'short', timeStyle: 'short' }); return this.translate.instant('notebookDetail.archiveLabel', { when, n: a.messages.length }); } // --- Chat --- send(deep = false): void { const text = this.draft.trim(); if (!text || this.sending) return; this.draft = ''; this.deepProgress = null; this.messages.push({ role: 'user', content: text }); const assistant: NotebookMessage = { role: 'assistant', content: '' }; this.messages.push(assistant); this.sending = true; this.service.streamChat( this.notebookId, text, deep, [...this.selectedSourceIds], [...this.referencedArchiveIds] ).subscribe({ next: (ev) => { if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; } else if (ev.type === 'sources') assistant.sources = ev.sources; else if (ev.type === 'progress') this.deepProgress = { current: ev.current, total: ev.total }; else if (ev.type === 'error') assistant.content += (assistant.content ? '\n\n' : '') + `⚠️ ${ev.message}`; }, complete: () => { this.sending = false; this.deepProgress = null; }, error: () => { this.sending = false; this.deepProgress = null; } }); } rename(): void { if (!this.detail || !this.detail.name.trim()) return; this.service.rename(this.notebookId, this.detail.name.trim()).subscribe(); } back(): void { this.router.navigate(['/campaigns', this.campaignId, 'notebooks']); } hasReadySource(): boolean { return this.sources.some(s => s.status === 'READY'); } }