Files
LoreMind/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts
IETM_FIXE\ietm6 b2c3800bf8
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m38s
Build & Push Images / build (web) (push) Successful in 1m53s
Build & Push Images / build (core) (push) Successful in 3m40s
Build & Push Images / build-switcher (push) Successful in 16s
Co-MJ v1 : quêtes de première classe, guidage de préparation, assistant IA,
mode séance, battlemaps multiples et export Foundry ciblé

- Quêtes (Niveau 1) : entité Quest orthogonale à l'arbre, rattachée à un arc HUB
  ou libre (migrations V9-V12, V18, V20 ; réconciliation des jumeaux V10).
  Fusion quête/conteneur dans la sidebar, progression par partie (statuts
  disponible/en cours/terminée), et quêtes libres avec espace de scènes créé
  automatiquement (arc technique « Quêtes libres », V21 : levée de la
  contrainte arcs.type héritée du baseline).
- Guidage (Pilier B) : bilan de préparation 100 % dérivé (règles arc/chapitre/
  scène/quête), panneau « Prochaines étapes » avec boutons « Corriger », et
  pastilles détaillées (tooltip des manques) dans l'arbre.
- Assistant IA (Pilier A) : étoffage champ par champ (scène, chapitre, arc) et
  brouillons de scènes en propose→applique (brain : narrative-fields,
  scene-drafts).
- Horloges & menaces (V15-V17) : clocks à segments avec déclencheurs, fronts.
- Mode séance : préparation de séance (readiness + quêtes dispo), scène
  épinglée (V19), récap « précédemment » (brain : session-recap), onglet
  « Partie » du panneau de référence, graphe amélioré (pan/zoom, éditeur de
  liens).
- Perf : endpoint agrégé GET /api/campaigns/{id}/tree — la sidebar charge en
  1 requête au lieu de ~15.
- Battlemaps multiples par scène (variantes jour/nuit, étages…) : liste JSON
  étiquetée (V22, reprise automatique de la carte existante), rendu PDF avec
  légendes, export/import rétro-compatible.
- Export Foundry ciblé : modale de périmètre (cartes+ennemis / journaux /
  tables), bundle filtré côté serveur (zip allégé), module Foundry à jour
  (respect du périmètre + une Scene Foundry par variante de carte,
  rétro-compatible anciens bundles).
2026-07-03 15:29:23 +02:00

341 lines
12 KiB
TypeScript

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<string, Chapter[]> = {};
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<string, number[]>();
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<string>();
/** IDs déjà vus — pour ne cocher par défaut que les NOUVELLES sources. */
private knownSourceIds = new Set<string>();
/** 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<string>();
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');
}
}