Plusieurs gros ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled

- Possibilité de discuter avec un PDF ; RAG ou analyse approfondie. Enlèvement de l'autre outil PDF de discussion qui analysait d'abord un PDF en proposant directement une intégration sans attendre qu'on pose de question
- Mise en place de l'import directement dans les outils dans la sidebar
- Mise en place d'un outil pour créer des tables aléatoires avec possibilité d'utiliser pendant la partie
- Mise en place d'un outil pour mettre en place des PNJ, scènes, chapitre.... directement à partir de la discussion avec le PDF
- Mise en place RAG avec mistal-embeding ou nomic si on utilise ollama
- Mise en place mistral, google en fournisseurs alternatifs pour l'IA dans le cloud
- version 0.11.0-bêta
This commit is contained in:
2026-06-07 09:52:15 +02:00
parent 5eb15dc449
commit edc4434298
113 changed files with 6347 additions and 662 deletions

View File

@@ -0,0 +1,41 @@
<div class="nac" [class.created]="status === 'created'">
<div class="nac-head">
<lucide-icon [img]="typeIcon" [size]="15"></lucide-icon>
<span class="nac-type">{{ typeLabel }}</span>
<span class="nac-name">{{ action.name }}</span>
</div>
<p class="nac-desc" *ngIf="action.description">{{ action.description }}</p>
<!-- Cibles -->
<div class="nac-targets" *ngIf="status !== 'created' && needsArc">
<label>
Arc
<select [(ngModel)]="selectedArcId" (ngModelChange)="syncChapter()">
<option *ngFor="let a of arcs" [value]="a.id">{{ a.name }}</option>
</select>
</label>
<label *ngIf="needsChapter">
Chapitre
<select [(ngModel)]="selectedChapterId">
<option *ngFor="let c of targetChapters" [value]="c.id">{{ c.name }}</option>
</select>
</label>
</div>
<p class="nac-warn" *ngIf="needsArc && arcs.length === 0">
Crée d'abord un arc {{ needsChapter ? 'et un chapitre ' : '' }}dans la campagne pour pouvoir y rattacher cet élément.
</p>
<div class="nac-foot">
<button class="nac-create" *ngIf="status !== 'created'" (click)="create()" [disabled]="!canCreate">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
{{ status === 'creating' ? 'Création…' : 'Créer dans la campagne' }}
</button>
<button class="nac-open" *ngIf="status === 'created'" (click)="openCreated()">
<lucide-icon [img]="Check" [size]="13"></lucide-icon> Créé — ouvrir
<lucide-icon [img]="ExternalLink" [size]="12"></lucide-icon>
</button>
<span class="nac-error" *ngIf="status === 'error'">{{ errorMsg }}</span>
</div>
</div>

View File

@@ -0,0 +1,52 @@
.nac {
margin-top: 0.5rem;
padding: 0.6rem 0.75rem;
border-radius: 9px;
border: 1px solid rgba(168, 130, 255, 0.3);
background: rgba(168, 130, 255, 0.08);
&.created { border-color: rgba(107, 208, 138, 0.4); background: rgba(107, 208, 138, 0.08); }
}
.nac-head {
display: flex; align-items: center; gap: 0.4rem;
color: #c4a8ff;
.nac-type {
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.05rem 0.4rem; border-radius: 4px; background: rgba(168,130,255,0.18);
}
.nac-name { font-weight: 600; color: inherit; }
}
.nac-desc {
margin: 0.4rem 0 0; font-size: 0.85rem; color: var(--color-text-muted, #cfd3da);
white-space: pre-wrap;
}
.nac-targets {
display: flex; gap: 0.6rem; flex-wrap: wrap; margin-top: 0.5rem;
label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); }
select {
padding: 0.3rem 0.45rem; border-radius: 6px; font: inherit;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); color: inherit;
}
}
.nac-warn { margin: 0.4rem 0 0; font-size: 0.78rem; color: #e0a458; }
.nac-foot { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.55rem; }
.nac-create, .nac-open {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.35rem 0.7rem; border-radius: 7px; cursor: pointer; font-size: 0.82rem; font-weight: 600;
border: none;
}
.nac-create {
background: #8a6dff; color: #fff;
&:hover:not(:disabled) { background: #7a5cf0; }
&:disabled { opacity: 0.5; cursor: default; }
}
.nac-open { background: rgba(107,208,138,0.2); color: #6bd08a; }
.nac-open:hover { background: rgba(107,208,138,0.3); }
.nac-error { color: #e88; font-size: 0.8rem; }

View File

@@ -0,0 +1,185 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LucideAngularModule, Plus, Check, Drama, Clapperboard, BookText, GitBranch, Dices, ExternalLink } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { Arc, Chapter } from '../../../services/campaign.model';
import { NotebookAction } from '../../../services/notebook-action.model';
/**
* Carte « Créer dans la campagne » issue d'une proposition de l'IA. Gère la cible
* (chapitre pour une scène, arc pour un chapitre) et appelle les services existants.
*/
@Component({
selector: 'app-notebook-action-card',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './notebook-action-card.component.html',
styleUrls: ['./notebook-action-card.component.scss']
})
export class NotebookActionCardComponent implements OnInit {
readonly Plus = Plus;
readonly Check = Check;
readonly ExternalLink = ExternalLink;
@Input() action!: NotebookAction;
@Input() campaignId!: string;
@Input() arcs: Arc[] = [];
@Input() chaptersByArc: Record<string, Chapter[]> = {};
/** Émis après une création réussie → l'atelier rafraîchit la sidebar. */
@Output() created = new EventEmitter<void>();
selectedArcId = '';
selectedChapterId = '';
status: 'idle' | 'creating' | 'created' | 'error' = 'idle';
errorMsg = '';
createdRoute: string[] | null = null;
constructor(
private campaignService: CampaignService,
private npcService: NpcService,
private tableService: RandomTableService,
private router: Router
) {}
ngOnInit(): void {
if (this.arcs.length) this.selectedArcId = this.arcs[0].id!;
this.syncChapter();
}
get typeLabel(): string {
switch (this.action.type) {
case 'npc': return 'PNJ';
case 'scene': return 'Scène';
case 'chapter': return 'Chapitre';
case 'arc': return 'Arc';
case 'table': return 'Table aléatoire';
default: return this.action.type;
}
}
get typeIcon() {
switch (this.action.type) {
case 'npc': return Drama;
case 'scene': return Clapperboard;
case 'chapter': return BookText;
case 'arc': return GitBranch;
case 'table': return Dices;
default: return Plus;
}
}
get needsArc(): boolean { return this.action.type === 'chapter' || this.action.type === 'scene'; }
get needsChapter(): boolean { return this.action.type === 'scene'; }
get targetChapters(): Chapter[] { return this.chaptersByArc[this.selectedArcId] ?? []; }
syncChapter(): void {
const chs = this.targetChapters;
this.selectedChapterId = chs.length ? chs[0].id! : '';
}
get canCreate(): boolean {
if (this.status === 'creating' || this.status === 'created') return false;
if (this.needsArc && !this.selectedArcId) return false;
if (this.needsChapter && !this.selectedChapterId) return false;
return true;
}
create(): void {
if (!this.canCreate) return;
this.status = 'creating';
this.errorMsg = '';
switch (this.action.type) {
case 'npc': return this.createNpc();
case 'arc': return this.createArc();
case 'chapter': return this.createChapter();
case 'scene': return this.createScene();
case 'table': return this.createTable();
}
}
private createNpc(): void {
this.npcService.create({
name: this.action.name,
campaignId: this.campaignId,
values: this.action.description ? { Description: this.action.description } : {}
}).subscribe({
next: (n) => this.done(['/campaigns', this.campaignId, 'npcs', n.id!]),
error: (e) => this.fail(e)
});
}
private createArc(): void {
this.campaignService.createArc({
name: this.action.name,
description: this.action.description,
campaignId: this.campaignId,
order: this.arcs.length,
type: this.action.arcType === 'HUB' ? 'HUB' : 'LINEAR'
}).subscribe({
next: (a) => this.done(['/campaigns', this.campaignId, 'arcs', a.id!]),
error: (e) => this.fail(e)
});
}
private createChapter(): void {
const order = (this.chaptersByArc[this.selectedArcId] ?? []).length;
this.campaignService.createChapter({
name: this.action.name,
description: this.action.description,
arcId: this.selectedArcId,
order
}).subscribe({
next: (c) => this.done(['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', c.id!]),
error: (e) => this.fail(e)
});
}
private createScene(): void {
this.campaignService.createScene({
name: this.action.name,
description: this.action.description,
playerNarration: this.action.content,
chapterId: this.selectedChapterId,
order: 0
}).subscribe({
next: (s) => this.done(
['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', this.selectedChapterId, 'scenes', s.id!]),
error: (e) => this.fail(e)
});
}
private createTable(): void {
this.tableService.create({
name: this.action.name,
diceFormula: this.action.diceFormula || '1d20',
campaignId: this.campaignId,
entries: (this.action.entries ?? []).map(e => ({
minRoll: e.minRoll, maxRoll: e.maxRoll, label: e.label, detail: e.detail
}))
}).subscribe({
next: (t) => this.done(['/campaigns', this.campaignId, 'random-tables', t.id!]),
error: (e) => this.fail(e)
});
}
private done(route: string[]): void {
this.status = 'created';
this.createdRoute = route;
this.created.emit();
}
private fail(err: unknown): void {
this.status = 'error';
this.errorMsg = (err as { error?: { message?: string } })?.error?.message || 'Échec de la création.';
}
openCreated(): void {
if (this.createdRoute) this.router.navigate(this.createdRoute);
}
}

View File

@@ -0,0 +1,98 @@
<div class="nbd-page" *ngIf="detail">
<div class="nbd-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Ateliers
</button>
<input class="nbd-title" [(ngModel)]="detail.name" (blur)="rename()" (keyup.enter)="rename()">
</div>
<div class="nbd-grid">
<!-- Sources -->
<aside class="nbd-sources">
<div class="nbd-sources-head">
<h3><lucide-icon [img]="FileText" [size]="15"></lucide-icon> Sources</h3>
<label class="btn-upload" [class.disabled]="uploading">
<lucide-icon [img]="Upload" [size]="13"></lucide-icon>
{{ uploading ? 'Indexation…' : 'Ajouter un PDF' }}
<input type="file" accept="application/pdf" hidden (change)="onFile($event)" [disabled]="uploading">
</label>
</div>
<p class="nbd-upload-error" *ngIf="uploadError">{{ uploadError }}</p>
<div class="nbd-source" *ngFor="let s of sources">
<lucide-icon
[img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)"
[size]="14"
[class.ok]="s.status === 'READY'" [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'">
</lucide-icon>
<div class="nbd-source-info">
<div class="nbd-source-name">{{ s.filename }}</div>
<div class="nbd-source-meta">
<span *ngIf="s.status === 'READY'">{{ s.pageCount }} p. · {{ s.chunkCount }} extraits</span>
<span *ngIf="s.status === 'INDEXING'">indexation en cours…</span>
<span *ngIf="s.status === 'FAILED'">échec de l'indexation</span>
</div>
</div>
<button class="nbd-source-del" (click)="removeSource(s)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
</div>
<p class="nbd-empty" *ngIf="sources.length === 0 && !uploading">
Ajoute un PDF source pour commencer à discuter avec.
</p>
</aside>
<!-- Chat -->
<section class="nbd-chat">
<div class="nbd-messages">
<p class="nbd-empty" *ngIf="messages.length === 0">
Pose une question sur ta source, ou demande une adaptation pour ta campagne.
<span *ngIf="!hasReadySource()"><br>(Ajoute d'abord une source indexée pour des réponses ancrées.)</span>
</p>
<div class="nbd-msg" *ngFor="let m of messages" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'">
<div class="nbd-msg-role">
<lucide-icon *ngIf="m.role === 'assistant'" [img]="Sparkles" [size]="12"></lucide-icon>
{{ m.role === 'user' ? 'Vous' : 'IA' }}
</div>
<ng-container *ngIf="m.role === 'assistant'; else userContent">
<ng-container *ngIf="parsedOf(m) as p">
<div class="nbd-deep-progress" *ngIf="sending && deepProgress && !p.text">
<lucide-icon [img]="Layers" [size]="13"></lucide-icon>
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }}
</div>
<div class="nbd-msg-content">{{ p.text }}<span class="cursor" *ngIf="sending && !p.text && !p.actions.length && !deepProgress"></span></div>
<app-notebook-action-card
*ngFor="let a of p.actions; trackBy: trackAction"
[action]="a"
[campaignId]="campaignId"
[arcs]="arcs"
[chaptersByArc]="chaptersByArc"
(created)="onActionCreated()">
</app-notebook-action-card>
</ng-container>
</ng-container>
<ng-template #userContent>
<div class="nbd-msg-content">{{ m.content }}</div>
</ng-template>
</div>
</div>
<div class="nbd-input">
<textarea [(ngModel)]="draft" rows="2"
placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…"
(keydown.enter)="$event.preventDefault(); send()"></textarea>
<button class="btn-deep" (click)="send(true)" [disabled]="sending || !draft.trim()"
title="Analyse approfondie : lit TOUT le document (plus lent, exhaustif). Idéal pour « liste tous les… »">
<lucide-icon [img]="Layers" [size]="15"></lucide-icon>
</button>
<button class="btn-send" (click)="send()" [disabled]="sending || !draft.trim()"
title="Réponse rapide (recherche ciblée dans le document)">
<lucide-icon [img]="Send" [size]="15"></lucide-icon>
</button>
</div>
</section>
</div>
</div>

View File

@@ -0,0 +1,106 @@
.nbd-page { max-width: 1100px; margin: 0 auto; padding: 1rem 1.5rem 2rem; height: calc(100vh - 60px); display: flex; flex-direction: column; }
.nbd-toolbar {
display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;
.btn-back {
display: inline-flex; align-items: center; gap: 0.35rem; flex-shrink: 0;
padding: 0.4rem 0.7rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer; font-size: 0.85rem;
&:hover { background: rgba(255,255,255,0.09); }
}
.nbd-title {
flex: 1; padding: 0.4rem 0.6rem; border-radius: 6px; font-size: 1.15rem; font-weight: 600;
border: 1px solid transparent; background: transparent; color: inherit;
&:hover, &:focus { border-color: rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); outline: none; }
}
}
.nbd-grid {
flex: 1; min-height: 0;
display: grid; grid-template-columns: 280px 1fr; gap: 1rem;
}
/* Sources */
.nbd-sources {
display: flex; flex-direction: column; gap: 0.5rem; overflow-y: auto;
border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 0.75rem;
}
.nbd-sources-head {
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
h3 { display: flex; align-items: center; gap: 0.4rem; margin: 0; font-size: 0.95rem; }
}
.btn-upload {
display: inline-flex; align-items: center; gap: 0.3rem; cursor: pointer;
padding: 0.3rem 0.55rem; border-radius: 6px; font-size: 0.78rem;
border: 1px solid rgba(102,126,234,0.4); color: #8ea2ff; background: rgba(102,126,234,0.1);
&:hover { background: rgba(102,126,234,0.2); }
&.disabled { opacity: 0.6; cursor: default; }
}
.nbd-upload-error { color: #e88; font-size: 0.8rem; margin: 0.2rem 0; }
.nbd-source {
display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem;
border-radius: 7px; background: rgba(255,255,255,0.03);
lucide-icon.ok { color: #6bd08a; }
lucide-icon.fail { color: #e88; }
lucide-icon.busy { color: #e0c074; }
.nbd-source-info { flex: 1; min-width: 0; }
.nbd-source-name { font-size: 0.85rem; font-weight: 500; word-break: break-word; }
.nbd-source-meta { font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); }
.nbd-source-del {
border: none; background: none; color: #e88; cursor: pointer; padding: 0.15rem;
border-radius: 4px; &:hover { background: rgba(224,90,90,0.15); }
}
}
/* Chat */
.nbd-chat {
display: flex; flex-direction: column; min-height: 0;
border: 1px solid rgba(255,255,255,0.08); border-radius: 10px;
}
.nbd-messages { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.85rem; }
.nbd-empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
.nbd-msg {
max-width: 88%;
.nbd-msg-role {
display: flex; align-items: center; gap: 0.3rem;
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--color-text-muted, #9aa0aa); margin-bottom: 0.2rem;
}
.nbd-msg-content { white-space: pre-wrap; line-height: 1.5; }
&.user { align-self: flex-end; text-align: right;
.nbd-msg-content { background: rgba(102,126,234,0.16); padding: 0.5rem 0.75rem; border-radius: 10px; display: inline-block; text-align: left; }
}
&.assistant { align-self: flex-start;
.nbd-msg-content { background: rgba(255,255,255,0.04); padding: 0.5rem 0.75rem; border-radius: 10px; }
}
}
.cursor { opacity: 0.6; }
.nbd-input {
display: flex; gap: 0.5rem; padding: 0.6rem; border-top: 1px solid rgba(255,255,255,0.08);
textarea {
flex: 1; resize: none; padding: 0.55rem 0.7rem; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); color: inherit; font: inherit;
}
.btn-send {
display: inline-flex; align-items: center; justify-content: center; width: 44px;
border: none; border-radius: 8px; background: #667eea; color: #fff; cursor: pointer;
&:hover:not(:disabled) { background: #5568d3; }
&:disabled { opacity: 0.5; cursor: default; }
}
.btn-deep {
display: inline-flex; align-items: center; justify-content: center; width: 44px;
border: 1px solid rgba(168,130,255,0.4); border-radius: 8px;
background: rgba(168,130,255,0.12); color: #c4a8ff; cursor: pointer;
&:hover:not(:disabled) { background: rgba(168,130,255,0.22); }
&:disabled { opacity: 0.5; cursor: default; }
}
}
.nbd-deep-progress {
display: inline-flex; align-items: center; gap: 0.35rem;
font-size: 0.82rem; color: #c4a8ff; font-style: italic;
}

View File

@@ -0,0 +1,187 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Upload, Trash2, Send, FileText, Loader, CheckCircle2, AlertCircle, Sparkles, Layers } from 'lucide-angular';
import { NotebookService } from '../../../services/notebook.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { 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';
/**
* Atelier (workspace) : sources indexées (gauche) + chat ancré RAG (droite).
* Route : /campaigns/:campaignId/notebooks/:notebookId
*/
@Component({
selector: 'app-notebook-detail',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, NotebookActionCardComponent],
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;
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 characterService: CharacterService,
private npcService: NpcService
) {}
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.characterService, this.npcService)
.subscribe({
next: (data) => { this.arcs = data.arcs; 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;
}
/** trackBy stable pour les cartes d'action (évite toute recréation parasite). */
trackAction(index: number): number { return index; }
load(): void {
this.service.get(this.notebookId).subscribe({
next: (d) => {
this.detail = d;
this.sources = d.sources ?? [];
this.messages = d.messages ?? [];
},
error: () => this.back()
});
}
reloadSources(): void {
this.service.get(this.notebookId).subscribe({ next: (d) => this.sources = d.sources ?? [] });
}
// --- 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 || 'Échec de l\'indexation. Réessaie ou vérifie le modèle d\'embedding.';
this.reloadSources();
}
});
}
removeSource(s: NotebookSource): void {
this.service.deleteSource(s.id).subscribe(() => this.reloadSources());
}
// --- 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).subscribe({
next: (ev) => {
if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; }
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');
}
}

View File

@@ -0,0 +1,35 @@
<div class="nbl-page">
<div class="nbl-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
</button>
</div>
<header class="nbl-header">
<h1><lucide-icon [img]="BookOpen" [size]="22"></lucide-icon> Ateliers d'adaptation</h1>
<p class="nbl-hint">
Discute avec l'IA à partir d'un PDF source (recherche dans le document) pour adapter
son contenu à ta campagne. La source et la conversation sont conservées.
</p>
</header>
<div class="nbl-create">
<input type="text" [(ngModel)]="newName" placeholder="Nom de l'atelier (ex: Adaptation du module X)"
(keyup.enter)="create()">
<button class="btn-create" (click)="create()" [disabled]="creating">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
{{ creating ? 'Création…' : 'Nouvel atelier' }}
</button>
</div>
<div class="nbl-list">
<p class="empty" *ngIf="notebooks.length === 0">Aucun atelier pour l'instant.</p>
<button class="nbl-item" *ngFor="let nb of notebooks" (click)="open(nb)">
<lucide-icon [img]="BookOpen" [size]="16"></lucide-icon>
<span class="nbl-item-name">{{ nb.name }}</span>
<span class="nbl-del" (click)="remove(nb, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,49 @@
.nbl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
.nbl-toolbar { margin-bottom: 1rem; }
.btn-back {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.4rem 0.7rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer; font-size: 0.85rem;
&:hover { background: rgba(255,255,255,0.09); }
}
.nbl-header {
margin-bottom: 1.25rem;
h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; }
.nbl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; }
}
.nbl-create {
display: flex; gap: 0.5rem; margin-bottom: 1.5rem;
input {
flex: 1; padding: 0.55rem 0.75rem; border-radius: 7px;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04);
color: inherit; font: inherit;
}
.btn-create {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.55rem 1rem; border: none; border-radius: 7px;
background: #667eea; color: #fff; font-weight: 600; cursor: pointer;
&:hover:not(:disabled) { background: #5568d3; }
&:disabled { opacity: 0.55; cursor: default; }
}
}
.nbl-list { display: flex; flex-direction: column; gap: 0.4rem; }
.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
.nbl-item {
display: flex; align-items: center; gap: 0.55rem;
padding: 0.7rem 0.85rem; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03);
color: inherit; cursor: pointer; text-align: left;
&:hover { background: rgba(255,255,255,0.07); }
.nbl-item-name { flex: 1; font-weight: 500; }
.nbl-del {
display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88;
&:hover { background: rgba(224,90,90,0.15); }
}
}

View File

@@ -0,0 +1,85 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Plus, Trash2, BookOpen } from 'lucide-angular';
import { NotebookService } from '../../../services/notebook.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { Notebook } from '../../../services/notebook.model';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Liste des ateliers (notebooks) d'une campagne + création.
* Route : /campaigns/:campaignId/notebooks
*/
@Component({
selector: 'app-notebook-list',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './notebook-list.component.html',
styleUrls: ['./notebook-list.component.scss']
})
export class NotebookListComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly BookOpen = BookOpen;
campaignId = '';
notebooks: Notebook[] = [];
newName = '';
creating = false;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: NotebookService,
private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? '';
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
this.load();
}
}
load(): void {
this.service.listByCampaign(this.campaignId).subscribe({
next: (list) => this.notebooks = list,
error: () => this.notebooks = []
});
}
create(): void {
if (this.creating) return;
this.creating = true;
this.service.create(this.campaignId, this.newName.trim() || 'Nouvel atelier').subscribe({
next: (nb) => this.router.navigate(['/campaigns', this.campaignId, 'notebooks', nb.id]),
error: () => this.creating = false
});
}
open(nb: Notebook): void {
this.router.navigate(['/campaigns', this.campaignId, 'notebooks', nb.id]);
}
remove(nb: Notebook, ev: Event): void {
ev.stopPropagation();
this.confirmDialog.confirm({
title: 'Supprimer l\'atelier',
message: `Supprimer « ${nb.name} » et ses sources indexées ?`,
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.service.delete(nb.id).subscribe(() => this.load());
});
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
}