Compare commits

...

3 Commits

Author SHA1 Message Date
809e00ce49 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
2026-06-12 16:57:57 +02:00
bc0cbb0f7b Correction sur le NotebookController....
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m34s
Build & Push Images / build (core) (push) Successful in 2m1s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m50s
2026-06-12 16:05:34 +02:00
6740ed2177 Mise en place de la sélection des source que l'on souhaite que ce soit la partie RAG ou la partie analyse approfondie : on est plus obligé d'envoyer tous les PDFs qu'on a dans la partie atelier PDF + IA.
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m41s
Build & Push Images / build (core) (push) Failing after 1m46s
Build & Push Images / build-switcher (push) Successful in 26s
Build & Push Images / build (web) (push) Successful in 1m54s
Les réponses ne ce baseront que sur les sources que l'on aura cocher au préalable
2026-06-12 15:58:45 +02:00
16 changed files with 671 additions and 73 deletions

View File

@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.12.4-beta", version="0.12.6-beta",
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.12.4-beta</version> <version>0.12.6-beta</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>

View File

@@ -119,6 +119,58 @@ public class NotebookService {
.notebookId(notebookId).role(role).content(content).build()); .notebookId(notebookId).role(role).content(content).build());
} }
/** « Vider la conversation » : archive le fil actif (rien n'est supprimé). */
public void clearChat(String notebookId) {
repository.archiveMessagesByNotebookId(notebookId);
}
/** Messages archivés, chronologiques — l'appelant regroupe par {@code archivedAt}. */
public List<NotebookMessage> getArchivedMessages(String notebookId) {
return repository.findArchivedMessagesByNotebookId(notebookId);
}
// Budget total (caractères ≈ tokens/4) des archives injectées en référence :
// borne le prompt même si l'utilisateur coche plusieurs longues conversations.
private static final int ARCHIVE_CONTEXT_MAX_CHARS = 16000;
/**
* Bloc de contexte construit à partir des archives COCHÉES par l'utilisateur
* (clés = {@code archivedAt.toString()}). Injecté dans le prompt du chat pour
* que l'IA puisse s'appuyer sur d'anciennes conversations. Chaîne vide si
* aucune clé valide. Chaque archive est tronquée PAR LE DÉBUT au-delà de son
* budget : la fin d'une conversation (conclusions) est la partie utile.
*/
public String buildArchiveContext(String notebookId, List<String> archivedAtKeys) {
if (archivedAtKeys == null || archivedAtKeys.isEmpty()) return "";
var wanted = new java.util.HashSet<>(archivedAtKeys);
var groups = new java.util.LinkedHashMap<java.time.LocalDateTime, List<NotebookMessage>>();
for (NotebookMessage m : repository.findArchivedMessagesByNotebookId(notebookId)) {
if (m.getArchivedAt() != null && wanted.contains(m.getArchivedAt().toString())) {
groups.computeIfAbsent(m.getArchivedAt(), k -> new java.util.ArrayList<>()).add(m);
}
}
if (groups.isEmpty()) return "";
int budgetPerArchive = Math.max(2000, ARCHIVE_CONTEXT_MAX_CHARS / groups.size());
StringBuilder out = new StringBuilder(
"--- ANCIENNES CONVERSATIONS DE CET ATELIER (références choisies par le MJ : "
+ "tu peux t'appuyer sur leurs conclusions) ---\n");
groups.forEach((archivedAt, messages) -> {
StringBuilder convo = new StringBuilder();
for (NotebookMessage m : messages) {
convo.append("user".equals(m.getRole()) ? "MJ : " : "IA : ")
.append(m.getContent()).append('\n');
}
String text = convo.toString();
if (text.length() > budgetPerArchive) {
text = "[…début tronqué…]\n" + text.substring(text.length() - budgetPerArchive);
}
out.append("[Archive du ").append(archivedAt).append("]\n").append(text).append('\n');
});
out.append("--- FIN DES ANCIENNES CONVERSATIONS ---");
return out.toString();
}
// --- Contexte campagne (oriente l'IA) --- // --- Contexte campagne (oriente l'IA) ---
/** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) : /** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) :

View File

@@ -17,4 +17,6 @@ public class NotebookMessage {
private String role; private String role;
private String content; private String content;
private LocalDateTime createdAt; private LocalDateTime createdAt;
/** Null = conversation active ; sinon horodatage du « vider » (lot d'archive). */
private LocalDateTime archivedAt;
} }

View File

@@ -28,5 +28,10 @@ public interface NotebookRepository {
// --- Messages (conversation) --- // --- Messages (conversation) ---
NotebookMessage saveMessage(NotebookMessage message); NotebookMessage saveMessage(NotebookMessage message);
/** Messages de la conversation ACTIVE (les archives sont exclues). */
List<NotebookMessage> findMessagesByNotebookId(String notebookId); List<NotebookMessage> findMessagesByNotebookId(String notebookId);
/** « Vider » : archive le fil actif en un lot horodaté (rien n'est supprimé). */
void archiveMessagesByNotebookId(String notebookId);
/** Messages archivés, chronologiques (regroupables par {@code archivedAt}). */
List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId);
} }

View File

@@ -34,6 +34,14 @@ public class NotebookMessageJpaEntity {
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
/**
* Null = message de la conversation ACTIVE. Non-null = message archivé lors
* d'un « vider la conversation » ; tous les messages d'un même clear portent
* le même horodatage, qui sert d'identifiant de lot d'archive.
*/
@Column(name = "archived_at")
private LocalDateTime archivedAt;
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now(); if (createdAt == null) createdAt = LocalDateTime.now();

View File

@@ -2,12 +2,27 @@ package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity; import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Repository @Repository
public interface NotebookMessageJpaRepository extends JpaRepository<NotebookMessageJpaEntity, Long> { public interface NotebookMessageJpaRepository extends JpaRepository<NotebookMessageJpaEntity, Long> {
List<NotebookMessageJpaEntity> findByNotebookIdOrderByCreatedAtAsc(Long notebookId); /** Messages de la conversation ACTIVE (les archives sont exclues). */
List<NotebookMessageJpaEntity> findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long notebookId);
/** Messages archivés (tous lots confondus, l'appelant regroupe par archivedAt). */
List<NotebookMessageJpaEntity> findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long notebookId);
void deleteByNotebookId(Long notebookId); void deleteByNotebookId(Long notebookId);
/** « Vider la conversation » : archive le fil actif en un lot horodaté. */
@Modifying
@Query("update NotebookMessageJpaEntity m set m.archivedAt = :now "
+ "where m.notebookId = :notebookId and m.archivedAt is null")
int archiveActiveMessages(@Param("notebookId") Long notebookId, @Param("now") LocalDateTime now);
} }

View File

@@ -115,7 +115,19 @@ public class PostgresNotebookRepository implements NotebookRepository {
@Override @Override
public List<NotebookMessage> findMessagesByNotebookId(String notebookId) { public List<NotebookMessage> findMessagesByNotebookId(String notebookId) {
return messageJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream() return messageJpa.findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toMessage).collect(Collectors.toList());
}
@Override
@Transactional
public void archiveMessagesByNotebookId(String notebookId) {
messageJpa.archiveActiveMessages(Long.parseLong(notebookId), java.time.LocalDateTime.now());
}
@Override
public List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId) {
return messageJpa.findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toMessage).collect(Collectors.toList()); .map(this::toMessage).collect(Collectors.toList());
} }
@@ -150,6 +162,7 @@ public class PostgresNotebookRepository implements NotebookRepository {
.role(e.getRole()) .role(e.getRole())
.content(e.getContent()) .content(e.getContent())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
.archivedAt(e.getArchivedAt())
.build(); .build();
} }
} }

View File

@@ -105,6 +105,43 @@ public class NotebookController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
// --- Conversation : vider (= archiver) et consulter les archives ---
/**
* « Vider la conversation » : le fil actif est ARCHIVÉ en un lot horodaté,
* jamais supprimé — consultable ensuite via {@link #listArchives}.
*/
@PostMapping("/{id}/chat/clear")
public ResponseEntity<Void> clearChat(@PathVariable String id) {
if (service.getNotebook(id).isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook introuvable");
}
service.clearChat(id);
return ResponseEntity.noContent().build();
}
/** Archives de conversation, plus récentes d'abord : [{archivedAt, messages:[…]}]. */
@GetMapping("/{id}/chat/archives")
public ResponseEntity<List<Map<String, Object>>> listArchives(@PathVariable String id) {
var grouped = new java.util.TreeMap<java.time.LocalDateTime, List<Map<String, Object>>>(
java.util.Comparator.reverseOrder());
for (var m : service.getArchivedMessages(id)) {
grouped.computeIfAbsent(m.getArchivedAt(), k -> new java.util.ArrayList<>())
.add(Map.of(
"role", m.getRole(),
"content", m.getContent(),
"createdAt", m.getCreatedAt().toString()));
}
List<Map<String, Object>> out = new java.util.ArrayList<>();
grouped.forEach((archivedAt, messages) -> {
Map<String, Object> archive = new LinkedHashMap<>();
archive.put("archivedAt", archivedAt.toString());
archive.put("messages", messages);
out.add(archive);
});
return ResponseEntity.ok(out);
}
// --- Chat ancré streamé --- // --- Chat ancré streamé ---
@PostMapping(value = "/{id}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @PostMapping(value = "/{id}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@@ -124,8 +161,26 @@ public class NotebookController {
List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream() List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream()
.map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent())) .map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent()))
.toList(); .toList();
List<String> sourceIds = service.readySourceIds(id); // Sélection de l'UI (cases cochées) : on ne garde que les sources qui
String context = service.buildContext(nb.getCampaignId()); // appartiennent bien à CE notebook et sont prêtes — un id étranger est
// ignoré. Limite le coût (ex. analyse approfondie sur 1 PDF au lieu de 5).
// Variable finale : elle est capturée par la lambda du taskExecutor.
List<String> readyIds = service.readySourceIds(id);
final List<String> sourceIds;
if (req.sourceIds() != null) {
var wanted = new java.util.HashSet<>(req.sourceIds());
sourceIds = readyIds.stream().filter(wanted::contains).toList();
} else {
sourceIds = readyIds;
}
// Contexte = brief de campagne + archives cochées en référence (le tout
// dans une variable finale : capturée par la lambda du taskExecutor).
String campaignContext = service.buildContext(nb.getCampaignId());
String archiveContext = service.buildArchiveContext(id, req.archiveIds());
final String context = archiveContext.isEmpty()
? campaignContext
: (campaignContext.isEmpty() ? archiveContext
: campaignContext + "\n\n" + archiveContext);
boolean deep = req.deep() != null && req.deep(); boolean deep = req.deep() != null && req.deep();
taskExecutor.execute(() -> { taskExecutor.execute(() -> {
@@ -220,5 +275,14 @@ public class NotebookController {
public record CreateRequest(String campaignId, String name) {} public record CreateRequest(String campaignId, String name) {}
public record RenameRequest(String name) {} public record RenameRequest(String name) {}
public record ChatRequest(String message, Boolean deep) {} /**
* @param sourceIds Optionnel : sous-ensemble de sources à utiliser pour ce tour
* (cases cochées dans l'UI). Null = toutes les sources prêtes.
* Toujours intersecté avec les sources du notebook (sécurité).
* @param archiveIds Optionnel : archives de conversation cochées comme RÉFÉRENCE
* (clés = archivedAt). Leur contenu est injecté dans le contexte
* du prompt — toujours résolu dans CE notebook (sécurité).
*/
public record ChatRequest(String message, Boolean deep, List<String> sourceIds,
List<String> archiveIds) {}
} }

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.12.4-beta", "version": "0.12.6-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "loremind-web", "name": "loremind-web",
"version": "0.12.4-beta", "version": "0.12.6-beta",
"dependencies": { "dependencies": {
"@angular/animations": "^21.2.16", "@angular/animations": "^21.2.16",
"@angular/common": "^21.2.16", "@angular/common": "^21.2.16",

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.12.4-beta", "version": "0.12.6-beta",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",

View File

@@ -20,13 +20,30 @@
@if (uploadError) { @if (uploadError) {
<p class="nbd-upload-error">{{ uploadError }}</p> <p class="nbd-upload-error">{{ uploadError }}</p>
} }
@if (readySourceCount > 1) {
<p class="nbd-sources-hint"
[class.partial]="selectedSourceIds.size < readySourceCount">
{{ selectedSourceIds.size }}/{{ readySourceCount }} source(s) utilisée(s) par le chat et l'analyse approfondie
</p>
}
@for (s of sources; track s) { @for (s of sources; track s) {
<div class="nbd-source"> <div class="nbd-source" [class.nbd-source--off]="s.status === 'READY' && !isSelected(s)">
@if (s.status === 'READY') {
<input
type="checkbox"
class="nbd-source-check"
[checked]="isSelected(s)"
(change)="toggleSource(s)"
[title]="isSelected(s)
? 'Source utilisée par le chat décocher pour l\'exclure'
: 'Source ignorée par le chat cocher pour l\'inclure'" />
} @else {
<lucide-icon <lucide-icon
[img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)" [img]="s.status === 'FAILED' ? AlertCircle : Loader"
[size]="14" [size]="14"
[class.ok]="s.status === 'READY'" [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'"> [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'">
</lucide-icon> </lucide-icon>
}
<div class="nbd-source-info"> <div class="nbd-source-info">
<div class="nbd-source-name">{{ s.filename }}</div> <div class="nbd-source-name">{{ s.filename }}</div>
<div class="nbd-source-meta"> <div class="nbd-source-meta">
@@ -54,7 +71,88 @@
</aside> </aside>
<!-- Chat --> <!-- Chat -->
<section class="nbd-chat"> <section class="nbd-chat">
<!-- 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"> <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) { @if (messages.length === 0) {
<p class="nbd-empty"> <p class="nbd-empty">
Pose une question sur ta source, ou demande une adaptation pour ta campagne. Pose une question sur ta source, ou demande une adaptation pour ta campagne.
@@ -79,9 +177,11 @@
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }} Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }}
</div> </div>
} }
<div class="nbd-msg-content">{{ p.text }}@if (sending && !p.text && !p.actions.length && !deepProgress) { @if (p.text) {
<span class="cursor"></span> <div class="nbd-msg-content md" [innerHTML]="p.text | markdown"></div>
}</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) { @for (a of p.actions; track $index) {
<app-notebook-action-card <app-notebook-action-card
[action]="a" [action]="a"
@@ -102,7 +202,9 @@
} }
</div> </div>
} }
}
</div> </div>
@if (!viewingArchive) {
<div class="nbd-input"> <div class="nbd-input">
<textarea [(ngModel)]="draft" rows="2" <textarea [(ngModel)]="draft" rows="2"
placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…" placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…"
@@ -116,6 +218,7 @@
<lucide-icon [img]="Send" [size]="15"></lucide-icon> <lucide-icon [img]="Send" [size]="15"></lucide-icon>
</button> </button>
</div> </div>
}
</section> </section>
</div> </div>
</div> </div>

View File

@@ -39,12 +39,36 @@
} }
.nbd-upload-error { color: #e88; font-size: 0.8rem; margin: 0.2rem 0; } .nbd-upload-error { color: #e88; font-size: 0.8rem; margin: 0.2rem 0; }
// Compteur de sources actives — ambre quand une partie est décochée, pour
// rappeler que le chat ne voit pas tout.
.nbd-sources-hint {
font-size: 0.72rem;
color: var(--color-text-muted, #9aa0aa);
margin: 0.1rem 0 0.2rem;
&.partial { color: #fbbf24; }
}
.nbd-source { .nbd-source {
display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem; display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem;
border-radius: 7px; background: rgba(255,255,255,0.03); border-radius: 7px; background: rgba(255,255,255,0.03);
lucide-icon.ok { color: #6bd08a; } lucide-icon.ok { color: #6bd08a; }
lucide-icon.fail { color: #e88; } lucide-icon.fail { color: #e88; }
lucide-icon.busy { color: #e0c074; } lucide-icon.busy { color: #e0c074; }
// Case « source utilisée par le chat » (sources prêtes uniquement).
.nbd-source-check {
margin: 2px 0 0;
accent-color: #6bd08a;
cursor: pointer;
flex-shrink: 0;
}
// Source décochée : grisée = hors du périmètre du chat et de l'analyse.
&.nbd-source--off {
opacity: 0.5;
}
.nbd-source-info { flex: 1; min-width: 0; } .nbd-source-info { flex: 1; min-width: 0; }
.nbd-source-name { font-size: 0.85rem; font-weight: 500; word-break: break-word; } .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-meta { font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); }
@@ -57,8 +81,84 @@
/* Chat */ /* Chat */
.nbd-chat { .nbd-chat {
display: flex; flex-direction: column; min-height: 0; 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; 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-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-empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
@@ -69,7 +169,90 @@
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--color-text-muted, #9aa0aa); margin-bottom: 0.2rem; 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; &.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; } .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 { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; 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 { NotebookService } from '../../../services/notebook.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.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 { parseNotebookActions, NotebookAction } from '../../../services/notebook-action.model';
import { Arc, Chapter } from '../../../services/campaign.model'; import { Arc, Chapter } from '../../../services/campaign.model';
import { loadCampaignTreeData } from '../../campaign-tree.helper'; import { loadCampaignTreeData } from '../../campaign-tree.helper';
import { NotebookActionCardComponent } from '../notebook-action-card/notebook-action-card.component'; 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). * Atelier (workspace) : sources indexées (gauche) + chat ancré RAG (droite).
@@ -20,7 +22,7 @@ import { NotebookActionCardComponent } from '../notebook-action-card/notebook-ac
*/ */
@Component({ @Component({
selector: 'app-notebook-detail', selector: 'app-notebook-detail',
imports: [FormsModule, LucideAngularModule, NotebookActionCardComponent], imports: [FormsModule, LucideAngularModule, NotebookActionCardComponent, MarkdownPipe],
templateUrl: './notebook-detail.component.html', templateUrl: './notebook-detail.component.html',
styleUrls: ['./notebook-detail.component.scss'] styleUrls: ['./notebook-detail.component.scss']
}) })
@@ -35,6 +37,9 @@ export class NotebookDetailComponent implements OnInit {
readonly AlertCircle = AlertCircle; readonly AlertCircle = AlertCircle;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly Layers = Layers; readonly Layers = Layers;
readonly Eraser = Eraser;
readonly History = History;
readonly X = X;
campaignId = ''; campaignId = '';
notebookId = ''; notebookId = '';
@@ -60,7 +65,8 @@ export class NotebookDetailComponent implements OnInit {
private campaignSidebar: CampaignSidebarService, private campaignSidebar: CampaignSidebarService,
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService private npcService: NpcService,
private confirmDialog: ConfirmDialogService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -137,13 +143,54 @@ export class NotebookDetailComponent implements OnInit {
this.detail = d; this.detail = d;
this.sources = d.sources ?? []; this.sources = d.sources ?? [];
this.messages = d.messages ?? []; this.messages = d.messages ?? [];
this.syncSelection();
}, },
error: () => this.back() error: () => this.back()
}); });
} }
reloadSources(): void { reloadSources(): void {
this.service.get(this.notebookId).subscribe({ next: (d) => this.sources = d.sources ?? [] }); 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 --- // --- Sources ---
@@ -169,6 +216,81 @@ export class NotebookDetailComponent implements OnInit {
this.service.deleteSource(s.id).subscribe(() => this.reloadSources()); 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 --- // --- Chat ---
send(deep = false): void { send(deep = false): void {
@@ -181,7 +303,10 @@ export class NotebookDetailComponent implements OnInit {
this.messages.push(assistant); this.messages.push(assistant);
this.sending = true; this.sending = true;
this.service.streamChat(this.notebookId, text, deep).subscribe({ this.service.streamChat(
this.notebookId, text, deep,
[...this.selectedSourceIds], [...this.referencedArchiveIds]
).subscribe({
next: (ev) => { next: (ev) => {
if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; } if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; }
else if (ev.type === 'sources') assistant.sources = ev.sources; else if (ev.type === 'sources') assistant.sources = ev.sources;

View File

@@ -34,6 +34,15 @@ export interface NotebookMessage {
sources?: NotebookChatSource[]; 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 { export interface NotebookDetail {
id: string; id: string;
name: string; name: string;

View File

@@ -1,7 +1,7 @@
import { Injectable, NgZone } from '@angular/core'; import { Injectable, NgZone } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; 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, * Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources,
@@ -44,11 +44,26 @@ export class NotebookService {
return this.http.delete<void>(`${this.apiUrl}/sources/${sourceId}`); 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). * Chat ancré streamé. fetch() + ReadableStream (HttpClient bufferise les SSE).
* Émissions forcées dans la zone Angular pour la détection de changement. * É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.
* `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): Observable<NotebookChatEvent> { streamChat(notebookId: string, message: string, deep = false, sourceIds?: string[], archiveIds?: string[]): Observable<NotebookChatEvent> {
return new Observable<NotebookChatEvent>((subscriber) => { return new Observable<NotebookChatEvent>((subscriber) => {
const controller = new AbortController(); const controller = new AbortController();
const emit = (ev: NotebookChatEvent) => this.zone.run(() => subscriber.next(ev)); const emit = (ev: NotebookChatEvent) => this.zone.run(() => subscriber.next(ev));
@@ -59,7 +74,11 @@ export class NotebookService {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }, headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ message, deep }), body: JSON.stringify({
message, deep,
sourceIds: sourceIds ?? null,
archiveIds: archiveIds?.length ? archiveIds : null
}),
signal: controller.signal, signal: controller.signal,
}); });
if (!response.ok || !response.body) { if (!response.ok || !response.body) {