Compare commits
4 Commits
v0.11.0-be
...
v0.11.2-be
| Author | SHA1 | Date | |
|---|---|---|---|
| 70ec1f2fb9 | |||
| f638fdd24b | |||
| e85ab0e6b1 | |||
| ed22d9f29c |
@@ -63,11 +63,18 @@ class NotebookDeepUseCase:
|
|||||||
async def stream(
|
async def stream(
|
||||||
self,
|
self,
|
||||||
source_ids: list[str],
|
source_ids: list[str],
|
||||||
question: str,
|
messages: list[ChatMessage],
|
||||||
context: str = "",
|
context: str = "",
|
||||||
|
history_limit: int = 8,
|
||||||
) -> AsyncIterator[dict]:
|
) -> AsyncIterator[dict]:
|
||||||
"""Yield des évènements : {type:'progress',current,total}, {type:'token',token},
|
"""Yield des évènements : {type:'progress',current,total}, {type:'token',token},
|
||||||
{type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.)"""
|
{type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.)
|
||||||
|
|
||||||
|
La dernière question utilisateur sert à la LECTURE du document (map) ; la
|
||||||
|
SYNTHÈSE (reduce) reçoit les `history_limit` derniers messages → les relances
|
||||||
|
conversationnelles (« et pour les autres ? ») fonctionnent aussi en approfondi.
|
||||||
|
"""
|
||||||
|
question = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||||
chunks: list[dict] = []
|
chunks: list[dict] = []
|
||||||
for sid in source_ids:
|
for sid in source_ids:
|
||||||
chunks.extend(vector_store.all_chunks(sid))
|
chunks.extend(vector_store.all_chunks(sid))
|
||||||
@@ -102,10 +109,11 @@ class NotebookDeepUseCase:
|
|||||||
if context.strip() else ""
|
if context.strip() else ""
|
||||||
)
|
)
|
||||||
system_prompt = _REDUCE_SYSTEM.format(context_block=context_block, notes_block=notes_block)
|
system_prompt = _REDUCE_SYSTEM.format(context_block=context_block, notes_block=notes_block)
|
||||||
|
# Historique récent pour la cohérence des relances ; on garantit que le
|
||||||
|
# dernier message est bien la question courante.
|
||||||
|
reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)]
|
||||||
llm_chat: LLMChatProvider = self._llm # type: ignore[assignment]
|
llm_chat: LLMChatProvider = self._llm # type: ignore[assignment]
|
||||||
async for token in llm_chat.stream_chat(
|
async for token in llm_chat.stream_chat(reduce_messages, system_prompt=system_prompt):
|
||||||
[ChatMessage(role="user", content=question)], system_prompt=system_prompt
|
|
||||||
):
|
|
||||||
yield {"type": "token", "token": token}
|
yield {"type": "token", "token": token}
|
||||||
yield {"type": "done"}
|
yield {"type": "done"}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
|
|||||||
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.11.0-beta",
|
version="0.11.2-beta",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -1100,7 +1100,8 @@ async def chat_notebook_deep_stream(
|
|||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE :
|
"""Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE :
|
||||||
`progress` {current,total} pendant la lecture, puis `token` {token}, puis `done`."""
|
`progress` {current,total} pendant la lecture, puis `token` {token}, puis `done`."""
|
||||||
question = next((m.content for m in reversed(body.messages) if m.role == "user"), "")
|
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
|
||||||
|
question = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||||
|
|
||||||
def _sse(event: str, data: dict) -> str:
|
def _sse(event: str, data: dict) -> str:
|
||||||
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
@@ -1110,7 +1111,7 @@ async def chat_notebook_deep_stream(
|
|||||||
yield _sse("error", {"message": "Question vide."})
|
yield _sse("error", {"message": "Question vide."})
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
async for ev in use_case.stream(body.source_ids, question, context=body.context):
|
async for ev in use_case.stream(body.source_ids, messages, context=body.context):
|
||||||
ev_type = ev.pop("type")
|
ev_type = ev.pop("type")
|
||||||
yield _sse(ev_type, ev)
|
yield _sse(ev_type, ev)
|
||||||
except (LLMProviderError, EmbeddingError) as exc:
|
except (LLMProviderError, EmbeddingError) as exc:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.11.0-beta</version>
|
<version>0.11.2-beta</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class NpcService {
|
|||||||
Map<String, List<String>> imageValues,
|
Map<String, List<String>> imageValues,
|
||||||
Map<String, Map<String, String>> keyValueValues,
|
Map<String, Map<String, String>> keyValueValues,
|
||||||
String campaignId,
|
String campaignId,
|
||||||
|
String folder,
|
||||||
Integer order
|
Integer order
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ public class NpcService {
|
|||||||
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||||
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
||||||
.campaignId(data.campaignId())
|
.campaignId(data.campaignId())
|
||||||
|
.folder(normalizeFolder(data.folder()))
|
||||||
.order(order)
|
.order(order)
|
||||||
.build();
|
.build();
|
||||||
return npcRepository.save(npc);
|
return npcRepository.save(npc);
|
||||||
@@ -66,6 +68,7 @@ public class NpcService {
|
|||||||
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||||
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||||
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
|
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
|
||||||
|
existing.setFolder(normalizeFolder(data.folder()));
|
||||||
if (data.order() != null) {
|
if (data.order() != null) {
|
||||||
existing.setOrder(data.order());
|
existing.setOrder(data.order());
|
||||||
}
|
}
|
||||||
@@ -76,6 +79,13 @@ public class NpcService {
|
|||||||
npcRepository.deleteById(id);
|
npcRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Trim le dossier ; chaîne vide → null (= non classé). */
|
||||||
|
private static String normalizeFolder(String folder) {
|
||||||
|
if (folder == null) return null;
|
||||||
|
String trimmed = folder.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
private int nextOrderFor(String campaignId) {
|
private int nextOrderFor(String campaignId) {
|
||||||
return npcRepository.findByCampaignId(campaignId).stream()
|
return npcRepository.findByCampaignId(campaignId).stream()
|
||||||
.mapToInt(Npc::getOrder)
|
.mapToInt(Npc::getOrder)
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ public class Npc {
|
|||||||
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
|
|
||||||
|
/** Dossier de classement (texte libre, ex. « Bard's Gate »). Nullable = non classé. */
|
||||||
|
private String folder;
|
||||||
|
|
||||||
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
|
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ public class NpcJpaEntity {
|
|||||||
@Column(name = "campaign_id", nullable = false)
|
@Column(name = "campaign_id", nullable = false)
|
||||||
private Long campaignId;
|
private Long campaignId;
|
||||||
|
|
||||||
|
@Column(name = "folder")
|
||||||
|
private String folder;
|
||||||
|
|
||||||
@Column(name = "\"order\"", nullable = false)
|
@Column(name = "\"order\"", nullable = false)
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ public class PostgresNpcRepository implements NpcRepository {
|
|||||||
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||||
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
|
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(e.getCampaignId().toString())
|
.campaignId(e.getCampaignId().toString())
|
||||||
|
.folder(e.getFolder())
|
||||||
.order(e.getOrder())
|
.order(e.getOrder())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
.updatedAt(e.getUpdatedAt())
|
.updatedAt(e.getUpdatedAt())
|
||||||
@@ -76,6 +77,7 @@ public class PostgresNpcRepository implements NpcRepository {
|
|||||||
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
|
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
|
||||||
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
|
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(Long.parseLong(n.getCampaignId()))
|
.campaignId(Long.parseLong(n.getCampaignId()))
|
||||||
|
.folder(n.getFolder())
|
||||||
.order(n.getOrder())
|
.order(n.getOrder())
|
||||||
.createdAt(n.getCreatedAt())
|
.createdAt(n.getCreatedAt())
|
||||||
.updatedAt(n.getUpdatedAt())
|
.updatedAt(n.getUpdatedAt())
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ import java.util.Map;
|
|||||||
@RequestMapping("/api/notebooks")
|
@RequestMapping("/api/notebooks")
|
||||||
public class NotebookController {
|
public class NotebookController {
|
||||||
|
|
||||||
private static final long SSE_TIMEOUT_MS = 10 * 60 * 1000L;
|
// L'analyse approfondie (map-reduce sur tout le doc) peut être longue sur un gros
|
||||||
|
// livre / un modèle lent → 30 min. Pour aller plus vite : modèle gros-contexte
|
||||||
|
// (moins de lots). Au-delà, l'expiration est gérée proprement (pas de crash).
|
||||||
|
private static final long SSE_TIMEOUT_MS = 30 * 60 * 1000L;
|
||||||
|
|
||||||
private final NotebookService service;
|
private final NotebookService service;
|
||||||
private final NotebookChatStreamer chatStreamer;
|
private final NotebookChatStreamer chatStreamer;
|
||||||
@@ -143,13 +146,18 @@ public class NotebookController {
|
|||||||
return emitter;
|
return emitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers SSE (mêmes conventions que AiChatController) ---
|
// --- Helpers SSE ---
|
||||||
|
// IMPORTANT : on attrape AUSSI IllegalStateException. Si le flux a déjà été fermé
|
||||||
|
// (timeout async, client déconnecté), `emitter.send/complete` la lève — et comme
|
||||||
|
// ces helpers tournent dans un thread d'exécuteur, une exception non gérée y
|
||||||
|
// remontait jusqu'au pool ("Exception in thread task-1: ResponseBodyEmitter has
|
||||||
|
// already completed"). On l'ignore silencieusement : il n'y a plus rien à envoyer.
|
||||||
|
|
||||||
private void sendToken(SseEmitter emitter, String token) {
|
private void sendToken(SseEmitter emitter, String token) {
|
||||||
try {
|
try {
|
||||||
emitter.send(SseEmitter.event().name("token").data("{\"token\":" + jsonEscape(token) + "}"));
|
emitter.send(SseEmitter.event().name("token").data("{\"token\":" + jsonEscape(token) + "}"));
|
||||||
} catch (IOException e) {
|
} catch (IOException | IllegalStateException e) {
|
||||||
emitter.completeWithError(e);
|
// flux fermé/expiré : on cesse d'écrire
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,8 +165,8 @@ public class NotebookController {
|
|||||||
try {
|
try {
|
||||||
emitter.send(SseEmitter.event().name("progress")
|
emitter.send(SseEmitter.event().name("progress")
|
||||||
.data("{\"current\":" + p.current() + ",\"total\":" + p.total() + "}"));
|
.data("{\"current\":" + p.current() + ",\"total\":" + p.total() + "}"));
|
||||||
} catch (IOException e) {
|
} catch (IOException | IllegalStateException e) {
|
||||||
emitter.completeWithError(e);
|
// flux fermé/expiré : on cesse d'écrire
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,8 +174,8 @@ public class NotebookController {
|
|||||||
try {
|
try {
|
||||||
emitter.send(SseEmitter.event().name("done").data("{}"));
|
emitter.send(SseEmitter.event().name("done").data("{}"));
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
} catch (IOException e) {
|
} catch (IOException | IllegalStateException e) {
|
||||||
emitter.completeWithError(e);
|
// flux déjà fermé/expiré : rien à compléter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,8 +184,8 @@ public class NotebookController {
|
|||||||
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
|
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
|
||||||
emitter.send(SseEmitter.event().name("error").data("{\"message\":" + jsonEscape(message) + "}"));
|
emitter.send(SseEmitter.event().name("error").data("{\"message\":" + jsonEscape(message) + "}"));
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
} catch (IOException ioe) {
|
} catch (IOException | IllegalStateException e) {
|
||||||
emitter.completeWithError(ioe);
|
// flux déjà fermé/expiré : rien à envoyer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ public class NpcController {
|
|||||||
dto.getImageValues(),
|
dto.getImageValues(),
|
||||||
dto.getKeyValueValues(),
|
dto.getKeyValueValues(),
|
||||||
dto.getCampaignId(),
|
dto.getCampaignId(),
|
||||||
|
dto.getFolder(),
|
||||||
order
|
order
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ public class NpcDTO {
|
|||||||
private Map<String, List<String>> imageValues = new HashMap<>();
|
private Map<String, List<String>> imageValues = new HashMap<>();
|
||||||
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
|
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
|
private String folder;
|
||||||
private int order;
|
private int order;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public class NpcMapper {
|
|||||||
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
|
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
|
||||||
dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>());
|
dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>());
|
||||||
dto.setCampaignId(n.getCampaignId());
|
dto.setCampaignId(n.getCampaignId());
|
||||||
|
dto.setFolder(n.getFolder());
|
||||||
dto.setOrder(n.getOrder());
|
dto.setOrder(n.getOrder());
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
@@ -35,6 +36,7 @@ public class NpcMapper {
|
|||||||
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
||||||
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
|
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(dto.getCampaignId())
|
.campaignId(dto.getCampaignId())
|
||||||
|
.folder(dto.getFolder())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
Npc result = npcService.createNpc(
|
Npc result = npcService.createNpc(
|
||||||
new NpcService.NpcData("Borin le forgeron", null, null,
|
new NpcService.NpcData("Borin le forgeron", null, null,
|
||||||
Map.of("Notes", "Borin"), null, null, "camp-1", 5));
|
Map.of("Notes", "Borin"), null, null, "camp-1", null,5));
|
||||||
|
|
||||||
assertNotNull(result);
|
assertNotNull(result);
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
@@ -67,7 +67,7 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
||||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null));
|
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null,null));
|
||||||
|
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
verify(npcRepository).save(captor.capture());
|
verify(npcRepository).save(captor.capture());
|
||||||
@@ -79,7 +79,7 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null));
|
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null,null));
|
||||||
|
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
verify(npcRepository).save(captor.capture());
|
verify(npcRepository).save(captor.capture());
|
||||||
@@ -124,7 +124,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
Npc result = npcService.updateNpc("npc-1",
|
Npc result = npcService.updateNpc("npc-1",
|
||||||
new NpcService.NpcData("Borin renommé", null, null,
|
new NpcService.NpcData("Borin renommé", null, null,
|
||||||
Map.of("Notes", "v2"), null, null, "camp-1", 7));
|
Map.of("Notes", "v2"), null, null, "camp-1", null,7));
|
||||||
|
|
||||||
assertEquals("Borin renommé", result.getName());
|
assertEquals("Borin renommé", result.getName());
|
||||||
assertEquals("v2", result.getValues().get("Notes"));
|
assertEquals("v2", result.getValues().get("Notes"));
|
||||||
@@ -138,7 +138,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
Npc result = npcService.updateNpc("npc-1",
|
Npc result = npcService.updateNpc("npc-1",
|
||||||
new NpcService.NpcData("Borin", null, null,
|
new NpcService.NpcData("Borin", null, null,
|
||||||
Map.of("Notes", "txt"), null, null, "camp-1", null));
|
Map.of("Notes", "txt"), null, null, "camp-1", null,null));
|
||||||
|
|
||||||
// testNpc avait order=1 → préservé
|
// testNpc avait order=1 → préservé
|
||||||
assertEquals(1, result.getOrder());
|
assertEquals(1, result.getOrder());
|
||||||
@@ -150,7 +150,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||||
() -> npcService.updateNpc("missing",
|
() -> npcService.updateNpc("missing",
|
||||||
new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null)));
|
new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null,null)));
|
||||||
assertTrue(ex.getMessage().contains("missing"));
|
assertTrue(ex.getMessage().contains("missing"));
|
||||||
verify(npcRepository, never()).save(any());
|
verify(npcRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.11.0-beta",
|
"version": "0.11.2-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.11.0-beta",
|
"version": "0.11.2-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^17.0.0",
|
"@angular/animations": "^17.0.0",
|
||||||
"@angular/common": "^17.0.0",
|
"@angular/common": "^17.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.11.0-beta",
|
"version": "0.11.2-beta",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -96,18 +96,45 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
// à une Partie (Playthrough). On ne les affiche donc plus dans la sidebar de
|
// à une Partie (Playthrough). On ne les affiche donc plus dans la sidebar de
|
||||||
// campagne — seuls les PNJ (donnée de scénario) restent sous "Personnages".
|
// campagne — seuls les PNJ (donnée de scénario) restent sous "Personnages".
|
||||||
const sortedNpcs = [...data.npcs].sort(byName);
|
const sortedNpcs = [...data.npcs].sort(byName);
|
||||||
const npcItems: TreeItem[] = sortedNpcs.map(n => ({
|
const npcItem = (n: Npc): TreeItem => ({
|
||||||
id: `npc-${n.id}`,
|
id: `npc-${n.id}`,
|
||||||
label: n.name,
|
label: n.name,
|
||||||
route: `/campaigns/${campaignId}/npcs/${n.id}`
|
route: `/campaigns/${campaignId}/npcs/${n.id}`
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
// Regroupement par DOSSIER : un sous-nœud (dépliable) par dossier, puis les PNJ
|
||||||
|
// non classés directement sous « PNJ ».
|
||||||
|
const npcsByFolder = new Map<string, Npc[]>();
|
||||||
|
const ungroupedNpcs: Npc[] = [];
|
||||||
|
for (const n of sortedNpcs) {
|
||||||
|
const f = (n.folder ?? '').trim();
|
||||||
|
if (f) {
|
||||||
|
if (!npcsByFolder.has(f)) npcsByFolder.set(f, []);
|
||||||
|
npcsByFolder.get(f)!.push(n);
|
||||||
|
} else {
|
||||||
|
ungroupedNpcs.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const npcFolderNodes: TreeItem[] = [...npcsByFolder.keys()]
|
||||||
|
.sort((a, b) => a.localeCompare(b, 'fr', { sensitivity: 'base' }))
|
||||||
|
.map(folder => {
|
||||||
|
const items = npcsByFolder.get(folder)!.map(npcItem);
|
||||||
|
return {
|
||||||
|
id: `npc-folder-${folder}`,
|
||||||
|
label: folder,
|
||||||
|
iconKey: 'folder',
|
||||||
|
children: items,
|
||||||
|
meta: String(items.length)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const npcChildren: TreeItem[] = [...npcFolderNodes, ...ungroupedNpcs.map(npcItem)];
|
||||||
|
|
||||||
const npcsNode: TreeItem = {
|
const npcsNode: TreeItem = {
|
||||||
id: 'npcs-root',
|
id: 'npcs-root',
|
||||||
label: 'PNJ',
|
label: 'PNJ',
|
||||||
iconKey: 'c-drama',
|
iconKey: 'c-drama',
|
||||||
children: npcItems,
|
children: npcChildren,
|
||||||
meta: npcItems.length ? String(npcItems.length) : undefined,
|
meta: sortedNpcs.length ? String(sortedNpcs.length) : undefined,
|
||||||
// Porte le header de section "Personnages" (les PJ ayant migré vers la Partie).
|
// Porte le header de section "Personnages" (les PJ ayant migré vers la Partie).
|
||||||
// Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar.
|
// Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar.
|
||||||
sectionHeaderBefore: 'Personnages',
|
sectionHeaderBefore: 'Personnages',
|
||||||
|
|||||||
@@ -36,6 +36,21 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="npc-folder">Dossier</label>
|
||||||
|
<input
|
||||||
|
id="npc-folder"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="folder"
|
||||||
|
name="folder"
|
||||||
|
list="npc-folders"
|
||||||
|
placeholder="Ex: Bard's Gate, Faction des Pipers… (laisser vide = non classé)"
|
||||||
|
/>
|
||||||
|
<datalist id="npc-folders">
|
||||||
|
<option *ngFor="let f of existingFolders" [value]="f"></option>
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field-row image-row">
|
<div class="field-row image-row">
|
||||||
<div class="field portrait-field">
|
<div class="field portrait-field">
|
||||||
<label>Portrait</label>
|
<label>Portrait</label>
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export class NpcEditComponent implements OnInit {
|
|||||||
npcId: string | null = null;
|
npcId: string | null = null;
|
||||||
|
|
||||||
name = '';
|
name = '';
|
||||||
|
folder = '';
|
||||||
|
/** Dossiers déjà utilisés dans la campagne (datalist d'auto-complétion). */
|
||||||
|
existingFolders: string[] = [];
|
||||||
portraitImageId: string | null = null;
|
portraitImageId: string | null = null;
|
||||||
headerImageId: string | null = null;
|
headerImageId: string | null = null;
|
||||||
values: Record<string, string> = {};
|
values: Record<string, string> = {};
|
||||||
@@ -72,12 +75,14 @@ export class NpcEditComponent implements OnInit {
|
|||||||
if (this.campaignId) {
|
if (this.campaignId) {
|
||||||
this.loadTemplateForCampaign(this.campaignId);
|
this.loadTemplateForCampaign(this.campaignId);
|
||||||
this.campaignSidebar.show(this.campaignId);
|
this.campaignSidebar.show(this.campaignId);
|
||||||
|
this.loadExistingFolders(this.campaignId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.npcId) {
|
if (this.npcId) {
|
||||||
this.service.getById(this.npcId).subscribe({
|
this.service.getById(this.npcId).subscribe({
|
||||||
next: (n) => {
|
next: (n) => {
|
||||||
this.name = n.name;
|
this.name = n.name;
|
||||||
|
this.folder = n.folder ?? '';
|
||||||
this.portraitImageId = n.portraitImageId ?? null;
|
this.portraitImageId = n.portraitImageId ?? null;
|
||||||
this.headerImageId = n.headerImageId ?? null;
|
this.headerImageId = n.headerImageId ?? null;
|
||||||
this.values = n.values ?? {};
|
this.values = n.values ?? {};
|
||||||
@@ -90,6 +95,17 @@ export class NpcEditComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadExistingFolders(campaignId: string): void {
|
||||||
|
this.service.getByCampaign(campaignId).subscribe({
|
||||||
|
next: (list) => {
|
||||||
|
this.existingFolders = [...new Set(
|
||||||
|
list.map(n => (n.folder ?? '').trim()).filter(f => f.length > 0)
|
||||||
|
)].sort((a, b) => a.localeCompare(b, 'fr'));
|
||||||
|
},
|
||||||
|
error: () => { this.existingFolders = []; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private loadTemplateForCampaign(campaignId: string): void {
|
private loadTemplateForCampaign(campaignId: string): void {
|
||||||
this.campaignService.getCampaignById(campaignId).subscribe({
|
this.campaignService.getCampaignById(campaignId).subscribe({
|
||||||
next: (campaign) => {
|
next: (campaign) => {
|
||||||
@@ -111,6 +127,7 @@ export class NpcEditComponent implements OnInit {
|
|||||||
if (!this.name.trim() || !this.campaignId) return;
|
if (!this.name.trim() || !this.campaignId) return;
|
||||||
const payload = {
|
const payload = {
|
||||||
name: this.name.trim(),
|
name: this.name.trim(),
|
||||||
|
folder: this.folder.trim() || null,
|
||||||
portraitImageId: this.portraitImageId,
|
portraitImageId: this.portraitImageId,
|
||||||
headerImageId: this.headerImageId,
|
headerImageId: this.headerImageId,
|
||||||
values: this.values,
|
values: this.values,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
|
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
@@ -22,7 +23,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr
|
|||||||
templateUrl: './npc-view.component.html',
|
templateUrl: './npc-view.component.html',
|
||||||
styleUrls: ['./npc-view.component.scss']
|
styleUrls: ['./npc-view.component.scss']
|
||||||
})
|
})
|
||||||
export class NpcViewComponent implements OnInit {
|
export class NpcViewComponent implements OnInit, OnDestroy {
|
||||||
readonly ArrowLeft = ArrowLeft;
|
readonly ArrowLeft = ArrowLeft;
|
||||||
readonly Edit3 = Edit3;
|
readonly Edit3 = Edit3;
|
||||||
readonly Sparkles = Sparkles;
|
readonly Sparkles = Sparkles;
|
||||||
@@ -36,6 +37,8 @@ export class NpcViewComponent implements OnInit {
|
|||||||
chatOpen = false;
|
chatOpen = false;
|
||||||
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||||
|
|
||||||
|
private paramsSub?: Subscription;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
@@ -46,16 +49,26 @@ export class NpcViewComponent implements OnInit {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const params = this.route.snapshot.paramMap;
|
// S'abonner aux paramMap (pas le snapshot) : quand on passe d'un PNJ à un autre,
|
||||||
this.campaignId = params.get('campaignId');
|
// Angular RÉUTILISE le composant (même route) → ngOnInit ne re-tourne pas. Sans ce
|
||||||
|
// subscribe, la fiche du centre resterait figée sur l'ancien PNJ.
|
||||||
|
this.paramsSub = this.route.paramMap.subscribe(params => {
|
||||||
|
const newCampaignId = params.get('campaignId');
|
||||||
this.npcId = params.get('npcId');
|
this.npcId = params.get('npcId');
|
||||||
|
|
||||||
|
// Recharge la fiche à CHAQUE changement de PNJ.
|
||||||
|
this.chatOpen = false;
|
||||||
if (this.npcId) {
|
if (this.npcId) {
|
||||||
this.service.getById(this.npcId).subscribe({
|
this.service.getById(this.npcId).subscribe({
|
||||||
next: n => { this.npc = n; },
|
next: n => { this.npc = n; },
|
||||||
error: () => this.back()
|
error: () => this.back()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (this.campaignId) {
|
|
||||||
|
// Sidebar + template du système : seulement quand la campagne change (inutile
|
||||||
|
// de les recharger à chaque switch de PNJ d'une même campagne).
|
||||||
|
if (newCampaignId && newCampaignId !== this.campaignId) {
|
||||||
|
this.campaignId = newCampaignId;
|
||||||
this.campaignSidebar.show(this.campaignId);
|
this.campaignSidebar.show(this.campaignId);
|
||||||
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
|
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
|
||||||
if (camp.gameSystemId) {
|
if (camp.gameSystemId) {
|
||||||
@@ -64,7 +77,14 @@ export class NpcViewComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else if (newCampaignId) {
|
||||||
|
this.campaignId = newCampaignId;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.paramsSub?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
edit(): void {
|
edit(): void {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface Npc {
|
|||||||
imageValues?: Record<string, string[]>;
|
imageValues?: Record<string, string[]>;
|
||||||
keyValueValues?: Record<string, Record<string, string>>;
|
keyValueValues?: Record<string, Record<string, string>>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
|
/** Dossier de classement (ex. « Bard's Gate »). Vide/absent = non classé. */
|
||||||
|
folder?: string | null;
|
||||||
order?: number;
|
order?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,4 +24,5 @@ export interface NpcCreate {
|
|||||||
imageValues?: Record<string, string[]>;
|
imageValues?: Record<string, string[]>;
|
||||||
keyValueValues?: Record<string, Record<string, string>>;
|
keyValueValues?: Record<string, Record<string, string>>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
|
folder?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user