Ajout de la possibilité d'archiver le chat dans l'atelier PDF + IA, ainsi que de référencer l'archive dans la conversation actuelle.
All checks were successful
Build & Push Images / build (core) (push) Successful in 1m47s
Build & Push Images / build (brain) (push) Successful in 1m52s
Build & Push Images / build-switcher (push) Successful in 27s
Build & Push Images / build (web) (push) Successful in 1m57s

Le chat est limité à 16 000 caractères pour l'archive et le début est tronqué pour laisser plutôt la conclusion en visibilité.
Passage bêta 0.12.6
This commit is contained in:
2026-06-12 16:57:57 +02:00
parent bc0cbb0f7b
commit 809e00ce49
16 changed files with 567 additions and 70 deletions

View File

@@ -71,55 +71,140 @@
</aside>
<!-- Chat -->
<section class="nbd-chat">
<div class="nbd-messages">
@if (messages.length === 0) {
<p class="nbd-empty">
Pose une question sur ta source, ou demande une adaptation pour ta campagne.
@if (!hasReadySource()) {
<span><br>(Ajoute d'abord une source indexée pour des réponses ancrées.)</span>
}
</p>
}
@for (m of messages; track m) {
<div class="nbd-msg" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'">
<div class="nbd-msg-role">
@if (m.role === 'assistant') {
<lucide-icon [img]="Sparkles" [size]="12"></lucide-icon>
}
{{ m.role === 'user' ? 'Vous' : 'IA' }}
</div>
@if (m.role === 'assistant') {
@if (parsedOf(m); as p) {
@if (sending && deepProgress && !p.text) {
<div class="nbd-deep-progress">
<lucide-icon [img]="Layers" [size]="13"></lucide-icon>
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }}
</div>
}
<div class="nbd-msg-content">{{ p.text }}@if (sending && !p.text && !p.actions.length && !deepProgress) {
<span class="cursor"></span>
}</div>
@for (a of p.actions; track $index) {
<app-notebook-action-card
[action]="a"
[campaignId]="campaignId"
[arcs]="arcs"
[chaptersByArc]="chaptersByArc"
(created)="onActionCreated()">
</app-notebook-action-card>
}
@if (sourcesLabel(m); as label) {
<div class="nbd-msg-sources" title="Pages de la source utilisées pour ancrer cette réponse">
📖 {{ label }}
</div>
}
}
} @else {
<div class="nbd-msg-content">{{ m.content }}</div>
}
</div>
<!-- Barre d'actions du chat : archives + vider -->
<div class="nbd-chat-head">
@if (viewingArchive) {
<span class="nbd-archive-banner">
<lucide-icon [img]="History" [size]="13"></lucide-icon>
Archive du {{ archiveLabel(viewingArchive) }} — lecture seule
</span>
<button type="button" class="nbd-chat-btn" (click)="closeArchive()">
<lucide-icon [img]="X" [size]="13"></lucide-icon>
Revenir au chat
</button>
} @else {
@if (referencedArchiveIds.size > 0) {
<span class="nbd-ref-badge"
title="Le contenu de ces archives est injecté comme référence dans chaque question">
<lucide-icon [img]="History" [size]="12"></lucide-icon>
{{ referencedArchiveIds.size }} archive(s) en référence
</span>
}
<span class="nbd-chat-head-spacer"></span>
<button type="button" class="nbd-chat-btn" (click)="toggleArchives()"
[class.active]="archivesOpen"
title="Conversations archivées (lecture seule + référence)">
<lucide-icon [img]="History" [size]="13"></lucide-icon>
Archives
</button>
<button type="button" class="nbd-chat-btn nbd-chat-btn--danger" (click)="clearChat()"
[disabled]="sending || messages.length === 0"
title="Vider la conversation (le fil actuel est archivé, pas supprimé)">
<lucide-icon [img]="Eraser" [size]="13"></lucide-icon>
Vider
</button>
}
</div>
<!-- Panneau des archives -->
@if (archivesOpen && !viewingArchive) {
<div class="nbd-archives">
@if (archives.length === 0) {
<p class="nbd-empty">Aucune conversation archivée pour l'instant — « Vider » archive le fil courant ici.</p>
} @else {
<p class="nbd-archives-help">
Cochez une archive pour l'utiliser comme <strong>référence</strong> dans le chat ;
cliquez sur son nom pour la relire.
</p>
}
@for (a of archives; track a.archivedAt) {
<div class="nbd-archive-row" [class.referenced]="isReferenced(a)">
<input
type="checkbox"
class="nbd-archive-check"
[checked]="isReferenced(a)"
(change)="toggleReference(a)"
[title]="isReferenced(a)
? 'Archive utilisée comme référence décocher pour la retirer'
: 'Utiliser cette archive comme référence dans le chat'" />
<button type="button" class="nbd-archive-item" (click)="openArchive(a)">
<lucide-icon [img]="History" [size]="13"></lucide-icon>
{{ archiveLabel(a) }}
</button>
</div>
}
</div>
}
<div class="nbd-messages">
@if (viewingArchive) {
<!-- Archive en lecture seule -->
@for (m of viewingArchive.messages; track $index) {
<div class="nbd-msg" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'">
<div class="nbd-msg-role">
@if (m.role === 'assistant') {
<lucide-icon [img]="Sparkles" [size]="12"></lucide-icon>
}
{{ m.role === 'user' ? 'Vous' : 'IA' }}
</div>
@if (m.role === 'assistant') {
<div class="nbd-msg-content md" [innerHTML]="m.content | markdown"></div>
} @else {
<div class="nbd-msg-content">{{ m.content }}</div>
}
</div>
}
} @else {
@if (messages.length === 0) {
<p class="nbd-empty">
Pose une question sur ta source, ou demande une adaptation pour ta campagne.
@if (!hasReadySource()) {
<span><br>(Ajoute d'abord une source indexée pour des réponses ancrées.)</span>
}
</p>
}
@for (m of messages; track m) {
<div class="nbd-msg" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'">
<div class="nbd-msg-role">
@if (m.role === 'assistant') {
<lucide-icon [img]="Sparkles" [size]="12"></lucide-icon>
}
{{ m.role === 'user' ? 'Vous' : 'IA' }}
</div>
@if (m.role === 'assistant') {
@if (parsedOf(m); as p) {
@if (sending && deepProgress && !p.text) {
<div class="nbd-deep-progress">
<lucide-icon [img]="Layers" [size]="13"></lucide-icon>
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }}
</div>
}
@if (p.text) {
<div class="nbd-msg-content md" [innerHTML]="p.text | markdown"></div>
} @else if (sending && !p.actions.length && !deepProgress) {
<div class="nbd-msg-content"><span class="cursor"></span></div>
}
@for (a of p.actions; track $index) {
<app-notebook-action-card
[action]="a"
[campaignId]="campaignId"
[arcs]="arcs"
[chaptersByArc]="chaptersByArc"
(created)="onActionCreated()">
</app-notebook-action-card>
}
@if (sourcesLabel(m); as label) {
<div class="nbd-msg-sources" title="Pages de la source utilisées pour ancrer cette réponse">
📖 {{ label }}
</div>
}
}
} @else {
<div class="nbd-msg-content">{{ m.content }}</div>
}
</div>
}
}
</div>
@if (!viewingArchive) {
<div class="nbd-input">
<textarea [(ngModel)]="draft" rows="2"
placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…"
@@ -133,6 +218,7 @@
<lucide-icon [img]="Send" [size]="15"></lucide-icon>
</button>
</div>
}
</section>
</div>
</div>

View File

@@ -81,8 +81,84 @@
/* Chat */
.nbd-chat {
display: flex; flex-direction: column; min-height: 0;
// min-width: 0 indispensable : enfant de grille (colonne 1fr), sinon sa largeur
// MINIMALE devient celle de la plus longue ligne insécable d'un message
// (tableau markdown, longue URL…) et le chat pousse toute la page hors écran.
min-width: 0;
border: 1px solid rgba(255,255,255,0.08); border-radius: 10px;
}
// Barre d'actions du chat : archives + vider (ou bandeau « archive en lecture seule »).
.nbd-chat-head {
display: flex; align-items: center; gap: 0.4rem;
padding: 0.45rem 0.6rem; border-bottom: 1px solid rgba(255,255,255,0.08);
.nbd-chat-head-spacer { flex: 1; }
.nbd-archive-banner {
flex: 1;
display: inline-flex; align-items: center; gap: 0.35rem;
font-size: 0.8rem; color: #e0c074; font-style: italic;
}
// Badge « N archive(s) en référence » : rappelle que le contexte du chat
// inclut d'anciennes conversations, même panneau fermé.
.nbd-ref-badge {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.74rem;
background: rgba(168,130,255,0.12); border: 1px solid rgba(168,130,255,0.4);
color: #c4a8ff;
}
.nbd-chat-btn {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.28rem 0.55rem; border-radius: 6px; font-size: 0.78rem;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer;
&:hover:not(:disabled) { background: rgba(255,255,255,0.09); }
&.active { border-color: #667eea; }
&:disabled { opacity: 0.45; cursor: default; }
&--danger {
color: #e88; border-color: rgba(224,90,90,0.35);
&:hover:not(:disabled) { background: rgba(224,90,90,0.12); }
}
}
}
// Panneau des conversations archivées.
.nbd-archives {
display: flex; flex-direction: column; gap: 0.3rem;
padding: 0.5rem 0.6rem; border-bottom: 1px solid rgba(255,255,255,0.08);
.nbd-archives-help {
margin: 0 0 0.2rem;
font-size: 0.74rem;
color: var(--color-text-muted, #9aa0aa);
}
.nbd-archive-row {
display: flex; align-items: center; gap: 0.45rem;
// Archive cochée en référence : léger liseré violet pour la repérer.
&.referenced .nbd-archive-item { border-color: rgba(168,130,255,0.5); }
}
.nbd-archive-check {
accent-color: #c4a8ff;
cursor: pointer;
flex-shrink: 0;
}
.nbd-archive-item {
flex: 1;
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.35rem 0.55rem; border-radius: 6px; font-size: 0.82rem;
border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.03);
color: inherit; cursor: pointer; text-align: left;
&:hover { background: rgba(255,255,255,0.08); }
}
}
.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; }
@@ -93,7 +169,90 @@
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; }
// overflow-wrap: anywhere : autorise la coupure DANS une séquence insécable
// (ligne de tableau markdown, URL) plutôt que de déborder du conteneur.
.nbd-msg-content { white-space: pre-wrap; line-height: 1.5; overflow-wrap: anywhere; }
// Rendu markdown des messages assistant (même esthétique que ai-chat-drawer).
// white-space normal : marked génère des <p>/<li>/<br>, pre-wrap doublerait
// les espacements entre blocs.
.nbd-msg-content.md {
white-space: normal;
> :first-child { margin-top: 0; }
> :last-child { margin-bottom: 0; }
p { margin: 0 0 0.5em; }
p:last-child { margin-bottom: 0; }
h1, h2, h3, h4, h5, h6 {
margin: 0.75em 0 0.35em;
font-weight: 600;
color: #f3f4f6;
line-height: 1.25;
}
h1 { font-size: 1.05rem; }
h2 { font-size: 1rem; }
h3 { font-size: 0.95rem; }
h4, h5, h6 { font-size: 0.9rem; }
strong { color: #f3f4f6; font-weight: 600; }
em { color: #d1d5db; font-style: italic; }
ul, ol { margin: 0.35em 0 0.5em; padding-left: 1.4em; }
li { margin: 0.15em 0; }
ul ul, ul ol, ol ul, ol ol { margin: 0.15em 0; }
code {
background: #0b0b15;
border: 1px solid #2a2a3d;
border-radius: 3px;
padding: 0 0.25em;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 0.82em;
}
pre {
background: #0b0b15;
border: 1px solid #2a2a3d;
border-radius: 6px;
padding: 0.6em 0.75em;
margin: 0.5em 0;
overflow-x: auto;
font-size: 0.82em;
code { background: transparent; border: 0; padding: 0; font-size: inherit; }
}
blockquote {
margin: 0.4em 0;
padding: 0.2em 0.8em;
border-left: 3px solid #3a3a5a;
color: #9ca3af;
font-style: italic;
}
a {
color: #a5b4fc;
text-decoration: underline;
&:hover { color: #c7d2fe; }
}
hr { border: 0; border-top: 1px solid #2a2a3d; margin: 0.6em 0; }
// Les tableaux (inventaires de boutique…) peuvent être plus larges que la
// bulle : on les laisse défiler horizontalement DANS le message.
table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin: 0.4em 0;
font-size: 0.85em;
th, td { border: 1px solid #2a2a3d; padding: 0.3em 0.5em; white-space: nowrap; }
th { background: #14142a; font-weight: 600; }
}
}
&.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; }
}

View File

@@ -2,17 +2,19 @@ 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 } from 'lucide-angular';
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 { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { NotebookDetail, NotebookSource, NotebookMessage } from '../../../services/notebook.model';
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';
/**
* Atelier (workspace) : sources indexées (gauche) + chat ancré RAG (droite).
@@ -20,7 +22,7 @@ import { NotebookActionCardComponent } from '../notebook-action-card/notebook-ac
*/
@Component({
selector: 'app-notebook-detail',
imports: [FormsModule, LucideAngularModule, NotebookActionCardComponent],
imports: [FormsModule, LucideAngularModule, NotebookActionCardComponent, MarkdownPipe],
templateUrl: './notebook-detail.component.html',
styleUrls: ['./notebook-detail.component.scss']
})
@@ -35,6 +37,9 @@ export class NotebookDetailComponent implements OnInit {
readonly AlertCircle = AlertCircle;
readonly Sparkles = Sparkles;
readonly Layers = Layers;
readonly Eraser = Eraser;
readonly History = History;
readonly X = X;
campaignId = '';
notebookId = '';
@@ -60,7 +65,8 @@ export class NotebookDetailComponent implements OnInit {
private campaignSidebar: CampaignSidebarService,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService
private npcService: NpcService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
@@ -210,6 +216,81 @@ export class NotebookDetailComponent implements OnInit {
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: 'Vider la conversation',
message: 'Repartir d\'une conversation vierge ?',
details: ['Le fil actuel est archivé (pas supprimé) : il restera consultable via « Archives ».'],
confirmLabel: 'Vider',
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 when = isNaN(date.getTime())
? a.archivedAt
: date.toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' });
return `${when} · ${a.messages.length} message(s)`;
}
// --- Chat ---
send(deep = false): void {
@@ -222,7 +303,10 @@ export class NotebookDetailComponent implements OnInit {
this.messages.push(assistant);
this.sending = true;
this.service.streamChat(this.notebookId, text, deep, [...this.selectedSourceIds]).subscribe({
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;

View File

@@ -34,6 +34,15 @@ export interface NotebookMessage {
sources?: NotebookChatSource[];
}
/**
* Conversation archivée par « Vider la conversation » : un lot horodaté de
* messages, consultable en lecture seule (rien n'est supprimé au clear).
*/
export interface NotebookArchive {
archivedAt: string;
messages: NotebookMessage[];
}
export interface NotebookDetail {
id: string;
name: string;

View File

@@ -1,7 +1,7 @@
import { Injectable, NgZone } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Notebook, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model';
import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model';
/**
* Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources,
@@ -44,14 +44,26 @@ export class NotebookService {
return this.http.delete<void>(`${this.apiUrl}/sources/${sourceId}`);
}
/** « Vider la conversation » : archive le fil actif (rien n'est supprimé). */
clearChat(notebookId: string): Observable<void> {
return this.http.post<void>(`${this.apiUrl}/${notebookId}/chat/clear`, {});
}
/** Conversations archivées, plus récentes d'abord. */
getArchives(notebookId: string): Observable<NotebookArchive[]> {
return this.http.get<NotebookArchive[]>(`${this.apiUrl}/${notebookId}/chat/archives`);
}
/**
* Chat ancré streamé. fetch() + ReadableStream (HttpClient bufferise les SSE).
* Émissions forcées dans la zone Angular pour la détection de changement.
*
* `sourceIds` : sous-ensemble de sources à utiliser pour ce tour (cases cochées) ;
* undefined = toutes les sources prêtes du notebook.
* `sourceIds` : sous-ensemble de sources à utiliser pour ce tour (cases cochées) ;
* undefined = toutes les sources prêtes du notebook.
* `archiveIds` : archives de conversation cochées comme référence (clés archivedAt) ;
* leur contenu est injecté dans le contexte du prompt.
*/
streamChat(notebookId: string, message: string, deep = false, sourceIds?: string[]): Observable<NotebookChatEvent> {
streamChat(notebookId: string, message: string, deep = false, sourceIds?: string[], archiveIds?: string[]): Observable<NotebookChatEvent> {
return new Observable<NotebookChatEvent>((subscriber) => {
const controller = new AbortController();
const emit = (ev: NotebookChatEvent) => this.zone.run(() => subscriber.next(ev));
@@ -62,7 +74,11 @@ export class NotebookService {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
credentials: 'include',
body: JSON.stringify({ message, deep, sourceIds: sourceIds ?? null }),
body: JSON.stringify({
message, deep,
sourceIds: sourceIds ?? null,
archiveIds: archiveIds?.length ? archiveIds : null
}),
signal: controller.signal,
});
if (!response.ok || !response.body) {