Ajout de 2 fonctionnalitées principales : import PDF que ce soit pour les règles ou les campagnes directement.
All checks were successful
All checks were successful
Fonctionnalité de comparaison PDF / campagne pour faire un mix et demander des conseils à l'IA
This commit is contained in:
108
web/src/app/services/campaign-adapt.service.ts
Normal file
108
web/src/app/services/campaign-adapt.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
72
web/src/app/services/campaign-import.model.ts
Normal file
72
web/src/app/services/campaign-import.model.ts
Normal 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 };
|
||||
113
web/src/app/services/campaign-import.service.ts
Normal file
113
web/src/app/services/campaign-import.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user