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
This commit is contained in:
2026-06-08 11:05:07 +02:00
parent edc4434298
commit ed22d9f29c
14 changed files with 145 additions and 32 deletions

View File

@@ -63,11 +63,18 @@ class NotebookDeepUseCase:
async def stream(
self,
source_ids: list[str],
question: str,
messages: list[ChatMessage],
context: str = "",
history_limit: int = 8,
) -> AsyncIterator[dict]:
"""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] = []
for sid in source_ids:
chunks.extend(vector_store.all_chunks(sid))
@@ -102,10 +109,11 @@ class NotebookDeepUseCase:
if context.strip() else ""
)
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]
async for token in llm_chat.stream_chat(
[ChatMessage(role="user", content=question)], system_prompt=system_prompt
):
async for token in llm_chat.stream_chat(reduce_messages, system_prompt=system_prompt):
yield {"type": "token", "token": token}
yield {"type": "done"}

View File

@@ -1100,7 +1100,8 @@ async def chat_notebook_deep_stream(
) -> StreamingResponse:
"""Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE :
`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:
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."})
return
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")
yield _sse(ev_type, ev)
except (LLMProviderError, EmbeddingError) as exc:

View File

@@ -29,6 +29,7 @@ public class NpcService {
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String campaignId,
String folder,
Integer order
) {}
@@ -44,6 +45,7 @@ public class NpcService {
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId())
.folder(normalizeFolder(data.folder()))
.order(order)
.build();
return npcRepository.save(npc);
@@ -66,6 +68,7 @@ public class NpcService {
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : 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.setFolder(normalizeFolder(data.folder()));
if (data.order() != null) {
existing.setOrder(data.order());
}
@@ -76,6 +79,13 @@ public class NpcService {
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) {
return npcRepository.findByCampaignId(campaignId).stream()
.mapToInt(Npc::getOrder)

View File

@@ -46,6 +46,9 @@ public class Npc {
/** Référence vers la Campaign parente (cross-aggregate via ID). */
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. */
private int order;

View File

@@ -54,6 +54,9 @@ public class NpcJpaEntity {
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "folder")
private String folder;
@Column(name = "\"order\"", nullable = false)
private int order;

View File

@@ -59,6 +59,7 @@ public class PostgresNpcRepository implements NpcRepository {
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString())
.folder(e.getFolder())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
@@ -76,6 +77,7 @@ public class PostgresNpcRepository implements NpcRepository {
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(n.getCampaignId()))
.folder(n.getFolder())
.order(n.getOrder())
.createdAt(n.getCreatedAt())
.updatedAt(n.getUpdatedAt())

View File

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

View File

@@ -20,5 +20,6 @@ public class NpcDTO {
private Map<String, List<String>> imageValues = new HashMap<>();
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
private String campaignId;
private String folder;
private int order;
}

View File

@@ -20,6 +20,7 @@ public class NpcMapper {
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>());
dto.setCampaignId(n.getCampaignId());
dto.setFolder(n.getFolder());
dto.setOrder(n.getOrder());
return dto;
}
@@ -35,6 +36,7 @@ public class NpcMapper {
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
.campaignId(dto.getCampaignId())
.folder(dto.getFolder())
.order(dto.getOrder())
.build();
}

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
// campagne — seuls les PNJ (donnée de scénario) restent sous "Personnages".
const sortedNpcs = [...data.npcs].sort(byName);
const npcItems: TreeItem[] = sortedNpcs.map(n => ({
const npcItem = (n: Npc): TreeItem => ({
id: `npc-${n.id}`,
label: n.name,
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 = {
id: 'npcs-root',
label: 'PNJ',
iconKey: 'c-drama',
children: npcItems,
meta: npcItems.length ? String(npcItems.length) : undefined,
children: npcChildren,
meta: sortedNpcs.length ? String(sortedNpcs.length) : undefined,
// 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.
sectionHeaderBefore: 'Personnages',

View File

@@ -36,6 +36,21 @@
/>
</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 portrait-field">
<label>Portrait</label>

View File

@@ -46,6 +46,9 @@ export class NpcEditComponent implements OnInit {
npcId: string | null = null;
name = '';
folder = '';
/** Dossiers déjà utilisés dans la campagne (datalist d'auto-complétion). */
existingFolders: string[] = [];
portraitImageId: string | null = null;
headerImageId: string | null = null;
values: Record<string, string> = {};
@@ -72,12 +75,14 @@ export class NpcEditComponent implements OnInit {
if (this.campaignId) {
this.loadTemplateForCampaign(this.campaignId);
this.campaignSidebar.show(this.campaignId);
this.loadExistingFolders(this.campaignId);
}
if (this.npcId) {
this.service.getById(this.npcId).subscribe({
next: (n) => {
this.name = n.name;
this.folder = n.folder ?? '';
this.portraitImageId = n.portraitImageId ?? null;
this.headerImageId = n.headerImageId ?? null;
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 {
this.campaignService.getCampaignById(campaignId).subscribe({
next: (campaign) => {
@@ -111,6 +127,7 @@ export class NpcEditComponent implements OnInit {
if (!this.name.trim() || !this.campaignId) return;
const payload = {
name: this.name.trim(),
folder: this.folder.trim() || null,
portraitImageId: this.portraitImageId,
headerImageId: this.headerImageId,
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 { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
import { NpcService } from '../../../services/npc.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',
styleUrls: ['./npc-view.component.scss']
})
export class NpcViewComponent implements OnInit {
export class NpcViewComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft;
readonly Edit3 = Edit3;
readonly Sparkles = Sparkles;
@@ -36,6 +37,8 @@ export class NpcViewComponent implements OnInit {
chatOpen = false;
toggleChat(): void { this.chatOpen = !this.chatOpen; }
private paramsSub?: Subscription;
constructor(
private route: ActivatedRoute,
private router: Router,
@@ -46,16 +49,26 @@ export class NpcViewComponent implements OnInit {
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
// S'abonner aux paramMap (pas le snapshot) : quand on passe d'un PNJ à un autre,
// 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');
// Recharge la fiche à CHAQUE changement de PNJ.
this.chatOpen = false;
if (this.npcId) {
this.service.getById(this.npcId).subscribe({
next: n => { this.npc = n; },
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.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
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 {

View File

@@ -11,6 +11,8 @@ export interface Npc {
imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>;
campaignId: string;
/** Dossier de classement (ex. « Bard's Gate »). Vide/absent = non classé. */
folder?: string | null;
order?: number;
}
@@ -22,4 +24,5 @@ export interface NpcCreate {
imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>;
campaignId: string;
folder?: string | null;
}