Plusieurs gros ajouts :
- Possibilité de discuter avec un PDF ; RAG ou analyse approfondie. Enlèvement de l'autre outil PDF de discussion qui analysait d'abord un PDF en proposant directement une intégration sans attendre qu'on pose de question - Mise en place de l'import directement dans les outils dans la sidebar - Mise en place d'un outil pour créer des tables aléatoires avec possibilité d'utiliser pendant la partie - Mise en place d'un outil pour mettre en place des PNJ, scènes, chapitre.... directement à partir de la discussion avec le PDF - Mise en place RAG avec mistal-embeding ou nomic si on utilise ollama - Mise en place mistral, google en fournisseurs alternatifs pour l'IA dans le cloud - version 0.11.0-bêta
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user