Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efe6f6c2b0 | |||
| 73a9d15786 | |||
| dfe05cf2d2 | |||
| fcba907438 | |||
| 5739602702 |
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.6.8</version>
|
<version>0.6.12</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ server.port=8080
|
|||||||
# de WebFlux (utilisé uniquement pour WebClient côté adapter SSE vers le Brain).
|
# de WebFlux (utilisé uniquement pour WebClient côté adapter SSE vers le Brain).
|
||||||
spring.main.web-application-type=servlet
|
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
|
# Configuration de la base de donnees PostgreSQL
|
||||||
# Valeurs surchargeables via variables d'env (cf. docker-compose.yml).
|
# 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
|
# En dev local, creez un fichier .env a la racine de core/ OU definissez les
|
||||||
|
|||||||
@@ -154,7 +154,12 @@ services:
|
|||||||
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
|
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
|
||||||
# compatibilite de version a verifier manuellement).
|
# compatibilite de version a verifier manuellement).
|
||||||
watchtower:
|
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
|
container_name: loremind-watchtower
|
||||||
profiles: ["autoupdate"]
|
profiles: ["autoupdate"]
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -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.8
|
Version : 0.6.12
|
||||||
|
|
||||||
.LINK
|
.LINK
|
||||||
https://git.igmlcreation.fr/ietm64/loremind
|
https://git.igmlcreation.fr/ietm64/loremind
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.6.8",
|
"version": "0.6.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.6.8",
|
"version": "0.6.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^17.0.0",
|
"@angular/animations": "^17.0.0",
|
||||||
"@angular/common": "^17.0.0",
|
"@angular/common": "^17.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.6.8",
|
"version": "0.6.12",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export class SettingsComponent implements OnInit {
|
|||||||
pullTotal = 0;
|
pullTotal = 0;
|
||||||
/** Souscription au flux de pull pour pouvoir l'annuler. */
|
/** Souscription au flux de pull pour pouvoir l'annuler. */
|
||||||
private pullSubscription: Subscription | null = null;
|
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. */
|
/** Modele en cours de suppression (nom) pour disabler son bouton. */
|
||||||
deletingModel: string | null = null;
|
deletingModel: string | null = null;
|
||||||
@@ -293,6 +297,9 @@ export class SettingsComponent implements OnInit {
|
|||||||
if (event.status) this.pullStatus = event.status;
|
if (event.status) this.pullStatus = event.status;
|
||||||
if (event.completed != null) this.pullCompleted = event.completed;
|
if (event.completed != null) this.pullCompleted = event.completed;
|
||||||
if (event.total != null) this.pullTotal = event.total;
|
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) => {
|
error: (err) => {
|
||||||
this.errorMessage = this.extractError(err, `Echec du telechargement de ${name}.`);
|
this.errorMessage = this.extractError(err, `Echec du telechargement de ${name}.`);
|
||||||
@@ -300,6 +307,14 @@ export class SettingsComponent implements OnInit {
|
|||||||
},
|
},
|
||||||
complete: () => {
|
complete: () => {
|
||||||
this.pullInProgress = false;
|
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.successMessage = `Modele ${name} telecharge.`;
|
||||||
this.refreshModels();
|
this.refreshModels();
|
||||||
// Si l'utilisateur n'avait aucun modele, on selectionne celui-ci.
|
// Si l'utilisateur n'avait aucun modele, on selectionne celui-ci.
|
||||||
@@ -326,6 +341,7 @@ export class SettingsComponent implements OnInit {
|
|||||||
this.pullStatus = '';
|
this.pullStatus = '';
|
||||||
this.pullCompleted = 0;
|
this.pullCompleted = 0;
|
||||||
this.pullTotal = 0;
|
this.pullTotal = 0;
|
||||||
|
this.pullSucceeded = false;
|
||||||
if (this.pullSubscription) {
|
if (this.pullSubscription) {
|
||||||
this.pullSubscription.unsubscribe();
|
this.pullSubscription.unsubscribe();
|
||||||
this.pullSubscription = null;
|
this.pullSubscription = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user