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