Compare commits
4 Commits
v0.12.3-be
...
v0.12.6-be
| Author | SHA1 | Date | |
|---|---|---|---|
| 809e00ce49 | |||
| bc0cbb0f7b | |||
| 6740ed2177 | |||
| 8cc90bd24d |
@@ -14,6 +14,11 @@ import asyncio
|
||||
import logging
|
||||
|
||||
from app.application.chunking import chunk_text, split_in_half
|
||||
from app.application.import_status import (
|
||||
notify_status,
|
||||
reset_status_queue,
|
||||
set_status_queue,
|
||||
)
|
||||
from app.application.llm_json import load_json_object, looks_like_truncated_json
|
||||
from app.application.llm_retry import generate_with_retry
|
||||
from app.application.streaming import with_heartbeat
|
||||
@@ -510,6 +515,11 @@ class ImportCampaignUseCase:
|
||||
skipped = 0
|
||||
last_error: str | None = None
|
||||
done_count = 0
|
||||
# Canal de statut : les couches profondes (retry LLM, re-découpage) y
|
||||
# publient des messages destinés à l'UI — cf. import_status.notify_status.
|
||||
status_queue: asyncio.Queue = asyncio.Queue()
|
||||
status_token = set_status_queue(status_queue)
|
||||
try:
|
||||
# PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency`
|
||||
# appels simultanés. L'ordre narratif est préservé : la fusion se fait
|
||||
# vague par vague, dans l'ordre du livre.
|
||||
@@ -526,9 +536,12 @@ class ImportCampaignUseCase:
|
||||
return_exceptions=True,
|
||||
)
|
||||
results: list | None = None
|
||||
async for kind, payload in with_heartbeat(gathered):
|
||||
async for kind, payload in with_heartbeat(gathered, status_queue=status_queue):
|
||||
if kind == "heartbeat":
|
||||
yield {"type": "heartbeat", "current": done_count + 1, "total": total}
|
||||
elif kind == "status":
|
||||
yield {"type": "status", "message": payload,
|
||||
"current": done_count + 1, "total": total}
|
||||
else:
|
||||
results = payload
|
||||
for (i, _), res in zip(wave, results or []):
|
||||
@@ -578,9 +591,14 @@ class ImportCampaignUseCase:
|
||||
# (best-effort, voir _consolidate). Inutile sur un import mono-morceau.
|
||||
if total > 1:
|
||||
yield {"type": "consolidating", "total": total}
|
||||
async for kind, _ in with_heartbeat(self._consolidate(merger)):
|
||||
async for kind, payload in with_heartbeat(
|
||||
self._consolidate(merger), status_queue=status_queue
|
||||
):
|
||||
if kind == "heartbeat":
|
||||
yield {"type": "heartbeat", "current": total, "total": total}
|
||||
elif kind == "status":
|
||||
yield {"type": "status", "message": payload,
|
||||
"current": total, "total": total}
|
||||
|
||||
yield {
|
||||
"type": "done",
|
||||
@@ -590,6 +608,8 @@ class ImportCampaignUseCase:
|
||||
"ocr_page_count": doc.ocr_page_count,
|
||||
"skipped": skipped,
|
||||
}
|
||||
finally:
|
||||
reset_status_queue(status_token)
|
||||
|
||||
# --- Consolidation finale (fusion des quasi-doublons) ---------------------
|
||||
|
||||
@@ -666,6 +686,9 @@ class ImportCampaignUseCase:
|
||||
logger.info(
|
||||
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
|
||||
index, depth + 1)
|
||||
notify_status(
|
||||
f"Le modèle est trop lent sur le morceau {index + 1} : "
|
||||
"re-découpage en 2 moitiés plus digestes…")
|
||||
a = await self._extract_payload(
|
||||
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
||||
b = await self._extract_payload(
|
||||
@@ -679,6 +702,9 @@ class ImportCampaignUseCase:
|
||||
logger.info(
|
||||
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
|
||||
index, depth + 1)
|
||||
notify_status(
|
||||
f"Réponse du modèle coupée sur le morceau {index + 1} : "
|
||||
"re-découpage en 2 moitiés plus digestes…")
|
||||
a = await self._extract_payload(
|
||||
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
||||
b = await self._extract_payload(
|
||||
|
||||
@@ -15,7 +15,14 @@ from __future__ import annotations
|
||||
import logging
|
||||
import re
|
||||
|
||||
import asyncio
|
||||
|
||||
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half
|
||||
from app.application.import_status import (
|
||||
notify_status,
|
||||
reset_status_queue,
|
||||
set_status_queue,
|
||||
)
|
||||
from app.application.llm_json import load_json_object, looks_like_truncated_json
|
||||
from app.application.llm_retry import generate_with_retry
|
||||
from app.application.streaming import with_heartbeat
|
||||
@@ -332,6 +339,11 @@ class ImportRulesUseCase:
|
||||
merger = _SectionMerger()
|
||||
skipped = 0
|
||||
last_error: str | None = None
|
||||
# Canal de statut : les couches profondes (retry LLM, re-découpage) y
|
||||
# publient des messages destinés à l'UI — cf. import_status.notify_status.
|
||||
status_queue: asyncio.Queue = asyncio.Queue()
|
||||
status_token = set_status_queue(status_queue)
|
||||
try:
|
||||
for i, chunk in enumerate(chunks):
|
||||
# RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue.
|
||||
# Abandon seulement si AUCUN morceau ne passe (cf. après la boucle).
|
||||
@@ -341,10 +353,14 @@ class ImportRulesUseCase:
|
||||
try:
|
||||
sections: dict[str, str] | None = None
|
||||
async for kind, payload in with_heartbeat(
|
||||
self._map_chunk(chunk, index=i, total=total)
|
||||
self._map_chunk(chunk, index=i, total=total),
|
||||
status_queue=status_queue,
|
||||
):
|
||||
if kind == "heartbeat":
|
||||
yield {"type": "heartbeat", "current": i + 1, "total": total}
|
||||
elif kind == "status":
|
||||
yield {"type": "status", "message": payload,
|
||||
"current": i + 1, "total": total}
|
||||
else:
|
||||
sections = payload
|
||||
new_titles = merger.add(sections or {})
|
||||
@@ -361,6 +377,8 @@ class ImportRulesUseCase:
|
||||
"new_sections": new_titles,
|
||||
"skipped": skipped,
|
||||
}
|
||||
finally:
|
||||
reset_status_queue(status_token)
|
||||
|
||||
if total > 0 and skipped == total:
|
||||
yield {"type": "error",
|
||||
@@ -423,6 +441,9 @@ class ImportRulesUseCase:
|
||||
logger.info(
|
||||
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
|
||||
index, depth + 1)
|
||||
notify_status(
|
||||
f"Le modèle est trop lent sur le morceau {index + 1} : "
|
||||
"re-découpage en 2 moitiés plus digestes…")
|
||||
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1)
|
||||
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
|
||||
return _combine_sections(a, b)
|
||||
@@ -437,6 +458,9 @@ class ImportRulesUseCase:
|
||||
logger.info(
|
||||
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
|
||||
index, depth + 1)
|
||||
notify_status(
|
||||
f"Réponse du modèle coupée sur le morceau {index + 1} : "
|
||||
"re-découpage en 2 moitiés plus digestes…")
|
||||
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1)
|
||||
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
|
||||
return _combine_sections(a, b)
|
||||
|
||||
39
brain/app/application/import_status.py
Normal file
39
brain/app/application/import_status.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Canal de statut des imports : remonte à l'UI ce qui n'existait qu'en logs.
|
||||
|
||||
Problème résolu : pendant un import, les événements internes (retry parce que
|
||||
le fournisseur IA est saturé, re-découpage d'un morceau trop gros…) n'étaient
|
||||
visibles que dans les logs Docker. L'utilisateur voyait une barre de
|
||||
progression figée sans explication.
|
||||
|
||||
Mécanisme : le flux d'import (use case `stream()`) installe une Queue dans une
|
||||
ContextVar ; les couches profondes (retry LLM, re-découpage) y publient des
|
||||
messages via `notify_status()` sans connaître le flux SSE. La ContextVar est
|
||||
propagée automatiquement aux tâches asyncio enfants → chaque import concurrent
|
||||
a SA queue, sans couplage ni paramètre à faire transiter partout.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextvars import ContextVar, Token
|
||||
|
||||
_QUEUE: ContextVar[asyncio.Queue | None] = ContextVar("import_status_queue", default=None)
|
||||
|
||||
|
||||
def set_status_queue(queue: asyncio.Queue | None) -> Token:
|
||||
"""Installe la queue de statut pour le contexte courant (et ses tâches filles).
|
||||
|
||||
Renvoie le token à passer à `reset_status_queue` en fin d'import.
|
||||
"""
|
||||
return _QUEUE.set(queue)
|
||||
|
||||
|
||||
def reset_status_queue(token: Token) -> None:
|
||||
_QUEUE.reset(token)
|
||||
|
||||
|
||||
def notify_status(message: str) -> None:
|
||||
"""Publie un message de statut si un import écoute. No-op sinon (appels
|
||||
LLM hors import : chat, génération de page…)."""
|
||||
queue = _QUEUE.get()
|
||||
if queue is not None:
|
||||
queue.put_nowait(message)
|
||||
@@ -14,6 +14,7 @@ import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.application.import_status import notify_status
|
||||
from app.domain.ports import LLMGenerationTimeout, LLMProvider, LLMProviderError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -103,6 +104,14 @@ async def generate_with_retry(
|
||||
attempt + 1, _ATTEMPTS, " [rate limit]" if _is_rate_limit(exc) else "",
|
||||
exc, wait,
|
||||
)
|
||||
# Remonte aussi l'info à l'UI (flux d'import) : sans ça l'utilisateur
|
||||
# voit une barre figée sans savoir que le fournisseur est saturé.
|
||||
notify_status(
|
||||
("Fournisseur IA saturé (rate limit)" if _is_rate_limit(exc)
|
||||
else "Appel IA échoué")
|
||||
+ f" — tentative {attempt + 1}/{_ATTEMPTS}, nouvel essai dans {int(wait)}s. "
|
||||
+ str(exc)[:160]
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
assert last_error is not None
|
||||
raise last_error
|
||||
|
||||
@@ -25,21 +25,43 @@ async def with_heartbeat(
|
||||
coro: Awaitable[Any],
|
||||
*,
|
||||
interval: float = HEARTBEAT_INTERVAL_SECONDS,
|
||||
status_queue: "asyncio.Queue | None" = None,
|
||||
) -> AsyncIterator[tuple[str, Any]]:
|
||||
"""Exécute `coro` en émettant ('heartbeat', None) toutes les `interval`s tant
|
||||
qu'elle n'est pas terminée, puis ('result', valeur).
|
||||
|
||||
Si `status_queue` est fournie, les messages qui y sont publiés pendant
|
||||
l'exécution (cf. import_status.notify_status : retry LLM, re-découpage…)
|
||||
sont émis AU FIL DE L'EAU sous forme ('status', message) — c'est ce qui
|
||||
permet à l'UI d'expliquer une attente au lieu d'une barre figée.
|
||||
|
||||
L'exception éventuelle de `coro` est propagée (re-levée par `task.result()`),
|
||||
donc l'appelant peut l'attraper normalement. Si l'itération est abandonnée
|
||||
(client déconnecté), la tâche sous-jacente est annulée.
|
||||
"""
|
||||
task: asyncio.Task = asyncio.ensure_future(coro)
|
||||
getter: asyncio.Task | None = None
|
||||
try:
|
||||
while not task.done():
|
||||
done, _ = await asyncio.wait({task}, timeout=interval)
|
||||
waiters: set[asyncio.Task] = {task}
|
||||
if status_queue is not None and getter is None:
|
||||
getter = asyncio.ensure_future(status_queue.get())
|
||||
if getter is not None:
|
||||
waiters.add(getter)
|
||||
done, _ = await asyncio.wait(
|
||||
waiters, timeout=interval, return_when=asyncio.FIRST_COMPLETED)
|
||||
if getter is not None and getter in done:
|
||||
yield ("status", getter.result())
|
||||
getter = None # un nouveau get() sera créé au tour suivant
|
||||
if not done:
|
||||
yield ("heartbeat", None)
|
||||
# Vide les statuts restés en file (publiés juste avant la fin de la tâche).
|
||||
if status_queue is not None:
|
||||
while not status_queue.empty():
|
||||
yield ("status", status_queue.get_nowait())
|
||||
yield ("result", task.result())
|
||||
finally:
|
||||
if getter is not None and not getter.done():
|
||||
getter.cancel()
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
@@ -144,6 +144,16 @@ class GeminiLLMProvider:
|
||||
) as response:
|
||||
if response.status_code >= 400:
|
||||
detail = (await response.aread()).decode("utf-8", "replace").strip()
|
||||
# 401/403 = clé rejetée par GOOGLE (pas un problème LoreMind) :
|
||||
# message actionnable plutôt que le JSON brut de l'API.
|
||||
if response.status_code in (401, 403):
|
||||
raise LLMProviderError(
|
||||
"Erreur Gemini : clé API refusée par Google "
|
||||
f"(HTTP {response.status_code}). Vérifiez que la clé vient bien "
|
||||
"de aistudio.google.com (« Get API key ») et qu'elle n'a pas de "
|
||||
"restrictions (API ou adresse IP) dans la Google Cloud Console. "
|
||||
f"Détail : {detail[:300]}"
|
||||
)
|
||||
raise LLMProviderError(
|
||||
f"Erreur Gemini (HTTP {response.status_code})"
|
||||
+ (f" : {detail[:500]}" if detail else "")
|
||||
|
||||
@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
|
||||
app = FastAPI(
|
||||
title="LoreMind Brain",
|
||||
description="Backend IA pour la génération de contenu narratif.",
|
||||
version="0.12.3-beta",
|
||||
version="0.12.6-beta",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.12.3-beta</version>
|
||||
<version>0.12.6-beta</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
|
||||
@@ -65,10 +65,11 @@ public class CampaignImportService {
|
||||
String filename,
|
||||
Consumer<CampaignImportProgress> onProgress,
|
||||
Runnable onHeartbeat,
|
||||
Consumer<String> onStatus,
|
||||
Consumer<CampaignImportProposal> onDone,
|
||||
Consumer<Throwable> onError) {
|
||||
campaignPdfImporter.importCampaignStreaming(
|
||||
pdfBytes, filename, onProgress, onHeartbeat, onDone, onError);
|
||||
pdfBytes, filename, onProgress, onHeartbeat, onStatus, onDone, onError);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -119,6 +119,58 @@ public class NotebookService {
|
||||
.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) ---
|
||||
|
||||
/** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) :
|
||||
|
||||
@@ -40,10 +40,11 @@ public class GameSystemService {
|
||||
String filename,
|
||||
java.util.function.Consumer<com.loremind.domain.gamesystemcontext.RulesImportProgress> onProgress,
|
||||
Runnable onHeartbeat,
|
||||
java.util.function.Consumer<String> onStatus,
|
||||
java.util.function.Consumer<RulesImportResult> onDone,
|
||||
java.util.function.Consumer<Throwable> onError) {
|
||||
rulesPdfImporter.importRulesStreaming(
|
||||
pdfBytes, filename, onProgress, onHeartbeat, onDone, onError);
|
||||
pdfBytes, filename, onProgress, onHeartbeat, onStatus, onDone, onError);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,4 +17,6 @@ public class NotebookMessage {
|
||||
private String role;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
/** Null = conversation active ; sinon horodatage du « vider » (lot d'archive). */
|
||||
private LocalDateTime archivedAt;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ public interface CampaignPdfImporter {
|
||||
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
|
||||
* avancée à afficher, mais le canal SSE vers le navigateur
|
||||
* doit rester actif — sinon un proxy intermédiaire le coupe).
|
||||
* @param onStatus invoqué avec un message lisible quand quelque chose se
|
||||
* passe pendant l'attente (fournisseur saturé → retry,
|
||||
* morceau re-découpé, morceau ignoré…) — affiché par l'UI
|
||||
* pour que l'utilisateur n'ait pas à lire les logs.
|
||||
* @param onDone invoqué une fois avec l'arbre proposé (non persisté).
|
||||
* @param onError invoqué si l'extraction/structuration échoue.
|
||||
*/
|
||||
@@ -27,6 +31,7 @@ public interface CampaignPdfImporter {
|
||||
String filename,
|
||||
Consumer<CampaignImportProgress> onProgress,
|
||||
Runnable onHeartbeat,
|
||||
Consumer<String> onStatus,
|
||||
Consumer<CampaignImportProposal> onDone,
|
||||
Consumer<Throwable> onError);
|
||||
}
|
||||
|
||||
@@ -28,5 +28,10 @@ public interface NotebookRepository {
|
||||
|
||||
// --- Messages (conversation) ---
|
||||
NotebookMessage saveMessage(NotebookMessage message);
|
||||
/** Messages de la conversation ACTIVE (les archives sont exclues). */
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ public interface RulesPdfImporter {
|
||||
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
|
||||
* avancée à afficher, mais le canal SSE vers le navigateur
|
||||
* doit rester actif — sinon un proxy intermédiaire le coupe).
|
||||
* @param onStatus invoqué avec un message lisible quand quelque chose se
|
||||
* passe pendant l'attente (fournisseur saturé → retry,
|
||||
* morceau re-découpé, morceau ignoré…) — affiché par l'UI
|
||||
* pour que l'utilisateur n'ait pas à lire les logs.
|
||||
* @param onDone invoqué une fois avec le résultat final.
|
||||
* @param onError invoqué si l'extraction/structuration échoue.
|
||||
*/
|
||||
@@ -38,6 +42,7 @@ public interface RulesPdfImporter {
|
||||
String filename,
|
||||
Consumer<RulesImportProgress> onProgress,
|
||||
Runnable onHeartbeat,
|
||||
Consumer<String> onStatus,
|
||||
Consumer<RulesImportResult> onDone,
|
||||
Consumer<Throwable> onError);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
String filename,
|
||||
Consumer<CampaignImportProgress> onProgress,
|
||||
Runnable onHeartbeat,
|
||||
Consumer<String> onStatus,
|
||||
Consumer<CampaignImportProposal> onDone,
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
@@ -85,7 +86,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
.timeout(Duration.ofSeconds(importTimeoutSeconds))
|
||||
.doOnNext(sse -> handleEvent(
|
||||
sse, pageCount, ocrPageCount, terminated,
|
||||
onProgress, onHeartbeat, onDone, onError))
|
||||
onProgress, onHeartbeat, onStatus, onDone, onError))
|
||||
.blockLast();
|
||||
if (!terminated[0]) {
|
||||
onError.accept(new CampaignImportException(
|
||||
@@ -110,6 +111,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
boolean[] terminated,
|
||||
Consumer<CampaignImportProgress> onProgress,
|
||||
Runnable onHeartbeat,
|
||||
Consumer<String> onStatus,
|
||||
Consumer<CampaignImportProposal> onDone,
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
@@ -122,6 +124,22 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
onHeartbeat.run();
|
||||
return;
|
||||
}
|
||||
if ("status".equals(event)) {
|
||||
// Message d'attente lisible (retry sur fournisseur saturé, morceau
|
||||
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
|
||||
onStatus.accept(readMessage(data));
|
||||
return;
|
||||
}
|
||||
if ("chunk_failed".equals(event)) {
|
||||
JsonNode node = readJson(data);
|
||||
String msg = node != null && node.hasNonNull("message")
|
||||
? node.get("message").asText() : "";
|
||||
int current = node != null ? node.path("current").asInt() : 0;
|
||||
int total = node != null ? node.path("total").asInt() : 0;
|
||||
onStatus.accept("Morceau " + current + "/" + total + " ignoré"
|
||||
+ (msg.isEmpty() ? "." : " : " + msg));
|
||||
return;
|
||||
}
|
||||
if ("error".equals(event)) {
|
||||
terminated[0] = true;
|
||||
onError.accept(new CampaignImportException(
|
||||
|
||||
@@ -115,6 +115,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
String filename,
|
||||
Consumer<RulesImportProgress> onProgress,
|
||||
Runnable onHeartbeat,
|
||||
Consumer<String> onStatus,
|
||||
Consumer<RulesImportResult> onDone,
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
@@ -141,7 +142,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
.timeout(Duration.ofSeconds(importTimeoutSeconds))
|
||||
.doOnNext(sse -> handleEvent(
|
||||
sse, pageCount, ocrPageCount, terminated,
|
||||
onProgress, onHeartbeat, onDone, onError))
|
||||
onProgress, onHeartbeat, onStatus, onDone, onError))
|
||||
.blockLast();
|
||||
// Flux terminé sans event done/error (ex: connexion coupée) → on signale.
|
||||
if (!terminated[0]) {
|
||||
@@ -168,6 +169,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
boolean[] terminated,
|
||||
Consumer<RulesImportProgress> onProgress,
|
||||
Runnable onHeartbeat,
|
||||
Consumer<String> onStatus,
|
||||
Consumer<RulesImportResult> onDone,
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
@@ -181,6 +183,22 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
onHeartbeat.run();
|
||||
return;
|
||||
}
|
||||
if ("status".equals(event)) {
|
||||
// Message d'attente lisible (retry sur fournisseur saturé, morceau
|
||||
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
|
||||
onStatus.accept(readMessage(data));
|
||||
return;
|
||||
}
|
||||
if ("chunk_failed".equals(event)) {
|
||||
JsonNode node = readJson(data);
|
||||
String msg = node != null && node.hasNonNull("message")
|
||||
? node.get("message").asText() : "";
|
||||
int current = node != null ? node.path("current").asInt() : 0;
|
||||
int total = node != null ? node.path("total").asInt() : 0;
|
||||
onStatus.accept("Morceau " + current + "/" + total + " ignoré"
|
||||
+ (msg.isEmpty() ? "." : " : " + msg));
|
||||
return;
|
||||
}
|
||||
if ("error".equals(event)) {
|
||||
terminated[0] = true;
|
||||
onError.accept(new RulesImportException(
|
||||
|
||||
@@ -34,6 +34,14 @@ public class NotebookMessageJpaEntity {
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
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
|
||||
protected void onCreate() {
|
||||
if (createdAt == null) createdAt = LocalDateTime.now();
|
||||
|
||||
@@ -2,12 +2,27 @@ package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity;
|
||||
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 java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
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);
|
||||
|
||||
/** « 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
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -150,6 +162,7 @@ public class PostgresNotebookRepository implements NotebookRepository {
|
||||
.role(e.getRole())
|
||||
.content(e.getContent())
|
||||
.createdAt(e.getCreatedAt())
|
||||
.archivedAt(e.getArchivedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ public class CampaignImportController {
|
||||
bytes, filename,
|
||||
progress -> sendEvent(emitter, clientGone, "progress", progress),
|
||||
() -> sendHeartbeat(emitter, clientGone),
|
||||
status -> sendEvent(emitter, clientGone, "status",
|
||||
Map.of("message", status != null ? status : "")),
|
||||
proposal -> {
|
||||
sendEvent(emitter, clientGone, "done", proposal);
|
||||
emitter.complete();
|
||||
|
||||
@@ -170,6 +170,8 @@ public class GameSystemController {
|
||||
bytes, filename,
|
||||
progress -> sendImportEvent(emitter, clientGone, "progress", progress),
|
||||
() -> sendImportHeartbeat(emitter, clientGone),
|
||||
status -> sendImportEvent(emitter, clientGone, "status",
|
||||
Map.of("message", status != null ? status : "")),
|
||||
result -> {
|
||||
sendImportEvent(emitter, clientGone, "done", result);
|
||||
emitter.complete();
|
||||
|
||||
@@ -105,6 +105,43 @@ public class NotebookController {
|
||||
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é ---
|
||||
|
||||
@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()
|
||||
.map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent()))
|
||||
.toList();
|
||||
List<String> sourceIds = service.readySourceIds(id);
|
||||
String context = service.buildContext(nb.getCampaignId());
|
||||
// Sélection de l'UI (cases cochées) : on ne garde que les sources qui
|
||||
// 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();
|
||||
taskExecutor.execute(() -> {
|
||||
@@ -220,5 +275,14 @@ public class NotebookController {
|
||||
|
||||
public record CreateRequest(String campaignId, 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
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.12.3-beta",
|
||||
"version": "0.12.6-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "loremind-web",
|
||||
"version": "0.12.3-beta",
|
||||
"version": "0.12.6-beta",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.16",
|
||||
"@angular/common": "^21.2.16",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.12.3-beta",
|
||||
"version": "0.12.6-beta",
|
||||
"description": "LoreMind Frontend - Angular",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (importStatus) {
|
||||
<p class="import-status" role="status">{{ importStatus }}</p>
|
||||
}
|
||||
@if (importCounts) {
|
||||
<p class="import-counts">
|
||||
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s) · {{ importCounts.npcs }} PNJ
|
||||
|
||||
@@ -87,6 +87,16 @@
|
||||
|
||||
.import-counts { margin: 0.55rem 0 0; color: #9ca3af; font-size: 0.8rem; }
|
||||
|
||||
// Message d'attente live (fournisseur saturé → retry, morceau re-découpé…) :
|
||||
// ambre pour signaler « ça travaille, mais il se passe quelque chose ».
|
||||
.import-status {
|
||||
margin: 0.55rem 0 0;
|
||||
color: #fbbf24;
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.import-error,
|
||||
.apply-error {
|
||||
margin: 1rem 0 0;
|
||||
|
||||
@@ -74,6 +74,12 @@ export class CampaignImportComponent implements OnInit {
|
||||
importing = false;
|
||||
importPhase = '';
|
||||
importProgress: { current: number; total: number } | null = null;
|
||||
/**
|
||||
* Dernier message de statut du flux (fournisseur saturé → retry, morceau
|
||||
* re-découpé/ignoré…). Effacé à chaque progression : il explique l'ATTENTE
|
||||
* en cours, pas l'historique.
|
||||
*/
|
||||
importStatus: string | null = null;
|
||||
importCounts: { arcs: number; chapters: number; scenes: number; npcs: number } | null = null;
|
||||
importError: string | null = null;
|
||||
/** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */
|
||||
@@ -132,12 +138,15 @@ export class CampaignImportComponent implements OnInit {
|
||||
this.applyError = null;
|
||||
this.importPhase = 'Extraction du texte…';
|
||||
this.importProgress = null;
|
||||
this.importStatus = null;
|
||||
this.importCounts = null;
|
||||
this.tree = [];
|
||||
|
||||
this.service.importStructureStream(this.campaignId, file).subscribe({
|
||||
next: (ev) => {
|
||||
if (ev.type === 'progress') {
|
||||
// Un morceau vient d'aboutir : le message d'attente est obsolète.
|
||||
this.importStatus = null;
|
||||
if (ev.total === 0) {
|
||||
this.importPhase = 'Extraction du texte…';
|
||||
this.importProgress = null;
|
||||
@@ -149,10 +158,13 @@ export class CampaignImportComponent implements OnInit {
|
||||
scenes: ev.sceneCount, npcs: ev.npcCount ?? 0
|
||||
};
|
||||
}
|
||||
} else if (ev.type === 'status') {
|
||||
this.importStatus = ev.message;
|
||||
} else if (ev.type === 'done') {
|
||||
this.importing = false;
|
||||
this.importPhase = '';
|
||||
this.importProgress = null;
|
||||
this.importStatus = null;
|
||||
if ((ev.arcs ?? []).length === 0 && (ev.npcs ?? []).length === 0) {
|
||||
this.importError = "Aucune structure narrative détectée dans ce PDF.";
|
||||
this.reviewing = false;
|
||||
|
||||
@@ -20,13 +20,30 @@
|
||||
@if (uploadError) {
|
||||
<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) {
|
||||
<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
|
||||
[img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)"
|
||||
[img]="s.status === 'FAILED' ? AlertCircle : Loader"
|
||||
[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>
|
||||
}
|
||||
<div class="nbd-source-info">
|
||||
<div class="nbd-source-name">{{ s.filename }}</div>
|
||||
<div class="nbd-source-meta">
|
||||
@@ -54,7 +71,88 @@
|
||||
</aside>
|
||||
<!-- 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">
|
||||
@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.
|
||||
@@ -79,9 +177,11 @@
|
||||
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>
|
||||
@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"
|
||||
@@ -102,7 +202,9 @@
|
||||
}
|
||||
</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…"
|
||||
@@ -116,6 +218,7 @@
|
||||
<lucide-icon [img]="Send" [size]="15"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,12 +39,36 @@
|
||||
}
|
||||
.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 {
|
||||
display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem;
|
||||
border-radius: 7px; background: rgba(255,255,255,0.03);
|
||||
lucide-icon.ok { color: #6bd08a; }
|
||||
lucide-icon.fail { color: #e88; }
|
||||
lucide-icon.busy { color: #e0c074; }
|
||||
|
||||
// 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-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); }
|
||||
@@ -57,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; }
|
||||
|
||||
@@ -69,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; }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -137,13 +143,54 @@ export class NotebookDetailComponent implements OnInit {
|
||||
this.detail = d;
|
||||
this.sources = d.sources ?? [];
|
||||
this.messages = d.messages ?? [];
|
||||
this.syncSelection();
|
||||
},
|
||||
error: () => this.back()
|
||||
});
|
||||
}
|
||||
|
||||
reloadSources(): void {
|
||||
this.service.get(this.notebookId).subscribe({ next: (d) => this.sources = d.sources ?? [] });
|
||||
this.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 ---
|
||||
@@ -169,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 {
|
||||
@@ -181,7 +303,10 @@ export class NotebookDetailComponent implements OnInit {
|
||||
this.messages.push(assistant);
|
||||
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) => {
|
||||
if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; }
|
||||
else if (ev.type === 'sources') assistant.sources = ev.sources;
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (importStatus) {
|
||||
<p class="import-status" role="status">{{ importStatus }}</p>
|
||||
}
|
||||
@if (importFound.length) {
|
||||
<p class="import-found">
|
||||
Sections trouvées : {{ importFound.join(' · ') }}
|
||||
|
||||
@@ -163,6 +163,16 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// Message d'attente live (fournisseur saturé → retry, morceau re-découpé…) :
|
||||
// ambre pour signaler « ça travaille, mais il se passe quelque chose ».
|
||||
.import-status {
|
||||
margin: 0.55rem 0 0;
|
||||
color: #fbbf24;
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.import-note {
|
||||
margin: -0.4rem 0 1rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
|
||||
@@ -69,6 +69,12 @@ export class GameSystemEditComponent implements OnInit {
|
||||
importProgress: { current: number; total: number } | null = null;
|
||||
/** Titres de sections trouvés au fil de l'eau (affichage live). */
|
||||
importFound: string[] = [];
|
||||
/**
|
||||
* Dernier message de statut du flux (fournisseur saturé → retry, morceau
|
||||
* re-découpé/ignoré…). Effacé à chaque progression : il explique l'ATTENTE
|
||||
* en cours, pas l'historique.
|
||||
*/
|
||||
importStatus: string | null = null;
|
||||
|
||||
name = '';
|
||||
description = '';
|
||||
@@ -139,10 +145,13 @@ export class GameSystemEditComponent implements OnInit {
|
||||
this.importPhase = 'Extraction du texte…';
|
||||
this.importProgress = null;
|
||||
this.importFound = [];
|
||||
this.importStatus = null;
|
||||
|
||||
this.service.importRulesStream(file).subscribe({
|
||||
next: (ev) => {
|
||||
if (ev.type === 'progress') {
|
||||
// Un morceau vient d'aboutir : le message d'attente est obsolète.
|
||||
this.importStatus = null;
|
||||
if (ev.total === 0) {
|
||||
// Phase d'extraction (total encore inconnu).
|
||||
this.importPhase = 'Extraction du texte…';
|
||||
@@ -154,6 +163,8 @@ export class GameSystemEditComponent implements OnInit {
|
||||
if (!this.importFound.includes(t)) this.importFound.push(t);
|
||||
}
|
||||
}
|
||||
} else if (ev.type === 'status') {
|
||||
this.importStatus = ev.message;
|
||||
} else if (ev.type === 'done') {
|
||||
this.finishImport(ev.sections, ev.pageCount, ev.ocrPageCount);
|
||||
}
|
||||
|
||||
@@ -63,10 +63,13 @@ export interface CampaignImportApplyResult {
|
||||
/**
|
||||
* Évènements du flux SSE d'import streamé.
|
||||
* - progress : avancement (total=0 ⇒ extraction en cours).
|
||||
* - status : message d'attente lisible (fournisseur saturé → retry, morceau
|
||||
* re-découpé, morceau ignoré…) — feedback live pour l'utilisateur.
|
||||
* - done : arbre proposé (à réviser).
|
||||
* - error : message d'erreur côté serveur.
|
||||
*/
|
||||
export type CampaignImportStreamEvent =
|
||||
| { type: 'status'; message: string }
|
||||
| {
|
||||
type: 'progress';
|
||||
current: number;
|
||||
|
||||
@@ -74,6 +74,12 @@ export class CampaignImportService {
|
||||
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
|
||||
terminated = true;
|
||||
subscriber.error(new Error(message));
|
||||
} else if (name === 'status') {
|
||||
// Message d'attente lisible (fournisseur saturé, morceau re-découpé…).
|
||||
try {
|
||||
const obj = JSON.parse(currentData) as { message?: string };
|
||||
if (obj.message) subscriber.next({ type: 'status', message: obj.message });
|
||||
} catch { /* bloc malformé ignoré */ }
|
||||
} else if (name === 'progress' || name === 'done') {
|
||||
try {
|
||||
const obj = JSON.parse(currentData);
|
||||
|
||||
@@ -32,11 +32,14 @@ export interface RulesImportResponse {
|
||||
/**
|
||||
* Évènements du flux SSE d'import streamé.
|
||||
* - progress : avancement (total=0 ⇒ phase d'extraction en cours).
|
||||
* - status : message d'attente lisible (fournisseur saturé → retry, morceau
|
||||
* re-découpé, morceau ignoré…) — feedback live pour l'utilisateur.
|
||||
* - done : résultat final (sections proposées).
|
||||
* - error : message d'erreur côté serveur.
|
||||
*/
|
||||
export type RulesImportStreamEvent =
|
||||
| { type: 'progress'; current: number; total: number; pageCount: number; ocrPageCount: number; newSectionTitles: string[] }
|
||||
| { type: 'status'; message: string }
|
||||
| { type: 'done'; sections: Record<string, string>; pageCount: number; ocrPageCount: number }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
|
||||
@@ -105,6 +105,12 @@ export class GameSystemService {
|
||||
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ }
|
||||
terminated = true;
|
||||
subscriber.error(new Error(message));
|
||||
} else if (name === 'status') {
|
||||
// Message d'attente lisible (fournisseur saturé, morceau re-découpé…).
|
||||
try {
|
||||
const obj = JSON.parse(currentData) as { message?: string };
|
||||
if (obj.message) subscriber.next({ type: 'status', message: obj.message });
|
||||
} catch { /* bloc malformé ignoré */ }
|
||||
} else if (name === 'progress' || name === 'done') {
|
||||
try {
|
||||
const obj = JSON.parse(currentData);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,11 +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.
|
||||
* `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) => {
|
||||
const controller = new AbortController();
|
||||
const emit = (ev: NotebookChatEvent) => this.zone.run(() => subscriber.next(ev));
|
||||
@@ -59,7 +74,11 @@ export class NotebookService {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ message, deep }),
|
||||
body: JSON.stringify({
|
||||
message, deep,
|
||||
sourceIds: sourceIds ?? null,
|
||||
archiveIds: archiveIds?.length ? archiveIds : null
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
|
||||
Reference in New Issue
Block a user