6 Commits

Author SHA1 Message Date
94a39cf3b4 Mise en place de la pipeline pour github plutot que gitea ; mise en place des images docker sur GHCR plutôt que gitea
Some checks failed
E2E Tests / e2e (push) Failing after 22s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m40s
Passage version v0.6.13
2026-04-26 10:46:46 +02:00
efe6f6c2b0 Empêche la modale de ce fermer tant que le llm n'est pas télécharger
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
2026-04-26 09:12:36 +02:00
73a9d15786 Forçage HTTP/1.1 pour la partie python et passage en v0.6.11
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m17s
Build & Push Images / build (web) (push) Successful in 1m27s
2026-04-26 01:55:02 +02:00
dfe05cf2d2 Correction d'un bug lors de tentative de téléchargement de llm pour ollama
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m22s
Build & Push Images / build (web) (push) Successful in 1m28s
2026-04-26 01:45:39 +02:00
fcba907438 Passage version 0.6.9
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 47s
Build & Push Images / build (core) (push) Successful in 1m18s
Build & Push Images / build (web) (push) Successful in 1m24s
2026-04-26 01:30:35 +02:00
5739602702 Changement du watchtower pour une version plus récente : projet originel abandonné, repris par un fork.
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Failing after 20s
Build & Push Images / build (web) (push) Failing after 21s
Build & Push Images / build (core) (push) Failing after 22s
2026-04-26 01:19:58 +02:00
14 changed files with 157 additions and 41 deletions

View File

@@ -6,8 +6,10 @@ on:
- 'v*'
env:
REGISTRY: git.igmlcreation.fr
REGISTRY_USER: ietm64
GITEA_REGISTRY: git.igmlcreation.fr
GITEA_REGISTRY_USER: ietm64
GHCR_REGISTRY: ghcr.io
GHCR_NAMESPACE: igmlcreation
jobs:
build:
@@ -26,19 +28,39 @@ jobs:
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.REGISTRY_USER }}
registry: ${{ env.GITEA_REGISTRY }}
username: ${{ env.GITEA_REGISTRY_USER }}
password: ${{ secrets.DOCKER_PAT }}
# Login to GHCR (GitHub Container Registry) pour distribuer les images
# publiquement aux utilisateurs finaux. Reputation domaine plus elevee
# que git.igmlcreation.fr (mieux pour les antivirus / SmartScreen).
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ env.GHCR_NAMESPACE }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Extract version
id: meta
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
# Push vers les deux registries en un seul build (build-push-action
# accepte une liste de tags ; aucun build supplementaire necessaire).
# Naming :
# - Gitea : conserve l'ancien pattern ietm64/<component> pour ne pas
# casser les installs existantes qui ont REGISTRY=git.igmlcreation.fr
# dans leur .env.
# - GHCR : nouveau pattern igmlcreation/loremind-<component> qui evite
# la collision avec d'autres projets de l'org.
- name: Build & push ${{ matrix.component }}
uses: docker/build-push-action@v5
with:
context: ./${{ matrix.component }}
push: true
tags: |
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:latest
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:latest
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}

View File

@@ -61,7 +61,16 @@ class OllamaLLMProvider:
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
response = await client.post(url, json=payload)
response.raise_for_status()
if response.status_code >= 400:
body = response.text
try:
err_obj = json.loads(body)
err_msg = err_obj.get("error") or body
except json.JSONDecodeError:
err_msg = body
raise LLMProviderError(
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
)
except httpx.HTTPError as exc:
raise LLMProviderError(
f"Erreur lors de l'appel à Ollama : {exc}"
@@ -105,7 +114,20 @@ class OllamaLLMProvider:
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream("POST", url, json=payload) as response:
response.raise_for_status()
if response.status_code >= 400:
# On lit le body d'erreur pour le remonter a l'utilisateur,
# sinon on ne voit que "500 Internal Server Error" sans
# savoir POURQUOI Ollama refuse (modele introuvable, OOM,
# num_ctx trop grand pour la VRAM, etc.).
body = (await response.aread()).decode("utf-8", errors="replace")
try:
err_obj = json.loads(body)
err_msg = err_obj.get("error") or body
except json.JSONDecodeError:
err_msg = body
raise LLMProviderError(
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
)
async for line in response.aiter_lines():
if not line.strip():
continue

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.6.8</version>
<version>0.6.13</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>

View File

@@ -21,12 +21,10 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
@@ -46,13 +44,16 @@ public class SettingsController {
private final RestTemplate restTemplate;
private final String brainBaseUrl;
private final String brainInternalSecret;
private final boolean demoMode;
public SettingsController(RestTemplate restTemplate,
@Value("${brain.base-url}") String brainBaseUrl,
@Value("${brain.internal-secret}") String brainInternalSecret,
@Value("${app.demo-mode:false}") boolean demoMode) {
this.restTemplate = restTemplate;
this.brainBaseUrl = brainBaseUrl;
this.brainInternalSecret = brainInternalSecret;
this.demoMode = demoMode;
}
@@ -90,15 +91,26 @@ public class SettingsController {
public ResponseEntity<StreamingResponseBody> pullOllamaModel(@RequestBody Map<String, Object> body) {
guardDemoMode();
StreamingResponseBody stream = output -> {
// Force HTTP/1.1 : le HttpClient JDK essaie HTTP/2 par defaut,
// mais uvicorn (Brain) ne supporte que HTTP/1.1 et rejette la
// tentative d'upgrade ("Unsupported upgrade request") -> la
// requete n'arrive jamais a notre endpoint Python.
HttpClient http = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest req = HttpRequest.newBuilder()
// Le RestTemplate auto-injecte X-Internal-Secret via un interceptor,
// mais on bypass RestTemplate pour le streaming -> on doit ajouter
// l'entete a la main, sinon le Brain repond 401.
HttpRequest.Builder reqBuilder = HttpRequest.newBuilder()
.uri(URI.create(brainBaseUrl + "/models/ollama/pull"))
.timeout(Duration.ofMinutes(60))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(toJson(body)))
.build();
.POST(HttpRequest.BodyPublishers.ofString(toJson(body)));
if (brainInternalSecret != null && !brainInternalSecret.isBlank()) {
reqBuilder.header("X-Internal-Secret", brainInternalSecret);
}
HttpRequest req = reqBuilder.build();
try {
HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
try (InputStream in = resp.body()) {

View File

@@ -5,6 +5,12 @@ server.port=8080
# de WebFlux (utilisé uniquement pour WebClient côté adapter SSE vers le Brain).
spring.main.web-application-type=servlet
# Pas de timeout sur les requetes async (StreamingResponseBody, SSE).
# Le defaut Tomcat coupe a 30s, ce qui interrompt le streaming d'un pull
# de modele Ollama (peut durer des dizaines de minutes pour un GGUF de 10+ Go).
# -1 = pas de timeout, on s'appuie sur la fermeture cote client ou cote upstream.
spring.mvc.async.request-timeout=-1
# Configuration de la base de donnees PostgreSQL
# Valeurs surchargeables via variables d'env (cf. docker-compose.yml).
# En dev local, creez un fichier .env a la racine de core/ OU definissez les

View File

@@ -60,7 +60,12 @@ services:
"
core:
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/core:${TAG:-latest}
# Defaut : GHCR (registry public, reputation domaine elevee).
# Pour les anciennes installs qui pointaient sur Gitea, REGISTRY et
# IMAGE_NAMESPACE peuvent etre overrides dans .env :
# REGISTRY=git.igmlcreation.fr
# IMAGE_NAMESPACE=ietm64/ (le slash final est important : voir image: ci-dessous)
image: ${REGISTRY:-ghcr.io}/${IMAGE_NAMESPACE:-igmlcreation/loremind-}core:${TAG:-latest}
container_name: loremind-core
labels:
- "com.centurylinklabs.watchtower.enable=true"
@@ -84,8 +89,8 @@ services:
# Detection des mises a jour : interroge le registry et delegue le pull/restart
# a Watchtower. Si WATCHTOWER_TOKEN est vide, la feature est desactivee
# (l'UI masque le badge et le bouton).
UPDATE_CHECK_REGISTRY: ${REGISTRY:-git.igmlcreation.fr}
UPDATE_CHECK_IMAGES: ietm64/core,ietm64/brain,ietm64/web
UPDATE_CHECK_REGISTRY: ${REGISTRY:-ghcr.io}
UPDATE_CHECK_IMAGES: ${IMAGE_NAMESPACE:-igmlcreation/loremind-}core,${IMAGE_NAMESPACE:-igmlcreation/loremind-}brain,${IMAGE_NAMESPACE:-igmlcreation/loremind-}web
UPDATE_CHECK_TAG: ${TAG:-latest}
WATCHTOWER_URL: http://watchtower:8080
WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-}
@@ -115,7 +120,7 @@ services:
restart: unless-stopped
brain:
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/brain:${TAG:-latest}
image: ${REGISTRY:-ghcr.io}/${IMAGE_NAMESPACE:-igmlcreation/loremind-}brain:${TAG:-latest}
container_name: loremind-brain
labels:
- "com.centurylinklabs.watchtower.enable=true"
@@ -138,7 +143,7 @@ services:
restart: unless-stopped
web:
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/web:${TAG:-latest}
image: ${REGISTRY:-ghcr.io}/${IMAGE_NAMESPACE:-igmlcreation/loremind-}web:${TAG:-latest}
container_name: loremind-web
labels:
- "com.centurylinklabs.watchtower.enable=true"
@@ -154,7 +159,12 @@ services:
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
# compatibilite de version a verifier manuellement).
watchtower:
image: containrrr/watchtower:latest
# Fork maintenu de containrrr/watchtower (l'original est abandonne depuis
# ~2023 et son client Docker API est trop vieux pour les versions recentes
# de Docker Desktop -- erreur "client version 1.25 is too old").
# nickfedor/watchtower est un drop-in : memes variables d'environnement,
# meme API HTTP, juste l'image change.
image: nickfedor/watchtower:latest
container_name: loremind-watchtower
profiles: ["autoupdate"]
volumes:

View File

@@ -29,7 +29,7 @@ déclaratif et auditable en quelques lignes.
## Linux (Debian / Ubuntu / Fedora / Arch)
```bash
curl -fsSL https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/installers/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/installers/install.sh | bash
```
Le script :

View File

@@ -40,16 +40,16 @@
Auteur : ietm64
Licence : AGPL-3.0
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
Version : 0.6.8
Version : 0.6.13
.LINK
https://git.igmlcreation.fr/ietm64/loremind
https://github.com/IGMLcreation/LoreMind
#>
[CmdletBinding()]
param(
[string]$InstallDir = "$env:LOCALAPPDATA\LoreMind",
[string]$ComposeUrl = "https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/docker-compose.yml",
[string]$ComposeUrl = "https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/docker-compose.yml",
[int]$WebPort = 8081,
[switch]$NonInteractive
)
@@ -316,7 +316,8 @@ $composeProfiles = $profilesList -join ','
$envContent = @"
# Genere par install.ps1 le $(Get-Date -Format 'yyyy-MM-dd HH:mm')
REGISTRY=git.igmlcreation.fr
REGISTRY=ghcr.io
IMAGE_NAMESPACE=igmlcreation/loremind-
TAG=latest
WEB_PORT=$WebPort

View File

@@ -2,12 +2,12 @@
# ==========================================================================
# Installeur LoreMindMJ pour Linux (Debian/Ubuntu/Fedora/Arch)
# Usage :
# curl -fsSL https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/installers/install.sh | bash
# curl -fsSL https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/installers/install.sh | bash
# ==========================================================================
set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/share/loremind}"
COMPOSE_URL="${COMPOSE_URL:-https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/docker-compose.yml}"
COMPOSE_URL="${COMPOSE_URL:-https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/docker-compose.yml}"
WEB_PORT="${WEB_PORT:-8081}"
NON_INTERACTIVE="${NON_INTERACTIVE:-0}"
@@ -190,7 +190,8 @@ COMPOSE_PROFILES="$(IFS=,; echo "${PROFILES_ARR[*]}")"
cat > .env <<EOF
# Genere par install.sh le $(date '+%Y-%m-%d %H:%M')
REGISTRY=git.igmlcreation.fr
REGISTRY=ghcr.io
IMAGE_NAMESPACE=igmlcreation/loremind-
TAG=latest
WEB_PORT=${WEB_PORT}

View File

@@ -24,7 +24,7 @@
faciliter leur identification et suppression ulterieure.
.LINK
https://git.igmlcreation.fr/ietm64/loremind
https://github.com/IGMLcreation/LoreMind
#>
[CmdletBinding()]

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "loremind-web",
"version": "0.6.8",
"version": "0.6.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
"version": "0.6.8",
"version": "0.6.13",
"dependencies": {
"@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.6.8",
"version": "0.6.13",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core';
import { Injectable, NgZone } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@@ -43,7 +43,7 @@ export class SettingsService {
// suivants en cross-origin (dev Angular sur :4200 -> core sur :8080).
private readonly authOptions = { withCredentials: true };
constructor(private http: HttpClient) {}
constructor(private http: HttpClient, private zone: NgZone) {}
getSettings(): Observable<AppSettings> {
return this.http.get<AppSettings>(this.apiUrl, this.authOptions);
@@ -78,8 +78,16 @@ export class SettingsService {
* bufferise les reponses XHR, ce qui empeche le streaming en temps reel.
*/
pullOllamaModel(name: string): Observable<OllamaPullEvent> {
// fetch() s'execute hors zone Angular -> les emissions du subscriber
// doivent etre forcees dans la zone via NgZone.run() pour que le change
// detection se declenche et que la barre de progression se mette a jour
// en temps reel.
return new Observable<OllamaPullEvent>((subscriber) => {
const controller = new AbortController();
const emit = (ev: OllamaPullEvent) => this.zone.run(() => subscriber.next(ev));
const fail = (e: unknown) => this.zone.run(() => subscriber.error(e));
const done = () => this.zone.run(() => subscriber.complete());
(async () => {
try {
const response = await fetch(`${this.apiUrl}/models/ollama/pull`, {
@@ -89,16 +97,22 @@ export class SettingsService {
body: JSON.stringify({ name }),
signal: controller.signal,
});
if (!response.ok || !response.body) {
subscriber.error(new Error(`HTTP ${response.status}`));
if (!response.ok) {
const text = await response.text();
fail(new Error(`HTTP ${response.status} : ${text || 'reponse vide'}`));
return;
}
if (!response.body) {
fail(new Error('Reponse sans corps : streaming non supporte par le navigateur'));
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let eventsReceived = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buffer += decoder.decode(value, { stream: true });
// Decoupage NDJSON : chaque ligne est un objet JSON complet.
let nl: number;
@@ -107,15 +121,27 @@ export class SettingsService {
buffer = buffer.slice(nl + 1);
if (!line) continue;
try {
subscriber.next(JSON.parse(line) as OllamaPullEvent);
emit(JSON.parse(line) as OllamaPullEvent);
eventsReceived++;
} catch {
// Ligne non JSON (rare) : on l'ignore.
}
}
}
subscriber.complete();
// Reste eventuel dans le buffer (derniere ligne sans '\n').
if (buffer.trim()) {
try {
emit(JSON.parse(buffer.trim()) as OllamaPullEvent);
eventsReceived++;
} catch { /* ignore */ }
}
if (eventsReceived === 0) {
fail(new Error('Aucun evenement recu du serveur. Verifiez les logs du Brain (docker logs loremind-brain).'));
return;
}
done();
} catch (err) {
if ((err as Error).name !== 'AbortError') subscriber.error(err);
if ((err as Error).name !== 'AbortError') fail(err);
}
})();
return () => controller.abort();

View File

@@ -59,6 +59,10 @@ export class SettingsComponent implements OnInit {
pullTotal = 0;
/** Souscription au flux de pull pour pouvoir l'annuler. */
private pullSubscription: Subscription | null = null;
/** True si on a recu un evenement {status:"success"} d'Ollama. Sans ca,
* une fermeture de stream (timeout proxy, perte reseau) ne doit PAS etre
* interpretee comme une reussite. */
private pullSucceeded = false;
/** Modele en cours de suppression (nom) pour disabler son bouton. */
deletingModel: string | null = null;
@@ -293,6 +297,9 @@ export class SettingsComponent implements OnInit {
if (event.status) this.pullStatus = event.status;
if (event.completed != null) this.pullCompleted = event.completed;
if (event.total != null) this.pullTotal = event.total;
// Marqueur explicite : Ollama emet "success" en derniere ligne quand
// le pull est reellement complet (manifest + layers + verify).
if (event.status === 'success') this.pullSucceeded = true;
},
error: (err) => {
this.errorMessage = this.extractError(err, `Echec du telechargement de ${name}.`);
@@ -300,6 +307,14 @@ export class SettingsComponent implements OnInit {
},
complete: () => {
this.pullInProgress = false;
if (!this.pullSucceeded) {
// Stream ferme sans 'success' final = connexion coupee
// (timeout proxy, perte reseau, ...). Le modele est probablement
// partiellement telecharge ; Ollama gardera les couches deja DL.
this.errorMessage = `Telechargement de ${name} interrompu avant la fin. Relancez pour reprendre.`;
this.refreshModels();
return;
}
this.successMessage = `Modele ${name} telecharge.`;
this.refreshModels();
// Si l'utilisateur n'avait aucun modele, on selectionne celui-ci.
@@ -326,6 +341,7 @@ export class SettingsComponent implements OnInit {
this.pullStatus = '';
this.pullCompleted = 0;
this.pullTotal = 0;
this.pullSucceeded = false;
if (this.pullSubscription) {
this.pullSubscription.unsubscribe();
this.pullSubscription = null;