Compare commits
10 Commits
v0.6.7
...
aaebeaa547
| Author | SHA1 | Date | |
|---|---|---|---|
| aaebeaa547 | |||
| 03ee3855f5 | |||
| 94a39cf3b4 | |||
| efe6f6c2b0 | |||
| 73a9d15786 | |||
| dfe05cf2d2 | |||
| fcba907438 | |||
| 5739602702 | |||
| addf78f01d | |||
| 5e04e84ee4 |
@@ -6,8 +6,10 @@ on:
|
|||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.igmlcreation.fr
|
GITEA_REGISTRY: git.igmlcreation.fr
|
||||||
REGISTRY_USER: ietm64
|
GITEA_REGISTRY_USER: ietm64
|
||||||
|
GHCR_REGISTRY: ghcr.io
|
||||||
|
GHCR_NAMESPACE: igmlcreation
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -26,19 +28,39 @@ jobs:
|
|||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.GITEA_REGISTRY }}
|
||||||
username: ${{ env.REGISTRY_USER }}
|
username: ${{ env.GITEA_REGISTRY_USER }}
|
||||||
password: ${{ secrets.DOCKER_PAT }}
|
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
|
- name: Extract version
|
||||||
id: meta
|
id: meta
|
||||||
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
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 }}
|
- name: Build & push ${{ matrix.component }}
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./${{ matrix.component }}
|
context: ./${{ matrix.component }}
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:latest
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_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 }}:${{ 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 }}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -91,6 +91,7 @@ Thumbs.db
|
|||||||
# Documentation hors-code (conservee hors du repo)
|
# Documentation hors-code (conservee hors du repo)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
docs/
|
docs/
|
||||||
|
loremind-docs/
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Docker Compose override (dev uniquement, non-distribue aux end users)
|
# Docker Compose override (dev uniquement, non-distribue aux end users)
|
||||||
|
|||||||
69
README.md
69
README.md
@@ -1,69 +1,22 @@
|
|||||||
# LoreMind
|
# LoreMind
|
||||||
|
|
||||||
Application web d'aide aux Maîtres de Jeu (JDR) pour centraliser la gestion de l'univers (Lore) et le suivi des campagnes, avec un moteur IA intégré pour générer du contenu structuré.
|

|
||||||
|
|
||||||
|
Loremind est une application web angular auto-hébergable afin de venir en aide aux Maîtres de jeu qui souhaitent centraliser leur univers et leurs campagnes.
|
||||||
|
Cette dernière intègre un moteur IA qui va ingérer le contenu du lore et de la campagne afin de pouvoir répondre à des questions précises sur l'univers ou la campagne, mais également proposer des idées de création dans le contexte de la campagne et du lore.
|
||||||
|
Pour le moment seul Ollama est supporté pour la partie locale, il y-a également une intégration pour 1min.ai. Plus tard, d'autres moteurs seront supportés.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
La documentation complète est accessible sur le site [loremind-docs](https://loremind-docs.igmlcreation.fr/)
|
||||||
|
|
||||||
|
Pour l'installation, consultez le guide dans cette dernière .
|
||||||
|
|
||||||
## Fonctionnalités
|
## Fonctionnalités
|
||||||
|
|
||||||
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
|
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
|
||||||
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
|
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
|
||||||
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates
|
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates
|
||||||
- Export vers FoundryVTT : Transfert structuré des données vers votre VTT préféré (en développement)
|
|
||||||
|
|
||||||
## Captures d'écran
|
|
||||||
|
|
||||||
### Page d'accueil
|
|
||||||

|
|
||||||
|
|
||||||
### Recherche
|
|
||||||

|
|
||||||
|
|
||||||
## Stack Technologique
|
|
||||||
|
|
||||||
LoreMind utilise une architecture distribuée pour séparer les responsabilités :
|
|
||||||
|
|
||||||
- **Frontend** : Angular (Interface utilisateur, affichage du lore, formulaires de templates)
|
|
||||||
- **Backend Core** : Java (Spring Boot) - Orchestration, persistance, export VTT
|
|
||||||
- **Backend IA** : Python - Traitement des LLM et génération de contenu
|
|
||||||
- **Base de données** : PostgreSQL avec JSONB pour les templates flexibles
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Backend Java (Domain-Driven Design & Hexagonal)
|
|
||||||
|
|
||||||
Le Backend Core respecte strictement :
|
|
||||||
- **Domain-Driven Design (DDD)** : Séparation en Bounded Contexts autonomes
|
|
||||||
- **Architecture Hexagonale (Ports et Adaptateurs)** : Domaine pur sans dépendances techniques
|
|
||||||
|
|
||||||
#### Bounded Contexts
|
|
||||||
- **LoreContext** : Gestion de l'encyclopédie de l'univers
|
|
||||||
- **CampaignContext** : Suivi des sessions et chronologie
|
|
||||||
- **GenerationContext** : Gestion des requêtes IA et templates
|
|
||||||
|
|
||||||
#### Couches
|
|
||||||
- **Domaine (Core)** : Entités métier pures et interfaces (Ports)
|
|
||||||
- **Application** : Orchestration des flux (Use Cases)
|
|
||||||
- **Infrastructure** : Implémentation technique (Adapters)
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
Pour installer LoreMind chez vous (Docker requis), suivez le guide **[INSTALL.md](INSTALL.md)** — 3 étapes, 5 minutes chrono :
|
|
||||||
|
|
||||||
1. Télécharger `docker-compose.yml` + `.env.example` depuis la [dernière release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases)
|
|
||||||
2. Renommer `.env.example` → `.env` et changer `POSTGRES_PASSWORD`
|
|
||||||
3. `docker compose up -d` → ouvrir http://localhost:8081
|
|
||||||
|
|
||||||
Mise à jour : `docker compose pull && docker compose up -d`.
|
|
||||||
|
|
||||||
## Développement (contributeurs)
|
|
||||||
|
|
||||||
Pour builder les images localement depuis les sources :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.igmlcreation.fr/ietm64/LoreMindMJ.git
|
|
||||||
cd LoreMindMJ
|
|
||||||
# Créer un docker-compose.override.yml local (voir docs de contrib)
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,16 @@ class OllamaLLMProvider:
|
|||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
try:
|
try:
|
||||||
response = await client.post(url, json=payload)
|
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:
|
except httpx.HTTPError as exc:
|
||||||
raise LLMProviderError(
|
raise LLMProviderError(
|
||||||
f"Erreur lors de l'appel à Ollama : {exc}"
|
f"Erreur lors de l'appel à Ollama : {exc}"
|
||||||
@@ -105,7 +114,20 @@ class OllamaLLMProvider:
|
|||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
try:
|
try:
|
||||||
async with client.stream("POST", url, json=payload) as response:
|
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():
|
async for line in response.aiter_lines():
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -689,6 +689,76 @@ async def get_ollama_model_info(
|
|||||||
return OllamaModelInfoDTO(context_length=0)
|
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")
|
@app.get("/models/onemin")
|
||||||
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
|
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
|
||||||
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
|
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.6.6</version>
|
<version>0.6.14</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -198,9 +198,18 @@ public class UpdateCheckService {
|
|||||||
for (String key : new String[]{"service", "scope"}) {
|
for (String key : new String[]{"service", "scope"}) {
|
||||||
String v = params.get(key);
|
String v = params.get(key);
|
||||||
if (v != null) {
|
if (v != null) {
|
||||||
|
// URLEncoder fait du "form encoding" qui transforme `:` et `/`
|
||||||
|
// en %3A et %2F. La plupart des registries (Docker Hub, Gitea)
|
||||||
|
// acceptent les deux, mais GHCR est strict et rejette le scope
|
||||||
|
// encode (403 DENIED). On preserve donc `:` et `/` dans la
|
||||||
|
// valeur, conformement a ce que GHCR attend
|
||||||
|
// (et que docker pull lui-meme envoie).
|
||||||
|
String encoded = URLEncoder.encode(v, StandardCharsets.UTF_8)
|
||||||
|
.replace("%3A", ":")
|
||||||
|
.replace("%2F", "/");
|
||||||
url.append(hasQuery ? '&' : '?')
|
url.append(hasQuery ? '&' : '?')
|
||||||
.append(key).append('=')
|
.append(key).append('=')
|
||||||
.append(URLEncoder.encode(v, StandardCharsets.UTF_8));
|
.append(encoded);
|
||||||
hasQuery = true;
|
hasQuery = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import org.springframework.http.HttpMethod;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
@@ -15,7 +17,15 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
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.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +79,92 @@ public class SettingsController {
|
|||||||
return forward(HttpMethod.POST, "/models/ollama/info", body);
|
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 -> {
|
||||||
|
// 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();
|
||||||
|
// 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)));
|
||||||
|
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()) {
|
||||||
|
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")
|
@GetMapping("/models/onemin")
|
||||||
public ResponseEntity<Map<String, Object>> listOneMinModels() {
|
public ResponseEntity<Map<String, Object>> listOneMinModels() {
|
||||||
return forward(HttpMethod.GET, "/models/onemin", null);
|
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() {
|
private void guardDemoMode() {
|
||||||
if (demoMode) {
|
if (demoMode) {
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Settings disabled in demo mode");
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Settings disabled in demo mode");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -60,7 +60,12 @@ services:
|
|||||||
"
|
"
|
||||||
|
|
||||||
core:
|
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
|
container_name: loremind-core
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
@@ -84,8 +89,8 @@ services:
|
|||||||
# Detection des mises a jour : interroge le registry et delegue le pull/restart
|
# Detection des mises a jour : interroge le registry et delegue le pull/restart
|
||||||
# a Watchtower. Si WATCHTOWER_TOKEN est vide, la feature est desactivee
|
# a Watchtower. Si WATCHTOWER_TOKEN est vide, la feature est desactivee
|
||||||
# (l'UI masque le badge et le bouton).
|
# (l'UI masque le badge et le bouton).
|
||||||
UPDATE_CHECK_REGISTRY: ${REGISTRY:-git.igmlcreation.fr}
|
UPDATE_CHECK_REGISTRY: ${REGISTRY:-ghcr.io}
|
||||||
UPDATE_CHECK_IMAGES: ietm64/core,ietm64/brain,ietm64/web
|
UPDATE_CHECK_IMAGES: ${IMAGE_NAMESPACE:-igmlcreation/loremind-}core,${IMAGE_NAMESPACE:-igmlcreation/loremind-}brain,${IMAGE_NAMESPACE:-igmlcreation/loremind-}web
|
||||||
UPDATE_CHECK_TAG: ${TAG:-latest}
|
UPDATE_CHECK_TAG: ${TAG:-latest}
|
||||||
WATCHTOWER_URL: http://watchtower:8080
|
WATCHTOWER_URL: http://watchtower:8080
|
||||||
WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-}
|
WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-}
|
||||||
@@ -115,7 +120,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
brain:
|
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
|
container_name: loremind-brain
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
@@ -138,7 +143,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
web:
|
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
|
container_name: loremind-web
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
@@ -154,7 +159,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:
|
||||||
|
|||||||
@@ -5,23 +5,31 @@ et lancent la stack. Aucune configuration manuelle requise.
|
|||||||
|
|
||||||
## Windows 10 / 11
|
## Windows 10 / 11
|
||||||
|
|
||||||
Ouvrir **PowerShell** (clic droit → *Exécuter en tant qu'administrateur*) :
|
**Procédure recommandée :**
|
||||||
|
|
||||||
```powershell
|
1. Téléchargez les trois fichiers suivants dans un même dossier
|
||||||
iwr https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/installers/install.ps1 -OutFile $env:TEMP\loremind-install.ps1
|
(par ex. `Téléchargements\LoreMind\`) :
|
||||||
powershell -ExecutionPolicy Bypass -File $env:TEMP\loremind-install.ps1
|
- [`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 :
|
Le script :
|
||||||
1. Vérifie / installe **WSL2** (un reboot peut être nécessaire — relancer le script après).
|
1. Vérifie / installe **WSL2** (un reboot peut être nécessaire — relancer le script après).
|
||||||
2. Vérifie / installe **Docker Desktop** via `winget`.
|
2. Vérifie / installe **Docker Desktop** via `winget`.
|
||||||
3. Génère `%LOCALAPPDATA%\LoreMind\.env` avec mots de passe aléatoires.
|
3. Vous demande quelques choix (admin, fournisseur LLM, mode Ollama, mises à jour auto).
|
||||||
4. Lance la stack et ouvre `http://localhost:8081`.
|
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)
|
## Linux (Debian / Ubuntu / Fedora / Arch)
|
||||||
|
|
||||||
```bash
|
```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 :
|
Le script :
|
||||||
@@ -30,6 +38,96 @@ Le script :
|
|||||||
3. Installe dans `~/.local/share/loremind`.
|
3. Installe dans `~/.local/share/loremind`.
|
||||||
4. Lance la stack et ouvre `http://localhost:8081`.
|
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
|
## Variables disponibles
|
||||||
|
|
||||||
| Variable | Défaut | Effet |
|
| Variable | Défaut | Effet |
|
||||||
|
|||||||
@@ -40,16 +40,16 @@
|
|||||||
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.6
|
Version : 0.6.14
|
||||||
|
|
||||||
.LINK
|
.LINK
|
||||||
https://git.igmlcreation.fr/ietm64/loremind
|
https://github.com/IGMLcreation/LoreMind
|
||||||
#>
|
#>
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[string]$InstallDir = "$env:LOCALAPPDATA\LoreMind",
|
[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,
|
[int]$WebPort = 8081,
|
||||||
[switch]$NonInteractive
|
[switch]$NonInteractive
|
||||||
)
|
)
|
||||||
@@ -91,17 +91,36 @@ function Test-Docker {
|
|||||||
return ($LASTEXITCODE -eq 0)
|
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-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)
|
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||||
|
$reportedFound = $false
|
||||||
while ((Get-Date) -lt $deadline) {
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
if (Get-Command docker -ErrorAction SilentlyContinue) {
|
||||||
|
if (-not $reportedFound) {
|
||||||
|
Write-Ok "Commande 'docker' detectee, attente du daemon..."
|
||||||
|
$reportedFound = $true
|
||||||
|
}
|
||||||
docker info *>$null
|
docker info *>$null
|
||||||
if ($LASTEXITCODE -eq 0) { Write-Ok "Docker repond"; return $true }
|
if ($LASTEXITCODE -eq 0) { Write-Ok "Docker repond"; return $true }
|
||||||
Start-Sleep -Seconds 3
|
}
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
}
|
}
|
||||||
return $false
|
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
|
# 0. Verification des droits administrateur
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -159,12 +178,25 @@ if (Test-Docker) {
|
|||||||
winget install --id Docker.DockerDesktop -e --accept-package-agreements --accept-source-agreements
|
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 }
|
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..."
|
Write-Step "Lancement de Docker Desktop..."
|
||||||
$dd = "$env:ProgramFiles\Docker\Docker\Docker Desktop.exe"
|
$dd = "$env:ProgramFiles\Docker\Docker\Docker Desktop.exe"
|
||||||
if (Test-Path $dd) { Start-Process $dd }
|
if (Test-Path $dd) { Start-Process $dd }
|
||||||
|
|
||||||
if (-not (Wait-Docker 240)) {
|
Write-Host ""
|
||||||
Write-Err "Docker n'a pas demarre. Lancez-le manuellement puis relancez ce script."
|
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
|
exit 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,41 +245,64 @@ if ($llmProvider -eq 'onemin' -and -not $NonInteractive) {
|
|||||||
$onemKey = Read-Host " Cle API 1min.ai"
|
$onemKey = Read-Host " Cle API 1min.ai"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Mode Ollama : embarque (defaut) vs hote -------------------------------
|
# --- Mode Ollama : 3 options possibles -------------------------------------
|
||||||
# Embarque : service 'ollama' du compose (profile local-ollama). Zero config reseau.
|
# 1. Hote : Ollama est deja installe sur cette machine -> on configure le
|
||||||
# Hote : Ollama deja installe sur la machine. Necessite OLLAMA_HOST=0.0.0.0
|
# pare-feu pour que Docker puisse l'atteindre sans exposer le port.
|
||||||
# pour que Docker Desktop puisse l'atteindre via host.docker.internal.
|
# 2. Embarque : Ollama tourne dans un conteneur Docker dedie (profile local-ollama).
|
||||||
$useEmbeddedOllama = $true
|
# 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'
|
$ollamaBaseUrl = 'http://ollama:11434'
|
||||||
if ($llmProvider -eq 'ollama') {
|
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]"
|
$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 {
|
} 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)"
|
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 {
|
$autoUpdate = if ($NonInteractive) { $true } else {
|
||||||
$r = Read-Host " Activer les mises a jour auto (chaque nuit a 4h) ? [O/n]"
|
$r = Read-Host " Activer les mises a jour auto (chaque nuit a 4h) ? [O/n]"
|
||||||
@@ -256,12 +311,13 @@ $autoUpdate = if ($NonInteractive) { $true } else {
|
|||||||
# Combinaison de profiles : autoupdate et/ou local-ollama (separes par virgule).
|
# Combinaison de profiles : autoupdate et/ou local-ollama (separes par virgule).
|
||||||
$profilesList = @()
|
$profilesList = @()
|
||||||
if ($autoUpdate) { $profilesList += 'autoupdate' }
|
if ($autoUpdate) { $profilesList += 'autoupdate' }
|
||||||
if ($useEmbeddedOllama -and $llmProvider -eq 'ollama') { $profilesList += 'local-ollama' }
|
if ($ollamaMode -eq 'embedded' -and $llmProvider -eq 'ollama'){ $profilesList += 'local-ollama' }
|
||||||
$composeProfiles = $profilesList -join ','
|
$composeProfiles = $profilesList -join ','
|
||||||
|
|
||||||
$envContent = @"
|
$envContent = @"
|
||||||
# Genere par install.ps1 le $(Get-Date -Format 'yyyy-MM-dd HH:mm')
|
# 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
|
TAG=latest
|
||||||
|
|
||||||
WEB_PORT=$WebPort
|
WEB_PORT=$WebPort
|
||||||
@@ -305,6 +361,43 @@ Write-Step "Demarrage de la stack"
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Err "Echec docker compose up"; exit 1 }
|
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
|
# 6. Recap
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -323,13 +416,19 @@ if ($autoUpdate) {
|
|||||||
Write-Host " Auto-update : desactive (mise a jour manuelle uniquement)"
|
Write-Host " Auto-update : desactive (mise a jour manuelle uniquement)"
|
||||||
}
|
}
|
||||||
if ($llmProvider -eq 'ollama') {
|
if ($llmProvider -eq 'ollama') {
|
||||||
if ($useEmbeddedOllama) {
|
switch ($ollamaMode) {
|
||||||
|
'embedded' {
|
||||||
Write-Host " Ollama : embarque (service Docker 'ollama')" -ForegroundColor Green
|
Write-Host " Ollama : embarque (service Docker 'ollama')" -ForegroundColor Green
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host " IMPORTANT : telechargez un modele avant utilisation :"
|
Write-Host " IMPORTANT : telechargez un modele avant utilisation :"
|
||||||
Write-Host " docker exec -it loremind-ollama ollama pull $llmModel"
|
Write-Host " docker exec -it loremind-ollama ollama pull $llmModel"
|
||||||
} else {
|
}
|
||||||
Write-Host " Ollama : hote (http://host.docker.internal:11434)"
|
'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 ""
|
Write-Host ""
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# Installeur LoreMindMJ pour Linux (Debian/Ubuntu/Fedora/Arch)
|
# Installeur LoreMindMJ pour Linux (Debian/Ubuntu/Fedora/Arch)
|
||||||
# Usage :
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/share/loremind}"
|
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}"
|
WEB_PORT="${WEB_PORT:-8081}"
|
||||||
NON_INTERACTIVE="${NON_INTERACTIVE:-0}"
|
NON_INTERACTIVE="${NON_INTERACTIVE:-0}"
|
||||||
|
|
||||||
@@ -123,37 +123,54 @@ if [ "$LLM_PROVIDER" = "onemin" ] && [ "$NON_INTERACTIVE" != "1" ]; then
|
|||||||
ONEMIN_API_KEY="$(ask "Cle API 1min.ai" "")"
|
ONEMIN_API_KEY="$(ask "Cle API 1min.ai" "")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Mode Ollama : embarque (defaut) vs hote -------------------------------
|
# --- Mode Ollama : 3 options possibles -------------------------------------
|
||||||
# Embarque : service 'ollama' du compose (profile local-ollama). Zero config reseau.
|
# 1. host : Ollama deja installe sur la machine -> helper de securisation
|
||||||
# Hote : Ollama deja installe sur la machine. Necessite OLLAMA_HOST=0.0.0.0
|
# 2. embedded : service 'ollama' du compose (profile local-ollama)
|
||||||
# via override systemd pour que le conteneur Brain l'atteigne.
|
# 3. none : aucune installation, configuration ulterieure via l'app
|
||||||
USE_EMBEDDED_OLLAMA=1
|
OLLAMA_MODE="embedded"
|
||||||
OLLAMA_BASE_URL_VAL="http://ollama:11434"
|
OLLAMA_BASE_URL_VAL="http://ollama:11434"
|
||||||
LLM_MODEL_VAL="gemma4:26b"
|
LLM_MODEL_VAL="gemma4:e4b"
|
||||||
if [ "$LLM_PROVIDER" = "ollama" ]; then
|
if [ "$LLM_PROVIDER" = "ollama" ]; then
|
||||||
HOST_OLLAMA_REPLY="$(ask "Avez-vous deja Ollama installe sur cette machine ? [o/N]" "N")"
|
HOST_OLLAMA_REPLY="$(ask "Avez-vous deja Ollama installe sur cette machine ? [o/N]" "N")"
|
||||||
case "$HOST_OLLAMA_REPLY" in
|
case "$HOST_OLLAMA_REPLY" in
|
||||||
o|O|y|Y|oui|yes|Oui|Yes)
|
o|O|y|Y|oui|yes|Oui|Yes)
|
||||||
USE_EMBEDDED_OLLAMA=0
|
OLLAMA_MODE="host"
|
||||||
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
|
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
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)"
|
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
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -166,14 +183,15 @@ esac
|
|||||||
# Combinaison de profiles : autoupdate et/ou local-ollama (separes par virgule).
|
# Combinaison de profiles : autoupdate et/ou local-ollama (separes par virgule).
|
||||||
PROFILES_ARR=()
|
PROFILES_ARR=()
|
||||||
[ "$AUTO_UPDATE" = "1" ] && PROFILES_ARR+=("autoupdate")
|
[ "$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")
|
PROFILES_ARR+=("local-ollama")
|
||||||
fi
|
fi
|
||||||
COMPOSE_PROFILES="$(IFS=,; echo "${PROFILES_ARR[*]}")"
|
COMPOSE_PROFILES="$(IFS=,; echo "${PROFILES_ARR[*]}")"
|
||||||
|
|
||||||
cat > .env <<EOF
|
cat > .env <<EOF
|
||||||
# Genere par install.sh le $(date '+%Y-%m-%d %H:%M')
|
# Genere par install.sh le $(date '+%Y-%m-%d %H:%M')
|
||||||
REGISTRY=git.igmlcreation.fr
|
REGISTRY=ghcr.io
|
||||||
|
IMAGE_NAMESPACE=igmlcreation/loremind-
|
||||||
TAG=latest
|
TAG=latest
|
||||||
|
|
||||||
WEB_PORT=${WEB_PORT}
|
WEB_PORT=${WEB_PORT}
|
||||||
@@ -211,6 +229,41 @@ docker compose pull
|
|||||||
step "Demarrage de la stack"
|
step "Demarrage de la stack"
|
||||||
docker compose up -d
|
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
|
# 6. Recap
|
||||||
URL="http://localhost:${WEB_PORT}"
|
URL="http://localhost:${WEB_PORT}"
|
||||||
echo
|
echo
|
||||||
@@ -227,14 +280,20 @@ else
|
|||||||
echo " Auto-update : desactive (mise a jour manuelle uniquement)"
|
echo " Auto-update : desactive (mise a jour manuelle uniquement)"
|
||||||
fi
|
fi
|
||||||
if [ "$LLM_PROVIDER" = "ollama" ]; then
|
if [ "$LLM_PROVIDER" = "ollama" ]; then
|
||||||
if [ "$USE_EMBEDDED_OLLAMA" = "1" ]; then
|
case "$OLLAMA_MODE" in
|
||||||
|
embedded)
|
||||||
echo -e " Ollama : ${c_green}embarque${c_off} (service Docker 'ollama')"
|
echo -e " Ollama : ${c_green}embarque${c_off} (service Docker 'ollama')"
|
||||||
echo
|
echo
|
||||||
echo " IMPORTANT : telechargez un modele avant utilisation :"
|
echo " IMPORTANT : telechargez un modele avant utilisation :"
|
||||||
echo " docker exec -it loremind-ollama ollama pull ${LLM_MODEL_VAL}"
|
echo " docker exec -it loremind-ollama ollama pull ${LLM_MODEL_VAL}"
|
||||||
else
|
;;
|
||||||
echo " Ollama : hote (http://host.docker.internal:11434)"
|
host)
|
||||||
fi
|
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
|
fi
|
||||||
echo
|
echo
|
||||||
echo " Commandes utiles (depuis $INSTALL_DIR) :"
|
echo " Commandes utiles (depuis $INSTALL_DIR) :"
|
||||||
|
|||||||
183
installers/secure-host-ollama.ps1
Normal file
183
installers/secure-host-ollama.ps1
Normal 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://github.com/IGMLcreation/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 ""
|
||||||
128
installers/secure-host-ollama.sh
Normal file
128
installers/secure-host-ollama.sh
Normal 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
|
||||||
@@ -20,6 +20,24 @@ server {
|
|||||||
proxy_send_timeout 300s;
|
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 / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
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.6",
|
"version": "0.6.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.6.6",
|
"version": "0.6.14",
|
||||||
"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.6",
|
"version": "0.6.14",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
|
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
import { catchError, throwError } from 'rxjs';
|
import { catchError, throwError } from 'rxjs';
|
||||||
|
import { ConfigService } from '../services/config.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detecte la perte de session demo (orchestrateur) via les codes 401/502 sur
|
* 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
|
* Le reload renvoie l'utilisateur sur la page "Preparation" pour creer une
|
||||||
* nouvelle session sans qu'il ait a faire Ctrl+Shift+R.
|
* nouvelle session sans qu'il ait a faire Ctrl+Shift+R.
|
||||||
*
|
*
|
||||||
* Cet interceptor est inerte en mode normal (non-demo) : si le backend natif
|
* Strictement inerte hors mode demo : sans cette garde, un 401 du backend
|
||||||
* renvoie un 401 legitime, ca declenche aussi le reload, ce qui est sans
|
* natif (par ex. HTTP Basic sur /api/settings avant authentification) ou un
|
||||||
* consequence puisqu'aucun flux d'auth utilisateur n'existe encore cote app.
|
* 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
|
// Module-level flag : evite de declencher overlay + reload plusieurs fois si
|
||||||
@@ -17,8 +20,16 @@ import { catchError, throwError } from 'rxjs';
|
|||||||
let alreadyTriggered = false;
|
let alreadyTriggered = false;
|
||||||
|
|
||||||
export const sessionExpiredInterceptor: HttpInterceptorFn = (req, next) => {
|
export const sessionExpiredInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
const config = inject(ConfigService);
|
||||||
|
|
||||||
return next(req).pipe(
|
return next(req).pipe(
|
||||||
catchError((err) => {
|
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 isApiCall = req.url.includes('/api/');
|
||||||
const isSessionLoss =
|
const isSessionLoss =
|
||||||
err instanceof HttpErrorResponse && (err.status === 401 || err.status === 502);
|
err instanceof HttpErrorResponse && (err.status === 401 || err.status === 502);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -65,6 +65,112 @@ export class SettingsService {
|
|||||||
listOneMinModels(): Observable<{ groups: OneMinModelGroup[] }> {
|
listOneMinModels(): Observable<{ groups: OneMinModelGroup[] }> {
|
||||||
return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions);
|
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> {
|
||||||
|
// 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`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
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: 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;
|
||||||
|
while ((nl = buffer.indexOf('\n')) >= 0) {
|
||||||
|
const line = buffer.slice(0, nl).trim();
|
||||||
|
buffer = buffer.slice(nl + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
try {
|
||||||
|
emit(JSON.parse(line) as OllamaPullEvent);
|
||||||
|
eventsReceived++;
|
||||||
|
} catch {
|
||||||
|
// Ligne non JSON (rare) : on l'ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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') fail(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, ...). */
|
/** Un groupe de modeles 1min.ai regroupes par fournisseur (Anthropic, OpenAI, ...). */
|
||||||
|
|||||||
@@ -56,11 +56,91 @@
|
|||||||
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
|
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
|
||||||
<span>{{ loadingModels ? 'Chargement...' : 'Actualiser' }}</span>
|
<span>{{ loadingModels ? 'Chargement...' : 'Actualiser' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn-secondary" (click)="openPullDialog()">
|
||||||
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||||
|
<span>Telecharger</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint" *ngIf="ollamaModels.length === 0">Aucun modele detecte. Verifie que Ollama tourne et que l'URL est correcte.</p>
|
<p class="hint" *ngIf="ollamaModels.length === 0">Aucun modele detecte. Verifie que Ollama tourne et que l'URL est correcte.</p>
|
||||||
</div>
|
</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>
|
</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 -->
|
<!-- Bloc 1min.ai -->
|
||||||
<section class="card" *ngIf="settings && settings.llm_provider === 'onemin'">
|
<section class="card" *ngIf="settings && settings.llm_provider === 'onemin'">
|
||||||
<h2>Configuration 1min.ai</h2>
|
<h2>Configuration 1min.ai</h2>
|
||||||
@@ -141,13 +221,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Bloc Mises a jour -->
|
||||||
<section class="card" *ngIf="config.updateCheckEnabled">
|
<section class="card" *ngIf="config.updateCheckEnabled">
|
||||||
<h2>Mises a jour</h2>
|
<h2>Mises a jour</h2>
|
||||||
@@ -198,4 +271,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,173 @@
|
|||||||
color: var(--color-text, #e8e8e8);
|
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 {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { Component, OnInit } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download } from 'lucide-angular';
|
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X } from 'lucide-angular';
|
||||||
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup } from '../services/settings.service';
|
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
import { UpdatesService, UpdateStatus } from '../services/updates.service';
|
import { UpdatesService, UpdateStatus } from '../services/updates.service';
|
||||||
import { ConfigService } from '../services/config.service';
|
import { ConfigService } from '../services/config.service';
|
||||||
|
|
||||||
@@ -33,6 +34,38 @@ export class SettingsComponent implements OnInit {
|
|||||||
readonly Check = Check;
|
readonly Check = Check;
|
||||||
readonly AlertCircle = AlertCircle;
|
readonly AlertCircle = AlertCircle;
|
||||||
readonly Download = Download;
|
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;
|
||||||
|
/** 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;
|
||||||
|
|
||||||
// Mises a jour conteneurs
|
// Mises a jour conteneurs
|
||||||
updateStatus: UpdateStatus | null = null;
|
updateStatus: UpdateStatus | null = null;
|
||||||
@@ -229,6 +262,131 @@ 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;
|
||||||
|
// 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}.`);
|
||||||
|
this.pullInProgress = false;
|
||||||
|
},
|
||||||
|
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.
|
||||||
|
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;
|
||||||
|
this.pullSucceeded = false;
|
||||||
|
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 {
|
goBack(): void {
|
||||||
this.router.navigate(['/lore']);
|
this.router.navigate(['/lore']);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user