Plusieurs gros ajouts :
- 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:
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user