Mise en place d'un installeur pour la version bureau sans passer par Docker.
Permet d'utiliser Loremind sans passer par Docker et sans lancer tous les conteneurs Passage en v0.15.0
This commit is contained in:
94
.github/workflows/desktop-release.yml
vendored
Normal file
94
.github/workflows/desktop-release.yml
vendored
Normal 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
7
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
27
brain/run_local.py
Normal 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")
|
||||||
72
core/pom.xml
72
core/pom.xml
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <0 si a<b, 0 si egales, >0 si a>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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(".");
|
||||||
|
}
|
||||||
|
}
|
||||||
86
core/src/main/resources/application-local.properties
Normal file
86
core/src/main/resources/application-local.properties
Normal 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
|
||||||
@@ -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.
|
||||||
|
|||||||
424
core/src/main/resources/db/migration/V1__baseline.sql
Normal file
424
core/src/main/resources/db/migration/V1__baseline.sql
Normal 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;
|
||||||
@@ -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
|
||||||
|
|||||||
214
installers/desktop/build-windows.ps1
Normal file
214
installers/desktop/build-windows.ps1
Normal 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
4
web/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 } }));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user