Plusieurs gros ajouts :
All checks were successful
All checks were successful
- 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:
@@ -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) },
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ?? '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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[]))),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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 ?? '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
49
web/src/app/services/notebook-action.model.ts
Normal file
49
web/src/app/services/notebook-action.model.ts
Normal 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 };
|
||||
}
|
||||
40
web/src/app/services/notebook.model.ts
Normal file
40
web/src/app/services/notebook.model.ts
Normal 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 };
|
||||
126
web/src/app/services/notebook.service.ts
Normal file
126
web/src/app/services/notebook.service.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
34
web/src/app/services/random-table.model.ts
Normal file
34
web/src/app/services/random-table.model.ts
Normal 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[];
|
||||
}
|
||||
49
web/src/app/services/random-table.service.ts
Normal file
49
web/src/app/services/random-table.service.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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; }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
66
web/src/app/shared/dice.utils.ts
Normal file
66
web/src/app/shared/dice.utils.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user