Mise en ligne de la version 0.2.0
All checks were successful
Build & Push Images / build (brain) (push) Successful in 46s
Build & Push Images / build (core) (push) Successful in 1m21s
Build & Push Images / build (web) (push) Successful in 1m25s

This commit is contained in:
2026-04-21 14:25:17 +02:00
parent ebee8e106b
commit ba8a503b3e
300 changed files with 35329 additions and 1 deletions

View File

@@ -0,0 +1,199 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
/**
* Un message d'une conversation IA (vue front).
* Aligné sur le DTO ChatMessageDTO côté Java.
*/
export interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string;
}
/**
* Événements émis par le flux SSE durant un chat streamé.
* - token : un fragment de texte vient d'arriver (à concaténer dans la bulle).
* - done : le stream s'est terminé proprement (l'observable va compléter).
* - error : une erreur s'est produite côté serveur (l'observable va erreur-compléter).
*/
export type ChatStreamEvent =
| { type: 'token'; value: string }
| { type: 'done' }
| { type: 'error'; message: string };
/**
* Service qui encapsule l'appel SSE au backend Java (POST /api/ai/chat/stream).
*
* On n'utilise pas EventSource (API navigateur natif) car elle ne supporte
* que GET sans body. On fait donc un fetch() avec un ReadableStream qu'on
* décode ligne par ligne pour extraire les événements SSE.
*/
/** Type d'entité narrative focus pour le chat Campagne. */
export type NarrativeEntityType = 'arc' | 'chapter' | 'scene';
@Injectable({ providedIn: 'root' })
export class AiChatService {
private readonly loreEndpoint = 'http://localhost:8080/api/ai/chat/stream';
private readonly campaignEndpoint = 'http://localhost:8080/api/ai/chat/stream-campaign';
/**
* Streame la réponse de l'IA pour un historique de messages donné (chat ancré Lore).
* L'Observable :
* - émet `{type: 'token', value}` à chaque fragment reçu ;
* - se complete quand `event: done` arrive ;
* - erreur-complete (via `throwError`) quand `event: error` arrive ou qu'une erreur réseau survient.
*
* Annuler la subscription annule proprement le fetch (AbortController).
*/
streamChat(
loreId: string,
messages: ChatMessage[],
pageId?: string | null
): Observable<ChatStreamEvent> {
const body: Record<string, unknown> = { loreId, messages };
if (pageId) body['pageId'] = pageId;
return this.streamSse(this.loreEndpoint, body);
}
/**
* Streame la réponse de l'IA pour un chat ancré sur une Campagne.
* Le backend charge automatiquement la carte narrative (arcs/chapitres/scènes)
* et, si la campagne est liée à un Lore, sa carte structurelle également.
*
* `entityType` + `entityId` sont optionnels : si fournis, focalisent l'IA
* sur l'arc / chapitre / scène en cours d'édition.
*/
streamChatForCampaign(
campaignId: string,
messages: ChatMessage[],
entityType?: NarrativeEntityType | null,
entityId?: string | null
): Observable<ChatStreamEvent> {
const body: Record<string, unknown> = { campaignId, messages };
if (entityType && entityId) {
body['entityType'] = entityType;
body['entityId'] = entityId;
}
return this.streamSse(this.campaignEndpoint, body);
}
/** Plumbing SSE mutualisé entre les 2 endpoints (Lore et Campaign). */
private streamSse(endpoint: string, body: Record<string, unknown>): Observable<ChatStreamEvent> {
return new Observable<ChatStreamEvent>((subscriber) => {
const controller = new AbortController();
fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream'
},
body: JSON.stringify(body),
signal: controller.signal
})
.then(async (response) => {
if (!response.ok || !response.body) {
subscriber.error(new Error(`HTTP ${response.status}`));
return;
}
await this.consumeSseStream(response.body, subscriber);
})
.catch((err) => {
if (controller.signal.aborted) return; // annulation volontaire, silencieuse
subscriber.error(err);
});
return () => controller.abort();
});
}
/**
* Consomme un ReadableStream SSE ligne par ligne.
* Format attendu (un événement = un bloc séparé par `\n\n`) :
* event: done (optionnel, défaut = 'message')
* data: {...} (une ou plusieurs lignes, concaténées avec '\n')
* <ligne vide> (séparateur d'événements)
*/
private async consumeSseStream(
body: ReadableStream<Uint8Array>,
subscriber: { next: (e: ChatStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
// Événement SSE en cours de construction (accumulé entre lignes vides).
let currentEvent: string | null = null;
let currentData = '';
const dispatchCurrentEvent = () => {
const eventName = currentEvent ?? 'message';
if (eventName === 'error') {
const message = this.safeParseMessage(currentData);
subscriber.error(new Error(message));
} else if (eventName === 'done') {
subscriber.next({ type: 'done' });
subscriber.complete();
} else {
// Événement 'message' (défaut) : JSON {"token": "..."}
const token = this.safeParseToken(currentData);
if (token) subscriber.next({ type: 'token', value: token });
}
currentEvent = null;
currentData = '';
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// On découpe par lignes ; la dernière (potentiellement incomplète) reste dans buffer.
let newlineIdx: number;
while ((newlineIdx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newlineIdx).replace(/\r$/, '');
buffer = buffer.slice(newlineIdx + 1);
if (line === '') {
// Ligne vide = fin d'un événement SSE : on dispatch ce qu'on a accumulé.
if (currentEvent !== null || currentData !== '') {
dispatchCurrentEvent();
}
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;
}
// Autres champs SSE (id:, retry:) ignorés pour le MVP.
}
}
// Fin de stream côté réseau sans event: done explicite → on complete quand même.
if (currentEvent !== null || currentData !== '') dispatchCurrentEvent();
subscriber.complete();
} catch (err) {
subscriber.error(err);
}
}
private safeParseToken(json: string): string | null {
try {
const obj = JSON.parse(json) as { token?: string };
return typeof obj.token === 'string' ? obj.token : null;
} catch {
return null;
}
}
private safeParseMessage(json: string): string {
try {
const obj = JSON.parse(json) as { message?: string };
return obj.message ?? 'Erreur inconnue côté serveur.';
} catch {
return json || 'Erreur inconnue côté serveur.';
}
}
}

View File

@@ -0,0 +1,142 @@
// Interface TypeScript pour CampaignDTO (correspond au DTO Java)
export interface Campaign {
id?: string;
name: string;
description: string;
playerCount?: number;
arcCount?: number;
chapterCount?: number;
/** ID du Lore associé (weak reference cross-context). `null` = pas d'univers lié. */
loreId?: string | null;
}
// Interface pour la création de Campaign (sans id)
export interface CampaignCreate {
name: string;
description: string;
playerCount: number;
loreId?: string | null;
}
export interface Arc {
id?: string;
name: string;
description?: string; // = Synopsis dans l'UI
campaignId: string;
order?: number;
chapterCount?: number;
// Champs narratifs enrichis
themes?: string;
stakes?: string;
gmNotes?: string;
rewards?: string;
resolution?: string;
/** IDs des pages du Lore liées à cet arc (weak cross-context refs). */
relatedPageIds?: string[];
/** IDs des images (Shared Kernel) illustrant cet arc. */
illustrationImageIds?: string[];
}
// Payload pour la création d'un Arc (pas d'id)
export interface ArcCreate {
name: string;
description?: string;
campaignId: string;
order: number;
themes?: string;
stakes?: string;
gmNotes?: string;
rewards?: string;
resolution?: string;
relatedPageIds?: string[];
illustrationImageIds?: string[];
}
export interface Chapter {
id?: string;
name: string;
description?: string;
arcId: string;
order?: number;
// Champs narratifs enrichis
gmNotes?: string;
playerObjectives?: string;
narrativeStakes?: string;
relatedPageIds?: string[];
illustrationImageIds?: string[];
}
export interface ChapterCreate {
name: string;
description?: string;
arcId: string;
order: number;
gmNotes?: string;
playerObjectives?: string;
narrativeStakes?: string;
relatedPageIds?: string[];
illustrationImageIds?: string[];
}
/**
* Branche narrative : sortie possible d'une scène vers une autre du même chapitre.
* Pendant TS du Value Object Java SceneBranch.
*/
export interface SceneBranch {
label: string;
targetSceneId: string;
condition?: string;
}
export interface Scene {
id?: string;
name: string;
description?: string; // = Description courte dans l'UI
chapterId: string;
order?: number;
// Champs narratifs enrichis
location?: string;
timing?: string;
atmosphere?: string;
playerNarration?: string;
gmSecretNotes?: string;
choicesConsequences?: string;
combatDifficulty?: string;
enemies?: string;
relatedPageIds?: string[];
illustrationImageIds?: string[];
/** Sorties narratives (graphe intra-chapitre). */
branches?: SceneBranch[];
}
export interface SceneCreate {
name: string;
description?: string;
chapterId: string;
order: number;
location?: string;
timing?: string;
atmosphere?: string;
playerNarration?: string;
gmSecretNotes?: string;
choicesConsequences?: string;
combatDifficulty?: string;
enemies?: string;
relatedPageIds?: string[];
illustrationImageIds?: string[];
branches?: SceneBranch[];
}

View File

@@ -0,0 +1,105 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Campaign, CampaignCreate, Arc, ArcCreate, Chapter, ChapterCreate, Scene, SceneCreate } from './campaign.model';
/**
* Service HTTP pour la gestion des Campagnes.
* Port de sortie vers le Backend Java (Architecture Hexagonale).
*/
@Injectable({
providedIn: 'root'
})
export class CampaignService {
private apiUrl = 'http://localhost:8080/api/campaigns';
constructor(private http: HttpClient) {}
getAllCampaigns(): Observable<Campaign[]> {
return this.http.get<Campaign[]>(this.apiUrl);
}
getCampaignById(id: string): Observable<Campaign> {
return this.http.get<Campaign>(`${this.apiUrl}/${id}`);
}
createCampaign(campaign: CampaignCreate): Observable<Campaign> {
return this.http.post<Campaign>(this.apiUrl, campaign);
}
updateCampaign(id: string, campaign: CampaignCreate): Observable<Campaign> {
return this.http.put<Campaign>(`${this.apiUrl}/${id}`, campaign);
}
deleteCampaign(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
// ========== ARC ==========
getArcs(campaignId: string): Observable<Arc[]> {
return this.http.get<Arc[]>(`http://localhost:8080/api/arcs/campaign/${campaignId}`);
}
getArcById(id: string): Observable<Arc> {
return this.http.get<Arc>(`http://localhost:8080/api/arcs/${id}`);
}
createArc(payload: ArcCreate): Observable<Arc> {
return this.http.post<Arc>('http://localhost:8080/api/arcs', payload);
}
updateArc(id: string, payload: ArcCreate): Observable<Arc> {
return this.http.put<Arc>(`http://localhost:8080/api/arcs/${id}`, payload);
}
deleteArc(id: string): Observable<void> {
return this.http.delete<void>(`http://localhost:8080/api/arcs/${id}`);
}
// ========== CHAPTER ==========
getChapters(arcId: string): Observable<Chapter[]> {
return this.http.get<Chapter[]>(`http://localhost:8080/api/chapters/arc/${arcId}`);
}
getChapterById(id: string): Observable<Chapter> {
return this.http.get<Chapter>(`http://localhost:8080/api/chapters/${id}`);
}
createChapter(payload: ChapterCreate): Observable<Chapter> {
return this.http.post<Chapter>('http://localhost:8080/api/chapters', payload);
}
updateChapter(id: string, payload: ChapterCreate): Observable<Chapter> {
return this.http.put<Chapter>(`http://localhost:8080/api/chapters/${id}`, payload);
}
deleteChapter(id: string): Observable<void> {
return this.http.delete<void>(`http://localhost:8080/api/chapters/${id}`);
}
// ========== SCENE ==========
getScenes(chapterId: string): Observable<Scene[]> {
return this.http.get<Scene[]>(`http://localhost:8080/api/scenes/chapter/${chapterId}`);
}
getSceneById(id: string): Observable<Scene> {
return this.http.get<Scene>(`http://localhost:8080/api/scenes/${id}`);
}
createScene(payload: SceneCreate): Observable<Scene> {
return this.http.post<Scene>('http://localhost:8080/api/scenes', payload);
}
updateScene(id: string, payload: SceneCreate): Observable<Scene> {
return this.http.put<Scene>(`http://localhost:8080/api/scenes/${id}`, payload);
}
deleteScene(id: string): Observable<void> {
return this.http.delete<void>(`http://localhost:8080/api/scenes/${id}`);
}
search(q: string): Observable<Campaign[]> {
const params = new HttpParams().set('q', q);
return this.http.get<Campaign[]>(`${this.apiUrl}/search`, { params });
}
}

View File

@@ -0,0 +1,16 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
/**
* État global de la command palette (modale de recherche).
* Ouverte via bouton sidebar, raccourci Ctrl+K / Cmd+K, ou API programmatique.
*/
@Injectable({ providedIn: 'root' })
export class GlobalSearchService {
private readonly _open$ = new BehaviorSubject<boolean>(false);
readonly open$ = this._open$.asObservable();
open(): void { this._open$.next(true); }
close(): void { this._open$.next(false); }
toggle(): void { this._open$.next(!this._open$.value); }
}

View File

@@ -0,0 +1,15 @@
// Interface TypeScript pour ImageDTO (Backend Java).
// Miroir de com.loremind.infrastructure.web.dto.images.ImageDTO.
export interface Image {
id: string;
filename: string;
contentType: string;
sizeBytes: number;
/**
* URL relative du binaire, ex: "/api/images/42/content".
* Le front prefixe avec ApiBase pour construire l'URL absolue.
*/
url: string;
uploadedAt: string;
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Image } from './image.model';
/**
* Service HTTP pour le Shared Kernel images.
* Port de sortie vers le backend Java (/api/images).
*/
@Injectable({ providedIn: 'root' })
export class ImageService {
/** Base absolue du backend — utile pour construire des URLs complètes (<img src>). */
readonly apiBase = 'http://localhost:8080';
private apiUrl = `${this.apiBase}/api/images`;
constructor(private http: HttpClient) {}
/**
* Upload d'un fichier via multipart/form-data.
* Le backend valide le MIME et la taille (10 Mo max).
*/
upload(file: File): Observable<Image> {
const form = new FormData();
form.append('file', file);
return this.http.post<Image>(this.apiUrl, form);
}
getById(id: string): Observable<Image> {
return this.http.get<Image>(`${this.apiUrl}/${id}`);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
/**
* Construit l'URL absolue du binaire d'une image.
* Utilise par les balises <img src> dans les composants.
*/
contentUrl(id: string): string {
return `${this.apiUrl}/${id}/content`;
}
}

View File

@@ -0,0 +1,101 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
export interface TreeItem {
id: string;
label: string;
children?: TreeItem[];
route?: string; // si défini, cliquer navigue au lieu de toggler
isAction?: boolean; // style "action" (ex: "+ Nouveau chapitre")
/** Clé d'icône optionnelle (ex: "users"). Résolue par le composant via `resolveIcon`. */
iconKey?: string;
/** Petit badge affiché à droite (ex: "3" pour compter les pages d'un dossier). */
meta?: string;
}
export interface GlobalItem {
id: string;
name: string;
route: string;
}
export interface SidebarAction {
id: string;
label: string;
variant: 'primary' | 'secondary' | 'success';
route?: string;
}
/**
* Item affiché dans un panneau secondaire (ex: liste des Templates).
* Plus simple qu'un TreeItem : pas de récursion, juste label + meta + route.
*/
export interface BottomPanelItem {
id: string;
label: string;
meta?: string; // petit badge à droite (ex: "8 champs")
route?: string;
isAction?: boolean; // style "action" (ex: "+ Nouveau template")
}
/**
* Panneau secondaire collapsible en bas de la sidebar (sous l'arbre).
* Utilisé notamment pour le panneau "Templates" côté Lore.
*/
export interface BottomPanel {
id: string; // identifiant pour mémoriser l'état ouvert/fermé
title: string;
items: BottomPanelItem[];
initiallyOpen?: boolean;
}
export interface SecondarySidebarConfig {
title: string;
items: TreeItem[];
createActions: SidebarAction[];
globalItems: GlobalItem[];
globalBackLabel: string;
globalBackRoute: string;
bottomPanel?: BottomPanel; // optionnel : présent côté Lore (Templates)
/** @deprecated Remplacé par bottomPanel. Gardé pour compat des callers campagne. */
footerLabel?: string;
}
/**
* Service de layout — contrôle l'affichage de la secondary sidebar
* et les données contextuelles de la sidebar globale.
*
* L'état d'expansion des items de l'arbre est maintenu au niveau du service
* (et non dans le composant secondary-sidebar), pour survivre aux
* destructions/recréations du composant lors des navigations (le *ngIf dans
* app.component.html détruit la sidebar à chaque `hide()`).
*/
@Injectable({ providedIn: 'root' })
export class LayoutService {
private config$ = new BehaviorSubject<SecondarySidebarConfig | null>(null);
private readonly expanded = new Set<string>();
readonly secondarySidebar$ = this.config$.asObservable();
show(config: SecondarySidebarConfig): void {
this.config$.next(config);
}
hide(): void {
this.config$.next(null);
}
isExpanded(id: string): boolean {
return this.expanded.has(id);
}
toggleExpanded(id: string): void {
if (this.expanded.has(id)) this.expanded.delete(id);
else this.expanded.add(id);
}
setExpanded(id: string, state: boolean): void {
if (state) this.expanded.add(id);
else this.expanded.delete(id);
}
}

View File

@@ -0,0 +1,37 @@
// Interface TypeScript pour LoreDTO (correspond au DTO Java)
export interface Lore {
id?: string;
name: string;
description: string;
nodeCount?: number;
pageCount?: number;
}
// Interface pour la création de Lore (sans id)
export interface LoreCreate {
name: string;
description: string;
}
export interface LoreNode {
id?: string;
name: string;
/** Clé d'icône lucide-angular (ex: "users", "map-pin"). */
icon?: string | null;
/** ID du dossier parent (null = racine). */
parentId?: string | null;
loreId: string;
/** Champs historiques non encore persistés côté backend — gardés pour compat de l'UI. */
type?: string;
description?: string;
address?: string;
}
export interface LoreNodeCreate {
name: string;
icon: string;
description: string;
address: string;
parentId?: string | null;
loreId: string;
}

View File

@@ -0,0 +1,69 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Lore, LoreCreate, LoreNode, LoreNodeCreate } from './lore.model';
/**
* Service HTTP pour la gestion des Lores.
* Port de sortie vers le Backend Java (Architecture Hexagonale).
*/
@Injectable({
providedIn: 'root'
})
export class LoreService {
private apiUrl = 'http://localhost:8080/api/lores';
private nodesUrl = 'http://localhost:8080/api/lore-nodes';
constructor(private http: HttpClient) {}
getAllLores(): Observable<Lore[]> {
return this.http.get<Lore[]>(this.apiUrl);
}
getLoreById(id: string): Observable<Lore> {
return this.http.get<Lore>(`${this.apiUrl}/${id}`);
}
createLore(lore: LoreCreate): Observable<Lore> {
return this.http.post<Lore>(this.apiUrl, lore);
}
updateLore(id: string, lore: LoreCreate): Observable<Lore> {
return this.http.put<Lore>(`${this.apiUrl}/${id}`, lore);
}
deleteLore(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
getLoreNodes(loreId: string): Observable<LoreNode[]> {
return this.http.get<LoreNode[]>(`${this.nodesUrl}?loreId=${loreId}`);
}
getLoreNodeById(id: string): Observable<LoreNode> {
return this.http.get<LoreNode>(`${this.nodesUrl}/${id}`);
}
createLoreNode(node: LoreNodeCreate): Observable<LoreNode> {
return this.http.post<LoreNode>(this.nodesUrl, node);
}
/** PUT complet — envoie l'objet entier au backend (qui attend un LoreNodeDTO). */
updateLoreNode(id: string, node: LoreNode): Observable<LoreNode> {
return this.http.put<LoreNode>(`${this.nodesUrl}/${id}`, node);
}
deleteLoreNode(id: string): Observable<void> {
return this.http.delete<void>(`${this.nodesUrl}/${id}`);
}
searchLores(q: string): Observable<Lore[]> {
const params = new HttpParams().set('q', q);
return this.http.get<Lore[]>(`${this.apiUrl}/search`, { params });
}
searchLoreNodes(q: string): Observable<LoreNode[]> {
const params = new HttpParams().set('q', q);
return this.http.get<LoreNode[]>(`${this.nodesUrl}/search`, { params });
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { Title } from '@angular/platform-browser';
/**
* Service centralisé pour le titre de l'onglet navigateur.
* Uniformise le format "LoreMind - <sujet>" partout dans l'app.
*
* Pourquoi un wrapper et pas Title directement ? Évite de dupliquer le préfixe
* "LoreMind - " dans chaque écran — si on veut changer le format un jour, un
* seul endroit à toucher.
*/
@Injectable({ providedIn: 'root' })
export class PageTitleService {
constructor(private title: Title) {}
/**
* Définit le titre de l'onglet au format "LoreMind - <subject>".
* Passer `null` (ou vide) remet juste "LoreMind" — utile pour les écrans
* listing qui n'ont pas de sujet spécifique.
*/
set(subject: string | null | undefined): void {
const s = subject?.trim();
this.title.setTitle(s ? `LoreMind - ${s}` : 'LoreMind');
}
}

View File

@@ -0,0 +1,26 @@
// Interfaces TypeScript pour PageDTO (Backend Java).
export interface Page {
id?: string;
loreId: string;
nodeId: string;
templateId?: string | null;
title: string;
values?: Record<string, string>;
/**
* Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images
* uploadees (Shared Kernel images). Structure separee de `values`.
*/
imageValues?: Record<string, string[]>;
notes?: string | null;
tags?: string[];
relatedPageIds?: string[];
}
/** Payload de création : seuls les champs structurels sont envoyés. */
export interface PageCreate {
loreId: string;
nodeId: string;
templateId: string;
title: string;
}

View File

@@ -0,0 +1,62 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Page, PageCreate } from './page.model';
/**
* Service HTTP pour la gestion des Pages.
* Port de sortie du Frontend vers le Backend Java (/api/pages).
*/
@Injectable({ providedIn: 'root' })
export class PageService {
private apiUrl = 'http://localhost:8080/api/pages';
constructor(private http: HttpClient) {}
/** Toutes les pages d'un Lore (flat, pour répartir ensuite par nodeId). */
getByLoreId(loreId: string): Observable<Page[]> {
const params = new HttpParams().set('loreId', loreId);
return this.http.get<Page[]>(this.apiUrl, { params });
}
/** Toutes les pages d'un noeud donné. */
getByNodeId(nodeId: string): Observable<Page[]> {
const params = new HttpParams().set('nodeId', nodeId);
return this.http.get<Page[]>(this.apiUrl, { params });
}
getById(id: string): Observable<Page> {
return this.http.get<Page>(`${this.apiUrl}/${id}`);
}
create(payload: PageCreate): Observable<Page> {
return this.http.post<Page>(this.apiUrl, payload);
}
update(id: string, page: Page): Observable<Page> {
return this.http.put<Page>(`${this.apiUrl}/${id}`, page);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
search(q: string): Observable<Page[]> {
const params = new HttpParams().set('q', q);
return this.http.get<Page[]>(`${this.apiUrl}/search`, { params });
}
/**
* Demande à l'IA (Brain Python, via le Core Java) des suggestions de valeurs
* pour les champs dynamiques de la page. Ne modifie PAS la page en base —
* l'appelant est responsable de fusionner les valeurs et de sauvegarder.
*
* Peut prendre plusieurs dizaines de secondes selon le modèle LLM.
*/
generateValues(pageId: string): Observable<Record<string, string>> {
return this.http
.post<{ values: Record<string, string> }>(`${this.apiUrl}/${pageId}/generate`, {})
.pipe(map(res => res.values ?? {}));
}
}

View File

@@ -0,0 +1,61 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
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 interface AppSettings {
llm_provider: 'ollama' | 'onemin';
ollama_base_url: string;
llm_model: string;
onemin_model: string;
onemin_api_key_set: boolean;
}
/**
* Patch partiel — seuls les champs a modifier sont presents.
* `onemin_api_key: ''` efface la cle, `null`/absent ne touche a rien.
*/
export interface AppSettingsUpdate {
llm_provider?: 'ollama' | 'onemin';
ollama_base_url?: string;
llm_model?: string;
onemin_model?: string;
onemin_api_key?: string;
}
@Injectable({ providedIn: 'root' })
export class SettingsService {
private readonly apiUrl = 'http://localhost:8080/api/settings';
// HTTP Basic : le browser gere le prompt natif de credentials au premier 401.
// withCredentials=true pour que les creds soient renvoyees sur les appels
// suivants en cross-origin (dev Angular sur :4200 -> core sur :8080).
private readonly authOptions = { withCredentials: true };
constructor(private http: HttpClient) {}
getSettings(): Observable<AppSettings> {
return this.http.get<AppSettings>(this.apiUrl, this.authOptions);
}
updateSettings(patch: AppSettingsUpdate): Observable<AppSettings> {
return this.http.put<AppSettings>(this.apiUrl, patch, this.authOptions);
}
listOllamaModels(): Observable<{ models: string[] }> {
return this.http.get<{ models: string[] }>(`${this.apiUrl}/models/ollama`, this.authOptions);
}
listOneMinModels(): Observable<{ groups: OneMinModelGroup[] }> {
return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions);
}
}
/** Un groupe de modeles 1min.ai regroupes par fournisseur (Anthropic, OpenAI, ...). */
export interface OneMinModelGroup {
provider: string;
models: string[];
}

View File

@@ -0,0 +1,36 @@
// Interfaces TypeScript pour TemplateDTO (Backend Java).
/**
* Type d'un champ de Template. Miroir de com.loremind.domain.lorecontext.FieldType.
* - 'TEXT' : champ textuel libre (rendu en textarea)
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
*/
export type FieldType = 'TEXT' | 'IMAGE';
/**
* Champ d'un Template : nom + type discriminant.
* Miroir de TemplateFieldDTO (backend).
*/
export interface TemplateField {
name: string;
type: FieldType;
}
export interface Template {
id?: string;
loreId: string;
name: string;
description: string;
defaultNodeId?: string | null;
fields: TemplateField[];
fieldCount?: number;
}
/** Payload de création : id absent, fieldCount absent (calculé côté serveur). */
export interface TemplateCreate {
loreId: string;
name: string;
description: string;
defaultNodeId?: string | null;
fields: TemplateField[];
}

View File

@@ -0,0 +1,42 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Template, TemplateCreate } from './template.model';
/**
* Service HTTP pour la gestion des Templates.
* Port de sortie du Frontend vers le Backend Java (/api/templates).
*/
@Injectable({ providedIn: 'root' })
export class TemplateService {
private apiUrl = 'http://localhost:8080/api/templates';
constructor(private http: HttpClient) {}
/** Tous les templates d'un Lore (alimente le panneau sidebar). */
getByLoreId(loreId: string): Observable<Template[]> {
const params = new HttpParams().set('loreId', loreId);
return this.http.get<Template[]>(this.apiUrl, { params });
}
getById(id: string): Observable<Template> {
return this.http.get<Template>(`${this.apiUrl}/${id}`);
}
create(payload: TemplateCreate): Observable<Template> {
return this.http.post<Template>(this.apiUrl, payload);
}
update(id: string, template: Template): Observable<Template> {
return this.http.put<Template>(`${this.apiUrl}/${id}`, template);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
search(q: string): Observable<Template[]> {
const params = new HttpParams().set('q', q);
return this.http.get<Template[]>(`${this.apiUrl}/search`, { params });
}
}