Compare commits

..

4 Commits

Author SHA1 Message Date
70ec1f2fb9 Modification du timeout pour permettre d'analyser de plus gros livres
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-08 14:49:00 +02:00
f638fdd24b Mise à jour d'un test unitaire qui ne passait plus avec la mise à jour précédente
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-08 11:11:50 +02:00
e85ab0e6b1 passage version 0.11.1-beta 2026-06-08 11:05:48 +02:00
ed22d9f29c Résolution d'un bug de switch entre les PNJ lorsqu'on essai de passer directement de l'un à l'autre
Ajout de dossiers pour la partie PNJ pour qu'on puisse les regrouper par cité par exemple et que ce soit plus lisible
2026-06-08 11:05:07 +02:00
19 changed files with 174 additions and 53 deletions

View File

@@ -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"}

View File

@@ -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:

View File

@@ -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>

View File

@@ -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)

View File

@@ -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;

View File

@@ -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;

View File

@@ -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())

View File

@@ -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
} }
} }

View File

@@ -64,6 +64,7 @@ public class NpcController {
dto.getImageValues(), dto.getImageValues(),
dto.getKeyValueValues(), dto.getKeyValueValues(),
dto.getCampaignId(), dto.getCampaignId(),
dto.getFolder(),
order order
); );
} }

View File

@@ -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;
} }

View File

@@ -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();
} }

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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',

View File

@@ -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>

View File

@@ -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,

View File

@@ -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,25 +49,42 @@ 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
this.npcId = params.get('npcId'); // subscribe, la fiche du centre resterait figée sur l'ancien PNJ.
if (this.npcId) { this.paramsSub = this.route.paramMap.subscribe(params => {
this.service.getById(this.npcId).subscribe({ const newCampaignId = params.get('campaignId');
next: n => { this.npc = n; }, this.npcId = params.get('npcId');
error: () => this.back()
}); // Recharge la fiche à CHAQUE changement de PNJ.
} this.chatOpen = false;
if (this.campaignId) { if (this.npcId) {
this.campaignSidebar.show(this.campaignId); this.service.getById(this.npcId).subscribe({
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => { next: n => { this.npc = n; },
if (camp.gameSystemId) { error: () => this.back()
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => { });
this.templateFields = gs.npcTemplate ?? []; }
});
} // 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.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
if (camp.gameSystemId) {
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
this.templateFields = gs.npcTemplate ?? [];
});
}
});
} else if (newCampaignId) {
this.campaignId = newCampaignId;
}
});
}
ngOnDestroy(): void {
this.paramsSub?.unsubscribe();
} }
edit(): void { edit(): void {

View File

@@ -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;
} }