Mise en ligne de la version 0.2.0
This commit is contained in:
199
web/src/app/services/ai-chat.service.ts
Normal file
199
web/src/app/services/ai-chat.service.ts
Normal 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.';
|
||||
}
|
||||
}
|
||||
}
|
||||
142
web/src/app/services/campaign.model.ts
Normal file
142
web/src/app/services/campaign.model.ts
Normal 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[];
|
||||
}
|
||||
105
web/src/app/services/campaign.service.ts
Normal file
105
web/src/app/services/campaign.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
16
web/src/app/services/global-search.service.ts
Normal file
16
web/src/app/services/global-search.service.ts
Normal 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); }
|
||||
}
|
||||
15
web/src/app/services/image.model.ts
Normal file
15
web/src/app/services/image.model.ts
Normal 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;
|
||||
}
|
||||
43
web/src/app/services/image.service.ts
Normal file
43
web/src/app/services/image.service.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
101
web/src/app/services/layout.service.ts
Normal file
101
web/src/app/services/layout.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
37
web/src/app/services/lore.model.ts
Normal file
37
web/src/app/services/lore.model.ts
Normal 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;
|
||||
}
|
||||
69
web/src/app/services/lore.service.ts
Normal file
69
web/src/app/services/lore.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
25
web/src/app/services/page-title.service.ts
Normal file
25
web/src/app/services/page-title.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
26
web/src/app/services/page.model.ts
Normal file
26
web/src/app/services/page.model.ts
Normal 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;
|
||||
}
|
||||
62
web/src/app/services/page.service.ts
Normal file
62
web/src/app/services/page.service.ts
Normal 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 ?? {}));
|
||||
}
|
||||
}
|
||||
61
web/src/app/services/settings.service.ts
Normal file
61
web/src/app/services/settings.service.ts
Normal 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[];
|
||||
}
|
||||
36
web/src/app/services/template.model.ts
Normal file
36
web/src/app/services/template.model.ts
Normal 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[];
|
||||
}
|
||||
42
web/src/app/services/template.service.ts
Normal file
42
web/src/app/services/template.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user