Ajout de 2 fonctionnalitées principales : import PDF que ce soit pour les règles ou les campagnes directement.
Some checks failed
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled

Fonctionnalité de comparaison PDF / campagne pour faire un mix et demander des conseils à l'IA
This commit is contained in:
2026-06-04 13:55:27 +02:00
parent 091de0daf7
commit 6d00543a59
78 changed files with 5250 additions and 183 deletions

View File

@@ -0,0 +1,108 @@
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);
}
}
}

View File

@@ -0,0 +1,72 @@
/**
* Types de l'import de PDF de campagne (arbre arc → chapitre → scène).
* Jumeaux des records Java (CampaignImportProposal / CampaignImportProgress).
*/
export interface RoomProposal {
name: string;
description: string;
enemies: string;
loot: string;
}
export interface SceneProposal {
name: string;
description: string;
/** Texte d'encadré « à lire aux joueurs ». */
playerNarration: string;
/** Secrets / développement MJ. */
gmNotes: string;
/** Non vide ⇒ la scène est un lieu explorable (donjon). */
rooms: RoomProposal[];
/** ID si la scène existe déjà (revue pré-chargée) ; absent = à créer. */
existingId?: string | null;
}
export interface ChapterProposal {
name: string;
description: string;
scenes: SceneProposal[];
existingId?: string | null;
}
export type ArcKind = 'LINEAR' | 'HUB';
export interface ArcProposal {
name: string;
description: string;
type: ArcKind;
chapters: ChapterProposal[];
existingId?: string | null;
}
export interface CampaignImportProposal {
arcs: ArcProposal[];
}
/** Récapitulatif renvoyé après création effective des entités. */
export interface CampaignImportApplyResult {
arcsCreated: number;
chaptersCreated: number;
scenesCreated: number;
}
/**
* Évènements du flux SSE d'import streamé.
* - progress : avancement (total=0 ⇒ extraction en cours).
* - done : arbre proposé (à réviser).
* - error : message d'erreur côté serveur.
*/
export type CampaignImportStreamEvent =
| {
type: 'progress';
current: number;
total: number;
pageCount: number;
ocrPageCount: number;
arcCount: number;
chapterCount: number;
sceneCount: number;
}
| { type: 'done'; arcs: ArcProposal[] }
| { type: 'error'; message: string };

View File

@@ -0,0 +1,113 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import {
CampaignImportApplyResult,
CampaignImportProposal,
CampaignImportStreamEvent
} from './campaign-import.model';
/**
* Service HTTP pour l'import d'un PDF de campagne.
*
* `importStructureStream` utilise fetch() (POST multipart + SSE, impossible
* avec EventSource) et décode le flux ligne par ligne, comme le chat.
* `applyStructure` poste l'arbre révisé pour créer les entités.
*/
@Injectable({ providedIn: 'root' })
export class CampaignImportService {
constructor(private http: HttpClient) {}
importStructureStream(campaignId: string, file: File): Observable<CampaignImportStreamEvent> {
return new Observable<CampaignImportStreamEvent>((subscriber) => {
const controller = new AbortController();
const form = new FormData();
form.append('file', file);
fetch(`/api/campaigns/${campaignId}/import-structure/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.consumeSse(response.body, subscriber);
})
.catch((err) => {
if (controller.signal.aborted) return;
subscriber.error(err);
});
return () => controller.abort();
});
}
applyStructure(campaignId: string, proposal: CampaignImportProposal): Observable<CampaignImportApplyResult> {
return this.http.post<CampaignImportApplyResult>(
`/api/campaigns/${campaignId}/import-structure/apply`, proposal);
}
/** Décode un ReadableStream SSE et émet les évènements d'import typés. */
private async consumeSse(
body: ReadableStream<Uint8Array>,
subscriber: { next: (e: CampaignImportStreamEvent) => 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\'import.';
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
subscriber.error(new Error(message));
} else if (name === 'progress' || name === 'done') {
try {
const obj = JSON.parse(currentData);
if (name === 'done') {
subscriber.next({ type: 'done', arcs: obj.arcs ?? [] });
subscriber.complete();
} else {
subscriber.next({ type: 'progress', ...obj });
}
} catch { /* bloc malformé 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);
}
}
}

View File

@@ -18,6 +18,28 @@ export interface GameSystem {
isPublic?: boolean;
}
/**
* Réponse de l'import d'un PDF de règles : proposition de sections à réviser.
* `sections` = {titre → contenu markdown}. `ocrPageCount` > 0 ⇒ le PDF était
* (au moins partiellement) un scan passé à l'OCR.
*/
export interface RulesImportResponse {
sections: Record<string, string>;
pageCount: number;
ocrPageCount: number;
}
/**
* Évènements du flux SSE d'import streamé.
* - progress : avancement (total=0 ⇒ phase d'extraction en cours).
* - done : résultat final (sections proposées).
* - error : message d'erreur côté serveur.
*/
export type RulesImportStreamEvent =
| { type: 'progress'; current: number; total: number; pageCount: number; ocrPageCount: number; newSectionTitles: string[] }
| { type: 'done'; sections: Record<string, string>; pageCount: number; ocrPageCount: number }
| { type: 'error'; message: string };
/** Payload de creation/mise a jour (sans id). */
export interface GameSystemCreate {
name: string;

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { GameSystem, GameSystemCreate } from './game-system.model';
import { GameSystem, GameSystemCreate, RulesImportResponse, RulesImportStreamEvent } from './game-system.model';
/**
* Service HTTP pour les GameSystems (systèmes de JDR).
@@ -36,4 +36,109 @@ export class GameSystemService {
const params = new HttpParams().set('q', q);
return this.http.get<GameSystem[]>(`${this.apiUrl}/search`, { params });
}
/**
* Importe un PDF de règles : renvoie une PROPOSITION de sections (titre →
* markdown). Rien n'est persisté côté serveur — l'appelant injecte les
* sections dans l'éditeur pour révision avant enregistrement.
*/
importRules(file: File): Observable<RulesImportResponse> {
const form = new FormData();
form.append('file', file);
return this.http.post<RulesImportResponse>(`${this.apiUrl}/import-rules`, form);
}
/**
* Variante streamée : émet l'avancement au fil de l'eau puis `done`.
* On utilise fetch() (pas EventSource : POST + multipart impossibles avec
* EventSource) et on décode le flux SSE ligne par ligne. Annuler la
* subscription annule le fetch (AbortController).
*/
importRulesStream(file: File): Observable<RulesImportStreamEvent> {
return new Observable<RulesImportStreamEvent>((subscriber) => {
const controller = new AbortController();
const form = new FormData();
form.append('file', file);
fetch(`${this.apiUrl}/import-rules/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.consumeImportSse(response.body, subscriber);
})
.catch((err) => {
if (controller.signal.aborted) return;
subscriber.error(err);
});
return () => controller.abort();
});
}
/** Décode un ReadableStream SSE et émet les évènements d'import typés. */
private async consumeImportSse(
body: ReadableStream<Uint8Array>,
subscriber: { next: (e: RulesImportStreamEvent) => 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\'import.';
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ }
subscriber.error(new Error(message));
} else if (name === 'progress' || name === 'done') {
try {
const obj = JSON.parse(currentData);
if (name === 'done') {
subscriber.next({ type: 'done', ...obj });
subscriber.complete();
} else {
subscriber.next({ type: 'progress', ...obj });
}
} catch { /* bloc malformé 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);
}
}
}

View File

@@ -73,6 +73,12 @@ export interface BottomPanel {
export interface SecondarySidebarConfig {
title: string;
/**
* Si défini, le titre devient cliquable et navigue vers cette route
* (ex: accueil de la campagne depuis n'importe quelle sous-page). Absent =
* titre statique (cas du Lore).
*/
titleRoute?: string;
items: TreeItem[];
createActions: SidebarAction[];
globalItems: GlobalItem[];

View File

@@ -13,6 +13,10 @@ export interface AppSettings {
onemin_model: string;
onemin_api_key_set: boolean;
llm_num_ctx: number;
/** Taille cible d'un morceau (tokens) pour l'import de PDF (règles/campagne). */
import_chunk_tokens: number;
/** Timeout HTTP des appels LLM (secondes). */
llm_timeout_seconds: number;
}
/**
@@ -26,6 +30,8 @@ export interface AppSettingsUpdate {
onemin_model?: string;
onemin_api_key?: string;
llm_num_ctx?: number;
import_chunk_tokens?: number;
llm_timeout_seconds?: number;
}
/** Metadonnees d'un modele Ollama (issues de /api/show). */