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

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