Mise en ligne de la version 0.2.0
This commit is contained in:
88
web/src/app/lore/page-create/page-create.component.html
Normal file
88
web/src/app/lore/page-create/page-create.component.html
Normal file
@@ -0,0 +1,88 @@
|
||||
<div class="page">
|
||||
|
||||
<header class="page-header">
|
||||
<h1>Créer une nouvelle Page</h1>
|
||||
<p class="subtitle">Créez une page à partir d'un template existant</p>
|
||||
</header>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="page-form">
|
||||
|
||||
<!-- Titre -->
|
||||
<div class="field">
|
||||
<label>Titre de la page *</label>
|
||||
<input type="text" formControlName="title" placeholder="Ex: Maître Eldrin, La Cité d'Argent..." />
|
||||
</div>
|
||||
|
||||
<!-- Template -->
|
||||
<div class="field">
|
||||
<label>Template *</label>
|
||||
|
||||
<div class="templates-grid" *ngIf="templates.length; else emptyTemplates">
|
||||
<button
|
||||
type="button"
|
||||
class="template-card"
|
||||
*ngFor="let t of templates"
|
||||
[class.selected]="selectedTemplateId === t.id"
|
||||
(click)="selectTemplate(t)">
|
||||
<div class="template-card-head">
|
||||
<lucide-icon [img]="FileText" [size]="16"></lucide-icon>
|
||||
<span class="template-name">{{ t.name }}</span>
|
||||
</div>
|
||||
<p class="template-description">{{ t.description || '—' }}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ng-template #emptyTemplates>
|
||||
<p class="empty-hint">
|
||||
Aucun template défini pour ce Lore.
|
||||
<a [routerLink]="['/lore', loreId, 'templates', 'create']">Créer un template</a> d'abord.
|
||||
</p>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<!-- Dossier de destination -->
|
||||
<div class="field">
|
||||
<label>Dossier de destination *</label>
|
||||
<select formControlName="nodeId" [attr.disabled]="preselectedNodeId ? true : null">
|
||||
<option value="" disabled>Sélectionnez un dossier</option>
|
||||
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
||||
</select>
|
||||
<p class="hint">La page sera créée dans ce dossier</p>
|
||||
</div>
|
||||
|
||||
<!-- Aide contextuelle -->
|
||||
<div class="info-box">
|
||||
💡 Option 1 : <strong>Créer la page</strong> vide, puis remplir les champs manuellement.<br>
|
||||
💡 Option 2 : <strong>Créer avec l'IA</strong> pour dialoguer avec un assistant qui pré-remplira les champs.
|
||||
</div>
|
||||
|
||||
<!-- Erreur wizard (parsing <values> ou échec HTTP) -->
|
||||
<div class="wizard-error" *ngIf="wizardError" role="alert">{{ wizardError }}</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions-row">
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="button" class="btn-ai" (click)="openWizard()" [disabled]="!canSubmit"
|
||||
title="Ouvrir l'assistant IA pour pré-remplir les champs">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
Créer avec l'IA
|
||||
</button>
|
||||
<button type="submit" class="btn-primary" [disabled]="!canSubmit">Créer la page</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Drawer chat IA en mode wizard -->
|
||||
<app-ai-chat-drawer
|
||||
[loreId]="loreId"
|
||||
[isOpen]="chatOpen"
|
||||
[welcomeMessage]="wizardWelcome"
|
||||
[systemPromptAddon]="wizardSystemPrompt"
|
||||
[quickSuggestions]="wizardSuggestions"
|
||||
[primaryAction]="wizardPrimaryAction"
|
||||
(close)="closeWizard()"
|
||||
(assistantReply)="onWizardReply($event)"
|
||||
(primaryActionClick)="applyWizardAndCreate()">
|
||||
</app-ai-chat-drawer>
|
||||
186
web/src/app/lore/page-create/page-create.component.scss
Normal file
186
web/src/app/lore/page-create/page-create.component.scss
Normal file
@@ -0,0 +1,186 @@
|
||||
.page {
|
||||
padding: 2rem 3rem;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.page-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
input, select {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.76rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
color: #9ca3af;
|
||||
font-size: 0.88rem;
|
||||
|
||||
a { color: #a5b4fc; text-decoration: underline; }
|
||||
}
|
||||
|
||||
.templates-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.9rem 1rem;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: #d1d5db;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
|
||||
&:hover { border-color: #3a3a55; background: #20203a; }
|
||||
|
||||
&.selected {
|
||||
border-color: #6c63ff;
|
||||
background: #1e1c3a;
|
||||
}
|
||||
|
||||
.template-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.template-name {
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.template-description {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: #9ca3af;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-left: 3px solid #6c63ff;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
color: #d1d5db;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
padding: 0.65rem 1.2rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) { background: #5a52e0; }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #2a2a3d;
|
||||
color: #d1d5db;
|
||||
|
||||
&:hover { background: #363650; }
|
||||
}
|
||||
|
||||
.btn-ai {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.65rem 1.1rem;
|
||||
background: transparent;
|
||||
color: #a5b4fc;
|
||||
border: 1px solid #6c63ff;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
|
||||
&:hover:not(:disabled) { background: #1f1d3a; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; border-color: #2a2a3d; color: #6b7280; }
|
||||
}
|
||||
|
||||
.wizard-error {
|
||||
background: #3f1f1f;
|
||||
color: #fca5a5;
|
||||
border: 1px solid #7f1d1d;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
253
web/src/app/lore/page-create/page-create.component.ts
Normal file
253
web/src/app/lore/page-create/page-create.component.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { LucideAngularModule, FileText, Sparkles } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { LoreNode } from '../../services/lore.model';
|
||||
import { Template } from '../../services/template.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
|
||||
/**
|
||||
* Écran de création d'une Page.
|
||||
*
|
||||
* Deux entrées possibles :
|
||||
* - /lore/:loreId/pages/create → noeud choisi depuis le template
|
||||
* - /lore/:loreId/nodes/:nodeId/pages/create → noeud pré-rempli depuis l'URL
|
||||
*
|
||||
* Le MVP est volontairement simple (maquette "création de page") : titre +
|
||||
* choix de template (grille) + noeud de destination. L'édition détaillée des
|
||||
* champs dynamiques du template se fait APRÈS création, via l'écran page-edit.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-page-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterModule, LucideAngularModule, AiChatDrawerComponent],
|
||||
templateUrl: './page-create.component.html',
|
||||
styleUrls: ['./page-create.component.scss']
|
||||
})
|
||||
export class PageCreateComponent implements OnInit, OnDestroy {
|
||||
readonly FileText = FileText;
|
||||
readonly Sparkles = Sparkles;
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
/** Pré-rempli si la route contient :nodeId. */
|
||||
preselectedNodeId: string | null = null;
|
||||
nodes: LoreNode[] = [];
|
||||
templates: Template[] = [];
|
||||
/** Template actuellement sélectionné dans la grille. */
|
||||
selectedTemplateId: string | null = null;
|
||||
|
||||
// --- Mode wizard IA (étape b6) -----------------------------------------
|
||||
|
||||
/** Drawer chat ouvert ? */
|
||||
chatOpen = false;
|
||||
/** Dernière réponse complète de l'assistant — on y cherchera le bloc <values>. */
|
||||
private lastWizardReply: string | null = null;
|
||||
/** Erreur de parsing du bloc <values> — affichée sous le drawer. */
|
||||
wizardError: string | null = null;
|
||||
/** Action primaire du wizard : applique les valeurs extraites et crée la page. */
|
||||
readonly wizardPrimaryAction: ChatPrimaryAction = { label: 'Appliquer et créer la page' };
|
||||
/** Suggestions rapides orientées "affiner le résultat" (mode wizard). */
|
||||
readonly wizardSuggestions: string[] = [
|
||||
'Rends la description plus courte',
|
||||
'Ajoute un trait distinctif marquant',
|
||||
'Donne un ton plus sombre'
|
||||
];
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
title: ['', Validators.required],
|
||||
nodeId: ['', Validators.required]
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageTitleService.set('Nouvelle page');
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
this.preselectedNodeId = this.route.snapshot.paramMap.get('nodeId');
|
||||
|
||||
loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService)
|
||||
.subscribe(data => {
|
||||
this.nodes = data.nodes;
|
||||
this.templates = data.templates;
|
||||
this.layoutService.show(buildLoreSidebarConfig(data));
|
||||
|
||||
// Si nodeId fourni par l'URL, on verrouille la valeur du formulaire.
|
||||
if (this.preselectedNodeId) {
|
||||
this.form.patchValue({ nodeId: this.preselectedNodeId });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectTemplate(template: Template): void {
|
||||
this.selectedTemplateId = template.id!;
|
||||
// Si pas de noeud pré-choisi par l'URL, on pré-remplit avec le defaultNodeId du template.
|
||||
if (!this.preselectedNodeId && template.defaultNodeId) {
|
||||
this.form.patchValue({ nodeId: template.defaultNodeId });
|
||||
}
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return this.form.valid && !!this.selectedTemplateId;
|
||||
}
|
||||
|
||||
get selectedTemplate(): Template | null {
|
||||
return this.templates.find(t => t.id === this.selectedTemplateId) ?? null;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) return;
|
||||
const raw = this.form.value;
|
||||
this.pageService.create({
|
||||
loreId: this.loreId,
|
||||
nodeId: raw.nodeId,
|
||||
templateId: this.selectedTemplateId!,
|
||||
title: raw.title
|
||||
}).subscribe({
|
||||
// Après la création classique, la coquille est vide → on redirige
|
||||
// vers l'écran d'édition pour que l'utilisateur remplisse les champs
|
||||
// dynamiques du template.
|
||||
next: created => this.router.navigate(['/lore', this.loreId, 'pages', created.id, 'edit']),
|
||||
error: () => console.error('Erreur lors de la création de la page')
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
|
||||
// --- Mode wizard IA (étape b6) -----------------------------------------
|
||||
|
||||
openWizard(): void {
|
||||
if (!this.canSubmit) return;
|
||||
this.wizardError = null;
|
||||
this.lastWizardReply = null;
|
||||
this.chatOpen = true;
|
||||
}
|
||||
|
||||
closeWizard(): void {
|
||||
this.chatOpen = false;
|
||||
}
|
||||
|
||||
/** Mémorise la dernière réponse de l'assistant — on y cherchera le bloc <values>. */
|
||||
onWizardReply(reply: string): void {
|
||||
this.lastWizardReply = reply;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clic sur "Appliquer et créer la page" :
|
||||
* 1. Extraire le bloc JSON <values>...</values> de la dernière réponse.
|
||||
* 2. Créer la page avec titre + template + nodeId + values.
|
||||
* 3. Naviguer vers l'édition pour que l'utilisateur finalise.
|
||||
*/
|
||||
applyWizardAndCreate(): void {
|
||||
if (!this.canSubmit || !this.lastWizardReply) {
|
||||
this.wizardError = "L'assistant n'a pas encore répondu. Décrivez d'abord votre idée.";
|
||||
return;
|
||||
}
|
||||
const values = this.extractValuesBlock(this.lastWizardReply);
|
||||
if (!values) {
|
||||
this.wizardError = "Impossible d'extraire les valeurs. Demandez à l'assistant de proposer à nouveau.";
|
||||
return;
|
||||
}
|
||||
this.wizardError = null;
|
||||
const raw = this.form.value;
|
||||
// Le backend POST /api/pages ne prend pas `values` — on crée d'abord la
|
||||
// coquille, puis on PUT immédiatement avec les valeurs extraites.
|
||||
// 2 roundtrips, mais zéro modification backend nécessaire.
|
||||
this.pageService.create({
|
||||
loreId: this.loreId,
|
||||
nodeId: raw.nodeId,
|
||||
templateId: this.selectedTemplateId!,
|
||||
title: raw.title
|
||||
}).subscribe({
|
||||
next: (created) => {
|
||||
const updated = { ...created, values };
|
||||
this.pageService.update(created.id!, updated).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId, 'pages', created.id]),
|
||||
error: () => this.wizardError = 'Page créée, mais impossible d\'appliquer les valeurs.'
|
||||
});
|
||||
},
|
||||
error: () => this.wizardError = 'Erreur lors de la création de la page.'
|
||||
});
|
||||
}
|
||||
|
||||
/** Prompt système injecté dans le backend pour le mode wizard. */
|
||||
get wizardSystemPrompt(): string | null {
|
||||
const tpl = this.selectedTemplate;
|
||||
if (!tpl || !this.canSubmit) return null;
|
||||
const title = this.form.value.title as string;
|
||||
// Seuls les champs TEXT sont proposes a l'IA : l'IA ne genere pas d'images.
|
||||
const textFields = (tpl.fields ?? []).filter(f => f.type === 'TEXT');
|
||||
const fieldsList = textFields.length ? textFields.map(f => `"${f.name}"`).join(', ') : '(aucun champ)';
|
||||
const exampleJson = textFields.length
|
||||
? '{\n ' + textFields.map(f => `"${f.name}": "valeur proposée"`).join(',\n ') + '\n}'
|
||||
: '{}';
|
||||
|
||||
return `MODE WIZARD — CRÉATION DE PAGE
|
||||
|
||||
L'utilisateur crée une nouvelle page intitulée "${title}" à partir du template "${tpl.name}".
|
||||
Les champs à proposer sont : ${fieldsList}.
|
||||
|
||||
Règles de cohérence :
|
||||
- Tu PEUX inventer des éléments originaux (personnages, lieux, objets, intrigues) — c'est ton rôle.
|
||||
- Tu ne peux PAS faire référence à un élément comme s'il existait déjà dans l'univers, sauf s'il apparaît EXACTEMENT dans la carte du Lore fournie plus haut.
|
||||
- Si l'utilisateur évoque un élément absent de la carte, suggère de le créer plutôt que d'inventer des détails fictifs à son sujet.
|
||||
|
||||
Format de réponse OBLIGATOIRE :
|
||||
Après avoir dialogué (1-3 phrases), termine CHAQUE réponse par un bloc JSON entre balises <values>, sans rien ajouter après :
|
||||
|
||||
<values>
|
||||
${exampleJson}
|
||||
</values>
|
||||
|
||||
Les clés du JSON doivent correspondre EXACTEMENT aux noms de champs indiqués. Laisse "" si tu manques d'info pour un champ.`;
|
||||
}
|
||||
|
||||
/** Welcome message contextualisé au template choisi. */
|
||||
get wizardWelcome(): string {
|
||||
const tpl = this.selectedTemplate;
|
||||
if (!tpl) return 'Décrivez ce que vous souhaitez créer.';
|
||||
return `Super, on va créer une page "${tpl.name}" ! Décrivez-la-moi en quelques mots — contexte, rôle, traits marquants — et je proposerai des valeurs pour chaque champ.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le bloc <values>{...}</values> de la réponse assistant et parse en objet.
|
||||
* Retourne null si absent ou JSON invalide.
|
||||
*/
|
||||
private extractValuesBlock(reply: string): Record<string, string> | null {
|
||||
const match = reply.match(/<values>\s*([\s\S]*?)\s*<\/values>/i);
|
||||
if (!match) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]) as Record<string, unknown>;
|
||||
// On coerce toute valeur non-string en string (l'IA peut parfois produire des nombres).
|
||||
const result: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
result[k] = v == null ? '' : String(v);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user