Compare commits

...

6 Commits

Author SHA1 Message Date
48baa08cfb Mise en place d'un installeur pour la version bureau sans passer par Docker.
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m25s
Build & Push Images / build (core) (push) Successful in 2m55s
Build & Push Images / build (web) (push) Successful in 1m49s
Build & Push Images / build-switcher (push) Successful in 39s
Permet d'utiliser Loremind sans passer par Docker et sans lancer tous les conteneurs
Passage en v0.15.0
2026-06-17 18:04:33 +02:00
f1c68634f7 Merge branch 'beta'
Some checks failed
E2E Tests / e2e (push) Failing after 1h1m2s
2026-06-16 15:16:19 +02:00
1e501e03a4 Mise en place du readme en anglais 2026-06-16 15:15:48 +02:00
bf871852b8 Mise à jour de l'orchestrateur
Some checks failed
E2E Tests / e2e (push) Failing after 2m31s
2026-06-14 17:18:48 +02:00
78e735c959 Mise à jour de la partie watchtower ; pas nécéssaire tout le temps
Some checks are pending
E2E Tests / e2e (push) Waiting to run
2026-06-14 16:51:46 +02:00
c734de447f Merge branch 'beta'
Some checks failed
E2E Tests / e2e (push) Failing after 24s
Build & Push Images / build (brain) (push) Successful in 1m42s
Build & Push Images / build (core) (push) Successful in 1m52s
Build & Push Images / build-switcher (push) Successful in 53s
Build & Push Images / build (web) (push) Successful in 2m0s
2026-06-14 16:32:51 +02:00
32 changed files with 1955 additions and 60 deletions

94
.github/workflows/desktop-release.yml vendored Normal file
View File

@@ -0,0 +1,94 @@
name: Desktop installers
# Produit les installeurs de BUREAU (.msi Windows pour l'instant) et les publie
# en tant qu'assets d'une GitHub Release, sur tag `v*`.
#
# Complementaire au pipeline Gitea Actions (.gitea/workflows/release.yml) qui,
# lui, build et pousse les IMAGES Docker. Ici on est sur GitHub car jpackage et
# PyInstaller ne savent PAS cross-compiler : le .msi DOIT etre construit sur un
# runner Windows, et GitHub en fournit gratuitement (windows-latest).
#
# Prerequis : le depot Gitea doit etre mirrore vers GitHub (push mirror, tags
# inclus) pour que le tag declenche ce workflow.
#
# Tag stable vX.Y.Z -> GitHub Release PUBLIQUE avec le .msi attache.
# Tag beta vX.Y.Z-beta* -> AUCUNE publication publique. Le .msi est depose en
# ARTEFACT PRIVE du run (telechargeable seulement par
# toi via l'onglet Actions) ; tu le joins ensuite a un
# post Patreon reserve a un palier. Patreon = la
# barriere d'acces (equivalent du registry prive +
# relais pour les images Docker beta).
on:
push:
tags: ['v*']
permissions:
contents: write # requis pour creer la Release et y attacher le .msi
jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
# Apporte jpackage (lanceur d'empaquetage natif) dans le PATH.
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
# Python 3.12 = meme version que l'image Docker du Brain (coherence runtime).
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
# jpackage genere le MSI via WiX Toolset v3 (candle.exe/light.exe). WiX 4+
# ne convient pas (outils renommes). Le paquet choco `wixtoolset` est la
# ligne 3.x et s'ajoute au PATH.
- name: Install WiX Toolset 3
shell: pwsh
run: choco install wixtoolset -y --no-progress
# Version de l'installeur = version du tag (numerique, sans suffixe -beta).
- name: Derive version from tag
id: ver
shell: pwsh
run: |
$full = "${{ github.ref_name }}" -replace '^v','' # ex: 0.15.0-beta.1
$num = ($full -split '-')[0] # ex: 0.15.0
"version=$num" >> $env:GITHUB_OUTPUT
- name: Build Windows installer
shell: pwsh
run: .\installers\desktop\build-windows.ps1 -Version ${{ steps.ver.outputs.version }}
# STABLE uniquement : Release GitHub publique avec le .msi.
- name: Publish installer to GitHub Release (stable)
if: ${{ !contains(github.ref_name, '-beta') }}
uses: softprops/action-gh-release@v2
with:
files: core/target/dist-out/*.msi
fail_on_unmatched_files: true
generate_release_notes: true
# BETA uniquement : artefact PRIVE (pas de release publique). A recuperer
# via l'onglet Actions puis a joindre a un post Patreon gate par palier.
- name: Upload installer as private artifact (beta)
if: ${{ contains(github.ref_name, '-beta') }}
uses: actions/upload-artifact@v4
with:
name: loremind-beta-${{ steps.ver.outputs.version }}-msi
path: core/target/dist-out/*.msi
retention-days: 90
# TODO (plus tard) : job `linux` sur ubuntu-latest produisant un AppImage
# (jpackage --type app-image + appimagetool) + PyInstaller Linux du Brain,
# attache a la MEME release. Reutilise la meme matrice / les memes etapes.

7
.gitignore vendored
View File

@@ -45,6 +45,12 @@ env/
.coverage .coverage
htmlcov/ htmlcov/
# Artefacts du build bureau (cf. installers/desktop)
.venv-build/
brain/build/
brain/dist-embed/
*.spec
# ============================================================================ # ============================================================================
# Angular / Node (Web) # Angular / Node (Web)
# ============================================================================ # ============================================================================
@@ -116,3 +122,4 @@ brain/data/notebooks/5.json
# Contient le site premium (sources) + son Worker de gate dans gate/. # Contient le site premium (sources) + son Worker de gate dans gate/.
# ============================================================================ # ============================================================================
docusaurus/loremind-patreon/ docusaurus/loremind-patreon/
installers/desktop/README.md

68
README.fr.md Normal file
View File

@@ -0,0 +1,68 @@
# LoreMind
[English](README.md) · **Français**
> Application web auto-hébergeable pour MJ qui veulent centraliser leur univers, leurs campagnes et leurs personnages — avec un assistant IA contextuel.
[![Licence: AGPL v3](https://img.shields.io/badge/Licence-AGPL%20v3-blue.svg)](LICENSE)
[![Documentation](https://img.shields.io/badge/docs-loremind--docs-green)](https://loremind-docs.igmlcreation.fr/)
[![Démo](https://img.shields.io/badge/d%C3%A9mo-en%20ligne-orange)](https://loremind-demo.igmlcreation.fr/)
[![Patreon](https://img.shields.io/badge/Patreon-soutenir-red)](https://www.patreon.com/c/IGMLCreation)
[![Discord](https://img.shields.io/badge/Discord-rejoindre-5865F2)](https://discord.gg/cPpFzCjEzQ)
## Découvrir LoreMind en vidéo
[![Présentation LoreMind](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
![Tableau de bord](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
## Ce que ça fait
LoreMind regroupe ce qu'un MJ utilise habituellement éparpillé entre plusieurs outils. L'application s'articule autour de trois modules principaux, augmentés par un assistant IA qui exploite tout votre contenu.
### Lore
Construire votre univers avec une arborescence de pages templatées : lieux, factions, PNJ, événements, organisations... Chaque type de page suit un template configurable, ce qui garantit la cohérence et facilite la navigation dans des univers riches.
### Game System
Stocker les règles de votre système de jeu (D&D, Nimble, créations maison...) et définir les modèles de fiches de personnages associés. Les règles indexées peuvent être injectées dans le contexte de l'IA pour des réponses fidèles à votre système.
### Campaign
Structurer vos campagnes en Arcs → Chapitres → Scènes avec séparation claire du contenu MJ et du contenu joueurs. Gérer les PJ et PNJ via des fiches dynamiques basées sur les templates du game system retenu.
### Assistant IA
Un assistant contextuel qui pioche dans votre Lore, vos règles et vos campagnes pour répondre à vos questions, suggérer du contenu cohérent, ou rebondir sur une situation improvisée en table.
L'IA s'exécute **en local via [Ollama](https://ollama.com/)** ou via **[1min.ai](https://1min.ai/)**. D'autres moteurs seront supportés à l'avenir.
## Documentation
Toute la documentation (installation, configuration, prise en main) est sur **[loremind-docs.igmlcreation.fr](https://loremind-docs.igmlcreation.fr/)**.
## Démo en ligne
Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**.
Quelques limites à connaître :
- 10 utilisateurs maximum simultanés (instances isolées)
- Session limitée à 20 minutes avant réinitialisation
- Partie IA non incluse dans la démo (nécessite Ollama ou 1min.ai côté serveur)
## Soutenir le projet
LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien :
- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — accès anticipé aux features, vote sur la roadmap, devlogs exclusifs
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs
## Licence
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
En pratique :
- Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer.
- Si vous modifiez le code et que vous exposez l'application modifiée sur un réseau (même en SaaS privé), vous devez rendre vos modifications publiques sous la même licence.
- Les univers (Lore) et campagnes que vous créez avec LoreMind **vous appartiennent entièrement** — la licence ne couvre que le code de l'application.

View File

@@ -1,66 +1,68 @@
# LoreMind # LoreMind
> Application web auto-hébergeable pour MJ qui veulent centraliser leur univers, leurs campagnes et leurs personnages — avec un assistant IA contextuel. **English** · [Français](README.fr.md)
[![Licence: AGPL v3](https://img.shields.io/badge/Licence-AGPL%20v3-blue.svg)](LICENSE) > A self-hostable web app for game masters who want to centralize their world, campaigns and characters — with a context-aware AI assistant.
[![Documentation](https://img.shields.io/badge/docs-loremind--docs-green)](https://loremind-docs.igmlcreation.fr/)
[![Démo](https://img.shields.io/badge/d%C3%A9mo-en%20ligne-orange)](https://loremind-demo.igmlcreation.fr/)
[![Patreon](https://img.shields.io/badge/Patreon-soutenir-red)](https://www.patreon.com/c/IGMLCreation)
[![Discord](https://img.shields.io/badge/Discord-rejoindre-5865F2)](https://discord.gg/cPpFzCjEzQ)
## Découvrir LoreMind en vidéo [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
[![Documentation](https://img.shields.io/badge/docs-loremind--docs-green)](https://loremind-docs.igmlcreation.fr/en/)
[![Demo](https://img.shields.io/badge/demo-online-orange)](https://loremind-demo.igmlcreation.fr/)
[![Patreon](https://img.shields.io/badge/Patreon-support-red)](https://www.patreon.com/c/IGMLCreation)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2)](https://discord.gg/cPpFzCjEzQ)
[![Présentation LoreMind](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8) ## See LoreMind in action
![Tableau de bord](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png) [![LoreMind overview](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
## Ce que ça fait ![Dashboard](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
LoreMind regroupe ce qu'un MJ utilise habituellement éparpillé entre plusieurs outils. L'application s'articule autour de trois modules principaux, augmentés par un assistant IA qui exploite tout votre contenu. ## What it does
LoreMind brings together what a game master usually scatters across several tools. The app is built around three core modules, augmented by an AI assistant that draws on all of your content.
### Lore ### Lore
Construire votre univers avec une arborescence de pages templatées : lieux, factions, PNJ, événements, organisations... Chaque type de page suit un template configurable, ce qui garantit la cohérence et facilite la navigation dans des univers riches. Build your world with a tree of templated pages: locations, factions, NPCs, events, organizations... Each page type follows a configurable template, which keeps things consistent and makes navigating rich worlds easy.
### Game System ### Game System
Stocker les règles de votre système de jeu (D&D, Nimble, créations maison...) et définir les modèles de fiches de personnages associés. Les règles indexées peuvent être injectées dans le contexte de l'IA pour des réponses fidèles à votre système. Store the rules of your game system (D&D, Nimble, homebrew...) and define the matching character sheet templates. Indexed rules can be injected into the AI's context for answers that stay true to your system.
### Campaign ### Campaign
Structurer vos campagnes en Arcs → Chapitres → Scènes avec séparation claire du contenu MJ et du contenu joueurs. Gérer les PJ et PNJ via des fiches dynamiques basées sur les templates du game system retenu. Structure your campaigns as Arcs → Chapters → Scenes, with a clear split between GM-only and player-facing content. Manage PCs and NPCs through dynamic sheets based on your chosen game system's templates.
### Assistant IA ### AI Assistant
Un assistant contextuel qui pioche dans votre Lore, vos règles et vos campagnes pour répondre à vos questions, suggérer du contenu cohérent, ou rebondir sur une situation improvisée en table. A context-aware assistant that pulls from your Lore, rules and campaigns to answer your questions, suggest consistent content, or improvise around an unexpected situation at the table.
L'IA s'exécute **en local via [Ollama](https://ollama.com/)** ou via **[1min.ai](https://1min.ai/)**. D'autres moteurs seront supportés à l'avenir. The AI runs **locally via [Ollama](https://ollama.com/)** or via **[1min.ai](https://1min.ai/)**. More engines will be supported in the future.
## Documentation ## Documentation
Toute la documentation (installation, configuration, prise en main) est sur **[loremind-docs.igmlcreation.fr](https://loremind-docs.igmlcreation.fr/)**. The full documentation (installation, configuration, getting started) lives at **[loremind-docs.igmlcreation.fr/en](https://loremind-docs.igmlcreation.fr/en/)**.
## Démo en ligne ## Live demo
Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**. A demo instance is available at **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**.
Quelques limites à connaître : A few limitations to be aware of:
- 10 utilisateurs maximum simultanés (instances isolées) - 10 concurrent users maximum (isolated instances)
- Session limitée à 20 minutes avant réinitialisation - Sessions limited to 20 minutes before reset
- Partie IA non incluse dans la démo (nécessite Ollama ou 1min.ai côté serveur) - The AI part is not included in the demo (requires Ollama or 1min.ai server-side)
## Soutenir le projet ## Support the project
LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien : LoreMind is **and will remain free when self-hosted**. Development moves faster with your support:
- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — accès anticipé aux features, vote sur la roadmap, devlogs exclusifs - **[Patreon](https://www.patreon.com/c/IGMLCreation)** — early access to features, roadmap voting, exclusive devlogs
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs - **[Discord](https://discord.gg/cPpFzCjEzQ)** — announcements, support, user feedback
## Licence ## License
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**. LoreMind is distributed under the **[GNU AGPL v3](LICENSE)** license.
En pratique : In practice:
- Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer. - You can use it for free, host it, modify it, and redistribute it.
- Si vous modifiez le code et que vous exposez l'application modifiée sur un réseau (même en SaaS privé), vous devez rendre vos modifications publiques sous la même licence. - If you modify the code and expose the modified app over a network (even as a private SaaS), you must make your changes public under the same license.
- Les univers (Lore) et campagnes que vous créez avec LoreMind **vous appartiennent entièrement**la licence ne couvre que le code de l'application. - The worlds (Lore) and campaigns you create with LoreMind **belong entirely to you**the license only covers the application's code.

View File

@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.14.0-beta", version="0.15.0",
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

27
brain/run_local.py Normal file
View File

@@ -0,0 +1,27 @@
"""Point d'entree LOCAL du Brain (hors Docker).
Lance le serveur uvicorn sur 127.0.0.1:8000 — l'equivalent autonome de la
commande Docker `uvicorn app.main:app --host 0.0.0.0 --port 8000`, mais en
n'ecoutant QUE sur la boucle locale (mono-utilisateur, jamais expose au reseau).
Empaquete avec le Python *embeddable* officiel (signe par la PSF) dans
l'application de bureau : on evite ainsi tout executable "gele" type PyInstaller
que les antivirus prennent souvent pour un trojan (bootloader packe).
Le Core le lance via : python\\python.exe run_local.py
On insere le dossier de CE fichier dans sys.path pour que le package `app`
soit importable quel que soit le repertoire de travail (le Core fixe le cwd
ailleurs, sous ~/.loremind/brain, pour y ecrire le dossier data/).
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import uvicorn # noqa: E402
from app.main import app # noqa: E402
if __name__ == "__main__":
# host 127.0.0.1 : accessible uniquement depuis le Core sur la meme machine.
uvicorn.run(app, host="127.0.0.1", port=8000, log_level="info")

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.14.0-beta</version> <version>0.15.0</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>
@@ -60,11 +60,31 @@
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- H2 Database pour les tests --> <!-- Flyway : migrations de schema versionnees (remplace ddl-auto=update).
Un SEUL jeu de migrations en SQL PostgreSQL sert les deux bases :
- Postgres (Docker/serveur) nativement ;
- H2 (mode local-first) via MODE=PostgreSQL dans l'URL JDBC.
flyway-database-postgresql : module requis depuis Flyway 10 (DBs
externalisees du core). H2 reste supporte par flyway-core. -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<!-- H2 Database :
- tests (toujours) ;
- RUNTIME du profil "local" (mode local-first / jpackage) : base
fichier embarquee a la place de Postgres, donc le driver doit etre
sur le classpath d'execution. Scope runtime (jamais compile contre)
=> present a l'execution + tests, ~2,5 Mo inutilises cote Docker. -->
<dependency> <dependency>
<groupId>com.h2database</groupId> <groupId>com.h2database</groupId>
<artifactId>h2</artifactId> <artifactId>h2</artifactId>
<scope>test</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- Lombok (réduit le code boilerplate) --> <!-- Lombok (réduit le code boilerplate) -->
@@ -179,4 +199,50 @@
</plugin> </plugin>
</plugins> </plugins>
</build> </build>
<profiles>
<!-- =================================================================
Profil "desktop" : build local-first (application de bureau).
Active avec : mvn -Pdesktop package
Embarque le build Angular dans le jar (classpath:/static/) pour que
le Core serve lui-meme le front (cf. LocalWebConfig, profil Spring
"local"). Le build Docker normal (sans ce profil) reste une API pure :
le front y est servi par le conteneur nginx, donc rien n'est copie.
================================================================= -->
<profile>
<id>desktop</id>
<properties>
<!-- Sortie du `ng build` (builder browser) : web/dist/web. -->
<frontend.dist>${project.basedir}/../web/dist/web</frontend.dist>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-frontend</id>
<!-- Avant le repackage Spring Boot : on injecte le
front dans les classes compilees -> embarque
dans le fat jar sous /static. -->
<phase>prepare-package</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.outputDirectory}/static</outputDirectory>
<resources>
<resource>
<directory>${frontend.dist}</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project> </project>

View File

@@ -1,5 +1,6 @@
package com.loremind; package com.loremind;
import com.loremind.infrastructure.desktop.DesktopSingleInstance;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@@ -13,6 +14,23 @@ import org.springframework.scheduling.annotation.EnableScheduling;
public class LoreMindApplication { public class LoreMindApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(LoreMindApplication.class, args); // Mode bureau (profil "local") : garde-fou instance unique. Si l'app
// tourne deja, on ouvre juste le navigateur et on sort proprement (code 0)
// au lieu de demarrer un 2e serveur qui echouerait sur le verrou H2 — ce
// qui evite le trompeur « Failed to launch JVM » du launcher jpackage.
boolean local = DesktopSingleInstance.isLocalProfile(args);
if (local && !DesktopSingleInstance.tryAcquire()) {
DesktopSingleInstance.openAppInBrowser();
return;
}
SpringApplication app = new SpringApplication(LoreMindApplication.class);
if (local) {
// Mode bureau : on a besoin d'AWT (icone de la zone de notification,
// cf. SystemTrayManager). Spring Boot force headless=true par defaut,
// ce qui leverait HeadlessException — on le desactive ici. En mode
// serveur/Docker, on reste en headless (defaut), aucun impact.
app.setHeadless(false);
}
app.run(args);
} }
} }

View File

@@ -40,26 +40,33 @@ public class LicenseService {
private final LicenseRelay relay; private final LicenseRelay relay;
private final long gracePeriodSeconds; private final long gracePeriodSeconds;
private final long refreshBeforeExpirySeconds; private final long refreshBeforeExpirySeconds;
private final boolean licensingEnabled;
public LicenseService( public LicenseService(
LicenseRepository repository, LicenseRepository repository,
JwtVerifier jwtVerifier, JwtVerifier jwtVerifier,
LicenseRelay relay, LicenseRelay relay,
@Value("${licensing.grace-period-days:14}") int gracePeriodDays, @Value("${licensing.grace-period-days:14}") int gracePeriodDays,
@Value("${licensing.refresh-before-expiry-days:2}") int refreshBeforeExpiryDays) { @Value("${licensing.refresh-before-expiry-days:2}") int refreshBeforeExpiryDays,
@Value("${licensing.enabled:true}") boolean licensingEnabled) {
this.repository = repository; this.repository = repository;
this.jwtVerifier = jwtVerifier; this.jwtVerifier = jwtVerifier;
this.relay = relay; this.relay = relay;
this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L; this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L;
this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L; this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L;
this.licensingEnabled = licensingEnabled;
} }
/** /**
* @return true si le verifier est configure (cle publique presente). * @return true si le licensing Patreon est actif : il faut a la fois que la
* L'UI peut masquer toute la section Patreon si false. * feature soit activee ({@code licensing.enabled}, faux en mode
* bureau/local ou le gating par image Docker n'a aucun sens) ET que
* le verifier soit configure (cle publique presente). Faux => l'UI
* masque toute la section Patreon, le daemon de refresh est no-op,
* et le canal beta est desactive.
*/ */
public boolean isLicensingEnabled() { public boolean isLicensingEnabled() {
return jwtVerifier.isConfigured(); return licensingEnabled && jwtVerifier.isConfigured();
} }
/** /**

View File

@@ -0,0 +1,126 @@
package com.loremind.infrastructure.ai;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Profile;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
/**
* Lance le Brain (service IA Python) comme SOUS-PROCESSUS du Core, en mode
* local-first (application de bureau empaquetee, sans Docker).
* <p>
* Cycle de vie calque sur celui du Core :
* <ul>
* <li>demarrage : a {@link ApplicationReadyEvent} (le serveur HTTP du Core
* est deja pret) ;</li>
* <li>arret : a {@link PreDestroy} (fermeture du contexte Spring) — on arrete
* proprement le Brain pour ne pas laisser de process orphelin.</li>
* </ul>
* <p>
* Tolerance aux pannes : si le Brain ne peut pas etre lance (exe absent,
* commande non configuree...), on LOGGUE sans faire echouer le Core. L'app
* reste utilisable (Lore, Campagnes, Systeme de jeu) ; seules les fonctions IA
* sont indisponibles jusqu'a correction.
*/
@Component
@Profile("local")
public class BrainSidecar {
private static final Logger log = LoggerFactory.getLogger(BrainSidecar.class);
private final BrainSidecarProperties props;
private final String internalSecret;
private volatile Process process;
public BrainSidecar(BrainSidecarProperties props,
@Value("${brain.internal-secret:}") String internalSecret) {
this.props = props;
this.internalSecret = internalSecret;
}
@EventListener(ApplicationReadyEvent.class)
public void start() {
if (!props.isEnabled()) {
log.info("[Brain] Sidecar desactive (brain.sidecar.enabled=false).");
return;
}
if (props.getCommand() == null || props.getCommand().isEmpty()) {
log.warn("[Brain] Aucune commande configuree (brain.sidecar.command) : "
+ "le Brain n'est pas lance. Les fonctions IA seront indisponibles.");
return;
}
try {
ProcessBuilder pb = new ProcessBuilder(props.getCommand());
pb.redirectErrorStream(true);
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
File workingDir = resolveWorkingDir();
if (workingDir != null) {
pb.directory(workingDir);
}
// Secret partage Core <-> Brain : le Brain est fail-closed sans lui.
// (cf. Settings.internal_shared_secret cote Python -> env INTERNAL_SHARED_SECRET)
pb.environment().put("INTERNAL_SHARED_SECRET", internalSecret);
this.process = pb.start();
log.info("[Brain] Sidecar demarre (pid={}, cwd={}).",
process.pid(), workingDir != null ? workingDir : "<heritee>");
} catch (IOException e) {
log.error("[Brain] Echec du lancement du sidecar (commande={}). "
+ "Les fonctions IA seront indisponibles. Cause : {}",
props.getCommand(), e.getMessage());
}
}
@PreDestroy
public void stop() {
Process p = this.process;
if (p == null || !p.isAlive()) {
return;
}
log.info("[Brain] Arret du sidecar (pid={})...", p.pid());
p.destroy();
try {
if (!p.waitFor(10, TimeUnit.SECONDS)) {
log.warn("[Brain] Arret propre depasse (10s) : kill force.");
p.destroyForcibly();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
p.destroyForcibly();
}
}
/**
* Resout (et cree au besoin) le repertoire de travail du Brain. Le Brain y
* ecrit son dossier {@code data/} (index vectoriel, settings.json).
*/
private File resolveWorkingDir() {
String dir = props.getWorkingDir();
if (dir == null || dir.isBlank()) {
return null; // herite du cwd du Core
}
Path path = Path.of(dir).toAbsolutePath().normalize();
try {
Files.createDirectories(path);
} catch (IOException e) {
log.warn("[Brain] Impossible de creer le repertoire de travail {} : {}. "
+ "Lancement avec le cwd herite.", path, e.getMessage());
return null;
}
return path.toFile();
}
}

View File

@@ -0,0 +1,53 @@
package com.loremind.infrastructure.ai;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Configuration du lancement du Brain (service IA Python) en SIDECAR, c.-a-d.
* comme sous-processus du Core, en mode local-first.
* <p>
* En deploiement Docker, le Brain est un conteneur independant : ce mecanisme
* est inactif (profil {@code local} uniquement). En application de bureau
* empaquetee, il n'y a pas de Docker : le Core demarre lui-meme le Brain.
*
* @see BrainSidecar
*/
@Component
@Profile("local")
@ConfigurationProperties(prefix = "brain.sidecar")
public class BrainSidecarProperties {
/** Active le lancement du Brain par le Core. */
private boolean enabled = false;
/**
* Commande de lancement (programme + arguments). Vide = ne rien lancer
* (cas du dev qui demarre le Brain a la main).
* <ul>
* <li>Mode empaquete (jpackage) : chemin de l'exe PyInstaller, ex.
* {@code C:\Program Files\LoreMind\brain\loremind-brain.exe}</li>
* <li>Dev : {@code python,-m,uvicorn,app.main:app,--host,127.0.0.1,--port,8000}</li>
* </ul>
*/
private List<String> command = List.of();
/**
* Repertoire de travail du process Brain. Le Brain ecrit ses donnees (index
* vectoriel, settings.json) sous {@code data/} RELATIF a ce dossier : on le
* place donc sous loremind.home pour que tout vive au meme endroit.
*/
private String workingDir;
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public List<String> getCommand() { return command; }
public void setCommand(List<String> command) { this.command = command; }
public String getWorkingDir() { return workingDir; }
public void setWorkingDir(String workingDir) { this.workingDir = workingDir; }
}

View File

@@ -0,0 +1,31 @@
package com.loremind.infrastructure.desktop;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Profile;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
/**
* En mode bureau (profil "local"), ouvre le navigateur par defaut sur
* l'application des que le serveur est pret. L'app n'ayant pas de fenetre
* native, c'est ce qui donne a l'utilisateur un retour visuel immediat apres
* le double-clic.
* <p>
* Concerne uniquement l'instance qui a effectivement demarre le serveur :
* l'instance « perdante » du verrou unique ouvre le navigateur des le {@code main}
* puis sort (cf. {@link DesktopSingleInstance}).
*/
@Component
@Profile("local")
public class DesktopBrowserOpener {
private static final Logger log = LoggerFactory.getLogger(DesktopBrowserOpener.class);
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
log.info("[Desktop] Application prete — ouverture du navigateur.");
DesktopSingleInstance.openAppInBrowser();
}
}

View File

@@ -0,0 +1,113 @@
package com.loremind.infrastructure.desktop;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
/**
* Utilitaires du mode BUREAU (profil "local", application empaquetee jpackage).
* <p>
* Resout deux problemes specifiques au lancement par double-clic :
* <ol>
* <li><b>Instance unique</b> : un serveur web n'ouvre pas de fenetre. Un
* utilisateur qui ne voit rien re-double-clique souvent — la 2e instance
* trouvait la base H2 verrouillee et sortait en erreur, ce que le launcher
* jpackage traduit par un trompeur « Failed to launch JVM ». On detecte
* donc tres tot (avant Spring) qu'une instance tourne deja, et on se
* contente d'ouvrir le navigateur puis de sortir proprement (code 0).</li>
* <li><b>Ouverture du navigateur</b> : l'app n'ayant pas de fenetre native,
* on ouvre le navigateur par defaut sur l'URL locale pour que l'utilisateur
* voie l'application immediatement.</li>
* </ol>
* Volontairement sans dependance a {@code java.awt.Desktop} : ce module
* ({@code java.desktop}) pourrait etre absent du runtime reduit par jlink.
* On passe donc par la commande systeme d'ouverture d'URL.
*/
public final class DesktopSingleInstance {
/** Conserve le verrou ouvert pour TOUTE la duree de vie du process (sinon GC = relache). */
@SuppressWarnings("unused")
private static FileChannel lockChannel;
private static FileLock lock;
private DesktopSingleInstance() {}
/** Vrai si le profil Spring actif inclut "local" (cas de l'app de bureau). */
public static boolean isLocalProfile(String[] args) {
String prop = System.getProperty("spring.profiles.active", "");
String env = System.getenv().getOrDefault("SPRING_PROFILES_ACTIVE", "");
if (containsLocal(prop) || containsLocal(env)) return true;
if (args != null) {
for (String a : args) {
if (a.startsWith("--spring.profiles.active=") && containsLocal(a)) return true;
}
}
return false;
}
private static boolean containsLocal(String s) {
for (String p : s.split("[,=]")) {
if (p.trim().equals("local")) return true;
}
return false;
}
/**
* Tente de prendre le verrou d'instance unique (fichier {@code .instance.lock}
* sous loremind.home). Retourne {@code true} si on est la PREMIERE instance
* (verrou obtenu, on doit demarrer le serveur), {@code false} si une autre
* instance le detient deja.
* <p>
* En cas d'erreur d'E/S inattendue, on retourne {@code true} (degradation
* prudente : mieux vaut tenter de demarrer que bloquer l'app).
*/
public static boolean tryAcquire() {
try {
Path dir = loremindHome();
Files.createDirectories(dir);
Path lockFile = dir.resolve(".instance.lock");
lockChannel = FileChannel.open(lockFile,
StandardOpenOption.CREATE, StandardOpenOption.WRITE);
lock = lockChannel.tryLock();
return lock != null; // null = deja verrouille par une autre instance
} catch (IOException e) {
System.err.println("[Desktop] Verrou d'instance indisponible (" + e.getMessage()
+ ") — on tente de demarrer quand meme.");
return true;
}
}
/** Ouvre le navigateur par defaut sur l'URL de l'application locale. */
public static void openAppInBrowser() {
openUrl("http://localhost:" + System.getProperty("server.port", "8080") + "/");
}
/** Ouvre le navigateur par defaut sur une URL quelconque (sans dependance AWT). */
public static void openUrl(String url) {
try {
String os = System.getProperty("os.name", "").toLowerCase();
ProcessBuilder pb;
if (os.contains("win")) {
// rundll32 : ouverture d'URL fiable sans dependance graphique Java.
pb = new ProcessBuilder("rundll32", "url.dll,FileProtocolHandler", url);
} else if (os.contains("mac")) {
pb = new ProcessBuilder("open", url);
} else {
pb = new ProcessBuilder("xdg-open", url);
}
pb.start();
} catch (IOException e) {
System.err.println("[Desktop] Impossible d'ouvrir le navigateur sur " + url
+ " : " + e.getMessage() + ". Ouvrez-le manuellement.");
}
}
private static Path loremindHome() {
String home = System.getProperty("loremind.home");
if (home != null && !home.isBlank()) return Path.of(home);
return Path.of(System.getProperty("user.home"), ".loremind");
}
}

View File

@@ -0,0 +1,129 @@
package com.loremind.infrastructure.desktop;
import com.fasterxml.jackson.databind.JsonNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.info.BuildProperties;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import java.util.Optional;
/**
* Verification des mises a jour pour l'application de BUREAU (profil "local").
* <p>
* Contrairement au mode Docker (registry + Watchtower, cf.
* {@link com.loremind.infrastructure.updates.UpdateCheckService}), il n'y a pas
* de mise a jour automatique : on interroge l'API <b>GitHub Releases</b> pour la
* derniere release STABLE, on compare a la version courante du binaire, et si
* une version plus recente existe on le signale (via l'icone systray, cf.
* {@link SystemTrayManager}). L'utilisateur telecharge puis lance le nouvel
* installeur (MSI de meme UpgradeCode = mise a jour en place).
* <p>
* {@code /releases/latest} ne renvoie que les releases stables (pas les
* prereleases) : les utilisateurs stables ne sont donc pas notifies des betas.
*/
@Service
@Profile("local")
public class DesktopUpdateService {
private static final Logger log = LoggerFactory.getLogger(DesktopUpdateService.class);
private final RestTemplate http;
private final boolean enabled;
private final String releasesApiUrl;
/** Version semver du binaire courant (ex: "0.14.0"), ou null en dev sans build-info. */
private final String currentVersion;
public DesktopUpdateService(
RestTemplateBuilder builder,
@Value("${desktop.update.enabled:true}") boolean enabled,
@Value("${desktop.update.releases-api-url:https://api.github.com/repos/IGMLcreation/LoreMind/releases/latest}") String releasesApiUrl,
@Nullable BuildProperties buildProperties) {
this.http = builder
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(10))
.build();
this.enabled = enabled;
this.releasesApiUrl = releasesApiUrl;
this.currentVersion = buildProperties != null ? buildProperties.getVersion() : null;
}
/**
* Interroge GitHub Releases. Retourne les infos de mise a jour SI une version
* plus recente que la version courante existe, sinon {@code Optional.empty()}
* (a jour, desactive, ou verification impossible — jamais d'exception propagee).
*/
public Optional<UpdateInfo> checkForUpdate() {
if (!enabled || currentVersion == null) {
return Optional.empty();
}
try {
HttpHeaders headers = new HttpHeaders();
// GitHub exige un User-Agent ; l'Accept versionne l'API.
headers.set(HttpHeaders.USER_AGENT, "LoreMind-Desktop");
headers.set(HttpHeaders.ACCEPT, "application/vnd.github+json");
ResponseEntity<JsonNode> resp = http.exchange(
releasesApiUrl, HttpMethod.GET, new HttpEntity<>(headers), JsonNode.class);
JsonNode body = resp.getBody();
if (body == null || body.path("tag_name").isMissingNode()) {
return Optional.empty();
}
String tag = body.path("tag_name").asText(""); // ex: "v0.15.0"
String releaseUrl = body.path("html_url").asText(null); // page de la release
String latest = tag.startsWith("v") ? tag.substring(1) : tag;
if (!latest.isBlank() && compareSemver(currentVersion, latest) < 0) {
log.info("[Update] Nouvelle version disponible : {} (courante : {})", latest, currentVersion);
return Optional.of(new UpdateInfo(currentVersion, latest, releaseUrl));
}
log.info("[Update] A jour (courante : {}, derniere release : {}).", currentVersion, latest);
return Optional.empty();
} catch (Exception e) {
// Hors-ligne, rate-limit GitHub, etc. : non bloquant, on ne notifie juste pas.
log.info("[Update] Verification GitHub Releases impossible : {}", e.getMessage());
return Optional.empty();
}
}
/** Infos d'une mise a jour disponible. */
public record UpdateInfo(String currentVersion, String latestVersion, String releaseUrl) {}
/**
* Compare deux versions MAJOR.MINOR.PATCH (suffixe -beta/-rc ignore).
* @return &lt;0 si a&lt;b, 0 si egales, &gt;0 si a&gt;b.
*/
static int compareSemver(String a, String b) {
int[] va = parse(a);
int[] vb = parse(b);
for (int i = 0; i < 3; i++) {
int cmp = Integer.compare(va[i], vb[i]);
if (cmp != 0) return cmp;
}
return 0;
}
private static int[] parse(String version) {
String core = version.split("[-+]", 2)[0]; // retire -beta, -rc, +build...
String[] parts = core.split("\\.");
int[] out = new int[3];
for (int i = 0; i < 3 && i < parts.length; i++) {
try {
out[i] = Integer.parseInt(parts[i].trim());
} catch (NumberFormatException ignored) {
out[i] = 0;
}
}
return out;
}
}

View File

@@ -0,0 +1,154 @@
package com.loremind.infrastructure.desktop;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Profile;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.MenuItem;
import java.awt.PopupMenu;
import java.awt.RenderingHints;
import java.awt.SystemTray;
import java.awt.TrayIcon;
import java.awt.image.BufferedImage;
/**
* Icone dans la zone de notification (barre des taches) en mode bureau
* (profil "local"). Donne a l'utilisateur un controle visible de l'application,
* qui tourne sinon en serveur sans fenetre : impossible autrement de la fermer
* proprement (fermer l'onglet du navigateur laisse le Core et le Brain tourner).
* <p>
* Menu : « Ouvrir LoreMind » (ouvre le navigateur) et « Quitter LoreMind »
* (ferme le contexte Spring — ce qui declenche le {@code @PreDestroy} de
* {@link com.loremind.infrastructure.ai.BrainSidecar} et arrete donc aussi le
* Brain — puis termine le process).
* <p>
* Necessite que le mode headless soit desactive (cf. LoreMindApplication.main,
* qui appelle {@code setHeadless(false)} en profil local). Le module
* {@code java.desktop} est embarque dans le runtime jpackage.
*/
@Component
@Profile("local")
public class SystemTrayManager {
private static final Logger log = LoggerFactory.getLogger(SystemTrayManager.class);
private final ConfigurableApplicationContext context;
private final DesktopUpdateService updateService;
private TrayIcon trayIcon;
private PopupMenu popup;
public SystemTrayManager(ConfigurableApplicationContext context,
DesktopUpdateService updateService) {
this.context = context;
this.updateService = updateService;
}
@EventListener(ApplicationReadyEvent.class)
public void install() {
if (!SystemTray.isSupported()) {
log.warn("[Tray] Zone de notification non supportee sur ce systeme — "
+ "pas d'icone. Pour quitter : menu de la fenetre console, ou gestionnaire des taches.");
return;
}
try {
popup = new PopupMenu();
MenuItem open = new MenuItem("Ouvrir LoreMind");
open.addActionListener(e -> DesktopSingleInstance.openAppInBrowser());
popup.add(open);
popup.addSeparator();
MenuItem quit = new MenuItem("Quitter LoreMind");
quit.addActionListener(e -> quit());
popup.add(quit);
trayIcon = new TrayIcon(createIcon(), "LoreMind", popup);
trayIcon.setImageAutoSize(true);
// Double-clic sur l'icone : ouvre l'application dans le navigateur.
trayIcon.addActionListener(e -> DesktopSingleInstance.openAppInBrowser());
SystemTray.getSystemTray().add(trayIcon);
log.info("[Tray] Icone installee dans la zone de notification.");
// Verification de mise a jour en arriere-plan (appel reseau GitHub) :
// ne bloque pas le demarrage ; met a jour le menu/notifie si dispo.
new Thread(this::checkForUpdate, "loremind-update-check").start();
} catch (Exception e) {
// Echec non bloquant : l'app reste utilisable, seul le confort de l'icone manque.
log.warn("[Tray] Installation de l'icone impossible : {}", e.getMessage());
}
}
/**
* Interroge GitHub Releases ; si une version plus recente existe, ajoute un
* item de menu « Telecharger » et affiche une bulle de notification. L'item
* ouvre la page de la release dans le navigateur (telechargement manuel du
* nouvel installeur).
*/
private void checkForUpdate() {
updateService.checkForUpdate().ifPresent(info -> {
String label = "⬇ Telecharger la mise a jour (v" + info.latestVersion() + ")";
MenuItem update = new MenuItem(label);
update.addActionListener(e -> DesktopSingleInstance.openUrl(info.releaseUrl()));
// En tete de menu pour la visibilite, suivi d'un separateur.
popup.insert(update, 0);
popup.insertSeparator(1);
trayIcon.displayMessage(
"LoreMind — mise a jour disponible",
"Version " + info.latestVersion() + " disponible (vous avez " + info.currentVersion()
+ "). Menu de l'icone → Telecharger.",
TrayIcon.MessageType.INFO);
});
}
/**
* Arret propre depuis le menu « Quitter » : on retire l'icone puis on ferme
* le contexte Spring dans un thread dedie (l'action s'execute sur l'EDT AWT ;
* fermer le contexte + arreter Tomcat/Brain depuis l'EDT pourrait le bloquer).
*/
private void quit() {
log.info("[Tray] Demande de fermeture de l'application.");
new Thread(() -> {
int code = SpringApplication.exit(context, () -> 0);
System.exit(code);
}, "loremind-shutdown").start();
}
/** Retire l'icone si le contexte se ferme par une autre voie (ex. Ctrl+C). */
@PreDestroy
public void remove() {
if (trayIcon != null) {
SystemTray.getSystemTray().remove(trayIcon);
}
}
/**
* Genere une petite icone (carre arrondi violet « L ») sans dependre d'un
* fichier image — robuste quel que soit l'empaquetage.
*/
private Image createIcon() {
int size = 16;
BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(new Color(0x8B, 0x5C, 0xF6)); // violet de marque LoreMind
g.fillRoundRect(0, 0, size, size, 5, 5);
g.setColor(Color.WHITE);
g.setFont(new Font("SansSerif", Font.BOLD, 12));
g.drawString("L", 4, 13);
g.dispose();
return img;
}
}

View File

@@ -0,0 +1,112 @@
package com.loremind.infrastructure.storage;
import com.loremind.domain.images.ports.ImageStorage;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
/**
* Adaptateur d'infrastructure : implemente le port ImageStorage en stockant
* les binaires sur le SYSTEME DE FICHIERS local.
* <p>
* Pendant a {@link MinioImageStorageAdapter} pour le mode "local-first"
* (application de bureau empaquetee via jpackage, sans Docker ni MinIO).
* Active uniquement quand {@code storage.backend=filesystem} ; en l'absence
* de cette propriete, c'est l'adaptateur MinIO qui prend le relais (defaut).
* <p>
* On reutilise EXACTEMENT le meme schema de cle que MinIO ({@code images/UUID.ext})
* pour que les cles restent interchangeables entre les deux backends : une base
* migree de l'un vers l'autre continue de fonctionner sans reecriture.
*/
@Component
@ConditionalOnProperty(name = "storage.backend", havingValue = "filesystem")
public class FilesystemImageStorageAdapter implements ImageStorage {
private final Path root;
public FilesystemImageStorageAdapter(@Value("${storage.filesystem.path}") String basePath) {
this.root = Path.of(basePath).toAbsolutePath().normalize();
}
@PostConstruct
void ensureRootExists() {
try {
Files.createDirectories(root);
System.out.println("[Storage] Backend filesystem actif — racine : " + root);
} catch (IOException e) {
throw new UncheckedIOException("Impossible de creer le dossier de stockage : " + root, e);
}
}
@Override
public String upload(String filename, String contentType, InputStream data, long sizeBytes) {
String storageKey = generateStorageKey(filename);
Path target = resolveKey(storageKey);
try {
Files.createDirectories(target.getParent());
Files.copy(data, target);
return storageKey;
} catch (IOException e) {
throw new UncheckedIOException("Echec de l'ecriture de l'image sur disque : " + target, e);
}
}
@Override
public InputStream download(String storageKey) {
Path source = resolveKey(storageKey);
if (!Files.exists(source)) {
// Cle orpheline : meme contrat que MinIO (null plutot qu'exception).
return null;
}
try {
return Files.newInputStream(source);
} catch (IOException e) {
throw new UncheckedIOException("Echec de la lecture de l'image : " + source, e);
}
}
@Override
public void delete(String storageKey) {
try {
Files.deleteIfExists(resolveKey(storageKey));
} catch (IOException e) {
// Suppression idempotente : on loggue mais on ne propage pas (cf. MinIO).
System.err.println("[Storage] Erreur suppression (non bloquante) : " + e.getMessage());
}
}
/**
* Resout une cle opaque en chemin physique, en se premunissant contre la
* traversee de repertoire : le chemin resolu DOIT rester sous {@code root}
* (une cle malveillante du type {@code ../../etc/passwd} est rejetee).
*/
private Path resolveKey(String storageKey) {
Path resolved = root.resolve(storageKey).normalize();
if (!resolved.startsWith(root)) {
throw new IllegalArgumentException("Cle de stockage invalide (hors racine) : " + storageKey);
}
return resolved;
}
/** Identique a MinioImageStorageAdapter : cle unique + extension d'origine. */
private String generateStorageKey(String originalFilename) {
return "images/" + UUID.randomUUID() + extractExtension(originalFilename);
}
private String extractExtension(String filename) {
if (filename == null) return "";
int dot = filename.lastIndexOf('.');
if (dot < 0 || dot == filename.length() - 1) return "";
String ext = filename.substring(dot).toLowerCase();
// On n'accepte que les extensions connues pour eviter les injections de path.
return ext.matches("\\.(jpg|jpeg|png|webp|gif)") ? ext : "";
}
}

View File

@@ -5,6 +5,7 @@ import io.minio.MakeBucketArgs;
import io.minio.MinioClient; import io.minio.MinioClient;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -14,8 +15,13 @@ import org.springframework.context.annotation.Configuration;
* Expose un bean MinioClient singleton injecte dans MinioImageStorageAdapter. * Expose un bean MinioClient singleton injecte dans MinioImageStorageAdapter.
* S'assure au demarrage que le bucket configure existe (filet de securite : * S'assure au demarrage que le bucket configure existe (filet de securite :
* normalement docker-compose/minio-init l'a deja cree). * normalement docker-compose/minio-init l'a deja cree).
* <p>
* Desactive en mode local-first ({@code storage.backend=filesystem}) : aucun
* client MinIO n'est alors instancie, donc aucune tentative de connexion au
* boot. Defaut = actif (propriete absente ou {@code minio}).
*/ */
@Configuration @Configuration
@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true)
public class MinioConfig { public class MinioConfig {
@Value("${minio.endpoint}") @Value("${minio.endpoint}")

View File

@@ -7,6 +7,7 @@ import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs; import io.minio.RemoveObjectArgs;
import io.minio.errors.ErrorResponseException; import io.minio.errors.ErrorResponseException;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.io.InputStream; import java.io.InputStream;
@@ -17,8 +18,13 @@ import java.util.UUID;
* MinIO (compatible S3) comme backend de stockage d'objets. * MinIO (compatible S3) comme backend de stockage d'objets.
* <p> * <p>
* Le domaine ne sait rien de MinIO : il manipule juste des cles opaques. * Le domaine ne sait rien de MinIO : il manipule juste des cles opaques.
* <p>
* Backend par defaut ({@code storage.backend=minio} ou propriete absente).
* Le mode local-first le remplace par {@link FilesystemImageStorageAdapter}
* via {@code storage.backend=filesystem}.
*/ */
@Component @Component
@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true)
public class MinioImageStorageAdapter implements ImageStorage { public class MinioImageStorageAdapter implements ImageStorage {
private final MinioClient minioClient; private final MinioClient minioClient;

View File

@@ -0,0 +1,86 @@
package com.loremind.infrastructure.web.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;
import java.io.IOException;
/**
* Service du front Angular en statique par le Core, en mode local-first.
* <p>
* En deploiement Docker, le front est servi par un conteneur nginx dedie
* (service {@code web}) et le Core reste une API pure. En mode local
* (application de bureau empaquetee), il n'y a pas de nginx : le build Angular
* est copie dans {@code classpath:/static/} (cf. profil Maven de packaging) et
* le Core le sert lui-meme sur la meme origine que l'API.
* <p>
* Active uniquement sous le profil {@code local} pour ne RIEN changer au
* comportement du conteneur Core en production.
* <p>
* Fallback SPA : toute route qui ne correspond pas a un fichier statique reel
* (et qui n'est pas une route d'API) renvoie {@code index.html}, afin que le
* routing cote Angular (deep links, rechargement de page) fonctionne.
*/
@Configuration
@Profile("local")
public class LocalWebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Fichiers de traduction (assets/i18n/*.json) : nom STABLE (non hashe),
// donc jamais mis en cache, sinon le navigateur ressert un JSON perime
// (voire un 304 sur un corps en cache obsolete) apres une mise a jour.
// Symptome observe : les libelles restent en cles brutes pour une langue.
// Motif plus specifique que "/**" => prioritaire pour ces chemins.
registry.addResourceHandler("/assets/i18n/**")
.addResourceLocations("classpath:/static/assets/i18n/")
.setCacheControl(CacheControl.noStore());
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/")
.resourceChain(true)
.addResolver(new PathResourceResolver() {
@Override
protected Resource getResource(String resourcePath, Resource location) throws IOException {
Resource requested = location.createRelative(resourcePath);
if (requested.exists() && requested.isReadable()) {
return requested;
}
// Ne JAMAIS rabattre les routes techniques sur index.html :
// une API inexistante doit rester un 404, pas du HTML.
if (resourcePath.startsWith("api/") || resourcePath.startsWith("actuator/")) {
return null;
}
// Un ASSET manquant (chemin avec extension : .json, .js, .css,
// .png...) doit renvoyer 404 — surtout PAS index.html. Sinon un
// loader JSON (ex. ngx-translate chargeant assets/i18n/fr.json)
// recevrait du HTML en 200 et echouerait au parse, donnant des
// cles brutes a l'ecran. On ne rabat sur la coquille SPA que les
// vraies routes applicatives (sans extension de fichier).
if (hasFileExtension(resourcePath)) {
return null;
}
// Route applicative Angular -> on sert la coquille SPA.
Resource index = new ClassPathResource("/static/index.html");
return index.exists() ? index : null;
}
});
}
/**
* Vrai si le dernier segment du chemin contient un point (donc une extension
* de fichier : {@code assets/i18n/fr.json}, {@code main.js}...). Les routes
* applicatives Angular ({@code settings}, {@code campaigns/42}) n'en ont pas.
*/
private static boolean hasFileExtension(String resourcePath) {
int lastSlash = resourcePath.lastIndexOf('/');
String lastSegment = resourcePath.substring(lastSlash + 1);
return lastSegment.contains(".");
}
}

View File

@@ -0,0 +1,86 @@
# ============================================================================
# Profil "local" — mode local-first (application de bureau, sans Docker)
# ============================================================================
# Active via : --spring.profiles.active=local (ou SPRING_PROFILES_ACTIVE=local)
#
# Objectif : faire tourner le Core sur le poste de l'utilisateur SANS aucune
# infrastructure externe (ni Postgres, ni MinIO, ni Docker). Toutes les donnees
# vivent sous loremind.home (defaut : ~/.loremind).
#
# Surcharge UNIQUEMENT ce qui differe du profil par defaut (application.properties) :
# le reste (timeouts, multipart, licensing...) est herite tel quel.
# --- Base de donnees : H2 en mode fichier, compatibilite PostgreSQL ---------
# MODE=PostgreSQL : H2 interprete le SQL PostgreSQL -> les MEMES migrations
# Flyway (ecrites en SQL Postgres) tournent ici comme sur la prod Postgres.
# Aligne aussi la casse des identifiants (minuscules, comme PG).
# DB_CLOSE_ON_EXIT=FALSE : Spring/Hikari pilote la fermeture (pas le shutdown
# hook H2). AUTO_SERVER volontairement absent (interdit avec DB_CLOSE_ON_EXIT,
# et inutile pour un process unique).
# NON_KEYWORDS=VALUE : PostgreSQL accepte `value` comme nom de colonne non-quote
# (colonne de playthrough_flag), mais H2 le reserve par defaut -> on le relache
# pour que le baseline SQL Postgres (non-quote) passe aussi sur H2. Ajouter
# d'autres mots ici si une migration future utilise un identifiant reserve H2.
spring.datasource.url=jdbc:h2:file:${loremind.home}/db/loremind;MODE=PostgreSQL;DB_CLOSE_ON_EXIT=FALSE;NON_KEYWORDS=VALUE
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
# Schema gere par Flyway (cf. application.properties) ; Hibernate valide seulement.
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
# --- Stockage des images : systeme de fichiers -----------------------------
storage.backend=filesystem
storage.filesystem.path=${loremind.home}/images
# --- Brain : service IA Python lance en sidecar sur le poste ---------------
brain.base-url=http://localhost:8000
# Secret partage Core <-> Brain. En local il n'a pas de role securitaire fort
# (tout est sur localhost, mono-utilisateur) mais le Brain est fail-closed :
# il DOIT etre defini des deux cotes. Le lanceur de sidecar (BrainSidecar)
# propage cette meme valeur au process Brain via la variable BRAIN_INTERNAL_SECRET.
brain.internal-secret=${BRAIN_INTERNAL_SECRET:loremind-local-secret}
# --- Brain en sidecar (lance par le Core) ----------------------------------
# Le Core demarre lui-meme le process Brain (pas de Docker en local).
brain.sidecar.enabled=${BRAIN_SIDECAR_ENABLED:true}
# Le Brain ecrit son dossier data/ (index vectoriel, settings) sous ce dossier.
brain.sidecar.working-dir=${loremind.home}/brain
# Commande de lancement. VIDE par defaut : le dev qui lance le Brain a la main
# n'est pas perturbe. Le packaging (jpackage) la renseigne vers l'exe PyInstaller.
# - Empaquete : chemin de l'exe, ex. C:/Program Files/LoreMind/brain/loremind-brain.exe
# - Dev : python,-m,uvicorn,app.main:app,--host,127.0.0.1,--port,8000
# (avec brain.sidecar.working-dir pointant sur le dossier brain/)
brain.sidecar.command=${BRAIN_SIDECAR_COMMAND:}
# --- Admin (localhost uniquement) ------------------------------------------
# Application mono-utilisateur sur le poste : l'utilisateur EST l'admin.
# Identifiants par defaut surchargables ; non exposes hors de la machine.
admin.username=${ADMIN_USERNAME:admin}
admin.password=${ADMIN_PASSWORD:admin}
# --- Front servi par le Core (meme origine) --------------------------------
# Le front Angular est servi en statique par le Core (cf. LocalWebConfig),
# donc tout est sur http://localhost:8080 : CORS sans objet, mais on autorise
# l'origine locale par securite si un dev lance Angular separement.
spring.web.cors.allowed-origins=http://localhost:8080,http://localhost:4200
# Mode demo desactive (instance personnelle complete).
app.demo-mode=false
# --- Licensing / Patreon : desactive en mode bureau ------------------------
# Le gating par IMAGE Docker (canal beta tire d'un registry prive) n'a aucun
# sens hors Docker. On coupe toute la machinerie : daemon de refresh no-op,
# /api/license/* renvoie enabled=false, et l'UI masque la section Patreon.
# La distribution beta desktop se fait par installeur beta via Patreon, pas
# par une connexion in-app.
licensing.enabled=false
# --- Verification des mises a jour (bureau) --------------------------------
# Interroge l'API GitHub Releases (derniere release STABLE) au demarrage ; si
# une version plus recente existe, l'icone systray le signale (bulle + item
# « Telecharger »). Pas de mise a jour auto : l'utilisateur telecharge et lance
# le nouvel installeur. Mettre a false pour desactiver toute requete sortante.
desktop.update.enabled=${DESKTOP_UPDATE_CHECK:true}
desktop.update.releases-api-url=https://api.github.com/repos/IGMLcreation/LoreMind/releases/latest

View File

@@ -22,10 +22,26 @@ spring.datasource.driver-class-name=org.postgresql.Driver
# Configuration JPA / Hibernate # Configuration JPA / Hibernate
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=update # Le schema est desormais gere par Flyway (migrations versionnees), plus par
# Hibernate. ddl-auto=validate : Hibernate ne MODIFIE plus le schema, il verifie
# juste au demarrage que les entites correspondent au schema cree par Flyway
# (filet de securite contre les derives entites<->migrations).
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=true spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true spring.jpa.properties.hibernate.format_sql=true
# ============================================================================
# Flyway : migrations de schema versionnees (src/main/resources/db/migration).
# ============================================================================
# baseline-on-migrate : sur une base EXISTANTE (prod deja peuplee, sans table
# d'historique Flyway), Flyway l'estampille a la version baseline SANS rejouer
# V1 -> les donnees existantes sont preservees. Sur une base VIDE (install
# neuve), Flyway joue V1__baseline.sql normalement pour creer le schema.
spring.flyway.enabled=true
spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=1
spring.flyway.baseline-description=Baseline (schema pre-Flyway, gere jusque-la par ddl-auto)
# Configuration CORS pour autoriser le Frontend Angular # Configuration CORS pour autoriser le Frontend Angular
spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:4200} spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:4200}
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
@@ -48,6 +64,17 @@ brain.internal-secret=${BRAIN_INTERNAL_SECRET:}
admin.username=${ADMIN_USERNAME:admin} admin.username=${ADMIN_USERNAME:admin}
admin.password=${ADMIN_PASSWORD:} admin.password=${ADMIN_PASSWORD:}
# Repertoire de base de l'instance (donnees locales hors Docker).
# Utilise par le profil "local" (H2 fichier, stockage images filesystem...).
loremind.home=${LOREMIND_HOME:${user.home}/.loremind}
# Backend de stockage des binaires d'images (port ImageStorage) :
# - minio : MinIO/S3 (defaut — deploiement Docker/serveur)
# - filesystem : systeme de fichiers local (mode local-first / jpackage)
storage.backend=${STORAGE_BACKEND:minio}
# Racine du stockage filesystem (ignoree si storage.backend=minio).
storage.filesystem.path=${STORAGE_FS_PATH:${loremind.home}/images}
# Configuration MinIO (Shared Kernel images - Object Storage) # Configuration MinIO (Shared Kernel images - Object Storage)
# Le bucket est cree automatiquement par le service minio-init (docker-compose up -d). # Le bucket est cree automatiquement par le service minio-init (docker-compose up -d).
# Defaults OK pour dev local ; overrides en prod via env. # Defaults OK pour dev local ; overrides en prod via env.

View File

@@ -0,0 +1,424 @@
create table arcs (
"order" integer not null,
campaign_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
type varchar(16) default 'LINEAR' not null check (type in ('LINEAR','HUB')),
description TEXT,
gm_notes TEXT,
icon varchar(255),
illustration_image_ids TEXT,
map_image_ids TEXT,
name varchar(255) not null,
related_page_ids TEXT,
resolution TEXT,
rewards TEXT,
stakes TEXT,
themes TEXT,
primary key (id)
);
create table campaigns (
arcs_count integer not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
description TEXT,
game_system_id varchar(255),
lore_id varchar(255),
name varchar(255) not null,
primary key (id)
);
create table catalog_items (
position integer not null,
catalog_id bigint not null,
id bigint generated by default as identity,
price varchar(64),
category varchar(128),
description TEXT,
name varchar(255) not null,
primary key (id)
);
create table chapters (
"order" integer not null,
arc_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
description TEXT,
gm_notes TEXT,
icon varchar(255),
illustration_image_ids TEXT,
map_image_ids TEXT,
name varchar(255) not null,
narrative_stakes TEXT,
player_objectives TEXT,
prerequisites TEXT,
related_page_ids TEXT,
primary key (id)
);
create table characters (
"order" integer not null,
campaign_id bigint,
created_at timestamp(6) not null,
id bigint generated by default as identity,
playthrough_id bigint,
updated_at timestamp(6) not null,
field_values TEXT,
header_image_id varchar(255),
image_values TEXT,
key_value_values TEXT,
name varchar(255) not null,
portrait_image_id varchar(255),
primary key (id)
);
create table conversation_messages (
conversation_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
role varchar(16) not null,
content TEXT not null,
primary key (id)
);
create table conversations (
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
campaign_id varchar(255),
entity_id varchar(255),
entity_type varchar(255),
lore_id varchar(255),
title varchar(255) not null,
primary key (id)
);
create table enemies (
"order" integer not null,
campaign_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
field_values TEXT,
folder varchar(255),
header_image_id varchar(255),
image_values TEXT,
key_value_values TEXT,
level varchar(255),
name varchar(255) not null,
portrait_image_id varchar(255),
primary key (id)
);
create table game_systems (
is_public boolean not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
author varchar(255),
character_template TEXT,
description TEXT,
enemy_template TEXT,
name varchar(255) not null,
npc_template TEXT,
rules_markdown TEXT,
primary key (id)
);
create table images (
id bigint generated by default as identity,
size_bytes bigint not null,
uploaded_at timestamp(6) not null,
content_type varchar(255) not null,
filename varchar(255) not null,
storage_key varchar(255) not null unique,
primary key (id)
);
create table item_catalogs (
"order" integer not null,
campaign_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
icon varchar(64),
description TEXT,
name varchar(255) not null,
primary key (id)
);
create table licenses (
beta_channel_enabled boolean not null,
last_refresh_succeeded boolean not null,
created_at timestamp(6) with time zone not null,
expires_at timestamp(6) with time zone not null,
issued_at timestamp(6) with time zone not null,
last_refresh_attempt_at timestamp(6) with time zone,
updated_at timestamp(6) with time zone not null,
id varchar(255) not null,
instance_id varchar(255) not null,
patreon_user_id varchar(255) not null,
raw_jwt TEXT not null,
tier_id varchar(255) not null,
primary key (id)
);
create table lore_nodes (
created_at timestamp(6) not null,
id bigint generated by default as identity,
lore_id bigint not null,
parent_id bigint,
updated_at timestamp(6) not null,
icon varchar(64),
name varchar(255) not null,
primary key (id)
);
create table lores (
node_count integer not null,
page_count integer not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
description TEXT,
name varchar(255) not null,
primary key (id)
);
create table notebook_messages (
archived_at timestamp(6),
created_at timestamp(6) not null,
id bigint generated by default as identity,
notebook_id bigint not null,
role varchar(16) not null,
content TEXT not null,
primary key (id)
);
create table notebook_sources (
chunk_count integer not null,
page_count integer not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
notebook_id bigint not null,
status varchar(16) not null,
filename varchar(255) not null,
primary key (id)
);
create table notebooks (
campaign_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
name varchar(255) not null,
primary key (id)
);
create table npcs (
"order" integer not null,
campaign_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
field_values TEXT,
folder varchar(255),
header_image_id varchar(255),
image_values TEXT,
key_value_values TEXT,
name varchar(255) not null,
portrait_image_id varchar(255),
related_page_ids TEXT,
primary key (id)
);
create table pages (
created_at timestamp(6) not null,
id bigint generated by default as identity,
lore_id bigint not null,
node_id bigint not null,
template_id bigint,
updated_at timestamp(6) not null,
image_values_json TEXT,
key_value_values TEXT,
notes TEXT,
related_page_ids TEXT,
table_values TEXT,
tags TEXT,
title varchar(255) not null,
values_json TEXT,
primary key (id)
);
create table playthrough_flag (
value boolean not null,
id bigint generated by default as identity,
playthrough_id bigint not null,
name varchar(128) not null,
primary key (id),
constraint uk_playthrough_flag_name unique (playthrough_id, name)
);
create table playthroughs (
campaign_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
description TEXT,
name varchar(255) not null,
primary key (id)
);
create table quest_progression (
chapter_id bigint not null,
id bigint generated by default as identity,
playthrough_id bigint not null,
status varchar(16) not null check (status in ('NOT_STARTED','IN_PROGRESS','COMPLETED')),
primary key (id),
constraint uk_quest_progression_unique unique (playthrough_id, chapter_id)
);
create table random_table_entries (
max_roll integer not null,
min_roll integer not null,
position integer not null,
id bigint generated by default as identity,
random_table_id bigint not null,
detail TEXT,
label varchar(255) not null,
primary key (id)
);
create table random_tables (
"order" integer not null,
campaign_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
dice_formula varchar(32) not null,
icon varchar(64),
description TEXT,
name varchar(255) not null,
primary key (id)
);
create table scenes (
"order" integer not null,
chapter_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
atmosphere TEXT,
branches TEXT,
choices_consequences TEXT,
combat_difficulty TEXT,
description TEXT,
enemies TEXT,
enemy_ids TEXT,
gm_secret_notes TEXT,
icon varchar(255),
illustration_image_ids TEXT,
location TEXT,
map_image_ids TEXT,
name varchar(255) not null,
player_narration TEXT,
related_page_ids TEXT,
rooms TEXT,
timing TEXT,
primary key (id)
);
create table session_entries (
created_at timestamp(6) not null,
id bigint generated by default as identity,
occurred_at timestamp(6) not null,
updated_at timestamp(6) not null,
type varchar(32) not null check (type in ('NOTE','EVENT','DICE_ROLL','PLAYER_ACTION')),
content TEXT not null,
session_id varchar(255) not null,
primary key (id)
);
create table sessions (
created_at timestamp(6) not null,
ended_at timestamp(6),
id bigint generated by default as identity,
playthrough_id bigint,
started_at timestamp(6) not null,
updated_at timestamp(6) not null,
campaign_id varchar(255),
name varchar(255) not null,
primary key (id)
);
create table templates (
created_at timestamp(6) not null,
default_node_id bigint,
id bigint generated by default as identity,
lore_id bigint not null,
updated_at timestamp(6) not null,
description TEXT,
fields TEXT,
name varchar(255) not null,
primary key (id)
);
create index idx_catalog_items_catalog_id
on catalog_items (catalog_id);
create index idx_conv_lore_entity
on conversations (lore_id, entity_type, entity_id, updated_at);
create index idx_conv_campaign_entity
on conversations (campaign_id, entity_type, entity_id, updated_at);
create index idx_enemies_campaign_id
on enemies (campaign_id);
create index idx_item_catalogs_campaign_id
on item_catalogs (campaign_id);
create index idx_notebook_messages_notebook_id
on notebook_messages (notebook_id);
create index idx_notebook_sources_notebook_id
on notebook_sources (notebook_id);
create index idx_notebooks_campaign_id
on notebooks (campaign_id);
create index ix_playthrough_flag_playthrough
on playthrough_flag (playthrough_id);
create index ix_playthrough_campaign
on playthroughs (campaign_id);
create index ix_quest_progression_playthrough
on quest_progression (playthrough_id);
create index idx_random_table_entries_table_id
on random_table_entries (random_table_id);
create index idx_session_entries_session_id
on session_entries (session_id);
alter table if exists catalog_items
add constraint FK7tuoq6mwuc00yv0hhpiw4aoni
foreign key (catalog_id)
references item_catalogs;
alter table if exists conversation_messages
add constraint FKcr8qqgnqnaqq2hw3gr4wtfe2a
foreign key (conversation_id)
references conversations;
alter table if exists random_table_entries
add constraint FKly4syvpfit2kibfjut67xxrrq
foreign key (random_table_id)
references random_tables;

View File

@@ -14,6 +14,10 @@ spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=false spring.jpa.show-sql=false
# Flyway desactive en test : le schema est gere par Hibernate create-drop
# (recree a chaque run), pas par les migrations. Evite tout conflit Flyway/DDL.
spring.flyway.enabled=false
# Pool Hikari volontairement minuscule en test : la suite cree de nombreux # Pool Hikari volontairement minuscule en test : la suite cree de nombreux
# contextes Spring distincts (combinaisons de @MockBean / @TestPropertySource), # contextes Spring distincts (combinaisons de @MockBean / @TestPropertySource),
# tous gardes en cache simultanement. Avec le pool par defaut (10), on epuisait # tous gardes en cache simultanement. Avec le pool par defaut (10), on epuisait

View File

@@ -1,7 +1,10 @@
# Copie en .env sur le serveur (jamais commite). # Copie en .env sur le serveur (jamais commite).
# Registre et tag des images core / brain a spawner par session. # Registre, namespace et tag des images core / brain a spawner par session.
REGISTRY=git.igmlcreation.fr # Doit pointer sur le registre ou la CI publie reellement (ghcr.io).
# Le slash final de IMAGE_NAMESPACE est important : image = REGISTRY/NAMESPACEcore:TAG
REGISTRY=ghcr.io
IMAGE_NAMESPACE=igmlcreation/loremind-
TAG=latest TAG=latest
# Secret partage entre core et brain (genere aleatoirement au build de chaque # Secret partage entre core et brain (genere aleatoirement au build de chaque

View File

@@ -10,6 +10,7 @@ import (
// Config centralise les parametres lus depuis les variables d'env au boot. // Config centralise les parametres lus depuis les variables d'env au boot.
type Config struct { type Config struct {
Registry string Registry string
Namespace string
Tag string Tag string
MaxSessions int MaxSessions int
SessionTTL time.Duration SessionTTL time.Duration
@@ -27,7 +28,12 @@ type Config struct {
func loadConfig() *Config { func loadConfig() *Config {
return &Config{ return &Config{
Registry: envStr("REGISTRY", "git.igmlcreation.fr"), // Aligne sur la nouvelle convention de publication des images
// (ghcr.io/igmlcreation/loremind-{core,brain}). L'ancien registre Gitea
// (git.igmlcreation.fr/ietm64/*) n'est plus republie : il restait gele
// sur une vieille version. Le slash final de Namespace est volontaire.
Registry: envStr("REGISTRY", "ghcr.io"),
Namespace: envStr("IMAGE_NAMESPACE", "igmlcreation/loremind-"),
Tag: envStr("TAG", "latest"), Tag: envStr("TAG", "latest"),
MaxSessions: envInt("MAX_SESSIONS", 10), MaxSessions: envInt("MAX_SESSIONS", 10),
SessionTTL: time.Duration(envInt("SESSION_TTL_MINUTES", 20)) * time.Minute, SessionTTL: time.Duration(envInt("SESSION_TTL_MINUTES", 20)) * time.Minute,

View File

@@ -111,7 +111,7 @@ func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Con
if err := d.runContainer(ctx, runSpec{ if err := d.runContainer(ctx, runSpec{
Name: brainName, Name: brainName,
Image: cfg.Registry + "/ietm64/brain:" + cfg.Tag, Image: cfg.Registry + "/" + cfg.Namespace + "brain:" + cfg.Tag,
Env: []string{ Env: []string{
"INTERNAL_SHARED_SECRET=" + brainSecret, "INTERNAL_SHARED_SECRET=" + brainSecret,
// Pas de provider LLM configure en demo : les features IA echoueront // Pas de provider LLM configure en demo : les features IA echoueront
@@ -129,7 +129,7 @@ func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Con
if err := d.runContainer(ctx, runSpec{ if err := d.runContainer(ctx, runSpec{
Name: coreName, Name: coreName,
Image: cfg.Registry + "/ietm64/core:" + cfg.Tag, Image: cfg.Registry + "/" + cfg.Namespace + "core:" + cfg.Tag,
Env: []string{ Env: []string{
"SPRING_DATASOURCE_URL=jdbc:postgresql://" + pgName + ":5432/loremind", "SPRING_DATASOURCE_URL=jdbc:postgresql://" + pgName + ":5432/loremind",
"SPRING_DATASOURCE_USERNAME=loremind", "SPRING_DATASOURCE_USERNAME=loremind",

View File

@@ -252,7 +252,11 @@ services:
# API HTTP pour declenchement manuel via le bouton UI (Core -> Watchtower). # API HTTP pour declenchement manuel via le bouton UI (Core -> Watchtower).
WATCHTOWER_HTTP_API_UPDATE: "true" WATCHTOWER_HTTP_API_UPDATE: "true"
WATCHTOWER_HTTP_API_PERIODIC_POLLS: "true" WATCHTOWER_HTTP_API_PERIODIC_POLLS: "true"
WATCHTOWER_HTTP_API_TOKEN: "${WATCHTOWER_TOKEN:?set WATCHTOWER_TOKEN in .env (re-run installer)}" # Pas de ":?" ici : Compose interpole TOUT le fichier avant de filtrer
# par profile, donc un ":?" planterait le "up" meme quand autoupdate est
# inactif. L'installeur genere toujours WATCHTOWER_TOKEN ; defaut vide
# pour ne rien casser sur les deploiements sans autoupdate.
WATCHTOWER_HTTP_API_TOKEN: "${WATCHTOWER_TOKEN:-}"
WATCHTOWER_TIMEOUT: 60s WATCHTOWER_TIMEOUT: 60s
WATCHTOWER_NOTIFICATIONS_LEVEL: info WATCHTOWER_NOTIFICATIONS_LEVEL: info
TZ: ${TZ:-Europe/Paris} TZ: ${TZ:-Europe/Paris}

View File

@@ -0,0 +1,214 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Construit l'installeur de BUREAU Windows de LoreMind (.msi), sans Docker.
.DESCRIPTION
Pipeline complet "local-first" :
1. Build du front Angular (web/ -> web/dist/web)
2. Prep du Brain (Python embeddable) (brain/ -> dist-embed : python.exe signe PSF + deps + sources)
3. Build du Core en fat jar + front (core/ -> target/*.jar, profil Maven "desktop")
4. Assemblage de la charge utile (jar + brain) dans un dossier d'entree jpackage
5. jpackage -> installeur .msi avec JRE embarque
L'app resultante se lance d'un double-clic : le Core demarre en profil Spring
"local" (H2 fichier + stockage filesystem) et lance lui-meme le Brain en sidecar.
Aucune dependance externe a installer cote utilisateur (ni Docker, ni Java, ni Python).
L'IA fonctionne via le cloud (1min.ai / Gemini / Mistral...) ou via Ollama si present.
.PARAMETER Version
Version de l'installeur (X.Y.Z, numerique). Defaut : derivee de core/pom.xml
(le suffixe -beta est retire car les MSI n'acceptent qu'une version numerique).
.PARAMETER SkipFront / SkipBrain / SkipJar
Sauter une etape (build incrementaux pendant la mise au point).
.PREREQUIS (sur la machine de build uniquement, PAS chez l'utilisateur final)
- JDK 21+ avec jpackage dans le PATH (Temurin OK).
- WiX Toolset v3 (https://github.com/wixtoolset/wix3/releases) — requis par
jpackage pour produire un .msi sur Windows.
- Node.js + npm (build Angular).
- Python + pip (telecharge les wheels cp312 du Brain ; toute version 3.x convient,
on cible explicitement 3.12 via --python-version) + acces Internet (python.org).
.NOTES
Projet : LoreMind — assistant pour Maitres de Jeu de JDR
Licence : AGPL-3.0
#>
[CmdletBinding()]
param(
[string]$Version,
[switch]$SkipFront,
[switch]$SkipBrain,
[switch]$SkipJar
)
$ErrorActionPreference = 'Stop'
function Write-Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
function Write-Ok($m) { Write-Host " OK $m" -ForegroundColor Green }
function Write-Err($m) { Write-Host " XX $m" -ForegroundColor Red }
# --- Chemins ---------------------------------------------------------------
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$WebDir = Join-Path $RepoRoot 'web'
$BrainDir = Join-Path $RepoRoot 'brain'
$CoreDir = Join-Path $RepoRoot 'core'
$StageDir = Join-Path $CoreDir 'target\dist-input' # charge utile jpackage
$OutDir = Join-Path $CoreDir 'target\dist-out' # .msi produit
# --- Version (numerique pour MSI) ------------------------------------------
if (-not $Version) {
$pom = Get-Content (Join-Path $CoreDir 'pom.xml') -Raw
# IMPORTANT : viser la version DU PROJET, pas le premier <version> du fichier
# (qui est celui du parent spring-boot-starter-parent). On l'ancre sur
# l'artifactId du projet, puis on garde la partie numerique (MSI = X.Y.Z).
if ($pom -match '<artifactId>loremind-core</artifactId>\s*<version>([0-9]+\.[0-9]+\.[0-9]+)') {
$Version = $Matches[1]
} else {
$Version = '0.0.0'
}
}
Write-Host ""
Write-Host "============================================================" -ForegroundColor Magenta
Write-Host " LoreMind - Build installeur bureau Windows (v$Version)" -ForegroundColor Magenta
Write-Host "============================================================" -ForegroundColor Magenta
# --- Verif outils ----------------------------------------------------------
if (-not (Get-Command jpackage -ErrorAction SilentlyContinue)) {
Write-Err "jpackage introuvable dans le PATH. Installez un JDK 21+ (Temurin)."
exit 1
}
# --- 1. Front Angular ------------------------------------------------------
if (-not $SkipFront) {
Write-Step "Build du front Angular"
Push-Location $WebDir
try {
if (-not (Test-Path 'node_modules')) { npm ci }
npm run build
if ($LASTEXITCODE -ne 0) { throw "Echec du build Angular" }
} finally { Pop-Location }
Write-Ok "Front construit (web/dist/web)"
} else { Write-Step "Front : saute (--SkipFront)" }
# --- 2. Brain (Python embeddable officiel, PAS d'exe gele) -----------------
# On embarque le Python *embeddable* de python.org (python.exe SIGNE par la PSF)
# + les dependances + les sources .py. Aucun executable "gele" type PyInstaller
# -> plus de faux positif antivirus (le bootloader packe etait pris pour un
# trojan). Le Core lancera : brain\python\python.exe brain\run_local.py
$PyVersion = '3.12.8' # doit matcher python:3.12 du Docker
$PyTag = 'python312' # prefixe du fichier ._pth
$BrainEmbed = Join-Path $BrainDir 'dist-embed' # staging du brain empaquete
if (-not $SkipBrain) {
Write-Step "Preparation du Brain (Python embeddable $PyVersion)"
if (Test-Path $BrainEmbed) { Remove-Item $BrainEmbed -Recurse -Force }
$PyDir = Join-Path $BrainEmbed 'python'
New-Item -ItemType Directory -Force -Path $PyDir | Out-Null
# a) Telecharger + extraire le Python embeddable (zip officiel).
$zip = Join-Path $BrainEmbed 'python-embed.zip'
$url = "https://www.python.org/ftp/python/$PyVersion/python-$PyVersion-embed-amd64.zip"
Write-Host " Telechargement $url"
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
Expand-Archive -Path $zip -DestinationPath $PyDir -Force
Remove-Item $zip -Force
# b) Activer site-packages dans le ._pth (sinon les deps ne sont pas importees).
$pth = Join-Path $PyDir "$PyTag._pth"
Set-Content -Path $pth -Encoding ascii -Value @(
"$PyTag.zip", '.', 'Lib\site-packages', 'import site'
)
# c) Installer les deps en wheels cp312 dans python\Lib\site-packages.
# --python-version 3.12 + --only-binary : on telecharge les roues 3.12
# meme si le pip courant tourne sous une autre version de Python.
$site = Join-Path $PyDir 'Lib\site-packages'
New-Item -ItemType Directory -Force -Path $site | Out-Null
Push-Location $BrainDir
try {
python -m pip install --upgrade pip *> $null
python -m pip install --target $site --only-binary=:all: --python-version 3.12 -r requirements.txt
if ($LASTEXITCODE -ne 0) { throw "Echec pip install (deps Brain)" }
} finally { Pop-Location }
# d) Copier les sources du Brain + le point d'entree.
Copy-Item (Join-Path $BrainDir 'app') (Join-Path $BrainEmbed 'app') -Recurse
Copy-Item (Join-Path $BrainDir 'run_local.py') (Join-Path $BrainEmbed 'run_local.py')
Write-Ok "Brain prepare (brain/dist-embed : python embeddable + deps + sources)"
} else { Write-Step "Brain : saute (--SkipBrain)" }
# --- 3. Core (fat jar avec front embarque) ---------------------------------
if (-not $SkipJar) {
Write-Step "Build du Core (fat jar, profil desktop)"
Push-Location $CoreDir
try {
# -Pdesktop : copie web/dist/web dans le jar (classpath:/static).
cmd /c "mvn -q -Pdesktop -DskipTests clean package"
if ($LASTEXITCODE -ne 0) { throw "Echec du build Maven" }
} finally { Pop-Location }
Write-Ok "Core construit (core/target)"
} else { Write-Step "Core : saute (--SkipJar)" }
# --- 4. Assemblage de la charge utile --------------------------------------
Write-Step "Assemblage de la charge utile jpackage"
if (Test-Path $StageDir) { Remove-Item $StageDir -Recurse -Force }
New-Item -ItemType Directory -Force -Path $StageDir | Out-Null
# Le jar repackage Spring Boot (executable) — on ignore le *.jar.original.
$jar = Get-ChildItem (Join-Path $CoreDir 'target') -Filter 'loremind-core-*.jar' |
Where-Object { $_.Name -notlike '*.original' } | Select-Object -First 1
if (-not $jar) { Write-Err "Jar introuvable dans core/target. Lancez sans --SkipJar."; exit 1 }
Copy-Item $jar.FullName (Join-Path $StageDir 'loremind-core.jar')
# Le Brain (python embeddable + deps + sources) -> stage/brain/
# stage/brain/python/python.exe , stage/brain/app , stage/brain/run_local.py
if (-not (Test-Path $BrainEmbed)) { Write-Err "Brain introuvable (brain/dist-embed). Lancez sans --SkipBrain."; exit 1 }
$stageBrain = Join-Path $StageDir 'brain'
New-Item -ItemType Directory -Force -Path $stageBrain | Out-Null
Copy-Item (Join-Path $BrainEmbed '*') $stageBrain -Recurse
Write-Ok "Charge utile prete ($StageDir)"
# --- 5. jpackage -> .msi ---------------------------------------------------
Write-Step "Generation de l'installeur (.msi) via jpackage"
if (Test-Path $OutDir) { Remove-Item $OutDir -Recurse -Force }
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
# $APPDIR est substitue par jpackage au LANCEMENT par le dossier 'app' de
# l'install (qui contient le jar ET le dossier brain copie depuis --input).
# Le Brain se lance via le python embeddable + run_local.py. brain.sidecar.command
# est une liste : les deux chemins separes par une virgule (aucun chemin Windows
# ne contient de virgule) sont bindes en List<String> par Spring.
$brainCmd = '$APPDIR\brain\python\python.exe,$APPDIR\brain\run_local.py'
# Note : pas de --win-console (app de bureau). Les logs Spring/Brain peuvent
# etre rediriges vers un fichier via une option ulterieure si besoin de debug.
jpackage `
--type msi `
--name LoreMind `
--app-version $Version `
--vendor 'IGML Creation' `
--input $StageDir `
--main-jar loremind-core.jar `
--main-class org.springframework.boot.loader.launch.JarLauncher `
--dest $OutDir `
--java-options '-Dspring.profiles.active=local' `
--java-options "-Dbrain.sidecar.command=$brainCmd" `
--win-per-user-install `
--win-menu --win-menu-group 'LoreMind' `
--win-shortcut --win-dir-chooser
if ($LASTEXITCODE -ne 0) {
Write-Err "Echec jpackage. Cause probable : WiX Toolset v3 absent."
Write-Err "Installez-le : https://github.com/wixtoolset/wix3/releases"
exit 1
}
$msi = Get-ChildItem $OutDir -Filter '*.msi' | Select-Object -First 1
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Installeur genere !" -ForegroundColor Green
Write-Host " $($msi.FullName)" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.14.0-beta", "version": "0.15.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "loremind-web", "name": "loremind-web",
"version": "0.14.0-beta", "version": "0.15.0",
"dependencies": { "dependencies": {
"@angular/animations": "^21.2.16", "@angular/animations": "^21.2.16",
"@angular/common": "^21.2.16", "@angular/common": "^21.2.16",

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.14.0-beta", "version": "0.15.0",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",

View File

@@ -1,6 +1,5 @@
import { HttpInterceptorFn } from '@angular/common/http'; import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core'; import { STORAGE_KEY } from '../services/language.service';
import { LanguageService } from '../services/language.service';
/** /**
* Ajoute l'entête `X-User-Language` (langue choisie dans l'UI : `fr`/`en`) à * Ajoute l'entête `X-User-Language` (langue choisie dans l'UI : `fr`/`en`) à
@@ -9,10 +8,27 @@ import { LanguageService } from '../services/language.service';
* *
* NB : les appels SSE en `fetch()` (chat, imports, notebooks) ne passent PAS par * NB : les appels SSE en `fetch()` (chat, imports, notebooks) ne passent PAS par
* les intercepteurs Angular — ils ajoutent l'entête manuellement de leur côté. * les intercepteurs Angular — ils ajoutent l'entête manuellement de leur côté.
*
* IMPORTANT — on lit la langue directement depuis localStorage et NON via
* LanguageService/TranslateService. Injecter ces services ici créerait une
* dépendance circulaire fatale au démarrage : provideTranslateService charge la
* langue de repli (`fr.json`) PENDANT la construction de TranslateService ; cette
* requête passe par cet intercepteur ; si l'intercepteur injectait LanguageService
* (qui injecte TranslateService, en cours de construction) → cycle → la requête
* `fr.json` échoue → ngx-translate marque `fr` comme chargé-mais-vide → toutes les
* clés s'affichent brutes (l'anglais, n'étant pas la langue de repli, se chargeait
* après la construction et fonctionnait). localStorage est de toute façon la
* source de vérité (persistée par LanguageService.use()).
*/ */
export const languageInterceptor: HttpInterceptorFn = (req, next) => { export const languageInterceptor: HttpInterceptorFn = (req, next) => {
const language = inject(LanguageService); let language = 'fr';
return next( try {
req.clone({ setHeaders: { 'X-User-Language': language.current } }) const stored = localStorage.getItem(STORAGE_KEY);
); if (stored) {
language = stored;
}
} catch {
// localStorage indisponible (mode privé strict) : on garde le défaut.
}
return next(req.clone({ setHeaders: { 'X-User-Language': language } }));
}; };

View File

@@ -22,7 +22,13 @@ export interface AppLanguage {
flag: string; flag: string;
} }
const STORAGE_KEY = 'loremind.lang'; /**
* Clé localStorage du choix de langue (par appareil). Exportée pour que le
* languageInterceptor lise la langue SANS injecter LanguageService/TranslateService
* (sinon dépendance circulaire au démarrage : le chargement initial de la langue
* de repli passe par l'intercepteur pendant la construction de TranslateService).
*/
export const STORAGE_KEY = 'loremind.lang';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class LanguageService { export class LanguageService {