All checks were successful
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).
341 lines
12 KiB
TypeScript
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');
|
|
}
|
|
}
|