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.
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:
@@ -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.5-beta",
|
version="0.12.6-beta",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.12.5-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>
|
||||||
|
|
||||||
|
|||||||
@@ -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) :
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -136,7 +173,14 @@ public class NotebookController {
|
|||||||
} else {
|
} else {
|
||||||
sourceIds = readyIds;
|
sourceIds = readyIds;
|
||||||
}
|
}
|
||||||
String context = service.buildContext(nb.getCampaignId());
|
// 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(() -> {
|
||||||
@@ -235,6 +279,10 @@ public class NotebookController {
|
|||||||
* @param sourceIds Optionnel : sous-ensemble de sources à utiliser pour ce tour
|
* @param sourceIds Optionnel : sous-ensemble de sources à utiliser pour ce tour
|
||||||
* (cases cochées dans l'UI). Null = toutes les sources prêtes.
|
* (cases cochées dans l'UI). Null = toutes les sources prêtes.
|
||||||
* Toujours intersecté avec les sources du notebook (sécurité).
|
* 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) {}
|
public record ChatRequest(String message, Boolean deep, List<String> sourceIds,
|
||||||
|
List<String> archiveIds) {}
|
||||||
}
|
}
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.5-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.5-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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.5-beta",
|
"version": "0.12.6-beta",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -71,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.
|
||||||
@@ -96,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"
|
||||||
@@ -119,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…"
|
||||||
@@ -133,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>
|
||||||
|
|||||||
@@ -81,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; }
|
||||||
|
|
||||||
@@ -93,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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -210,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 {
|
||||||
@@ -222,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, [...this.selectedSourceIds]).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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,14 +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) ;
|
* `sourceIds` : sous-ensemble de sources à utiliser pour ce tour (cases cochées) ;
|
||||||
* undefined = toutes les sources prêtes du notebook.
|
* 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) => {
|
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));
|
||||||
@@ -62,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, sourceIds: sourceIds ?? null }),
|
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user