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

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