4 Commits

Author SHA1 Message Date
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
addf78f01d Mise en place v0.6.8
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
Amélioration de l'installation automatique
Ajout de la possibilité de télécharger le llm que l'on veut à l'interieur de l'application en communicant avec ollama
2026-04-26 01:11:04 +02:00
5e04e84ee4 Mise à jour de la conf pour être sur que le cache angular est bien refresh
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Mise à jour des installeurs
Mise en place de secure-host pour ne pas exposer Ollama à l'exterieur
2026-04-26 00:18:49 +02:00
17 changed files with 1317 additions and 97 deletions

View File

@@ -689,6 +689,76 @@ async def get_ollama_model_info(
return OllamaModelInfoDTO(context_length=0)
@app.post("/models/ollama/pull")
async def pull_ollama_model(
body: dict[str, str],
settings: Annotated[Settings, Depends(get_settings)],
) -> StreamingResponse:
"""Telecharge un modele depuis Ollama et streame la progression.
Proxifie l'endpoint `/api/pull` d'Ollama qui renvoie du JSON ligne par
ligne (NDJSON) avec le statut de chaque etape : manifest, layers,
digest, success. On reemet ce flux tel quel au client (le front
parsera les lignes et affichera une barre de progression).
Le timeout est intentionnellement tres long (60 min) car certains
modeles font 30+ Go.
"""
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/pull"
async def stream() -> AsyncIterator[bytes]:
# On utilise un timeout long pour la lecture (60 min) mais court pour
# la connexion (10s) — si Ollama n'est pas joignable, on echoue vite.
timeout = httpx.Timeout(connect=10, read=3600, write=10, pool=10)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("POST", url, json={"model": name, "stream": True}) as r:
if r.status_code != 200:
# Ollama renvoie un message JSON d'erreur. On le passe
# tel quel au client en preservant le code HTTP.
body_text = await r.aread()
yield body_text
return
async for chunk in r.aiter_bytes():
yield chunk
except httpx.HTTPError as e:
# Erreur reseau : on emet une ligne JSON d'erreur compatible
# avec le format NDJSON d'Ollama.
err = json.dumps({"error": f"Connexion a Ollama impossible : {e}"}) + "\n"
yield err.encode("utf-8")
# application/x-ndjson : un objet JSON par ligne, pas de wrapping SSE.
# C'est le format natif d'Ollama, le front le parsera ligne par ligne.
return StreamingResponse(stream(), media_type="application/x-ndjson")
@app.delete("/models/ollama/{name:path}")
async def delete_ollama_model(
name: str,
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, str]:
"""Supprime un modele du serveur Ollama.
Le `:path` dans le pattern autorise les `:` du nom (ex: `gemma4:e4b`)
sans avoir besoin de URL-encoder cote client.
"""
if not name.strip():
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/delete"
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.request("DELETE", url, json={"model": name})
if response.status_code == 404:
raise HTTPException(status_code=404, detail=f"Modele '{name}' introuvable")
response.raise_for_status()
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Ollama injoignable : {e}")
return {"status": "deleted", "name": name}
@app.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.

View File

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

View File

@@ -7,7 +7,9 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -15,7 +17,17 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
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;
/**
@@ -66,11 +78,81 @@ public class SettingsController {
return forward(HttpMethod.POST, "/models/ollama/info", body);
}
/**
* Telecharge un modele Ollama et streame la progression au client.
* <p>
* On bypass RestTemplate (qui bufferise toute la reponse) au profit du
* client HTTP standard de Java en mode streaming. Le Brain renvoie du
* NDJSON ligne par ligne ; on relaie chaque chunk tel quel pour que le
* frontend voie la progression en temps reel.
*/
@PostMapping(value = "/models/ollama/pull", produces = "application/x-ndjson")
public ResponseEntity<StreamingResponseBody> pullOllamaModel(@RequestBody Map<String, Object> body) {
guardDemoMode();
StreamingResponseBody stream = output -> {
HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest req = 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();
try {
HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
try (InputStream in = resp.body()) {
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) != -1) {
output.write(buf, 0, n);
output.flush();
}
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IOException("Pull interrompu", ie);
}
};
return ResponseEntity.ok().contentType(MediaType.parseMediaType("application/x-ndjson")).body(stream);
}
@DeleteMapping("/models/ollama/{name}")
public ResponseEntity<Map<String, Object>> deleteOllamaModel(@PathVariable("name") String name) {
guardDemoMode();
return forward(HttpMethod.DELETE, "/models/ollama/" + name, null);
}
@GetMapping("/models/onemin")
public ResponseEntity<Map<String, Object>> listOneMinModels() {
return forward(HttpMethod.GET, "/models/onemin", null);
}
/**
* Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque
* appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des
* String/Number/Boolean en valeur.
*/
private static String toJson(Map<String, Object> m) {
StringBuilder sb = new StringBuilder("{");
boolean first = true;
for (Map.Entry<String, Object> e : m.entrySet()) {
if (!first) sb.append(",");
sb.append("\"").append(escape(e.getKey())).append("\":");
Object v = e.getValue();
if (v == null) sb.append("null");
else if (v instanceof Number || v instanceof Boolean) sb.append(v);
else sb.append("\"").append(escape(v.toString())).append("\"");
first = false;
}
return sb.append("}").toString();
}
private static String escape(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"")
.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t");
}
private void guardDemoMode() {
if (demoMode) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Settings disabled in demo mode");

View File

@@ -154,7 +154,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

@@ -5,18 +5,26 @@ et lancent la stack. Aucune configuration manuelle requise.
## Windows 10 / 11
Ouvrir **PowerShell** (clic droit → *Exécuter en tant qu'administrateur*) :
**Procédure recommandée :**
```powershell
iwr https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/installers/install.ps1 -OutFile $env:TEMP\loremind-install.ps1
powershell -ExecutionPolicy Bypass -File $env:TEMP\loremind-install.ps1
```
1. Téléchargez les trois fichiers suivants dans un même dossier
(par ex. `Téléchargements\LoreMind\`) :
- [`install.bat`](install.bat) — lanceur
- [`install.ps1`](install.ps1) — script principal
- [`secure-host-ollama.ps1`](secure-host-ollama.ps1) — *uniquement si vous avez déjà Ollama sur votre PC*
2. **Clic-droit** sur `install.bat`**Exécuter en tant qu'administrateur**.
3. Acceptez le prompt UAC.
Le script :
1. Vérifie / installe **WSL2** (un reboot peut être nécessaire — relancer le script après).
2. Vérifie / installe **Docker Desktop** via `winget`.
3. Génère `%LOCALAPPDATA%\LoreMind\.env` avec mots de passe aléatoires.
4. Lance la stack et ouvre `http://localhost:8081`.
3. Vous demande quelques choix (admin, fournisseur LLM, mode Ollama, mises à jour auto).
4. Génère `%LOCALAPPDATA%\LoreMind\.env` avec mots de passe aléatoires.
5. Lance la stack et ouvre `http://localhost:8081`.
Le `install.bat` sert juste à lancer `install.ps1` proprement (avec UAC + ExecutionPolicy
adaptée à la session, sans modifier les paramètres système). Il est purement
déclaratif et auditable en quelques lignes.
## Linux (Debian / Ubuntu / Fedora / Arch)
@@ -30,6 +38,96 @@ Le script :
3. Installe dans `~/.local/share/loremind`.
4. Lance la stack et ouvre `http://localhost:8081`.
## Mode Ollama (moteur LLM local)
Pendant l'installation, l'installeur pose deux questions successives pour
déterminer comment LoreMind utilisera Ollama :
### 1. *« Avez-vous déjà Ollama installé sur cette machine ? »*
#### Réponse : **Oui** → mode **hôte sécurisé**
L'installeur appelle automatiquement le helper `secure-host-ollama.{sh,ps1}`
qui configure votre Ollama existant pour qu'il soit joignable par le conteneur
Docker LoreMind **sans être exposé sur le réseau local ni Internet**.
- **Linux** : Ollama écoute sur l'IP de la passerelle Docker (`172.17.0.1`
par défaut). Cette IP n'est jamais routée hors de la machine. Override
systemd écrit dans `/etc/systemd/system/ollama.service.d/loremind-host.conf`.
- **Windows** : Ollama écoute sur `0.0.0.0` (techniquement nécessaire avec
Docker Desktop) mais le pare-feu Windows est configuré pour ne **laisser
passer que** le loopback et les sous-réseaux Docker Desktop. Règles
ajoutées préfixées `LoreMind-Ollama-*`.
L'URL configurée dans `.env` est `OLLAMA_BASE_URL=http://host.docker.internal:11434`.
#### Réponse : **Non** → l'installeur pose la question 2.
### 2. *« Voulez-vous installer Ollama via Docker maintenant ? »*
#### Réponse : **Oui (défaut)** → mode **embarqué**
Un service `ollama` est ajouté à la stack via le profile Docker `local-ollama`.
Ollama tourne dans un conteneur dédié, sur le réseau interne Docker, **jamais
exposé au LAN ni à Internet**. Les modèles sont stockés dans le volume
Docker `ollama-data` (persistants entre redémarrages et mises à jour).
- URL : `OLLAMA_BASE_URL=http://ollama:11434` (DNS interne Docker).
- Aucune configuration réseau ou pare-feu requise.
- Support GPU NVIDIA automatique si disponible.
Pour télécharger un modèle :
```bash
docker exec -it loremind-ollama ollama pull gemma3:27b
docker exec -it loremind-ollama ollama list
```
#### Réponse : **Non** → mode **différé**
Aucune configuration Ollama n'est appliquée. L'installeur termine sans
Ollama. Vous configurez Ollama plus tard via la page **Paramètres** de LoreMind
en y indiquant l'URL de votre serveur Ollama.
### Lancer le helper de sécurisation manuellement
Si vous avez choisi le mode différé puis installé Ollama plus tard sur votre
poste, ou si vous voulez basculer du mode embarqué vers le mode hôte :
**Linux :**
```bash
bash secure-host-ollama.sh
# Puis dans .env du dossier d'installation :
# OLLAMA_BASE_URL=http://host.docker.internal:11434
# Et : docker compose up -d
```
**Windows (PowerShell admin) :**
```powershell
.\secure-host-ollama.ps1
# Puis editez .env (dans %LOCALAPPDATA%\LoreMind\) :
# OLLAMA_BASE_URL=http://host.docker.internal:11434
# Et : docker compose up -d
```
Les helpers sont **réexécutables sans risque** : ils suppriment leurs
anciennes règles avant de les recréer. Utile par exemple si vous avez
réinitialisé Docker Desktop et que les sous-réseaux ont changé.
### Annuler la configuration de sécurisation
**Linux :**
```bash
sudo rm /etc/systemd/system/ollama.service.d/loremind-host.conf
sudo systemctl daemon-reload && sudo systemctl restart ollama
```
**Windows (PowerShell admin) :**
```powershell
Get-NetFirewallRule -DisplayName "LoreMind-Ollama-*" | Remove-NetFirewallRule
[Environment]::SetEnvironmentVariable("OLLAMA_HOST", $null, "User")
```
## Variables disponibles
| Variable | Défaut | Effet |

View File

@@ -40,7 +40,7 @@
Auteur : ietm64
Licence : AGPL-3.0
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
Version : 0.6.6
Version : 0.6.9
.LINK
https://git.igmlcreation.fr/ietm64/loremind
@@ -91,17 +91,36 @@ function Test-Docker {
return ($LASTEXITCODE -eq 0)
}
function Wait-Docker([int]$TimeoutSec = 180) {
function Wait-Docker([int]$TimeoutSec = 600) {
# Attend que Docker reponde. Tolere les erreurs "command not found" pendant
# les premieres iterations le temps que le PATH soit rafraichi.
Write-Step "Attente du demarrage de Docker Desktop (max ${TimeoutSec}s)..."
Write-Host " Si Docker Desktop affiche un contrat de licence, acceptez-le."
$deadline = (Get-Date).AddSeconds($TimeoutSec)
$reportedFound = $false
while ((Get-Date) -lt $deadline) {
docker info *>$null
if ($LASTEXITCODE -eq 0) { Write-Ok "Docker repond"; return $true }
Start-Sleep -Seconds 3
if (Get-Command docker -ErrorAction SilentlyContinue) {
if (-not $reportedFound) {
Write-Ok "Commande 'docker' detectee, attente du daemon..."
$reportedFound = $true
}
docker info *>$null
if ($LASTEXITCODE -eq 0) { Write-Ok "Docker repond"; return $true }
}
Start-Sleep -Seconds 5
}
return $false
}
function Update-PathFromRegistry {
# winget install ne propage pas les modifs de PATH a la session courante.
# On relit la valeur PATH depuis le registre (Machine + User) et on
# l'applique a $env:PATH pour rendre 'docker.exe' immediatement utilisable.
$machinePath = [Environment]::GetEnvironmentVariable('Path','Machine')
$userPath = [Environment]::GetEnvironmentVariable('Path','User')
$env:PATH = ($machinePath, $userPath -join ';').TrimEnd(';')
}
# ---------------------------------------------------------------------------
# 0. Verification des droits administrateur
# ---------------------------------------------------------------------------
@@ -159,12 +178,25 @@ if (Test-Docker) {
winget install --id Docker.DockerDesktop -e --accept-package-agreements --accept-source-agreements
if ($LASTEXITCODE -ne 0) { Write-Err "Echec de l'installation Docker Desktop via winget"; exit 1 }
# winget a modifie le PATH systeme mais pas celui de la session courante.
# On le rafraichit pour que la commande 'docker' soit immediatement trouvable.
Update-PathFromRegistry
Write-Step "Lancement de Docker Desktop..."
$dd = "$env:ProgramFiles\Docker\Docker\Docker Desktop.exe"
if (Test-Path $dd) { Start-Process $dd }
if (-not (Wait-Docker 240)) {
Write-Err "Docker n'a pas demarre. Lancez-le manuellement puis relancez ce script."
Write-Host ""
Write-Host " Docker Desktop demarre pour la premiere fois." -ForegroundColor Yellow
Write-Host " Au premier lancement, il affiche un contrat de licence (Subscription Service Agreement)."
Write-Host " Cliquez 'Accept' pour continuer."
Write-Host ""
Read-Host " Appuyez sur Entree une fois que Docker Desktop affiche 'Engine running' (icone baleine verte)"
if (-not (Wait-Docker 600)) {
Write-Err "Docker ne repond toujours pas apres 10 minutes."
Write-Err "Verifiez que Docker Desktop est lance et que vous avez accepte le contrat,"
Write-Err "puis relancez install.bat."
exit 1
}
}
@@ -213,41 +245,64 @@ if ($llmProvider -eq 'onemin' -and -not $NonInteractive) {
$onemKey = Read-Host " Cle API 1min.ai"
}
# --- Mode Ollama : embarque (defaut) vs hote -------------------------------
# Embarque : service 'ollama' du compose (profile local-ollama). Zero config reseau.
# Hote : Ollama deja installe sur la machine. Necessite OLLAMA_HOST=0.0.0.0
# pour que Docker Desktop puisse l'atteindre via host.docker.internal.
$useEmbeddedOllama = $true
# --- Mode Ollama : 3 options possibles -------------------------------------
# 1. Hote : Ollama est deja installe sur cette machine -> on configure le
# pare-feu pour que Docker puisse l'atteindre sans exposer le port.
# 2. Embarque : Ollama tourne dans un conteneur Docker dedie (profile local-ollama).
# 3. Aucun : on n'installe rien tout de suite. L'utilisateur configurera
# Ollama plus tard via la page Parametres de LoreMind.
$ollamaMode = 'embedded' # valeurs : 'host' | 'embedded' | 'none'
$ollamaBaseUrl = 'http://ollama:11434'
if ($llmProvider -eq 'ollama') {
$useEmbeddedOllama = if ($NonInteractive) { $true } else {
$hasHostOllama = if ($NonInteractive) { $false } else {
$r = Read-Host " Avez-vous deja Ollama installe sur cette machine ? [o/N]"
-not ($r -match '^(o|O|y|Y|oui|yes)$')
($r -match '^(o|O|y|Y|oui|yes)$')
}
if (-not $useEmbeddedOllama) {
$ollamaBaseUrl = 'http://host.docker.internal:11434'
Write-Step "Configuration d'Ollama hote..."
# Pour que le conteneur Docker puisse atteindre Ollama via host.docker.internal,
# Ollama doit ecouter sur 0.0.0.0 (et non 127.0.0.1 par defaut). On positionne
# la variable d'environnement utilisateur OLLAMA_HOST en consequence.
try {
[Environment]::SetEnvironmentVariable('OLLAMA_HOST','0.0.0.0:11434','User')
Write-Ok "Variable d'environnement utilisateur OLLAMA_HOST=0.0.0.0:11434 definie"
Write-Host ""
Write-Host " Pour que ce changement prenne effet, vous devez :" -ForegroundColor Yellow
Write-Host " 1. Quitter completement Ollama (icone systray > Quit Ollama)"
Write-Host " 2. Relancer Ollama"
Write-Host ""
Read-Host " Appuyez sur Entree une fois Ollama redemarre"
} catch {
Write-Warn2 "Impossible de definir OLLAMA_HOST automatiquement. Definissez-la manuellement (Parametres systeme > Variables d'environnement) puis redemarrez Ollama."
}
if ($hasHostOllama) {
$ollamaMode = 'host'
} else {
# Pas d'Ollama present : proposer l'installation Docker, sinon laisser
# l'utilisateur le configurer plus tard via la page Parametres.
$installViaDocker = if ($NonInteractive) { $true } else {
$r = Read-Host " Voulez-vous installer Ollama via Docker maintenant ? [O/n]"
-not ($r -match '^(n|N|no|non)$')
}
$ollamaMode = if ($installViaDocker) { 'embedded' } else { 'none' }
}
if ($ollamaMode -eq 'host') {
$ollamaBaseUrl = 'http://host.docker.internal:11434'
# Delegue au helper dedie : configure OLLAMA_HOST=0.0.0.0 ET ajoute des
# regles Windows Firewall qui n'autorisent l'acces qu'aux conteneurs
# Docker (loopback + sous-reseaux Docker Desktop). Resultat : Ollama
# n'est pas expose au LAN ni a Internet.
$secureHelper = Join-Path $PSScriptRoot 'secure-host-ollama.ps1'
if (Test-Path $secureHelper) {
Write-Step "Configuration securisee d'Ollama hote (helper dedie)..."
try {
& $secureHelper
} catch {
Write-Warn2 "Le helper secure-host-ollama.ps1 a echoue : $($_.Exception.Message)"
Write-Warn2 "Configurez Ollama manuellement avant de continuer."
}
Write-Host ""
Read-Host "Appuyez sur Entree une fois Ollama redemarre pour continuer l'installation"
} else {
Write-Warn2 "secure-host-ollama.ps1 introuvable a cote de install.ps1."
Write-Warn2 "Telechargez-le depuis le depot et relancez-le manuellement."
}
} elseif ($ollamaMode -eq 'embedded') {
Write-Ok "Ollama sera lance dans Docker (modeles dans un volume Docker dedie)"
} else {
# Mode 'none' : on cible host.docker.internal en supposant qu'Ollama
# sera installe plus tard sur l'hote. L'utilisateur peut aussi changer
# l'URL via la page Parametres pour pointer vers un Ollama distant.
$ollamaBaseUrl = 'http://host.docker.internal:11434'
Write-Warn2 "Aucun Ollama ne sera installe pour le moment. Configurez-le plus tard via la page Parametres de LoreMind."
}
}
$llmModel = 'gemma4:26b'
$llmModel = 'gemma4:e4b'
$autoUpdate = if ($NonInteractive) { $true } else {
$r = Read-Host " Activer les mises a jour auto (chaque nuit a 4h) ? [O/n]"
@@ -255,8 +310,8 @@ $autoUpdate = if ($NonInteractive) { $true } else {
}
# Combinaison de profiles : autoupdate et/ou local-ollama (separes par virgule).
$profilesList = @()
if ($autoUpdate) { $profilesList += 'autoupdate' }
if ($useEmbeddedOllama -and $llmProvider -eq 'ollama') { $profilesList += 'local-ollama' }
if ($autoUpdate) { $profilesList += 'autoupdate' }
if ($ollamaMode -eq 'embedded' -and $llmProvider -eq 'ollama'){ $profilesList += 'local-ollama' }
$composeProfiles = $profilesList -join ','
$envContent = @"
@@ -305,6 +360,43 @@ Write-Step "Demarrage de la stack"
docker compose up -d
if ($LASTEXITCODE -ne 0) { Write-Err "Echec docker compose up"; exit 1 }
# ---------------------------------------------------------------------------
# 5b. Telechargement du modele Ollama (mode embarque uniquement)
# ---------------------------------------------------------------------------
# En mode embarque, le conteneur Ollama est prêt mais ne contient aucun modele
# par defaut. On propose de pull le modele configure tout de suite pour que
# l'utilisateur ait quelque chose a utiliser des le premier lancement.
if ($ollamaMode -eq 'embedded' -and $llmProvider -eq 'ollama') {
$pullNow = if ($NonInteractive) { $true } else {
$r = Read-Host " Telecharger le modele '$llmModel' maintenant ? (peut prendre quelques minutes) [O/n]"
-not ($r -match '^(n|N|no|non)$')
}
if ($pullNow) {
# Petite attente pour laisser le conteneur ollama finir son init.
Write-Step "Attente de la disponibilite du conteneur Ollama..."
$ollamaReady = $false
for ($i = 0; $i -lt 30; $i++) {
docker exec loremind-ollama ollama list *>$null
if ($LASTEXITCODE -eq 0) { $ollamaReady = $true; break }
Start-Sleep -Seconds 2
}
if (-not $ollamaReady) {
Write-Warn2 "Le conteneur Ollama ne repond pas encore. Vous pourrez pull le modele plus tard avec :"
Write-Warn2 " docker exec -it loremind-ollama ollama pull $llmModel"
} else {
Write-Step "Telechargement du modele $llmModel (peut prendre plusieurs minutes selon votre connexion)..."
docker exec loremind-ollama ollama pull $llmModel
if ($LASTEXITCODE -eq 0) {
Write-Ok "Modele $llmModel pret a l'emploi"
} else {
Write-Warn2 "Echec du pull. Reessayez manuellement : docker exec -it loremind-ollama ollama pull $llmModel"
}
}
} else {
Write-Host " Pour le telecharger plus tard : docker exec -it loremind-ollama ollama pull $llmModel"
}
}
# ---------------------------------------------------------------------------
# 6. Recap
# ---------------------------------------------------------------------------
@@ -323,13 +415,19 @@ if ($autoUpdate) {
Write-Host " Auto-update : desactive (mise a jour manuelle uniquement)"
}
if ($llmProvider -eq 'ollama') {
if ($useEmbeddedOllama) {
Write-Host " Ollama : embarque (service Docker 'ollama')" -ForegroundColor Green
Write-Host ""
Write-Host " IMPORTANT : telechargez un modele avant utilisation :"
Write-Host " docker exec -it loremind-ollama ollama pull $llmModel"
} else {
Write-Host " Ollama : hote (http://host.docker.internal:11434)"
switch ($ollamaMode) {
'embedded' {
Write-Host " Ollama : embarque (service Docker 'ollama')" -ForegroundColor Green
Write-Host ""
Write-Host " IMPORTANT : telechargez un modele avant utilisation :"
Write-Host " docker exec -it loremind-ollama ollama pull $llmModel"
}
'host' {
Write-Host " Ollama : hote (configure via secure-host-ollama.ps1)"
}
'none' {
Write-Host " Ollama : non configure - a faire via Parametres dans l'app" -ForegroundColor Yellow
}
}
}
Write-Host ""

View File

@@ -123,37 +123,54 @@ if [ "$LLM_PROVIDER" = "onemin" ] && [ "$NON_INTERACTIVE" != "1" ]; then
ONEMIN_API_KEY="$(ask "Cle API 1min.ai" "")"
fi
# --- Mode Ollama : embarque (defaut) vs hote -------------------------------
# Embarque : service 'ollama' du compose (profile local-ollama). Zero config reseau.
# Hote : Ollama deja installe sur la machine. Necessite OLLAMA_HOST=0.0.0.0
# via override systemd pour que le conteneur Brain l'atteigne.
USE_EMBEDDED_OLLAMA=1
# --- Mode Ollama : 3 options possibles -------------------------------------
# 1. host : Ollama deja installe sur la machine -> helper de securisation
# 2. embedded : service 'ollama' du compose (profile local-ollama)
# 3. none : aucune installation, configuration ulterieure via l'app
OLLAMA_MODE="embedded"
OLLAMA_BASE_URL_VAL="http://ollama:11434"
LLM_MODEL_VAL="gemma4:26b"
LLM_MODEL_VAL="gemma4:e4b"
if [ "$LLM_PROVIDER" = "ollama" ]; then
HOST_OLLAMA_REPLY="$(ask "Avez-vous deja Ollama installe sur cette machine ? [o/N]" "N")"
case "$HOST_OLLAMA_REPLY" in
o|O|y|Y|oui|yes|Oui|Yes)
USE_EMBEDDED_OLLAMA=0
OLLAMA_BASE_URL_VAL="http://host.docker.internal:11434"
step "Configuration d'Ollama hote (OLLAMA_HOST=0.0.0.0:11434)..."
if systemctl list-unit-files 2>/dev/null | grep -q '^ollama\.service'; then
sudo mkdir -p /etc/systemd/system/ollama.service.d
sudo tee /etc/systemd/system/ollama.service.d/loremind-host.conf >/dev/null <<EOF
[Service]
Environment="OLLAMA_HOST=0.0.0.0:11434"
EOF
sudo systemctl daemon-reload
sudo systemctl restart ollama
ok "Service systemd ollama redemarre avec OLLAMA_HOST=0.0.0.0:11434"
else
warn "Service systemd 'ollama' introuvable. Definissez OLLAMA_HOST=0.0.0.0:11434 manuellement avant de relancer Ollama."
fi
OLLAMA_MODE="host"
;;
*)
USE_EMBEDDED_OLLAMA=1
# Pas d'Ollama present : proposer l'installation Docker.
INSTALL_DOCKER_REPLY="$(ask "Voulez-vous installer Ollama via Docker maintenant ? [O/n]" "O")"
case "$INSTALL_DOCKER_REPLY" in
n|N|no|non|No|Non) OLLAMA_MODE="none" ;;
*) OLLAMA_MODE="embedded" ;;
esac
;;
esac
case "$OLLAMA_MODE" in
host)
OLLAMA_BASE_URL_VAL="http://host.docker.internal:11434"
# Delegue la configuration securisee au helper dedie : il fait
# ecouter Ollama uniquement sur l'IP du bridge Docker (jamais
# exposee au LAN ni a Internet) plutot que sur 0.0.0.0.
SECURE_HELPER="$(dirname -- "$0")/secure-host-ollama.sh"
if [ -f "$SECURE_HELPER" ]; then
step "Configuration securisee d'Ollama hote..."
bash "$SECURE_HELPER" || warn "Le helper secure-host-ollama.sh a echoue. Configurez Ollama manuellement."
else
warn "secure-host-ollama.sh introuvable a cote de install.sh."
warn "Telechargez-le depuis le depot et relancez : bash secure-host-ollama.sh"
fi
;;
embedded)
ok "Ollama sera lance dans Docker (modeles dans un volume Docker)"
;;
none)
# On cible host.docker.internal par defaut en supposant qu'Ollama
# sera installe plus tard sur l'hote. L'utilisateur peut aussi
# changer l'URL via la page Parametres pour un Ollama distant.
OLLAMA_BASE_URL_VAL="http://host.docker.internal:11434"
warn "Aucun Ollama ne sera installe pour le moment. Configurez-le plus tard via la page Parametres de LoreMind."
;;
esac
fi
@@ -166,7 +183,7 @@ esac
# Combinaison de profiles : autoupdate et/ou local-ollama (separes par virgule).
PROFILES_ARR=()
[ "$AUTO_UPDATE" = "1" ] && PROFILES_ARR+=("autoupdate")
if [ "$LLM_PROVIDER" = "ollama" ] && [ "$USE_EMBEDDED_OLLAMA" = "1" ]; then
if [ "$LLM_PROVIDER" = "ollama" ] && [ "$OLLAMA_MODE" = "embedded" ]; then
PROFILES_ARR+=("local-ollama")
fi
COMPOSE_PROFILES="$(IFS=,; echo "${PROFILES_ARR[*]}")"
@@ -211,6 +228,41 @@ docker compose pull
step "Demarrage de la stack"
docker compose up -d
# 5b. Telechargement du modele Ollama (mode embarque uniquement)
# ----------------------------------------------------------------------------
# Le conteneur Ollama est pret mais sans modele. On propose le pull tout de
# suite pour que l'utilisateur ait quelque chose a utiliser au premier lancement.
if [ "$LLM_PROVIDER" = "ollama" ] && [ "$OLLAMA_MODE" = "embedded" ]; then
PULL_REPLY="$(ask "Telecharger le modele '${LLM_MODEL_VAL}' maintenant ? (peut prendre plusieurs minutes) [O/n]" "O")"
case "$PULL_REPLY" in
n|N|no|non|No|Non)
echo " Pour le telecharger plus tard : docker exec -it loremind-ollama ollama pull ${LLM_MODEL_VAL}"
;;
*)
step "Attente de la disponibilite du conteneur Ollama..."
OLLAMA_READY=0
for i in $(seq 1 30); do
if docker exec loremind-ollama ollama list >/dev/null 2>&1; then
OLLAMA_READY=1
break
fi
sleep 2
done
if [ "$OLLAMA_READY" = "0" ]; then
warn "Le conteneur Ollama ne repond pas encore. Vous pourrez pull plus tard :"
warn " docker exec -it loremind-ollama ollama pull ${LLM_MODEL_VAL}"
else
step "Telechargement du modele ${LLM_MODEL_VAL} (peut prendre plusieurs minutes selon votre connexion)..."
if docker exec loremind-ollama ollama pull "${LLM_MODEL_VAL}"; then
ok "Modele ${LLM_MODEL_VAL} pret a l'emploi"
else
warn "Echec du pull. Reessayez : docker exec -it loremind-ollama ollama pull ${LLM_MODEL_VAL}"
fi
fi
;;
esac
fi
# 6. Recap
URL="http://localhost:${WEB_PORT}"
echo
@@ -227,14 +279,20 @@ else
echo " Auto-update : desactive (mise a jour manuelle uniquement)"
fi
if [ "$LLM_PROVIDER" = "ollama" ]; then
if [ "$USE_EMBEDDED_OLLAMA" = "1" ]; then
echo -e " Ollama : ${c_green}embarque${c_off} (service Docker 'ollama')"
echo
echo " IMPORTANT : telechargez un modele avant utilisation :"
echo " docker exec -it loremind-ollama ollama pull ${LLM_MODEL_VAL}"
else
echo " Ollama : hote (http://host.docker.internal:11434)"
fi
case "$OLLAMA_MODE" in
embedded)
echo -e " Ollama : ${c_green}embarque${c_off} (service Docker 'ollama')"
echo
echo " IMPORTANT : telechargez un modele avant utilisation :"
echo " docker exec -it loremind-ollama ollama pull ${LLM_MODEL_VAL}"
;;
host)
echo " Ollama : hote (configure via secure-host-ollama.sh)"
;;
none)
echo -e " Ollama : ${c_yellow}non configure${c_off} - a faire via Parametres dans l'app"
;;
esac
fi
echo
echo " Commandes utiles (depuis $INSTALL_DIR) :"

View File

@@ -0,0 +1,183 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Configuration securisee d'Ollama hote pour LoreMindMJ (Windows).
.DESCRIPTION
But : permettre au conteneur Docker LoreMind d'atteindre l'Ollama installe
sur l'hote, SANS exposer Ollama sur le LAN ni Internet.
Strategie (specifique a Docker Desktop / WSL2 sur Windows) :
1. Ollama doit ecouter sur 0.0.0.0 (techniquement necessaire car Docker
Desktop sur Windows utilise un reseau Hyper-V / WSL2 separe).
2. On compense en ajoutant des regles Windows Firewall qui :
- BLOQUENT le port 11434 entrant par defaut sur tout profil
- AUTORISENT 11434 uniquement depuis les sous-reseaux Docker Desktop
(detectes dynamiquement) et depuis le loopback.
Resultat : Ollama est joignable par les conteneurs Docker mais
inaccessible depuis le reseau local ou Internet.
.NOTES
Ce script doit etre execute en tant qu'administrateur.
Les regles ajoutees sont prefixees par "LoreMind-Ollama-" pour
faciliter leur identification et suppression ulterieure.
.LINK
https://git.igmlcreation.fr/ietm64/loremind
#>
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
function Write-Ok($msg) { Write-Host " OK $msg" -ForegroundColor Green }
function Write-Warn2($msg) { Write-Host " !! $msg" -ForegroundColor Yellow }
function Write-Err($msg) { Write-Host " XX $msg" -ForegroundColor Red }
# --- 1. Verification admin -------------------------------------------------
$current = [Security.Principal.WindowsIdentity]::GetCurrent()
$isAdmin = ([Security.Principal.WindowsPrincipal]$current).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Err "Ce script doit etre execute en tant qu'administrateur."
Write-Host ""
Write-Host "Procedure : clic-droit sur PowerShell > 'Executer en tant qu'administrateur',"
Write-Host "puis relancez ce script."
Read-Host "Appuyez sur Entree pour quitter"
exit 1
}
# --- 2. Detection des sous-reseaux Docker Desktop --------------------------
Write-Step "Detection des sous-reseaux utilises par Docker Desktop..."
$dockerSubnets = @()
# Methode 1 : interroger Docker pour les bridges actifs.
try {
$networks = docker network ls --filter driver=bridge --format "{{.Name}}" 2>$null
foreach ($net in $networks) {
if ([string]::IsNullOrWhiteSpace($net)) { continue }
$subnet = docker network inspect $net -f "{{range .IPAM.Config}}{{.Subnet}}{{end}}" 2>$null
if (-not [string]::IsNullOrWhiteSpace($subnet)) {
$dockerSubnets += $subnet.Trim()
}
}
} catch {
Write-Warn2 "Impossible d'interroger Docker pour les sous-reseaux. Utilisation des plages par defaut."
}
# Methode 2 : interfaces vEthernet (WSL/DockerNAT) detectees par Windows.
try {
$wslInterfaces = Get-NetIPConfiguration -ErrorAction SilentlyContinue |
Where-Object { $_.InterfaceAlias -match 'vEthernet \(WSL|vEthernet \(Default Switch|vEthernet \(Docker' }
foreach ($iface in $wslInterfaces) {
$ipv4 = $iface.IPv4Address
if ($ipv4 -and $ipv4.IPAddress) {
# On deduit un /24 a partir de l'adresse de l'interface (approximation safe).
$octets = $ipv4.IPAddress.Split('.')
$subnet = "{0}.{1}.{2}.0/24" -f $octets[0], $octets[1], $octets[2]
$dockerSubnets += $subnet
}
}
} catch { }
# Methode 3 : fallback sur les plages connues de Docker Desktop si rien detecte.
if ($dockerSubnets.Count -eq 0) {
Write-Warn2 "Aucun sous-reseau Docker detecte. Utilisation des plages par defaut Docker Desktop."
$dockerSubnets = @(
"172.16.0.0/12", # Plage standard des reseaux bridge Docker
"192.168.65.0/24" # Plage WSL2 / Docker Desktop frequente
)
}
# Deduplication et nettoyage.
$dockerSubnets = $dockerSubnets | Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+/\d+$' } | Select-Object -Unique
Write-Ok "Sous-reseaux autorises : $($dockerSubnets -join ', ')"
# --- 3. Variable d'environnement OLLAMA_HOST -------------------------------
Write-Step "Configuration de la variable OLLAMA_HOST..."
[Environment]::SetEnvironmentVariable('OLLAMA_HOST','0.0.0.0:11434','User')
Write-Ok "OLLAMA_HOST=0.0.0.0:11434 definie au niveau utilisateur"
# --- 4. Suppression des anciennes regles LoreMind --------------------------
Write-Step "Nettoyage des anciennes regles Windows Firewall LoreMind..."
$oldRules = Get-NetFirewallRule -DisplayName "LoreMind-Ollama-*" -ErrorAction SilentlyContinue
if ($oldRules) {
$oldRules | Remove-NetFirewallRule
Write-Ok "$($oldRules.Count) ancienne(s) regle(s) supprimee(s)"
} else {
Write-Ok "Aucune ancienne regle a supprimer"
}
# --- 5. Creation des regles --------------------------------------------------
Write-Step "Creation des regles Windows Firewall..."
# 5a. Regle de blocage par defaut (priorite la plus basse en cas de conflit :
# les regles Allow ont priorite sur les Block dans Windows Firewall, donc
# ce Block sert de filet final pour tout ce qui n'est pas explicitement
# autorise par les regles ci-dessous).
New-NetFirewallRule `
-DisplayName "LoreMind-Ollama-Block-All" `
-Description "LoreMind: bloque toute connexion entrante Ollama par defaut" `
-Direction Inbound `
-Action Block `
-Protocol TCP `
-LocalPort 11434 `
-Profile Any `
-RemoteAddress Any | Out-Null
Write-Ok "Regle Block-All (port 11434) creee"
# 5b. Regle d'autorisation : loopback uniquement.
New-NetFirewallRule `
-DisplayName "LoreMind-Ollama-Allow-Loopback" `
-Description "LoreMind: autorise Ollama depuis 127.0.0.1" `
-Direction Inbound `
-Action Allow `
-Protocol TCP `
-LocalPort 11434 `
-Profile Any `
-RemoteAddress "127.0.0.1" | Out-Null
Write-Ok "Regle Allow-Loopback creee"
# 5c. Regles d'autorisation : sous-reseaux Docker Desktop.
foreach ($subnet in $dockerSubnets) {
$safeName = "LoreMind-Ollama-Allow-Docker-$($subnet -replace '[\./]','_')"
New-NetFirewallRule `
-DisplayName $safeName `
-Description "LoreMind: autorise Ollama depuis le sous-reseau Docker $subnet" `
-Direction Inbound `
-Action Allow `
-Protocol TCP `
-LocalPort 11434 `
-Profile Any `
-RemoteAddress $subnet | Out-Null
Write-Ok "Regle Allow-Docker creee pour $subnet"
}
# --- 6. Redemarrage Ollama -------------------------------------------------
Write-Step "Redemarrage d'Ollama pour appliquer OLLAMA_HOST..."
Write-Host ""
Write-Host " Pour que la variable d'environnement prenne effet, vous devez :" -ForegroundColor Yellow
Write-Host " 1. Quitter completement Ollama (icone systray > Quit Ollama)"
Write-Host " 2. Le relancer depuis le menu Demarrer"
Write-Host ""
# --- 7. Recap --------------------------------------------------------------
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Ollama hote configure de maniere securisee" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Adresse d'ecoute : 0.0.0.0:11434 (toutes interfaces)"
Write-Host " Pare-feu Windows : bloque par defaut, autorise loopback + Docker"
Write-Host " Inaccessible depuis : LAN, WiFi public, Internet"
Write-Host ""
Write-Host " Pour LoreMind, definissez dans le fichier .env :"
Write-Host " OLLAMA_BASE_URL=http://host.docker.internal:11434"
Write-Host ""
Write-Host " Pour annuler cette configuration :"
Write-Host ' Get-NetFirewallRule -DisplayName "LoreMind-Ollama-*" | Remove-NetFirewallRule'
Write-Host ' [Environment]::SetEnvironmentVariable("OLLAMA_HOST",$null,"User")'
Write-Host ""

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# ============================================================================
# LoreMindMJ - Configuration securisee d'Ollama hote (Linux)
# ----------------------------------------------------------------------------
# But : permettre au conteneur Docker de LoreMind d'atteindre l'Ollama
# installe sur l'hote, SANS l'exposer sur le LAN ni Internet.
#
# Strategie : faire ecouter Ollama uniquement sur l'IP de la passerelle du
# bridge Docker (typiquement 172.17.0.1). Cette IP n'est jamais
# routee en dehors de la machine — seuls les conteneurs Docker
# peuvent l'atteindre.
#
# Ce script peut etre lance independamment de install.sh, par ex. si vous
# avez initialement choisi le mode "Ollama embarque" et changez d'avis.
#
# Usage : bash secure-host-ollama.sh
# ============================================================================
set -euo pipefail
c_cyan='\033[1;36m'; c_green='\033[1;32m'; c_yellow='\033[1;33m'; c_red='\033[1;31m'; c_off='\033[0m'
step() { echo -e "${c_cyan}==> $*${c_off}"; }
ok() { echo -e " ${c_green}OK${c_off} $*"; }
warn() { echo -e " ${c_yellow}!!${c_off} $*"; }
err() { echo -e " ${c_red}XX${c_off} $*" >&2; }
# --- 1. Verifications prealables -------------------------------------------
if ! command -v docker >/dev/null 2>&1; then
err "Docker introuvable. Installez Docker avant de lancer ce script."
exit 1
fi
if ! command -v systemctl >/dev/null 2>&1; then
err "systemctl introuvable. Ce script suppose un systeme avec systemd."
err "Configurez OLLAMA_HOST manuellement selon votre init system."
exit 1
fi
if ! systemctl list-unit-files 2>/dev/null | grep -q '^ollama\.service'; then
err "Service systemd 'ollama' introuvable."
err "Installez Ollama via le script officiel : curl -fsSL https://ollama.com/install.sh | sh"
exit 1
fi
# --- 2. Detection de l'IP de la passerelle Docker --------------------------
step "Detection de l'IP du bridge Docker..."
BRIDGE_IP=""
# Methode 1 : docker network inspect (la plus fiable)
if BRIDGE_IP="$(docker network inspect bridge -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}' 2>/dev/null)"; then
if [ -n "$BRIDGE_IP" ]; then
ok "IP du bridge Docker detectee via docker network inspect : $BRIDGE_IP"
fi
fi
# Methode 2 : interface docker0 (si docker network inspect echoue)
if [ -z "$BRIDGE_IP" ] && command -v ip >/dev/null 2>&1; then
BRIDGE_IP="$(ip -4 addr show docker0 2>/dev/null | awk '/inet / {print $2}' | cut -d/ -f1 | head -1)"
if [ -n "$BRIDGE_IP" ]; then
ok "IP du bridge Docker detectee via interface docker0 : $BRIDGE_IP"
fi
fi
# Methode 3 : valeur par defaut (compatible avec 99% des installations)
if [ -z "$BRIDGE_IP" ]; then
BRIDGE_IP="172.17.0.1"
warn "Detection automatique echouee, utilisation de la valeur par defaut : $BRIDGE_IP"
warn "Si Docker n'a jamais ete demarre sur cette machine, lancez 'docker info' une fois pour creer le bridge."
fi
# --- 3. Ecriture de l'override systemd -------------------------------------
step "Configuration du service systemd Ollama..."
OVERRIDE_DIR="/etc/systemd/system/ollama.service.d"
OVERRIDE_FILE="$OVERRIDE_DIR/loremind-host.conf"
sudo mkdir -p "$OVERRIDE_DIR"
sudo tee "$OVERRIDE_FILE" >/dev/null <<EOF
# Genere par LoreMind secure-host-ollama.sh
# Lie Ollama exclusivement a l'IP de la passerelle Docker.
# Consequence : Ollama est joignable depuis les conteneurs Docker
# (via host.docker.internal) mais PAS depuis le LAN ni Internet.
# Pour revenir a la configuration par defaut : sudo rm $OVERRIDE_FILE && sudo systemctl daemon-reload && sudo systemctl restart ollama
[Service]
Environment="OLLAMA_HOST=$BRIDGE_IP:11434"
EOF
ok "Override ecrit : $OVERRIDE_FILE"
# --- 4. Rechargement et redemarrage ----------------------------------------
step "Rechargement de la configuration systemd..."
sudo systemctl daemon-reload
ok "daemon-reload effectue"
step "Redemarrage du service Ollama..."
sudo systemctl restart ollama
sleep 2
if sudo systemctl is-active --quiet ollama; then
ok "Ollama redemarre et actif"
else
err "Ollama n'a pas redemarre correctement. Verifiez : sudo journalctl -u ollama -n 50"
exit 1
fi
# --- 5. Verification du binding --------------------------------------------
step "Verification : Ollama doit ecouter sur $BRIDGE_IP:11434..."
sleep 1
if command -v ss >/dev/null 2>&1; then
if ss -tln 2>/dev/null | grep -q "$BRIDGE_IP:11434"; then
ok "Ollama ecoute bien sur $BRIDGE_IP:11434"
else
warn "Verification impossible (ss n'a pas trouve le binding). Cela peut etre normal si le service vient juste de demarrer."
fi
fi
# --- 6. Recap --------------------------------------------------------------
echo
echo -e "${c_green}============================================================${c_off}"
echo -e "${c_green} Ollama hote configure de maniere securisee${c_off}"
echo -e "${c_green}============================================================${c_off}"
echo " Adresse d'ecoute : $BRIDGE_IP:11434"
echo " Accessible depuis : conteneurs Docker uniquement (via host.docker.internal)"
echo " Inaccessible depuis : LAN, WiFi public, Internet"
echo
echo " Pour LoreMind, definissez dans le fichier .env :"
echo " OLLAMA_BASE_URL=http://host.docker.internal:11434"
echo
echo " Pour annuler cette configuration :"
echo " sudo rm $OVERRIDE_FILE"
echo " sudo systemctl daemon-reload && sudo systemctl restart ollama"
echo

View File

@@ -20,6 +20,24 @@ server {
proxy_send_timeout 300s;
}
# index.html : toujours revalide. Empeche un navigateur qui a precedemment
# visite une autre instance LoreMind (demo en ligne, dev local, etc.) de
# servir une vieille version cachee a la place de l'app reelle.
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
expires 0;
try_files $uri =404;
}
# Assets Angular avec hash dans le nom (main.<hash>.js, etc.) :
# immuables, peuvent etre caches longtemps.
location ~* \.(?:js|css|woff2?|ttf|svg|png|jpg|jpeg|webp|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable" always;
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}

4
web/package-lock.json generated
View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { ConfigService } from '../services/config.service';
/**
* Detecte la perte de session demo (orchestrateur) via les codes 401/502 sur
@@ -7,9 +9,10 @@ import { catchError, throwError } from 'rxjs';
* Le reload renvoie l'utilisateur sur la page "Preparation" pour creer une
* nouvelle session sans qu'il ait a faire Ctrl+Shift+R.
*
* Cet interceptor est inerte en mode normal (non-demo) : si le backend natif
* renvoie un 401 legitime, ca declenche aussi le reload, ce qui est sans
* consequence puisqu'aucun flux d'auth utilisateur n'existe encore cote app.
* Strictement inerte hors mode demo : sans cette garde, un 401 du backend
* natif (par ex. HTTP Basic sur /api/settings avant authentification) ou un
* 502 transitoire au boot (Brain pas encore pret) declencherait a tort
* l'overlay "session demo expiree" sur les installs self-hosted.
*/
// Module-level flag : evite de declencher overlay + reload plusieurs fois si
@@ -17,8 +20,16 @@ import { catchError, throwError } from 'rxjs';
let alreadyTriggered = false;
export const sessionExpiredInterceptor: HttpInterceptorFn = (req, next) => {
const config = inject(ConfigService);
return next(req).pipe(
catchError((err) => {
// Garde stricte : l'overlay et le reload n'ont de sens qu'en mode demo,
// ou l'orchestrateur drop les sessions sans prevenir le client.
if (!config.demoMode) {
return throwError(() => err);
}
const isApiCall = req.url.includes('/api/');
const isSessionLoss =
err instanceof HttpErrorResponse && (err.status === 401 || err.status === 502);

View File

@@ -65,6 +65,86 @@ export class SettingsService {
listOneMinModels(): Observable<{ groups: OneMinModelGroup[] }> {
return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions);
}
/**
* Telecharge un modele Ollama et streame la progression au client.
*
* Le backend renvoie du NDJSON (un objet JSON par ligne) avec le format
* Ollama natif : `{status, digest?, total?, completed?}`. On parse chaque
* ligne au fur et a mesure et on emet via un Observable que le composant
* peut consommer pour mettre a jour une barre de progression.
*
* On utilise `fetch` directement plutot que `HttpClient` car Angular
* bufferise les reponses XHR, ce qui empeche le streaming en temps reel.
*/
pullOllamaModel(name: string): Observable<OllamaPullEvent> {
return new Observable<OllamaPullEvent>((subscriber) => {
const controller = new AbortController();
(async () => {
try {
const response = await fetch(`${this.apiUrl}/models/ollama/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name }),
signal: controller.signal,
});
if (!response.ok || !response.body) {
subscriber.error(new Error(`HTTP ${response.status}`));
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Decoupage NDJSON : chaque ligne est un objet JSON complet.
let nl: number;
while ((nl = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line) continue;
try {
subscriber.next(JSON.parse(line) as OllamaPullEvent);
} catch {
// Ligne non JSON (rare) : on l'ignore.
}
}
}
subscriber.complete();
} catch (err) {
if ((err as Error).name !== 'AbortError') subscriber.error(err);
}
})();
return () => controller.abort();
});
}
deleteOllamaModel(name: string): Observable<{ status: string; name: string }> {
return this.http.delete<{ status: string; name: string }>(
`${this.apiUrl}/models/ollama/${encodeURIComponent(name)}`, this.authOptions);
}
}
/**
* Format des evenements emis par Ollama pendant un pull. Les champs sont
* optionnels car le serveur emet differents types de messages selon l'etape :
* - `{status: "pulling manifest"}`
* - `{status: "downloading", digest, total, completed}`
* - `{status: "verifying sha256 digest"}`
* - `{status: "writing manifest"}`
* - `{status: "removing any unused layers"}`
* - `{status: "success"}`
* - `{error: "..."}` en cas d'erreur (modele inexistant, reseau, etc.)
*/
export interface OllamaPullEvent {
status?: string;
digest?: string;
total?: number;
completed?: number;
error?: string;
}
/** Un groupe de modeles 1min.ai regroupes par fournisseur (Anthropic, OpenAI, ...). */

View File

@@ -56,11 +56,91 @@
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ loadingModels ? 'Chargement...' : 'Actualiser' }}</span>
</button>
<button type="button" class="btn-secondary" (click)="openPullDialog()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
<span>Telecharger</span>
</button>
</div>
<p class="hint" *ngIf="ollamaModels.length === 0">Aucun modele detecte. Verifie que Ollama tourne et que l'URL est correcte.</p>
</div>
<!-- Liste des modeles installes avec bouton supprimer -->
<div class="form-row" *ngIf="ollamaModels.length > 0">
<label>Modeles installes</label>
<ul class="installed-models">
<li *ngFor="let m of ollamaModels">
<span class="model-name">{{ m }}</span>
<button type="button" class="btn-icon btn-danger"
(click)="deleteModel(m)"
[disabled]="deletingModel === m"
[title]="'Supprimer ' + m">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</li>
</ul>
</div>
</section>
<!-- Dialog de telechargement de modele -->
<div class="modal-overlay" *ngIf="pullDialogOpen" (click)="closePullDialog()">
<div class="modal-content" (click)="$event.stopPropagation()">
<header class="modal-header">
<h3>Telecharger un modele Ollama</h3>
<button type="button" class="btn-icon" (click)="closePullDialog()" [disabled]="pullInProgress" title="Fermer">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
</header>
<div class="modal-body">
<div *ngIf="!pullInProgress">
<label for="pull-name">Nom du modele</label>
<input id="pull-name" type="text" [(ngModel)]="pullModelName"
placeholder="ex: gemma4:e4b" autocomplete="off"
(keydown.enter)="startPull()">
<p class="hint">Suggestions :</p>
<div class="suggestions">
<button type="button" *ngFor="let s of pullSuggestions"
class="suggestion-chip" (click)="selectSuggestion(s)">{{ s }}</button>
</div>
<p class="hint" style="margin-top: 0.75rem;">
La liste complete est sur <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a>.
</p>
</div>
<div *ngIf="pullInProgress" class="pull-progress">
<div class="pull-status">{{ pullStatus }}</div>
<div class="progress-bar" *ngIf="pullTotal > 0">
<div class="progress-fill" [style.width.%]="pullPercent"></div>
</div>
<div class="progress-text" *ngIf="pullTotal > 0">
{{ formatBytes(pullCompleted) }} / {{ formatBytes(pullTotal) }} ({{ pullPercent }}%)
</div>
<div class="progress-text" *ngIf="pullTotal === 0">
Preparation...
</div>
</div>
</div>
<footer class="modal-footer">
<button type="button" class="btn-secondary"
(click)="cancelPull()" *ngIf="pullInProgress">
Annuler
</button>
<button type="button" class="btn-secondary"
(click)="closePullDialog()" *ngIf="!pullInProgress">
Fermer
</button>
<button type="button" class="btn-primary"
(click)="startPull()"
[disabled]="pullInProgress || !pullModelName.trim()" *ngIf="!pullInProgress">
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
<span>Telecharger</span>
</button>
</footer>
</div>
</div>
<!-- Bloc 1min.ai -->
<section class="card" *ngIf="settings && settings.llm_provider === 'onemin'">
<h2>Configuration 1min.ai</h2>
@@ -141,13 +221,6 @@
</div>
</section>
<div class="actions" *ngIf="settings">
<button class="btn-primary" (click)="save()" [disabled]="saving">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
<span>{{ saving ? 'Sauvegarde...' : 'Sauvegarder' }}</span>
</button>
</div>
<!-- Bloc Mises a jour -->
<section class="card" *ngIf="config.updateCheckEnabled">
<h2>Mises a jour</h2>
@@ -198,4 +271,11 @@
</div>
</section>
<div class="actions" *ngIf="settings">
<button class="btn-primary" (click)="save()" [disabled]="saving">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
<span>{{ saving ? 'Sauvegarde...' : 'Sauvegarder' }}</span>
</button>
</div>
</div>

View File

@@ -5,6 +5,173 @@
color: var(--color-text, #e8e8e8);
}
// --- Liste des modeles installes -----------------------------------------
.installed-models {
list-style: none;
margin: 6px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
font-size: 0.9rem;
}
.model-name {
font-family: ui-monospace, SFMono-Regular, monospace;
color: #c4b8e0;
}
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 5px;
color: inherit;
cursor: pointer;
&:hover { background: rgba(255, 255, 255, 0.06); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
.btn-icon.btn-danger:hover {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.4);
color: #fca5a5;
}
// --- Modal de pull -------------------------------------------------------
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #1f1a2e;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
width: min(520px, 92vw);
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
h3 {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
}
}
.modal-body {
padding: 20px;
overflow-y: auto;
label {
display: block;
margin-bottom: 6px;
font-weight: 500;
font-size: 0.9rem;
}
input[type="text"] {
width: 100%;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: inherit;
font-family: ui-monospace, SFMono-Regular, monospace;
box-sizing: border-box;
&:focus { outline: 2px solid #b794f4; outline-offset: 0; }
}
}
.suggestions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.suggestion-chip {
padding: 4px 10px;
background: rgba(183, 148, 244, 0.1);
border: 1px solid rgba(183, 148, 244, 0.25);
border-radius: 14px;
color: #d6c5f0;
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.82rem;
cursor: pointer;
&:hover { background: rgba(183, 148, 244, 0.2); }
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.pull-progress {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 0;
}
.pull-status {
font-size: 0.92rem;
color: #d6c5f0;
font-family: ui-monospace, SFMono-Regular, monospace;
}
.progress-bar {
width: 100%;
height: 8px;
background: rgba(255, 255, 255, 0.06);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #8b5cf6, #b794f4);
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.82rem;
color: #aaa0c5;
font-family: ui-monospace, SFMono-Regular, monospace;
}
.page-header {
display: flex;
align-items: center;

View File

@@ -2,8 +2,9 @@ import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download } from 'lucide-angular';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup } from '../services/settings.service';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X } from 'lucide-angular';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
import { Subscription } from 'rxjs';
import { UpdatesService, UpdateStatus } from '../services/updates.service';
import { ConfigService } from '../services/config.service';
@@ -33,6 +34,34 @@ export class SettingsComponent implements OnInit {
readonly Check = Check;
readonly AlertCircle = AlertCircle;
readonly Download = Download;
readonly Trash2 = Trash2;
readonly Plus = Plus;
readonly X = X;
// --- Pull / delete de modeles Ollama ---
/** Dialog d'ajout de modele ouvert/ferme. */
pullDialogOpen = false;
/** Nom saisi par l'utilisateur dans le dialog. */
pullModelName = '';
/** Suggestions courantes affichees dans le dialog. */
readonly pullSuggestions = [
'gemma4:e4b', 'gemma3:4b', 'gemma3:12b',
'llama3.2:3b', 'llama3.1:8b',
'mistral:7b', 'qwen2.5:3b', 'qwen2.5:7b'
];
/** Pull en cours ; null si aucun. */
pullInProgress = false;
/** Etape courante affichee a l'utilisateur (ex: "downloading", "verifying"). */
pullStatus = '';
/** Bytes telecharges sur le digest courant. */
pullCompleted = 0;
/** Bytes totaux du digest courant. */
pullTotal = 0;
/** Souscription au flux de pull pour pouvoir l'annuler. */
private pullSubscription: Subscription | null = null;
/** Modele en cours de suppression (nom) pour disabler son bouton. */
deletingModel: string | null = null;
// Mises a jour conteneurs
updateStatus: UpdateStatus | null = null;
@@ -229,6 +258,119 @@ export class SettingsComponent implements OnInit {
});
}
// --- Gestion des modeles Ollama (pull / delete) -------------------------
openPullDialog(): void {
this.pullDialogOpen = true;
this.pullModelName = '';
this.resetPullState();
}
closePullDialog(): void {
if (this.pullInProgress) return; // empêche fermeture pendant un pull
this.pullDialogOpen = false;
}
selectSuggestion(name: string): void {
this.pullModelName = name;
}
startPull(): void {
const name = this.pullModelName.trim();
if (!name || this.pullInProgress) return;
this.resetPullState();
this.pullInProgress = true;
this.pullStatus = 'connexion...';
this.errorMessage = '';
this.pullSubscription = this.settingsService.pullOllamaModel(name).subscribe({
next: (event: OllamaPullEvent) => {
if (event.error) {
this.errorMessage = `Echec : ${event.error}`;
this.pullInProgress = false;
return;
}
if (event.status) this.pullStatus = event.status;
if (event.completed != null) this.pullCompleted = event.completed;
if (event.total != null) this.pullTotal = event.total;
},
error: (err) => {
this.errorMessage = this.extractError(err, `Echec du telechargement de ${name}.`);
this.pullInProgress = false;
},
complete: () => {
this.pullInProgress = false;
this.successMessage = `Modele ${name} telecharge.`;
this.refreshModels();
// Si l'utilisateur n'avait aucun modele, on selectionne celui-ci.
if (this.settings && !this.settings.llm_model) {
this.settings.llm_model = name;
this.fetchOllamaModelInfo();
}
// Petite tempo avant de fermer pour que le user voie "success".
setTimeout(() => this.closePullDialog(), 1200);
}
});
}
cancelPull(): void {
if (this.pullSubscription) {
this.pullSubscription.unsubscribe();
this.pullSubscription = null;
}
this.pullInProgress = false;
this.pullStatus = 'annule';
}
private resetPullState(): void {
this.pullStatus = '';
this.pullCompleted = 0;
this.pullTotal = 0;
if (this.pullSubscription) {
this.pullSubscription.unsubscribe();
this.pullSubscription = null;
}
}
/** Pourcentage du digest courant pour la barre de progression. */
get pullPercent(): number {
if (this.pullTotal <= 0) return 0;
return Math.min(100, Math.round((this.pullCompleted / this.pullTotal) * 100));
}
/** Affichage humain des octets ('1.2 GB' / '450 MB'). */
formatBytes(b: number): string {
if (!b) return '0';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let v = b;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`;
}
deleteModel(name: string): void {
if (!confirm(`Supprimer le modele '${name}' ? L'espace disque sera libere.`)) return;
this.deletingModel = name;
this.errorMessage = '';
this.settingsService.deleteOllamaModel(name).subscribe({
next: () => {
this.deletingModel = null;
this.successMessage = `Modele ${name} supprime.`;
// Si l'utilisateur supprime le modele actuellement selectionne,
// on bascule sur le premier disponible (ou vide).
this.refreshModels();
if (this.settings && this.settings.llm_model === name) {
this.settings.llm_model = '';
this.ollamaModelMaxContext = 0;
}
},
error: (err) => {
this.deletingModel = null;
this.errorMessage = this.extractError(err, `Echec de la suppression de ${name}.`);
}
});
}
goBack(): void {
this.router.navigate(['/lore']);
}