2 Commits

Author SHA1 Message Date
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
6 changed files with 57 additions and 19 deletions

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.6.9</version> <version>0.6.11</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <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.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.Map; import java.util.Map;
@@ -46,13 +44,16 @@ public class SettingsController {
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
private final String brainBaseUrl; private final String brainBaseUrl;
private final String brainInternalSecret;
private final boolean demoMode; private final boolean demoMode;
public SettingsController(RestTemplate restTemplate, public SettingsController(RestTemplate restTemplate,
@Value("${brain.base-url}") String brainBaseUrl, @Value("${brain.base-url}") String brainBaseUrl,
@Value("${brain.internal-secret}") String brainInternalSecret,
@Value("${app.demo-mode:false}") boolean demoMode) { @Value("${app.demo-mode:false}") boolean demoMode) {
this.restTemplate = restTemplate; this.restTemplate = restTemplate;
this.brainBaseUrl = brainBaseUrl; this.brainBaseUrl = brainBaseUrl;
this.brainInternalSecret = brainInternalSecret;
this.demoMode = demoMode; this.demoMode = demoMode;
} }
@@ -90,15 +91,26 @@ public class SettingsController {
public ResponseEntity<StreamingResponseBody> pullOllamaModel(@RequestBody Map<String, Object> body) { public ResponseEntity<StreamingResponseBody> pullOllamaModel(@RequestBody Map<String, Object> body) {
guardDemoMode(); guardDemoMode();
StreamingResponseBody stream = output -> { 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() HttpClient http = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10)) .connectTimeout(Duration.ofSeconds(10))
.build(); .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")) .uri(URI.create(brainBaseUrl + "/models/ollama/pull"))
.timeout(Duration.ofMinutes(60)) .timeout(Duration.ofMinutes(60))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(toJson(body))) .POST(HttpRequest.BodyPublishers.ofString(toJson(body)));
.build(); if (brainInternalSecret != null && !brainInternalSecret.isBlank()) {
reqBuilder.header("X-Internal-Secret", brainInternalSecret);
}
HttpRequest req = reqBuilder.build();
try { try {
HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream()); HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
try (InputStream in = resp.body()) { try (InputStream in = resp.body()) {

View File

@@ -40,7 +40,7 @@
Auteur : ietm64 Auteur : ietm64
Licence : AGPL-3.0 Licence : AGPL-3.0
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
Version : 0.6.9 Version : 0.6.11
.LINK .LINK
https://git.igmlcreation.fr/ietm64/loremind https://git.igmlcreation.fr/ietm64/loremind

4
web/package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.6.9", "version": "0.6.11",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "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 { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@@ -43,7 +43,7 @@ export class SettingsService {
// suivants en cross-origin (dev Angular sur :4200 -> core sur :8080). // suivants en cross-origin (dev Angular sur :4200 -> core sur :8080).
private readonly authOptions = { withCredentials: true }; private readonly authOptions = { withCredentials: true };
constructor(private http: HttpClient) {} constructor(private http: HttpClient, private zone: NgZone) {}
getSettings(): Observable<AppSettings> { getSettings(): Observable<AppSettings> {
return this.http.get<AppSettings>(this.apiUrl, this.authOptions); 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. * bufferise les reponses XHR, ce qui empeche le streaming en temps reel.
*/ */
pullOllamaModel(name: string): Observable<OllamaPullEvent> { 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) => { return new Observable<OllamaPullEvent>((subscriber) => {
const controller = new AbortController(); 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 () => { (async () => {
try { try {
const response = await fetch(`${this.apiUrl}/models/ollama/pull`, { const response = await fetch(`${this.apiUrl}/models/ollama/pull`, {
@@ -89,16 +97,22 @@ export class SettingsService {
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
signal: controller.signal, signal: controller.signal,
}); });
if (!response.ok || !response.body) { if (!response.ok) {
subscriber.error(new Error(`HTTP ${response.status}`)); 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; return;
} }
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
let eventsReceived = 0;
while (true) { while (true) {
const { value, done } = await reader.read(); const { value, done: streamDone } = await reader.read();
if (done) break; if (streamDone) break;
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
// Decoupage NDJSON : chaque ligne est un objet JSON complet. // Decoupage NDJSON : chaque ligne est un objet JSON complet.
let nl: number; let nl: number;
@@ -107,15 +121,27 @@ export class SettingsService {
buffer = buffer.slice(nl + 1); buffer = buffer.slice(nl + 1);
if (!line) continue; if (!line) continue;
try { try {
subscriber.next(JSON.parse(line) as OllamaPullEvent); emit(JSON.parse(line) as OllamaPullEvent);
eventsReceived++;
} catch { } catch {
// Ligne non JSON (rare) : on l'ignore. // 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) { } catch (err) {
if ((err as Error).name !== 'AbortError') subscriber.error(err); if ((err as Error).name !== 'AbortError') fail(err);
} }
})(); })();
return () => controller.abort(); return () => controller.abort();