Plusieurs gros ajouts :
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

- Possibilité de discuter avec un PDF ; RAG ou analyse approfondie. Enlèvement de l'autre outil PDF de discussion qui analysait d'abord un PDF en proposant directement une intégration sans attendre qu'on pose de question
- Mise en place de l'import directement dans les outils dans la sidebar
- Mise en place d'un outil pour créer des tables aléatoires avec possibilité d'utiliser pendant la partie
- Mise en place d'un outil pour mettre en place des PNJ, scènes, chapitre.... directement à partir de la discussion avec le PDF
- Mise en place RAG avec mistal-embeding ou nomic si on utilise ollama
- Mise en place mistral, google en fournisseurs alternatifs pour l'IA dans le cloud
- version 0.11.0-bêta
This commit is contained in:
2026-06-07 09:52:15 +02:00
parent 5eb15dc449
commit edc4434298
113 changed files with 6347 additions and 662 deletions

View File

@@ -17,7 +17,6 @@ export const routes: Routes = [
{ path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) },
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
{ path: 'campaigns/:campaignId/import', loadComponent: () => import('./campaigns/campaign/campaign-import/campaign-import.component').then(m => m.CampaignImportComponent) },
{ path: 'campaigns/:campaignId/adapt', loadComponent: () => import('./campaigns/campaign/campaign-adapt/campaign-adapt.component').then(m => m.CampaignAdaptComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId', loadComponent: () => import('./campaigns/playthrough/playthrough-detail/playthrough-detail.component').then(m => m.PlaythroughDetailComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/flags', loadComponent: () => import('./campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component').then(m => m.PlaythroughFlagsPageComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
@@ -26,6 +25,11 @@ export const routes: Routes = [
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
{ path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) },
{ path: 'campaigns/:campaignId/notebooks', loadComponent: () => import('./campaigns/notebook/notebook-list/notebook-list.component').then(m => m.NotebookListComponent) },
{ path: 'campaigns/:campaignId/notebooks/:notebookId', loadComponent: () => import('./campaigns/notebook/notebook-detail/notebook-detail.component').then(m => m.NotebookDetailComponent) },
{ path: 'campaigns/:campaignId/random-tables/create', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
{ path: 'campaigns/:campaignId/random-tables/:tableId/edit', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
{ path: 'campaigns/:campaignId/random-tables/:tableId', loadComponent: () => import('./campaigns/random-table/random-table-view/random-table-view.component').then(m => m.RandomTableViewComponent) },
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },

View File

@@ -7,6 +7,7 @@ import { LucideAngularModule, BookOpen } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
@@ -40,6 +41,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
@@ -59,7 +61,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
this.existingArcCount = treeData.arcs.length;

View File

@@ -8,6 +8,7 @@ import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
@@ -77,6 +78,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
@@ -116,7 +118,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -8,6 +8,7 @@ import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
@@ -59,6 +60,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
@@ -82,7 +84,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -3,10 +3,13 @@ import { switchMap, map } from 'rxjs/operators';
import { CampaignService } from '../services/campaign.service';
import { CharacterService } from '../services/character.service';
import { NpcService } from '../services/npc.service';
import { RandomTableService } from '../services/random-table.service';
import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service';
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
import { Character } from '../services/character.model';
import { Npc } from '../services/npc.model';
import { RandomTable } from '../services/random-table.model';
import { catchError } from 'rxjs/operators';
/**
* Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes)
@@ -22,13 +25,17 @@ export interface CampaignTreeData {
scenesByChapter: Record<string, Scene[]>;
characters: Character[];
npcs: Npc[];
randomTables: RandomTable[];
}
export function loadCampaignTreeData(
service: CampaignService,
campaignId: string,
characterService: CharacterService,
npcService: NpcService
npcService: NpcService,
// Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les
// tables aléatoires sont chargées et apparaissent dans la sidebar.
randomTableService?: RandomTableService
): Observable<CampaignTreeData> {
// Note refonte Playthrough : les PJ appartiennent désormais à une Partie,
// pas à la campagne — on ne les charge plus ici (les vues qui les affichent
@@ -36,11 +43,14 @@ export function loadCampaignTreeData(
return forkJoin({
arcs: service.getArcs(campaignId),
characters: of([] as Character[]),
npcs: npcService.getByCampaign(campaignId)
npcs: npcService.getByCampaign(campaignId),
randomTables: randomTableService
? randomTableService.getByCampaign(campaignId).pipe(catchError(() => of([] as RandomTable[])))
: of([] as RandomTable[])
}).pipe(
switchMap(({ arcs, characters, npcs }) => {
switchMap(({ arcs, characters, npcs, randomTables }) => {
if (arcs.length === 0) {
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs });
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs, randomTables });
}
const chapterCalls = arcs.map(a =>
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
@@ -55,7 +65,7 @@ export function loadCampaignTreeData(
});
if (allChapters.length === 0) {
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs });
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs, randomTables });
}
const sceneCalls = allChapters.map(c =>
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
@@ -64,7 +74,7 @@ export function loadCampaignTreeData(
map(sceneResults => {
const scenesByChapter: Record<string, Scene[]> = {};
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
return { arcs, chaptersByArc, scenesByChapter, characters, npcs };
return { arcs, chaptersByArc, scenesByChapter, characters, npcs, randomTables };
})
);
})
@@ -157,7 +167,46 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
};
});
return [...arcNodes, npcsNode];
const sortedTables = [...(data.randomTables ?? [])].sort(byName);
const tableItems: TreeItem[] = sortedTables.map(t => ({
id: `random-table-${t.id}`,
label: t.name,
iconKey: t.icon ?? 'dice',
route: `/campaigns/${campaignId}/random-tables/${t.id}`
}));
const tablesNode: TreeItem = {
id: 'random-tables-root',
label: 'Tables aléatoires',
iconKey: 'dice',
children: tableItems,
meta: tableItems.length ? String(tableItems.length) : undefined,
sectionHeaderBefore: 'Outils',
createActions: [{
id: 'new-random-table',
label: 'Nouvelle table',
route: `/campaigns/${campaignId}/random-tables/create`,
actionIcon: 'plus'
}]
};
// Lien simple vers les ateliers (la liste se charge sur sa page — pas de fetch ici).
const notebooksNode: TreeItem = {
id: 'notebooks-root',
label: 'Ateliers (IA + PDF)',
iconKey: 'book-open',
route: `/campaigns/${campaignId}/notebooks`
};
// Importer un PDF de campagne → arborescence (outil, comme tables & ateliers).
const importNode: TreeItem = {
id: 'import-pdf-root',
label: 'Importer un PDF',
iconKey: 'file-up',
route: `/campaigns/${campaignId}/import`
};
return [...arcNodes, npcsNode, tablesNode, notebooksNode, importNode];
}
/**

View File

@@ -1,61 +0,0 @@
<div class="adapt-page">
<div class="page-header">
<button type="button" class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour à la campagne
</button>
<h1>Adapter un PDF à cette campagne</h1>
<p class="subtitle">
L'IA connaît votre campagne (structure, PNJ, univers) et lit le PDF, puis discute avec vous
pour l'intégrer et l'adapter. Ce sont des <strong>conseils</strong> : rien n'est créé,
vous appliquez ce qui vous plaît — et vous pouvez lui répondre pour qu'elle ajuste.
</p>
</div>
<!-- Choix / changement du PDF -->
<section class="upload-bar">
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
<button type="button" class="btn-primary" [disabled]="streaming" (click)="pdfInput.click()">
<lucide-icon [img]="Upload" [size]="15"></lucide-icon>
{{ hasConversation ? 'Changer de PDF' : 'Choisir un PDF à adapter' }}
</button>
<span class="file-name" *ngIf="fileName">{{ fileName }}</span>
</section>
<p class="adapt-error" *ngIf="error">{{ error }}</p>
<!-- Conversation -->
<section class="chat" *ngIf="hasConversation">
<div class="msg" *ngFor="let m of messages; let i = index"
[class.msg--user]="m.role === 'user'" [class.msg--assistant]="m.role === 'assistant'">
<div class="msg-role">{{ m.role === 'user' ? 'Vous' : 'IA' }}</div>
<div class="msg-body" *ngIf="m.role === 'user'">{{ m.content }}</div>
<div class="msg-body markdown-body" *ngIf="m.role === 'assistant'" [innerHTML]="m.content | markdown"></div>
<div class="msg-actions" *ngIf="m.role === 'assistant' && m.content && !(streaming && i === messages.length - 1)">
<button type="button" class="btn-copy" (click)="copy(i)">
<lucide-icon [img]="copiedIndex === i ? Check : Copy" [size]="12"></lucide-icon>
{{ copiedIndex === i ? 'Copié' : 'Copier' }}
</button>
</div>
<span class="streaming-cursor" *ngIf="streaming && i === messages.length - 1 && m.role === 'assistant'"></span>
</div>
</section>
<!-- Saisie d'un feedback -->
<section class="composer" *ngIf="hasConversation">
<textarea
[(ngModel)]="input"
[disabled]="streaming"
rows="2"
placeholder="Répondez à l'IA : corrigez, demandez une alternative, un autre lieu à intégrer…"
(keydown.enter)="$event.preventDefault(); sendCurrent()"></textarea>
<button type="button" class="btn-send" [disabled]="streaming || !input.trim()" (click)="sendCurrent()">
<lucide-icon [img]="Send" [size]="16"></lucide-icon>
</button>
</section>
</div>

View File

@@ -1,184 +0,0 @@
.adapt-page {
padding: 2rem 2.5rem;
max-width: 900px;
}
.page-header {
margin-bottom: 1.25rem;
h1 { margin: 0.6rem 0 0.3rem; font-size: 1.5rem; color: #f3f4f6; }
}
.btn-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
font-size: 0.85rem;
padding: 0;
&:hover { color: #c4b5fd; }
}
.subtitle { margin: 0; color: #9ca3af; font-size: 0.9rem; strong { color: #d1d5db; } }
// --- Barre PDF --------------------------------------------------------------
.upload-bar {
display: flex;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.6rem 1.1rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
border: none;
background: #6c63ff;
color: white;
&:hover:not(:disabled) { background: #5b52e0; }
&:disabled { opacity: 0.6; cursor: progress; }
}
.file-name { color: #9ca3af; font-size: 0.82rem; }
.adapt-error {
margin: 0 0 1rem;
padding: 0.55rem 0.8rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.35);
border-radius: 8px;
color: #fca5a5;
font-size: 0.85rem;
}
// --- Conversation -----------------------------------------------------------
.chat {
display: flex;
flex-direction: column;
gap: 0.9rem;
margin-bottom: 1rem;
}
.msg {
border-radius: 12px;
padding: 0.7rem 0.9rem;
border: 1px solid #1f2937;
&--user {
background: rgba(108, 99, 255, 0.1);
border-color: rgba(108, 99, 255, 0.3);
align-self: flex-end;
max-width: 85%;
}
&--assistant {
background: #0b1220;
}
}
.msg-role {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9ca3af;
margin-bottom: 0.35rem;
}
.msg-body {
color: #d1d5db;
font-size: 0.92rem;
line-height: 1.6;
white-space: pre-wrap;
&.markdown-body { white-space: normal; }
::ng-deep {
h1, h2, h3 { color: #f3f4f6; margin: 0.9rem 0 0.4rem; }
h2 { font-size: 1.08rem; }
h3 { font-size: 1rem; }
ul, ol { padding-left: 1.3rem; }
li { margin: 0.2rem 0; }
strong { color: #fff; }
code { background: #1f2937; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.85em; }
a { color: #a78bfa; }
p { margin: 0.4rem 0; }
}
}
.msg-actions { margin-top: 0.5rem; }
.btn-copy {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: transparent;
border: 1px solid #374151;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
&:hover { color: #c4b5fd; border-color: #6c63ff; }
}
.streaming-cursor {
display: inline-block;
color: #6c63ff;
animation: blink 1s step-start infinite;
}
@keyframes blink { 50% { opacity: 0; } }
// --- Composer ---------------------------------------------------------------
.composer {
display: flex;
gap: 0.5rem;
align-items: flex-end;
position: sticky;
bottom: 0;
padding-top: 0.5rem;
background: linear-gradient(to top, var(--color-bg, #0a0f1a) 70%, transparent);
textarea {
flex: 1;
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 8px;
color: #f3f4f6;
padding: 0.55rem 0.7rem;
font-size: 0.9rem;
font-family: inherit;
resize: vertical;
&:focus { outline: none; border-color: #6c63ff; }
&:disabled { opacity: 0.6; }
}
}
.btn-send {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 8px;
border: none;
background: #6c63ff;
color: white;
cursor: pointer;
&:hover:not(:disabled) { background: #5b52e0; }
&:disabled { opacity: 0.5; cursor: default; }
}

View File

@@ -1,130 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, ArrowLeft, Upload, Copy, Check, Send } from 'lucide-angular';
import { CampaignAdaptService, AdaptMessage } from '../../../services/campaign-adapt.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { PageTitleService } from '../../../services/page-title.service';
import { MarkdownPipe } from '../../../shared/markdown.pipe';
const FIRST_PROMPT = 'Propose-moi comment intégrer et adapter ce PDF à ma campagne.';
/**
* Page « Adapter un PDF » — CONVERSATIONNELLE. L'IA connaît la campagne (structure,
* PNJ, univers) + lit le PDF, propose une 1re adaptation, puis l'utilisateur peut
* répondre (corriger, demander des alternatives…) et l'IA rebondit.
* Route : /campaigns/:campaignId/adapt — rien n'est créé, conseils à appliquer à la main.
*/
@Component({
selector: 'app-campaign-adapt',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, MarkdownPipe],
templateUrl: './campaign-adapt.component.html',
styleUrls: ['./campaign-adapt.component.scss']
})
export class CampaignAdaptComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Upload = Upload;
readonly Copy = Copy;
readonly Check = Check;
readonly Send = Send;
campaignId = '';
/** PDF choisi, conservé pour les tours de conversation suivants. */
private file: File | null = null;
fileName = '';
/** Conversation affichée (user + assistant). */
messages: AdaptMessage[] = [];
streaming = false;
error: string | null = null;
/** Saisie du message en cours. */
input = '';
copiedIndex: number | null = null;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: CampaignAdaptService,
private campaignSidebar: CampaignSidebarService,
private pageTitle: PageTitleService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId')!;
this.pageTitle.set('Adapter un PDF');
this.campaignSidebar.show(this.campaignId);
}
get hasConversation(): boolean { return this.messages.length > 0; }
// --- Choix du PDF (démarre / réinitialise la conversation) ---------------
onPdfSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
this.file = file;
this.fileName = file.name;
this.messages = [];
this.error = null;
this.send(FIRST_PROMPT);
}
// --- Envoi d'un message (1er tour ou feedback) ---------------------------
sendCurrent(): void {
const text = this.input.trim();
if (!text || this.streaming) return;
this.input = '';
this.send(text);
}
private send(text: string): void {
if (!this.file || this.streaming) return;
this.error = null;
this.messages.push({ role: 'user', content: text });
// Historique envoyé = tout jusqu'au message user inclus (sans la bulle vide).
const payload: AdaptMessage[] = this.messages.map(m => ({ role: m.role, content: m.content }));
const assistant: AdaptMessage = { role: 'assistant', content: '' };
this.messages.push(assistant);
this.streaming = true;
this.service.adviseStream(this.campaignId, this.file, payload).subscribe({
next: (ev) => {
if (ev.type === 'token') {
assistant.content += ev.value;
} else if (ev.type === 'done') {
this.streaming = false;
}
},
error: (err: Error) => {
this.streaming = false;
// Bulle assistant restée vide → on la retire pour ne pas afficher de vide.
if (!assistant.content) {
this.messages = this.messages.filter(m => m !== assistant);
}
this.error = err?.message ? `Échec : ${err.message}` : "Échec de l'adaptation.";
}
});
}
copy(index: number): void {
const msg = this.messages[index];
if (!msg) return;
navigator.clipboard?.writeText(msg.content).then(() => {
this.copiedIndex = index;
setTimeout(() => (this.copiedIndex = null), 2000);
});
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
}

View File

@@ -144,14 +144,6 @@
<div class="section-header">
<h2>Arcs narratifs</h2>
<div class="section-header-actions">
<button class="btn-add btn-add--secondary" (click)="adaptCampaign()" title="Conseils IA pour adapter un PDF à cette campagne">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Adapter un PDF
</button>
<button class="btn-add btn-add--secondary" (click)="importCampaign()" title="Générer l'arborescence depuis un PDF de campagne">
<lucide-icon [img]="Upload" [size]="14"></lucide-icon>
Importer un PDF
</button>
<button class="btn-add" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvel arc

View File

@@ -12,6 +12,7 @@ import { GameSystemService } from '../../../services/game-system.service';
import { GameSystem } from '../../../services/game-system.model';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { SessionService } from '../../../services/session.service';
import { PlaythroughService } from '../../../services/playthrough.service';
import { Playthrough } from '../../../services/campaign.model';
@@ -94,6 +95,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private gameSystemService: GameSystemService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private sessionService: SessionService,
private playthroughService: PlaythroughService,
private layoutService: LayoutService,
@@ -111,8 +113,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
switchMap(id => forkJoin({
campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData))
),
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
}))
@@ -148,8 +150,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData))
),
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
}).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
@@ -246,18 +248,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);
}
/** Ouvre la page d'import d'un PDF de campagne (proposition d'arbre à réviser). */
importCampaign(): void {
if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'import']);
}
/** Ouvre la page de conseils d'adaptation d'un PDF à cette campagne. */
adaptCampaign(): void {
if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'adapt']);
}
openArc(arc: Arc): void {
if (!this.campaign || !arc.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]);

View File

@@ -10,6 +10,7 @@ import { CampaignImportService } from '../../../services/campaign-import.service
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { PageTitleService } from '../../../services/page-title.service';
import { ArcKind, ArcProposal, ChapterProposal, SceneProposal } from '../../../services/campaign-import.model';
@@ -91,6 +92,7 @@ export class CampaignImportComponent implements OnInit {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private campaignSidebar: CampaignSidebarService,
private pageTitle: PageTitleService
) {}
@@ -102,7 +104,7 @@ export class CampaignImportComponent implements OnInit {
// Pré-chargement de l'arborescence existante (pour fusionner à la revue).
// En cas d'échec on dégrade : tout sera considéré comme nouveau.
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
.pipe(catchError(() => of(null)))
.subscribe(data => this.existingData = data);
}

View File

@@ -7,6 +7,7 @@ import { LucideAngularModule } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
@@ -42,6 +43,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
@@ -60,7 +62,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
this.arcName = currentArc?.name ?? '';

View File

@@ -8,6 +8,7 @@ import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
@@ -92,6 +93,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
@@ -128,7 +130,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -6,6 +6,7 @@ import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Chapter, Scene } from '../../../services/campaign.model';
@@ -70,6 +71,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
@@ -89,7 +91,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
scenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
this.chapter = chapter;
this.scenes = scenes;

View File

@@ -8,6 +8,7 @@ import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
@@ -51,6 +52,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
@@ -78,7 +80,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -0,0 +1,41 @@
<div class="nac" [class.created]="status === 'created'">
<div class="nac-head">
<lucide-icon [img]="typeIcon" [size]="15"></lucide-icon>
<span class="nac-type">{{ typeLabel }}</span>
<span class="nac-name">{{ action.name }}</span>
</div>
<p class="nac-desc" *ngIf="action.description">{{ action.description }}</p>
<!-- Cibles -->
<div class="nac-targets" *ngIf="status !== 'created' && needsArc">
<label>
Arc
<select [(ngModel)]="selectedArcId" (ngModelChange)="syncChapter()">
<option *ngFor="let a of arcs" [value]="a.id">{{ a.name }}</option>
</select>
</label>
<label *ngIf="needsChapter">
Chapitre
<select [(ngModel)]="selectedChapterId">
<option *ngFor="let c of targetChapters" [value]="c.id">{{ c.name }}</option>
</select>
</label>
</div>
<p class="nac-warn" *ngIf="needsArc && arcs.length === 0">
Crée d'abord un arc {{ needsChapter ? 'et un chapitre ' : '' }}dans la campagne pour pouvoir y rattacher cet élément.
</p>
<div class="nac-foot">
<button class="nac-create" *ngIf="status !== 'created'" (click)="create()" [disabled]="!canCreate">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
{{ status === 'creating' ? 'Création…' : 'Créer dans la campagne' }}
</button>
<button class="nac-open" *ngIf="status === 'created'" (click)="openCreated()">
<lucide-icon [img]="Check" [size]="13"></lucide-icon> Créé — ouvrir
<lucide-icon [img]="ExternalLink" [size]="12"></lucide-icon>
</button>
<span class="nac-error" *ngIf="status === 'error'">{{ errorMsg }}</span>
</div>
</div>

View File

@@ -0,0 +1,52 @@
.nac {
margin-top: 0.5rem;
padding: 0.6rem 0.75rem;
border-radius: 9px;
border: 1px solid rgba(168, 130, 255, 0.3);
background: rgba(168, 130, 255, 0.08);
&.created { border-color: rgba(107, 208, 138, 0.4); background: rgba(107, 208, 138, 0.08); }
}
.nac-head {
display: flex; align-items: center; gap: 0.4rem;
color: #c4a8ff;
.nac-type {
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.05rem 0.4rem; border-radius: 4px; background: rgba(168,130,255,0.18);
}
.nac-name { font-weight: 600; color: inherit; }
}
.nac-desc {
margin: 0.4rem 0 0; font-size: 0.85rem; color: var(--color-text-muted, #cfd3da);
white-space: pre-wrap;
}
.nac-targets {
display: flex; gap: 0.6rem; flex-wrap: wrap; margin-top: 0.5rem;
label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); }
select {
padding: 0.3rem 0.45rem; border-radius: 6px; font: inherit;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); color: inherit;
}
}
.nac-warn { margin: 0.4rem 0 0; font-size: 0.78rem; color: #e0a458; }
.nac-foot { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.55rem; }
.nac-create, .nac-open {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.35rem 0.7rem; border-radius: 7px; cursor: pointer; font-size: 0.82rem; font-weight: 600;
border: none;
}
.nac-create {
background: #8a6dff; color: #fff;
&:hover:not(:disabled) { background: #7a5cf0; }
&:disabled { opacity: 0.5; cursor: default; }
}
.nac-open { background: rgba(107,208,138,0.2); color: #6bd08a; }
.nac-open:hover { background: rgba(107,208,138,0.3); }
.nac-error { color: #e88; font-size: 0.8rem; }

View File

@@ -0,0 +1,185 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LucideAngularModule, Plus, Check, Drama, Clapperboard, BookText, GitBranch, Dices, ExternalLink } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { Arc, Chapter } from '../../../services/campaign.model';
import { NotebookAction } from '../../../services/notebook-action.model';
/**
* Carte « Créer dans la campagne » issue d'une proposition de l'IA. Gère la cible
* (chapitre pour une scène, arc pour un chapitre) et appelle les services existants.
*/
@Component({
selector: 'app-notebook-action-card',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './notebook-action-card.component.html',
styleUrls: ['./notebook-action-card.component.scss']
})
export class NotebookActionCardComponent implements OnInit {
readonly Plus = Plus;
readonly Check = Check;
readonly ExternalLink = ExternalLink;
@Input() action!: NotebookAction;
@Input() campaignId!: string;
@Input() arcs: Arc[] = [];
@Input() chaptersByArc: Record<string, Chapter[]> = {};
/** Émis après une création réussie → l'atelier rafraîchit la sidebar. */
@Output() created = new EventEmitter<void>();
selectedArcId = '';
selectedChapterId = '';
status: 'idle' | 'creating' | 'created' | 'error' = 'idle';
errorMsg = '';
createdRoute: string[] | null = null;
constructor(
private campaignService: CampaignService,
private npcService: NpcService,
private tableService: RandomTableService,
private router: Router
) {}
ngOnInit(): void {
if (this.arcs.length) this.selectedArcId = this.arcs[0].id!;
this.syncChapter();
}
get typeLabel(): string {
switch (this.action.type) {
case 'npc': return 'PNJ';
case 'scene': return 'Scène';
case 'chapter': return 'Chapitre';
case 'arc': return 'Arc';
case 'table': return 'Table aléatoire';
default: return this.action.type;
}
}
get typeIcon() {
switch (this.action.type) {
case 'npc': return Drama;
case 'scene': return Clapperboard;
case 'chapter': return BookText;
case 'arc': return GitBranch;
case 'table': return Dices;
default: return Plus;
}
}
get needsArc(): boolean { return this.action.type === 'chapter' || this.action.type === 'scene'; }
get needsChapter(): boolean { return this.action.type === 'scene'; }
get targetChapters(): Chapter[] { return this.chaptersByArc[this.selectedArcId] ?? []; }
syncChapter(): void {
const chs = this.targetChapters;
this.selectedChapterId = chs.length ? chs[0].id! : '';
}
get canCreate(): boolean {
if (this.status === 'creating' || this.status === 'created') return false;
if (this.needsArc && !this.selectedArcId) return false;
if (this.needsChapter && !this.selectedChapterId) return false;
return true;
}
create(): void {
if (!this.canCreate) return;
this.status = 'creating';
this.errorMsg = '';
switch (this.action.type) {
case 'npc': return this.createNpc();
case 'arc': return this.createArc();
case 'chapter': return this.createChapter();
case 'scene': return this.createScene();
case 'table': return this.createTable();
}
}
private createNpc(): void {
this.npcService.create({
name: this.action.name,
campaignId: this.campaignId,
values: this.action.description ? { Description: this.action.description } : {}
}).subscribe({
next: (n) => this.done(['/campaigns', this.campaignId, 'npcs', n.id!]),
error: (e) => this.fail(e)
});
}
private createArc(): void {
this.campaignService.createArc({
name: this.action.name,
description: this.action.description,
campaignId: this.campaignId,
order: this.arcs.length,
type: this.action.arcType === 'HUB' ? 'HUB' : 'LINEAR'
}).subscribe({
next: (a) => this.done(['/campaigns', this.campaignId, 'arcs', a.id!]),
error: (e) => this.fail(e)
});
}
private createChapter(): void {
const order = (this.chaptersByArc[this.selectedArcId] ?? []).length;
this.campaignService.createChapter({
name: this.action.name,
description: this.action.description,
arcId: this.selectedArcId,
order
}).subscribe({
next: (c) => this.done(['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', c.id!]),
error: (e) => this.fail(e)
});
}
private createScene(): void {
this.campaignService.createScene({
name: this.action.name,
description: this.action.description,
playerNarration: this.action.content,
chapterId: this.selectedChapterId,
order: 0
}).subscribe({
next: (s) => this.done(
['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', this.selectedChapterId, 'scenes', s.id!]),
error: (e) => this.fail(e)
});
}
private createTable(): void {
this.tableService.create({
name: this.action.name,
diceFormula: this.action.diceFormula || '1d20',
campaignId: this.campaignId,
entries: (this.action.entries ?? []).map(e => ({
minRoll: e.minRoll, maxRoll: e.maxRoll, label: e.label, detail: e.detail
}))
}).subscribe({
next: (t) => this.done(['/campaigns', this.campaignId, 'random-tables', t.id!]),
error: (e) => this.fail(e)
});
}
private done(route: string[]): void {
this.status = 'created';
this.createdRoute = route;
this.created.emit();
}
private fail(err: unknown): void {
this.status = 'error';
this.errorMsg = (err as { error?: { message?: string } })?.error?.message || 'Échec de la création.';
}
openCreated(): void {
if (this.createdRoute) this.router.navigate(this.createdRoute);
}
}

View File

@@ -0,0 +1,98 @@
<div class="nbd-page" *ngIf="detail">
<div class="nbd-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Ateliers
</button>
<input class="nbd-title" [(ngModel)]="detail.name" (blur)="rename()" (keyup.enter)="rename()">
</div>
<div class="nbd-grid">
<!-- Sources -->
<aside class="nbd-sources">
<div class="nbd-sources-head">
<h3><lucide-icon [img]="FileText" [size]="15"></lucide-icon> Sources</h3>
<label class="btn-upload" [class.disabled]="uploading">
<lucide-icon [img]="Upload" [size]="13"></lucide-icon>
{{ uploading ? 'Indexation…' : 'Ajouter un PDF' }}
<input type="file" accept="application/pdf" hidden (change)="onFile($event)" [disabled]="uploading">
</label>
</div>
<p class="nbd-upload-error" *ngIf="uploadError">{{ uploadError }}</p>
<div class="nbd-source" *ngFor="let s of sources">
<lucide-icon
[img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)"
[size]="14"
[class.ok]="s.status === 'READY'" [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">
<span *ngIf="s.status === 'READY'">{{ s.pageCount }} p. · {{ s.chunkCount }} extraits</span>
<span *ngIf="s.status === 'INDEXING'">indexation en cours…</span>
<span *ngIf="s.status === 'FAILED'">échec de l'indexation</span>
</div>
</div>
<button class="nbd-source-del" (click)="removeSource(s)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
</div>
<p class="nbd-empty" *ngIf="sources.length === 0 && !uploading">
Ajoute un PDF source pour commencer à discuter avec.
</p>
</aside>
<!-- Chat -->
<section class="nbd-chat">
<div class="nbd-messages">
<p class="nbd-empty" *ngIf="messages.length === 0">
Pose une question sur ta source, ou demande une adaptation pour ta campagne.
<span *ngIf="!hasReadySource()"><br>(Ajoute d'abord une source indexée pour des réponses ancrées.)</span>
</p>
<div class="nbd-msg" *ngFor="let m of messages" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'">
<div class="nbd-msg-role">
<lucide-icon *ngIf="m.role === 'assistant'" [img]="Sparkles" [size]="12"></lucide-icon>
{{ m.role === 'user' ? 'Vous' : 'IA' }}
</div>
<ng-container *ngIf="m.role === 'assistant'; else userContent">
<ng-container *ngIf="parsedOf(m) as p">
<div class="nbd-deep-progress" *ngIf="sending && deepProgress && !p.text">
<lucide-icon [img]="Layers" [size]="13"></lucide-icon>
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }}
</div>
<div class="nbd-msg-content">{{ p.text }}<span class="cursor" *ngIf="sending && !p.text && !p.actions.length && !deepProgress"></span></div>
<app-notebook-action-card
*ngFor="let a of p.actions; trackBy: trackAction"
[action]="a"
[campaignId]="campaignId"
[arcs]="arcs"
[chaptersByArc]="chaptersByArc"
(created)="onActionCreated()">
</app-notebook-action-card>
</ng-container>
</ng-container>
<ng-template #userContent>
<div class="nbd-msg-content">{{ m.content }}</div>
</ng-template>
</div>
</div>
<div class="nbd-input">
<textarea [(ngModel)]="draft" rows="2"
placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…"
(keydown.enter)="$event.preventDefault(); send()"></textarea>
<button class="btn-deep" (click)="send(true)" [disabled]="sending || !draft.trim()"
title="Analyse approfondie : lit TOUT le document (plus lent, exhaustif). Idéal pour « liste tous les… »">
<lucide-icon [img]="Layers" [size]="15"></lucide-icon>
</button>
<button class="btn-send" (click)="send()" [disabled]="sending || !draft.trim()"
title="Réponse rapide (recherche ciblée dans le document)">
<lucide-icon [img]="Send" [size]="15"></lucide-icon>
</button>
</div>
</section>
</div>
</div>

View File

@@ -0,0 +1,106 @@
.nbd-page { max-width: 1100px; margin: 0 auto; padding: 1rem 1.5rem 2rem; height: calc(100vh - 60px); display: flex; flex-direction: column; }
.nbd-toolbar {
display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;
.btn-back {
display: inline-flex; align-items: center; gap: 0.35rem; flex-shrink: 0;
padding: 0.4rem 0.7rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer; font-size: 0.85rem;
&:hover { background: rgba(255,255,255,0.09); }
}
.nbd-title {
flex: 1; padding: 0.4rem 0.6rem; border-radius: 6px; font-size: 1.15rem; font-weight: 600;
border: 1px solid transparent; background: transparent; color: inherit;
&:hover, &:focus { border-color: rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); outline: none; }
}
}
.nbd-grid {
flex: 1; min-height: 0;
display: grid; grid-template-columns: 280px 1fr; gap: 1rem;
}
/* Sources */
.nbd-sources {
display: flex; flex-direction: column; gap: 0.5rem; overflow-y: auto;
border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 0.75rem;
}
.nbd-sources-head {
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
h3 { display: flex; align-items: center; gap: 0.4rem; margin: 0; font-size: 0.95rem; }
}
.btn-upload {
display: inline-flex; align-items: center; gap: 0.3rem; cursor: pointer;
padding: 0.3rem 0.55rem; border-radius: 6px; font-size: 0.78rem;
border: 1px solid rgba(102,126,234,0.4); color: #8ea2ff; background: rgba(102,126,234,0.1);
&:hover { background: rgba(102,126,234,0.2); }
&.disabled { opacity: 0.6; cursor: default; }
}
.nbd-upload-error { color: #e88; font-size: 0.8rem; margin: 0.2rem 0; }
.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; }
.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); }
.nbd-source-del {
border: none; background: none; color: #e88; cursor: pointer; padding: 0.15rem;
border-radius: 4px; &:hover { background: rgba(224,90,90,0.15); }
}
}
/* Chat */
.nbd-chat {
display: flex; flex-direction: column; min-height: 0;
border: 1px solid rgba(255,255,255,0.08); border-radius: 10px;
}
.nbd-messages { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.85rem; }
.nbd-empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
.nbd-msg {
max-width: 88%;
.nbd-msg-role {
display: flex; align-items: center; gap: 0.3rem;
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; }
&.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; }
}
&.assistant { align-self: flex-start;
.nbd-msg-content { background: rgba(255,255,255,0.04); padding: 0.5rem 0.75rem; border-radius: 10px; }
}
}
.cursor { opacity: 0.6; }
.nbd-input {
display: flex; gap: 0.5rem; padding: 0.6rem; border-top: 1px solid rgba(255,255,255,0.08);
textarea {
flex: 1; resize: none; padding: 0.55rem 0.7rem; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); color: inherit; font: inherit;
}
.btn-send {
display: inline-flex; align-items: center; justify-content: center; width: 44px;
border: none; border-radius: 8px; background: #667eea; color: #fff; cursor: pointer;
&:hover:not(:disabled) { background: #5568d3; }
&:disabled { opacity: 0.5; cursor: default; }
}
.btn-deep {
display: inline-flex; align-items: center; justify-content: center; width: 44px;
border: 1px solid rgba(168,130,255,0.4); border-radius: 8px;
background: rgba(168,130,255,0.12); color: #c4a8ff; cursor: pointer;
&:hover:not(:disabled) { background: rgba(168,130,255,0.22); }
&:disabled { opacity: 0.5; cursor: default; }
}
}
.nbd-deep-progress {
display: inline-flex; align-items: center; gap: 0.35rem;
font-size: 0.82rem; color: #c4a8ff; font-style: italic;
}

View File

@@ -0,0 +1,187 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
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 { 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 { 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';
/**
* Atelier (workspace) : sources indexées (gauche) + chat ancré RAG (droite).
* Route : /campaigns/:campaignId/notebooks/:notebookId
*/
@Component({
selector: 'app-notebook-detail',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, NotebookActionCardComponent],
templateUrl: './notebook-detail.component.html',
styleUrls: ['./notebook-detail.component.scss']
})
export class NotebookDetailComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Upload = Upload;
readonly Trash2 = Trash2;
readonly Send = Send;
readonly FileText = FileText;
readonly Loader = Loader;
readonly CheckCircle2 = CheckCircle2;
readonly AlertCircle = AlertCircle;
readonly Sparkles = Sparkles;
readonly Layers = Layers;
campaignId = '';
notebookId = '';
detail: NotebookDetail | null = null;
sources: NotebookSource[] = [];
messages: NotebookMessage[] = [];
uploading = false;
uploadError = '';
sending = false;
draft = '';
/** Avancement de l'analyse approfondie (lecture du doc par lots). */
deepProgress: { current: number; total: number } | null = null;
// Arbre de la campagne — sert aux cartes d'action (cibles arc/chapitre).
arcs: Arc[] = [];
chaptersByArc: Record<string, Chapter[]> = {};
constructor(
private route: ActivatedRoute,
private router: Router,
private service: NotebookService,
private campaignSidebar: CampaignSidebarService,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? '';
this.notebookId = this.route.snapshot.paramMap.get('notebookId') ?? '';
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
this.loadTree();
}
this.load();
}
private loadTree(): void {
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
.subscribe({
next: (data) => { this.arcs = data.arcs; this.chaptersByArc = data.chaptersByArc; },
error: () => { /* cibles indisponibles : les cartes le signaleront */ }
});
}
/** Après création depuis une carte : rafraîchit la sidebar (l'élément apparaît)
* et l'arbre des cibles (pour les cartes suivantes). */
onActionCreated(): void {
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
this.loadTree();
}
}
/** Sépare le texte affiché des blocs d'action — MÉMORISÉ par message : renvoie
* une référence STABLE tant que le contenu n'a pas changé. Indispensable :
* appeler le parseur directement dans le template recréait le DOM à chaque
* détection de changement (au mousedown) → les clics sur les cartes étaient
* perdus. */
parsedOf(m: NotebookMessage): { text: string; actions: NotebookAction[] } {
const cache = m as unknown as {
_parsedFor?: string;
_parsed?: { text: string; actions: NotebookAction[] };
};
if (cache._parsedFor !== m.content || !cache._parsed) {
cache._parsed = parseNotebookActions(m.content);
cache._parsedFor = m.content;
}
return cache._parsed;
}
/** trackBy stable pour les cartes d'action (évite toute recréation parasite). */
trackAction(index: number): number { return index; }
load(): void {
this.service.get(this.notebookId).subscribe({
next: (d) => {
this.detail = d;
this.sources = d.sources ?? [];
this.messages = d.messages ?? [];
},
error: () => this.back()
});
}
reloadSources(): void {
this.service.get(this.notebookId).subscribe({ next: (d) => this.sources = d.sources ?? [] });
}
// --- Sources ---
onFile(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
this.uploading = true;
this.uploadError = '';
this.service.addSource(this.notebookId, file).subscribe({
next: () => { this.uploading = false; this.reloadSources(); },
error: (err) => {
this.uploading = false;
this.uploadError = err?.error?.message || 'Échec de l\'indexation. Réessaie ou vérifie le modèle d\'embedding.';
this.reloadSources();
}
});
}
removeSource(s: NotebookSource): void {
this.service.deleteSource(s.id).subscribe(() => this.reloadSources());
}
// --- Chat ---
send(deep = false): void {
const text = this.draft.trim();
if (!text || this.sending) return;
this.draft = '';
this.deepProgress = null;
this.messages.push({ role: 'user', content: text });
const assistant: NotebookMessage = { role: 'assistant', content: '' };
this.messages.push(assistant);
this.sending = true;
this.service.streamChat(this.notebookId, text, deep).subscribe({
next: (ev) => {
if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; }
else if (ev.type === 'progress') this.deepProgress = { current: ev.current, total: ev.total };
else if (ev.type === 'error') assistant.content += (assistant.content ? '\n\n' : '') + `⚠️ ${ev.message}`;
},
complete: () => { this.sending = false; this.deepProgress = null; },
error: () => { this.sending = false; this.deepProgress = null; }
});
}
rename(): void {
if (!this.detail || !this.detail.name.trim()) return;
this.service.rename(this.notebookId, this.detail.name.trim()).subscribe();
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId, 'notebooks']);
}
hasReadySource(): boolean {
return this.sources.some(s => s.status === 'READY');
}
}

View File

@@ -0,0 +1,35 @@
<div class="nbl-page">
<div class="nbl-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
</button>
</div>
<header class="nbl-header">
<h1><lucide-icon [img]="BookOpen" [size]="22"></lucide-icon> Ateliers d'adaptation</h1>
<p class="nbl-hint">
Discute avec l'IA à partir d'un PDF source (recherche dans le document) pour adapter
son contenu à ta campagne. La source et la conversation sont conservées.
</p>
</header>
<div class="nbl-create">
<input type="text" [(ngModel)]="newName" placeholder="Nom de l'atelier (ex: Adaptation du module X)"
(keyup.enter)="create()">
<button class="btn-create" (click)="create()" [disabled]="creating">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
{{ creating ? 'Création…' : 'Nouvel atelier' }}
</button>
</div>
<div class="nbl-list">
<p class="empty" *ngIf="notebooks.length === 0">Aucun atelier pour l'instant.</p>
<button class="nbl-item" *ngFor="let nb of notebooks" (click)="open(nb)">
<lucide-icon [img]="BookOpen" [size]="16"></lucide-icon>
<span class="nbl-item-name">{{ nb.name }}</span>
<span class="nbl-del" (click)="remove(nb, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,49 @@
.nbl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
.nbl-toolbar { margin-bottom: 1rem; }
.btn-back {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.4rem 0.7rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer; font-size: 0.85rem;
&:hover { background: rgba(255,255,255,0.09); }
}
.nbl-header {
margin-bottom: 1.25rem;
h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; }
.nbl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; }
}
.nbl-create {
display: flex; gap: 0.5rem; margin-bottom: 1.5rem;
input {
flex: 1; padding: 0.55rem 0.75rem; border-radius: 7px;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04);
color: inherit; font: inherit;
}
.btn-create {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.55rem 1rem; border: none; border-radius: 7px;
background: #667eea; color: #fff; font-weight: 600; cursor: pointer;
&:hover:not(:disabled) { background: #5568d3; }
&:disabled { opacity: 0.55; cursor: default; }
}
}
.nbl-list { display: flex; flex-direction: column; gap: 0.4rem; }
.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
.nbl-item {
display: flex; align-items: center; gap: 0.55rem;
padding: 0.7rem 0.85rem; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03);
color: inherit; cursor: pointer; text-align: left;
&:hover { background: rgba(255,255,255,0.07); }
.nbl-item-name { flex: 1; font-weight: 500; }
.nbl-del {
display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88;
&:hover { background: rgba(224,90,90,0.15); }
}
}

View File

@@ -0,0 +1,85 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Plus, Trash2, BookOpen } from 'lucide-angular';
import { NotebookService } from '../../../services/notebook.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { Notebook } from '../../../services/notebook.model';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Liste des ateliers (notebooks) d'une campagne + création.
* Route : /campaigns/:campaignId/notebooks
*/
@Component({
selector: 'app-notebook-list',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './notebook-list.component.html',
styleUrls: ['./notebook-list.component.scss']
})
export class NotebookListComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly BookOpen = BookOpen;
campaignId = '';
notebooks: Notebook[] = [];
newName = '';
creating = false;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: NotebookService,
private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? '';
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
this.load();
}
}
load(): void {
this.service.listByCampaign(this.campaignId).subscribe({
next: (list) => this.notebooks = list,
error: () => this.notebooks = []
});
}
create(): void {
if (this.creating) return;
this.creating = true;
this.service.create(this.campaignId, this.newName.trim() || 'Nouvel atelier').subscribe({
next: (nb) => this.router.navigate(['/campaigns', this.campaignId, 'notebooks', nb.id]),
error: () => this.creating = false
});
}
open(nb: Notebook): void {
this.router.navigate(['/campaigns', this.campaignId, 'notebooks', nb.id]);
}
remove(nb: Notebook, ev: Event): void {
ev.stopPropagation();
this.confirmDialog.confirm({
title: 'Supprimer l\'atelier',
message: `Supprimer « ${nb.name} » et ses sources indexées ?`,
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.service.delete(nb.id).subscribe(() => this.load());
});
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
}

View File

@@ -7,6 +7,7 @@ import { LucideAngularModule, ArrowLeft, Play, Flag, Users, Trash2, Pencil, Plus
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PlaythroughService } from '../../../services/playthrough.service';
import { SessionService } from '../../../services/session.service';
import { LayoutService } from '../../../services/layout.service';
@@ -55,6 +56,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private playthroughService: PlaythroughService,
private sessionService: SessionService,
private layoutService: LayoutService,
@@ -78,7 +80,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService),
playthrough: this.playthroughService.getById(this.playthroughId),
sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))),
characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))),

View File

@@ -6,6 +6,7 @@ import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PlaythroughService } from '../../../services/playthrough.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
@@ -37,6 +38,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private playthroughService: PlaythroughService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -58,7 +60,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService),
playthrough: this.playthroughService.getById(this.playthroughId)
}).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => {
this.playthrough = playthrough;

View File

@@ -0,0 +1,85 @@
<div class="rte-page">
<div class="rte-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<span class="spacer"></span>
<button class="btn-save" (click)="save()" [disabled]="saving">
<lucide-icon [img]="Save" [size]="14"></lucide-icon>
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
</button>
</div>
<h1>{{ tableId ? 'Éditer la table' : 'Nouvelle table aléatoire' }}</h1>
<div class="error" *ngIf="errorMessage">{{ errorMessage }}</div>
<div class="form-row">
<label for="rt-name">Nom *</label>
<input id="rt-name" type="text" [(ngModel)]="name" placeholder="Ex: Rencontres en forêt">
</div>
<div class="form-row">
<label for="rt-desc">Description</label>
<textarea id="rt-desc" rows="2" [(ngModel)]="description" placeholder="À quoi sert cette table ?"></textarea>
</div>
<div class="form-row">
<label for="rt-formula">Formule du dé *</label>
<input id="rt-formula" type="text" [(ngModel)]="diceFormula" placeholder="1d20, 2d6, d100…"
[class.invalid]="diceFormula && !formulaValid">
<small class="hint" [class.bad]="diceFormula && !formulaValid">
{{ formulaValid ? 'Formule valide' : 'Format attendu : NdM (ex. 1d20, 2d6, d100)' }}
</small>
</div>
<!-- Génération IA -->
<div class="ai-box">
<div class="ai-title">
<lucide-icon [img]="Sparkles" [size]="15"></lucide-icon> Générer avec l'IA
</div>
<p class="ai-hint">Décris la table ; l'IA propose les entrées (revois-les avant d'enregistrer). Contextualisé avec ta campagne.</p>
<textarea rows="2" [(ngModel)]="aiPrompt"
placeholder="Ex: rencontres aléatoires dans une forêt hantée, ton sombre"></textarea>
<div class="ai-actions">
<button class="btn-ai" (click)="generateWithAI()" [disabled]="generating">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
{{ generating ? 'Génération…' : 'Générer (' + diceFormula + ')' }}
</button>
<span class="ai-error" *ngIf="aiError">{{ aiError }}</span>
</div>
</div>
<div class="entries-head">
<h2>Entrées</h2>
<div class="entries-actions">
<button class="btn-mini" (click)="autoRanges()" title="Répartir les plages sur la formule">
<lucide-icon [img]="Wand2" [size]="13"></lucide-icon> Auto-plages
</button>
<button class="btn-mini" (click)="addEntry()">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon> Ajouter
</button>
</div>
</div>
<div class="entry-row head">
<span class="c-range">Min</span>
<span class="c-range">Max</span>
<span class="c-label">Résultat</span>
<span class="c-detail">Détail</span>
<span class="c-del"></span>
</div>
<div class="entry-row" *ngFor="let e of entries; let i = index">
<input class="c-range" type="number" [(ngModel)]="e.minRoll">
<input class="c-range" type="number" [(ngModel)]="e.maxRoll">
<input class="c-label" type="text" [(ngModel)]="e.label" placeholder="Résultat">
<input class="c-detail" type="text" [(ngModel)]="e.detail" placeholder="Détail (optionnel)">
<button class="c-del btn-del" (click)="removeEntry(i)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
<p class="empty-hint" *ngIf="entries.length === 0">Aucune entrée — clique « Ajouter ».</p>
</div>

View File

@@ -0,0 +1,172 @@
.rte-page {
max-width: 900px;
margin: 0 auto;
padding: 1rem 1.5rem 3rem;
}
.rte-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
.spacer { flex: 1; }
button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.8rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
cursor: pointer;
font-size: 0.85rem;
&:hover { background: rgba(255, 255, 255, 0.09); }
&:disabled { opacity: 0.5; cursor: default; }
}
.btn-save { background: #667eea; border-color: #667eea; color: #fff; }
.btn-save:hover:not(:disabled) { background: #5568d3; }
}
h1 { font-size: 1.4rem; margin: 0 0 1rem; }
h2 { font-size: 1rem; margin: 0; }
.error {
padding: 0.6rem 0.9rem;
border-radius: 6px;
background: rgba(224, 90, 90, 0.12);
border: 1px solid rgba(224, 90, 90, 0.4);
color: #e88;
margin-bottom: 1rem;
}
.form-row {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 1rem;
label { font-size: 0.85rem; color: var(--color-text-muted, #9aa0aa); }
input, textarea {
padding: 0.5rem 0.7rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font: inherit;
&.invalid { border-color: rgba(224, 90, 90, 0.6); }
}
.hint { font-size: 0.75rem; color: var(--color-text-muted, #9aa0aa); }
.hint.bad { color: #e88; }
}
.entries-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 1.5rem 0 0.5rem;
.entries-actions { display: flex; gap: 0.4rem; }
.btn-mini {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.6rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: inherit;
cursor: pointer;
font-size: 0.8rem;
&:hover { background: rgba(255, 255, 255, 0.09); }
}
}
.entry-row {
display: grid;
grid-template-columns: 70px 70px 1.2fr 1.6fr 36px;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.4rem;
&.head {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, #9aa0aa);
margin-bottom: 0.3rem;
}
input {
padding: 0.4rem 0.55rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font: inherit;
width: 100%;
}
.btn-del {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.35rem;
border-radius: 6px;
border: 1px solid rgba(224, 90, 90, 0.3);
background: rgba(224, 90, 90, 0.08);
color: #e88;
cursor: pointer;
&:hover { background: rgba(224, 90, 90, 0.18); }
}
}
.empty-hint { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
.ai-box {
margin: 1.25rem 0 0.5rem;
padding: 0.85rem 1rem;
border-radius: 10px;
background: rgba(168, 130, 255, 0.08);
border: 1px solid rgba(168, 130, 255, 0.28);
.ai-title {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
color: #c4a8ff;
}
.ai-hint { margin: 0.3rem 0 0.5rem; font-size: 0.8rem; color: var(--color-text-muted, #9aa0aa); }
textarea {
width: 100%;
padding: 0.5rem 0.7rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font: inherit;
}
.ai-actions { display: flex; align-items: center; gap: 0.7rem; margin-top: 0.5rem; }
.btn-ai {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.9rem;
border: none;
border-radius: 7px;
background: #8a6dff;
color: #fff;
font-weight: 600;
cursor: pointer;
&:hover:not(:disabled) { background: #7a5cf0; }
&:disabled { opacity: 0.55; cursor: default; }
}
.ai-error { color: #e88; font-size: 0.82rem; }
}

View File

@@ -0,0 +1,185 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Save, Plus, Trash2, Wand2, Sparkles } from 'lucide-angular';
import { RandomTableService } from '../../../services/random-table.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { RandomTable, RandomTableEntry, RandomTableCreate } from '../../../services/random-table.model';
import { DiceUtils } from '../../../shared/dice.utils';
/**
* Création/édition d'une table aléatoire (formule de dé + entrées par plage).
* Routes : /campaigns/:campaignId/random-tables/create
* /campaigns/:campaignId/random-tables/:tableId/edit
*/
@Component({
selector: 'app-random-table-edit',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './random-table-edit.component.html',
styleUrls: ['./random-table-edit.component.scss']
})
export class RandomTableEditComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Save = Save;
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly Wand2 = Wand2;
readonly Sparkles = Sparkles;
campaignId: string | null = null;
tableId: string | null = null;
name = '';
description = '';
diceFormula = '1d20';
entries: RandomTableEntry[] = [];
saving = false;
errorMessage = '';
// --- Génération IA ---
aiPrompt = '';
generating = false;
aiError = '';
constructor(
private route: ActivatedRoute,
private router: Router,
private service: RandomTableService,
private campaignSidebar: CampaignSidebarService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.tableId = params.get('tableId');
if (this.campaignId) this.campaignSidebar.show(this.campaignId);
if (this.tableId) {
this.service.getById(this.tableId).subscribe({
next: (t: RandomTable) => {
this.name = t.name;
this.description = t.description ?? '';
this.diceFormula = t.diceFormula;
this.entries = t.entries.map(e => ({ ...e }));
},
error: () => this.back()
});
} else {
// Démarre avec une entrée vide pour guider.
this.addEntry();
}
}
get formulaValid(): boolean {
return DiceUtils.parse(this.diceFormula) !== null;
}
addEntry(): void {
const range = DiceUtils.totalRange(this.diceFormula);
const next = this.entries.length ? this.entries[this.entries.length - 1].maxRoll + 1 : (range?.min ?? 1);
this.entries.push({ minRoll: next, maxRoll: next, label: '', detail: '' });
}
removeEntry(i: number): void {
this.entries.splice(i, 1);
}
/** Répartit équitablement la plage du dé sur toutes les entrées. */
autoRanges(): void {
const range = DiceUtils.totalRange(this.diceFormula);
if (!range || this.entries.length === 0) return;
const span = range.max - range.min + 1;
const n = this.entries.length;
const base = Math.floor(span / n);
let cursor = range.min;
this.entries.forEach((e, idx) => {
const size = idx === n - 1 ? (range.max - cursor + 1) : Math.max(1, base);
e.minRoll = cursor;
e.maxRoll = Math.min(range.max, cursor + size - 1);
cursor = e.maxRoll + 1;
});
}
/** Génère la table via l'IA et préremplit le formulaire (l'utilisateur révise avant d'enregistrer). */
generateWithAI(): void {
if (!this.campaignId) return;
if (!this.aiPrompt.trim()) { this.aiError = 'Décris la table à générer.'; return; }
if (!this.formulaValid) { this.aiError = 'Choisis d\'abord une formule de dé valide.'; return; }
this.generating = true;
this.aiError = '';
this.service.generate(this.campaignId, this.aiPrompt.trim(), this.diceFormula.trim()).subscribe({
next: (t) => {
this.generating = false;
if (t.name && !this.name.trim()) this.name = t.name;
if (t.description) this.description = t.description;
this.entries = (t.entries ?? []).map(e => ({ ...e }));
},
error: (err) => {
this.generating = false;
this.aiError = err?.error?.message || 'Échec de la génération IA. Réessaie ou reformule.';
}
});
}
save(): void {
if (!this.campaignId) return;
if (!this.name.trim()) { this.errorMessage = 'Le nom est requis.'; return; }
if (!this.formulaValid) { this.errorMessage = 'Formule de dé invalide (ex. 1d20, 2d6, d100).'; return; }
this.saving = true;
this.errorMessage = '';
const cleanEntries = this.entries
.filter(e => e.label.trim())
.map(e => ({
minRoll: e.minRoll,
maxRoll: Math.max(e.minRoll, e.maxRoll),
label: e.label.trim(),
detail: e.detail?.trim() || undefined
}));
if (this.tableId) {
const payload: RandomTable = {
id: this.tableId,
name: this.name.trim(),
description: this.description.trim() || undefined,
diceFormula: this.diceFormula.trim(),
campaignId: this.campaignId,
entries: cleanEntries
};
this.service.update(this.tableId, payload).subscribe({
next: () => this.goToView(this.tableId!),
error: (e) => this.fail(e)
});
} else {
const payload: RandomTableCreate = {
name: this.name.trim(),
description: this.description.trim() || undefined,
diceFormula: this.diceFormula.trim(),
campaignId: this.campaignId,
entries: cleanEntries
};
this.service.create(payload).subscribe({
next: (t) => this.goToView(t.id!),
error: (e) => this.fail(e)
});
}
}
private goToView(id: string): void {
this.saving = false;
this.router.navigate(['/campaigns', this.campaignId, 'random-tables', id]);
}
private fail(err: unknown): void {
this.saving = false;
this.errorMessage = 'Échec de l\'enregistrement.';
console.error('RandomTable save failed', err);
}
back(): void {
this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']);
}
}

View File

@@ -0,0 +1,57 @@
<div class="rt-page" *ngIf="table">
<div class="rt-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<span class="spacer"></span>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Éditer
</button>
</div>
<header class="rt-header">
<h1><lucide-icon [img]="Dices" [size]="22"></lucide-icon> {{ table.name }}</h1>
<p class="rt-desc" *ngIf="table.description">{{ table.description }}</p>
<span class="rt-formula">Dé : <code>{{ table.diceFormula }}</code></span>
</header>
<!-- Zone de jet -->
<section class="rt-roll">
<button class="btn-roll" (click)="roll()">
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Lancer {{ table.diceFormula }}
</button>
<div class="rt-result" *ngIf="lastRoll">
<span class="rt-total">{{ lastRoll.total }}</span>
<span class="rt-rolls" *ngIf="lastRoll.rolls.length > 1">({{ lastRoll.rolls.join(' + ') }})</span>
<span class="rt-arrow"></span>
<span class="rt-matched" *ngIf="matched">{{ matched.label }}</span>
<span class="rt-nomatch" *ngIf="!matched">Aucune entrée pour ce résultat</span>
</div>
</section>
<div class="rt-detail" *ngIf="matched?.detail">{{ matched?.detail }}</div>
<!-- Liste des entrées -->
<section class="rt-entries">
<div class="rt-empty" *ngIf="table.entries.length === 0">
Aucune entrée — édite la table pour en ajouter.
</div>
<table *ngIf="table.entries.length > 0">
<thead>
<tr><th class="col-range">Jet</th><th>Résultat</th></tr>
</thead>
<tbody>
<tr *ngFor="let e of table.entries" [class.matched]="isMatched(e)">
<td class="col-range">{{ rangeLabel(e) }}</td>
<td>
<div class="entry-label">{{ e.label }}</div>
<div class="entry-detail" *ngIf="e.detail">{{ e.detail }}</div>
</td>
</tr>
</tbody>
</table>
</section>
</div>

View File

@@ -0,0 +1,133 @@
.rt-page {
max-width: 860px;
margin: 0 auto;
padding: 1rem 1.5rem 3rem;
}
.rt-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
.spacer { flex: 1; }
button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.7rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
cursor: pointer;
font-size: 0.85rem;
&:hover { background: rgba(255, 255, 255, 0.09); }
}
}
.rt-header {
margin-bottom: 1.25rem;
h1 {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.35rem;
font-size: 1.5rem;
}
.rt-desc { margin: 0 0 0.4rem; color: var(--color-text-muted, #9aa0aa); }
.rt-formula code {
background: rgba(255, 255, 255, 0.08);
padding: 0.1rem 0.4rem;
border-radius: 4px;
}
}
.rt-roll {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
padding: 1rem;
border-radius: 10px;
background: rgba(102, 126, 234, 0.08);
border: 1px solid rgba(102, 126, 234, 0.25);
margin-bottom: 1rem;
.btn-roll {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.1rem;
border: none;
border-radius: 8px;
background: #667eea;
color: #fff;
font-weight: 600;
cursor: pointer;
&:hover { background: #5568d3; }
}
.rt-result {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 1.05rem;
}
.rt-total { font-weight: 700; font-size: 1.3rem; color: #8ea2ff; }
.rt-rolls { color: var(--color-text-muted, #9aa0aa); font-size: 0.85rem; }
.rt-matched { font-weight: 600; }
.rt-nomatch { color: #e0a458; font-style: italic; }
}
.rt-detail {
white-space: pre-wrap;
padding: 0.85rem 1rem;
margin-bottom: 1.5rem;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border-left: 3px solid #667eea;
}
.rt-entries {
.rt-empty {
color: var(--color-text-muted, #9aa0aa);
font-style: italic;
padding: 1rem 0;
}
table { width: 100%; border-collapse: collapse; }
th, td {
text-align: left;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
vertical-align: top;
}
th {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, #9aa0aa);
}
.col-range { width: 84px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.entry-label { font-weight: 500; }
.entry-detail {
margin-top: 0.2rem;
font-size: 0.85rem;
color: var(--color-text-muted, #9aa0aa);
white-space: pre-wrap;
}
tr.matched {
background: rgba(102, 126, 234, 0.16);
td { border-bottom-color: rgba(102, 126, 234, 0.3); }
}
}

View File

@@ -0,0 +1,85 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Dices } from 'lucide-angular';
import { RandomTableService } from '../../../services/random-table.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { RandomTable, RandomTableEntry } from '../../../services/random-table.model';
import { DiceUtils, DiceRoll } from '../../../shared/dice.utils';
/**
* Vue d'une table aléatoire : affiche les entrées et permet de LANCER le dé
* (jet côté client) → surligne l'entrée tombée + montre son détail.
* Route : /campaigns/:campaignId/random-tables/:tableId
*/
@Component({
selector: 'app-random-table-view',
standalone: true,
imports: [CommonModule, LucideAngularModule],
templateUrl: './random-table-view.component.html',
styleUrls: ['./random-table-view.component.scss']
})
export class RandomTableViewComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Edit3 = Edit3;
readonly Dices = Dices;
campaignId: string | null = null;
tableId: string | null = null;
table: RandomTable | null = null;
lastRoll: DiceRoll | null = null;
matched: RandomTableEntry | null = null;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: RandomTableService,
private campaignSidebar: CampaignSidebarService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.tableId = params.get('tableId');
if (this.tableId) {
this.service.getById(this.tableId).subscribe({
next: t => this.table = t,
error: () => this.back()
});
}
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
}
}
roll(): void {
if (!this.table) return;
const result = DiceUtils.roll(this.table.diceFormula);
if (!result) { this.lastRoll = null; this.matched = null; return; }
this.lastRoll = result;
this.matched = this.table.entries.find(
e => result.total >= e.minRoll && result.total <= e.maxRoll
) ?? null;
}
isMatched(entry: RandomTableEntry): boolean {
return this.matched === entry;
}
rangeLabel(entry: RandomTableEntry): string {
return entry.minRoll === entry.maxRoll
? String(entry.minRoll)
: `${entry.minRoll}${entry.maxRoll}`;
}
edit(): void {
if (this.campaignId && this.tableId) {
this.router.navigate(['/campaigns', this.campaignId, 'random-tables', this.tableId, 'edit']);
}
}
back(): void {
this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']);
}
}

View File

@@ -7,6 +7,7 @@ import { LucideAngularModule } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
@@ -41,6 +42,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
@@ -60,7 +62,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
this.chapterName = currentChapter?.name ?? '';

View File

@@ -8,6 +8,7 @@ import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
@@ -80,6 +81,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
@@ -132,7 +134,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(),
scene: this.campaignService.getSceneById(this.sceneId),
chapterScenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -8,6 +8,7 @@ import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
@@ -48,6 +49,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
@@ -78,7 +80,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
scene: this.campaignService.getSceneById(this.sceneId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -2,7 +2,7 @@ import {
Folder,
Users, Swords, MapPin, Shield, Crown, Skull, Gem,
BookOpen, Scroll, Wand2, Sparkles, TreePine, Mountain,
Ship, Flame, Star, Moon, Key, Globe, Compass, LucideIconData
Ship, Flame, Star, Moon, Key, Globe, Compass, Dices, FileUp, LucideIconData
} from 'lucide-angular';
import { CAMPAIGN_ICON_OPTIONS } from '../campaigns/campaign-icons';
@@ -42,6 +42,8 @@ export const LORE_ICON_OPTIONS: IconOption[] = [
{ key: 'key', icon: Key },
{ key: 'globe', icon: Globe },
{ key: 'compass', icon: Compass },
{ key: 'dice', icon: Dices },
{ key: 'file-up', icon: FileUp },
];
/** Icône par défaut pour un dossier sans icône. */

View File

@@ -1,108 +0,0 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
/** Évènements du flux SSE de conseils d'adaptation. */
export type AdaptStreamEvent =
| { type: 'token'; value: string }
| { type: 'done' }
| { type: 'error'; message: string };
/** Message de la conversation d'adaptation. */
export interface AdaptMessage {
role: 'user' | 'assistant';
content: string;
}
/**
* Service : adaptation conversationnelle d'un PDF à une campagne (streamée).
* fetch() + SSE (POST multipart impossible avec EventSource), décodage ligne à ligne.
*/
@Injectable({ providedIn: 'root' })
export class CampaignAdaptService {
adviseStream(campaignId: string, file: File, messages: AdaptMessage[]): Observable<AdaptStreamEvent> {
return new Observable<AdaptStreamEvent>((subscriber) => {
const controller = new AbortController();
const form = new FormData();
form.append('file', file);
form.append('messages', JSON.stringify(messages ?? []));
fetch(`/api/campaigns/${campaignId}/adapt-pdf/stream`, {
method: 'POST',
headers: { 'Accept': 'text/event-stream' },
body: form,
signal: controller.signal
})
.then(async (response) => {
if (!response.ok || !response.body) {
subscriber.error(new Error(`HTTP ${response.status}`));
return;
}
await this.consume(response.body, subscriber);
})
.catch((err) => {
if (controller.signal.aborted) return;
subscriber.error(err);
});
return () => controller.abort();
});
}
private async consume(
body: ReadableStream<Uint8Array>,
subscriber: { next: (e: AdaptStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let currentEvent: string | null = null;
let currentData = '';
const dispatch = () => {
const name = currentEvent ?? 'message';
if (name === 'error') {
let message = "Échec de l'adaptation.";
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
subscriber.error(new Error(message));
} else if (name === 'done') {
subscriber.next({ type: 'done' });
subscriber.complete();
} else if (name === 'token') {
try {
const tok = (JSON.parse(currentData) as { token?: string }).token;
if (tok) subscriber.next({ type: 'token', value: tok });
} catch { /* fragment ignoré */ }
}
currentEvent = null;
currentData = '';
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).replace(/\r$/, '');
buffer = buffer.slice(idx + 1);
if (line === '') {
if (currentEvent !== null || currentData !== '') dispatch();
continue;
}
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const chunk = line.slice(5).replace(/^ /, '');
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
}
}
}
if (currentEvent !== null || currentData !== '') dispatch();
subscriber.complete();
} catch (err) {
subscriber.error(err);
}
}
}

View File

@@ -3,6 +3,7 @@ import { forkJoin, Subscription } from 'rxjs';
import { CampaignService } from './campaign.service';
import { CharacterService } from './character.service';
import { NpcService } from './npc.service';
import { RandomTableService } from './random-table.service';
import { LayoutService } from './layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../campaigns/campaign-tree.helper';
@@ -26,6 +27,7 @@ export class CampaignSidebarService {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private layoutService: LayoutService
) {}
@@ -42,7 +44,8 @@ export class CampaignSidebarService {
this.campaignService,
campaignId,
this.characterService,
this.npcService
this.npcService,
this.randomTableService
)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, campaignId));

View File

@@ -0,0 +1,49 @@
/**
* Protocole d'« actions » proposées par l'IA dans le chat d'un atelier : des blocs
* ```loremind-action {json} ``` que le front transforme en cartes « Créer dans la
* campagne ». Ce module définit les types + le parseur (extraction + texte nettoyé).
*/
export interface NotebookActionEntry {
minRoll: number;
maxRoll: number;
label: string;
detail?: string;
}
export interface NotebookAction {
type: 'npc' | 'scene' | 'chapter' | 'arc' | 'table';
name: string;
description?: string;
content?: string;
arcType?: 'LINEAR' | 'HUB';
diceFormula?: string;
entries?: NotebookActionEntry[];
}
const ACTION_RE = /```loremind-action\s*([\s\S]*?)```/g;
const VALID_TYPES = new Set(['npc', 'scene', 'chapter', 'arc', 'table']);
/**
* Extrait les blocs d'action COMPLETS d'un message et renvoie le texte sans ces
* blocs. Les blocs encore en cours de streaming (clôture absente) ne matchent pas
* → ils restent affichés tels quels brièvement, puis deviennent une carte.
*/
export function parseNotebookActions(content: string): { text: string; actions: NotebookAction[] } {
const actions: NotebookAction[] = [];
if (!content) return { text: '', actions };
let match: RegExpExecArray | null;
ACTION_RE.lastIndex = 0;
while ((match = ACTION_RE.exec(content)) !== null) {
try {
const obj = JSON.parse(match[1].trim());
if (obj && typeof obj.type === 'string' && VALID_TYPES.has(obj.type) && typeof obj.name === 'string') {
actions.push(obj as NotebookAction);
}
} catch {
/* bloc JSON invalide : ignoré */
}
}
const text = content.replace(ACTION_RE, '').trim();
return { text, actions };
}

View File

@@ -0,0 +1,40 @@
/** Reflet des entités notebook côté Core (atelier RAG). */
export interface Notebook {
id: string;
name: string;
campaignId: string;
}
export interface NotebookSource {
id: string;
notebookId: string;
filename: string;
/** INDEXING | READY | FAILED */
status: string;
chunkCount: number;
pageCount: number;
}
export interface NotebookMessage {
id?: string;
notebookId?: string;
/** "user" | "assistant" */
role: string;
content: string;
}
export interface NotebookDetail {
id: string;
name: string;
campaignId: string;
sources: NotebookSource[];
messages: NotebookMessage[];
}
/** Évènements du chat ancré streamé. */
export type NotebookChatEvent =
| { type: 'token'; value: string }
| { type: 'progress'; current: number; total: number }
| { type: 'done' }
| { type: 'error'; message: string };

View File

@@ -0,0 +1,126 @@
import { Injectable, NgZone } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Notebook, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model';
/**
* Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources,
* et chat ancré STREAMÉ (SSE via fetch, comme ai-chat.service).
*/
@Injectable({ providedIn: 'root' })
export class NotebookService {
private readonly apiUrl = '/api/notebooks';
constructor(private http: HttpClient, private zone: NgZone) {}
listByCampaign(campaignId: string): Observable<Notebook[]> {
return this.http.get<Notebook[]>(`${this.apiUrl}/campaign/${campaignId}`);
}
get(id: string): Observable<NotebookDetail> {
return this.http.get<NotebookDetail>(`${this.apiUrl}/${id}`);
}
create(campaignId: string, name: string): Observable<Notebook> {
return this.http.post<Notebook>(this.apiUrl, { campaignId, name });
}
rename(id: string, name: string): Observable<Notebook> {
return this.http.put<Notebook>(`${this.apiUrl}/${id}`, { name });
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
/** Upload + indexation d'une source (multipart). Bloquant côté serveur (peut être long). */
addSource(notebookId: string, file: File): Observable<NotebookSource> {
const form = new FormData();
form.append('file', file, file.name);
return this.http.post<NotebookSource>(`${this.apiUrl}/${notebookId}/sources`, form);
}
deleteSource(sourceId: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/sources/${sourceId}`);
}
/**
* Chat ancré streamé. fetch() + ReadableStream (HttpClient bufferise les SSE).
* Émissions forcées dans la zone Angular pour la détection de changement.
*/
streamChat(notebookId: string, message: string, deep = false): Observable<NotebookChatEvent> {
return new Observable<NotebookChatEvent>((subscriber) => {
const controller = new AbortController();
const emit = (ev: NotebookChatEvent) => this.zone.run(() => subscriber.next(ev));
(async () => {
try {
const response = await fetch(`${this.apiUrl}/${notebookId}/chat/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
credentials: 'include',
body: JSON.stringify({ message, deep }),
signal: controller.signal,
});
if (!response.ok || !response.body) {
emit({ type: 'error', message: `HTTP ${response.status}` });
this.zone.run(() => subscriber.complete());
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let currentEvent: string | null = null;
let currentData = '';
const dispatch = () => {
const name = currentEvent ?? 'message';
if (name === 'token') {
try { emit({ type: 'token', value: (JSON.parse(currentData) as { token?: string }).token ?? '' }); }
catch { /* ignore */ }
} else if (name === 'progress') {
try {
const o = JSON.parse(currentData) as { current?: number; total?: number };
emit({ type: 'progress', current: o.current ?? 0, total: o.total ?? 0 });
} catch { /* ignore */ }
} else if (name === 'done') {
emit({ type: 'done' });
} else if (name === 'error') {
let msg = 'Erreur du chat.';
try { msg = (JSON.parse(currentData) as { message?: string }).message ?? msg; } catch { /* garde */ }
emit({ type: 'error', message: msg });
}
currentEvent = null;
currentData = '';
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).replace(/\r$/, '');
buffer = buffer.slice(idx + 1);
if (line === '') { if (currentEvent !== null || currentData !== '') dispatch(); continue; }
if (line.startsWith('event:')) currentEvent = line.slice(6).trim();
else if (line.startsWith('data:')) {
const chunk = line.slice(5).replace(/^ /, '');
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
}
}
}
if (currentEvent !== null || currentData !== '') dispatch();
this.zone.run(() => subscriber.complete());
} catch (err) {
if ((err as Error).name !== 'AbortError') {
emit({ type: 'error', message: (err as Error).message || 'Erreur réseau' });
}
this.zone.run(() => subscriber.complete());
}
})();
return () => controller.abort();
});
}
}

View File

@@ -0,0 +1,34 @@
/**
* Reflet du RandomTableDTO côté Core. Une table aléatoire campagne + ses entrées.
*/
export interface RandomTableEntry {
/** Borne basse du jet (incluse). */
minRoll: number;
/** Borne haute du jet (incluse). */
maxRoll: number;
/** Résultat court. */
label: string;
/** Détail markdown (« ce que c'est »). */
detail?: string;
}
export interface RandomTable {
id?: string;
name: string;
description?: string;
/** Formule du dé : "1d20", "2d6", "d100"… */
diceFormula: string;
icon?: string;
campaignId: string;
order?: number;
entries: RandomTableEntry[];
}
export interface RandomTableCreate {
name: string;
description?: string;
diceFormula: string;
icon?: string;
campaignId: string;
entries: RandomTableEntry[];
}

View File

@@ -0,0 +1,49 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { RandomTable, RandomTableCreate } from './random-table.model';
/**
* CRUD des tables aléatoires (campagne). Mirroir de NpcService.
*/
@Injectable({ providedIn: 'root' })
export class RandomTableService {
private apiUrl = '/api/random-tables';
constructor(private http: HttpClient) {}
getByCampaign(campaignId: string): Observable<RandomTable[]> {
return this.http.get<RandomTable[]>(`${this.apiUrl}/campaign/${campaignId}`);
}
getById(id: string): Observable<RandomTable> {
return this.http.get<RandomTable>(`${this.apiUrl}/${id}`);
}
create(payload: RandomTableCreate): Observable<RandomTable> {
return this.http.post<RandomTable>(this.apiUrl, payload);
}
update(id: string, payload: RandomTable): Observable<RandomTable> {
return this.http.put<RandomTable>(`${this.apiUrl}/${id}`, payload);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
/** Génère une PROPOSITION de table via l'IA (non persistée) à préremplir dans le formulaire. */
generate(campaignId: string, description: string, diceFormula: string): Observable<RandomTable> {
return this.http.post<RandomTable>(`${this.apiUrl}/generate`, { campaignId, description, diceFormula });
}
/** Improvisation IA d'un court récit sur un résultat tiré (en partie). */
improvise(
campaignId: string, tableName: string, resultLabel: string, resultDetail: string
): Observable<{ narration: string }> {
return this.http.post<{ narration: string }>(
`${this.apiUrl}/improvise`,
{ campaignId, tableName, resultLabel, resultDetail }
);
}
}

View File

@@ -6,7 +6,7 @@ import { Observable } from 'rxjs';
* Reflet de SettingsDTO cote Brain / SettingsController cote Core.
* `onemin_api_key_set` indique si une cle est configuree, sans la reveler.
*/
export type LlmProvider = 'ollama' | 'onemin' | 'openrouter';
export type LlmProvider = 'ollama' | 'onemin' | 'openrouter' | 'mistral' | 'gemini';
export interface AppSettings {
llm_provider: LlmProvider;
@@ -16,6 +16,17 @@ export interface AppSettings {
onemin_api_key_set: boolean;
openrouter_model: string;
openrouter_api_key_set: boolean;
mistral_model: string;
mistral_api_key_set: boolean;
gemini_model: string;
gemini_api_key_set: boolean;
/** Embeddings (RAG des ateliers). */
embedding_provider: 'ollama' | 'mistral';
ollama_embedding_model: string;
mistral_embedding_model: string;
auto_pull_embedding_model: boolean;
/** Nombre d'extraits récupérés par question (RAG). */
rag_top_k: number;
llm_num_ctx: number;
/** Taille cible d'un morceau (tokens) pour l'import de PDF (règles/campagne). */
import_chunk_tokens: number;
@@ -35,6 +46,15 @@ export interface AppSettingsUpdate {
onemin_api_key?: string;
openrouter_model?: string;
openrouter_api_key?: string;
mistral_model?: string;
mistral_api_key?: string;
gemini_model?: string;
gemini_api_key?: string;
embedding_provider?: 'ollama' | 'mistral';
ollama_embedding_model?: string;
mistral_embedding_model?: string;
auto_pull_embedding_model?: boolean;
rag_top_k?: number;
llm_num_ctx?: number;
import_chunk_tokens?: number;
llm_timeout_seconds?: number;
@@ -83,6 +103,16 @@ export class SettingsService {
return this.http.get<{ models: OpenRouterModel[] }>(`${this.apiUrl}/models/openrouter`, this.authOptions);
}
/** Catalogue des modeles Mistral (dynamique si cle configuree, repli statique sinon). */
listMistralModels(): Observable<{ models: MistralModel[] }> {
return this.http.get<{ models: MistralModel[] }>(`${this.apiUrl}/models/mistral`, this.authOptions);
}
/** Catalogue des modeles Gemini (dynamique si cle configuree, repli statique sinon). */
listGeminiModels(): Observable<{ models: GeminiModel[] }> {
return this.http.get<{ models: GeminiModel[] }>(`${this.apiUrl}/models/gemini`, this.authOptions);
}
/**
* Telecharge un modele Ollama et streame la progression au client.
*
@@ -205,3 +235,13 @@ export interface OpenRouterModel {
/** True si le modele est gratuit (id `:free` ou prix nul). */
free: boolean;
}
/** Un modele Mistral (catalogue dynamique ou repli statique). */
export interface MistralModel {
id: string;
}
/** Un modele Gemini (catalogue dynamique ou repli statique). */
export interface GeminiModel {
id: string;
}

View File

@@ -0,0 +1,51 @@
<div class="srt-panel">
<p class="loading-hint" *ngIf="loading">Chargement…</p>
<!-- Liste des tables -->
<div *ngIf="!loading && !selected" class="srt-list">
<p class="empty-hint" *ngIf="tables.length === 0">
Aucune table aléatoire dans cette campagne. Crée-en une depuis la sidebar.
</p>
<button *ngFor="let t of tables" type="button" class="srt-item" (click)="select(t)">
<lucide-icon [img]="Dices" [size]="13"></lucide-icon>
<span class="srt-item-name">{{ t.name }}</span>
<span class="srt-item-formula">{{ t.diceFormula }}</span>
</button>
</div>
<!-- Table sélectionnée : jet -->
<div *ngIf="selected" class="srt-detail">
<button type="button" class="srt-back" (click)="backToList()">
<lucide-icon [img]="ChevronLeft" [size]="13"></lucide-icon> Tables
</button>
<h4>{{ selected.name }}</h4>
<button type="button" class="srt-roll" (click)="roll()">
<lucide-icon [img]="Dices" [size]="15"></lucide-icon>
Lancer {{ selected.diceFormula }}
</button>
<div class="srt-result" *ngIf="lastRoll">
<div class="srt-total">
{{ lastRoll.total }}
<span class="srt-rolls" *ngIf="lastRoll.rolls.length > 1">({{ lastRoll.rolls.join(' + ') }})</span>
<strong *ngIf="matched">{{ matched.label }}</strong>
<em *ngIf="!matched">aucune entrée</em>
</div>
<div class="srt-detail-text" *ngIf="matched?.detail">{{ matched?.detail }}</div>
<div class="srt-actions">
<button type="button" class="srt-act" (click)="addToJournal()" [disabled]="!canAddToJournal">
<lucide-icon [img]="BookmarkPlus" [size]="13"></lucide-icon> Journal
</button>
<button type="button" class="srt-act srt-act--ai" (click)="improvise()"
[disabled]="!matched || improvising || !canAddToJournal">
<lucide-icon [img]="Sparkles" [size]="13"></lucide-icon>
{{ improvising ? '…' : 'IA improvise' }}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,108 @@
.srt-panel {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.25rem;
}
.loading-hint, .empty-hint {
color: var(--color-text-muted, #9aa0aa);
font-size: 0.85rem;
font-style: italic;
padding: 0.5rem 0;
}
.srt-list { display: flex; flex-direction: column; gap: 0.3rem; }
.srt-item {
display: flex;
align-items: center;
gap: 0.45rem;
width: 100%;
padding: 0.45rem 0.6rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 7px;
background: rgba(255, 255, 255, 0.03);
color: inherit;
cursor: pointer;
text-align: left;
&:hover { background: rgba(255, 255, 255, 0.08); }
.srt-item-name { flex: 1; }
.srt-item-formula {
font-size: 0.72rem;
color: var(--color-text-muted, #9aa0aa);
background: rgba(255, 255, 255, 0.06);
padding: 0.05rem 0.35rem;
border-radius: 4px;
}
}
.srt-detail { display: flex; flex-direction: column; gap: 0.6rem; }
.srt-back {
display: inline-flex;
align-items: center;
gap: 0.25rem;
align-self: flex-start;
padding: 0.2rem 0.4rem;
border: none;
background: none;
color: var(--color-text-muted, #9aa0aa);
cursor: pointer;
font-size: 0.8rem;
&:hover { color: inherit; }
}
h4 { margin: 0; font-size: 0.95rem; }
.srt-roll {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.5rem 0.9rem;
border: none;
border-radius: 8px;
background: #667eea;
color: #fff;
font-weight: 600;
cursor: pointer;
&:hover { background: #5568d3; }
}
.srt-result {
padding: 0.6rem 0.7rem;
border-radius: 8px;
background: rgba(102, 126, 234, 0.1);
border: 1px solid rgba(102, 126, 234, 0.25);
.srt-total { font-size: 1rem; }
.srt-rolls { color: var(--color-text-muted, #9aa0aa); font-size: 0.8rem; }
.srt-detail-text {
margin-top: 0.4rem;
font-size: 0.85rem;
color: var(--color-text-muted, #cfd3da);
white-space: pre-wrap;
}
}
.srt-actions { display: flex; gap: 0.4rem; margin-top: 0.6rem; }
.srt-act {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.6rem;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: inherit;
cursor: pointer;
font-size: 0.8rem;
&:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); }
&:disabled { opacity: 0.5; cursor: default; }
&--ai { border-color: rgba(168, 130, 255, 0.35); color: #c4a8ff; }
&--ai:hover:not(:disabled) { background: rgba(168, 130, 255, 0.14); }
}

View File

@@ -0,0 +1,110 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, Dices, BookmarkPlus, Sparkles, ChevronLeft } from 'lucide-angular';
import { catchError, of } from 'rxjs';
import { RandomTableService } from '../../services/random-table.service';
import { RandomTable, RandomTableEntry } from '../../services/random-table.model';
import { DiceUtils, DiceRoll } from '../../shared/dice.utils';
import { DiceRollResult } from '../session-dice-panel/session-dice-panel.component';
/**
* Panneau "Tables aléatoires" du mode jeu : choisir une table de la campagne,
* la JETER, consigner le résultat au journal, et (option) demander à l'IA
* d'improviser un court récit sur le tirage.
*
* Réutilise les sorties existantes du panneau de référence :
* - `rolled` (DiceRollResult) → entrée DICE_ROLL dans le journal ;
* - `aiReplyToJournal` (string) → entrée NOTE (récit IA).
*/
@Component({
selector: 'app-session-random-tables-panel',
standalone: true,
imports: [CommonModule, LucideAngularModule],
templateUrl: './session-random-tables-panel.component.html',
styleUrls: ['./session-random-tables-panel.component.scss']
})
export class SessionRandomTablesPanelComponent implements OnInit {
readonly Dices = Dices;
readonly BookmarkPlus = BookmarkPlus;
readonly Sparkles = Sparkles;
readonly ChevronLeft = ChevronLeft;
@Input() campaignId!: string;
@Input() canAddToJournal = true;
@Output() rolled = new EventEmitter<DiceRollResult>();
@Output() aiReplyToJournal = new EventEmitter<string>();
tables: RandomTable[] = [];
loading = false;
selected: RandomTable | null = null;
lastRoll: DiceRoll | null = null;
matched: RandomTableEntry | null = null;
improvising = false;
constructor(private service: RandomTableService) {}
ngOnInit(): void {
if (!this.campaignId) return;
this.loading = true;
this.service.getByCampaign(this.campaignId).pipe(catchError(() => of([] as RandomTable[])))
.subscribe(list => { this.tables = list; this.loading = false; });
}
select(table: RandomTable): void {
this.selected = table;
this.lastRoll = null;
this.matched = null;
}
backToList(): void {
this.selected = null;
this.lastRoll = null;
this.matched = null;
}
roll(): void {
if (!this.selected) return;
const result = DiceUtils.roll(this.selected.diceFormula);
if (!result) { this.lastRoll = null; this.matched = null; return; }
this.lastRoll = result;
this.matched = this.selected.entries.find(
e => result.total >= e.minRoll && result.total <= e.maxRoll
) ?? null;
}
rangeLabel(entry: RandomTableEntry): string {
return entry.minRoll === entry.maxRoll ? String(entry.minRoll) : `${entry.minRoll}${entry.maxRoll}`;
}
/** Consigne le tirage au journal (entrée DICE_ROLL via la sortie `rolled`). */
addToJournal(): void {
if (!this.canAddToJournal || !this.selected || !this.lastRoll) return;
const label = this.matched?.label ?? 'aucun résultat';
const result: DiceRollResult = {
notation: this.selected.diceFormula,
rolls: this.lastRoll.rolls,
modifier: 0,
total: this.lastRoll.total,
summary: `🎲 ${this.selected.name} : ${this.lastRoll.total}${label}`
};
this.rolled.emit(result);
}
/** Demande à l'IA un court récit sur le résultat, puis l'envoie au journal (NOTE). */
improvise(): void {
if (!this.selected || !this.matched || this.improvising) return;
this.improvising = true;
this.service.improvise(
this.campaignId, this.selected.name, this.matched.label, this.matched.detail ?? ''
).subscribe({
next: (r) => {
this.improvising = false;
if (r.narration?.trim() && this.canAddToJournal) {
this.aiReplyToJournal.emit(r.narration.trim());
}
},
error: () => { this.improvising = false; }
});
}
}

View File

@@ -15,6 +15,13 @@
<lucide-icon [img]="Dices" [size]="14"></lucide-icon>
Dés
</button>
<button type="button"
class="ref-tab"
[class.ref-tab--active]="activeTab === 'tables'"
(click)="selectTab('tables')">
<lucide-icon [img]="Table2" [size]="14"></lucide-icon>
Tables
</button>
<button type="button"
class="ref-tab"
[class.ref-tab--active]="activeTab === 'characters'"
@@ -48,6 +55,15 @@
(rolled)="onDiceRolled($event)">
</app-session-dice-panel>
<!-- ====== Tables aléatoires ====== -->
<app-session-random-tables-panel
*ngIf="activeTab === 'tables'"
[campaignId]="campaignId"
[canAddToJournal]="canAddToJournal"
(rolled)="onDiceRolled($event)"
(aiReplyToJournal)="onAiSaveToJournal($event)">
</app-session-random-tables-panel>
<!-- ====== Personnages (PJ + PNJ) ====== -->
<div *ngIf="activeTab === 'characters'" class="ref-list">
<p class="loading-hint" *ngIf="loadingChars">Chargement…</p>

View File

@@ -1,6 +1,6 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles } from 'lucide-angular';
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2 } from 'lucide-angular';
import { catchError, of } from 'rxjs';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
@@ -13,8 +13,9 @@ import {
SessionDicePanelComponent, DiceRollResult
} from '../session-dice-panel/session-dice-panel.component';
import { SessionAiChatPanelComponent } from '../session-ai-chat-panel/session-ai-chat-panel.component';
import { SessionRandomTablesPanelComponent } from '../session-random-tables-panel/session-random-tables-panel.component';
type TabId = 'dice' | 'characters' | 'scenes' | 'ai';
type TabId = 'dice' | 'tables' | 'characters' | 'scenes' | 'ai';
/**
* Panneau latéral du mode jeu : référence rapide en lecture seule.
@@ -29,7 +30,7 @@ type TabId = 'dice' | 'characters' | 'scenes' | 'ai';
@Component({
selector: 'app-session-reference-panel',
standalone: true,
imports: [CommonModule, LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent],
imports: [CommonModule, LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent, SessionRandomTablesPanelComponent],
templateUrl: './session-reference-panel.component.html',
styleUrls: ['./session-reference-panel.component.scss']
})
@@ -40,6 +41,7 @@ export class SessionReferencePanelComponent implements OnChanges {
readonly Dices = Dices;
readonly ExternalLink = ExternalLink;
readonly Sparkles = Sparkles;
readonly Table2 = Table2;
@Input() campaignId!: string;
/** Partie active — nécessaire pour charger les PJ (refonte Playthrough). */
@@ -109,7 +111,7 @@ export class SessionReferencePanelComponent implements OnChanges {
if (this.treeLoaded || this.loadingTree || !this.campaignId) return;
this.loadingTree = true;
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData))
).subscribe(data => {
this.treeData = data;
this.loadingTree = false;

View File

@@ -36,6 +36,14 @@
<input type="radio" name="provider" value="openrouter" [(ngModel)]="settings.llm_provider">
<span>OpenRouter (cloud)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="mistral" [(ngModel)]="settings.llm_provider">
<span>Mistral (cloud)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="gemini" [(ngModel)]="settings.llm_provider">
<span>Gemini (cloud)</span>
</label>
</div>
</div>
</section>
@@ -212,6 +220,145 @@
</div>
</section>
<!-- Bloc Mistral -->
<section class="card" *ngIf="settings && settings.llm_provider === 'mistral'">
<h2>Configuration Mistral</h2>
<div class="form-row">
<label for="mistral-key">Cle API</label>
<input
id="mistral-key"
type="password"
[(ngModel)]="mistralApiKeyInput"
[placeholder]="settings.mistral_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API Mistral'">
<label class="checkbox" *ngIf="settings.mistral_api_key_set">
<input type="checkbox" [(ngModel)]="clearMistralKey">
<span>Effacer la cle enregistree</span>
</label>
</div>
<div class="form-row">
<label for="mistral-model">Modele</label>
<select id="mistral-model" [(ngModel)]="settings.mistral_model">
<option *ngFor="let o of mistralSelectOptions" [value]="o.id">{{ o.label }}</option>
</select>
<p class="hint">
Cle gratuite sur <code>console.mistral.ai</code> (tier « Experiment », sans CB).
Pour les imports, prefere <code>mistral-large-latest</code> (128k contexte, fidele
et bon en francais) ou <code>mistral-small-latest</code> (plus rapide).
</p>
</div>
</section>
<!-- Bloc Gemini -->
<section class="card" *ngIf="settings && settings.llm_provider === 'gemini'">
<h2>Configuration Gemini</h2>
<div class="form-row">
<label for="gemini-key">Cle API</label>
<input
id="gemini-key"
type="password"
[(ngModel)]="geminiApiKeyInput"
[placeholder]="settings.gemini_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API Gemini'">
<label class="checkbox" *ngIf="settings.gemini_api_key_set">
<input type="checkbox" [(ngModel)]="clearGeminiKey">
<span>Effacer la cle enregistree</span>
</label>
</div>
<div class="form-row">
<label for="gemini-model">Modele</label>
<select id="gemini-model" [(ngModel)]="settings.gemini_model">
<option *ngFor="let o of geminiSelectOptions" [value]="o.id">{{ o.label }}</option>
</select>
<p class="hint">
Cle gratuite sur <code>aistudio.google.com</code> (sans CB). Ideal pour les imports :
<code>gemini-2.0-flash</code> a un <strong>contexte ~1M</strong> → un livre entier tient
en 1-2 appels (plus de morceaux perdus). Pense a monter la <strong>taille des morceaux</strong>
d'import pour en profiter.
</p>
</div>
</section>
<!-- Bloc Embeddings (Atelier RAG) : indépendant du modèle de CHAT ci-dessus. -->
<section class="card" *ngIf="settings">
<h2>Embeddings (Atelier RAG)</h2>
<p class="hint">
Modèle utilisé pour <strong>indexer les sources PDF</strong> des ateliers et y faire
de la recherche. C'est un modèle <strong>séparé</strong> du modèle de chat ci-dessus.
</p>
<div class="form-row">
<label>Fournisseur d'embeddings</label>
<div class="radio-group">
<label class="radio">
<input type="radio" name="embprovider" value="ollama" [(ngModel)]="settings.embedding_provider">
<span>Ollama (local, gratuit)</span>
</label>
<label class="radio">
<input type="radio" name="embprovider" value="mistral" [(ngModel)]="settings.embedding_provider">
<span>Mistral (cloud, EU)</span>
</label>
</div>
</div>
<!-- Ollama embeddings -->
<div *ngIf="settings.embedding_provider === 'ollama'">
<div class="form-row">
<label for="emb-ollama-model">Modèle d'embedding Ollama</label>
<input id="emb-ollama-model" type="text" [(ngModel)]="settings.ollama_embedding_model"
placeholder="nomic-embed-text">
<p class="hint">
Recommandé : <code>nomic-embed-text</code> (léger). Gratuit et illimité (local).
</p>
</div>
<div class="form-row">
<label class="checkbox">
<input type="checkbox" [(ngModel)]="settings.auto_pull_embedding_model">
<span>Installer automatiquement le modèle au démarrage s'il manque</span>
</label>
</div>
</div>
<div class="form-row">
<label for="rag-topk">Extraits récupérés par question (couverture RAG)</label>
<input id="rag-topk" type="number" min="3" max="200" [(ngModel)]="settings.rag_top_k">
<p class="hint">
Plus haut = l'IA voit plus de passages par question (mieux pour les questions
larges « liste les… »), mais prompt plus long. Défaut 8. Avec un modèle
<strong>gros-contexte</strong> (Gemini), tu peux monter à <strong>50-150</strong> pour
une couverture quasi « document entier ». Pour les questions exhaustives, préfère
le bouton <strong>« Analyse approfondie »</strong> de l'atelier.
</p>
</div>
<!-- Mistral embeddings -->
<div *ngIf="settings.embedding_provider === 'mistral'">
<div class="form-row">
<label for="emb-mistral-key">
Clé API Mistral
<span class="key-state" *ngIf="settings.mistral_api_key_set">✓ configurée</span>
</label>
<input id="emb-mistral-key" type="password" [(ngModel)]="mistralApiKeyInput"
[placeholder]="settings.mistral_api_key_set ? 'Clé configurée (laisser vide pour ne pas changer)' : 'Saisir votre clé API Mistral'">
<label class="checkbox" *ngIf="settings.mistral_api_key_set">
<input type="checkbox" [(ngModel)]="clearMistralKey">
<span>Effacer la clé enregistrée</span>
</label>
<p class="hint">
C'est la <strong>même clé</strong> que pour le chat Mistral (une seule clé partagée).
Soumis au rate limit du tier gratuit.
</p>
</div>
<div class="form-row">
<label for="emb-mistral-model">Modèle d'embedding Mistral</label>
<input id="emb-mistral-model" type="text" [(ngModel)]="settings.mistral_embedding_model"
placeholder="mistral-embed">
</div>
</div>
</section>
<!-- Bloc Fenetre de contexte : seulement pour Ollama (num_ctx est un vrai
parametre du modele local). Pour le cloud (1min/OpenRouter), on ne fixe rien :
on utilise ce que le modele accepte, et la jauge du chat s'affiche sans max. -->

View File

@@ -4,7 +4,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, OllamaPullEvent } from '../services/settings.service';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, MistralModel, GeminiModel, OllamaPullEvent } from '../services/settings.service';
import { UpdatesService, UpdateStatus } from '../services/updates.service';
import { ConfigService } from '../services/config.service';
import { LayoutService } from '../services/layout.service';
@@ -107,6 +107,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** Catalogue OpenRouter (chargé dynamiquement) + filtre "gratuits seulement". */
openrouterModels: OpenRouterModel[] = [];
openrouterFreeOnly = true;
/** Catalogue Mistral (dynamique si cle configuree, repli statique sinon). */
mistralModels: MistralModel[] = [];
/** Catalogue Gemini (dynamique si cle configuree, repli statique sinon). */
geminiModels: GeminiModel[] = [];
/** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */
oneminProvider: string = '';
@@ -137,6 +143,16 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** True si l'utilisateur a coche "effacer la cle OpenRouter". */
clearOpenrouterKey = false;
/** Cle Mistral saisie — vide = on ne touche pas a la cle persistee. */
mistralApiKeyInput = '';
/** True si l'utilisateur a coche "effacer la cle Mistral". */
clearMistralKey = false;
/** Cle Gemini saisie — vide = on ne touche pas a la cle persistee. */
geminiApiKeyInput = '';
/** True si l'utilisateur a coche "effacer la cle Gemini". */
clearGeminiKey = false;
constructor(
private settingsService: SettingsService,
private router: Router,
@@ -471,6 +487,38 @@ export class SettingsComponent implements OnInit, OnDestroy {
next: (r) => this.openrouterModels = r.models,
error: () => this.openrouterModels = []
});
this.settingsService.listMistralModels().subscribe({
next: (r) => this.mistralModels = r.models,
error: () => this.mistralModels = []
});
this.settingsService.listGeminiModels().subscribe({
next: (r) => this.geminiModels = r.models,
error: () => this.geminiModels = []
});
}
/** Options du select Mistral : liste + valeur courante garantie selectionnable. */
get mistralSelectOptions(): { id: string; label: string }[] {
const options: { id: string; label: string }[] =
this.mistralModels.map(m => ({ id: m.id, label: m.id }));
const cur = this.settings?.mistral_model;
if (cur && !options.some(o => o.id === cur)) {
options.unshift({ id: cur, label: `${cur} (actuel)` });
}
return options;
}
/** Options du select Gemini : liste + valeur courante garantie selectionnable. */
get geminiSelectOptions(): { id: string; label: string }[] {
const options: { id: string; label: string }[] =
this.geminiModels.map(m => ({ id: m.id, label: m.id }));
const cur = this.settings?.gemini_model;
if (cur && !options.some(o => o.id === cur)) {
options.unshift({ id: cur, label: `${cur} (actuel)` });
}
return options;
}
/** Modeles OpenRouter, filtres (gratuits seulement par defaut). */
@@ -557,6 +605,13 @@ export class SettingsComponent implements OnInit, OnDestroy {
llm_model: this.settings.llm_model,
onemin_model: this.settings.onemin_model,
openrouter_model: this.settings.openrouter_model,
mistral_model: this.settings.mistral_model,
gemini_model: this.settings.gemini_model,
embedding_provider: this.settings.embedding_provider,
ollama_embedding_model: this.settings.ollama_embedding_model,
mistral_embedding_model: this.settings.mistral_embedding_model,
auto_pull_embedding_model: this.settings.auto_pull_embedding_model,
rag_top_k: this.settings.rag_top_k,
llm_num_ctx: this.settings.llm_num_ctx,
import_chunk_tokens: this.settings.import_chunk_tokens,
llm_timeout_seconds: this.settings.llm_timeout_seconds
@@ -571,6 +626,16 @@ export class SettingsComponent implements OnInit, OnDestroy {
} else if (this.openrouterApiKeyInput.trim()) {
patch.openrouter_api_key = this.openrouterApiKeyInput.trim();
}
if (this.clearMistralKey) {
patch.mistral_api_key = '';
} else if (this.mistralApiKeyInput.trim()) {
patch.mistral_api_key = this.mistralApiKeyInput.trim();
}
if (this.clearGeminiKey) {
patch.gemini_api_key = '';
} else if (this.geminiApiKeyInput.trim()) {
patch.gemini_api_key = this.geminiApiKeyInput.trim();
}
this.settingsService.updateSettings(patch).subscribe({
next: (s) => {
@@ -579,6 +644,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.clearApiKey = false;
this.openrouterApiKeyInput = '';
this.clearOpenrouterKey = false;
this.mistralApiKeyInput = '';
this.clearMistralKey = false;
this.geminiApiKeyInput = '';
this.clearGeminiKey = false;
this.successMessage = 'Parametres sauvegardes.';
this.saving = false;
},

View File

@@ -0,0 +1,66 @@
/**
* Utilitaires de dés partagés (jets aléatoires cryptographiques).
*
* Centralise le parsing de formules ("1d20", "2d6", "d100") et le jet, utilisé
* par les tables aléatoires (et réutilisable ailleurs). Pas de Math.random pour
* le tirage : on privilégie crypto.getRandomValues quand disponible.
*/
export interface ParsedDice {
/** Nombre de dés (ex. 2 pour "2d6"). */
count: number;
/** Nombre de faces (ex. 6 pour "2d6"). */
faces: number;
}
export interface DiceRoll {
/** Formule normalisée utilisée pour le jet. */
formula: string;
/** Jets individuels de chaque dé. */
rolls: number[];
/** Somme des jets (la valeur qui désigne l'entrée d'une table). */
total: number;
}
export class DiceUtils {
/** Parse une formule simple `[N]dM`. Renvoie null si invalide. */
static parse(formula: string | null | undefined): ParsedDice | null {
const m = /^\s*(\d*)\s*[dD]\s*(\d+)\s*$/.exec(formula ?? '');
if (!m) return null;
const count = m[1] ? parseInt(m[1], 10) : 1;
const faces = parseInt(m[2], 10);
if (count < 1 || count > 100 || faces < 2 || faces > 10000) return null;
return { count, faces };
}
/** Entier aléatoire dans [min, max] inclus (crypto si dispo). */
static randomInt(min: number, max: number): number {
const span = max - min + 1;
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return min + (buf[0] % span);
}
return min + Math.floor(Math.random() * span);
}
/** Lance la formule. Renvoie null si la formule est invalide. */
static roll(formula: string): DiceRoll | null {
const parsed = this.parse(formula);
if (!parsed) return null;
const rolls: number[] = [];
for (let i = 0; i < parsed.count; i++) {
rolls.push(this.randomInt(1, parsed.faces));
}
const total = rolls.reduce((a, b) => a + b, 0);
return { formula, rolls, total };
}
/** Plage de totaux possibles d'une formule (ex. "2d6" → {min:2, max:12}). */
static totalRange(formula: string): { min: number; max: number } | null {
const parsed = this.parse(formula);
if (!parsed) return null;
return { min: parsed.count, max: parsed.count * parsed.faces };
}
}